mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
Both writers now ask the shared stroke geometry how to write each stroke (effects::stroke::for_writer): a plain stroke where that is exact, else the canvas's filled outlines (arrowheads, width profiles, dashes fitted to corners, dots with round or projecting caps), grouped under the stroke opacity when the line and its heads overlap. Brushed strokes are written as their brush art. Inside/outside alignment only applies to closed paths, so open paths export as centred strokes. The PDF outside-stroke clip frame and the SVG outside mask now cover the whole stroke reach (miter spikes, projecting caps). SVG stroke gradients use the same paint box as the canvas. The svg crate docs no longer claim arrowheads, profiles, brushes and effects are dropped. 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)], effects: vec![] });
|
|
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}");
|
|
}
|