mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 02:07:09 -06:00
Export: the art's bounds take in the effects of groups, type and images
The bounds of the visible art (`vectorcraft_render::encode::art_bounds`) are the crop of Export with Use Artboards off, EPS without artboards, Export for Screens' Full Document and Save for Web (Legacy) with Clip to Artboard off, and the size Fit to Artwork Bounds gives an artboard. They measured a group or layer by its members and left out its own effects, and measured type and images without their effects. A group's drop shadow, or the shadow of a line of type, was cut off at the image's edge and left outside the fitted artboard. They also counted a Shaper group's transparent source art. Each visible object is now measured by `vectorcraft_render::effect_bounds`, the measure of Export Selection's crop. A group or layer without effects of its own is still measured by its visible members, skipping a Shaper group's source art. A group or layer with a drop shadow, a glow or another raster effect is measured without its hidden members, guides and template layers, unless it is a Shaper group or also has a geometry, Pathfinder, Crop Marks or 3D effect or a visible effect on its own fill or stroke. The SVG of a chosen artboard also keeps an object whose effects alone reach the artboard. Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
@@ -15,6 +15,8 @@ pub mod tga;
|
||||
pub mod tiff;
|
||||
pub mod web;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Document, Node, NodeKind, SymbolBoxes};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
@@ -191,8 +193,9 @@ pub(crate) fn webp(img: &Rendered) -> Result<Vec<u8>, String> {
|
||||
}
|
||||
|
||||
/// Bounds of the art an export draws: visible objects off template layers (guides left out),
|
||||
/// with their strokes and live effects, symbol instances measured by their art. `None` when
|
||||
/// nothing would be drawn.
|
||||
/// each measured by [`crate::effect_bounds`]: with its strokes and its effects (the effects of
|
||||
/// groups, layers, type and images included), symbol instances by their art, and a Shaper group
|
||||
/// without effects of its own by the art it shows. `None` when nothing would be drawn.
|
||||
pub fn art_bounds(doc: &Document) -> Option<Rect> {
|
||||
let symbols = doc.symbol_boxes();
|
||||
doc.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, drawn_bounds(&symbols, l)))
|
||||
@@ -222,16 +225,54 @@ fn drawn_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. } => None,
|
||||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
|
||||
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, drawn_bounds(symbols, c)))
|
||||
}
|
||||
_ if fx::has_fx(n) => fx::visual_bounds(symbols, n),
|
||||
// An instance's box takes in its art, the effects in the art and its own effects.
|
||||
NodeKind::SymbolInstance { .. } => fx::cull_bounds(symbols, n),
|
||||
_ => n.visual_bounds_with(symbols),
|
||||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !fx::has_object_fx(n) => children
|
||||
.iter()
|
||||
.skip(usize::from(n.extras().shaper.is_some()))
|
||||
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, drawn_bounds(symbols, c))),
|
||||
_ => crate::effect_bounds(symbols, without_undrawn(n).as_ref().unwrap_or(n)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `n`, a group or layer that doesn't clip, without the hidden objects, guides and template layers
|
||||
/// in it and in the groups and layers in it that don't clip, or `None` when it holds none of them
|
||||
/// or is anything else. A Shaper group keeps all its members, and so does a group or layer with a
|
||||
/// visible geometry, Pathfinder, Crop Marks or 3D effect, or with a visible effect on one of its
|
||||
/// own visible fills or strokes.
|
||||
fn without_undrawn(n: &Node) -> Option<Node> {
|
||||
use crate::effects;
|
||||
let keeps_all = n.extras().shaper.is_some()
|
||||
|| effects::has_geometry(&n.appearance.effects)
|
||||
|| effects::has_pathfinder(n)
|
||||
|| effects::has_crop_marks(n)
|
||||
|| effects::has_revolve(n)
|
||||
|| effects::has_solid(n)
|
||||
|| n.appearance.items.iter().any(|i| i.visible() && i.effects().iter().any(|e| e.visible));
|
||||
if keeps_all || !matches!(n.kind, NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. }) {
|
||||
return None;
|
||||
}
|
||||
let undrawn = |c: &Node| !c.visible || matches!(c.kind, NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. });
|
||||
let mut changed = false;
|
||||
let kept: Vec<Arc<Node>> = n
|
||||
.children()?
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
if undrawn(c) {
|
||||
changed = true;
|
||||
return None;
|
||||
}
|
||||
let part = without_undrawn(c);
|
||||
changed |= part.is_some();
|
||||
Some(part.map_or_else(|| c.clone(), Arc::new))
|
||||
})
|
||||
.collect();
|
||||
if !changed {
|
||||
return None;
|
||||
}
|
||||
let mut out = n.clone();
|
||||
*out.children_mut()? = kept;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -244,6 +244,87 @@ fn art_bounds_take_in_the_effects_in_a_symbols_art() {
|
||||
assert!(b.x0 <= 20.0 && b.x1 >= 38.0, "the art from x 20 and its shadow to x 38: {b:?}");
|
||||
}
|
||||
|
||||
/// A drop shadow 8 pt to the right, without blur.
|
||||
fn shadow_right() -> vectorcraft_doc::Effect {
|
||||
vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 8, "y": 0, "blur": 0}), visible: true }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_bounds_take_in_the_effects_of_groups_type_and_images() {
|
||||
let black = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let square = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)), black);
|
||||
let group = Node::group(d.alloc_id(), vec![std::sync::Arc::new(square)]);
|
||||
let t = TextObject::point(Point::new(10.0, 40.0), "Hill", CharStyle { size: 12.0, ..CharStyle::default() });
|
||||
let text = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
|
||||
let im = vectorcraft_doc::ImageObject {
|
||||
key: "image".into(),
|
||||
width: 30,
|
||||
height: 30,
|
||||
xf: vectorcraft_geom::Affine::translate((20.0, 50.0)),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
};
|
||||
let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
|
||||
let cut_off: Vec<_> = [("group", group), ("type", text), ("image", image)]
|
||||
.into_iter()
|
||||
.filter_map(|(what, mut n)| {
|
||||
let mut d = d.clone();
|
||||
let plain = n.visual_bounds().unwrap();
|
||||
n.appearance.effects.push(shadow_right());
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
let b = art_bounds(&d).unwrap();
|
||||
(b.x0 > plain.x0 || b.x1 < plain.x1 + 8.0).then(|| format!("{what}: {b:?}, the shadow 8 pt right of {plain:?}"))
|
||||
})
|
||||
.collect();
|
||||
assert!(cut_off.is_empty(), "{cut_off:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_bounds_leave_out_the_hidden_members_of_a_group_with_effects() {
|
||||
let black = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let square = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)), black.clone());
|
||||
let mut hidden = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(200.0, 10.0, 220.0, 20.0)), black.clone());
|
||||
hidden.visible = false;
|
||||
let mut guide = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(-300.0, 10.0, -200.0, 20.0)), black);
|
||||
if let NodeKind::Path { guide: g, .. } = &mut guide.kind {
|
||||
*g = true;
|
||||
}
|
||||
let mut group = Node::group(d.alloc_id(), [square, hidden, guide].map(std::sync::Arc::new).to_vec());
|
||||
// A geometry effect on the group's own fill that is turned off.
|
||||
group.appearance = Appearance::basic(Paint::None, Paint::None, 0.0).into();
|
||||
group.appearance.effects.push(shadow_right());
|
||||
let off = vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"scaleH": 50}), visible: false };
|
||||
group.appearance.fill_mut().unwrap().effects.push(off);
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, group).unwrap();
|
||||
let b = art_bounds(&d).unwrap();
|
||||
assert!(b.x0 >= 0.0 && (28.0..50.0).contains(&b.x1), "the square and its shadow, not the hidden square or the guide: {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_bounds_hold_a_groups_geometry_effect_measured_with_its_hidden_members() {
|
||||
// The Transform effect halves the group's width about the center of all its members, the
|
||||
// hidden one too: the visible rectangle is drawn from x 30 to x 80.
|
||||
let black = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let shown = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 10.0, 100.0, 20.0)), black.clone());
|
||||
let mut hidden = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(110.0, 10.0, 120.0, 20.0)), black);
|
||||
hidden.visible = false;
|
||||
let mut group = Node::group(d.alloc_id(), [shown, hidden].map(std::sync::Arc::new).to_vec());
|
||||
let half = serde_json::json!({"scaleH": 50, "scaleV": 100});
|
||||
group.appearance.effects.push(vectorcraft_doc::Effect { id: "distort.transform".into(), params: half, visible: true });
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, group).unwrap();
|
||||
let b = art_bounds(&d).unwrap();
|
||||
assert!(b.x0 <= 30.0 && b.x1 >= 80.0, "{b:?}");
|
||||
let img = Renderer::new().render_region(&d, Rect::new(0.0, 0.0, 200.0, 100.0), 1.0, false);
|
||||
let drawn = |x: u32| (0..100).any(|y| img.pixels[((y * 200 + x) * 4 + 3) as usize] > 0);
|
||||
assert!(drawn(31) && drawn(78) && !drawn(28) && !drawn(82), "the rectangle is drawn from x 30 to x 80");
|
||||
}
|
||||
|
||||
/// A black path round `r` in the first layer of `d`, made a guide when `guide`.
|
||||
fn add_rect(d: &mut Document, r: Rect, guide: bool) {
|
||||
let id = d.alloc_id();
|
||||
|
||||
Reference in New Issue
Block a user