M3.36: SVG and PDF export draw arrowheads, width profiles, brushes and aligned strokes as the canvas does
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>
This commit is contained in:
parent
18d870c7df
commit
f363cdb4ee
|
|
@ -4218,10 +4218,12 @@ dependencies = [
|
|||
"log",
|
||||
"smallvec",
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-brush",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
]
|
||||
|
||||
|
|
@ -4251,10 +4253,12 @@ dependencies = [
|
|||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
"resvg",
|
||||
"serde_json",
|
||||
"svgtypes 0.16.1",
|
||||
"thiserror 2.0.21",
|
||||
"usvg 0.48.1",
|
||||
"vectorcraft-brush",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
//! - [`width_outline`]: variable-width (profile) strokes;
|
||||
//! - [`line_outline`]: the line part of a stroke as one filled outline (profile, dashes, dots,
|
||||
//! caps and joins);
|
||||
//! - [`outline_region`]: a stroke as one clean filled path (Outline Stroke).
|
||||
//! - [`for_writer`] and [`outline_region`]: strokes for the SVG/PDF writers and Outline Stroke.
|
||||
|
||||
mod arrow;
|
||||
mod dash;
|
||||
|
|
@ -22,7 +22,7 @@ use vectorcraft_doc::{LineCap, LineJoin, StrokeAlign, StrokeLayer};
|
|||
|
||||
pub use arrow::Arrow;
|
||||
pub use dash::{Dashed, Dot, dash, dot_outline};
|
||||
pub use outline::{OUTLINE_TOL, outline_region};
|
||||
pub use outline::{OUTLINE_TOL, Written, WrittenShape, for_writer, is_plain, outline_region};
|
||||
pub use width::width_outline;
|
||||
|
||||
/// Arc-length accuracy for dashing and trimming (document points).
|
||||
|
|
|
|||
|
|
@ -1,17 +1,100 @@
|
|||
//! Strokes as filled geometry outside the canvas:
|
||||
//!
|
||||
//! - [`for_writer`]: what a file format whose strokes only have a weight, caps, joins, a miter
|
||||
//! limit and a dash array (SVG, PDF) writes so a stroke looks as it does on the canvas: a plain
|
||||
//! stroke where that is exact, else the canvas's filled outlines (arrowheads, width profiles,
|
||||
//! dashes fitted to corners, dots);
|
||||
//! - [`outline_region`]: the area a stroke paints as one clean path (Outline Stroke and the live
|
||||
//! Outline Stroke effect).
|
||||
|
||||
use vectorcraft_doc::{StrokeAlign, StrokeLayer};
|
||||
use kurbo::{BezPath, Rect, Shape};
|
||||
use vectorcraft_doc::{LineCap, LineJoin, StrokeAlign, StrokeLayer};
|
||||
use vectorcraft_geom::{FillRule, PathData};
|
||||
use vectorcraft_pathops::BoolOp;
|
||||
|
||||
use super::{aligned_width, is_closed, line_outline, stroke_pieces};
|
||||
use super::{StrokePieces, aligned_width, is_closed, line_outline, stroke_pieces};
|
||||
|
||||
/// Flattening/fitting tolerance of outlined strokes (document points).
|
||||
/// Flattening/fitting tolerance of written and outlined strokes (document points).
|
||||
pub const OUTLINE_TOL: f64 = 0.01;
|
||||
|
||||
/// What a writer draws for one stroke (in the coordinate space of its path).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Written {
|
||||
pub shape: WrittenShape,
|
||||
/// `Some` for an inside or outside stroke of a closed path: the shape is drawn twice as wide
|
||||
/// and clipped to the path's interior (inside) or has the interior removed (outside).
|
||||
pub side: Option<StrokeAlign>,
|
||||
}
|
||||
|
||||
/// The geometry of a [`Written`] stroke.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum WrittenShape {
|
||||
/// A plain stroke of the path itself at `width`, with the layer's caps, joins, miter limit
|
||||
/// and dash pattern.
|
||||
Stroke { width: f64 },
|
||||
/// Filled outlines (non-zero rule): the line, then each arrowhead. They overlap, so they take
|
||||
/// the stroke's opacity once, as a group.
|
||||
Fill(Vec<BezPath>),
|
||||
}
|
||||
|
||||
/// Does `st` draw as a plain centred stroke on every path (no alignment, arrowheads, width
|
||||
/// profile, brush, fitted dashes or dots)? Writers then need no geometry for it.
|
||||
pub fn is_plain(st: &StrokeLayer) -> bool {
|
||||
st.align == StrokeAlign::Center
|
||||
&& st.start_arrow.is_none()
|
||||
&& st.end_arrow.is_none()
|
||||
&& st.profile.is_none()
|
||||
&& st.brush.is_none()
|
||||
&& !needs_outline_dashes(st)
|
||||
}
|
||||
|
||||
/// Dashes a plain stroke can't draw like the canvas: fitted to corners, or zero-length dashes
|
||||
/// (dots) with a cap that paints them.
|
||||
fn needs_outline_dashes(st: &StrokeLayer) -> bool {
|
||||
st.dash.as_ref().is_some_and(|d| d.is_dashed() && (d.align_corners || (st.cap != LineCap::Butt && d.pattern.contains(&0.0))))
|
||||
}
|
||||
|
||||
/// Do these pieces of `st` need filled outlines (see [`is_plain`])? Alignment alone doesn't.
|
||||
fn needs_outline(pieces: &StrokePieces, st: &StrokeLayer) -> bool {
|
||||
!pieces.heads.is_empty() || st.profile.is_some() || needs_outline_dashes(st)
|
||||
}
|
||||
|
||||
/// How a writer draws stroke `st` along `bp` (brushes aside) to match the canvas.
|
||||
pub fn for_writer(bp: &BezPath, st: &StrokeLayer) -> Written {
|
||||
if is_plain(st) {
|
||||
return Written { shape: WrittenShape::Stroke { width: st.width }, side: None };
|
||||
}
|
||||
let closed = is_closed(bp);
|
||||
let side = (st.align != StrokeAlign::Center && closed).then_some(st.align);
|
||||
let width = aligned_width(st, closed);
|
||||
let pieces = stroke_pieces(bp, st);
|
||||
let shape = if needs_outline(&pieces, st) {
|
||||
let mut fills = vec![line_outline(&pieces.line, st, width, OUTLINE_TOL)];
|
||||
fills.extend(pieces.heads.into_iter().map(|h| h.outline));
|
||||
fills.retain(|f| !f.elements().is_empty());
|
||||
WrittenShape::Fill(fills)
|
||||
} else {
|
||||
WrittenShape::Stroke { width }
|
||||
};
|
||||
Written { shape, side }
|
||||
}
|
||||
|
||||
impl Written {
|
||||
/// Bounds of what is drawn before the alignment clip, for a path with bounds `path_bounds`
|
||||
/// (covers miter spikes and projecting caps).
|
||||
pub fn reach(&self, st: &StrokeLayer, path_bounds: Rect) -> Rect {
|
||||
match &self.shape {
|
||||
WrittenShape::Stroke { width } => {
|
||||
let miter = if st.join == LineJoin::Miter { st.miter_limit.max(1.0) } else { 1.0 };
|
||||
let cap = if st.cap == LineCap::Square { std::f64::consts::SQRT_2 } else { 1.0 };
|
||||
let r = width / 2.0 * miter.max(cap);
|
||||
path_bounds.inflate(r, r)
|
||||
}
|
||||
WrittenShape::Fill(fills) => fills.iter().map(Shape::bounding_box).fold(path_bounds, |a, b| a.union(b)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The area stroke `st` paints along `path` (fill rule `rule`) as one clean path, as on the
|
||||
/// canvas: the line (width profile, dashes, caps, joins) united with the arrowheads, and kept
|
||||
/// inside or outside a closed path for inside or outside alignment. Brushes are not drawn.
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ workspace = true
|
|||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-doc = { workspace = true }
|
||||
vectorcraft-effects = { workspace = true }
|
||||
vectorcraft-brush = { workspace = true }
|
||||
vectorcraft-color = { workspace = true }
|
||||
vectorcraft-text = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
|
|
@ -22,3 +23,6 @@ hayro-syntax = "0.7.2"
|
|||
hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts"] }
|
||||
log = { workspace = true }
|
||||
smallvec = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ use kurbo::{PathEl, Shape, Vec2};
|
|||
use vectorcraft_color::{BlendMode, Color, GradientKind, Paint};
|
||||
use vectorcraft_doc::appearance::stroke_paint_bounds;
|
||||
use vectorcraft_doc::{AppearanceItem, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, TextObject};
|
||||
use vectorcraft_effects::stroke::{self, WrittenShape};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, Rect};
|
||||
|
||||
use crate::{Compatibility, ExportReport, PdfError, PdfOptions};
|
||||
|
|
@ -70,7 +71,7 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
|
|||
}
|
||||
pdf.set_metadata(meta);
|
||||
|
||||
let mut ex = Exporter { doc, warnings: vec![], images: HashMap::new() };
|
||||
let mut ex = Exporter { doc, warnings: vec![], images: HashMap::new(), brushes: None };
|
||||
for i in indices {
|
||||
let ab = &doc.artboards[i];
|
||||
let r = ab.rect;
|
||||
|
|
@ -129,6 +130,8 @@ struct Exporter<'a> {
|
|||
doc: &'a Document,
|
||||
warnings: Vec<String>,
|
||||
images: HashMap<String, Option<Image>>,
|
||||
/// The brush library, parsed when the first brushed stroke is written.
|
||||
brushes: Option<Vec<vectorcraft_brush::Brush>>,
|
||||
}
|
||||
|
||||
fn xf(a: Affine) -> Transform {
|
||||
|
|
@ -406,7 +409,7 @@ impl Exporter<'_> {
|
|||
}
|
||||
NodeKind::Path { path, rule, guide, .. } => {
|
||||
if !*guide {
|
||||
self.shape(s, n, &path.to_bezpath(), *rule);
|
||||
self.shape(s, n, &path.to_bezpath(), *rule, page);
|
||||
}
|
||||
}
|
||||
NodeKind::Compound { children, rule } => {
|
||||
|
|
@ -416,7 +419,7 @@ impl Exporter<'_> {
|
|||
bp.extend(p.to_bezpath());
|
||||
}
|
||||
}
|
||||
self.shape(s, n, &bp, *rule);
|
||||
self.shape(s, n, &bp, *rule, page);
|
||||
}
|
||||
NodeKind::Text(t) => self.text(s, n, t),
|
||||
NodeKind::Image(im) => self.image(s, im),
|
||||
|
|
@ -440,7 +443,7 @@ impl Exporter<'_> {
|
|||
}
|
||||
}
|
||||
|
||||
fn shape(&mut self, s: &mut Surface, n: &Node, bp: &BezPath, r: FillRule) {
|
||||
fn shape(&mut self, s: &mut Surface, n: &Node, bp: &BezPath, r: FillRule, page: Rect) {
|
||||
let Some(path) = to_path(bp) else { return };
|
||||
let bounds = bp.bounding_box();
|
||||
for item in &n.appearance.items {
|
||||
|
|
@ -486,7 +489,7 @@ impl Exporter<'_> {
|
|||
if !st.visible || st.paint.is_none() || st.width <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
self.stroke(s, bp, &path, r, st, bounds);
|
||||
self.stroke(s, bp, &path, r, st, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -494,43 +497,27 @@ impl Exporter<'_> {
|
|||
s.set_stroke(None);
|
||||
}
|
||||
|
||||
fn stroke(&mut self, s: &mut Surface, bp: &BezPath, path: &Path, r: FillRule, st: &StrokeLayer, bounds: Rect) {
|
||||
if st.profile.is_some() || st.brush.is_some() {
|
||||
self.warn("variable-width profiles and brushes are exported as uniform strokes");
|
||||
fn stroke(&mut self, s: &mut Surface, bp: &BezPath, path: &Path, r: FillRule, st: &StrokeLayer, page: Rect) {
|
||||
if !stroke::is_plain(st) && self.brush_art(s, bp, st, page) {
|
||||
return;
|
||||
}
|
||||
let Some(paint) = self.paint(&st.paint, bounds.inflate(st.width / 2.0, st.width / 2.0)) else { return };
|
||||
let closed = bp.elements().last().is_some_and(|e| matches!(e, PathEl::ClosePath));
|
||||
let bounds = bp.bounding_box();
|
||||
let Some(paint) = self.paint(&st.paint, st.paint_bounds(bounds)) else { return };
|
||||
let w = stroke::for_writer(bp, st);
|
||||
let mut pushes = 0;
|
||||
if st.blend != BlendMode::Normal {
|
||||
s.push_blend_mode(blend(st.blend));
|
||||
pushes += 1;
|
||||
}
|
||||
// The line ends under its arrowheads; they overlap, so both take the opacity once, as a group.
|
||||
let pieces = vectorcraft_effects::stroke::stroke_pieces(bp, st);
|
||||
let grouped = !pieces.heads.is_empty() && st.opacity < 1.0;
|
||||
if grouped {
|
||||
s.push_opacity(norm(st.opacity));
|
||||
pushes += 1;
|
||||
}
|
||||
let opacity = if grouped { NormalizedF32::ONE } else { norm(st.opacity) };
|
||||
let trimmed = match &pieces.line {
|
||||
std::borrow::Cow::Owned(line) => Some(to_path(line)),
|
||||
std::borrow::Cow::Borrowed(_) => None,
|
||||
};
|
||||
let width = match st.align {
|
||||
StrokeAlign::Center => st.width,
|
||||
_ if closed => st.width * 2.0,
|
||||
_ => st.width,
|
||||
};
|
||||
match st.align {
|
||||
StrokeAlign::Inside if closed => {
|
||||
match w.side {
|
||||
Some(StrokeAlign::Inside) => {
|
||||
s.push_clip_path(path, &rule(r));
|
||||
pushes += 1;
|
||||
}
|
||||
StrokeAlign::Outside if closed => {
|
||||
// Clip to everything outside the path: a big frame plus the path, even-odd.
|
||||
let big = bounds.inflate(width * 2.0 + 10.0, width * 2.0 + 10.0);
|
||||
let mut outside = big.to_path(0.1);
|
||||
Some(StrokeAlign::Outside) => {
|
||||
// Clip to everything outside the path: a frame around all the stroke reaches
|
||||
// (miter spikes included) plus the path, even-odd.
|
||||
let mut outside = w.reach(st, bounds).inflate(1.0, 1.0).to_path(0.1);
|
||||
outside.extend(bp.iter());
|
||||
if let Some(p) = to_path(&outside) {
|
||||
s.push_clip_path(&p, &krilla::paint::FillRule::EvenOdd);
|
||||
|
|
@ -539,6 +526,8 @@ impl Exporter<'_> {
|
|||
}
|
||||
_ => {}
|
||||
}
|
||||
match &w.shape {
|
||||
WrittenShape::Stroke { width } => {
|
||||
let dash = st.dash.as_ref().filter(|d| d.is_dashed()).map(|d| {
|
||||
let mut pat: Vec<f32> = d.pattern.iter().map(|v| *v as f32).collect();
|
||||
if pat.len() % 2 == 1 {
|
||||
|
|
@ -548,8 +537,8 @@ impl Exporter<'_> {
|
|||
});
|
||||
s.set_fill(None);
|
||||
s.set_stroke(Some(Stroke {
|
||||
paint: paint.clone(),
|
||||
width: width as f32,
|
||||
paint,
|
||||
width: *width as f32,
|
||||
miter_limit: st.miter_limit.max(1.0) as f32,
|
||||
line_cap: match st.cap {
|
||||
LineCap::Butt => krilla::paint::LineCap::Butt,
|
||||
|
|
@ -561,33 +550,58 @@ impl Exporter<'_> {
|
|||
LineJoin::Round => krilla::paint::LineJoin::Round,
|
||||
LineJoin::Bevel => krilla::paint::LineJoin::Bevel,
|
||||
},
|
||||
opacity,
|
||||
opacity: norm(st.opacity),
|
||||
dash,
|
||||
}));
|
||||
// A line fully covered by its heads (Tip alignment) has nothing left to stroke.
|
||||
if let Some(line) = trimmed.as_ref().map_or(Some(path), Option::as_ref) {
|
||||
s.draw_path(line);
|
||||
}
|
||||
s.draw_path(path);
|
||||
s.set_stroke(None);
|
||||
// Pop the alignment clip before drawing arrowheads.
|
||||
if matches!(st.align, StrokeAlign::Inside | StrokeAlign::Outside) && closed && pushes > 0 {
|
||||
s.pop();
|
||||
pushes -= 1;
|
||||
}
|
||||
if !pieces.heads.is_empty() {
|
||||
WrittenShape::Fill(outlines) => {
|
||||
// The line and its arrowheads overlap: they take the opacity once, as a group.
|
||||
let grouped = outlines.len() > 1 && st.opacity < 1.0;
|
||||
if grouped {
|
||||
s.push_opacity(norm(st.opacity));
|
||||
pushes += 1;
|
||||
}
|
||||
let opacity = if grouped { NormalizedF32::ONE } else { norm(st.opacity) };
|
||||
s.set_stroke(None);
|
||||
s.set_fill(Some(Fill { paint, opacity, rule: krilla::paint::FillRule::NonZero }));
|
||||
for head in &pieces.heads {
|
||||
if let Some(p) = to_path(&head.outline) {
|
||||
for p in outlines.iter().filter_map(to_path) {
|
||||
s.draw_path(&p);
|
||||
}
|
||||
}
|
||||
s.set_fill(None);
|
||||
}
|
||||
}
|
||||
for _ in 0..pushes {
|
||||
s.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint stroke `st` of the shape `bp` with its brush art (the stroke's opacity and blend mode
|
||||
/// over all of it). False when it has no known brush.
|
||||
fn brush_art(&mut self, s: &mut Surface, bp: &BezPath, st: &StrokeLayer, page: Rect) -> bool {
|
||||
let doc = self.doc;
|
||||
let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc));
|
||||
let Some(b) = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) else { return false };
|
||||
let art = vectorcraft_brush::stroke_pieces(b, bp, st);
|
||||
let mut pushes = 0;
|
||||
if st.blend != BlendMode::Normal {
|
||||
s.push_blend_mode(blend(st.blend));
|
||||
pushes += 1;
|
||||
}
|
||||
if st.opacity < 1.0 {
|
||||
s.push_opacity(norm(st.opacity));
|
||||
pushes += 1;
|
||||
}
|
||||
for piece in &art {
|
||||
self.node(s, piece, page, false);
|
||||
}
|
||||
for _ in 0..pushes {
|
||||
s.pop();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn text(&mut self, s: &mut Surface, n: &Node, t: &TextObject) {
|
||||
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let tb = t.xf.transform_rect_bbox(layout.bounds);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
//! VectorCraft PDF export and import.
|
||||
//!
|
||||
//! - [`export`] writes one PDF page per artboard with `krilla`: vector paths (fills, strokes with
|
||||
//! caps/joins/miter/dashes, non-zero/even-odd), opacity and blend modes (transparency groups),
|
||||
//! caps/joins/miter/dashes, inside/outside alignment as clips, non-zero/even-odd; arrowheads,
|
||||
//! width profiles, fitted or dotted dashes as the canvas's filled outlines and brushed strokes
|
||||
//! as their brush art), opacity and blend modes (transparency groups),
|
||||
//! clip groups, linear/radial gradients (shadings), embedded images and text as outlined glyph
|
||||
//! paths. Hidden objects, guides and template layers are skipped.
|
||||
//! - [`import`] reads PDF (and PDF-compatible `.ai`) pages with `hayro-interpret` into a
|
||||
|
|
@ -107,3 +109,5 @@ pub enum PdfError {
|
|||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_stroke;
|
||||
#[cfg(test)]
|
||||
mod tests_strokeout;
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
//! Stroke geometry in PDF export: arrowheads and the trimmed line come from the shared
|
||||
//! `vectorcraft_effects::stroke` geometry, and they take the stroke opacity once.
|
||||
//! Stroke geometry in PDF export: arrowheads and the trimmed line are written as the shared
|
||||
//! `vectorcraft_effects::stroke` outlines, and they take the stroke opacity once.
|
||||
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, ArrowAlign, Arrowhead, Document, Node, NodeId, NodeKind, StrokeLayer};
|
||||
use vectorcraft_effects::stroke::stroke_pieces;
|
||||
use vectorcraft_effects::stroke::{OUTLINE_TOL, line_outline, stroke_pieces};
|
||||
use vectorcraft_geom::{Point, Rect, Shape, shapes};
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn line_doc(st: StrokeLayer) -> (Document, Node) {
|
||||
pub(crate) fn line_doc(st: StrokeLayer) -> (Document, Node) {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let mut n = Node::path(
|
||||
NodeId(0),
|
||||
|
|
@ -34,7 +34,7 @@ fn close(a: Rect, b: Rect) -> bool {
|
|||
}
|
||||
|
||||
/// Bounding boxes of the filled and the stroked paths of a PDF read back.
|
||||
fn painted_boxes(d: &Document) -> (Vec<Rect>, Vec<Rect>) {
|
||||
pub(crate) fn painted_boxes(d: &Document) -> (Vec<Rect>, Vec<Rect>) {
|
||||
let back = import(&export(d, &PdfOptions::default()).unwrap()).unwrap();
|
||||
let (mut fills, mut strokes) = (vec![], vec![]);
|
||||
back.walk(|m| {
|
||||
|
|
@ -54,15 +54,17 @@ fn arrowheads_and_the_trimmed_line_match_the_shared_geometry() {
|
|||
for kind in Arrowhead::ALL {
|
||||
let (d, n) = line_doc(arrow_stroke(kind, align, 1.0));
|
||||
let bp = n.path_data().unwrap().to_bezpath();
|
||||
let pieces = stroke_pieces(&bp, n.appearance.stroke().unwrap());
|
||||
let st = n.appearance.stroke().unwrap();
|
||||
let pieces = stroke_pieces(&bp, st);
|
||||
let (fills, strokes) = painted_boxes(&d);
|
||||
let head = pieces.heads[0].outline.bounding_box();
|
||||
assert_eq!(fills.len(), 1, "{kind:?} {align:?}: one head");
|
||||
assert!(close(fills[0], head), "{kind:?} {align:?}: {:?} vs {head:?}", fills[0]);
|
||||
assert_eq!(strokes.len(), 1, "{kind:?} {align:?}: one line");
|
||||
assert!(close(strokes[0], pieces.line.bounding_box()), "{kind:?} {align:?}: {:?}", strokes[0]);
|
||||
let line = line_outline(&pieces.line, st, st.width, OUTLINE_TOL).bounding_box();
|
||||
assert!(strokes.is_empty(), "{kind:?} {align:?}: the line is written as its outline");
|
||||
assert_eq!(fills.len(), 2, "{kind:?} {align:?}: the line, then the head");
|
||||
assert!(close(fills[0], line), "{kind:?} {align:?}: {:?} vs {line:?}", fills[0]);
|
||||
assert!(close(fills[1], head), "{kind:?} {align:?}: {:?} vs {head:?}", fills[1]);
|
||||
if align == ArrowAlign::Tip {
|
||||
assert!((strokes[0].x1 - (80.0 - pieces.heads[0].inset)).abs() < 0.01, "{kind:?}: the stroke stops under the head");
|
||||
assert!((fills[0].x1 - (80.0 - pieces.heads[0].inset)).abs() < 0.01, "{kind:?}: the stroke stops under the head");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
//! Strokes a PDF stroke can't draw — width profiles, brushes — are written as the canvas's filled
|
||||
//! outlines; aligned strokes are clipped like on the canvas, open paths stroke centred.
|
||||
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, LineJoin, Node, NodeId, StrokeAlign, StrokeLayer, WidthProfile};
|
||||
use vectorcraft_geom::{PathData, Point, SubPath};
|
||||
use vectorcraft_testkit::raster::render_artboard;
|
||||
|
||||
use crate::tests_stroke::{line_doc, painted_boxes};
|
||||
use crate::*;
|
||||
|
||||
fn black(width: f64, f: impl FnOnce(&mut StrokeLayer)) -> StrokeLayer {
|
||||
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width);
|
||||
f(&mut st);
|
||||
st
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lens_profile_is_written_as_a_fill() {
|
||||
let (d, _) = line_doc(black(10.0, |s| s.profile = Some(WidthProfile::lens())));
|
||||
let (fills, strokes) = painted_boxes(&d);
|
||||
assert!(strokes.is_empty(), "{strokes:?}");
|
||||
assert_eq!(fills.len(), 1);
|
||||
// The lens is full width (10 pt) only in the middle of the 20..80 line.
|
||||
assert!((fills[0].height() - 10.0).abs() < 0.05 && (fills[0].width() - 60.0).abs() < 0.05, "{:?}", fills[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_path_with_inside_alignment_strokes_centred() {
|
||||
let (d, _) = line_doc(black(4.0, |s| s.align = StrokeAlign::Inside));
|
||||
let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap();
|
||||
let mut widths = vec![];
|
||||
back.walk(|m| {
|
||||
assert!(!matches!(m.kind, vectorcraft_doc::NodeKind::Group { clip: true, .. }), "no clip");
|
||||
widths.extend(m.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.width) } else { None }));
|
||||
});
|
||||
assert_eq!(widths.len(), 1);
|
||||
assert!((widths[0] - 4.0).abs() < 1e-3, "{widths:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brushed_strokes_are_written_as_their_art() {
|
||||
let (d, _) = line_doc(black(2.0, |s| s.brush = Some("Tapered Stroke".into())));
|
||||
let (fills, strokes) = painted_boxes(&d);
|
||||
assert!(strokes.is_empty() && !fills.is_empty(), "{fills:?} {strokes:?}");
|
||||
}
|
||||
|
||||
/// The clip frame of an outside stroke covers its miter spikes: a thin spike far above a sharp
|
||||
/// apex is as dark in the exported PDF as on the canvas.
|
||||
#[test]
|
||||
fn outside_strokes_keep_their_miter_spikes() {
|
||||
let mut d = Document::new(300.0, 300.0);
|
||||
let tri = SubPath::polyline(&[Point::new(130.0, 250.0), Point::new(170.0, 250.0), Point::new(150.0, 100.0)], true);
|
||||
let st = black(10.0, |s| {
|
||||
s.align = StrokeAlign::Outside;
|
||||
s.join = LineJoin::Miter;
|
||||
s.miter_limit = 10.0;
|
||||
});
|
||||
let mut n = Node::path(NodeId(0), PathData::single(tri), Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] });
|
||||
n.id = d.alloc_id();
|
||||
let layer = d.default_layer().unwrap();
|
||||
d.insert(Some(layer), 0, n).unwrap();
|
||||
// The spike's tip is ~76 pt above the apex (10 pt / sin 7.6°); 60 pt up it is still 4 pt wide.
|
||||
let (x, y) = (150, 40);
|
||||
let canvas = render_artboard(&d);
|
||||
let back = render_artboard(&import(&export(&d, &PdfOptions::default()).unwrap()).unwrap());
|
||||
let dark = |p: [u8; 3]| p.iter().all(|c| *c < 64);
|
||||
assert!(dark(canvas.over_white(x, y)), "the canvas draws the spike: {:?}", canvas.over_white(x, y));
|
||||
assert!(dark(back.over_white(x, y)), "the PDF keeps the spike: {:?}", back.over_white(x, y));
|
||||
assert!(!dark(back.over_white(x, 20)), "past its tip");
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ vectorcraft-geom = { workspace = true }
|
|||
vectorcraft-color = { workspace = true }
|
||||
vectorcraft-doc = { workspace = true }
|
||||
vectorcraft-effects = { workspace = true }
|
||||
vectorcraft-brush = { workspace = true }
|
||||
vectorcraft-text = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
usvg = { workspace = true }
|
||||
|
|
@ -22,6 +23,7 @@ image = { workspace = true }
|
|||
svgtypes = "0.16"
|
||||
|
||||
[dev-dependencies]
|
||||
resvg = { version = "0.45", default-features = false }
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
image = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ use vectorcraft_doc::{
|
|||
AppearanceItem, Document, FillLayer, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextKind, TextObject,
|
||||
};
|
||||
use vectorcraft_doc::{CharStyle, Justify};
|
||||
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect};
|
||||
use vectorcraft_effects::stroke::{self, Written, WrittenShape};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
|
||||
|
||||
use crate::{ExportOptions, Styling, base64_encode, fmt_num, xml_escape};
|
||||
|
||||
|
|
@ -44,6 +45,7 @@ pub(crate) fn export(doc: &Document, opts: &ExportOptions) -> String {
|
|||
depth: 1,
|
||||
patterns: HashMap::new(),
|
||||
pattern_nest: 0,
|
||||
brushes: None,
|
||||
};
|
||||
w.assign_name_ids();
|
||||
for l in &doc.layers {
|
||||
|
|
@ -101,6 +103,15 @@ struct Writer<'a> {
|
|||
/// `<pattern>` def ids by pattern name + placement.
|
||||
patterns: HashMap<String, String>,
|
||||
pattern_nest: u32,
|
||||
/// The brush library, parsed when the first brushed stroke is written.
|
||||
brushes: Option<Vec<vectorcraft_brush::Brush>>,
|
||||
}
|
||||
|
||||
/// How one stroke is written.
|
||||
enum StrokePlan {
|
||||
/// Its brush art (document space).
|
||||
Brush(Vec<Node>),
|
||||
Written(Written),
|
||||
}
|
||||
|
||||
pub(crate) fn blend_css(b: BlendMode) -> &'static str {
|
||||
|
|
@ -430,8 +441,57 @@ impl Writer<'_> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Paint a shape (`d`) with a node's appearance stack.
|
||||
fn shape(&mut self, n: &Node, d: &str, rule: FillRule, bounds: Option<Rect>) {
|
||||
/// How stroke `st` is written (`bp`: the shape in document space, built on first use from
|
||||
/// `paths`): its brush art, or a plain stroke or filled outlines matching the canvas.
|
||||
fn stroke_plan(&mut self, st: &StrokeLayer, bp: &mut Option<BezPath>, paths: &[&PathData]) -> StrokePlan {
|
||||
if stroke::is_plain(st) {
|
||||
return StrokePlan::Written(stroke::for_writer(&BezPath::new(), st));
|
||||
}
|
||||
let bp = bp.get_or_insert_with(|| {
|
||||
let mut bp = BezPath::new();
|
||||
for p in paths {
|
||||
bp.extend(p.to_bezpath());
|
||||
}
|
||||
bp
|
||||
});
|
||||
let doc = self.doc;
|
||||
let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc));
|
||||
match st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) {
|
||||
Some(b) => StrokePlan::Brush(vectorcraft_brush::stroke_pieces(b, bp, st)),
|
||||
None => StrokePlan::Written(stroke::for_writer(bp, st)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `clip-path` (inside) or `mask` (outside) attribute that keeps an aligned stroke on its
|
||||
/// side of the shape `d`; `reach` (document space) is what the stroke covers.
|
||||
fn side_attr(&mut self, side: Option<StrokeAlign>, d: &str, rule: FillRule, reach: Rect) -> String {
|
||||
match side {
|
||||
None | Some(StrokeAlign::Center) => String::new(),
|
||||
Some(StrokeAlign::Inside) => {
|
||||
let cid = self.fresh_id("clip-path");
|
||||
let r = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" };
|
||||
self.def(1, &format!("<clipPath id=\"{cid}\">"));
|
||||
self.def(2, &format!("<path d=\"{d}\"{r}/>"));
|
||||
self.def(1, "</clipPath>");
|
||||
format!(" clip-path=\"url(#{cid})\"")
|
||||
}
|
||||
Some(StrokeAlign::Outside) => {
|
||||
let mid = self.fresh_id("mask");
|
||||
let b = self.xf.transform_rect_bbox(reach).inflate(1.0, 1.0);
|
||||
let (x, y, w, h) = (self.num(b.x0), self.num(b.y0), self.num(b.width()), self.num(b.height()));
|
||||
let fr = if rule == FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" };
|
||||
self.def(1, &format!("<mask id=\"{mid}\" maskUnits=\"userSpaceOnUse\" x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\">"));
|
||||
self.def(2, &format!("<rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" fill=\"#fff\"/>"));
|
||||
self.def(2, &format!("<path d=\"{d}\" fill=\"#000\"{fr}/>"));
|
||||
self.def(1, "</mask>");
|
||||
format!(" mask=\"url(#{mid})\"")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint a shape (`d`) with a node's appearance stack. `paths` is the same shape in document
|
||||
/// space, for strokes that aren't plain (it may be empty when all are).
|
||||
fn shape(&mut self, n: &Node, d: &str, paths: &[&PathData], rule: FillRule, bounds: Option<Rect>) {
|
||||
let id = self.id_attr(n);
|
||||
let items: Vec<&AppearanceItem> = n
|
||||
.appearance
|
||||
|
|
@ -442,14 +502,22 @@ impl Writer<'_> {
|
|||
AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0,
|
||||
})
|
||||
.collect();
|
||||
let mut bp = None;
|
||||
let plans: Vec<Option<StrokePlan>> = items
|
||||
.iter()
|
||||
.map(|i| match i {
|
||||
AppearanceItem::Stroke(s) => Some(self.stroke_plan(s, &mut bp, paths)),
|
||||
AppearanceItem::Fill(_) => None,
|
||||
})
|
||||
.collect();
|
||||
let fills = items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).count();
|
||||
let strokes = items.len() - fills;
|
||||
// A stroke's paint box is the bounds grown by half its weight, as on the canvas.
|
||||
let paint_bounds = |s: &StrokeLayer| bounds.map(|b| s.paint_bounds(b));
|
||||
let simple = fills <= 1
|
||||
&& strokes <= 1
|
||||
&& items.iter().all(|i| match i {
|
||||
AppearanceItem::Fill(f) => f.blend == BlendMode::Normal,
|
||||
AppearanceItem::Stroke(s) => s.blend == BlendMode::Normal && s.align == StrokeAlign::Center,
|
||||
});
|
||||
&& items.iter().all(|i| i.blend() == BlendMode::Normal)
|
||||
&& plans.iter().flatten().all(|p| matches!(p, StrokePlan::Written(Written { shape: WrittenShape::Stroke { .. }, side: None })));
|
||||
if simple {
|
||||
let mut p = Props::new();
|
||||
match items.iter().find_map(|i| if let AppearanceItem::Fill(f) = i { Some(f) } else { None }) {
|
||||
|
|
@ -462,7 +530,7 @@ impl Writer<'_> {
|
|||
}
|
||||
}
|
||||
if let Some(s) = items.iter().find_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s) } else { None }) {
|
||||
self.stroke_props(s, s.width, bounds, &mut p);
|
||||
self.stroke_props(s, s.width, paint_bounds(s), &mut p);
|
||||
if matches!(items.first(), Some(AppearanceItem::Stroke(_))) && fills == 1 {
|
||||
p.push(("paint-order", "stroke".into()));
|
||||
}
|
||||
|
|
@ -475,10 +543,10 @@ impl Writer<'_> {
|
|||
let a = self.attrs(&Self::node_props(n));
|
||||
self.line(&format!("<g{id}{a}>"));
|
||||
self.depth += 1;
|
||||
for it in items {
|
||||
for (it, plan) in items.into_iter().zip(plans) {
|
||||
let mut p = Props::new();
|
||||
match it {
|
||||
AppearanceItem::Fill(f) => {
|
||||
match (it, plan) {
|
||||
(AppearanceItem::Fill(f), _) => {
|
||||
self.fill_props(f, rule, bounds, &mut p);
|
||||
if f.blend != BlendMode::Normal {
|
||||
p.push(("mix-blend-mode", blend_css(f.blend).into()));
|
||||
|
|
@ -486,44 +554,62 @@ impl Writer<'_> {
|
|||
let a = self.attrs(&p);
|
||||
self.line(&format!("<path d=\"{d}\"{a}/>"));
|
||||
}
|
||||
AppearanceItem::Stroke(s) => {
|
||||
p.push(("fill", "none".into()));
|
||||
let width = if s.align == StrokeAlign::Center { s.width } else { s.width * 2.0 };
|
||||
self.stroke_props(s, width, bounds, &mut p);
|
||||
(AppearanceItem::Stroke(s), Some(StrokePlan::Brush(art))) => {
|
||||
// The brush art takes the stroke's opacity and blend mode as a group.
|
||||
if s.opacity < 1.0 {
|
||||
p.push(("opacity", fmt_num(s.opacity as f64, 3)));
|
||||
}
|
||||
if s.blend != BlendMode::Normal {
|
||||
p.push(("mix-blend-mode", blend_css(s.blend).into()));
|
||||
}
|
||||
let rule_attr = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" };
|
||||
let extra = match s.align {
|
||||
StrokeAlign::Center => String::new(),
|
||||
StrokeAlign::Inside => {
|
||||
let cid = self.fresh_id("clip-path");
|
||||
self.def(1, &format!("<clipPath id=\"{cid}\">"));
|
||||
self.def(2, &format!("<path d=\"{d}\"{rule_attr}/>"));
|
||||
self.def(1, "</clipPath>");
|
||||
format!(" clip-path=\"url(#{cid})\"")
|
||||
}
|
||||
StrokeAlign::Outside => {
|
||||
let mid = self.fresh_id("mask");
|
||||
let b = self
|
||||
.xf
|
||||
.transform_rect_bbox(bounds.unwrap_or_default())
|
||||
.inflate(width * s.miter_limit.max(1.0) + 1.0, width * s.miter_limit.max(1.0) + 1.0);
|
||||
let (x, y, w, h) = (self.num(b.x0), self.num(b.y0), self.num(b.width()), self.num(b.height()));
|
||||
let fr = if rule == FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" };
|
||||
self.def(
|
||||
1,
|
||||
&format!("<mask id=\"{mid}\" maskUnits=\"userSpaceOnUse\" x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\">"),
|
||||
);
|
||||
self.def(2, &format!("<rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" fill=\"#fff\"/>"));
|
||||
self.def(2, &format!("<path d=\"{d}\" fill=\"#000\"{fr}/>"));
|
||||
self.def(1, "</mask>");
|
||||
format!(" mask=\"url(#{mid})\"")
|
||||
}
|
||||
};
|
||||
let a = self.attrs(&p);
|
||||
self.line(&format!("<path d=\"{d}\"{a}{extra}/>"));
|
||||
self.line(&format!("<g{a}>"));
|
||||
self.depth += 1;
|
||||
for piece in &art {
|
||||
self.node(piece);
|
||||
}
|
||||
self.depth -= 1;
|
||||
self.line("</g>");
|
||||
}
|
||||
(AppearanceItem::Stroke(s), Some(StrokePlan::Written(w))) => {
|
||||
let side = self.side_attr(w.side, d, rule, w.reach(s, bounds.unwrap_or_default()));
|
||||
let blend = (s.blend != BlendMode::Normal).then(|| ("mix-blend-mode", blend_css(s.blend).to_string()));
|
||||
match &w.shape {
|
||||
WrittenShape::Stroke { width } => {
|
||||
p.push(("fill", "none".into()));
|
||||
self.stroke_props(s, *width, paint_bounds(s), &mut p);
|
||||
p.extend(blend);
|
||||
let a = self.attrs(&p);
|
||||
self.line(&format!("<path d=\"{d}\"{a}{side}/>"));
|
||||
}
|
||||
WrittenShape::Fill(outlines) => {
|
||||
let paint = self.paint(&s.paint, paint_bounds(s));
|
||||
let ds: Vec<String> = outlines.iter().map(|o| self.path_d(&PathData::from_bezpath(o), self.xf)).collect();
|
||||
let opacity = (s.opacity < 1.0).then(|| fmt_num(s.opacity as f64, 3));
|
||||
if let [one] = &ds[..] {
|
||||
p.push(("fill", paint));
|
||||
p.extend(opacity.map(|o| ("fill-opacity", o)));
|
||||
p.extend(blend);
|
||||
let a = self.attrs(&p);
|
||||
self.line(&format!("<path d=\"{one}\"{a}{side}/>"));
|
||||
} else if !ds.is_empty() {
|
||||
// The line and its arrowheads overlap: one group takes the opacity.
|
||||
p.extend(opacity.map(|o| ("opacity", o)));
|
||||
p.extend(blend);
|
||||
let a = self.attrs(&p);
|
||||
self.line(&format!("<g{a}{side}>"));
|
||||
self.depth += 1;
|
||||
let fill = self.attrs(&vec![("fill", paint)]);
|
||||
for one in &ds {
|
||||
self.line(&format!("<path d=\"{one}\"{fill}/>"));
|
||||
}
|
||||
self.depth -= 1;
|
||||
self.line("</g>");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(AppearanceItem::Stroke(_), None) => {}
|
||||
}
|
||||
}
|
||||
self.depth -= 1;
|
||||
|
|
@ -622,20 +708,15 @@ impl Writer<'_> {
|
|||
return;
|
||||
}
|
||||
let d = self.path_d(path, self.xf);
|
||||
self.shape(n, &d, *rule, n.geometric_bounds());
|
||||
self.shape(n, &d, &[path], *rule, n.geometric_bounds());
|
||||
}
|
||||
NodeKind::Compound { children, rule } => {
|
||||
let d: Vec<String> = children
|
||||
.iter()
|
||||
.filter(|c| c.visible)
|
||||
.filter_map(|c| c.path_data())
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|p| self.path_d(p, self.xf))
|
||||
.collect();
|
||||
if d.is_empty() {
|
||||
let paths: Vec<&PathData> = children.iter().filter(|c| c.visible).filter_map(|c| c.path_data()).filter(|p| !p.is_empty()).collect();
|
||||
if paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.shape(n, &d.join(" "), *rule, n.geometric_bounds());
|
||||
let d: Vec<String> = paths.iter().map(|p| self.path_d(p, self.xf)).collect();
|
||||
self.shape(n, &d.join(" "), &paths, *rule, n.geometric_bounds());
|
||||
}
|
||||
NodeKind::Text(t) if self.opts.outline_text => self.text_outlines(n, t),
|
||||
NodeKind::Text(t) => self.text(n, t),
|
||||
|
|
@ -824,7 +905,8 @@ impl Writer<'_> {
|
|||
Node::path(NodeId(u64::MAX), pd.clone(), vectorcraft_doc::Appearance::basic(st.fill.clone(), st.stroke.clone(), st.stroke_width));
|
||||
let d = self.path_d(&pd, Affine::IDENTITY);
|
||||
let bounds = pd.bounds();
|
||||
self.shape(&glyphs, &d, FillRule::NonZero, bounds);
|
||||
// Run strokes are plain: they need no document-space geometry.
|
||||
self.shape(&glyphs, &d, &[], FillRule::NonZero, bounds);
|
||||
}
|
||||
self.depth -= 1;
|
||||
self.line("</g>");
|
||||
|
|
|
|||
|
|
@ -8,14 +8,19 @@
|
|||
//!
|
||||
//! ## Export approximations
|
||||
//!
|
||||
//! * **Stroke alignment** is not expressible in SVG 1.1. An *inside* stroke is written as a stroke of
|
||||
//! double width clipped to the path's own shape (`<clipPath>`); an *outside* stroke as a stroke of
|
||||
//! double width masked by a `<mask>` that hides the path's interior. Both are visually exact for
|
||||
//! closed paths but re-import as a group (clip group / masked stroke) rather than an aligned stroke.
|
||||
//! * **Stroke alignment** is not expressible in SVG 1.1. An *inside* stroke of a closed path is
|
||||
//! written as a stroke of double width clipped to the path's own shape (`<clipPath>`); an
|
||||
//! *outside* stroke as a stroke of double width masked by a `<mask>` that hides the path's
|
||||
//! interior. Both are visually exact but re-import as a group (clip group / masked stroke)
|
||||
//! rather than an aligned stroke. Open paths have no inside: their strokes are written centred,
|
||||
//! as the canvas draws them.
|
||||
//! * **Arrowheads, width profiles, dashes fitted to corners and dotted dashes** are written as the
|
||||
//! filled outlines the canvas paints (the line and each arrowhead, grouped under the stroke's
|
||||
//! opacity); **brushed strokes** as their brush art. Both re-import as filled paths.
|
||||
//! * **Multiple fills/strokes** (or per-fill/stroke blend modes) are written as a `<g>` holding one
|
||||
//! `<path>` per appearance item, in paint order.
|
||||
//! * Effects, arrowheads, width profiles, brushes, patterns and freeform gradients are not
|
||||
//! expanded (patterns export as `none`, freeform gradients as linear).
|
||||
//! * Live geometry effects are written as their result and raster effects (shadows, glows, blurs)
|
||||
//! as SVG filters. Freeform gradients export as linear gradients.
|
||||
//!
|
||||
//! ## Import approximations
|
||||
//!
|
||||
|
|
@ -34,7 +39,7 @@ mod import;
|
|||
pub use vectorcraft_doc::Document;
|
||||
pub use vectorcraft_doc::TextObject;
|
||||
|
||||
/// How style properties are written (Illustrator's "Styling" option).
|
||||
/// How style properties are written (the "Styling" export option).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Styling {
|
||||
/// `fill="#ff0000"` attributes.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
//! 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}");
|
||||
}
|
||||
Loading…
Reference in New Issue