diff --git a/src/SixLabors.Fonts/Rendering/PaintedCanvasMetadata.cs b/src/SixLabors.Fonts/Rendering/PaintedCanvasMetadata.cs
index 186858407..4ed535ae7 100644
--- a/src/SixLabors.Fonts/Rendering/PaintedCanvasMetadata.cs
+++ b/src/SixLabors.Fonts/Rendering/PaintedCanvasMetadata.cs
@@ -15,7 +15,7 @@ internal readonly struct PaintedCanvasMetadata
///
/// The viewBox rectangle (minX, minY, width, height).
/// True if the source coordinate system is y-down; false if y-up.
- /// An optional root transform in document-space.
+ /// An optional root transform, applied after the viewBox mapping and before any Y-flip.
public PaintedCanvasMetadata(FontRectangle viewBox, bool isYDown, Matrix3x2 rootTransform)
{
this.HasViewBox = viewBox != FontRectangle.Empty;
@@ -40,7 +40,9 @@ public PaintedCanvasMetadata(FontRectangle viewBox, bool isYDown, Matrix3x2 root
public bool IsYDown { get; }
///
- /// Gets the root transform in document-space.
+ /// Gets the root transform. It applies in the space the viewBox maps into (font units, in the
+ /// source's Y direction), after the viewBox mapping and before any Y-flip. Layer transforms
+ /// do not include it.
///
public Matrix3x2 RootTransform { get; }
}
diff --git a/src/SixLabors.Fonts/Rendering/PaintedGlyph.cs b/src/SixLabors.Fonts/Rendering/PaintedGlyph.cs
index 5c8b3e4a5..e421fdb15 100644
--- a/src/SixLabors.Fonts/Rendering/PaintedGlyph.cs
+++ b/src/SixLabors.Fonts/Rendering/PaintedGlyph.cs
@@ -31,7 +31,7 @@ public PaintedGlyph(List layers, List? co
this.Layers = layers;
this.CompositeCommands = compositeCommands;
this.ClipBounds = clipBounds;
- this.Bounds = CalculateBounds(layers);
+ this.Bounds = CalculateBounds(layers, Matrix3x2.Identity);
}
///
@@ -68,11 +68,22 @@ public PaintedGlyph(List layers, List? co
public static PaintedGlyph Empty => new([]);
///
- /// Calculates the bounds of all painted geometry after applying each layer transform.
+ /// Calculates the bounds of all painted geometry after applying each layer transform followed by
+ /// . Unlike transforming , this stays tight when
+ /// rotates or skews, at the cost of a pass over all geometry.
+ ///
+ /// The transform applied after each layer transform.
+ /// The calculated bounds.
+ public Bounds GetBounds(Matrix3x2 transform) => CalculateBounds(this.Layers, transform);
+
+ ///
+ /// Calculates the bounds of all painted geometry after applying each layer transform followed by
+ /// .
///
/// The painted layers.
+ /// The transform applied after each layer transform.
/// The calculated bounds.
- private static Bounds CalculateBounds(List layers)
+ private static Bounds CalculateBounds(IReadOnlyList layers, Matrix3x2 transform)
{
Vector2 min = new(float.MaxValue);
Vector2 max = new(float.MinValue);
@@ -81,6 +92,9 @@ private static Bounds CalculateBounds(List layers)
for (int i = 0; i < layers.Count; i++)
{
PaintedLayer layer = layers[i];
+
+ // Matrix3x2 transforms row vectors, so the layer transform applies first.
+ Matrix3x2 layerTransform = layer.Transform * transform;
IReadOnlyList path = layer.Path;
Vector2 current = default;
@@ -91,19 +105,19 @@ private static Bounds CalculateBounds(List layers)
{
case PathVerb.MoveTo:
case PathVerb.LineTo:
- Include(command.EndPoint, layer.Transform, ref min, ref max, ref hasPoint);
+ Include(command.EndPoint, layerTransform, ref min, ref max, ref hasPoint);
break;
case PathVerb.QuadraticTo:
- Include(command.ControlPoint1, layer.Transform, ref min, ref max, ref hasPoint);
- Include(command.EndPoint, layer.Transform, ref min, ref max, ref hasPoint);
+ Include(command.ControlPoint1, layerTransform, ref min, ref max, ref hasPoint);
+ Include(command.EndPoint, layerTransform, ref min, ref max, ref hasPoint);
break;
case PathVerb.CubicTo:
- Include(command.ControlPoint1, layer.Transform, ref min, ref max, ref hasPoint);
- Include(command.ControlPoint2, layer.Transform, ref min, ref max, ref hasPoint);
- Include(command.EndPoint, layer.Transform, ref min, ref max, ref hasPoint);
+ Include(command.ControlPoint1, layerTransform, ref min, ref max, ref hasPoint);
+ Include(command.ControlPoint2, layerTransform, ref min, ref max, ref hasPoint);
+ Include(command.EndPoint, layerTransform, ref min, ref max, ref hasPoint);
break;
case PathVerb.ArcTo:
- IncludeArc(current, command, layer.Transform, ref min, ref max, ref hasPoint);
+ IncludeArc(current, command, layerTransform, ref min, ref max, ref hasPoint);
break;
}
diff --git a/src/SixLabors.Fonts/Rendering/PaintedGlyphMetrics.cs b/src/SixLabors.Fonts/Rendering/PaintedGlyphMetrics.cs
index a5f56099d..cbc46325a 100644
--- a/src/SixLabors.Fonts/Rendering/PaintedGlyphMetrics.cs
+++ b/src/SixLabors.Fonts/Rendering/PaintedGlyphMetrics.cs
@@ -2,6 +2,7 @@
// Licensed under the Six Labors Split License.
using System.Numerics;
+using System.Runtime.CompilerServices;
using SixLabors.Fonts.Unicode;
namespace SixLabors.Fonts.Rendering;
@@ -15,6 +16,12 @@ public sealed class PaintedGlyphMetrics : FontGlyphMetrics
{
private readonly IPaintedGlyphSource source;
+ ///
+ /// The design bounds measured from the glyph's geometry when the source-to-UPEM mapping
+ /// rotates or skews, or until first requested.
+ ///
+ private StrongBox? rotatedDesignBounds;
+
///
/// Initializes a new instance of the class.
///
@@ -79,7 +86,40 @@ internal override Bounds GetDesignBounds()
}
Matrix3x2 sourceToUpem = ComputeSourceToUpem(canvas, this.UnitsPerEm);
- return Bounds.Transform(glyph.Bounds, sourceToUpem);
+
+ // Transforming the cached box is exact while the mapping keeps the axes aligned (scale,
+ // translation, Y-flip, or a quarter-turn that swaps the axes), because the image of an
+ // axis-aligned box is then itself a box. A rotation or skew, which only a root transform
+ // introduces, would inflate the box around the rotated corners, so the geometry is
+ // measured under the full mapping instead.
+ if (IsAxisAligned(in sourceToUpem))
+ {
+ return Bounds.Transform(glyph.Bounds, sourceToUpem);
+ }
+
+ // The glyph and its canvas are fixed for this instance, so the geometry pass runs once.
+ // A reference write is atomic, so concurrent callers at worst compute the same value twice.
+ StrongBox? cached = this.rotatedDesignBounds;
+ if (cached is null)
+ {
+ cached = new StrongBox(glyph.GetBounds(sourceToUpem));
+ this.rotatedDesignBounds = cached;
+ }
+
+ return cached.Value;
+ }
+
+ ///
+ /// Returns whether a transform maps axis-aligned boxes to axis-aligned boxes, allowing for the
+ /// rounding noise that trigonometric construction leaves in the zero terms.
+ ///
+ /// The transform to test.
+ /// if the transform keeps or swaps the axes.
+ private static bool IsAxisAligned(in Matrix3x2 m)
+ {
+ float eps = 1e-5F * (MathF.Abs(m.M11) + MathF.Abs(m.M12) + MathF.Abs(m.M21) + MathF.Abs(m.M22));
+ return (MathF.Abs(m.M12) <= eps && MathF.Abs(m.M21) <= eps)
+ || (MathF.Abs(m.M11) <= eps && MathF.Abs(m.M22) <= eps);
}
///
@@ -160,25 +200,20 @@ private Matrix3x2 ComputeTotalTransform(
layout *= Matrix3x2.CreateScale(1F, -1F);
layout.Translation += glyphOrigin;
- // Source-to-UPEM: viewBox mapping (uniform "meet"), optional y-flip, optional root transform.
+ // Source-to-UPEM: viewBox mapping (uniform "meet"), optional root transform, optional y-flip.
return ComputeSourceToUpem(canvas, this.UnitsPerEm) * layout;
}
///
/// Computes the mapping from the interpreter's document-space to UPEM font space.
- /// Enforces a uniform 'meet' scale from the root viewBox (if present) and normalizes
- /// Y-down document coordinates to the Y-up glyph-metrics coordinate system.
+ /// Enforces a uniform 'meet' scale from the root viewBox (if present), then applies the
+ /// root transform in the space the viewBox maps into, and finally normalizes Y-down
+ /// document coordinates to the Y-up glyph-metrics coordinate system.
///
private static Matrix3x2 ComputeSourceToUpem(in PaintedCanvasMetadata canvas, ushort upem)
{
Matrix3x2 m = Matrix3x2.Identity;
- // Root transform (doc-space). Apply first if provided.
- if (!canvas.RootTransform.IsIdentity)
- {
- m *= canvas.RootTransform;
- }
-
// Translate viewBox min to origin, then uniform scale to UPEM using "meet".
if (canvas.HasViewBox)
{
@@ -193,6 +228,14 @@ private static Matrix3x2 ComputeSourceToUpem(in PaintedCanvasMetadata canvas, us
m = m * t * sUni;
}
+ // Root transform. SVG 2 applies a transform on an element as if it were set on a
+ // parent element, so it acts in the parent (UPEM) space after the viewBox mapping.
+ // Matrix3x2 transforms row vectors, so appending it here makes it apply after the viewBox.
+ if (!canvas.RootTransform.IsIdentity)
+ {
+ m *= canvas.RootTransform;
+ }
+
// Normalize every painted source to the Y-up font-space contract used by glyph metrics.
if (canvas.IsYDown)
{
diff --git a/src/SixLabors.Fonts/Tables/General/Svg/SvgGlyphSource.cs b/src/SixLabors.Fonts/Tables/General/Svg/SvgGlyphSource.cs
index 4c0281b78..8e8bf1e64 100644
--- a/src/SixLabors.Fonts/Tables/General/Svg/SvgGlyphSource.cs
+++ b/src/SixLabors.Fonts/Tables/General/Svg/SvgGlyphSource.cs
@@ -47,10 +47,15 @@ public bool TryGetPaintedGlyph(ushort glyphId, out PaintedGlyph glyph, out Paint
string wantedId = "glyph" + gid.ToString(CultureInfo.InvariantCulture);
XElement glyphRoot = parsed.IdMap.TryGetValue(wantedId, out XElement? ge) ? ge : root;
+ // SVG 2 applies a transform on an element as if it were set on a parent
+ // element, so it acts in the parent space outside the viewBox mapping. Only the
+ // canvas metadata can express that ordering, so the root transform travels there
+ // alone: the layer transforms start from identity, and Walk never applies the
+ // root element's own transform.
List layers = [];
Walk(
glyphRoot,
- rootTransform,
+ Matrix3x2.Identity,
inheritedPaint: DefaultBlackFillPaint,
inheritedOpacityMul: 1F,
outputLayers: layers,
@@ -172,9 +177,15 @@ private static void Walk(
case "svg":
case "g":
{
+ // The document root's own transform is carried once on the canvas, after the viewBox
+ // mapping, so its children start from the parent transform, however the walk reached the
+ // root (for example as the glyph when no glyph{gid} element exists). The root of an SVG
+ // document is an container, and the only element without a parent, so this is the
+ // only place its transform could enter the layers.
+ Matrix3x2 childTransform = node.Parent is null ? parentLocalTransform : localTransform;
foreach (XElement child in node.Elements())
{
- Walk(child, localTransform, fillNone ? null : paint, combinedOpacityMul, outputLayers, parsedDoc);
+ Walk(child, childTransform, fillNone ? null : paint, combinedOpacityMul, outputLayers, parsedDoc);
}
break;
@@ -474,8 +485,8 @@ private static FillRule ResolveFillRule(XElement e, FillRule inheritedDefault)
Paint? paint = tag switch
{
// SVG only has linearGradient and radialGradient.
- "linearGradient" => BuildLinearGradient(server, parsedDoc.IdMap),
- "radialGradient" => BuildRadialGradient(server, parsedDoc.IdMap),
+ "linearGradient" => BuildLinearGradient(server, id, parsedDoc.IdMap),
+ "radialGradient" => BuildRadialGradient(server, id, parsedDoc.IdMap),
_ => null
};
@@ -487,67 +498,97 @@ private static FillRule ResolveFillRule(XElement e, FillRule inheritedDefault)
return paint;
}
- private static LinearGradientPaint? BuildLinearGradient(XElement grad, Dictionary idMap)
+ ///
+ /// Builds a linear gradient paint from a linearGradient element, resolving the attributes
+ /// and stops it inherits through its href chain.
+ ///
+ /// The linearGradient element to build.
+ /// The id was referenced by.
+ /// The document's elements by id, used to follow href references.
+ /// The linear gradient paint.
+ private static LinearGradientPaint? BuildLinearGradient(XElement grad, string id, Dictionary idMap)
{
- GradientUnits units = GradientUnits.ObjectBoundingBox;
- SpreadMethod spread = SpreadMethod.Pad;
- Matrix3x2 gxf = Matrix3x2.Identity;
+ // A gradient inherits an attribute or its stops from the gradient it references only when
+ // it does not specify them itself (SVG 1.1 §13.2.2 xlink:href; SVG 2 paint server templates).
+ // The chain is walked from the referencing gradient outward, so the first element to specify
+ // a value supplies it and later ones are ignored. Null means "not yet specified".
+ GradientUnits? units = null;
+ SpreadMethod? spread = null;
+ Matrix3x2? gxf = null;
float? x1 = null, y1 = null, x2 = null, y2 = null;
List<(float Offset, GlyphColor Color)> stops = [];
+ bool hasStops = false;
+
+ // Seed the visited set with the starting gradient so a chain that leads back to it ends there.
+ HashSet visited = new(StringComparer.Ordinal) { id };
- HashSet visited = new(StringComparer.Ordinal);
XElement? cur = grad;
while (cur is not null)
{
+ // An enumerated or transform attribute counts as specified when it is present, matching
+ // browsers. A value the parsers do not recognize resolves to the attribute's initial value
+ // (objectBoundingBox, pad, identity) and still hides the referenced gradient's value.
string? u = cur.Attribute("gradientUnits")?.Value;
- if (u is not null)
+ if (units is null && u is not null)
{
units = ParseGradientUnits(u);
}
string? sm = cur.Attribute("spreadMethod")?.Value;
- if (sm is not null)
+ if (spread is null && sm is not null)
{
spread = ParseSpreadMethod(sm);
}
- gxf = ParseTransform(cur.Attribute("gradientTransform")?.Value) * gxf;
+ string? t = cur.Attribute("gradientTransform")?.Value;
+ if (gxf is null && t is not null)
+ {
+ gxf = ParseTransform(t);
+ }
- x1 ??= ParseCoordNullable(cur.Attribute("x1")?.Value);
- y1 ??= ParseCoordNullable(cur.Attribute("y1")?.Value);
- x2 ??= ParseCoordNullable(cur.Attribute("x2")?.Value);
- y2 ??= ParseCoordNullable(cur.Attribute("y2")?.Value);
+ // The geometry attributes belong to linearGradient alone, so a radialGradient template
+ // in the chain supplies only the common attributes and stops.
+ if (cur.Name.LocalName == "linearGradient")
+ {
+ 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())
+ // Stops come from the first element in the chain with stop children, even if none of them
+ // parse. Later elements may still supply attributes, so the walk continues past it.
+ if (!hasStops)
{
- if (s.Name.LocalName != "stop")
+ foreach (XElement s in cur.Elements())
{
- continue;
- }
+ if (s.Name.LocalName != "stop")
+ {
+ continue;
+ }
- if (TryParseStop(s, out float off, out GlyphColor c))
- {
- stops.Add((off, c));
- hadStops = true;
+ hasStops = true;
+ if (TryParseStop(s, out float off, out GlyphColor c))
+ {
+ stops.Add((off, c));
+ }
}
}
- if (hadStops)
- {
- break;
- }
-
string? href = GetHref(cur);
if (href is null || href.Length <= 1 || href[0] != '#')
{
break;
}
+ // A template must be a linearGradient or radialGradient element. A reference to anything
+ // else is invalid and ignored, ending the chain without reading the target's attributes.
string refId = href[1..];
- if (!visited.Add(refId) || !idMap.TryGetValue(refId, out cur))
+ if (!visited.Add(refId)
+ || !idMap.TryGetValue(refId, out cur)
+ || cur.Name.LocalName is not ("linearGradient" or "radialGradient"))
{
break;
}
@@ -563,9 +604,11 @@ private static FillRule ResolveFillRule(XElement e, FillRule inheritedDefault)
y1 = 0f;
}
+ GradientUnits resolvedUnits = units ?? GradientUnits.ObjectBoundingBox;
+
if (!x2.HasValue)
{
- x2 = units == GradientUnits.ObjectBoundingBox ? 1f : 0f;
+ x2 = resolvedUnits == GradientUnits.ObjectBoundingBox ? 1f : 0f;
}
if (!y2.HasValue)
@@ -577,96 +620,128 @@ private static FillRule ResolveFillRule(XElement e, FillRule inheritedDefault)
return new LinearGradientPaint
{
- Units = units,
+ Units = resolvedUnits,
P0 = new Vector2(x1.Value, y1.Value),
P1 = new Vector2(x2.Value, y2.Value),
- Spread = spread,
+ Spread = spread ?? SpreadMethod.Pad,
Stops = gs,
- Transform = gxf
+ Transform = gxf ?? Matrix3x2.Identity
};
}
- private static RadialGradientPaint? BuildRadialGradient(XElement grad, Dictionary idMap)
+ ///
+ /// Builds a radial gradient paint from a radialGradient element, resolving the attributes
+ /// and stops it inherits through its href chain.
+ ///
+ /// The radialGradient element to build.
+ /// The id was referenced by.
+ /// The document's elements by id, used to follow href references.
+ /// The radial gradient paint.
+ private static RadialGradientPaint? BuildRadialGradient(XElement grad, string id, Dictionary idMap)
{
- GradientUnits units = GradientUnits.ObjectBoundingBox;
- SpreadMethod spread = SpreadMethod.Pad;
- Matrix3x2 gxf = Matrix3x2.Identity;
+ // A gradient inherits an attribute or its stops from the gradient it references only when
+ // it does not specify them itself (SVG 1.1 §13.2.3 xlink:href; SVG 2 paint server templates).
+ // The chain is walked from the referencing gradient outward, so the first element to specify
+ // a value supplies it and later ones are ignored. Null means "not yet specified".
+ GradientUnits? units = null;
+ SpreadMethod? spread = null;
+ Matrix3x2? gxf = null;
float? cx = null, cy = null, r = null, fx = null, fy = null, fr = null;
List<(float Offset, GlyphColor Color)> stops = [];
+ bool hasStops = false;
+
+ // Seed the visited set with the starting gradient so a chain that leads back to it ends there.
+ HashSet visited = new(StringComparer.Ordinal) { id };
- HashSet visited = new(StringComparer.Ordinal);
XElement? cur = grad;
while (cur is not null)
{
+ // An enumerated or transform attribute counts as specified when it is present, matching
+ // browsers. A value the parsers do not recognize resolves to the attribute's initial value
+ // (objectBoundingBox, pad, identity) and still hides the referenced gradient's value.
string? u = cur.Attribute("gradientUnits")?.Value;
- if (u is not null)
+ if (units is null && u is not null)
{
units = ParseGradientUnits(u);
}
string? sm = cur.Attribute("spreadMethod")?.Value;
- if (sm is not null)
+ if (spread is null && sm is not null)
{
spread = ParseSpreadMethod(sm);
}
- gxf = ParseTransform(cur.Attribute("gradientTransform")?.Value) * gxf;
+ string? t = cur.Attribute("gradientTransform")?.Value;
+ if (gxf is null && t is not null)
+ {
+ gxf = ParseTransform(t);
+ }
- 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);
+ // The geometry attributes belong to radialGradient alone, so a linearGradient template
+ // in the chain supplies only the common attributes and stops.
+ if (cur.Name.LocalName == "radialGradient")
+ {
+ 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())
+ // Stops come from the first element in the chain with stop children, even if none of them
+ // parse. Later elements may still supply attributes, so the walk continues past it.
+ if (!hasStops)
{
- if (s.Name.LocalName != "stop")
+ foreach (XElement s in cur.Elements())
{
- continue;
- }
+ if (s.Name.LocalName != "stop")
+ {
+ continue;
+ }
- if (TryParseStop(s, out float off, out GlyphColor c))
- {
- stops.Add((off, c));
- hadStops = true;
+ hasStops = true;
+ if (TryParseStop(s, out float off, out GlyphColor c))
+ {
+ stops.Add((off, c));
+ }
}
}
- if (hadStops)
- {
- break;
- }
-
string? href = GetHref(cur);
if (href is null || href.Length <= 1 || href[0] != '#')
{
break;
}
+ // A template must be a linearGradient or radialGradient element. A reference to anything
+ // else is invalid and ignored, ending the chain without reading the target's attributes.
string refId = href[1..];
- if (!visited.Add(refId) || !idMap.TryGetValue(refId, out cur))
+ if (!visited.Add(refId)
+ || !idMap.TryGetValue(refId, out cur)
+ || cur.Name.LocalName is not ("linearGradient" or "radialGradient"))
{
break;
}
}
+ GradientUnits resolvedUnits = units ?? GradientUnits.ObjectBoundingBox;
+
if (!cx.HasValue)
{
- cx = units == GradientUnits.ObjectBoundingBox ? 0.5f : 0f;
+ cx = resolvedUnits == GradientUnits.ObjectBoundingBox ? 0.5f : 0f;
}
if (!cy.HasValue)
{
- cy = units == GradientUnits.ObjectBoundingBox ? 0.5f : 0f;
+ cy = resolvedUnits == GradientUnits.ObjectBoundingBox ? 0.5f : 0f;
}
if (!r.HasValue)
{
- r = units == GradientUnits.ObjectBoundingBox ? 0.5f : 0f;
+ r = resolvedUnits == GradientUnits.ObjectBoundingBox ? 0.5f : 0f;
}
if (!fx.HasValue)
@@ -689,14 +764,14 @@ private static FillRule ResolveFillRule(XElement e, FillRule inheritedDefault)
// Center0=(fx,fy), Radius0=fr; Center1=(cx,cy), Radius1=r
return new RadialGradientPaint
{
- Units = units,
+ Units = resolvedUnits,
Center0 = new Vector2(fx.Value, fy.Value),
Radius0 = fr.Value,
Center1 = new Vector2(cx.Value, cy.Value),
Radius1 = r.Value,
- Spread = spread,
+ Spread = spread ?? SpreadMethod.Pad,
Stops = gs,
- Transform = gxf
+ Transform = gxf ?? Matrix3x2.Identity
};
}
diff --git a/tests/SixLabors.Fonts.Tests/SvgGradientInheritanceTests.cs b/tests/SixLabors.Fonts.Tests/SvgGradientInheritanceTests.cs
new file mode 100644
index 000000000..200674fb0
--- /dev/null
+++ b/tests/SixLabors.Fonts.Tests/SvgGradientInheritanceTests.cs
@@ -0,0 +1,419 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Numerics;
+using SixLabors.Fonts.Rendering;
+
+namespace SixLabors.Fonts.Tests;
+
+///
+/// Tests that SVG gradients referencing a template gradient through href inherit only the attributes they
+/// do not specify themselves.
+///
+///
+/// Each test renders a document whose gradient g uses an href chain, and an equivalent document whose
+/// gradient g spells out the resolved attributes directly, and asserts that both produce the same paint.
+/// Comparing against an equivalent document keeps the tests independent of how the paint is mapped to device space.
+///
+public class SvgGradientInheritanceTests
+{
+ ///
+ /// Two gradient stops shared by the documents in these tests.
+ ///
+ private const string Stops = "";
+
+ ///
+ /// A square filled with gradient g , drawn above the baseline.
+ ///
+ private const string GlyphBody = "";
+
+ ///
+ /// A linear gradient's own gradientTransform replaces the referenced gradient's rather than combining with it.
+ ///
+ [Fact]
+ public void LinearGradient_OwnGradientTransformOverridesReferenced()
+ => AssertEquivalentPaint(
+ $"{Stops} " +
+ " ",
+ $"{Stops} ");
+
+ ///
+ /// A linear gradient without a gradientTransform inherits the referenced gradient's.
+ ///
+ [Fact]
+ public void LinearGradient_InheritsGradientTransformWhenNotSpecified()
+ => AssertEquivalentPaint(
+ $"{Stops} " +
+ "",
+ $"{Stops} ");
+
+ ///
+ /// Along a chain of references, the nearest gradient that specifies a gradientTransform supplies it.
+ ///
+ [Fact]
+ public void LinearGradient_NearestGradientTransformInChainWins()
+ => AssertEquivalentPaint(
+ $"{Stops} " +
+ " " +
+ "",
+ $"{Stops} ");
+
+ ///
+ /// A linear gradient's own gradientUnits overrides the referenced gradient's, in either direction.
+ ///
+ /// The units specified by the referencing gradient.
+ /// The units specified by the referenced gradient.
+ [Theory]
+ [InlineData("userSpaceOnUse", "objectBoundingBox")]
+ [InlineData("objectBoundingBox", "userSpaceOnUse")]
+ public void LinearGradient_OwnGradientUnitsOverridesReferenced(string own, string referenced)
+ => AssertEquivalentPaint(
+ $"{Stops} " +
+ $"",
+ $"{Stops} ");
+
+ ///
+ /// A linear gradient without gradientUnits inherits the referenced gradient's.
+ ///
+ [Fact]
+ public void LinearGradient_InheritsGradientUnitsWhenNotSpecified()
+ => AssertEquivalentPaint(
+ $"{Stops} " +
+ "",
+ $"{Stops} ");
+
+ ///
+ /// A linear gradient's own spreadMethod overrides the referenced gradient's, in either direction.
+ ///
+ /// The spread method specified by the referencing gradient.
+ /// The spread method specified by the referenced gradient.
+ [Theory]
+ [InlineData("reflect", "repeat")]
+ [InlineData("pad", "reflect")]
+ [InlineData("repeat", "pad")]
+ public void LinearGradient_OwnSpreadMethodOverridesReferenced(string own, string referenced)
+ => AssertEquivalentPaint(
+ $"{Stops} " +
+ $"",
+ $"{Stops} ");
+
+ ///
+ /// A specified but unrecognized spreadMethod resolves to the initial value pad
+ /// and still overrides the referenced gradient's.
+ ///
+ [Fact]
+ public void LinearGradient_UnrecognizedSpreadMethodStillOverridesReferenced()
+ => AssertEquivalentPaint(
+ $"{Stops} " +
+ "",
+ $"{Stops} ");
+
+ ///
+ /// A linear gradient with its own stops still inherits the attributes it does not specify from the referenced gradient.
+ ///
+ [Fact]
+ public void LinearGradient_WithOwnStopsInheritsUnspecifiedAttributes()
+ => AssertEquivalentPaint(
+ " " +
+ $"{Stops} ",
+ $"{Stops} ");
+
+ ///
+ /// A radial gradient's own gradientTransform replaces the referenced gradient's rather than combining with it.
+ ///
+ [Fact]
+ public void RadialGradient_OwnGradientTransformOverridesReferenced()
+ => AssertEquivalentPaint(
+ $"{Stops} " +
+ " ",
+ $"{Stops} ");
+
+ ///
+ /// A radial gradient without a gradientTransform inherits the referenced gradient's.
+ ///
+ [Fact]
+ public void RadialGradient_InheritsGradientTransformWhenNotSpecified()
+ => AssertEquivalentPaint(
+ $"{Stops} " +
+ "",
+ $"{Stops} ");
+
+ ///
+ /// Along a chain of references, the nearest gradient that specifies a gradientTransform supplies it.
+ ///
+ [Fact]
+ public void RadialGradient_NearestGradientTransformInChainWins()
+ => AssertEquivalentPaint(
+ $"{Stops} " +
+ " " +
+ "",
+ $"{Stops} ");
+
+ ///
+ /// A radial gradient's own gradientUnits overrides the referenced gradient's, in either direction.
+ ///
+ /// The units specified by the referencing gradient.
+ /// The units specified by the referenced gradient.
+ [Theory]
+ [InlineData("userSpaceOnUse", "objectBoundingBox")]
+ [InlineData("objectBoundingBox", "userSpaceOnUse")]
+ public void RadialGradient_OwnGradientUnitsOverridesReferenced(string own, string referenced)
+ => AssertEquivalentPaint(
+ $"{Stops} " +
+ $"",
+ $"{Stops} ");
+
+ ///
+ /// A radial gradient without gradientUnits inherits the referenced gradient's.
+ ///
+ [Fact]
+ public void RadialGradient_InheritsGradientUnitsWhenNotSpecified()
+ => AssertEquivalentPaint(
+ $"{Stops} " +
+ "",
+ $"{Stops} ");
+
+ ///
+ /// A radial gradient's own spreadMethod overrides the referenced gradient's, in either direction.
+ ///
+ /// The spread method specified by the referencing gradient.
+ /// The spread method specified by the referenced gradient.
+ [Theory]
+ [InlineData("reflect", "repeat")]
+ [InlineData("pad", "reflect")]
+ [InlineData("repeat", "pad")]
+ public void RadialGradient_OwnSpreadMethodOverridesReferenced(string own, string referenced)
+ => AssertEquivalentPaint(
+ $"{Stops} " +
+ $"",
+ $"{Stops} ");
+
+ ///
+ /// A radial gradient with its own stops still inherits the attributes it does not specify from the referenced gradient.
+ ///
+ [Fact]
+ public void RadialGradient_WithOwnStopsInheritsUnspecifiedAttributes()
+ => AssertEquivalentPaint(
+ " " +
+ $"{Stops} ",
+ $"{Stops} ");
+
+ ///
+ /// A linear gradient referencing an element that is not a gradient ignores the reference,
+ /// and so does not take geometry from a line with the same attribute names.
+ ///
+ [Fact]
+ public void LinearGradient_IgnoresReferenceToNonGradientElement()
+ => AssertEquivalentPaint(
+ " " +
+ $"{Stops} ",
+ $"{Stops} ");
+
+ ///
+ /// A radial gradient referencing an element that is not a gradient ignores the reference, and so neither takes
+ /// geometry from a circle with the same attribute names nor follows the circle's own reference.
+ ///
+ [Fact]
+ public void RadialGradient_IgnoresReferenceToNonGradientElement()
+ => AssertEquivalentPaint(
+ "" +
+ " " +
+ $"{Stops} ",
+ $"{Stops} ");
+
+ ///
+ /// A linear gradient referencing a radial gradient inherits the attributes common to both gradient types and the
+ /// stops, but not geometry attributes, which only apply to linear gradients.
+ ///
+ [Fact]
+ public void LinearGradient_InheritsOnlyCommonAttributesFromRadialGradient()
+ => AssertEquivalentPaint(
+ "{Stops} " +
+ "",
+ $"{Stops} ");
+
+ ///
+ /// A radial gradient takes its geometry from the nearest radial gradient in its chain, passing over
+ /// a linear gradient in between, while still inheriting the common attributes from that linear gradient.
+ ///
+ [Fact]
+ public void RadialGradient_InheritsGeometryOnlyFromRadialGradients()
+ => AssertEquivalentPaint(
+ "" +
+ $"{Stops} " +
+ "",
+ $"{Stops} ");
+
+ ///
+ /// A linear gradient with stop children does not inherit the referenced gradient's stops,
+ /// even when its own stops have invalid values.
+ ///
+ [Fact]
+ public void LinearGradient_WithInvalidStopsDoesNotInheritStops()
+ => AssertEquivalentPaint(
+ $"{Stops} " +
+ " ",
+ " ");
+
+ ///
+ /// A radial gradient with stop children does not inherit the referenced gradient's stops,
+ /// even when its own stops have invalid values.
+ ///
+ [Fact]
+ public void RadialGradient_WithInvalidStopsDoesNotInheritStops()
+ => AssertEquivalentPaint(
+ $"{Stops} " +
+ " ",
+ " ");
+
+ ///
+ /// A chain that refers back to the starting linear gradient terminates and resolves each attribute once.
+ ///
+ [Fact]
+ public void LinearGradient_CycleBackToStartTerminates()
+ => AssertEquivalentPaint(
+ $"{Stops} " +
+ "",
+ $"{Stops} ");
+
+ ///
+ /// A chain that refers back to the starting radial gradient terminates and resolves each attribute once.
+ ///
+ [Fact]
+ public void RadialGradient_CycleBackToStartTerminates()
+ => AssertEquivalentPaint(
+ $"{Stops} " +
+ "",
+ $"{Stops} ");
+
+ ///
+ /// A radial gradient's own focal radius is kept when a referenced radial gradient specifies another.
+ ///
+ [Fact]
+ public void RadialGradient_OwnFocalRadiusOverridesReferenced()
+ => AssertEquivalentPaint(
+ "" +
+ $"{Stops} ",
+ $"{Stops} ");
+
+ ///
+ /// The defaults for a radial gradient's unspecified center and radius follow the gradient units it
+ /// inherits, not the initial objectBoundingBox units.
+ ///
+ [Fact]
+ public void RadialGradient_DefaultGeometryFollowsInheritedUnits()
+ => AssertEquivalentPaint(
+ "" +
+ $"{Stops} ",
+ $"{Stops} ");
+
+ ///
+ /// Renders both documents and asserts that their gradient g resolves to the same paint.
+ ///
+ /// Definitions in which gradient g references other gradients.
+ /// Definitions in which gradient g specifies the expected attributes directly.
+ private static void AssertEquivalentPaint(string chainedDefs, string flatDefs)
+ {
+ Paint expected = RenderPaint(flatDefs);
+ Paint actual = RenderPaint(chainedDefs);
+
+ Assert.Equal(expected.GetType(), actual.GetType());
+ AssertEqual(expected.Transform, actual.Transform);
+
+ switch (expected)
+ {
+ case LinearGradientPaint e:
+ {
+ LinearGradientPaint a = (LinearGradientPaint)actual;
+ Assert.Equal(e.Units, a.Units);
+ Assert.Equal(e.Spread, a.Spread);
+ AssertEqual(e.P0, a.P0);
+ AssertEqual(e.P1, a.P1);
+ Assert.Equal(e.P2.HasValue, a.P2.HasValue);
+ AssertEqual(e.P2.GetValueOrDefault(), a.P2.GetValueOrDefault());
+ Assert.Equal(e.Stops, a.Stops);
+ break;
+ }
+
+ case RadialGradientPaint e:
+ {
+ RadialGradientPaint a = (RadialGradientPaint)actual;
+ Assert.Equal(e.Units, a.Units);
+ Assert.Equal(e.Spread, a.Spread);
+ AssertEqual(e.Center0, a.Center0);
+ AssertEqual(e.Center1, a.Center1);
+ Assert.Equal(e.Radius0, a.Radius0, 3);
+ Assert.Equal(e.Radius1, a.Radius1, 3);
+ Assert.Equal(e.Stops, a.Stops);
+ break;
+ }
+
+ default:
+ {
+ Assert.Fail($"Expected a gradient paint but found {expected.GetType().Name}.");
+ break;
+ }
+ }
+ }
+
+ ///
+ /// Renders the glyph of a font whose SVG document holds the given definitions and returns the single paint it draws with.
+ ///
+ /// The SVG definitions placed outside the glyph's group element.
+ /// The paint passed to the renderer.
+ private static Paint RenderPaint(string defs)
+ {
+ Font font = SvgTestFonts.CreateFont(GlyphBody, defs);
+ TextOptions options = new(font) { ColorFontSupport = ColorFontSupport.Svg };
+
+ PaintCaptureRenderer renderer = new();
+ TextRenderer.RenderTo(renderer, "A", options);
+
+ return Assert.Single(renderer.Paints);
+ }
+
+ ///
+ /// Asserts that two matrices are equal within a small tolerance.
+ ///
+ /// The expected matrix.
+ /// The actual matrix.
+ private static void AssertEqual(Matrix3x2 expected, Matrix3x2 actual)
+ {
+ Assert.Equal(expected.M11, actual.M11, 3);
+ Assert.Equal(expected.M12, actual.M12, 3);
+ Assert.Equal(expected.M21, actual.M21, 3);
+ Assert.Equal(expected.M22, actual.M22, 3);
+ Assert.Equal(expected.M31, actual.M31, 3);
+ Assert.Equal(expected.M32, actual.M32, 3);
+ }
+
+ ///
+ /// Asserts that two points are equal within a small tolerance.
+ ///
+ /// The expected point.
+ /// The actual point.
+ private static void AssertEqual(Vector2 expected, Vector2 actual)
+ {
+ Assert.Equal(expected.X, actual.X, 3);
+ Assert.Equal(expected.Y, actual.Y, 3);
+ }
+
+ ///
+ /// A renderer that records the paint of every layer it is asked to draw.
+ ///
+ private sealed class PaintCaptureRenderer : GlyphRenderer
+ {
+ ///
+ /// Gets the paints passed to , in order.
+ ///
+ public List Paints { get; } = [];
+
+ ///
+ public override void BeginLayer(Paint paint, FillRule fillRule)
+ {
+ this.Paints.Add(paint);
+ base.BeginLayer(paint, fillRule);
+ }
+ }
+}
diff --git a/tests/SixLabors.Fonts.Tests/SvgRootTransformTests.cs b/tests/SixLabors.Fonts.Tests/SvgRootTransformTests.cs
new file mode 100644
index 000000000..ab956d2e7
--- /dev/null
+++ b/tests/SixLabors.Fonts.Tests/SvgRootTransformTests.cs
@@ -0,0 +1,265 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Globalization;
+using System.Numerics;
+using SixLabors.Fonts.Rendering;
+
+namespace SixLabors.Fonts.Tests;
+
+///
+/// Tests that a transform on the root <svg> element of an OT-SVG glyph document applies exactly
+/// once, in the parent space outside the viewBox mapping.
+///
+public class SvgRootTransformTests
+{
+ ///
+ /// The opening tag attributes shared by every document in these tests.
+ ///
+ private const string Namespaces = "xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\"";
+
+ ///
+ /// A root translation moves a glyph{gid} group by the translation once.
+ ///
+ [Fact]
+ public void RootTransformAppliesOnceToGlyphGroup()
+ {
+ // translate(100 -200) moves the rect's (0, -500) corner to (100, -700).
+ Font transformed = SvgTestFonts.CreateFontFromDocument(gid => Format(
+ $" "));
+
+ Font expected = SvgTestFonts.CreateFontFromDocument(gid => Format(
+ $" "));
+
+ AssertSamePoints(expected, transformed);
+ }
+
+ ///
+ /// A root translation moves the content of a document without a glyph{gid} element, which renders
+ /// from the root element itself, by the translation once.
+ ///
+ [Fact]
+ public void RootTransformAppliesOnceWithoutGlyphId()
+ {
+ // translate(100 -200) moves the rect's (0, -500) corner to (100, -700).
+ Font transformed = SvgTestFonts.CreateFontFromDocument(_ =>
+ $" ");
+
+ Font expected = SvgTestFonts.CreateFontFromDocument(_ =>
+ $" ");
+
+ AssertSamePoints(expected, transformed);
+ }
+
+ ///
+ /// The root transform applies once in font units, after the viewBox has mapped user space onto the em.
+ ///
+ [Fact]
+ public void RootTransformAppliesOnceOutsideViewBox()
+ {
+ ushort upem = new FontCollection().Add(TestFonts.SimpleFontFile).CreateFont(100).FontMetrics.UnitsPerEm;
+ float side = upem * 2F;
+
+ // The viewBox spans two em in each direction, so user space is scaled by 1/2 into font units:
+ // the rect (0, -1000) 600x800 becomes (0, -500) 300x400. matrix(2 0 0 2 100 -200) then maps
+ // (x, y) to (2x + 100, 2y - 200) in font units, giving (100, -1200) 600x800. Applying the
+ // matrix inside the viewBox instead would place the rect at (50, -1100) 600x800.
+ Font transformed = SvgTestFonts.CreateFontFromDocument(gid => Format(
+ $" "));
+
+ Font expected = SvgTestFonts.CreateFontFromDocument(gid => Format(
+ $" "));
+
+ AssertSamePoints(expected, transformed);
+ }
+
+ ///
+ /// A rotated root transform yields the same tight ink bounds as the equivalent pre-rotated geometry,
+ /// not the looser box around the rotated bounds of the unrotated geometry.
+ ///
+ [Fact]
+ public void RotatedRootTransformKeepsTightBounds()
+ {
+ // A thin diagonal strip. rotate(45) maps (x, y) to (c(x - y), c(x + y)) with c = sqrt(2) / 2, so
+ // (0, 0) -> (0, 0), (500, -500) -> (707.107, 0), (490, -510) -> (707.107, -14.142) and
+ // (-10, -10) -> (0, -14.142): an axis-aligned 707.107 x 14.142 rectangle. Rotating the strip's
+ // own 510 x 510 box instead would give a box roughly 721 units on each side.
+ Font transformed = SvgTestFonts.CreateFontFromDocument(gid => Format(
+ $" "));
+
+ Font expected = SvgTestFonts.CreateFontFromDocument(gid => Format(
+ $" "));
+
+ ApproximateFloatComparer comparer = new(0.01F);
+
+ TextOptions expectedOptions = new(expected) { ColorFontSupport = ColorFontSupport.Svg };
+ TextOptions transformedOptions = new(transformed) { ColorFontSupport = ColorFontSupport.Svg };
+ Assert.Equal(TextMeasurer.MeasureBounds("A", expectedOptions), TextMeasurer.MeasureBounds("A", transformedOptions), comparer);
+
+ GlyphRenderer expectedRenderer = new();
+ GlyphRenderer transformedRenderer = new();
+ TextRenderer.RenderTo(expectedRenderer, "A", expectedOptions);
+ TextRenderer.RenderTo(transformedRenderer, "A", transformedOptions);
+ Assert.Equal(Assert.Single(expectedRenderer.GlyphRects), Assert.Single(transformedRenderer.GlyphRects), comparer);
+ }
+
+ ///
+ /// A quarter-turn root transform keeps the axes aligned, so the bounds match the equivalent pre-rotated
+ /// geometry exactly.
+ ///
+ [Fact]
+ public void QuarterTurnRootTransformKeepsExactBounds()
+ {
+ // rotate(90) maps (x, y) to (-y, x), so the rect x 0..300, y -500..-100 becomes x 100..500, y 0..300.
+ Font transformed = SvgTestFonts.CreateFontFromDocument(gid => Format(
+ $" "));
+
+ Font expected = SvgTestFonts.CreateFontFromDocument(gid => Format(
+ $" "));
+
+ AssertSameBounds(expected, transformed);
+ }
+
+ ///
+ /// A skewed root transform yields the same tight bounds as the equivalent pre-skewed geometry.
+ ///
+ [Fact]
+ public void SkewedRootTransformKeepsTightBounds()
+ {
+ // skewX(45) maps (x, y) to (x + y, y), so the triangle (0, 0), (300, 0), (300, -300) becomes
+ // (0, 0), (300, 0), (0, -300), x 0..300. Skewing the triangle's own box instead would give
+ // x -300..300.
+ Font transformed = SvgTestFonts.CreateFontFromDocument(gid => Format(
+ $" "));
+
+ Font expected = SvgTestFonts.CreateFontFromDocument(gid => Format(
+ $" "));
+
+ AssertSameBounds(expected, transformed);
+ }
+
+ ///
+ /// Under a rotated root transform the bounds of a cubic curve are measured from its transformed control
+ /// points, matching the equivalent pre-rotated curve.
+ ///
+ [Fact]
+ public void RotatedRootTransformMeasuresCubicControlPoints()
+ {
+ // rotate(45) maps (x, y) to (c(x - y), c(x + y)) with c = sqrt(2) / 2, so the control points
+ // (0, -400), (400, -400) and the end point (400, 0) become (282.843, -282.843), (565.685, 0)
+ // and (282.843, 282.843).
+ Font transformed = SvgTestFonts.CreateFontFromDocument(gid => Format(
+ $" "));
+
+ Font expected = SvgTestFonts.CreateFontFromDocument(gid => Format(
+ $" "));
+
+ AssertSameBounds(expected, transformed);
+ }
+
+ ///
+ /// Under a rotated root transform the bounds of an arc still enclose the whole arc.
+ ///
+ [Fact]
+ public void RotatedRootTransformBoundsEncloseArcs()
+ {
+ // rotate(45) moves the circle's centre (300, -300) to (424.264, 0). A diamond through the rotated
+ // circle's four extreme points has exactly the circle's true bounds.
+ Font transformed = SvgTestFonts.CreateFontFromDocument(gid => Format(
+ $" "));
+
+ Font circleExtent = SvgTestFonts.CreateFontFromDocument(gid => Format(
+ $" "));
+
+ FontRectangle actual = TextMeasurer.MeasureBounds("A", new TextOptions(transformed) { ColorFontSupport = ColorFontSupport.Svg });
+ FontRectangle extent = TextMeasurer.MeasureBounds("A", new TextOptions(circleExtent) { ColorFontSupport = ColorFontSupport.Svg });
+
+ const float tolerance = 0.01F;
+ Assert.True(actual.Left <= extent.Left + tolerance, $"{actual} does not enclose {extent}");
+ Assert.True(actual.Top <= extent.Top + tolerance, $"{actual} does not enclose {extent}");
+ Assert.True(actual.Right >= extent.Right - tolerance, $"{actual} does not enclose {extent}");
+ Assert.True(actual.Bottom >= extent.Bottom - tolerance, $"{actual} does not enclose {extent}");
+ }
+
+ ///
+ /// fill="none" on the root element, which renders as the glyph when no glyph{gid} element exists,
+ /// does not hide a child's own fill.
+ ///
+ [Fact]
+ public void RootFillNoneKeepsChildFill()
+ {
+ Font font = SvgTestFonts.CreateFontFromDocument(_ =>
+ $" ");
+
+ Font expected = SvgTestFonts.CreateFontFromDocument(_ =>
+ $" ");
+
+ AssertSamePoints(expected, font);
+
+ ColorGlyphRenderer renderer = new();
+ TextRenderer.RenderTo(renderer, "A", new TextOptions(font) { ColorFontSupport = ColorFontSupport.Svg });
+ Assert.Equal(new GlyphColor(255, 0, 0, 255), Assert.Single(renderer.Colors));
+ }
+
+ ///
+ /// Formats an interpolated document string with the invariant culture so numbers use '.' separators.
+ ///
+ /// The interpolated document.
+ /// The formatted document.
+ private static string Format(FormattableString document)
+ => document.ToString(CultureInfo.InvariantCulture);
+
+ ///
+ /// Renders 'A' from both fonts and asserts that the SVG glyphs emit the same device-space points.
+ ///
+ /// The font with the hand-computed, untransformed document.
+ /// The font with the transformed document.
+ private static void AssertSamePoints(Font expected, Font actual)
+ {
+ List expectedPoints = RenderPoints(expected);
+ List actualPoints = RenderPoints(actual);
+
+ Assert.NotEmpty(expectedPoints);
+ Assert.Equal(expectedPoints.Count, actualPoints.Count);
+
+ ApproximateFloatComparer comparer = new(0.01F);
+ for (int i = 0; i < expectedPoints.Count; i++)
+ {
+ Assert.Equal(expectedPoints[i], actualPoints[i], comparer);
+ }
+ }
+
+ ///
+ /// Asserts that 'A' from both fonts has the same measured ink bounds and the same glyph box passed to the renderer.
+ ///
+ /// The font with the hand-computed, untransformed document.
+ /// The font with the transformed document.
+ private static void AssertSameBounds(Font expected, Font actual)
+ {
+ ApproximateFloatComparer comparer = new(0.01F);
+
+ TextOptions expectedOptions = new(expected) { ColorFontSupport = ColorFontSupport.Svg };
+ TextOptions actualOptions = new(actual) { ColorFontSupport = ColorFontSupport.Svg };
+ Assert.Equal(TextMeasurer.MeasureBounds("A", expectedOptions), TextMeasurer.MeasureBounds("A", actualOptions), comparer);
+
+ GlyphRenderer expectedRenderer = new();
+ GlyphRenderer actualRenderer = new();
+ TextRenderer.RenderTo(expectedRenderer, "A", expectedOptions);
+ TextRenderer.RenderTo(actualRenderer, "A", actualOptions);
+ Assert.Equal(Assert.Single(expectedRenderer.GlyphRects), Assert.Single(actualRenderer.GlyphRects), comparer);
+ }
+
+ ///
+ /// Renders 'A' through the SVG glyph pipeline and returns the device-space points it emits.
+ ///
+ /// The font to render.
+ /// The emitted points, in order.
+ private static List RenderPoints(Font font)
+ {
+ GlyphRenderer renderer = new();
+ TextRenderer.RenderTo(renderer, "A", new TextOptions(font) { ColorFontSupport = ColorFontSupport.Svg });
+
+ Assert.Contains(renderer.GlyphKeys, k => k.GlyphType == GlyphType.Painted);
+ return renderer.ControlPoints;
+ }
+}
diff --git a/tests/SixLabors.Fonts.Tests/SvgTestFonts.cs b/tests/SixLabors.Fonts.Tests/SvgTestFonts.cs
new file mode 100644
index 000000000..0d048e523
--- /dev/null
+++ b/tests/SixLabors.Fonts.Tests/SvgTestFonts.cs
@@ -0,0 +1,101 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Buffers.Binary;
+using System.Globalization;
+using System.Text;
+using SixLabors.Fonts.Tables;
+using SixLabors.Fonts.Unicode;
+
+namespace SixLabors.Fonts.Tests;
+
+///
+/// Builds fonts whose glyph for 'A' is drawn from an SVG document, so tests can exercise the SVG glyph pipeline
+/// with documents written inline.
+///
+internal static class SvgTestFonts
+{
+ ///
+ /// Creates a font whose glyph for 'A' is drawn from an SVG document.
+ ///
+ /// The SVG markup to place inside the glyph's glyph{gid} group element.
+ /// Optional SVG markup to place in the document outside the glyph's group element.
+ /// The font size.
+ /// The font.
+ public static Font CreateFont(string glyphBody, string defs = "", float size = 100)
+ {
+ byte[] source = File.ReadAllBytes(TestFonts.SimpleFontFile);
+ Font plain = new FontCollection().Add(new MemoryStream(source)).CreateFont(size);
+ Assert.True(plain.TryGetGlyphId(new CodePoint('A'), out ushort glyphId));
+
+ string svg = string.Create(
+ CultureInfo.InvariantCulture,
+ $"{defs}{glyphBody} ");
+
+ return new FontCollection().Add(AddSvgTable(source, glyphId, svg)).CreateFont(size);
+ }
+
+ ///
+ /// Creates a font whose glyph for 'A' is drawn from a complete SVG document, so tests can control the root
+ /// <svg> element and whether a glyph{gid} element exists at all.
+ ///
+ /// Builds the complete SVG document from the glyph id of 'A'.
+ /// The font size.
+ /// The font.
+ public static Font CreateFontFromDocument(Func document, float size = 100)
+ {
+ byte[] source = File.ReadAllBytes(TestFonts.SimpleFontFile);
+ Font plain = new FontCollection().Add(new MemoryStream(source)).CreateFont(size);
+ Assert.True(plain.TryGetGlyphId(new CodePoint('A'), out ushort glyphId));
+
+ return new FontCollection().Add(AddSvgTable(source, glyphId, document(glyphId))).CreateFont(size);
+ }
+
+ ///
+ /// Returns a copy of a TrueType font with an 'SVG ' table holding one document for one glyph.
+ ///
+ /// The source font, which must not already contain an 'SVG ' table.
+ /// The glyph the document applies to.
+ /// The SVG document.
+ /// A stream over the new font.
+ private static MemoryStream AddSvgTable(byte[] font, ushort glyphId, string svgDocument)
+ {
+ // Lift the existing tables out of the source font's table directory.
+ int numTables = BinaryPrimitives.ReadUInt16BigEndian(font.AsSpan(4));
+ List<(string Tag, byte[] Data)> tables = [];
+ for (int i = 0; i < numTables; i++)
+ {
+ ReadOnlySpan record = font.AsSpan(12 + (16 * i), 16);
+ string tag = Encoding.ASCII.GetString(record[..4]);
+ int offset = (int)BinaryPrimitives.ReadUInt32BigEndian(record[8..]);
+ int length = (int)BinaryPrimitives.ReadUInt32BigEndian(record[12..]);
+ tables.Add((tag, font.AsSpan(offset, length).ToArray()));
+ }
+
+ // SVG table: the header, a document index with a single entry, then the document.
+ byte[] document = Encoding.UTF8.GetBytes(svgDocument);
+ BigEndianBinaryWriter svg = new();
+ svg.WriteUInt16(0); // version
+ svg.WriteUInt32(10); // svgDocumentListOffset
+ svg.WriteUInt32(0); // reserved
+ svg.WriteUInt16(1); // numEntries
+ svg.WriteUInt16(glyphId); // startGlyphID
+ svg.WriteUInt16(glyphId); // endGlyphID
+ svg.WriteUInt32(14); // svgDocOffset, from the start of the document index
+ svg.WriteUInt32((uint)document.Length); // svgDocLength
+ svg.Write(document);
+
+ tables.Add(("SVG ", svg.GetStream().ToArray()));
+ tables.Sort((x, y) => string.CompareOrdinal(x.Tag, y.Tag));
+
+ // The header lays the tables out back to back in directory order.
+ BigEndianBinaryWriter writer = new();
+ writer.WriteTrueTypeFileHeader([.. tables.Select(t => new TableHeader(t.Tag, 0, 0, (uint)t.Data.Length))]);
+ foreach ((_, byte[] data) in tables)
+ {
+ writer.Write(data);
+ }
+
+ return writer.GetStream();
+ }
+}