Files
vectorcraft/crates/svg/src/export.rs
T
NetterSilverandClaude Haiku 5.5 05017111ba Filter control characters in SVG export title and description
Found while testing crates/svg/src/export.rs:138 and :143

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 03:15:47 +07:00

2444 lines
115 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The SVG writer.
use std::collections::{BTreeSet, HashMap, HashSet};
use std::fmt::Write as _;
use std::sync::Arc;
use vectorcraft_color::{BlendMode, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{
AppearanceItem, Document, FillLayer, LineCap, LineJoin, Node, NodeId, NodeKind, SVG_EVENTS, StrokeAlign, StrokeLayer, TextKind, TextObject,
};
use vectorcraft_doc::{CharStyle, ImageBlob, Justify};
use vectorcraft_effects::stroke::{self, Written, WrittenShape};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
use vectorcraft_text::{FontDb, FontFace};
use crate::{
EDITING_NS, Encoding, ExportOptions, ImageMode, LinkedImage, ObjectIds, Output, Profile, Styling, base64_encode, body_hash, fmt_num, fnv1a,
xml_escape,
};
use crate::css::{self, Props, blend_css, css_string, font_descriptor};
pub(crate) fn export(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>) -> Output {
// 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);
if opts.object_ids != ObjectIds::Unique {
return write(doc, opts, native, "");
}
// Unique ids: every id and class name gets a prefix hashed from the output written without
// one, so the same document always gets the same ids and different ones never share any.
let plain = write(doc, opts, native, "");
let prefix = format!("u{:010x}-", fnv1a(plain.svg.as_bytes()) & 0xff_ffff_ffff);
write(doc, opts, native, &prefix)
}
fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: &str) -> Output {
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(),
class_ids: HashMap::new(),
used_ids: HashSet::new(),
names: HashMap::new(),
depth: 1,
patterns: HashMap::new(),
gradients: HashMap::new(),
pattern_nest: 0,
brushes: None,
knockout: doc.page_knockout,
anonymous: false,
knockout_filter: None,
warnings: vec![],
id_prefix,
linked: Vec::new(),
instance_xf: Affine::IDENTITY,
symbols: HashMap::new(),
symbol_nest: 0,
fonts: Vec::new(),
shared_images: HashMap::new(),
layer_nest: 0,
non_latin1_names: false,
};
w.assign_name_ids();
// Page Isolated Blending / Page Knockout Group: the page content is one isolated group.
let page_group = doc.page_isolate || doc.page_knockout;
if page_group {
let a = w.attrs(&vec![("isolation", "isolate".into())]);
w.line(&format!("<g{a}>"));
w.depth += 1;
}
w.children(&doc.layers);
if page_group {
w.depth -= 1;
w.line("</g>");
}
// XML names cannot use character references. Preserve names that Latin-1 cannot encode.
let encoding = if opts.encoding == Encoding::Latin1 && w.non_latin1_names {
w.warn("Unicode data attribute names require UTF-8; SVG was written in UTF-8");
Encoding::Utf8
} else {
opts.encoding
};
let nl = if opts.minify { "" } else { "\n" };
let mut out = String::new();
// A file in neither UTF-8 nor UTF-16 must name its encoding.
if !opts.minify || encoding == Encoding::Latin1 {
out.push_str(&format!("<?xml version=\"1.0\" encoding=\"{}\"?>{nl}", encoding.xml_name()));
}
if opts.styling == Styling::StyleEntities && !w.classes.is_empty() {
out.push_str("<!DOCTYPE svg [");
for (i, c) in w.classes.iter().enumerate() {
out.push_str(&format!("{nl}{}<!ENTITY st{} \"{}\">", w.indent(1), i + 1, entity_value(c)));
}
out.push_str(&format!("{nl}]>{nl}"));
}
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 w.tiny() {
out.push_str(" version=\"1.2\" baseProfile=\"tiny\"");
}
if !opts.responsive {
out.push_str(&format!(" width=\"{ww}\" height=\"{hh}\""));
}
out.push_str(&format!(" viewBox=\"0 0 {ww} {hh}\">{nl}"));
let css = opts.styling == Styling::InternalCss && !w.classes.is_empty();
let fonts = w.font_faces();
if !w.defs.is_empty() || css || !fonts.is_empty() {
out.push_str(&w.indent(1));
out.push_str(&format!("<defs>{nl}"));
if css || !fonts.is_empty() {
out.push_str(&w.indent(2));
out.push_str("<style>");
let classes = w.classes.iter().enumerate().filter(|_| css).map(|(i, c)| format!(".{id_prefix}cls-{}{{{c}}}", i + 1));
for rule in classes.chain(fonts) {
out.push_str(&format!("{nl}{}{}", w.indent(3), xml_escape(&rule)));
}
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_text(&doc.title)));
}
// File Info's description.
if !doc.metadata.description.trim().is_empty() {
out.push_str(&w.indent(1));
out.push_str(&format!("<desc>{}</desc>{nl}", xml_text(&doc.metadata.description)));
}
// Window › SVG Interactivity › Link JavaScript Files.
for file in &doc.svg_scripts {
if file.is_empty() || !valid_xml_value(file) {
w.warn("JavaScript files with empty names or invalid XML characters were left out of SVG");
continue;
}
out.push_str(&w.indent(1));
out.push_str(&format!("<script xlink:href=\"{}\"/>{nl}", xml_attr_escape(file)));
}
let latin1 = encoding == Encoding::Latin1;
if latin1 {
latin1_refs(&mut out);
latin1_refs(&mut w.body);
}
// The editing data carries a hash of the markup around its `<metadata>`.
let native = native.filter(|_| opts.preserve_editing).map(|bytes| (bytes, body_hash(&[&out, &w.body, "</svg>", nl])));
let mut meta = String::new();
w.metadata(&mut meta, native);
if latin1 {
latin1_refs(&mut meta);
}
out.push_str(&meta);
out.push_str(&w.body);
out.push_str("</svg>");
out.push_str(nl);
Output { svg: out, linked: w.linked, warnings: w.warnings, encoding }
}
/// XML 1.0 literal attribute whitespace is normalized to spaces; references retain the value.
fn xml_attr_escape(s: &str) -> String {
xml_escape(s).replace('\t', "&#9;").replace('\n', "&#10;").replace('\r', "&#13;")
}
/// Escape type for XML content. Characters XML 1.0 does not allow are dropped, except the line
/// and page breaks (U+000B, U+000C) other apps paste, which become spaces.
fn xml_text(s: &str) -> String {
let s: String = s
.chars()
.filter_map(|c| match c {
'\u{b}' | '\u{c}' => Some(' '),
c if valid_xml_value(c.encode_utf8(&mut [0; 4])) => Some(c),
_ => None,
})
.collect();
xml_escape(&s)
}
fn valid_xml_value(s: &str) -> bool {
s.chars().all(|c| matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{d7ff}' | '\u{e000}'..='\u{fffd}' | '\u{10000}'..='\u{10ffff}'))
}
/// Namespace-free XML 1.0 NameChar. The fixed `data-` prefix supplies NameStartChar.
fn xml_name_char(c: char) -> bool {
matches!(c,
'A'..='Z' | 'a'..='z' | '0'..='9' | '_' | '-' | '.' | '\u{b7}' |
'\u{c0}'..='\u{d6}' | '\u{d8}'..='\u{f6}' | '\u{f8}'..='\u{2ff}' |
'\u{300}'..='\u{37d}' | '\u{37f}'..='\u{1fff}' | '\u{200c}'..='\u{200d}' |
'\u{203f}'..='\u{2040}' | '\u{2070}'..='\u{218f}' | '\u{2c00}'..='\u{2fef}' |
'\u{3001}'..='\u{d7ff}' | '\u{f900}'..='\u{fdcf}' | '\u{fdf0}'..='\u{fffd}' |
'\u{10000}'..='\u{effff}'
)
}
/// Write every character of `s` beyond ISO 8859-1 as a character reference. Called only when
/// names fit Latin-1, so references occur in values, text or style sheets.
fn latin1_refs(s: &mut String) {
if s.chars().all(|c| u32::from(c) < 0x100) {
return;
}
let mut o = String::with_capacity(s.len() + 64);
for c in s.chars() {
if u32::from(c) < 0x100 {
o.push(c);
} else {
let _ = write!(o, "&#x{:X};", u32::from(c));
}
}
*s = o;
}
/// `chars` (sorted) as a CSS `unicode-range` (`U+41-5A,U+61`).
fn unicode_range(chars: &BTreeSet<char>) -> String {
let mut runs: Vec<(u32, u32)> = Vec::new();
for c in chars.iter().map(|c| u32::from(*c)) {
match runs.last_mut() {
Some((_, end)) if c == *end + 1 => *end = c,
_ => runs.push((c, c)),
}
}
let one = |(a, b): (u32, u32)| if a == b { format!("U+{a:X}") } else { format!("U+{a:X}-{b:X}") };
runs.into_iter().map(one).collect::<Vec<_>>().join(",")
}
/// A CSS declaration block as the literal value of an `<!ENTITY>`: quotes, `%` and markup
/// characters become references that still read as themselves inside the `style` attribute.
fn entity_value(decl: &str) -> String {
let mut o = String::with_capacity(decl.len());
for c in decl.chars() {
match c {
'"' => o.push_str("&#34;"),
'%' => o.push_str("&#37;"),
// Doubly escaped: the entity's replacement text is parsed again where it is used.
'&' => o.push_str("&#38;#38;"),
'<' => o.push_str("&#38;#60;"),
_ => o.push(c),
}
}
o
}
/// Image `b` as SVG viewers show it: PNG, JPEG, GIF, WebP and SVG as they are, other formats (a
/// CMYK TIFF) as PNG.
fn web_image(b: &ImageBlob) -> std::borrow::Cow<'_, ImageBlob> {
use std::borrow::Cow;
if matches!(b.mime.as_str(), "image/png" | "image/jpeg" | "image/jpg" | "image/gif" | "image/webp" | "image/svg+xml") {
return Cow::Borrowed(b);
}
let png = image::load_from_memory(&b.bytes).ok().and_then(|img| {
let mut out = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?;
Some(out)
});
png.map_or(Cow::Borrowed(b), |png| Cow::Owned(ImageBlob::png(png)))
}
/// The usual file extension of an image MIME type.
fn image_ext(mime: &str) -> &str {
match mime {
"image/jpeg" => "jpg",
"image/svg+xml" => "svg",
m => m.strip_prefix("image/").filter(|e| !e.is_empty() && e.chars().all(|c| c.is_ascii_alphanumeric())).unwrap_or("bin"),
}
}
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, in first-use order (used for deterministic output).
classes: Vec<String>,
/// 1-based class/entity number by declaration; avoids a linear scan for each style.
class_ids: HashMap<String, usize>,
used_ids: HashSet<String>,
names: HashMap<NodeId, String>,
depth: usize,
/// `<pattern>` def ids by pattern name + placement.
patterns: HashMap<String, String>,
/// Gradient def ids by content (everything but the id).
gradients: HashMap<String, String>,
pattern_nest: u32,
/// Whether the group being written is a knockout group (what its neutral children inherit).
knockout: bool,
/// Writing a copy of an object (a knockout mask): no object ids, so they stay unique.
anonymous: bool,
/// The filter that paints art black keeping its alpha (knockout masks), once defined.
knockout_filter: Option<String>,
/// The brush library, parsed when the first brushed stroke is written.
brushes: Option<Vec<vectorcraft_brush::Brush>>,
/// Features written approximately (once each).
warnings: Vec<String>,
/// Prepended to every id and class name ([`ObjectIds::Unique`]).
id_prefix: &'a str,
/// Image files the SVG links to ([`ImageMode::Link`]).
linked: Vec<LinkedImage>,
/// The transform of the symbol instances written out as their own art around the object
/// being written (document space; identity outside them and in symbol defs).
instance_xf: Affine,
/// What each symbol's instances can share, by symbol name (see [`Writer::symbol_use`]).
symbols: HashMap<String, SymbolDef>,
/// Symbols being written inside one another (instances nested deeper are left out: a symbol
/// can't contain itself).
symbol_nest: u32,
/// The characters type uses from each face ([`ExportOptions::embed_fonts`]).
fonts: Vec<FontUse>,
/// Images written once in the defs and drawn with `<use>`, by key and size: the pieces an
/// envelope cuts a distorted image into share its pixels.
shared_images: HashMap<(String, u32, u32), String>,
/// Layers being written inside one another (more than one: a sublayer).
layer_nest: usize,
/// An emitted attribute name requires an encoding wider than Latin-1.
non_latin1_names: bool,
}
/// The characters type uses from one face, under one `@font-face` description: the family the
/// type names and the weight, italic and width its style asks for.
struct FontUse {
family: String,
weight: u16,
italic: bool,
/// The `font-stretch` percentage ([`css::font_stretch`]), as bits; `None` for the normal width.
stretch: Option<u64>,
face: Arc<FontFace>,
chars: BTreeSet<char>,
}
/// A symbol's `<symbol>` def and the instance transforms that can `<use>` it.
struct SymbolDef {
/// The def's id, once written.
id: Option<String>,
reuse: Reuse,
}
/// Which instance transforms paint a symbol's art exactly as the def under that transform (the
/// canvas transforms the art's geometry alone: stroke weights, effects, patterns and unlinked
/// masks stay put).
#[derive(Clone, Copy)]
pub(crate) struct Reuse {
/// Translations (no pattern paints or unlinked masks).
moves: bool,
/// Rotations and reflections (nor effects or brushes).
turns: bool,
/// Any other transform (nor strokes or live objects).
scales: bool,
}
/// How one stroke is written.
enum StrokePlan {
/// Its brush art (document space).
Brush(Vec<Node>),
Written(Written),
}
/// How deep symbols may sit in one another's art (deeper instances are left out).
const MAX_SYMBOL_NEST: u32 = 8;
impl Reuse {
const NONE: Self = Self { moves: false, turns: false, scales: false };
/// What a def of symbol art `art` stands for.
pub(crate) fn of(doc: &Document, art: &Node) -> Self {
let mut r = Self { moves: true, turns: true, scales: true };
r.scan(doc, art, 0);
r
}
/// Narrow down to what `n` (inside symbols nested `depth` deep) allows.
fn scan(&mut self, doc: &Document, n: &Node, depth: u32) {
let pattern = |p: &Paint| matches!(p, Paint::Pattern { .. });
let text_patterns = matches!(&n.kind, NodeKind::Text(t) if t.runs.iter().any(|r| pattern(&r.style.fill) || pattern(&r.style.stroke)));
let unlinked_mask = n.extras().mask.as_ref().is_some_and(|m| !m.linked);
if depth > MAX_SYMBOL_NEST || text_patterns || unlinked_mask || vectorcraft_doc::pattern::uses_pattern(n, None) {
*self = Self::NONE;
return;
}
let effects = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible);
if effects(&n.appearance.effects) || n.appearance.items.iter().any(|i| effects(i.effects())) {
(self.turns, self.scales) = (false, false);
}
for i in &n.appearance.items {
if let AppearanceItem::Stroke(s) = i
&& s.visible
&& !s.paint.is_none()
&& s.width > 0.0
{
// Stroke weights don't scale; brushes can orient their art to the page.
self.scales = false;
self.turns &= s.brush.is_none();
}
}
match &n.kind {
NodeKind::Blend { .. }
| NodeKind::Envelope { .. }
| NodeKind::Mesh(_)
| NodeKind::Repeat(_)
| NodeKind::PlacedDocument(_)
| NodeKind::Intertwine { .. } => (self.turns, self.scales) = (false, false),
NodeKind::SymbolInstance { symbol, .. } => {
if let Some(s) = doc.symbols.iter().find(|s| s.name == *symbol) {
self.scan(doc, &s.art, depth + 1);
}
}
_ => {}
}
if let Some(m) = &n.extras().mask {
self.scan(doc, &m.art, depth);
}
for c in n.children().into_iter().flatten() {
self.scan(doc, c, depth);
}
}
/// Can the def stand for an instance with transform `xf`?
pub(crate) fn allows(self, xf: Affine) -> bool {
const EPS: f64 = 1e-9;
let [a, b, c, d, _, _] = xf.as_coeffs();
if (a - 1.0).abs() < EPS && b.abs() < EPS && c.abs() < EPS && (d - 1.0).abs() < EPS {
return self.moves;
}
let rigid = (a * a + b * b - 1.0).abs() < EPS && (c * c + d * d - 1.0).abs() < EPS && (a * c + b * d).abs() < EPS;
if rigid { self.turns } else { self.scales }
}
}
/// Art about to be written moved by `m.inverse()` (a symbol instance's): map what the canvas
/// leaves on the page by `m` first, so it stays there. That is every pattern paint's placement
/// and the art of unlinked opacity masks.
fn pin_to_page(n: &mut Node, m: Affine) {
vectorcraft_doc::pattern::transform_pattern_paints(n, m);
if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) {
let linked = mask.linked;
let art = std::sync::Arc::make_mut(&mut mask.art);
if !linked {
art.transform(m, false);
}
pin_to_page(art, m);
}
for c in n.children_mut().into_iter().flatten() {
pin_to_page(std::sync::Arc::make_mut(c), m);
}
}
/// The Dublin Core terms File Info writes to `<metadata>`: the format, title, author (creator),
/// description, each keyword (subject), copyright notice and URL (rights) and created date, the
/// empty ones left out.
fn dublin_core(doc: &Document) -> Vec<(&'static str, String)> {
let info = &doc.metadata;
let mut terms = vec![("format", "image/svg+xml".to_string())];
let created = info.created.map(vectorcraft_doc::metadata::iso8601).unwrap_or_default();
let fields = [("title", doc.title.as_str()), ("creator", info.author.as_str()), ("description", info.description.as_str())];
let rights = [("rights", info.copyright_notice.as_str()), ("rights", info.copyright_url.as_str()), ("date", created.as_str())];
let keywords = info.keywords.iter().map(|k| ("subject", k.as_str()));
terms.extend(fields.into_iter().chain(keywords).chain(rights).filter(|(_, v)| !v.trim().is_empty()).map(|(k, v)| (k, v.to_string())));
terms
}
/// What SVG Tiny 1.2 does without masks.
const TINY_MASKS: &str = "SVG Tiny 1.2 has no masks: opacity masks are left out and knockout groups written as plain groups";
/// A region covering any artwork (mask and filter extents).
const BIG: &str = "x=\"-100000\" y=\"-100000\" width=\"200000\" height=\"200000\"";
/// Attributes of every `<mask>`: user-space units, and luminance taken from the sRGB values (as the
/// canvas and PDF take it) rather than from linearised ones.
const MASK: &str = "maskUnits=\"userSpaceOnUse\" color-interpolation=\"sRGB\"";
/// Attribute of an exported opacity mask listing its options that differ from the defaults
/// (clipping, not inverted): `noclip`, `invert`.
pub(crate) const MASK_FLAGS: &str = "data-vectorcraft-mask";
/// Turn an object name into a valid, readable XML id.
pub(crate) 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<'_> {
/// Writing the SVG Tiny 1.2 profile?
fn tiny(&self) -> bool {
self.opts.profile == Profile::Tiny12
}
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');
}
}
/// The `href` of image `im`: its file with [`ImageMode::Link`] (or when its pixels are
/// missing), else its bytes. `None` when it has neither.
fn image_href(&mut self, im: &vectorcraft_doc::ImageObject) -> Option<String> {
let (doc, link) = (self.doc, self.opts.images == ImageMode::Link);
match (im.link.as_ref().filter(|_| link), doc.images.get(&im.key)) {
(Some(l), _) => Some(l.path.clone()),
(None, Some(b)) if !b.bytes.is_empty() => Some(self.blob_href(&web_image(b))),
_ => im.link.as_ref().map(|l| l.path.clone()),
}
}
/// Images drawn more than once in `art` (the pieces of a distorted image) go into the defs
/// once; each piece then draws them with `<use>`.
fn share_images(&mut self, art: &Node) {
let mut seen: HashMap<(String, u32, u32), usize> = HashMap::new();
art.walk(&mut |c| {
if let NodeKind::Image(im) = &c.kind {
*seen.entry((im.key.clone(), im.width, im.height)).or_default() += 1;
}
});
let mut repeated: Vec<_> = seen.into_iter().filter(|(k, n)| *n > 1 && !self.shared_images.contains_key(k)).map(|(k, _)| k).collect();
repeated.sort();
for (key, width, height) in repeated {
let link = self.linked_of(art, &key);
let im = vectorcraft_doc::ImageObject { key: key.clone(), width, height, xf: Affine::IDENTITY, link, placement: Default::default() };
let Some(href) = self.image_href(&im) else { continue };
let id = self.fresh_id("image");
self.def(
0,
&format!(
"<image id=\"{id}\" width=\"{width}\" height=\"{height}\" preserveAspectRatio=\"none\" xlink:href=\"{}\"/>",
xml_escape(&href)
),
);
self.shared_images.insert((key, width, height), id);
}
}
/// The link of the first image with blob `key` in `art`.
fn linked_of(&self, art: &Node, key: &str) -> Option<vectorcraft_doc::LinkInfo> {
let mut link = None;
art.walk(&mut |c| {
if let NodeKind::Image(im) = &c.kind
&& im.key == key
&& link.is_none()
{
link = im.link.clone();
}
});
link
}
fn fresh_id(&mut self, prefix: &str) -> String {
let mut i = 1;
loop {
let id = format!("{}{prefix}-{i}", self.id_prefix);
if self.used_ids.insert(id.clone()) {
return id;
}
i += 1;
}
}
/// `base` (with the unique prefix) if free, else `base-2`, `base-3`…
fn unique_id(&mut self, base: &str) -> String {
let base = format!("{}{base}", self.id_prefix);
let mut cand = base.clone();
for i in 2.. {
if self.used_ids.insert(cand.clone()) {
break;
}
cand = format!("{base}-{i}");
}
cand
}
/// 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 == ObjectIds::Minimal {
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 cand = self.unique_id(&base);
self.names.insert(id, cand);
}
}
/// ` id="…"` for a named object, plus ` data-name="…"` with the name itself when the id had to
/// differ from it (spaces, punctuation, duplicates, the unique prefix).
/// The id of `n`'s element (with its name when the id isn't it), then the object's own data
/// as `data-*` attributes. Sublayers also retain their import marker.
fn id_attr(&mut self, n: &Node) -> String {
if self.anonymous {
return String::new();
}
let mut out = self.names.get(&n.id).map_or_else(String::new, |id| format!(" id=\"{}\"", xml_escape(id)));
if let (Some(id), Some(name)) = (self.names.get(&n.id), n.name.as_deref())
&& name != id
{
if valid_xml_value(name) {
out.push_str(&format!(" data-name=\"{}\"", xml_attr_escape(name)));
} else {
self.warn("Object names containing invalid XML characters were left out of SVG name attributes");
}
}
// Preserve sublayer identity independently of object data attributes.
if !out.is_empty() && n.is_layer() && self.layer_nest > 1 {
out.push_str(&format!(" {}=\"sublayer\"", crate::import::SUBLAYER));
}
let mut keys = HashSet::new();
for (k, v) in n.extras().attrs.as_deref().map_or(&[][..], |a| a.data.as_slice()) {
// Ours (`name`, `vc-…`) are written by the export itself.
if k == "name" || k.starts_with("vc-") {
continue;
}
if k.is_empty() || !k.chars().all(xml_name_char) || !valid_xml_value(v) {
self.warn("Data attributes with invalid XML names or characters were left out of SVG");
continue;
}
// Native data is an ordered list. Keep the first valid value of each key.
if !keys.insert(k.as_str()) {
self.warn("Repeated data attribute keys were written only once in SVG");
continue;
}
self.non_latin1_names |= k.chars().any(|c| u32::from(c) > 0xff);
out.push_str(&format!(" data-{k}=\"{}\"", xml_attr_escape(v)));
}
self.event_attrs(n, &mut out);
out
}
/// Window › SVG Interactivity: `n`'s events as event attributes (`onclick="…"`), added to
/// `out`. The SVG Tiny 1.2 profile leaves them out with a warning.
fn event_attrs(&mut self, n: &Node, out: &mut String) {
let events = n.extras().attrs.as_deref().map_or(&[][..], |a| a.events.as_slice());
if events.is_empty() {
return;
}
if self.tiny() {
self.warn("SVG Tiny 1.2 has no event attributes: SVG Interactivity events were left out");
return;
}
let mut seen = HashSet::new();
for (event, script) in events {
// Native data can name anything: only the panel's events are written, once each.
if !SVG_EVENTS.contains(&event.as_str()) || !valid_xml_value(script) {
self.warn("SVG Interactivity events with unknown names or invalid XML characters were left out of SVG");
continue;
}
if !seen.insert(event.as_str()) {
self.warn("Repeated SVG Interactivity events were written only once in SVG");
continue;
}
out.push_str(&format!(" {event}=\"{}\"", xml_attr_escape(script)));
}
}
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();
}
// SVG Tiny has presentation attributes alone.
let styling = if self.tiny() { Styling::PresentationAttributes } else { self.opts.styling };
match styling {
Styling::PresentationAttributes => {
// CSS-only properties go in a style attribute (SVG Tiny has none: left out).
let (style, attrs): (Vec<_>, Vec<_>) = props.iter().partition(|(k, _)| {
matches!(*k, "mix-blend-mode" | "isolation" | "font-feature-settings" | "font-variation-settings" | "font-kerning")
});
let mut s: String = attrs.iter().map(|(k, v)| format!(" {k}=\"{}\"", xml_escape(v))).collect();
if !style.is_empty() {
if self.tiny() {
self.warn("SVG Tiny 1.2 has no style sheets: blend modes, isolation, font features and font variation settings are left out");
} else {
s.push_str(&format!(" style=\"{}\"", xml_escape(&css::declarations(style))));
}
}
s
}
Styling::InlineStyle => format!(" style=\"{}\"", xml_escape(&css::declarations(props))),
Styling::StyleEntities => format!(" style=\"&st{};\"", self.class(css::declarations(props))),
Styling::InternalCss => {
let i = self.class(css::declarations(props));
format!(" class=\"{}cls-{i}\"", self.id_prefix)
}
}
}
/// The 1-based number of a distinct declaration block (a CSS class or a style entity).
fn class(&mut self, decl: String) -> usize {
if let Some(&i) = self.class_ids.get(decl.as_str()) {
return i;
}
let i = self.classes.len() + 1;
self.class_ids.insert(decl.clone(), i);
self.classes.push(decl);
i
}
/// `<metadata>`: the Dublin Core terms of File Info ([`ExportOptions::metadata`]) and the
/// native document ([`ExportOptions::preserve_editing`]) with the [`body_hash`] of the markup
/// around this element.
fn metadata(&self, out: &mut String, native: Option<(&[u8], String)>) {
if !self.opts.metadata && native.is_none() {
return;
}
let nl = if self.opts.minify { "" } else { "\n" };
let mut line = |depth: usize, s: &str| {
out.push_str(&self.indent(depth));
out.push_str(s);
out.push_str(nl);
};
line(1, "<metadata>");
if self.opts.metadata {
line(2, "<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\">");
line(3, "<rdf:Description rdf:about=\"\">");
for (term, value) in dublin_core(self.doc) {
line(4, &format!("<dc:{term}>{}</dc:{term}>", xml_escape(&value)));
}
line(3, "</rdf:Description>");
line(2, "</rdf:RDF>");
}
if let Some((bytes, hash)) = native {
line(
2,
&format!(
"<vectorcraft:document xmlns:vectorcraft=\"{EDITING_NS}\" hash=\"{hash}\"><![CDATA[{}]]></vectorcraft:document>",
base64_encode(bytes)
),
);
}
line(1, "</metadata>");
}
fn path_d(&self, p: &PathData, m: Affine) -> String {
let mut s = String::new();
// Each point as "x y", appended in place (no string per number or per point).
let dec = self.opts.decimals;
let pt = |s: &mut String, p: Point| {
let p = m * p;
crate::push_num(s, p.x, dec);
s.push(' ');
crate::push_num(s, p.y, dec);
};
for sp in &p.subpaths {
let n = sp.anchors.len();
if n == 0 {
continue;
}
if !s.is_empty() {
s.push(' ');
}
s.push('M');
pt(&mut s, 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(" L");
pt(&mut s, b.p);
} else {
s.push_str(" C");
pt(&mut s, a.h_out);
s.push(' ');
pt(&mut s, b.h_in);
s.push(' ');
pt(&mut s, b.p);
}
}
if sp.closed {
s.push_str(" Z");
}
}
s
}
/// A fill or stroke value. Gradients resolve unset geometry against `bounds` (the space
/// [`Self::xf`] maps into user space, as the renderer does).
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)
}
/// The id of a `<linearGradient>`/`<radialGradient>` def for `g` resolved against `bounds`.
/// Identical gradients share one def; a gradient from a swatch takes the swatch's name as its
/// id. A midpoint other than halfway is written as an extra stop marked `data-vc-midpoint`
/// (what import turns back into a midpoint).
fn gradient_def(&mut self, g: &GradientPaint, bounds: Rect) -> String {
if g.gradient.kind == GradientKind::Freeform {
// Object fills and strokes are images (see `raster_paint`); characters' paints aren't.
self.warn("freeform gradients on characters are written as linear gradients");
}
let mut geom = g.resolve(bounds);
geom.transform(self.xf, g.gradient.kind);
let (s, e) = (geom.start, geom.end);
// An off-centre focal point: `fx`/`fy`, in the gradient's own (unsquashed) space.
let focal = |w: &Self, f: Point| format!(" fx=\"{}\" fy=\"{}\"", w.num(f.x), w.num(f.y));
let (tag, prefix, attrs) = match g.gradient.kind {
GradientKind::Radial => {
let r = self.num((e - s).hypot());
let attrs = if (geom.aspect - 1.0).abs() < 1e-9 {
let f = geom.focal.map(|f| focal(self, f)).unwrap_or_default();
format!(" cx=\"{}\" cy=\"{}\" r=\"{r}\"{f}", self.num(s.x), self.num(s.y))
} 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);
let f = geom.focal.map(|f| focal(self, m.inverse() * f)).unwrap_or_default();
format!(" cx=\"0\" cy=\"0\" r=\"{r}\"{f} gradientTransform=\"{}\"", self.matrix(m))
};
("radialGradient", "radial-gradient", attrs)
}
GradientKind::Linear | GradientKind::Freeform => (
"linearGradient",
"linear-gradient",
format!(" x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\"", self.num(s.x), self.num(s.y), self.num(e.x), self.num(e.y)),
),
};
let stops: Vec<String> = g
.gradient
.expanded()
.map(|(offset, c, o, mid)| {
let op = if o < 1.0 { format!(" stop-opacity=\"{}\"", fmt_num(o as f64, 3)) } else { String::new() };
let mid = mid.map(|m| format!(" data-vc-midpoint=\"{}\"", fmt_num(m as f64, 4))).unwrap_or_default();
format!("<stop offset=\"{}\" stop-color=\"{}\"{op}{mid}/>", fmt_num(offset as f64, 4), c.to_hex())
})
.collect();
let key = format!("{tag}{attrs}{}", stops.concat());
if let Some(id) = self.gradients.get(&key) {
return id.clone();
}
let id = match &g.swatch {
Some(name) => self.unique_id(&sanitize_id(name)),
None => self.fresh_id(prefix),
};
self.gradients.insert(key, id.clone());
self.def(1, &format!("<{tag} id=\"{id}\"{attrs} gradientUnits=\"userSpaceOnUse\">"));
for stop in &stops {
self.def(2, stop);
}
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(|| doc_path(paths));
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(doc, 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) if self.tiny() => {
// No masks: clipped by everything around the shape instead.
let cid = self.fresh_id("clip-path");
let b = self.xf.transform_rect_bbox(reach).inflate(1.0, 1.0);
let (x0, y0, x1, y1) = (self.num(b.x0), self.num(b.y0), self.num(b.x1), self.num(b.y1));
self.def(1, &format!("<clipPath id=\"{cid}\">"));
self.def(2, &format!("<path d=\"M{x0} {y0} H{x1} V{y1} H{x0} Z {d}\" clip-rule=\"evenodd\"/>"));
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}\" {MASK} 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().any(|i| has_raster(i.effects()))
&& items.iter().all(|i| i.blend() == BlendMode::Normal)
&& !items.iter().any(|i| freeform(i.paint()).is_some())
&& 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(css::transparency(n));
let a = self.attrs(&p);
self.line(&format!("<path{id} d=\"{d}\"{a}/>"));
return;
}
let a = self.attrs(&css::transparency(n));
self.line(&format!("<g{id}{a}>"));
self.depth += 1;
for (it, plan) in items.into_iter().zip(plans) {
// The item's own raster effects filter that item alone.
let filters = self.open_filters(n, it.effects());
let mut p = Props::new();
match (it, plan) {
(AppearanceItem::Fill(f), _) => {
let blend = (f.blend != BlendMode::Normal).then(|| ("mix-blend-mode", blend_css(f.blend).to_string()));
if let (Some(g), Some(b)) = (freeform(&f.paint), bounds) {
p.extend((f.opacity < 1.0).then(|| ("opacity", fmt_num(f.opacity as f64, 3))));
p.extend(blend);
self.raster_paint(g, b, &[d.to_string()], rule, &p, "");
} else {
self.fill_props(f, rule, bounds, &mut p);
p.extend(blend);
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()));
let opacity = (s.opacity < 1.0).then(|| ("opacity", fmt_num(s.opacity as f64, 3)));
match (&w.shape, freeform(&s.paint).zip(paint_bounds(s))) {
(WrittenShape::Stroke { width }, Some((g, b))) => {
// The stroke's outline clips the gradient's image.
let bp = bp.get_or_insert_with(|| doc_path(paths));
let outline = stroke::line_outline(bp, s, *width, OUTLINE_TOLERANCE);
let clip = self.path_d(&PathData::from_bezpath(&outline), self.xf);
p.extend(opacity);
p.extend(blend);
self.raster_paint(g, b, &[clip], FillRule::NonZero, &p, &side);
}
(WrittenShape::Fill(outlines), Some((g, b))) if s.path_gradient().is_none() => {
let clip: Vec<String> = outlines.iter().map(|o| self.path_d(&PathData::from_bezpath(o), self.xf)).collect();
p.extend(opacity);
p.extend(blend);
self.raster_paint(g, b, &clip, FillRule::NonZero, &p, &side);
}
(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), _) if s.path_gradient().is_some() => {
// A gradient along or across the stroke: slices clipped to its outlines.
if let Some(ws) = bp.as_ref().and_then(|bp| stroke::written_slices(bp, rule, s, outlines)) {
p.extend(opacity);
p.extend(blend);
self.sliced(&ws, &p, &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.close_filters(filters);
}
self.depth -= 1;
self.line("</g>");
}
/// A stroke whose gradient runs along or across it ([`stroke::written_slices`]): a group (with
/// `props` and the `side` attribute) of its slices, clipped to its outlines.
fn sliced(&mut self, ws: &stroke::WrittenSlices, props: &Props, side: &str) {
self.warn("gradients along or across strokes are written as slices of linear gradients");
let cid = self.fresh_id("clip-path");
let clip = self.path_d(&PathData::from_bezpath(&ws.clip), self.xf);
self.def(1, &format!("<clipPath id=\"{cid}\">"));
self.def(2, &format!("<path d=\"{clip}\"/>"));
self.def(1, "</clipPath>");
let a = self.attrs(props);
self.line(&format!("<g{a}{side}>"));
self.depth += 1;
self.line(&format!("<g clip-path=\"url(#{cid})\">"));
self.depth += 1;
for (shape, paint) in &ws.slices {
let fill = self.paint(paint, None);
let fill = self.attrs(&vec![("fill", fill)]);
let d = self.path_d(&PathData::from_bezpath(shape), self.xf);
self.line(&format!("<path d=\"{d}\"{fill}/>"));
}
self.depth -= 1;
self.line("</g>");
self.depth -= 1;
self.line("</g>");
}
fn warn(&mut self, w: &str) {
if !self.warnings.iter().any(|x| x == w) {
self.warnings.push(w.to_string());
}
}
/// 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) {
if self.tiny() {
self.warn(TINY_MASKS);
let mut bare = n.clone();
bare.extras_mut().mask = None;
return self.node_body(&bare);
}
let mid = self.fresh_id("mask");
// Mask art is a picture of its own: it takes no part in a knockout group around the object.
let knockout = std::mem::take(&mut self.knockout);
let art = self.detached(2, |w| w.node(&m.art));
self.knockout = knockout;
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
});
// The mask's options, so import restores them (and its art without backdrop and filter).
let flags: Vec<&str> = [(!m.clip).then_some("noclip"), m.invert.then_some("invert")].into_iter().flatten().collect();
let data = if flags.is_empty() { String::new() } else { format!(" {MASK_FLAGS}=\"{}\"", flags.join(" ")) };
self.def(1, &format!("<mask id=\"{mid}\" {MASK} {BIG}{data}>"));
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.extras_mut().mask = None;
self.node_body(&bare);
self.depth -= 1;
self.line("</g>");
}
/// What `write` writes to the body, taken out of it (written at `depth`, for `<defs>`).
fn detached(&mut self, depth: usize, write: impl FnOnce(&mut Self)) -> String {
let (body, saved) = (std::mem::take(&mut self.body), self.depth);
self.depth = depth;
write(self);
self.depth = saved;
std::mem::replace(&mut self.body, body)
}
/// Props of a group (or layer): a knockout group is isolated, a hidden one not displayed.
fn group_props(&self, n: &Node) -> Props {
let mut p = css::transparency(n);
if !n.isolate && n.knocks_out(self.knockout) {
p.push(("isolation", "isolate".into()));
}
p
}
/// The children of group `n`, as the elements of a knockout group when it is one.
fn group_children(&mut self, n: &Node, children: &[std::sync::Arc<Node>]) {
let knockout = n.knocks_out(self.knockout);
let enclosing = std::mem::replace(&mut self.knockout, knockout);
self.children(children);
self.knockout = enclosing;
}
/// Children of the group being written. SVG has no knockout groups: in one, each element is
/// drawn through a mask of where the elements above it don't paint (the same look for Normal
/// blending). The masks nest, so element i sits inside the masks of elements i+1…n.
fn children(&mut self, children: &[std::sync::Arc<Node>]) {
if self.knockout && self.tiny() {
self.warn(TINY_MASKS);
}
if !self.knockout || self.tiny() {
for c in children {
self.node(c);
}
return;
}
let elements = Node::knockout_elements(children);
// Where every element over another covers it fully, drawing them in order knocks out as
// much: the masks would only add seams along the shapes' antialiased edges.
if elements.iter().skip(1).all(|c| paints_opaque(c)) {
for c in elements {
self.node(c);
}
return;
}
let Some((first, rest)) = elements.split_first() else { return };
for c in rest.iter().rev() {
let mid = self.knockout_mask(c);
self.line(&format!("<g mask=\"url(#{mid})\">"));
self.depth += 1;
}
self.node(first);
for c in rest {
self.depth -= 1;
self.line("</g>");
self.node(c);
}
}
/// A mask that is 1 − the knockout shape of `c`: white, then `c` painted black (at full object
/// opacity without its own mask, unless those define its shape). Returns the mask id.
fn knockout_mask(&mut self, c: &Node) -> String {
let filter = match &self.knockout_filter {
Some(f) => f.clone(),
None => {
let f = self.fresh_id("knockout-shape");
self.def(1, &format!("<filter id=\"{f}\" filterUnits=\"userSpaceOnUse\" {BIG}>"));
self.def(2, "<feColorMatrix type=\"matrix\" values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0\"/>");
self.def(1, "</filter>");
self.knockout_filter = Some(f.clone());
f
}
};
let shape = if c.knockout_shape {
c.clone()
} else {
let mut s = c.clone().with_mask(None);
s.opacity = 1.0;
s
};
let anonymous = std::mem::replace(&mut self.anonymous, true);
let art = self.detached(3, |w| w.node(&shape));
self.anonymous = anonymous;
let mid = self.fresh_id("knockout");
self.def(1, &format!("<mask id=\"{mid}\" {MASK} {BIG}>"));
self.def(2, &format!("<rect {BIG} fill=\"white\"/>"));
self.def(2, &format!("<g filter=\"url(#{filter})\">"));
self.defs.push_str(&art);
self.def(2, "</g>");
self.def(1, "</mask>");
mid
}
/// An object, inside `<a>` when it links to a URL (Attributes panel).
fn node(&mut self, n: &Node) {
let layer = usize::from(n.is_layer());
self.layer_nest += layer;
match n.url().filter(|_| n.visible && !self.anonymous) {
Some(url) => {
self.line(&format!("<a xlink:href=\"{}\">", xml_escape(url)));
self.depth += 1;
self.node_body(n);
self.depth -= 1;
self.line("</a>");
}
None => self.node_body(n),
}
self.layer_nest -= layer;
}
fn node_body(&mut self, n: &Node) {
// Hidden layers and objects are left out, unless the options keep them (hidden). Template
// layers never print.
if !(n.visible || self.opts.hidden_layers) {
return;
}
if has_raster(&n.appearance.effects) {
return self.filtered(n);
}
if let Some(m) = n.extras().mask.as_deref()
&& !m.disabled
{
return self.masked(n, m);
}
match &n.kind {
NodeKind::Layer { template: true, .. } => {}
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
let id = self.id_attr(n);
let a = self.attrs(&self.group_props(n));
self.line(&format!("<g{id}{a}>"));
self.depth += 1;
self.group_children(n, children);
self.depth -= 1;
self.line("</g>");
}
NodeKind::Group { children, clip: true } | NodeKind::Layer { 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.group_props(n));
// The clipping path's fill paints behind the clipped art and its stroke over it,
// outside the clip (the group then wraps both).
let paint = clip.clip_paint();
if paint.stroke.is_some() {
self.line(&format!("<g{id}{a}>"));
self.depth += 1;
self.line(&format!("<g clip-path=\"url(#{cid})\">"));
} else {
self.line(&format!("<g{id} clip-path=\"url(#{cid})\"{a}>"));
}
self.depth += 1;
if let Some(fill) = &paint.fill {
self.node(fill);
}
self.group_children(n, rest);
self.depth -= 1;
self.line("</g>");
if let Some(stroke) = &paint.stroke {
// One element keeps the clipping path's id.
let anonymous = self.anonymous;
self.anonymous |= paint.fill.is_some();
self.node(stroke);
self.anonymous = anonymous;
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) => self.text_node(n, t),
NodeKind::Image(im) => {
let id = self.id_attr(n);
let m = self.matrix(self.xf * im.xf);
let a = self.attrs(&css::transparency(n));
if let Some(shared) = self.shared_images.get(&(im.key.clone(), im.width, im.height)) {
let shared = shared.clone();
self.line(&format!("<use{id} transform=\"{m}\"{a} xlink:href=\"#{shared}\"/>"));
return;
}
let Some(href) = self.image_href(im) else { return };
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 doc = self.doc;
let Some(sym) = doc.symbols.iter().find(|s| s.name == *symbol) else { return };
if self.symbol_nest > MAX_SYMBOL_NEST {
return;
}
let id = self.id_attr(n);
let a = self.attrs(&css::transparency(n));
if let Some(def) = self.symbol_use(sym, n, *xf) {
let m = self.xf * *xf;
let tr = if m == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(m)) };
self.line(&format!("<use{id} xlink:href=\"#{def}\"{tr}{a}/>"));
return;
}
// An instance of its own: the (stained) art moved by the instance, its pattern
// paints and unlinked masks left in place as on the canvas.
let mut art = vectorcraft_brush::instance_art(&sym.art, n);
let moved = self.instance_xf * *xf;
if moved.determinant().abs() > 1e-12 {
pin_to_page(&mut art, moved.inverse());
}
let saved = (self.xf, self.instance_xf);
self.xf = saved.0 * *xf;
self.instance_xf = moved;
self.line(&format!("<g{id}{a}>"));
self.depth += 1;
// The symbol's art is the instance's own picture, outside any knockout around it.
let knockout = std::mem::take(&mut self.knockout);
self.symbol_nest += 1;
self.node(&art);
self.symbol_nest -= 1;
self.knockout = knockout;
self.depth -= 1;
self.line("</g>");
(self.xf, self.instance_xf) = saved;
}
// Live blends/envelopes/meshes export their evaluated (expanded) form.
NodeKind::Blend { .. }
| NodeKind::Envelope { .. }
| NodeKind::Mesh(_)
| NodeKind::Repeat(_)
| NodeKind::PlacedDocument(_)
| NodeKind::Intertwine { .. } => {
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
self.share_images(&g);
self.node_body(&g);
}
}
}
/// The `href` of image bytes `b`: a `data:` URI, or with [`ImageMode::Link`] a file written
/// next to the SVG, named after the bytes (image keys such as "raster-1" repeat across
/// documents).
fn blob_href(&mut self, b: &ImageBlob) -> String {
if self.opts.images != ImageMode::Link {
return format!("data:{};base64,{}", b.mime, base64_encode(&b.bytes));
}
let name = format!("{}.{}", b.content_key(), image_ext(&b.mime));
if !self.linked.iter().any(|l| l.name == name) {
self.linked.push(LinkedImage { name: name.clone(), bytes: b.bytes.clone() });
}
name
}
/// A freeform gradient `g` painted over `bounds` (the space [`Self::xf`] maps into user
/// space), which SVG can't express: an `<image>` of the colour field over its painted box,
/// sampled as the canvas samples it (at twice the box's size in points) and clipped to
/// `clip` (outlines in user space filled by `rule`), in a group with `props` and the `side`
/// attribute. Nothing is written for an empty box.
fn raster_paint(&mut self, g: &GradientPaint, bounds: Rect, clip: &[String], rule: FillRule, props: &Props, side: &str) {
use vectorcraft_color::freeform::{grid_size, painted_box, spread_scale};
let Some(b) = painted_box(bounds) else { return };
let (cols, rows) = grid_size(b, 2.0 * b.width().max(b.height()));
let field = g.freeform_on(b).field(spread_scale(b));
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
let rgba: Vec<u8> = field.grid(b, cols, rows).flat_map(|([r, g, bl], a)| [q(r), q(g), q(bl), q(a)]).collect();
let mut png = Vec::new();
let Some(img) = image::RgbaImage::from_raw(u32::from(cols), u32::from(rows), rgba) else { return };
if img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_err() {
return;
}
self.warn("freeform gradients are written as images clipped to their shapes");
let href = self.blob_href(&ImageBlob::new("image/png", png));
let cid = self.fresh_id("clip-path");
let r = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" };
self.def(1, &format!("<clipPath id=\"{cid}\">"));
for d in clip {
self.def(2, &format!("<path d=\"{d}\"{r}/>"));
}
self.def(1, "</clipPath>");
let a = self.attrs(props);
let nested = !side.is_empty();
if nested {
self.line(&format!("<g{a}{side}>"));
self.depth += 1;
self.line(&format!("<g clip-path=\"url(#{cid})\">"));
} else {
self.line(&format!("<g clip-path=\"url(#{cid})\"{a}>"));
}
self.depth += 1;
let (x, y, w, h) = (self.num(b.x0), self.num(b.y0), self.num(b.width()), self.num(b.height()));
let tr = if self.xf == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(self.xf)) };
self.line(&format!(
"<image x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\"{tr} preserveAspectRatio=\"none\" xlink:href=\"{}\"/>",
xml_escape(&href)
));
self.depth -= 1;
self.line("</g>");
if nested {
self.depth -= 1;
self.line("</g>");
}
}
/// The id of the `<symbol>` def instance `inst` (transform `xf`) can `<use>`, written on first
/// use. `None` when the instance needs its own copy of the art: stained by its fill, or moved
/// by a transform under which the def would not look as the canvas paints it ([`Reuse`]).
fn symbol_use(&mut self, sym: &vectorcraft_doc::Symbol, inst: &Node, xf: Affine) -> Option<String> {
// SVG Tiny has no `<symbol>`.
if self.tiny() || vectorcraft_brush::stain(inst).is_some() {
return None;
}
if !self.symbols.contains_key(&sym.name) {
let reuse = Reuse::of(self.doc, &sym.art);
self.symbols.insert(sym.name.clone(), SymbolDef { id: None, reuse });
}
let def = self.symbols.get(&sym.name)?;
if !def.reuse.allows(xf) {
return None;
}
if let Some(id) = &def.id {
return Some(id.clone());
}
// The art in symbol space, as its own picture: no knockout around it, and no object ids
// (every instance shows it).
let id = self.unique_id(&sanitize_id(&sym.name));
let saved = (self.xf, self.instance_xf, std::mem::take(&mut self.knockout), std::mem::take(&mut self.names), self.anonymous);
(self.xf, self.instance_xf, self.anonymous) = (Affine::IDENTITY, Affine::IDENTITY, false);
self.symbol_nest += 1;
let art = self.detached(3, |w| w.node(&sym.art));
self.symbol_nest -= 1;
(self.xf, self.instance_xf, self.knockout, self.names, self.anonymous) = saved;
// The symbol's name, when the id had to differ from it, comes back on import.
let name = if id == sym.name {
String::new()
} else if valid_xml_value(&sym.name) {
format!(" data-name=\"{}\"", xml_attr_escape(&sym.name))
} else {
self.warn("Symbol names containing invalid XML characters were left out of SVG name attributes");
String::new()
};
self.def(1, &format!("<symbol id=\"{id}\"{name} overflow=\"visible\">"));
self.defs.push_str(&art);
self.def(1, "</symbol>");
if let Some(def) = self.symbols.get_mut(&sym.name) {
def.id = Some(id.clone());
}
Some(id)
}
/// Raster effects (drop shadow, glows, Gaussian blur, feather) as SVG filters: one `<g filter>`
/// per effect, the first effect innermost (the reference app's stacking order). A drop shadow
/// with Darkness darkens the screen colors, in a CMYK document too. Photoshop-style
/// filters have no SVG equivalent: they are left out with a warning (exports from the app turn
/// such objects into images first).
fn filtered(&mut self, n: &Node) {
let mut inner = n.clone();
inner.appearance.effects.retain(|e| !vectorcraft_effects::is_raster(&e.id));
let opened = self.open_filters(n, &n.appearance.effects);
self.node_body(&inner);
self.close_filters(opened);
}
/// Open one `<g filter>` per visible raster effect of `effects` (an object's, or one fill or
/// stroke's) on `n`, outermost last effect; returns how many to close.
fn open_filters(&mut self, n: &Node, effects: &[vectorcraft_doc::Effect]) -> usize {
use vectorcraft_effects::RasterFx;
let fx = vectorcraft_effects::raster_effects(effects);
if self.tiny() {
if !fx.is_empty() {
self.warn("SVG Tiny 1.2 has no filters: shadows, glows, blurs and feathers are left out");
}
return 0;
}
let bounds = n.visual_bounds_with(&self.doc.symbol_boxes());
// An Outer Glow's silhouette grows by a quarter of a pixel of the document's raster grid first.
let grow = 18.0 / self.doc.raster_ppi();
let reach: f64 =
fx.iter().map(|f| f.outset(bounds.unwrap_or_default()) + if matches!(f, RasterFx::OuterGlow { .. }) { grow } else { 0.0 }).sum::<f64>()
+ 2.0;
let region = bounds.map(|b| self.xf.transform_rect_bbox(b).inflate(reach, reach));
let mut opened = 0;
for f in fx.iter().rev() {
if matches!(f, RasterFx::Pixel(_) | RasterFx::Rasterize(_)) {
self.warn("SVG has no pixel effects such as Radial Blur, Smart Blur or Unsharp Mask: they are left out");
continue;
}
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 {
// Darkness: the object blurred and moved, each color channel times 1 − darkness.
RasterFx::DropShadow { opacity, dx, dy, blur, darkness: Some(darkness), .. } => format!(
"<feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"{}\"/><feOffset dx=\"{}\" dy=\"{}\"/><feComponentTransfer result=\"paint\"><feFuncR type=\"linear\" slope=\"{keep}\"/><feFuncG type=\"linear\" slope=\"{keep}\"/><feFuncB type=\"linear\" slope=\"{keep}\"/><feFuncA type=\"linear\" slope=\"{}\"/></feComponentTransfer><feMerge><feMergeNode in=\"paint\"/><feMergeNode in=\"SourceGraphic\"/></feMerge>",
sd(*blur),
self.num(*dx),
self.num(*dy),
fmt_num(*opacity as f64, 3),
keep = fmt_num(1.0 - *darkness as f64, 3)
),
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)
),
// The glow blurs a solid silhouette (alpha × 255 makes any alpha full) grown by a quarter raster pixel.
RasterFx::OuterGlow { opacity, blur, color, .. } => format!(
"<feComponentTransfer in=\"SourceAlpha\"><feFuncA type=\"linear\" slope=\"255\"/></feComponentTransfer><feMorphology operator=\"dilate\" radius=\"{}\"/><feGaussianBlur stdDeviation=\"{}\" result=\"glow\"/>{}<feComposite in2=\"glow\" operator=\"in\" result=\"paint\"/><feMerge><feMergeNode in=\"paint\"/><feMergeNode in=\"SourceGraphic\"/></feMerge>",
fmt_num(grow, 3),
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)),
RasterFx::Pixel(_) | RasterFx::Rasterize(_) => continue,
};
// Filters composite normally: a shadow's or glow's other blend mode is recorded for import.
let mode = match f {
RasterFx::DropShadow { mode, .. } | RasterFx::OuterGlow { mode, .. } | RasterFx::InnerGlow { mode, .. }
if *mode != BlendMode::Normal =>
{
format!(" {}=\"{}\"", crate::import::BLEND, blend_css(*mode))
}
_ => String::new(),
};
self.def(1, &format!("<filter id=\"{fid}\"{region_attr}{mode} color-interpolation-filters=\"sRGB\">{body}</filter>"));
self.line(&format!("<g filter=\"url(#{fid})\">"));
self.depth += 1;
opened += 1;
}
opened
}
fn close_filters(&mut self, opened: usize) {
for _ in 0..opened {
self.depth -= 1;
self.line("</g>");
}
}
/// The `<text>`/`<tspan>` properties of a character style. `space`: the text's layout bounds
/// and the map from text space to user space, which character gradients resolve against
/// (as on the canvas).
fn char_props(&mut self, st: &CharStyle, space: (Rect, Affine)) -> Props {
let decimals = self.opts.decimals;
let len = |v: f64| fmt_num(v, decimals);
let mut p = css::font_props(st, &len);
// Character paints resolve in the text's space (`to_user` maps it into user space).
let (bounds, to_user) = space;
let saved = std::mem::replace(&mut self.xf, to_user);
let fill = self.paint(&st.fill, Some(bounds));
p.push(("fill", fill));
// An inside or outside stroke is drawn on the glyph outlines instead
// ([`Self::aligned_char_strokes`]): SVG strokes text centred only.
if st.has_stroke() && !aligned(st) {
// Weight, cap, join, miter limit and dashes as for object strokes; a gradient spans
// the text grown by half the weight, as on the canvas.
let layer = st.stroke_layer();
self.stroke_props(&layer, layer.width, Some(layer.paint_bounds(bounds)), &mut p);
}
self.xf = saved;
p.extend(css::type_props(st, &len));
p
}
/// Type: its characters (live text, or glyph outlines when text is exported as outlines) and,
/// as the canvas paints them, the object's own fills and strokes on the glyph outlines: those
/// below the Characters row under the characters, the others over them.
///
/// Inline graphics ([`vectorcraft_doc::TextRun::inline`]) are written after the characters as
/// instances of their symbols (a `<use>` of the symbol's def where it can be shared), all in
/// one group carrying the object's transparency. The live text skips their characters and
/// places the text after each one; type on a path with inline graphics is written as outlines
/// (a `<textPath>` couldn't leave room for the art).
fn text_node(&mut self, n: &Node, t: &TextObject) {
let doc = self.doc;
let resolved = doc.inline_resolved(t);
let t = &*resolved;
let inline = t.runs.iter().any(|r| r.inline.is_some());
let outlined = self.opts.outline_text || t.vertical || (inline && matches!(t.kind, TextKind::OnPath { .. }));
let chars = |w: &mut Self, n: &Node| if outlined { w.text_outlines(n, t) } else { w.text(n, t) };
let painted = n.appearance.items.iter().any(|i| i.visible() && !i.paint().is_none());
// Live type with an inside or outside character stroke writes that stroke apart from the
// text, in the group.
let apart = !outlined && t.runs.iter().any(|r| aligned(&r.style));
if !painted && !inline && !apart {
return chars(self, n);
}
let id = self.id_attr(n);
let a = self.attrs(&css::transparency(n));
self.line(&format!("<g{id}{a}>"));
self.depth += 1;
let db = vectorcraft_text::FontDb::global();
let lay = vectorcraft_text::layout(db, t);
let mut all = lay.to_bezpath();
for (_, bar) in vectorcraft_text::decorations(&lay, db, t) {
all.extend(bar.iter());
}
let pd = PathData::from_bezpath(&all).transformed(t.xf);
let d = self.path_d(&pd, self.xf);
let tb = Some(t.xf.transform_rect_bbox(lay.bounds));
let (below, above) = n.appearance.split_contents();
let glyphs = |w: &mut Self, items: &[AppearanceItem]| {
if !items.is_empty() {
let ap = vectorcraft_doc::Appearance { items: items.to_vec(), ..Default::default() };
w.shape(&Node::path(NodeId(u64::MAX), pd.clone(), ap), &d, &[&pd], FillRule::NonZero, tb);
}
};
glyphs(self, below);
// The group carries the id and the object's transparency.
let mut bare = n.clone();
(bare.opacity, bare.blend, bare.isolate) = (1.0, BlendMode::Normal, false);
let anonymous = std::mem::replace(&mut self.anonymous, true);
chars(self, &bare);
for ig in &lay.inlines {
let Some(art) = t.runs.get(ig.run).and_then(|r| r.inline.as_ref()) else { continue };
let inst = Node::new(
NodeId(u64::MAX),
NodeKind::SymbolInstance { symbol: art.symbol.clone(), xf: t.xf * ig.xf * doc.symbol_natural_xf(&art.symbol) },
);
self.node(&inst);
}
glyphs(self, above);
self.anonymous = anonymous;
self.depth -= 1;
self.line("</g>");
}
/// Text as glyph outlines: one compound path per run, painted like the run (gradients span
/// the whole text, as on the canvas).
fn text_outlines(&mut self, n: &Node, t: &TextObject) {
let db = vectorcraft_text::FontDb::global();
let lay = vectorcraft_text::layout(db, t);
let mut runs: Vec<(usize, kurbo::BezPath)> = vec![];
// Underline and strikethrough bars join their run's outlines.
let bars = vectorcraft_text::decorations(&lay, db, t);
for (run, outline) in lay.glyphs.iter().map(|g| (g.run, &g.outline)).chain(bars.iter().map(|(r, b)| (*r, b))) {
match runs.iter_mut().find(|(r, _)| *r == run) {
Some((_, bp)) => bp.extend(outline.iter()),
None => runs.push((run, outline.clone())),
}
}
let id = self.id_attr(n);
let a = self.attrs(&css::transparency(n));
self.line(&format!("<g{id}{a}>"));
self.depth += 1;
// The glyphs stay in text space; the text's transform joins the document's meanwhile.
let saved = self.xf;
self.xf = saved * t.xf;
for (r, bp) in runs {
let Some(run) = t.runs.get(r) else { continue };
let pd = PathData::from_bezpath(&bp);
// An unnamed id: the pieces carry no id attribute (the group has it).
let glyphs = Node::path(NodeId(u64::MAX), pd.clone(), run.style.appearance());
let d = self.path_d(&pd, self.xf);
self.shape(&glyphs, &d, &[&pd], FillRule::NonZero, Some(lay.bounds));
}
self.xf = saved;
self.depth -= 1;
self.line("</g>");
}
/// Point and area type, every laid-out line at its own position: wraps, indents, tabs and
/// spacing come out as on the canvas. Centred and right-aligned lines are anchored
/// (`text-anchor`) at their centre or right end, so they stay aligned in a viewer whose font
/// differs; other lines are placed where each style run (and, justified, each word) starts.
fn text(&mut self, n: &Node, t: &TextObject) {
// Live SVG text is written in logical order from left-aligned pieces: bidirectional text
// (and right-to-left paragraphs) keep their look as outlines until it is written with
// `direction`/`unicode-bidi`.
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
if lay.glyphs.iter().any(|g| g.rtl) || lay.lines.iter().any(|l| l.rtl) {
self.warn("bidirectional text is outlined to preserve shaping and visual order");
return self.text_outlines(n, t);
}
if let TextKind::OnPath { path, start, end } = &t.kind {
// `<textPath>` has a start offset only: an end bracket, Align to Path and Spacing
// keep their look as outlines.
if end.is_some() || t.path_align != vectorcraft_doc::PathAlign::Baseline || t.path_spacing != 0.0 {
self.warn("type on a path with an end bracket, Align to Path or Spacing is outlined to keep its look");
return self.text_outlines(n, t);
}
return self.text_on_path(n, t, path, *start);
}
self.note_fonts(t, &lay);
let lines = text_lines(t, &lay, self.opts.fewer_tspans);
// Each line is anchored as its paragraph is aligned. A tab starts a new chunk at its
// stop, and so does the text after an inline graphic: lines with tabs, and text with
// inline graphics, are never anchored.
let has_inline = t.runs.iter().any(|r| r.inline.is_some());
let anchors: Vec<Option<(&str, f64)>> = lines
.iter()
.map(|l| {
match t.para_at(l.para).justify {
Justify::Center => Some(("middle", 0.5)),
Justify::Right => Some(("end", 1.0)),
_ => None,
}
.filter(|_| !has_inline && !l.segs.iter().any(|s| s.brk))
})
.collect();
// Every line anchored alike (the text's only paragraph style, typically): the <text>
// carries the anchor. Otherwise each anchored line is a <tspan> carrying its own.
let uniform = anchors.first().copied().filter(|a| a.is_some() && anchors.iter().all(|b| b == a)).flatten();
// The <text> element's user space is text space.
let space = (lay.bounds, Affine::IDENTITY);
let base = TextBase { props: self.char_props(&t.first_style(), space), space, anchored: uniform.is_some() };
let mut props = base.props.clone();
if let Some((a, _)) = uniform {
props.push(("text-anchor", a.into()));
}
props.extend(css::transparency(n));
let id = self.id_attr(n);
let a = self.attrs(&props);
let m = self.xf * t.xf;
let tr = if m == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(m)) };
let starts: Vec<Option<(f64, f64)>> = lines.iter().zip(&anchors).map(|(l, a)| line_start(&l.segs, a.map(|a| a.1))).collect();
// 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 at = match starts.iter().flatten().next() {
Some((x, y)) => format!(" x=\"{}\" y=\"{}\"", self.num(*x), self.num(*y)),
None => String::new(),
};
let mut s = format!("<text{id}{tr}{at} xml:space=\"preserve\"{a}>");
for ((line, start), anchor) in lines.iter().zip(starts).zip(&anchors) {
let Some(start) = start else { continue };
match anchor.filter(|_| uniform.is_none()) {
// A line anchored on its own: one positioned <tspan> with the anchor around it.
Some((a, _)) => {
s.push_str(&format!("<tspan x=\"{}\" y=\"{}\" text-anchor=\"{a}\">", self.num(start.0), self.num(start.1)));
let inner = TextBase { anchored: true, ..base.clone() };
self.text_line(&mut s, t, &line.segs, start, &inner, false);
s.push_str("</tspan>");
}
None => self.text_line(&mut s, t, &line.segs, start, &base, true),
}
}
s.push_str("</text>");
self.line(&s);
self.aligned_char_strokes(t);
}
/// The inside and outside character strokes of live type `t`, over its text (which goes
/// without them, see [`Self::char_props`]): each such run's stroke on its glyph outlines, drawn
/// twice as wide and clipped or masked to its side as aligned path strokes are
/// ([`Self::side_attr`]). The text stays live; only the stroke follows the outlines.
fn aligned_char_strokes(&mut self, t: &TextObject) {
if !t.runs.iter().any(|r| aligned(&r.style)) {
return;
}
let db = vectorcraft_text::FontDb::global();
let lay = vectorcraft_text::layout(db, t);
let bars = vectorcraft_text::decorations(&lay, db, t);
// The glyphs stay in text space; the text's transform joins the document's meanwhile.
let saved = self.xf;
self.xf = saved * t.xf;
for (i, run) in t.runs.iter().enumerate().filter(|(_, r)| aligned(&r.style)) {
let mut bp = BezPath::new();
for g in lay.glyphs.iter().filter(|g| g.run == i) {
bp.extend(g.outline.iter());
}
for (_, bar) in bars.iter().filter(|(r, _)| *r == i) {
bp.extend(bar.iter());
}
if bp.elements().is_empty() {
continue;
}
let pd = PathData::from_bezpath(&bp);
let ap = vectorcraft_doc::Appearance { items: vec![AppearanceItem::Stroke(run.style.stroke_layer())], ..Default::default() };
let d = self.path_d(&pd, self.xf);
self.shape(&Node::path(NodeId(u64::MAX), pd.clone(), ap), &d, &[&pd], FillRule::NonZero, Some(lay.bounds));
}
self.xf = saved;
}
/// One line of laid-out text starting at `start`. Default: a positioned `<tspan>` per segment
/// (only the first of an anchored line). Fewer tspans: one positioned `<tspan>` per line with
/// the style changes nested in it, positioned again only after tabs and inline graphics.
/// Baseline shifts are relative (`dy`), undone by the next segment that isn't shifted.
/// `positioned`: false when the caller already positioned the line (an outer `<tspan>`).
fn text_line(&mut self, s: &mut String, t: &TextObject, line: &[Segment], start: (f64, f64), base: &TextBase, positioned: bool) {
let fewer = self.opts.fewer_tspans && positioned;
if fewer {
s.push_str(&format!("<tspan x=\"{}\" y=\"{}\">", self.num(start.0), self.num(start.1)));
}
let mut place = !fewer && positioned;
let mut first = true;
// The baseline shift the current text position carries.
let mut shift = 0.0;
for seg in line {
let Some(st) = t.runs.get(seg.run).map(|r| &r.style) else { continue };
let mut attrs = String::new();
if place {
let (x, y) = if first { start } else { (seg.x, seg.y) };
attrs.push_str(&format!(" x=\"{}\" y=\"{}\"", self.num(x), self.num(y)));
shift = 0.0;
}
first = false;
if st.baseline_shift != shift {
attrs.push_str(&format!(" dy=\"{}\"", self.num(shift - st.baseline_shift)));
shift = st.baseline_shift;
}
// After a tab, an inline graphic or a justified word space, and before a character that a
// reader would draw elsewhere, the next segment is placed.
place = seg.brk || (!fewer && !base.anchored && positioned);
if (st.h_scale - st.v_scale).abs() > 1e-9 || seg.scale != 1.0 {
attrs.push_str(&format!(" textLength=\"{}\" lengthAdjust=\"spacingAndGlyphs\"", self.num(seg.advance * seg.scale)));
}
if st.rotation != 0.0 {
attrs.push_str(&format!(" rotate=\"{}\"", self.num(-st.rotation)));
}
let diff = run_diff(&base.props, self.char_props(st, base.space));
attrs.push_str(&self.attrs(&diff));
let text = xml_text(&seg.text);
if attrs.is_empty() && fewer {
s.push_str(&text);
} else {
s.push_str(&format!("<tspan{attrs}>{text}</tspan>"));
}
}
if fewer {
s.push_str("</tspan>");
}
}
/// Record the characters of laid-out type `t` under the faces that supply them, for
/// [`Self::font_faces`] (when fonts are embedded).
fn note_fonts(&mut self, t: &TextObject, lay: &vectorcraft_text::TextLayout) {
if !self.opts.embed_fonts || self.opts.outline_text {
return;
}
if self.tiny() {
return self.warn("SVG Tiny 1.2 has no web fonts: fonts are not embedded");
}
let db = FontDb::global();
let plain = t.plain_text();
let mut face: Option<Arc<FontFace>> = None;
for g in &lay.glyphs {
let Some(st) = t.runs.get(g.run).map(|r| &r.style) else { continue };
if face.as_ref().is_none_or(|f| f.id() != g.font_id) {
face = db.face_by_id(g.font_id);
}
let Some(f) = &face else { continue };
let (weight, italic) = font_descriptor(st);
let stretch = css::font_stretch(st).map(f64::to_bits);
let at = self
.fonts
.iter()
.position(|u| u.face.id() == f.id() && (u.weight, u.italic, u.stretch) == (weight, italic, stretch) && u.family == st.font_family);
let at = at.unwrap_or_else(|| {
let family = st.font_family.clone();
self.fonts.push(FontUse { family, weight, italic, stretch, face: f.clone(), chars: BTreeSet::new() });
self.fonts.len() - 1
});
let Some(chars) = self.fonts.get_mut(at).map(|u| &mut u.chars) else { continue };
// As written: a hyphen where a line breaks, capitals for All Caps, the style's Digits.
let src = if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") };
for c in src.chars().filter(|c| !c.is_control() && *c != '\u{ad}').map(|c| st.digits.map(c)) {
if st.all_caps {
chars.extend(c.to_uppercase());
} else {
chars.insert(c);
}
}
}
}
/// The `@font-face` rules of the faces type uses ([`ExportOptions::embed_fonts`]): each face
/// subset to its characters (whole when its licence forbids subsetting, left out with a
/// warning when it forbids embedding). Faces sharing a description cover their own
/// characters (`unicode-range`); where two of them share characters as well, a warning says
/// that browsers draw those in one of the faces.
fn font_faces(&mut self) -> Vec<String> {
let uses = std::mem::take(&mut self.fonts);
let mut rules = Vec::new();
fn described(u: &FontUse) -> (&str, u16, bool, Option<u64>) {
(&u.family, u.weight, u.italic, u.stretch)
}
let mut clashing: Vec<&String> = vec![];
for (i, u) in uses.iter().enumerate() {
let clash = uses
.get(..i)
.unwrap_or_default()
.iter()
.any(|o| described(o) == described(u) && o.face.id() != u.face.id() && !o.chars.is_disjoint(&u.chars));
if clash && !clashing.contains(&&u.family) {
clashing.push(&u.family);
}
}
for family in clashing {
self.warn(&format!(
"type in {family} uses faces of one weight, style and width that share characters: browsers draw those characters in one of the embedded faces"
));
}
for u in &uses {
let chars: Vec<char> = u.chars.iter().copied().collect();
let name = format!("{} {}", u.face.family, u.face.style);
let Some(font) = u.face.embed(&chars) else {
self.warn(&format!("the licence of the font {name} doesn't allow embedding: viewers show its type in their own fonts"));
continue;
};
if !font.subset {
self.warn(&format!("the font {name} is embedded whole: its licence doesn't allow subsetting"));
}
let shared = uses.iter().filter(|o| described(o) == described(u)).count() > 1;
let range = if shared { format!(";unicode-range:{}", unicode_range(&u.chars)) } else { String::new() };
let stretch = u.stretch.map(|w| format!(";font-stretch:{}%", fmt_num(f64::from_bits(w), 3))).unwrap_or_default();
let (mime, format) = if font.cff { ("font/otf", "opentype") } else { ("font/ttf", "truetype") };
rules.push(format!(
"@font-face{{font-family:{};font-weight:{};font-style:{}{stretch};src:url(data:{mime};base64,{}) format(\"{format}\"){range}}}",
css_string(&u.family),
u.weight,
if u.italic { "italic" } else { "normal" },
base64_encode(&font.data)
));
}
rules
}
/// Type on a path: a `<textPath>` along the path (a def).
fn text_on_path(&mut self, n: &Node, t: &TextObject, path: &PathData, start: f64) {
let id = self.id_attr(n);
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}\"/>"));
// The <text> element's user space is the document's; gradients span the laid-out text.
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
self.note_fonts(t, &lay);
let space = (lay.bounds, self.xf * t.xf);
let base = self.char_props(&t.first_style(), space);
let mut props = base.clone();
match t.para_at(0).justify {
Justify::Center | Justify::JustifyCenter => props.push(("text-anchor", "middle".into())),
Justify::Right | Justify::JustifyRight => props.push(("text-anchor", "end".into())),
_ => {}
}
props.extend(css::transparency(n));
let a = self.attrs(&props);
let offset = fmt_num(start * 100.0, 3);
let mut s = format!("<text{id} xml:space=\"preserve\"{a}><textPath xlink:href=\"#{pid}\" startOffset=\"{offset}%\">");
for run in &t.runs {
let diff = run_diff(&base, self.char_props(&run.style, space));
let a = self.attrs(&diff);
s.push_str(&format!("<tspan{a}>{}</tspan>", xml_text(&run.text.replace('\n', " "))));
}
s.push_str("</textPath></text>");
self.line(&s);
self.aligned_char_strokes(t);
}
}
/// Does a character style stroke inside or outside its glyphs (not centred)?
fn aligned(st: &CharStyle) -> bool {
st.has_stroke() && st.stroke_align != StrokeAlign::Center
}
/// What the `<tspan>` of a run whose properties are `run` sets over its `<text>`'s `base`: the
/// properties that differ, and the initial value of each one `base` sets and the run doesn't.
fn run_diff(base: &Props, run: Props) -> Props {
let reset: Props =
base.iter().filter(|(k, _)| !run.iter().any(|(r, _)| r == k)).filter_map(|(k, _)| initial_value(k).map(|v| (*k, v.to_string()))).collect();
run.into_iter().filter(|kv| !base.contains(kv)).chain(reset).collect()
}
/// The initial value of an inherited property a run may leave unset (decorations can't be undone
/// on a `<tspan>`).
fn initial_value(k: &str) -> Option<&'static str> {
Some(match k {
"font-weight" | "font-style" | "font-stretch" | "font-feature-settings" | "font-variation-settings" => "normal",
"stroke" | "stroke-dasharray" => "none",
"stroke-width" | "stroke-opacity" => "1",
"stroke-linecap" => "butt",
"stroke-linejoin" => "miter",
"stroke-miterlimit" => "4",
"stroke-dashoffset" | "letter-spacing" => "0",
"font-kerning" => "auto",
_ => return None,
})
}
/// A shape's paths as one path (document space).
fn doc_path(paths: &[&PathData]) -> BezPath {
let mut bp = BezPath::new();
for p in paths {
bp.extend(p.to_bezpath());
}
bp
}
/// How closely stroke outlines that clip an image follow the stroke (points).
const OUTLINE_TOLERANCE: f64 = 0.01;
/// The freeform gradient a paint is, if it is one (SVG has no freeform gradients).
fn freeform(p: &Paint) -> Option<&GradientPaint> {
match p {
Paint::Gradient(g) if g.gradient.kind == GradientKind::Freeform => Some(g),
_ => None,
}
}
/// Any visible raster effect (shadow, glow, blur, feather) in `effects`?
fn has_raster(effects: &[vectorcraft_doc::Effect]) -> bool {
effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id))
}
/// What the lines of one `<text>` share.
#[derive(Clone)]
struct TextBase {
/// The `<text>` element's character properties, which each `<tspan>` differs from.
props: Props,
/// The text's layout bounds and the map from text space to user space (character paints).
space: (Rect, Affine),
/// Lines anchored at their centre or right end (`text-anchor`), positioned once each.
anchored: bool,
}
/// One laid-out line: its paragraph (index) and its segments.
struct Line {
para: usize,
segs: Vec<Segment>,
}
/// Characters of one style on one line, written as one `<tspan>`.
struct Segment {
/// Index into `TextObject::runs`.
run: usize,
/// Where a reader starts drawing the segment (text space; `y` is the baseline).
x: f64,
y: f64,
text: String,
/// The width a reader gives the segment (tracking included). On a line that is justified or
/// set with Mojikumi (unless `fewer`), that is the width without justification and Mojikumi
/// ([`reader_advances`]), for horizontally scaled type also without its last character's
/// kerning against the character after it ([`end_segment`]); elsewhere, the laid-out width.
advance: f64,
/// The next segment needs its own position (after a tab, a word space of a justified line, or
/// the next segment starts with a character the layout draws elsewhere than a reader would).
brk: bool,
/// Glyph Scaling of the segment's one glyph (Justification…), which a reader stretches it to
/// (`textLength`); 1 for other segments.
scale: f64,
}
/// How far a reader may draw a character from where the layout draws it before the character is
/// given its own position (points).
const PLACE_TOLERANCE: f64 = 1e-6;
/// The laid-out lines of `t` as segments: a new segment at every style change, after tabs and,
/// on justified lines, after each word space (unless `fewer`). The text comes from the clusters
/// the layout placed: soft hyphens draw nothing, a line broken by hyphenation ends in `-`, all
/// caps are upper case. Unless `fewer`, a line that is justified or composed with Mojikumi also
/// starts a new segment at each character the layout draws elsewhere than a reader continuing
/// the segment would ([`reader_advances`]): a Justify All line's letter gaps, the gaps between
/// the characters of a justified Japanese line, and the spacing Mojikumi changes.
fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -> Vec<Line> {
let plain = t.plain_text();
let mut lines = Vec::with_capacity(lay.lines.len());
let (mut kerned, mut unkerned) = (None, None);
for line in &lay.lines {
let para = plain.as_bytes().get(..line.start).map_or(0, |b| b.iter().filter(|&&c| c == b'\n').count());
let style = t.para_at(para);
let split_words = !fewer && !matches!(style.justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right);
// Each cluster's advance and drawing offset as a reader gives them, by its byte offset.
// Justification…'s Desired word and letter spacing and glyph scaling move characters from
// where a reader draws them, on every line.
let spaced = !fewer && !style.justification.desired_is_default();
let reader = if split_words || spaced || (!fewer && !style.mojikumi.is_none()) {
Some(&*kerned.get_or_insert_with(|| reader_advances(t, lay, true)))
} else {
None
};
// Where a reader draws the next character of the last segment, and the byte offset and
// reader's advance of the last segment's last cluster.
let mut pen = 0.0;
let mut tail = None;
let mut segs: Vec<Segment> = Vec::new();
let mut cluster = None;
let source = |g: &vectorcraft_text::PositionedGlyph| if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") };
let mut glyphs = lay.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or_default();
// Spaces where the line wrapped (it doesn't end its paragraph) don't belong to the line.
if plain.get(line.end..).is_some_and(|rest| !rest.is_empty() && !rest.starts_with('\n')) {
while let Some((_, rest)) = glyphs.split_last().filter(|(g, _)| source(g).chars().all(|c| c.is_whitespace() && c != '\t')) {
glyphs = rest;
}
}
let mut after_inline = false;
for g in glyphs {
let Some(run) = t.runs.get(g.run) else { continue };
if run.inline.is_some() {
// An inline graphic (written as art): the text after it is placed anew.
if let Some(last) = segs.last_mut() {
last.brk = true;
}
after_inline = true;
cluster = None;
continue;
}
// The cluster as a reader sets it (a hyphen the layout adds is set as laid out).
let set = reader.and_then(|r| r.get(&g.byte)).filter(|_| g.len > 0);
if g.len > 0 && cluster == Some(g.byte) {
// Another glyph of the same cluster (a reader's advance covers the whole cluster).
if let Some(last) = segs.last_mut().filter(|_| set.is_none()) {
last.advance += g.advance;
pen += g.advance;
}
continue;
}
cluster = Some(g.byte);
let (advance, x) = match set {
Some(&(advance, offset)) => {
// A glyph the layout draws before its pen (Mojikumi) starts that much earlier.
let x = g.xf.translation().x - offset;
(advance, if (x - g.origin.x).abs() > PLACE_TOLERANCE { x } else { g.origin.x })
}
None => (g.advance, g.origin.x),
};
let src: String = source(g).chars().filter(|c| *c != '\u{ad}').map(|c| run.style.digits.map(c)).collect();
let piece = if run.style.all_caps { src.to_uppercase() } else { src };
let brk = piece == "\t" || (split_words && !piece.is_empty() && piece.chars().all(char::is_whitespace));
// A glyph Glyph Scaling widens or narrows is a segment of its own, stretched to its width.
let scale = if reader.is_some() && (g.scale - 1.0).abs() > 1e-9 && g.scale.is_finite() { g.scale } else { 1.0 };
let fresh = std::mem::take(&mut after_inline) || scale != 1.0 || segs.last().is_some_and(|last| last.scale != 1.0);
// The layout draws the character elsewhere than a reader continuing the line would.
let moved = reader.is_some()
&& segs.last().is_some_and(|last| (x - pen).abs() > PLACE_TOLERANCE || (g.origin.y - last.y).abs() > PLACE_TOLERANCE);
pen = x + advance;
match segs.last_mut() {
Some(last) if last.run == g.run && !last.brk && !fresh && !moved => {
last.text.push_str(&piece);
last.advance += advance;
last.brk = brk;
}
last => {
if let Some(last) = last {
last.brk |= moved;
end_segment(last, tail, t, lay, &mut unkerned);
}
segs.push(Segment { run: g.run, x, y: g.origin.y, text: piece, advance, brk, scale });
}
}
tail = set.map(|_| (g.byte, advance));
}
if let Some(last) = segs.last_mut() {
end_segment(last, tail, t, lay, &mut unkerned);
}
segs.retain(|s| !s.text.is_empty());
if !segs.is_empty() {
lines.push(Line { para, segs });
}
}
lines
}
/// Ends segment `seg` of horizontally scaled type (or a glyph Glyph Scaling scales), which a reader stretches to its width
/// (`textLength`): its last cluster (`tail`: byte offset and reader's advance) takes the advance
/// a reader gives it at the end of a `<tspan>`, without the font's kerning against the character
/// after it. `unkerned` holds [`reader_advances`] without kerning once a segment needs them.
fn end_segment(
seg: &mut Segment,
tail: Option<(usize, f64)>,
t: &TextObject,
lay: &vectorcraft_text::TextLayout,
unkerned: &mut Option<HashMap<usize, (f64, f64)>>,
) {
let Some((byte, advance)) = tail else { return };
if seg.scale == 1.0 && t.runs.get(seg.run).is_none_or(|r| (r.style.h_scale - r.style.v_scale).abs() <= 1e-9) {
return;
}
if let Some(&(alone, _)) = unkerned.get_or_insert_with(|| reader_advances(t, lay, false)).get(&byte) {
seg.advance += alone - advance;
}
}
/// The advance a reader of live SVG text gives each character cluster of `t` and how far from
/// its pen it draws the cluster's first glyph, keyed by the cluster's byte offset: `t` laid out
/// as point type (which isn't justified) without Mojikumi, scaled as `t`'s layout `lay` is by
/// Shrink Text to Fit. Without `kerning`, the font's pair kerning is left out (as manual
/// kerning leaves it out).
fn reader_advances(t: &TextObject, lay: &vectorcraft_text::TextLayout, kerning: bool) -> HashMap<usize, (f64, f64)> {
let mut set = TextObject { kind: TextKind::Point, ..t.clone() };
for p in std::iter::once(&mut set.para).chain(set.paras.iter_mut()) {
p.mojikumi = vectorcraft_doc::Mojikumi::None;
// A reader spaces words and letters as the font does.
p.justification = vectorcraft_doc::JustificationOptions::default();
}
if !kerning {
for r in &mut set.runs {
r.style.kerning.get_or_insert(0.0);
}
}
let mut out: HashMap<usize, (f64, f64)> = HashMap::new();
for g in vectorcraft_text::layout(FontDb::global(), &set).glyphs.iter().filter(|g| g.len > 0) {
let (advance, _) = out.entry(g.byte).or_insert((0.0, (g.xf.translation().x - g.origin.x) * lay.fit_scale));
*advance += g.advance * lay.fit_scale;
}
out
}
/// Where a line of segments starts: where its first segment starts or, `anchor`ed (0.5: center, 1:
/// right end), that point of its width. `None` for an empty line.
fn line_start(line: &[Segment], anchor: Option<f64>) -> Option<(f64, f64)> {
let (first, last) = (line.first()?, line.last()?);
let x = match anchor {
Some(f) => first.x + f * (last.x + last.advance - first.x),
None => first.x,
};
Some((x, first.y))
}
/// Does `n` paint opaquely wherever it paints (no transparency, blending, masks or effects in
/// it)? Anything this can't tell is taken as not.
fn paints_opaque(n: &Node) -> bool {
use vectorcraft_doc::{AppearanceItem, NodeKind};
let paint = |p: &Paint| match p {
Paint::None | Paint::Solid { .. } => true,
Paint::Gradient(g) => g.gradient.stops.iter().all(|s| s.opacity >= 1.0),
_ => false,
};
let items = n.appearance.items.iter().all(|i| match i {
AppearanceItem::Fill(f) => !f.visible || (f.opacity >= 1.0 && f.blend == BlendMode::Normal && f.effects.is_empty() && paint(&f.paint)),
// Brush art draws as pieces with opacities of their own (bristles vary theirs).
AppearanceItem::Stroke(s) => {
!s.visible || (s.opacity >= 1.0 && s.blend == BlendMode::Normal && s.effects.is_empty() && s.brush.is_none() && paint(&s.paint))
}
});
let own = n.opacity >= 1.0 && n.blend == BlendMode::Normal && n.extras().mask.is_none() && n.appearance.effects.is_empty() && items;
own && match &n.kind {
NodeKind::Path { .. } => true,
NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => children.iter().all(|c| paints_opaque(c)),
NodeKind::Mesh(m) => m.points.iter().all(|p| p.opacity >= 1.0),
_ => false,
}
}