diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index fc0b564e1..202d88670 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -364,6 +364,22 @@ pub fn encode_with_warnings(doc: &Document, format: &str, p: &Value) -> Result<( /// [`encode`], with every file the export writes. pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { + // Placed documents output their files' art (a native file keeps what it keeps: see + // `native::encode`). + if !matches!(format, "vectorcraft" | "template") { + let (full, warnings) = crate::cmd::place::document::full_documents(doc); + if !warnings.is_empty() { + let mut enc = encode_all_of(&full, format, p)?; + enc.warnings.extend(warnings); + return Ok(enc); + } + return encode_all_of(&full, format, p); + } + encode_all_of(doc, format, p) +} + +/// [`encode_all`] of `doc` as it is. +fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result { let f = super::writable(C, Some(format), None)?; let mut enc = encode_files(doc, f, p)?; // Formats that draw type: missing fonts come out in the fallback font (PDF and SVG say so diff --git a/crates/engine/src/cmd/fileio/native.rs b/crates/engine/src/cmd/fileio/native.rs index dda393864..cb2d9511d 100644 --- a/crates/engine/src/cmd/fileio/native.rs +++ b/crates/engine/src/cmd/fileio/native.rs @@ -127,6 +127,13 @@ pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) -> /// The `.vectorcraft` file of `doc` with the options in `p`. pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result> { let (mut so, extras) = save_options(cmd, f, doc, p)?; + // Include Linked Files keeps placed documents' files; older versions get their art. + let full = if so.include_linked || so.version < vectorcraft_format::VERSION { + crate::cmd::place::document::full_documents(doc).0 + } else { + std::borrow::Cow::Borrowed(doc) + }; + let doc = &*full; if extras.preview { so.preview = preview_png(doc)?; } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index f4b6a9902..c0115bcf9 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -48,7 +48,7 @@ mod pathops; mod patterncmds; pub mod pdfcmds; pub mod perspgrid; -mod place; +pub(crate) mod place; pub mod plugin; pub mod prefscmds; pub mod print; diff --git a/crates/engine/src/cmd/place/adopt.rs b/crates/engine/src/cmd/place/adopt.rs index ee4205d6a..928a4baf9 100644 --- a/crates/engine/src/cmd/place/adopt.rs +++ b/crates/engine/src/cmd/place/adopt.rs @@ -67,6 +67,7 @@ fn refs(n: &mut Node, f: &mut impl FnMut(Ref)) { } } NodeKind::Image(im) => f(Ref::Image(&mut im.key)), + NodeKind::PlacedDocument(pl) => f(Ref::Image(&mut pl.key)), NodeKind::SymbolInstance { symbol, .. } => f(Ref::Symbol(symbol)), _ => {} } @@ -129,7 +130,7 @@ fn copies(m: &Renames) -> Vec<(&str, &str)> { /// Bring the resources `nodes` (art from `src`) use into `dst`, renaming on conflict, and point /// `nodes` at the names they got. -pub(super) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) { +pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) { let mut used = Used::default(); for n in nodes.iter_mut() { refs(n, &mut |r| used.note(r)); diff --git a/crates/engine/src/cmd/place/document.rs b/crates/engine/src/cmd/place/document.rs new file mode 100644 index 000000000..624259d0e --- /dev/null +++ b/crates/engine/src/cmd/place/document.rs @@ -0,0 +1,246 @@ +//! Placed documents (`file.place` of a VectorCraft document from a file, with `link`): one +//! locked object showing an artboard of the document, a [`PlacedDocument`]. +//! +//! The object keeps the file's bytes (in the document's images, like image data) and the link; the +//! art it shows is read from them the way placing the file without Link reads it +//! ([`document_art`]: the artboard's art, with the file's own linked images found). The Links +//! commands ([`super::super::links`]) tell when the file changed and read it again. +//! +//! Each placed file also gets a preview ([`PREVIEW_PX_PER_PT`]; JPEG when opaque, else PNG), kept +//! as the blob's [`ImageBlob::proxy`]: a save writes only the preview (unless Include Linked +//! Files), as for linked images. The file is read again when it is needed ([`read_file_again`]: +//! output, or the screen zoomed past the preview), while it is unchanged, and output falls back to +//! the preview with a warning when it can't be ([`full_documents`]). + +use std::borrow::Cow; +use std::collections::BTreeSet; +use std::sync::Arc; + +use vectorcraft_doc::links::hash_bytes; +use vectorcraft_doc::placed_document::{PREVIEW_MAX, PREVIEW_PX_PER_PT, key_for}; +use vectorcraft_doc::{Document, ImageBlob, LinkInfo, Node, NodeKind, PlacedDocument, PlacementOptions}; +use vectorcraft_geom::{Affine, Rect}; + +use super::super::fileio; +use super::super::*; + +/// The largest file placed (bytes). +pub(crate) const MAX_FILE: usize = 256 << 20; +/// The format ids of VectorCraft documents. +const NATIVE: [&str; 2] = ["vectorcraft", "template"]; +/// The type a placed file's bytes are stored with. +const MIME: &str = "application/json"; + +/// Is `name` with `bytes` a VectorCraft document? +pub(crate) fn is_native(name: &str, bytes: &[u8]) -> bool { + fileio::detect(name, bytes).is_some_and(|f| NATIVE.contains(&f.id)) +} + +/// An artboard of a VectorCraft document, read as art to place. +pub(crate) struct DocumentArt { + /// The document, its layers taken: the art's resources. + pub doc: Document, + /// The art on the artboard (guides, hidden and template layers left out). + pub nodes: Vec, + /// The artboard the art is clipped to (`None`: the art's bounds are shown). + pub clip: Option, + /// What is shown: the artboard, or the art's bounds. + pub frame: Rect, + pub warnings: Vec, +} + +/// Artboard `page` (1-based) of the VectorCraft document `name` (`bytes`, read from `path` when +/// there is one, so its linked images are found), clipped to the artboard unless `bounding`. +pub(crate) fn document_art(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bounding: bool, cmd: &str) -> Result { + let file = fileio::file_name(name); + if !is_native(name, bytes) { + return Err(bad(cmd, format!("`{file}` isn't a VectorCraft document"))); + } + if bytes.len() > MAX_FILE { + return Err(bad(cmd, format!("`{file}` is over {} MB", MAX_FILE >> 20))); + } + let mut l = fileio::load(name, bytes)?; + // Its linked images show their files; its placed documents stay as they are. + super::super::links::resolve(&mut l.doc, path, false); + l.doc.drop_edit_modes(); + let n = l.doc.artboards.len(); + let board = (page as usize).checked_sub(1).and_then(|i| l.doc.artboards.get(i)).map(|a| a.rect); + let board = board.ok_or_else(|| bad(cmd, format!("page {page}: `{file}` has {n} artboard(s)")))?; + let layers = std::mem::take(&mut l.doc.layers); + let nodes = super::on_artboard(&layers, board); + let frame = if bounding { nodes.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.visual_bounds())) } else { Some(board) }; + let frame = frame.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{file}` has no art to place")))?; + Ok(DocumentArt { doc: l.doc, nodes, clip: (!bounding).then_some(board), frame, warnings: l.warnings }) +} + +/// `nodes` as one group, clipped to `clip` (its own ids: what drawing reads). +fn group(nodes: Vec, clip: Option) -> Node { + let mut children: Vec> = nodes.into_iter().map(Arc::new).collect(); + if let Some(r) = clip { + children.insert(0, Arc::new(super::clip_path(NodeId(0), r))); + } + Node::new(NodeId(0), NodeKind::Group { children, clip: clip.is_some() }) +} + +/// `p`'s file (`bytes`) read as the art it shows: how the document model reads placed documents +/// ([`vectorcraft_doc::placed_document::set_loader`]). +pub(crate) fn read_document(p: &PlacedDocument, bytes: &[u8]) -> Option<(Document, Node, Rect)> { + let a = document_art(p.link.name(), bytes, Some(&p.link.path), p.page(), p.bounding, "draw").ok()?; + Some((a.doc, group(a.nodes, a.clip), a.frame)) +} + +/// `p`'s file read again, while it is the file it was read from (how the document model gets it +/// when only the preview is stored, [`vectorcraft_doc::placed_document::set_file_reader`]). +pub(crate) fn read_file_again(p: &PlacedDocument) -> Option> { + let bytes = fileio::read_file(&p.link.path).ok()?; + if p.link.hash.as_ref().is_some_and(|h| *h != hash_bytes(&bytes)) { + return None; + } + Some(bytes) +} + +/// A VectorCraft document read for placing linked. +#[derive(Clone)] +pub(crate) struct Source { + /// The file's bytes, with their preview. + pub blob: ImageBlob, + /// The blob's key ([`key_for`]). + pub key: String, + /// What is shown, in the file's coordinates: the artboard or the art's bounds. + pub frame: Rect, + pub warnings: Vec, +} + +/// The VectorCraft document `name` (`bytes`, from `path` when there is one) read for showing +/// artboard `page` (its art's bounds when `bounding`). +pub(crate) fn source(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bounding: bool, cmd: &str) -> Result { + // The same document placed the same way is read once (placing it again is free). + let key = key_for(&hash_bytes(bytes), page, bounding); + if let Some(s) = lock_memo().iter().find(|s| s.key == key).cloned() { + return Ok(s); + } + let a = document_art(name, bytes, path, page, bounding, cmd)?; + let mut blob = ImageBlob::new(MIME, bytes.to_vec()); + let shown = PlacedDocument { + link: LinkInfo { page: Some(page), ..LinkInfo::new(path.unwrap_or(name)) }, + key: key.clone(), + bounding, + width: a.frame.width(), + height: a.frame.height(), + xf: Affine::IDENTITY, + placement: PlacementOptions::default(), + }; + let art = group(a.nodes, a.clip); + blob.proxy = preview(&a.doc, &art, a.frame).map(Arc::new); + // Drawing it needn't read it again. + vectorcraft_doc::placed_document::prime(&shown, blob.bytes.clone(), (a.doc, art, a.frame)); + let s = Source { blob, key, frame: a.frame, warnings: a.warnings }; + let mut m = lock_memo(); + if m.len() >= MEMO_SIZE { + m.remove(0); + } + m.push(s.clone()); + Ok(s) +} + +/// The most files remembered. +const MEMO_SIZE: usize = 32; + +/// Files read lately for placing, by key. +static MEMO: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +fn lock_memo() -> std::sync::MutexGuard<'static, Vec> { + // Entries are only pushed and removed: a panic while held leaves it whole. + MEMO.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// The preview of `art` (of document `doc`, showing `frame`): [`PREVIEW_PX_PER_PT`], at most +/// [`PREVIEW_MAX`] pixels a side; JPEG when opaque (inside its outer pixels), else PNG. +fn preview(doc: &Document, art: &Node, frame: Rect) -> Option> { + let k = PREVIEW_PX_PER_PT.min(PREVIEW_MAX as f64 / frame.width().max(frame.height()).max(1e-9)); + let side = |v: f64| (v * k).round().clamp(1.0, PREVIEW_MAX as f64) as u32; + let (w, h) = (side(frame.width()), side(frame.height())); + let view = Affine::scale_non_uniform(w as f64 / frame.width().max(1e-9), h as f64 / frame.height().max(1e-9)) + * Affine::translate((-frame.x0, -frame.y0)); + let mut r = vectorcraft_render::Renderer::new(); + let img = vectorcraft_render::placed_document::render_inside(&mut r, doc, &Arc::new(art.clone()), w as u16, h as u16, view)?; + // Premultiplied → straight. + let px = img + .pixels + .as_chunks::<4>() + .0 + .iter() + .flat_map(|&[r, g, b, a]| { + let un = |c: u8| if a == 0 { 0 } else { ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8 }; + [un(r), un(g), un(b), a] + }) + .collect(); + let rgba = image::RgbaImage::from_raw(w, h, px)?; + let mut out = vec![]; + // Opaque but for the edge's anti-aliasing (an artboard drawn to whole pixels) and compositing's + // rounding: JPEG. + let inner = |x: u32, y: u32| x > 0 && y > 0 && x + 1 < w && y + 1 < h; + if rgba.enumerate_pixels().all(|(x, y, p)| p[3] >= 250 || !inner(x, y)) { + let rgb = image::DynamicImage::ImageRgba8(rgba).to_rgb8(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 80).encode_image(&rgb).ok()?; + } else { + rgba.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?; + } + Some(out) +} + +/// `s` stored in `d` (objects showing the same thing share one blob). +pub(crate) fn store(d: &mut Document, s: &Source) { + d.images.entry(s.key.clone()).or_insert_with(|| s.blob.clone()); +} + +/// A new placed document of `d` showing `s` at 100%, where its art lies in the file. +pub(crate) fn node(d: &mut Document, s: &Source, link: LinkInfo, bounding: bool) -> Node { + store(d, s); + let placed = PlacedDocument { + link, + key: s.key.clone(), + bounding, + width: s.frame.width(), + height: s.frame.height(), + xf: Affine::translate((s.frame.x0, s.frame.y0)), + placement: PlacementOptions::default(), + }; + Node::new(d.alloc_id(), NodeKind::PlacedDocument(Box::new(placed))) +} + +/// `d` for output: the placed documents that keep only their previews get their files back, +/// else (their files gone or changed) output as their previews, with a warning. +pub(crate) fn full_documents(d: &Document) -> (Cow<'_, Document>, Vec) { + let mut need: Vec = vec![]; + d.visit_placed(|_, p| { + if d.images.get(&p.key).is_some_and(ImageBlob::is_proxy) && !need.iter().any(|n| n.key == p.key) { + need.push(p.clone()); + } + }); + if need.is_empty() { + return (Cow::Borrowed(d), vec![]); + } + let mut out = d.clone(); + let mut previews = BTreeSet::new(); + for p in need { + let Some(blob) = d.images.get(&p.key) else { continue }; + match vectorcraft_doc::placed_document::full_bytes(&p, blob) { + Some(bytes) => { + out.images.insert(p.key.clone(), ImageBlob { mime: MIME.into(), bytes, proxy: blob.proxy.clone() }); + } + None => { + previews.insert(p.key); + } + } + } + let mut warnings = vec![]; + if !previews.is_empty() { + warnings.push(format!( + "{} placed document(s) output as their previews: their files can't be found, or changed since they were read (relink or update them)", + previews.len() + )); + out.placed_as_previews(&previews); + } + (Cow::Owned(out), warnings) +} diff --git a/crates/engine/src/cmd/place/mod.rs b/crates/engine/src/cmd/place/mod.rs index c760001da..68e4a58cc 100644 --- a/crates/engine/src/cmd/place/mod.rs +++ b/crates/engine/src/cmd/place/mod.rs @@ -16,13 +16,17 @@ //! (`crop: "bounding"`). //! - A text file becomes area type, decoded and cleaned up with the Text Import Options (`text`, //! [`text`]). +//! - A VectorCraft document placed from a file with Link (the default) becomes a placed document: +//! one locked object showing the artboard `page`, linked to the file and read again when it +//! changes ([`document`]); without Link, an editable copy of its art. //! //! The art lands centred on `at`, fitted into `rect`, or (Replace) where the replaced object was, //! with its transform; Template puts it on a new template layer. The images, symbols, patterns and //! swatches it uses join the document ([`adopt`]). `file.place.queue` loads the place cursor (the //! `place` tool) with several files, which emits one `file.place` per click or drag. -mod adopt; +pub(crate) mod adopt; +pub(crate) mod document; mod text; use std::sync::Arc; @@ -53,7 +57,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+Shift+P"), - "{path | name+dataBase64, link?: true (a raster image keeps its file's path; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it) and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard", + "{path | name+dataBase64, link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it) and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard", has_doc, place ), @@ -94,6 +98,9 @@ enum Art { Image(RasterImage), /// A text file's text, placed as area type filling the natural box. Text(String), + /// A VectorCraft document placed linked: the file read, the link, and whether the art's + /// bounds (not the artboard) are shown. + Document(document::Source, LinkInfo, bool), Vector { /// The loaded document: the source of the art's resources. src: Box, @@ -157,6 +164,19 @@ fn load(p: &Value, cmd: &str, board: Option) -> Result { )); } let crop = opts.crop != CropTo::Bounding; + // A VectorCraft document: placed linked from its file, else as an editable copy. + if matches!(format.id, "vectorcraft" | "template") { + let page = page as u32; + if let Some(path) = src.path.filter(|_| bool_or(p, "link", true)) { + let s = document::source(src.name, &src.bytes, Some(path), page, !crop, cmd)?; + let link = LinkInfo { page: Some(page), ..super::links::link_info(path, &src.bytes) }; + let (natural, warnings) = (s.frame, s.warnings.clone()); + return Ok(Loaded { name, format, art: Art::Document(s, link.clone(), !crop), natural, warnings, link: Some(link), board: None }); + } + let a = document::document_art(src.name, &src.bytes, src.path, page, !crop, cmd)?; + let art = Art::Vector { src: Box::new(a.doc), nodes: a.nodes, clip: a.clip }; + return Ok(Loaded { name, format, art, natural: a.frame, warnings: a.warnings, link: None, board: None }); + } if format.raster { let img = fileio::raster_image(&src.bytes)?; let (sx, sy) = pt_per_px(img.ppi); @@ -176,7 +196,7 @@ fn load(p: &Value, cmd: &str, board: Option) -> Result { // Of the open options, only a DXF drawing's apply (the colour mode stays the file's). let o = fileio::LoadOptions { dxf: opts.dxf, ..Default::default() }; let mut l = fileio::load_with(src.name, &src.bytes, &o)?; - // Linked images (a native document's) show their files, found from its folder. + // Linked images show their files, found from its folder. super::links::resolve(&mut l.doc, src.path, false); (l.doc, l.warnings) } @@ -190,22 +210,31 @@ fn load(p: &Value, cmd: &str, board: Option) -> Result { // The page placed is a PDF import's only one. let index = if pdf { 0 } else { page - 1 }; let n = doc.artboards.len(); - let board = doc.artboards.get(index).map(|a| a.rect).ok_or_else(|| { - let what = if matches!(format.id, "vectorcraft" | "template") { "artboard" } else { "page" }; - bad(cmd, format!("page {page}: `{name}` has {n} {what}(s)")) - })?; - // A PDF import has one layer per page; a native document's (or an EPS file's) art is - // whatever lies on the artboard. - let pages: Vec<&Arc> = if pdf { layers.get(index).into_iter().collect() } else { layers.iter().collect() }; - let on_board = |c: &&Arc| c.visual_bounds().is_some_and(|b| b.intersect(board).area() > 0.0 || board.contains(b.origin())); - let nodes = art_of(pages.into_iter().filter(|l| placeable(l)).flat_map(|l| l.children().into_iter().flatten()).filter(on_board)); - (nodes, crop.then_some(board)) + let board = doc.artboards.get(index).map(|a| a.rect).ok_or_else(|| bad(cmd, format!("page {page}: `{name}` has {n} page(s)")))?; + // A PDF import has one layer per page; an EPS file's art is whatever lies on the artboard. + let pages = if pdf { layers.get(index..=index).unwrap_or_default() } else { &layers[..] }; + (on_artboard(pages, board), crop.then_some(board)) }; let natural = clip.or_else(|| nodes.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.visual_bounds()))); let natural = natural.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{name}` has no art to place")))?; Ok(Loaded { name, format, art: Art::Vector { src: Box::new(doc), nodes, clip }, natural, warnings, link: None, board }) } +/// The placeable art of `layers` that lies on `board`. +fn on_artboard(layers: &[Arc], board: Rect) -> Vec { + let on_board = |c: &&Arc| c.visual_bounds().is_some_and(|b| b.intersect(board).area() > 0.0 || board.contains(b.origin())); + art_of(layers.iter().filter(|l| placeable(l)).flat_map(|l| l.children().into_iter().flatten()).filter(on_board)) +} + +/// A clipping path of rectangle `r`, with id `id`. +fn clip_path(id: NodeId, r: Rect) -> Node { + let mut c = Node::path(id, shapes::rectangle(r), Appearance::basic(Paint::None, Paint::None, 0.0)); + if let NodeKind::Path { clipping, .. } = &mut c.kind { + *clipping = true; + } + c +} + /// A visible, non-template layer. fn placeable(l: &Node) -> bool { l.visible && !matches!(l.kind, NodeKind::Layer { template: true, .. }) @@ -243,15 +272,12 @@ fn build(d: &mut Document, l: Loaded, link: Option) -> Node { Node::new(d.alloc_id(), NodeKind::Image(im)) } Art::Text(t) => Node::new(d.alloc_id(), NodeKind::Text(Box::new(text::area_text(t, l.natural)))), + Art::Document(s, link, bounding) => document::node(d, &s, link, bounding), Art::Vector { src, mut nodes, clip } => { adopt::adopt(d, &src, &mut nodes); let mut children: Vec> = nodes.iter().map(|n| Arc::new(d.reid(n))).collect(); if let Some(r) = clip { - let mut c = Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::None, Paint::None, 0.0)); - if let NodeKind::Path { clipping, .. } = &mut c.kind { - *clipping = true; - } - children.insert(0, Arc::new(c)); + children.insert(0, Arc::new(clip_path(d.alloc_id(), r))); } Node::new(d.alloc_id(), NodeKind::Group { children, clip: clip.is_some() }) } diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index e852f80bf..a5ecac06e 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -118,6 +118,9 @@ fn print(s: &mut Session, p: &Value) -> Result { let preset = given.or(Some(set.advanced.flattener_preset.as_str()).filter(|n| !n.trim().is_empty())).unwrap_or("medium"); set.advanced.flattener_preset = preset.to_string(); } + // Placed documents print their files' art. + let (full, _) = super::place::document::full_documents(doc); + let doc = &*full; // Raster effects print as images at the document's raster effects resolution. let flat = super::rasterfx::flatten_raster_effects(doc); let doc = flat.as_ref().unwrap_or(doc); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d615abcb1..874cb74d2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -801,6 +801,9 @@ impl Default for Session { impl Session { pub fn new() -> Self { + // Placed documents are read by the native format's loader. + vectorcraft_doc::placed_document::set_loader(cmd::place::document::read_document); + vectorcraft_doc::placed_document::set_file_reader(cmd::place::document::read_file_again); Self { docs: vec![], active: None, @@ -1450,6 +1453,8 @@ mod tests_perspgrid; #[cfg(test)] mod tests_place; #[cfg(test)] +mod tests_placed_document; +#[cfg(test)] mod tests_plugins; #[cfg(test)] mod tests_prefs; diff --git a/crates/engine/src/tests_links.rs b/crates/engine/src/tests_links.rs index 5585ef997..da8a2e53e 100644 --- a/crates/engine/src/tests_links.rs +++ b/crates/engine/src/tests_links.rs @@ -320,7 +320,8 @@ fn a_placed_document_brings_its_links_with_the_files_pixels() { let doc_path = dir.file("part.vectorcraft"); save(&mut s, &doc_path); let mut s = session(); - s.execute("file.place", &json!({"path": doc_path})).unwrap(); + // As an editable copy (placed linked, it stays one locked object). + s.execute("file.place", &json!({"path": doc_path, "link": false})).unwrap(); let doc = &s.doc().unwrap().doc; let mut placed = vec![]; doc.visit_images(|_, im| placed.push(im.clone())); diff --git a/crates/engine/src/tests_placed_document.rs b/crates/engine/src/tests_placed_document.rs new file mode 100644 index 000000000..da0f6b90f --- /dev/null +++ b/crates/engine/src/tests_placed_document.rs @@ -0,0 +1,249 @@ +//! Placed documents: a VectorCraft document placed linked (`file.place`'s default for one read +//! from a file), drawn and output as vectors, and saved with its preview. + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Node, NodeKind, PlacedDocument}; +use vectorcraft_geom::{Rect, shapes}; + +use super::tests_links::{BLUE, Folder, RED, centre_colour, near, open, save, session, write}; +use super::*; + +/// A VectorCraft document at `path`: one `w`×`h` artboard filled with `rgb` (and `extra` more +/// rectangles, which change the file's size). +fn source(path: &str, w: f64, h: f64, rgb: [u8; 3], extra: usize) { + let mut d = Document::new(w, h); + let layer = d.layers[0].id; + let [r, g, b] = rgb; + for i in 0..=extra { + let id = d.alloc_id(); + let look = Appearance::basic(Paint::solid(Color::rgb8(r, g, b)), Paint::None, 0.0); + d.insert(Some(layer), i, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, w, h)), look)).unwrap(); + } + write(path, &vectorcraft_format::save_file(&d)); +} + +/// A document drawing symbol `Star` (a blue 100×50 rectangle) at `path`, titled `title` (tests +/// that remove the file give it content of its own: files read are cached by content). +fn star_source(path: &str, title: &str) { + let mut d = Document::new(100.0, 50.0); + d.title = title.into(); + let layer = d.layers[0].id; + let art = Node::path(vectorcraft_doc::NodeId(900), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), look(BLUE)); + d.symbols.push(vectorcraft_doc::Symbol { name: "Star".into(), art: std::sync::Arc::new(art) }); + let id = d.alloc_id(); + d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Star".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); + write(path, &vectorcraft_format::save_file(&d)); +} + +fn look(c: [u8; 3]) -> Appearance { + Appearance::basic(Paint::solid(Color::rgb8(c[0], c[1], c[2])), Paint::None, 0.0) +} + +/// The active document gets a red symbol named `Star` of its own. +fn parent_star(s: &mut Session) { + let star = Node::path(vectorcraft_doc::NodeId(901), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), look(RED)); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc) + .symbols + .push(vectorcraft_doc::Symbol { name: "Star".into(), art: std::sync::Arc::new(star) }); +} + +/// Place `path` (linked: the default), centred on the artboard → its id. +fn place(s: &mut Session, path: &str) -> NodeId { + let r = s.execute("file.place", &json!({"path": path, "at": [200, 150]})).unwrap(); + assert_eq!(r["linked"], true, "{r}"); + NodeId(r["ids"][0].as_u64().unwrap()) +} + +fn placed(s: &Session, id: NodeId) -> PlacedDocument { + match &s.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::PlacedDocument(p) => (**p).clone(), + k => panic!("not a placed document: {k:?}"), + } +} + +fn bounds(s: &Session, id: NodeId) -> Rect { + s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap() +} + +fn decode(v: &Value) -> Vec { + vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap() +} + +/// Images and paths in `bytes`, a PDF, read back. +fn pdf_contents(bytes: &[u8]) -> (usize, usize) { + // The page as drawn (not the VectorCraft document the PDF carries). + let (doc, _) = super::cmd::fileio::page_document(bytes, 0, &Default::default()).unwrap(); + let (mut images, mut paths) = (0, 0); + doc.walk(|n| match n.kind { + NodeKind::Image(_) => images += 1, + NodeKind::Path { .. } | NodeKind::Compound { .. } => paths += 1, + _ => {} + }); + (images, paths) +} + +/// The JSON of a `.vectorcraft` file (the data after it left out). +fn file_json(bytes: &[u8]) -> Value { + let end = bytes.windows(vectorcraft_format::BLOB_MAGIC.len()).position(|w| w == vectorcraft_format::BLOB_MAGIC).unwrap_or(bytes.len()); + serde_json::from_slice(&bytes[..end]).unwrap() +} + +#[test] +fn a_document_places_linked_by_default_at_its_artboard_size() { + let dir = Folder::new("placed-basic"); + let src = dir.file("logo.vectorcraft"); + source(&src, 100.0, 50.0, RED, 0); + let mut s = session(); + let id = place(&mut s, &src); + let p = placed(&s, id); + assert!((p.width - 100.0).abs() < 1e-6 && (p.height - 50.0).abs() < 1e-6, "{p:?}"); + assert_eq!(bounds(&s, id), Rect::new(150.0, 125.0, 250.0, 175.0)); + assert_eq!((p.link.path.as_str(), p.link.page), (src.as_str(), Some(1))); + let n = s.doc().unwrap().doc.node(id).unwrap(); + assert_eq!((n.name.as_deref(), n.kind_label()), (Some("logo.vectorcraft"), "Placed Document")); + assert!(near(centre_colour(&s.doc().unwrap().doc), RED)); + // It moves and scales like any object. + s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap(); + assert_eq!(bounds(&s, id), Rect::new(160.0, 125.0, 260.0, 175.0)); + // Its art's bounds instead of the artboard. + let mut d = Document::new(100.0, 50.0); + let layer = d.layers[0].id; + let id2 = d.alloc_id(); + d.insert(Some(layer), 0, Node::path(id2, shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 30.0)), look(RED))).unwrap(); + let small = dir.file("small.vectorcraft"); + write(&small, &vectorcraft_format::save_file(&d)); + let r = s.execute("file.place", &json!({"path": small, "crop": "bounding", "at": [200, 150]})).unwrap(); + let p = placed(&s, NodeId(r["ids"][0].as_u64().unwrap())); + assert!(p.bounding && (p.width - 30.0).abs() < 1e-6 && (p.height - 20.0).abs() < 1e-6, "{p:?}"); +} + +#[test] +fn without_link_or_a_file_a_document_places_as_an_editable_copy() { + let dir = Folder::new("placed-copy"); + let src = dir.file("logo.vectorcraft"); + source(&src, 100.0, 50.0, RED, 0); + let mut s = session(); + let data = vectorcraft_format::base64_encode(&std::fs::read(&src).unwrap()); + for p in [json!({"path": src, "link": false}), json!({"name": "logo.vectorcraft", "dataBase64": data})] { + let r = s.execute("file.place", &p).unwrap(); + assert_eq!(r["linked"], false, "{r}"); + let n = s.doc().unwrap().doc.node(NodeId(r["ids"][0].as_u64().unwrap())).unwrap(); + assert_eq!(n.kind_label(), "Clip Group", "{p}"); + } + assert!(!s.doc().unwrap().doc.has_placed()); +} + +#[test] +fn a_placed_document_is_vectors_in_every_output() { + let dir = Folder::new("placed-outputs"); + let src = dir.file("logo.vectorcraft"); + source(&src, 100.0, 50.0, RED, 0); + let mut s = session(); + place(&mut s, &src); + let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); + let text = svg["text"].as_str().unwrap(); + assert!(!text.contains(" 0); + let png = decode(&s.execute("document.serialize", &json!({"format": "png"})).unwrap()); + let [r, g, b, _] = image::load_from_memory(&png).unwrap().to_rgba8().get_pixel(200, 150).0; + assert!(near([r, g, b], RED), "{:?}", [r, g, b]); + for format in ["eps", "emf", "dxf"] { + let out = s.execute("document.serialize", &json!({"format": format})).unwrap(); + assert!(out.to_string().len() > 200, "{format}: {out}"); + } + let info = s.execute("document.info", &json!({})).unwrap(); + assert!(info.to_string().contains("\"placedDocuments\""), "{info}"); +} + +#[test] +fn a_document_saved_with_previews_outputs_its_files_art() { + let dir = Folder::new("placed-preview-output"); + let src = dir.file("logo.vectorcraft"); + source(&src, 100.0, 50.0, RED, 0); + let mut s = session(); + let id = place(&mut s, &src); + let doc_path = dir.file("poster.vectorcraft"); + save(&mut s, &doc_path); + open(&mut s, &doc_path); + let key = placed(&s, id).key; + assert!(s.doc().unwrap().doc.images[&key].is_proxy(), "opened with the preview only"); + assert!(near(centre_colour(&s.doc().unwrap().doc), RED)); + // Output reads the file again: vectors, no warning. + let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); + assert_eq!(svg["warnings"], json!([]), "{}", svg["warnings"]); + let text = svg["text"].as_str().unwrap(); + assert!(!text.contains("