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(); + } +}