mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 21:27:00 -06:00
Merge main into feat/live-rasterize-effect
This commit is contained in:
+39
-5
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user