// 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.Fonts.Unicode;
using SixLabors.ImageSharp.Drawing.Text;
using SixLabors.ImageSharp.PixelFormats;
namespace SixLabors.ImageSharp.Drawing.Processing.Processors.Text {
///
/// Allows the rendering of rich text configured via .
///
internal sealed partial class RichTextGlyphRenderer : BaseGlyphBuilder, IDisposable
{
// --- Render-pass ordering constants ---
// Within DrawTextOperations, operations are sorted first by RenderPass so that
// fills paint beneath outlines, and outlines beneath decorations.
private const byte RenderOrderFill = 0;
private const byte RenderOrderOutline = 1;
private const byte RenderOrderDecoration = 2;
private readonly DrawingOptions drawingOptions;
/// The default pen supplied by the caller (e.g. from DrawText(..., pen)).
private readonly Pen? defaultPen;
/// The default brush supplied by the caller (e.g. from DrawText(..., brush)).
private readonly Brush? defaultBrush;
///
/// When the text is laid out along a path, this holds the path internals
/// for point-along-path queries. for normal (linear) text.
///
private readonly IPathInternals? path;
private bool isDisposed;
// --- Per-glyph mutable state reset in BeginGlyph ---
/// The (or ) governing the current glyph.
private TextRun? currentTextRun;
/// Brush resolved from the current , or .
private Brush? currentBrush;
/// Pen resolved from the current , or .
private Pen? currentPen;
/// The fill rule for the current color layer (COLR).
private FillRule currentFillRule;
/// Alpha composition mode active for the current glyph/layer.
private PixelAlphaCompositionMode currentCompositionMode;
/// Color blending mode active for the current glyph/layer.
private PixelColorBlendingMode currentBlendingMode;
/// Whether the current glyph uses vertical layout (affects decoration orientation).
private bool currentDecorationIsVertical;
/// Set to when is called, cleared in .
private bool hasLayer;
// --- Glyph outline cache ---
// Glyphs that share the same CacheKey (same glyph id, sub-pixel position quantized
// to 1/AccuracyMultiple, pen reference, etc.) reuse the translated IPath from the
// first occurrence. This avoids re-building the full outline for repeated characters.
//
// AccuracyMultiple = 8 means sub-pixel positions are quantized to 1/8 px steps.
// Benchmarked to give <0.2% image difference vs. uncached, with >60% cache hit ratio.
private const float AccuracyMultiple = 8;
/// Maps cache keys to their list of entries (one per layer).
/// Owned by the enclosing and shared across every DrawText
/// call on that canvas, so glyph outlines persist beyond a single text draw.
private readonly Dictionary> glyphCache;
/// Read cursor into the cached layer list for layered cache hits.
private int cacheReadIndex;
///
/// when the current glyph is a cache miss and its outline
/// must be fully rasterized; on a cache hit (reuse path).
///
private bool rasterizationRequired;
///
/// to disable the glyph cache entirely (e.g. path-based text
/// where every glyph has a unique transform).
///
private readonly bool noCache;
/// The cache key computed for the current glyph in .
private CacheKey currentCacheKey;
///
/// The transformed (post-) bounding-box location
/// of the current glyph. Stored so can compute
/// for future cache-hit render location estimation.
///
private PointF currentTransformedBoundsLocation;
///
/// Initializes a new instance of the class.
///
/// Drawing options (transform, graphics options) for the text.
/// Optional path to draw the text along.
/// Default pen for outlined text, or for fill-only.
/// Default brush for filled text, or for outline-only.
/// Caller-owned per-canvas glyph cache shared across renderer
/// instances so glyph outlines persist beyond a single text draw.
public RichTextGlyphRenderer(
DrawingOptions drawingOptions,
IPath? path,
Pen? pen,
Brush? brush,
Dictionary> glyphCache)
: base(drawingOptions.Transform)
{
this.drawingOptions = drawingOptions;
this.defaultPen = pen;
this.defaultBrush = brush;
this.glyphCache = glyphCache;
this.DrawingOperations = [];
this.currentCompositionMode = drawingOptions.GraphicsOptions.AlphaCompositionMode;
this.currentBlendingMode = drawingOptions.GraphicsOptions.ColorBlendingMode;
if (path is not null)
{
// Path-based text gives each glyph a unique per-position transform,
// so cache hits are vanishingly rare; disable caching entirely.
this.rasterizationRequired = true;
this.noCache = true;
if (path is IPathInternals internals)
{
this.path = internals;
}
else
{
this.path = new ComplexPolygon(path);
}
}
}
///
/// Gets the list of instances accumulated during text rendering.
/// After RenderText completes, this list is consumed by
/// to build composition commands.
///
public List DrawingOperations { get; }
///
protected override void BeginText(in FontRectangle bounds) => this.DrawingOperations.Clear();
///
protected override bool BeginGlyph(in FontRectangle bounds, in GlyphRendererParameters parameters)
{
// Resolves the active brush/pen from the text run, computes the cache key,
// and takes one of three paths:
// 1. Non-layered cache hit without decorations: emit cached ops, return false (fast path).
// 2. Layered or decorated cache hit: reuse cached path, return true for EndGlyph/SetDecoration.
// 3. Cache miss: rasterize from scratch.
this.cacheReadIndex = 0;
this.currentDecorationIsVertical = parameters.LayoutMode is GlyphLayoutMode.Vertical or GlyphLayoutMode.VerticalRotated;
this.currentTextRun = parameters.TextRun;
if (parameters.TextRun is RichTextRun drawingRun)
{
this.currentBrush = drawingRun.Brush;
this.currentPen = drawingRun.Pen;
}
else
{
this.currentBrush = null;
this.currentPen = null;
}
if (!this.noCache)
{
// Transform the font-metric bounds by the drawing transform so that the
// sub-pixel position and size reflect the final screen coordinates.
// Quantize to 1/AccuracyMultiple px steps for cache key comparison.
RectangleF currentBounds = RectangleF.Transform(
new RectangleF(bounds.Location, new SizeF(bounds.Width, bounds.Height)),
this.drawingOptions.Transform);
this.currentTransformedBoundsLocation = currentBounds.Location;
PointF currentBoundsDelta = currentBounds.Location - ClampToPixel(currentBounds.Location);
PointF subPixelLocation = new(
MathF.Round(currentBoundsDelta.X * AccuracyMultiple) / AccuracyMultiple,
MathF.Round(currentBoundsDelta.Y * AccuracyMultiple) / AccuracyMultiple);
SizeF subPixelSize = new(
MathF.Round(currentBounds.Width * AccuracyMultiple) / AccuracyMultiple,
MathF.Round(currentBounds.Height * AccuracyMultiple) / AccuracyMultiple);
this.currentCacheKey = CacheKey.FromParameters(
parameters,
new RectangleF(subPixelLocation, subPixelSize),
this.currentPen ?? this.defaultPen);
if (this.glyphCache.TryGetValue(this.currentCacheKey, out List? cachedEntries))
{
if (cachedEntries.Count > 0 && !cachedEntries[0].IsLayered
&& this.EnabledDecorations() == TextDecorations.None)
{
// Non-layered cache hit without decorations: emit operations directly
// and tell the font engine to skip the outline entirely
// (no MoveTo/LineTo/SetDecoration/EndGlyph).
this.EmitCachedGlyphOperations(cachedEntries[0], currentBounds.Location);
return false;
}
// Layered or decorated cache hit: let the normal flow handle
// per-layer state and decoration callbacks.
this.rasterizationRequired = false;
return true;
}
}
// Transform the glyph vectors using the original bounds
// The default transform will automatically be applied.
this.TransformGlyph(in bounds);
this.rasterizationRequired = true;
return true;
}
///
protected override void BeginLayer(Paint? paint, FillRule fillRule, ClipQuad? clipBounds)
{
// Capture the color-layer paint, fill rule, and composite mode.
// Setting hasLayer tells EndGlyph to skip its default single-layer path emission.
this.hasLayer = true;
this.currentFillRule = fillRule;
if (TryCreateBrush(paint, this.Builder.Transform, out Brush? brush))
{
this.currentBrush = brush;
this.currentCompositionMode = TextUtilities.MapCompositionMode(paint.CompositeMode);
this.currentBlendingMode = TextUtilities.MapBlendingMode(paint.CompositeMode);
}
}
///
protected override void EndLayer()
{
// Finalizes a color layer. On a cache miss, translates the built path to local
// coordinates and stores it for future hits. On a cache hit, reads the stored
// path and adjusts the render location using sub-pixel delta compensation.
GlyphRenderData renderData = default;
IPath? fillPath = null;
// Fix up the text runs colors.
// Only if both brush and pen is null do we fallback to the default value.
if (this.currentBrush == null && this.currentPen == null)
{
this.currentBrush = this.defaultBrush;
this.currentPen = this.defaultPen;
}
// When rendering layers we only fill them.
// Any drawing of outlines is ignored as that doesn't really make sense.
bool renderFill = this.currentBrush != null;
// Path has already been added to the collection via the base class.
IPath path = this.CurrentPaths[^1];
Point renderLocation = ClampToPixel(path.Bounds.Location);
if (this.noCache || this.rasterizationRequired)
{
if (path.Bounds.Equals(RectangleF.Empty))
{
return;
}
if (renderFill)
{
renderData.FillPath = path.Translate(-renderLocation);
fillPath = renderData.FillPath;
}
// Capture the delta between the location and the truncated render location.
// We can use this to offset the render location on the next instance of this glyph.
renderData.LocationDelta = (Vector2)(path.Bounds.Location - renderLocation);
renderData.IsLayered = true;
if (!this.noCache)
{
this.UpdateCache(renderData);
}
}
else
{
renderData = this.glyphCache[this.currentCacheKey][this.cacheReadIndex++];
// Offset the render location by the delta from the cached glyph and this one.
Vector2 previousDelta = renderData.LocationDelta;
Vector2 currentLocation = path.Bounds.Location;
Vector2 currentDelta = path.Bounds.Location - ClampToPixel(path.Bounds.Location);
if (previousDelta.Y > currentDelta.Y)
{
// Move the location down to match the previous location offset.
currentLocation += new Vector2(0, previousDelta.Y - currentDelta.Y);
}
else if (previousDelta.Y < currentDelta.Y)
{
// Move the location up to match the previous location offset.
currentLocation -= new Vector2(0, currentDelta.Y - previousDelta.Y);
}
else if (previousDelta.X > currentDelta.X)
{
// Move the location right to match the previous location offset.
currentLocation += new Vector2(previousDelta.X - currentDelta.X, 0);
}
else if (previousDelta.X < currentDelta.X)
{
// Move the location left to match the previous location offset.
currentLocation -= new Vector2(currentDelta.X - previousDelta.X, 0);
}
renderLocation = ClampToPixel(currentLocation);
if (renderFill && renderData.FillPath is not null)
{
fillPath = renderData.FillPath;
}
}
if (fillPath is not null)
{
IntersectionRule fillRule = TextUtilities.MapFillRule(this.currentFillRule);
this.DrawingOperations.Add(new DrawingOperation
{
Kind = DrawingOperationKind.Fill,
Path = fillPath,
RenderLocation = renderLocation,
IntersectionRule = fillRule,
Brush = this.currentBrush,
RenderPass = RenderOrderFill,
PixelAlphaCompositionMode = this.currentCompositionMode,
PixelColorBlendingMode = this.currentBlendingMode
});
}
this.currentFillRule = FillRule.NonZero;
this.currentCompositionMode = this.drawingOptions.GraphicsOptions.AlphaCompositionMode;
this.currentBlendingMode = this.drawingOptions.GraphicsOptions.ColorBlendingMode;
}
///
public override TextDecorations EnabledDecorations()
{
// Returns the union of decorations from TextRun.TextDecorations and any
// decoration pens set on the current RichTextRun. The font engine uses
// this result to decide which SetDecoration calls to emit.
TextRun? run = this.currentTextRun;
TextDecorations decorations = run?.TextDecorations ?? TextDecorations.None;
if (this.currentTextRun is RichTextRun drawingRun)
{
if (drawingRun.UnderlinePen != null)
{
decorations |= TextDecorations.Underline;
}
if (drawingRun.StrikeoutPen != null)
{
decorations |= TextDecorations.Strikeout;
}
if (drawingRun.OverlinePen != null)
{
decorations |= TextDecorations.Overline;
}
}
return decorations;
}
///
public override void SetDecoration(TextDecorations textDecorations, Vector2 start, Vector2 end, float thickness)
{
// Emits a DrawingOperation for a text decoration. Resolves the decoration pen
// from the current RichTextRun, re-scales the base-class path when the pen's
// stroke width differs from the font-metric thickness, and anchors the scaling
// per decoration type (overline to bottom edge, underline to top edge, strikeout to center).
// Decorations are not cached.
if (thickness == 0)
{
return;
}
Brush? brush = null;
Pen? pen = null;
if (this.currentTextRun is RichTextRun drawingRun)
{
brush = drawingRun.Brush;
if (textDecorations == TextDecorations.Strikeout)
{
pen = drawingRun.StrikeoutPen ?? pen;
}
else if (textDecorations == TextDecorations.Underline)
{
pen = drawingRun.UnderlinePen ?? pen;
}
else if (textDecorations == TextDecorations.Overline)
{
pen = drawingRun.OverlinePen;
}
}
// Always respect the pen stroke width if explicitly set.
float originalThickness = thickness;
if (pen is not null)
{
// Clamp the thickness to whole pixels.
thickness = MathF.Max(1F, (float)Math.Round(pen.StrokeWidth));
}
else
{
// The thickness of the line has already been clamped in the base class.
pen = new SolidPen((brush ?? this.defaultBrush)!, thickness);
}
// Path has already been added to the collection via the base class.
IPath path = this.CurrentPaths[^1];
IPath outline = path;
if (originalThickness != thickness)
{
// Respect edge anchoring per decoration type:
// - Overline: keep the base edge fixed (bottom in horizontal; left in vertical)
// - Underline: keep the top edge fixed (top in horizontal; right in vertical)
// - Strikeout: keep the center fixed (default behavior)
float ratio = thickness / originalThickness;
if (ratio != 1f)
{
Vector2 scale = this.currentDecorationIsVertical
? new Vector2(ratio, 1f)
: new Vector2(1f, ratio);
RectangleF b = path.Bounds;
Vector2 center = new(b.Left + (b.Width * 0.5f), b.Top + (b.Height * 0.5f));
Vector2 anchor = center;
if (textDecorations == TextDecorations.Overline)
{
anchor = this.currentDecorationIsVertical
? new Vector2(b.Left, center.Y) // vertical: anchor left edge
: new Vector2(center.X, b.Bottom); // horizontal: anchor bottom edge
}
else if (textDecorations == TextDecorations.Underline)
{
anchor = this.currentDecorationIsVertical
? new Vector2(b.Right, center.Y) // vertical: anchor right edge
: new Vector2(center.X, b.Top); // horizontal: anchor top edge
}
// Scale about the chosen anchor so the fixed edge stays in place.
outline = outline.Transform(Matrix4x4.CreateScale(scale.X, scale.Y, 1, new Vector3(anchor, 0)));
}
}
// Render the path here. Decorations are un-cached.
Point renderLocation = ClampToPixel(outline.Bounds.Location);
IPath decorationPath = outline.Translate(-renderLocation);
Brush decorationBrush = pen.StrokeFill;
this.DrawingOperations.Add(new DrawingOperation
{
Kind = DrawingOperationKind.Fill,
Path = decorationPath,
RenderLocation = renderLocation,
IntersectionRule = IntersectionRule.NonZero,
Brush = decorationBrush,
RenderPass = RenderOrderDecoration
});
}
///
protected override void EndGlyph()
{
// If hasLayer is set, layers were already handled by EndLayer; skip.
// Otherwise, on a cache miss the built path is translated to local coordinates,
// stored for future hits, and emitted as fill and/or outline DrawingOperations.
// On a cache hit the stored path is reused with sub-pixel delta compensation.
if (this.hasLayer)
{
// The layer has already been rendered.
this.hasLayer = false;
return;
}
GlyphRenderData renderData = default;
IPath? glyphPath = null;
// Fix up the text runs colors.
// Only if both brush and pen is null do we fallback to the default value.
if (this.currentBrush == null && this.currentPen == null)
{
this.currentBrush = this.defaultBrush;
this.currentPen = this.defaultPen;
}
bool renderFill = false;
bool renderOutline = false;
// If we are using the fonts color layers we ignore the request to draw an outline only
// because that won't really work. Instead we force drawing using fill with the requested color.
if (this.currentBrush != null)
{
renderFill = true;
}
if (this.currentPen != null)
{
renderOutline = true;
}
// Path has already been added to the collection via the base class.
IPath path = this.CurrentPaths[^1];
Point renderLocation = ClampToPixel(path.Bounds.Location);
if (this.noCache || this.rasterizationRequired)
{
if (path.Bounds.Equals(RectangleF.Empty))
{
return;
}
IPath localPath = path.Translate(-renderLocation);
if (renderFill || renderOutline)
{
renderData.FillPath = localPath;
glyphPath = renderData.FillPath;
}
// Capture the delta between the location and the truncated render location.
// We can use this to offset the render location on the next instance of this glyph.
renderData.LocationDelta = (Vector2)(path.Bounds.Location - renderLocation);
// Store the offset between outline bounds and font metric bounds so that
// cache hits in BeginGlyph can accurately estimate the path location.
renderData.BoundsOffset = (Vector2)(path.Bounds.Location - this.currentTransformedBoundsLocation);
if (!this.noCache)
{
this.UpdateCache(renderData);
}
}
else
{
renderData = this.glyphCache[this.currentCacheKey][this.cacheReadIndex++];
// Offset the render location by the delta from the cached glyph and this one.
Vector2 previousDelta = renderData.LocationDelta;
Vector2 currentLocation = path.Bounds.Location;
Vector2 currentDelta = path.Bounds.Location - ClampToPixel(path.Bounds.Location);
if (previousDelta.Y > currentDelta.Y)
{
// Move the location down to match the previous location offset.
currentLocation += new Vector2(0, previousDelta.Y - currentDelta.Y);
}
else if (previousDelta.Y < currentDelta.Y)
{
// Move the location up to match the previous location offset.
currentLocation -= new Vector2(0, currentDelta.Y - previousDelta.Y);
}
else if (previousDelta.X > currentDelta.X)
{
// Move the location right to match the previous location offset.
currentLocation += new Vector2(previousDelta.X - currentDelta.X, 0);
}
else if (previousDelta.X < currentDelta.X)
{
// Move the location left to match the previous location offset.
currentLocation -= new Vector2(currentDelta.X - previousDelta.X, 0);
}
renderLocation = ClampToPixel(currentLocation);
if (renderFill && renderData.FillPath is not null)
{
glyphPath = renderData.FillPath;
}
if (renderOutline && renderData.FillPath is not null)
{
glyphPath = renderData.FillPath;
}
}
if (renderFill && glyphPath is not null)
{
IntersectionRule fillRule = TextUtilities.MapFillRule(this.currentFillRule);
this.DrawingOperations.Add(new DrawingOperation
{
Kind = DrawingOperationKind.Fill,
Path = glyphPath,
RenderLocation = renderLocation,
IntersectionRule = fillRule,
Brush = this.currentBrush,
RenderPass = RenderOrderFill,
PixelAlphaCompositionMode = this.currentCompositionMode,
PixelColorBlendingMode = this.currentBlendingMode
});
}
if (renderOutline && glyphPath is not null)
{
IntersectionRule outlineRule = TextUtilities.MapFillRule(this.currentFillRule);
this.DrawingOperations.Add(new DrawingOperation
{
Kind = DrawingOperationKind.Draw,
Path = glyphPath,
RenderLocation = renderLocation,
IntersectionRule = outlineRule,
Pen = this.currentPen,
RenderPass = RenderOrderOutline,
PixelAlphaCompositionMode = this.currentCompositionMode,
PixelColorBlendingMode = this.currentBlendingMode
});
}
}
///
/// Emits fill and/or outline s from a cached
/// entry. Called from on a
/// non-layered, decoration-free cache hit when the font engine is told to skip
/// the outline entirely (returns ).
///
/// The cached render data containing the translated path and location delta.
/// The transformed bounding-box origin for the current glyph instance.
private void EmitCachedGlyphOperations(GlyphRenderData renderData, PointF currentBoundsLocation)
{
// Estimate the outline bounds location using the stored offset between
// the outline bounds and the font metric bounds from the original glyph.
PointF estimatedPathLocation = new(
currentBoundsLocation.X + renderData.BoundsOffset.X,
currentBoundsLocation.Y + renderData.BoundsOffset.Y);
Point renderLocation = ComputeCacheHitRenderLocation(estimatedPathLocation, renderData.LocationDelta);
// Fix up the text runs colors.
Brush? brush = this.currentBrush;
Pen? pen = this.currentPen;
if (brush == null && pen == null)
{
brush = this.defaultBrush;
pen = this.defaultPen;
}
IPath? glyphPath = renderData.FillPath;
if (glyphPath is null)
{
return;
}
if (brush != null)
{
IntersectionRule fillRule = TextUtilities.MapFillRule(this.currentFillRule);
this.DrawingOperations.Add(new DrawingOperation
{
Kind = DrawingOperationKind.Fill,
Path = glyphPath,
RenderLocation = renderLocation,
IntersectionRule = fillRule,
Brush = brush,
RenderPass = RenderOrderFill,
PixelAlphaCompositionMode = this.currentCompositionMode,
PixelColorBlendingMode = this.currentBlendingMode
});
}
if (pen != null)
{
IntersectionRule outlineRule = TextUtilities.MapFillRule(this.currentFillRule);
this.DrawingOperations.Add(new DrawingOperation
{
Kind = DrawingOperationKind.Draw,
Path = glyphPath,
RenderLocation = renderLocation,
IntersectionRule = outlineRule,
Pen = pen,
RenderPass = RenderOrderOutline,
PixelAlphaCompositionMode = this.currentCompositionMode,
PixelColorBlendingMode = this.currentBlendingMode
});
}
}
///
/// Computes the pixel-snapped render location for a cache-hit glyph by compensating
/// for the sub-pixel delta difference between the original cached glyph and the
/// current instance. This keeps glyphs visually aligned even when their sub-pixel
/// positions differ slightly.
///
/// The estimated outline bounds origin for the current glyph.
/// The sub-pixel delta recorded when the path was first cached.
/// A pixel-snapped render location.
private static Point ComputeCacheHitRenderLocation(PointF pathLocation, Vector2 previousDelta)
{
Vector2 currentLocation = (Vector2)pathLocation;
Vector2 currentDelta = currentLocation - (Vector2)ClampToPixel(pathLocation);
if (previousDelta.Y > currentDelta.Y)
{
currentLocation += new Vector2(0, previousDelta.Y - currentDelta.Y);
}
else if (previousDelta.Y < currentDelta.Y)
{
currentLocation -= new Vector2(0, currentDelta.Y - previousDelta.Y);
}
else if (previousDelta.X > currentDelta.X)
{
currentLocation += new Vector2(previousDelta.X - currentDelta.X, 0);
}
else if (previousDelta.X < currentDelta.X)
{
currentLocation -= new Vector2(currentDelta.X - previousDelta.X, 0);
}
return ClampToPixel(currentLocation);
}
///
/// Stores a entry in the glyph cache under the
/// current key. Creates the cache list on first insertion for a given key.
///
private void UpdateCache(GlyphRenderData renderData)
{
if (!this.glyphCache.TryGetValue(this.currentCacheKey, out List? _))
{
this.glyphCache[this.currentCacheKey] = [];
}
this.glyphCache[this.currentCacheKey].Add(renderData);
}
///
public void Dispose() => this.Dispose(true);
///
/// 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);
///
/// Applies the path-based transform to the
/// for the current glyph, positioning it along the text path (if any) or
/// leaving the identity transform for linear text.
///
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void TransformGlyph(in FontRectangle bounds)
=> this.Builder.SetTransform(this.ComputeTransform(in bounds));
///
/// Computes the combined translation + rotation matrix that places a glyph
/// along the text path. For linear text (no path), returns .
///
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private Matrix4x4 ComputeTransform(in FontRectangle bounds)
{
if (this.path is null)
{
return Matrix4x4.Identity;
}
// Find the point of this intersection along the given path.
// We want to find the point on the path that is closest to the center-bottom side of the glyph.
Vector2 half = new(bounds.Width * .5F, 0);
SegmentInfo pathPoint = this.path.PointAlongPath(bounds.Left + half.X);
// Now offset to our target point since we're aligning the top-left location of our glyph against the path.
Vector2 translation = (Vector2)pathPoint.Point - bounds.Location - half + new Vector2(0, bounds.Top);
return Matrix4x4.CreateTranslation(translation.X, translation.Y, 0)
* new Matrix4x4(Matrix3x2.CreateRotation(pathPoint.Angle - MathF.PI, (Vector2)pathPoint.Point));
}
///
/// Releases managed resources (glyph cache and drawing operations list).
///
/// to release managed resources.
private void Dispose(bool disposing)
{
if (!this.isDisposed)
{
if (disposing)
{
// The glyph cache is owned by the canvas and outlives this renderer.
this.DrawingOperations.Clear();
}
this.isDisposed = true;
}
}
///
/// Per-layer cached data for a rasterized glyph. Stores the locally-translated
/// path and the sub-pixel deltas needed to reposition the path at a different
/// screen location on a cache hit.
///
internal struct GlyphRenderData
{
///
/// The fractional-pixel offset between the path's bounding-box origin
/// and the truncated (pixel-snapped) render location. Used to compensate
/// for sub-pixel position differences between cache hits.
///
public Vector2 LocationDelta;
///
/// The offset between the outline path's bounding-box origin and the
/// font-metric bounds origin. Stored on first rasterization so that
/// can estimate the path location
/// from only the font-metric bounds (which are available without outline data).
///
public Vector2 BoundsOffset;
///
/// The glyph outline path translated to local coordinates (origin at 0,0).
/// Shared across all cache hits for the same .
///
public IPath? FillPath;
///
/// if this entry belongs to a multi-layer (COLR) glyph.
/// Non-layered cache hits with no decorations can skip the outline entirely
/// (return from ); layered hits
/// still need the per-layer BeginLayer/EndLayer callbacks.
///
public bool IsLayered;
}
///
/// Identifies a unique glyph variant for caching purposes. Two glyphs with the same
/// share identical outline geometry and can reuse the same
/// . The key includes the glyph id, font metrics,
/// sub-pixel position (quantized to ), and the pen reference
/// (since stroke width affects the outline path).
///
internal readonly struct CacheKey : IEquatable
{
/// Gets the font family name.
public string Font { get; init; }
/// Gets the glyph color variant (normal, COLR, etc.).
public GlyphColor GlyphColor { get; init; }
/// Gets the glyph type (simple, composite, etc.).
public GlyphType GlyphType { get; init; }
/// Gets the font style (regular, bold, italic, etc.).
public FontStyle FontStyle { get; init; }
/// Gets the glyph index within the font.
public ushort GlyphId { get; init; }
/// Gets the composite glyph parent index (0 for non-composite).
public ushort CompositeGlyphId { get; init; }
/// Gets the Unicode code point this glyph represents.
public CodePoint CodePoint { get; init; }
/// Gets the em-size at which the glyph is rendered.
public float PointSize { get; init; }
/// Gets the DPI used for rendering.
public float Dpi { get; init; }
/// Gets the layout mode (horizontal, vertical, vertical-rotated).
public GlyphLayoutMode LayoutMode { get; init; }
/// Gets any text attributes (e.g. superscript/subscript) that affect rendering.
public TextAttributes TextAttributes { get; init; }
/// Gets text decorations that may influence outline geometry.
public TextDecorations TextDecorations { get; init; }
/// Gets the quantized sub-pixel bounds used for position-sensitive cache lookup.
public RectangleF Bounds { get; init; }
///
/// Gets the pen reference used for outlined text. Compared by reference equality
/// so that different pen instances (even with the same stroke width) produce
/// separate cache entries; this is correct because pen identity affects stroke
/// pattern and dash style.
///
public Pen? PenReference { get; init; }
public static bool operator ==(CacheKey left, CacheKey right) => left.Equals(right);
public static bool operator !=(CacheKey left, CacheKey right) => !(left == right);
///
/// Creates a from glyph renderer parameters and quantized bounds.
/// The grapheme index is intentionally excluded because it varies per glyph instance
/// while the outline geometry remains the same for matching glyph+position.
///
/// The glyph renderer parameters from the font engine.
/// Quantized sub-pixel bounds for position-sensitive lookup.
/// The pen reference for outlined text, or .
/// A new cache key.
public static CacheKey FromParameters(
in GlyphRendererParameters parameters,
RectangleF bounds,
Pen? penReference)
=> new()
{
// Do not include the grapheme index as that will
// always vary per glyph instance.
Font = parameters.Font,
GlyphType = parameters.GlyphType,
FontStyle = parameters.FontStyle,
GlyphId = parameters.GlyphId,
CompositeGlyphId = parameters.CompositeGlyphId,
CodePoint = parameters.CodePoint,
PointSize = parameters.PointSize,
Dpi = parameters.Dpi,
LayoutMode = parameters.LayoutMode,
TextAttributes = parameters.TextRun.TextAttributes,
TextDecorations = parameters.TextRun.TextDecorations,
Bounds = bounds,
PenReference = penReference
};
public override bool Equals(object? obj)
=> obj is CacheKey key && this.Equals(key);
public bool Equals(CacheKey other)
=> this.Font == other.Font &&
this.GlyphColor.Equals(other.GlyphColor) &&
this.GlyphType == other.GlyphType &&
this.FontStyle == other.FontStyle &&
this.GlyphId == other.GlyphId &&
this.CompositeGlyphId == other.CompositeGlyphId &&
this.CodePoint.Equals(other.CodePoint) &&
this.PointSize == other.PointSize &&
this.Dpi == other.Dpi &&
this.LayoutMode == other.LayoutMode &&
this.TextAttributes == other.TextAttributes &&
this.TextDecorations == other.TextDecorations &&
this.Bounds.Equals(other.Bounds) &&
ReferenceEquals(this.PenReference, other.PenReference);
public override int GetHashCode()
{
HashCode hash = default;
hash.Add(this.Font);
hash.Add(this.GlyphColor);
hash.Add(this.GlyphType);
hash.Add(this.FontStyle);
hash.Add(this.GlyphId);
hash.Add(this.CompositeGlyphId);
hash.Add(this.CodePoint);
hash.Add(this.PointSize);
hash.Add(this.Dpi);
hash.Add(this.LayoutMode);
hash.Add(this.TextAttributes);
hash.Add(this.TextDecorations);
hash.Add(this.Bounds);
hash.Add(this.PenReference is null ? 0 : RuntimeHelpers.GetHashCode(this.PenReference));
return hash.ToHashCode();
}
}
}
}