diff --git a/src/SixLabors.Fonts/FontGlyphMetrics.cs b/src/SixLabors.Fonts/FontGlyphMetrics.cs
index 74883360e..4d794e4f9 100644
--- a/src/SixLabors.Fonts/FontGlyphMetrics.cs
+++ b/src/SixLabors.Fonts/FontGlyphMetrics.cs
@@ -23,6 +23,14 @@ public abstract class FontGlyphMetrics
///
private const float SyntheticObliqueSkew = 0.24932839F;
+ ///
+ /// The outward outline offset, expressed as a fraction of the em size, applied to synthesize
+ /// a bold (faux bold) weight when a bold style is requested but the resolved font face provides
+ /// no bold face. The value is tuned to visually approximate a real bold weight, mirroring the
+ /// CSS font-synthesis: weight behavior used by web browsers.
+ ///
+ private const float SyntheticBoldEmScale = 0.021F;
+
internal FontGlyphMetrics(
StreamFontMetrics font,
ushort glyphId,
@@ -283,6 +291,35 @@ internal static Matrix3x2 CreateObliqueMatrix(float skew)
return matrix;
}
+ ///
+ /// Gets a value indicating whether a bold (faux bold) weight must be synthesized for this
+ /// glyph. This is true only when the associated text run requests a bold style that the
+ /// resolved font face does not itself provide, mirroring the CSS font-synthesis: weight
+ /// behavior used by web browsers.
+ ///
+ /// true if bold synthesis is required; otherwise false.
+ internal bool ShouldSynthesizeBold()
+ {
+ Font? font = this.TextRun?.Font;
+ if (font is null)
+ {
+ return false;
+ }
+
+ bool requestedBold = (font.RequestedStyle & FontStyle.Bold) == FontStyle.Bold;
+ bool resolvedBold = (this.FontMetrics.Description.Style & FontStyle.Bold) == FontStyle.Bold;
+ return requestedBold && !resolvedBold;
+ }
+
+ ///
+ /// Gets the outward outline offset, in pixels, used to synthesize a bold (faux bold) weight for
+ /// this glyph, or 0 when no synthesis is required.
+ ///
+ /// The scaled point size, mapped to pixels by the caller.
+ /// The emboldening strength in pixels.
+ internal float GetSyntheticBoldStrength(float scaledPointSize)
+ => this.ShouldSynthesizeBold() ? SyntheticBoldEmScale * this.UnitsPerEm * (scaledPointSize / this.ScaleFactor.X) : 0F;
+
///
/// Calculates the glyph bounding box in device-space (Y-down) coordinates,
/// given the layout mode, render origin, and scaled point size.
@@ -323,6 +360,14 @@ internal FontRectangle GetBoundingBox(GlyphLayoutMode mode, Vector2 origin, floa
// 2) Rotate for vertical rotated layout.
Vector2 offsetUp = this.Offset;
+ // Inflate the ink bounds by the synthetic bold offset (in font units) so that the reported
+ // bounds enclose the emboldened outline produced during rendering.
+ if (this.ShouldSynthesizeBold())
+ {
+ float inflate = SyntheticBoldEmScale * this.UnitsPerEm;
+ b = new Bounds(b.Min - new Vector2(inflate), b.Max + new Vector2(inflate));
+ }
+
// Apply synthetic oblique (faux italic) shear before rotation so that the reported ink
// bounds match the sheared outline produced during rendering.
float skew = this.GetObliqueSkew();
diff --git a/src/SixLabors.Fonts/Rendering/EmboldeningGlyphRenderer.cs b/src/SixLabors.Fonts/Rendering/EmboldeningGlyphRenderer.cs
new file mode 100644
index 000000000..ec2549988
--- /dev/null
+++ b/src/SixLabors.Fonts/Rendering/EmboldeningGlyphRenderer.cs
@@ -0,0 +1,267 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Numerics;
+
+namespace SixLabors.Fonts.Rendering;
+
+///
+/// An decorator that synthesizes a bold (faux bold) weight by
+/// dilating each glyph outline outward. It is used when a bold style is requested for a font
+/// family that provides no bold face, mirroring the CSS font-synthesis: weight behavior
+/// used by web browsers.
+///
+///
+/// Outline contours are buffered per fill group (a color layer, or the whole glyph when no
+/// layers are present) so that the group's overall winding can be determined. Every point,
+/// including off-curve control points, is then shifted along the local outline normal using a
+/// mitred offset, which grows the filled area while shrinking any counters, just as a real bold
+/// weight would. The dilated contours are replayed to the wrapped renderer, preserving the
+/// original segment types.
+///
+internal sealed class EmboldeningGlyphRenderer : IGlyphRenderer
+{
+ private readonly IGlyphRenderer inner;
+ private readonly float strength;
+ private readonly List group = new();
+ private Contour? current;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The renderer that receives the dilated outline.
+ /// The outward offset applied to each outline edge, in pixels.
+ public EmboldeningGlyphRenderer(IGlyphRenderer inner, float strength)
+ {
+ this.inner = inner;
+ this.strength = strength;
+ }
+
+ private enum SegmentType : byte
+ {
+ Move,
+ Line,
+ Quadratic,
+ Cubic,
+ }
+
+ ///
+ public void BeginText(in FontRectangle bounds) => this.inner.BeginText(in bounds);
+
+ ///
+ public void EndText() => this.inner.EndText();
+
+ ///
+ public bool BeginGlyph(in FontRectangle bounds, in GlyphRendererParameters parameters)
+ => this.inner.BeginGlyph(in bounds, in parameters);
+
+ ///
+ public void EndGlyph()
+ {
+ this.Flush();
+ this.inner.EndGlyph();
+ }
+
+ ///
+ public void BeginLayer(Paint? paint, FillRule fillRule, ClipQuad? clipBounds)
+ => this.inner.BeginLayer(paint, fillRule, clipBounds);
+
+ ///
+ public void EndLayer()
+ {
+ this.Flush();
+ this.inner.EndLayer();
+ }
+
+ ///
+ public void BeginFigure() => this.current = new Contour();
+
+ ///
+ public void MoveTo(Vector2 point) => this.Add(SegmentType.Move, point);
+
+ ///
+ public void LineTo(Vector2 point) => this.Add(SegmentType.Line, point);
+
+ ///
+ public void QuadraticBezierTo(Vector2 secondControlPoint, Vector2 point)
+ => this.Add(SegmentType.Quadratic, secondControlPoint, point);
+
+ ///
+ public void CubicBezierTo(Vector2 secondControlPoint, Vector2 thirdControlPoint, Vector2 point)
+ => this.Add(SegmentType.Cubic, secondControlPoint, thirdControlPoint, point);
+
+ ///
+ public void ArcTo(float radiusX, float radiusY, float rotation, bool largeArc, bool sweep, Vector2 point)
+ => this.Add(SegmentType.Line, point);
+
+ ///
+ public void EndFigure()
+ {
+ if (this.current is { Points.Count: > 0 })
+ {
+ this.group.Add(this.current);
+ }
+
+ this.current = null;
+ }
+
+ ///
+ public TextDecorations EnabledDecorations() => this.inner.EnabledDecorations();
+
+ ///
+ public void SetDecoration(TextDecorations textDecorations, Vector2 start, Vector2 end, float thickness)
+ => this.inner.SetDecoration(textDecorations, start, end, thickness);
+
+ private static Vector2[] Offset(List points, float strength)
+ {
+ int n = points.Count;
+ Vector2[] result = new Vector2[n];
+ for (int i = 0; i < n; i++)
+ {
+ Vector2 p = points[i];
+ Vector2 e1 = Normalize(p - points[(i - 1 + n) % n]);
+ Vector2 e2 = Normalize(points[(i + 1) % n] - p);
+
+ // Edge normals (rotate each edge direction by -90 degrees).
+ Vector2 n1 = new(e1.Y, -e1.X);
+ Vector2 n2 = new(e2.Y, -e2.X);
+ Vector2 sum = n1 + n2;
+
+ Vector2 direction;
+ if (sum == Vector2.Zero)
+ {
+ direction = n1 == Vector2.Zero ? n2 : n1;
+ }
+ else
+ {
+ // Mitre so that each adjacent edge moves out by exactly the strength.
+ // The denominator is clamped to tame the mitre spike at sharp corners.
+ float d = 1F + Vector2.Dot(n1, n2);
+ direction = sum / MathF.Max(d, 0.25F);
+ }
+
+ result[i] = p + (strength * direction);
+ }
+
+ return result;
+ }
+
+ private static Vector2 Normalize(Vector2 v)
+ {
+ float length = v.Length();
+ return length < 1e-6F ? Vector2.Zero : v / length;
+ }
+
+ private static float SignedArea(List points)
+ {
+ float area = 0F;
+ for (int i = 0, j = points.Count - 1; i < points.Count; j = i++)
+ {
+ area += (points[j].X * points[i].Y) - (points[i].X * points[j].Y);
+ }
+
+ return area * 0.5F;
+ }
+
+ private void Add(SegmentType type, Vector2 p0)
+ {
+ Contour contour = this.current ??= new Contour();
+ contour.Segments.Add((type, 1));
+ contour.Points.Add(p0);
+ }
+
+ private void Add(SegmentType type, Vector2 p0, Vector2 p1)
+ {
+ Contour contour = this.current ??= new Contour();
+ contour.Segments.Add((type, 2));
+ contour.Points.Add(p0);
+ contour.Points.Add(p1);
+ }
+
+ private void Add(SegmentType type, Vector2 p0, Vector2 p1, Vector2 p2)
+ {
+ Contour contour = this.current ??= new Contour();
+ contour.Segments.Add((type, 3));
+ contour.Points.Add(p0);
+ contour.Points.Add(p1);
+ contour.Points.Add(p2);
+ }
+
+ private void Flush()
+ {
+ if (this.group.Count == 0)
+ {
+ return;
+ }
+
+ // Grow the fill outward regardless of the source outline's winding convention:
+ // a positive total area means the group is wound counter-clockwise, for which the
+ // edge normals already point outward, otherwise the offset direction is reversed.
+ // Only on-curve anchor points are used so that extreme cubic control points cannot
+ // distort the winding calculation.
+ float area = 0F;
+ foreach (Contour contour in this.group)
+ {
+ area += SignedArea(contour.Anchors());
+ }
+
+ float signedStrength = (area >= 0F ? 1F : -1F) * this.strength;
+
+ foreach (Contour contour in this.group)
+ {
+ Vector2[] offset = Offset(contour.Points, signedStrength);
+ this.inner.BeginFigure();
+
+ int index = 0;
+ foreach ((SegmentType type, int count) in contour.Segments)
+ {
+ switch (type)
+ {
+ case SegmentType.Move:
+ this.inner.MoveTo(offset[index]);
+ break;
+ case SegmentType.Line:
+ this.inner.LineTo(offset[index]);
+ break;
+ case SegmentType.Quadratic:
+ this.inner.QuadraticBezierTo(offset[index], offset[index + 1]);
+ break;
+ case SegmentType.Cubic:
+ this.inner.CubicBezierTo(offset[index], offset[index + 1], offset[index + 2]);
+ break;
+ }
+
+ index += count;
+ }
+
+ this.inner.EndFigure();
+ }
+
+ this.group.Clear();
+ }
+
+ private sealed class Contour
+ {
+ public List Points { get; } = new();
+
+ public List<(SegmentType Type, int Count)> Segments { get; } = new();
+
+ ///
+ /// Gets the on-curve anchor points of the contour, i.e. the endpoint of each segment,
+ /// which describe the contour's winding independently of any off-curve control points.
+ ///
+ /// The anchor points.
+ public List Anchors()
+ {
+ List anchors = new(this.Segments.Count);
+ int index = 0;
+ foreach ((SegmentType _, int count) in this.Segments)
+ {
+ index += count;
+ anchors.Add(this.Points[index - 1]);
+ }
+
+ return anchors;
+ }
+ }
+}
diff --git a/src/SixLabors.Fonts/Tables/Cff/CffGlyphMetrics.cs b/src/SixLabors.Fonts/Tables/Cff/CffGlyphMetrics.cs
index 57e967f2e..7850fbf0c 100644
--- a/src/SixLabors.Fonts/Tables/Cff/CffGlyphMetrics.cs
+++ b/src/SixLabors.Fonts/Tables/Cff/CffGlyphMetrics.cs
@@ -157,7 +157,12 @@ internal override void RenderTo(
FontRectangle box = this.GetBoundingBox(mode, glyphOrigin, scaledPPEM);
GlyphRendererParameters parameters = new(this, this.TextRun, pointSize, dpi, mode, graphemeIndex);
- if (renderer.BeginGlyph(in box, in parameters))
+ // Synthesize a bold (faux bold) weight when requested but unavailable by dilating the
+ // outline through a decorator. Decorations continue to use the original renderer.
+ float boldStrength = this.GetSyntheticBoldStrength(scaledPPEM);
+ IGlyphRenderer target = boldStrength > 0F ? new EmboldeningGlyphRenderer(renderer, boldStrength) : renderer;
+
+ if (target.BeginGlyph(in box, in parameters))
{
if (!UnicodeUtility.ShouldRenderWhiteSpaceOnly(this.CodePoint))
{
@@ -173,10 +178,10 @@ internal override void RenderTo(
}
Vector2 scaledOffset = this.Offset * scale;
- this.glyphData.RenderTo(renderer, glyphOrigin, scale, scaledOffset, transform);
+ this.glyphData.RenderTo(target, glyphOrigin, scale, scaledOffset, transform);
}
- renderer.EndGlyph();
+ target.EndGlyph();
this.RenderDecorationsTo(renderer, decorationOrigin, mode, rotation, scaledPPEM, options);
}
}
diff --git a/src/SixLabors.Fonts/Tables/TrueType/TrueTypeGlyphMetrics.cs b/src/SixLabors.Fonts/Tables/TrueType/TrueTypeGlyphMetrics.cs
index 27d08ef2d..d1ba1c13a 100644
--- a/src/SixLabors.Fonts/Tables/TrueType/TrueTypeGlyphMetrics.cs
+++ b/src/SixLabors.Fonts/Tables/TrueType/TrueTypeGlyphMetrics.cs
@@ -157,7 +157,12 @@ internal override void RenderTo(
FontRectangle box = this.GetBoundingBox(mode, glyphOrigin, scaledPPEM);
GlyphRendererParameters parameters = new(this, this.TextRun, pointSize, dpi, mode, graphemeIndex);
- if (renderer.BeginGlyph(in box, in parameters))
+ // Synthesize a bold (faux bold) weight when requested but unavailable by dilating the
+ // outline through a decorator. Decorations continue to use the original renderer.
+ float boldStrength = this.GetSyntheticBoldStrength(scaledPPEM);
+ IGlyphRenderer target = boldStrength > 0F ? new EmboldeningGlyphRenderer(renderer, boldStrength) : renderer;
+
+ if (target.BeginGlyph(in box, in parameters))
{
if (!UnicodeUtility.ShouldRenderWhiteSpaceOnly(this.CodePoint))
{
@@ -194,7 +199,7 @@ internal override void RenderTo(
int endOfContour = -1;
for (int i = 0; i < scaledVector.EndPoints.Count; i++)
{
- renderer.BeginFigure();
+ target.BeginFigure();
int startOfContour = endOfContour + 1;
endOfContour = endPoints[i];
@@ -204,19 +209,19 @@ internal override void RenderTo(
if (controlPoints[endOfContour].OnCurve)
{
- renderer.MoveTo(curr);
+ target.MoveTo(curr);
}
else
{
if (controlPoints[startOfContour].OnCurve)
{
- renderer.MoveTo(next);
+ target.MoveTo(next);
}
else
{
// If both first and last points are off-curve, start at their middle.
Vector2 startPoint = (curr + next) * .5F;
- renderer.MoveTo(startPoint);
+ target.MoveTo(startPoint);
}
}
@@ -233,7 +238,7 @@ internal override void RenderTo(
if (controlPoints[currentIndex].OnCurve)
{
// This is a straight line.
- renderer.LineTo(curr);
+ target.LineTo(curr);
}
else
{
@@ -243,7 +248,7 @@ internal override void RenderTo(
if (!controlPoints[prevIndex].OnCurve)
{
prev2 = (curr + prev) * .5F;
- renderer.LineTo(prev2);
+ target.LineTo(prev2);
}
if (!controlPoints[nextIndex].OnCurve)
@@ -251,16 +256,16 @@ internal override void RenderTo(
next2 = (curr + next) * .5F;
}
- renderer.LineTo(prev2);
- renderer.QuadraticBezierTo(curr, next2);
+ target.LineTo(prev2);
+ target.QuadraticBezierTo(curr, next2);
}
}
- renderer.EndFigure();
+ target.EndFigure();
}
}
- renderer.EndGlyph();
+ target.EndGlyph();
this.RenderDecorationsTo(renderer, decorationOrigin, mode, rotation, scaledPPEM, options);
}
}
diff --git a/tests/SixLabors.Fonts.Tests/FontSynthesisTests.cs b/tests/SixLabors.Fonts.Tests/FontSynthesisTests.cs
index 5bd6720f8..c2678cec4 100644
--- a/tests/SixLabors.Fonts.Tests/FontSynthesisTests.cs
+++ b/tests/SixLabors.Fonts.Tests/FontSynthesisTests.cs
@@ -134,4 +134,277 @@ private static void AssertSyntheticItalicShear(string file, string text)
// Ensure the glyph was actually slanted and not left upright.
Assert.True(maxHorizontalShift > 1F);
}
+
+ [Fact]
+ public void FamilyWithoutBold_FallsBackToRegularMetrics()
+ {
+ FontFamily family = RegularOnlyFamily(TestFonts.OpenSansFile);
+ Font bold = new(family, 24, FontStyle.Bold);
+
+ // Bold was requested but the resolved face is still the regular one.
+ Assert.Equal(FontStyle.Bold, bold.RequestedStyle);
+ Assert.False(bold.IsBold);
+ }
+
+ [Fact]
+ public void ShouldSynthesizeBold_TrueType_TrueOnlyWhenBoldSynthesized()
+ => AssertShouldSynthesizeBold(TestFonts.OpenSansFile);
+
+ [Fact]
+ public void ShouldSynthesizeBold_Cff_TrueOnlyWhenBoldSynthesized()
+ => AssertShouldSynthesizeBold(TestFonts.PlantinStdRegularFile);
+
+ [Fact]
+ public void SyntheticBold_DilatesGlyphOutline_TrueType()
+ => AssertSyntheticBoldGrows(TestFonts.OpenSansFile, "H");
+
+ [Fact]
+ public void SyntheticBold_DilatesGlyphOutline_Cff()
+ => AssertSyntheticBoldGrows(TestFonts.PlantinStdRegularFile, "H");
+
+ [Fact]
+ public void SyntheticBold_PreservesOutlineSegments_TrueType()
+ => AssertSyntheticBoldPreservesSegments(TestFonts.OpenSansFile, "Hg");
+
+ [Fact]
+ public void SyntheticBold_PreservesOutlineSegments_Cff()
+ => AssertSyntheticBoldPreservesSegments(TestFonts.PlantinStdRegularFile, "Hg");
+
+ [Fact]
+ public void SyntheticBold_DoesNotChangeAdvance_ButWidensBounds()
+ {
+ FontFamily family = RegularOnlyFamily(TestFonts.OpenSansFile);
+ Font regular = new(family, 48, FontStyle.Regular);
+ Font bold = new(family, 48, FontStyle.Bold);
+
+ const string text = "Hg";
+
+ // Browsers (verified against Chrome/Blink) keep the faux-bold advance identical to the
+ // regular advance - they thicken the stems in place rather than widening the advance - so
+ // our advance must stay unchanged to match a browser rendering the same regular face.
+ FontRectangle regularAdvance = TextMeasurer.MeasureAdvance(text, new TextOptions(regular));
+ FontRectangle boldAdvance = TextMeasurer.MeasureAdvance(text, new TextOptions(bold));
+ Assert.Equal(regularAdvance.Width, boldAdvance.Width, SkewComparer);
+
+ // The rendered ink bounds however grow because the outline is dilated.
+ FontRectangle regularBounds = TextMeasurer.MeasureBounds(text, new TextOptions(regular));
+ FontRectangle boldBounds = TextMeasurer.MeasureBounds(text, new TextOptions(bold));
+ Assert.True(boldBounds.Width > regularBounds.Width);
+ Assert.True(boldBounds.Height > regularBounds.Height);
+ }
+
+ [Fact]
+ public void SyntheticBoldItalic_CombinesBothSyntheses()
+ {
+ FontFamily family = RegularOnlyFamily(TestFonts.OpenSansFile);
+ CodePoint codePoint = new('H');
+ Font boldItalic = new(family, 24, FontStyle.BoldItalic);
+
+ Assert.True(boldItalic.TryGetGlyphs(codePoint, out Glyph? glyph));
+
+ // Both syntheses are driven independently, so requesting bold italic on a regular-only
+ // family must enable both the shear and the outline dilation.
+ Assert.Equal(ExpectedSkew, glyph.Value.GlyphMetrics.GetObliqueSkew(), SkewComparer);
+ Assert.True(glyph.Value.GlyphMetrics.ShouldSynthesizeBold());
+ }
+
+ [Fact]
+ public void ShouldSynthesizeBold_ReturnsFalse_WhenGlyphHasNoTextRun()
+ {
+ // The metrics returned directly from the font (i.e. not cloned for a specific run)
+ // carry no text run, so synthesis cannot be determined and must be disabled.
+ Font bold = new(RegularOnlyFamily(TestFonts.OpenSansFile), 24, FontStyle.Bold);
+
+ Assert.True(bold.FontMetrics.TryGetGlyphMetrics(
+ new CodePoint('H'),
+ TextAttributes.None,
+ TextDecorations.None,
+ LayoutMode.HorizontalTopBottom,
+ ColorFontSupport.None,
+ out FontGlyphMetrics metrics));
+
+ Assert.False(metrics.ShouldSynthesizeBold());
+ }
+
+ private static void AssertShouldSynthesizeBold(string file)
+ {
+ FontFamily family = RegularOnlyFamily(file);
+ CodePoint codePoint = new('H');
+
+ Font regular = new(family, 24, FontStyle.Regular);
+ Font bold = new(family, 24, FontStyle.Bold);
+
+ Assert.True(regular.TryGetGlyphs(codePoint, out Glyph? regularGlyph));
+ Assert.True(bold.TryGetGlyphs(codePoint, out Glyph? boldGlyph));
+
+ Assert.False(regularGlyph.Value.GlyphMetrics.ShouldSynthesizeBold());
+ Assert.True(boldGlyph.Value.GlyphMetrics.ShouldSynthesizeBold());
+ }
+
+ private static void AssertSyntheticBoldGrows(string file, string text)
+ {
+ FontFamily family = RegularOnlyFamily(file);
+ Font regular = new(family, 48, FontStyle.Regular);
+ Font bold = new(family, 48, FontStyle.Bold);
+
+ // The dilated outline must enclose the original one, so the rendered ink extents must
+ // grow outward. Note the top edge is pinned to the layout ascender, so growth is asserted
+ // via the overall width and height together with the left, right and bottom ink extents.
+ FontRectangle regularBounds = TextMeasurer.MeasureBounds(text, new TextOptions(regular));
+ FontRectangle boldBounds = TextMeasurer.MeasureBounds(text, new TextOptions(bold));
+
+ Assert.True(boldBounds.Width > regularBounds.Width);
+ Assert.True(boldBounds.Height > regularBounds.Height);
+ Assert.True(boldBounds.Left < regularBounds.Left);
+ Assert.True(boldBounds.Right > regularBounds.Right);
+ Assert.True(boldBounds.Bottom > regularBounds.Bottom);
+ }
+
+ private static void AssertSyntheticBoldPreservesSegments(string file, string text)
+ {
+ FontFamily family = RegularOnlyFamily(file);
+ Font regular = new(family, 48, FontStyle.Regular);
+ Font bold = new(family, 48, FontStyle.Bold);
+
+ GlyphRenderer regularRenderer = new();
+ TextRenderer.RenderTextTo(regularRenderer, text, new TextOptions(regular) { HintingMode = HintingMode.None });
+
+ GlyphRenderer boldRenderer = new();
+ TextRenderer.RenderTextTo(boldRenderer, text, new TextOptions(bold) { HintingMode = HintingMode.None });
+
+ List r = regularRenderer.ControlPoints;
+ List b = boldRenderer.ControlPoints;
+
+ // Emboldening only offsets existing points; it never adds or removes outline segments.
+ Assert.NotEmpty(r);
+ Assert.Equal(r.Count, b.Count);
+
+ // Every point moves by at most a small fraction of the em, confirming a dilation rather
+ // than an arbitrary distortion of the outline.
+ float maxShift = 0F;
+ for (int p = 0; p < r.Count; p++)
+ {
+ maxShift = MathF.Max(maxShift, Vector2.Distance(r[p], b[p]));
+ }
+
+ Assert.True(maxShift > 0.1F);
+ Assert.True(maxShift < 48F * 0.1F);
+ }
+
+ [Fact]
+ public void EmboldeningRenderer_ForwardsCallsAndDilatesOutline()
+ {
+ RecordingGlyphRenderer inner = new();
+ EmboldeningGlyphRenderer sut = new(inner, 2F);
+
+ // Exercise the non-outline pass-through surface.
+ sut.BeginText(default);
+ Assert.True(sut.BeginGlyph(default, default));
+ sut.BeginLayer(null, default, null);
+
+ // A triangular contour covering every segment kind plus an arc (treated as a line).
+ sut.BeginFigure();
+ sut.MoveTo(new Vector2(0, 0));
+ sut.LineTo(new Vector2(10, 0));
+ sut.QuadraticBezierTo(new Vector2(12, 5), new Vector2(10, 10));
+ sut.CubicBezierTo(new Vector2(8, 12), new Vector2(4, 12), new Vector2(0, 10));
+ sut.ArcTo(1, 1, 0, false, false, new Vector2(0, 0));
+ sut.EndFigure();
+
+ // An empty figure must be dropped rather than replayed.
+ sut.BeginFigure();
+ sut.EndFigure();
+
+ _ = sut.EnabledDecorations();
+ sut.SetDecoration(TextDecorations.Underline, default, default, 1F);
+ sut.EndLayer();
+ sut.EndGlyph();
+ sut.EndText();
+
+ Assert.True(inner.BeganText);
+ Assert.True(inner.BeganGlyph);
+ Assert.True(inner.BeganLayer);
+ Assert.True(inner.EndedLayer);
+ Assert.True(inner.EndedGlyph);
+ Assert.True(inner.EndedText);
+ Assert.True(inner.QueriedDecorations);
+ Assert.True(inner.SetDecorationCalled);
+
+ // Exactly one non-empty contour is replayed, preserving the emitted point count
+ // (Move=1, Line=1, Quadratic=2, Cubic=3, Arc-as-Line=1).
+ Assert.Single(inner.Figures);
+ Assert.Equal(8, inner.Figures[0].Count);
+ }
+
+ private sealed class RecordingGlyphRenderer : IGlyphRenderer
+ {
+ public bool BeganText { get; private set; }
+
+ public bool EndedText { get; private set; }
+
+ public bool BeganGlyph { get; private set; }
+
+ public bool EndedGlyph { get; private set; }
+
+ public bool BeganLayer { get; private set; }
+
+ public bool EndedLayer { get; private set; }
+
+ public bool QueriedDecorations { get; private set; }
+
+ public bool SetDecorationCalled { get; private set; }
+
+ public List> Figures { get; } = new();
+
+ private List? current;
+
+ public void BeginText(in FontRectangle bounds) => this.BeganText = true;
+
+ public void EndText() => this.EndedText = true;
+
+ public bool BeginGlyph(in FontRectangle bounds, in GlyphRendererParameters parameters)
+ {
+ this.BeganGlyph = true;
+ return true;
+ }
+
+ public void EndGlyph() => this.EndedGlyph = true;
+
+ public void BeginLayer(Paint? paint, FillRule fillRule, ClipQuad? clipBounds) => this.BeganLayer = true;
+
+ public void EndLayer() => this.EndedLayer = true;
+
+ public void BeginFigure() => this.current = new List();
+
+ public void MoveTo(Vector2 point) => this.current!.Add(point);
+
+ public void LineTo(Vector2 point) => this.current!.Add(point);
+
+ public void QuadraticBezierTo(Vector2 secondControlPoint, Vector2 point)
+ {
+ this.current!.Add(secondControlPoint);
+ this.current!.Add(point);
+ }
+
+ public void CubicBezierTo(Vector2 secondControlPoint, Vector2 thirdControlPoint, Vector2 point)
+ {
+ this.current!.Add(secondControlPoint);
+ this.current!.Add(thirdControlPoint);
+ this.current!.Add(point);
+ }
+
+ public void ArcTo(float radiusX, float radiusY, float rotation, bool largeArc, bool sweep, Vector2 point)
+ => this.current!.Add(point);
+
+ public void EndFigure() => this.Figures.Add(this.current!);
+
+ public TextDecorations EnabledDecorations()
+ {
+ this.QueriedDecorations = true;
+ return TextDecorations.None;
+ }
+
+ public void SetDecoration(TextDecorations textDecorations, Vector2 start, Vector2 end, float thickness)
+ => this.SetDecorationCalled = true;
+ }
}