Merge main into feat/live-rasterize-effect

This commit is contained in:
dotCooCoo
2026-10-17 04:09:12 -07:00
392 changed files with 31047 additions and 2149 deletions
+39 -5
View File
@@ -74,11 +74,13 @@ 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`]), or is it a
/// 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`])?
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)
|| effects::has_gradient_copies(n)
|| effects::has_container_appearance(n)
|| effects::has_crop_marks(n)
|| effects::has_adjustment(n)
@@ -619,6 +621,10 @@ pub(crate) struct PixelEntry {
/// rasters made before their objects moved: only a frame rendered while interacting draws
/// it.
moved: bool,
/// How many images in the content failed to decode while the raster was made
/// ([`crate::paint::decode_failures`]): each frame that draws the raster counts them
/// again ([`crate::paint::add_decode_failures`]).
failed: u64,
pub(crate) stamp: u64,
}
@@ -1131,7 +1137,7 @@ impl Renderer {
off.flush();
let mut pm = vello_cpu::Pixmap::new(w, h);
off.render(&mut pm, &mut self.resources);
if !self.shadows.get(&old).is_some_and(|e| silhouette(&pm, colored) == e.made.pixels) {
if self.shadows.get(&old).is_none_or(|e| silhouette(&pm, colored) != e.made.pixels) {
return;
}
if let Some(mut e) = self.shadows.remove(&old) {
@@ -1618,6 +1624,7 @@ impl Renderer {
self.stats.moved += usize::from(e.moved);
let p = f.view * e.anchor;
let (image, at, k) = (e.image.clone(), ((p.x + e.rel.0).round(), (p.y + e.rel.1).round()), e.k);
crate::paint::add_decode_failures(e.failed);
draw_scaled(ctx, image, at, k);
return;
}
@@ -1627,7 +1634,7 @@ impl Renderer {
let px = f.px / k;
let frame = Frame { mt: false, view, visible: to_doc.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)), px, ..*f };
let line = 72.0 / ppi;
let before = self.stats.moved;
let (before, decoded) = (self.stats.moved, crate::paint::decode_failures());
// A Rasterize first in the chain draws the content on its image's own grid instead, unless
// that image is too large: the content is drawn once.
let mut rasterized = match chain.first() {
@@ -1662,6 +1669,7 @@ impl Renderer {
off.flush();
off.render(&mut pm, &mut self.resources);
}
let failed = crate::paint::decode_failures() - decoded;
// Content drawn over moved rasters makes a moved raster.
let moved = self.stats.moved > before;
// What the raster is made from, for its object once it moves (`move_pixel_content`).
@@ -1712,13 +1720,16 @@ impl Renderer {
}
}
let image = std::sync::Arc::new(pm);
if let Some(((key, made), inputs)) = key.zip(made).zip(inputs) {
// A cancelled export drew part of the content: that raster isn't kept for later frames.
if !vectorcraft_doc::cancel::is_cancelled()
&& let Some(((key, made), inputs)) = key.zip(made).zip(inputs)
{
let anchor = content.reach.origin();
let p = f.view * anchor;
let rel = (x0 - p.x, y0 - p.y);
let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node)));
let node = Drawn::of(content.node, owner.as_ref());
self.pixel_fx.insert(key, PixelEntry { node, linear, anchor, rel, k, image: image.clone(), made, inputs, moved, stamp });
self.pixel_fx.insert(key, PixelEntry { node, linear, anchor, rel, k, image: image.clone(), made, inputs, moved, failed, stamp });
self.trim_fx_rasters(FX_RASTER_BUDGET);
}
// An image over the limits was drawn at a frame's resolution: its raster is kept.
@@ -2494,6 +2505,29 @@ mod tests {
assert_eq!(b.pixels, c.pixels);
}
/// A blurred image that can't be decoded: every frame that draws it counts the failure
/// ([`crate::paint::decode_failures`]), from a new raster or from the raster kept.
#[test]
fn cached_effect_rasters_count_the_images_they_could_not_decode() {
use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind};
let mut d = Document::new(100.0, 100.0);
// A PNG signature with nothing after it.
d.images.insert("cut".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec()));
let im =
ImageObject { key: "cut".into(), width: 40, height: 40, xf: Affine::translate((30.0, 30.0)), link: None, placement: Default::default() };
let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
n.appearance.effects = vec![Effect { id: "blur.gaussian".into(), params: json!({"radius": 4.0}), visible: true }];
d.insert(Some(d.layers[0].id), 0, n).unwrap();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = Renderer::new();
for frame in 0..3 {
let before = crate::paint::decode_failures();
r.render(&d, 100, 100, Affine::IDENTITY, &opts);
assert!(crate::paint::decode_failures() > before, "frame {frame}");
}
assert_eq!(r.pixel_fx.len(), 1, "the later frames draw from the raster kept");
}
/// While interacting, an object that only moved draws its drop shadow and filtered content
/// from the rasters of an earlier frame, at the nearest whole pixel, without running the
/// effects again (#1257). The frame after the interaction runs them again: it is what a fresh
+3
View File
@@ -274,6 +274,9 @@ pub(crate) fn draw_pixmap(ctx: &mut RenderContext, pm: Arc<Pixmap>, at: (f64, f6
#[cfg(test)]
PIXELS_DRAWN.with(|n| n.set(n.get() + u64::from(pm.width()) * u64::from(pm.height())));
ctx.set_transform(Affine::translate(at));
// The pixmap maps 1:1 onto the rect: a paint transform left by an earlier fill (a pattern,
// radial or freeform gradient) would resample it (#2426).
ctx.reset_paint_transform();
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler: peniko::ImageSampler::default() });
ctx.fill_rect(&Rect::new(0.0, 0.0, w, h));
ctx.set_transform(Affine::IDENTITY);
+1 -1
View File
@@ -153,7 +153,7 @@ pub(crate) struct InkLut(CmykLut);
impl InkLut {
fn build(cms: &Cms) -> Self {
Self(CmykLut::build(|inks| {
let [x, y, z] = lab::lab_to_xyz(cms.cmyk_to_lab(inks));
let [x, y, z] = lab::lab_to_xyz(cms.cmyk_display_lab(inks));
[x, y, z, 0.0]
}))
}
+6 -2
View File
@@ -1144,6 +1144,10 @@ impl Renderer {
if !force && (!n.visible || f.opts.hidden.contains(&n.id)) {
return;
}
// A cancelled export stops drawing; whoever asked for the picture drops it (see `cancel`).
if vectorcraft_doc::cancel::is_cancelled() {
return;
}
if force {
// Bounds were already checked by draw_arc (or the caller wants it drawn regardless).
} else if let Some(b) = fx::cull_bounds(f.symbols, n) {
@@ -1786,12 +1790,12 @@ impl Renderer {
return;
}
let tb = t.xf.transform_rect_bbox(g.bounds);
// Overprinting characters multiply per draw, like overprinting fills and strokes
// Overprinting characters darken per draw, like overprinting fills and strokes
// ([`proof`]); the object blend mode is Normal here or applied by an enclosing layer.
let overprints = proof::overprints(f.opts);
let overprint = |ctx: &mut RenderContext, on: bool| {
if overprints {
ctx.set_blend_mode(if on { blend_mode(vectorcraft_color::BlendMode::Multiply) } else { BlendMode::default() });
ctx.set_blend_mode(if on { blend_mode(vectorcraft_color::BlendMode::Darken) } else { BlendMode::default() });
}
};
// The object's own fills and strokes paint the whole outline: those below the Characters
+56
View File
@@ -290,6 +290,26 @@ impl Renderer {
ctx.set_fill_rule(peniko::Fill::NonZero);
let grow = f.px * 0.5;
let alpha = self.alpha;
// Where a patch narrows to slivers, many quads share each pixel, each covering a little of it: their edges
// composite to part coverage, and hard edges drop each one. An opaque patch is backed by its own outline in
// its middle colour, one path that covers fully, so the background can't show through.
for r in 0..m.rows as usize {
for c in 0..m.cols as usize {
if [m.idx(r, c), m.idx(r, c + 1), m.idx(r + 1, c), m.idx(r + 1, c + 1)].iter().any(|&i| m.points[i].opacity < 1.0) {
continue;
}
let (top, right, bottom, left) = (m.edge_u(r, c), m.edge_v(r, c + 1), m.edge_u(r + 1, c), m.edge_v(r, c));
let mut bp = BezPath::new();
bp.move_to(top.p0);
bp.curve_to(top.p1, top.p2, top.p3);
bp.curve_to(right.p1, right.p2, right.p3);
bp.curve_to(bottom.p2, bottom.p1, bottom.p0);
bp.curve_to(left.p2, left.p1, left.p0);
bp.close_path();
ctx.set_paint(f.ink.color(&m.color_at(r, c, 0.5, 0.5).0, alpha));
ctx.fill_path(&bp);
}
}
for q in quads.iter() {
if q.opacity <= 0.0 {
continue;
@@ -326,6 +346,42 @@ impl Renderer {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_mesh_patch_of_slivers_leaves_no_holes_with_hard_edges() {
// GUARDS: patches that narrow to slivers (here fanning out from nearly one point) draw as many sub-pixel quads;
// with hard edges each was dropped below half coverage, leaving holes through the mesh.
use vectorcraft_doc::live::MeshPoint;
let point = |x: f64, y: f64, handles: [(f64, f64); 4]| MeshPoint {
p: vectorcraft_geom::Point::new(x, y),
color: vectorcraft_color::Color::rgb(0.7, 0.8, 0.9),
opacity: 1.0,
handles: handles.map(|(x, y)| Vec2::new(x, y)),
};
let rows = [
((100.0, 60.0), (84.0, 46.0)),
((98.5, 60.0), (60.0, 51.0)),
((97.8, 60.0), (42.0, 52.0)),
((70.0, 59.0), (28.0, 51.5)),
((15.0, 56.0), (12.0, 55.0)),
];
let mut points = vec![];
for (i, &((x0, y0), (x1, y1))) in rows.iter().enumerate() {
let along = if i < 3 { 5.0 } else { 20.0 };
points.push(point(x0, y0, [((x1 - x0) * 0.35, (y1 - y0) * 0.1), (0.0, 0.0), (-along, 0.0), (if i > 0 { 5.0 } else { 0.0 }, 0.0)]));
points.push(point(x1, y1, [(0.0, 0.0), ((x0 - x1) * 0.35, (y0 - y1) * 0.6), (-8.0, 0.5), (8.0, -0.5)]));
}
let mut d = Document::new(110.0, 30.0);
let l = d.layers[0].id;
let id = d.alloc_id();
d.insert(Some(l), 0, Node::new(id, NodeKind::Mesh(GradientMesh { rows: 4, cols: 1, points }))).unwrap();
let view = Affine::scale(4.0) * Affine::translate((0.0, -40.0));
let draw = |anti_alias| crate::Renderer::new().render(&d, 440, 120, view, &crate::RenderOptions { anti_alias, ..Default::default() });
let (smooth, hard) = (draw(crate::AntiAlias::Art), draw(crate::AntiAlias::None));
let holes =
(0..120).flat_map(|y| (0..440).map(move |x| (x, y))).filter(|&(x, y)| smooth.pixel(x, y)[3] >= 200 && hard.pixel(x, y)[3] == 0).count();
assert!(holes < 60, "{holes} pixels the smooth render covers are empty with hard edges");
}
use vectorcraft_doc::{Appearance, EnvelopeKind, Symbol};
use vectorcraft_geom::{Affine, Rect, shapes};
+39 -14
View File
@@ -56,27 +56,45 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr
/// 6614 x 59055 px, is 1.56 GB as RGBA), past the decoder's 512 MB default. The web keeps it.
const MAX_DECODE_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 };
/// Images that couldn't be decoded (and so drew nothing), counted for exports to report.
static DECODE_FAILURES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// How many images have failed to decode so far (process-wide): an export compares the count
/// before and after to say it left images out.
pub fn decode_failures() -> u64 {
DECODE_FAILURES.load(std::sync::atomic::Ordering::Relaxed)
thread_local! {
/// Images that couldn't be decoded (and so drew nothing) on this thread, counted for exports to
/// report.
static DECODE_FAILURES: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
/// `bytes` decoded within [`MAX_DECODE_ALLOC`]; a failure is counted in [`decode_failures`].
/// How many images have failed to decode on this thread so far: an export compares the count
/// before and after it draws to say it left images out. Images that other threads (another export,
/// the canvas, thumbnails) fail to decode meanwhile are not counted.
pub fn decode_failures() -> u64 {
DECODE_FAILURES.with(std::cell::Cell::get)
}
/// `bytes` decoded within [`MAX_DECODE_ALLOC`] ([`decode_within_allowance`]); a failure is counted
/// in [`decode_failures`].
fn decode(bytes: &[u8]) -> Option<image::DynamicImage> {
let img = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok().and_then(|mut r| {
let img = decode_within_allowance(bytes).ok();
if img.is_none() {
add_decode_failures(1);
}
img
}
/// `bytes` decoded within [`MAX_DECODE_ALLOC`], counted as RGBA: the image's size is read from its
/// header first, and an image of more than that as RGBA gets a limit error before anything is
/// decoded (#1717).
pub(crate) fn decode_within_allowance(bytes: &[u8]) -> image::ImageResult<image::DynamicImage> {
let reader = || -> image::ImageResult<_> {
let mut r = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format()?;
let mut limits = image::Limits::default();
limits.max_alloc = Some(MAX_DECODE_ALLOC);
r.limits(limits);
r.decode().ok()
});
if img.is_none() {
DECODE_FAILURES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Ok(r)
};
let (w, h) = reader()?.into_dimensions()?;
if u64::from(w) * u64::from(h) > MAX_DECODE_ALLOC / 4 {
return Err(image::ImageError::Limits(image::error::LimitError::from_kind(image::error::LimitErrorKind::InsufficientMemory)));
}
img
reader()?.decode()
}
/// `img` shrunk, keeping its proportions, to fit a pixmap's sides (at most `u16::MAX` pixels).
@@ -107,6 +125,13 @@ pub fn decode_pixmap_fit(bytes: &[u8], w: u16, h: u16) -> Option<vello_cpu::Pixm
pixmap_of(img.to_rgba8())
}
/// Count `n` images that couldn't be decoded on this thread ([`decode_failures`]). [`decode`]
/// counts each failure, and a raster kept in a cache counts the failures it was drawn with again
/// each time it is drawn.
pub(crate) fn add_decode_failures(n: u64) {
DECODE_FAILURES.with(|c| c.set(c.get() + n));
}
/// An RGBA image as a premultiplied pixmap.
fn pixmap_of(img: image::RgbaImage) -> Option<vello_cpu::Pixmap> {
let (w, h) = img.dimensions();
+38 -3
View File
@@ -89,6 +89,10 @@ struct TileEntry {
sx: f64,
sy: f64,
pixmap: Arc<Pixmap>,
/// How many images in the art failed to decode while the tile was drawn
/// ([`crate::paint::decode_failures`]): each frame that draws from the tile counts them
/// again ([`crate::paint::add_decode_failures`]).
failed: u64,
}
struct PatternCache {
@@ -244,25 +248,35 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine,
&& std::ptr::eq(e.cms.as_ptr(), Arc::as_ptr(&cms))
&& e.deps.as_ref().is_none_or(|d| d.current(doc))
})
.map(|e| (e.pixmap.clone(), e.sx, e.sy))
.map(|e| (e.pixmap.clone(), e.sx, e.sy, e.failed))
});
let (pm, sx, sy) = match hit {
Some(h) => h,
Some((pm, sx, sy, failed)) => {
crate::paint::add_decode_failures(failed);
(pm, sx, sy)
}
None => {
let w = (pw * scale).ceil().clamp(1.0, 4096.0) as u16;
let h = (ph * scale).ceil().clamp(1.0, 4096.0) as u16;
let before = crate::paint::decode_failures();
let Some(pm) = with_tile_renderer(|r| rasterize(r, doc, def, Rect::new(0.0, 0.0, pw, ph), w, h, ink)) else {
ctx.set_paint(missing());
return true;
};
let failed = crate::paint::decode_failures() - before;
let pm = Arc::new(pm);
let (sx, sy) = (w as f64 / pw, h as f64 / ph);
let deps = crate::fx::Reads::of_all(def.art.iter().map(|a| &**a)).any().then(|| Deps::of(doc));
// A cancelled export drew part of the tile: it isn't kept for later frames.
CACHE.with(|c| {
if vectorcraft_doc::cancel::is_cancelled() {
return;
}
let mut c = c.borrow_mut();
let tiles = &mut c.tiles[ink as usize];
let (t, bytes) = (tiles.tick(), size_of::<TileEntry>() + w as usize * h as usize * 4);
tiles.insert(key, TileEntry { art: def.art.clone(), deps, cms: Arc::downgrade(&cms), params, sx, sy, pixmap: pm.clone() }, bytes, t);
let entry = TileEntry { art: def.art.clone(), deps, cms: Arc::downgrade(&cms), params, sx, sy, pixmap: pm.clone(), failed };
tiles.insert(key, entry, bytes, t);
});
(pm, sx, sy)
}
@@ -573,6 +587,27 @@ mod tests {
assert!(kept().1 <= budget, "{:?}", kept());
}
/// A pattern whose art is an image that can't be decoded: every frame that draws the pattern
/// counts the failure ([`crate::paint::decode_failures`]), from a new tile or from the
/// tile kept.
#[test]
fn kept_tiles_count_the_images_they_could_not_decode() {
use vectorcraft_doc::{ImageBlob, ImageObject};
let mut d = doc_with_pattern(TileType::Grid);
// A PNG signature with nothing after it.
d.images.insert("cut".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec()));
let im = ImageObject { key: "cut".into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default() };
d.patterns[0].art = vec![Arc::new(Node::new(NodeId(901), NodeKind::Image(Box::new(im))))];
let mut r = Renderer::new();
let start = tiles_rasterized();
for n in 0..3 {
let before = crate::paint::decode_failures();
frame(&mut r, &d, 1.0);
assert!(crate::paint::decode_failures() > before, "frame {n}");
}
assert_eq!(tiles_rasterized() - start, 1, "the later frames draw from the tile kept");
}
#[test]
fn missing_pattern_is_grey() {
let mut d = doc_with_pattern(TileType::Grid);
+5 -5
View File
@@ -11,7 +11,7 @@
//! Placed raster images are not separated (limitation).
//! * **Overprint Preview** (and Separations Preview, which implies it): fills and strokes that
//! overprint ([`vectorcraft_doc::FillLayer::overprint`], characters' too) are drawn with
//! Multiply, which approximates their inks printing over the inks below: a zero ink lets the
//! Darken, which approximates their inks printing over the inks below: a zero ink lets the
//! inks below show through, where a knockout would replace them.
//!
//! The app's current view state lives in [`view`] / [`set_view`]; the canvas copies it into
@@ -25,7 +25,7 @@ use vectorcraft_color::cms::{self, Cms, PROCESS_PLATES};
pub use vectorcraft_color::cms::{Intent, ProofSetup, ProofTarget};
use vectorcraft_color::{Color, Swatch, SwatchGroup};
pub use vectorcraft_doc::inks::{Inks, Link, Plate, inks, map_document_colors, map_node_colors, plates, spot_color};
use vectorcraft_doc::overprint::multiply_overprints;
use vectorcraft_doc::overprint::darken_overprints;
use vectorcraft_doc::{Document, Node, NodeId, Shared};
use crate::RenderOptions;
@@ -103,8 +103,8 @@ impl PartialEq for Plates {
}
}
/// What [`prepare`] does to the fills and strokes of each node: overprinting ones multiply
/// ([`multiply_overprints`]), and with Separations Preview every color shows as the visible plates
/// What [`prepare`] does to the fills and strokes of each node: overprinting ones darken
/// ([`darken_overprints`]), and with Separations Preview every color shows as the visible plates
/// print it ([`plate_color`]).
struct How<'a> {
doc: &'a Document,
@@ -122,7 +122,7 @@ impl How<'_> {
/// Change the fills, strokes and other colors of node `a`, which holds no members.
fn apply(&self, a: &mut Arc<Node>) {
multiply_overprints(a, self.discard_white);
darken_overprints(a, self.discard_white);
if let Some((c, proof, visible)) = self.plates {
map_node_colors(Arc::make_mut(a), &mut |col, sw| plate_color(self.doc, c, proof, visible, col, sw));
}
+84
View File
@@ -776,6 +776,23 @@ fn invalid_dash_pattern_strokes_solid() {
}
}
/// An image that can't be decoded is counted on the thread that tried to decode it: an export
/// compares [`decode_failures`] before and after it draws, so another thread's failure meanwhile is
/// not its own.
#[test]
fn decode_failures_counts_this_threads_images_only() {
let undecodable = || paint::decode_pixmap(b"\x89PNG\r\n\x1a\n").is_none();
let before = decode_failures();
let other = std::thread::spawn(move || {
let start = decode_failures();
undecodable() && decode_failures() == start + 1
});
assert!(other.join().unwrap(), "counted on the other thread");
assert_eq!(decode_failures(), before, "the other thread's failure");
assert!(undecodable());
assert_eq!(decode_failures(), before + 1, "this thread's failure");
}
/// An image drawn larger than its pixels samples smoothly, or with `smooth_images` off each screen
/// pixel takes its nearest image pixel (Pixel Preview with File Handling › Display Bitmaps as
/// Anti-aliased Images off, #394).
@@ -799,6 +816,73 @@ fn images_take_their_nearest_pixel_without_smoothing() {
assert_eq!(greys(false), 0, "nearest: black, then white");
}
/// A PNG that says it is `w` × `h` 1-bit gray pixels (its header and no rows).
fn png_header(w: u32, h: u32) -> Vec<u8> {
let mut out = b"\x89PNG\r\n\x1a\n".to_vec();
let ihdr: Vec<u8> = [&b"IHDR"[..], &w.to_be_bytes(), &h.to_be_bytes(), &[1, 0, 0, 0, 0]].concat();
for chunk in [&ihdr[..], b"IDAT\x78\x9c", b"IEND"] {
out.extend((chunk.len() as u32 - 4).to_be_bytes());
out.extend(chunk);
let mut crc = flate2::Crc::new();
crc.update(chunk);
out.extend(crc.sum().to_be_bytes());
}
out
}
/// A little-endian TIFF that says it is `w` × `h` 8-bit gray pixels in one strip (its header and
/// no pixels).
fn tiff_header(w: u32, h: u32) -> Vec<u8> {
let mut out = b"II*\0".to_vec();
out.extend(8u32.to_le_bytes());
// (tag, type: 3 SHORT or 4 LONG, value), one value each.
let entries: [(u16, u16, u32); 9] =
[(256, 4, w), (257, 4, h), (258, 3, 8), (259, 3, 1), (262, 3, 1), (273, 4, 8), (277, 3, 1), (278, 4, h), (279, 4, 0)];
out.extend((entries.len() as u16).to_le_bytes());
for (tag, kind, value) in entries {
out.extend(tag.to_le_bytes());
out.extend(kind.to_le_bytes());
out.extend(1u32.to_le_bytes());
out.extend(value.to_le_bytes());
}
out.extend(0u32.to_le_bytes());
out
}
/// The decode allowance (2 GB) counts an image's pixels as RGBA, read from its header (#1717): a
/// 1-bit gray image whose 8-bit gray pixels are within it but whose RGBA is over it is not decoded
/// and draws nothing, counted for an export to report; one a column narrower goes on to decoding.
#[test]
fn an_image_too_large_as_rgba_is_not_decoded() {
let over = paint::decode_within_allowance(&png_header(23_171, 23_170));
assert!(matches!(over, Err(image::ImageError::Limits(_))), "{over:?}");
let failed = decode_failures();
assert!(paint::decode_pixmap(&png_header(23_171, 23_170)).is_none());
assert!(decode_failures() > failed);
// Within the allowance: decoded, and its missing rows can't be read.
let within = paint::decode_within_allowance(&png_header(23_170, 23_170));
assert!(matches!(within, Err(image::ImageError::Decoding(_))), "{within:?}");
// A header whose size as RGBA does not fit in 64 bits gets the same limit error.
let huge = paint::decode_within_allowance(&tiff_header(1 << 31, 1 << 31));
assert!(matches!(huge, Err(image::ImageError::Limits(_))), "{huge:?}");
}
/// A gray image within the allowance draws its gray levels.
#[test]
fn a_gray_image_draws_its_gray_levels() {
use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind};
let img = image::GrayImage::from_fn(2, 1, |x, _| image::Luma([if x == 0 { 40 } else { 200 }]));
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let mut d = Document::new(40.0, 20.0);
d.images.insert("gray".into(), ImageBlob::new("image/png", png));
let id = d.alloc_id();
let im = ImageObject { key: "gray".into(), width: 2, height: 1, xf: Affine::scale(20.0), link: None, placement: Default::default() };
d.insert(Some(d.layers[0].id), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap();
let r = Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &RenderOptions { smooth_images: false, ..Default::default() });
assert_eq!((r.pixel(5, 10), r.pixel(35, 10)), ([40, 40, 40, 255], [200, 200, 200, 255]));
}
/// Art Optimized exports are supersampled (#983): two red shapes meeting along a diagonal leave no
/// seam of the white background between them, where shape-by-shape smoothing shows one; the outer
/// edges stay smooth.
+19
View File
@@ -261,3 +261,22 @@ fn rich_black_shows_black_ink_alone_as_rgb_black() {
add(&mut d, n);
assert_eq!(centre_with(&d, true), centre_with(&d, false), "an image shows as it is");
}
/// An overprinting stroke in the colour of the fill under it shows no darker band in Overprint
/// Preview: the same ink printed over itself is that ink (#2415). Multiply would square the colour.
#[test]
fn overprint_of_the_same_colour_shows_no_darker_band() {
for (mode, c) in [(ColorMode::Rgb, Color::rgb(0.0, 0.55, 0.29)), (ColorMode::Cmyk, Color::cmyk(0.85, 0.0, 1.0, 0.0))] {
let mut d = Document::new_with_mode(40.0, 40.0, mode);
let mut n =
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), Appearance::basic(Paint::solid(c), Paint::solid(c), 8.0));
for it in &mut n.appearance.items {
*it.overprint_mut() = true;
}
add(&mut d, n);
let opts = RenderOptions { background: Some([255, 255, 255, 255]), overprint_preview: true, ..Default::default() };
let r = Renderer::new().render_region_with(&d, d.artboards[0].rect, 1.0, &opts);
// (20, 20) is fill only; (12, 20) lies in the stroke's inner half, over the fill.
assert!(near(r.pixel(12, 20), r.pixel(20, 20), 2), "{mode:?}: {:?} vs {:?}", r.pixel(12, 20), r.pixel(20, 20));
}
}
+2 -1
View File
@@ -1535,7 +1535,8 @@ fn proof_previews_draw_changes_as_a_new_renderer_does() {
// A square in spot color `Lab Spot`, defined in Lab in a color group, on its own plate.
let lab_doc = || {
let mut d = proof_doc();
let lab = Color::lab(50.0, 60.0, 40.0);
// Outside the press gamut, so its CMYK alternate looks different from the Lab colour.
let lab = Color::lab(50.0, 90.0, 70.0);
let swatch = vectorcraft_color::Swatch { name: "Lab Spot".into(), paint: Paint::solid(lab), global: true, spot: true };
d.swatch_groups.push(vectorcraft_color::SwatchGroup { name: "Lab".into(), swatches: vec![swatch] });
let fill = Paint::Solid { color: lab, swatch: Some("Lab Spot".into()), tint: 1.0 };
+21
View File
@@ -697,3 +697,24 @@ fn a_strokes_own_glow_paints_around_that_stroke_only() {
let worst = img.pixels.iter().zip(&mt.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap();
assert!(worst <= 4, "max channel difference {worst}");
}
#[test]
fn a_path_with_its_own_transform_paints_each_copy_with_its_own_gradient() {
use vectorcraft_color::GradientGeom;
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let ramp = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
let geom = GradientGeom { start: Point::new(0.0, 50.0), end: Point::new(20.0, 50.0), aspect: 1.0, focal: None };
// Fitted to the box on each render, and placed on the object.
for paint in [ramp.clone(), GradientPaint { geom: Some(geom), ..ramp }] {
let square = shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 40.0, 20.0, 60.0));
let mut n = Node::path(NodeId(0), square, Appearance::basic(Paint::Gradient(Box::new(paint)), Paint::None, 0.0));
n.appearance.effects = vec![fx("distort.transform", json!({"moveH": 40, "copies": 1}))];
let img = render(&doc_with(Document::new(100.0, 100.0), n));
let at = |x: usize| img.pixels.chunks(4).nth(50 * 100 + x).map(|p| (p[0], p[2])).unwrap_or_default();
// The left edge of each copy is red, its right edge blue.
for (left, right) in [(1, 18), (41, 58)] {
assert!(at(left).0 > 200 && at(left).1 < 60, "{:?} at {left}", at(left));
assert!(at(right).1 > 200 && at(right).0 < 60, "{:?} at {right}", at(right));
}
}
}