mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 12:24:19 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
@@ -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(¶ms, "pdfCompatible", true);
|
||||
let f = &if pdf_compatible { *f } else { without_pdf(f) };
|
||||
let native_options = super::native::ai_options(cmd, f, doc, ¶ms)?;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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`).
|
||||
|
||||
Reference in New Issue
Block a user