diff --git a/crates/render/src/blobs.rs b/crates/render/src/blobs.rs new file mode 100644 index 000000000..ea2f41b26 --- /dev/null +++ b/crates/render/src/blobs.rs @@ -0,0 +1,113 @@ +use super::*; + +/// `key`'s pixels in the shared image store; `touch` marks them used. +pub(crate) fn lookup(key: &images::ImageKey, touch: bool) -> Option> { + let mut s = images::store(); + if touch { s.get(key) } else { s.peek(key) } +} + +/// The key of `blob`'s decoded pixels in the shared store, the display table a CMYK image's inks +/// show through (its generation is in the key), and the pixels' size once decoded. +pub(crate) fn blob_key(blob: &vectorcraft_doc::ImageBlob) -> (images::ImageKey, Option>, Option<(u16, u16)>) { + let id = images::blob_id(blob); + let lut = id.cmyk.then(ink::display_lut); + let key = + images::ImageKey { content: id.content, adjust: None, grey: false, ink: Ink::Display, luts: (lut.as_ref().map_or(0, |l| l.1), 0), mip: 0 }; + (key, lut.map(|l| l.0), id.size) +} + +/// The decoded pixels of `blob` (a CMYK image's inks shown through the working CMYK profile, again +/// when the colour settings change), from the shared store, and their key there. An image whose +/// pixels can't be decoded is noted in the store and not decoded again; each request for it counts +/// a failure in [`decode_failures`]. +pub(crate) fn blob_pixmap(blob: &vectorcraft_doc::ImageBlob, touch: bool) -> Option<(images::ImageKey, Arc)> { + let (key, lut, size) = blob_key(blob); + let pm = match lookup(&key, touch) { + Some(pm) => pm, + None => { + if images::store().is_undecodable(&key.content) { + paint::add_decode_failures(1); + return None; + } + // Decoded outside the lock: other renderers carry on meanwhile. + let pm = match lut.and_then(|lut| ink::cmyk_pixmap(blob, &lut)) { + Some(pm) => pm, + None => match paint::decode_pixmap_or_why(&blob.bytes) { + Ok(pm) => pm, + Err(why) => { + if why == paint::Undecoded::Unreadable { + images::store().set_undecodable(key.content.clone()); + } + return None; + } + }, + }; + images::store().insert(key.clone(), Arc::new(pm)) + } + }; + let decoded = (pm.width(), pm.height()); + if size != Some(decoded) { + images::remember_size(blob, decoded); + } + Some((key, pm)) +} + +/// How many times to halve image pixels of `w`×`h` drawn through `m` (unit square → device +/// pixels): [`images::mip_level`]. +pub(crate) fn mip_level(m: Affine, w: u16, h: u16) -> u8 { + images::mip_level(m * Affine::scale_non_uniform(1.0 / w.max(1) as f64, 1.0 / h.max(1) as f64), w, h) +} + +/// Image pixels `pm` (stored as `key`) to draw through `m` (unit square → device pixels), shrunk +/// by halves while still at least as fine as the device pixels ([`images::mip_level`]); the +/// pixels drawn are marked used in the store. +pub(crate) fn image_mip(key: &images::ImageKey, pm: Arc, m: Affine) -> Arc { + let level = mip_level(m, pm.width(), pm.height()); + let mut s = images::store(); + if level == 0 { + let _ = s.get(key); // Marks the full pixels used (they were only looked at). + return pm; + } + let at = |l: u8| images::ImageKey { mip: l, ..key.clone() }; + if let Some(p) = s.get(&at(level)) { + return p; + } + // From the finest level kept below it. + let (mut l, mut cur) = (1..level).rev().find_map(|l| s.peek(&at(l)).map(|p| (l, p))).unwrap_or((0, pm)); + drop(s); + while l < level { + l += 1; + let half = Arc::new(images::half(&cur)); + cur = images::store().insert(at(l), half); + } + cur +} + +/// Can image node `n` (`im`) clipped by `region` be painted straight into the region: visible, +/// with no transparency, effects or paint of its own, and the region inside the image's frame +/// (within a pixel), so no paint past the image's edge shows? +pub(crate) fn plain_image(n: &Node, im: &vectorcraft_doc::ImageObject, region: &BezPath) -> bool { + if !(n.visible && n.has_default_transparency() && n.appearance.items.is_empty() && n.appearance.effects.is_empty()) { + return false; + } + let det = im.xf.determinant(); + if !(det.abs() > 1e-12 && det.is_finite()) { + return false; + } + let b = im.xf.inverse().transform_rect_bbox(region.bounding_box()); + b.x0 >= -1.0 && b.y0 >= -1.0 && b.x1 <= im.width as f64 + 1.0 && b.y1 <= im.height as f64 + 1.0 +} + +/// Opacity-mask coverage of one premultiplied pixel: luminance, with the area outside the mask +/// art black (clip) or white (no clip), optionally inverted. +pub(crate) fn mask_value(r: u8, g: u8, b: u8, a: u8, clip: bool, invert: bool) -> u8 { + let [kr, kg, kb] = vectorcraft_color::blend::MASK_LUM; + let mut l = (kr * r as f32 + kg * g as f32 + kb * b as f32) / 255.0; + if !clip { + l += 1.0 - a as f32 / 255.0; + } + if invert { + l = 1.0 - l; + } + (l.clamp(0.0, 1.0) * 255.0 + 0.5) as u8 +} diff --git a/crates/render/src/bounds.rs b/crates/render/src/bounds.rs new file mode 100644 index 000000000..05fb6f0f4 --- /dev/null +++ b/crates/render/src/bounds.rs @@ -0,0 +1,36 @@ +use super::*; + +/// Bounds of everything `n` paints, as the renderer culls it: its members, strokes, brush art and +/// geometry effects, and the shadows and glows of it and its members. Symbol instances are +/// measured by their art in `doc` ([`Node::visual_bounds_with`]). +pub fn painted_bounds(doc: &Document, n: &Node) -> Option { + brush_fx::cull_bounds(&doc.symbol_boxes(), n) +} + +/// [`painted_bounds`] of `n` without the room it leaves for brush art past the brushed strokes of +/// `n` and of a symbol instance's art (the brush art of the objects in a blend, envelope or repeat +/// still counts). A group or layer without effects of its own is measured by its members, leaving +/// out the transparent source art of a Shaper group as [`Node::visual_bounds_with`] does. Symbol +/// instances are measured by their art in `symbols`. +pub fn effect_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { + match &n.kind { + NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !fx::has_object_fx(n) => children + .iter() + .skip(usize::from(n.extras().shaper.is_some())) + .fold(None, |acc, c| vectorcraft_geom::union_opt(acc, effect_bounds(symbols, c))), + _ => fx::cull_bounds(symbols, n), + } +} + +/// Bounds of what `n` draws with its own and its members' geometry effects applied and with the +/// reach of its own and its members' raster effects, measured as [`Node::visual_bounds_with`] +/// measures art without effects: what Object › Rasterize makes its image over. +pub fn drawn_bounds(doc: &Document, n: &Node) -> Option { + fx::painted_reach(&doc.symbol_boxes(), n) +} + +/// The outline, with `n`'s geometry effects applied, that the canvas clips `n`'s Rasterize with +/// Create Clipping Mask to. Object › Rasterize's clipping mask follows it. +pub fn drawn_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRule)> { + fx::drawn_outline(doc, n) +} diff --git a/crates/render/src/clock.rs b/crates/render/src/clock.rs new file mode 100644 index 000000000..3c45dac98 --- /dev/null +++ b/crates/render/src/clock.rs @@ -0,0 +1,10 @@ +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn now() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_micros() as u64).unwrap_or(0) +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn now() -> u64 { + 0 +} diff --git a/crates/render/src/frame.rs b/crates/render/src/frame.rs new file mode 100644 index 000000000..29b317ce4 --- /dev/null +++ b/crates/render/src/frame.rs @@ -0,0 +1,149 @@ +use super::options::{AntiAlias, RenderOptions}; +use super::threads::single_threaded_context; +use super::*; + +/// Hasher for pointer-keyed caches: a multiply-xorshift (SipHash showed up in frame profiles). +#[derive(Default, Clone, Copy)] +pub(crate) struct PtrHasher(pub(crate) u64); + +impl std::hash::Hasher for PtrHasher { + fn write(&mut self, bytes: &[u8]) { + for b in bytes { + self.0 = (self.0 ^ *b as u64).wrapping_mul(0x0100_0000_01b3); + } + } + fn write_u64(&mut self, n: u64) { + let x = (self.0 ^ n).wrapping_mul(0x9e37_79b9_7f4a_7c15); + self.0 = x ^ (x >> 29); + } + fn write_usize(&mut self, n: usize) { + self.write_u64(n as u64); + } + fn finish(&self) -> u64 { + self.0 + } +} + +pub(crate) type PtrMap = HashMap>; + +/// The key of a cached stroke outline: ([`Renderer::stroke_owner`] of what owns the stroke, the +/// 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). +pub(crate) struct StrokeEntry { + pub(crate) node: Arc, + pub(crate) outline: Arc, + pub(crate) stamp: u64, +} + +/// The slices of a stroke whose gradient runs along or across it, each with its paint and bounds. +pub(crate) type Slices = Arc>; + +/// [`Slices`] cached for a stroke: (owning node, slices, last frame used). +pub(crate) struct SliceEntry { + pub(crate) node: Arc, + pub(crate) slices: Slices, + pub(crate) stamp: u64, +} + +/// Cached per-node geometry, keyed by `Arc` identity. Structural sharing means an unchanged node +/// keeps its allocation across edits, so a pointer match (with the Arc kept alive here so the +/// address can't be reused) is an exact cache hit — no invalidation logic needed. +pub(crate) struct GeomEntry { + pub(crate) node: Arc, + pub(crate) bounds: Option, + pub(crate) path: Option>, + pub(crate) stamp: u64, +} + +/// A clipping path (kept alive so its address can't be reused) and the region it clips to. +pub(crate) type ClipEntry = (Arc, Option>); + +/// A clipping path (kept alive) and what it paints: its fills and its strokes ([`Node::clip_paint`]). +pub(crate) type ClipPaintEntry = (Arc, Option>, Option>); + +/// An object with effects (kept alive), the art drawn for it ([`Renderer::fx_art`]), and, when that +/// art can be made from evaluated live objects (`fx::evaluates_live`) or from type, symbol art or +/// colors shaded through the color settings (`fx::reads_outside`), the inputs it was made with +/// ([`live::Found`]). +pub(crate) type FxArtEntry = (Arc, Arc, Option); + +/// What symbol art placed by [`Renderer::placed_art`] is kept under: the art's address, the bits +/// of the transform's coefficients, whether strokes scale with it, and the stain ([`stain_bits`]). +pub(crate) type PlacedKey = (usize, [u64; 6], bool, [u32; 6]); + +/// Symbol art placed by [`Renderer::placed_art`]. +pub(crate) struct PlacedEntry { + /// The symbol's art, held weakly: the reference keeps the art's allocation, so no other art can + /// get its address while the entry exists, and does not keep the art's content. + pub(crate) art: std::sync::Weak, + /// The art placed, or `None` for art placed again in every frame ([`depends_on_document`]). + pub(crate) placed: Option>, + /// The font generation ([`vectorcraft_text::FontDb::generation`]) the art was placed in, when + /// it holds type: the art is placed again when the fonts change. + pub(crate) fonts: Option, +} + +/// How deep inline graphics nest (text in a symbol shown inline in text…) before they draw nothing. +pub(crate) const MAX_INLINE_DEPTH: u32 = 4; + +/// Set once a missing inline symbol has been logged (it would log every frame otherwise). +pub(crate) static MISSING_INLINE_LOGGED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +#[derive(Clone, Copy, Debug, Default)] +pub struct FrameStats { + pub drawn: usize, + pub culled: usize, + /// The drop shadows, outer glows and filtered contents the frame drew from rasters made before + /// their object moved, or made over such rasters ([`RenderOptions::interacting`]), and those it + /// drew at a drag's lower resolution ([`Self::previewed`]). + pub moved: usize, + /// The filtered contents, shadows and glows the frame drew at a drag's lower resolution + /// ([`RenderOptions::dragging`]). [`Self::moved`] counts them too. + pub previewed: usize, + /// How many threads composed the frame from its two ink planes, in a CMYK document that shows + /// transparency; 0 for other frames. + pub ink_threads: usize, + pub micros: u64, +} + +#[derive(Clone, Copy)] +pub(crate) struct Frame<'a> { + /// Drawing into a multithreaded context: vello filter layers must be rendered offscreen. + pub(crate) mt: bool, + pub(crate) doc: &'a Document, + /// `doc`'s symbol art boxes, which culling measures symbol instances by ([`SymbolBoxes::quick`]). + pub(crate) symbols: &'a SymbolBoxes<'a>, + pub(crate) view: Affine, + /// Visible region in document coordinates (for culling). + pub(crate) visible: Rect, + /// Size of one output pixel in document units. + pub(crate) px: f64, + pub(crate) opts: &'a RenderOptions, + /// What colours paint as: screen colours, or one ink plane of a CMYK document (see [`ink`]). + pub(crate) ink: Ink, +} + +impl Frame<'_> { + /// Text space → device pixels when type is snapped to the pixel grid (Type anti-aliasing). + pub(crate) fn text_snap(&self, t: &TextObject) -> Option { + (self.opts.anti_alias == AntiAlias::Type).then(|| self.view * t.xf) + } + + /// 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 { + #[cfg(test)] + OFFSCREEN_PIXELS.with(|n| n.set(n.get() + u64::from(w) * u64::from(h))); + let mut ctx = single_threaded_context(w, h); + ctx.set_aliasing_threshold(self.opts.anti_alias.threshold()); + ctx + } +} + +#[cfg(test)] +thread_local! { + /// The pixels of the offscreen contexts [`Frame::offscreen_context`] has made on this thread. + pub(crate) static OFFSCREEN_PIXELS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} diff --git a/crates/render/src/geometry.rs b/crates/render/src/geometry.rs new file mode 100644 index 000000000..90053c8cd --- /dev/null +++ b/crates/render/src/geometry.rs @@ -0,0 +1,37 @@ +use super::*; + +/// Most dashes a dotted guide is drawn with (1.5 on, 2.5 off on screen); a longer one is solid. +pub(crate) const MAX_GUIDE_DASHES: f64 = 100_000.0; + +/// The length of `p`'s control polygon: at least its own length (infinite when a point is). +pub(crate) fn polygon_length(p: &BezPath) -> f64 { + let (mut last, mut len): (Option, f64) = (None, 0.0); + for el in p.elements() { + let points = match *el { + kurbo::PathEl::MoveTo(q) => { + last = Some(q); + continue; + } + kurbo::PathEl::LineTo(q) => [Some(q), None, None], + kurbo::PathEl::QuadTo(a, b) => [Some(a), Some(b), None], + kurbo::PathEl::CurveTo(a, b, c) => [Some(a), Some(b), Some(c)], + kurbo::PathEl::ClosePath => continue, + }; + for q in points.into_iter().flatten() { + if let Some(l) = last { + len += l.distance(q); + } + last = Some(q); + } + } + if len.is_nan() { f64::INFINITY } else { len } +} + +/// The pixel size of `region` rendered at `scale` pixels per point (at least 1×1). +pub fn region_pixels(region: Rect, scale: f64) -> (u32, u32) { + ((region.width() * scale).round().max(1.0) as u32, (region.height() * scale).round().max(1.0) as u32) +} + +pub(crate) fn rects_overlap(a: Rect, b: Rect) -> bool { + a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 01c753867..f7541c07a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -45,421 +45,6 @@ pub use pattern::render_pattern_swatch; pub use vectorcraft_effects as effects; pub use vello_cpu; -/// Bounds of everything `n` paints, as the renderer culls it: its members, strokes, brush art and -/// geometry effects, and the shadows and glows of it and its members. Symbol instances are -/// measured by their art in `doc` ([`Node::visual_bounds_with`]). -pub fn painted_bounds(doc: &Document, n: &Node) -> Option { - brush_fx::cull_bounds(&doc.symbol_boxes(), n) -} - -/// [`painted_bounds`] of `n` without the room it leaves for brush art past the brushed strokes of -/// `n` and of a symbol instance's art (the brush art of the objects in a blend, envelope or repeat -/// still counts). A group or layer without effects of its own is measured by its members, leaving -/// out the transparent source art of a Shaper group as [`Node::visual_bounds_with`] does. Symbol -/// instances are measured by their art in `symbols`. -pub fn effect_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { - match &n.kind { - NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !fx::has_object_fx(n) => children - .iter() - .skip(usize::from(n.extras().shaper.is_some())) - .fold(None, |acc, c| vectorcraft_geom::union_opt(acc, effect_bounds(symbols, c))), - _ => fx::cull_bounds(symbols, n), - } -} - -/// Bounds of what `n` draws with its own and its members' geometry effects applied and with the -/// reach of its own and its members' raster effects, measured as [`Node::visual_bounds_with`] -/// measures art without effects: what Object › Rasterize makes its image over. -pub fn drawn_bounds(doc: &Document, n: &Node) -> Option { - fx::painted_reach(&doc.symbol_boxes(), n) -} - -/// The outline, with `n`'s geometry effects applied, that the canvas clips `n`'s Rasterize with -/// Create Clipping Mask to. Object › Rasterize's clipping mask follows it. -pub fn drawn_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRule)> { - fx::drawn_outline(doc, n) -} - -/// Largest raster an export may ask [`Renderer::render_region`] for, per side. The CPU rasteriser -/// addresses at most `u16::MAX` pixels per side (and panics at that edge). -pub const MAX_RASTER_SIDE: u32 = 32_768; -/// Largest side of a render context the renderer makes. The CPU rasteriser keeps pixel positions -/// in `u16` and overflows near `u16::MAX` (its tile and bucket arithmetic rounds past it), so -/// contexts stay within the side raster exports are limited to. -const MAX_CONTEXT_SIDE: u32 = MAX_RASTER_SIDE; - -/// Largest raster an export may ask for in total (16384², 1 GiB of RGBA pixels). -pub const MAX_RASTER_PIXELS: u64 = 1 << 28; - -/// Pixel size of `region` rendered at `scale` (as [`Renderer::render_region`] rounds it), or an error -/// when it exceeds [`MAX_RASTER_SIDE`] / [`MAX_RASTER_PIXELS`]. Raster exports check this first, -/// because the renderer can only clamp (wrong picture) or fail (abort) at those sizes. -pub fn raster_size(region: Rect, scale: f64) -> Result<(u32, u32), String> { - if !(scale.is_finite() && scale > 0.0) { - return Err(format!("invalid scale {scale}")); - } - let (w, h) = region_pixels(region, scale); - if !(w <= MAX_RASTER_SIDE && h <= MAX_RASTER_SIDE && u64::from(w) * u64::from(h) <= MAX_RASTER_PIXELS) { - return Err(format!( - "the image would be {w} × {h} pixels; raster exports are limited to {MAX_RASTER_SIDE} pixels per side and {} megapixels: lower the scale or resolution", - MAX_RASTER_PIXELS / 1_000_000 - )); - } - Ok((w, h)) -} - -/// Rendering options. -#[derive(Clone, Debug, PartialEq)] -pub struct RenderOptions { - /// Outline (wireframe) view: 1 px black paths, no paint. - pub outline: bool, - /// Pasteboard colour behind everything (premultiplied RGBA8); `None` = transparent. - pub background: Option<[u8; 4]>, - /// Draw artboards as white rectangles (screen view). Export sets this to false. - pub artboards: bool, - /// Objects not to draw (e.g. the object being edited by a live drag preview). - pub hidden: Vec, - /// Draw the layers as their Layer Options show them on screen: Preview off in outline, Dim - /// Images fading their images (a template layer's too), a template in Preview in Outline view. - pub dim_templates: bool, - /// Soft proof / separations preview (see [`proof`]). - pub proof: Option, - /// Overprint Preview (see [`proof`] for what overprints). - pub overprint_preview: bool, - /// Trim View: clip the artwork to the artboards (nothing on the pasteboard is drawn). - pub trim: bool, - /// Leave template layers and guides out (exports and thumbnails: they're aids to drawing, not - /// artwork). - pub skip_templates: bool, - /// View › Hide Template: template layers left out of the screen view (guides stay). - pub hide_templates: bool, - /// View › Hide Guides: paths made into guides left out of the screen view. Exports use - /// `skip_templates`, which independently leaves both templates and guides out. - pub hide_guides: bool, - /// Pattern editing mode's tile edge and swatch bounds colour (Object → Pattern → Tile Edge - /// Color), RGB. - pub tile_edge: [u8; 3], - /// Colour and style of the paths made into guides (Preferences › Guides & Grid, as the ruler - /// guides the UI draws): RGB, and whether they are dotted. - pub guide: ([u8; 3], bool), - /// View Opacity Mask (Alt-click the mask thumbnail): instead of the artwork, show this object's - /// opacity mask alone as its coverage in greyscale (white = opaque, black = transparent). - pub mask_view: Option, - /// Screen view: highlight substituted fonts and glyphs, as Preferences › Type and Document - /// Setup ask. - pub highlight_substitutions: bool, - /// Screen view: Preferences › Type › Highlight Substituted Fonts. Pink behind text drawn in a - /// substitute for a missing font, in every document (on by default). - pub highlight_substituted_fonts: bool, - /// Screen view: Preferences › Type › Highlight Alternate Glyphs. Pink behind glyphs OpenType - /// features substituted (ligatures, contextual forms), not Document Setup's missing-glyph - /// highlights. - pub highlight_alternate_glyphs: bool, - /// Edge smoothing (raster export option). - pub anti_alias: AntiAlias, - /// Screen view: placed documents draw from cached bitmaps made in the background (see - /// [`placed_document`]); off, they draw exactly, read when needed. - pub progressive_placed: bool, - /// Screen view: Image Trace objects draw as their View asks (outlines, the source image…); - /// off, they draw their tracing result, as exports and printing do. - pub trace_views: bool, - /// Screen view with `trace_views` on: the Image Trace objects drawn with the Source Image view - /// for this render only, whatever View they have (the Image Trace panel's eye button, while it - /// is held down). - pub trace_source: Option>>, - /// Images are sampled smoothly when scaled or rotated; off, each pixel takes its nearest image - /// pixel (Pixel Preview with File Handling › Display Bitmaps as Anti-aliased Images off). - pub smooth_images: bool, - /// Screen view in isolation mode: the isolated group or layer. Everything around it draws - /// dimmed ([`ISOLATION_DIM`]). - pub isolated: Option, - /// Composite in floating point (exports): translucent art over opaque art comes out exactly - /// opaque, where 8-bit compositing leaves alpha 254 on some anti-aliased edges (#787). Off, - /// the faster 8-bit pipeline (the canvas). - pub precise: bool, - /// Exports: Art Optimized edges are supersampled ([`supersample_factor`]): the art is drawn - /// with hard edges at several times the size and averaged down, so shapes that meet edge to - /// edge leave no seam of the background between them (#983). Off, edges are smoothed shape by - /// shape (the canvas). - pub supersample: bool, - /// Screen views: an image drawn at half its size or smaller samples a copy shrunk by halves - /// (box-filtered from the full image, kept with the decoded images), so the full image needn't - /// stay in memory or be sampled. Off, images always sample their full pixels (exports). - pub image_mips: bool, - /// Screen views: an image whose pixels can't be decoded draws as a light gray box crossed - /// from corner to corner. Off, it draws nothing (exports, which report it instead). - pub mark_undecoded_images: bool, - /// Screen views while an interaction is in progress (a drag, a dialog's preview): an object - /// whose content only moved since an earlier frame draws its cached drop shadows, outer glows - /// and filtered content from the rasters made then, at the nearest whole pixel, without - /// running the effects again (see `fx`). Off, no raster made that way, or made over one, is - /// drawn: the effects run again. - pub interacting: bool, - /// Screen views during a tool's drag on the canvas: the renderer draws filtered content within - /// `fx::DRAG_PIXEL_BUDGET` pixels, at a lower resolution where it would need more, unless it - /// can draw the content from a raster kept from an earlier frame. With [`Renderer::threads`] - /// above 0 it does the same for drop shadows, outer glows and inner glows. No raster made that - /// way is kept (see `fx`). Off, they render as in a still frame. - pub dragging: bool, - /// 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, -} - -/// The opacity of the art around an isolated group or layer. -pub const ISOLATION_DIM: f32 = 0.5; - -/// How edges are rasterized (raster export option). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum AntiAlias { - /// Hard edges: a pixel is painted when a path, text or image covers at least half of it. - /// Raster effects (blur, shadows, glows) and pattern tiles stay smooth. - None, - /// Smooth edges everywhere. - #[default] - Art, - /// Smooth edges with type snapped to the pixel grid (see `TextLayout::snap_to_pixels`): - /// crisper small text. - Type, -} - -impl AntiAlias { - pub const ALL: [Self; 3] = [Self::None, Self::Art, Self::Type]; - - /// The id used in command params (`none`, `art`, `type`). - pub fn id(self) -> &'static str { - match self { - Self::None => "none", - Self::Art => "art", - Self::Type => "type", - } - } - - /// The mode an id names (any case). - pub fn from_id(id: &str) -> Option { - Self::ALL.into_iter().find(|a| a.id().eq_ignore_ascii_case(id)) - } - - /// The name shown in options dialogs. - pub fn label(self) -> &'static str { - match self { - Self::None => "None", - Self::Art => "Art Optimized", - Self::Type => "Type Optimized", - } - } - - /// vello's aliasing threshold: paint a pixel only above half coverage when edges are hard. - fn threshold(self) -> Option { - (self == Self::None).then_some(127) - } -} - -impl Default for RenderOptions { - fn default() -> Self { - Self { - outline: false, - background: None, - artboards: false, - hidden: vec![], - dim_templates: true, - proof: None, - overprint_preview: false, - trim: false, - skip_templates: false, - hide_templates: false, - hide_guides: false, - tile_edge: vectorcraft_doc::LAYER_COLORS[0].1, - guide: ([0x4a, 0xd8, 0xff], false), - mask_view: None, - highlight_substitutions: false, - highlight_substituted_fonts: true, - highlight_alternate_glyphs: false, - anti_alias: AntiAlias::Art, - progressive_placed: false, - trace_views: false, - trace_source: None, - smooth_images: true, - isolated: None, - precise: false, - supersample: false, - image_mips: false, - mark_undecoded_images: false, - interacting: false, - dragging: false, - rich_black: false, - } - } -} - -/// The most pixels a supersampled render draws (the frame and its compositing buffers hold a few -/// times this many bytes). -const MAX_SUPERSAMPLED_PIXELS: u64 = 40_000_000; - -/// How many times wider and taller a `w`×`h` render is drawn when supersampled: 4, or less where -/// that would pass [`MAX_SUPERSAMPLED_PIXELS`] or the renderer's 65,535-pixel side; `None` when not -/// even twice fits (the edges are then smoothed shape by shape). -pub fn supersample_factor(w: u32, h: u32) -> Option { - (2..=4u32).rev().find(|k| { - let (sw, sh) = (u64::from(w) * u64::from(*k), u64::from(h) * u64::from(*k)); - sw <= u64::from(MAX_CONTEXT_SIDE) && sh <= u64::from(MAX_CONTEXT_SIDE) && sw * sh <= MAX_SUPERSAMPLED_PIXELS - }) -} - -/// Average each `k`×`k` block of `src` (`w`×`h` pixels of 4 premultiplied bytes) into one pixel. -fn downsample(src: &[u8], w: usize, h: usize, k: usize) -> Vec { - let (ow, oh) = (w / k.max(1), h / k.max(1)); - let n = (k * k).max(1) as u32; - let mut out = vec![0u8; ow * oh * 4]; - for (o, px) in out.as_chunks_mut::<4>().0.iter_mut().enumerate() { - let (ox, oy) = (o % ow.max(1), o / ow.max(1)); - let mut acc = [0u32; 4]; - for y in oy * k..(oy + 1) * k { - let row = y * w + ox * k; - for p in src.get(row * 4..(row + k) * 4).unwrap_or_default().as_chunks::<4>().0 { - for (a, v) in acc.iter_mut().zip(p) { - *a += u32::from(*v); - } - } - } - for (d, a) in px.iter_mut().zip(acc) { - *d = ((a + n / 2) / n).min(255) as u8; - } - } - out -} - -/// A rendered image (premultiplied RGBA8, row-major). -pub struct Rendered { - pub width: u32, - pub height: u32, - pub pixels: Vec, -} - -impl Rendered { - /// Un-premultiplied RGBA8 copy. - pub fn to_straight(&self) -> Vec { - let mut out = self.pixels.clone(); - for px in out.as_chunks_mut::<4>().0 { - let a = px[3] as u32; - if a != 0 && a != 255 { - for c in &mut px[..3] { - *c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8; - } - } - } - out - } - /// Encode as PNG without metadata (thumbnails, screenshots, embedded rasters; exports use - /// [`encode`]). - pub fn to_png(&self) -> Result, String> { - let mut buf = Vec::new(); - let img = image::RgbaImage::from_raw(self.width, self.height, self.to_straight()) - .ok_or("PNG encoding failed: pixel buffer doesn't match the image size")?; - img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).map_err(|e| format!("PNG encoding failed: {e}"))?; - Ok(buf) - } - /// Encode as JPEG (flattened on white) at `quality` 1..=100. - pub fn to_jpeg(&self, quality: u8) -> Result, String> { - encode::jpeg(self, quality, None) - } - /// Encode as lossless WebP. - pub fn to_webp(&self) -> Result, String> { - encode::webp(self) - } - /// Straight-alpha RGBA at (x, y). - pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] { - let i = ((y * self.width + x) * 4) as usize; - let p = &self.pixels[i..i + 4]; - let a = p[3] as u32; - if a == 0 { - return [0, 0, 0, 0]; - } - let un = |c: u8| ((c as u32 * 255 + a / 2) / a).min(255) as u8; - [un(p[0]), un(p[1]), un(p[2]), p[3]] - } -} - -/// Hasher for pointer-keyed caches: a multiply-xorshift (SipHash showed up in frame profiles). -#[derive(Default, Clone, Copy)] -struct PtrHasher(u64); - -impl std::hash::Hasher for PtrHasher { - fn write(&mut self, bytes: &[u8]) { - for b in bytes { - self.0 = (self.0 ^ *b as u64).wrapping_mul(0x0100_0000_01b3); - } - } - fn write_u64(&mut self, n: u64) { - let x = (self.0 ^ n).wrapping_mul(0x9e37_79b9_7f4a_7c15); - self.0 = x ^ (x >> 29); - } - fn write_usize(&mut self, n: usize) { - self.write_u64(n as u64); - } - fn finish(&self) -> u64 { - self.0 - } -} - -type PtrMap = HashMap>; - -/// The key of a cached stroke outline: ([`Renderer::stroke_owner`] of what owns the stroke, the -/// stroke's place in it, width bits, tolerance level). -type StrokeKey = (usize, usize, u64, i32); - -/// A stroke expanded to a fill outline: (owning node, outline, last frame used). -struct StrokeEntry { - node: Arc, - outline: Arc, - stamp: u64, -} - -/// The slices of a stroke whose gradient runs along or across it, each with its paint and bounds. -type Slices = Arc>; - -/// [`Slices`] cached for a stroke: (owning node, slices, last frame used). -struct SliceEntry { - node: Arc, - slices: Slices, - stamp: u64, -} - -/// Cached per-node geometry, keyed by `Arc` identity. Structural sharing means an unchanged node -/// keeps its allocation across edits, so a pointer match (with the Arc kept alive here so the -/// address can't be reused) is an exact cache hit — no invalidation logic needed. -struct GeomEntry { - node: Arc, - bounds: Option, - path: Option>, - stamp: u64, -} - -/// A clipping path (kept alive so its address can't be reused) and the region it clips to. -type ClipEntry = (Arc, Option>); -/// A clipping path (kept alive) and what it paints: its fills and its strokes ([`Node::clip_paint`]). -type ClipPaintEntry = (Arc, Option>, Option>); -/// An object with effects (kept alive), the art drawn for it ([`Renderer::fx_art`]), and, when that -/// art can be made from evaluated live objects (`fx::evaluates_live`) or from type, symbol art or -/// colors shaded through the color settings (`fx::reads_outside`), the inputs it was made with -/// ([`live::Found`]). -type FxArtEntry = (Arc, Arc, Option); -/// What symbol art placed by [`Renderer::placed_art`] is kept under: the art's address, the bits -/// of the transform's coefficients, whether strokes scale with it, and the stain ([`stain_bits`]). -type PlacedKey = (usize, [u64; 6], bool, [u32; 6]); - -/// Symbol art placed by [`Renderer::placed_art`]. -struct PlacedEntry { - /// The symbol's art, held weakly: the reference keeps the art's allocation, so no other art can - /// get its address while the entry exists, and does not keep the art's content. - art: std::sync::Weak, - /// The art placed, or `None` for art placed again in every frame ([`depends_on_document`]). - placed: Option>, - /// The font generation ([`vectorcraft_text::FontDb::generation`]) the art was placed in, when - /// it holds type: the art is placed again when the fonts change. - fonts: Option, -} - /// Reusable renderer (keeps the render context, geometry and glyph caches between frames; decoded /// images are shared by all renderers, see [`set_image_cache_budget`]). pub struct Renderer { @@ -600,2254 +185,44 @@ pub struct Renderer { hairline: f64, } -/// How deep inline graphics nest (text in a symbol shown inline in text…) before they draw nothing. -const MAX_INLINE_DEPTH: u32 = 4; - -/// Set once a missing inline symbol has been logged (it would log every frame otherwise). -static MISSING_INLINE_LOGGED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); - -#[derive(Clone, Copy, Debug, Default)] -pub struct FrameStats { - pub drawn: usize, - pub culled: usize, - /// The drop shadows, outer glows and filtered contents the frame drew from rasters made before - /// their object moved, or made over such rasters ([`RenderOptions::interacting`]), and those it - /// drew at a drag's lower resolution ([`Self::previewed`]). - pub moved: usize, - /// The filtered contents, shadows and glows the frame drew at a drag's lower resolution - /// ([`RenderOptions::dragging`]). [`Self::moved`] counts them too. - pub previewed: usize, - /// How many threads composed the frame from its two ink planes, in a CMYK document that shows - /// transparency; 0 for other frames. - pub ink_threads: usize, - pub micros: u64, -} - impl Default for Renderer { fn default() -> Self { Self::new() } } -#[derive(Clone, Copy)] -struct Frame<'a> { - /// Drawing into a multithreaded context: vello filter layers must be rendered offscreen. - mt: bool, - doc: &'a Document, - /// `doc`'s symbol art boxes, which culling measures symbol instances by ([`SymbolBoxes::quick`]). - symbols: &'a SymbolBoxes<'a>, - view: Affine, - /// Visible region in document coordinates (for culling). - visible: Rect, - /// Size of one output pixel in document units. - px: f64, - opts: &'a RenderOptions, - /// What colours paint as: screen colours, or one ink plane of a CMYK document (see [`ink`]). - ink: Ink, -} - -impl Frame<'_> { - /// Text space → device pixels when type is snapped to the pixel grid (Type anti-aliasing). - fn text_snap(&self, t: &TextObject) -> Option { - (self.opts.anti_alias == AntiAlias::Type).then(|| self.view * t.xf) - } - - /// 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. - fn offscreen_context(&self, w: u16, h: u16) -> RenderContext { - #[cfg(test)] - OFFSCREEN_PIXELS.with(|n| n.set(n.get() + u64::from(w) * u64::from(h))); - let mut ctx = single_threaded_context(w, h); - ctx.set_aliasing_threshold(self.opts.anti_alias.threshold()); - ctx - } -} - -impl Renderer { - pub fn new() -> Self { - Self { - texts: FrameCache::new(lru::TEXT_BUDGET, 1024), - text_fonts: 0, - clips: FrameCache::new(lru::CLIP_BUDGET, 256), - threads: default_threads(), - geom: PtrMap::default(), - symbols: vec![], - plugin_revision: 0, - strokes: PtrMap::default(), - char_strokes: FrameCache::new(lru::CHAR_STROKE_BUDGET, 0), - cur: None, - made_again: false, - fx_owner: None, - live_root: None, - fx_geom: PtrMap::default(), - stamp: 0, - alpha: 1.0, - contexts: Vec::new(), - resources: Resources::new(), - stats: FrameStats::default(), - brushes: Default::default(), - shadows: PtrMap::default(), - pixel_fx: PtrMap::default(), - doc_inputs: None, - transparency: None, - live: live::LiveCache::default(), - knockout: false, - shape_of: 0, - fx_arts: FrameCache::new(lru::FX_ART_BUDGET, 256), - placed_arts: FrameCache::new(lru::PLACED_ART_BUDGET, 256), - placed_frame: None, - nested: 0, - backdrop: None, - blends: PtrMap::default(), - clip_paths: vec![], - picture: None, - offscreen_live: 0, - offscreen_budget: 0, - symbol_nodes: 0, - twice_drawn: 0, - clip_paints: FrameCache::new(lru::CLIP_BUDGET, 256), - stroke_slices: PtrMap::default(), - adjusts: HashMap::new(), - dim_images: None, - inline_depth: 0, - raster: vello_cpu::RasterizerSettings::default(), - isolation: None, - isolation_settled: false, - proof_art: None, - hairline: 1.0, - } - } - - /// Render `doc` into a `width`×`height` image using `view` (document → pixel transform). - pub fn render(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions) -> Rendered { - self.render_as(doc, width, height, view, opts, false) - } - - /// [`Self::render`], or with `inks` the ink amounts in the working CMYK space instead of - /// screen colours (4 bytes a pixel, see [`Self::render_region_inks`]). - fn render_as(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions, inks: bool) -> Rendered { - let start = now(); - if opts.supersample && opts.anti_alias == AntiAlias::Art && opts.mask_view.is_none() { - let (w, h) = (width.clamp(1, MAX_CONTEXT_SIDE), height.clamp(1, MAX_CONTEXT_SIDE)); - if let Some(k) = supersample_factor(w, h) { - let hard = RenderOptions { anti_alias: AntiAlias::None, supersample: false, ..opts.clone() }; - let hairline = std::mem::replace(&mut self.hairline, f64::from(k)); - let big = self.render_as(doc, w * k, h * k, Affine::scale(f64::from(k)) * view, &hard, inks); - self.hairline = hairline; - let pixels = downsample(&big.pixels, big.width as usize, big.height as usize, k as usize); - self.stats.micros = now().saturating_sub(start); - return Rendered { width: w, height: h, pixels }; - } - } - self.check_plugins(); - // The document's own symbols: proofing rewrites the colors and blend modes of their art. - self.check_symbols(doc); - let prepared = proof::prepare(doc, opts, &mut self.proof_art); - let doc: &Document = &prepared; - let w = width.clamp(1, MAX_CONTEXT_SIDE) as u16; - let h = height.clamp(1, MAX_CONTEXT_SIDE) as u16; - // Raster filters (drop shadow, glows, blur) are rendered offscreen on this thread when the - // main context is multithreaded (see `fx::with_filters`). - let threads = self.threads; - self.stats = FrameStats::default(); - 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 screen = if opts.rich_black { Ink::RichBlack } else { Ink::Display }; - let symbols = doc.symbol_boxes().quick(); - let frame = Frame { mt: threads > 0, doc, symbols: &symbols, view, visible, px, opts, ink: screen }; - self.begin_frame(); - // View Opacity Mask: the mask's coverage in greyscale instead of the artwork. - if let Some(m) = opts.mask_view.and_then(|id| doc.node(id)).and_then(|n| n.extras().mask.as_deref()) { - let pixels = self.mask_values(&frame, m, w, h).into_iter().flat_map(|v| [v, v, v, 255]).collect(); - self.end_frame(); - self.stats.micros = now().saturating_sub(start); - return Rendered { width: w as u32, height: h as u32, pixels }; - } - let mut pixels = if inks || self.blends_in_cmyk(doc, opts) { - // Blending in CMYK: one frame per ink plane, then their inks shown on screen. - let cmy = self.draw_frame(&Frame { ink: Ink::Cmy, ..frame }, w, h); - let stats = self.stats; - let k = self.draw_frame(&Frame { ink: Ink::K, ..frame }, w, h); - self.stats = stats; - if inks { - ink::amounts(&cmy, &k) - } else { - let (pixels, composed) = ink::compose(&cmy, &k, opts, threads); - self.stats.ink_threads = composed; - pixels - } - } else { - self.draw_frame(&frame, w, h) - }; - self.end_frame(); - if !inks { - proof::post(&mut pixels, opts); - } - self.stats.micros = now().saturating_sub(start); - Rendered { width: w as u32, height: h as u32, pixels } - } - - /// Start a frame: cache entries used from now on are marked with its stamp. - fn begin_frame(&mut self) { - self.stamp += 1; - self.inline_depth = 0; - // Nothing is drawn offscreen yet (even if an earlier frame stopped part way). - (self.offscreen_live, self.offscreen_budget) = (0, 0); - self.symbol_nodes = 0; - self.twice_drawn = 0; - self.placed_frame = None; - } - - /// End a frame: drop cache entries not used for a few frames (every render, thumbnails too, - /// so the nodes they hold go with them). - fn end_frame(&mut self) { - let g = self.stamp; - if self.geom.len() > 1024 { - self.geom.retain(|_, e| g - e.stamp <= 3); - } - if self.strokes.len() > 1024 { - self.strokes.retain(|_, e| g - e.stamp <= 3); - } - if self.stroke_slices.len() > 256 { - self.stroke_slices.retain(|_, e| g - e.stamp <= 3); - } - if self.shadows.len() > 256 { - self.shadows.retain(|_, e| g - e.stamp <= 3); - } - if self.fx_geom.len() > 256 { - self.fx_geom.retain(|_, e| g - e.stamp <= 3); - } - // Filtered rasters are large: only those of the last frame stay. - self.pixel_fx.retain(|_, e| e.stamp == g); - self.texts.sweep(g); - self.char_strokes.sweep(g); - self.clips.sweep(g); - self.clip_paints.sweep(g); - self.fx_arts.sweep(g); - self.placed_arts.sweep(g); - self.brushes.entries.sweep(g); - self.live.sweep(g); - } - - /// Draw frame `f` (`w`×`h` pixels): the background, the artboards and the page's art. - fn draw_frame(&mut self, f: &Frame, w: u16, h: u16) -> Vec { - let (threads, opts) = (self.threads, f.opts); - let kept = self.contexts.iter().position(|(t, c)| *t == threads && c.width() == w && c.height() == h); - let mut ctx = match kept.map(|i| self.contexts.remove(i)) { - Some((_, mut c)) => { - c.reset(); - c - } - None => RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: threads, ..Default::default() }), - }; - // `reset` keeps the threshold of the previous render: set it every time. - ctx.set_aliasing_threshold(opts.anti_alias.threshold()); - let render_mode = if opts.precise { vello_cpu::RenderMode::OptimizeQuality } else { vello_cpu::RenderMode::OptimizeSpeed }; - self.raster = vello_cpu::RasterizerSettings { render_mode, ..Default::default() }; - if let Some(bg) = opts.background { - ctx.set_transform(Affine::IDENTITY); - ctx.set_paint(f.ink.fixed(bg)); - ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, h as f64)); - } - if opts.artboards && !opts.outline { - // Paper: white on screen, no ink on the ink planes. - ctx.set_transform(f.view); - ctx.set_paint(peniko::Color::WHITE); - for ab in &f.doc.artboards { - ctx.fill_rect(&ab.rect); - } - } - (self.nested, self.backdrop, self.picture) = (0, None, None); - self.clip_paths.clear(); - if opts.trim && !f.doc.artboards.is_empty() { - let mut clip = BezPath::new(); - for ab in &f.doc.artboards { - clip.extend(ab.rect.path_elements(0.1)); - } - let blends = self.children_blend(&f.doc.layers); - let trim = Composite { clip: Some((&clip, FillRule::NonZero)), blends, ..Default::default() }; - self.group(&mut ctx, f, trim, &mut |r, c, fr| r.draw_page(c, fr)); - } else { - self.draw_page(&mut ctx, f); - } - let pm = self.finish(&mut ctx, self.raster); - self.contexts.push((threads, ctx)); - if self.contexts.len() > 2 { - self.contexts.remove(0); - } - pm.data_as_u8_slice().to_vec() - } - - /// The page's art: the layers (or the pattern being edited), inside the page group when the - /// page has one. - fn draw_page(&mut self, ctx: &mut RenderContext, f: &Frame) { - if self.draw_pattern_edit(ctx, f) { - return; - } - let doc = f.doc; - // While editing an opacity mask its art is seen only through the mask. - let mask_layer = doc.mask_edit.map(|m| m.layer); - let layers: Vec> = doc.layers.iter().filter(|l| Some(l.id) != mask_layer).cloned().collect(); - // Page Isolated Blending / Page Knockout Group: the page is a group of its own. - let page_group = !f.opts.outline && (doc.page_isolate || doc.page_knockout); - let knockout = page_group && doc.page_knockout; - let mut draw = |r: &mut Self, c: &mut RenderContext, fr: &Frame| { - r.knockout = knockout; - r.draw_children(c, fr, &layers, knockout); - r.knockout = false; - }; - if page_group { - let blends = self.children_blend(&layers); - self.group(ctx, f, Composite { isolated: doc.page_isolate, blends, ..Default::default() }, &mut draw); - } else { - draw(self, ctx, f); - } - } - - /// Render one artboard (or any document rect) at `scale` pixels per point, transparent or on white, - /// as exported: template layers and guides are left out. - pub fn render_region(&mut self, doc: &Document, region: Rect, scale: f64, white: bool) -> Rendered { - let opts = RenderOptions { background: white.then_some([255, 255, 255, 255]), skip_templates: true, ..Default::default() }; - self.render_region_with(doc, region, scale, &opts) - } - - /// Render a document rect at `scale` pixels per point with `opts`. - pub fn render_region_with(&mut self, doc: &Document, region: Rect, scale: f64, opts: &RenderOptions) -> Rendered { - let (w, h) = region_pixels(region, scale); - let view = Affine::scale(scale) * Affine::translate((-region.x0, -region.y0)); - self.render(doc, w, h, view, opts) - } - - /// `region` of `doc` as ink amounts in the working CMYK space, 4 bytes a pixel (0 = no ink): - /// drawn as the two ink planes of [`ink`], so CMYK colours keep their own inks (black type - /// stays black ink alone) and RGB ones are separated with the colour settings. Where nothing - /// is drawn (no background) there is no ink. CMYK exports draw this. - pub fn render_region_inks(&mut self, doc: &Document, region: Rect, scale: f64, opts: &RenderOptions) -> Vec { - let (w, h) = region_pixels(region, scale); - let view = Affine::scale(scale) * Affine::translate((-region.x0, -region.y0)); - self.render_as(doc, w, h, view, opts, true).pixels - } - - /// Render a single node (thumbnails, previews) fitted into `size`×`size` pixels. - pub fn render_thumbnail(&mut self, doc: &Document, id: NodeId, size: u32) -> Option { - self.render_node_thumbnail(doc, doc.node(id)?, size, None) - } - - /// Render node `n` of `doc` (its resources) into `w`×`h` transparent pixels through `view`. - pub fn render_node(&mut self, doc: &Document, n: &Arc, w: u16, h: u16, view: Affine) -> Option { - 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(); - let symbols = doc.symbol_boxes().quick(); - let frame = Frame { mt: false, doc, symbols: &symbols, view, visible, px, opts: &opts, ink: Ink::Display }; - self.begin_frame(); - (self.knockout, self.nested, self.backdrop, self.picture) = (false, 0, None, None); - self.clip_paths.clear(); - self.draw_arc(&mut ctx, &frame, n); - let pm = self.finish(&mut ctx, vello_cpu::RasterizerSettings::default()); - self.end_frame(); - Some(Rendered { width: w as u32, height: h as u32, pixels: pm.data_as_u8_slice().to_vec() }) - } - - /// Render any node (also one outside the tree, e.g. opacity-mask art) fitted into - /// `size`×`size` pixels, optionally over a solid premultiplied background. A symbol instance - /// is fitted by its symbol's art in `doc`. - pub fn render_node_thumbnail(&mut self, doc: &Document, n: &Node, size: u32, background: Option<[u8; 4]>) -> Option { - self.render_node_thumbnail_with(doc, n, size, background, &RenderOptions::default()) - } - - /// [`Self::render_thumbnail`] with `opts` (e.g. [`RenderOptions::image_mips`]). - pub fn render_thumbnail_with(&mut self, doc: &Document, id: NodeId, size: u32, opts: &RenderOptions) -> Option { - self.render_node_thumbnail_with(doc, doc.node(id)?, size, None, opts) - } - - /// [`Self::render_node_thumbnail`] with `opts` (e.g. [`RenderOptions::image_mips`]). - pub fn render_node_thumbnail_with( - &mut self, - doc: &Document, - n: &Node, - size: u32, - background: Option<[u8; 4]>, - opts: &RenderOptions, - ) -> Option { - self.check_plugins(); - self.check_symbols(doc); - let b = brush_fx::cull_bounds(&doc.symbol_boxes(), n)?; - let s = (size as f64 - 2.0) / b.width().max(b.height()).max(1e-6); - let view = Affine::translate((size as f64 / 2.0, size as f64 / 2.0)) * Affine::scale(s) * Affine::translate(-b.center().to_vec2()); - let w = size.clamp(1, MAX_CONTEXT_SIDE) as u16; - let mut ctx = single_threaded_context(w, w); - 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)); - } - let symbols = doc.symbol_boxes().quick(); - let frame = Frame { mt: false, doc, symbols: &symbols, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts, ink: Ink::Display }; - self.begin_frame(); - (self.knockout, self.nested, self.backdrop, self.picture) = (false, 0, None, None); - self.clip_paths.clear(); - self.draw_node(&mut ctx, &frame, n, true); - let pm = self.finish(&mut ctx, vello_cpu::RasterizerSettings::default()); - self.end_frame(); - Some(Rendered { width: size, height: size, pixels: pm.data_as_u8_slice().to_vec() }) - } - - /// Cached cull bounds of a node (containers union their cached children), symbol instances - /// measured by their art box from `symbols`. - fn bounds_of(&mut self, symbols: &SymbolBoxes, a: &Arc) -> Option { - let key = Arc::as_ptr(a) as usize; - if let Some(e) = self.geom.get_mut(&key) - && Arc::ptr_eq(&e.node, a) - { - e.stamp = self.stamp; - return e.bounds; - } - let b = match &a.kind { - NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !fx::has_object_fx(a) => { - let mut acc: Option = None; - // An Image Trace object's hidden source image shows in some of its views. - let source = a.extras().trace.is_some().then(|| children.first()).flatten(); - for c in children { - if c.visible || source.is_some_and(|s| Arc::ptr_eq(s, c)) { - acc = vectorcraft_geom::union_opt(acc, self.bounds_of(symbols, c)); - } - } - acc - } - _ => brush_fx::cull_bounds(symbols, a), - }; - self.geom.insert(key, GeomEntry { node: a.clone(), bounds: b, path: None, stamp: self.stamp }); - b - } - - /// Drop the cached bounds of symbol instances and of the objects that hold them when `doc`'s - /// symbols are not those of the document last drawn (by name and art): those bounds measured - /// the instances by their symbol's art. - fn check_symbols(&mut self, doc: &Document) { - let same = self.symbols.len() == doc.symbols.len() - && self.symbols.iter().zip(&doc.symbols).all(|((name, art), s)| *name == s.name && std::ptr::eq(art.as_ptr(), Arc::as_ptr(&s.art))); - if !same { - self.geom.retain(|_, e| !holds_instance(&e.node)); - self.symbols = doc.symbols.iter().map(|s| (s.name.clone(), Arc::downgrade(&s.art))).collect(); - } - } - - /// When a plug-in has been installed or removed since the last check - /// ([`effects::plugin_revision`]), drop what the caches keep from plug-in effects: the effect - /// geometry of paths with a plug-in effect, the cull bounds of objects that have one or hold one, - /// of symbol instances and of the objects that hold them, and the art drawn for the effects of - /// objects with a plug-in effect. Rasters of raster effects and evaluations of live objects hold - /// the revision in their keys when what they draw has a plug-in effect (`fx::Inputs`, - /// `live::resources_key`). - fn check_plugins(&mut self) { - let revision = effects::plugin_revision(); - if revision == self.plugin_revision { - return; - } - self.plugin_revision = revision; - self.fx_geom.retain(|_, e| !effects::has_plugin_effect(&e.node)); - self.geom.retain(|_, e| !holds_plugin_effect(&e.node) && !holds_instance(&e.node)); - self.fx_arts.retain(|(node, ..)| !effects::has_plugin_effect(node)); - } - - /// Cached region a clip group's clipping path `a` clips to ([`effects::clip_outline`]): - /// outlining text and uniting shapes is too slow to repeat every frame. - fn clip_of(&mut self, a: &Arc) -> Option> { - let key = Arc::as_ptr(a) as usize; - if let Some((node, region)) = self.clips.get(&key, self.stamp) - && Arc::ptr_eq(node, a) - { - return region.clone(); - } - let region = effects::clip_outline(a).map(Arc::new); - let bytes = size_of::() + region.as_ref().map_or(0, |r| lru::path_bytes(&r.0)); - self.clips.insert(key, (a.clone(), region.clone()), bytes, self.stamp); - region - } - - /// Cached fills and strokes of clipping path `a` ([`Node::clip_paint`]): the parts keep their - /// allocations between frames, so their own geometry stays cached. - fn clip_paint_of(&mut self, a: &Arc) -> (Option>, Option>) { - let key = Arc::as_ptr(a) as usize; - if let Some((node, fill, stroke)) = self.clip_paints.get(&key, self.stamp) - && Arc::ptr_eq(node, a) - { - return (fill.clone(), stroke.clone()); - } - let paint = a.clip_paint(); - let (fill, stroke) = (paint.fill.map(Arc::new), paint.stroke.map(Arc::new)); - let bytes = size_of::() + [&fill, &stroke].iter().filter_map(|p| p.as_deref()).map(lru::node_bytes).sum::(); - self.clip_paints.insert(key, (a.clone(), fill.clone(), stroke.clone()), bytes, self.stamp); - (fill, stroke) - } - - /// Cached BezPath of a path or compound path node (its bounds cached with it, see - /// [`Self::bounds_of`]). - fn path_of(&mut self, symbols: &SymbolBoxes, a: &Arc) -> Option> { - let key = Arc::as_ptr(a) as usize; - if let Some(e) = self.geom.get(&key) - && Arc::ptr_eq(&e.node, a) - && let Some(p) = &e.path - { - return Some(p.clone()); - } - let p = Arc::new(a.stroke_path()?); - let bounds = self.bounds_of(symbols, a); - self.geom.insert(key, GeomEntry { node: a.clone(), bounds, path: Some(p.clone()), stamp: self.stamp }); - Some(p) - } - - /// The cached path of path or compound path `n` ([`Self::path_of`]) when the frame is in - /// Outline view and `n` is `fx_owner`, else `None`. - fn outline_path(&mut self, f: &Frame, n: &Node) -> Option> { - let owner = self.fx_owner.as_ref().filter(|o| f.opts.outline && std::ptr::eq(&***o, n)).cloned()?; - self.path_of(f.symbols, &owner) - } - - /// Whether `a` is left out of this frame: hidden, a skipped template or guide, or culled. - fn skipped(&mut self, f: &Frame, a: &Arc) -> bool { - let skipped_view_item = (f.opts.skip_templates || f.opts.hide_templates) && matches!(a.kind, NodeKind::Layer { template: true, .. }) - || (f.opts.skip_templates || f.opts.hide_guides) && matches!(a.kind, NodeKind::Path { guide: true, .. }); - if !a.visible || skipped_view_item || f.opts.hidden.contains(&a.id) { - return true; - } - match self.bounds_of(f.symbols, a) { - Some(b) => { - let pad = f.px * 2.0; - // Level of detail: leaves smaller than a quarter pixel are invisible. - let culled = - !rects_overlap(b.inflate(pad, pad), f.visible) || (!a.is_container() && b.width() < f.px * 0.25 && b.height() < f.px * 0.25); - self.stats.culled += culled as usize; - culled - } - None => !a.is_container(), - } - } - - fn draw_arc(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc) { - if self.skipped(f, a) { - return; - } - if let Some(m) = a.extras().mask.as_deref() - && !m.disabled - && !f.opts.outline - && !self.is_shape(a) - { - // A masked object is a non-isolated group of its own, drawn over its own pixels: the - // mask is rendered over those only. - let bounds = self.bounds_of(f.symbols, a); - let Some((r, sub)) = group::area(ctx, f, bounds) else { return }; - let mask = Some(self.mask_values(&sub, m, r.w, r.h)); - let blends = a.blend != vectorcraft_color::BlendMode::Normal || (!a.isolate && self.blends_through(a)); - self.group(ctx, f, Composite { mask, blends, bounds, ..Default::default() }, &mut |r, c, fr| r.draw_arc_body(c, fr, a)); - return; - } - self.draw_arc_body(ctx, f, a); - } - - /// The coverage of opacity mask `m` per output pixel (row-major): its art rendered offscreen, - /// as luminance ([`mask_value`]). - fn mask_values(&mut self, f: &Frame, m: &vectorcraft_doc::OpacityMask, w: u16, h: u16) -> Vec { - let mut mctx = f.offscreen_context(w, h); - // Mask art is a picture of its own: it takes no part in a knockout group around the object, - // and its luminance is that of its screen colours. - let outer = ( - std::mem::take(&mut self.knockout), - std::mem::take(&mut self.nested), - self.backdrop.take(), - std::mem::take(&mut self.clip_paths), - self.picture.take(), - ); - self.draw_kept_node(&mut mctx, &Frame { mt: false, ink: Ink::Display, ..*f }, &m.art); - let pm = self.finish(&mut mctx, self.raster); - (self.knockout, self.nested, self.backdrop, self.clip_paths, self.picture) = outer; - pm.data().iter().map(|p| mask_value(p.r, p.g, p.b, p.a, m.clip, m.invert)).collect() - } - - /// Everything [`Self::draw_arc`] does after culling. - fn draw_arc_body(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc) { - // Fast path for plain paths: cached geometry, opacity folded into the paint. - // A blend mode on a single plain fill applies per draw (exact for one paint operation); - // a blend layer would composite the whole viewport. - let blended = a.blend != vectorcraft_color::BlendMode::Normal; - if let NodeKind::Path { rule, guide: false, .. } = &a.kind - && !f.opts.outline - && (!blended || single_plain_fill(a)) - && !a.isolate - && !fx::has_fx(a) - && (self.opacity_of(a) >= 1.0 || painted_items(a) == 1) - && let Some(bp) = self.path_of(f.symbols, a) - { - self.alpha = self.opacity_of(a).clamp(0.0, 1.0); - self.cur = Some(a.clone()); - if blended { - ctx.set_blend_mode(blend_mode(a.blend)); - } - self.draw_shape(ctx, f, a, &bp, *rule); - if blended { - ctx.set_blend_mode(blend_mode(vectorcraft_color::BlendMode::Normal)); - } - self.cur = None; - self.alpha = 1.0; - self.stats.drawn += 1; - return; - } - if fx::has_object_fx(a) { - return self.draw_object_fx(ctx, f, a, true); - } - if let NodeKind::Text(t) = &a.kind - && self.opacity_of(a) >= 1.0 - && a.blend == vectorcraft_color::BlendMode::Normal - && !fx::has_fx(a) - { - let t = &*f.doc.inline_resolved(t); - let (g, kept) = match f.text_snap(t) { - Some(xf) => (Arc::new(text_geom_snapped(t, Some(xf))), false), - None => self.text_geom_of(a, t), - }; - self.draw_text_geom(ctx, f, a, t, &g, kept); - self.stats.drawn += 1; - return; - } - if vectorcraft_doc::live::is_live(a) || effects::has_pathfinder(a) { - return self.draw_live(ctx, f, a); - } - // Its live-effect geometry depends on the node alone, and for plug-in effects on the - // plug-ins installed (`check_plugins`), so it can be kept across frames. - let outer = self.fx_owner.replace(a.clone()); - self.draw_node(ctx, f, a, true); - self.fx_owner = outer; - } - - fn draw_node(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, force: bool) { - 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) { - let pad = f.px * 2.0; - if !rects_overlap(b.inflate(pad, pad), f.visible) { - self.stats.culled += n.count(); - return; - } - } else if !n.is_container() { - return; - } - // Layer Options on screen: a layer whose Preview is off draws in outline, and Dim Images - // fades its images, a template's too (exports and thumbnails, which leave templates out, - // ignore both). - if let NodeKind::Layer { preview, dim_images, template, .. } = &n.kind - && f.opts.dim_templates - && !f.opts.skip_templates - { - // A template layer stays in Preview while the view is in Outline (and whatever its own - // Preview option says): its picture is there to be traced (#1486). - if *template && f.opts.outline { - let opts = RenderOptions { outline: false, ..f.opts.clone() }; - return self.draw_node(ctx, &Frame { opts: &opts, ..*f }, n, force); - } - if !preview && !template && !f.opts.outline { - let opts = RenderOptions { outline: true, ..f.opts.clone() }; - return self.draw_node(ctx, &Frame { opts: &opts, ..*f }, n, force); - } - if let Some(p) = *dim_images { - let outer = self.dim_images; - let dim = f32::from(p).clamp(0.0, 100.0) / 100.0; - self.dim_images = Some(outer.map_or(dim, |o| o.min(dim))); - self.draw_layer_node(ctx, f, n); - self.dim_images = outer; - return; - } - } - self.draw_layer_node(ctx, f, n); - } - - /// [`Self::draw_node`] after culling and Layer Options. - fn draw_layer_node(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node) { - // Isolation mode: the art around the isolated container draws dimmed (#833). - if let Some((isolated, path)) = self.isolation_path(f) { - // A container on the way down to it draws as usual: its members decide. - if path.contains(&n.id) && n.id != isolated { - return self.draw_layer_node_now(ctx, f, n); - } - self.isolation_settled = true; - if n.id == isolated { - self.draw_layer_node_now(ctx, f, n); - } else { - self.dimmed(ctx, f, &mut |r, c, fr| r.draw_layer_node_now(c, fr, n)); - } - self.isolation_settled = false; - return; - } - self.draw_layer_node_now(ctx, f, n); - } - - /// In isolation mode, while drawing outside the isolated container and the art dimmed around - /// it: the isolated container and its layers and groups, from the top layer down to it. - fn isolation_path(&mut self, f: &Frame) -> Option<(NodeId, Vec)> { - // Opacity-mask editing isolates the temporary layer for tools; that layer is not painted on - // the page (the mask applies on the object), so canvas dimming would wash out everything. - let isolated = f.opts.isolated.filter(|_| f.doc.mask_edit.is_none() && !self.isolation_settled)?; - let doc = std::ptr::from_ref(f.doc) as usize; - if self.isolation.as_ref().is_none_or(|(stamp, d, id, _)| (*stamp, *d, *id) != (self.stamp, doc, isolated)) { - self.isolation = Some((self.stamp, doc, isolated, f.doc.ancestry(isolated).unwrap_or_default())); - } - let path = self.isolation.as_ref().map(|(.., path)| path.clone()).filter(|p| !p.is_empty())?; - Some((isolated, path)) - } - - /// `draw` at [`ISOLATION_DIM`], with nothing inside dimmed again. - fn dimmed(&mut self, ctx: &mut RenderContext, f: &Frame, draw: &mut group::Content) { - let settled = std::mem::replace(&mut self.isolation_settled, true); - let comp = Composite { opacity: ISOLATION_DIM, ..Default::default() }; - self.group(ctx, f, comp, draw); - self.isolation_settled = settled; - } - - /// [`Self::draw_layer_node`] past isolation mode's dimming. - fn draw_layer_node_now(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node) { - if fx::has_object_fx(n) { - return self.draw_object_fx(ctx, f, &Arc::new(n.clone()), false); - } - let outline = f.opts.outline; - let opacity = self.opacity_of(n); - // Whether this container's children knock each other out. - let knockout = !outline && n.knocks_out(self.knockout); - let enclosing = std::mem::replace(&mut self.knockout, knockout); - if !outline && (opacity < 1.0 || n.blend != vectorcraft_color::BlendMode::Normal || n.isolate || knockout) { - let blends = self.blends_through(n); - let bounds = if blends { self.node_bounds(f.symbols, n) } else { None }; - let comp = Composite { blend: n.blend, opacity, isolated: n.isolate, blends, bounds, ..Default::default() }; - self.group(ctx, f, comp, &mut |r, c, fr| r.draw_content(c, fr, n, knockout)); - } else { - self.draw_content(ctx, f, n, knockout); - } - self.knockout = enclosing; - self.stats.drawn += 1; - } - - /// What `n` draws inside its transparency group (`knockout`: its children knock each other out). - fn draw_content(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, knockout: bool) { - match &n.kind { - NodeKind::Group { children, clip: false } if f.opts.trace_views && !f.opts.outline && Self::trace_view(f, n) != TraceView::Result => { - self.draw_trace_view(ctx, f, children, Self::trace_view(f, n), knockout) - } - NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { - self.draw_children(ctx, f, children, knockout) - } - NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } if f.opts.outline => { - for c in children { - self.draw_outline_member(ctx, f, c); - } - } - NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { - // Nothing to clip by hides the clipped art (as in the SVG and PDF output). A clip - // group doesn't isolate: blending inside it reaches the art below. The clipping - // path's fill paints behind the clipped art and its stroke over it, unclipped. - if let Some((clip, rest)) = children.split_first() - && let Some(region) = self.clip_of(clip) - { - let (fill, stroke) = self.clip_paint_of(clip); - // An image inside its own frame painted straight into the clipping path, without - // a clip layer (envelopes cut distorted images into many such pieces). - if let ([image], None, None) = (rest, &fill, &stroke) - && let NodeKind::Image(im) = &image.kind - && plain_image(image, im, ®ion.0) - { - self.draw_image_in(ctx, f, im, Some(®ion)); - self.stats.drawn += 1; - return; - } - let blends = self.blends_through(n); - let bounds = if blends { Some(region.0.bounding_box()) } else { None }; - let comp = Composite { clip: Some((®ion.0, region.1)), blends, bounds, ..Default::default() }; - self.group(ctx, f, comp, &mut |r, c, fr| { - if let Some(fill) = &fill { - r.draw_arc(c, fr, fill); - } - r.draw_children(c, fr, rest, knockout) - }); - if let Some(stroke) = &stroke { - self.draw_arc(ctx, f, stroke); - } - } - } - NodeKind::Path { path, rule, guide, .. } => { - let bp = self.outline_path(f, n).unwrap_or_else(|| Arc::new(path.to_bezpath())); - if *guide { - let ([r, g, b], dots) = f.opts.guide; - let mut screen = BezPath::clone(&bp); - screen.apply_affine(f.view); - // The rasterizer makes every dash of the whole path before clipping: a guide - // running far off screen (from a file) asked for millions of them. Past - // [`MAX_GUIDE_DASHES`] (by its control polygon, which is at least as long) it - // is drawn solid. - let h = self.hairline; - if dots && polygon_length(&screen) / (4.0 * h) <= MAX_GUIDE_DASHES { - ctx.set_transform(Affine::IDENTITY); - ctx.set_stroke(kurbo::Stroke::new(1.5 * h).with_dashes(0.0, [1.5 * h, 2.5 * h])); - ctx.set_paint(f.ink.fixed([r, g, b, 255])); - ctx.stroke_path(&screen); - } else { - self.hairline(ctx, f, &bp, [r, g, b, 255]); - } - } else { - self.draw_shape(ctx, f, n, &bp, *rule); - } - } - NodeKind::Compound { children, rule } => { - if let Some(bp) = self.outline_path(f, n) { - return self.draw_shape(ctx, f, n, &bp, *rule); - } - let mut bp = BezPath::new(); - for c in children { - if let Some(p) = c.path_data() { - bp.extend(p.to_bezpath()); - } - } - self.draw_shape(ctx, f, n, &bp, *rule); - } - NodeKind::Text(t) => self.draw_text(ctx, f, n, t), - NodeKind::Image(im) => self.draw_image(ctx, f, im), - NodeKind::SymbolInstance { symbol, xf } => { - if let Some(sym) = f.doc.symbols.iter().find(|s| &s.name == symbol) - && self.charge_symbol(&sym.art) - { - let (art, kept) = self.placed_art(&sym.art, *xf, false, Some(n)); - self.draw_placed_art(ctx, f, &art, kept); - } - } - NodeKind::Blend { .. } - | NodeKind::Envelope { .. } - | NodeKind::Mesh(_) - | NodeKind::Repeat(_) - | NodeKind::PlacedDocument(_) - | NodeKind::Intertwine { .. } => match self.live_root.clone().filter(|a| std::ptr::eq(&**a, n)) { - Some(a) => self.draw_live_body(ctx, f, &a, true), - None => self.draw_live_node(ctx, f, n), - }, - } - } - - /// Count symbol art `art`'s objects against the frame's [`vectorcraft_doc::MAX_SYMBOL_NODES`]: `false` (the - /// instance isn't drawn) once they would pass it. - fn charge_symbol(&mut self, art: &Node) -> bool { - let total = self.symbol_nodes.saturating_add(art.count() as u64); - (total <= vectorcraft_doc::MAX_SYMBOL_NODES).then(|| self.symbol_nodes = total).is_some() - } - - /// Draw symbol art `art` as [`Self::placed_art`] placed it for an instance or an inline graphic - /// (`kept`: kept between frames, see [`Self::draw_kept_node`]). - fn draw_placed_art(&mut self, ctx: &mut RenderContext, f: &Frame, art: &Arc, kept: bool) { - let outer = std::mem::replace(&mut self.made_again, !kept); - if kept { - self.draw_kept_node(ctx, f, art); - } else { - self.draw_node(ctx, f, art, true); - } - self.made_again = outer; - } - - /// [`Self::draw_node`] of `a` (drawn whatever its visibility and bounds), whose `Arc` stays the - /// same from frame to frame while the art does. When `a` is a live object, - /// [`Self::draw_content`] draws its evaluation from the live cache ([`Self::live_root`]). - fn draw_kept_node(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc) { - let live = vectorcraft_doc::live::is_live(a).then(|| a.clone()); - let outer = std::mem::replace(&mut self.live_root, live); - self.draw_node(ctx, f, a, true); - self.live_root = outer; - } - - /// The View `n` draws with on screen: Source Image when it is an Image Trace object listed in - /// [`RenderOptions::trace_source`], else its own. - fn trace_view(f: &Frame, n: &Node) -> TraceView { - match &f.opts.trace_source { - Some(ids) if n.extras().trace.is_some() && ids.contains(&n.id) => TraceView::Source, - _ => n.trace_view(), - } - } - - /// An Image Trace object's `children` (the hidden source image, then the traced shapes) as - /// `view` shows them on screen. - fn draw_trace_view(&mut self, ctx: &mut RenderContext, f: &Frame, children: &[Arc], view: TraceView, knockout: bool) { - let Some((source, shapes)) = children.split_first() else { return }; - if view.shows_source() && matches!(source.kind, NodeKind::Image(_)) { - self.draw_node(ctx, f, source, true); - } - if view.shows_result() { - self.draw_children(ctx, f, shapes, knockout); - } - if view.shows_outlines() { - let opts = RenderOptions { outline: true, ..f.opts.clone() }; - let frame = Frame { opts: &opts, ..*f }; - for c in shapes { - self.draw_outline_member(ctx, &frame, c); - } - } - } - - /// [`Self::draw_node`] for `c`, a member of a clip group or a shape of an Image Trace object in - /// Outline view, culled by its cached bounds ([`Self::bounds_of`]) and drawn with `c` as - /// `fx_owner`, so its path, its live-effect geometry and, through [`Self::draw_kept_node`], a - /// live object's evaluation come from the caches. - fn draw_outline_member(&mut self, ctx: &mut RenderContext, f: &Frame, c: &Arc) { - if !c.visible || f.opts.hidden.contains(&c.id) || f.opts.hide_guides && matches!(c.kind, NodeKind::Path { guide: true, .. }) { - return; - } - match self.bounds_of(f.symbols, c) { - Some(b) => { - let pad = f.px * 2.0; - if !rects_overlap(b.inflate(pad, pad), f.visible) { - self.stats.culled += c.count(); - return; - } - } - None if !c.is_container() => return, - None => {} - } - let outer = self.fx_owner.replace(c.clone()); - self.draw_kept_node(ctx, f, c); - self.fx_owner = outer; - } - - /// The children of a group: as the elements of a knockout group when `knockout`. - fn draw_children(&mut self, ctx: &mut RenderContext, f: &Frame, children: &[Arc], knockout: bool) { - if knockout { - self.draw_knockout(ctx, f, children); - } else if let Some((_, path)) = self.isolation_path(f) { - // Isolation mode, in a container on the way down to the isolated one: each run of - // members around it draws dimmed as one (#833). - let mut rest = children; - while let Some(i) = rest.iter().position(|c| path.contains(&c.id)) { - let (around, from) = rest.split_at(i); - if !around.is_empty() { - self.dimmed(ctx, f, &mut |r, c, fr| around.iter().for_each(|n| r.draw_arc(c, fr, n))); - } - let Some((on_path, after)) = from.split_first() else { break }; - self.draw_arc(ctx, f, on_path); - rest = after; - } - if !rest.is_empty() { - self.dimmed(ctx, f, &mut |r, c, fr| rest.iter().for_each(|n| r.draw_arc(c, fr, n))); - } - } else { - for c in children { - self.draw_arc(ctx, f, c); - } - } - } - - /// Cull bounds of `n` (symbol instances measured by their art box from `symbols`): cached when - /// it is a node of the tree being drawn. - fn node_bounds(&mut self, symbols: &SymbolBoxes, n: &Node) -> Option { - match self.geom.get(&(n as *const Node as usize)) { - // The entry keeps its node alive, so the address is that node's. - Some(e) if std::ptr::eq(Arc::as_ptr(&e.node), n) => e.bounds, - _ => fx::cull_bounds(symbols, n), - } - } - - /// The children of a knockout group ([`Node::knockout_elements`]). Each element first erases - /// what the elements below it drew wherever it paints (its knockout shape: its coverage at full - /// object opacity without its opacity mask, or as drawn when its opacity and mask define the - /// knockout shape), then adds itself: it composites against the group's backdrop instead of - /// over the elements below it (the art below the group when the group is non-isolated and - /// drawn over a copy of it, see [`Self::group`] and [`Self::knockout_over`]). - fn draw_knockout(&mut self, ctx: &mut RenderContext, f: &Frame, children: &[Arc]) { - let backdrop = self.backdrop.take(); - for c in Node::knockout_elements(children) { - if self.skipped(f, c) { - continue; - } - self.knockout_pass(ctx, f, c, Compose::DestOut); - match &backdrop { - Some(b) => self.knockout_over(ctx, f, c, b), - None => self.knockout_pass(ctx, f, c, Compose::Plus), - } - } - self.backdrop = backdrop; - } - - /// Whether `n` is the knockout element being drawn as its shape (see [`Self::draw_knockout`]). - fn is_shape(&self, n: &Node) -> bool { - self.shape_of == n as *const Node as usize - } - - /// The object opacity `n` is drawn with (full while it is drawn as its knockout shape). - pub(crate) fn opacity_of(&self, n: &Node) -> f32 { - if self.is_shape(n) { 1.0 } else { n.opacity } - } - - fn hairline(&mut self, ctx: &mut RenderContext, f: &Frame, bp: &BezPath, rgba: [u8; 4]) { - ctx.set_transform(Affine::IDENTITY); - let mut screen = bp.clone(); - screen.apply_affine(f.view); - ctx.set_stroke(kurbo::Stroke::new(self.hairline)); - ctx.set_paint(f.ink.fixed(rgba)); - ctx.stroke_path(&screen); - } - - fn draw_shape(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, bp: &BezPath, rule: FillRule) { - if !drawable(bp, f.view, n) { - return; - } - if fx::has_fx(n) { - return self.draw_shape_fx(ctx, f, n, bp, rule); - } - if f.opts.outline { - self.hairline(ctx, f, bp, [0, 0, 0, 255]); - return; - } - // Bounds only matter to gradients and patterns (their geometry is relative to the object). - let solid = |p: &vectorcraft_color::Paint| matches!(p, vectorcraft_color::Paint::Solid { .. } | vectorcraft_color::Paint::None); - let needs_bounds = n.appearance.items.iter().any(|i| match i { - AppearanceItem::Fill(fl) => !solid(&fl.paint), - AppearanceItem::Stroke(st) => !solid(&st.paint), - }); - let bounds = if needs_bounds { bp.bounding_box() } else { Rect::ZERO }; - for item in &n.appearance.items { - match item { - AppearanceItem::Fill(fl) => { - if !fl.visible || fl.paint.is_none() { - continue; - } - let layered = fl.opacity < 1.0 || fl.blend != vectorcraft_color::BlendMode::Normal; - if layered { - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, Some(blend_mode(fl.blend)), Some(fl.opacity), None, None); - } - ctx.set_transform(f.view); - if paint::set_fill_paint(ctx, &fl.paint, bounds, f) { - self.fold_alpha(ctx, f.ink, &fl.paint); - ctx.set_fill_rule(fill_rule(rule)); - ctx.fill_path(bp); - } - if layered { - ctx.pop_layer(); - } - } - AppearanceItem::Stroke(st) => { - if !st.visible || st.paint.is_none() || st.width <= 0.0 { - continue; - } - if st.brush.is_some() && self.draw_brush(ctx, f, n, bp, st) { - continue; - } - self.draw_stroke(ctx, f, Affine::IDENTITY, bp, rule, st, None, bounds); - } - } - } - } - - /// Multiply the folded object opacity into a solid paint. - fn fold_alpha(&self, ctx: &mut RenderContext, ink: Ink, p: &vectorcraft_color::Paint) { - if self.alpha < 1.0 - && let vectorcraft_color::Paint::Solid { color, .. } = p - { - ctx.set_paint(ink.color(color, self.alpha)); - } - } - - /// Paint stroke `st` of the shape `bp` (in the space `xf` maps to the document; `bounds`: its - /// geometric bounds there) with its alignment, dashes, profile, arrowheads, opacity and blend. - /// `chars` is the glyph geometry kept in `texts` and the run that `st` is the character stroke - /// of, when it is one. - #[allow(clippy::too_many_arguments)] - fn draw_stroke( - &mut self, - ctx: &mut RenderContext, - f: &Frame, - xf: Affine, - bp: &BezPath, - rule: FillRule, - st: &StrokeLayer, - chars: Option<(&Arc, usize)>, - bounds: Rect, - ) { - 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. - let folded = if pieces.heads.is_empty() { 1.0 } else { std::mem::replace(&mut self.alpha, 1.0) }; - let opacity = st.opacity * folded; - let layered = opacity < 1.0 || st.blend != vectorcraft_color::BlendMode::Normal || st.align == StrokeAlign::Outside; - if layered { - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, Some(blend_mode(st.blend)), Some(opacity), None, None); - } - ctx.set_transform(f.view * xf); - let closed = effects::stroke::is_closed(bp); - // Keep hairlines visible when zoomed far out (at least ~1 device pixel). - let width = effects::stroke::aligned_width(st, closed).max(f.px * 0.5 / xf.determinant().abs().sqrt().max(1e-9)); - let inside = st.align == StrokeAlign::Inside && closed; - if inside { - ctx.set_fill_rule(fill_rule(rule)); - ctx.push_clip_layer(bp); - } - // One paint box for the line and the heads, so a gradient runs on into the heads. - let paint_bounds = bounds.inflate(st.width / 2.0, st.width / 2.0); - if let Some(g) = st.path_gradient() { - self.fill_path_gradient(ctx, f, bp, rule, st, chars, &pieces, width, &g.gradient); - } else if paint::set_paint(ctx, &st.paint, paint_bounds, f) { - self.fold_alpha(ctx, f.ink, &st.paint); - ctx.set_fill_rule(peniko::Fill::NonZero); - match self.cached_stroke(f, chars, &pieces.line, st, width) { - Some(o) => ctx.fill_path(&o), - None => ctx.fill_path(&effects::stroke::line_outline(&pieces.line, st, width, f.px * 0.25)), - } - for head in &pieces.heads { - ctx.fill_path(&head.outline); - } - } - if inside { - ctx.pop_layer(); - } - if st.align == StrokeAlign::Outside && closed { - // Punch out the interior. - ctx.set_blend_mode(BlendMode::new(Mix::Normal, Compose::DestOut)); - ctx.set_paint(peniko::Color::BLACK); - ctx.set_fill_rule(fill_rule(rule)); - ctx.fill_path(bp); - ctx.set_blend_mode(BlendMode::default()); - } - if layered { - ctx.pop_layer(); - } - if !pieces.heads.is_empty() { - self.alpha = folded; - } - } - - /// Paint the line and arrowheads (`pieces`) of stroke `st` of `bp` (filled with `rule`) with - /// its gradient `g` laid along or across the path ([`effects::stroke::gradient_slices`]). Each - /// piece clips the slices, grown a pixel into each other and each replacing what it covers, so - /// neighbours meet without seams and translucent colours paint once. - #[allow(clippy::too_many_arguments)] - fn fill_path_gradient( - &mut self, - ctx: &mut RenderContext, - f: &Frame, - bp: &BezPath, - rule: FillRule, - st: &StrokeLayer, - chars: Option<(&Arc, usize)>, - pieces: &effects::stroke::StrokePieces, - width: f64, - g: &vectorcraft_color::Gradient, - ) { - let level = (f.px * 0.25).log2().floor() as i32; - let make = || -> Slices { - let tol = 2f64.powi(level); - // Reach a pixel past the stroke, so its antialiased edge has colour under it. - let slices = effects::stroke::gradient_slices(bp, rule, st, tol, f.px + tol, f.px); - Arc::new( - slices - .into_iter() - .map(|s| { - let (b, paint) = (s.shape.bounding_box(), s.paint(g)); - (s.shape, paint, b) - }) - .collect(), - ) - }; - let slices = match self.cur.clone().filter(|n| owns_stroke(n, st)) { - Some(node) => { - let key = (self.stroke_owner(Arc::as_ptr(&node) as usize), st as *const StrokeLayer as usize, level); - match self.stroke_slices.get_mut(&key) { - Some(e) if Arc::ptr_eq(&e.node, &node) => { - e.stamp = self.stamp; - e.slices.clone() - } - _ => { - let slices = make(); - self.stroke_slices.insert(key, SliceEntry { node, slices: slices.clone(), stamp: self.stamp }); - slices - } - } - } - None => make(), - }; - let line = match self.cached_stroke(f, chars, &pieces.line, st, width) { - Some(o) => o, - None => Arc::new(effects::stroke::line_outline(&pieces.line, st, width, f.px * 0.25)), - }; - ctx.set_fill_rule(peniko::Fill::NonZero); - for clip in std::iter::once(&*line).chain(pieces.heads.iter().map(|h| &h.outline)) { - let cb = clip.bounding_box(); - if clip.elements().is_empty() || !cb.is_finite() { - continue; - } - ctx.push_clip_layer(clip); - ctx.set_blend_mode(BlendMode::new(Mix::Normal, Compose::Copy)); - for (shape, paint, b) in slices.iter() { - if rects_overlap(*b, cb) && paint::set_paint(ctx, paint, *b, f) { - ctx.fill_path(shape); - } - } - ctx.set_blend_mode(BlendMode::default()); - ctx.pop_layer(); - } - } - - /// The line part of a stroke (see [`effects::stroke::line_outline`]), cached across frames: a - /// character stroke of run `chars.1` of kept glyph geometry `chars.0` in `char_strokes`, a - /// stroke of `self.cur` in `strokes` (under [`Self::stroke_owner`] and the stroke layer). The - /// tolerance (vello's 0.25 device px) is bucketed to powers of two, never coarser than needed, - /// so zooming reuses outlines until the level changes. Other strokes aren't cached: for them - /// this returns `None`. - fn cached_stroke( - &mut self, - f: &Frame, - chars: Option<(&Arc, usize)>, - line: &BezPath, - st: &StrokeLayer, - width: f64, - ) -> Option> { - let level = (f.px * 0.25).log2().floor() as i32; - let expand = || Arc::new(effects::stroke::line_outline(line, st, width, 2f64.powi(level))); - if let Some((g, run)) = chars { - let key = (self.stroke_owner(Arc::as_ptr(g) as usize), run, width.to_bits(), level); - if let Some((kept, outline)) = self.char_strokes.get(&key, self.stamp) - && Arc::ptr_eq(kept, g) - { - return Some(outline.clone()); - } - let outline = expand(); - self.char_strokes.insert(key, (g.clone(), outline.clone()), lru::path_bytes(&outline), self.stamp); - return Some(outline); - } - 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); - if let Some(e) = self.strokes.get_mut(&key) - && Arc::ptr_eq(&e.node, &node) - { - e.stamp = self.stamp; - return Some(e.outline.clone()); - } - let outline = expand(); - self.strokes.insert(key, StrokeEntry { node, outline: outline.clone(), stamp: self.stamp }); - Some(outline) - } - - /// What the `strokes`, `char_strokes` and `stroke_slices` keys hold for a stroke owner at - /// `address` (`self.cur` or kept glyph geometry): the address, or 0 while art made again in - /// every frame is drawn ([`Self::made_again`]). - fn stroke_owner(&self, address: usize) -> usize { - if self.made_again { 0 } else { address } - } - - fn draw_text(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject) { - let t = &*f.doc.inline_resolved(t); - // Type drawn as `fx_owner` (the node `draw_arc` is drawing, or type art that `fx_arts` - // keeps, see `draw_fx_art`) takes its glyph geometry from `texts`, as on the fast path, - // except in frames that snap type to pixels. - let owner = self.fx_owner.as_ref().filter(|o| std::ptr::eq(&***o, n)).cloned(); - let (g, kept) = match (f.text_snap(t), owner) { - (None, Some(a)) => self.text_geom_of(&a, t), - (snap, _) => (Arc::new(text_geom_snapped(t, snap)), false), - }; - self.draw_text_geom(ctx, f, n, t, &g, kept); - } - - /// Draw the inline graphics of type `t` (laid out in `g`): each symbol's art through the - /// normal node path, placed by the layout (inside the text's own transparency group, so its - /// opacity and blend mode apply). A missing symbol draws nothing. - fn draw_inlines(&mut self, ctx: &mut RenderContext, f: &Frame, t: &TextObject, g: &TextGeom) { - if g.inlines.is_empty() && !t.runs.iter().any(|r| r.inline.is_some()) { - return; - } - if self.inline_depth >= MAX_INLINE_DEPTH { - return; - } - for r in t.runs.iter().filter_map(|r| r.inline.as_ref()) { - if !f.doc.symbols.iter().any(|s| s.name == r.symbol) && !MISSING_INLINE_LOGGED.swap(true, std::sync::atomic::Ordering::Relaxed) { - log::warn!("inline graphic shows symbol {:?}, which the document doesn't have: it draws nothing", r.symbol); - } - } - self.inline_depth += 1; - for ig in &g.inlines { - let Some(art) = t.runs.get(ig.run).and_then(|r| r.inline.as_ref()) else { continue }; - let Some(sym) = f.doc.symbols.iter().find(|s| s.name == art.symbol) else { continue }; - // Strokes scale with the art (it is sized to the type, as in the SVG ``). - let (node, kept) = self.placed_art(&sym.art, t.xf * ig.xf * f.doc.symbol_natural_xf(&art.symbol), true, None); - self.draw_placed_art(ctx, f, &node, kept); - } - self.inline_depth -= 1; - } - - /// Symbol art `art` as an instance or an inline graphic draws it ([`place`]), and whether it is - /// kept. Art that is a group, a layer or a live object without an appearance of its own is kept - /// between frames under the art's `Arc`, `xf`, `scale_strokes` and the stain of instance - /// `inst`: it and its members keep their `Arc`s from frame to frame, and the caches keyed by a - /// node's address (geometry, stroke outlines, glyphs, live-effect geometry, evaluated live - /// objects) hit for them. Other art is placed again in every frame, and so is art that holds - /// symbol instances, type with inline graphics, images, placed documents or raster effects - /// ([`depends_on_document`]), and art that the cache has no room for. Art that holds type is - /// placed again when the fonts change. - fn placed_art(&mut self, art: &Arc, xf: Affine, scale_strokes: bool, inst: Option<&Node>) -> (Arc, bool) { - let kept_kind = matches!(art.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) || vectorcraft_doc::live::is_live(art); - if !kept_kind || fx::has_object_fx(art) { - return (place(art, xf, scale_strokes, inst), false); - } - let stain = stain_bits(inst.and_then(vectorcraft_brush::stain)); - let key = (Arc::as_ptr(art) as usize, xf.as_coeffs().map(f64::to_bits), scale_strokes, stain); - if let Some(e) = self.placed_arts.get(&key, self.stamp) - && std::ptr::eq(e.art.as_ptr(), Arc::as_ptr(art)) - && e.fonts.is_none_or(|g| g == vectorcraft_text::FontDb::global().generation()) - { - return match &e.placed { - Some(placed) => (placed.clone(), true), - None => (place(art, xf, scale_strokes, inst), false), - }; - } - let placed = place(art, xf, scale_strokes, inst); - let kept = (!depends_on_document(art)).then(|| placed.clone()); - let keeps = kept.is_some(); - let fonts = (keeps && holds_type(art)).then(|| vectorcraft_text::FontDb::global().generation()); - // The bytes count the key and the entry, the art's allocation that the weak reference - // keeps, and the kept art. - let bytes = size_of::<(PlacedKey, PlacedEntry, Node)>() + kept.as_deref().map_or(0, lru::node_bytes); - let inserted = self.placed_arts.insert(key, PlacedEntry { art: Arc::downgrade(art), placed: kept, fonts }, bytes, self.stamp); - (placed, keeps && inserted) - } - - /// Cached glyph geometry for a text node (keyed by Arc identity like paths). - /// Text `t` that is not the node's own, such as the copy whose inline graphics - /// `Document::inline_resolved` sized again, is laid out without the cache. Also returns - /// whether the geometry is kept in the cache. - fn text_geom_of(&mut self, a: &Arc, t: &TextObject) -> (Arc, bool) { - if !matches!(&a.kind, NodeKind::Text(own) if std::ptr::eq(&**own, t)) { - return (Arc::new(text_geom(t)), false); - } - let fonts = vectorcraft_text::FontDb::global().generation(); - if fonts != self.text_fonts { - self.texts.clear(); - self.char_strokes.clear(); - self.text_fonts = fonts; - } - let key = Arc::as_ptr(a) as usize; - if let Some((node, g)) = self.texts.get(&key, self.stamp) - && Arc::ptr_eq(node, a) - { - return (g.clone(), true); - } - let g = Arc::new(text_geom(t)); - let kept = self.texts.insert(key, (a.clone(), g.clone()), g.bytes(), self.stamp); - (g, kept) - } - - /// Draw type `n` (`t`, laid out in `g`). The outlines of its character strokes are cached - /// when `g` is kept in `texts` (`kept`). - fn draw_text_geom(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject, g: &Arc, kept: bool) { - let xf = f.view * t.xf; - if f.opts.outline { - let mut p = g.all.clone(); - p.apply_affine(xf); - ctx.set_transform(Affine::IDENTITY); - ctx.set_stroke(kurbo::Stroke::new(self.hairline)); - ctx.set_paint(peniko::Color::BLACK); - ctx.stroke_path(&p); - // Colour bitmap glyphs: their boxes, as images show in outline mode. - for (_, image, to_text) in &g.images { - let mut b = image.rect().to_path(0.1); - b.apply_affine(t.xf * *to_text); - self.hairline(ctx, f, &b, [0, 0, 0, 255]); - } - // COLR glyphs: their layers' outlines. - for (_, art) in &g.colours { - self.draw_colour_glyph(ctx, f, art, t.xf); - } - self.draw_inlines(ctx, f, t, g); - return; - } - let tb = t.xf.transform_rect_bbox(g.bounds); - // 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::Darken) } else { BlendMode::default() }); - } - }; - // The object's own fills and strokes paint the whole outline: those below the Characters - // row under the characters' own fill and stroke, the others over them. - let (below, above) = n.appearance.split_contents(); - let all = (!n.appearance.items.is_empty()).then(|| { - let mut all = g.all.clone(); - all.apply_affine(t.xf); - all - }); - if let Some(all) = &all { - self.draw_text_items(ctx, f, n, below, all, tb); - } - if f.opts.highlight_substitutions { - for (on, path) in [ - (f.opts.highlight_substituted_fonts, Some(&g.substituted_fonts)), - (f.doc.setup.highlight_substituted_glyphs, Some(&g.substituted_glyphs)), - // Measured only when shown: it lays the text out again. - (f.opts.highlight_alternate_glyphs, f.opts.highlight_alternate_glyphs.then(|| g.alternate_glyphs(t))), - ] { - let Some(path) = path else { continue }; - if on && !path.elements().is_empty() { - ctx.set_transform(xf); - ctx.set_fill_rule(peniko::Fill::NonZero); - ctx.set_paint(SUBSTITUTED); - ctx.fill_path(path); - } - } - } - for (i, run) in t.runs.iter().enumerate() { - // Colour bitmap glyphs paint themselves: the run's fill and stroke don't. - for (_, image, to_text) in g.images.iter().filter(|(r, ..)| *r == i) { - overprint(ctx, false); - self.draw_glyph_image(ctx, f, image, t.xf * *to_text); - } - for (_, art) in g.colours.iter().filter(|(r, _)| *r == i) { - self.draw_colour_glyph(ctx, f, art, t.xf); - } - let path = g.run(i); - let path = path.as_ref(); - if path.elements().is_empty() { - continue; - } - ctx.set_transform(xf); - ctx.set_fill_rule(peniko::Fill::NonZero); - if paint::set_fill_paint(ctx, &run.style.fill, g.bounds, f) { - self.fold_alpha(ctx, f.ink, &run.style.fill); - overprint(ctx, run.style.overprint_fill); - ctx.fill_path(path); - } - if run.style.has_stroke() { - // Character strokes are drawn in text space, with their cap, join and dashes. - overprint(ctx, run.style.overprint_stroke); - let chars = kept.then_some((g, i)); - self.draw_stroke(ctx, f, t.xf, path, FillRule::NonZero, &run.style.stroke_layer(), chars, g.bounds); - } - } - overprint(ctx, false); - self.draw_inlines(ctx, f, t, g); - if let Some(all) = &all { - self.draw_text_items(ctx, f, n, above, all, tb); - } - } - - /// Paint some of type `n`'s own fills and strokes (`items`) on its glyph outlines `all` - /// (document space; `tb` their bounds). Fills composite with their own opacity and blend mode; - /// strokes take every stroke option, as on paths. - fn draw_text_items(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, items: &[AppearanceItem], all: &BezPath, tb: Rect) { - for item in items.iter().filter(|i| i.visible() && !i.paint().is_none()) { - match item { - AppearanceItem::Fill(fl) => { - let layered = fl.opacity < 1.0 || fl.blend != vectorcraft_color::BlendMode::Normal; - if layered { - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, Some(blend_mode(fl.blend)), Some(fl.opacity), None, None); - } - ctx.set_transform(f.view); - if paint::set_fill_paint(ctx, &fl.paint, tb, f) { - ctx.set_fill_rule(peniko::Fill::NonZero); - ctx.fill_path(all); - } - if layered { - ctx.pop_layer(); - } - } - AppearanceItem::Stroke(st) if st.width > 0.0 => { - if st.brush.is_none() || !self.draw_brush(ctx, f, n, all, st) { - self.draw_stroke(ctx, f, Affine::IDENTITY, all, FillRule::NonZero, st, None, tb); - } - } - AppearanceItem::Stroke(_) => {} - } - } - } - - /// Draw COLR glyph `art` (its layers in text space) through `xf` (text space → document), as - /// art: kept between frames as symbol art is ([`Self::placed_art`]). - fn draw_colour_glyph(&mut self, ctx: &mut RenderContext, f: &Frame, art: &Arc, xf: Affine) { - let (node, kept) = self.placed_art(art, xf, true, None); - let outer = std::mem::replace(&mut self.made_again, !kept); - self.draw_node(ctx, f, &node, true); - self.made_again = outer; - } - - /// Draw colour bitmap glyph `image` through `xf` (its pixels → document space), its pixels - /// decoded once and kept with the images' pixels. - fn draw_glyph_image(&mut self, ctx: &mut RenderContext, f: &Frame, image: &vectorcraft_text::GlyphImage, xf: Affine) { - let Some((key, pm)) = blob_pixmap(&image.blob, true) else { return }; - let (key, pm) = ink::ink_image(&key, &pm, f.ink, Some(&image.blob), true); - let unit = f.view * xf * Affine::scale_non_uniform(image.width as f64, image.height as f64); - let pm = image_mip(&key, pm, unit); - let sx = image.width as f64 / pm.width().max(1) as f64; - let sy = image.height as f64 / pm.height().max(1) as f64; - let quality = if f.opts.smooth_images { peniko::ImageQuality::Medium } else { peniko::ImageQuality::Low }; - ctx.set_paint(vello_cpu::Image { - image: vello_cpu::ImageSource::Pixmap(pm), - sampler: peniko::ImageSampler { quality, ..Default::default() }, - }); - ctx.set_transform(f.view * xf); - ctx.set_paint_transform(Affine::scale_non_uniform(sx, sy)); - ctx.fill_rect(&image.rect()); - ctx.reset_paint_transform(); - } - - fn draw_image(&mut self, ctx: &mut RenderContext, f: &Frame, im: &vectorcraft_doc::ImageObject) { - self.draw_image_in(ctx, f, im, None); - } - - /// Draw an image, or with `area` (a region in the document inside the image's frame, see - /// [`plain_image`]) the image painted into that region. - fn draw_image_in(&mut self, ctx: &mut RenderContext, f: &Frame, im: &vectorcraft_doc::ImageObject, area: Option<&(BezPath, FillRule)>) { - let rect = Rect::new(0.0, 0.0, im.width as f64, im.height as f64); - // Outline mode draws the image's frame, and with Document Setup → Show Images in Outline - // Mode its pixels in greyscale under the frame. - let outline = f.opts.outline; - // With mips the full pixels are only looked at (not marked used) until they're drawn, and - // a mip the store holds is drawn without them: they may have been dropped (#2820). - let mips = f.opts.image_mips; - let unit = f.view * im.xf * Affine::scale_non_uniform(im.width as f64, im.height as f64); - let shown = if outline && !f.doc.setup.outline_images { - None - } else if let Some(pm) = mips.then(|| self.kept_mip(f, &im.key, im.pixels.as_deref(), outline, unit)).flatten() { - Some(pm) - } else { - self.image_pixmap(f.doc, &im.key, im.pixels.as_deref(), outline, !mips).map(|(key, pm)| { - let (key, pm) = if outline { (key, pm) } else { ink::ink_image(&key, &pm, f.ink, f.doc.image_blob(im), !mips) }; - if mips { image_mip(&key, pm, unit) } else { pm } - }) - }; - if let Some(pm) = shown { - let sx = im.width as f64 / pm.width().max(1) as f64; - let sy = im.height as f64 / pm.height().max(1) as f64; - let quality = if f.opts.smooth_images { peniko::ImageQuality::Medium } else { peniko::ImageQuality::Low }; - let sampler = peniko::ImageSampler { quality, ..Default::default() }; - ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler }); - match area { - Some((bp, rule)) => { - ctx.set_transform(f.view); - ctx.set_paint_transform(im.xf * Affine::scale_non_uniform(sx, sy)); - ctx.set_fill_rule(fill_rule(*rule)); - ctx.fill_path(bp); - } - None => { - ctx.set_transform(f.view * im.xf); - ctx.set_paint_transform(Affine::scale_non_uniform(sx, sy)); - ctx.fill_rect(&rect); - } - } - ctx.reset_paint_transform(); - // A dimmed layer's images: white over them, so they show at that opacity on paper. - if let Some(dim) = self.dim_images.filter(|d| *d < 1.0 && !outline) { - ctx.set_paint(f.ink.fixed([255, 255, 255, ((1.0 - dim) * 255.0).round() as u8])); - match area { - Some((bp, rule)) => { - ctx.set_transform(f.view); - ctx.set_fill_rule(fill_rule(*rule)); - ctx.fill_path(bp); - } - None => { - ctx.set_transform(f.view * im.xf); - ctx.fill_rect(&rect); - } - } - } - } else if f.opts.mark_undecoded_images && (!outline || f.doc.setup.outline_images) { - self.draw_undecoded_mark(ctx, f, im, area); - } - if outline { - let mut p = rect.to_path(0.1); - p.apply_affine(im.xf); - self.hairline(ctx, f, &p, [0, 0, 0, 255]); - } - } - - /// Draw the mark of image `im` whose pixels can't be had ([`RenderOptions::mark_undecoded_images`]): - /// a light gray box over its frame, crossed from corner to corner with a gray hairline, and - /// with `area` (see [`Self::draw_image_in`]) only inside that region. - fn draw_undecoded_mark(&mut self, ctx: &mut RenderContext, f: &Frame, im: &vectorcraft_doc::ImageObject, area: Option<&(BezPath, FillRule)>) { - let (w, h) = (im.width as f64, im.height as f64); - if let Some((bp, rule)) = area { - ctx.set_transform(f.view); - ctx.set_fill_rule(fill_rule(*rule)); - ctx.push_clip_layer(bp); - } - let rect = Rect::new(0.0, 0.0, w, h); - ctx.set_transform(f.view * im.xf); - ctx.set_paint(f.ink.fixed([128, 128, 128, 40])); - ctx.fill_rect(&rect); - let mut cross = rect.to_path(0.1); - cross.move_to((0.0, 0.0)); - cross.line_to((w, h)); - cross.move_to((w, 0.0)); - cross.line_to((0.0, h)); - cross.apply_affine(im.xf); - self.hairline(ctx, f, &cross, [128, 128, 128, 255]); - if area.is_some() { - ctx.pop_layer(); - } - } - - /// Name image `key` recoloured by `map` (its hash `hash`) as `derived` (the key the adjusted - /// art's image takes): drawing `derived` draws the recoloured pixels, made when first drawn and - /// kept with the decoded images. Names past a limit drop the art made with them too, so it is - /// made again. - fn adjusted_image(&mut self, key: &str, derived: String, hash: u64, map: &vectorcraft_effects::ColorMap) { - if self.adjusts.contains_key(&derived) { - return; - } - if self.adjusts.len() >= 1024 { - self.adjusts.clear(); - self.fx_arts.clear(); - } - self.adjusts.insert(derived, (key.to_string(), hash, map.clone())); - } - - /// The pixels of image `key` (`own`, the pixels an image holds itself, a blob of `doc`, or a - /// recoloured one named by [`Self::adjusted_image`]), or a greyscale copy of them, with their - /// key in the shared store. `touch` marks them used there. - fn image_pixmap( - &mut self, - doc: &Document, - key: &str, - own: Option<&vectorcraft_doc::ImageBlob>, - grey: bool, - touch: bool, - ) -> Option<(images::ImageKey, Arc)> { - let (key, colour) = match own.or_else(|| doc.images.get(key)) { - Some(blob) => blob_pixmap(blob, touch)?, - None => { - let (src, hash, map) = self.adjusts.get(key)?.clone(); - let (src_key, src) = self.image_pixmap(doc, &src, None, false, touch)?; - let key = images::ImageKey { adjust: Some(hash), ..src_key }; - match lookup(&key, touch) { - Some(pm) => (key, pm), - None => { - let mut pm = (*src).clone(); - let mut memo: HashMap<[u8; 4], [u8; 3]> = HashMap::new(); - for px in pm.data_mut().iter_mut().filter(|p| p.a > 0) { - let a = px.a as u32; - let [r, g, b] = *memo.entry([px.r, px.g, px.b, px.a]).or_insert_with(|| { - // Premultiplied to straight, adjusted, then premultiplied again. - let straight = [px.r, px.g, px.b].map(|v| ((v as u32 * 255 + a / 2) / a).min(255) as u8); - map.apply_rgb8(straight).map(|v| ((v as u32 * a + 127) / 255) as u8) - }); - (px.r, px.g, px.b) = (r, g, b); - } - let pm = images::store().insert(key.clone(), Arc::new(pm)); - (key, pm) - } - } - } - }; - if !grey { - return Some((key, colour)); - } - let key = images::ImageKey { grey: true, ..key }; - if let Some(p) = lookup(&key, touch) { - return Some((key, p)); - } - let mut pm = (*colour).clone(); - for px in pm.data_mut() { - // The luma of premultiplied components is the premultiplied luma. - let l = (0.2126 * px.r as f32 + 0.7152 * px.g as f32 + 0.0722 * px.b as f32 + 0.5) as u8; - (px.r, px.g, px.b) = (l, l, l); - } - let pm = images::store().insert(key.clone(), Arc::new(pm)); - Some((key, pm)) - } - - /// The key [`Self::image_pixmap`] gives the pixels of image `key` (or `own`) and their size, without - /// making or looking at them: `None` until the image has been decoded. - fn image_key(&self, doc: &Document, key: &str, own: Option<&vectorcraft_doc::ImageBlob>, grey: bool) -> Option<(images::ImageKey, (u16, u16))> { - let (key, size) = match own.or_else(|| doc.images.get(key)) { - Some(blob) => { - let (key, _, size) = blob_key(blob); - (key, size?) - } - None => { - let (src, hash, _) = self.adjusts.get(key)?; - let (src_key, size) = self.image_key(doc, src, None, false)?; - (images::ImageKey { adjust: Some(*hash), ..src_key }, size) - } - }; - Some((if grey { images::ImageKey { grey: true, ..key } } else { key }, size)) - } - - /// The mip [`image_mip`] draws image `key` (or `own`) with through `m`, in greyscale with - /// `grey` (the outline view's), when the store holds it, marked used there; `None` when it - /// draws the full pixels or the mip is yet to be made. - fn kept_mip(&self, f: &Frame, key: &str, own: Option<&vectorcraft_doc::ImageBlob>, grey: bool, m: Affine) -> Option> { - let (key, (w, h)) = self.image_key(f.doc, key, own, grey)?; - let key = if grey { key } else { ink::ink_key(&key, f.ink) }; - let level = mip_level(m, w, h); - if level == 0 { - return None; - } - images::store().get(&images::ImageKey { mip: level, ..key }) - } -} - -/// `key`'s pixels in the shared image store; `touch` marks them used. -fn lookup(key: &images::ImageKey, touch: bool) -> Option> { - let mut s = images::store(); - if touch { s.get(key) } else { s.peek(key) } -} - -/// The key of `blob`'s decoded pixels in the shared store, the display table a CMYK image's inks -/// show through (its generation is in the key), and the pixels' size once decoded. -fn blob_key(blob: &vectorcraft_doc::ImageBlob) -> (images::ImageKey, Option>, Option<(u16, u16)>) { - let id = images::blob_id(blob); - let lut = id.cmyk.then(ink::display_lut); - let key = - images::ImageKey { content: id.content, adjust: None, grey: false, ink: Ink::Display, luts: (lut.as_ref().map_or(0, |l| l.1), 0), mip: 0 }; - (key, lut.map(|l| l.0), id.size) -} - -/// The decoded pixels of `blob` (a CMYK image's inks shown through the working CMYK profile, again -/// when the colour settings change), from the shared store, and their key there. An image whose -/// pixels can't be decoded is noted in the store and not decoded again; each request for it counts -/// a failure in [`decode_failures`]. -fn blob_pixmap(blob: &vectorcraft_doc::ImageBlob, touch: bool) -> Option<(images::ImageKey, Arc)> { - let (key, lut, size) = blob_key(blob); - let pm = match lookup(&key, touch) { - Some(pm) => pm, - None => { - if images::store().is_undecodable(&key.content) { - paint::add_decode_failures(1); - return None; - } - // Decoded outside the lock: other renderers carry on meanwhile. - let pm = match lut.and_then(|lut| ink::cmyk_pixmap(blob, &lut)) { - Some(pm) => pm, - None => match paint::decode_pixmap_or_why(&blob.bytes) { - Ok(pm) => pm, - Err(why) => { - if why == paint::Undecoded::Unreadable { - images::store().set_undecodable(key.content.clone()); - } - return None; - } - }, - }; - images::store().insert(key.clone(), Arc::new(pm)) - } - }; - let decoded = (pm.width(), pm.height()); - if size != Some(decoded) { - images::remember_size(blob, decoded); - } - Some((key, pm)) -} - -/// How many times to halve image pixels of `w`×`h` drawn through `m` (unit square → device -/// pixels): [`images::mip_level`]. -fn mip_level(m: Affine, w: u16, h: u16) -> u8 { - images::mip_level(m * Affine::scale_non_uniform(1.0 / w.max(1) as f64, 1.0 / h.max(1) as f64), w, h) -} - -/// Image pixels `pm` (stored as `key`) to draw through `m` (unit square → device pixels), shrunk -/// by halves while still at least as fine as the device pixels ([`images::mip_level`]); the -/// pixels drawn are marked used in the store. -fn image_mip(key: &images::ImageKey, pm: Arc, m: Affine) -> Arc { - let level = mip_level(m, pm.width(), pm.height()); - let mut s = images::store(); - if level == 0 { - let _ = s.get(key); // Marks the full pixels used (they were only looked at). - return pm; - } - let at = |l: u8| images::ImageKey { mip: l, ..key.clone() }; - if let Some(p) = s.get(&at(level)) { - return p; - } - // From the finest level kept below it. - let (mut l, mut cur) = (1..level).rev().find_map(|l| s.peek(&at(l)).map(|p| (l, p))).unwrap_or((0, pm)); - drop(s); - while l < level { - l += 1; - let half = Arc::new(images::half(&cur)); - cur = images::store().insert(at(l), half); - } - cur -} - -/// Can image node `n` (`im`) clipped by `region` be painted straight into the region: visible, -/// with no transparency, effects or paint of its own, and the region inside the image's frame -/// (within a pixel), so no paint past the image's edge shows? -fn plain_image(n: &Node, im: &vectorcraft_doc::ImageObject, region: &BezPath) -> bool { - if !(n.visible && n.has_default_transparency() && n.appearance.items.is_empty() && n.appearance.effects.is_empty()) { - return false; - } - let det = im.xf.determinant(); - if !(det.abs() > 1e-12 && det.is_finite()) { - return false; - } - let b = im.xf.inverse().transform_rect_bbox(region.bounding_box()); - b.x0 >= -1.0 && b.y0 >= -1.0 && b.x1 <= im.width as f64 + 1.0 && b.y1 <= im.height as f64 + 1.0 -} - -/// Opacity-mask coverage of one premultiplied pixel: luminance, with the area outside the mask -/// art black (clip) or white (no clip), optionally inverted. -fn mask_value(r: u8, g: u8, b: u8, a: u8, clip: bool, invert: bool) -> u8 { - let [kr, kg, kb] = vectorcraft_color::blend::MASK_LUM; - let mut l = (kr * r as f32 + kg * g as f32 + kb * b as f32) / 255.0; - if !clip { - l += 1.0 - a as f32 / 255.0; - } - if invert { - l = 1.0 - l; - } - (l.clamp(0.0, 1.0) * 255.0 + 0.5) as u8 -} - -/// Most pixels from the origin that a shape handed to the rasterizer may reach, or a stroke be -/// wide. A document's art stays within 4 million points, 2.6 billion pixels at the closest zoom. -/// Geometry far beyond that (from a malformed file) makes vello_cpu panic on a worker thread or ask -/// for more memory than there is, either of which ends the app: it is left undrawn. -const MAX_DEVICE: f64 = 1e10; - -/// Can shape `bp` of `n` be drawn through `view`: its control points (finite) within -/// [`MAX_DEVICE`] pixels, and its visible strokes no wider? -fn drawable(bp: &BezPath, view: Affine, n: &Node) -> bool { - let b = view.transform_rect_bbox(bp.control_box()); - let scale = view.determinant().abs().sqrt(); - let widest = n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(st) = i { Some(st.width) } else { None }).fold(0.0, f64::max); - [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_DEVICE) && (widest * scale).is_finite() && widest * scale <= MAX_DEVICE -} - -/// A render context on the calling thread. vello_cpu's `RenderContext::new` defaults to a -/// multithreaded dispatcher, which panics on filter effects (glows, shadows, blur). -pub(crate) fn single_threaded_context(w: u16, h: u16) -> RenderContext { - RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }) -} - -/// Exactly one visible painted item, a fill with its own Normal blend and full opacity (one -/// paint operation, so an object blend mode can be applied per draw). -fn single_plain_fill(n: &Node) -> bool { - let mut painted = n.appearance.items.iter().filter(|i| match i { - AppearanceItem::Fill(f) => f.visible && !f.paint.is_none(), - AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0, - }); - matches!((painted.next(), painted.next()), (Some(AppearanceItem::Fill(f)), None) if f.blend == vectorcraft_color::BlendMode::Normal && f.opacity >= 1.0 && !matches!(f.paint, vectorcraft_color::Paint::Pattern { .. })) -} - -/// Number of visible painted fill/stroke items (opacity folding is exact only for one). A dashed -/// stroke is one paint: its dashes are outlined into one path and filled once, so overlapping -/// dashes don't darken where they overlap. A dashed brush stroke draws its pieces one by one, so -/// it takes the layered path, with or without other paints. -fn painted_items(n: &Node) -> usize { - n.appearance - .items - .iter() - .filter(|i| match i { - AppearanceItem::Fill(f) => f.visible && !f.paint.is_none() && matches!(f.paint, vectorcraft_color::Paint::Solid { .. }), - AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0 && matches!(s.paint, vectorcraft_color::Paint::Solid { .. }), - }) - .count() - .max(if n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.visible && !matches!(f.paint, vectorcraft_color::Paint::Solid { .. } | vectorcraft_color::Paint::None)) || matches!(i, AppearanceItem::Stroke(s) if s.visible && (!matches!(s.paint, vectorcraft_color::Paint::Solid { .. } | vectorcraft_color::Paint::None) || (s.dash.is_some() && s.brush.is_some() && !s.paint.is_none())))) { 2 } else { 0 }) -} - -/// Whether `st` is one of `n`'s own strokes (the strokes of the brush art drawn for `n` are not). -fn owns_stroke(n: &Node, st: &StrokeLayer) -> bool { - n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if std::ptr::eq(s, st))) -} - -/// Symbol art `art` stained by instance `inst` ([`instance_art`]) and placed by `xf`, with -/// strokes scaled by `xf` when `scale_strokes`. -fn place(art: &Node, xf: Affine, scale_strokes: bool, inst: Option<&Node>) -> Arc { - let mut placed = match inst { - Some(i) => brush_fx::instance_art(art, i), - None => art.clone(), - }; - placed.transform(xf, scale_strokes); - Arc::new(placed) -} - -/// Does `n` hold, in it or in the opacity masks of the objects in it, a symbol instance, type -/// with an inline graphic, an image, a placed document, or an object or a fill or stroke with a -/// visible raster effect? -fn depends_on_document(n: &Node) -> bool { - let raster = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible && effects::is_raster(&e.id)); - let mut depends = false; - n.walk(&mut |c| { - depends |= match &c.kind { - NodeKind::SymbolInstance { .. } | NodeKind::Image(_) | NodeKind::PlacedDocument(_) => true, - NodeKind::Text(t) => t.runs.iter().any(|r| r.inline.is_some()), - _ => false, - } || raster(&c.appearance.effects) - || c.appearance.items.iter().any(|i| raster(i.effects())) - || c.extras().mask.as_ref().is_some_and(|m| depends_on_document(&m.art)); - }); - depends -} - -/// Does `n` hold type, in it or in the opacity masks of the objects in it? -fn holds_type(n: &Node) -> bool { - let mut holds = false; - n.walk(&mut |c| holds |= matches!(c.kind, NodeKind::Text(_)) || c.extras().mask.as_ref().is_some_and(|m| holds_type(&m.art))); - holds -} - -/// Is `n` a symbol instance, or does it hold one? Its cull bounds measure the instance by its -/// symbol's art. -fn holds_instance(n: &Node) -> bool { - let mut holds = false; - n.walk(&mut |c| holds |= matches!(c.kind, NodeKind::SymbolInstance { .. })); - holds -} - -/// Does `n` hold an object with a visible plug-in effect ([`effects::has_plugin_effect`]), in it or -/// in the opacity masks of the objects in it? -fn holds_plugin_effect(n: &Node) -> bool { - let mut holds = false; - n.walk(&mut |c| holds |= effects::has_plugin_effect(c) || c.extras().mask.as_ref().is_some_and(|m| holds_plugin_effect(&m.art))); - holds -} - -/// A symbol instance's stain ([`vectorcraft_brush::stain`]) as bits for a cache key: its color -/// model, the color's components and the amount (all 0 without a stain). -fn stain_bits(stain: Option<(vectorcraft_color::Color, f32)>) -> [u32; 6] { - use vectorcraft_color::Color; - let Some((color, amount)) = stain else { return [0; 6] }; - let (model, [p, q, r, s]) = match color { - Color::Rgb { r, g, b } => (1, [r, g, b, 0.0]), - Color::Cmyk { c, m, y, k } => (2, [c, m, y, k]), - Color::Gray { k } => (3, [k, 0.0, 0.0, 0.0]), - Color::Lab { l, a, b } => (4, [l, a, b, 0.0]), - }; - [model, p.to_bits(), q.to_bits(), r.to_bits(), s.to_bits(), amount.to_bits()] -} - -/// Preferred rasterizer thread count set by the app (Preferences → Performance); negative = automatic. -static THREADS_OVERRIDE: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1); - -/// Most dashes a dotted guide is drawn with (1.5 on, 2.5 off on screen); a longer one is solid. -const MAX_GUIDE_DASHES: f64 = 100_000.0; - -/// The length of `p`'s control polygon: at least its own length (infinite when a point is). -fn polygon_length(p: &BezPath) -> f64 { - let (mut last, mut len): (Option, f64) = (None, 0.0); - for el in p.elements() { - let points = match *el { - kurbo::PathEl::MoveTo(q) => { - last = Some(q); - continue; - } - kurbo::PathEl::LineTo(q) => [Some(q), None, None], - kurbo::PathEl::QuadTo(a, b) => [Some(a), Some(b), None], - kurbo::PathEl::CurveTo(a, b, c) => [Some(a), Some(b), Some(c)], - kurbo::PathEl::ClosePath => continue, - }; - for q in points.into_iter().flatten() { - if let Some(l) = last { - len += l.distance(q); - } - last = Some(q); - } - } - if len.is_nan() { f64::INFINITY } else { len } -} - -/// Override the worker-thread count used by renderers created from now on (`None` = automatic). -pub fn set_default_threads(n: Option) { - THREADS_OVERRIDE.store(n.map_or(-1, i32::from), std::sync::atomic::Ordering::Relaxed); -} - -/// Rasterizer worker threads: 0 on wasm; otherwise one per core up to 8, leaving a core free. On a -/// 10-core machine 8 workers render a 50,000-path page 1.4× faster than 4 (9 are slower again); -/// the pixels don't depend on the count (`pixels_do_not_depend_on_the_thread_count`). -pub fn default_threads() -> u16 { - #[cfg(target_arch = "wasm32")] - { - 0 - } - #[cfg(not(target_arch = "wasm32"))] - { - let o = THREADS_OVERRIDE.load(std::sync::atomic::Ordering::Relaxed); - if o >= 0 { - return o.min(64) as u16; - } - if std::env::var_os("VECTORCRAFT_RENDER_THREADS").is_some() { - return std::env::var("VECTORCRAFT_RENDER_THREADS").ok().and_then(|v| v.parse().ok()).unwrap_or(0); - } - std::thread::available_parallelism().map(|n| (n.get().saturating_sub(1)).min(8) as u16).unwrap_or(0) - } -} - -/// The whole text's glyph outlines as one path, and which of them each run draws. -struct TextGeom { - all: BezPath, - /// Spans of `all`'s elements drawn by a run, in order: (run, start, end). Outlines of runs - /// the text doesn't have are in `all` only. - spans: Vec<(usize, usize, usize)>, - bounds: Rect, - /// The boxes of characters whose font is missing (drawn in a substitute), and of glyphs the - /// font lacks (drawn from a fallback font): Highlight Substituted Fonts and Highlight - /// Substituted Glyphs. - substituted_fonts: BezPath, - substituted_glyphs: BezPath, - /// Glyphs OpenType may have substituted (a ligature, or not the face's cmap glyph for their - /// character), each with its box: what Preferences › Highlight Alternate Glyphs weighs - /// ([`TextGeom::alternate_glyphs`]). - alternate_candidates: Vec, - /// The boxes of the candidates that are alternates, measured the first time the preference - /// shows them. - alternate_glyphs: std::sync::OnceLock, - /// Inline graphics placed by the layout. - inlines: Vec, - /// Colour bitmap glyphs: (run, image, its pixels → text space), in text order. - images: Vec<(usize, Arc, Affine)>, - /// COLR colour glyphs: (run, their layers placed in text space), in text order. - colours: Vec<(usize, Arc)>, -} - -impl TextGeom { - /// The outlines run `i` draws: the glyphs of `all` it has, in their order. Kept once rather - /// than per run as well, which held every outline twice. - fn run(&self, i: usize) -> std::borrow::Cow<'_, BezPath> { - let els = self.all.elements(); - if let [(r, 0, end)] = self.spans[..] - && r == i - && end == els.len() - { - return std::borrow::Cow::Borrowed(&self.all); - } - let mut p = BezPath::new(); - for &(_, start, end) in self.spans.iter().filter(|s| s.0 == i) { - p.extend(els.get(start..end).unwrap_or_default().iter().copied()); - } - std::borrow::Cow::Owned(p) - } - - /// The boxes of the glyphs of `t` (the text this geometry is of) that optional OpenType features - /// chose (Preferences › Highlight Alternate Glyphs): candidates the text's required shaping - /// alone doesn't give ([`required_shaping`]). Arabic letters joining, Indic conjuncts and the - /// lam-alef ligature are what the script needs, not alternates. Measured once. - fn alternate_glyphs(&self, t: &TextObject) -> &BezPath { - self.alternate_glyphs.get_or_init(|| { - let mut out = BezPath::new(); - if self.alternate_candidates.is_empty() { - return out; - } - let required = required_shaping(t); - for c in &self.alternate_candidates { - if !required.get(&(c.byte, c.len)).is_some_and(|glyphs| glyphs.contains(&(c.font_id, c.gid))) { - out.extend(c.cell.iter()); - } - } - out - }) - } - - /// Approximate heap bytes, for the cache's budget. - fn bytes(&self) -> usize { - size_of::() - + [&self.all, &self.substituted_fonts, &self.substituted_glyphs].into_iter().map(lru::path_bytes).sum::() - + self.alternate_candidates.iter().map(|c| size_of::() + lru::path_bytes(&c.cell)).sum::() - + self.alternate_glyphs.get().map_or(0, lru::path_bytes) - + self.spans.len() * size_of::<(usize, usize, usize)>() - + self.inlines.len() * size_of::() - + self.images.len() * size_of::<(usize, Arc, Affine)>() - + self.colours.iter().map(|(_, art)| lru::node_bytes(art)).sum::() - } -} - -/// OpenType-substituted form: a ligature (several source characters → one glyph) or a glyph whose -/// id is not the face's cmap mapping for its first character (contextual / stylistic alternate). -pub(crate) fn is_opentype_alternate(nominal_gid: u32, shaped_gid: u32, cluster_chars: usize) -> bool { - shaped_gid != 0 && (cluster_chars > 1 || shaped_gid != nominal_gid) -} - -/// A glyph [`is_opentype_alternate`] marks, in its cluster of the text (`byte`, `len`), with its -/// box. -#[derive(Debug)] -struct AlternateCandidate { - byte: usize, - len: usize, - font_id: u32, - gid: u32, - cell: BezPath, -} - -/// The glyphs `t` is shaped to by required shaping alone, by cluster (`byte`, `len`) → `(font, -/// glyph)`: laid out again with every optional feature off (standard and contextual ligatures and -/// alternates, and the features its characters turn on: stylistic sets, swashes, discretionary -/// ligatures, figure styles…). What is left is what the script needs: `ccmp`, `locl`, `rlig`, -/// Arabic's `init`, `medi`, `fina` and `isol`, the Indic and other complex scripts' required sets. -fn required_shaping(t: &TextObject) -> std::collections::HashMap<(usize, usize), Vec<(u32, u32)>> { - let mut plain = t.clone(); - for run in &mut plain.runs { - run.style.features = vec!["-liga".into(), "-calt".into()]; - } - let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &plain); - let mut glyphs: std::collections::HashMap<(usize, usize), Vec<(u32, u32)>> = std::collections::HashMap::new(); - for g in &layout.glyphs { - glyphs.entry((g.byte, g.len)).or_default().push((g.font_id, g.gid)); - } - glyphs -} - -fn text_geom(t: &TextObject) -> TextGeom { - text_geom_snapped(t, None) -} - -/// Glyph geometry of `t`; `snap` (text space → device pixels) puts the glyphs on whole pixels. -fn text_geom_snapped(t: &TextObject, snap: Option) -> TextGeom { - let db = vectorcraft_text::FontDb::global(); - let mut layout = vectorcraft_text::layout(db, t); - if let Some(xf) = snap { - layout.snap_to_pixels(xf); - } - let mut all = BezPath::new(); - let mut spans: Vec<(usize, usize, usize)> = vec![]; - // Append `outline` to `all`, drawn by `run` when the text has that run. - let mut push = |all: &mut BezPath, run: usize, outline: &BezPath| { - let start = all.elements().len(); - all.extend(outline.iter()); - let end = all.elements().len(); - if run >= t.runs.len() || start == end { - return; - } - match spans.last_mut() { - Some((r, _, e)) if *r == run && *e == start => *e = end, - _ => spans.push((run, start, end)), - } - }; - // Per run: is its family missing, and the face its glyphs should come from. - let faces: Vec<(bool, Option)> = t - .runs - .iter() - .map(|r| match db.resolve(&r.style.font_family, &r.style.font_style) { - // The version the type names, when it's installed (see `FontDb::face_version`), at its - // Variable Font settings (see `FontDb::style_face`). - Some((f, m)) => { - let face = db.style_face(&r.style).unwrap_or(f); - (m == vectorcraft_text::FontMatch::Missing, Some(face.id())) - } - None => (true, None), - }) - .collect(); - let (mut substituted_fonts, mut substituted_glyphs, mut alternate_candidates) = (BezPath::new(), BezPath::new(), vec![]); - let plain = t.plain_text(); - let (mut images, mut colours) = (vec![], vec![]); - for g in &layout.glyphs { - push(&mut all, g.run, &g.outline); - if let Some((image, xf)) = g.placed_image() { - images.push((g.run, image.clone(), xf)); - } - if let Some(art) = g.placed_colour(Affine::IDENTITY) { - colours.push((g.run, Arc::new(art))); - } - let Some(&(missing, face)) = faces.get(g.run) else { continue }; - let Some(line) = layout.lines.get(g.line) else { continue }; - let mut cell = Rect::new(g.origin.x, g.origin.y - line.ascent, g.origin.x + g.advance, g.origin.y + line.descent).to_path(0.1); - cell.apply_affine(Affine::rotate_about(g.angle, g.origin)); - if missing { - substituted_fonts.extend(cell.iter()); - continue; - } - if face.is_some_and(|f| f != g.font_id) { - substituted_glyphs.extend(cell.iter()); - continue; - } - // Same face: OpenType may have swapped the glyph (liga, calt, …). - let Some(cluster) = plain.get(g.byte..g.byte.saturating_add(g.len)) else { continue }; - let cluster_chars = cluster.chars().count(); - let Some(first) = cluster.chars().next() else { continue }; - let nominal = db.face_by_id(g.font_id).map(|f| f.glyph_for(first)).unwrap_or(0); - if is_opentype_alternate(nominal, g.gid, cluster_chars) { - alternate_candidates.push(AlternateCandidate { byte: g.byte, len: g.len, font_id: g.font_id, gid: g.gid, cell }); - } - } - // Ruby (furigana) glyphs: painted with their base text's run. - for g in &layout.ruby { - push(&mut all, g.run, &g.outline); - } - // Underline and strikethrough bars, painted as their run's type is (#847). - for (run, bar) in vectorcraft_text::decorations(&layout, db, t) { - push(&mut all, run, &bar); - } - TextGeom { - all, - spans, - bounds: layout.bounds, - substituted_fonts, - substituted_glyphs, - alternate_candidates, - alternate_glyphs: std::sync::OnceLock::new(), - inlines: layout.inlines, - images, - colours, - } -} - -/// The substitution highlights' pink: Preferences › Highlight Substituted Fonts and Highlight -/// Alternate Glyphs, and Document Setup's Highlight Substituted Glyphs (screen only). -const SUBSTITUTED: peniko::Color = peniko::Color::from_rgba8(255, 120, 190, 110); -/// The pixel size of `region` rendered at `scale` pixels per point (at least 1×1). -pub fn region_pixels(region: Rect, scale: f64) -> (u32, u32) { - ((region.width() * scale).round().max(1.0) as u32, (region.height() * scale).round().max(1.0) as u32) -} - -fn rects_overlap(a: Rect, b: Rect) -> bool { - a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 -} - -fn fill_rule(r: FillRule) -> peniko::Fill { - match r { - FillRule::NonZero => peniko::Fill::NonZero, - FillRule::EvenOdd => peniko::Fill::EvenOdd, - } -} - -pub(crate) fn blend_mode(b: vectorcraft_color::BlendMode) -> BlendMode { - use vectorcraft_color::BlendMode as B; - let mix = match b { - B::Normal => Mix::Normal, - B::Darken => Mix::Darken, - B::Multiply => Mix::Multiply, - B::ColorBurn => Mix::ColorBurn, - B::Lighten => Mix::Lighten, - B::Screen => Mix::Screen, - B::ColorDodge => Mix::ColorDodge, - B::Overlay => Mix::Overlay, - B::SoftLight => Mix::SoftLight, - B::HardLight => Mix::HardLight, - B::Difference => Mix::Difference, - B::Exclusion => Mix::Exclusion, - B::Hue => Mix::Hue, - B::Saturation => Mix::Saturation, - B::Color => Mix::Color, - B::Luminosity => Mix::Luminosity, - }; - BlendMode::new(mix, Compose::SrcOver) -} - -#[cfg(not(target_arch = "wasm32"))] -fn now() -> u64 { - use std::time::{SystemTime, UNIX_EPOCH}; - SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_micros() as u64).unwrap_or(0) -} -#[cfg(target_arch = "wasm32")] -fn now() -> u64 { - 0 -} - -#[cfg(test)] -thread_local! { - /// The pixels of the offscreen contexts [`Frame::offscreen_context`] has made on this thread. - static OFFSCREEN_PIXELS: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - #[cfg(test)] use tests::fontchange as tests_fontchange; + +mod blobs; +mod bounds; +mod clock; +mod frame; +mod geometry; +mod node_checks; +mod options; +mod renderer; +mod text_geom; +mod threads; + +pub use bounds::{drawn_bounds, drawn_outline, effect_bounds, painted_bounds}; +pub use frame::FrameStats; +use frame::{ClipEntry, ClipPaintEntry, Frame, FxArtEntry, GeomEntry, PlacedEntry, PlacedKey, PtrHasher, PtrMap, SliceEntry, StrokeEntry, StrokeKey}; +#[cfg(test)] +use frame::{OFFSCREEN_PIXELS, Slices}; +pub use geometry::region_pixels; +use node_checks::holds_plugin_effect; +#[cfg(test)] +use node_checks::painted_items; +pub use options::{AntiAlias, ISOLATION_DIM, MAX_RASTER_PIXELS, MAX_RASTER_SIDE, RenderOptions, Rendered, raster_size, supersample_factor}; +pub(crate) use paint::blend_mode; +use paint::fill_rule; +use text_geom::TextGeom; +#[cfg(test)] +pub(crate) use text_geom::is_opentype_alternate; +#[cfg(test)] +use text_geom::text_geom; +pub(crate) use threads::single_threaded_context; +pub use threads::{default_threads, set_default_threads}; #[cfg(test)] mod tests; diff --git a/crates/render/src/node_checks.rs b/crates/render/src/node_checks.rs new file mode 100644 index 000000000..a85854443 --- /dev/null +++ b/crates/render/src/node_checks.rs @@ -0,0 +1,113 @@ +use super::*; + +/// Most pixels from the origin that a shape handed to the rasterizer may reach, or a stroke be +/// wide. A document's art stays within 4 million points, 2.6 billion pixels at the closest zoom. +/// Geometry far beyond that (from a malformed file) makes vello_cpu panic on a worker thread or ask +/// for more memory than there is, either of which ends the app: it is left undrawn. +const MAX_DEVICE: f64 = 1e10; + +/// Can shape `bp` of `n` be drawn through `view`: its control points (finite) within +/// [`MAX_DEVICE`] pixels, and its visible strokes no wider? +pub(crate) fn drawable(bp: &BezPath, view: Affine, n: &Node) -> bool { + let b = view.transform_rect_bbox(bp.control_box()); + let scale = view.determinant().abs().sqrt(); + let widest = n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(st) = i { Some(st.width) } else { None }).fold(0.0, f64::max); + [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_DEVICE) && (widest * scale).is_finite() && widest * scale <= MAX_DEVICE +} + +/// Exactly one visible painted item, a fill with its own Normal blend and full opacity (one +/// paint operation, so an object blend mode can be applied per draw). +pub(crate) fn single_plain_fill(n: &Node) -> bool { + let mut painted = n.appearance.items.iter().filter(|i| match i { + AppearanceItem::Fill(f) => f.visible && !f.paint.is_none(), + AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0, + }); + matches!((painted.next(), painted.next()), (Some(AppearanceItem::Fill(f)), None) if f.blend == vectorcraft_color::BlendMode::Normal && f.opacity >= 1.0 && !matches!(f.paint, vectorcraft_color::Paint::Pattern { .. })) +} + +/// Number of visible painted fill/stroke items (opacity folding is exact only for one). A dashed +/// stroke is one paint: its dashes are outlined into one path and filled once, so overlapping +/// dashes don't darken where they overlap. A dashed brush stroke draws its pieces one by one, so +/// it takes the layered path, with or without other paints. +pub(crate) fn painted_items(n: &Node) -> usize { + n.appearance + .items + .iter() + .filter(|i| match i { + AppearanceItem::Fill(f) => f.visible && !f.paint.is_none() && matches!(f.paint, vectorcraft_color::Paint::Solid { .. }), + AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0 && matches!(s.paint, vectorcraft_color::Paint::Solid { .. }), + }) + .count() + .max(if n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.visible && !matches!(f.paint, vectorcraft_color::Paint::Solid { .. } | vectorcraft_color::Paint::None)) || matches!(i, AppearanceItem::Stroke(s) if s.visible && (!matches!(s.paint, vectorcraft_color::Paint::Solid { .. } | vectorcraft_color::Paint::None) || (s.dash.is_some() && s.brush.is_some() && !s.paint.is_none())))) { 2 } else { 0 }) +} + +/// Whether `st` is one of `n`'s own strokes (the strokes of the brush art drawn for `n` are not). +pub(crate) fn owns_stroke(n: &Node, st: &StrokeLayer) -> bool { + n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if std::ptr::eq(s, st))) +} + +/// Symbol art `art` stained by instance `inst` ([`instance_art`]) and placed by `xf`, with +/// strokes scaled by `xf` when `scale_strokes`. +pub(crate) fn place(art: &Node, xf: Affine, scale_strokes: bool, inst: Option<&Node>) -> Arc { + let mut placed = match inst { + Some(i) => brush_fx::instance_art(art, i), + None => art.clone(), + }; + placed.transform(xf, scale_strokes); + Arc::new(placed) +} + +/// Does `n` hold, in it or in the opacity masks of the objects in it, a symbol instance, type +/// with an inline graphic, an image, a placed document, or an object or a fill or stroke with a +/// visible raster effect? +pub(crate) fn depends_on_document(n: &Node) -> bool { + let raster = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible && effects::is_raster(&e.id)); + let mut depends = false; + n.walk(&mut |c| { + depends |= match &c.kind { + NodeKind::SymbolInstance { .. } | NodeKind::Image(_) | NodeKind::PlacedDocument(_) => true, + NodeKind::Text(t) => t.runs.iter().any(|r| r.inline.is_some()), + _ => false, + } || raster(&c.appearance.effects) + || c.appearance.items.iter().any(|i| raster(i.effects())) + || c.extras().mask.as_ref().is_some_and(|m| depends_on_document(&m.art)); + }); + depends +} + +/// Does `n` hold type, in it or in the opacity masks of the objects in it? +pub(crate) fn holds_type(n: &Node) -> bool { + let mut holds = false; + n.walk(&mut |c| holds |= matches!(c.kind, NodeKind::Text(_)) || c.extras().mask.as_ref().is_some_and(|m| holds_type(&m.art))); + holds +} + +/// Is `n` a symbol instance, or does it hold one? Its cull bounds measure the instance by its +/// symbol's art. +pub(crate) fn holds_instance(n: &Node) -> bool { + let mut holds = false; + n.walk(&mut |c| holds |= matches!(c.kind, NodeKind::SymbolInstance { .. })); + holds +} + +/// Does `n` hold an object with a visible plug-in effect ([`effects::has_plugin_effect`]), in it or +/// in the opacity masks of the objects in it? +pub(crate) fn holds_plugin_effect(n: &Node) -> bool { + let mut holds = false; + n.walk(&mut |c| holds |= effects::has_plugin_effect(c) || c.extras().mask.as_ref().is_some_and(|m| holds_plugin_effect(&m.art))); + holds +} + +/// A symbol instance's stain ([`vectorcraft_brush::stain`]) as bits for a cache key: its color +/// model, the color's components and the amount (all 0 without a stain). +pub(crate) fn stain_bits(stain: Option<(vectorcraft_color::Color, f32)>) -> [u32; 6] { + use vectorcraft_color::Color; + let Some((color, amount)) = stain else { return [0; 6] }; + let (model, [p, q, r, s]) = match color { + Color::Rgb { r, g, b } => (1, [r, g, b, 0.0]), + Color::Cmyk { c, m, y, k } => (2, [c, m, y, k]), + Color::Gray { k } => (3, [k, 0.0, 0.0, 0.0]), + Color::Lab { l, a, b } => (4, [l, a, b, 0.0]), + }; + [model, p.to_bits(), q.to_bits(), r.to_bits(), s.to_bits(), amount.to_bits()] +} diff --git a/crates/render/src/options.rs b/crates/render/src/options.rs new file mode 100644 index 000000000..7aaf365b2 --- /dev/null +++ b/crates/render/src/options.rs @@ -0,0 +1,303 @@ +use super::geometry::region_pixels; +use super::*; + +/// Largest raster an export may ask [`Renderer::render_region`] for, per side. The CPU rasteriser +/// addresses at most `u16::MAX` pixels per side (and panics at that edge). +pub const MAX_RASTER_SIDE: u32 = 32_768; + +/// Largest side of a render context the renderer makes. The CPU rasteriser keeps pixel positions +/// in `u16` and overflows near `u16::MAX` (its tile and bucket arithmetic rounds past it), so +/// contexts stay within the side raster exports are limited to. +pub(crate) const MAX_CONTEXT_SIDE: u32 = MAX_RASTER_SIDE; + +/// Largest raster an export may ask for in total (16384², 1 GiB of RGBA pixels). +pub const MAX_RASTER_PIXELS: u64 = 1 << 28; + +/// Pixel size of `region` rendered at `scale` (as [`Renderer::render_region`] rounds it), or an error +/// when it exceeds [`MAX_RASTER_SIDE`] / [`MAX_RASTER_PIXELS`]. Raster exports check this first, +/// because the renderer can only clamp (wrong picture) or fail (abort) at those sizes. +pub fn raster_size(region: Rect, scale: f64) -> Result<(u32, u32), String> { + if !(scale.is_finite() && scale > 0.0) { + return Err(format!("invalid scale {scale}")); + } + let (w, h) = region_pixels(region, scale); + if !(w <= MAX_RASTER_SIDE && h <= MAX_RASTER_SIDE && u64::from(w) * u64::from(h) <= MAX_RASTER_PIXELS) { + return Err(format!( + "the image would be {w} × {h} pixels; raster exports are limited to {MAX_RASTER_SIDE} pixels per side and {} megapixels: lower the scale or resolution", + MAX_RASTER_PIXELS / 1_000_000 + )); + } + Ok((w, h)) +} + +/// Rendering options. +#[derive(Clone, Debug, PartialEq)] +pub struct RenderOptions { + /// Outline (wireframe) view: 1 px black paths, no paint. + pub outline: bool, + /// Pasteboard colour behind everything (premultiplied RGBA8); `None` = transparent. + pub background: Option<[u8; 4]>, + /// Draw artboards as white rectangles (screen view). Export sets this to false. + pub artboards: bool, + /// Objects not to draw (e.g. the object being edited by a live drag preview). + pub hidden: Vec, + /// Draw the layers as their Layer Options show them on screen: Preview off in outline, Dim + /// Images fading their images (a template layer's too), a template in Preview in Outline view. + pub dim_templates: bool, + /// Soft proof / separations preview (see [`proof`]). + pub proof: Option, + /// Overprint Preview (see [`proof`] for what overprints). + pub overprint_preview: bool, + /// Trim View: clip the artwork to the artboards (nothing on the pasteboard is drawn). + pub trim: bool, + /// Leave template layers and guides out (exports and thumbnails: they're aids to drawing, not + /// artwork). + pub skip_templates: bool, + /// View › Hide Template: template layers left out of the screen view (guides stay). + pub hide_templates: bool, + /// View › Hide Guides: paths made into guides left out of the screen view. Exports use + /// `skip_templates`, which independently leaves both templates and guides out. + pub hide_guides: bool, + /// Pattern editing mode's tile edge and swatch bounds colour (Object → Pattern → Tile Edge + /// Color), RGB. + pub tile_edge: [u8; 3], + /// Colour and style of the paths made into guides (Preferences › Guides & Grid, as the ruler + /// guides the UI draws): RGB, and whether they are dotted. + pub guide: ([u8; 3], bool), + /// View Opacity Mask (Alt-click the mask thumbnail): instead of the artwork, show this object's + /// opacity mask alone as its coverage in greyscale (white = opaque, black = transparent). + pub mask_view: Option, + /// Screen view: highlight substituted fonts and glyphs, as Preferences › Type and Document + /// Setup ask. + pub highlight_substitutions: bool, + /// Screen view: Preferences › Type › Highlight Substituted Fonts. Pink behind text drawn in a + /// substitute for a missing font, in every document (on by default). + pub highlight_substituted_fonts: bool, + /// Screen view: Preferences › Type › Highlight Alternate Glyphs. Pink behind glyphs OpenType + /// features substituted (ligatures, contextual forms), not Document Setup's missing-glyph + /// highlights. + pub highlight_alternate_glyphs: bool, + /// Edge smoothing (raster export option). + pub anti_alias: AntiAlias, + /// Screen view: placed documents draw from cached bitmaps made in the background (see + /// [`placed_document`]); off, they draw exactly, read when needed. + pub progressive_placed: bool, + /// Screen view: Image Trace objects draw as their View asks (outlines, the source image…); + /// off, they draw their tracing result, as exports and printing do. + pub trace_views: bool, + /// Screen view with `trace_views` on: the Image Trace objects drawn with the Source Image view + /// for this render only, whatever View they have (the Image Trace panel's eye button, while it + /// is held down). + pub trace_source: Option>>, + /// Images are sampled smoothly when scaled or rotated; off, each pixel takes its nearest image + /// pixel (Pixel Preview with File Handling › Display Bitmaps as Anti-aliased Images off). + pub smooth_images: bool, + /// Screen view in isolation mode: the isolated group or layer. Everything around it draws + /// dimmed ([`ISOLATION_DIM`]). + pub isolated: Option, + /// Composite in floating point (exports): translucent art over opaque art comes out exactly + /// opaque, where 8-bit compositing leaves alpha 254 on some anti-aliased edges (#787). Off, + /// the faster 8-bit pipeline (the canvas). + pub precise: bool, + /// Exports: Art Optimized edges are supersampled ([`supersample_factor`]): the art is drawn + /// with hard edges at several times the size and averaged down, so shapes that meet edge to + /// edge leave no seam of the background between them (#983). Off, edges are smoothed shape by + /// shape (the canvas). + pub supersample: bool, + /// Screen views: an image drawn at half its size or smaller samples a copy shrunk by halves + /// (box-filtered from the full image, kept with the decoded images), so the full image needn't + /// stay in memory or be sampled. Off, images always sample their full pixels (exports). + pub image_mips: bool, + /// Screen views: an image whose pixels can't be decoded draws as a light gray box crossed + /// from corner to corner. Off, it draws nothing (exports, which report it instead). + pub mark_undecoded_images: bool, + /// Screen views while an interaction is in progress (a drag, a dialog's preview): an object + /// whose content only moved since an earlier frame draws its cached drop shadows, outer glows + /// and filtered content from the rasters made then, at the nearest whole pixel, without + /// running the effects again (see `fx`). Off, no raster made that way, or made over one, is + /// drawn: the effects run again. + pub interacting: bool, + /// Screen views during a tool's drag on the canvas: the renderer draws filtered content within + /// `fx::DRAG_PIXEL_BUDGET` pixels, at a lower resolution where it would need more, unless it + /// can draw the content from a raster kept from an earlier frame. With [`Renderer::threads`] + /// above 0 it does the same for drop shadows, outer glows and inner glows. No raster made that + /// way is kept (see `fx`). Off, they render as in a still frame. + pub dragging: bool, + /// 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, +} + +/// The opacity of the art around an isolated group or layer. +pub const ISOLATION_DIM: f32 = 0.5; + +/// How edges are rasterized (raster export option). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum AntiAlias { + /// Hard edges: a pixel is painted when a path, text or image covers at least half of it. + /// Raster effects (blur, shadows, glows) and pattern tiles stay smooth. + None, + /// Smooth edges everywhere. + #[default] + Art, + /// Smooth edges with type snapped to the pixel grid (see `TextLayout::snap_to_pixels`): + /// crisper small text. + Type, +} + +impl AntiAlias { + pub const ALL: [Self; 3] = [Self::None, Self::Art, Self::Type]; + + /// The id used in command params (`none`, `art`, `type`). + pub fn id(self) -> &'static str { + match self { + Self::None => "none", + Self::Art => "art", + Self::Type => "type", + } + } + + /// The mode an id names (any case). + pub fn from_id(id: &str) -> Option { + Self::ALL.into_iter().find(|a| a.id().eq_ignore_ascii_case(id)) + } + + /// The name shown in options dialogs. + pub fn label(self) -> &'static str { + match self { + Self::None => "None", + Self::Art => "Art Optimized", + Self::Type => "Type Optimized", + } + } + + /// vello's aliasing threshold: paint a pixel only above half coverage when edges are hard. + pub(crate) fn threshold(self) -> Option { + (self == Self::None).then_some(127) + } +} + +impl Default for RenderOptions { + fn default() -> Self { + Self { + outline: false, + background: None, + artboards: false, + hidden: vec![], + dim_templates: true, + proof: None, + overprint_preview: false, + trim: false, + skip_templates: false, + hide_templates: false, + hide_guides: false, + tile_edge: vectorcraft_doc::LAYER_COLORS[0].1, + guide: ([0x4a, 0xd8, 0xff], false), + mask_view: None, + highlight_substitutions: false, + highlight_substituted_fonts: true, + highlight_alternate_glyphs: false, + anti_alias: AntiAlias::Art, + progressive_placed: false, + trace_views: false, + trace_source: None, + smooth_images: true, + isolated: None, + precise: false, + supersample: false, + image_mips: false, + mark_undecoded_images: false, + interacting: false, + dragging: false, + rich_black: false, + } + } +} + +/// The most pixels a supersampled render draws (the frame and its compositing buffers hold a few +/// times this many bytes). +const MAX_SUPERSAMPLED_PIXELS: u64 = 40_000_000; + +/// How many times wider and taller a `w`×`h` render is drawn when supersampled: 4, or less where +/// that would pass [`MAX_SUPERSAMPLED_PIXELS`] or the renderer's 65,535-pixel side; `None` when not +/// even twice fits (the edges are then smoothed shape by shape). +pub fn supersample_factor(w: u32, h: u32) -> Option { + (2..=4u32).rev().find(|k| { + let (sw, sh) = (u64::from(w) * u64::from(*k), u64::from(h) * u64::from(*k)); + sw <= u64::from(MAX_CONTEXT_SIDE) && sh <= u64::from(MAX_CONTEXT_SIDE) && sw * sh <= MAX_SUPERSAMPLED_PIXELS + }) +} + +/// Average each `k`×`k` block of `src` (`w`×`h` pixels of 4 premultiplied bytes) into one pixel. +pub(crate) fn downsample(src: &[u8], w: usize, h: usize, k: usize) -> Vec { + let (ow, oh) = (w / k.max(1), h / k.max(1)); + let n = (k * k).max(1) as u32; + let mut out = vec![0u8; ow * oh * 4]; + for (o, px) in out.as_chunks_mut::<4>().0.iter_mut().enumerate() { + let (ox, oy) = (o % ow.max(1), o / ow.max(1)); + let mut acc = [0u32; 4]; + for y in oy * k..(oy + 1) * k { + let row = y * w + ox * k; + for p in src.get(row * 4..(row + k) * 4).unwrap_or_default().as_chunks::<4>().0 { + for (a, v) in acc.iter_mut().zip(p) { + *a += u32::from(*v); + } + } + } + for (d, a) in px.iter_mut().zip(acc) { + *d = ((a + n / 2) / n).min(255) as u8; + } + } + out +} + +/// A rendered image (premultiplied RGBA8, row-major). +pub struct Rendered { + pub width: u32, + pub height: u32, + pub pixels: Vec, +} + +impl Rendered { + /// Un-premultiplied RGBA8 copy. + pub fn to_straight(&self) -> Vec { + let mut out = self.pixels.clone(); + for px in out.as_chunks_mut::<4>().0 { + let a = px[3] as u32; + if a != 0 && a != 255 { + for c in &mut px[..3] { + *c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8; + } + } + } + out + } + /// Encode as PNG without metadata (thumbnails, screenshots, embedded rasters; exports use + /// [`encode`]). + pub fn to_png(&self) -> Result, String> { + let mut buf = Vec::new(); + let img = image::RgbaImage::from_raw(self.width, self.height, self.to_straight()) + .ok_or("PNG encoding failed: pixel buffer doesn't match the image size")?; + img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).map_err(|e| format!("PNG encoding failed: {e}"))?; + Ok(buf) + } + /// Encode as JPEG (flattened on white) at `quality` 1..=100. + pub fn to_jpeg(&self, quality: u8) -> Result, String> { + encode::jpeg(self, quality, None) + } + /// Encode as lossless WebP. + pub fn to_webp(&self) -> Result, String> { + encode::webp(self) + } + /// Straight-alpha RGBA at (x, y). + pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] { + let i = ((y * self.width + x) * 4) as usize; + let p = &self.pixels[i..i + 4]; + let a = p[3] as u32; + if a == 0 { + return [0, 0, 0, 0]; + } + let un = |c: u8| ((c as u32 * 255 + a / 2) / a).min(255) as u8; + [un(p[0]), un(p[1]), un(p[2]), p[3]] + } +} diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 195a92c1a..5dfb6307c 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -5,6 +5,7 @@ use vectorcraft_geom::{Rect, Vec2}; use vello_cpu::RenderContext; use vello_cpu::peniko::{self, ColorStop}; +use super::*; use crate::Frame; /// Set the context paint (colours as frame `f` paints them). Returns false if nothing should be @@ -467,6 +468,40 @@ fn pixmap_of(img: image::RgbaImage) -> Option { Some(vello_cpu::Pixmap::from_parts(data, w as u16, h as u16)) } +/// The substitution highlights' pink: Preferences › Highlight Substituted Fonts and Highlight +/// Alternate Glyphs, and Document Setup's Highlight Substituted Glyphs (screen only). +pub(crate) const SUBSTITUTED: peniko::Color = peniko::Color::from_rgba8(255, 120, 190, 110); + +pub(crate) fn fill_rule(r: FillRule) -> peniko::Fill { + match r { + FillRule::NonZero => peniko::Fill::NonZero, + FillRule::EvenOdd => peniko::Fill::EvenOdd, + } +} + +pub(crate) fn blend_mode(b: vectorcraft_color::BlendMode) -> BlendMode { + use vectorcraft_color::BlendMode as B; + let mix = match b { + B::Normal => Mix::Normal, + B::Darken => Mix::Darken, + B::Multiply => Mix::Multiply, + B::ColorBurn => Mix::ColorBurn, + B::Lighten => Mix::Lighten, + B::Screen => Mix::Screen, + B::ColorDodge => Mix::ColorDodge, + B::Overlay => Mix::Overlay, + B::SoftLight => Mix::SoftLight, + B::HardLight => Mix::HardLight, + B::Difference => Mix::Difference, + B::Exclusion => Mix::Exclusion, + B::Hue => Mix::Hue, + B::Saturation => Mix::Saturation, + B::Color => Mix::Color, + B::Luminosity => Mix::Luminosity, + }; + BlendMode::new(mix, Compose::SrcOver) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs new file mode 100644 index 000000000..e713841b7 --- /dev/null +++ b/crates/render/src/renderer.rs @@ -0,0 +1,355 @@ +use super::blobs::{blob_key, blob_pixmap, image_mip, lookup, mask_value, mip_level, plain_image}; +use super::clock::now; +use super::frame::{ + ClipEntry, ClipPaintEntry, Frame, FrameStats, GeomEntry, MAX_INLINE_DEPTH, MISSING_INLINE_LOGGED, PlacedEntry, PlacedKey, PtrMap, SliceEntry, + Slices, StrokeEntry, +}; +use super::geometry::{MAX_GUIDE_DASHES, polygon_length, rects_overlap, region_pixels}; +use super::node_checks::{ + depends_on_document, drawable, holds_instance, holds_plugin_effect, holds_type, owns_stroke, painted_items, place, single_plain_fill, stain_bits, +}; +use super::options::{AntiAlias, ISOLATION_DIM, MAX_CONTEXT_SIDE, RenderOptions, Rendered, downsample, supersample_factor}; +use super::paint::{SUBSTITUTED, blend_mode, fill_rule}; +use super::text_geom::{TextGeom, text_geom, text_geom_snapped}; +use super::threads::{default_threads, single_threaded_context}; +use super::*; + +mod images; +mod nodes; +mod paint; +mod text; + +impl Renderer { + pub fn new() -> Self { + Self { + texts: FrameCache::new(lru::TEXT_BUDGET, 1024), + text_fonts: 0, + clips: FrameCache::new(lru::CLIP_BUDGET, 256), + threads: default_threads(), + geom: PtrMap::default(), + symbols: vec![], + plugin_revision: 0, + strokes: PtrMap::default(), + char_strokes: FrameCache::new(lru::CHAR_STROKE_BUDGET, 0), + cur: None, + made_again: false, + fx_owner: None, + live_root: None, + fx_geom: PtrMap::default(), + stamp: 0, + alpha: 1.0, + contexts: Vec::new(), + resources: Resources::new(), + stats: FrameStats::default(), + brushes: Default::default(), + shadows: PtrMap::default(), + pixel_fx: PtrMap::default(), + doc_inputs: None, + transparency: None, + live: live::LiveCache::default(), + knockout: false, + shape_of: 0, + fx_arts: FrameCache::new(lru::FX_ART_BUDGET, 256), + placed_arts: FrameCache::new(lru::PLACED_ART_BUDGET, 256), + placed_frame: None, + nested: 0, + backdrop: None, + blends: PtrMap::default(), + clip_paths: vec![], + picture: None, + offscreen_live: 0, + offscreen_budget: 0, + symbol_nodes: 0, + twice_drawn: 0, + clip_paints: FrameCache::new(lru::CLIP_BUDGET, 256), + stroke_slices: PtrMap::default(), + adjusts: HashMap::new(), + dim_images: None, + inline_depth: 0, + raster: vello_cpu::RasterizerSettings::default(), + isolation: None, + isolation_settled: false, + proof_art: None, + hairline: 1.0, + } + } + + /// Render `doc` into a `width`×`height` image using `view` (document → pixel transform). + pub fn render(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions) -> Rendered { + self.render_as(doc, width, height, view, opts, false) + } + + /// [`Self::render`], or with `inks` the ink amounts in the working CMYK space instead of + /// screen colours (4 bytes a pixel, see [`Self::render_region_inks`]). + fn render_as(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions, inks: bool) -> Rendered { + let start = now(); + if opts.supersample && opts.anti_alias == AntiAlias::Art && opts.mask_view.is_none() { + let (w, h) = (width.clamp(1, MAX_CONTEXT_SIDE), height.clamp(1, MAX_CONTEXT_SIDE)); + if let Some(k) = supersample_factor(w, h) { + let hard = RenderOptions { anti_alias: AntiAlias::None, supersample: false, ..opts.clone() }; + let hairline = std::mem::replace(&mut self.hairline, f64::from(k)); + let big = self.render_as(doc, w * k, h * k, Affine::scale(f64::from(k)) * view, &hard, inks); + self.hairline = hairline; + let pixels = downsample(&big.pixels, big.width as usize, big.height as usize, k as usize); + self.stats.micros = now().saturating_sub(start); + return Rendered { width: w, height: h, pixels }; + } + } + self.check_plugins(); + // The document's own symbols: proofing rewrites the colors and blend modes of their art. + self.check_symbols(doc); + let prepared = proof::prepare(doc, opts, &mut self.proof_art); + let doc: &Document = &prepared; + let w = width.clamp(1, MAX_CONTEXT_SIDE) as u16; + let h = height.clamp(1, MAX_CONTEXT_SIDE) as u16; + // Raster filters (drop shadow, glows, blur) are rendered offscreen on this thread when the + // main context is multithreaded (see `fx::with_filters`). + let threads = self.threads; + self.stats = FrameStats::default(); + 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 screen = if opts.rich_black { Ink::RichBlack } else { Ink::Display }; + let symbols = doc.symbol_boxes().quick(); + let frame = Frame { mt: threads > 0, doc, symbols: &symbols, view, visible, px, opts, ink: screen }; + self.begin_frame(); + // View Opacity Mask: the mask's coverage in greyscale instead of the artwork. + if let Some(m) = opts.mask_view.and_then(|id| doc.node(id)).and_then(|n| n.extras().mask.as_deref()) { + let pixels = self.mask_values(&frame, m, w, h).into_iter().flat_map(|v| [v, v, v, 255]).collect(); + self.end_frame(); + self.stats.micros = now().saturating_sub(start); + return Rendered { width: w as u32, height: h as u32, pixels }; + } + let mut pixels = if inks || self.blends_in_cmyk(doc, opts) { + // Blending in CMYK: one frame per ink plane, then their inks shown on screen. + let cmy = self.draw_frame(&Frame { ink: Ink::Cmy, ..frame }, w, h); + let stats = self.stats; + let k = self.draw_frame(&Frame { ink: Ink::K, ..frame }, w, h); + self.stats = stats; + if inks { + ink::amounts(&cmy, &k) + } else { + let (pixels, composed) = ink::compose(&cmy, &k, opts, threads); + self.stats.ink_threads = composed; + pixels + } + } else { + self.draw_frame(&frame, w, h) + }; + self.end_frame(); + if !inks { + proof::post(&mut pixels, opts); + } + self.stats.micros = now().saturating_sub(start); + Rendered { width: w as u32, height: h as u32, pixels } + } + + /// Start a frame: cache entries used from now on are marked with its stamp. + fn begin_frame(&mut self) { + self.stamp += 1; + self.inline_depth = 0; + // Nothing is drawn offscreen yet (even if an earlier frame stopped part way). + (self.offscreen_live, self.offscreen_budget) = (0, 0); + self.symbol_nodes = 0; + self.twice_drawn = 0; + self.placed_frame = None; + } + + /// End a frame: drop cache entries not used for a few frames (every render, thumbnails too, + /// so the nodes they hold go with them). + fn end_frame(&mut self) { + let g = self.stamp; + if self.geom.len() > 1024 { + self.geom.retain(|_, e| g - e.stamp <= 3); + } + if self.strokes.len() > 1024 { + self.strokes.retain(|_, e| g - e.stamp <= 3); + } + if self.stroke_slices.len() > 256 { + self.stroke_slices.retain(|_, e| g - e.stamp <= 3); + } + if self.shadows.len() > 256 { + self.shadows.retain(|_, e| g - e.stamp <= 3); + } + if self.fx_geom.len() > 256 { + self.fx_geom.retain(|_, e| g - e.stamp <= 3); + } + // Filtered rasters are large: only those of the last frame stay. + self.pixel_fx.retain(|_, e| e.stamp == g); + self.texts.sweep(g); + self.char_strokes.sweep(g); + self.clips.sweep(g); + self.clip_paints.sweep(g); + self.fx_arts.sweep(g); + self.placed_arts.sweep(g); + self.brushes.entries.sweep(g); + self.live.sweep(g); + } + + /// Draw frame `f` (`w`×`h` pixels): the background, the artboards and the page's art. + fn draw_frame(&mut self, f: &Frame, w: u16, h: u16) -> Vec { + let (threads, opts) = (self.threads, f.opts); + let kept = self.contexts.iter().position(|(t, c)| *t == threads && c.width() == w && c.height() == h); + let mut ctx = match kept.map(|i| self.contexts.remove(i)) { + Some((_, mut c)) => { + c.reset(); + c + } + None => RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: threads, ..Default::default() }), + }; + // `reset` keeps the threshold of the previous render: set it every time. + ctx.set_aliasing_threshold(opts.anti_alias.threshold()); + let render_mode = if opts.precise { vello_cpu::RenderMode::OptimizeQuality } else { vello_cpu::RenderMode::OptimizeSpeed }; + self.raster = vello_cpu::RasterizerSettings { render_mode, ..Default::default() }; + if let Some(bg) = opts.background { + ctx.set_transform(Affine::IDENTITY); + ctx.set_paint(f.ink.fixed(bg)); + ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, h as f64)); + } + if opts.artboards && !opts.outline { + // Paper: white on screen, no ink on the ink planes. + ctx.set_transform(f.view); + ctx.set_paint(peniko::Color::WHITE); + for ab in &f.doc.artboards { + ctx.fill_rect(&ab.rect); + } + } + (self.nested, self.backdrop, self.picture) = (0, None, None); + self.clip_paths.clear(); + if opts.trim && !f.doc.artboards.is_empty() { + let mut clip = BezPath::new(); + for ab in &f.doc.artboards { + clip.extend(ab.rect.path_elements(0.1)); + } + let blends = self.children_blend(&f.doc.layers); + let trim = Composite { clip: Some((&clip, FillRule::NonZero)), blends, ..Default::default() }; + self.group(&mut ctx, f, trim, &mut |r, c, fr| r.draw_page(c, fr)); + } else { + self.draw_page(&mut ctx, f); + } + let pm = self.finish(&mut ctx, self.raster); + self.contexts.push((threads, ctx)); + if self.contexts.len() > 2 { + self.contexts.remove(0); + } + pm.data_as_u8_slice().to_vec() + } + + /// The page's art: the layers (or the pattern being edited), inside the page group when the + /// page has one. + fn draw_page(&mut self, ctx: &mut RenderContext, f: &Frame) { + if self.draw_pattern_edit(ctx, f) { + return; + } + let doc = f.doc; + // While editing an opacity mask its art is seen only through the mask. + let mask_layer = doc.mask_edit.map(|m| m.layer); + let layers: Vec> = doc.layers.iter().filter(|l| Some(l.id) != mask_layer).cloned().collect(); + // Page Isolated Blending / Page Knockout Group: the page is a group of its own. + let page_group = !f.opts.outline && (doc.page_isolate || doc.page_knockout); + let knockout = page_group && doc.page_knockout; + let mut draw = |r: &mut Self, c: &mut RenderContext, fr: &Frame| { + r.knockout = knockout; + r.draw_children(c, fr, &layers, knockout); + r.knockout = false; + }; + if page_group { + let blends = self.children_blend(&layers); + self.group(ctx, f, Composite { isolated: doc.page_isolate, blends, ..Default::default() }, &mut draw); + } else { + draw(self, ctx, f); + } + } + + /// Render one artboard (or any document rect) at `scale` pixels per point, transparent or on white, + /// as exported: template layers and guides are left out. + pub fn render_region(&mut self, doc: &Document, region: Rect, scale: f64, white: bool) -> Rendered { + let opts = RenderOptions { background: white.then_some([255, 255, 255, 255]), skip_templates: true, ..Default::default() }; + self.render_region_with(doc, region, scale, &opts) + } + + /// Render a document rect at `scale` pixels per point with `opts`. + pub fn render_region_with(&mut self, doc: &Document, region: Rect, scale: f64, opts: &RenderOptions) -> Rendered { + let (w, h) = region_pixels(region, scale); + let view = Affine::scale(scale) * Affine::translate((-region.x0, -region.y0)); + self.render(doc, w, h, view, opts) + } + + /// `region` of `doc` as ink amounts in the working CMYK space, 4 bytes a pixel (0 = no ink): + /// drawn as the two ink planes of [`ink`], so CMYK colours keep their own inks (black type + /// stays black ink alone) and RGB ones are separated with the colour settings. Where nothing + /// is drawn (no background) there is no ink. CMYK exports draw this. + pub fn render_region_inks(&mut self, doc: &Document, region: Rect, scale: f64, opts: &RenderOptions) -> Vec { + let (w, h) = region_pixels(region, scale); + let view = Affine::scale(scale) * Affine::translate((-region.x0, -region.y0)); + self.render_as(doc, w, h, view, opts, true).pixels + } + + /// Render a single node (thumbnails, previews) fitted into `size`×`size` pixels. + pub fn render_thumbnail(&mut self, doc: &Document, id: NodeId, size: u32) -> Option { + self.render_node_thumbnail(doc, doc.node(id)?, size, None) + } + + /// Render node `n` of `doc` (its resources) into `w`×`h` transparent pixels through `view`. + pub fn render_node(&mut self, doc: &Document, n: &Arc, w: u16, h: u16, view: Affine) -> Option { + 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(); + let symbols = doc.symbol_boxes().quick(); + let frame = Frame { mt: false, doc, symbols: &symbols, view, visible, px, opts: &opts, ink: Ink::Display }; + self.begin_frame(); + (self.knockout, self.nested, self.backdrop, self.picture) = (false, 0, None, None); + self.clip_paths.clear(); + self.draw_arc(&mut ctx, &frame, n); + let pm = self.finish(&mut ctx, vello_cpu::RasterizerSettings::default()); + self.end_frame(); + Some(Rendered { width: w as u32, height: h as u32, pixels: pm.data_as_u8_slice().to_vec() }) + } + + /// Render any node (also one outside the tree, e.g. opacity-mask art) fitted into + /// `size`×`size` pixels, optionally over a solid premultiplied background. A symbol instance + /// is fitted by its symbol's art in `doc`. + pub fn render_node_thumbnail(&mut self, doc: &Document, n: &Node, size: u32, background: Option<[u8; 4]>) -> Option { + self.render_node_thumbnail_with(doc, n, size, background, &RenderOptions::default()) + } + + /// [`Self::render_thumbnail`] with `opts` (e.g. [`RenderOptions::image_mips`]). + pub fn render_thumbnail_with(&mut self, doc: &Document, id: NodeId, size: u32, opts: &RenderOptions) -> Option { + self.render_node_thumbnail_with(doc, doc.node(id)?, size, None, opts) + } + + /// [`Self::render_node_thumbnail`] with `opts` (e.g. [`RenderOptions::image_mips`]). + pub fn render_node_thumbnail_with( + &mut self, + doc: &Document, + n: &Node, + size: u32, + background: Option<[u8; 4]>, + opts: &RenderOptions, + ) -> Option { + self.check_plugins(); + self.check_symbols(doc); + let b = brush_fx::cull_bounds(&doc.symbol_boxes(), n)?; + let s = (size as f64 - 2.0) / b.width().max(b.height()).max(1e-6); + let view = Affine::translate((size as f64 / 2.0, size as f64 / 2.0)) * Affine::scale(s) * Affine::translate(-b.center().to_vec2()); + let w = size.clamp(1, MAX_CONTEXT_SIDE) as u16; + let mut ctx = single_threaded_context(w, w); + 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)); + } + let symbols = doc.symbol_boxes().quick(); + let frame = Frame { mt: false, doc, symbols: &symbols, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts, ink: Ink::Display }; + self.begin_frame(); + (self.knockout, self.nested, self.backdrop, self.picture) = (false, 0, None, None); + self.clip_paths.clear(); + self.draw_node(&mut ctx, &frame, n, true); + let pm = self.finish(&mut ctx, vello_cpu::RasterizerSettings::default()); + self.end_frame(); + Some(Rendered { width: size, height: size, pixels: pm.data_as_u8_slice().to_vec() }) + } +} diff --git a/crates/render/src/renderer/images.rs b/crates/render/src/renderer/images.rs new file mode 100644 index 000000000..f87a1edb9 --- /dev/null +++ b/crates/render/src/renderer/images.rs @@ -0,0 +1,205 @@ +use super::*; +use crate::images; + +impl Renderer { + pub(crate) fn draw_image(&mut self, ctx: &mut RenderContext, f: &Frame, im: &vectorcraft_doc::ImageObject) { + self.draw_image_in(ctx, f, im, None); + } + + /// Draw an image, or with `area` (a region in the document inside the image's frame, see + /// [`plain_image`]) the image painted into that region. + pub(super) fn draw_image_in( + &mut self, + ctx: &mut RenderContext, + f: &Frame, + im: &vectorcraft_doc::ImageObject, + area: Option<&(BezPath, FillRule)>, + ) { + let rect = Rect::new(0.0, 0.0, im.width as f64, im.height as f64); + // Outline mode draws the image's frame, and with Document Setup → Show Images in Outline + // Mode its pixels in greyscale under the frame. + let outline = f.opts.outline; + // With mips the full pixels are only looked at (not marked used) until they're drawn, and + // a mip the store holds is drawn without them: they may have been dropped (#2820). + let mips = f.opts.image_mips; + let unit = f.view * im.xf * Affine::scale_non_uniform(im.width as f64, im.height as f64); + let shown = if outline && !f.doc.setup.outline_images { + None + } else if let Some(pm) = mips.then(|| self.kept_mip(f, &im.key, im.pixels.as_deref(), outline, unit)).flatten() { + Some(pm) + } else { + self.image_pixmap(f.doc, &im.key, im.pixels.as_deref(), outline, !mips).map(|(key, pm)| { + let (key, pm) = if outline { (key, pm) } else { ink::ink_image(&key, &pm, f.ink, f.doc.image_blob(im), !mips) }; + if mips { image_mip(&key, pm, unit) } else { pm } + }) + }; + if let Some(pm) = shown { + let sx = im.width as f64 / pm.width().max(1) as f64; + let sy = im.height as f64 / pm.height().max(1) as f64; + let quality = if f.opts.smooth_images { peniko::ImageQuality::Medium } else { peniko::ImageQuality::Low }; + let sampler = peniko::ImageSampler { quality, ..Default::default() }; + ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler }); + match area { + Some((bp, rule)) => { + ctx.set_transform(f.view); + ctx.set_paint_transform(im.xf * Affine::scale_non_uniform(sx, sy)); + ctx.set_fill_rule(fill_rule(*rule)); + ctx.fill_path(bp); + } + None => { + ctx.set_transform(f.view * im.xf); + ctx.set_paint_transform(Affine::scale_non_uniform(sx, sy)); + ctx.fill_rect(&rect); + } + } + ctx.reset_paint_transform(); + // A dimmed layer's images: white over them, so they show at that opacity on paper. + if let Some(dim) = self.dim_images.filter(|d| *d < 1.0 && !outline) { + ctx.set_paint(f.ink.fixed([255, 255, 255, ((1.0 - dim) * 255.0).round() as u8])); + match area { + Some((bp, rule)) => { + ctx.set_transform(f.view); + ctx.set_fill_rule(fill_rule(*rule)); + ctx.fill_path(bp); + } + None => { + ctx.set_transform(f.view * im.xf); + ctx.fill_rect(&rect); + } + } + } + } else if f.opts.mark_undecoded_images && (!outline || f.doc.setup.outline_images) { + self.draw_undecoded_mark(ctx, f, im, area); + } + if outline { + let mut p = rect.to_path(0.1); + p.apply_affine(im.xf); + self.hairline(ctx, f, &p, [0, 0, 0, 255]); + } + } + + /// Draw the mark of image `im` whose pixels can't be had ([`RenderOptions::mark_undecoded_images`]): + /// a light gray box over its frame, crossed from corner to corner with a gray hairline, and + /// with `area` (see [`Self::draw_image_in`]) only inside that region. + fn draw_undecoded_mark(&mut self, ctx: &mut RenderContext, f: &Frame, im: &vectorcraft_doc::ImageObject, area: Option<&(BezPath, FillRule)>) { + let (w, h) = (im.width as f64, im.height as f64); + if let Some((bp, rule)) = area { + ctx.set_transform(f.view); + ctx.set_fill_rule(fill_rule(*rule)); + ctx.push_clip_layer(bp); + } + let rect = Rect::new(0.0, 0.0, w, h); + ctx.set_transform(f.view * im.xf); + ctx.set_paint(f.ink.fixed([128, 128, 128, 40])); + ctx.fill_rect(&rect); + let mut cross = rect.to_path(0.1); + cross.move_to((0.0, 0.0)); + cross.line_to((w, h)); + cross.move_to((w, 0.0)); + cross.line_to((0.0, h)); + cross.apply_affine(im.xf); + self.hairline(ctx, f, &cross, [128, 128, 128, 255]); + if area.is_some() { + ctx.pop_layer(); + } + } + + /// Name image `key` recoloured by `map` (its hash `hash`) as `derived` (the key the adjusted + /// art's image takes): drawing `derived` draws the recoloured pixels, made when first drawn and + /// kept with the decoded images. Names past a limit drop the art made with them too, so it is + /// made again. + pub(crate) fn adjusted_image(&mut self, key: &str, derived: String, hash: u64, map: &vectorcraft_effects::ColorMap) { + if self.adjusts.contains_key(&derived) { + return; + } + if self.adjusts.len() >= 1024 { + self.adjusts.clear(); + self.fx_arts.clear(); + } + self.adjusts.insert(derived, (key.to_string(), hash, map.clone())); + } + + /// The pixels of image `key` (`own`, the pixels an image holds itself, a blob of `doc`, or a + /// recoloured one named by [`Self::adjusted_image`]), or a greyscale copy of them, with their + /// key in the shared store. `touch` marks them used there. + pub(crate) fn image_pixmap( + &mut self, + doc: &Document, + key: &str, + own: Option<&vectorcraft_doc::ImageBlob>, + grey: bool, + touch: bool, + ) -> Option<(images::ImageKey, Arc)> { + let (key, colour) = match own.or_else(|| doc.images.get(key)) { + Some(blob) => blob_pixmap(blob, touch)?, + None => { + let (src, hash, map) = self.adjusts.get(key)?.clone(); + let (src_key, src) = self.image_pixmap(doc, &src, None, false, touch)?; + let key = images::ImageKey { adjust: Some(hash), ..src_key }; + match lookup(&key, touch) { + Some(pm) => (key, pm), + None => { + let mut pm = (*src).clone(); + let mut memo: HashMap<[u8; 4], [u8; 3]> = HashMap::new(); + for px in pm.data_mut().iter_mut().filter(|p| p.a > 0) { + let a = px.a as u32; + let [r, g, b] = *memo.entry([px.r, px.g, px.b, px.a]).or_insert_with(|| { + // Premultiplied to straight, adjusted, then premultiplied again. + let straight = [px.r, px.g, px.b].map(|v| ((v as u32 * 255 + a / 2) / a).min(255) as u8); + map.apply_rgb8(straight).map(|v| ((v as u32 * a + 127) / 255) as u8) + }); + (px.r, px.g, px.b) = (r, g, b); + } + let pm = images::store().insert(key.clone(), Arc::new(pm)); + (key, pm) + } + } + } + }; + if !grey { + return Some((key, colour)); + } + let key = images::ImageKey { grey: true, ..key }; + if let Some(p) = lookup(&key, touch) { + return Some((key, p)); + } + let mut pm = (*colour).clone(); + for px in pm.data_mut() { + // The luma of premultiplied components is the premultiplied luma. + let l = (0.2126 * px.r as f32 + 0.7152 * px.g as f32 + 0.0722 * px.b as f32 + 0.5) as u8; + (px.r, px.g, px.b) = (l, l, l); + } + let pm = images::store().insert(key.clone(), Arc::new(pm)); + Some((key, pm)) + } + + /// The key [`Self::image_pixmap`] gives the pixels of image `key` (or `own`) and their size, without + /// making or looking at them: `None` until the image has been decoded. + fn image_key(&self, doc: &Document, key: &str, own: Option<&vectorcraft_doc::ImageBlob>, grey: bool) -> Option<(images::ImageKey, (u16, u16))> { + let (key, size) = match own.or_else(|| doc.images.get(key)) { + Some(blob) => { + let (key, _, size) = blob_key(blob); + (key, size?) + } + None => { + let (src, hash, _) = self.adjusts.get(key)?; + let (src_key, size) = self.image_key(doc, src, None, false)?; + (images::ImageKey { adjust: Some(*hash), ..src_key }, size) + } + }; + Some((if grey { images::ImageKey { grey: true, ..key } } else { key }, size)) + } + + /// The mip [`image_mip`] draws image `key` (or `own`) with through `m`, in greyscale with + /// `grey` (the outline view's), when the store holds it, marked used there; `None` when it + /// draws the full pixels or the mip is yet to be made. + fn kept_mip(&self, f: &Frame, key: &str, own: Option<&vectorcraft_doc::ImageBlob>, grey: bool, m: Affine) -> Option> { + let (key, (w, h)) = self.image_key(f.doc, key, own, grey)?; + let key = if grey { key } else { ink::ink_key(&key, f.ink) }; + let level = mip_level(m, w, h); + if level == 0 { + return None; + } + images::store().get(&images::ImageKey { mip: level, ..key }) + } +} diff --git a/crates/render/src/renderer/nodes.rs b/crates/render/src/renderer/nodes.rs new file mode 100644 index 000000000..6eedf3da1 --- /dev/null +++ b/crates/render/src/renderer/nodes.rs @@ -0,0 +1,589 @@ +use super::*; + +impl Renderer { + /// Cached cull bounds of a node (containers union their cached children), symbol instances + /// measured by their art box from `symbols`. + pub(crate) fn bounds_of(&mut self, symbols: &SymbolBoxes, a: &Arc) -> Option { + let key = Arc::as_ptr(a) as usize; + if let Some(e) = self.geom.get_mut(&key) + && Arc::ptr_eq(&e.node, a) + { + e.stamp = self.stamp; + return e.bounds; + } + let b = match &a.kind { + NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !fx::has_object_fx(a) => { + let mut acc: Option = None; + // An Image Trace object's hidden source image shows in some of its views. + let source = a.extras().trace.is_some().then(|| children.first()).flatten(); + for c in children { + if c.visible || source.is_some_and(|s| Arc::ptr_eq(s, c)) { + acc = vectorcraft_geom::union_opt(acc, self.bounds_of(symbols, c)); + } + } + acc + } + _ => brush_fx::cull_bounds(symbols, a), + }; + self.geom.insert(key, GeomEntry { node: a.clone(), bounds: b, path: None, stamp: self.stamp }); + b + } + + /// Drop the cached bounds of symbol instances and of the objects that hold them when `doc`'s + /// symbols are not those of the document last drawn (by name and art): those bounds measured + /// the instances by their symbol's art. + pub(crate) fn check_symbols(&mut self, doc: &Document) { + let same = self.symbols.len() == doc.symbols.len() + && self.symbols.iter().zip(&doc.symbols).all(|((name, art), s)| *name == s.name && std::ptr::eq(art.as_ptr(), Arc::as_ptr(&s.art))); + if !same { + self.geom.retain(|_, e| !holds_instance(&e.node)); + self.symbols = doc.symbols.iter().map(|s| (s.name.clone(), Arc::downgrade(&s.art))).collect(); + } + } + + /// When a plug-in has been installed or removed since the last check + /// ([`effects::plugin_revision`]), drop what the caches keep from plug-in effects: the effect + /// geometry of paths with a plug-in effect, the cull bounds of objects that have one or hold one, + /// of symbol instances and of the objects that hold them, and the art drawn for the effects of + /// objects with a plug-in effect. Rasters of raster effects and evaluations of live objects hold + /// the revision in their keys when what they draw has a plug-in effect (`fx::Inputs`, + /// `live::resources_key`). + pub(crate) fn check_plugins(&mut self) { + let revision = effects::plugin_revision(); + if revision == self.plugin_revision { + return; + } + self.plugin_revision = revision; + self.fx_geom.retain(|_, e| !effects::has_plugin_effect(&e.node)); + self.geom.retain(|_, e| !holds_plugin_effect(&e.node) && !holds_instance(&e.node)); + self.fx_arts.retain(|(node, ..)| !effects::has_plugin_effect(node)); + } + + /// Cached region a clip group's clipping path `a` clips to ([`effects::clip_outline`]): + /// outlining text and uniting shapes is too slow to repeat every frame. + pub(crate) fn clip_of(&mut self, a: &Arc) -> Option> { + let key = Arc::as_ptr(a) as usize; + if let Some((node, region)) = self.clips.get(&key, self.stamp) + && Arc::ptr_eq(node, a) + { + return region.clone(); + } + let region = effects::clip_outline(a).map(Arc::new); + let bytes = size_of::() + region.as_ref().map_or(0, |r| lru::path_bytes(&r.0)); + self.clips.insert(key, (a.clone(), region.clone()), bytes, self.stamp); + region + } + + /// Cached fills and strokes of clipping path `a` ([`Node::clip_paint`]): the parts keep their + /// allocations between frames, so their own geometry stays cached. + fn clip_paint_of(&mut self, a: &Arc) -> (Option>, Option>) { + let key = Arc::as_ptr(a) as usize; + if let Some((node, fill, stroke)) = self.clip_paints.get(&key, self.stamp) + && Arc::ptr_eq(node, a) + { + return (fill.clone(), stroke.clone()); + } + let paint = a.clip_paint(); + let (fill, stroke) = (paint.fill.map(Arc::new), paint.stroke.map(Arc::new)); + let bytes = size_of::() + [&fill, &stroke].iter().filter_map(|p| p.as_deref()).map(lru::node_bytes).sum::(); + self.clip_paints.insert(key, (a.clone(), fill.clone(), stroke.clone()), bytes, self.stamp); + (fill, stroke) + } + + /// Cached BezPath of a path or compound path node (its bounds cached with it, see + /// [`Self::bounds_of`]). + fn path_of(&mut self, symbols: &SymbolBoxes, a: &Arc) -> Option> { + let key = Arc::as_ptr(a) as usize; + if let Some(e) = self.geom.get(&key) + && Arc::ptr_eq(&e.node, a) + && let Some(p) = &e.path + { + return Some(p.clone()); + } + let p = Arc::new(a.stroke_path()?); + let bounds = self.bounds_of(symbols, a); + self.geom.insert(key, GeomEntry { node: a.clone(), bounds, path: Some(p.clone()), stamp: self.stamp }); + Some(p) + } + + /// The cached path of path or compound path `n` ([`Self::path_of`]) when the frame is in + /// Outline view and `n` is `fx_owner`, else `None`. + fn outline_path(&mut self, f: &Frame, n: &Node) -> Option> { + let owner = self.fx_owner.as_ref().filter(|o| f.opts.outline && std::ptr::eq(&***o, n)).cloned()?; + self.path_of(f.symbols, &owner) + } + + /// Whether `a` is left out of this frame: hidden, a skipped template or guide, or culled. + fn skipped(&mut self, f: &Frame, a: &Arc) -> bool { + let skipped_view_item = (f.opts.skip_templates || f.opts.hide_templates) && matches!(a.kind, NodeKind::Layer { template: true, .. }) + || (f.opts.skip_templates || f.opts.hide_guides) && matches!(a.kind, NodeKind::Path { guide: true, .. }); + if !a.visible || skipped_view_item || f.opts.hidden.contains(&a.id) { + return true; + } + match self.bounds_of(f.symbols, a) { + Some(b) => { + let pad = f.px * 2.0; + // Level of detail: leaves smaller than a quarter pixel are invisible. + let culled = + !rects_overlap(b.inflate(pad, pad), f.visible) || (!a.is_container() && b.width() < f.px * 0.25 && b.height() < f.px * 0.25); + self.stats.culled += culled as usize; + culled + } + None => !a.is_container(), + } + } + + pub(crate) fn draw_arc(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc) { + if self.skipped(f, a) { + return; + } + if let Some(m) = a.extras().mask.as_deref() + && !m.disabled + && !f.opts.outline + && !self.is_shape(a) + { + // A masked object is a non-isolated group of its own, drawn over its own pixels: the + // mask is rendered over those only. + let bounds = self.bounds_of(f.symbols, a); + let Some((r, sub)) = group::area(ctx, f, bounds) else { return }; + let mask = Some(self.mask_values(&sub, m, r.w, r.h)); + let blends = a.blend != vectorcraft_color::BlendMode::Normal || (!a.isolate && self.blends_through(a)); + self.group(ctx, f, Composite { mask, blends, bounds, ..Default::default() }, &mut |r, c, fr| r.draw_arc_body(c, fr, a)); + return; + } + self.draw_arc_body(ctx, f, a); + } + + /// The coverage of opacity mask `m` per output pixel (row-major): its art rendered offscreen, + /// as luminance ([`mask_value`]). + pub(super) fn mask_values(&mut self, f: &Frame, m: &vectorcraft_doc::OpacityMask, w: u16, h: u16) -> Vec { + let mut mctx = f.offscreen_context(w, h); + // Mask art is a picture of its own: it takes no part in a knockout group around the object, + // and its luminance is that of its screen colours. + let outer = ( + std::mem::take(&mut self.knockout), + std::mem::take(&mut self.nested), + self.backdrop.take(), + std::mem::take(&mut self.clip_paths), + self.picture.take(), + ); + self.draw_kept_node(&mut mctx, &Frame { mt: false, ink: Ink::Display, ..*f }, &m.art); + let pm = self.finish(&mut mctx, self.raster); + (self.knockout, self.nested, self.backdrop, self.clip_paths, self.picture) = outer; + pm.data().iter().map(|p| mask_value(p.r, p.g, p.b, p.a, m.clip, m.invert)).collect() + } + + /// Everything [`Self::draw_arc`] does after culling. + fn draw_arc_body(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc) { + // Fast path for plain paths: cached geometry, opacity folded into the paint. + // A blend mode on a single plain fill applies per draw (exact for one paint operation); + // a blend layer would composite the whole viewport. + let blended = a.blend != vectorcraft_color::BlendMode::Normal; + if let NodeKind::Path { rule, guide: false, .. } = &a.kind + && !f.opts.outline + && (!blended || single_plain_fill(a)) + && !a.isolate + && !fx::has_fx(a) + && (self.opacity_of(a) >= 1.0 || painted_items(a) == 1) + && let Some(bp) = self.path_of(f.symbols, a) + { + self.alpha = self.opacity_of(a).clamp(0.0, 1.0); + self.cur = Some(a.clone()); + if blended { + ctx.set_blend_mode(blend_mode(a.blend)); + } + self.draw_shape(ctx, f, a, &bp, *rule); + if blended { + ctx.set_blend_mode(blend_mode(vectorcraft_color::BlendMode::Normal)); + } + self.cur = None; + self.alpha = 1.0; + self.stats.drawn += 1; + return; + } + if fx::has_object_fx(a) { + return self.draw_object_fx(ctx, f, a, true); + } + if let NodeKind::Text(t) = &a.kind + && self.opacity_of(a) >= 1.0 + && a.blend == vectorcraft_color::BlendMode::Normal + && !fx::has_fx(a) + { + let t = &*f.doc.inline_resolved(t); + let (g, kept) = match f.text_snap(t) { + Some(xf) => (Arc::new(text_geom_snapped(t, Some(xf))), false), + None => self.text_geom_of(a, t), + }; + self.draw_text_geom(ctx, f, a, t, &g, kept); + self.stats.drawn += 1; + return; + } + if vectorcraft_doc::live::is_live(a) || effects::has_pathfinder(a) { + return self.draw_live(ctx, f, a); + } + // Its live-effect geometry depends on the node alone, and for plug-in effects on the + // plug-ins installed (`check_plugins`), so it can be kept across frames. + let outer = self.fx_owner.replace(a.clone()); + self.draw_node(ctx, f, a, true); + self.fx_owner = outer; + } + + pub(crate) fn draw_node(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, force: bool) { + 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) { + let pad = f.px * 2.0; + if !rects_overlap(b.inflate(pad, pad), f.visible) { + self.stats.culled += n.count(); + return; + } + } else if !n.is_container() { + return; + } + // Layer Options on screen: a layer whose Preview is off draws in outline, and Dim Images + // fades its images, a template's too (exports and thumbnails, which leave templates out, + // ignore both). + if let NodeKind::Layer { preview, dim_images, template, .. } = &n.kind + && f.opts.dim_templates + && !f.opts.skip_templates + { + // A template layer stays in Preview while the view is in Outline (and whatever its own + // Preview option says): its picture is there to be traced (#1486). + if *template && f.opts.outline { + let opts = RenderOptions { outline: false, ..f.opts.clone() }; + return self.draw_node(ctx, &Frame { opts: &opts, ..*f }, n, force); + } + if !preview && !template && !f.opts.outline { + let opts = RenderOptions { outline: true, ..f.opts.clone() }; + return self.draw_node(ctx, &Frame { opts: &opts, ..*f }, n, force); + } + if let Some(p) = *dim_images { + let outer = self.dim_images; + let dim = f32::from(p).clamp(0.0, 100.0) / 100.0; + self.dim_images = Some(outer.map_or(dim, |o| o.min(dim))); + self.draw_layer_node(ctx, f, n); + self.dim_images = outer; + return; + } + } + self.draw_layer_node(ctx, f, n); + } + + /// [`Self::draw_node`] after culling and Layer Options. + fn draw_layer_node(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node) { + // Isolation mode: the art around the isolated container draws dimmed (#833). + if let Some((isolated, path)) = self.isolation_path(f) { + // A container on the way down to it draws as usual: its members decide. + if path.contains(&n.id) && n.id != isolated { + return self.draw_layer_node_now(ctx, f, n); + } + self.isolation_settled = true; + if n.id == isolated { + self.draw_layer_node_now(ctx, f, n); + } else { + self.dimmed(ctx, f, &mut |r, c, fr| r.draw_layer_node_now(c, fr, n)); + } + self.isolation_settled = false; + return; + } + self.draw_layer_node_now(ctx, f, n); + } + + /// In isolation mode, while drawing outside the isolated container and the art dimmed around + /// it: the isolated container and its layers and groups, from the top layer down to it. + pub(crate) fn isolation_path(&mut self, f: &Frame) -> Option<(NodeId, Vec)> { + // Opacity-mask editing isolates the temporary layer for tools; that layer is not painted on + // the page (the mask applies on the object), so canvas dimming would wash out everything. + let isolated = f.opts.isolated.filter(|_| f.doc.mask_edit.is_none() && !self.isolation_settled)?; + let doc = std::ptr::from_ref(f.doc) as usize; + if self.isolation.as_ref().is_none_or(|(stamp, d, id, _)| (*stamp, *d, *id) != (self.stamp, doc, isolated)) { + self.isolation = Some((self.stamp, doc, isolated, f.doc.ancestry(isolated).unwrap_or_default())); + } + let path = self.isolation.as_ref().map(|(.., path)| path.clone()).filter(|p| !p.is_empty())?; + Some((isolated, path)) + } + + /// `draw` at [`ISOLATION_DIM`], with nothing inside dimmed again. + fn dimmed(&mut self, ctx: &mut RenderContext, f: &Frame, draw: &mut group::Content) { + let settled = std::mem::replace(&mut self.isolation_settled, true); + let comp = Composite { opacity: ISOLATION_DIM, ..Default::default() }; + self.group(ctx, f, comp, draw); + self.isolation_settled = settled; + } + + /// [`Self::draw_layer_node`] past isolation mode's dimming. + fn draw_layer_node_now(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node) { + if fx::has_object_fx(n) { + return self.draw_object_fx(ctx, f, &Arc::new(n.clone()), false); + } + let outline = f.opts.outline; + let opacity = self.opacity_of(n); + // Whether this container's children knock each other out. + let knockout = !outline && n.knocks_out(self.knockout); + let enclosing = std::mem::replace(&mut self.knockout, knockout); + if !outline && (opacity < 1.0 || n.blend != vectorcraft_color::BlendMode::Normal || n.isolate || knockout) { + let blends = self.blends_through(n); + let bounds = if blends { self.node_bounds(f.symbols, n) } else { None }; + let comp = Composite { blend: n.blend, opacity, isolated: n.isolate, blends, bounds, ..Default::default() }; + self.group(ctx, f, comp, &mut |r, c, fr| r.draw_content(c, fr, n, knockout)); + } else { + self.draw_content(ctx, f, n, knockout); + } + self.knockout = enclosing; + self.stats.drawn += 1; + } + + /// What `n` draws inside its transparency group (`knockout`: its children knock each other out). + fn draw_content(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, knockout: bool) { + match &n.kind { + NodeKind::Group { children, clip: false } if f.opts.trace_views && !f.opts.outline && Self::trace_view(f, n) != TraceView::Result => { + self.draw_trace_view(ctx, f, children, Self::trace_view(f, n), knockout) + } + NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { + self.draw_children(ctx, f, children, knockout) + } + NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } if f.opts.outline => { + for c in children { + self.draw_outline_member(ctx, f, c); + } + } + NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { + // Nothing to clip by hides the clipped art (as in the SVG and PDF output). A clip + // group doesn't isolate: blending inside it reaches the art below. The clipping + // path's fill paints behind the clipped art and its stroke over it, unclipped. + if let Some((clip, rest)) = children.split_first() + && let Some(region) = self.clip_of(clip) + { + let (fill, stroke) = self.clip_paint_of(clip); + // An image inside its own frame painted straight into the clipping path, without + // a clip layer (envelopes cut distorted images into many such pieces). + if let ([image], None, None) = (rest, &fill, &stroke) + && let NodeKind::Image(im) = &image.kind + && plain_image(image, im, ®ion.0) + { + self.draw_image_in(ctx, f, im, Some(®ion)); + self.stats.drawn += 1; + return; + } + let blends = self.blends_through(n); + let bounds = if blends { Some(region.0.bounding_box()) } else { None }; + let comp = Composite { clip: Some((®ion.0, region.1)), blends, bounds, ..Default::default() }; + self.group(ctx, f, comp, &mut |r, c, fr| { + if let Some(fill) = &fill { + r.draw_arc(c, fr, fill); + } + r.draw_children(c, fr, rest, knockout) + }); + if let Some(stroke) = &stroke { + self.draw_arc(ctx, f, stroke); + } + } + } + NodeKind::Path { path, rule, guide, .. } => { + let bp = self.outline_path(f, n).unwrap_or_else(|| Arc::new(path.to_bezpath())); + if *guide { + let ([r, g, b], dots) = f.opts.guide; + let mut screen = BezPath::clone(&bp); + screen.apply_affine(f.view); + // The rasterizer makes every dash of the whole path before clipping: a guide + // running far off screen (from a file) asked for millions of them. Past + // [`MAX_GUIDE_DASHES`] (by its control polygon, which is at least as long) it + // is drawn solid. + let h = self.hairline; + if dots && polygon_length(&screen) / (4.0 * h) <= MAX_GUIDE_DASHES { + ctx.set_transform(Affine::IDENTITY); + ctx.set_stroke(kurbo::Stroke::new(1.5 * h).with_dashes(0.0, [1.5 * h, 2.5 * h])); + ctx.set_paint(f.ink.fixed([r, g, b, 255])); + ctx.stroke_path(&screen); + } else { + self.hairline(ctx, f, &bp, [r, g, b, 255]); + } + } else { + self.draw_shape(ctx, f, n, &bp, *rule); + } + } + NodeKind::Compound { children, rule } => { + if let Some(bp) = self.outline_path(f, n) { + return self.draw_shape(ctx, f, n, &bp, *rule); + } + let mut bp = BezPath::new(); + for c in children { + if let Some(p) = c.path_data() { + bp.extend(p.to_bezpath()); + } + } + self.draw_shape(ctx, f, n, &bp, *rule); + } + NodeKind::Text(t) => self.draw_text(ctx, f, n, t), + NodeKind::Image(im) => self.draw_image(ctx, f, im), + NodeKind::SymbolInstance { symbol, xf } => { + if let Some(sym) = f.doc.symbols.iter().find(|s| &s.name == symbol) + && self.charge_symbol(&sym.art) + { + let (art, kept) = self.placed_art(&sym.art, *xf, false, Some(n)); + self.draw_placed_art(ctx, f, &art, kept); + } + } + NodeKind::Blend { .. } + | NodeKind::Envelope { .. } + | NodeKind::Mesh(_) + | NodeKind::Repeat(_) + | NodeKind::PlacedDocument(_) + | NodeKind::Intertwine { .. } => match self.live_root.clone().filter(|a| std::ptr::eq(&**a, n)) { + Some(a) => self.draw_live_body(ctx, f, &a, true), + None => self.draw_live_node(ctx, f, n), + }, + } + } + + /// Count symbol art `art`'s objects against the frame's [`vectorcraft_doc::MAX_SYMBOL_NODES`]: `false` (the + /// instance isn't drawn) once they would pass it. + fn charge_symbol(&mut self, art: &Node) -> bool { + let total = self.symbol_nodes.saturating_add(art.count() as u64); + (total <= vectorcraft_doc::MAX_SYMBOL_NODES).then(|| self.symbol_nodes = total).is_some() + } + + /// Draw symbol art `art` as [`Self::placed_art`] placed it for an instance or an inline graphic + /// (`kept`: kept between frames, see [`Self::draw_kept_node`]). + pub(super) fn draw_placed_art(&mut self, ctx: &mut RenderContext, f: &Frame, art: &Arc, kept: bool) { + let outer = std::mem::replace(&mut self.made_again, !kept); + if kept { + self.draw_kept_node(ctx, f, art); + } else { + self.draw_node(ctx, f, art, true); + } + self.made_again = outer; + } + + /// [`Self::draw_node`] of `a` (drawn whatever its visibility and bounds), whose `Arc` stays the + /// same from frame to frame while the art does. When `a` is a live object, + /// [`Self::draw_content`] draws its evaluation from the live cache ([`Self::live_root`]). + pub(crate) fn draw_kept_node(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc) { + let live = vectorcraft_doc::live::is_live(a).then(|| a.clone()); + let outer = std::mem::replace(&mut self.live_root, live); + self.draw_node(ctx, f, a, true); + self.live_root = outer; + } + + /// The View `n` draws with on screen: Source Image when it is an Image Trace object listed in + /// [`RenderOptions::trace_source`], else its own. + fn trace_view(f: &Frame, n: &Node) -> TraceView { + match &f.opts.trace_source { + Some(ids) if n.extras().trace.is_some() && ids.contains(&n.id) => TraceView::Source, + _ => n.trace_view(), + } + } + + /// An Image Trace object's `children` (the hidden source image, then the traced shapes) as + /// `view` shows them on screen. + fn draw_trace_view(&mut self, ctx: &mut RenderContext, f: &Frame, children: &[Arc], view: TraceView, knockout: bool) { + let Some((source, shapes)) = children.split_first() else { return }; + if view.shows_source() && matches!(source.kind, NodeKind::Image(_)) { + self.draw_node(ctx, f, source, true); + } + if view.shows_result() { + self.draw_children(ctx, f, shapes, knockout); + } + if view.shows_outlines() { + let opts = RenderOptions { outline: true, ..f.opts.clone() }; + let frame = Frame { opts: &opts, ..*f }; + for c in shapes { + self.draw_outline_member(ctx, &frame, c); + } + } + } + + /// [`Self::draw_node`] for `c`, a member of a clip group or a shape of an Image Trace object in + /// Outline view, culled by its cached bounds ([`Self::bounds_of`]) and drawn with `c` as + /// `fx_owner`, so its path, its live-effect geometry and, through [`Self::draw_kept_node`], a + /// live object's evaluation come from the caches. + fn draw_outline_member(&mut self, ctx: &mut RenderContext, f: &Frame, c: &Arc) { + if !c.visible || f.opts.hidden.contains(&c.id) || f.opts.hide_guides && matches!(c.kind, NodeKind::Path { guide: true, .. }) { + return; + } + match self.bounds_of(f.symbols, c) { + Some(b) => { + let pad = f.px * 2.0; + if !rects_overlap(b.inflate(pad, pad), f.visible) { + self.stats.culled += c.count(); + return; + } + } + None if !c.is_container() => return, + None => {} + } + let outer = self.fx_owner.replace(c.clone()); + self.draw_kept_node(ctx, f, c); + self.fx_owner = outer; + } + + /// The children of a group: as the elements of a knockout group when `knockout`. + pub(super) fn draw_children(&mut self, ctx: &mut RenderContext, f: &Frame, children: &[Arc], knockout: bool) { + if knockout { + self.draw_knockout(ctx, f, children); + } else if let Some((_, path)) = self.isolation_path(f) { + // Isolation mode, in a container on the way down to the isolated one: each run of + // members around it draws dimmed as one (#833). + let mut rest = children; + while let Some(i) = rest.iter().position(|c| path.contains(&c.id)) { + let (around, from) = rest.split_at(i); + if !around.is_empty() { + self.dimmed(ctx, f, &mut |r, c, fr| around.iter().for_each(|n| r.draw_arc(c, fr, n))); + } + let Some((on_path, after)) = from.split_first() else { break }; + self.draw_arc(ctx, f, on_path); + rest = after; + } + if !rest.is_empty() { + self.dimmed(ctx, f, &mut |r, c, fr| rest.iter().for_each(|n| r.draw_arc(c, fr, n))); + } + } else { + for c in children { + self.draw_arc(ctx, f, c); + } + } + } + + /// Cull bounds of `n` (symbol instances measured by their art box from `symbols`): cached when + /// it is a node of the tree being drawn. + fn node_bounds(&mut self, symbols: &SymbolBoxes, n: &Node) -> Option { + match self.geom.get(&(n as *const Node as usize)) { + // The entry keeps its node alive, so the address is that node's. + Some(e) if std::ptr::eq(Arc::as_ptr(&e.node), n) => e.bounds, + _ => fx::cull_bounds(symbols, n), + } + } + + /// The children of a knockout group ([`Node::knockout_elements`]). Each element first erases + /// what the elements below it drew wherever it paints (its knockout shape: its coverage at full + /// object opacity without its opacity mask, or as drawn when its opacity and mask define the + /// knockout shape), then adds itself: it composites against the group's backdrop instead of + /// over the elements below it (the art below the group when the group is non-isolated and + /// drawn over a copy of it, see [`Self::group`] and [`Self::knockout_over`]). + pub(crate) fn draw_knockout(&mut self, ctx: &mut RenderContext, f: &Frame, children: &[Arc]) { + let backdrop = self.backdrop.take(); + for c in Node::knockout_elements(children) { + if self.skipped(f, c) { + continue; + } + self.knockout_pass(ctx, f, c, Compose::DestOut); + match &backdrop { + Some(b) => self.knockout_over(ctx, f, c, b), + None => self.knockout_pass(ctx, f, c, Compose::Plus), + } + } + self.backdrop = backdrop; + } + + /// Whether `n` is the knockout element being drawn as its shape (see [`Self::draw_knockout`]). + pub(super) fn is_shape(&self, n: &Node) -> bool { + self.shape_of == n as *const Node as usize + } +} diff --git a/crates/render/src/renderer/paint.rs b/crates/render/src/renderer/paint.rs new file mode 100644 index 000000000..36e3e53e9 --- /dev/null +++ b/crates/render/src/renderer/paint.rs @@ -0,0 +1,266 @@ +use super::*; +use crate::paint; + +impl Renderer { + /// The object opacity `n` is drawn with (full while it is drawn as its knockout shape). + pub(crate) fn opacity_of(&self, n: &Node) -> f32 { + if self.is_shape(n) { 1.0 } else { n.opacity } + } + + pub(crate) fn hairline(&mut self, ctx: &mut RenderContext, f: &Frame, bp: &BezPath, rgba: [u8; 4]) { + ctx.set_transform(Affine::IDENTITY); + let mut screen = bp.clone(); + screen.apply_affine(f.view); + ctx.set_stroke(kurbo::Stroke::new(self.hairline)); + ctx.set_paint(f.ink.fixed(rgba)); + ctx.stroke_path(&screen); + } + + pub(super) fn draw_shape(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, bp: &BezPath, rule: FillRule) { + if !drawable(bp, f.view, n) { + return; + } + if fx::has_fx(n) { + return self.draw_shape_fx(ctx, f, n, bp, rule); + } + if f.opts.outline { + self.hairline(ctx, f, bp, [0, 0, 0, 255]); + return; + } + // Bounds only matter to gradients and patterns (their geometry is relative to the object). + let solid = |p: &vectorcraft_color::Paint| matches!(p, vectorcraft_color::Paint::Solid { .. } | vectorcraft_color::Paint::None); + let needs_bounds = n.appearance.items.iter().any(|i| match i { + AppearanceItem::Fill(fl) => !solid(&fl.paint), + AppearanceItem::Stroke(st) => !solid(&st.paint), + }); + let bounds = if needs_bounds { bp.bounding_box() } else { Rect::ZERO }; + for item in &n.appearance.items { + match item { + AppearanceItem::Fill(fl) => { + if !fl.visible || fl.paint.is_none() { + continue; + } + let layered = fl.opacity < 1.0 || fl.blend != vectorcraft_color::BlendMode::Normal; + if layered { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(blend_mode(fl.blend)), Some(fl.opacity), None, None); + } + ctx.set_transform(f.view); + if paint::set_fill_paint(ctx, &fl.paint, bounds, f) { + self.fold_alpha(ctx, f.ink, &fl.paint); + ctx.set_fill_rule(fill_rule(rule)); + ctx.fill_path(bp); + } + if layered { + ctx.pop_layer(); + } + } + AppearanceItem::Stroke(st) => { + if !st.visible || st.paint.is_none() || st.width <= 0.0 { + continue; + } + if st.brush.is_some() && self.draw_brush(ctx, f, n, bp, st) { + continue; + } + self.draw_stroke(ctx, f, Affine::IDENTITY, bp, rule, st, None, bounds); + } + } + } + } + + /// Multiply the folded object opacity into a solid paint. + pub(super) fn fold_alpha(&self, ctx: &mut RenderContext, ink: Ink, p: &vectorcraft_color::Paint) { + if self.alpha < 1.0 + && let vectorcraft_color::Paint::Solid { color, .. } = p + { + ctx.set_paint(ink.color(color, self.alpha)); + } + } + + /// Paint stroke `st` of the shape `bp` (in the space `xf` maps to the document; `bounds`: its + /// geometric bounds there) with its alignment, dashes, profile, arrowheads, opacity and blend. + /// `chars` is the glyph geometry kept in `texts` and the run that `st` is the character stroke + /// of, when it is one. + #[allow(clippy::too_many_arguments)] + pub(crate) fn draw_stroke( + &mut self, + ctx: &mut RenderContext, + f: &Frame, + xf: Affine, + bp: &BezPath, + rule: FillRule, + st: &StrokeLayer, + chars: Option<(&Arc, usize)>, + bounds: Rect, + ) { + 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. + let folded = if pieces.heads.is_empty() { 1.0 } else { std::mem::replace(&mut self.alpha, 1.0) }; + let opacity = st.opacity * folded; + let layered = opacity < 1.0 || st.blend != vectorcraft_color::BlendMode::Normal || st.align == StrokeAlign::Outside; + if layered { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(blend_mode(st.blend)), Some(opacity), None, None); + } + ctx.set_transform(f.view * xf); + let closed = effects::stroke::is_closed(bp); + // Keep hairlines visible when zoomed far out (at least ~1 device pixel). + let width = effects::stroke::aligned_width(st, closed).max(f.px * 0.5 / xf.determinant().abs().sqrt().max(1e-9)); + let inside = st.align == StrokeAlign::Inside && closed; + if inside { + ctx.set_fill_rule(fill_rule(rule)); + ctx.push_clip_layer(bp); + } + // One paint box for the line and the heads, so a gradient runs on into the heads. + let paint_bounds = bounds.inflate(st.width / 2.0, st.width / 2.0); + if let Some(g) = st.path_gradient() { + self.fill_path_gradient(ctx, f, bp, rule, st, chars, &pieces, width, &g.gradient); + } else if paint::set_paint(ctx, &st.paint, paint_bounds, f) { + self.fold_alpha(ctx, f.ink, &st.paint); + ctx.set_fill_rule(peniko::Fill::NonZero); + match self.cached_stroke(f, chars, &pieces.line, st, width) { + Some(o) => ctx.fill_path(&o), + None => ctx.fill_path(&effects::stroke::line_outline(&pieces.line, st, width, f.px * 0.25)), + } + for head in &pieces.heads { + ctx.fill_path(&head.outline); + } + } + if inside { + ctx.pop_layer(); + } + if st.align == StrokeAlign::Outside && closed { + // Punch out the interior. + ctx.set_blend_mode(BlendMode::new(Mix::Normal, Compose::DestOut)); + ctx.set_paint(peniko::Color::BLACK); + ctx.set_fill_rule(fill_rule(rule)); + ctx.fill_path(bp); + ctx.set_blend_mode(BlendMode::default()); + } + if layered { + ctx.pop_layer(); + } + if !pieces.heads.is_empty() { + self.alpha = folded; + } + } + + /// Paint the line and arrowheads (`pieces`) of stroke `st` of `bp` (filled with `rule`) with + /// its gradient `g` laid along or across the path ([`effects::stroke::gradient_slices`]). Each + /// piece clips the slices, grown a pixel into each other and each replacing what it covers, so + /// neighbours meet without seams and translucent colours paint once. + #[allow(clippy::too_many_arguments)] + fn fill_path_gradient( + &mut self, + ctx: &mut RenderContext, + f: &Frame, + bp: &BezPath, + rule: FillRule, + st: &StrokeLayer, + chars: Option<(&Arc, usize)>, + pieces: &effects::stroke::StrokePieces, + width: f64, + g: &vectorcraft_color::Gradient, + ) { + let level = (f.px * 0.25).log2().floor() as i32; + let make = || -> Slices { + let tol = 2f64.powi(level); + // Reach a pixel past the stroke, so its antialiased edge has colour under it. + let slices = effects::stroke::gradient_slices(bp, rule, st, tol, f.px + tol, f.px); + Arc::new( + slices + .into_iter() + .map(|s| { + let (b, paint) = (s.shape.bounding_box(), s.paint(g)); + (s.shape, paint, b) + }) + .collect(), + ) + }; + let slices = match self.cur.clone().filter(|n| owns_stroke(n, st)) { + Some(node) => { + let key = (self.stroke_owner(Arc::as_ptr(&node) as usize), st as *const StrokeLayer as usize, level); + match self.stroke_slices.get_mut(&key) { + Some(e) if Arc::ptr_eq(&e.node, &node) => { + e.stamp = self.stamp; + e.slices.clone() + } + _ => { + let slices = make(); + self.stroke_slices.insert(key, SliceEntry { node, slices: slices.clone(), stamp: self.stamp }); + slices + } + } + } + None => make(), + }; + let line = match self.cached_stroke(f, chars, &pieces.line, st, width) { + Some(o) => o, + None => Arc::new(effects::stroke::line_outline(&pieces.line, st, width, f.px * 0.25)), + }; + ctx.set_fill_rule(peniko::Fill::NonZero); + for clip in std::iter::once(&*line).chain(pieces.heads.iter().map(|h| &h.outline)) { + let cb = clip.bounding_box(); + if clip.elements().is_empty() || !cb.is_finite() { + continue; + } + ctx.push_clip_layer(clip); + ctx.set_blend_mode(BlendMode::new(Mix::Normal, Compose::Copy)); + for (shape, paint, b) in slices.iter() { + if rects_overlap(*b, cb) && paint::set_paint(ctx, paint, *b, f) { + ctx.fill_path(shape); + } + } + ctx.set_blend_mode(BlendMode::default()); + ctx.pop_layer(); + } + } + + /// The line part of a stroke (see [`effects::stroke::line_outline`]), cached across frames: a + /// character stroke of run `chars.1` of kept glyph geometry `chars.0` in `char_strokes`, a + /// stroke of `self.cur` in `strokes` (under [`Self::stroke_owner`] and the stroke layer). The + /// tolerance (vello's 0.25 device px) is bucketed to powers of two, never coarser than needed, + /// so zooming reuses outlines until the level changes. Other strokes aren't cached: for them + /// this returns `None`. + fn cached_stroke( + &mut self, + f: &Frame, + chars: Option<(&Arc, usize)>, + line: &BezPath, + st: &StrokeLayer, + width: f64, + ) -> Option> { + let level = (f.px * 0.25).log2().floor() as i32; + let expand = || Arc::new(effects::stroke::line_outline(line, st, width, 2f64.powi(level))); + if let Some((g, run)) = chars { + let key = (self.stroke_owner(Arc::as_ptr(g) as usize), run, width.to_bits(), level); + if let Some((kept, outline)) = self.char_strokes.get(&key, self.stamp) + && Arc::ptr_eq(kept, g) + { + return Some(outline.clone()); + } + let outline = expand(); + self.char_strokes.insert(key, (g.clone(), outline.clone()), lru::path_bytes(&outline), self.stamp); + return Some(outline); + } + 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); + if let Some(e) = self.strokes.get_mut(&key) + && Arc::ptr_eq(&e.node, &node) + { + e.stamp = self.stamp; + return Some(e.outline.clone()); + } + let outline = expand(); + self.strokes.insert(key, StrokeEntry { node, outline: outline.clone(), stamp: self.stamp }); + Some(outline) + } + + /// What the `strokes`, `char_strokes` and `stroke_slices` keys hold for a stroke owner at + /// `address` (`self.cur` or kept glyph geometry): the address, or 0 while art made again in + /// every frame is drawn ([`Self::made_again`]). + fn stroke_owner(&self, address: usize) -> usize { + if self.made_again { 0 } else { address } + } +} diff --git a/crates/render/src/renderer/text.rs b/crates/render/src/renderer/text.rs new file mode 100644 index 000000000..f5536a667 --- /dev/null +++ b/crates/render/src/renderer/text.rs @@ -0,0 +1,259 @@ +use super::*; +use crate::paint; + +impl Renderer { + pub(super) fn draw_text(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject) { + let t = &*f.doc.inline_resolved(t); + // Type drawn as `fx_owner` (the node `draw_arc` is drawing, or type art that `fx_arts` + // keeps, see `draw_fx_art`) takes its glyph geometry from `texts`, as on the fast path, + // except in frames that snap type to pixels. + let owner = self.fx_owner.as_ref().filter(|o| std::ptr::eq(&***o, n)).cloned(); + let (g, kept) = match (f.text_snap(t), owner) { + (None, Some(a)) => self.text_geom_of(&a, t), + (snap, _) => (Arc::new(text_geom_snapped(t, snap)), false), + }; + self.draw_text_geom(ctx, f, n, t, &g, kept); + } + + /// Draw the inline graphics of type `t` (laid out in `g`): each symbol's art through the + /// normal node path, placed by the layout (inside the text's own transparency group, so its + /// opacity and blend mode apply). A missing symbol draws nothing. + fn draw_inlines(&mut self, ctx: &mut RenderContext, f: &Frame, t: &TextObject, g: &TextGeom) { + if g.inlines.is_empty() && !t.runs.iter().any(|r| r.inline.is_some()) { + return; + } + if self.inline_depth >= MAX_INLINE_DEPTH { + return; + } + for r in t.runs.iter().filter_map(|r| r.inline.as_ref()) { + if !f.doc.symbols.iter().any(|s| s.name == r.symbol) && !MISSING_INLINE_LOGGED.swap(true, std::sync::atomic::Ordering::Relaxed) { + log::warn!("inline graphic shows symbol {:?}, which the document doesn't have: it draws nothing", r.symbol); + } + } + self.inline_depth += 1; + for ig in &g.inlines { + let Some(art) = t.runs.get(ig.run).and_then(|r| r.inline.as_ref()) else { continue }; + let Some(sym) = f.doc.symbols.iter().find(|s| s.name == art.symbol) else { continue }; + // Strokes scale with the art (it is sized to the type, as in the SVG ``). + let (node, kept) = self.placed_art(&sym.art, t.xf * ig.xf * f.doc.symbol_natural_xf(&art.symbol), true, None); + self.draw_placed_art(ctx, f, &node, kept); + } + self.inline_depth -= 1; + } + + /// Symbol art `art` as an instance or an inline graphic draws it ([`place`]), and whether it is + /// kept. Art that is a group, a layer or a live object without an appearance of its own is kept + /// between frames under the art's `Arc`, `xf`, `scale_strokes` and the stain of instance + /// `inst`: it and its members keep their `Arc`s from frame to frame, and the caches keyed by a + /// node's address (geometry, stroke outlines, glyphs, live-effect geometry, evaluated live + /// objects) hit for them. Other art is placed again in every frame, and so is art that holds + /// symbol instances, type with inline graphics, images, placed documents or raster effects + /// ([`depends_on_document`]), and art that the cache has no room for. Art that holds type is + /// placed again when the fonts change. + pub(super) fn placed_art(&mut self, art: &Arc, xf: Affine, scale_strokes: bool, inst: Option<&Node>) -> (Arc, bool) { + let kept_kind = matches!(art.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) || vectorcraft_doc::live::is_live(art); + if !kept_kind || fx::has_object_fx(art) { + return (place(art, xf, scale_strokes, inst), false); + } + let stain = stain_bits(inst.and_then(vectorcraft_brush::stain)); + let key = (Arc::as_ptr(art) as usize, xf.as_coeffs().map(f64::to_bits), scale_strokes, stain); + if let Some(e) = self.placed_arts.get(&key, self.stamp) + && std::ptr::eq(e.art.as_ptr(), Arc::as_ptr(art)) + && e.fonts.is_none_or(|g| g == vectorcraft_text::FontDb::global().generation()) + { + return match &e.placed { + Some(placed) => (placed.clone(), true), + None => (place(art, xf, scale_strokes, inst), false), + }; + } + let placed = place(art, xf, scale_strokes, inst); + let kept = (!depends_on_document(art)).then(|| placed.clone()); + let keeps = kept.is_some(); + let fonts = (keeps && holds_type(art)).then(|| vectorcraft_text::FontDb::global().generation()); + // The bytes count the key and the entry, the art's allocation that the weak reference + // keeps, and the kept art. + let bytes = size_of::<(PlacedKey, PlacedEntry, Node)>() + kept.as_deref().map_or(0, lru::node_bytes); + let inserted = self.placed_arts.insert(key, PlacedEntry { art: Arc::downgrade(art), placed: kept, fonts }, bytes, self.stamp); + (placed, keeps && inserted) + } + + /// Cached glyph geometry for a text node (keyed by Arc identity like paths). + /// Text `t` that is not the node's own, such as the copy whose inline graphics + /// `Document::inline_resolved` sized again, is laid out without the cache. Also returns + /// whether the geometry is kept in the cache. + pub(super) fn text_geom_of(&mut self, a: &Arc, t: &TextObject) -> (Arc, bool) { + if !matches!(&a.kind, NodeKind::Text(own) if std::ptr::eq(&**own, t)) { + return (Arc::new(text_geom(t)), false); + } + let fonts = vectorcraft_text::FontDb::global().generation(); + if fonts != self.text_fonts { + self.texts.clear(); + self.char_strokes.clear(); + self.text_fonts = fonts; + } + let key = Arc::as_ptr(a) as usize; + if let Some((node, g)) = self.texts.get(&key, self.stamp) + && Arc::ptr_eq(node, a) + { + return (g.clone(), true); + } + let g = Arc::new(text_geom(t)); + let kept = self.texts.insert(key, (a.clone(), g.clone()), g.bytes(), self.stamp); + (g, kept) + } + + /// Draw type `n` (`t`, laid out in `g`). The outlines of its character strokes are cached + /// when `g` is kept in `texts` (`kept`). + pub(super) fn draw_text_geom(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject, g: &Arc, kept: bool) { + let xf = f.view * t.xf; + if f.opts.outline { + let mut p = g.all.clone(); + p.apply_affine(xf); + ctx.set_transform(Affine::IDENTITY); + ctx.set_stroke(kurbo::Stroke::new(self.hairline)); + ctx.set_paint(peniko::Color::BLACK); + ctx.stroke_path(&p); + // Colour bitmap glyphs: their boxes, as images show in outline mode. + for (_, image, to_text) in &g.images { + let mut b = image.rect().to_path(0.1); + b.apply_affine(t.xf * *to_text); + self.hairline(ctx, f, &b, [0, 0, 0, 255]); + } + // COLR glyphs: their layers' outlines. + for (_, art) in &g.colours { + self.draw_colour_glyph(ctx, f, art, t.xf); + } + self.draw_inlines(ctx, f, t, g); + return; + } + let tb = t.xf.transform_rect_bbox(g.bounds); + // 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::Darken) } else { BlendMode::default() }); + } + }; + // The object's own fills and strokes paint the whole outline: those below the Characters + // row under the characters' own fill and stroke, the others over them. + let (below, above) = n.appearance.split_contents(); + let all = (!n.appearance.items.is_empty()).then(|| { + let mut all = g.all.clone(); + all.apply_affine(t.xf); + all + }); + if let Some(all) = &all { + self.draw_text_items(ctx, f, n, below, all, tb); + } + if f.opts.highlight_substitutions { + for (on, path) in [ + (f.opts.highlight_substituted_fonts, Some(&g.substituted_fonts)), + (f.doc.setup.highlight_substituted_glyphs, Some(&g.substituted_glyphs)), + // Measured only when shown: it lays the text out again. + (f.opts.highlight_alternate_glyphs, f.opts.highlight_alternate_glyphs.then(|| g.alternate_glyphs(t))), + ] { + let Some(path) = path else { continue }; + if on && !path.elements().is_empty() { + ctx.set_transform(xf); + ctx.set_fill_rule(peniko::Fill::NonZero); + ctx.set_paint(SUBSTITUTED); + ctx.fill_path(path); + } + } + } + for (i, run) in t.runs.iter().enumerate() { + // Colour bitmap glyphs paint themselves: the run's fill and stroke don't. + for (_, image, to_text) in g.images.iter().filter(|(r, ..)| *r == i) { + overprint(ctx, false); + self.draw_glyph_image(ctx, f, image, t.xf * *to_text); + } + for (_, art) in g.colours.iter().filter(|(r, _)| *r == i) { + self.draw_colour_glyph(ctx, f, art, t.xf); + } + let path = g.run(i); + let path = path.as_ref(); + if path.elements().is_empty() { + continue; + } + ctx.set_transform(xf); + ctx.set_fill_rule(peniko::Fill::NonZero); + if paint::set_fill_paint(ctx, &run.style.fill, g.bounds, f) { + self.fold_alpha(ctx, f.ink, &run.style.fill); + overprint(ctx, run.style.overprint_fill); + ctx.fill_path(path); + } + if run.style.has_stroke() { + // Character strokes are drawn in text space, with their cap, join and dashes. + overprint(ctx, run.style.overprint_stroke); + let chars = kept.then_some((g, i)); + self.draw_stroke(ctx, f, t.xf, path, FillRule::NonZero, &run.style.stroke_layer(), chars, g.bounds); + } + } + overprint(ctx, false); + self.draw_inlines(ctx, f, t, g); + if let Some(all) = &all { + self.draw_text_items(ctx, f, n, above, all, tb); + } + } + + /// Paint some of type `n`'s own fills and strokes (`items`) on its glyph outlines `all` + /// (document space; `tb` their bounds). Fills composite with their own opacity and blend mode; + /// strokes take every stroke option, as on paths. + fn draw_text_items(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, items: &[AppearanceItem], all: &BezPath, tb: Rect) { + for item in items.iter().filter(|i| i.visible() && !i.paint().is_none()) { + match item { + AppearanceItem::Fill(fl) => { + let layered = fl.opacity < 1.0 || fl.blend != vectorcraft_color::BlendMode::Normal; + if layered { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(blend_mode(fl.blend)), Some(fl.opacity), None, None); + } + ctx.set_transform(f.view); + if paint::set_fill_paint(ctx, &fl.paint, tb, f) { + ctx.set_fill_rule(peniko::Fill::NonZero); + ctx.fill_path(all); + } + if layered { + ctx.pop_layer(); + } + } + AppearanceItem::Stroke(st) if st.width > 0.0 => { + if st.brush.is_none() || !self.draw_brush(ctx, f, n, all, st) { + self.draw_stroke(ctx, f, Affine::IDENTITY, all, FillRule::NonZero, st, None, tb); + } + } + AppearanceItem::Stroke(_) => {} + } + } + } + + /// Draw COLR glyph `art` (its layers in text space) through `xf` (text space → document), as + /// art: kept between frames as symbol art is ([`Self::placed_art`]). + fn draw_colour_glyph(&mut self, ctx: &mut RenderContext, f: &Frame, art: &Arc, xf: Affine) { + let (node, kept) = self.placed_art(art, xf, true, None); + let outer = std::mem::replace(&mut self.made_again, !kept); + self.draw_node(ctx, f, &node, true); + self.made_again = outer; + } + + /// Draw colour bitmap glyph `image` through `xf` (its pixels → document space), its pixels + /// decoded once and kept with the images' pixels. + fn draw_glyph_image(&mut self, ctx: &mut RenderContext, f: &Frame, image: &vectorcraft_text::GlyphImage, xf: Affine) { + let Some((key, pm)) = blob_pixmap(&image.blob, true) else { return }; + let (key, pm) = ink::ink_image(&key, &pm, f.ink, Some(&image.blob), true); + let unit = f.view * xf * Affine::scale_non_uniform(image.width as f64, image.height as f64); + let pm = image_mip(&key, pm, unit); + let sx = image.width as f64 / pm.width().max(1) as f64; + let sy = image.height as f64 / pm.height().max(1) as f64; + let quality = if f.opts.smooth_images { peniko::ImageQuality::Medium } else { peniko::ImageQuality::Low }; + ctx.set_paint(vello_cpu::Image { + image: vello_cpu::ImageSource::Pixmap(pm), + sampler: peniko::ImageSampler { quality, ..Default::default() }, + }); + ctx.set_transform(f.view * xf); + ctx.set_paint_transform(Affine::scale_non_uniform(sx, sy)); + ctx.fill_rect(&image.rect()); + ctx.reset_paint_transform(); + } +} diff --git a/crates/render/src/text_geom.rs b/crates/render/src/text_geom.rs new file mode 100644 index 000000000..9c2ddad2f --- /dev/null +++ b/crates/render/src/text_geom.rs @@ -0,0 +1,208 @@ +use super::*; + +/// The whole text's glyph outlines as one path, and which of them each run draws. +pub(crate) struct TextGeom { + pub(crate) all: BezPath, + /// Spans of `all`'s elements drawn by a run, in order: (run, start, end). Outlines of runs + /// the text doesn't have are in `all` only. + spans: Vec<(usize, usize, usize)>, + pub(crate) bounds: Rect, + /// The boxes of characters whose font is missing (drawn in a substitute), and of glyphs the + /// font lacks (drawn from a fallback font): Highlight Substituted Fonts and Highlight + /// Substituted Glyphs. + pub(crate) substituted_fonts: BezPath, + pub(crate) substituted_glyphs: BezPath, + /// Glyphs OpenType may have substituted (a ligature, or not the face's cmap glyph for their + /// character), each with its box: what Preferences › Highlight Alternate Glyphs weighs + /// ([`TextGeom::alternate_glyphs`]). + pub(crate) alternate_candidates: Vec, + /// The boxes of the candidates that are alternates, measured the first time the preference + /// shows them. + alternate_glyphs: std::sync::OnceLock, + /// Inline graphics placed by the layout. + pub(crate) inlines: Vec, + /// Colour bitmap glyphs: (run, image, its pixels → text space), in text order. + pub(crate) images: Vec<(usize, Arc, Affine)>, + /// COLR colour glyphs: (run, their layers placed in text space), in text order. + pub(crate) colours: Vec<(usize, Arc)>, +} + +impl TextGeom { + /// The outlines run `i` draws: the glyphs of `all` it has, in their order. Kept once rather + /// than per run as well, which held every outline twice. + pub(crate) fn run(&self, i: usize) -> std::borrow::Cow<'_, BezPath> { + let els = self.all.elements(); + if let [(r, 0, end)] = self.spans[..] + && r == i + && end == els.len() + { + return std::borrow::Cow::Borrowed(&self.all); + } + let mut p = BezPath::new(); + for &(_, start, end) in self.spans.iter().filter(|s| s.0 == i) { + p.extend(els.get(start..end).unwrap_or_default().iter().copied()); + } + std::borrow::Cow::Owned(p) + } + + /// The boxes of the glyphs of `t` (the text this geometry is of) that optional OpenType features + /// chose (Preferences › Highlight Alternate Glyphs): candidates the text's required shaping + /// alone doesn't give ([`required_shaping`]). Arabic letters joining, Indic conjuncts and the + /// lam-alef ligature are what the script needs, not alternates. Measured once. + pub(crate) fn alternate_glyphs(&self, t: &TextObject) -> &BezPath { + self.alternate_glyphs.get_or_init(|| { + let mut out = BezPath::new(); + if self.alternate_candidates.is_empty() { + return out; + } + let required = required_shaping(t); + for c in &self.alternate_candidates { + if !required.get(&(c.byte, c.len)).is_some_and(|glyphs| glyphs.contains(&(c.font_id, c.gid))) { + out.extend(c.cell.iter()); + } + } + out + }) + } + + /// Approximate heap bytes, for the cache's budget. + pub(crate) fn bytes(&self) -> usize { + size_of::() + + [&self.all, &self.substituted_fonts, &self.substituted_glyphs].into_iter().map(lru::path_bytes).sum::() + + self.alternate_candidates.iter().map(|c| size_of::() + lru::path_bytes(&c.cell)).sum::() + + self.alternate_glyphs.get().map_or(0, lru::path_bytes) + + self.spans.len() * size_of::<(usize, usize, usize)>() + + self.inlines.len() * size_of::() + + self.images.len() * size_of::<(usize, Arc, Affine)>() + + self.colours.iter().map(|(_, art)| lru::node_bytes(art)).sum::() + } +} + +/// OpenType-substituted form: a ligature (several source characters → one glyph) or a glyph whose +/// id is not the face's cmap mapping for its first character (contextual / stylistic alternate). +pub(crate) fn is_opentype_alternate(nominal_gid: u32, shaped_gid: u32, cluster_chars: usize) -> bool { + shaped_gid != 0 && (cluster_chars > 1 || shaped_gid != nominal_gid) +} + +/// A glyph [`is_opentype_alternate`] marks, in its cluster of the text (`byte`, `len`), with its +/// box. +#[derive(Debug)] +pub(crate) struct AlternateCandidate { + byte: usize, + len: usize, + font_id: u32, + gid: u32, + cell: BezPath, +} + +/// The glyphs `t` is shaped to by required shaping alone, by cluster (`byte`, `len`) → `(font, +/// glyph)`: laid out again with every optional feature off (standard and contextual ligatures and +/// alternates, and the features its characters turn on: stylistic sets, swashes, discretionary +/// ligatures, figure styles…). What is left is what the script needs: `ccmp`, `locl`, `rlig`, +/// Arabic's `init`, `medi`, `fina` and `isol`, the Indic and other complex scripts' required sets. +fn required_shaping(t: &TextObject) -> std::collections::HashMap<(usize, usize), Vec<(u32, u32)>> { + let mut plain = t.clone(); + for run in &mut plain.runs { + run.style.features = vec!["-liga".into(), "-calt".into()]; + } + let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &plain); + let mut glyphs: std::collections::HashMap<(usize, usize), Vec<(u32, u32)>> = std::collections::HashMap::new(); + for g in &layout.glyphs { + glyphs.entry((g.byte, g.len)).or_default().push((g.font_id, g.gid)); + } + glyphs +} + +pub(crate) fn text_geom(t: &TextObject) -> TextGeom { + text_geom_snapped(t, None) +} + +/// Glyph geometry of `t`; `snap` (text space → device pixels) puts the glyphs on whole pixels. +pub(crate) fn text_geom_snapped(t: &TextObject, snap: Option) -> TextGeom { + let db = vectorcraft_text::FontDb::global(); + let mut layout = vectorcraft_text::layout(db, t); + if let Some(xf) = snap { + layout.snap_to_pixels(xf); + } + let mut all = BezPath::new(); + let mut spans: Vec<(usize, usize, usize)> = vec![]; + // Append `outline` to `all`, drawn by `run` when the text has that run. + let mut push = |all: &mut BezPath, run: usize, outline: &BezPath| { + let start = all.elements().len(); + all.extend(outline.iter()); + let end = all.elements().len(); + if run >= t.runs.len() || start == end { + return; + } + match spans.last_mut() { + Some((r, _, e)) if *r == run && *e == start => *e = end, + _ => spans.push((run, start, end)), + } + }; + // Per run: is its family missing, and the face its glyphs should come from. + let faces: Vec<(bool, Option)> = t + .runs + .iter() + .map(|r| match db.resolve(&r.style.font_family, &r.style.font_style) { + // The version the type names, when it's installed (see `FontDb::face_version`), at its + // Variable Font settings (see `FontDb::style_face`). + Some((f, m)) => { + let face = db.style_face(&r.style).unwrap_or(f); + (m == vectorcraft_text::FontMatch::Missing, Some(face.id())) + } + None => (true, None), + }) + .collect(); + let (mut substituted_fonts, mut substituted_glyphs, mut alternate_candidates) = (BezPath::new(), BezPath::new(), vec![]); + let plain = t.plain_text(); + let (mut images, mut colours) = (vec![], vec![]); + for g in &layout.glyphs { + push(&mut all, g.run, &g.outline); + if let Some((image, xf)) = g.placed_image() { + images.push((g.run, image.clone(), xf)); + } + if let Some(art) = g.placed_colour(Affine::IDENTITY) { + colours.push((g.run, Arc::new(art))); + } + let Some(&(missing, face)) = faces.get(g.run) else { continue }; + let Some(line) = layout.lines.get(g.line) else { continue }; + let mut cell = Rect::new(g.origin.x, g.origin.y - line.ascent, g.origin.x + g.advance, g.origin.y + line.descent).to_path(0.1); + cell.apply_affine(Affine::rotate_about(g.angle, g.origin)); + if missing { + substituted_fonts.extend(cell.iter()); + continue; + } + if face.is_some_and(|f| f != g.font_id) { + substituted_glyphs.extend(cell.iter()); + continue; + } + // Same face: OpenType may have swapped the glyph (liga, calt, …). + let Some(cluster) = plain.get(g.byte..g.byte.saturating_add(g.len)) else { continue }; + let cluster_chars = cluster.chars().count(); + let Some(first) = cluster.chars().next() else { continue }; + let nominal = db.face_by_id(g.font_id).map(|f| f.glyph_for(first)).unwrap_or(0); + if is_opentype_alternate(nominal, g.gid, cluster_chars) { + alternate_candidates.push(AlternateCandidate { byte: g.byte, len: g.len, font_id: g.font_id, gid: g.gid, cell }); + } + } + // Ruby (furigana) glyphs: painted with their base text's run. + for g in &layout.ruby { + push(&mut all, g.run, &g.outline); + } + // Underline and strikethrough bars, painted as their run's type is (#847). + for (run, bar) in vectorcraft_text::decorations(&layout, db, t) { + push(&mut all, run, &bar); + } + TextGeom { + all, + spans, + bounds: layout.bounds, + substituted_fonts, + substituted_glyphs, + alternate_candidates, + alternate_glyphs: std::sync::OnceLock::new(), + inlines: layout.inlines, + images, + colours, + } +} diff --git a/crates/render/src/threads.rs b/crates/render/src/threads.rs new file mode 100644 index 000000000..13a833a3b --- /dev/null +++ b/crates/render/src/threads.rs @@ -0,0 +1,36 @@ +use super::*; + +/// A render context on the calling thread. vello_cpu's `RenderContext::new` defaults to a +/// multithreaded dispatcher, which panics on filter effects (glows, shadows, blur). +pub(crate) fn single_threaded_context(w: u16, h: u16) -> RenderContext { + RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }) +} + +/// Preferred rasterizer thread count set by the app (Preferences → Performance); negative = automatic. +static THREADS_OVERRIDE: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1); + +/// Override the worker-thread count used by renderers created from now on (`None` = automatic). +pub fn set_default_threads(n: Option) { + THREADS_OVERRIDE.store(n.map_or(-1, i32::from), std::sync::atomic::Ordering::Relaxed); +} + +/// Rasterizer worker threads: 0 on wasm; otherwise one per core up to 8, leaving a core free. On a +/// 10-core machine 8 workers render a 50,000-path page 1.4× faster than 4 (9 are slower again); +/// the pixels don't depend on the count (`pixels_do_not_depend_on_the_thread_count`). +pub fn default_threads() -> u16 { + #[cfg(target_arch = "wasm32")] + { + 0 + } + #[cfg(not(target_arch = "wasm32"))] + { + let o = THREADS_OVERRIDE.load(std::sync::atomic::Ordering::Relaxed); + if o >= 0 { + return o.min(64) as u16; + } + if std::env::var_os("VECTORCRAFT_RENDER_THREADS").is_some() { + return std::env::var("VECTORCRAFT_RENDER_THREADS").ok().and_then(|v| v.parse().ok()).unwrap_or(0); + } + std::thread::available_parallelism().map(|n| (n.get().saturating_sub(1)).min(8) as u16).unwrap_or(0) + } +}