Merge remote-tracking branch 'upstream/main' into fix/3260-halftone-radius-pixels

# Conflicts:
#	crates/doc/src/limits.rs
#	crates/engine/src/cmd/fileio/tests_pdfedit.rs
#	crates/format/src/lib.rs
#	docs/target-app-parity.md
This commit is contained in:
datatimp
2026-11-04 12:30:23 -05:00
428 changed files with 30074 additions and 2532 deletions
+15 -2
View File
@@ -1,6 +1,6 @@
use super::options::{AntiAlias, RenderOptions};
use super::threads::single_threaded_context;
use super::{Affine, Arc, BezPath, Document, FillRule, HashMap, Ink, Node, Rect, RenderContext, SymbolBoxes, TextObject, live};
use super::{Affine, Arc, BezPath, Document, FillRule, HashMap, Ink, Node, Rect, RenderContext, StrokeLayer, SymbolBoxes, TextObject, live};
/// Hasher for pointer-keyed caches: a multiply-xorshift (SipHash showed up in frame profiles).
#[derive(Default, Clone, Copy)]
@@ -30,11 +30,14 @@ pub(crate) type PtrMap<K, V> = HashMap<K, V, std::hash::BuildHasherDefault<PtrHa
/// stroke's place in it, width bits, tolerance level).
pub(crate) type StrokeKey = (usize, usize, u64, i32);
/// A stroke expanded to a fill outline: (owning node, outline, last frame used).
/// A stroke expanded to a fill outline: (owning node, outline, last frame used, and the region a
/// frame must lie in to draw the outline, when the outline leaves out the segments far from that
/// region; `None` when it has every segment).
pub(crate) struct StrokeEntry {
pub(crate) node: Arc<Node>,
pub(crate) outline: Arc<BezPath>,
pub(crate) stamp: u64,
pub(crate) crop: Option<Rect>,
}
/// The slices of a stroke whose gradient runs along or across it, each with its paint and bounds.
@@ -142,6 +145,16 @@ impl Frame<'_> {
(self.opts.anti_alias == AntiAlias::Type).then(|| self.view * t.xf)
}
/// Stroke `st` as this frame draws it: a copy without its dashes when the frame draws dashed
/// lines as solid lines ([`RenderOptions::solid_dashes`]), else `st` itself.
pub(crate) fn stroke<'s>(&self, st: &'s StrokeLayer) -> std::borrow::Cow<'s, StrokeLayer> {
if self.opts.solid_dashes && st.dash.is_some() {
std::borrow::Cow::Owned(StrokeLayer { dash: None, ..st.clone() })
} else {
std::borrow::Cow::Borrowed(st)
}
}
/// A render context for drawing this frame's art offscreen (on the calling thread), with its
/// edge smoothing: hard edges must not depend on where the art is drawn.
pub(crate) fn offscreen_context(&self, w: u16, h: u16) -> RenderContext {
+9 -3
View File
@@ -54,7 +54,7 @@ impl Renderer {
let split_frame;
let (f, screen) = match content.kept.and_then(|k| k.frame.map(|frame| (k.window, frame))) {
Some((window, (view, px))) => {
split_frame = Frame { view, px, ..*f };
split_frame = Frame { view, visible: window, px, ..*f };
(&split_frame, view.transform_rect_bbox(window))
}
None => (f, Rect::new(0.0, 0.0, ctx.width() as f64, ctx.height() as f64)),
@@ -386,7 +386,7 @@ impl Renderer {
}
/// The inputs a raster of `n`'s content, which reads `reads`, is drawn with in frame `f`: the
/// color settings, the edge smoothing, the document's raster effects resolution (which a
/// color settings, the edge smoothing, whether dashed strokes draw as solid lines, the document's raster effects resolution (which a
/// Rasterize or De-Interlace anywhere in the content follows), the knockout `n` inherits and,
/// while `n` holds the isolated group, isolation mode; the pattern definitions, symbols and
/// brush library it reads; for images and placed documents their pixels and files, the
@@ -402,6 +402,8 @@ impl Renderer {
(doc.cms, f.opts.anti_alias.threshold(), self.knockout, f.doc.raster_effects_ppi.to_bits()).hash(&mut h);
// Cached effects on containers can include paths made into guides.
f.opts.hide_guides.hash(&mut h);
// Cached effects can include dashed strokes drawn as solid lines.
f.opts.solid_dashes.hash(&mut h);
if let Some((isolated, path)) = self.isolation_path(f)
&& n.id != isolated
&& path.contains(&n.id)
@@ -584,7 +586,10 @@ impl Renderer {
None => (x0, y0, w, h, k),
};
let mut off = f.offscreen_context(w, h);
let shifted = Frame { mt: false, view: Affine::scale(k) * Affine::translate((-x0, -y0)) * f.view, px: f.px / k, ..*f };
let view = Affine::scale(k) * Affine::translate((-x0, -y0)) * f.view;
// Art is culled against, and long strokes outlined around, the offscreen's own area.
let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h)));
let shifted = Frame { mt: false, view, visible, px: f.px / k, ..*f };
self.inside_layer(|r| draw(r, &mut off, &shifted, None));
off.flush();
let mut pm = vello_cpu::Pixmap::new(w, h);
@@ -641,6 +646,7 @@ impl Renderer {
let outline = match item {
AppearanceItem::Fill(_) => ig.clone(),
AppearanceItem::Stroke(st) => {
let st = &*f.stroke(st);
let w = effects::stroke::aligned_width(st, effects::stroke::is_closed(ig));
effects::stroke::line_outline(&effects::stroke::stroke_pieces(ig, st).line, st, w, f.px * 0.25)
}
+4
View File
@@ -134,6 +134,9 @@ pub struct RenderOptions {
/// Appearance of Black's Rich Black: pure CMYK black (black ink alone at 100%) shows as RGB
/// black. Off, it shows through the CMYK profile, a dark grey, as other colours do.
pub rich_black: bool,
/// Dashed strokes draw as solid lines, without their dashes (the Navigator's Draw Dashed Lines
/// as Solid Lines). Off, they draw their dashes.
pub solid_dashes: bool,
}
/// The opacity of the art around an isolated group or layer.
@@ -219,6 +222,7 @@ impl Default for RenderOptions {
interacting: false,
dragging: false,
rich_black: false,
solid_dashes: false,
}
}
}
+32 -13
View File
@@ -189,8 +189,7 @@ fn fits_beside_pinned(bytes: u64) -> bool {
/// all: an image that isn't a PNG, JPEG, GIF or WebP; an image of more pixels than the allowance
/// has bytes (2,147,483,648 pixels, or 536,870,912 in the web app; 46,341 × 46,341 is more); and an
/// image whose decoded samples take more than the allowance, unless it is a PNG that isn't
/// interlaced and is at most the allowance divided by [`ROW_SCRATCH`] pixels wide (read a row at a
/// time).
/// interlaced whose reader's buffers ([`png_row_scratch`]) fit the allowance (read a row at a time).
pub fn reduced_size(bytes: &[u8]) -> Option<(u32, u32)> {
reduction(bytes, allowance()).map(|r| r.to)
}
@@ -228,26 +227,46 @@ fn reduction(bytes: &[u8], allowance: u64) -> Option<Reduction> {
return None;
}
let to = fitted_dims(w, h, allowance)?;
// The PNG reader's row buffers take at most `ROW_SCRATCH` bytes a column, also within the allowance.
let rows = format == image::ImageFormat::Png
&& u64::from(w).saturating_mul(ROW_SCRATCH) <= allowance
&& png_reader(bytes).is_some_and(|r| !r.info().interlaced);
let scratch = if rows {
u64::from(w) * ROW_SCRATCH
// A PNG that isn't interlaced is read a row at a time when the reader's buffers fit the allowance.
let row_scratch = if format == image::ImageFormat::Png {
png_reader(bytes).filter(|r| !r.info().interlaced).and_then(|r| png_row_scratch(&r)).filter(|&s| s <= allowance)
} else {
None
};
let rows = row_scratch.is_some();
let scratch = row_scratch.unwrap_or_else(|| {
let band = u64::from(BAND) * u64::from(w) * (u64::from(d.color_type().bytes_per_pixel()) + 4);
d.total_bytes().saturating_add(band)
};
});
(rows || d.total_bytes() <= allowance).then_some(Reduction { size: (w, h), to, rows, scratch })
}
/// How many rows of an image decoded whole [`reduced_pixmap`] converts to RGBA at a time.
const BAND: u32 = 16;
/// The most memory a column of a PNG read a row at a time takes in the reader's row buffers: the
/// row as stored (up to 8 bytes a pixel and a filter byte), the row before it, and the row given
/// out (up to 4 bytes a pixel).
const ROW_SCRATCH: u64 = 32;
/// The bytes of rows already read that png 0.18's reader keeps in its stream buffer, at least,
/// before it moves the rest of the buffer back to the start.
const PNG_KEPT_ROWS: u64 = 128 << 10;
/// The deflate window png 0.18's reader keeps after the rows it has unfiltered, and the step its
/// stream buffer grows by.
const PNG_WINDOW_AND_STEP: u64 = (32 << 10) + (8 << 10);
/// The most memory `r` takes in its buffers to read its image a row at a time: the stream buffer
/// holds the rows already read up to [`PNG_KEPT_ROWS`] or four rows, whichever is more, the previous
/// and current rows, and [`PNG_WINDOW_AND_STEP`]. The buffer grows by doubling its reservation,
/// and while it grows its old reservation and the new one, up to twice the size it holds, are both
/// allocated: three times that size at most. A copy of a stored row and the row given out come on
/// top. `None` when a row given out doesn't fit memory.
fn png_row_scratch(r: &png::Reader<std::io::Cursor<&[u8]>>) -> Option<u64> {
let info = r.info();
// A filter byte and the row's samples, in whole bytes.
let stored = 1 + (u64::from(info.width) * info.color_type.samples() as u64 * info.bit_depth as u64).div_ceil(8);
let out = u64::try_from(r.output_line_size(info.width)?).ok()?;
let kept = (stored * 4).next_multiple_of(64).max(PNG_KEPT_ROWS);
let stream = kept + stored * 2 + PNG_WINDOW_AND_STEP;
Some(stream * 3 + stored + out)
}
/// The largest size of at most `w`×`h` pixels, keeping their proportions, whose pixels take at most
/// `allowance` bytes as RGBA, with sides of at most `u16::MAX` pixels.
+61 -18
View File
@@ -6,7 +6,10 @@
//!
//! On screen ([`RenderOptions::progressive_placed`]): a placed document can't be edited, so what
//! it shows is cached as a bitmap per file and zoom step (a power of two, in device pixels per
//! point), shared by every object showing that file. A bitmap not made yet is made on a
//! point), shared by every object showing that file. Images inside it whose pixels can't be
//! decoded are drawn as crossed boxes in the bitmap when the frame marks them
//! ([`RenderOptions::mark_undecoded_images`]), and the bitmaps with and without marks are kept
//! apart. A bitmap not made yet is made on a
//! background worker while the frame shows another step's bitmap, or a light placeholder;
//! [`generation`] changes when one is ready, [`made_between`] says for which files, and [`busy`]
//! says whether any is coming, so the screen can draw again where those files show
@@ -45,8 +48,9 @@ const MAX_BYTES: usize = 4 * BUDGET;
/// The smallest zoom step (device pixels per point).
const MIN_STEP: f64 = 1.0 / 64.0;
/// A bitmap's identity: the file shown (its key) and the zoom step's bits.
type BitmapKey = (String, u64);
/// A bitmap's identity: the file shown (its key), the zoom step's bits, and whether images whose
/// pixels can't be decoded are marked ([`crate::RenderOptions::mark_undecoded_images`]).
type BitmapKey = (String, u64, bool);
struct Bitmap {
pm: Arc<Pixmap>,
@@ -130,10 +134,19 @@ pub(crate) fn scheduled(key: &str) -> usize {
lock(&SCHEDULED).get(key).copied().unwrap_or(0)
}
/// Mark the bitmap of file `key` at zoom `step` as not kept past [`MAX_BYTES`].
/// Mark the bitmaps of file `key` at zoom `step`, with and without marks on undecoded images, as
/// not kept past [`MAX_BYTES`].
#[cfg(test)]
pub(crate) fn mark_unkept(key: &str, step: f64) {
lock(&UNKEPT).insert((key.to_string(), step.to_bits()));
for mark in [false, true] {
lock(&UNKEPT).insert((key.to_string(), step.to_bits(), mark));
}
}
/// `object`'s file (`blob`) as a bitmap of `step` device pixels per point, made here and now.
#[cfg(test)]
pub(crate) fn bitmap(object: &PlacedDocument, blob: &ImageBlob, step: f64, mark_undecoded_images: bool) -> Option<Pixmap> {
make(&mut Renderer::new(), object, blob, step, mark_undecoded_images)
}
/// The zoom step for `scale` device pixels per point: the power of two at or above it.
@@ -150,11 +163,12 @@ impl Bitmaps {
Some(b.pm.clone())
}
/// The bitmap of another step of the same file, the nearest to `step`.
fn nearest(&self, key: &str, step: f64) -> Option<(Arc<Pixmap>, f64)> {
/// The bitmap of another step of the same file, with the same marks on undecoded images, the
/// nearest to `step`.
fn nearest(&self, key: &str, step: f64, mark: bool) -> Option<(Arc<Pixmap>, f64)> {
self.map
.iter()
.filter(|((k, _), _)| k == key)
.filter(|((k, _, m), _)| k == key && *m == mark)
.min_by(|a, b| (a.1.step / step).log2().abs().total_cmp(&(b.1.step / step).log2().abs()))
.map(|(_, b)| (b.pm.clone(), b.step))
}
@@ -245,14 +259,30 @@ fn depth() -> usize {
/// Render `art` of a placed document's file (`doc`, its resources) into `w`×`h` transparent
/// pixels through `view`, the placed documents inside it one level deeper.
pub fn render_inside(r: &mut Renderer, doc: &vectorcraft_doc::Document, art: &Arc<Node>, w: u16, h: u16, view: Affine) -> Option<crate::Rendered> {
render_inside_marked(r, doc, art, w, h, view, false)
}
/// [`render_inside`], drawing images whose pixels can't be decoded as crossed boxes when
/// `mark_undecoded_images` is set ([`crate::RenderOptions::mark_undecoded_images`]).
fn render_inside_marked(
r: &mut Renderer,
doc: &vectorcraft_doc::Document,
art: &Arc<Node>,
w: u16,
h: u16,
view: Affine,
mark_undecoded_images: bool,
) -> Option<crate::Rendered> {
let _inside = Inside::enter();
r.render_node(doc, art, w, h, view)
let opts = crate::RenderOptions { mark_undecoded_images, ..Default::default() };
r.render_node_with(doc, art, w, h, view, &opts)
}
/// `object`'s file (`blob`) as a bitmap of `step` device pixels per point of its box: from its
/// preview when that is all there is and is fine enough, else from the file (read again when
/// needed; the preview when it can't be).
fn make(r: &mut Renderer, object: &PlacedDocument, blob: &ImageBlob, step: f64) -> Option<Pixmap> {
/// needed; the preview when it can't be). Images whose pixels can't be decoded are drawn as
/// crossed boxes when `mark_undecoded_images` is set.
fn make(r: &mut Renderer, object: &PlacedDocument, blob: &ImageBlob, step: f64, mark_undecoded_images: bool) -> Option<Pixmap> {
let (w, h) = (pixels(object.width * step), pixels(object.height * step));
let preview = if blob.is_proxy() { Some(blob.bytes.clone()) } else { blob.proxy.clone() };
let full = match blob.is_proxy() {
@@ -265,7 +295,7 @@ fn make(r: &mut Renderer, object: &PlacedDocument, blob: &ImageBlob, step: f64)
let f = e.frame;
let (sx, sy) = (w as f64 / f.width(), h as f64 / f.height());
let view = Affine::scale_non_uniform(sx, sy) * Affine::translate((-f.x0, -f.y0));
let img = render_inside(r, &e.doc, &e.art, w, h, view)?;
let img = render_inside_marked(r, &e.doc, &e.art, w, h, view, mark_undecoded_images)?;
let data = img.pixels.as_chunks::<4>().0.iter().map(|&[r, g, b, a]| vello_cpu::color::PremulRgba8 { r, g, b, a }).collect();
Some(Pixmap::from_parts(data, w, h))
}
@@ -281,7 +311,7 @@ fn pixels(v: f64) -> u16 {
fn run(r: &mut Renderer, mut job: Job) {
let scope = job.scope.take();
let mut body = || {
match make(r, &job.object, &job.blob, job.step) {
match make(r, &job.object, &job.blob, job.step, job.key.2) {
Some(pm) => {
if !lock(&BITMAPS).insert(job.key.clone(), pm, job.step, job.keep, (BUDGET, MAX_BYTES)) {
lock(&UNKEPT).insert(job.key.clone());
@@ -413,7 +443,7 @@ impl Renderer {
return false;
}
let Some(blob) = f.doc.images.get(&p.key).cloned() else { return true };
let key = (p.key.clone(), step.to_bits());
let key = (p.key.clone(), step.to_bits(), f.opts.mark_undecoded_images);
// Not kept past [`MAX_BYTES`]: drawn exactly.
if lock(&UNKEPT).contains(&key) {
return false;
@@ -423,7 +453,7 @@ impl Renderer {
// The clock when this frame first drew a placed document's bitmap.
let keep = *self.placed_frame.get_or_insert(b.clock);
let found = b.get(&key);
let nearest = if found.is_none() { b.nearest(&p.key, step) } else { None };
let nearest = if found.is_none() { b.nearest(&p.key, step, f.opts.mark_undecoded_images) } else { None };
(found, nearest, keep)
};
let shown = match found {
@@ -434,7 +464,7 @@ impl Renderer {
schedule(Job { key, object: p.clone(), blob, step, keep, scope: SCOPE_CAPTURE.get().map(|capture| capture()) });
// Made here and now (no threads) or meanwhile another step's.
let mut b = lock(&BITMAPS);
b.get(&(p.key.clone(), step.to_bits())).or(nearest.map(|n| n.0))
b.get(&(p.key.clone(), step.to_bits(), f.opts.mark_undecoded_images)).or(nearest.map(|n| n.0))
}
};
let rect = Rect::new(0.0, 0.0, p.width, p.height);
@@ -480,7 +510,7 @@ mod tests {
let mut b = Bitmaps::default();
// 400 bytes each: a budget of 1,000 bytes and at most 2,000.
let limits = (1000, 2000);
let key = |name: &str| (name.to_string(), 1f64.to_bits());
let key = |name: &str| (name.to_string(), 1f64.to_bits(), false);
for name in ["a", "b", "c"] {
assert!(b.insert(key(name), Pixmap::new(10, 10), 1.0, 0, limits));
}
@@ -489,7 +519,7 @@ mod tests {
let frame = b.clock;
assert!(b.get(&key("a")).is_some());
assert!(b.insert(key("d"), Pixmap::new(10, 10), 1.0, frame, limits));
let mut kept: Vec<&str> = b.map.keys().map(|(name, _)| name.as_str()).collect();
let mut kept: Vec<&str> = b.map.keys().map(|(name, _, _)| name.as_str()).collect();
kept.sort_unstable();
assert_eq!((kept, b.bytes), (vec!["a", "d"], 800), "b and c, not drawn since, are dropped");
for name in ["e", "f", "g"] {
@@ -499,4 +529,17 @@ mod tests {
assert!(!b.insert(key("h"), Pixmap::new(10, 10), 1.0, frame, limits), "past the most: not kept");
assert!(!b.map.contains_key(&key("h")) && b.bytes == 2000);
}
/// While a bitmap is made, another step's bitmap stands in only when it has the same marks on
/// undecoded images.
#[test]
fn a_stand_in_bitmap_has_the_same_marks() {
let mut b = Bitmaps::default();
let limits = (BUDGET, MAX_BYTES);
assert!(b.insert(("a".into(), 1f64.to_bits(), false), Pixmap::new(10, 10), 1.0, 0, limits));
assert!(b.insert(("a".into(), 4f64.to_bits(), true), Pixmap::new(40, 40), 4.0, 0, limits));
assert_eq!(b.nearest("a", 2.0, false).map(|n| n.1), Some(1.0));
assert_eq!(b.nearest("a", 2.0, true).map(|n| n.1), Some(4.0));
assert!(b.nearest("b", 2.0, false).is_none());
}
}
+11 -2
View File
@@ -23,6 +23,9 @@ mod nodes;
mod paint;
mod text;
#[cfg(test)]
pub(crate) use paint::{CROP_STROKES, crop_line};
impl Renderer {
pub fn new() -> Self {
Self {
@@ -307,15 +310,20 @@ impl Renderer {
/// Render node `n` of `doc` (its resources) into `w`×`h` transparent pixels through `view`.
pub fn render_node(&mut self, doc: &Document, n: &Arc<Node>, w: u16, h: u16, view: Affine) -> Option<Rendered> {
self.render_node_with(doc, n, w, h, view, &RenderOptions::default())
}
/// [`Self::render_node`] with `opts` (e.g. [`RenderOptions::mark_undecoded_images`]).
pub fn render_node_with(&mut self, doc: &Document, n: &Arc<Node>, w: u16, h: u16, view: Affine, opts: &RenderOptions) -> Option<Rendered> {
self.check_plugins();
self.check_symbols(doc);
let inv = view.inverse();
let visible = inv.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64));
let px = 1.0 / view.determinant().abs().sqrt().max(1e-12);
let mut ctx = single_threaded_context(w, h);
let opts = RenderOptions::default();
ctx.set_aliasing_threshold(opts.anti_alias.threshold());
let symbols = doc.symbol_boxes().quick();
let frame = Frame { mt: false, doc, symbols: &symbols, view, visible, px, opts: &opts, ink: Ink::Display, plates: None };
let frame = Frame { mt: false, doc, symbols: &symbols, view, visible, px, opts, ink: Ink::Display, plates: None };
self.begin_frame();
(self.knockout, self.nested, self.backdrop, self.picture) = (false, 0, None, None);
self.clip_paths.clear();
@@ -354,6 +362,7 @@ impl Renderer {
let s = (w as f64 - 2.0).max(1.0) / b.width().max(b.height()).max(1e-6);
let view = Affine::translate((w as f64 / 2.0, w as f64 / 2.0)) * Affine::scale(s) * Affine::translate(-b.center().to_vec2());
let mut ctx = single_threaded_context(w, w);
ctx.set_aliasing_threshold(opts.anti_alias.threshold());
if let Some(bg) = background {
ctx.set_paint(peniko::Color::from_rgba8(bg[0], bg[1], bg[2], bg[3]));
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, w as f64));
+114 -2
View File
@@ -97,6 +97,13 @@ impl Renderer {
chars: Option<(&Arc<TextGeom>, usize)>,
bounds: Rect,
) {
// A stroke drawn without its dashes is a copy, not the document's stroke: its outline is
// made here and not kept with the document's outlines.
let drawn = f.stroke(st);
let (st, chars) = match &drawn {
std::borrow::Cow::Owned(solid) => (solid, None),
std::borrow::Cow::Borrowed(st) => (*st, chars),
};
let pieces = effects::stroke::stroke_pieces(bp, st);
// The line and its heads overlap: they take the opacity once, in one layer, instead of
// each folding it into its paint.
@@ -253,14 +260,27 @@ impl Renderer {
}
let node = self.cur.clone().filter(|n| owns_stroke(n, st))?;
let key = (self.stroke_owner(Arc::as_ptr(&node) as usize), st as *const StrokeLayer as usize, width.to_bits(), level);
let v = f.visible;
let holds = |crop: Option<Rect>| crop.is_none_or(|r| r.x0 <= v.x0 && r.y0 <= v.y0 && r.x1 >= v.x1 && r.y1 >= v.y1);
if let Some(e) = self.strokes.get_mut(&key)
&& Arc::ptr_eq(&e.node, &node)
&& holds(e.crop)
{
e.stamp = self.stamp;
return Some(e.outline.clone());
}
let outline = expand();
self.strokes.insert(key, StrokeEntry { node, outline: outline.clone(), stamp: self.stamp });
// A long stroke without dashes or a width profile is outlined only in and around the view,
// with a margin of the view's own size on each side ([`crop_line`]).
let region = v.inflate(v.width(), v.height());
let tol = 2f64.powi(level);
let cropped = (crops() && line.elements().len() > CROP_ELEMENTS && st.dash.is_none() && st.profile.is_none())
.then(|| crop_line(line, region, crop_reach(st, width, f.px, tol), width))
.flatten();
let (outline, crop) = match cropped {
Some(part) => (Arc::new(effects::stroke::line_outline(&part, st, width, tol)), Some(region)),
None => (expand(), None),
};
self.strokes.insert(key, StrokeEntry { node, outline: outline.clone(), stamp: self.stamp, crop });
Some(outline)
}
@@ -271,3 +291,95 @@ impl Renderer {
if self.made_again { 0 } else { address }
}
}
/// The stroke lines with more path elements than this are outlined in and around the view only
/// ([`crop_line`]).
pub(crate) const CROP_ELEMENTS: usize = 1024;
#[cfg(test)]
thread_local! {
/// Whether long strokes are outlined near the view only on this thread ([`crop_line`]).
pub(crate) static CROP_STROKES: std::cell::Cell<bool> = const { std::cell::Cell::new(true) };
}
fn crops() -> bool {
#[cfg(test)]
return CROP_STROKES.with(std::cell::Cell::get);
#[cfg(not(test))]
true
}
/// How far stroke `st` at `width` can paint from its path, at a join or a cap, with two output
/// pixels (`px`) and the outline's tolerance `tol` added.
pub(crate) fn crop_reach(st: &StrokeLayer, width: f64, px: f64, tol: f64) -> f64 {
let corner = if st.join == vectorcraft_doc::LineJoin::Miter { st.miter_limit.max(std::f64::consts::SQRT_2) } else { std::f64::consts::SQRT_2 };
width / 2.0 * corner + 2.0 * px + tol
}
/// The stroke line `line` without the segments whose control points' box lies more than `reach`
/// from `region`, for outlining a stroke of `width` that is drawn only in `region`. A subpath that
/// loses some of its segments is replaced by its runs of kept segments, each an open subpath; a
/// closed one is walked from a removed segment, so that a run passes through the point where it
/// starts and ends. A closed subpath that a stroke of `width` can cross over (`width` at least the
/// smaller side of its bounds) keeps all its segments, and so does a subpath with no segments.
/// `None` when every segment is kept.
pub(crate) fn crop_line(line: &BezPath, region: Rect, reach: f64, width: f64) -> Option<BezPath> {
use kurbo::{PathEl, PathSeg};
let near = region.inflate(reach, reach);
let keep = |s: &PathSeg| {
let pts: &[kurbo::Point] = match s {
PathSeg::Line(l) => &[l.p0, l.p1],
PathSeg::Quad(q) => &[q.p0, q.p1, q.p2],
PathSeg::Cubic(c) => &[c.p0, c.p1, c.p2, c.p3],
};
// A coordinate that is not a number keeps the segment.
!(pts.iter().all(|p| p.x < near.x0)
|| pts.iter().all(|p| p.x > near.x1)
|| pts.iter().all(|p| p.y < near.y0)
|| pts.iter().all(|p| p.y > near.y1))
};
let els = line.elements();
let mut starts: Vec<usize> = els.iter().enumerate().filter(|(_, e)| matches!(e, PathEl::MoveTo(_))).map(|(i, _)| i).collect();
if starts.first() != Some(&0) {
starts.insert(0, 0);
}
let mut out = BezPath::new();
let mut cut = false;
for (k, &start) in starts.iter().enumerate() {
let Some(sub) = els.get(start..starts.get(k + 1).copied().unwrap_or(els.len())) else { continue };
let segs = || kurbo::segments(sub.iter().copied());
let (total, kept) = segs().fold((0, 0), |(t, n), s| (t + 1, n + usize::from(keep(&s))));
let closed = sub.iter().any(|e| matches!(e, PathEl::ClosePath));
let crosses = || {
let b = BezPath::from_vec(sub.to_vec()).bounding_box();
width >= b.width().min(b.height())
};
if kept == total || closed && crosses() {
out.extend(sub.iter().copied());
continue;
}
cut = true;
let first = if closed { segs().position(|s| !keep(&s)).map_or(0, |i| i + 1) } else { 0 };
let mut open = false;
for s in segs().skip(first).chain(segs().take(first)) {
if !keep(&s) {
open = false;
continue;
}
if !open {
out.move_to(match s {
PathSeg::Line(l) => l.p0,
PathSeg::Quad(q) => q.p0,
PathSeg::Cubic(c) => c.p0,
});
open = true;
}
match s {
PathSeg::Line(l) => out.line_to(l.p1),
PathSeg::Quad(q) => out.quad_to(q.p1, q.p2),
PathSeg::Cubic(c) => out.curve_to(c.p1, c.p2, c.p3),
}
}
}
cut.then_some(out)
}
+109 -29
View File
@@ -21,6 +21,7 @@ mod fxzoom;
mod isolation;
mod knockout;
mod layeropts;
mod longstroke;
mod memory;
mod objectfx;
mod placed_document;
@@ -377,6 +378,27 @@ fn a_scaled_instance_is_culled_by_the_effects_in_its_art_as_placed() {
assert_eq!(img.pixel(5, 50), [255, 0, 0, 255]);
}
#[test]
fn an_instance_is_culled_by_the_art_its_9_slice_guides_move() {
// A 100 × 100 frame with guides a quarter in, holding an instance of a red bar from x 10 to
// 70. Scaled 2 times across, the bar crosses the left guide and is placed by the middle
// column's map, x → 3x − 50: moved 110 pt right, it draws from x 90 to 270 while the frame
// starts at x 110, out of view.
let mut d = Document::new(100.0, 100.0);
let bar = rect_node(&mut d, Rect::new(10.0, 40.0, 70.0, 60.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Bar".into(), art: Arc::new(bar) });
let frame = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::None, Paint::None, 0.0);
let inner = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::IDENTITY });
let art = Node::group(d.alloc_id(), vec![Arc::new(frame), Arc::new(inner)]);
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Panel".into(), art: Arc::new(art) });
d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default()));
let (id, l) = (d.alloc_id(), d.layers[0].id);
let xf = Affine::translate((110.0, 0.0)) * Affine::scale_non_uniform(2.0, 1.0);
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Panel".into(), xf })).unwrap();
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
assert_eq!(img.pixel(95, 50), [255, 0, 0, 255]);
}
#[test]
fn an_instance_with_a_geometry_effect_is_culled_by_its_reshaped_art() {
// The art, (0, 0) to (20, 20), drawn from (-100, 10) is out of view, and the Transform effect
@@ -885,6 +907,25 @@ fn node_thumbnail_reports_the_size_it_draws() {
}
}
/// A node rendered with `AntiAlias::None` has hard edges, as a page render with that option does.
#[test]
fn node_renders_follow_the_anti_alias_option() {
let mut d = Document::new(100.0, 100.0);
let id = d.alloc_id();
let n = Node::path(id, shapes::ellipse(Rect::new(10.3, 10.3, 60.6, 60.6)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
let node = Arc::new(n.clone());
let hard = RenderOptions { anti_alias: AntiAlias::None, ..Default::default() };
let partial = |img: &Rendered| img.pixels.chunks(4).any(|p| p[3] != 0 && p[3] != 255);
let drawn = |img: &Rendered| img.pixels.chunks(4).any(|p| p[3] == 255);
let mut r = Renderer::new();
assert!(partial(&r.render_node(&d, &node, 100, 100, Affine::IDENTITY).unwrap()), "art anti-aliasing has partial edge pixels");
assert!(partial(&r.render_node_thumbnail(&d, &n, 37, None).unwrap()), "art anti-aliasing has partial edge pixels");
let img = r.render_node_with(&d, &node, 100, 100, Affine::IDENTITY, &hard).unwrap();
assert!(drawn(&img) && !partial(&img), "render_node_with: every alpha is 0 or 255");
let thumb = r.render_node_thumbnail_with(&d, &n, 37, None, &hard).unwrap();
assert!(drawn(&thumb) && !partial(&thumb), "render_node_thumbnail_with: every alpha is 0 or 255");
}
#[test]
fn opacity_mask_with_undecodable_image_leaves_out_object() {
use vectorcraft_doc::{Effect, ImageBlob, ImageObject, NodeKind, OpacityMask};
@@ -1272,6 +1313,43 @@ fn invalid_dash_pattern_strokes_solid() {
}
}
/// With `solid_dashes` a dashed stroke draws as the same stroke without dashes, on its own and as
/// the silhouette of a drop shadow; without it the dashes stay.
#[test]
fn solid_dashes_draw_dashed_strokes_as_solid_lines() {
let line = |dash: bool, shadow: bool| {
let mut d = Document::new(100.0, 100.0);
let mut bp = BezPath::new();
bp.move_to((10.0, 50.0));
bp.line_to((90.0, 50.0));
let id = d.alloc_id();
let mut n = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&bp), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 6.0));
let st = n.appearance.stroke_mut().unwrap();
st.dash = dash.then(|| vectorcraft_doc::Dash { pattern: vec![8.0, 8.0], ..Default::default() });
if shadow {
let params = serde_json::json!({"x": 4, "y": 4, "blur": 1, "opacity": 100});
st.effects.push(vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params, visible: true });
}
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
};
let opts = |solid_dashes: bool| RenderOptions { background: Some([255, 255, 255, 255]), solid_dashes, ..Default::default() };
let draw = |d: &Document, solid: bool| Renderer::new().render(d, 100, 100, Affine::IDENTITY, &opts(solid));
for shadow in [false, true] {
let solid = draw(&line(false, shadow), false);
assert_ne!(draw(&line(true, shadow), false).pixels, solid.pixels, "the dashes show without the option (shadow: {shadow})");
assert_eq!(draw(&line(true, shadow), true).pixels, solid.pixels, "the dashes go with the option (shadow: {shadow})");
assert_eq!(draw(&line(false, shadow), true).pixels, solid.pixels, "a solid stroke is unchanged (shadow: {shadow})");
}
// One renderer that draws the shadow both ways makes its raster again for each setting.
let (dashed, mut r) = (line(true, true), Renderer::new());
let fresh = |solid: bool| draw(&dashed, solid).pixels;
for solid in [false, true, false] {
assert_eq!(r.render(&dashed, 100, 100, Affine::IDENTITY, &opts(solid)).pixels, fresh(solid), "reused renderer, solid: {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.
@@ -1648,46 +1726,48 @@ fn encoded(img: image::DynamicImage, format: image::ImageFormat) -> Vec<u8> {
#[test]
fn a_png_past_the_allowance_draws_at_reduced_size() {
use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind};
let img = image::ImageBuffer::<image::Rgba<u16>, _>::from_fn(64, 48, |x, _| {
if x < 32 { image::Rgba([51400, 10280, 10280, 65535]) } else { image::Rgba([10280, 10280, 51400, 65535]) }
let img = image::ImageBuffer::<image::Rgba<u16>, _>::from_fn(400, 400, |x, _| {
if x < 200 { image::Rgba([51400, 10280, 10280, 65535]) } else { image::Rgba([10280, 10280, 51400, 65535]) }
});
let png = encoded(image::DynamicImage::ImageRgba16(img), image::ImageFormat::Png);
// 64 × 48 is 12,288 bytes as RGBA; 36 × 27 is the largest within 4,096.
assert_eq!(with_allowance(4096, || paint::reduced_size(&png)), Some((36, 27)));
// 400 × 400 is 640,000 bytes as RGBA; 387 × 387 is the largest within 600,000.
assert_eq!(with_allowance(600_000, || paint::reduced_size(&png)), Some((387, 387)));
assert_eq!(paint::reduced_size(&png), None, "within the 2 GB allowance");
let mut d = Document::new(64.0, 48.0);
let mut d = Document::new(400.0, 400.0);
d.images.insert("over".into(), ImageBlob::new("image/png", png));
let id = d.alloc_id();
let im = ImageObject { key: "over".into(), width: 64, height: 48, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };
let im =
ImageObject { key: "over".into(), width: 400, height: 400, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };
d.insert(Some(d.layers[0].id), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap();
let failed = decode_failures();
// As on screen, where an image that can't be decoded draws as a crossed box.
let screen = RenderOptions { smooth_images: false, mark_undecoded_images: true, ..Default::default() };
let r = with_allowance(4096, || Renderer::new().render(&d, 64, 48, Affine::IDENTITY, &screen));
let r = with_allowance(600_000, || Renderer::new().render(&d, 400, 400, Affine::IDENTITY, &screen));
assert_eq!(decode_failures(), failed);
assert_eq!((r.pixel(8, 24), r.pixel(56, 24)), ([200, 40, 40, 255], [40, 40, 200, 255]));
// 16 × 16 at 16 bits a sample is 2,048 bytes decoded and 1,024 as RGBA: past 1,500 as decoded
// and within it as RGBA, it is read a row at a time at its own size.
let small = image::ImageBuffer::<image::Rgba<u16>, _>::from_pixel(16, 16, image::Rgba([51400, 10280, 10280, 65535]));
assert_eq!((r.pixel(50, 200), r.pixel(350, 200)), ([200, 40, 40, 255], [40, 40, 200, 255]));
// 300 × 300 at 16 bits a sample is 720,000 bytes decoded and 360,000 as RGBA: past 600,000 as
// decoded and within it as RGBA, it is read a row at a time at its own size.
let small = image::ImageBuffer::<image::Rgba<u16>, _>::from_pixel(300, 300, image::Rgba([51400, 10280, 10280, 65535]));
let small = encoded(image::DynamicImage::ImageRgba16(small), image::ImageFormat::Png);
assert_eq!(with_allowance(1500, || paint::reduced_size(&small)), Some((16, 16)));
let pm = with_allowance(1500, || paint::decode_pixmap(&small)).expect("read a row at a time");
assert_eq!(with_allowance(600_000, || paint::reduced_size(&small)), Some((300, 300)));
let pm = with_allowance(600_000, || paint::decode_pixmap(&small)).expect("read a row at a time");
let c = pm.sample(3, 3);
assert_eq!((pm.width(), pm.height(), [c.r, c.g, c.b, c.a]), (16, 16, [200, 40, 40, 255]));
// A row is read only while the reader's row buffers, 32 bytes a column, fit the allowance:
// 128 columns do within 4,096 bytes, and 129 columns are not decoded (their samples, 8 bytes a
// pixel, are past it too).
assert_eq!((pm.width(), pm.height(), [c.r, c.g, c.b, c.a]), (300, 300, [200, 40, 40, 255]));
// A row is read only while the reader's buffers fit the allowance, counting the stream buffer
// three times for the moment it grows. Below 4,096 columns of 16-bit RGBA they take 516,103
// bytes and 60 a column: 1,398 columns take 599,983 bytes, within 600,000, and 1,399 columns
// take 600,043 and are not decoded (their samples, 8 bytes a pixel, are past it too).
let wide = |w: u32| {
let img = image::ImageBuffer::<image::Rgba<u16>, _>::from_pixel(w, 6, image::Rgba([51400, 10280, 10280, 65535]));
let img = image::ImageBuffer::<image::Rgba<u16>, _>::from_pixel(w, 60, image::Rgba([51400, 10280, 10280, 65535]));
encoded(image::DynamicImage::ImageRgba16(img), image::ImageFormat::Png)
};
assert_eq!(with_allowance(4096, || paint::reduced_size(&wide(128))), Some((128, 6)));
assert_eq!(with_allowance(4096, || paint::reduced_size(&wide(129))), None);
// An image of more pixels than the allowance has bytes is not read: 128 × 32 8-bit gray pixels
// are drawn at 64 × 16 within 4,096 bytes, and 128 × 33 are not.
let gray = |h: u32| encoded(image::DynamicImage::ImageLuma8(image::GrayImage::from_pixel(128, h, image::Luma([90]))), image::ImageFormat::Png);
assert_eq!(with_allowance(4096, || paint::reduced_size(&gray(32))), Some((64, 16)));
assert_eq!(with_allowance(4096, || paint::reduced_size(&gray(33))), None);
assert_eq!(with_allowance(600_000, || paint::reduced_size(&wide(1398))), Some((1398, 60)));
assert_eq!(with_allowance(600_000, || paint::reduced_size(&wide(1399))), None);
// An image of more pixels than the allowance has bytes is not read: 1,000 × 400 8-bit gray
// pixels are drawn at 500 × 200 within 400,000 bytes, and 1,000 × 401 are not.
let gray = |h: u32| encoded(image::DynamicImage::ImageLuma8(image::GrayImage::from_pixel(1000, h, image::Luma([90]))), image::ImageFormat::Png);
assert_eq!(with_allowance(400_000, || paint::reduced_size(&gray(400))), Some((500, 200)));
assert_eq!(with_allowance(400_000, || paint::reduced_size(&gray(401))), None);
}
/// A JPEG whose RGB samples are within the allowance but whose RGBA is past it is decoded whole
@@ -1730,13 +1810,13 @@ fn a_reduced_decode_counts_what_reading_takes_beside_the_images_paints_hold() {
// Decoded whole: 14,400 bytes of RGB and a 16-row band of 8,960 bytes, beside a 73 × 54 result
// of 15,768 bytes.
let jpeg = encoded(image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(80, 60, image::Rgb([30, 120, 200]))), image::ImageFormat::Jpeg);
// Read a row at a time: 64 columns of 32 bytes in the reader's row buffers, beside a 36 × 27
// result of 3,888 bytes.
// Read a row at a time: 540,103 bytes in the reader's buffers for 400 columns of 16-bit RGBA,
// beside a 387 × 387 result of 599,076 bytes.
let png = encoded(
image::DynamicImage::ImageRgba16(image::ImageBuffer::from_pixel(64, 48, image::Rgba([51400, 10280, 10280, 65535]))),
image::DynamicImage::ImageRgba16(image::ImageBuffer::from_pixel(400, 400, image::Rgba([51400, 10280, 10280, 65535]))),
image::ImageFormat::Png,
);
for (bytes, allowance, room, size) in [(&jpeg, 16_000, 15_768 + 14_400 + 8_960, (73, 54)), (&png, 4_096, 3_888 + 2_048, (36, 27))] {
for (bytes, allowance, room, size) in [(&jpeg, 16_000, 15_768 + 14_400 + 8_960, (73, 54)), (&png, 600_000, 599_076 + 540_103, (387, 387))] {
let failed = decode_failures();
let none = with_allowance(allowance, || with_room(room - 1, || paint::decode_pixmap_or_why(bytes)));
assert_eq!(none.err(), Some(paint::Undecoded::NoRoom), "not decoded with a byte too few, and tried again later");
+225
View File
@@ -0,0 +1,225 @@
//! A long stroke without dashes or a width profile is outlined in and around the view only
//! (`crop_line`), and draws the same pixels as when it is outlined whole.
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, LineCap, LineJoin, Node, StrokeLayer};
use vectorcraft_geom::{PathData, Point, Vec2};
use crate::renderer::{CROP_STROKES, crop_line};
use crate::*;
const SIZE: u32 = 200;
/// A document holding `path` stroked with `st` and no fill.
fn doc(path: &BezPath, st: StrokeLayer) -> Document {
let mut d = Document::new(1000.0, 1000.0);
let n = Node::path(d.alloc_id(), PathData::from_bezpath(path), Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() });
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
}
fn stroke(width: f64, join: LineJoin, cap: LineCap) -> StrokeLayer {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width);
st.join = join;
st.cap = cap;
st.miter_limit = 10.0;
st
}
/// The view that shows `at` in the middle of the 200×200 frame.
fn view_at(at: Point) -> Affine {
Affine::translate((100.0 - at.x, 100.0 - at.y))
}
fn render(r: &mut Renderer, d: &Document, at: Point) -> Rendered {
r.render(d, SIZE, SIZE, view_at(at), &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
}
/// What a renderer that outlines every stroke whole draws.
fn whole(d: &Document, at: Point) -> Rendered {
CROP_STROKES.with(|c| c.set(false));
let mut r = Renderer::new();
let img = render(&mut r, d, at);
CROP_STROKES.with(|c| c.set(true));
assert!(r.strokes.values().all(|e| e.crop.is_none()), "the reference outlines the stroke whole");
img
}
/// 3,000 anchors 8 pt apart, up and down by 30 pt, every third segment curved: sharp miter joins.
fn zigzag() -> BezPath {
let mut bp = BezPath::new();
let at = |i: usize| Point::new(i as f64 * 8.0, if i.is_multiple_of(2) { 0.0 } else { 30.0 });
bp.move_to(at(0));
for i in 1..3000 {
if i % 3 == 0 {
bp.curve_to(at(i - 1) + Vec2::new(6.0, 0.0), at(i) + Vec2::new(-6.0, 10.0), at(i));
} else {
bp.line_to(at(i));
}
}
bp
}
/// A closed polygon of `n` anchors round a circle of radius `r` about the origin.
fn ring(n: usize, r: f64) -> BezPath {
let mut bp = BezPath::new();
for i in 0..n {
let t = i as f64 / n as f64 * std::f64::consts::TAU;
let p = Point::new(r * t.cos(), r * t.sin());
if i == 0 { bp.move_to(p) } else { bp.line_to(p) }
}
bp.close_path();
bp
}
#[test]
fn a_long_stroke_draws_the_same_pixels_outlined_near_the_view() {
let mut inside = stroke(8.0, LineJoin::Round, LineCap::Butt);
inside.align = vectorcraft_doc::StrokeAlign::Inside;
// The ring starts at (5000, 0): the second view is on its start and end.
let cases = [
("zigzag, miter joins, square caps", doc(&zigzag(), stroke(6.0, LineJoin::Miter, LineCap::Square)), Point::new(12_000.0, 15.0), true),
("ring, round joins", doc(&ring(4000, 5000.0), stroke(12.0, LineJoin::Round, LineCap::Round)), Point::new(0.0, 5000.0), true),
("ring through its start, bevel joins", doc(&ring(4000, 5000.0), stroke(5.0, LineJoin::Bevel, LineCap::Butt)), Point::new(5000.0, 0.0), true),
("ring, inside stroke", doc(&ring(4000, 5000.0), inside), Point::new(-5000.0, 0.0), true),
];
for (what, d, at, crops) in &cases {
let mut r = Renderer::new();
let img = render(&mut r, d, *at);
assert_eq!(r.strokes.values().any(|e| e.crop.is_some()), *crops, "{what}: outlined near the view");
assert!(img.pixels == whole(d, *at).pixels, "{what}: the same pixels as the whole outline");
// A view inside the region kept around the first one reuses the outline; one beyond it
// outlines the stroke again.
for step in [Point::new(60.0, 40.0), Point::new(450.0, 0.0), Point::new(-900.0, 300.0)] {
let next = *at + step.to_vec2();
assert!(render(&mut r, d, next).pixels == whole(d, next).pixels, "{what}, moved by {step:?}: the same pixels");
}
}
}
/// A zigzag of 3,000 anchors 8 pt apart between y = 60 and y = 550, through the bottom of a view
/// on y = 0.
fn tall_zigzag() -> BezPath {
let mut bp = BezPath::new();
let at = |i: usize| Point::new(i as f64 * 8.0, if i.is_multiple_of(2) { 60.0 } else { 550.0 });
bp.move_to(at(0));
for i in 1..3000 {
bp.line_to(at(i));
}
bp
}
#[test]
fn a_long_stroke_under_a_wide_blur_draws_the_same_pixels_while_dragging_on_threads() {
// While dragging on threads, a drop shadow this large is drawn through a filter layer whose
// offscreen reaches 602 px past the view: the parts of the stroke that far away feed the blur.
let mut d = Document::new(1000.0, 1000.0);
let shadow = serde_json::json!({"x": 0, "y": 0, "blur": 400, "opacity": 100, "mode": "normal"});
let look = Appearance { items: vec![AppearanceItem::Stroke(stroke(20.0, LineJoin::Round, LineCap::Butt))], ..Default::default() };
let n = Node::path(d.alloc_id(), PathData::from_bezpath(&tall_zigzag()), look);
// The shadow is the group's: the path itself draws as a plain path.
let mut g = Node::group(d.alloc_id(), vec![n.into()]);
g.appearance.effects.push(vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: shadow, visible: true });
let l = d.layers[0].id;
d.insert(Some(l), 0, g).unwrap();
let at = Point::new(12_000.0, 0.0);
let opts = RenderOptions { background: Some([255, 255, 255, 255]), dragging: true, ..Default::default() };
let draw = |crops: bool| {
CROP_STROKES.with(|c| c.set(crops));
let mut r = Renderer::new();
r.threads = 2;
let img = r.render(&d, SIZE, SIZE, view_at(at), &opts);
CROP_STROKES.with(|c| c.set(true));
(img, r.strokes.values().any(|e| e.crop.is_some()))
};
let ((near, cropped), (all, _)) = (draw(true), draw(false));
assert!(cropped, "outlined near the view");
assert!(all.pixel(100, 0)[0] < 250, "the shadow shows above the stroke: {:?}", all.pixel(100, 0));
assert!(near.pixels == all.pixels, "the same pixels as the whole outline");
}
#[test]
fn a_member_below_the_view_casts_its_wide_shadow_while_dragging_on_threads() {
// A 400 pt shadow of a group whose only member, a rectangle, lies 50 pt below the view.
let mut d = Document::new(1000.0, 1000.0);
let shadow = serde_json::json!({"x": 0, "y": 0, "blur": 400, "opacity": 100, "mode": "normal"});
let look = Appearance { items: vec![AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(Paint::solid(Color::BLACK)))], ..Default::default() };
let n = Node::path(d.alloc_id(), PathData::from_bezpath(&Rect::new(-1000.0, 150.0, 1000.0, 550.0).to_path(0.1)), look);
let mut g = Node::group(d.alloc_id(), vec![n.into()]);
g.appearance.effects.push(vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: shadow, visible: true });
let l = d.layers[0].id;
d.insert(Some(l), 0, g).unwrap();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), dragging: true, ..Default::default() };
let mut r = Renderer::new();
r.threads = 2;
let img = r.render(&d, SIZE, SIZE, view_at(Point::ZERO), &opts);
assert!(img.pixel(100, 199)[0] < 200, "the shadow shows at the bottom of the view: {:?}", img.pixel(100, 199));
}
#[test]
fn a_long_stroke_is_outlined_near_the_view_only() {
let d = doc(&zigzag(), stroke(6.0, LineJoin::Miter, LineCap::Square));
let at = Point::new(12_000.0, 15.0);
let mut r = Renderer::new();
render(&mut r, &d, at);
let near = r.strokes.values().map(|e| e.outline.elements().len()).sum::<usize>();
CROP_STROKES.with(|c| c.set(false));
let mut full = Renderer::new();
render(&mut full, &d, at);
CROP_STROKES.with(|c| c.set(true));
let all = full.strokes.values().map(|e| e.outline.elements().len()).sum::<usize>();
assert!(near > 0 && near * 10 < all, "{near} outline elements near the view, {all} for the whole stroke");
}
#[test]
fn a_stroke_a_wide_stroke_crosses_over_stays_whole() {
// A closed sliver 4 pt tall: a 10 pt stroke covers it from side to side.
let mut bp = BezPath::new();
bp.move_to((0.0, 0.0));
for i in 1..=2000 {
bp.line_to((f64::from(i) * 10.0, 0.0));
}
bp.line_to((20_000.0, 4.0));
bp.line_to((0.0, 4.0));
bp.close_path();
let d = doc(&bp, stroke(10.0, LineJoin::Miter, LineCap::Butt));
let at = Point::new(20_000.0, 2.0);
let mut r = Renderer::new();
let img = render(&mut r, &d, at);
assert!(r.strokes.values().all(|e| e.crop.is_none()));
assert!(img.pixels == whole(&d, at).pixels);
}
fn path(svg: &str) -> BezPath {
BezPath::from_svg(svg).unwrap()
}
#[test]
fn crop_line_keeps_the_runs_of_segments_near_the_region() {
let region = Rect::new(0.0, 0.0, 100.0, 100.0);
let crop = |svg: &str| crop_line(&path(svg), region, 5.0, 1.0).map(|bp| bp.to_svg());
// Nothing to leave out: an empty path, one segment, a path in the region.
assert_eq!(crop(""), None);
assert_eq!(crop("M10,10 L90,90"), None);
assert_eq!(crop("M10,10 L90,90 L10,90 Z"), None);
// A segment within the reach of the region stays; one beyond it goes, and the open path is cut.
assert_eq!(crop("M50,50 L104,50 L200,50 L300,50").as_deref(), Some("M50,50 L104,50 L200,50"));
assert_eq!(crop("M50,50 L200,50 L300,50 L400,50").as_deref(), Some("M50,50 L200,50"));
// A curve whose control point reaches the region stays.
assert_eq!(crop("M200,0 C50,50 300,0 400,0 L500,0").as_deref(), Some("M200,0 C50,50 300,0 400,0"));
// Two runs, each its own open subpath.
assert_eq!(crop("M50,10 L300,10 L300,90 L50,90").as_deref(), Some("M50,10 L300,10 M300,90 L50,90"));
// Every segment goes.
assert_eq!(crop("M300,300 L400,400").as_deref(), Some(""));
// A closed subpath is walked from a segment it loses: its start stays inside one run.
assert_eq!(crop("M50,50 L50,500 L500,500 L500,50 Z").as_deref(), Some("M500,50 L50,50 L50,500"));
// A subpath with no segments stays, and so does an untouched subpath beside a cut one.
assert_eq!(crop("M900,900 M10,10 L20,20 M50,50 L300,50 L400,50").as_deref(), Some("M900,900 M10,10 L20,20 M50,50 L300,50"));
// A closed subpath that the stroke crosses over keeps all its segments.
assert_eq!(crop_line(&path("M50,0 L50,2 L500,2 L500,0 Z"), region, 5.0, 3.0), None);
// A coordinate that is not a number keeps its segment.
let mut nan = path("M50,50 L300,50 L400,50");
nan.line_to((f64::NAN, 50.0));
assert!(crop_line(&nan, region, 5.0, 1.0).is_some_and(|bp| bp.elements().iter().any(|e| e.end_point().is_some_and(|p| p.x.is_nan()))));
}
+46 -1
View File
@@ -8,7 +8,9 @@ use vectorcraft_geom::shapes;
use crate::*;
/// Every file reads as a 40×20 artboard filled blue; a file whose bytes start with `loop` also
/// places itself (the same key) in its middle, and one whose bytes start with `bad` can't be read.
/// places itself (the same key) in its middle, one whose bytes start with `cut` also shows an
/// image whose pixels can't be decoded over its left half, and one whose bytes start with `bad`
/// can't be read.
fn blue_page(p: &PlacedDocument, bytes: &[u8]) -> Option<(Document, Node, Rect)> {
if bytes.starts_with(b"bad") {
return None;
@@ -17,6 +19,19 @@ fn blue_page(p: &PlacedDocument, bytes: &[u8]) -> Option<(Document, Node, Rect)>
d.layers.clear();
let blue = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0);
let mut children = vec![Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 20.0)), blue))];
if bytes.starts_with(b"cut") {
d.images.insert("cut-image".into(), ImageBlob::new("image/png", b"not a PNG: placed document".to_vec()));
let im = vectorcraft_doc::ImageObject {
key: "cut-image".into(),
width: 20,
height: 20,
xf: Affine::IDENTITY,
link: None,
placement: Default::default(),
pixels: None,
};
children.push(Arc::new(Node::new(NodeId(3), NodeKind::Image(Box::new(im)))));
}
if bytes.starts_with(b"loop") {
d.images.insert(p.key.clone(), ImageBlob::new("application/json", bytes.to_vec()));
let me = PlacedDocument { xf: Affine::translate((10.0, 5.0)) * Affine::scale(0.5), ..p.clone() };
@@ -254,6 +269,36 @@ fn a_bitmap_that_cant_be_made_is_not_asked_for_again() {
assert!(shown != [255, 255, 255] && shown != [0, 0, 255], "the placeholder: {shown:?}");
}
/// A bitmap made for a frame that marks images whose pixels can't be decoded shows such an image
/// in the file as a crossed box; one made for a frame that does not shows the art under it.
#[test]
fn a_bitmap_marks_undecoded_images_when_the_frame_does() {
let d = doc_with("cut-1", b"cut");
let Some(NodeKind::PlacedDocument(p)) = d.node(NodeId(50)).map(|n| &n.kind) else { panic!("a placed document") };
let Some(blob) = d.images.get("cut-1") else { panic!("the file's bytes") };
for mark in [false, true] {
// 40 × 20 pixels at one pixel a point; the image covers x 0 to 20.
let Some(pm) = placed_document::bitmap(p, blob, 1.0, mark) else { panic!("a bitmap") };
let pixel = |x: usize, y: usize| pm.data_as_u8_slice()[(y * 40 + x) * 4..][..4].to_vec();
assert_eq!(pixel(30, 10), [0, 0, 255, 255], "mark {mark}: the art beside the image");
assert_eq!(pixel(5, 10) != [0, 0, 255, 255], mark, "mark {mark}: the crossed box over the art: {:?}", pixel(5, 10));
}
}
/// Frames with and without marks on undecoded images ask for bitmaps of their own, so a bitmap
/// made for one is not shown in the other. The file can't be read, so no bitmap is made after
/// the test ends.
#[test]
fn bitmaps_with_and_without_undecoded_marks_are_asked_for_apart() {
let _serial = serial();
let d = doc_with("marks-1", b"bad");
for mark in [false, true] {
let opts = RenderOptions { progressive_placed: true, mark_undecoded_images: mark, ..Default::default() };
Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts);
}
assert_eq!(placed_document::scheduled("marks-1"), 2, "one for each");
}
/// Once bitmaps are ready, drawing again where [`placed_area`] says gives what drawing the whole
/// frame again gives. The area of a file covers every object showing it (a group's member and
/// an object with a drop shadow too) and no object showing another file; art of a brush or a
+1
View File
@@ -49,6 +49,7 @@ pub fn default_threads() -> u16 {
/// [`default_threads`] when automatic, on `cpus` processors of which the process may use `usable`
/// (0 = all).
#[cfg(any(test, not(target_arch = "wasm32")))]
fn automatic_threads(cpus: usize, usable: usize) -> u16 {
let cores = if usable == 0 { cpus } else { cpus.min(usable) };
(cores.saturating_sub(1)).min(8) as u16