mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 01:15:25 +00:00
Export: live effects in symbol art apply in PDF, EPS, print and DXF
PDF, print, EPS and DXF wrote a symbol instance by placing its symbol's art as stored, so live effects inside that art were missing: a square moved 100 pt by a Transform effect stayed in place in the file while the canvas drew it moved. bake_document_placing_symbols replaces each instance of a symbol whose art (or the art of a symbol nested in it) needs baking by that art placed as the instance places it, then baked. The art is placed before its effects apply, as on the canvas, so an instance that scales its art does not scale what its effects do. A dynamic instance's own paint changes apply before baking. Nesting stops at 32 levels and the placed art at 16,000,000 objects per export; past them an instance stays an instance. DXF places 9-slice cells unclipped, as its scene does. The symbols' own art, SVG and the .ai editing data are unchanged, and HPGL already baked each placed instance.
This commit is contained in:
@@ -428,7 +428,8 @@ pub fn export(doc: &Document, opts: &DxfOptions) -> Result<DxfOutput, String> {
|
||||
if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) {
|
||||
return Err("the export region is not finite".into());
|
||||
}
|
||||
let baked = vectorcraft_effects::bake_document(doc);
|
||||
// Symbols' art is placed without clipping, as the scene places it.
|
||||
let baked = vectorcraft_effects::bake_document_placing_symbols(doc, false);
|
||||
let doc = baked.as_ref().unwrap_or(doc);
|
||||
Ok(scene::Scene::new(doc, opts).run())
|
||||
}
|
||||
|
||||
@@ -1395,6 +1395,29 @@ fn crop_keeps_an_instance_whose_art_reaches_the_region() {
|
||||
assert!(!kinds(&export(&d, &o).unwrap()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_art_effects_are_written_where_they_draw() {
|
||||
// A line and the symbol's line below it, each moved 100 pt right by a Transform effect, start
|
||||
// at the same x.
|
||||
let moved = |mut n: Node| {
|
||||
n.appearance.effects.push(vectorcraft_doc::Effect {
|
||||
id: "distort.transform".into(),
|
||||
params: serde_json::json!({"moveH": 100.0}),
|
||||
visible: true,
|
||||
});
|
||||
n
|
||||
};
|
||||
let mut d = doc_with(|d| {
|
||||
let line = path(d, shapes::line((10.0, 10.0).into(), (20.0, 10.0).into()), Paint::None, Paint::solid(Color::BLACK), 1.0);
|
||||
vec![moved(line), Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Moved".into(), xf: Affine::IDENTITY })]
|
||||
});
|
||||
let art = path(&mut d, shapes::line((10.0, 40.0).into(), (20.0, 40.0).into()), Paint::None, Paint::solid(Color::BLACK), 1.0);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(moved(art)) });
|
||||
let starts: Vec<f64> = entities(&export(&d, &opts(&d)).unwrap()).iter().map(|e| all(e, 10).into_iter().fold(f64::INFINITY, f64::min)).collect();
|
||||
assert_eq!(starts.len(), 2, "{starts:?}");
|
||||
assert!((starts[0] - starts[1]).abs() < 1e-6, "{starts:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbols_fanning_out_into_empty_symbols_stop_at_the_symbol_budget() {
|
||||
// Five levels of 100 instances of the level below, ending in an empty symbol: 10^10 empty
|
||||
|
||||
+135
-4
@@ -17,7 +17,8 @@
|
||||
//! object of its own, a stroke a path stroked by it alone (brushed strokes their brush art, made by
|
||||
//! the caller).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::color::Paint;
|
||||
@@ -26,7 +27,7 @@ use vectorcraft_geom::{Affine, FillRule, PathData, Rect, Vec2};
|
||||
|
||||
use crate::distort::{transform_move, transform_offsets};
|
||||
use crate::group::{has_own_paint, paints};
|
||||
use crate::reshape::{as_art, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves};
|
||||
use crate::reshape::{as_art, dressed, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves};
|
||||
use crate::{
|
||||
GeomContext, adjust_in_document, apply_geometry_carrying, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry,
|
||||
has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, outline_text, reshape,
|
||||
@@ -113,13 +114,105 @@ thread_local! {
|
||||
/// The objects of symbol art that [`bake_document`] has baked for instances inside the art of
|
||||
/// other instances, counted against [`crate::SYMBOL_ART_MAX_NODES`].
|
||||
static NESTED_NODES: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
/// While [`bake_document_placing_symbols`] bakes: the symbols whose instances become their
|
||||
/// placed art ([`placed_symbol_art`]).
|
||||
static PLACING: std::cell::RefCell<Option<Rc<Placing>>> = const { std::cell::RefCell::new(None) };
|
||||
/// The objects of symbol art that [`placed_symbol_art`] has placed for one export, counted
|
||||
/// against [`vectorcraft_doc::MAX_SYMBOL_NODES`].
|
||||
static PLACED_NODES: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// The symbols whose instances [`bake_document_placing_symbols`] replaces by their placed art, and
|
||||
/// how that art is placed through 9-slice guides.
|
||||
struct Placing {
|
||||
/// The names of the symbols whose art draws something that baking changes, directly or through
|
||||
/// the instances of other such symbols in it ([`drawn_symbols`]).
|
||||
symbols: HashSet<String>,
|
||||
/// Whether 9-slice cells are clipped ([`Document::place_symbol_art`]) or not
|
||||
/// ([`Document::place_symbol_art_unclipped`]).
|
||||
clip_slices: bool,
|
||||
}
|
||||
|
||||
/// The names of the symbols of `d` whose art needs baking ([`needs_bake`]), and of those whose art
|
||||
/// holds an instance of one of them at any depth.
|
||||
fn drawn_symbols(d: &Document) -> HashSet<String> {
|
||||
let mut out: HashSet<String> = d.symbols.iter().filter(|s| needs_bake(&s.art)).map(|s| s.name.clone()).collect();
|
||||
if out.is_empty() {
|
||||
return out;
|
||||
}
|
||||
let uses: Vec<(&str, Vec<String>)> = d
|
||||
.symbols
|
||||
.iter()
|
||||
.filter(|s| !out.contains(&s.name))
|
||||
.map(|s| {
|
||||
let mut names = vec![];
|
||||
s.art.walk(&mut |c| {
|
||||
if let NodeKind::SymbolInstance { symbol, .. } = &c.kind {
|
||||
names.push(symbol.clone());
|
||||
}
|
||||
});
|
||||
(s.name.as_str(), names)
|
||||
})
|
||||
.collect();
|
||||
loop {
|
||||
let before = out.len();
|
||||
for (name, names) in &uses {
|
||||
if !out.contains(*name) && names.iter().any(|x| out.contains(x)) {
|
||||
out.insert((*name).to_string());
|
||||
}
|
||||
}
|
||||
if out.len() == before {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `n`'s subtree hold an instance of a symbol that [`bake_document_placing_symbols`] replaces
|
||||
/// by its placed art? False outside it.
|
||||
fn holds_placed_symbol(n: &Node) -> bool {
|
||||
fn walk(n: &Node, symbols: &HashSet<String>) -> bool {
|
||||
matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if symbols.contains(symbol))
|
||||
|| n.children().is_some_and(|ch| ch.iter().any(|c| walk(c, symbols)))
|
||||
}
|
||||
PLACING.with(|p| p.borrow().as_ref().is_some_and(|p| walk(n, &p.symbols)))
|
||||
}
|
||||
|
||||
/// Symbol instance `n`, while [`bake_document_placing_symbols`] bakes and its symbol is one it
|
||||
/// places, as the art it draws: the symbol's art with the instance's own appearance changes
|
||||
/// ([`vectorcraft_doc::symbol_overrides::dynamic_art`]), placed by the instance as the exporters
|
||||
/// place it, in a group with the instance's id, name, visibility, transparency, opacity mask and
|
||||
/// raster effects. The art is placed before its effects are baked, as the canvas draws it, so an
|
||||
/// instance that scales its art does not scale what its effects do. `None` for other objects,
|
||||
/// for instances nested [`vectorcraft_doc::depth::MAX_DEPTH`] deep in such art, and once the art
|
||||
/// placed for one export would pass [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects; those
|
||||
/// instances stay as they are.
|
||||
fn placed_symbol_art(d: &Document, n: &Node) -> Option<Node> {
|
||||
let NodeKind::SymbolInstance { symbol, xf } = &n.kind else { return None };
|
||||
let clip = PLACING.with(|p| p.borrow().as_ref().filter(|p| p.symbols.contains(symbol)).map(|p| p.clip_slices))?;
|
||||
if INSTANCE_DEPTH.with(|c| c.get()) >= vectorcraft_doc::depth::MAX_DEPTH {
|
||||
return None;
|
||||
}
|
||||
let sym = d.symbols.iter().find(|s| s.name == *symbol)?;
|
||||
let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n);
|
||||
let most = match d.symbol_slice_map(symbol, *xf) {
|
||||
Some(m) if clip => m.most_placed(&art, d),
|
||||
Some(m) => m.most_placed_unclipped(&art, d),
|
||||
None => art.count_with_masks() as u64,
|
||||
};
|
||||
let spent = PLACED_NODES.with(|c| c.get()).saturating_add(most);
|
||||
if spent > vectorcraft_doc::MAX_SYMBOL_NODES {
|
||||
return None;
|
||||
}
|
||||
PLACED_NODES.with(|c| c.set(spent));
|
||||
let placed = if clip { d.place_symbol_art(symbol, &art, *xf) } else { d.place_symbol_art_unclipped(symbol, &art, *xf) }?;
|
||||
Some(dressed(Node::group(n.id, vec![Arc::new(placed)]), n))
|
||||
}
|
||||
|
||||
/// `n` with its geometry effects evaluated (`None` = unchanged).
|
||||
fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
// A cancelled export leaves the rest of the document as it is: the caller drops the result.
|
||||
// A ruler guide draws nothing and stays as it is, last in its layer.
|
||||
if n.is_ruler_guide() || !needs_bake(n) || vectorcraft_doc::cancel::is_cancelled() {
|
||||
if n.is_ruler_guide() || !(needs_bake(n) || holds_placed_symbol(n)) || vectorcraft_doc::cancel::is_cancelled() {
|
||||
return None;
|
||||
}
|
||||
// Colour adjustments: the object recoloured (inside it too), then baked as it is.
|
||||
@@ -176,6 +269,14 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
}
|
||||
return Some(baked.unwrap_or(m));
|
||||
}
|
||||
// Symbol instances whose art draws live effects, when the caller asks for them: the art
|
||||
// placed, then baked.
|
||||
if let Some(m) = placed_symbol_art(d, n) {
|
||||
INSTANCE_DEPTH.with(|c| c.set(c.get() + 1));
|
||||
let baked = bake_node(d, &m);
|
||||
INSTANCE_DEPTH.with(|c| c.set(c.get() - 1));
|
||||
return Some(baked.unwrap_or(m));
|
||||
}
|
||||
// A path whose Transform effect copies it with a gradient, or scales its strokes or moves its
|
||||
// patterns: each copy an object of its own, so its gradient fits it rather than spanning
|
||||
// every copy and its strokes and patterns can differ from the others'.
|
||||
@@ -693,8 +794,38 @@ fn bake_appearance_pieces(doc: Option<&Document>, n: &mut Node, outline_strokes:
|
||||
/// resources of its placed documents' art added ([`Document::with_placed_art`]); `None` when
|
||||
/// there is nothing to do (export it as is).
|
||||
pub fn bake_document(doc: &Document) -> Option<Document> {
|
||||
bake(doc, None)
|
||||
}
|
||||
|
||||
/// [`bake_document`], and every symbol instance whose symbol's art draws something baking changes
|
||||
/// (live geometry effects, color adjustments and the rest [`needs_bake`] counts, in its own art or
|
||||
/// in the art of the symbols it places) replaced by that art as the instance places it, baked
|
||||
/// ([`placed_symbol_art`]), so the art draws as on the canvas wherever the instance is. For
|
||||
/// exporters that write each instance's art in its place (PDF, print, EPS, DXF), rather than
|
||||
/// writing each symbol once. `clip_slices`: whether 9-slice cells are clipped
|
||||
/// ([`Document::place_symbol_art`]) or not ([`Document::place_symbol_art_unclipped`]), as the
|
||||
/// exporter places other instances. The symbols' own art is left as it is.
|
||||
pub fn bake_document_placing_symbols(doc: &Document, clip_slices: bool) -> Option<Document> {
|
||||
bake(doc, Some(clip_slices))
|
||||
}
|
||||
|
||||
/// Restores what [`bake`] changes in the thread's baking state when it ends.
|
||||
struct Restore(Option<Rc<Placing>>, u64);
|
||||
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
PLACING.with(|p| *p.borrow_mut() = self.0.take());
|
||||
PLACED_NODES.with(|c| c.set(self.1));
|
||||
}
|
||||
}
|
||||
|
||||
/// [`bake_document`], or with `placing` [`bake_document_placing_symbols`] with that `clip_slices`.
|
||||
fn bake(doc: &Document, placing: Option<bool>) -> Option<Document> {
|
||||
let placed = doc.with_placed_art();
|
||||
if !placed.layers.iter().any(|l| needs_bake(l)) {
|
||||
let placing = placing.map(|clip_slices| Placing { symbols: drawn_symbols(&placed), clip_slices }).filter(|p| !p.symbols.is_empty());
|
||||
// A bake inside another one (of art an effect makes) places symbols only when it asks to.
|
||||
let _restore = Restore(PLACING.with(|p| p.replace(placing.map(Rc::new))), PLACED_NODES.with(|c| c.replace(0)));
|
||||
if !placed.layers.iter().any(|l| needs_bake(l) || holds_placed_symbol(l)) {
|
||||
return match placed {
|
||||
std::borrow::Cow::Owned(d) => Some(d),
|
||||
std::borrow::Cow::Borrowed(_) => None,
|
||||
|
||||
@@ -1025,3 +1025,110 @@ fn exporting_a_path_with_a_transform_gives_each_copy_its_own_gradient() {
|
||||
// Three squares of their own, not one path spanning all three.
|
||||
assert_eq!(gradient_pieces(d.node(NodeId(50)).unwrap()), vec![10.0; 3]);
|
||||
}
|
||||
|
||||
/// A 10 × 10 square at (10, 40), with id `id`, that a Transform effect moves 100 pt right.
|
||||
fn moved_square(id: u64) -> Node {
|
||||
let mut n = Node::path(NodeId(id), shapes::rectangle(Rect::new(10.0, 40.0, 20.0, 50.0)), Appearance::default_art());
|
||||
n.appearance.effects.push(fx("distort.transform", json!({"moveH": 100.0})));
|
||||
n
|
||||
}
|
||||
|
||||
fn instance(id: u64, symbol: &str, xf: vectorcraft_geom::Affine) -> Node {
|
||||
Node::new(NodeId(id), NodeKind::SymbolInstance { symbol: symbol.into(), xf })
|
||||
}
|
||||
|
||||
fn add_symbol(d: &mut Document, name: &str, art: Node) {
|
||||
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: name.into(), art: Arc::new(art) });
|
||||
}
|
||||
|
||||
fn bounds(d: &Document, id: u64) -> Rect {
|
||||
d.node(NodeId(id)).and_then(|n| n.geometric_bounds()).unwrap_or_else(|| panic!("node {id} has bounds"))
|
||||
}
|
||||
|
||||
fn near_rect(a: Rect, b: Rect) -> bool {
|
||||
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() < 1e-6)
|
||||
}
|
||||
|
||||
/// Each instance draws its symbol's art with the art's effects applied where the instance places
|
||||
/// it: an instance that doubles the art's size moves it the effect's 100 pt, as the canvas draws
|
||||
/// it, not 200 pt. Plain baking leaves the instances and the symbols' art as they are.
|
||||
#[test]
|
||||
fn placing_symbols_bakes_their_art_where_each_instance_places_it() {
|
||||
use vectorcraft_geom::Affine;
|
||||
let mut d = doc_with(instance(900, "S", Affine::IDENTITY));
|
||||
let layer = d.layers[0].id;
|
||||
d.insert(Some(layer), 0, instance(901, "S", Affine::translate((0.0, 100.0)) * Affine::scale(2.0))).unwrap();
|
||||
add_symbol(&mut d, "S", moved_square(800));
|
||||
assert!(bake_document(&d).is_none(), "nothing in the layers needs baking");
|
||||
let out = bake_document_placing_symbols(&d, true).unwrap();
|
||||
assert!(matches!(out.node(NodeId(900)).unwrap().kind, NodeKind::Group { .. }));
|
||||
assert!(near_rect(bounds(&out, 900), Rect::new(110.0, 40.0, 120.0, 50.0)), "{:?}", bounds(&out, 900));
|
||||
assert!(near_rect(bounds(&out, 901), Rect::new(120.0, 180.0, 140.0, 200.0)), "{:?}", bounds(&out, 901));
|
||||
assert_eq!(out.symbols[0].art.appearance.effects.len(), 1, "the symbol's art keeps its effect");
|
||||
}
|
||||
|
||||
/// An instance of a symbol whose art holds an instance of another symbol with effects draws that
|
||||
/// art with its effects too.
|
||||
#[test]
|
||||
fn placing_symbols_reaches_the_art_of_nested_instances() {
|
||||
use vectorcraft_geom::Affine;
|
||||
let mut d = doc_with(instance(900, "Outer", Affine::IDENTITY));
|
||||
add_symbol(&mut d, "Inner", moved_square(800));
|
||||
add_symbol(&mut d, "Outer", Node::group(NodeId(801), vec![Arc::new(instance(802, "Inner", Affine::translate((0.0, 100.0))))]));
|
||||
let out = bake_document_placing_symbols(&d, true).unwrap();
|
||||
assert!(near_rect(bounds(&out, 900), Rect::new(110.0, 140.0, 120.0, 150.0)), "{:?}", bounds(&out, 900));
|
||||
}
|
||||
|
||||
/// A symbol whose art holds an instance of itself is placed until the depth limit, and the export
|
||||
/// ends.
|
||||
#[test]
|
||||
fn placing_a_symbol_that_holds_itself_stops() {
|
||||
use vectorcraft_geom::Affine;
|
||||
let mut d = doc_with(instance(900, "Loop", Affine::IDENTITY));
|
||||
add_symbol(
|
||||
&mut d,
|
||||
"Loop",
|
||||
Node::group(NodeId(801), vec![Arc::new(moved_square(800)), Arc::new(instance(802, "Loop", Affine::translate((0.0, 1.0))))]),
|
||||
);
|
||||
let out = bake_document_placing_symbols(&d, true).unwrap();
|
||||
let mut lefts = vec![];
|
||||
out.node(NodeId(900)).unwrap().walk(&mut |n| lefts.extend(n.path_data().and_then(|p| p.bounds()).map(|b| b.x0)));
|
||||
assert!(lefts.len() > 1 && lefts.iter().all(|x| (x - 110.0).abs() < 1e-6), "{lefts:?}");
|
||||
let mut depth = 0;
|
||||
let mut n = out.node(NodeId(900)).unwrap();
|
||||
while let Some(next) = n.children().and_then(|ch| ch.last()) {
|
||||
n = next;
|
||||
depth += 1;
|
||||
}
|
||||
assert!(depth <= 3 * (vectorcraft_doc::depth::MAX_DEPTH + 1), "{depth}");
|
||||
}
|
||||
|
||||
/// A dynamic symbol instance's own paint for an object of the art applies before the object's
|
||||
/// effects are baked: a fill whose own Transform effect makes the square a group of pieces still
|
||||
/// paints in the instance's red, and its sibling keeps the symbol's paint.
|
||||
#[test]
|
||||
fn placing_symbols_keeps_dynamic_instances_changes() {
|
||||
use vectorcraft_geom::Affine;
|
||||
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
|
||||
let mut square = Node::path(NodeId(800), shapes::rectangle(Rect::new(10.0, 40.0, 20.0, 50.0)), Appearance::default_art());
|
||||
if let Some(AppearanceItem::Fill(f)) = square.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) {
|
||||
f.effects.push(fx("distort.transform", json!({"moveH": 100.0})));
|
||||
}
|
||||
let sibling = Node::path(NodeId(801), shapes::rectangle(Rect::new(10.0, 60.0, 20.0, 70.0)), Appearance::default_art());
|
||||
let mut inst = instance(900, "D", Affine::IDENTITY);
|
||||
inst.extras_mut().symbol_overrides = vec![vectorcraft_doc::SymbolOverride { path: vec![0], fill: Some(red.clone()), ..Default::default() }];
|
||||
let mut d = doc_with(inst);
|
||||
add_symbol(&mut d, "D", Node::group(NodeId(802), vec![Arc::new(square), Arc::new(sibling)]));
|
||||
d.symbols[0].dynamic = true;
|
||||
let out = bake_document_placing_symbols(&d, true).unwrap();
|
||||
let mut fills = vec![];
|
||||
out.node(NodeId(900)).unwrap().walk(&mut |n| {
|
||||
if n.path_data().is_some()
|
||||
&& let Some(b) = n.geometric_bounds()
|
||||
{
|
||||
fills.push((b.x0.round(), b.y0.round(), n.appearance.fill_paint()));
|
||||
}
|
||||
});
|
||||
assert!(fills.contains(&(110.0, 40.0, red)), "{fills:?}");
|
||||
assert!(fills.contains(&(10.0, 60.0, Paint::solid(Color::WHITE))), "{fills:?}");
|
||||
}
|
||||
|
||||
@@ -68,7 +68,9 @@ pub use adjust::{
|
||||
ADJUSTMENTS, CHANNELS, ColorMap, ImageHook, adjust, adjust_in_document, color_map, curve_at, curve_points, has_adjustment, is_adjustment,
|
||||
is_channel,
|
||||
};
|
||||
pub use bake::{StrokeArt, bake_appearance, bake_document, bake_type_appearance, expand_art, expand_leaf, fresh_ids, needs_bake};
|
||||
pub use bake::{
|
||||
StrokeArt, bake_appearance, bake_document, bake_document_placing_symbols, bake_type_appearance, expand_art, expand_leaf, fresh_ids, needs_bake,
|
||||
};
|
||||
pub use clip::{WarpSeams, clip_outline, knockout_silhouette, warp_pieces_translucent};
|
||||
pub use group::{
|
||||
OutlineHook, PATHFINDER_EFFECTS, compound_shape_area, compound_shape_art, evaluate_container, has_container_appearance, has_pathfinder,
|
||||
|
||||
@@ -845,7 +845,7 @@ fn as_art_opts(
|
||||
|
||||
/// `art`, the art made of `n`, with `n`'s name, visibility, transparency, opacity mask and
|
||||
/// remaining (raster) effects.
|
||||
fn dressed(mut art: Node, n: &Node) -> Node {
|
||||
pub(crate) fn dressed(mut art: Node, n: &Node) -> Node {
|
||||
art.name = n.name.clone();
|
||||
art.visible = n.visible;
|
||||
art.opacity = n.opacity;
|
||||
|
||||
@@ -230,7 +230,7 @@ pub fn export(doc: &Document, opts: &EpsOptions, preview: Option<&Raster>, thumb
|
||||
}
|
||||
// Type in the document's composite fonts is drawn in them, whether or not an app has the document open.
|
||||
let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts);
|
||||
let baked = vectorcraft_effects::bake_document(doc);
|
||||
let baked = vectorcraft_effects::bake_document_placing_symbols(doc, true);
|
||||
let doc = baked.as_ref().unwrap_or(doc);
|
||||
let page = scene::Scene::new(doc, opts).run();
|
||||
let mut setting_notes = vec![];
|
||||
|
||||
@@ -1409,6 +1409,23 @@ fn an_instance_whose_brush_art_reaches_the_region_is_written() {
|
||||
assert!(page.contains("1 0 0 rg"), "{page}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_art_effects_are_written_where_they_draw() {
|
||||
// The symbol's square at (10, 40), which a Transform effect moves 100 pt right, is drawn from
|
||||
// x = 110 and not from x = 10.
|
||||
let mut d = doc_with(|d| vec![Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Moved".into(), xf: Affine::IDENTITY })]);
|
||||
let mut art = path(&mut d, shapes::rectangle(Rect::new(10.0, 40.0, 20.0, 50.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
art.appearance.effects.push(vectorcraft_doc::Effect {
|
||||
id: "distort.transform".into(),
|
||||
params: serde_json::json!({"moveH": 100.0}),
|
||||
visible: true,
|
||||
});
|
||||
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(art) });
|
||||
let page = page_of(&d, &opts(&d));
|
||||
let starts: Vec<f64> = page.lines().filter(|l| l.ends_with(" m")).filter_map(|l| l.split_whitespace().next()?.parse().ok()).collect();
|
||||
assert!(!starts.is_empty() && starts.iter().all(|x| (110.0..=120.0).contains(x)), "{starts:?}\n{page}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_preview_and_overprint_ids_read_back() {
|
||||
for l in Level::ALL {
|
||||
|
||||
@@ -45,8 +45,9 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha
|
||||
let set = &opts.settings;
|
||||
set.check()?;
|
||||
vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?;
|
||||
// Live geometry effects export as their result; raster effects are reported below.
|
||||
let baked = vectorcraft_effects::bake_document(doc);
|
||||
// Live geometry effects export as their result, in symbols' art too; raster effects are
|
||||
// reported below.
|
||||
let baked = vectorcraft_effects::bake_document_placing_symbols(doc, true);
|
||||
// Baking stops early when cancelled, leaving a half-baked copy that must not be written.
|
||||
vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?;
|
||||
let doc = baked.as_ref().unwrap_or(doc);
|
||||
|
||||
@@ -183,7 +183,7 @@ pub fn keep_layers(nodes: &mut Vec<Arc<Node>>, which: PrintLayers) {
|
||||
/// only the layers that print, overprints in composite output as [`PrintAdvanced::overprints`]
|
||||
/// says.
|
||||
pub fn printed_document<'a>(doc: &'a Document, set: &PrintSettings) -> Cow<'a, Document> {
|
||||
let mut doc = vectorcraft_effects::bake_document(doc).map_or(Cow::Borrowed(doc), Cow::Owned);
|
||||
let mut doc = vectorcraft_effects::bake_document_placing_symbols(doc, true).map_or(Cow::Borrowed(doc), Cow::Owned);
|
||||
let d = doc.to_mut();
|
||||
keep_layers(&mut d.layers, set.print_layers);
|
||||
composite_overprints(d, set);
|
||||
|
||||
@@ -246,6 +246,26 @@ fn an_instance_whose_brush_art_reaches_the_page_is_written() {
|
||||
assert!(close(l[0].geometric_bounds().unwrap(), Rect::new(-15.0, 30.0, 5.0, 70.0), 0.01), "{:?}", l[0].geometric_bounds());
|
||||
}
|
||||
|
||||
/// The live effects in a symbol's art apply where the instance places the art, as on the canvas:
|
||||
/// the symbol's square at (10, 40), which a Transform effect moves 100 pt right, is written at
|
||||
/// (110, 40).
|
||||
#[test]
|
||||
fn symbol_art_effects_are_written_where_they_draw() {
|
||||
let mut d = doc(200.0, 100.0);
|
||||
let mut square = rect_node(Rect::new(10.0, 40.0, 20.0, 50.0), Color::rgb(1.0, 0.0, 0.0));
|
||||
square.appearance.effects.push(vectorcraft_doc::Effect {
|
||||
id: "distort.transform".into(),
|
||||
params: serde_json::json!({"moveH": 100.0}),
|
||||
visible: true,
|
||||
});
|
||||
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(square) });
|
||||
add(&mut d, Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Moved".into(), xf: Affine::IDENTITY }));
|
||||
let out = roundtrip(&d);
|
||||
let l = leaves(&out);
|
||||
assert_eq!(l.len(), 1);
|
||||
assert!(close(l[0].geometric_bounds().unwrap(), Rect::new(110.0, 40.0, 120.0, 50.0), 0.01), "{:?}", l[0].geometric_bounds());
|
||||
}
|
||||
|
||||
/// An instance of a symbol with 9-slice guides, scaled to 200 × 150, is written as the canvas
|
||||
/// draws it: its 10 × 10 corner square stays 10 × 10 and its 10 × 10 center square becomes 30 × 20.
|
||||
#[test]
|
||||
|
||||
@@ -451,6 +451,29 @@ fn a_symbol_instances_brush_art_keeps_its_artboard_printed() {
|
||||
assert!(p[0].1.iter().any(|(_, paint)| rgb8(paint) == [255, 0, 0]), "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_art_effects_print_where_they_draw() {
|
||||
// A red square and the symbol's blue square, each moved 100 pt right by a Transform effect,
|
||||
// print at the same distance from the page's left edge.
|
||||
let moved = |mut n: Node| {
|
||||
n.appearance.effects.push(vectorcraft_doc::Effect { id: "distort.transform".into(), params: json!({"moveH": 100.0}), visible: true });
|
||||
n
|
||||
};
|
||||
let mut d = doc(200.0, 100.0, &[]);
|
||||
let (layer, id) = (d.default_layer().unwrap(), d.alloc_id());
|
||||
let red = Node::path(id, shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)), Appearance::basic(rgb(1.0, 0.0, 0.0), Paint::None, 0.0));
|
||||
d.insert(Some(layer), 0, moved(red)).unwrap();
|
||||
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 40.0, 20.0, 50.0)), Appearance::basic(rgb(0.0, 0.0, 1.0), Paint::None, 0.0));
|
||||
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(moved(art)) });
|
||||
let id = d.alloc_id();
|
||||
d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Moved".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap();
|
||||
let p = pages(&job(&d, json!({})).bytes);
|
||||
assert_eq!(p.len(), 1);
|
||||
let left = |c: [u8; 3]| p[0].1.iter().find(|(_, paint)| rgb8(paint) == c).map(|(r, _)| r.x0);
|
||||
let (red, blue) = (left([255, 0, 0]), left([0, 0, 255]));
|
||||
assert!(red.zip(blue).is_some_and(|(r, b)| (r - b).abs() < 0.05), "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copies_collate_and_reverse() {
|
||||
let mut d =
|
||||
|
||||
@@ -1552,6 +1552,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
Outline Object on an image's own fill or stroke follows the outline of the image's opaque area, and the image's frame when the image has no transparency. Path effects apply to a fill or stroke added to a bitmap object ([archived reference manual, page 561](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=561)), and with Outline Object such a stroke follows the boundary of the art when the image has transparent areas and the image's frame when it has none ([CreativePro article on strokes for placed images](https://web.archive.org/web/20250815214529/https://creativepro.com/add-strokes-placed-images-illustrator/)). VectorCraft's own rules: the opaque area is the pixels at least 50% opaque, and its outline is traced as Image Trace's Black and White mode traces at threshold 128 and fitted with curves. Evidence: `tests::imageoutline` (vectorcraft-effects), `opaque::tests` (vectorcraft-trace), `tests::containers::an_images_stroke_with_outline_object_follows_its_opaque_area` and `tests::containers::an_images_outline_object_stroke_exports_selects_and_measures_along_its_opaque_area` (vectorcraft-engine), `tests::objectfx::an_images_outline_object_stroke_follows_new_pixels_under_the_same_key` (vectorcraft-render).
|
||||
A geometry effect on one of type's own fills or strokes reshapes the whole outline that the fill or stroke paints, on the canvas and in exports; PDF, SVG, EPS and the other vector exports write such type as paths. On type that holds inline graphics, those effects are drawn and exported only when the type has a geometry effect or perspective of its own.
|
||||
A geometry effect on a symbol instance's fill reshapes the filled shapes of the symbol's art and leaves their strokes as they are; one on the instance's stroke reshapes the stroked shapes and leaves their fills as they are. The shapes keep their colors. Only the instance's topmost fill and topmost stroke count, and only while they are visible with an opacity above 0. The canvas and Object › Expand Appearance use the same art, SVG and PDF export bake the same shapes, and Expand Appearance turns the instance into an editable group in one undo step. A group's own fills and strokes in the art take the effects too. An instance inside the art passes the effects on to its own art, and a dynamic symbol's instance reshapes the fills and strokes it paints on the art. How the effects map onto the symbol's art is VectorCraft's own rule. The help page <https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/apply-and-modify-effects.html> describes effects on one fill or stroke, and the help page <https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/expand-objects.html> describes Expand Appearance (engine `expand::a_symbol_instance_fill_effect_reshapes_its_filled_art_and_expands`, `a_symbol_instance_stroke_effect_reshapes_its_stroked_art_and_expands`, `a_stroke_effect_reshapes_a_stroke_a_dynamic_instance_adds`, `a_fill_effect_reshapes_the_fill_of_a_group_in_the_art`, `only_the_topmost_visible_instance_fill_reshapes_the_art`, `a_nested_symbol_instance_takes_the_outer_fill_effect`, `export_bakes_a_symbol_instance_fill_effect`, `a_fill_effect_on_an_instance_of_a_symbol_that_holds_itself_ends`). Existing documents with a geometry effect on a symbol instance's fill or stroke draw differently: before, the effect drew nothing.
|
||||
Live effects inside a symbol's art (geometry effects, color adjustments and the rest that export bakes) are written where each instance places the art in PDF, print, EPS and DXF, as the canvas draws them: the art is placed first and its effects are applied after, so an instance that scales its art does not scale the distance a Transform effect moves it. Symbols nested in the art and a dynamic symbol instance's own paint changes are kept, a symbol whose art holds itself stops at the nesting limit, and the symbols' own art keeps its effects. Before, these exports wrote the art without its effects (effects `bake::placing_symbols_bakes_their_art_where_each_instance_places_it`, `placing_symbols_reaches_the_art_of_nested_instances`, `placing_a_symbol_that_holds_itself_stops`, `placing_symbols_keeps_dynamic_instances_changes`; PDF, print, EPS and DXF `symbol_art_effects_are_written_where_they_draw`, `symbol_art_effects_print_where_they_draw`).
|
||||
The Appearance panel's inline effect editor labels each parameter as the effect dialogs' generic fields do, without the colon ("Max. Radius", "Channel 1", "Create New Fields by"). Roughen's and Zig Zag's Points, Offset Path's Joins, Inner Glow's Source and the menus of Radial Blur, Smart Blur, Sprayed Strokes, Glass, Mezzotint, Halftone Pattern, Grain, Texturizer and De-Interlace are dropdowns with the dialog's labels that send the dialog's values, and a plug-in's choice is a dropdown of the plug-in's values.
|
||||
Revolve's edge, Extrude & Bevel's bevel, every Warp style's orientation, Rasterize's Color Model, Background and Anti-aliasing and the channel of Levels and Curves are dropdowns too, with the labels their own dialogs show. `effect.apply` and `effect.setParams` refuse a Levels or Curves `channel` other than `rgb`, `red`, `green` or `blue` (or `r`, `g` and `b`, in any letter case).
|
||||
Object › Expand Appearance keeps each stroke of a path a stroke (weight, caps, joins, dashes, arrowheads, alignment and width profile) on a copy of the path with the geometry effects baked in, and Object › Expand… is dimmed, with a tooltip, while the selection has appearance attributes, so a stroked line with Zig Zag becomes a stroked zig-zag line and only Expand then outlines it, the two steps the [Expand objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/expand-objects.html) help page describes (#3089). Brushed strokes still become their brush art; `object.expand` still bakes and outlines in one step for agents.
|
||||
|
||||
Reference in New Issue
Block a user