refactor(svg): move export.rs items to export/{nodes,output,paint,text}.rs

This commit is contained in:
aiongg
2026-10-26 21:45:32 +00:00
parent 6c562d5f41
commit 7e52760174
5 changed files with 2447 additions and 2406 deletions
+11 -2406
View File
File diff suppressed because it is too large Load Diff
+705
View File
@@ -0,0 +1,705 @@
use super::output::{sanitize_id, valid_xml_value, xml_attr_escape};
use super::paint::{StrokePlan, has_raster, pixel_gradient};
use super::text::OUTLINE_TOLERANCE;
use super::*;
/// A symbol's `<symbol>` def and the instance transforms that can `<use>` it.
pub(super) 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 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);
}
}
/// 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.
pub(super) 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";
impl Writer<'_> {
/// 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).
pub(super) 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| pixel_gradient(i.paint(), matches!(i, AppearanceItem::Fill(_))).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)) = (pixel_gradient(&f.paint, true), 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, "", true);
} 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, pixel_gradient(&s.paint, false).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, false);
}
(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, false);
}
(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>");
}
/// 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.
pub(super) 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).
pub(super) 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;
}
pub(super) 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 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)
}
}
/// A shape's paths as one path (document space).
pub(super) fn doc_path(paths: &[&PathData]) -> BezPath {
let mut bp = BezPath::new();
for p in paths {
bp.extend(p.to_bezpath());
}
bp
}
/// 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,
}
}
+422
View File
@@ -0,0 +1,422 @@
use super::*;
/// XML 1.0 literal attribute whitespace is normalized to spaces; references retain the value.
pub(super) 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.
pub(super) 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)
}
pub(super) 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}'))
}
/// An XML name without a colon (an NCName): a letter or `_`, then [`xml_name_char`]s.
fn xml_ncname(s: &str) -> bool {
let mut chars = s.chars();
chars.next().is_some_and(|c| c.is_alphabetic() || c == '_') && chars.all(xml_name_char)
}
/// 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.
pub(super) 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;
}
/// 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.
pub(super) 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
}
/// 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
}
/// 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?
pub(super) fn tiny(&self) -> bool {
self.opts.profile == Profile::Tiny12
}
pub(super) fn num(&self, v: f64) -> String {
fmt_num(v, self.opts.decimals)
}
pub(super) fn indent(&self, depth: usize) -> String {
if self.opts.minify { String::new() } else { " ".repeat(depth) }
}
pub(super) 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');
}
}
pub(super) 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');
}
}
pub(super) 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`…
pub(super) fn unique_id(&mut self, base: &str) -> String {
let base = format!("{}{base}", self.id_prefix);
if self.used_ids.insert(base.clone()) {
return base;
}
let next = self.next_suffix.entry(base.clone()).or_insert(2);
loop {
let cand = format!("{base}-{next}");
*next += 1;
if self.used_ids.insert(cand.clone()) {
return cand;
}
}
}
/// Reserve ids for every named object up front so generated def ids never collide with them.
pub(super) 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.
pub(super) 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.foreign_attrs(n, &mut out);
self.event_attrs(n, &mut out);
out
}
/// `n`'s attributes in other applications' namespaces (`plotter:pen="2"`), added to `out`.
/// Each namespace is declared once, on the root, under the prefix the file gave it unless the
/// export uses that prefix itself or for another namespace ([`Writer::foreign_prefix`]).
fn foreign_attrs(&mut self, n: &Node, out: &mut String) {
let attrs = n.extras().attrs.as_deref().map_or(&[][..], |a| a.foreign.as_slice());
let mut seen = HashSet::new();
for a in attrs {
if !xml_ncname(&a.prefix) || !xml_ncname(&a.name) || a.ns.is_empty() || !valid_xml_value(&a.ns) || !valid_xml_value(&a.value) {
self.warn("Attributes of other applications with invalid XML names or characters were left out of SVG");
continue;
}
// Native data is a list: one attribute per namespace and name.
if !seen.insert((a.ns.as_str(), a.name.as_str())) {
self.warn("Repeated attributes of other applications were written only once in SVG");
continue;
}
let prefix = self.foreign_prefix(&a.ns, &a.prefix);
self.non_latin1_names |= prefix.chars().chain(a.name.chars()).any(|c| u32::from(c) > 0xff);
out.push_str(&format!(" {prefix}:{}=\"{}\"", a.name, xml_attr_escape(&a.value)));
}
}
/// The prefix namespace `ns` is written under: `wanted` (the file's), else `wanted2`,
/// `wanted3`… when the export's own prefixes or another namespace already have it. XML keeps
/// prefixes starting with `xml` for itself: those become `ns`, `ns2`…
fn foreign_prefix(&mut self, ns: &str, wanted: &str) -> String {
if let Some(p) = self.foreign_ns.get(ns) {
return p.clone();
}
const OURS: [&str; 5] = ["svg", "xlink", "rdf", "dc", "vectorcraft"];
let base = if wanted.to_ascii_lowercase().starts_with("xml") { "ns" } else { wanted };
let taken = |p: &str| OURS.contains(&p) || self.foreign_ns.values().any(|q| q == p);
let mut prefix = base.to_string();
let mut k = 2;
while taken(&prefix) {
prefix = format!("{base}{k}");
k += 1;
}
self.foreign_ns.insert(ns.to_string(), prefix.clone());
prefix
}
/// 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)));
}
}
pub(super) 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.
pub(super) 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.
pub(super) 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>");
}
pub(super) 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
}
pub(super) fn warn(&mut self, w: &str) {
if !self.warnings.iter().any(|x| x == w) {
self.warnings.push(w.to_string());
}
}
}
+503
View File
@@ -0,0 +1,503 @@
use super::nodes::{MASK, doc_path};
use super::output::sanitize_id;
use super::*;
/// 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"),
}
}
/// How one stroke is written.
pub(super) enum StrokePlan {
/// Its brush art (document space).
Brush(Vec<Node>),
Written(Written),
}
impl Writer<'_> {
/// The `href` of image `im`: its file with [`ImageMode::Link`] (or when its pixels are
/// missing), else its bytes. `None` when it has neither.
pub(super) 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.image_blob(im)) {
(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>`.
pub(super) fn share_images(&mut self, art: &Node) {
// Each image's count and its first piece, which shows what they all show.
let mut seen: HashMap<(String, u32, u32), (usize, vectorcraft_doc::ImageObject)> = HashMap::new();
art.walk(&mut |c| {
if let NodeKind::Image(im) = &c.kind {
seen.entry((im.key.clone(), im.width, im.height)).or_insert_with(|| (0, (**im).clone())).0 += 1;
}
});
let mut repeated: Vec<_> = seen.into_iter().filter(|(k, (n, _))| *n > 1 && !self.shared_images.contains_key(k)).collect();
repeated.sort_by(|a, b| a.0.cmp(&b.0));
for ((key, width, height), (_, im)) in repeated {
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);
}
}
/// A fill or stroke value. Gradients resolve unset geometry against `bounds` (the space
/// [`Self::xf`] maps into user space, as the renderer does).
pub(super) 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
}
pub(super) 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()));
}
}
pub(super) 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.
pub(super) 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.
pub(super) 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})\"")
}
}
}
/// 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).
pub(super) 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_names.insert(name.clone()) {
self.linked.push(LinkedImage { name: name.clone(), bytes: b.bytes.clone() });
}
name
}
/// A gradient `g` painted over `bounds` (the space [`Self::xf`] maps into user space) that is
/// drawn as pixels ([`GradientPaint::draws_as_pixels`]) when it paints a fill (`fill`) or a
/// stroke: an `<image>` over its painted box, clipped to `clip` (outlines in user space filled
/// by `rule`), in a group with `props` and the `side` attribute. A freeform gradient's colour
/// field, which SVG can't express, is sampled as the canvas samples it (at twice the box's size
/// in points); a linear or radial gradient at the document's raster effects resolution (at most
/// [`MAX_GRADIENT_PX`] a side), with dither's noise on a fill. Nothing is written for an empty
/// box.
#[allow(clippy::too_many_arguments)]
pub(super) fn raster_paint(&mut self, g: &GradientPaint, bounds: Rect, clip: &[String], rule: FillRule, props: &Props, side: &str, fill: bool) {
use vectorcraft_color::freeform::{grid_size, painted_box, spread_scale};
let Some(b) = painted_box(bounds) else { return };
let freeform = g.gradient.kind == GradientKind::Freeform;
let (rgba, cols, rows): (Vec<u8>, u16, u16) = if freeform {
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;
(field.grid(b, cols, rows).flat_map(|([r, g, bl], a)| [q(r), q(g), q(bl), q(a)]).collect(), cols, rows)
} else {
let k = self.doc.raster_effects_ppi / 72.0;
let along = |len: f64| (len * k).ceil().clamp(1.0, MAX_GRADIENT_PX) as u16;
let (cols, rows) = (along(b.width()), along(b.height()));
(g.pixels(b, b, cols, rows, fill, &Color::to_rgb).flatten().collect(), cols, rows)
};
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(if freeform {
"freeform gradients are written as images clipped to their shapes"
} else {
"linear and radial gradients drawn as pixels 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>");
}
}
/// 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).
pub(super) 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.
pub(super) 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
}
pub(super) fn close_filters(&mut self, opened: usize) {
for _ in 0..opened {
self.depth -= 1;
self.line("</g>");
}
}
}
/// Most pixels along a side of a linear or radial gradient's image.
const MAX_GRADIENT_PX: f64 = 4096.0;
/// The gradient a fill (`fill`) or stroke paint is, if it is drawn as pixels
/// ([`GradientPaint::draws_as_pixels`]; SVG has no freeform gradients, for one).
pub(super) fn pixel_gradient(p: &Paint, fill: bool) -> Option<&GradientPaint> {
match p {
Paint::Gradient(g) if g.draws_as_pixels(fill) => Some(g),
_ => None,
}
}
/// Any visible raster effect (shadow, glow, blur, feather) in `effects`?
pub(super) fn has_raster(effects: &[vectorcraft_doc::Effect]) -> bool {
effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id))
}
+806
View File
@@ -0,0 +1,806 @@
use super::output::xml_text;
use super::*;
/// `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(",")
}
/// 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.
pub(super) 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>,
}
impl Writer<'_> {
/// 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);
if let Paint::Gradient(g) = &st.fill
&& g.gradient.dither
&& g.gradient.kind != GradientKind::Freeform
{
self.warn("dithered gradients on characters are written without dither");
}
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).
pub(super) 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());
// Ruby is written as outlines with its base text: a reader's <text> can't place it. So are
// small capitals, superscript and subscript and tab leaders, which SVG text doesn't draw (#3869).
let unwritable = t.runs.iter().any(|r| r.style.small_caps.is_some() || r.style.position != vectorcraft_doc::CharPosition::Normal)
|| std::iter::once(&t.para).chain(&t.paras).any(|p| p.tabs.iter().any(|tab| !tab.leader.is_empty()));
let outlined =
self.opts.outline_text || t.vertical || !t.ruby.is_empty() || (inline && matches!(t.kind, TextKind::OnPath { .. })) || unwritable;
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), over its colour glyphs, 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![kurbo::BezPath::new(); t.runs.len()];
// Underline and strikethrough bars join their run's outlines; ruby glyphs are painted
// with the run of the text they annotate.
let bars = vectorcraft_text::decorations(&lay, db, t);
for (run, outline) in lay
.glyphs
.iter()
.map(|g| (g.run, &g.outline))
.chain(lay.ruby.iter().map(|g| (g.run, &g.outline)))
.chain(bars.iter().map(|(r, b)| (*r, b)))
{
if let Some(bp) = runs.get_mut(run) {
bp.extend(outline.iter());
}
}
let id = self.id_attr(n);
let a = self.attrs(&css::transparency(n));
// Glyphs bent along a path (joined scripts on a curve) can only be outlines: the group
// names its text for screen readers and search.
let label = if lay.glyphs.iter().any(|g| g.bend.is_some()) {
format!(" role=\"img\" aria-label=\"{}\"", xml_escape(&t.plain_text()))
} else {
String::new()
};
self.line(&format!("<g{id}{a}{label}>"));
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 (i, (bp, run)) in runs.iter().zip(&t.runs).enumerate() {
// Colour glyphs paint themselves, under their run's paint: bitmap glyphs (emoji) as
// their images, COLR and SVG glyphs as their art.
for g in lay.glyphs.iter().filter(|g| g.run == i) {
if let Some((image, to_text)) = g.placed_image() {
let m = self.matrix(self.xf * to_text);
let href = self.blob_href(&image.blob);
self.line(&format!(
"<image width=\"{}\" height=\"{}\" transform=\"{m}\" preserveAspectRatio=\"none\" xlink:href=\"{}\"/>",
image.width,
image.height,
xml_escape(&href)
));
}
if let Some(art) = g.placed_colour(Affine::IDENTITY) {
self.node(&art);
}
}
if bp.elements().is_empty() {
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`. So do runs set Right to Left (Character Direction), whose
// `direction="rtl"` would anchor their pieces at the right end.
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let rtl_runs = t.runs.iter().any(|r| r.style.char_direction == Some(vectorcraft_doc::ParaDirection::RightToLeft));
if lay.glyphs.iter().any(|g| g.rtl) || lay.lines.iter().any(|l| l.rtl) || rtl_runs {
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 {
// A reader lays a `<textPath>`'s glyphs straight: joined glyphs bent along the path
// keep their look as outlines.
if lay.glyphs.iter().any(|g| g.bend.is_some()) {
self.warn("joined type bent along a path is outlined to keep its look");
return self.text_outlines(n, t);
}
// `<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);
}
// It cannot hang glyphs outside the start bracket either.
if hangs_on_path(t, &lay) {
self.warn("type on a path with glyphs hanging outside its brackets is outlined to keep its look");
return self.text_outlines(n, t);
}
// A `<textPath>` holds the characters of the text, not the bullets or numbers drawn
// before them.
if !lay.markers.is_empty() {
self.warn("type on a path with bullets or numbers 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);
// `drawn` holds the styles the layout draws the text in: Shrink Text to Fit scales its sizes, explicit leading and baseline shifts.
let fitted = (lay.fit_scale != 1.0).then(|| vectorcraft_text::scaled(t, lay.fit_scale));
let drawn = fitted.as_ref().unwrap_or(t);
// 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 mut base_props = self.char_props(&drawn.first_style(), space);
// A `<tspan>` cannot undo the decoration of the `<text>` around it: with bullets, which
// have none, each run's `<tspan>` carries its own.
if !lay.markers.is_empty() {
base_props.retain(|(k, _)| *k != "text-decoration");
}
let base = TextBase { props: base_props, 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, drawn, &line.segs, start, &inner, false);
s.push_str("</tspan>");
}
None => self.text_line(&mut s, drawn, &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(run_style) = t.runs.get(seg.run).map(|r| &r.style) else { continue };
let marker_style;
let st = if seg.marker {
marker_style = vectorcraft_doc::text::ParaList::marker_style(run_style);
&marker_style
} else {
run_style
};
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 mut diff = run_diff(&base.props, self.char_props(st, base.space));
diff.extend(direction_props(st));
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 (i, g) in lay.glyphs.iter().enumerate() {
let Some(st) = t.runs.get(g.run).map(|r| &r.style) else { continue };
// A colour font's subset would hold no bitmaps or COLR layers: its emoji are left to the
// viewer.
if g.image.is_some() || g.colour.is_some() {
self.warn("colour emoji fonts are not embedded: viewers show emoji in their own fonts");
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 list marker's characters, a hyphen where a line breaks, capitals for All
// Caps, the style's Digits.
let src = lay.marker_text(i).unwrap_or_else(|| 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.
pub(super) 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 mut diff = run_diff(&base, self.char_props(&run.style, space));
diff.extend(direction_props(&run.style));
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
}
/// Does type on a path `t`, laid out as `lay`, hang glyphs outside its brackets? It does when
/// Optical Margin Alignment or its first paragraph's Roman Hanging Punctuation is on and its
/// glyphs sit elsewhere with both off.
fn hangs_on_path(t: &TextObject, lay: &vectorcraft_text::TextLayout) -> bool {
if !t.optical_margin && !t.para_at(0).roman_hanging {
return false;
}
let mut flush = t.clone();
flush.optical_margin = false;
let mut styles = flush.paragraph_styles();
for p in &mut styles {
p.roman_hanging = false;
}
flush.set_paragraph_styles(styles);
let flush = vectorcraft_text::layout(FontDb::global(), &flush);
lay.glyphs.len() != flush.glyphs.len() || lay.glyphs.iter().zip(&flush.glyphs).any(|(a, b)| a.origin != b.origin)
}
/// The `direction` and `unicode-bidi` of a `<tspan>` of characters in style `st`: an embedding in
/// its Character Direction, and nothing without one. Each `<tspan>` of characters with a direction
/// carries them, and the `<text>` does not. Type with a run set Right to Left is written as
/// outlines ([`Writer::text`]), so live text writes `direction="ltr"` only.
fn direction_props(st: &CharStyle) -> Props {
let Some(d) = st.char_direction else { return Props::new() };
let direction = if d == vectorcraft_doc::ParaDirection::RightToLeft { "rtl" } else { "ltr" };
vec![("direction", direction.into()), ("unicode-bidi", "embed".into())]
}
/// 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,
})
}
/// How closely stroke outlines that clip an image follow the stroke (points).
pub(super) const OUTLINE_TOLERANCE: f64 = 0.01;
/// 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,
/// A list marker's characters and tab, written in
/// [`vectorcraft_doc::text::ParaList::marker_style`] of the run's style.
marker: bool,
}
/// 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;
// A list marker's glyphs write its characters and then a tab (glyph `i` of the layout).
let source = |i: usize, g: &vectorcraft_text::PositionedGlyph| {
lay.marker_text(i).unwrap_or_else(|| 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, rest)| source(line.glyph_start + rest.len(), g).chars().all(|c| c.is_whitespace() && c != '\t'))
{
glyphs = rest;
}
}
let mut after_inline = false;
for (k, g) in glyphs.iter().enumerate() {
let Some(run) = t.runs.get(g.run) else { continue };
// A list marker before an inline graphic is in the graphic's run, and is text.
if run.inline.is_some() && lay.marker_of(line.glyph_start + k).is_none() {
// 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;
}
// A glyph that stands for no character (a list marker before the paragraph's first
// character) starts no cluster.
cluster = (g.len > 0).then_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(line.glyph_start + k, 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;
// A list marker is a segment of its own, and so is the text after it. In horizontally
// scaled type a tab is a segment apart from the characters before it, so a reader
// stretching those characters to their width (`textLength`) stretches them alone.
let marker = lay.marker_of(line.glyph_start + k).is_some();
let tab_apart = piece == "\t" && (run.style.h_scale - run.style.v_scale).abs() > 1e-9;
match segs.last_mut() {
Some(last) if last.run == g.run && !last.brk && !fresh && !moved && last.marker == marker && !tab_apart => {
last.text.push_str(&piece);
last.advance += advance;
last.brk = brk;
}
last => {
if let Some(last) = last {
last.brk |= moved || last.marker != marker;
end_segment(last, tail, t, lay, &mut unkerned);
}
segs.push(Segment { run: g.run, x, y: g.origin.y, text: piece, advance, brk, scale, marker });
}
}
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))
}