Files
vectorcraft/crates/svg/src/export.rs
T
bflatasticandClaude Opus 5.5 f363cdb4ee 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>
2026-10-04 23:07:33 +07:00

1033 lines
45 KiB
Rust

//! The SVG writer.
use std::collections::{HashMap, HashSet};
use vectorcraft_color::{BlendMode, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{
AppearanceItem, Document, FillLayer, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextKind, TextObject,
};
use vectorcraft_doc::{CharStyle, Justify};
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};
type Props = Vec<(&'static str, String)>;
/// One laid-out line of area type: baseline anchor (text space) and its pieces of each run.
struct AreaLine<'t> {
x: f64,
y: f64,
pieces: Vec<(usize, &'t str)>,
hyphenated: bool,
}
pub(crate) fn export(doc: &Document, opts: &ExportOptions) -> String {
// Live geometry effects (Roughen, Warp, Offset Path, Effect → Pathfinder…) export as their result.
let baked = vectorcraft_effects::bake_document(doc);
let doc = baked.as_ref().unwrap_or(doc);
let rect = opts
.artboard
.and_then(|i| doc.artboards.get(i))
.map(|a| a.rect)
.or_else(|| doc.art_bounds())
.or_else(|| doc.artboards.first().map(|a| a.rect))
.unwrap_or(Rect::new(0.0, 0.0, 1.0, 1.0));
let mut w = Writer {
doc,
opts,
xf: Affine::translate((-rect.x0, -rect.y0)),
body: String::new(),
defs: String::new(),
classes: Vec::new(),
used_ids: HashSet::new(),
names: HashMap::new(),
depth: 1,
patterns: HashMap::new(),
pattern_nest: 0,
brushes: None,
};
w.assign_name_ids();
for l in &doc.layers {
w.node(l);
}
let nl = if opts.minify { "" } else { "\n" };
let mut out = String::new();
if !opts.minify {
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
}
let (ww, hh) = (w.num(rect.width()), w.num(rect.height()));
out.push_str("<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\"");
if !opts.responsive {
out.push_str(&format!(" width=\"{ww}\" height=\"{hh}\""));
}
out.push_str(&format!(" viewBox=\"0 0 {ww} {hh}\">{nl}"));
if !w.defs.is_empty() || !w.classes.is_empty() {
out.push_str(&w.indent(1));
out.push_str(&format!("<defs>{nl}"));
if !w.classes.is_empty() {
out.push_str(&w.indent(2));
out.push_str("<style>");
for (i, c) in w.classes.iter().enumerate() {
out.push_str(&format!("{nl}{}.cls-{}{{{}}}", w.indent(3), i + 1, xml_escape(c)));
}
out.push_str(&format!("{nl}{}</style>{nl}", w.indent(2)));
}
out.push_str(&w.defs);
out.push_str(&w.indent(1));
out.push_str(&format!("</defs>{nl}"));
}
if !doc.title.is_empty() {
out.push_str(&w.indent(1));
out.push_str(&format!("<title>{}</title>{nl}", xml_escape(&doc.title)));
}
out.push_str(&w.body);
out.push_str("</svg>");
out.push_str(nl);
out
}
struct Writer<'a> {
doc: &'a Document,
opts: &'a ExportOptions,
/// Document → SVG user space (artboard origin translation, symbol instance transforms).
xf: Affine,
body: String,
defs: String,
/// Unique CSS declaration blocks; class name is `cls-{index + 1}`.
classes: Vec<String>,
used_ids: HashSet<String>,
names: HashMap<NodeId, String>,
depth: usize,
/// `<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 {
match b {
BlendMode::Normal => "normal",
BlendMode::Darken => "darken",
BlendMode::Multiply => "multiply",
BlendMode::ColorBurn => "color-burn",
BlendMode::Lighten => "lighten",
BlendMode::Screen => "screen",
BlendMode::ColorDodge => "color-dodge",
BlendMode::Overlay => "overlay",
BlendMode::SoftLight => "soft-light",
BlendMode::HardLight => "hard-light",
BlendMode::Difference => "difference",
BlendMode::Exclusion => "exclusion",
BlendMode::Hue => "hue",
BlendMode::Saturation => "saturation",
BlendMode::Color => "color",
BlendMode::Luminosity => "luminosity",
}
}
/// Turn an object name into a valid, readable XML id.
fn sanitize_id(name: &str) -> String {
let mut s: String = name.trim().chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' }).collect();
if s.is_empty() || !s.starts_with(|c: char| c.is_alphabetic() || c == '_') {
s.insert(0, '_');
}
s
}
impl Writer<'_> {
fn num(&self, v: f64) -> String {
fmt_num(v, self.opts.decimals)
}
fn indent(&self, depth: usize) -> String {
if self.opts.minify { String::new() } else { " ".repeat(depth) }
}
fn line(&mut self, s: &str) {
let ind = self.indent(self.depth);
self.body.push_str(&ind);
self.body.push_str(s);
if !self.opts.minify {
self.body.push('\n');
}
}
fn def(&mut self, depth: usize, s: &str) {
let ind = self.indent(depth + 1);
self.defs.push_str(&ind);
self.defs.push_str(s);
if !self.opts.minify {
self.defs.push('\n');
}
}
fn fresh_id(&mut self, prefix: &str) -> String {
let mut i = 1;
loop {
let id = format!("{prefix}-{i}");
if self.used_ids.insert(id.clone()) {
return id;
}
i += 1;
}
}
/// Reserve ids for every named object up front so generated def ids never collide with them.
fn assign_name_ids(&mut self) {
if !self.opts.object_ids {
return;
}
let mut named: Vec<(NodeId, String)> = Vec::new();
self.doc.walk(|n| {
if let Some(name) = &n.name {
named.push((n.id, sanitize_id(name)));
}
});
for (id, base) in named {
let mut cand = base.clone();
let mut i = 2;
while !self.used_ids.insert(cand.clone()) {
cand = format!("{base}-{i}");
i += 1;
}
self.names.insert(id, cand);
}
}
fn id_attr(&self, n: &Node) -> String {
self.names.get(&n.id).map(|id| format!(" id=\"{}\"", xml_escape(id))).unwrap_or_default()
}
fn matrix(&self, m: Affine) -> String {
let c = m.as_coeffs();
format!("matrix({} {} {} {} {} {})", self.num(c[0]), self.num(c[1]), self.num(c[2]), self.num(c[3]), self.num(c[4]), self.num(c[5]))
}
/// Style properties as attributes, an inline style, or a CSS class, per the styling option.
fn attrs(&mut self, props: &Props) -> String {
if props.is_empty() {
return String::new();
}
let css = |ps: &[&(&str, String)]| ps.iter().map(|(k, v)| format!("{k}:{v}")).collect::<Vec<_>>().join(";");
match self.opts.styling {
Styling::PresentationAttributes => {
// CSS-only properties go in a style attribute.
let (style, attrs): (Vec<_>, Vec<_>) =
props.iter().partition(|(k, _)| matches!(*k, "mix-blend-mode" | "isolation" | "font-feature-settings"));
let mut s: String = attrs.iter().map(|(k, v)| format!(" {k}=\"{}\"", xml_escape(v))).collect();
if !style.is_empty() {
s.push_str(&format!(" style=\"{}\"", xml_escape(&css(&style))));
}
s
}
Styling::InlineStyle => format!(" style=\"{}\"", xml_escape(&css(&props.iter().collect::<Vec<_>>()))),
Styling::InternalCss => {
let decl = css(&props.iter().collect::<Vec<_>>());
let i = match self.classes.iter().position(|c| *c == decl) {
Some(i) => i,
None => {
self.classes.push(decl);
self.classes.len() - 1
}
};
format!(" class=\"cls-{}\"", i + 1)
}
}
}
fn node_props(n: &Node) -> Props {
let mut p = Props::new();
if n.opacity < 1.0 {
p.push(("opacity", fmt_num(n.opacity as f64, 3)));
}
if n.blend != BlendMode::Normal {
p.push(("mix-blend-mode", blend_css(n.blend).into()));
}
if n.isolate {
p.push(("isolation", "isolate".into()));
}
p
}
fn path_d(&self, p: &PathData, m: Affine) -> String {
let mut s = String::new();
let pt = |p: Point| {
let p = m * p;
format!("{} {}", self.num(p.x), self.num(p.y))
};
for sp in &p.subpaths {
let n = sp.anchors.len();
if n == 0 {
continue;
}
if !s.is_empty() {
s.push(' ');
}
s.push('M');
s.push_str(&pt(sp.anchors[0].p));
for i in 0..sp.segment_count() {
let (a, b) = (&sp.anchors[i], &sp.anchors[(i + 1) % n]);
if sp.segment_is_line(i) {
if sp.closed && i == n - 1 {
break;
}
s.push_str(&format!(" L{}", pt(b.p)));
} else {
s.push_str(&format!(" C{} {} {}", pt(a.h_out), pt(b.h_in), pt(b.p)));
}
}
if sp.closed {
s.push_str(" Z");
}
}
s
}
fn paint(&mut self, p: &Paint, bounds: Option<Rect>) -> String {
match p {
Paint::None => "none".into(),
Paint::Pattern { pattern, xf } => match self.pattern_def(pattern, *xf) {
Some(id) => format!("url(#{id})"),
None => "none".into(),
},
Paint::Solid { color, .. } => color.to_hex(),
Paint::Gradient(g) => format!("url(#{})", self.gradient_def(g, bounds.unwrap_or(Rect::new(0.0, 0.0, 1.0, 1.0)))),
}
}
/// A `<pattern>` def for pattern `name` placed by `xf`: one period (super-tile) of the tiling,
/// with every instance that reaches into it.
fn pattern_def(&mut self, name: &str, xf: Affine) -> Option<String> {
let doc = self.doc;
let def = doc.pattern(name)?;
let m = self.xf * xf;
let key = format!("{name}|{:?}", m.as_coeffs());
if let Some(id) = self.patterns.get(&key) {
return Some(id.clone());
}
if self.pattern_nest > 4 {
return None;
}
let id = self.fresh_id("pattern");
self.patterns.insert(key, id.clone());
let (pw, ph) = def.period();
let (saved_body, saved_xf, saved_depth) = (std::mem::take(&mut self.body), self.xf, self.depth);
self.depth = 3;
self.pattern_nest += 1;
for o in def.offsets_covering(Rect::new(0.0, 0.0, pw, ph)) {
self.xf = def.instance_xf(o);
for a in &def.art {
self.node(a);
}
}
self.pattern_nest -= 1;
let content = std::mem::replace(&mut self.body, saved_body);
self.xf = saved_xf;
self.depth = saved_depth;
let head = format!(
"<pattern id=\"{id}\" patternUnits=\"userSpaceOnUse\" width=\"{}\" height=\"{}\" patternTransform=\"{}\">",
self.num(pw),
self.num(ph),
self.matrix(m)
);
self.def(1, &head);
self.defs.push_str(&content);
self.def(1, "</pattern>");
Some(id)
}
fn gradient_def(&mut self, g: &GradientPaint, bounds: Rect) -> String {
let mut geom = g.resolve(bounds);
geom.transform(self.xf, g.gradient.kind);
let (s, e) = (geom.start, geom.end);
let head = match g.gradient.kind {
GradientKind::Radial => {
let id = self.fresh_id("radial-gradient");
let r = (e - s).hypot();
if (geom.aspect - 1.0).abs() < 1e-9 {
(
id.clone(),
format!(
"<radialGradient id=\"{id}\" cx=\"{}\" cy=\"{}\" r=\"{}\" gradientUnits=\"userSpaceOnUse\">",
self.num(s.x),
self.num(s.y),
self.num(r)
),
"radialGradient",
)
} else {
let v = e - s;
let m = Affine::translate(s.to_vec2()) * Affine::rotate(v.y.atan2(v.x)) * Affine::scale_non_uniform(1.0, geom.aspect);
(
id.clone(),
format!(
"<radialGradient id=\"{id}\" cx=\"0\" cy=\"0\" r=\"{}\" gradientTransform=\"{}\" gradientUnits=\"userSpaceOnUse\">",
self.num(r),
self.matrix(m)
),
"radialGradient",
)
}
}
GradientKind::Linear | GradientKind::Freeform => {
let id = self.fresh_id("linear-gradient");
(
id.clone(),
format!(
"<linearGradient id=\"{id}\" x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" gradientUnits=\"userSpaceOnUse\">",
self.num(s.x),
self.num(s.y),
self.num(e.x),
self.num(e.y)
),
"linearGradient",
)
}
};
let (id, open, tag) = head;
self.def(1, &open);
for (off, c, o) in g.gradient.expanded_stops() {
let op = if o < 1.0 { format!(" stop-opacity=\"{}\"", fmt_num(o as f64, 3)) } else { String::new() };
self.def(2, &format!("<stop offset=\"{}\" stop-color=\"{}\"{op}/>", fmt_num(off as f64, 4), c.to_hex()));
}
self.def(1, &format!("</{tag}>"));
id
}
fn fill_props(&mut self, f: &FillLayer, rule: FillRule, bounds: Option<Rect>, p: &mut Props) {
let paint = self.paint(&f.paint, bounds);
p.push(("fill", paint));
if f.opacity < 1.0 {
p.push(("fill-opacity", fmt_num(f.opacity as f64, 3)));
}
if rule == FillRule::EvenOdd {
p.push(("fill-rule", "evenodd".into()));
}
}
fn stroke_props(&mut self, s: &StrokeLayer, width: f64, bounds: Option<Rect>, p: &mut Props) {
let paint = self.paint(&s.paint, bounds);
p.push(("stroke", paint));
if s.opacity < 1.0 {
p.push(("stroke-opacity", fmt_num(s.opacity as f64, 3)));
}
if (width - 1.0).abs() > 1e-9 {
p.push(("stroke-width", self.num(width)));
}
match s.cap {
LineCap::Butt => {}
LineCap::Round => p.push(("stroke-linecap", "round".into())),
LineCap::Square => p.push(("stroke-linecap", "square".into())),
}
match s.join {
LineJoin::Miter => {
if (s.miter_limit - 4.0).abs() > 1e-9 {
p.push(("stroke-miterlimit", self.num(s.miter_limit)));
}
}
LineJoin::Round => p.push(("stroke-linejoin", "round".into())),
LineJoin::Bevel => p.push(("stroke-linejoin", "bevel".into())),
}
if let Some(d) = &s.dash
&& d.is_dashed()
{
p.push(("stroke-dasharray", d.pattern.iter().map(|v| self.num(*v)).collect::<Vec<_>>().join(" ")));
if d.offset != 0.0 {
p.push(("stroke-dashoffset", self.num(d.offset)));
}
}
}
/// 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
.items
.iter()
.filter(|i| match i {
AppearanceItem::Fill(f) => f.visible && !f.paint.is_none(),
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| 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 }) {
Some(f) => self.fill_props(f, rule, bounds, &mut p),
None => {
p.push(("fill", "none".into()));
if rule == FillRule::EvenOdd {
p.push(("fill-rule", "evenodd".into()));
}
}
}
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, paint_bounds(s), &mut p);
if matches!(items.first(), Some(AppearanceItem::Stroke(_))) && fills == 1 {
p.push(("paint-order", "stroke".into()));
}
}
p.extend(Self::node_props(n));
let a = self.attrs(&p);
self.line(&format!("<path{id} d=\"{d}\"{a}/>"));
return;
}
let a = self.attrs(&Self::node_props(n));
self.line(&format!("<g{id}{a}>"));
self.depth += 1;
for (it, plan) in items.into_iter().zip(plans) {
let mut p = Props::new();
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()));
}
let a = self.attrs(&p);
self.line(&format!("<path d=\"{d}\"{a}/>"));
}
(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 a = self.attrs(&p);
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;
self.line("</g>");
}
/// An object with an opacity mask: `<g mask="url(#…)">` around the unmasked object. The mask
/// art goes in `<defs>`; no-clip adds a white backdrop and invert a colour-inverting filter.
fn masked(&mut self, n: &Node, m: &vectorcraft_doc::OpacityMask) {
const BIG: &str = "x=\"-100000\" y=\"-100000\" width=\"200000\" height=\"200000\"";
let mid = self.fresh_id("mask");
let (body, depth) = (std::mem::take(&mut self.body), self.depth);
self.depth = 2;
self.node(&m.art);
let art = std::mem::replace(&mut self.body, body);
self.depth = depth;
let inv = m.invert.then(|| {
let fid = self.fresh_id("invert");
self.def(1, &format!("<filter id=\"{fid}\" filterUnits=\"userSpaceOnUse\" color-interpolation-filters=\"sRGB\" {BIG}>"));
self.def(2, "<feColorMatrix type=\"matrix\" values=\"-1 0 0 0 1 0 -1 0 0 1 0 0 -1 0 1 0 0 0 1 0\"/>");
self.def(1, "</filter>");
fid
});
self.def(1, &format!("<mask id=\"{mid}\" maskUnits=\"userSpaceOnUse\" {BIG}>"));
if let Some(fid) = &inv {
self.def(1, &format!("<g filter=\"url(#{fid})\">"));
}
if !m.clip || m.invert {
let c = if m.clip { "black" } else { "white" };
self.def(1, &format!("<rect {BIG} fill=\"{c}\"/>"));
}
self.defs.push_str(&art);
if inv.is_some() {
self.def(1, "</g>");
}
self.def(1, "</mask>");
self.line(&format!("<g mask=\"url(#{mid})\">"));
self.depth += 1;
let mut bare = n.clone();
bare.mask = None;
self.node(&bare);
self.depth -= 1;
self.line("</g>");
}
fn node(&mut self, n: &Node) {
if !n.visible {
return;
}
if n.appearance.effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) {
return self.filtered(n);
}
if let Some(m) = n.mask.as_deref()
&& !m.disabled
{
return self.masked(n, m);
}
match &n.kind {
NodeKind::Layer { template: true, .. } => {}
NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => {
let id = self.id_attr(n);
let a = self.attrs(&Self::node_props(n));
self.line(&format!("<g{id}{a}>"));
self.depth += 1;
for c in children {
self.node(c);
}
self.depth -= 1;
self.line("</g>");
}
NodeKind::Group { children, clip: true } => {
let Some((clip, rest)) = children.split_first() else { return };
let cid = self.fresh_id("clip-path");
self.def(1, &format!("<clipPath id=\"{cid}\">"));
// The region every output clips to; with nothing to clip by, an empty clip path
// hides the clipped art.
if let Some((bp, rule)) = vectorcraft_effects::clip_outline(clip) {
let d = self.path_d(&PathData::from_bezpath(&bp), self.xf);
let r = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" };
self.def(2, &format!("<path d=\"{d}\"{r}/>"));
}
self.def(1, "</clipPath>");
let id = self.id_attr(n);
let a = self.attrs(&Self::node_props(n));
self.line(&format!("<g{id} clip-path=\"url(#{cid})\"{a}>"));
self.depth += 1;
for c in rest {
self.node(c);
}
self.depth -= 1;
self.line("</g>");
}
NodeKind::Path { guide: true, .. } => {}
NodeKind::Path { path, rule, .. } => {
if path.is_empty() {
return;
}
let d = self.path_d(path, self.xf);
self.shape(n, &d, &[path], *rule, n.geometric_bounds());
}
NodeKind::Compound { children, rule } => {
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;
}
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),
NodeKind::Image(im) => {
let href = match self.doc.images.get(&im.key) {
Some(b) if !b.bytes.is_empty() => format!("data:{};base64,{}", b.mime, base64_encode(&b.bytes)),
_ => match &im.link {
Some(l) => l.clone(),
None => return,
},
};
let id = self.id_attr(n);
let m = self.matrix(self.xf * im.xf);
let a = self.attrs(&Self::node_props(n));
self.line(&format!(
"<image{id} width=\"{}\" height=\"{}\" transform=\"{m}\" preserveAspectRatio=\"none\"{a} xlink:href=\"{}\"/>",
im.width,
im.height,
xml_escape(&href)
));
}
NodeKind::SymbolInstance { symbol, xf } => {
let Some(sym) = self.doc.symbols.iter().find(|s| s.name == *symbol) else { return };
let saved = self.xf;
self.xf = saved * *xf;
let id = self.id_attr(n);
let a = self.attrs(&Self::node_props(n));
self.line(&format!("<g{id}{a}>"));
self.depth += 1;
self.node(&sym.art.clone());
self.depth -= 1;
self.line("</g>");
self.xf = saved;
}
// Live blends/envelopes/meshes export their evaluated (expanded) form.
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
let g = vectorcraft_doc::live::expand_deep(n, None);
self.node(&g);
}
}
}
/// Raster effects (drop shadow, glows, Gaussian blur, feather) as SVG filters: one `<g filter>`
/// per effect, the first effect innermost (Illustrator's stacking order).
fn filtered(&mut self, n: &Node) {
use vectorcraft_effects::RasterFx;
let fx = vectorcraft_effects::raster_effects(&n.appearance.effects);
let mut inner = n.clone();
inner.appearance.effects.retain(|e| !vectorcraft_effects::is_raster(&e.id));
let reach: f64 = fx.iter().map(RasterFx::outset).sum::<f64>() + 2.0;
let region = n.visual_bounds().map(|b| self.xf.transform_rect_bbox(b).inflate(reach, reach));
let mut opened = 0;
for f in fx.iter().rev() {
let fid = self.fresh_id("filter");
let region_attr = match region {
Some(r) => format!(
" filterUnits=\"userSpaceOnUse\" x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"",
self.num(r.x0),
self.num(r.y0),
self.num(r.width()),
self.num(r.height())
),
None => String::new(),
};
let sd = |blur: f64| fmt_num((blur / 2.0).max(0.0), 3);
let flood = |c: &vectorcraft_color::Color, o: f32| {
format!("<feFlood flood-color=\"{}\" flood-opacity=\"{}\"/>", c.to_hex(), fmt_num(o as f64, 3))
};
let body = match f {
RasterFx::DropShadow { opacity, dx, dy, blur, color, .. } => format!(
"<feGaussianBlur in=\"SourceAlpha\" stdDeviation=\"{}\"/><feOffset dx=\"{}\" dy=\"{}\" result=\"shadow\"/>{}<feComposite in2=\"shadow\" operator=\"in\" result=\"paint\"/><feMerge><feMergeNode in=\"paint\"/><feMergeNode in=\"SourceGraphic\"/></feMerge>",
sd(*blur),
self.num(*dx),
self.num(*dy),
flood(color, *opacity)
),
RasterFx::OuterGlow { opacity, blur, color, .. } => format!(
"<feGaussianBlur in=\"SourceAlpha\" stdDeviation=\"{}\" result=\"glow\"/>{}<feComposite in2=\"glow\" operator=\"in\" result=\"paint\"/><feMerge><feMergeNode in=\"paint\"/><feMergeNode in=\"SourceGraphic\"/></feMerge>",
sd(*blur),
flood(color, *opacity)
),
RasterFx::InnerGlow { opacity, blur, color, center, .. } => {
// Edge: the blurred inverse silhouette; Center: the blurred silhouette; both clipped to the shape.
let src = if *center {
"<feGaussianBlur in=\"SourceAlpha\" stdDeviation=\"SD\" result=\"glow\"/>".replace("SD", &sd(*blur))
} else {
format!(
"<feComponentTransfer in=\"SourceAlpha\"><feFuncA type=\"table\" tableValues=\"1 0\"/></feComponentTransfer><feGaussianBlur stdDeviation=\"{}\" result=\"glow\"/>",
sd(*blur)
)
};
format!(
"{src}{}<feComposite in2=\"glow\" operator=\"in\"/><feComposite in2=\"SourceAlpha\" operator=\"in\" result=\"paint\"/><feMerge><feMergeNode in=\"SourceGraphic\"/><feMergeNode in=\"paint\"/></feMerge>",
flood(color, *opacity)
)
}
RasterFx::Feather { radius } => format!(
"<feGaussianBlur in=\"SourceAlpha\" stdDeviation=\"{}\" result=\"soft\"/><feComposite in=\"SourceGraphic\" in2=\"soft\" operator=\"in\" result=\"f\"/><feComposite in=\"f\" in2=\"SourceAlpha\" operator=\"in\"/>",
sd(*radius)
),
RasterFx::GaussianBlur { radius } => format!("<feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"{}\"/>", sd(*radius)),
};
self.def(1, &format!("<filter id=\"{fid}\"{region_attr} color-interpolation-filters=\"sRGB\">{body}</filter>"));
self.line(&format!("<g filter=\"url(#{fid})\">"));
self.depth += 1;
opened += 1;
}
self.node(&inner);
for _ in 0..opened {
self.depth -= 1;
self.line("</g>");
}
}
fn char_props(&mut self, st: &CharStyle) -> Props {
let mut p = Props::new();
let fam =
if st.font_family.contains(|c: char| c.is_whitespace() || c == ',') { format!("'{}'", st.font_family) } else { st.font_family.clone() };
p.push(("font-family", fam));
p.push(("font-size", self.num(st.size)));
let fs = st.font_style.to_ascii_lowercase();
if fs.contains("bold") || fs.contains("black") || fs.contains("heavy") {
p.push(("font-weight", "bold".into()));
}
if fs.contains("italic") || fs.contains("oblique") {
p.push(("font-style", "italic".into()));
}
let fill = match &st.fill {
Paint::Gradient(g) => g.gradient.stops.first().map(|s| s.color.to_hex()).unwrap_or_else(|| "none".into()),
p => self.paint(p, None),
};
p.push(("fill", fill));
if !st.stroke.is_none() && st.stroke_width > 0.0 {
let s = match &st.stroke {
Paint::Gradient(g) => g.gradient.stops.first().map(|s| s.color.to_hex()).unwrap_or_else(|| "none".into()),
p => self.paint(p, None),
};
p.push(("stroke", s));
p.push(("stroke-width", self.num(st.stroke_width)));
}
if st.tracking != 0.0 {
p.push(("letter-spacing", self.num(st.tracking / 1000.0 * st.size)));
}
if !st.features.is_empty() {
let v: Vec<String> = st
.features
.iter()
.map(|t| match t.strip_prefix('-') {
Some(off) => format!("\"{off}\" 0"),
None => format!("\"{t}\" 1"),
})
.collect();
p.push(("font-feature-settings", v.join(", ")));
}
match (st.underline, st.strikethrough) {
(true, true) => p.push(("text-decoration", "underline line-through".into())),
(true, false) => p.push(("text-decoration", "underline".into())),
(false, true) => p.push(("text-decoration", "line-through".into())),
_ => {}
}
p
}
/// Text as glyph outlines: one compound path per run, painted like the run.
fn text_outlines(&mut self, n: &Node, t: &TextObject) {
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let m = self.xf * t.xf;
let mut runs: Vec<(usize, kurbo::BezPath)> = vec![];
for g in &lay.glyphs {
match runs.last_mut() {
Some((r, bp)) if *r == g.run => bp.extend(g.outline.iter()),
_ => runs.push((g.run, g.outline.clone())),
}
}
let id = self.id_attr(n);
let a = self.attrs(&Self::node_props(n));
self.line(&format!("<g{id}{a}>"));
self.depth += 1;
for (r, mut bp) in runs {
let Some(run) = t.runs.get(r) else { continue };
bp.apply_affine(m);
let pd = PathData::from_bezpath(&bp);
let st = &run.style;
// An unnamed id: the pieces carry no id attribute (the group has it).
let glyphs =
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();
// Run strokes are plain: they need no document-space geometry.
self.shape(&glyphs, &d, &[], FillRule::NonZero, bounds);
}
self.depth -= 1;
self.line("</g>");
}
fn text(&mut self, n: &Node, t: &TextObject) {
let first = t.first_style();
let lead = first.effective_leading();
let id = self.id_attr(n);
let (m, on_path) = match &t.kind {
// Area type lines are positioned in text space (see `area_lines`).
TextKind::Point | TextKind::Area { .. } => (self.xf * t.xf, None),
TextKind::OnPath { path, start } => {
let pid = self.fresh_id("text-path");
let d = self.path_d(path, self.xf * t.xf);
self.def(1, &format!("<path id=\"{pid}\" d=\"{d}\"/>"));
(Affine::IDENTITY, Some((pid, *start)))
}
};
let mut props = self.char_props(&first);
match t.para.justify {
Justify::Center | Justify::JustifyCenter => props.push(("text-anchor", "middle".into())),
Justify::Right | Justify::JustifyRight => props.push(("text-anchor", "end".into())),
_ => {}
}
props.extend(Self::node_props(n));
let a = self.attrs(&props);
let tr = if m == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(m)) };
// Area type: laid-out lines (the `<text>` carries the first line's position too, so readers
// that place text by its own x/y start where the first line does).
let lines = matches!(t.kind, TextKind::Area { .. }).then(|| self.area_lines(t));
let at = match lines.as_ref().and_then(|l| l.first()) {
Some(l) => format!(" x=\"{}\" y=\"{}\"", self.num(l.x), self.num(l.y)),
None => String::new(),
};
let mut s = format!("<text{id}{tr}{at} xml:space=\"preserve\"{a}>");
if let Some((pid, start)) = &on_path {
s.push_str(&format!("<textPath xlink:href=\"#{pid}\" startOffset=\"{}%\">", fmt_num(start * 100.0, 3)));
}
let base = self.char_props(&first);
if let Some(lines) = lines {
for l in lines {
let last = l.pieces.len() - 1;
for (k, (ri, text)) in l.pieces.into_iter().enumerate() {
let pos = if k == 0 { format!(" x=\"{}\" y=\"{}\"", self.num(l.x), self.num(l.y)) } else { String::new() };
let diff: Props = self.char_props(&t.runs[ri].style).into_iter().filter(|kv| !base.contains(kv)).collect();
let a = self.attrs(&diff);
let hy = if k == last && l.hyphenated { "-" } else { "" };
s.push_str(&format!("<tspan{pos}{a}>{}{hy}</tspan>", xml_escape(text)));
}
}
} else {
let mut line = 0usize;
let mut pending = false;
for run in &t.runs {
let rp = self.char_props(&run.style);
let diff: Props = rp.into_iter().filter(|kv| !base.contains(kv)).collect();
for (i, piece) in run.text.split('\n').enumerate() {
if i > 0 {
line += 1;
pending = true;
}
if piece.is_empty() {
continue;
}
let pos = if pending && on_path.is_none() { format!(" x=\"0\" y=\"{}\"", self.num(line as f64 * lead)) } else { String::new() };
pending = false;
let a = self.attrs(&diff);
s.push_str(&format!("<tspan{pos}{a}>{}</tspan>", xml_escape(piece)));
}
}
}
if on_path.is_some() {
s.push_str("</textPath>");
}
s.push_str("</text>");
self.line(&s);
}
/// Area type as laid out. Soft line breaks are only known to the layout, so each laid-out line
/// is placed at its baseline (text space), at its left edge, centre or right edge to match the
/// `text-anchor` of the paragraph alignment.
fn area_lines<'t>(&self, t: &'t TextObject) -> Vec<AreaLine<'t>> {
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let mut offsets = Vec::with_capacity(t.runs.len());
let mut off = 0;
for r in &t.runs {
offsets.push(off);
off += r.text.len();
}
let mut out = vec![];
for l in &lay.lines {
// Run pieces of this line (the paragraph's `\n` is outside `start..end`).
let mut pieces: Vec<(usize, &str)> = vec![];
for (ri, (r, &o)) in t.runs.iter().zip(&offsets).enumerate() {
let (a, b) = (l.start.max(o), l.end.min(o + r.text.len()));
if a < b {
pieces.push((ri, &r.text[a - o..b - o]));
}
}
// Spaces where the line wrapped don't belong to the line.
while let Some((_, p)) = pieces.last_mut() {
*p = p.trim_end();
if !p.is_empty() {
break;
}
pieces.pop();
}
if pieces.is_empty() {
continue;
}
let x = match t.para.justify {
Justify::Center | Justify::JustifyCenter => (l.x0 + l.x1) / 2.0,
Justify::Right | Justify::JustifyRight => l.x1,
_ => l.x0,
};
// A hyphenated break: the layout adds the hyphen as a glyph with no source text.
let hyphenated = l.glyph_end > l.glyph_start && lay.glyphs.get(l.glyph_end - 1).is_some_and(|g| g.len == 0);
out.push(AreaLine { x, y: l.baseline, pieces, hyphenated });
}
out
}
}