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