mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
Type is drawn as its glyph outlines under a geometry effect, in EMF and WMF export, in DXF export when it is written as outlines, through Flatten Transparency with Convert All Text to Outlines, and inside an envelope. Those outlines differed from the type the canvas draws in four ways. They had no underline or strikethrough bars. A character stroke on scaled type kept its unscaled weight. An unplaced gradient was fitted to the glyphs it painted instead of the layout box. A run's pattern tiles stayed in document space instead of scaling and turning with the type. A group's or layer's own fills and strokes also painted the glyphs of the type in it without the bars. The outlines now include each run's underline and strikethrough bars, as the canvas has drawn them since #850. Each run's path is made in text space with its unplaced gradients fixed to the layout box, then placed by the type's transform, which also scales its stroke weight and dashes and transforms its pattern tiles. The type's own fills and strokes have their unplaced gradients fixed to the bounding box of the layout box in the document, where the canvas fits them. Geometry effects that move or scale the outlines move and scale the type's gradients with them: the type's own effects, also inside an envelope and after Flatten Transparency has converted the type to outlines, and the effects of a group, symbol instance or envelope that holds the type. outline_text marks its group with a new node field, type_outlines, which is saved with the document. reshape_leaves maps the placed gradients in each marked group through the map that takes the group's bounds before the effects onto its bounds after them. A scale out of proportion stretches a radial gradient into an ellipse and turns the isolines of a diagonal linear gradient, as it does on type scaled by its own transform. Placed gradients outside those groups stay where they are.
212 lines
9.5 KiB
Rust
212 lines
9.5 KiB
Rust
//! Object → Expand Appearance: fills and strokes as objects, baked geometry effects, raster
|
||
//! effects as images, brushes as art, and the enable rule.
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_color::BlendMode;
|
||
use vectorcraft_doc::{Node, NodeKind};
|
||
|
||
use super::*;
|
||
|
||
fn session() -> Session {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||
s
|
||
}
|
||
|
||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||
}
|
||
|
||
fn node(s: &Session, id: NodeId) -> Node {
|
||
s.doc().unwrap().doc.node(id).unwrap().clone()
|
||
}
|
||
|
||
/// A selected 100×100 square at (50, 50) with a red fill and a 4 pt blue stroke.
|
||
fn square(s: &mut Session) -> NodeId {
|
||
let id = NodeId(run(s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100}))["id"].as_u64().unwrap());
|
||
run(s, "paint.setFill", json!({"color": "#ff0000"}));
|
||
run(s, "paint.setStroke", json!({"color": "#0000ff"}));
|
||
run(s, "stroke.set", json!({"weight": 4}));
|
||
id
|
||
}
|
||
|
||
fn render(s: &Session) -> vectorcraft_render::Rendered {
|
||
let doc = s.doc().unwrap().doc.clone();
|
||
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||
vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts)
|
||
}
|
||
|
||
/// Pixels (of 200×200) whose colour differs by more than `tol` in any channel.
|
||
fn differing(a: &vectorcraft_render::Rendered, b: &vectorcraft_render::Rendered, tol: u8) -> usize {
|
||
a.pixels
|
||
.as_chunks::<4>()
|
||
.0
|
||
.iter()
|
||
.zip(b.pixels.as_chunks::<4>().0)
|
||
.filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > tol))
|
||
.count()
|
||
}
|
||
|
||
fn expandable(s: &Session) -> bool {
|
||
s.commands().iter().find(|c| c.id == "effect.expandAppearance").unwrap().enabled
|
||
}
|
||
|
||
#[test]
|
||
fn two_fills_and_an_offset_stroke_become_three_paths_that_look_the_same() {
|
||
let mut s = session();
|
||
let id = square(&mut s);
|
||
assert!(!expandable(&s), "a basic appearance has nothing to expand");
|
||
// A second fill on top (half-transparent green) and an outset stroke.
|
||
run(&mut s, "appearance.addFill", json!({}));
|
||
run(&mut s, "appearance.setItem", json!({"index": 2, "color": "#00ff00", "opacity": 50}));
|
||
run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 10}, "item": 1}));
|
||
assert!(expandable(&s));
|
||
let before = render(&s);
|
||
let r = run(&mut s, "effect.expandAppearance", json!({}));
|
||
assert_eq!(r["ids"], json!([id.0]));
|
||
let n = node(&s, id);
|
||
let ch = n.children().expect("a group of the fills and the stroke");
|
||
assert_eq!(ch.len(), 3);
|
||
assert!(ch.iter().all(|c| matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && c.appearance.items.len() == 1));
|
||
// In paint order: red fill, the outlined stroke (a filled ring 10 pt out), the green fill.
|
||
assert!(ch.iter().all(|c| c.appearance.items[0].is_fill() && c.appearance.items[0].effects().is_empty()));
|
||
let ring = ch[1].geometric_bounds().unwrap();
|
||
assert!((ring.width() - 124.0).abs() < 0.5, "{ring:?}");
|
||
assert!((ch[2].opacity - 0.5).abs() < 1e-6, "the fill's opacity is the path's own");
|
||
assert!(ch[2].appearance.is_basic());
|
||
let after = render(&s);
|
||
assert!(differing(&before, &after, 8) < 200, "{} pixels differ", differing(&before, &after, 8));
|
||
assert!(!expandable(&s));
|
||
// One undo step.
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert!(matches!(node(&s, id).kind, NodeKind::Path { .. }));
|
||
assert_eq!(node(&s, id).appearance.items.len(), 3);
|
||
}
|
||
|
||
#[test]
|
||
fn a_shadow_becomes_an_image_under_the_art_and_transparency_stays_on_the_group() {
|
||
let mut s = session();
|
||
let id = square(&mut s);
|
||
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "params": {"x": 8, "y": 8, "blur": 2, "opacity": 100}}));
|
||
run(&mut s, "transparency.set", json!({"opacity": 60, "blend": "multiply"}));
|
||
let before = render(&s);
|
||
run(&mut s, "effect.expandAppearance", json!({}));
|
||
let n = node(&s, id);
|
||
assert!((n.opacity - 0.6).abs() < 1e-6 && n.blend == BlendMode::Multiply);
|
||
assert!(n.appearance.effects.is_empty() && n.appearance.items.is_empty());
|
||
let ch = n.children().unwrap();
|
||
assert_eq!(ch.len(), 3, "the shadow image, the fill, the outlined stroke");
|
||
let NodeKind::Image(im) = &ch[0].kind else { panic!("the shadow is an image: {}", ch[0].kind_label()) };
|
||
assert!(s.doc().unwrap().doc.images.contains_key(&im.key));
|
||
assert!(ch[1..].iter().all(|c| c.opacity == 1.0 && c.appearance.effects.is_empty()));
|
||
let after = render(&s);
|
||
assert!(differing(&before, &after, 24) < 400, "{} pixels differ", differing(&before, &after, 24));
|
||
// The shadow is still there, below and right of the square.
|
||
let p = after.pixel(155, 155);
|
||
assert!(p[0] < 200, "{p:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_blur_turns_the_object_into_an_image() {
|
||
let mut s = session();
|
||
let id = square(&mut s);
|
||
run(&mut s, "effect.apply", json!({"effect": "blur.gaussian", "params": {"radius": 3}}));
|
||
run(&mut s, "effect.expandAppearance", json!({}));
|
||
let n = node(&s, id);
|
||
assert!(matches!(n.kind, NodeKind::Image(_)), "{}", n.kind_label());
|
||
assert!(n.appearance.effects.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn an_art_brush_becomes_a_group_of_its_art() {
|
||
let mut s = session();
|
||
let id = NodeId(run(&mut s, "shape.line", json!({"x1": 20, "y1": 100, "x2": 180, "y2": 100}))["id"].as_u64().unwrap());
|
||
run(&mut s, "select.set", json!({"ids": [id.0]}));
|
||
run(&mut s, "paint.setFill", json!({"none": true}));
|
||
run(&mut s, "brush.apply", json!({"name": "Charcoal"}));
|
||
assert!(expandable(&s));
|
||
let before = render(&s);
|
||
run(&mut s, "effect.expandAppearance", json!({}));
|
||
let n = node(&s, id);
|
||
assert!(matches!(n.kind, NodeKind::Group { .. }), "{}", n.kind_label());
|
||
let mut brushed = false;
|
||
n.walk(&mut |c| brushed |= vectorcraft_brush::has_brush(c));
|
||
assert!(!brushed, "no brush strokes are left");
|
||
let after = render(&s);
|
||
assert!(differing(&before, &after, 24) < 200, "{} pixels differ", differing(&before, &after, 24));
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert!(vectorcraft_brush::has_brush(&node(&s, id)));
|
||
}
|
||
|
||
#[test]
|
||
fn a_targeted_layer_with_a_fill_expands_into_art_among_its_members() {
|
||
let mut s = session();
|
||
let a = square(&mut s);
|
||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||
run(&mut s, "layer.target", json!({"id": layer.0}));
|
||
run(&mut s, "appearance.addStroke", json!({}));
|
||
run(&mut s, "stroke.set", json!({"item": 0, "weight": 6}));
|
||
assert!(expandable(&s));
|
||
let before = render(&s);
|
||
run(&mut s, "effect.expandAppearance", json!({}));
|
||
let l = node(&s, layer);
|
||
assert!(l.appearance.items.is_empty());
|
||
let ch = l.children().unwrap();
|
||
assert_eq!(ch.len(), 2, "the square, then the layer's stroke above it");
|
||
assert_eq!(ch[0].id, a);
|
||
// The layer's stroke is outlined: a filled path.
|
||
assert!(ch[1].appearance.items.iter().all(|i| i.is_fill()));
|
||
let after = render(&s);
|
||
assert!(differing(&before, &after, 8) < 200, "{} pixels differ", differing(&before, &after, 8));
|
||
}
|
||
|
||
#[test]
|
||
fn type_with_a_shadow_stays_type_and_type_with_its_own_stroke_is_outlined() {
|
||
let mut s = session();
|
||
let t = NodeId(run(&mut s, "text.create", json!({"x": 20, "y": 80, "text": "Hi", "size": 48}))["id"].as_u64().unwrap());
|
||
run(&mut s, "select.set", json!({"ids": [t.0]}));
|
||
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow"}));
|
||
run(&mut s, "effect.expandAppearance", json!({}));
|
||
let n = node(&s, t);
|
||
let ch = n.children().unwrap();
|
||
assert!(matches!(ch[0].kind, NodeKind::Image(_)) && matches!(ch[1].kind, NodeKind::Text(_)));
|
||
// A stroke of its own: outlined glyphs.
|
||
let u = NodeId(run(&mut s, "text.create", json!({"x": 20, "y": 160, "text": "Yo", "size": 48}))["id"].as_u64().unwrap());
|
||
run(&mut s, "select.set", json!({"ids": [u.0]}));
|
||
run(&mut s, "appearance.addStroke", json!({}));
|
||
run(&mut s, "effect.expandAppearance", json!({}));
|
||
let mut text = false;
|
||
node(&s, u).walk(&mut |c| text |= matches!(c.kind, NodeKind::Text(_)));
|
||
assert!(!text, "the type is outlined");
|
||
assert!(!expandable(&s));
|
||
}
|
||
|
||
#[test]
|
||
fn type_with_its_own_stroke_expands_with_its_underline() {
|
||
let mut s = session();
|
||
let t = NodeId(run(&mut s, "text.create", json!({"x": 20, "y": 160, "text": "Hi", "size": 48}))["id"].as_u64().unwrap());
|
||
run(&mut s, "select.set", json!({"ids": [t.0]}));
|
||
run(&mut s, "text.setFormat", json!({"underline": true}));
|
||
run(&mut s, "appearance.addStroke", json!({}));
|
||
run(&mut s, "effect.expandAppearance", json!({}));
|
||
// "Hi" has no ink below its baseline (y 160): the pieces reach the bar under it.
|
||
let b = node(&s, t).geometric_bounds().unwrap();
|
||
assert!(b.y1 > 162.0, "{b:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_blurred_layer_keeps_its_image_as_its_only_member() {
|
||
let mut s = session();
|
||
square(&mut s);
|
||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||
run(&mut s, "layer.clippingMask.toggle", json!({"id": layer.0}));
|
||
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 200}));
|
||
run(&mut s, "layer.target", json!({"id": layer.0}));
|
||
run(&mut s, "effect.apply", json!({"effect": "blur.gaussian"}));
|
||
run(&mut s, "effect.expandAppearance", json!({}));
|
||
let l = node(&s, layer);
|
||
assert!(l.is_layer() && !l.clips() && l.appearance.effects.is_empty());
|
||
let ch = l.children().unwrap();
|
||
assert!(ch.len() == 1 && matches!(ch[0].kind, NodeKind::Image(_)));
|
||
}
|