// Copyright (c) Six Labors. // Licensed under the Six Labors Split License. using System; using System.Collections.Generic; using System.Numerics; using System.Runtime.CompilerServices; using SixLabors.Fonts; using SixLabors.Fonts.Rendering; using SixLabors.ImageSharp.Drawing.Processing; namespace SixLabors.ImageSharp.Drawing.Text { /// /// Defines a base rendering surface that Fonts can use to generate shapes. /// internal class BaseGlyphBuilder : IGlyphRenderer { /// /// The last point emitted by MoveTo / LineTo / curve commands. /// Used as the implicit start of the next segment. /// private Vector2 currentPoint; /// /// Snapshot of the for the glyph currently /// being processed. Set at the start of each BeginGlyph call and read by /// SetDecoration to determine layout orientation. /// private GlyphRendererParameters parameters; // Tracks whether geometry was emitted inside BeginLayer/EndLayer pairs for this glyph. // When true, EndGlyph skips its default single-layer path capture because layers // already contributed their paths individually. private bool usedLayers; // Tracks whether we are currently inside a layer block. // Guards against unbalanced EndLayer calls. private bool inLayer; // --- Per-GRAPHEME layered capture --- // A grapheme cluster (e.g. a base glyph + COLR v0 color layers) may span // multiple BeginGlyph/EndGlyph calls. These fields aggregate all layers // belonging to the same grapheme into a single GlyphPathCollection. private GlyphPathCollection.Builder? graphemeBuilder; private int graphemePathCount; private int currentGraphemeIndex = -1; private readonly List currentGlyphs = []; // Previous decoration details per decoration type, used to stitch adjacent // decorations together and eliminate sub-pixel gaps between glyphs. private TextDecorationDetails? previousUnderlineTextDecoration; private TextDecorationDetails? previousOverlineTextDecoration; private TextDecorationDetails? previousStrikeoutTextDecoration; // Per-layer (within current grapheme) bookkeeping: private int layerStartIndex; private Paint? currentLayerPaint; private FillRule currentLayerFillRule; private ClipQuad? currentClipBounds; /// /// Initializes a new instance of the class /// with an identity transform. /// public BaseGlyphBuilder() => this.Builder = new PathBuilder(); /// /// Initializes a new instance of the class /// with the specified transform applied to all incoming glyph geometry. /// /// A matrix transform applied to every point received from the font engine. public BaseGlyphBuilder(Matrix4x4 transform) => this.Builder = new PathBuilder(transform); /// /// Gets the flattened paths captured for all glyphs/graphemes. /// public IPathCollection Paths => new PathCollection(this.CurrentPaths); /// /// Gets the layer-preserving collections captured per grapheme in rendering order. /// Each entry aggregates all glyph layers that belong to a single grapheme cluster. /// public IReadOnlyList Glyphs => this.currentGlyphs; /// /// Gets the used to accumulate outline segments /// (MoveTo, LineTo, curves) for the current glyph or layer. /// The builder is cleared between glyphs / layers. /// protected PathBuilder Builder { get; } /// /// Gets the running list of all instances produced so far /// (glyph outlines, layer outlines, and decoration rectangles). Subclasses /// read from the end of this list (e.g. CurrentPaths[^1]) to obtain /// the most recently built path. /// protected List CurrentPaths { get; } = []; /// /// Called by the font engine after all glyphs in the text block have been rendered. /// Flushes any in-progress grapheme aggregate and resets per-text-block state. /// void IGlyphRenderer.EndText() { // Finalize the last grapheme, if any: if (this.graphemeBuilder is not null && this.graphemePathCount > 0) { this.currentGlyphs.Add(this.graphemeBuilder.Build()); } this.graphemeBuilder = null; this.graphemePathCount = 0; this.currentGraphemeIndex = -1; this.previousUnderlineTextDecoration = null; this.previousOverlineTextDecoration = null; this.previousStrikeoutTextDecoration = null; this.EndText(); } void IGlyphRenderer.BeginText(in FontRectangle bounds) => this.BeginText(bounds); /// /// Called by the font engine before emitting outline data for a single glyph. /// Manages grapheme-cluster transitions and resets per-glyph state. /// /// /// to have the font engine emit the full outline /// (MoveTo/LineTo/curves/EndGlyph); to skip it entirely, /// which is used by caching subclasses when the glyph path is already available. /// bool IGlyphRenderer.BeginGlyph(in FontRectangle bounds, in GlyphRendererParameters parameters) { // If grapheme changed, flush previous aggregate and start a new one: if (this.graphemeBuilder is not null && this.currentGraphemeIndex != parameters.GraphemeIndex) { if (this.graphemePathCount > 0) { this.currentGlyphs.Add(this.graphemeBuilder.Build()); } this.graphemeBuilder = null; this.graphemePathCount = 0; } if (this.graphemeBuilder is null) { this.graphemeBuilder = new GlyphPathCollection.Builder(); this.currentGraphemeIndex = parameters.GraphemeIndex; this.graphemePathCount = 0; } this.parameters = parameters; this.Builder.Clear(); this.usedLayers = false; this.inLayer = false; this.layerStartIndex = this.graphemePathCount; this.currentLayerPaint = null; this.currentLayerFillRule = FillRule.NonZero; this.currentClipBounds = null; return this.BeginGlyph(in bounds, in parameters); } /// void IGlyphRenderer.BeginFigure() => this.Builder.StartFigure(); /// void IGlyphRenderer.CubicBezierTo(Vector2 secondControlPoint, Vector2 thirdControlPoint, Vector2 point) { this.Builder.AddCubicBezier(this.currentPoint, secondControlPoint, thirdControlPoint, point); this.currentPoint = point; } /// /// Called by the font engine after the outline for a single glyph has been fully emitted. /// Builds the accumulated path and registers it as a grapheme layer unless explicit /// BeginLayer/EndLayer pairs already handled layer registration. /// void IGlyphRenderer.EndGlyph() { // If the glyph did not open any explicit layer, treat its geometry as a single // implicit layer so that non-color glyphs still produce a GlyphPathCollection entry. if (!this.usedLayers) { IPath path = this.Builder.Build(); this.CurrentPaths.Add(path); if (this.graphemeBuilder is not null) { this.graphemeBuilder.AddPath(path); this.graphemeBuilder.AddLayer( startIndex: this.graphemePathCount, count: 1, paint: null, fillRule: FillRule.NonZero, bounds: path.Bounds, kind: GlyphLayerKind.Glyph); this.graphemePathCount++; } } this.EndGlyph(); this.Builder.Clear(); this.inLayer = false; this.usedLayers = false; this.layerStartIndex = this.graphemePathCount; } /// void IGlyphRenderer.EndFigure() => this.Builder.CloseFigure(); /// void IGlyphRenderer.LineTo(Vector2 point) { this.Builder.AddLine(this.currentPoint, point); this.currentPoint = point; } /// void IGlyphRenderer.MoveTo(Vector2 point) { this.Builder.StartFigure(); this.currentPoint = point; } /// void IGlyphRenderer.ArcTo(float radiusX, float radiusY, float rotation, bool largeArc, bool sweep, Vector2 point) { this.Builder.AddArc(this.currentPoint, radiusX, radiusY, rotation, largeArc, sweep, point); this.currentPoint = point; } /// void IGlyphRenderer.QuadraticBezierTo(Vector2 secondControlPoint, Vector2 point) { this.Builder.AddQuadraticBezier(this.currentPoint, secondControlPoint, point); this.currentPoint = point; } /// /// Called by the font engine to begin a color layer within a COLR v0/v1 glyph. /// Each layer receives its own paint, fill rule, and optional clip bounds. /// void IGlyphRenderer.BeginLayer(Paint? paint, FillRule fillRule, ClipQuad? clipBounds) { this.usedLayers = true; this.inLayer = true; this.layerStartIndex = this.graphemePathCount; this.currentLayerPaint = paint; this.currentLayerFillRule = fillRule; this.currentClipBounds = clipBounds; this.Builder.Clear(); this.BeginLayer(paint, fillRule, clipBounds); } /// /// Called by the font engine to close a color layer opened by BeginLayer. /// Builds the layer path, applies any clip quad, and registers the result /// as a painted layer in the current grapheme aggregate. /// void IGlyphRenderer.EndLayer() { if (!this.inLayer) { return; } IPath path = this.Builder.Build(); // If the layer defines a clip quad (e.g. from COLR v1), intersect the // built path with the quad polygon to constrain rendering. if (this.currentClipBounds is not null) { ClipQuad clip = this.currentClipBounds.Value; PointF[] points = [clip.TopLeft, clip.TopRight, clip.BottomRight, clip.BottomLeft]; LinearLineSegment segment = new(points); Polygon polygon = new(segment); ShapeOptions options = new() { BooleanOperation = BooleanOperation.Intersection, IntersectionRule = TextUtilities.MapFillRule(this.currentLayerFillRule) }; path = path.Clip(options, polygon); } this.CurrentPaths.Add(path); if (this.graphemeBuilder is not null) { this.graphemeBuilder.AddPath(path); this.graphemeBuilder.AddLayer( startIndex: this.layerStartIndex, count: 1, paint: this.currentLayerPaint, fillRule: this.currentLayerFillRule, bounds: path.Bounds, kind: GlyphLayerKind.Painted); this.graphemePathCount++; } this.Builder.Clear(); this.inLayer = false; this.currentLayerPaint = null; this.currentLayerFillRule = FillRule.NonZero; this.currentClipBounds = null; this.EndLayer(); } /// /// Called by the font engine to emit a text decoration (underline, strikeout, or overline) /// for the current glyph. Builds a filled rectangle path from the start/end positions and /// thickness, then registers it as a layer. /// Adjacent decorations are stitched together using the previous decoration details to /// eliminate sub-pixel gaps caused by font metric rounding. /// void IGlyphRenderer.SetDecoration(TextDecorations textDecorations, Vector2 start, Vector2 end, float thickness) { if (thickness == 0) { return; } // Clamp the thickness to whole pixels. thickness = MathF.Max(1F, (float)Math.Round(thickness)); IGlyphRenderer renderer = this; bool rotated = this.parameters.LayoutMode is GlyphLayoutMode.Vertical or GlyphLayoutMode.VerticalRotated; Vector2 pad = rotated ? new Vector2(thickness * .5F, 0) : new Vector2(0, thickness * .5F); start = ClampToPixel(start, (int)thickness, rotated); end = ClampToPixel(end, (int)thickness, rotated); // Sometimes the start and end points do not align properly leaving pixel sized gaps // so we need to adjust them. Use any previous decoration to try and continue the line. TextDecorationDetails? previous = textDecorations switch { TextDecorations.Underline => this.previousUnderlineTextDecoration, TextDecorations.Overline => this.previousOverlineTextDecoration, TextDecorations.Strikeout => this.previousStrikeoutTextDecoration, _ => null }; if (previous != null) { float prevThickness = previous.Value.Thickness; Vector2 prevStart = previous.Value.Start; Vector2 prevEnd = previous.Value.End; // If the previous line is identical to the new one ignore it. // This can happen when multiple glyph layers are used. if (prevStart == start && prevEnd == end) { return; } // Align the new line with the previous one if they are close enough. // Use a 2 pixel threshold to account for anti-aliasing gaps. if (rotated) { if (thickness == prevThickness && prevEnd.Y + 2 >= start.Y && prevEnd.X == start.X) { start = prevEnd; } } else if (thickness == prevThickness && prevEnd.Y == start.Y && prevEnd.X + 2 >= start.X) { start = prevEnd; } } TextDecorationDetails current = new() { Start = start, End = end, Thickness = thickness }; switch (textDecorations) { case TextDecorations.Underline: this.previousUnderlineTextDecoration = current; break; case TextDecorations.Strikeout: this.previousStrikeoutTextDecoration = current; break; case TextDecorations.Overline: this.previousOverlineTextDecoration = current; break; } Vector2 a = start - pad; Vector2 b = start + pad; Vector2 c = end + pad; Vector2 d = end - pad; // Drawing is always centered around the point so we need to offset by half. Vector2 offset = Vector2.Zero; if (textDecorations == TextDecorations.Overline) { // CSS overline is drawn above the position, so we need to move it up. offset = rotated ? new Vector2(thickness * .5F, 0) : new Vector2(0, -(thickness * .5F)); } else if (textDecorations == TextDecorations.Underline) { // CSS underline is drawn below the position, so we need to move it down. offset = rotated ? new Vector2(-(thickness * .5F), 0) : new Vector2(0, thickness * .5F); } // We clamp the start and end points to the pixel grid to avoid anti-aliasing // when there is no transform. renderer.BeginFigure(); renderer.MoveTo(ClampToPixel(a + offset)); renderer.LineTo(ClampToPixel(b + offset)); renderer.LineTo(ClampToPixel(c + offset)); renderer.LineTo(ClampToPixel(d + offset)); renderer.EndFigure(); IPath path = this.Builder.Build(); // If the path is degenerate (e.g. zero width line) we just skip it // and return. This might happen when clamping moves the points. if (path.Bounds.IsEmpty) { this.Builder.Clear(); return; } this.CurrentPaths.Add(path); if (this.graphemeBuilder is not null) { // Decorations are emitted as independent paths; each layer must point // at the path index appended for this specific decoration. this.graphemeBuilder.AddPath(path); this.graphemeBuilder.AddLayer( startIndex: this.graphemePathCount, count: 1, paint: this.currentLayerPaint, fillRule: FillRule.NonZero, bounds: path.Bounds, kind: GlyphLayerKind.Decoration); this.graphemePathCount++; } this.Builder.Clear(); this.SetDecoration(textDecorations, start, end, thickness); } /// protected virtual void BeginText(in FontRectangle bounds) { } /// /// Called after base-class bookkeeping in IGlyphRenderer.BeginGlyph. /// Subclasses override this to apply transforms, consult caches, or opt out of /// outline emission by returning . /// /// The font-metric bounding rectangle of the glyph. /// Identifies the glyph (id, font, layout mode, text run, etc.). /// /// to receive outline data and an EndGlyph call; /// to skip outline emission for this glyph entirely. /// protected virtual bool BeginGlyph(in FontRectangle bounds, in GlyphRendererParameters parameters) => true; /// /// Called after the base class has built and registered the glyph path. /// Subclasses override this to emit drawing operations from the captured path. /// protected virtual void EndGlyph() { } /// /// Called after the base class has flushed all grapheme aggregates. /// Subclasses override this for any per-text-block finalization. /// protected virtual void EndText() { } /// /// Called when a COLR color layer begins. Subclasses override this to /// capture the layer's paint and composite mode. /// /// The paint for this color layer, or for the default foreground. /// The fill rule to use when rasterizing this layer. /// Optional clip quad constraining the layer region. protected virtual void BeginLayer(Paint? paint, FillRule fillRule, ClipQuad? clipBounds) { } /// /// Called when a COLR color layer ends. Subclasses override this to /// emit the layer as a drawing operation. /// protected virtual void EndLayer() { } /// /// Returns the set of text decorations enabled for the current glyph. /// The font engine calls this to decide which SetDecoration callbacks to emit. /// Subclasses override this to include decorations implied by rich-text pens /// (e.g. ). /// /// A flags enum of the active text decorations. public virtual TextDecorations EnabledDecorations() => this.parameters.TextRun.TextDecorations; /// /// Override point for subclasses to emit decoration drawing operations. /// Called after the base class has built and registered the decoration path /// in . /// /// The type of decoration (underline, strikeout, or overline). /// The start position of the decoration line. /// The end position of the decoration line. /// The thickness of the decoration line in pixels. public virtual void SetDecoration(TextDecorations textDecorations, Vector2 start, Vector2 end, float thickness) { } /// /// Truncates a floating-point position to the nearest whole pixel toward negative infinity. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private static Point ClampToPixel(PointF point) => Point.Truncate(point); /// /// Snaps a decoration endpoint to the pixel grid, taking stroke thickness and /// orientation into account. Even-thickness lines snap to whole pixels; odd-thickness /// lines snap to half pixels so the stroke center lands on a pixel boundary. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private static PointF ClampToPixel(PointF point, int thickness, bool rotated) { // Even thickness: snap to whole pixels. if ((thickness & 1) == 0) { return Point.Truncate(point); } // Odd thickness: snap to half pixels along the perpendicular axis // so the 1px-wide center row/column aligns with physical pixels. if (rotated) { return Point.Truncate(point) + new Vector2(.5F, 0); } return Point.Truncate(point) + new Vector2(0, .5F); } /// /// Records the start, end, and thickness of a previously emitted decoration line /// so that the next adjacent decoration can be stitched seamlessly. /// private struct TextDecorationDetails { /// Gets or sets the start position of the decoration. public Vector2 Start { get; set; } /// Gets or sets the end position of the decoration. public Vector2 End { get; set; } /// Gets or sets the decoration thickness in pixels. public float Thickness { get; internal set; } } } }