mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
M3.36: SVG and PDF export draw arrowheads, width profiles, brushes and aligned strokes as the canvas does
Both writers now ask the shared stroke geometry how to write each stroke (effects::stroke::for_writer): a plain stroke where that is exact, else the canvas's filled outlines (arrowheads, width profiles, dashes fitted to corners, dots with round or projecting caps), grouped under the stroke opacity when the line and its heads overlap. Brushed strokes are written as their brush art. Inside/outside alignment only applies to closed paths, so open paths export as centred strokes. The PDF outside-stroke clip frame and the SVG outside mask now cover the whole stroke reach (miter spikes, projecting caps). SVG stroke gradients use the same paint box as the canvas. The svg crate docs no longer claim arrowheads, profiles, brushes and effects are dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
18d870c7df
commit
f363cdb4ee
+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");
|
||||
}
|
||||
Reference in New Issue
Block a user