// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Xml;
using System.Xml.Linq;
using SixLabors.Fonts.Rendering;
namespace SixLabors.Fonts.Tables.General.Svg {
///
/// Supplies painted glyphs (layers + commands + paints) and canvas metadata for OT-SVG glyphs.
/// Geometry coordinates are kept in SVG user space; all transforms are carried as matrices
/// on the canvas (root) and on each layer. No point-transforming is performed here.
///
internal sealed class SvgGlyphSource : IPaintedGlyphSource
{
private static readonly SolidPaint DefaultBlackFillPaint = new() { Color = GlyphColor.Black };
private readonly SvgTable svgTable;
private readonly ConcurrentDictionary<(int Start, int Length), ParsedDoc> docCache = [];
private readonly ConcurrentDictionary cachedGlyphs = [];
///
/// Initializes a new instance of the class.
///
/// The SVG table.
public SvgGlyphSource(SvgTable svgTable) => this.svgTable = svgTable;
///
public bool TryGetPaintedGlyph(ushort glyphId, out PaintedGlyph glyph, out PaintedCanvasMetadata canvas)
{
(PaintedGlyph Glyph, PaintedCanvasMetadata Canvas) result = this.cachedGlyphs.GetOrAdd(glyphId, gid =>
{
if (this.TryGetParsedDoc(gid, out ParsedDoc? parsed))
{
XElement? root = parsed.Doc.Root;
if (root is not null)
{
FontRectangle viewBox = GetViewBox(root);
Matrix3x2 rootTransform = ParseTransform(root.Attribute("transform")?.Value);
// Prefer a dedicated group with id="glyph{gid}", else fall back to the root.
string wantedId = "glyph" + gid.ToString(CultureInfo.InvariantCulture);
XElement glyphRoot = parsed.IdMap.TryGetValue(wantedId, out XElement? ge) ? ge : root;
List layers = [];
Walk(
glyphRoot,
rootTransform,
inheritedPaint: DefaultBlackFillPaint,
inheritedOpacityMul: 1F,
outputLayers: layers,
parsedDoc: parsed);
if (layers.Count > 0)
{
PaintedGlyph glyph = new(layers);
PaintedCanvasMetadata canvas = new(viewBox, true, rootTransform);
return (glyph, canvas);
}
}
}
return (default, default);
});
glyph = result.Glyph;
canvas = result.Canvas;
return result.Glyph.Layers.Count > 0;
}
private bool TryGetParsedDoc(ushort glyphId, [NotNullWhen(true)] out ParsedDoc? parsed)
{
parsed = default;
if (!this.svgTable.TryGetDocumentSpan(glyphId, out int start, out int length))
{
return false;
}
(int Start, int Length) docKey = (start, length);
if (this.docCache.TryGetValue(docKey, out parsed))
{
return true;
}
if (!this.svgTable.TryOpenDecodedDocumentStream(glyphId, out Stream stream))
{
return false;
}
using (stream)
{
XDocument doc = LoadXml(stream);
if (doc.Root is null)
{
return false;
}
// TODO: How large is this likely to get? If large, consider a more memory-efficient structure.
Dictionary idMap = new(1024, StringComparer.Ordinal);
foreach (XElement e in doc.Root.DescendantsAndSelf())
{
XAttribute? id = e.Attribute("id");
if (id is not null)
{
idMap[id.Value] = e; // last-wins
}
}
parsed = new ParsedDoc
{
Doc = doc,
IdMap = idMap
};
this.docCache[docKey] = parsed;
return true;
}
}
private static XDocument LoadXml(Stream stream)
{
XmlReaderSettings settings = new()
{
DtdProcessing = DtdProcessing.Ignore,
XmlResolver = null,
IgnoreComments = true,
IgnoreProcessingInstructions = true,
IgnoreWhitespace = true
};
using XmlReader reader = XmlReader.Create(stream, settings);
return XDocument.Load(reader, LoadOptions.None);
}
private static FontRectangle GetViewBox(XElement svg)
{
if (TryParseViewBox(svg.Attribute("viewBox")?.Value, out float x, out float y, out float w, out float h))
{
return new FontRectangle(x, y, w, h);
}
// No viewBox; return an empty rect. Metrics layer must decide fallback mapping.
return FontRectangle.Empty;
}
private static void Walk(
XElement node,
Matrix3x2 parentLocalTransform,
Paint? inheritedPaint,
float inheritedOpacityMul,
List outputLayers,
ParsedDoc parsedDoc)
{
Dictionary idMap = parsedDoc.IdMap;
Matrix3x2 nodeTransform = ParseTransform(node.Attribute("transform")?.Value);
Matrix3x2 localTransform = parentLocalTransform * nodeTransform;
FillRule fillRule = ResolveFillRule(node, FillRule.NonZero);
Paint? paint = ResolvePaint(node, inheritedPaint, parsedDoc, out bool fillNone, out float opacityMul);
float combinedOpacityMul = inheritedOpacityMul * opacityMul;
string name = node.Name.LocalName;
switch (name)
{
case "svg":
case "g":
{
foreach (XElement child in node.Elements())
{
Walk(child, localTransform, fillNone ? null : paint, combinedOpacityMul, outputLayers, parsedDoc);
}
break;
}
case "use":
{
string? href = GetHref(node);
if (href is null)
{
break;
}
float ux = ParseFloat(node.Attribute("x")?.Value);
float uy = ParseFloat(node.Attribute("y")?.Value);
Matrix3x2 xf = parentLocalTransform
* Matrix3x2.CreateTranslation(ux, uy)
* nodeTransform;
Paint? childInherited = fillNone ? null : paint;
XElement? target = LookupById(idMap, href);
if (target is not null)
{
Walk(target, xf, childInherited, combinedOpacityMul, outputLayers, parsedDoc);
}
break;
}
case "path":
{
if (fillNone)
{
break;
}
string? d = node.Attribute("d")?.Value;
if (string.IsNullOrWhiteSpace(d))
{
break;
}
List cmds = GetOrBuildPathCommands(node, d, parsedDoc);
if (cmds.Count > 0)
{
Paint? layerPaint = ApplyOpacityToPaint(paint, combinedOpacityMul);
outputLayers.Add(new(layerPaint, fillRule, localTransform, null, cmds));
}
break;
}
case "polygon":
case "polyline":
{
if (fillNone)
{
break;
}
string pts = node.Attribute("points")?.Value ?? string.Empty;
float[] coords = ParseFloatList(pts);
if (coords.Length >= 4)
{
bool close = string.Equals(node.Name.LocalName, "polygon", StringComparison.Ordinal);
List cmds = GetOrBuildPolyCommands(node, coords, close, parsedDoc);
if (cmds.Count > 0)
{
Paint? layerPaint = ApplyOpacityToPaint(paint, combinedOpacityMul);
outputLayers.Add(new(layerPaint, fillRule, localTransform, null, cmds));
}
}
break;
}
case "rect":
{
if (fillNone)
{
break;
}
float x = ParseFloat(node.Attribute("x")?.Value);
float y = ParseFloat(node.Attribute("y")?.Value);
float w = ParseFloat(node.Attribute("width")?.Value);
float h = ParseFloat(node.Attribute("height")?.Value);
// TODO: Rounded corners (rx/ry) not handled here (could be approximated later if needed).
if (w > 0f && h > 0f)
{
List cmds = GetOrBuildRectCommands(node, x, y, w, h, parsedDoc);
if (cmds.Count > 0)
{
Paint? layerPaint = ApplyOpacityToPaint(paint, combinedOpacityMul);
outputLayers.Add(new(layerPaint, fillRule, localTransform, null, cmds));
}
}
break;
}
case "circle":
{
if (fillNone)
{
break;
}
float cx = ParseFloat(node.Attribute("cx")?.Value);
float cy = ParseFloat(node.Attribute("cy")?.Value);
float r = ParseFloat(node.Attribute("r")?.Value);
if (r > 0f)
{
List cmds = GetOrBuildEllipseCommands(node, cx, cy, r, r, parsedDoc);
if (cmds.Count > 0)
{
Paint? layerPaint = ApplyOpacityToPaint(paint, combinedOpacityMul);
outputLayers.Add(new(layerPaint, fillRule, localTransform, null, cmds));
}
}
break;
}
case "ellipse":
{
if (fillNone)
{
break;
}
float cx = ParseFloat(node.Attribute("cx")?.Value);
float cy = ParseFloat(node.Attribute("cy")?.Value);
float rx = ParseFloat(node.Attribute("rx")?.Value);
float ry = ParseFloat(node.Attribute("ry")?.Value);
if (rx > 0f && ry > 0f)
{
List cmds = GetOrBuildEllipseCommands(node, cx, cy, rx, ry, parsedDoc);
if (cmds.Count > 0)
{
Paint? layerPaint = ApplyOpacityToPaint(paint, combinedOpacityMul);
outputLayers.Add(new(layerPaint, fillRule, localTransform, null, cmds));
}
}
break;
}
default:
{
// Unhandled (image, text, mask, clipPath, etc.) in v1.
break;
}
}
}
private static Paint? ApplyOpacityToPaint(Paint? basePaint, float opacityMul)
{
if (basePaint is null)
{
return null;
}
float effective = Math.Clamp(basePaint.Opacity * opacityMul, 0f, 1f);
if (effective <= 0f)
{
return null;
}
return basePaint switch
{
SolidPaint s => new SolidPaint { Color = s.Color, Opacity = effective },
LinearGradientPaint lg => new LinearGradientPaint
{
Units = lg.Units,
P0 = lg.P0,
P1 = lg.P1,
Spread = lg.Spread,
Stops = lg.Stops,
Transform = lg.Transform,
Opacity = effective
},
RadialGradientPaint rg => new RadialGradientPaint
{
Units = rg.Units,
Center0 = rg.Center0,
Radius0 = rg.Radius0,
Center1 = rg.Center1,
Radius1 = rg.Radius1,
Spread = rg.Spread,
Stops = rg.Stops,
Transform = rg.Transform,
Opacity = effective
},
_ => null,
};
}
private static FillRule ResolveFillRule(XElement e, FillRule inheritedDefault)
{
string? styleRule = TryCss(e.Attribute("style")?.Value, "fill-rule");
string? attrRule = e.Attribute("fill-rule")?.Value;
string? value = styleRule ?? attrRule;
if (string.Equals(value, "evenodd", StringComparison.OrdinalIgnoreCase))
{
return FillRule.EvenOdd;
}
if (string.Equals(value, "nonzero", StringComparison.OrdinalIgnoreCase))
{
return FillRule.NonZero;
}
return inheritedDefault;
}
private static Paint? ResolvePaint(
XElement e,
Paint? inherited,
ParsedDoc parsedDoc,
out bool fillNone,
out float opacityMul)
{
fillNone = false;
opacityMul = 1f;
string? style = e.Attribute("style")?.Value;
string? fillAttr = e.Attribute("fill")?.Value;
string? opacityAttr = e.Attribute("opacity")?.Value;
string? fillOpacityAttr = e.Attribute("fill-opacity")?.Value;
string? styleFill = TryCss(style, "fill");
string? styleOpacity = TryCss(style, "opacity");
string? styleFillOpacity = TryCss(style, "fill-opacity");
string? fill = styleFill ?? fillAttr;
string? op = styleOpacity ?? opacityAttr;
string? fop = styleFillOpacity ?? fillOpacityAttr;
if (!string.IsNullOrEmpty(op) && float.TryParse(op, NumberStyles.Float, CultureInfo.InvariantCulture, out float o))
{
opacityMul *= Math.Clamp(o, 0f, 1f);
}
if (!string.IsNullOrEmpty(fop) && float.TryParse(fop, NumberStyles.Float, CultureInfo.InvariantCulture, out float fo))
{
opacityMul *= Math.Clamp(fo, 0f, 1f);
}
if (string.IsNullOrEmpty(fill))
{
return inherited;
}
if (string.Equals(fill, "none", StringComparison.OrdinalIgnoreCase))
{
fillNone = true;
return null;
}
if (TryParseColor(fill, out GlyphColor color))
{
return new SolidPaint { Color = color };
}
if (TryExtractUrlId(fill, out string? paintId) && paintId is not null)
{
return ResolvePaintServer(paintId, parsedDoc) ?? inherited;
}
return inherited;
}
///
/// Resolves a referenced paint server and caches the parsed paint so repeated
/// uses of the same gradient id do not rebuild the gradient definition.
///
/// The referenced paint server identifier.
/// The parsed SVG document and its caches.
/// The resolved paint, or if the reference is unknown.
private static Paint? ResolvePaintServer(string id, ParsedDoc parsedDoc)
{
if (parsedDoc.PaintServerCache.TryGetValue(id, out Paint? cached))
{
return cached;
}
if (!parsedDoc.IdMap.TryGetValue(id, out XElement? server))
{
return null;
}
string tag = server.Name.LocalName;
Paint? paint = tag switch
{
// SVG only has linearGradient and radialGradient.
"linearGradient" => BuildLinearGradient(server, parsedDoc.IdMap),
"radialGradient" => BuildRadialGradient(server, parsedDoc.IdMap),
_ => null
};
if (paint is not null)
{
parsedDoc.PaintServerCache.TryAdd(id, paint);
}
return paint;
}
private static LinearGradientPaint? BuildLinearGradient(XElement grad, Dictionary idMap)
{
GradientUnits units = GradientUnits.ObjectBoundingBox;
SpreadMethod spread = SpreadMethod.Pad;
Matrix3x2 gxf = Matrix3x2.Identity;
float? x1 = null, y1 = null, x2 = null, y2 = null;
List<(float Offset, GlyphColor Color)> stops = [];
HashSet visited = new(StringComparer.Ordinal);
XElement? cur = grad;
while (cur is not null)
{
string? u = cur.Attribute("gradientUnits")?.Value;
if (u is not null)
{
units = ParseGradientUnits(u);
}
string? sm = cur.Attribute("spreadMethod")?.Value;
if (sm is not null)
{
spread = ParseSpreadMethod(sm);
}
gxf = ParseTransform(cur.Attribute("gradientTransform")?.Value) * gxf;
x1 ??= ParseCoordNullable(cur.Attribute("x1")?.Value);
y1 ??= ParseCoordNullable(cur.Attribute("y1")?.Value);
x2 ??= ParseCoordNullable(cur.Attribute("x2")?.Value);
y2 ??= ParseCoordNullable(cur.Attribute("y2")?.Value);
bool hadStops = false;
foreach (XElement s in cur.Elements())
{
if (s.Name.LocalName != "stop")
{
continue;
}
if (TryParseStop(s, out float off, out GlyphColor c))
{
stops.Add((off, c));
hadStops = true;
}
}
if (hadStops)
{
break;
}
string? href = GetHref(cur);
if (href is null || href.Length <= 1 || href[0] != '#')
{
break;
}
string refId = href[1..];
if (!visited.Add(refId) || !idMap.TryGetValue(refId, out cur))
{
break;
}
}
if (!x1.HasValue)
{
x1 = 0f;
}
if (!y1.HasValue)
{
y1 = 0f;
}
if (!x2.HasValue)
{
x2 = units == GradientUnits.ObjectBoundingBox ? 1f : 0f;
}
if (!y2.HasValue)
{
y2 = 0f;
}
GradientStop[] gs = BuildStopsArray(stops);
return new LinearGradientPaint
{
Units = units,
P0 = new Vector2(x1.Value, y1.Value),
P1 = new Vector2(x2.Value, y2.Value),
Spread = spread,
Stops = gs,
Transform = gxf
};
}
private static RadialGradientPaint? BuildRadialGradient(XElement grad, Dictionary idMap)
{
GradientUnits units = GradientUnits.ObjectBoundingBox;
SpreadMethod spread = SpreadMethod.Pad;
Matrix3x2 gxf = Matrix3x2.Identity;
float? cx = null, cy = null, r = null, fx = null, fy = null, fr = null;
List<(float Offset, GlyphColor Color)> stops = [];
HashSet visited = new(StringComparer.Ordinal);
XElement? cur = grad;
while (cur is not null)
{
string? u = cur.Attribute("gradientUnits")?.Value;
if (u is not null)
{
units = ParseGradientUnits(u);
}
string? sm = cur.Attribute("spreadMethod")?.Value;
if (sm is not null)
{
spread = ParseSpreadMethod(sm);
}
gxf = ParseTransform(cur.Attribute("gradientTransform")?.Value) * gxf;
cx ??= ParseCoordNullable(cur.Attribute("cx")?.Value);
cy ??= ParseCoordNullable(cur.Attribute("cy")?.Value);
r ??= ParseRadiusNullable(cur.Attribute("r")?.Value);
fx ??= ParseCoordNullable(cur.Attribute("fx")?.Value);
fy ??= ParseCoordNullable(cur.Attribute("fy")?.Value);
fr ??= ParseRadiusNullable(cur.Attribute("fr")?.Value);
bool hadStops = false;
foreach (XElement s in cur.Elements())
{
if (s.Name.LocalName != "stop")
{
continue;
}
if (TryParseStop(s, out float off, out GlyphColor c))
{
stops.Add((off, c));
hadStops = true;
}
}
if (hadStops)
{
break;
}
string? href = GetHref(cur);
if (href is null || href.Length <= 1 || href[0] != '#')
{
break;
}
string refId = href[1..];
if (!visited.Add(refId) || !idMap.TryGetValue(refId, out cur))
{
break;
}
}
if (!cx.HasValue)
{
cx = units == GradientUnits.ObjectBoundingBox ? 0.5f : 0f;
}
if (!cy.HasValue)
{
cy = units == GradientUnits.ObjectBoundingBox ? 0.5f : 0f;
}
if (!r.HasValue)
{
r = units == GradientUnits.ObjectBoundingBox ? 0.5f : 0f;
}
if (!fx.HasValue)
{
fx = cx.Value;
}
if (!fy.HasValue)
{
fy = cy.Value;
}
if (!fr.HasValue)
{
fr = 0f;
}
GradientStop[] gs = BuildStopsArray(stops);
// Center0=(fx,fy), Radius0=fr; Center1=(cx,cy), Radius1=r
return new RadialGradientPaint
{
Units = units,
Center0 = new Vector2(fx.Value, fy.Value),
Radius0 = fr.Value,
Center1 = new Vector2(cx.Value, cy.Value),
Radius1 = r.Value,
Spread = spread,
Stops = gs,
Transform = gxf
};
}
private static SpreadMethod ParseSpreadMethod(string value)
{
if (string.Equals(value, "reflect", StringComparison.OrdinalIgnoreCase))
{
return SpreadMethod.Reflect;
}
if (string.Equals(value, "repeat", StringComparison.OrdinalIgnoreCase))
{
return SpreadMethod.Repeat;
}
return SpreadMethod.Pad;
}
private static GradientUnits ParseGradientUnits(string value)
=> string.Equals(value, "userSpaceOnUse", StringComparison.OrdinalIgnoreCase)
? GradientUnits.UserSpaceOnUse
: GradientUnits.ObjectBoundingBox;
private static float? ParseCoordNullable(string? s)
{
if (string.IsNullOrEmpty(s))
{
return null;
}
if (s.EndsWith('%'))
{
if (float.TryParse(s.AsSpan(0, s.Length - 1), NumberStyles.Float, CultureInfo.InvariantCulture, out float p))
{
return p / 100f;
}
return null;
}
if (float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out float v))
{
return v; // In OBB this is already a fraction; in userSpace it is absolute user units.
}
return null;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static float? ParseRadiusNullable(string? s)
=> ParseCoordNullable(s);
private static bool TryParseStop(XElement stop, out float offset, out GlyphColor color)
{
offset = 0f;
color = default;
string? style = stop.Attribute("style")?.Value;
string? offAttr = stop.Attribute("offset")?.Value;
string? sc = stop.Attribute("stop-color")?.Value ?? TryCss(style, "stop-color");
string? so = stop.Attribute("stop-opacity")?.Value ?? TryCss(style, "stop-opacity");
if (!string.IsNullOrEmpty(offAttr))
{
if (offAttr.EndsWith('%'))
{
if (float.TryParse(offAttr.AsSpan(0, offAttr.Length - 1), NumberStyles.Float, CultureInfo.InvariantCulture, out float p))
{
offset = Math.Clamp(p / 100f, 0f, 1f);
}
}
else if (float.TryParse(offAttr, NumberStyles.Float, CultureInfo.InvariantCulture, out float v))
{
offset = Math.Clamp(v, 0f, 1f);
}
}
GlyphColor baseColor = new(0, 0, 0, 255);
if (!string.IsNullOrEmpty(sc) && TryParseColor(sc, out GlyphColor parsed))
{
baseColor = parsed;
}
float aMul = 1f;
if (!string.IsNullOrEmpty(so) && float.TryParse(so, NumberStyles.Float, CultureInfo.InvariantCulture, out float soVal))
{
aMul = Math.Clamp(soVal, 0f, 1f);
}
byte a = (byte)Math.Clamp((int)Math.Round(baseColor.A * aMul), 0, 255);
color = new GlyphColor(baseColor.R, baseColor.G, baseColor.B, a);
return true;
}
private static GradientStop[] BuildStopsArray(List<(float Offset, GlyphColor Color)> list)
{
if (list.Count == 0)
{
return
[
new GradientStop(0f, new GlyphColor(0, 0, 0, 255)),
new GradientStop(1f, new GlyphColor(0, 0, 0, 255))
];
}
list.Sort((a, b) => a.Offset.CompareTo(b.Offset));
GradientStop[] stops = new GradientStop[list.Count];
for (int i = 0; i < list.Count; i++)
{
(float o, GlyphColor c) = list[i];
stops[i] = new GradientStop(Math.Clamp(o, 0f, 1f), c);
}
return stops;
}
private static string? TryCss(string? style, string prop)
{
if (string.IsNullOrEmpty(style))
{
return null;
}
ReadOnlySpan span = style.AsSpan();
while (span.Length > 0)
{
int semi = span.IndexOf(';');
ReadOnlySpan part = semi >= 0 ? span[..semi] : span;
span = semi >= 0 ? span[(semi + 1)..] : [];
int colon = part.IndexOf(':');
if (colon <= 0)
{
continue;
}
ReadOnlySpan name = part[..colon].Trim();
if (name.Equals(prop.AsSpan(), StringComparison.OrdinalIgnoreCase))
{
return part[(colon + 1)..].Trim().ToString();
}
}
return null;
}
private static bool TryParseColor(string s, out GlyphColor color)
{
if (GlyphColor.TryParseNamed(s, out color))
{
return true;
}
if (GlyphColor.TryParseHex(s, out GlyphColor hex))
{
color = hex;
return true;
}
if (s.StartsWith("rgb", StringComparison.OrdinalIgnoreCase))
{
int l = s.IndexOf('(');
int r = s.IndexOf(')');
if (l >= 0 && r > l)
{
ReadOnlySpan inner = s.AsSpan(l + 1, r - l - 1);
Span ranges = stackalloc Range[5];
int count = inner.Split(ranges, ',');
if (count >= 3)
{
byte rr = ParseByte(inner[ranges[0]]);
byte gg = ParseByte(inner[ranges[1]]);
byte bb = ParseByte(inner[ranges[2]]);
byte aa = 255;
if (count >= 4 && float.TryParse(inner[ranges[3]].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out float af))
{
aa = (byte)Math.Clamp((int)Math.Round(255f * af), 0, 255);
}
color = new GlyphColor(rr, gg, bb, aa);
return true;
}
}
}
return false;
static byte ParseByte(ReadOnlySpan x)
{
if (x.IsEmpty)
{
return 0;
}
ReadOnlySpan t = x.Trim();
if (t[^1] == '%')
{
if (float.TryParse(t[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out float p))
{
return (byte)Math.Clamp((int)Math.Round(255f * (p / 100f)), 0, 255);
}
return 0;
}
if (int.TryParse(t, NumberStyles.Integer, CultureInfo.InvariantCulture, out int v))
{
return (byte)Math.Clamp(v, 0, 255);
}
return 0;
}
}
private static bool TryExtractUrlId(string s, [NotNullWhen(true)] out string? id)
{
id = null;
int lp = s.IndexOf("url(", StringComparison.OrdinalIgnoreCase);
if (lp < 0)
{
return false;
}
int rp = s.IndexOf(')', lp + 4);
if (rp < 0)
{
return false;
}
string inner = s[(lp + 4)..rp].Trim();
if (inner.Length > 1 && inner[0] == '#')
{
id = inner[1..];
return true;
}
return false;
}
private static XElement? LookupById(Dictionary idMap, string href)
{
if (string.IsNullOrEmpty(href) || href[0] != '#')
{
return null;
}
return idMap.TryGetValue(href.AsSpan(1).ToString(), out XElement? e) ? e : null;
}
private static string? GetHref(XElement e)
{
XNamespace xlink = "http://www.w3.org/1999/xlink";
return e.Attribute(xlink + "href")?.Value ?? e.Attribute("href")?.Value;
}
///
/// Returns cached path commands for an SVG path element, or parses and caches them
/// when the element is a reusable definition with an id.
///
/// The SVG node that owns the geometry.
/// The raw SVG path data.
/// The parsed SVG document and its caches.
/// The parsed path commands.
private static List GetOrBuildPathCommands(XElement node, string d, ParsedDoc parsedDoc)
{
if (TryGetCachedGeometry(node, parsedDoc, out List? cached, out string? geometryId))
{
return cached;
}
return CacheGeometry(geometryId, parsedDoc, BuildCommandsFromPathData(d));
}
///
/// Returns cached path commands for a polygon or polyline definition, or builds and caches them.
///
/// The SVG node that owns the geometry.
/// The parsed coordinate list.
/// Whether the geometry should be explicitly closed.
/// The parsed SVG document and its caches.
/// The parsed path commands.
private static List GetOrBuildPolyCommands(XElement node, float[] coords, bool close, ParsedDoc parsedDoc)
{
if (TryGetCachedGeometry(node, parsedDoc, out List? cached, out string? geometryId))
{
return cached;
}
return CacheGeometry(geometryId, parsedDoc, BuildCommandsFromPoly(coords, close));
}
///
/// Returns cached path commands for a rectangle definition, or builds and caches them.
///
/// The SVG node that owns the geometry.
/// The rectangle origin X.
/// The rectangle origin Y.
/// The rectangle width.
/// The rectangle height.
/// The parsed SVG document and its caches.
/// The parsed path commands.
private static List GetOrBuildRectCommands(
XElement node,
float x,
float y,
float w,
float h,
ParsedDoc parsedDoc)
{
if (TryGetCachedGeometry(node, parsedDoc, out List? cached, out string? geometryId))
{
return cached;
}
float[] coords =
[
x, y,
x + w, y,
x + w, y + h,
x, y + h
];
return CacheGeometry(geometryId, parsedDoc, BuildCommandsFromPoly(coords, close: true));
}
///
/// Returns cached path commands for an ellipse or circle definition, or builds and caches them.
///
/// The SVG node that owns the geometry.
/// The ellipse center X.
/// The ellipse center Y.
/// The ellipse radius on the X axis.
/// The ellipse radius on the Y axis.
/// The parsed SVG document and its caches.
/// The parsed path commands.
private static List GetOrBuildEllipseCommands(
XElement node,
float cx,
float cy,
float rx,
float ry,
ParsedDoc parsedDoc)
{
if (TryGetCachedGeometry(node, parsedDoc, out List? cached, out string? geometryId))
{
return cached;
}
return CacheGeometry(geometryId, parsedDoc, BuildCommandsForEllipse(cx, cy, rx, ry));
}
///
/// Looks up cached geometry for a reusable SVG element by its id.
///
/// The SVG node that may have cached geometry.
/// The parsed SVG document and its caches.
/// When this method returns, contains the cached commands if found.
/// When this method returns, contains the element id used as the cache key.
/// if cached geometry was found; otherwise, .
private static bool TryGetCachedGeometry(
XElement node,
ParsedDoc parsedDoc,
[NotNullWhen(true)] out List? cached,
[NotNullWhen(true)] out string? geometryId)
{
geometryId = node.Attribute("id")?.Value;
if (geometryId is not null && parsedDoc.GeometryCache.TryGetValue(geometryId, out List? commands))
{
cached = commands;
return true;
}
cached = null;
return false;
}
///
/// Stores geometry in the per-document cache when the
/// source element has a reusable id.
///
/// The cache key, or when the element is anonymous.
/// The parsed SVG document and its caches.
/// The newly built commands.
/// The cached or materialized command list.
private static List CacheGeometry(string? geometryId, ParsedDoc parsedDoc, List commands)
{
if (commands.Count == 0)
{
return [];
}
if (geometryId is not null)
{
parsedDoc.GeometryCache.TryAdd(geometryId, commands);
}
return commands;
}
private static List BuildCommandsFromPoly(float[] coords, bool close)
{
List cmds = [];
Vector2 start = new(coords[0], coords[1]);
cmds.Add(PathCommand.MoveTo(start));
Vector2 prev = start;
for (int i = 2; i + 1 < coords.Length; i += 2)
{
Vector2 p = new(coords[i], coords[i + 1]);
if (!NearlyEqual(prev, p))
{
cmds.Add(PathCommand.LineTo(p));
prev = p;
}
}
if (close && !NearlyEqual(prev, start))
{
cmds.Add(PathCommand.LineTo(start));
cmds.Add(PathCommand.Close());
}
return cmds;
}
private static List BuildCommandsForEllipse(float cx, float cy, float rx, float ry)
{
List cmds = [];
// Start at (cx + rx, cy)
Vector2 s = new(cx + rx, cy);
cmds.Add(PathCommand.MoveTo(s));
// First half to (cx - rx, cy)
Vector2 p1 = new(cx - rx, cy);
cmds.Add(PathCommand.ArcTo(rx, ry, 0f, true, true, p1));
// Second half back to start
Vector2 p2 = new(cx + rx, cy);
cmds.Add(PathCommand.ArcTo(rx, ry, 0f, true, true, p2));
cmds.Add(PathCommand.Close());
return cmds;
}
private static List BuildCommandsFromPathData(string d)
{
List cmds = [];
ReadOnlySpan s = d.AsSpan();
Vector2 first = default;
Vector2 curr = default;
Vector2 lastc = default;
Vector2 p1, p2, p3;
char op = '\0';
char prevOp = '\0';
bool rel = false;
bool figureOpen = false;
while (true)
{
s = s.TrimStart();
if (s.Length == 0)
{
break;
}
char ch = s[0];
if (char.IsDigit(ch) || ch == '-' || ch == '+' || ch == '.')
{
if (s.Length == 0 || op == 'Z')
{
return [];
}
}
else if (IsSeparator(ch))
{
s = TrimSeparator(s);
}
else
{
op = ch;
rel = false;
if (char.IsLower(op))
{
op = char.ToUpper(op, CultureInfo.InvariantCulture);
rel = true;
}
s = TrimSeparator(s[1..]);
}
switch (op)
{
case 'M':
{
s = FindPoint(s, rel, curr, out p1);
if (figureOpen)
{
cmds.Add(PathCommand.Close());
}
cmds.Add(PathCommand.MoveTo(p1));
first = curr = p1;
prevOp = '\0';
op = 'L';
figureOpen = true;
break;
}
case 'L':
{
s = FindPoint(s, rel, curr, out p1);
if (!NearlyEqual(p1, curr))
{
cmds.Add(PathCommand.LineTo(p1));
}
curr = p1;
break;
}
case 'H':
{
s = FindScaler(s, out float x);
if (rel)
{
x += curr.X;
}
p1 = new Vector2(x, curr.Y);
if (!NearlyEqual(p1, curr))
{
cmds.Add(PathCommand.LineTo(p1));
}
curr = p1;
break;
}
case 'V':
{
s = FindScaler(s, out float y);
if (rel)
{
y += curr.Y;
}
p1 = new Vector2(curr.X, y);
if (!NearlyEqual(p1, curr))
{
cmds.Add(PathCommand.LineTo(p1));
}
curr = p1;
break;
}
case 'C':
{
s = FindPoint(s, rel, curr, out p1);
s = FindPoint(s, rel, curr, out p2);
s = FindPoint(s, rel, curr, out p3);
cmds.Add(PathCommand.CubicTo(p1, p2, p3));
lastc = p2;
curr = p3;
break;
}
case 'S':
{
s = FindPoint(s, rel, curr, out p2);
s = FindPoint(s, rel, curr, out p3);
p1 = curr;
if (prevOp is 'C' or 'S')
{
p1.X -= lastc.X - curr.X;
p1.Y -= lastc.Y - curr.Y;
}
cmds.Add(PathCommand.CubicTo(p1, p2, p3));
lastc = p2;
curr = p3;
break;
}
case 'Q':
{
s = FindPoint(s, rel, curr, out p1);
s = FindPoint(s, rel, curr, out p2);
cmds.Add(PathCommand.QuadraticTo(p1, p2));
lastc = p1;
curr = p2;
break;
}
case 'T':
{
s = FindPoint(s, rel, curr, out p2);
p1 = curr;
if (prevOp is 'Q' or 'T')
{
p1.X -= lastc.X - curr.X;
p1.Y -= lastc.Y - curr.Y;
}
cmds.Add(PathCommand.QuadraticTo(p1, p2));
lastc = p1;
curr = p2;
break;
}
case 'A':
{
if (TryFindScaler(ref s, out float rx)
&& TryTrimSeparator(ref s)
&& TryFindScaler(ref s, out float ry)
&& TryTrimSeparator(ref s)
&& TryFindScaler(ref s, out float angle)
&& TryTrimSeparator(ref s)
&& TryFindScaler(ref s, out float largeArc)
&& TryTrimSeparator(ref s)
&& TryFindScaler(ref s, out float sweep)
&& TryFindPoint(ref s, rel, curr, out p1))
{
cmds.Add(PathCommand.ArcTo(rx, ry, angle, largeArc == 1, sweep == 1, p1));
curr = p1;
}
break;
}
case 'Z':
{
if (figureOpen)
{
if (!NearlyEqual(curr, first))
{
cmds.Add(PathCommand.LineTo(first));
}
cmds.Add(PathCommand.Close());
curr = first;
figureOpen = false;
}
break;
}
default:
{
return [];
}
}
if (prevOp == 0)
{
first = curr;
}
prevOp = op;
if (op == 'M')
{
figureOpen = true;
}
}
return cmds;
}
private static bool TryParseViewBox(string? s, out float x, out float y, out float w, out float h)
{
x = 0f;
y = 0f;
w = 0f;
h = 0f;
if (string.IsNullOrEmpty(s))
{
return false;
}
float[] v = ParseFloatList(s);
if (v.Length == 4)
{
x = v[0];
y = v[1];
w = v[2];
h = v[3];
return true;
}
return false;
}
private static Matrix3x2 ParseTransform(string? s)
{
if (string.IsNullOrEmpty(s))
{
return Matrix3x2.Identity;
}
Matrix3x2 m = Matrix3x2.Identity;
int i = 0;
int n = s.Length;
while (i < n)
{
SkipSep(s, ref i);
if (i >= n)
{
break;
}
int start = i;
while (i < n && char.IsLetter(s[i]))
{
i++;
}
ReadOnlySpan op = s.AsSpan(start, i - start);
SkipSep(s, ref i);
if (i >= n || s[i] != '(')
{
break;
}
i++; // '('
int argsStart = i;
int depth = 1;
while (i < n && depth > 0)
{
if (s[i] == '(')
{
depth++;
}
else if (s[i] == ')')
{
depth--;
}
i++;
}
ReadOnlySpan args = s.AsSpan(argsStart, (i - argsStart) - 1);
float[] a = ParseFloatList(args);
Matrix3x2 t = Matrix3x2.Identity;
if (op.SequenceEqual("matrix"))
{
if (a.Length >= 6)
{
t = new Matrix3x2(a[0], a[1], a[2], a[3], a[4], a[5]);
}
}
else if (op.SequenceEqual("translate"))
{
if (a.Length == 1)
{
t = Matrix3x2.CreateTranslation(a[0], 0f);
}
else if (a.Length >= 2)
{
t = Matrix3x2.CreateTranslation(a[0], a[1]);
}
}
else if (op.SequenceEqual("scale"))
{
if (a.Length == 1)
{
t = Matrix3x2.CreateScale(a[0], a[0]);
}
else if (a.Length >= 2)
{
t = Matrix3x2.CreateScale(a[0], a[1]);
}
}
else if (op.SequenceEqual("rotate"))
{
if (a.Length >= 1)
{
t = Matrix3x2.CreateRotation(a[0] * (float)(Math.PI / 180.0));
}
}
else if (op.SequenceEqual("skewX"))
{
if (a.Length >= 1)
{
t = new Matrix3x2(1f, 0f, MathF.Tan(a[0] * (float)(Math.PI / 180.0)), 1f, 0f, 0f);
}
}
else if (op.SequenceEqual("skewY"))
{
if (a.Length >= 1)
{
t = new Matrix3x2(1f, MathF.Tan(a[0] * (float)(Math.PI / 180.0)), 0f, 1f, 0f, 0f);
}
}
m *= t;
SkipSep(s, ref i);
}
return m;
static void SkipSep(string s, ref int i)
{
int n = s.Length;
while (i < n)
{
char c = s[i];
if (char.IsWhiteSpace(c) || c == ',')
{
i++;
}
else
{
break;
}
}
}
}
private static ReadOnlySpan FindPoint(ReadOnlySpan str, bool rel, Vector2 current, out Vector2 value)
{
str = FindScaler(str, out float x);
str = FindScaler(str, out float y);
if (rel)
{
x += current.X;
y += current.Y;
}
value = new Vector2(x, y);
return str;
}
private static ReadOnlySpan FindScaler(ReadOnlySpan str, out float scaler)
{
str = TrimSeparator(str);
scaler = 0f;
for (int i = 0; i < str.Length; i++)
{
if (IsSeparator(str[i]))
{
scaler = ParseFloat(str[..i]);
return str[i..];
}
}
if (str.Length > 0)
{
scaler = ParseFloat(str);
}
return [];
}
private static bool TryTrimSeparator(ref ReadOnlySpan str)
{
ReadOnlySpan result = TrimSeparator(str);
if (str[^result.Length..].StartsWith(result))
{
str = result;
return true;
}
return false;
}
private static bool TryFindScaler(ref ReadOnlySpan str, out float value)
{
ReadOnlySpan result = FindScaler(str, out float v);
if (str[^result.Length..].StartsWith(result))
{
value = v;
str = result;
return true;
}
value = default;
return false;
}
private static bool TryFindPoint(ref ReadOnlySpan str, bool relative, Vector2 current, out Vector2 value)
{
ReadOnlySpan result = FindPoint(str, relative, current, out Vector2 v);
if (str[^result.Length..].StartsWith(result))
{
value = v;
str = result;
return true;
}
value = default;
return false;
}
private static bool IsSeparator(char ch)
=> char.IsWhiteSpace(ch) || ch == ',';
private static ReadOnlySpan TrimSeparator(ReadOnlySpan s)
{
int idx = 0;
for (; idx < s.Length; idx++)
{
if (!IsSeparator(s[idx]))
{
break;
}
}
return s[idx..];
}
private static float ParseFloat(ReadOnlySpan str)
=> str.IsEmpty ? 0 : float.Parse(str, CultureInfo.InvariantCulture);
private static float[] ParseFloatList(string s)
=> string.IsNullOrEmpty(s) ? [] : ParseFloatList(s.AsSpan());
private static float[] ParseFloatList(ReadOnlySpan s)
{
if (s.IsEmpty)
{
return [];
}
List vals = [];
int i = 0;
int n = s.Length;
while (i < n)
{
while (i < n && (char.IsWhiteSpace(s[i]) || s[i] == ','))
{
i++;
}
if (i >= n)
{
break;
}
int start = i;
if (s[i] is '+' or '-')
{
i++;
}
bool dot = false;
while (i < n)
{
char c = s[i];
if (char.IsDigit(c))
{
i++;
continue;
}
if (c == '.' && !dot)
{
dot = true;
i++;
continue;
}
break;
}
if (i < n && (s[i] == 'e' || s[i] == 'E'))
{
i++;
if (i < n && (s[i] == '+' || s[i] == '-'))
{
i++;
}
while (i < n && char.IsDigit(s[i]))
{
i++;
}
}
if (float.TryParse(s[start..i], NumberStyles.Float, CultureInfo.InvariantCulture, out float v))
{
vals.Add(v);
}
}
return [.. vals];
}
private static bool NearlyEqual(in Vector2 a, in Vector2 b, float eps = 1e-3f)
=> MathF.Abs(a.X - b.X) <= eps && MathF.Abs(a.Y - b.Y) <= eps;
private sealed class ParsedDoc
{
public required XDocument Doc { get; init; }
public required Dictionary IdMap { get; init; }
///
/// Gets the per-document cache of parsed geometry for reusable SVG defs.
///
public ConcurrentDictionary> GeometryCache { get; } = new(StringComparer.Ordinal);
///
/// Gets the per-document cache of resolved paint servers.
///
public ConcurrentDictionary PaintServerCache { get; } = new(StringComparer.Ordinal);
}
}
}