diff --git a/crates/cad/src/hpgl/write.rs b/crates/cad/src/hpgl/write.rs index 6ab80c8ba0..95c8ecfb7a 100644 --- a/crates/cad/src/hpgl/write.rs +++ b/crates/cad/src/hpgl/write.rs @@ -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 diff --git a/crates/cad/src/hpgl/write/tests.rs b/crates/cad/src/hpgl/write/tests.rs index 9cb4f7d727..60f7cf5b3d 100644 --- a/crates/cad/src/hpgl/write/tests.rs +++ b/crates/cad/src/hpgl/write/tests.rs @@ -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] diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 84fb121d0c..6995075d2f 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -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}; diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index ca74243699..24a71cc883 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -36,7 +36,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; pdf/ai: without editing data, with a warning, and ai refuses pdfCompatible: false; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts, variableData} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked). Never changes the document's path", + "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; pdf/ai: of the layers the file draws, without editing data, with a warning, and ai refuses pdfCompatible: false; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts, variableData} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the art it plots, without placed images, unpainted paths and art at 0% opacity), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked). Never changes the document's path", has_doc, export::export ), diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 0fbffcea82..0bb18483cb 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -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> { /// 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> { +/// 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> { 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 { + 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 { + // 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; + } + 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 /// 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], left_out: &dyn Fn(&Node) -> bool) -> Option>> { + let mut out: Option>> = 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(r: std::result::Result) -> Result { r.map_err(|e| bad(C, e)) } @@ -636,8 +770,9 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result { { 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); } diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index a891a81ff2..da6da069e9 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -128,7 +128,7 @@ pub fn specs() -> Vec { "Export PDF", [], None, - "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), useArtboards?: true (false: one page, the bounds of the visible art, as document.export writes it; artboard, artboards and range are then ignored, Preserve Editing is left out (with a warning when it is on), and a document with no visible art is refused), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; in pdfA2b files, and with longPagesInPoints: false, a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: true (such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in; false: the UserUnit as above, with a warning that names this option), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says; a CMYK image that keeps its numbers carries the CMYK profile it embeds with all and taggedSource, and in PDF/A, pdfX3 and pdfX4 unless destination, never in pdfX1a), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", + "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), useArtboards?: true (false: one page, the bounds of the visible art the page draws (non-printing layers left out unless includeNonPrinting or createLayers keeps them), as document.export writes it; artboard, artboards and range are then ignored, Preserve Editing is left out (with a warning when it is on), and a document with no visible art is refused), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; in pdfA2b files, and with longPagesInPoints: false, a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: true (such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in; false: the UserUnit as above, with a warning that names this option), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says; a CMYK image that keeps its numbers carries the CMYK profile it embeds with all and taggedSource, and in PDF/A, pdfX3 and pdfX4 unless destination, never in pdfX1a), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", has_doc, export_pdf ), @@ -289,7 +289,7 @@ pub const EDITING_NEEDS_THE_ARTBOARDS: &str = /// Editing on, the notes end with [`EDITING_NEEDS_THE_ARTBOARDS`]. `None` when `p` asks for the /// artboards. pub(super) fn encode_art_page(cmd: &str, doc: &Document, p: &Value) -> Result, Vec)>> { - let Some((art, mut q)) = super::encode::art_page(cmd, doc, p)? else { return Ok(None) }; + let Some((art, mut q)) = art_page(cmd, doc, p)? else { return Ok(None) }; let left_out = settings(cmd, &q)?.preserve_editing; if left_out && let Some(o) = q.as_object_mut() { o.insert("preserveEditing".into(), json!(false)); @@ -301,6 +301,18 @@ pub(super) fn encode_art_page(cmd: &str, doc: &Document, p: &Value) -> Result Result> { + if super::encode::use_artboards(cmd, p)? != Some(false) { + return Ok(None); + } + let set = settings(cmd, p)?; + let keeps = set.include_non_printing || set.create_layers; + super::encode::art_page(cmd, doc, p, |l| !keeps && matches!(l.kind, NodeKind::Layer { printable: false, .. })) +} + /// The warning for a PDF/X file that leaves out the document's bleed /// ([`vectorcraft_pdf::PdfSettings::leaves_out_bleed`]). pub const BLEED_LEFT_OUT: &str = "the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings"; @@ -477,29 +489,7 @@ fn thumbnails(doc: &Document, opts: &PdfOptions) -> std::result::Result]) -> Option>> { - let mut out: Option>> = None; - for (i, l) in layers.iter().enumerate() { - let NodeKind::Layer { printable, children, .. } = &l.kind else { continue }; - let new = if !printable { - l.visible.then(|| { - let mut n = (**l).clone(); - n.visible = false; - Arc::new(n) - }) - } else { - without_non_printing(children).map(|c| { - let mut n = (**l).clone(); - if let NodeKind::Layer { children, .. } = &mut n.kind { - *children = c; - } - Arc::new(n) - }) - }; - if let Some(slot) = new.and_then(|new| Some((out.get_or_insert_with(|| layers.to_vec()).get_mut(i)?, new))) { - *slot.0 = slot.1; - } - } - out + super::encode::hidden_nodes(layers, &|l| matches!(l.kind, NodeKind::Layer { printable: false, .. })) } fn export_pdf(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index af9ed8e58a..e4a72ffc35 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -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> { @@ -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 { +/// `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 { + let params = ai_pdf_params(p, editing); let pdf_compatible = bool_or(¶ms, "pdfCompatible", true); let f = &if pdf_compatible { *f } else { without_pdf(f) }; let native_options = super::native::ai_options(cmd, f, doc, ¶ms)?; diff --git a/crates/engine/src/cmd/fileio/tests_hpgl.rs b/crates/engine/src/cmd/fileio/tests_hpgl.rs index f4767fd81d..a7fe16690e 100644 --- a/crates/engine/src/cmd/fileio/tests_hpgl.rs +++ b/crates/engine/src/cmd/fileio/tests_hpgl.rs @@ -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(); diff --git a/crates/engine/src/cmd/fileio/tests_pdf.rs b/crates/engine/src/cmd/fileio/tests_pdf.rs index f57e02fc2d..f5b1b8e34d 100644 --- a/crates/engine/src/cmd/fileio/tests_pdf.rs +++ b/crates/engine/src/cmd/fileio/tests_pdf.rs @@ -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 diff --git a/crates/ui-egui/src/dialogs/export_as.rs b/crates/ui-egui/src/dialogs/export_as.rs index add0fbed73..cd18d6b153 100644 --- a/crates/ui-egui/src/dialogs/export_as.rs +++ b/crates/ui-egui/src/dialogs/export_as.rs @@ -140,7 +140,10 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } // 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; diff --git a/crates/ui-egui/src/dialogs/tests_export.rs b/crates/ui-egui/src/dialogs/tests_export.rs index ca37940601..f8a2e05515 100644 --- a/crates/ui-egui/src/dialogs/tests_export.rs +++ b/crates/ui-egui/src/dialogs/tests_export.rs @@ -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); diff --git a/docs/mcp.md b/docs/mcp.md index de3422afda..04c28a4903 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1085,7 +1085,15 @@ or `range`), named `-.`, 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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a647777563..5e8c2dbed8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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`).