mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Merge wp/C2: stroke export parity and Outline Stroke fidelity (M3.36, M3.37)
This commit is contained in:
Generated
+4
@@ -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",
|
||||
|
||||
@@ -101,8 +101,7 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
|
||||
fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
let (base, rule) = geometry(n)?;
|
||||
let w = n.appearance.stroke_width();
|
||||
let ctx = GeomContext { stroke_width: if w > 0.0 { w } else { 1.0 } };
|
||||
let ctx = GeomContext::of(n);
|
||||
let g = apply(&n.appearance.effects, &base, &ctx);
|
||||
let mut m = n.clone();
|
||||
m.appearance.effects.retain(|e| !is_geometry(&e.id));
|
||||
@@ -117,7 +116,7 @@ fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
let ig = apply(item_effects(item), &g, &ctx);
|
||||
let ig = apply(item_effects(item), &g, &ctx.item(item));
|
||||
let mut it = item.clone();
|
||||
clear_item_effects(&mut it);
|
||||
let id = d.alloc_id();
|
||||
|
||||
+48
-13
@@ -33,8 +33,8 @@ mod warp;
|
||||
mod tests;
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::Effect;
|
||||
use vectorcraft_geom::{BezPath, PathData, Rect};
|
||||
use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{BezPath, FillRule, PathData, Rect};
|
||||
|
||||
pub use bake::{bake_document, needs_bake};
|
||||
pub use clip::clip_outline;
|
||||
@@ -158,7 +158,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{offset: pt (10; negative insets), joins: \"miter\"|\"round\"|\"bevel\", miterLimit: (4)}",
|
||||
json!({"offset": 10.0, "joins": "miter", "miterLimit": 4.0}),
|
||||
),
|
||||
g("path.outlineStroke", "Outline Stroke", PATH, "{width?: pt (defaults to the object's stroke weight)}", json!({})),
|
||||
g(
|
||||
"path.outlineStroke",
|
||||
"Outline Stroke",
|
||||
PATH,
|
||||
"{width?: pt (defaults to the stroke's weight)} the outline of the stroke the effect is on (on a fill or the object: the top stroke), with its caps, joins, dashes, alignment, width profile and arrowheads",
|
||||
json!({}),
|
||||
),
|
||||
g("stylize.roundCorners", "Round Corners…", STYLIZE, "{radius: pt (10)}", json!({"radius": 10.0})),
|
||||
g(
|
||||
"stylize.scribble",
|
||||
@@ -269,15 +275,33 @@ pub fn has_geometry(effects: &[Effect]) -> bool {
|
||||
}
|
||||
|
||||
/// Context the geometry effects may need from the object's appearance.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct GeomContext {
|
||||
/// Stroke weight used by `path.outlineStroke` when the effect has no `width`.
|
||||
pub stroke_width: f64,
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct GeomContext<'a> {
|
||||
/// The stroke `path.outlineStroke` outlines (its weight, caps, joins, dashes, alignment,
|
||||
/// profile and arrowheads): the stroke the effect sits on, else the object's top painted
|
||||
/// stroke. `None` outlines a plain 1 pt stroke.
|
||||
pub stroke: Option<&'a StrokeLayer>,
|
||||
/// The object's fill rule (inside and outside alignment).
|
||||
pub rule: FillRule,
|
||||
}
|
||||
|
||||
impl Default for GeomContext {
|
||||
fn default() -> Self {
|
||||
Self { stroke_width: 1.0 }
|
||||
impl<'a> GeomContext<'a> {
|
||||
/// The context of `n`'s object-level effects.
|
||||
pub fn of(n: &'a Node) -> Self {
|
||||
let rule = match &n.kind {
|
||||
NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } => *rule,
|
||||
_ => FillRule::NonZero,
|
||||
};
|
||||
Self { stroke: n.appearance.stroke().filter(|s| !s.paint.is_none() && s.width > 0.0), rule }
|
||||
}
|
||||
|
||||
/// The context of the effects on `item`, one of the same object's appearance items: a
|
||||
/// stroke's own effects outline that stroke.
|
||||
pub fn item(self, item: &'a AppearanceItem) -> Self {
|
||||
match item {
|
||||
AppearanceItem::Stroke(s) => Self { stroke: Some(s), ..self },
|
||||
AppearanceItem::Fill(_) => self,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,11 +354,22 @@ fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext) -
|
||||
vectorcraft_pathops::offset_path(path, off, join(p, "joins"), num(p, "miterLimit", 4.0).clamp(1.0, 500.0))
|
||||
}
|
||||
"path.outlineStroke" => {
|
||||
let w = p.get("width").and_then(Value::as_f64).unwrap_or(ctx.stroke_width);
|
||||
if w <= 0.0 {
|
||||
let fallback;
|
||||
let base = match ctx.stroke {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
fallback = StrokeLayer::new(vectorcraft_doc::color::Paint::None, 1.0);
|
||||
&fallback
|
||||
}
|
||||
};
|
||||
let st = match p.get("width").and_then(Value::as_f64) {
|
||||
Some(w) => std::borrow::Cow::Owned(StrokeLayer { width: w, ..base.clone() }),
|
||||
None => std::borrow::Cow::Borrowed(base),
|
||||
};
|
||||
if st.width.is_nan() || st.width <= 0.0 {
|
||||
return path.clone();
|
||||
}
|
||||
vectorcraft_pathops::outline_stroke(path, w, vectorcraft_pathops::Cap::Butt, join(p, "joins"), num(p, "miterLimit", 10.0).max(1.0))
|
||||
stroke::outline_region(path, ctx.rule, &st)
|
||||
}
|
||||
"convertToShape.rectangle" | "convertToShape.roundedRectangle" | "convertToShape.ellipse" => stylize::convert_to_shape(id, b, p),
|
||||
"stylize.roundCorners" => stylize::round_corners(path, num(p, "radius", 10.0)),
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
//! turns into discs or squares ([`dot_outline`]);
|
||||
//! - [`width_outline`]: variable-width (profile) strokes;
|
||||
//! - [`line_outline`]: the line part of a stroke as one filled outline (profile, dashes, dots,
|
||||
//! caps and joins).
|
||||
//! caps and joins);
|
||||
//! - [`for_writer`] and [`outline_region`]: strokes for the SVG/PDF writers and Outline Stroke.
|
||||
|
||||
mod arrow;
|
||||
mod dash;
|
||||
mod outline;
|
||||
mod width;
|
||||
|
||||
use std::borrow::Cow;
|
||||
@@ -20,6 +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, Written, WrittenShape, for_writer, is_plain, outline_region};
|
||||
pub use width::width_outline;
|
||||
|
||||
/// Arc-length accuracy for dashing and trimming (document points).
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
//! 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 kurbo::{BezPath, Rect, Shape};
|
||||
use vectorcraft_doc::{LineCap, LineJoin, StrokeAlign, StrokeLayer};
|
||||
use vectorcraft_geom::{FillRule, PathData};
|
||||
use vectorcraft_pathops::BoolOp;
|
||||
|
||||
use super::{StrokePieces, aligned_width, is_closed, line_outline, stroke_pieces};
|
||||
|
||||
/// 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.
|
||||
pub fn outline_region(path: &PathData, rule: FillRule, st: &StrokeLayer) -> PathData {
|
||||
if !(st.width > 0.0 && st.width.is_finite()) || path.is_empty() {
|
||||
return PathData::default();
|
||||
}
|
||||
let bp = path.to_bezpath();
|
||||
let closed = is_closed(&bp);
|
||||
let pieces = stroke_pieces(&bp, st);
|
||||
let mut parts = vec![line_outline(&pieces.line, st, aligned_width(st, closed), OUTLINE_TOL)];
|
||||
parts.extend(pieces.heads.into_iter().map(|h| h.outline));
|
||||
let region = vectorcraft_pathops::stroke_region(&parts);
|
||||
match st.align {
|
||||
StrokeAlign::Inside if closed => vectorcraft_pathops::boolean(®ion, FillRule::NonZero, path, rule, BoolOp::Intersect),
|
||||
StrokeAlign::Outside if closed => vectorcraft_pathops::boolean(®ion, FillRule::NonZero, path, rule, BoolOp::Difference),
|
||||
_ => region,
|
||||
}
|
||||
}
|
||||
@@ -212,11 +212,17 @@ fn offset_path_grows_and_shrinks() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_stroke_uses_context_width() {
|
||||
let ctx = GeomContext { stroke_width: 10.0 };
|
||||
fn outline_stroke_uses_context_stroke() {
|
||||
let st = vectorcraft_doc::StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), 10.0);
|
||||
let ctx = GeomContext { stroke: Some(&st), ..Default::default() };
|
||||
let out = apply_geometry_with(&[fx("path.outlineStroke", json!({}))], &square(), Rect::new(0.0, 0.0, 100.0, 100.0), &ctx);
|
||||
let b = out.bounds().unwrap();
|
||||
assert!(close(b.width(), 110.0, 0.1), "{b:?}");
|
||||
// An explicit width overrides the weight; without a stroke a plain 1 pt one is outlined.
|
||||
let out = apply_geometry_with(&[fx("path.outlineStroke", json!({"width": 4}))], &square(), Rect::new(0.0, 0.0, 100.0, 100.0), &ctx);
|
||||
assert!(close(out.bounds().unwrap().width(), 104.0, 0.1));
|
||||
let out = run("path.outlineStroke", json!({}), &square());
|
||||
assert!(close(out.bounds().unwrap().width(), 101.0, 0.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -249,9 +249,7 @@ fn expand_node(d: &mut vectorcraft_doc::Document, id: NodeId, out: &mut Vec<Node
|
||||
}
|
||||
let Some((path, rule)) = node_geometry(&n) else { return };
|
||||
let Some(bounds) = path.bounds() else { return };
|
||||
let w = n.appearance.stroke_width();
|
||||
let ctx = effects::GeomContext { stroke_width: if w > 0.0 { w } else { 1.0 } };
|
||||
let baked = effects::apply_geometry_with(&n.appearance.effects, &path, bounds, &ctx);
|
||||
let baked = effects::apply_geometry_with(&n.appearance.effects, &path, bounds, &effects::GeomContext::of(&n));
|
||||
let kind = if matches!(n.kind, NodeKind::Compound { .. }) || baked.subpaths.len() > 1 {
|
||||
let children = baked
|
||||
.subpaths
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::appearance::{AppearanceItem, LineCap, LineJoin, StrokeAlign, StrokeLayer};
|
||||
use vectorcraft_doc::appearance::{AppearanceItem, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_geom::{FillRule, PathData};
|
||||
use vectorcraft_pathops as po;
|
||||
@@ -450,41 +450,107 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
// ---------- Outline Stroke ----------
|
||||
|
||||
fn cap_of(c: LineCap) -> po::Cap {
|
||||
match c {
|
||||
LineCap::Butt => po::Cap::Butt,
|
||||
LineCap::Round => po::Cap::Round,
|
||||
LineCap::Square => po::Cap::Square,
|
||||
}
|
||||
}
|
||||
fn join_of(j: LineJoin) -> po::Join {
|
||||
match j {
|
||||
LineJoin::Miter => po::Join::Miter,
|
||||
LineJoin::Round => po::Join::Round,
|
||||
LineJoin::Bevel => po::Join::Bevel,
|
||||
}
|
||||
/// Can Outline Stroke turn `st` into filled art?
|
||||
fn outlinable(st: &StrokeLayer) -> bool {
|
||||
st.visible && !st.paint.is_none() && st.width > 0.0
|
||||
}
|
||||
|
||||
/// The filled outline of `st` stroking `path` (honours inside/outside alignment on closed paths).
|
||||
pub(crate) fn stroke_outline(path: &PathData, rule: FillRule, st: &StrokeLayer) -> PathData {
|
||||
let (cap, join) = (cap_of(st.cap), join_of(st.join));
|
||||
let w = st.width;
|
||||
if !w.is_finite() || w <= 0.0 {
|
||||
return PathData::default();
|
||||
/// `n` with `opacity` and `blend` applied: set on it when it has none of its own, else on a
|
||||
/// group around it.
|
||||
fn with_transparency(d: &mut Document, mut n: Node, opacity: f32, blend: BlendMode) -> Node {
|
||||
if opacity >= 1.0 && blend == BlendMode::Normal {
|
||||
return n;
|
||||
}
|
||||
match st.align {
|
||||
StrokeAlign::Inside | StrokeAlign::Outside if path.is_closed() => {
|
||||
let ring = po::outline_stroke(path, 2.0 * w, cap, join, st.miter_limit);
|
||||
let op = if st.align == StrokeAlign::Inside { BoolOp::Intersect } else { BoolOp::Difference };
|
||||
po::boolean(&ring, FillRule::NonZero, path, rule, op)
|
||||
if n.opacity < 1.0 || n.blend != BlendMode::Normal {
|
||||
n = Node::group(d.alloc_id(), vec![Arc::new(n)]);
|
||||
}
|
||||
n.opacity = opacity;
|
||||
n.blend = blend;
|
||||
n
|
||||
}
|
||||
|
||||
/// The art stroke `st` of a path (`path`, `rule`) paints, as fills: its brush art, or the
|
||||
/// outline of the stroke (as on the canvas) filled with the stroke's paint, opacity, blend mode
|
||||
/// and effects. `None` when it paints nothing.
|
||||
fn outlined_stroke(d: &mut Document, brushes: &[vectorcraft_brush::Brush], path: &PathData, rule: FillRule, st: &StrokeLayer) -> Option<Node> {
|
||||
if let Some(b) = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) {
|
||||
let mut pieces = vectorcraft_brush::stroke_pieces(b, &path.to_bezpath(), st);
|
||||
let art = match pieces.len() {
|
||||
0 => return None,
|
||||
1 => pieces.remove(0),
|
||||
_ => Node::group(NodeId(0), pieces.into_iter().map(Arc::new).collect()),
|
||||
};
|
||||
let art = d.reid(&art);
|
||||
return Some(with_transparency(d, art, st.opacity, st.blend));
|
||||
}
|
||||
let outline = vectorcraft_render::effects::stroke::outline_region(path, rule, st);
|
||||
if outline.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut n = shape_node(d, outline, None);
|
||||
let fill = vectorcraft_doc::appearance::FillLayer {
|
||||
opacity: st.opacity,
|
||||
blend: st.blend,
|
||||
effects: st.effects.clone(),
|
||||
..vectorcraft_doc::appearance::FillLayer::new(st.paint.clone())
|
||||
};
|
||||
n.appearance = Appearance { items: vec![AppearanceItem::Fill(fill)], effects: vec![] };
|
||||
Some(n)
|
||||
}
|
||||
|
||||
/// Path `l` with every stroke outlined, in appearance order: runs of fills stay on copies of
|
||||
/// the path, each stroke becomes its outline (or brush art), grouped when there are several.
|
||||
/// `None` when `l` has no stroke to outline.
|
||||
fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], l: &Node) -> Option<Node> {
|
||||
if !l.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if outlinable(st))) {
|
||||
return None;
|
||||
}
|
||||
let (path, rule) = node_path(l)?;
|
||||
let mut parts: Vec<Node> = vec![];
|
||||
let mut fills: Vec<AppearanceItem> = vec![];
|
||||
let fill_node = |d: &mut Document, items: Vec<AppearanceItem>| {
|
||||
let mut f = d.reid(l);
|
||||
f.appearance = Appearance { items, effects: vec![] };
|
||||
f.opacity = 1.0;
|
||||
f.blend = BlendMode::Normal;
|
||||
f.name = None;
|
||||
f.mask = None;
|
||||
if let NodeKind::Path { live, .. } = &mut f.kind {
|
||||
*live = None;
|
||||
}
|
||||
f
|
||||
};
|
||||
for item in &l.appearance.items {
|
||||
match item {
|
||||
AppearanceItem::Fill(f) if f.visible && !f.paint.is_none() => fills.push(item.clone()),
|
||||
AppearanceItem::Stroke(st) if outlinable(st) => {
|
||||
if !fills.is_empty() {
|
||||
parts.push(fill_node(d, std::mem::take(&mut fills)));
|
||||
}
|
||||
parts.extend(outlined_stroke(d, brushes, &path, rule, st));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
_ => po::outline_stroke(path, w, cap, join, st.miter_limit),
|
||||
}
|
||||
if !fills.is_empty() {
|
||||
parts.push(fill_node(d, fills));
|
||||
}
|
||||
let root = match parts.len() {
|
||||
0 => return None,
|
||||
1 => parts.remove(0),
|
||||
_ => Node::group(d.alloc_id(), parts.into_iter().map(Arc::new).collect()),
|
||||
};
|
||||
let mut root = with_transparency(d, root, l.opacity, l.blend);
|
||||
root.name = l.name.clone();
|
||||
root.mask = l.mask.clone();
|
||||
root.appearance.effects = l.appearance.effects.clone();
|
||||
Some(root)
|
||||
}
|
||||
|
||||
fn outline_stroke(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
let ids = s.edit("Outline Stroke", |d, sel| {
|
||||
let brushes = vectorcraft_brush::library(d);
|
||||
let mut new_sel = vec![];
|
||||
let mut changed = 0;
|
||||
for root in &roots {
|
||||
@@ -496,43 +562,7 @@ fn outline_stroke(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
if matches!(l.kind, NodeKind::Text(_)) {
|
||||
continue;
|
||||
}
|
||||
let Some(st) = l.appearance.stroke().filter(|s| s.visible && !s.paint.is_none() && s.width > 0.0).cloned() else { continue };
|
||||
let Some((path, rule)) = node_path(&l) else { continue };
|
||||
let outline = stroke_outline(&path, rule, &st);
|
||||
if outline.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut stroke_node = shape_node(d, outline, Some(&l));
|
||||
stroke_node.appearance =
|
||||
Appearance { items: vec![AppearanceItem::Fill(vectorcraft_doc::appearance::FillLayer::new(st.paint.clone()))], effects: vec![] };
|
||||
let fill = l.appearance.fill().filter(|f| f.visible && !f.paint.is_none()).cloned();
|
||||
let new = if let Some(f) = fill {
|
||||
let mut fill_node = l.clone();
|
||||
fill_node.id = d.alloc_id();
|
||||
if let Some(ch) = fill_node.children_mut() {
|
||||
for c in ch.iter_mut() {
|
||||
Arc::make_mut(c).id = d.alloc_id();
|
||||
}
|
||||
}
|
||||
fill_node.appearance = Appearance { items: vec![AppearanceItem::Fill(f)], effects: vec![] };
|
||||
if let NodeKind::Path { live, .. } = &mut fill_node.kind {
|
||||
*live = None;
|
||||
}
|
||||
let gid = d.alloc_id();
|
||||
let mut g = Node::group(gid, vec![Arc::new(fill_node), Arc::new(stroke_node)]);
|
||||
g.opacity = l.opacity;
|
||||
g.blend = l.blend;
|
||||
if let Some(ch) = g.children_mut() {
|
||||
for c in ch.iter_mut() {
|
||||
let c = Arc::make_mut(c);
|
||||
c.opacity = 1.0;
|
||||
c.blend = BlendMode::Normal;
|
||||
}
|
||||
}
|
||||
g
|
||||
} else {
|
||||
stroke_node
|
||||
};
|
||||
let Some(new) = outline_strokes(d, &brushes, &l) else { continue };
|
||||
let (par, idx, _) = d.position(l.id).ok_or(EngineError::NoNode(l.id))?;
|
||||
let nid = d.insert(par, idx, new)?;
|
||||
d.remove(l.id)?;
|
||||
|
||||
@@ -760,6 +760,8 @@ mod tests_menucmds;
|
||||
#[cfg(test)]
|
||||
mod tests_opacitymask;
|
||||
#[cfg(test)]
|
||||
mod tests_outlinestroke;
|
||||
#[cfg(test)]
|
||||
mod tests_paintproxy;
|
||||
#[cfg(test)]
|
||||
mod tests_panelcmds;
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Outline Stroke (`object.path.outlineStroke`) and the live Outline Stroke effect produce the
|
||||
//! stroke as the canvas paints it: dashes, arrowheads, width profiles, alignment, every stroke
|
||||
//! item in order, with the stroke's opacity and blend mode moved to the new fill.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::BlendMode;
|
||||
use vectorcraft_doc::{AppearanceItem, Node, NodeKind};
|
||||
use vectorcraft_geom::{FillRule, PathData, Rect};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// A horizontal 100 pt line from (0, 50), black, no fill, with stroke options `opts`.
|
||||
fn line(s: &mut Session, opts: Value) -> NodeId {
|
||||
let r = s.execute("shape.line", &json!({"x1": 0, "y1": 50, "x2": 100, "y2": 50})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("stroke.set", &opts).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn outline(s: &mut Session) -> Node {
|
||||
let r = s.execute("object.path.outlineStroke", &json!({})).unwrap();
|
||||
let id = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
s.doc().unwrap().doc.node(id).cloned().unwrap()
|
||||
}
|
||||
|
||||
fn path_of(n: &Node) -> PathData {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, .. } => path.clone(),
|
||||
NodeKind::Compound { children, .. } => PathData::new(children.iter().flat_map(|c| c.path_data().unwrap().subpaths.clone()).collect()),
|
||||
_ => panic!("not a path: {:?}", n.kind),
|
||||
}
|
||||
}
|
||||
|
||||
fn area(n: &Node) -> f64 {
|
||||
vectorcraft_pathops::area(&path_of(n), FillRule::NonZero)
|
||||
}
|
||||
|
||||
fn near(a: f64, b: f64, tol: f64) -> bool {
|
||||
(a - b).abs() <= tol
|
||||
}
|
||||
|
||||
fn close(a: Rect, b: Rect, tol: f64) -> bool {
|
||||
near(a.x0, b.x0, tol) && near(a.y0, b.y0, tol) && near(a.x1, b.x1, tol) && near(a.y1, b.y1, tol)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dashed_line_gives_one_subpath_per_dash() {
|
||||
let mut s = session();
|
||||
line(&mut s, json!({"weight": 4, "dash": [10, 10]}));
|
||||
let n = outline(&mut s);
|
||||
assert_eq!(path_of(&n).subpaths.len(), 5, "dashes at 0, 20, 40, 60 and 80");
|
||||
assert!(near(area(&n), 5.0 * 10.0 * 4.0, 0.5), "{}", area(&n));
|
||||
assert!(close(n.geometric_bounds().unwrap(), Rect::new(0.0, 48.0, 90.0, 52.0), 0.01));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrowheads_are_part_of_the_outline() {
|
||||
let mut s = session();
|
||||
line(&mut s, json!({"weight": 2, "endArrow": "Triangle"}));
|
||||
let n = outline(&mut s);
|
||||
// The 2 pt line plus an 8 × 8 triangle whose tip sits past the end point.
|
||||
let b = n.geometric_bounds().unwrap();
|
||||
assert!(b.x1 > 103.0 && near(b.height(), 8.0, 0.05), "{b:?}");
|
||||
assert!(area(&n) > 200.0 + 20.0, "{}", area(&n));
|
||||
assert_eq!(path_of(&n).subpaths.len(), 1, "line and head are one shape");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lens_profile_outlines_to_half_the_rectangle() {
|
||||
let mut s = session();
|
||||
line(&mut s, json!({"weight": 10, "profile": "lens"}));
|
||||
let n = outline(&mut s);
|
||||
assert!(near(area(&n), 0.5 * 10.0 * 100.0, 2.0), "{}", area(&n));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inside_alignment_keeps_the_outline_inside_the_path() {
|
||||
let mut s = session();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap();
|
||||
s.execute("stroke.set", &json!({"weight": 4, "align": "inside"})).unwrap();
|
||||
let g = outline(&mut s);
|
||||
let ring = &g.children().unwrap()[1];
|
||||
assert!(close(ring.geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0), 0.01));
|
||||
assert!(near(area(ring), 100.0 * 100.0 - 92.0 * 92.0, 1.0), "{}", area(ring));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_opacity_and_blend_move_to_the_new_fill() {
|
||||
let mut s = session();
|
||||
line(&mut s, json!({"weight": 4}));
|
||||
s.execute("appearance.setItem", &json!({"index": 1, "opacity": 50, "blend": "multiply"})).unwrap();
|
||||
let n = outline(&mut s);
|
||||
let Some(AppearanceItem::Fill(f)) = n.appearance.items.first() else { panic!("{:?}", n.appearance) };
|
||||
assert!(near(f.opacity as f64, 0.5, 1e-6) && f.blend == BlendMode::Multiply, "{f:?}");
|
||||
assert!(n.opacity == 1.0 && n.blend == BlendMode::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_stroke_is_outlined_in_paint_order() {
|
||||
let mut s = session();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap();
|
||||
s.execute("appearance.addStroke", &json!({})).unwrap();
|
||||
// Items: fill, 1 pt stroke, then the new top stroke at 10 pt red.
|
||||
s.execute("appearance.setItem", &json!({"index": 2, "weight": 10, "color": "#ff0000"})).unwrap();
|
||||
let g = outline(&mut s);
|
||||
let ch = g.children().unwrap();
|
||||
assert_eq!(ch.len(), 3, "the fill, then both outlines");
|
||||
assert!(ch[0].appearance.items.iter().all(AppearanceItem::is_fill));
|
||||
assert!(close(ch[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0), 1e-9));
|
||||
assert!(close(ch[1].geometric_bounds().unwrap(), Rect::new(-0.5, -0.5, 100.5, 100.5), 0.01));
|
||||
assert!(close(ch[2].geometric_bounds().unwrap(), Rect::new(-5.0, -5.0, 105.0, 105.0), 0.01));
|
||||
assert_eq!(ch[2].appearance.fill().unwrap().paint.color().unwrap().to_hex(), "#ff0000");
|
||||
// One undo step restores the stroked rectangle.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.layers[0].children().unwrap()[0].appearance.items.len(), 3);
|
||||
}
|
||||
|
||||
/// The live effect on a stroke item outlines that stroke (its own weight), not the top stroke.
|
||||
#[test]
|
||||
fn the_live_effect_outlines_the_stroke_it_sits_on() {
|
||||
let mut s = session();
|
||||
let id = line(&mut s, json!({"weight": 20}));
|
||||
// Items: the line's empty fill, its 20 pt stroke, then a new 2 pt top stroke.
|
||||
s.execute("appearance.addStroke", &json!({})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"index": 2, "weight": 2})).unwrap();
|
||||
// The lower (20 pt) stroke gets the effect.
|
||||
s.execute("effect.apply", &json!({"effect": "path.outlineStroke", "item": 1})).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let n = doc.node(id).unwrap();
|
||||
assert!(matches!(&n.appearance.items[1], AppearanceItem::Stroke(st) if st.width == 20.0 && st.effects.len() == 1), "{:?}", n.appearance.items);
|
||||
let baked = vectorcraft_render::effects::bake_document(doc).unwrap();
|
||||
let parts = baked.node(id).unwrap().children().unwrap().to_vec();
|
||||
assert!(near(parts[1].geometric_bounds().unwrap().height(), 20.0, 0.01), "{:?}", parts[1].geometric_bounds());
|
||||
assert!(near(parts[2].geometric_bounds().unwrap().height(), 0.0, 1e-9), "the top stroke keeps the line");
|
||||
// On the object, the effect outlines the top stroke.
|
||||
s.execute("effect.apply", &json!({"effect": "path.outlineStroke", "item": null})).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let n = doc.node(id).unwrap();
|
||||
let ctx = vectorcraft_render::effects::GeomContext::of(n);
|
||||
let g = vectorcraft_render::effects::apply_geometry_with(&n.appearance.effects, n.path_data().unwrap(), Rect::new(0.0, 50.0, 100.0, 50.0), &ctx);
|
||||
assert!(close(g.bounds().unwrap(), Rect::new(0.0, 49.0, 100.0, 51.0), 0.01), "{:?}", g.bounds());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brushed_strokes_outline_to_their_brush_art() {
|
||||
let mut s = session();
|
||||
line(&mut s, json!({"weight": 2}));
|
||||
s.execute("brush.apply", &json!({"name": "Tapered Stroke"})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"index": 1, "opacity": 50})).unwrap();
|
||||
let n = outline(&mut s);
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let mut fills = 0;
|
||||
n.walk(&mut |m| fills += m.appearance.items.iter().filter(|i| i.is_fill()).count());
|
||||
assert!(fills > 0 && n.appearance.stroke().is_none(), "brush art: {n:?}");
|
||||
assert!(near(n.opacity as f64, 0.5, 1e-6), "the stroke opacity stays on the art");
|
||||
assert!(!doc.layers[0].children().unwrap().iter().any(|c| vectorcraft_brush::has_brush(c)));
|
||||
}
|
||||
@@ -16,7 +16,7 @@ pub use boolean::{BoolOp, DEFAULT_PRECISION, area, boolean, boolean_n, normalize
|
||||
pub use edit::{
|
||||
AverageAxis, SimplifyOptions, add_anchor_points, average, join, remove_redundant_points, simplify, simplify_with, smooth, split_into_grid,
|
||||
};
|
||||
pub use offset::{Cap, Join, offset_path, outline_stroke};
|
||||
pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region};
|
||||
pub use pathfinder::{PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions};
|
||||
|
||||
/// Errors from fallible operations.
|
||||
|
||||
@@ -46,11 +46,31 @@ pub fn outline_stroke(path: &PathData, width: f64, cap: Cap, join: Join, miter_l
|
||||
if width <= 0.0 || !width.is_finite() || path.is_empty() {
|
||||
return PathData::default();
|
||||
}
|
||||
let s = stroke_bez(&path.to_bezpath(), width, cap, join, miter_limit);
|
||||
if s.elements().is_empty() {
|
||||
stroke_region(&[stroke_bez(&path.to_bezpath(), width, cap, join, miter_limit)])
|
||||
}
|
||||
|
||||
/// The area covered by stroke outlines (each filled with the non-zero rule, open subpaths
|
||||
/// closed) as one clean path: overlaps removed and the stroker's pieces refitted.
|
||||
pub fn stroke_region(outlines: &[BezPath]) -> PathData {
|
||||
let tidy = Tidy::free(DEFAULT_PRECISION);
|
||||
let parts: Vec<BezPath> = outlines.iter().map(close_all).filter(|o| !o.elements().is_empty()).collect();
|
||||
if let [one] = &parts[..] {
|
||||
return normalize_bez(one, FillRule::NonZero).map(|c| all_contours_to_path(&c, &tidy)).unwrap_or_default();
|
||||
}
|
||||
// Each outline is normalised on its own first: a hollow arrowhead may wind against the line
|
||||
// it overlaps, and the raw windings would cancel there under the non-zero rule.
|
||||
let mut all = BezPath::new();
|
||||
for p in &parts {
|
||||
if let Ok(c) = normalize_bez(p, FillRule::NonZero) {
|
||||
for ct in c.contours() {
|
||||
all.extend(ct.path.iter());
|
||||
}
|
||||
}
|
||||
}
|
||||
if all.elements().is_empty() {
|
||||
return PathData::default();
|
||||
}
|
||||
normalize_bez(&s, FillRule::NonZero).map(|c| all_contours_to_path(&c, &Tidy::free(DEFAULT_PRECISION))).unwrap_or_default()
|
||||
normalize_bez(&all, FillRule::NonZero).map(|c| all_contours_to_path(&c, &tidy)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Object → Path → Offset Path. Positive `delta` grows the filled area, negative insets it.
|
||||
@@ -154,3 +174,22 @@ fn dist_to(bp: &BezPath, p: kurbo::Point) -> f64 {
|
||||
use kurbo::ParamCurveNearest;
|
||||
bp.segments().map(|s| s.nearest(p, 1e-6).distance_sq).fold(f64::INFINITY, f64::min).sqrt()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use kurbo::Shape;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stroke_region_unites_outlines_whatever_their_winding() {
|
||||
// A bar, and a square ring wound the other way that the bar runs into.
|
||||
let bar = kurbo::Rect::new(0.0, 0.0, 20.0, 4.0).to_path(0.1);
|
||||
let mut ring = kurbo::Rect::new(10.0, -8.0, 26.0, 12.0).to_path(0.1).reverse_subpaths();
|
||||
ring.extend(kurbo::Rect::new(14.0, -4.0, 22.0, 8.0).to_path(0.1).iter());
|
||||
let region = stroke_region(&[bar, ring]);
|
||||
let area = crate::area(®ion, FillRule::NonZero);
|
||||
// Ring 16·20 − 8·12 = 224, plus the bar outside it (10·4) and inside its hole (6·4).
|
||||
assert!((area - (224.0 + 40.0 + 24.0)).abs() < 1e-6, "{area}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
+86
-72
@@ -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,55 +526,82 @@ impl Exporter<'_> {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
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 {
|
||||
pat.extend(pat.clone());
|
||||
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 {
|
||||
pat.extend(pat.clone());
|
||||
}
|
||||
StrokeDash { array: pat, offset: d.offset as f32 }
|
||||
});
|
||||
s.set_fill(None);
|
||||
s.set_stroke(Some(Stroke {
|
||||
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,
|
||||
LineCap::Round => krilla::paint::LineCap::Round,
|
||||
LineCap::Square => krilla::paint::LineCap::Square,
|
||||
},
|
||||
line_join: match st.join {
|
||||
LineJoin::Miter => krilla::paint::LineJoin::Miter,
|
||||
LineJoin::Round => krilla::paint::LineJoin::Round,
|
||||
LineJoin::Bevel => krilla::paint::LineJoin::Bevel,
|
||||
},
|
||||
opacity: norm(st.opacity),
|
||||
dash,
|
||||
}));
|
||||
s.draw_path(path);
|
||||
s.set_stroke(None);
|
||||
}
|
||||
StrokeDash { array: pat, offset: d.offset as f32 }
|
||||
});
|
||||
s.set_fill(None);
|
||||
s.set_stroke(Some(Stroke {
|
||||
paint: paint.clone(),
|
||||
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,
|
||||
LineCap::Round => krilla::paint::LineCap::Round,
|
||||
LineCap::Square => krilla::paint::LineCap::Square,
|
||||
},
|
||||
line_join: match st.join {
|
||||
LineJoin::Miter => krilla::paint::LineJoin::Miter,
|
||||
LineJoin::Round => krilla::paint::LineJoin::Round,
|
||||
LineJoin::Bevel => krilla::paint::LineJoin::Bevel,
|
||||
},
|
||||
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.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() {
|
||||
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) {
|
||||
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 p in outlines.iter().filter_map(to_path) {
|
||||
s.draw_path(&p);
|
||||
}
|
||||
s.set_fill(None);
|
||||
}
|
||||
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");
|
||||
}
|
||||
+5
-10
@@ -54,11 +54,6 @@ fn node_bezpath(n: &Node) -> Option<BezPath> {
|
||||
}
|
||||
}
|
||||
|
||||
fn geom_ctx(n: &Node) -> GeomContext {
|
||||
let w = n.appearance.stroke_width();
|
||||
GeomContext { stroke_width: if w > 0.0 { w } else { 1.0 } }
|
||||
}
|
||||
|
||||
fn apply(effects: &[Effect], bp: &BezPath, ctx: &GeomContext) -> BezPath {
|
||||
if !effects::has_geometry(effects) || bp.elements().is_empty() {
|
||||
return bp.clone();
|
||||
@@ -68,7 +63,7 @@ fn apply(effects: &[Effect], bp: &BezPath, ctx: &GeomContext) -> BezPath {
|
||||
|
||||
/// The object-level effected geometry of `n` (base path `bp`).
|
||||
pub(crate) fn effected_path(n: &Node, bp: &BezPath) -> BezPath {
|
||||
apply(&n.appearance.effects, bp, &geom_ctx(n))
|
||||
apply(&n.appearance.effects, bp, &GeomContext::of(n))
|
||||
}
|
||||
|
||||
fn item_effects(item: &AppearanceItem) -> &[Effect] {
|
||||
@@ -81,13 +76,13 @@ fn item_effects(item: &AppearanceItem) -> &[Effect] {
|
||||
/// Visual bounds including geometry effects, stroke outsets and shadows/glows.
|
||||
pub(crate) fn visual_bounds(n: &Node) -> Option<Rect> {
|
||||
let bp = node_bezpath(n)?;
|
||||
let ctx = geom_ctx(n);
|
||||
let ctx = GeomContext::of(n);
|
||||
let g = effected_path(n, &bp);
|
||||
let mut r: Option<Rect> = (!g.elements().is_empty()).then(|| g.bounding_box());
|
||||
for item in &n.appearance.items {
|
||||
let fx = item_effects(item);
|
||||
if effects::has_geometry(fx) {
|
||||
let ig = apply(fx, &g, &ctx);
|
||||
let ig = apply(fx, &g, &ctx.item(item));
|
||||
if !ig.elements().is_empty() {
|
||||
let b = ig.bounding_box();
|
||||
r = Some(r.map_or(b, |r| r.union(b)));
|
||||
@@ -298,7 +293,7 @@ impl Renderer {
|
||||
self.hairline(ctx, f, &g, [0, 0, 0, 255]);
|
||||
return;
|
||||
}
|
||||
let gctx = geom_ctx(n);
|
||||
let gctx = GeomContext::of(n);
|
||||
let rfx = effects::raster_effects(&n.appearance.effects);
|
||||
// Document-space reach of the painted geometry (strokes, arrowheads…).
|
||||
let outset = n.appearance.outset();
|
||||
@@ -452,7 +447,7 @@ impl Renderer {
|
||||
fn paint_items(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, g: &BezPath, rule: FillRule, gctx: &GeomContext) {
|
||||
let bounds = g.bounding_box();
|
||||
for item in &n.appearance.items {
|
||||
let ig = apply(item_effects(item), g, gctx);
|
||||
let ig = apply(item_effects(item), g, &gctx.item(item));
|
||||
let ib = if effects::has_geometry(item_effects(item)) && !ig.elements().is_empty() { ig.bounding_box() } else { bounds };
|
||||
match item {
|
||||
AppearanceItem::Fill(fl) => {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
+137
-55
@@ -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>");
|
||||
|
||||
+12
-7
@@ -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}");
|
||||
}
|
||||
Reference in New Issue
Block a user