diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 96766913..21ae0278 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -315,7 +315,7 @@ impl<'a> Scene<'a> { } } // Live blends, envelopes, meshes and repeats are written as their evaluated art. - NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => { + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => { let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); for c in g.children().into_iter().flatten() { self.node(c, false); diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index e41efc8a..5695834a 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -527,7 +527,8 @@ impl Lerp { (NodeKind::Mesh(ma), NodeKind::Mesh(mb)) if ma.rows == mb.rows && ma.cols == mb.cols && ma.points.len() == mb.points.len() => Shape::Mesh, (NodeKind::Text(_), NodeKind::Text(_)) | (NodeKind::SymbolInstance { .. }, NodeKind::SymbolInstance { .. }) - | (NodeKind::Image(_), NodeKind::Image(_)) => Shape::Placed, + | (NodeKind::Image(_), NodeKind::Image(_)) + | (NodeKind::PlacedDocument(_), NodeKind::PlacedDocument(_)) => Shape::Placed, _ => match (node_path(a), node_path(b)) { (Some((pa, ra, ma)), Some((pb, rb, mb))) => { Shape::Path { pair: PathPair::new(&pa, &pb, starts), rules: (ra, rb), members: ma.zip(mb) } @@ -586,6 +587,7 @@ impl Lerp { *o = lerp_affine(*x, *y, t) } (NodeKind::Image(o), NodeKind::Image(x), NodeKind::Image(y)) => o.xf = lerp_affine(x.xf, y.xf, t), + (NodeKind::PlacedDocument(o), NodeKind::PlacedDocument(x), NodeKind::PlacedDocument(y)) => o.xf = lerp_affine(x.xf, y.xf, t), _ => {} } n.mask = None; diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 9db6e3dc..a741c109 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -199,9 +199,12 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option { n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p))?; on_type_path(n, t, p, opt.tol).then_some(HitKind::Outline) } - NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::SymbolInstance { .. } | NodeKind::Blend { .. } | NodeKind::Envelope { .. } => { - n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds) - } + NodeKind::Text(_) + | NodeKind::Image(_) + | NodeKind::PlacedDocument(_) + | NodeKind::SymbolInstance { .. } + | NodeKind::Blend { .. } + | NodeKind::Envelope { .. } => n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds), NodeKind::Repeat(r) => r.expand().iter().find_map(|g| hit_any(g, p, opt)), NodeKind::Mesh(m) => { let bp = m.outline().to_bezpath(); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index a9fb74da..e3b54a84 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -24,6 +24,7 @@ pub mod overprint; pub mod pattern; pub mod perspective; mod pixels; +pub mod placed_document; pub mod profiles; pub mod puppet; pub mod range; @@ -72,6 +73,7 @@ pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, N pub use orient::OrientedBox; pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType}; pub use perspective::PerspectiveAttachment; +pub use placed_document::PlacedDocument; pub use profiles::ColorProfiles; pub use puppet::{PuppetPin, PuppetPins}; pub use rastersettings::{RasterColorModel, RasterEffectsSettings}; diff --git a/crates/doc/src/links.rs b/crates/doc/src/links.rs index 2cd24d5a..d32ab993 100644 --- a/crates/doc/src/links.rs +++ b/crates/doc/src/links.rs @@ -137,7 +137,7 @@ impl Document { } } - /// The keys of image blobs only linked images show: what a save writes as their preview. + /// The keys of image blobs only linked images (or placed documents, always linked) show: what a save writes as their preview. pub fn linked_only_images(&self) -> BTreeSet { if self.images.values().all(|b| b.proxy.is_none()) { return BTreeSet::new(); @@ -152,6 +152,8 @@ impl Document { u.1 = true; } }); + // A placed document saves its file's preview the same way. + self.visit_placed(|_, p| uses.entry(p.key.as_str()).or_default().0 = true); uses.into_iter().filter(|(_, (linked, embedded))| *linked && !embedded).map(|(k, _)| k.to_string()).collect() } } diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 339a1cc8..51641b14 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -1447,6 +1447,8 @@ fn map_node(n: &Node, w: &Warper) -> Node { let im = im.clone(); return warp_image(&out, &im, w); } + // Its art is plain, so it warps at this depth. + NodeKind::PlacedDocument(_) => return map_node(&expanded_group_hooks(n, w.hooks), w), } out } @@ -1607,7 +1609,7 @@ pub fn envelope_bounds(content: &[Arc], kind: &EnvelopeKind, frame: Affine /// Is this one of the live kinds? pub fn is_live(n: &Node) -> bool { - matches!(n.kind, NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_)) + matches!(n.kind, NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_)) } /// Mesh tessellation as filled quad paths (no stroke), `n`×`n` per patch. @@ -1641,6 +1643,7 @@ pub fn expand_live_hooks(n: &Node, hooks: Hooks) -> Vec { NodeKind::Envelope { content, kind, fidelity, options, frame, .. } => expand_envelope(content, kind, *fidelity, *options, *frame, hooks), NodeKind::Mesh(m) => mesh_quad_nodes(m, 8), NodeKind::Repeat(r) => r.expand(), + NodeKind::PlacedDocument(p) => p.art(n), _ => vec![n.clone()], } } diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 845f6f2e..13ff5298 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -423,6 +423,9 @@ pub enum NodeKind { Mesh(GradientMesh), /// Live Repeat (radial / grid / mirror) of source art. Repeat(RepeatSpec), + /// A placed document: an artboard of another VectorCraft file, linked and locked (see + /// [`crate::placed_document`]). + PlacedDocument(Box), } fn yes() -> bool { @@ -642,6 +645,7 @@ impl Node { NodeKind::Envelope { .. } => "Envelope", NodeKind::Mesh(_) => "Mesh", NodeKind::Repeat(r) => r.kind.label(), + NodeKind::PlacedDocument(_) => "Placed Document", } } /// Name shown in the Layers panel: explicit name or ``. @@ -715,6 +719,7 @@ impl Node { NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, *frame), NodeKind::Mesh(m) => m.bounds(), NodeKind::Repeat(r) => r.bounds(), + NodeKind::PlacedDocument(p) => Some(p.bounds()), } } /// Visual bounds: what the object paints, its strokes included (paths and compound paths @@ -810,6 +815,7 @@ impl Node { } } NodeKind::Image(im) => im.xf = a * im.xf, + NodeKind::PlacedDocument(p) => p.xf = a * p.xf, NodeKind::SymbolInstance { xf, .. } => *xf = a * *xf, NodeKind::Blend { children, spec } => { for c in children.iter_mut() { @@ -972,6 +978,10 @@ impl Node { let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf); out.push((frame.to_bezpath(), FillRule::NonZero)); } + NodeKind::PlacedDocument(p) => { + let frame = shapes::rectangle(p.natural()).transformed(p.xf); + out.push((frame.to_bezpath(), FillRule::NonZero)); + } NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => { crate::live::expand_deep(self, text).push_clip_shapes(text, out); } diff --git a/crates/doc/src/placed_document.rs b/crates/doc/src/placed_document.rs new file mode 100644 index 00000000..2e0e93da --- /dev/null +++ b/crates/doc/src/placed_document.rs @@ -0,0 +1,684 @@ +//! Placed documents: another VectorCraft file placed linked ([`PlacedDocument`]). +//! +//! The object keeps the link to the file and the file's bytes, stored like image data in +//! [`Document::images`] (with a small preview a save writes instead, as for linked images), so the +//! document shows correctly without the file. The art it shows is read from those bytes and never +//! joins the object tree, so editing can't reach it: it is locked by design. Drawing and exporting +//! expand it like a live object ([`crate::live::expanded`]). +//! +//! The file is read by the native format's loader, which lives above this crate and registers +//! itself ([`set_loader`]); the art read is kept in a small cache by content. Before exporting, +//! every exporter calls [`Document::with_placed_art`], which adds the resources the art uses +//! (images, symbols, patterns) under names of their own ([`RESOURCE_PREFIX`]) to a temporary copy +//! of the document, so they never meet the user's or show in panels. Placed documents inside a +//! placed document are read the same way, up to [`MAX_DEPTH`] deep (documents that place each +//! other stop there, showing their previews). + +use std::borrow::Cow; +use std::collections::BTreeSet; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError}; + +use serde::{Deserialize, Serialize}; +use vectorcraft_geom::{Affine, Rect}; + +use vectorcraft_color::Paint; + +use crate::{AppearanceItem, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeKind, PatternDef, PlacementOptions, Symbol}; + +/// A placed document: an artboard of another VectorCraft file, shown as one locked object. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct PlacedDocument { + /// The file (its `page` is the artboard shown, 1-based). + pub link: LinkInfo, + /// Key into [`Document::images`]: the file's bytes (see [`key_for`]). + pub key: String, + /// The art's bounds rather than the artboard (File › Place's `crop: "bounding"`). + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub bounding: bool, + /// The artboard's size (or the art's) at 100% (pt). + pub width: f64, + pub height: f64, + /// Maps the box (0..width, 0..height) into the document. + pub xf: Affine, + /// Links panel → Placement Options: how the file read again takes this object's place. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub placement: PlacementOptions, +} + +/// The images key of a file's bytes (`hash`, [`crate::links::hash_bytes`]) placed showing +/// artboard `page` (`bounding`: its art's bounds): objects showing the same thing share a blob and +/// its preview. +pub fn key_for(hash: &str, page: u32, bounding: bool) -> String { + format!("{hash}-{page}{}", if bounding { "b" } else { "" }) +} + +impl PlacedDocument { + /// The artboard shown (1-based). + pub fn page(&self) -> u32 { + self.link.page.unwrap_or(1) + } + + /// The box at 100%. + pub fn natural(&self) -> Rect { + Rect::new(0.0, 0.0, self.width, self.height) + } + + /// The four corners of the box in the document. + pub fn corners(&self) -> [vectorcraft_geom::Point; 4] { + let r = self.natural(); + [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| self.xf * vectorcraft_geom::Point::new(x, y)) + } + + /// The box in the document (axis-aligned). + pub fn bounds(&self) -> Rect { + self.xf.transform_rect_bbox(self.natural()) + } + + /// The transform from the file's coordinates (its artboard or art bounds `frame`) to the + /// document; `None` when either size is empty. + pub fn art_xf(&self, frame: Rect) -> Option { + let ok = self.width > 0.0 && self.height > 0.0 && frame.width() > 0.0 && frame.height() > 0.0; + ok.then(|| { + self.xf * Affine::scale_non_uniform(self.width / frame.width(), self.height / frame.height()) * Affine::translate((-frame.x0, -frame.y0)) + }) + } + + /// The art the object shows, as plain objects with `n`'s id: one group, its resources named + /// as [`Document::with_placed_art`] adds them (nothing until that has read it). + pub fn art(&self, n: &Node) -> Vec { + let prefix = prefix(&self.key); + read_cached(self).and_then(|e| self.art_node(n, &e, &prefix)).into_iter().collect() + } + + /// Read `e`'s art placed as this object `n` shows it (`n`'s id), its resources renamed with + /// `prefix`. + fn art_node(&self, n: &Node, e: &ReadDocument, prefix: &str) -> Option { + let m = self.art_xf(e.frame)?; + let mut g = (*e.art).clone(); + rename(&mut g, prefix); + g.transform(m, true); + each_mut(&mut g, &mut |c| crate::pattern::transform_pattern_paints(c, m)); + g.id = n.id; + Some(g) + } + + /// An image object showing preview `bytes` (stored under this object's key) in this object's + /// box: what it shows and outputs as when the file can't be read. + pub fn preview_image(&self, bytes: &[u8]) -> Option { + let (w, h) = image_size(bytes).filter(|(w, h)| *w > 0 && *h > 0)?; + let px = Affine::scale_non_uniform(self.width / w as f64, self.height / h as f64); + Some(ImageObject { key: self.key.clone(), width: w, height: h, xf: self.xf * px, link: None, placement: Default::default() }) + } +} + +/// The names the resources of the art stored under `key` take in a prepared document. +fn prefix(key: &str) -> String { + format!("{RESOURCE_PREFIX}{key}/") +} + +/// `n` and every descendant, opacity-mask art included. +fn walk_all<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) { + f(n); + if let Some(m) = &n.mask { + walk_all(&m.art, f); + } + for c in n.children().into_iter().flatten() { + walk_all(c, f); + } +} + +/// The roots of `d`'s art: layers, symbol definitions and pattern swatches. +fn roots(d: &Document) -> impl Iterator> { + d.layers.iter().chain(d.symbols.iter().map(|s| &s.art)).chain(d.patterns.iter().flat_map(|p| &p.art)) +} + +fn roots_mut(d: &mut Document) -> impl Iterator> { + d.layers.iter_mut().chain(d.symbols.iter_mut().map(|s| &mut s.art)).chain(d.patterns.iter_mut().flat_map(|p| &mut p.art)) +} + +impl Document { + /// Visit every placed document: in the layers, then in symbol definitions and pattern + /// swatches, opacity-mask art included. + pub fn visit_placed<'a>(&'a self, mut f: impl FnMut(&'a Node, &'a PlacedDocument)) { + for root in roots(self) { + walk_all(root, &mut |n| { + if let NodeKind::PlacedDocument(p) = &n.kind { + f(n, p); + } + }); + } + } + + /// Does the document hold a placed document anywhere? + pub fn has_placed(&self) -> bool { + let mut any = false; + self.visit_placed(|_, _| any = true); + any + } + + /// Every placed document (those in the art they show too) replaced by a plain group of that + /// art, for apps that don't know them: call it on [`Self::with_placed_art`]'s copy. Only the + /// subtrees holding one are copied. + pub fn placed_as_groups(&mut self) { + fn holds(n: &Node) -> bool { + let mut hit = false; + walk_all(n, &mut |c| hit |= matches!(c.kind, NodeKind::PlacedDocument(_))); + hit + } + fn update(n: &mut Arc) { + if !holds(n) { + return; + } + if matches!(n.kind, NodeKind::PlacedDocument(_)) { + let mut g = crate::live::expanded_group(n, None); + g.appearance = n.appearance.clone(); + *n = Arc::new(g); + } + // The art shown may hold placed documents too. + let n = Arc::make_mut(n); + if let Some(m) = &mut n.mask { + update(&mut m.art); + } + for c in n.children_mut().into_iter().flatten() { + update(c); + } + } + for root in roots_mut(self) { + update(root); + } + } + + /// Placed documents whose key is in `keys` replaced by images of their previews (for output + /// when their files can't be read). + pub fn placed_as_previews(&mut self, keys: &BTreeSet) { + fn update(n: &mut Arc, keys: &BTreeSet, images: &std::collections::BTreeMap) { + let mut hit = false; + walk_all(n, &mut |c| hit |= matches!(&c.kind, NodeKind::PlacedDocument(p) if keys.contains(&p.key))); + if !hit { + return; + } + let n = Arc::make_mut(n); + if let NodeKind::PlacedDocument(p) = &n.kind + && keys.contains(&p.key) + { + if let Some(im) = images.get(&p.key).and_then(|b| p.preview_image(&b.bytes)) { + n.kind = NodeKind::Image(im); + } + return; + } + if let Some(m) = &mut n.mask { + update(&mut m.art, keys, images); + } + for c in n.children_mut().into_iter().flatten() { + update(c, keys, images); + } + } + let images = self.images.clone(); + for root in roots_mut(self) { + update(root, keys, &images); + } + } +} + +// ---------- previews and the files' bytes ---------- + +/// A placed document's preview: this many pixels per point of its box… +pub const PREVIEW_PX_PER_PT: f64 = 1.0; +/// …and at most this many on its longer side. +pub const PREVIEW_MAX: u32 = 1024; +/// How deep placed documents inside placed documents are read; deeper ones show their previews. +pub const MAX_DEPTH: usize = 8; + +/// Reads `p`'s file again (`None`: not found, or changed since it was read). The engine registers +/// one with [`set_file_reader`]. +pub type FileReader = fn(&PlacedDocument) -> Option>; + +static FILE_READER: OnceLock = OnceLock::new(); + +/// Register how files are read again (the first registration stays). +pub fn set_file_reader(f: FileReader) { + // Already set: the same engine, registered by another session. + let _ = FILE_READER.set(f); +} + +/// The most bytes of files read again kept. +const FULL_BYTES: usize = 512 << 20; + +/// Files read again, by key, the most recently read last. +type FullFiles = Vec<(String, Arc>)>; + +static FULL: Mutex = Mutex::new(Vec::new()); + +fn full() -> MutexGuard<'static, FullFiles> { + // Entries are only pushed and removed: a panic while held leaves it whole. + FULL.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// The file's bytes when they are at hand: stored (`blob` isn't just the preview) or read again +/// already. Never reads it. +pub fn full_bytes_cached(p: &PlacedDocument, blob: &ImageBlob) -> Option>> { + if !blob.is_proxy() { + return Some(blob.bytes.clone()); + } + full().iter().rev().find(|(k, _)| *k == p.key).map(|(_, b)| b.clone()) +} + +/// The file's bytes: stored, read again already, or read again now ([`set_file_reader`]). `None` +/// when only the preview can be had. +pub fn full_bytes(p: &PlacedDocument, blob: &ImageBlob) -> Option>> { + if let Some(b) = full_bytes_cached(p, blob) { + return Some(b); + } + let b = Arc::new(FILE_READER.get()?(p)?); + let mut c = full(); + c.push((p.key.clone(), b.clone())); + let mut total: usize = c.iter().map(|(_, b)| b.len()).sum(); + while total > FULL_BYTES && c.len() > 1 { + total = total.saturating_sub(c.remove(0).1.len()); + } + Some(b) +} + +/// The size in pixels of image bytes `bytes` (a preview), read from its header. +pub fn image_size(bytes: &[u8]) -> Option<(u32, u32)> { + image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok() +} + +// ---------- reading files ---------- + +/// The prefix of the names the resources of placed documents' art take in a prepared document: +/// `placed:/`. +pub const RESOURCE_PREFIX: &str = "placed:"; + +/// Reads a placed document's file (its bytes) into what it shows: a document holding the +/// resources (no layers), the art as one group in the file's coordinates, and the frame shown +/// (the artboard, or the art's bounds). The native format lives above this crate, so the engine +/// registers one with [`set_loader`]. +pub type Loader = fn(&PlacedDocument, &[u8]) -> Option<(Document, Node, Rect)>; + +static LOADER: OnceLock = OnceLock::new(); + +/// Register how files are read (the first registration stays). +pub fn set_loader(l: Loader) { + // Already set: the same loader, registered by another session. + let _ = LOADER.set(l); +} + +/// The most files kept read whatever their size. +const CACHE_SIZE: usize = 64; +/// Past [`CACHE_SIZE`], the most bytes of files kept read (their own sizes, standing for what +/// reading them holds). +const CACHE_BYTES: usize = 256 << 20; + +/// A placed document's file read: the art it shows, ready to draw with its own resources. +pub struct ReadDocument { + key: String, + /// Read from the preview, not the file. + preview: bool, + /// The bytes read (what tells a cached read is still the one shown). + bytes: Arc>, + /// The artboard (or art bounds) shown: what the object's box shows. + pub frame: Rect, + /// The art as one group, in the file's coordinates. Its images, symbols and patterns are + /// `doc`'s, under their own names. + pub art: Arc, + /// The file's resources and setup (no layers: the art is `art`). + pub doc: Arc, +} + +struct Entry { + e: Arc, + used: u64, +} + +#[derive(Default)] +struct Cache { + entries: std::collections::HashMap<(String, bool), Entry>, + clock: u64, +} + +static CACHE: std::sync::LazyLock> = std::sync::LazyLock::new(Default::default); + +fn cache() -> MutexGuard<'static, Cache> { + // A panic while the cache was held leaves it whole (entries are only inserted and removed). + CACHE.lock().unwrap_or_else(PoisonError::into_inner) +} + +impl Cache { + /// The read of `key` (its preview's when `preview`; made from `bytes` when given), marked used. + fn get(&mut self, key: &str, preview: bool, bytes: Option<&Arc>>) -> Option> { + self.clock += 1; + let clock = self.clock; + let e = self.entries.get_mut(&(key.to_string(), preview))?; + if bytes.is_some_and(|b| !Arc::ptr_eq(&e.e.bytes, b)) { + return None; + } + e.used = clock; + Some(e.e.clone()) + } + + fn insert(&mut self, e: Arc) { + self.clock += 1; + self.entries.insert((e.key.clone(), e.preview), Entry { e, used: self.clock }); + // The least recently used go while over both limits. + let mut total: usize = self.entries.values().map(|x| x.e.bytes.len()).sum(); + while self.entries.len() > CACHE_SIZE && total > CACHE_BYTES { + let Some(k) = self.entries.iter().min_by_key(|(_, x)| x.used).map(|(k, _)| k.clone()) else { break }; + if let Some(x) = self.entries.remove(&k) { + total = total.saturating_sub(x.e.bytes.len()); + } + } + } +} + +/// `p`'s file (`bytes`) read, from the cache when it was read already: what the renderer draws a +/// placed document from. `None` when it can't be read (or no loader is registered). +pub fn read(p: &PlacedDocument, bytes: &Arc>) -> Option> { + if let Some(e) = cache().get(&p.key, false, Some(bytes)) { + return Some(e); + } + let loader = LOADER.get()?; + let (doc, art, frame) = loader(p, bytes)?; + let e = Arc::new(ReadDocument { key: p.key.clone(), preview: false, bytes: bytes.clone(), frame, art: Arc::new(art), doc: Arc::new(doc) }); + cache().insert(e.clone()); + Some(e) +} + +/// `p`'s preview (`bytes`) read as art: one image filling the box. +fn read_preview(p: &PlacedDocument, bytes: &Arc>) -> Option> { + if let Some(e) = cache().get(&p.key, true, Some(bytes)) { + return Some(e); + } + let (w, h) = image_size(bytes).filter(|(w, h)| *w > 0 && *h > 0)?; + let frame = p.natural(); + let mut doc = Document::new(frame.width().max(1.0), frame.height().max(1.0)); + doc.layers.clear(); + doc.swatches.clear(); + let mime = if bytes.starts_with(&[0xff, 0xd8]) { "image/jpeg" } else { "image/png" }; + doc.images.insert("preview".into(), ImageBlob { mime: mime.into(), bytes: bytes.clone(), proxy: None }); + let xf = Affine::scale_non_uniform(frame.width() / w as f64, frame.height() / h as f64); + let im = ImageObject { key: "preview".into(), width: w, height: h, xf, link: None, placement: Default::default() }; + let art = Node::new(art_id(), NodeKind::Group { children: vec![Arc::new(Node::new(art_id(), NodeKind::Image(im)))], clip: false }); + let e = Arc::new(ReadDocument { key: p.key.clone(), preview: true, bytes: bytes.clone(), frame, art: Arc::new(art), doc: Arc::new(doc) }); + cache().insert(e.clone()); + Some(e) +} + +/// `p`'s file as read already (else its preview's), without reading it. +pub fn read_cached(p: &PlacedDocument) -> Option> { + let mut c = cache(); + c.get(&p.key, false, None).or_else(|| c.get(&p.key, true, None)) +} + +/// Keep `read` (what [`Loader`] gives), just read from `bytes` for `p` (when it was placed), so +/// drawing it doesn't read it again. +pub fn prime(p: &PlacedDocument, bytes: Arc>, (doc, art, frame): (Document, Node, Rect)) { + cache().insert(Arc::new(ReadDocument { key: p.key.clone(), preview: false, bytes, frame, art: Arc::new(art), doc: Arc::new(doc) })); +} + +/// The id the read art's own nodes take (their object's, once expanded). +fn art_id() -> crate::NodeId { + crate::NodeId(0) +} + +/// Visit `n` and every descendant (opacity-mask art included) mutably. +fn each_mut(n: &mut Node, f: &mut impl FnMut(&mut Node)) { + f(n); + if let Some(m) = &mut n.mask { + each_mut(Arc::make_mut(&mut m.art), f); + } + for c in n.children_mut().into_iter().flatten() { + each_mut(Arc::make_mut(c), f); + } +} + +/// `n`'s references to images, symbols, patterns and placed files prefixed with `prefix`. Colours +/// keep their swatch links (a spot colour stays a plate, one with the parent's spot of that name). +fn rename(n: &mut Node, prefix: &str) { + let paint = |p: &mut Paint| { + if let Paint::Pattern { pattern, .. } = p { + *pattern = format!("{prefix}{pattern}"); + } + }; + each_mut(n, &mut |n| { + for it in &mut n.appearance.items { + match it { + AppearanceItem::Fill(f) => paint(&mut f.paint), + AppearanceItem::Stroke(s) => paint(&mut s.paint), + } + } + match &mut n.kind { + NodeKind::Text(t) => { + for r in &mut t.runs { + paint(&mut r.style.fill); + paint(&mut r.style.stroke); + } + } + NodeKind::Image(im) => im.key = format!("{prefix}{}", im.key), + NodeKind::PlacedDocument(p) => p.key = format!("{prefix}{}", p.key), + NodeKind::SymbolInstance { symbol, .. } => *symbol = format!("{prefix}{symbol}"), + _ => {} + } + }); +} + +impl Document { + /// This document ready to export its placed documents: their files read (the file read again + /// when only the preview is stored, else the preview), and the resources their art uses added + /// under names of their own ([`RESOURCE_PREFIX`]); the same for the placed documents in that + /// art, up to [`MAX_DEPTH`] deep. Borrowed when there is none. (The renderer draws them from + /// [`read`] instead.) + pub fn with_placed_art(&self) -> Cow<'_, Document> { + let mut queue: Vec<(PlacedDocument, usize)> = vec![]; + let mut seen = BTreeSet::new(); + self.visit_placed(|_, p| { + if seen.insert(p.key.clone()) { + queue.push((p.clone(), 0)); + } + }); + if queue.is_empty() { + return Cow::Borrowed(self); + } + let mut d = self.clone(); + let mut found: Vec> = vec![]; + while let Some((p, depth)) = queue.pop() { + let Some(blob) = d.images.get(&p.key).cloned() else { continue }; + let full = if depth < MAX_DEPTH { full_bytes(&p, &blob) } else { None }; + if full.and_then(|b| read(&p, &b)).is_none() { + let preview = if blob.is_proxy() { Some(blob.bytes.clone()) } else { blob.proxy.clone() }; + if let Some(bytes) = preview { + read_preview(&p, &bytes); + } + } + // What the art (`PlacedDocument::art`) will show. + let Some(e) = read_cached(&p) else { continue }; + let prefix = prefix(&p.key); + add_resources(&mut d, &e.doc, &prefix); + // The placed documents in that art, under their names here. + let inner = std::iter::once(&e.art).chain(e.doc.symbols.iter().map(|s| &s.art)).chain(e.doc.patterns.iter().flat_map(|p| &p.art)); + for root in inner { + walk_all(root, &mut |n| { + if let NodeKind::PlacedDocument(q) = &n.kind { + let key = format!("{prefix}{}", q.key); + if seen.insert(key.clone()) { + queue.push((PlacedDocument { key, ..(**q).clone() }, depth + 1)); + } + } + }); + } + found.push(e); + } + // Read last, so all stay read while the art is made (see `PlacedDocument::art`). + { + let mut c = cache(); + for e in &found { + c.get(&e.key, e.preview, None); + } + } + Cow::Owned(d) + } + + /// Drop the resources [`Self::with_placed_art`] added under its names (what a save leaves out). + pub fn drop_placed_resources(&mut self) { + self.symbols.retain(|s| !s.name.starts_with(RESOURCE_PREFIX)); + self.patterns.retain(|p| !p.name.starts_with(RESOURCE_PREFIX)); + self.images.retain(|k, _| !k.starts_with(RESOURCE_PREFIX)); + } +} + +/// The resources of `src` (a read file's) added to `d` under `prefix`: symbols, patterns, images; +/// swatches by name, when `d` has none of that name. +fn add_resources(d: &mut Document, src: &Document, prefix: &str) { + for s in &src.symbols { + let name = format!("{prefix}{}", s.name); + if !d.symbols.iter().any(|x| x.name == name) { + let mut s = s.clone(); + rename(Arc::make_mut(&mut s.art), prefix); + d.symbols.push(Symbol { name, ..s }); + } + } + for p in &src.patterns { + let name = format!("{prefix}{}", p.name); + if d.pattern(&name).is_none() { + let mut p = p.clone(); + p.art.iter_mut().for_each(|a| rename(Arc::make_mut(a), prefix)); + d.patterns.push(PatternDef { name, ..p }); + } + } + for (k, b) in &src.images { + d.images.entry(format!("{prefix}{k}")).or_insert_with(|| b.clone()); + } + for w in &src.swatches { + if d.swatch(&w.name).is_none() { + d.swatches.push(w.clone()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::NodeId; + + fn placed(key: &str) -> PlacedDocument { + PlacedDocument { + link: LinkInfo::new("/art/logo.vectorcraft"), + key: key.into(), + bounding: false, + width: 80.0, + height: 40.0, + xf: Affine::translate((10.0, 10.0)), + placement: Default::default(), + } + } + + /// Whatever the bytes: a 40×20 artboard showing a symbol instance and, when the bytes say + /// `nest`, a placed document of key `inner`. + fn stub_loader(_: &PlacedDocument, bytes: &[u8]) -> Option<(Document, Node, Rect)> { + let mut d = Document::new(40.0, 20.0); + d.layers.clear(); + let art = Node::new( + NodeId(50), + NodeKind::Image(ImageObject { key: "pic".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default() }), + ); + d.symbols.push(Symbol { name: "Star".into(), art: Arc::new(art) }); + d.images.insert("pic".into(), ImageBlob::new("image/png", vec![1, 2, 3])); + let mut children = vec![Arc::new(Node::new(NodeId(51), NodeKind::SymbolInstance { symbol: "Star".into(), xf: Affine::IDENTITY }))]; + if bytes.starts_with(b"nest") { + d.images.insert("inner".into(), ImageBlob::new("application/json", b"leaf".to_vec())); + let inner = PlacedDocument { key: "inner".into(), width: 10.0, height: 10.0, xf: Affine::IDENTITY, ..placed("inner") }; + children.push(Arc::new(Node::new(NodeId(52), NodeKind::PlacedDocument(Box::new(inner))))); + } + Some((d, Node::new(NodeId(0), NodeKind::Group { children, clip: false }), Rect::new(0.0, 0.0, 40.0, 20.0))) + } + + #[test] + fn round_trips_through_json() { + let n = Node::new(NodeId(3), NodeKind::PlacedDocument(Box::new(placed("doc1-1")))); + let v = serde_json::to_value(&n).unwrap(); + assert_eq!(v["kind"]["type"], "placeddocument"); + assert_eq!(v["kind"]["link"]["path"], "/art/logo.vectorcraft"); + let back: Node = serde_json::from_value(v).unwrap(); + assert_eq!(back, n); + assert_eq!(key_for("img00", 2, true), "img00-2b"); + } + + #[test] + fn the_art_and_its_resources_join_only_a_prepared_copy_under_their_own_names() { + set_loader(stub_loader); + let mut d = Document::new(200.0, 200.0); + d.symbols.push(Symbol { name: "Star".into(), art: Arc::new(Node::new(NodeId(60), NodeKind::Group { children: vec![], clip: false })) }); + d.images.insert("doc1-1".into(), ImageBlob::new("application/json", b"nest".to_vec())); + let p = placed("doc1-1"); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, Node::new(NodeId(7), NodeKind::PlacedDocument(Box::new(p.clone())))).unwrap(); + assert!(d.has_placed()); + let prepared = d.with_placed_art(); + let names: Vec<&str> = prepared.symbols.iter().map(|s| s.name.as_str()).collect(); + // The placed document inside is read too, its resources under its own names. + assert_eq!(names, ["Star", "placed:doc1-1/Star", "placed:placed:doc1-1/inner/Star"]); + assert!(prepared.images.contains_key("placed:doc1-1/pic")); + assert_eq!(d.symbols.len(), 1, "the document itself is unchanged"); + // The art: a group of the drawing, its resources renamed. + let n = d.node(NodeId(7)).unwrap(); + let art = p.art(n); + let [g] = &art[..] else { panic!("{art:?}") }; + let mut refs = vec![]; + g.walk(&mut |c| match &c.kind { + NodeKind::SymbolInstance { symbol, .. } => refs.push(symbol.clone()), + NodeKind::PlacedDocument(q) => { + // Its 10×10 box at the file's origin, scaled ×2 into the box at (10, 10). + assert_eq!(q.bounds(), Rect::new(10.0, 10.0, 30.0, 30.0)); + refs.push(q.key.clone()) + } + _ => {} + }); + assert_eq!(refs, ["placed:doc1-1/Star", "placed:doc1-1/inner"]); + // For older apps: plain groups all the way down. + let mut older = prepared.clone().into_owned(); + older.placed_as_groups(); + assert!(!older.has_placed()); + let mut saved = prepared.into_owned(); + saved.drop_placed_resources(); + assert_eq!((saved.symbols.len(), saved.images.len()), (1, 1)); + } + + #[test] + fn more_files_than_the_cache_holds_all_draw() { + set_loader(stub_loader); + let mut d = Document::new(200.0, 200.0); + let layer = d.layers[0].id; + let n = CACHE_SIZE + 10; + for i in 0..n { + let key = format!("many{i}-1"); + d.images.insert(key.clone(), ImageBlob::new("application/json", format!("doc {i}").into_bytes())); + d.insert(Some(layer), i, Node::new(NodeId(100 + i as u64), NodeKind::PlacedDocument(Box::new(placed(&key))))).unwrap(); + } + let _prepared = d.with_placed_art(); + let mut empty = 0; + d.visit_placed(|n, p| empty += usize::from(p.art(n).is_empty())); + assert_eq!(empty, 0, "every file of the document is kept read"); + } + + #[test] + fn a_file_that_cant_be_read_shows_its_preview() { + set_loader(stub_loader); + let mut png = vec![]; + image::RgbaImage::from_pixel(8, 4, image::Rgba([0, 200, 0, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let mut blob = ImageBlob::new("image/png", png); + blob.proxy = Some(blob.bytes.clone()); + let mut d = Document::new(200.0, 200.0); + d.images.insert("gone-1".into(), blob); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, Node::new(NodeId(7), NodeKind::PlacedDocument(Box::new(placed("gone-1"))))).unwrap(); + // No file reader: only the preview can be had. + let prepared = d.with_placed_art(); + assert!(prepared.images.contains_key("placed:gone-1/preview")); + let n = d.node(NodeId(7)).unwrap(); + let NodeKind::PlacedDocument(p) = &n.kind else { panic!("not a placed document") }; + let art = p.art(n); + assert_eq!(art.first().and_then(Node::geometric_bounds), Some(Rect::new(10.0, 10.0, 90.0, 50.0))); + } +} diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 893d2125..f1a08bee 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -324,14 +324,19 @@ pub fn bake_appearance(n: &Node) -> Option { Some(m) } -/// A copy of `doc` with every live geometry effect baked into plain paths, or `None` when the -/// document has none (export it as is). +/// A copy of `doc` ready to export: every live geometry effect baked into plain paths, and the +/// resources of its placed documents' art added ([`Document::with_placed_art`]); `None` when +/// there is nothing to do (export it as is). pub fn bake_document(doc: &Document) -> Option { - if !doc.layers.iter().any(|l| needs_bake(l)) { - return None; + let placed = doc.with_placed_art(); + if !placed.layers.iter().any(|l| needs_bake(l)) { + return match placed { + std::borrow::Cow::Owned(d) => Some(d), + std::borrow::Cow::Borrowed(_) => None, + }; } - let mut d = doc.clone(); - let layers = doc.layers.clone(); + let mut d = placed.into_owned(); + let layers = d.layers.clone(); d.layers = layers.iter().map(|l| bake_node(&mut d, l).map(Arc::new).unwrap_or_else(|| l.clone())).collect(); Some(d) } diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 0efcb90e..d39fbef4 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -26,6 +26,7 @@ pub fn needs_outline(n: &Node) -> bool { | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) + | NodeKind::PlacedDocument(_) ) } diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index b118c842..e966c644 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -127,6 +127,7 @@ fn node_refs(n: &Node, f: Visit) { } } NodeKind::Image(im) => f(Ref::Name(Res::Image, &im.key)), + NodeKind::PlacedDocument(pl) => f(Ref::Name(Res::Image, &pl.key)), NodeKind::SymbolInstance { symbol, .. } => f(Ref::Name(Res::Symbol, symbol)), _ => {} } @@ -444,6 +445,7 @@ impl Relink { } } NodeKind::Image(im) => self.name(Res::Image, &mut im.key), + NodeKind::PlacedDocument(pl) => self.name(Res::Image, &mut pl.key), NodeKind::SymbolInstance { symbol, .. } => self.name(Res::Symbol, symbol), _ => {} } diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index 6654df57..34027e70 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -418,6 +418,7 @@ fn not_liquified(n: &Node) -> Option<&'static str> { NodeKind::Mesh(_) => "meshes", NodeKind::Envelope { .. } => "envelopes", NodeKind::Repeat(_) => "repeats", + NodeKind::PlacedDocument(_) => "placed documents", NodeKind::Blend { .. } => "blends", _ => return None, }) diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 29a0d744..05cf1bc3 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -49,13 +49,14 @@ pub const CATEGORIES: [(&str, &str); 11] = [ ]; /// The object counts of `document.info` and their labels. -const OBJECT_LABELS: [(&str, &str); 16] = [ +const OBJECT_LABELS: [(&str, &str); 17] = [ ("paths", "Paths"), ("compoundPaths", "Compound Paths"), ("groups", "Groups"), ("clipGroups", "Clipping Masks"), ("textObjects", "Text Objects"), ("images", "Images"), + ("placedDocuments", "Placed Documents"), ("symbolInstances", "Symbol Instances"), ("gradients", "Gradient Objects"), ("patterns", "Pattern Objects"), @@ -252,6 +253,7 @@ fn info(s: &mut Session, p: &Value) -> Result { NodeKind::Envelope { .. } => Some("envelopes"), NodeKind::Mesh(_) => Some("meshes"), NodeKind::Repeat(_) => Some("repeats"), + NodeKind::PlacedDocument(_) => Some("placedDocuments"), }; if let Some(k) = k { *counts.entry(k).or_default() += 1; diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index 7661793c..6def788b 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -1185,6 +1185,8 @@ impl Scratch { let art = self.doc.symbols.iter().find(|s| &s.name == symbol).map(|s| vectorcraft_render::instance_art(&s.art, n)); art.and_then(|a| effects::outline_art(n, Some(&a))).map(|g| carry(n, g)) } + // A placed document stays one: its art's resources aren't the document's. + NodeKind::PlacedDocument(_) => None, NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) => { let hook: &dyn Fn(&Node) -> Option = &effects::outline_text; Some(carry(n, vectorcraft_doc::live::expanded_group(n, Some(hook)))) diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index 0c154ac6..99f4012a 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -291,7 +291,7 @@ impl<'a> Scene<'a> { } } // Live blends, envelopes, meshes and repeats are written as their evaluated art. - NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => { + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => { let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); for c in g.children().into_iter().flatten() { self.node(c, false); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index c6395c5c..e49c7e0f 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -23,7 +23,7 @@ //! Versions: v1 wrote anchors as `{p: {x, y}, in: {x, y}, out: {x, y}, kind}`; v2 as //! `{p: [x, y], in?, out?, kind?}` with default-valued fields left out; v3 keeps the assigned colour //! profiles in `document.color_profiles` (before: `document.unknown.colorProfiles`) and may be -//! compressed; v4 may keep large data (images, the PDF, profiles of at least +//! compressed; v4 may keep large data (images, placed documents, the PDF, profiles of at least //! [`INLINE_MAX`] bytes) after the JSON instead of in it: the JSON is followed by [`BLOB_MAGIC`] and //! the bytes, and such an entry says where they are (`"blob": [offset, length]`) instead of giving //! `data`. Every version loads, and [`save_with`] writes any of them for older apps (under the @@ -137,6 +137,11 @@ impl Blobs { } } +/// The MIME type of a preview's bytes (JPEG or PNG). +fn preview_mime(bytes: &[u8]) -> &'static str { + if bytes.starts_with(&[0xff, 0xd8]) { "image/jpeg" } else { vectorcraft_doc::links::PROXY_MIME } +} + /// The JSON of a file and the data after it (v4), split at [`BLOB_MAGIC`] after the first JSON /// value. fn split_tail(text: &[u8]) -> (&[u8], &[u8]) { @@ -232,6 +237,14 @@ pub fn save_with(doc: &Document, o: &SaveOptions) -> Result, FormatError o.check()?; let pretty = o.pretty && !o.compress; let mut d = doc.without_edit_modes().into_owned(); + // Older apps don't know placed documents: they get the art the objects show, with its + // resources. Otherwise resources only exporting adds are never saved. + if o.version < VERSION && d.has_placed() { + d = d.with_placed_art().into_owned(); + d.placed_as_groups(); + } else { + d.drop_placed_resources(); + } let linked = d.linked_only_images(); // The blobs go in the file's `images` (only those the document uses). let blobs = std::mem::take(&mut d.images); @@ -256,7 +269,7 @@ pub fn save_with(doc: &Document, o: &SaveOptions) -> Result, FormatError // Include Linked Files keeps the file's bytes whenever they are loaded. let preview_only = linked.contains(k) && (!o.include_linked || b.is_proxy()); let image = match b.proxy.as_ref().filter(|_| preview_only) { - Some(p) => out_blobs.image(vectorcraft_doc::links::PROXY_MIME, p, true), + Some(p) => out_blobs.image(preview_mime(p), p, true), None => out_blobs.image(&b.mime, &b.bytes, false), }; (k.as_str(), image) diff --git a/crates/metafile/src/scene.rs b/crates/metafile/src/scene.rs index 3734f27c..b19545b6 100644 --- a/crates/metafile/src/scene.rs +++ b/crates/metafile/src/scene.rs @@ -189,7 +189,7 @@ impl<'a> Scene<'a> { } } // Live blends, envelopes, meshes and repeats are written as their evaluated art. - NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => { + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => { let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); for c in g.children().into_iter().flatten() { self.node(c, false); diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 239c935b..548c33b9 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -982,7 +982,7 @@ impl Exporter<'_> { } } // Live blends/envelopes/meshes export their evaluated (expanded) form. - NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => { + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => { let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); for c in g.children().into_iter().flatten() { self.node(s, c, page, false); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index f548d147..a6e43538 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -15,6 +15,7 @@ mod ink; mod live; mod paint; mod pattern; +pub mod placed_document; pub mod proof; use std::collections::HashMap; @@ -580,6 +581,23 @@ impl Renderer { 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 { + 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 frame = Frame { mt: false, doc, view, visible, px, opts: &opts, ink: Ink::Display }; + (self.knockout, self.nested, self.backdrop) = (false, 0, None); + self.clip_paths.clear(); + self.draw_arc(&mut ctx, &frame, n); + ctx.flush(); + let mut pm = Pixmap::new(w, h); + ctx.render(&mut pm, &mut self.resources); + 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. pub fn render_node_thumbnail(&mut self, doc: &Document, n: &Node, size: u32, background: Option<[u8; 4]>) -> Option { @@ -916,7 +934,9 @@ impl Renderer { self.draw_node(ctx, f, &art, true); } } - NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => self.draw_live_node(ctx, f, n), + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => { + self.draw_live_node(ctx, f, n) + } } } @@ -1649,6 +1669,8 @@ mod tests_layeropts; #[cfg(test)] mod tests_objectfx; #[cfg(test)] +mod tests_placed_document; +#[cfg(test)] mod tests_setup; #[cfg(test)] mod tests_strokegradient; diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs index f1b5fb3d..5b870d2d 100644 --- a/crates/render/src/live.rs +++ b/crates/render/src/live.rs @@ -143,6 +143,7 @@ impl Renderer { /// Draw the evaluated content of a live node (no transparency group). pub(crate) fn draw_live_body(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc, cache: bool) { match &a.kind { + NodeKind::PlacedDocument(p) => self.draw_placed(ctx, f, p), NodeKind::Mesh(m) => self.draw_mesh(ctx, f, a, m, cache), NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) | NodeKind::Group { .. } | NodeKind::Layer { .. } => { let items = self.live_expanded(f.doc, a, cache); diff --git a/crates/render/src/placed_document.rs b/crates/render/src/placed_document.rs new file mode 100644 index 00000000..8955b6b0 --- /dev/null +++ b/crates/render/src/placed_document.rs @@ -0,0 +1,88 @@ +//! Drawing placed documents ([`PlacedDocument`]): the art read from the file +//! ([`vectorcraft_doc::placed_document::read`]) drawn with its own resources, through a view that +//! maps its artboard into the object's box (nothing is copied into the document). +//! +//! A document opened with only its placed documents' previews +//! ([`vectorcraft_doc::ImageBlob::is_proxy`]) shows them until the file is at hand +//! ([`vectorcraft_doc::placed_document::full_bytes_cached`]), and for good when it can't be found. Placed documents inside a placed document are read from their +//! files, up to [`MAX_DEPTH`] deep; deeper ones show their previews. + +use std::cell::Cell; +use std::sync::Arc; + +use vectorcraft_doc::placed_document::MAX_DEPTH; +use vectorcraft_doc::{Node, PlacedDocument}; +use vectorcraft_geom::Affine; +use vello_cpu::RenderContext; + +use crate::{Frame, Renderer}; + +thread_local! { + /// How deep inside placed documents this thread is drawing. + static DEPTH: Cell = const { Cell::new(0) }; +} + +/// While alive: drawing inside a placed document, one level deeper. +struct Inside; + +impl Inside { + fn enter() -> Self { + DEPTH.with(|d| d.set(d.get() + 1)); + Inside + } +} + +impl Drop for Inside { + fn drop(&mut self) { + DEPTH.with(|d| d.set(d.get().saturating_sub(1))); + } +} + +fn depth() -> usize { + DEPTH.with(Cell::get) +} + +/// Render `art` of a placed document's file (`doc`, its resources) into `w`×`h` transparent +/// pixels through `view`, the placed documents inside it one level deeper. +pub fn render_inside(r: &mut Renderer, doc: &vectorcraft_doc::Document, art: &Arc, w: u16, h: u16, view: Affine) -> Option { + let _inside = Inside::enter(); + r.render_node(doc, art, w, h, view) +} + +/// The mean scale of `a`. +fn mean_scale(a: Affine) -> f64 { + a.determinant().abs().sqrt() +} + +impl Renderer { + /// Draw placed document `p` in frame `f`. + pub(crate) fn draw_placed(&mut self, ctx: &mut RenderContext, f: &Frame, p: &PlacedDocument) { + let Some(blob) = f.doc.images.get(&p.key) else { return }; + // The file's bytes: stored, read again already, or (inside another placed document, while + // not too deep) read again now. + let full = match depth() { + 0 => vectorcraft_doc::placed_document::full_bytes_cached(p, blob), + d if d < MAX_DEPTH => vectorcraft_doc::placed_document::full_bytes(p, blob), + _ => None, + }; + let read = full.and_then(|bytes| vectorcraft_doc::placed_document::read(p, &bytes)); + let Some(e) = read else { + // Only the preview: drawn scaled into the box. + if blob.is_proxy() + && let Some(im) = p.preview_image(&blob.bytes) + { + self.draw_image(ctx, f, &im); + } + return; + }; + let Some(m) = p.art_xf(e.frame) else { return }; + let k = mean_scale(m); + if !k.is_finite() || k <= 1e-12 { + return; + } + // The art in its own coordinates, with its own resources. + let inner = Frame { doc: &e.doc, view: f.view * m, visible: m.inverse().transform_rect_bbox(f.visible), px: f.px / k, ..*f }; + let _inside = Inside::enter(); + self.draw_arc(ctx, &inner, &e.art); + } +} diff --git a/crates/render/src/tests_placed_document.rs b/crates/render/src/tests_placed_document.rs new file mode 100644 index 00000000..0224fa39 --- /dev/null +++ b/crates/render/src/tests_placed_document.rs @@ -0,0 +1,83 @@ +//! Placed documents drawn exactly: with their own art, as their previews when that is all there +//! is, and a few levels deep when they place themselves. + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, ImageBlob, LinkInfo, Node, NodeId, NodeKind, PlacedDocument}; +use vectorcraft_geom::shapes; + +use super::*; + +/// Every file reads as a 40×20 artboard filled blue; a file whose bytes start with `loop` also +/// places itself (the same key) in its middle. +fn blue_page(p: &PlacedDocument, bytes: &[u8]) -> Option<(Document, Node, Rect)> { + let mut d = Document::new(40.0, 20.0); + d.layers.clear(); + let blue = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0); + let mut children = vec![Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 20.0)), blue))]; + if bytes.starts_with(b"loop") { + d.images.insert(p.key.clone(), ImageBlob::new("application/json", bytes.to_vec())); + let me = PlacedDocument { xf: Affine::translate((10.0, 5.0)) * Affine::scale(0.5), ..p.clone() }; + children.push(Arc::new(Node::new(NodeId(2), NodeKind::PlacedDocument(Box::new(me))))); + } + Some((d, Node::new(NodeId(0), NodeKind::Group { children, clip: false }), Rect::new(0.0, 0.0, 40.0, 20.0))) +} + +/// A 100×100 document showing one placed file (`bytes` under `key`) in the box (10, 10)–(90, 50). +fn doc_with(key: &str, bytes: &[u8]) -> Document { + vectorcraft_doc::placed_document::set_loader(blue_page); + let mut d = Document::new(100.0, 100.0); + d.images.insert(key.into(), ImageBlob::new("application/json", bytes.to_vec())); + let p = PlacedDocument { + link: LinkInfo::new("/art/card.vectorcraft"), + key: key.into(), + bounding: false, + width: 40.0, + height: 20.0, + xf: Affine::translate((10.0, 10.0)) * Affine::scale(2.0), + placement: Default::default(), + }; + let l = d.layers[0].id; + d.insert(Some(l), 0, Node::new(NodeId(50), NodeKind::PlacedDocument(Box::new(p)))).unwrap(); + d +} + +fn doc(key: &str) -> Document { + doc_with(key, format!("{{\"doc\": \"{key}\"}}").as_bytes()) +} + +fn render(d: &Document) -> Rendered { + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + Renderer::new().render(d, 100, 100, Affine::IDENTITY, &opts) +} + +#[test] +fn a_placed_document_draws_exactly_with_its_own_art() { + let d = doc("exact-1"); + let r = render(&d); + assert_eq!(&r.pixel(50, 30)[..3], &[0, 0, 255], "blue inside the box"); + assert_eq!(&r.pixel(95, 95)[..3], &[255, 255, 255], "nothing outside"); +} + +#[test] +fn a_placed_document_kept_as_its_preview_draws_the_preview() { + let mut d = doc("preview-1"); + // Only the preview (a 20×10 green PNG) is there, and the file can't be read here. + let img = image::RgbaImage::from_pixel(20, 10, image::Rgba([0, 200, 0, 255])); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut blob = ImageBlob::new("image/png", png); + blob.proxy = Some(blob.bytes.clone()); + d.images.insert("preview-1".into(), blob); + let exact = render(&d); + assert_eq!(&exact.pixel(50, 30)[..3], &[0, 200, 0], "the preview, in the box"); + assert_eq!(&exact.pixel(95, 95)[..3], &[255, 255, 255]); +} + +#[test] +fn a_document_that_places_itself_draws_a_few_levels_deep_then_stops() { + let d = doc_with("loop-1", b"loop"); + let t = std::time::Instant::now(); + let r = render(&d); + assert!(t.elapsed().as_secs() < 10); + assert_eq!(&r.pixel(50, 30)[..3], &[0, 0, 255]); +} diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index df5617bc..6eec850c 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -343,7 +343,7 @@ impl Reuse { } } match &n.kind { - NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => { + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => { (self.turns, self.scales) = (false, false) } NodeKind::SymbolInstance { symbol, .. } => { @@ -1357,7 +1357,7 @@ impl Writer<'_> { (self.xf, self.instance_xf) = saved; } // Live blends/envelopes/meshes export their evaluated (expanded) form. - NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => { + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => { let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); self.share_images(&g); self.node_body(&g); diff --git a/crates/testkit/src/invariants.rs b/crates/testkit/src/invariants.rs index 82b90b89..a0821027 100644 --- a/crates/testkit/src/invariants.rs +++ b/crates/testkit/src/invariants.rs @@ -98,11 +98,16 @@ fn native_roundtrip(doc: &Document) -> Result<(Value, Value), String> { } let mut lost = None; back.walk(|n| { - if let NodeKind::Image(im) = &n.kind - && doc.images.contains_key(&im.key) - && !back.images.contains_key(&im.key) + let key = match &n.kind { + NodeKind::Image(im) => Some(&im.key), + NodeKind::PlacedDocument(p) => Some(&p.key), + _ => None, + }; + if let Some(key) = key + && doc.images.contains_key(key) + && !back.images.contains_key(key) { - lost = Some(im.key.clone()); + lost = Some(key.clone()); } }); if let Some(k) = lost {