diff --git a/Cargo.lock b/Cargo.lock index 3e0705ef..1c3e883f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/crates/effects/src/stroke.rs b/crates/effects/src/stroke.rs index 36abf110..38a7b347 100644 --- a/crates/effects/src/stroke.rs +++ b/crates/effects/src/stroke.rs @@ -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). diff --git a/crates/effects/src/stroke/outline.rs b/crates/effects/src/stroke/outline.rs index 39eca794..1688b3c2 100644 --- a/crates/effects/src/stroke/outline.rs +++ b/crates/effects/src/stroke/outline.rs @@ -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, +} + +/// 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), +} + +/// 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. diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 35143d4e..540e7f33 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -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 } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 0d34a750..f90b5953 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -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 { doc: &'a Document, warnings: Vec, images: HashMap>, + /// The brush library, parsed when the first brushed stroke is written. + brushes: Option>, } 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 = 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 = 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); diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 680c0076..e0a18467 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -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; diff --git a/crates/pdf/src/tests_stroke.rs b/crates/pdf/src/tests_stroke.rs index 26515ccf..25c413b9 100644 --- a/crates/pdf/src/tests_stroke.rs +++ b/crates/pdf/src/tests_stroke.rs @@ -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, Vec) { +pub(crate) fn painted_boxes(d: &Document) -> (Vec, Vec) { 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"); } } } diff --git a/crates/pdf/src/tests_strokeout.rs b/crates/pdf/src/tests_strokeout.rs new file mode 100644 index 00000000..23a8ea8a --- /dev/null +++ b/crates/pdf/src/tests_strokeout.rs @@ -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"); +} diff --git a/crates/svg/Cargo.toml b/crates/svg/Cargo.toml index 97f496fa..8cebaabe 100644 --- a/crates/svg/Cargo.toml +++ b/crates/svg/Cargo.toml @@ -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 } diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 60064d69..db75cf0d 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -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> { /// `` def ids by pattern name + placement. patterns: HashMap, pattern_nest: u32, + /// The brush library, parsed when the first brushed stroke is written. + brushes: Option>, +} + +/// How one stroke is written. +enum StrokePlan { + /// Its brush art (document space). + Brush(Vec), + 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) { + /// 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, 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, 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!("")); + self.def(2, &format!("")); + self.def(1, ""); + 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!("")); + self.def(2, &format!("")); + self.def(2, &format!("")); + self.def(1, ""); + 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) { 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> = 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!("")); 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!("")); } - 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!("")); - self.def(2, &format!("")); - self.def(1, ""); - 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!(""), - ); - self.def(2, &format!("")); - self.def(2, &format!("")); - self.def(1, ""); - format!(" mask=\"url(#{mid})\"") - } - }; let a = self.attrs(&p); - self.line(&format!("")); + self.line(&format!("")); + self.depth += 1; + for piece in &art { + self.node(piece); + } + self.depth -= 1; + self.line(""); } + (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!("")); + } + WrittenShape::Fill(outlines) => { + let paint = self.paint(&s.paint, paint_bounds(s)); + let ds: Vec = 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!("")); + } 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!("")); + self.depth += 1; + let fill = self.attrs(&vec![("fill", paint)]); + for one in &ds { + self.line(&format!("")); + } + self.depth -= 1; + self.line(""); + } + } + } + } + (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 = 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 = 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(""); diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index a5937723..15aba18c 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -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 (``); an *outside* stroke as a stroke of -//! double width masked by a `` 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 (``); an +//! *outside* stroke as a stroke of double width masked by a `` 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 `` holding one //! `` 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. diff --git a/crates/svg/tests/stroke_export.rs b/crates/svg/tests/stroke_export.rs new file mode 100644 index 00000000..d38b4d75 --- /dev/null +++ b/crates/svg/tests/stroke_export.rs @@ -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}"); +}