From 10225d82e587ee7bcf2f86c8ee9d855340198ec3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 08:50:21 -0800 Subject: [PATCH] Export: an HP-GL plot of the art's bounds leaves out images in symbol art The bounds of a plot of the art's bounds no longer count the objects a plot leaves out (placed images, objects at 0% opacity, unpainted paths) when they sit in the art of a symbol. An instance whose symbol holds nothing else is left out, so a document with only such instances is refused with "nothing to export". The art of a dynamic symbol, and of a group with effects, fills or strokes of its own, is measured whole. --- crates/engine/src/cmd/fileio/encode.rs | 93 ++++++++++++++++++++-- crates/engine/src/cmd/fileio/tests_hpgl.rs | 33 ++++++++ docs/mcp.md | 2 + 3 files changed, 122 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index cc8fc60f8f..3461aca056 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -2,6 +2,7 @@ //! (unknown keys are ignored, so one params object can carry the options of several formats). use std::borrow::Cow; +use std::collections::HashSet; use std::sync::Arc; use serde::Deserialize; @@ -447,14 +448,94 @@ pub fn export_art_bounds(doc: &Document, format: &str) -> Option { } /// The bounds of the visible art of `doc` with the objects that `left_out` picks hidden -/// ([`hidden_nodes`]). +/// ([`hidden_nodes`]) and the ones in the art of the symbols that instances show taken out +/// ([`without_nodes`]: symbol art is measured with its hidden objects). An instance of a symbol +/// whose art is all picked or hidden is hidden too. The art of a dynamic symbol is measured whole: +/// an instance can paint its objects, and finds them by their places in the art. fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option { - let drawn = hidden_nodes(&doc.layers, left_out).map(|layers| { - let mut d = doc.clone(); + // The symbols instances show (the first of each name) that aren't dynamic. + let mut names = HashSet::new(); + let shown: Vec<(usize, &vectorcraft_doc::Symbol)> = + doc.symbols.iter().enumerate().filter(|(_, s)| names.insert(s.name.as_str())).filter(|(_, s)| !s.dynamic).collect(); + // The symbols whose art `left_out` leaves nothing of, those that only hold instances of such + // symbols included. + let mut empty: HashSet<&str> = HashSet::new(); + while let Some((_, s)) = shown.iter().find(|(_, s)| !empty.contains(s.name.as_str()) && all_left_out(&s.art, left_out, &empty)) { + empty.insert(&s.name); + } + let left_out = + &|n: &Node| left_out(n) || matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if empty.contains(symbol.as_str()) && !has_effects(n)); + let layers = hidden_nodes(&doc.layers, left_out); + let symbols: Vec<(usize, Arc)> = shown + .iter() + .filter(|(_, s)| !empty.contains(s.name.as_str())) + .filter_map(|(i, s)| without_nodes(&s.art, left_out).map(|art| (*i, art))) + .collect(); + if layers.is_none() && symbols.is_empty() { + return vectorcraft_render::encode::art_bounds(doc); + } + let mut d = doc.clone(); + if let Some(layers) = layers { d.layers = layers; - d - }); - vectorcraft_render::encode::art_bounds(drawn.as_ref().unwrap_or(doc)) + } + for (i, art) in symbols { + if let Some(s) = d.symbols.get_mut(i) { + s.art = art; + } + } + vectorcraft_render::encode::art_bounds(&d) +} + +/// Whether `n` is hidden, picked by `left_out`, an instance of one of the `empty` symbols, or a +/// layer or a group holding only such objects (its clipping path among them when it clips). A +/// Shaper group, or a layer, group or instance with effects, fills or strokes of its own, is none +/// of these unless it is hidden or picked. +fn all_left_out(n: &Node, left_out: &dyn Fn(&Node) -> bool, empty: &HashSet<&str>) -> bool { + !n.visible + || left_out(n) + || !has_effects(n) + && n.extras().shaper.is_none() + && match &n.kind { + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().all(|c| all_left_out(c, left_out, empty)), + NodeKind::SymbolInstance { symbol, .. } => empty.contains(symbol.as_str()), + _ => false, + } +} + +/// Whether `n` has a visible effect, fill or stroke of its own, which the objects it holds can +/// take part in. +fn has_effects(n: &Node) -> bool { + n.appearance.effects.iter().any(|e| e.visible) || n.appearance.items.iter().any(|i| i.visible()) +} + +/// `n` without the objects that `left_out` picks in it, when it is a layer or a group, and in the +/// layers and groups in it at any depth; `None` when it picks none of them. A clipping layer or +/// group keeps its clipping path, its first object, and a Shaper group and a layer or group with +/// effects, fills or strokes of its own keep all they hold. +fn without_nodes(n: &Arc, left_out: &dyn Fn(&Node) -> bool) -> Option> { + let (NodeKind::Layer { children, clip, .. } | NodeKind::Group { children, clip }) = &n.kind else { return None }; + if has_effects(n) || n.extras().shaper.is_some() { + return None; + } + let mut changed = false; + let kept: Vec> = children + .iter() + .enumerate() + .filter_map(|(i, c)| { + if left_out(c) && !(*clip && i == 0) { + changed = true; + return None; + } + Some(without_nodes(c, left_out).inspect(|_| changed = true).unwrap_or_else(|| c.clone())) + }) + .collect(); + changed.then(|| { + let mut new = (**n).clone(); + if let NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } = &mut new.kind { + *children = kept; + } + Arc::new(new) + }) } /// The `rect` param of a raster export: `[x, y, width, height]` in points, y down. `None` when diff --git a/crates/engine/src/cmd/fileio/tests_hpgl.rs b/crates/engine/src/cmd/fileio/tests_hpgl.rs index 09fe5e132c..5b7e3f0185 100644 --- a/crates/engine/src/cmd/fileio/tests_hpgl.rs +++ b/crates/engine/src/cmd/fileio/tests_hpgl.rs @@ -221,6 +221,39 @@ fn a_plot_of_the_arts_bounds_leaves_out_the_layers_it_does_not_plot() { assert!(e.contains("no visible art"), "{e}"); } +/// A placed image in a symbol's art doesn't move a plot of the art's bounds either, and a +/// document whose only other visible art is an instance of that symbol is refused. +#[test] +fn a_plot_of_the_arts_bounds_leaves_out_images_in_symbol_art() { + let mut s = two_boards(); + let plot = |s: &mut Session| s.execute("document.export", &json!({"format": "hpgl", "useArtboards": false})).map(|v| bytes(&v)); + let alone = plot(&mut s).unwrap(); + let printing = s.doc().unwrap().doc.layers[0].id; + s.execute("layer.new", &json!({})).unwrap(); + let mut png = vec![]; + image::DynamicImage::new_rgb8(4, 4).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + s.execute("file.place", &json!({"name": "far.png", "dataBase64": b64(&png), "at": [-600, 800]})).unwrap(); + s.execute("symbol.new", &json!({"name": "Far"})).unwrap(); + assert!(s.doc().unwrap().doc.symbols.iter().any(|sym| sym.name == "Far")); + assert_eq!(plot(&mut s).unwrap(), alone, "an image in symbol art"); + + s.execute("layer.setProps", &json!({"id": printing, "visible": false})).unwrap(); + let e = plot(&mut s).unwrap_err().to_string(); + assert!(e.contains("no visible art"), "{e}"); + + // A symbol of a square and an image is measured by its square. + let bounds = |s: &Session| super::export_art_bounds(&s.doc().unwrap().doc, "hpgl").unwrap(); + let square = s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 20, "height": 20})).unwrap()["id"].clone(); + let before = bounds(&s); + let image = s.execute("file.place", &json!({"name": "near.png", "dataBase64": b64(&png), "at": [400, 600]})).unwrap()["ids"][0].clone(); + s.execute("symbol.new", &json!({"name": "Both", "ids": [square, image]})).unwrap(); + assert_eq!(s.doc().unwrap().doc.symbols.len(), 2); + let after = bounds(&s); + for (a, b) in [(before.x0, after.x0), (before.y0, after.y0), (before.x1, after.x1), (before.y1, after.y1)] { + assert!((a - b).abs() < 1e-6, "{before:?} {after:?}"); + } +} + #[test] fn export_options_are_checked() { let mut s = two_boards(); diff --git a/docs/mcp.md b/docs/mcp.md index b71e8ab752..5f9abe7988 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1089,6 +1089,8 @@ way. `document.exportPdf {useArtboards: false}` writes that one page as well, an (unless `includeNonPrinting` or `createLayers` keeps them; a `.ai` file keeps layers by default). An HP-GL plot of the art's bounds is measured without what the plot leaves out: non-printing layers, objects at 0% opacity, placed images, gradient meshes whose every point is at 0% opacity, and paths without effects whose fills and strokes paint nothing. +Such objects in the art of a symbol that is not dynamic are left out of its instances too, and an instance whose +symbol holds nothing else is left out. A document whose only visible art is left out is refused, and Export As refuses an HP-GL plot of it before the save dialog. A PDF of that page, from either command, carries no Preserve Editing data; when Preserve Editing is on (as it is in VectorCraft Default), the export comes back with a warning that says so. A `.ai` export with