mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
A group's or layer's own fills and strokes paint every member's geometry, below or above the members as the new contents slot (Appearance.contents_index) says; its geometry effects reshape the members as one piece and its raster effects apply to the composite (one combined shadow), respecting knockout groups. Type's own fills and strokes paint under or over its characters. Render, SVG/PDF (via bake) and Expand Appearance evaluate them alike. Commands: appearance.* / effect.* take target: "object"|"contents" and layer ids; appearance.moveItem moves the Contents slot (from: "contents") or an item across it (contents); appearance.targetContents selects a group's members. Graphic styles applied to a group now style the group itself. The Appearance panel shows Contents/Characters rows that drag, and double-click to target the members or edit the characters' paint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
82 lines
3.6 KiB
Rust
82 lines
3.6 KiB
Rust
//! Strokes in SVG export look as on the canvas: arrowheads, width profiles, dots and brushes are
|
|
//! written as the canvas's filled outlines or art, aligned strokes are clipped or masked, and open
|
|
//! paths stroke centred. Checked by rendering the exported SVG with resvg.
|
|
|
|
use vectorcraft_color::{Color, Paint};
|
|
use vectorcraft_doc::{Appearance, AppearanceItem, Arrowhead, Dash, Document, LineCap, LineJoin, Node, StrokeAlign, StrokeLayer, WidthProfile};
|
|
use vectorcraft_geom::{PathData, Point, SubPath, shapes};
|
|
use vectorcraft_svg::{ExportOptions, export};
|
|
use vectorcraft_testkit::raster::{Image, assert_similar, render_artboard};
|
|
|
|
fn add(d: &mut Document, path: PathData, f: impl FnOnce(&mut StrokeLayer)) {
|
|
let mut st = StrokeLayer::new(Paint::solid(Color::rgb(0.1, 0.2, 0.6)), 4.0);
|
|
f(&mut st);
|
|
let n = Node::path(d.alloc_id(), path, Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() });
|
|
let l = d.layers[0].id;
|
|
d.insert(Some(l), usize::MAX, n).unwrap();
|
|
}
|
|
|
|
fn line(x0: f64, y: f64, x1: f64) -> PathData {
|
|
shapes::line(Point::new(x0, y), Point::new(x1, y))
|
|
}
|
|
|
|
/// The exported SVG rendered by resvg on white at 1 px/pt.
|
|
fn resvg_render(svg: &str, w: u32, h: u32) -> Image {
|
|
let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default()).expect("parse");
|
|
let mut pm = resvg::tiny_skia::Pixmap::new(w, h).unwrap();
|
|
pm.fill(resvg::tiny_skia::Color::WHITE);
|
|
resvg::render(&tree, resvg::tiny_skia::Transform::identity(), &mut pm.as_mut());
|
|
Image { width: w, height: h, rgba: pm.data().to_vec() }
|
|
}
|
|
|
|
#[test]
|
|
fn exported_strokes_render_like_the_canvas() {
|
|
let mut d = Document::new(200.0, 200.0);
|
|
add(&mut d, line(20.0, 20.0, 150.0), |s| {
|
|
s.end_arrow = Some(Arrowhead::Triangle);
|
|
s.start_arrow = Some(Arrowhead::CircleOpen);
|
|
s.opacity = 0.5;
|
|
});
|
|
add(&mut d, line(20.0, 50.0, 180.0), |s| {
|
|
s.width = 14.0;
|
|
s.profile = Some(WidthProfile::lens());
|
|
});
|
|
add(&mut d, line(20.0, 80.0, 180.0), |s| {
|
|
s.cap = LineCap::Square;
|
|
s.dash = Some(Dash { pattern: vec![0.0, 12.0], offset: 0.0, align_corners: false });
|
|
});
|
|
add(&mut d, line(20.0, 110.0, 180.0), |s| s.brush = Some("Tapered Stroke".into()));
|
|
let tri = SubPath::polyline(&[Point::new(60.0, 185.0), Point::new(100.0, 185.0), Point::new(80.0, 135.0)], true);
|
|
add(&mut d, PathData::single(tri), |s| {
|
|
s.align = StrokeAlign::Outside;
|
|
s.join = LineJoin::Miter;
|
|
s.width = 6.0;
|
|
});
|
|
let svg = export(&d, &ExportOptions::default());
|
|
assert!(!svg.contains("stroke-dasharray"), "dots are outlines:\n{svg}");
|
|
let canvas = render_artboard(&d);
|
|
let written = resvg_render(&svg, 200, 200);
|
|
assert_similar(&canvas, &written, 24.0, 0.002);
|
|
// The comparison is meaningful: every stroke drew something.
|
|
for y in [20, 50, 80, 110, 160] {
|
|
assert!((0..200).any(|x| canvas.over_white(x, y).iter().any(|c| *c < 200)), "row {y} has ink");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_open_path_with_inside_alignment_exports_a_plain_stroke() {
|
|
let mut d = Document::new(200.0, 100.0);
|
|
add(&mut d, line(20.0, 50.0, 180.0), |s| s.align = StrokeAlign::Inside);
|
|
let svg = export(&d, &ExportOptions::default());
|
|
assert!(svg.contains("stroke-width=\"4\""), "{svg}");
|
|
assert!(!svg.contains("clip-path") && !svg.contains("mask"), "{svg}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_lens_profile_exports_a_fill() {
|
|
let mut d = Document::new(200.0, 100.0);
|
|
add(&mut d, line(20.0, 50.0, 180.0), |s| s.profile = Some(WidthProfile::lens()));
|
|
let svg = export(&d, &ExportOptions::default());
|
|
assert!(svg.contains("fill=\"#1a3399\"") && !svg.contains("stroke="), "{svg}");
|
|
}
|