Merge pull request #7683 from dotCooCoo/fix/hpgl-art-bounds-dynamic-symbols

Export: an HP-GL plot of the art's bounds leaves out images in dynamic symbols
This commit is contained in:
BFlat
2026-11-15 06:16:40 +07:00
committed by GitHub
3 changed files with 281 additions and 31 deletions
+194 -29
View File
@@ -2,13 +2,13 @@
//! (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::collections::{HashMap, HashSet};
use std::sync::Arc;
use serde::Deserialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
use vectorcraft_doc::{Artboard, Document, Node, NodeKind};
use vectorcraft_doc::{Artboard, Document, Node, NodeKind, SymbolOverride};
use vectorcraft_geom::Rect;
use vectorcraft_render::AntiAlias;
use vectorcraft_render::encode::jpeg::{self, JpegOptions};
@@ -450,31 +450,85 @@ pub fn export_art_bounds(doc: &Document, format: &str) -> Option<Rect> {
/// The bounds of the visible art of `doc` with the objects that `left_out` picks hidden
/// ([`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.
/// whose art is all picked or hidden is hidden too. The drawn instances of a dynamic symbol that
/// make the same appearance changes to its art are measured on a copy of the symbol of their own,
/// in which each object is judged with those changes ([`as_drawn`]); the objects taken out of
/// a dynamic symbol's art leave empty groups in their places, by which instances find the objects
/// they change.
fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option<Rect> {
// The symbols instances show (the first of each name) that aren't dynamic.
// The symbols instances show (the first of each name).
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();
let shown: Vec<usize> = doc.symbols.iter().enumerate().filter(|(_, s)| names.insert(s.name.as_str())).map(|(i, _)| i).collect();
// The sets of appearance changes the drawn instances of each dynamic symbol make to its art;
// `None` for a symbol with more than `MAX_ART_COPIES`, which is measured whole.
let mut sets: HashMap<&str, Option<Vec<Vec<&SymbolOverride>>>> =
shown.iter().filter_map(|i| doc.symbols.get(*i)).filter(|s| s.dynamic).map(|s| (s.name.as_str(), Some(Vec::new()))).collect();
if !sets.is_empty() {
let mut walked = HashSet::new();
for l in &doc.layers {
drawn_changes(doc, l, left_out, true, &mut walked, &mut sets);
}
}
// Each set of changes gets a copy of its symbol, appended after the symbols, which the instances
// that make those changes show instead.
let variant = |symbol: &str, n: &Node| -> Option<String> {
let k = sets.get(symbol)?.as_ref()?.iter().position(|c| *c == own_changes(n))?;
Some(format!("{symbol}\u{0}{k}"))
};
let mut copies: Vec<(usize, &[&SymbolOverride])> = Vec::new();
let remapped_doc = sets.values().flatten().any(|v| !v.is_empty()).then(|| {
let mut d = doc.clone();
for l in &mut d.layers {
if let Some(n) = remapped(l, &variant) {
*l = n;
}
}
for s in &mut d.symbols {
if let Some(n) = remapped(&s.art, &variant) {
s.art = n;
}
}
for i in &shown {
let Some(s) = d.symbols.get(*i).cloned() else { continue };
for (k, c) in sets.get(s.name.as_str()).into_iter().flatten().flatten().enumerate() {
copies.push((d.symbols.len(), c.as_slice()));
d.symbols.push(vectorcraft_doc::Symbol { name: format!("{}\u{0}{k}", s.name), ..s.clone() });
}
}
d
});
let src = remapped_doc.as_ref().unwrap_or(doc);
// The symbols to measure with the changes their instances make (`None` for a static symbol),
// without the dynamic symbols measured whole.
let entries: Vec<(usize, Option<&[&SymbolOverride]>)> = shown
.iter()
.filter_map(|i| src.symbols.get(*i).map(|s| (*i, s)))
.filter(|(_, s)| !s.dynamic || sets.get(s.name.as_str()).is_some_and(Option::is_some))
.map(|(i, s)| (i, s.dynamic.then_some(&[][..])))
.chain(copies.iter().map(|(i, c)| (*i, Some(*c))))
.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)) {
while let Some(s) = entries.iter().find_map(|(i, c)| {
src.symbols.get(*i).filter(|s| !empty.contains(s.name.as_str()) && all_left_out(&s.art, left_out, &empty, *c, &mut Vec::new()))
}) {
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<Node>)> = shown
let layers = hidden_nodes(&src.layers, left_out);
let symbols: Vec<(usize, Arc<Node>)> = entries
.iter()
.filter(|(_, s)| !empty.contains(s.name.as_str()))
.filter_map(|(i, s)| without_nodes(&s.art, left_out).map(|art| (*i, art)))
.filter_map(|(i, c)| {
let s = src.symbols.get(*i).filter(|s| !empty.contains(s.name.as_str()))?;
without_nodes(&s.art, left_out, *c, &mut Vec::new()).map(|art| (*i, art))
})
.collect();
if layers.is_none() && symbols.is_empty() {
return vectorcraft_render::encode::art_bounds(doc);
return vectorcraft_render::encode::art_bounds(src);
}
let mut d = doc.clone();
let mut d = remapped_doc.unwrap_or_else(|| doc.clone());
if let Some(layers) = layers {
d.layers = layers;
}
@@ -486,22 +540,126 @@ fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option<Re
vectorcraft_render::encode::art_bounds(&d)
}
/// The appearance changes instance `n` makes to its symbol's art, without those that change nothing.
fn own_changes(n: &Node) -> Vec<&SymbolOverride> {
n.extras().symbol_overrides.iter().filter(|o| !o.is_empty()).collect()
}
/// `n` with each instance at any depth that `variant` gives another symbol (from the instance's
/// symbol and the instance) made an instance of that symbol; `None` when none is.
fn remapped(n: &Arc<Node>, variant: &dyn Fn(&str, &Node) -> Option<String>) -> Option<Arc<Node>> {
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind {
let name = variant(symbol, n)?;
let mut m = (**n).clone();
if let NodeKind::SymbolInstance { symbol, .. } = &mut m.kind {
*symbol = name;
}
return Some(Arc::new(m));
}
let children = n.children()?;
let mut out: Option<Vec<Arc<Node>>> = None;
for (i, c) in children.iter().enumerate() {
if let Some(new) = remapped(c, variant)
&& let Some(slot) = out.get_or_insert_with(|| children.clone()).get_mut(i)
{
*slot = new;
}
}
out.map(|c| {
let mut m = (**n).clone();
if let Some(children) = m.children_mut() {
*children = c;
}
Arc::new(m)
})
}
/// 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 {
/// of these unless it is hidden or picked. In the art of a dynamic symbol (`changed`, the changes
/// an instance makes to it; `n` is at place `at`), each object is judged with those changes
/// ([`as_drawn`]).
fn all_left_out(n: &Node, left_out: &dyn Fn(&Node) -> bool, empty: &HashSet<&str>, changed: Option<&[&SymbolOverride]>, at: &mut Vec<u32>) -> bool {
let m = as_drawn(n, changed, at);
!n.visible
|| left_out(n)
|| !has_effects(n)
|| left_out(&m)
|| !has_effects(&m)
&& 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::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().enumerate().all(|(i, c)| {
at.push(i as u32);
let out = all_left_out(c, left_out, empty, changed, at);
at.pop();
out
}),
NodeKind::SymbolInstance { symbol, .. } => empty.contains(symbol.as_str()),
_ => false,
}
}
/// Add the sets of appearance changes that the instances in `n` and its subtree make to the art of
/// the symbols `sets` names, each set once, and those of the instances in the art of the symbols the
/// instances show (`walked`, the symbols already looked in). The instances that `left_out` picks or
/// that are in an object it picks are left out, and with `hidden` (in a layer) the hidden ones and
/// those in a hidden object. A symbol with more than [`MAX_ART_COPIES`] sets gets `None`.
fn drawn_changes<'a>(
doc: &'a Document,
n: &'a Node,
left_out: &dyn Fn(&Node) -> bool,
hidden: bool,
walked: &mut HashSet<&'a str>,
sets: &mut HashMap<&'a str, Option<Vec<Vec<&'a SymbolOverride>>>>,
) {
if hidden && !n.visible || left_out(n) {
return;
}
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind {
let changes = own_changes(n);
if let Some(entry) = sets.get_mut(symbol.as_str())
&& !changes.is_empty()
{
let full = match entry {
Some(list) if !list.contains(&changes) && list.len() < MAX_ART_COPIES => {
list.push(changes);
false
}
Some(list) => !list.contains(&changes),
None => false,
};
if full {
*entry = None;
}
}
if walked.insert(symbol.as_str())
&& let Some(s) = doc.symbols.iter().find(|s| s.name == *symbol)
{
drawn_changes(doc, &s.art, left_out, false, walked, sets);
}
}
for c in n.children().into_iter().flatten() {
drawn_changes(doc, c, left_out, hidden, walked, sets);
}
}
/// The most copies of one dynamic symbol's art [`bounds_without`] measures, one for each set of
/// appearance changes its instances make; a symbol whose instances make more sets is measured whole.
const MAX_ART_COPIES: usize = 64;
/// `n`, the object at place `at` of a symbol's art, with the appearance changes `changed` that an
/// instance makes to it applied in their order; `n` itself for the art of a symbol that is not
/// dynamic (`changed` is `None`) and when no change names `at`.
fn as_drawn<'a>(n: &'a Node, changed: Option<&[&SymbolOverride]>, at: &[u32]) -> Cow<'a, Node> {
let here: Vec<SymbolOverride> =
changed.into_iter().flatten().filter(|o| o.path == at).map(|o| SymbolOverride { path: Vec::new(), ..(*o).clone() }).collect();
if here.is_empty() {
return Cow::Borrowed(n);
}
let mut m = n.clone();
vectorcraft_doc::symbol_overrides::apply(&mut m, &here);
Cow::Owned(m)
}
/// 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 {
@@ -511,25 +669,32 @@ fn has_effects(n: &Node) -> bool {
/// `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<Node>, left_out: &dyn Fn(&Node) -> bool) -> Option<Arc<Node>> {
/// effects, fills or strokes of its own keep all they hold. In the art of a dynamic symbol
/// (`changed`, the changes an instance makes to it, as for [`all_left_out`]; `n` is at place
/// `at`), each object is judged with those changes ([`as_drawn`]), and an object taken out leaves
/// an empty group in its place.
fn without_nodes(n: &Arc<Node>, left_out: &dyn Fn(&Node) -> bool, changed: Option<&[&SymbolOverride]>, at: &mut Vec<u32>) -> Option<Arc<Node>> {
let (NodeKind::Layer { children, clip, .. } | NodeKind::Group { children, clip }) = &n.kind else { return None };
if has_effects(n) || n.extras().shaper.is_some() {
if has_effects(&as_drawn(n, changed, at)) || n.extras().shaper.is_some() {
return None;
}
let mut changed = false;
let mut taken = false;
let kept: Vec<Arc<Node>> = 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()))
at.push(i as u32);
let out = if left_out(&as_drawn(c, changed, at)) && !(*clip && i == 0) {
taken = true;
changed.map(|_| Arc::new(Node::group(c.id, Vec::new())))
} else {
Some(without_nodes(c, left_out, changed, at).inspect(|_| taken = true).unwrap_or_else(|| c.clone()))
};
at.pop();
out
})
.collect();
changed.then(|| {
taken.then(|| {
let mut new = (**n).clone();
if let NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } = &mut new.kind {
*children = kept;
@@ -254,6 +254,90 @@ fn a_plot_of_the_arts_bounds_leaves_out_images_in_symbol_art() {
}
}
/// In the art of a dynamic symbol a placed image and a path that paints nothing are left out as
/// well, until an instance paints the path, and a document whose only other visible art is an
/// instance of a dynamic symbol of an image is refused.
#[test]
fn a_plot_of_the_arts_bounds_leaves_out_images_in_dynamic_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 bounds = |s: &Session| super::export_art_bounds(&s.doc().unwrap().doc, "hpgl").unwrap();
let same = |a: vectorcraft_geom::Rect, b: vectorcraft_geom::Rect| {
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(a, b)| (a - b).abs() < 1e-6)
};
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();
let image = s.execute("file.place", &json!({"name": "far.png", "dataBase64": b64(&png), "at": [-600, 800]})).unwrap()["ids"][0].clone();
let square = s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 20, "height": 20})).unwrap()["id"].clone();
let bare = s.execute("shape.rectangle", &json!({"x": 500, "y": -400, "width": 20, "height": 20})).unwrap()["id"].clone();
s.execute("paint.setStroke", &json!({"ids": [bare], "none": true})).unwrap();
let before = bounds(&s);
let inst = s.execute("symbol.new", &json!({"name": "Mixed", "ids": [image, square, bare], "type": "dynamic"})).unwrap()["id"].clone();
let after = bounds(&s);
assert!(same(before, after), "{before:?} {after:?}");
// A change of the path's blending mode leaves it painting nothing; once the instance paints the
// path, the path is measured.
let art = s.doc().unwrap().doc.symbols[0].art.clone();
let x0 = |c: &std::sync::Arc<vectorcraft_doc::Node>| c.visual_bounds().map_or(f64::MIN, |b| b.x0);
let at = (0..art.children().unwrap().len()).max_by(|a, b| x0(&art.children().unwrap()[*a]).total_cmp(&x0(&art.children().unwrap()[*b]))).unwrap();
s.execute("symbol.instance.setAppearance", &json!({"id": inst, "path": [at], "blend": "multiply"})).unwrap();
let blended = bounds(&s);
assert!(same(before, blended), "{before:?} {blended:?}");
s.execute("symbol.instance.setAppearance", &json!({"id": inst, "path": [at], "stroke": "#000000"})).unwrap();
let painted = bounds(&s);
assert!(painted.x1 > 500.0 && painted.y0 < -390.0, "{painted:?}");
// A later change to the same object, as a file can hold, takes the paint off again.
{
let id = vectorcraft_doc::NodeId(inst.as_u64().unwrap());
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let n = d.node_mut(id).unwrap();
let mut overrides = n.extras().symbol_overrides.clone();
overrides.push(vectorcraft_doc::SymbolOverride { path: vec![at as u32], opacity: Some(0.0), ..Default::default() });
n.extras_mut().symbol_overrides = overrides.clone();
let taken_off = bounds(&s);
assert!(same(before, taken_off), "{before:?} {taken_off:?}");
overrides.pop();
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
d.node_mut(id).unwrap().extras_mut().symbol_overrides = overrides;
}
// Another instance, far above, that doesn't paint the path is measured without it, whether or
// not the first one paints it.
let other = s.execute("symbol.place", &json!({"name": "Mixed", "x": 0, "y": -5000})).unwrap()["id"].clone();
let with_paint = bounds(&s);
s.execute("symbol.instance.reset", &json!({"ids": [inst]})).unwrap();
let without_paint = bounds(&s);
assert!((with_paint.y0 - without_paint.y0).abs() < 1e-6, "{with_paint:?} {without_paint:?}");
s.execute("edit.undo", &json!({})).unwrap();
let undone = bounds(&s);
assert!(same(undone, with_paint), "{undone:?} {with_paint:?}");
s.execute("select.set", &json!({"ids": [other]})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [inst]})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
s.execute("layer.setProps", &json!({"id": printing, "visible": false})).unwrap();
let image = s.execute("file.place", &json!({"name": "far.png", "dataBase64": b64(&png), "at": [-600, 800]})).unwrap()["ids"][0].clone();
s.execute("symbol.new", &json!({"name": "Image", "ids": [image], "type": "dynamic"})).unwrap();
let e = plot(&mut s).unwrap_err().to_string();
assert!(e.contains("no visible art"), "{e}");
// An instance that brings a group at 0% opacity back to full opacity still draws nothing when
// the group holds only an image.
let image = s.execute("file.place", &json!({"name": "far.png", "dataBase64": b64(&png), "at": [-600, 800]})).unwrap()["ids"][0].clone();
let group = s.execute("object.group", &json!({"ids": [image]})).unwrap()["id"].clone();
s.execute("transparency.set", &json!({"ids": [group], "opacity": 0})).unwrap();
let inst = s.execute("symbol.new", &json!({"name": "Clear", "ids": [group], "type": "dynamic"})).unwrap()["id"].clone();
let id = vectorcraft_doc::NodeId(inst.as_u64().unwrap());
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
d.node_mut(id).unwrap().extras_mut().symbol_overrides =
[vec![], vec![0]].into_iter().map(|path| vectorcraft_doc::SymbolOverride { path, opacity: Some(1.0), ..Default::default() }).collect();
let e = plot(&mut s).unwrap_err().to_string();
assert!(e.contains("no visible art"), "{e}");
}
/// A placed image with a Vector Halftone effect is measured when the effect makes dots, and left
/// out when it would take more cells than the effect makes.
#[test]
+3 -2
View File
@@ -1106,8 +1106,9 @@ way. `document.exportPdf {useArtboards: false}` writes that one page as well, an
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.
An image or a mesh with a Crop Marks effect, or with a Vector Halftone effect that makes dots, is measured.
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.
Such objects in the art of a symbol are left out of its instances too, and an instance whose symbol holds nothing
else is left out. In the art of a dynamic symbol, an object that an instance's fill, stroke or opacity makes
the plot draw is measured.
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