Merge pull request #7604 from dotCooCoo/fix/pdf-art-bounds-printing-layers

Export: a page of the art's bounds is measured on the art the file draws
This commit is contained in:
BFlat
2026-11-15 03:21:52 +07:00
committed by GitHub
13 changed files with 460 additions and 41 deletions
+20
View File
@@ -1024,6 +1024,26 @@ fn overlaps(a: Rect, b: Rect) -> bool {
a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1
}
/// Whether a plot draws nothing of `n` wherever it is, apart from what every export leaves out
/// (hidden objects, guides and template layers): an object at 0% opacity, a non-printing layer, a
/// placed image, a gradient mesh whose every point is at 0% opacity, or a path or compound path
/// without effects whose fills and strokes paint nothing. An image or a mesh in `doc` with a
/// visible Crop Marks effect, or with a Vector Halftone effect that makes dots, is not one of them.
/// A clipping path still clips when the plot draws nothing of it.
pub fn plots_nothing(doc: &Document, n: &Node) -> bool {
let unpainted = || n.appearance.effects.is_empty() && top_stroke(n).is_none() && top_fill(n).is_none();
let dots = || vectorcraft_effects::addons::halftone::halftone_art(doc, n).is_some_and(|g| g.children().is_some_and(|c| !c.is_empty()));
let adds_art = || vectorcraft_effects::has_crop_marks(n) || dots();
n.opacity <= 0.0
|| match &n.kind {
NodeKind::Layer { printable, .. } => !printable,
NodeKind::Image(_) => !adds_art(),
NodeKind::Mesh(m) => !adds_art() && m.points.iter().all(|p| p.opacity <= 0.0),
NodeKind::Path { .. } | NodeKind::Compound { .. } => unpainted(),
_ => false,
}
}
/// `nodes` (the children of a clipping container when `clipping`) without the layers and
/// sublayers a plot leaves out (hidden, non-printing, template or at 0% opacity) and without
/// guides, in layers and groups at any depth, so that baking can't wrap one in art that is drawn
+84
View File
@@ -177,6 +177,90 @@ fn meshes_in_blends_are_outlines_and_clear_meshes_draw_nothing() {
assert_eq!(t.matches("PD").count(), 3, "{t}");
}
/// `plots_nothing` picks the objects and layers that a plot draws nothing of: a path that paints
/// nothing, objects at 0% opacity, placed images, meshes with every point at 0% opacity and
/// non-printing layers, and not a painted path or a mesh.
#[test]
fn plots_nothing_picks_what_a_plot_draws_nothing_of() {
type Make = fn(&mut Document) -> Node;
let cases: [(&str, Make, bool); 7] = [
("stroked", |d| stroked(d, rect(10.0, 10.0, 40.0, 40.0), Color::BLACK), false),
("unpainted", |d| Node::path(d.alloc_id(), rect(10.0, 10.0, 40.0, 40.0), Appearance::basic(Paint::None, Paint::None, 1.0)), true),
(
"clear",
|d| {
let mut n = filled(d, rect(10.0, 10.0, 40.0, 40.0), Color::BLACK);
n.opacity = 0.0;
n
},
true,
),
(
"image",
|d| {
let image = vectorcraft_doc::ImageObject {
key: "raster-1".into(),
width: 4,
height: 2,
xf: Affine::translate((10.0, 20.0)) * Affine::scale(5.0),
link: None,
placement: Default::default(),
pixels: None,
};
Node::new(d.alloc_id(), NodeKind::Image(Box::new(image)))
},
true,
),
(
"image with Crop Marks",
|d| {
let image = vectorcraft_doc::ImageObject {
key: "raster-1".into(),
width: 4,
height: 2,
xf: Affine::translate((10.0, 20.0)) * Affine::scale(5.0),
link: None,
placement: Default::default(),
pixels: None,
};
let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image)));
n.appearance.effects.push(vectorcraft_doc::Effect { id: "cropMarks".into(), params: serde_json::json!({}), visible: true });
n
},
false,
),
("mesh", |d| Node::new(d.alloc_id(), NodeKind::Mesh(mesh_at(10.0))), false),
(
"clear mesh",
|d| {
let mut clear = mesh_at(10.0);
clear.points.iter_mut().for_each(|p| p.opacity = 0.0);
Node::new(d.alloc_id(), NodeKind::Mesh(clear))
},
true,
),
];
for (name, make, nothing) in cases {
let mut held = None;
let d = doc_with(|d| {
let n = make(d);
held = Some(n.clone());
vec![n]
});
let Some(n) = held else { panic!("{name}") };
assert_eq!(plots_nothing(&d, &n), nothing, "{name}");
assert_eq!(text(&plot(&d, &opts(&d))) == "IN;PU;SP0;", nothing, "{name}");
}
let mut d = doc_with(|d| vec![stroked(d, rect(10.0, 10.0, 40.0, 40.0), Color::BLACK)]);
let layer = d.layers[0].id;
assert!(!plots_nothing(&d, &d.layers[0]));
if let Some(NodeKind::Layer { printable, .. }) = d.node_mut(layer).map(|n| &mut n.kind) {
*printable = false;
}
assert!(plots_nothing(&d, &d.layers[0]));
assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP0;");
}
/// Live art that makes more objects than the plot's limit (1,000 in tests) is left out with a
/// warning.
#[test]
+4 -1
View File
@@ -66,7 +66,10 @@ use serde_json::{Value, json};
pub use action_batch::batch_overwrites_one_file;
pub use dxf::{UNSUPPORTED, Unsupported, unsupported};
pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, Note, NoteKind, encode, encode_all, encode_with_notes, encode_with_warnings, note_texts};
pub use encode::{
ARTBOARD_PARAMS, ArtboardPick, Encoded, Note, NoteKind, encode, encode_all, encode_with_notes, encode_with_warnings, export_art_bounds,
note_texts,
};
pub(crate) use encode::{anti_alias, background, with_single_artboard};
pub(crate) use export::with_artboard_fills;
pub(crate) use export::{EXPORT_PARAMS, export_crop, isolated};
File diff suppressed because one or more lines are too long
+140 -5
View File
@@ -2,11 +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::sync::Arc;
use serde::Deserialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
use vectorcraft_doc::{Artboard, Document};
use vectorcraft_doc::{Artboard, Document, Node, NodeKind};
use vectorcraft_geom::Rect;
use vectorcraft_render::AntiAlias;
use vectorcraft_render::encode::jpeg::{self, JpegOptions};
@@ -419,13 +421,15 @@ pub(super) fn use_artboards(cmd: &str, p: &Value) -> Result<Option<bool>> {
/// With `useArtboards: false` in `p`: `doc` without its opacity-mask or symbol editing layer and
/// with one artboard, the bounds of its visible art, and `p` without the artboard choice. `None`
/// when `useArtboards` is absent, null or true. A document with no visible art is refused.
pub(super) fn art_page(cmd: &str, doc: &Document, p: &Value) -> Result<Option<(Document, Value)>> {
/// when `useArtboards` is absent, null or true. The objects that `left_out` picks (layers or
/// objects the format leaves out, see [`hidden_nodes`]) are not measured, and a document with no
/// other visible art is refused.
pub(super) fn art_page(cmd: &str, doc: &Document, p: &Value, left_out: impl Fn(&Node) -> bool) -> Result<Option<(Document, Value)>> {
if use_artboards(cmd, p)? != Some(false) {
return Ok(None);
}
let doc = &*doc.without_edit_modes();
let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(cmd, "nothing to export: the document has no visible art"))?;
let bounds = bounds_without(doc, &left_out).ok_or_else(|| bad(cmd, "nothing to export: the document has no visible art"))?;
let mut q = super::export::without_artboards(p);
if let Some(o) = q.as_object_mut() {
o.remove("useArtboards");
@@ -433,6 +437,107 @@ pub(super) fn art_page(cmd: &str, doc: &Document, p: &Value) -> Result<Option<(D
Ok(Some((single_artboard(doc, bounds, "Art"), q)))
}
/// The bounds of the visible art of `doc` that a `format` file of the art's bounds draws, as
/// [`art_page`] measures it with that format's default options: for HP-GL without what a plot
/// leaves out ([`vectorcraft_cad::hpgl::write::plots_nothing`]), for other formats all of it (the
/// non-printing layers a PDF leaves out depend on options chosen after the save panel). `None`
/// when there is none, which such an export refuses.
pub fn export_art_bounds(doc: &Document, format: &str) -> Option<Rect> {
let doc = &*doc.without_edit_modes();
bounds_without(doc, &|n| format == "hpgl" && vectorcraft_cad::hpgl::write::plots_nothing(doc, n))
}
/// 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.
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.
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<Node>)> = 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;
}
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<Node>, left_out: &dyn Fn(&Node) -> bool) -> 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() {
return None;
}
let mut changed = 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()))
})
.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
/// absent; an error when it is not four finite numbers with a positive size, when the format isn't
/// a raster one, or when it comes with an artboard choice (`rect` is the area, so those would
@@ -481,6 +586,35 @@ pub(crate) fn with_single_artboard(mut d: Document, rect: Rect, name: &str) -> D
d
}
/// `nodes` with the visible objects that `left_out` picks hidden, in layers, sublayers and groups
/// that don't clip, at any depth (what an object it picks holds is not looked at); `None` when it
/// picks none of them. Clipping layers are looked in too, as their sublayers can be picked.
pub(super) fn hidden_nodes(nodes: &[Arc<Node>], left_out: &dyn Fn(&Node) -> bool) -> Option<Vec<Arc<Node>>> {
let mut out: Option<Vec<Arc<Node>>> = None;
for (i, l) in nodes.iter().enumerate() {
let new = if left_out(l) {
l.visible.then(|| {
let mut n = (**l).clone();
n.visible = false;
Arc::new(n)
})
} else {
let (NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false }) = &l.kind else { continue };
hidden_nodes(children, left_out).map(|c| {
let mut n = (**l).clone();
if let NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } = &mut n.kind {
*children = c;
}
Arc::new(n)
})
};
if let Some(slot) = new.and_then(|new| Some((out.get_or_insert_with(|| nodes.to_vec()).get_mut(i)?, new))) {
*slot.0 = slot.1;
}
}
out
}
fn boards<T>(r: std::result::Result<T, String>) -> Result<T> {
r.map_err(|e| bad(C, e))
}
@@ -636,8 +770,9 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
{
return Ok(enc);
}
let left_out = |n: &Node| f.id == "hpgl" && vectorcraft_cad::hpgl::write::plots_nothing(doc, n);
if use_artboards == Some(false)
&& let Some((art, q)) = art_page(C, doc, p)?
&& let Some((art, q)) = art_page(C, doc, p, left_out)?
{
return encode_files(&art, f, &q);
}
File diff suppressed because one or more lines are too long
+15 -6
View File
@@ -757,8 +757,9 @@ pub const AI_EDITING_NEEDS_THE_ARTBOARDS: &str =
pub const ART_PAGE_NEEDS_PDF: &str = "pdfCompatible: false needs the artboards to keep the editable artwork when useArtboards is false";
/// With `useArtboards: false` in `p`: the PDF-compatible `.ai` file of one page, the bounds of
/// `doc`'s visible art ([`super::encode::art_page`]). It carries neither the native document nor
/// the editing copy for other apps, and its notes end with [`AI_EDITING_NEEDS_THE_ARTBOARDS`].
/// `doc`'s visible art on the layers its pages draw ([`super::pdf::art_page`]). It carries neither
/// the native document nor the editing copy for other apps, and its notes end with
/// [`AI_EDITING_NEEDS_THE_ARTBOARDS`].
/// `pdfCompatible: false` is refused. `None` when `p` asks for the artboards or for a legacy
/// version 7 `.ai` file.
pub(super) fn encode_ai_art_page(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Option<Encoded>> {
@@ -768,7 +769,7 @@ pub(super) fn encode_ai_art_page(cmd: &str, f: &Format, doc: &Document, p: &Valu
if !bool_or(p, "pdfCompatible", true) {
return Err(bad(cmd, ART_PAGE_NEEDS_PDF));
}
let Some((art, q)) = super::encode::art_page(cmd, doc, p)? else { return Ok(None) };
let Some((art, q)) = super::pdf::art_page(cmd, doc, &ai_pdf_params(p, false))? else { return Ok(None) };
let mut enc = encode_ai_pages(cmd, f, &art, &q, false)?;
enc.setting_notes.push(AI_EDITING_NEEDS_THE_ARTBOARDS.to_string());
Ok(Some(enc))
@@ -784,9 +785,10 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res
encode_ai_pages(cmd, f, doc, p, true)
}
/// [`encode_ai`] of a `.ai` file with or without PDF content, carrying the native document and
/// the editing copy for other apps when `editing` is true, and neither otherwise.
fn encode_ai_pages(cmd: &str, f: &Format, doc: &Document, p: &Value, editing: bool) -> Result<Encoded> {
/// `p` with the PDF options a `.ai` file is written with: Preserve Editing when `editing` is true,
/// layers unless a standard decides, long pages with a `UserUnit`, and `compress` as
/// `compression.compressText`.
fn ai_pdf_params(p: &Value, editing: bool) -> Value {
let mut params = if p.is_object() { p.clone() } else { json!({}) };
if let Some(o) = params.as_object_mut() {
o.insert("preserveEditing".into(), json!(editing));
@@ -805,6 +807,13 @@ fn encode_ai_pages(cmd: &str, f: &Format, doc: &Document, p: &Value, editing: bo
}
}
}
params
}
/// [`encode_ai`] of a `.ai` file with or without PDF content, carrying the native document and
/// the editing copy for other apps when `editing` is true, and neither otherwise.
fn encode_ai_pages(cmd: &str, f: &Format, doc: &Document, p: &Value, editing: bool) -> Result<Encoded> {
let params = ai_pdf_params(p, editing);
let pdf_compatible = bool_or(&params, "pdfCompatible", true);
let f = &if pdf_compatible { *f } else { without_pdf(f) };
let native_options = super::native::ai_options(cmd, f, doc, &params)?;
+107
View File
@@ -170,6 +170,113 @@ fn a_plot_reads_back_as_its_art() {
assert!((board.height() - (30.0 + pen)).abs() < 0.1, "{board:?}");
}
/// A plot of the art's bounds is measured on the art it plots: art on a non-printing layer, on a
/// layer at 0% opacity or at 0% opacity in a group, a placed image and a path that paints nothing
/// don't move its origin, a clipping layer is measured as clipped, and a document whose only
/// visible art is such art is refused.
#[test]
fn a_plot_of_the_arts_bounds_leaves_out_the_layers_it_does_not_plot() {
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;
for props in [json!({"printable": false}), json!({})] {
let layer = s.execute("layer.new", &json!({})).unwrap()["id"].clone();
if props.as_object().unwrap().is_empty() {
s.execute("transparency.set", &json!({"ids": [layer], "opacity": 0})).unwrap();
} else {
let mut p = props.clone();
p["id"] = layer.clone();
s.execute("layer.setProps", &p).unwrap();
}
s.execute("shape.rectangle", &json!({"x": -300, "y": 500, "width": 20, "height": 20})).unwrap();
assert_eq!(plot(&mut s).unwrap(), alone, "{props}");
}
// An object at 0% opacity in a group, on a layer that plots.
s.execute("layer.new", &json!({})).unwrap();
let far = s.execute("shape.rectangle", &json!({"x": -500, "y": 700, "width": 20, "height": 20})).unwrap()["id"].clone();
s.execute("object.group", &json!({"ids": [far]})).unwrap();
s.execute("transparency.set", &json!({"ids": [far], "opacity": 0})).unwrap();
assert_eq!(plot(&mut s).unwrap(), alone, "0% opacity in a group");
// A placed image and a path whose fill and stroke paint nothing.
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();
assert_eq!(plot(&mut s).unwrap(), alone, "a placed image");
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();
assert_eq!(plot(&mut s).unwrap(), alone, "a path that paints nothing");
// A clipping layer's clipping path, which loses its paint, still clips the art measured: a
// large square clipped to the first square draws nothing more.
let clipping = s.execute("layer.new", &json!({})).unwrap()["id"].clone();
s.execute("shape.rectangle", &json!({"x": -300, "y": -300, "width": 800, "height": 800})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap();
s.execute("layer.clippingMask.toggle", &json!({"id": clipping})).unwrap();
assert_eq!(plot(&mut s).unwrap(), alone, "a clipping layer");
s.execute("layer.setProps", &json!({"id": clipping, "visible": false})).unwrap();
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 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:?}");
}
}
/// 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]
fn a_plot_of_the_arts_bounds_measures_images_by_their_halftone_dots() {
let mut s = two_boards();
let plot = |s: &mut Session| s.execute("document.export", &json!({"format": "hpgl", "useArtboards": false})).map(|v| bytes(&v));
let printing = s.doc().unwrap().doc.layers[0].id;
s.execute("layer.new", &json!({})).unwrap();
let mut png = vec![];
image::DynamicImage::new_rgb8(100, 100).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
s.execute("file.place", &json!({"name": "dots.png", "dataBase64": b64(&png), "at": [300, 300]})).unwrap();
s.execute("layer.setProps", &json!({"id": printing, "visible": false})).unwrap();
// At 300 lines per inch a 100 pt image takes more cells than the effect makes.
s.execute("effect.apply", &json!({"effect": "addon.color.halftone", "params": {"frequency": 300}})).unwrap();
let e = plot(&mut s).unwrap_err().to_string();
assert!(e.contains("no visible art"), "{e}");
s.execute("effect.apply", &json!({"effect": "addon.color.halftone", "params": {"frequency": 20}})).unwrap();
let out = String::from_utf8(plot(&mut s).unwrap()).unwrap();
assert!(out.contains("PD"), "{out}");
}
#[test]
fn export_options_are_checked() {
let mut s = two_boards();
+42
View File
@@ -103,6 +103,48 @@ fn use_artboards_false_writes_one_page_of_the_arts_bounds() {
}
}
/// A page of the art's bounds is measured on the layers the PDF draws: a non-printing layer that
/// the PDF leaves out doesn't make it larger, unless `includeNonPrinting` or `createLayers` keeps
/// the layer. A `.ai` file of the art's bounds, which keeps layers by default, measures it unless
/// `createLayers` is off. A document whose only visible art doesn't print is refused.
#[test]
fn a_page_of_the_arts_bounds_leaves_out_non_printing_layers() {
let mut s = session(1);
let printing = s.doc().unwrap().doc.layers[0].id;
let notes = s.execute("layer.new", &json!({})).unwrap()["id"].clone();
s.execute("layer.setProps", &json!({"id": notes, "printable": false})).unwrap();
s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 20, "height": 20})).unwrap();
let size = |bytes: &[u8]| vectorcraft_pdf::import(bytes).unwrap().artboards[0].rect.size();
let printed = size(&b64(&export(&mut s, json!({"useArtboards": false}))));
assert!(printed.width < 50.0 && printed.height < 40.0, "{printed:?}");
let through_export = s.execute("document.export", &as_export(&json!({"useArtboards": false}))).unwrap();
assert_eq!(size(&b64(&through_export)), printed);
let ai = |s: &mut Session, extra: Value| {
let mut p = json!({"format": "ai", "useArtboards": false});
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
size(&b64(&s.execute("document.export", &p).unwrap_or_else(|e| panic!("{p}: {e}"))))
};
assert_eq!(ai(&mut s, json!({"createLayers": false})), printed);
let kept = [
size(&b64(&export(&mut s, json!({"useArtboards": false, "includeNonPrinting": true})))),
size(&b64(&export(&mut s, json!({"useArtboards": false, "createLayers": true})))),
ai(&mut s, json!({})),
];
for all in kept {
assert!(all.width > 200.0 && all.height > 200.0, "{all:?}");
}
// Only the non-printing layer shows: the PDF would draw nothing.
s.execute("layer.setProps", &json!({"id": printing, "visible": false})).unwrap();
for p in [json!({"useArtboards": false}), json!({"useArtboards": false, "includeNonPrinting": false})] {
let e = s.execute("document.exportPdf", &p).unwrap_err().to_string();
assert!(e.contains("no visible art"), "{p}: {e}");
}
let e = s.execute("document.export", &json!({"format": "ai", "useArtboards": false, "createLayers": false})).unwrap_err().to_string();
assert!(e.contains("no visible art"), "{e}");
assert!(size(&b64(&export(&mut s, json!({"useArtboards": false, "includeNonPrinting": true})))).width < 25.0);
}
/// In a PDF without a standard, the warning for a page longer than 14,400 pt written with a
/// `UserUnit` above 1 names "Write pages longer than 200 in at full size in points": at PDF 1.5
/// (written as 1.6) and later, through `document.exportPdf`, `document.export`, a page of the
+4 -1
View File
@@ -140,7 +140,10 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
}
// Nothing to export keeps the dialog open too (before the save dialog, not after it). An
// exporter plug-in decides for itself what it can write.
if !use_artboards && plugin.is_none() && app.session.active().is_some_and(|s| vectorcraft_render::encode::art_bounds(&s.doc).is_none()) {
if !use_artboards
&& plugin.is_none()
&& app.session.active().is_some_and(|s| vectorcraft_engine::cmd::fileio::export_art_bounds(&s.doc, f.id).is_none())
{
return Err("nothing to export: the document has no visible art (or turn on Use Artboards)".into());
}
app.ui.dialog = None;
@@ -304,6 +304,23 @@ fn nothing_to_export_keeps_export_as_open_before_the_save_dialog() {
assert!(written.borrow().is_empty());
}
/// Art that a plot leaves out (here at 0% opacity) is nothing to export as HP-GL of the art's
/// bounds, before the save dialog; other formats still export it.
#[test]
fn art_a_plot_leaves_out_keeps_hpgl_export_as_open_before_the_save_dialog() {
let (mut app, _) = app(1);
app.services.pick_save = Some(Box::new(|_: &crate::FilePick| panic!("no save dialog without art the plot draws")));
app.run("select.all", json!({})).unwrap();
app.run("transparency.set", json!({"opacity": 0})).unwrap();
app.run("file.exportAs", json!({"format": "hpgl"})).unwrap();
assert!(confirm(&mut app).unwrap_err().contains("no visible art"));
assert_eq!(kind(&app), Some("exportAs"));
app.services.pick_save = Some(Box::new(|p: &crate::FilePick| Some(format!("/out/{}", p.name))));
set(&mut app, "format", json!("png"));
confirm(&mut app).unwrap();
assert_eq!(kind(&app), Some("pngOptions"));
}
#[test]
fn pdf_artboards_continue_in_the_save_pdf_dialog() {
let (mut app, written) = app(3);
+10 -2
View File
@@ -1085,7 +1085,15 @@ or `range`), named `<file>-<artboard>.<ext>`, and returns `{path, files}` (a PDF
SVG writes one file per artboard of a `range`); `useArtboards: false` exports the bounds of the visible art with what
its effects paint, the effects of groups, type and images included. `artboard.fitToArt` measures the art the same
way. `document.exportPdf {useArtboards: false}` writes that one page as well, and ignores `artboard`, `artboards` and
`range`. A PDF of that page, from either command, carries no Preserve Editing data; when Preserve Editing is on (as it
`range`. A PDF or `.ai` page of the art's bounds is measured without the non-printing layers that the file leaves out
(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.
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.
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
`useArtboards: false` writes that one page too, without the native document and without the editing copy for other
apps (`editingData`), and always comes back with the warning `Preserve editing was left out: an .ai file of the art's
@@ -7915,7 +7923,7 @@ instructions is refused with "not an HP-GL file".
`document.export` (and `export`, and Export As) with format `hpgl` writes a plot file (`.plt`) of the first artboard
(or `artboard`) for pen plotters and cutters; `useArtboards: true` writes one file per chosen artboard
(`{stem}-{artboard}.plt`), `false` the bounds of the visible art. Each visible path is drawn once along its own line:
(`{stem}-{artboard}.plt`), `false` the bounds of the art it plots. Each visible path is drawn once along its own line:
a filled shape is its closed outline, also when a stroke gives its color, and a stroked path is its center line, so
fills, stroke widths, dashes, brushes and arrowheads are not painted. Type is drawn as its glyph outlines, and a
gradient mesh as its outline, also inside a blend (a mesh whose every point is at 0% opacity draws nothing). A
+1
View File
@@ -1763,6 +1763,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Export As PDF with Use Artboards off also opens the Save PDF dialog, without its Artboards section, and writes one page of the visible art's bounds with the dialog's options (`document.exportPdf {useArtboards: false}`; `crates/ui-egui/src/dialogs/tests_export.rs`, `crates/engine/src/cmd/fileio/tests_pdf.rs`).
`document.exportSelection` run without params (a shortcut) takes the same route after its save panel: a picked PDF opens the Save PDF dialog without its Artboards section, and SVG, the raster formats, EPS, DXF, text and exporter plug-ins with parameters open their options, whose OK writes the selection alone, cropped to it, with the chosen options (`crates/ui-egui/src/dialogs/tests_export_selection.rs`).
A `.ai` export with Use Artboards off writes one page of the visible art's bounds without the native document or the editing copy for other apps, with a warning, so the file does not reopen with one artboard in place of the document's; without PDF content it is refused (`crates/engine/src/cmd/fileio/tests_pdfedit.rs`).
That page, and an HP-GL plot of the art's bounds, are measured on the art the file draws: non-printing layers the PDF or `.ai` file leaves out do not make it larger, and nor do the non-printing layers, objects at 0% opacity, placed images, clear gradient meshes and unpainted paths a plot leaves out; Export As refuses an HP-GL plot of nothing it draws before the save dialog (`crates/engine/src/cmd/fileio/tests_pdf.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.rs`, `crates/ui-egui/src/dialogs/tests_export.rs`).
PDF export warns when a PDF/X file has no bleed on a side where the document's bleed has one, and writes the file as asked.
In a PDF without a standard, the warning for a page longer than 14,400 pt written with a `UserUnit` names "Write pages longer than 200 in at full size in points" (`crates/engine/src/cmd/fileio/tests_pdf.rs`).
A PDF/A-2b export at PDF 1.4 with a page longer than 14,400 pt is written as PDF 1.6 with a `UserUnit` and a warning, whether or not `longPagesInPoints` is on, so its page boundaries stay within PDF/A-2's limit of 14,400 units (ISO 19005-2, 6.1.13) unless the page is too narrow for that (`crates/pdf/src/tests/marks.rs`).