diff --git a/external/Numerge b/external/Numerge
new file mode 160000
index 0000000000..5530e1cbe9
--- /dev/null
+++ b/external/Numerge
@@ -0,0 +1 @@
+Subproject commit 5530e1cbe9e105ff4ebc9da1f4af3253a8756754
diff --git a/samples/Sandbox/MainWindow.axaml.cs b/samples/Sandbox/MainWindow.axaml.cs
index e3dda25b29..81732c8019 100644
--- a/samples/Sandbox/MainWindow.axaml.cs
+++ b/samples/Sandbox/MainWindow.axaml.cs
@@ -1,17 +1,194 @@
+using System;
+using System.Linq;
using Avalonia;
using Avalonia.Controls;
-using Avalonia.Controls.Presenters;
-using Avalonia.Input.TextInput;
-using Avalonia.Markup.Xaml;
-using Avalonia.Win32.WinRT.Composition;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Media.Fonts;
+using Avalonia.Media.TextFormatting;
+using Avalonia.Media.TextFormatting.Unicode;
+using Avalonia.Rendering.SceneGraph;
+using Avalonia.Skia;
+using Avalonia.Skia.Helpers;
+using SkiaSharp;
namespace Sandbox
{
public partial class MainWindow : Window
{
+ private int[] clockCodepoints = new int[]
+ {
+ 0x1F550, // 🕐 Clock Face One O’Clock
+ 0x1F551, // 🕑 Clock Face Two O’Clock
+ 0x1F552, // 🕒 Clock Face Three O’Clock
+ 0x1F553, // 🕓 Clock Face Four O’Clock
+ 0x1F554, // 🕔 Clock Face Five O’Clock
+ 0x1F555, // 🕕 Clock Face Six O’Clock
+ 0x1F556, // 🕖 Clock Face Seven O’Clock
+ 0x1F557, // 🕗 Clock Face Eight O’Clock
+ 0x1F558, // 🕘 Clock Face Nine O’Clock
+ 0x1F559, // 🕙 Clock Face Ten O’Clock
+ 0x1F55A, // 🕚 Clock Face Eleven O’Clock
+ 0x1F55B, // 🕛 Clock Face Twelve O’Clock
+
+ 0x1F55C, // 🕜 Clock Face One-Thirty
+ 0x1F55D, // 🕝 Clock Face Two-Thirty
+ 0x1F55E, // 🕞 Clock Face Three-Thirty
+ 0x1F55F, // 🕟 Clock Face Four-Thirty
+ 0x1F560, // 🕠 Clock Face Five-Thirty
+ 0x1F561, // 🕡 Clock Face Six-Thirty
+ 0x1F562, // 🕢 Clock Face Seven-Thirty
+ 0x1F563, // 🕣 Clock Face Eight-Thirty
+ 0x1F564, // 🕤 Clock Face Nine-Thirty
+ 0x1F565, // 🕥 Clock Face Ten-Thirty
+ 0x1F566, // 🕦 Clock Face Eleven-Thirty
+ 0x1F567 // 🕧 Clock Face Twelve-Thirty
+ };
+
public MainWindow()
{
InitializeComponent();
+
+ var fontCollection = new EmbeddedFontCollection(new Uri("fonts:colr"),
+ new Uri("resm:Sandbox?assembly=Sandbox", UriKind.Absolute));
+
+ FontManager.Current.AddFontCollection(fontCollection);
+
+ var notoColorEmojiTypeface = new Typeface("fonts:colr#Noto Color Emoji");
+ var notoColorEmojiGlyphTypeface = notoColorEmojiTypeface.GlyphTypeface;
+
+ var wrap = new WrapPanel
+ {
+ Margin = new Thickness(8),
+ Orientation = Orientation.Horizontal,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center
+ };
+
+ foreach (var (c, g) in notoColorEmojiGlyphTypeface.CharacterToGlyphMap.AsReadOnlyDictionary())
+ {
+ // Create a glyph control for each glyph
+ var glyphControl = new GlyphControl
+ {
+ GlyphTypeface = notoColorEmojiGlyphTypeface,
+ GlyphId = g,
+ Width = 66,
+ Height = 66,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center
+ };
+
+ var border = new Border
+ {
+ BorderBrush = Brushes.LightGray,
+ BorderThickness = new Thickness(1),
+ MinHeight = 80,
+ MinWidth = 80,
+ Padding = new Thickness(4),
+ Child = new Grid { Children = { glyphControl } },
+ Margin = new Thickness(4)
+ };
+
+ wrap.Children.Add(border);
+
+ Content = new ScrollViewer { Content = wrap };
+ }
+ }
+
+ ///
+ /// Custom control that renders a single glyph using GetGlyphDrawing for color glyphs
+ /// and GetGlyphOutline for outline glyphs.
+ ///
+ public class GlyphControl : Control
+ {
+ public static readonly StyledProperty GlyphTypefaceProperty =
+ AvaloniaProperty.Register(nameof(GlyphTypeface));
+
+ public static readonly StyledProperty GlyphIdProperty =
+ AvaloniaProperty.Register(nameof(GlyphId));
+
+ public static readonly StyledProperty ForegroundProperty =
+ AvaloniaProperty.Register(nameof(Foreground), Brushes.Black);
+
+ static GlyphControl()
+ {
+ AffectsRender(GlyphTypefaceProperty, GlyphIdProperty, ForegroundProperty);
+ }
+
+ public GlyphTypeface? GlyphTypeface
+ {
+ get => GetValue(GlyphTypefaceProperty);
+ set => SetValue(GlyphTypefaceProperty, value);
+ }
+
+ public ushort GlyphId
+ {
+ get => GetValue(GlyphIdProperty);
+ set => SetValue(GlyphIdProperty, value);
+ }
+
+ public IBrush? Foreground
+ {
+ get => GetValue(ForegroundProperty);
+ set => SetValue(ForegroundProperty, value);
+ }
+
+ public override void Render(DrawingContext context)
+ {
+ base.Render(context);
+
+ var glyphTypeface = GlyphTypeface;
+ if (glyphTypeface == null)
+ {
+ return;
+ }
+
+ var glyphId = GlyphId;
+
+ // Calculate scale
+ var targetSize = Math.Min(Width, Height);
+ var designEmHeight = glyphTypeface.Metrics.DesignEmHeight;
+ var scale = targetSize / designEmHeight;
+
+ // Try to get color glyph drawing first
+ var glyphDrawing = glyphTypeface.GetGlyphDrawing(glyphId);
+
+ if (glyphDrawing != null)
+ {
+ var bounds = glyphDrawing.Bounds;
+
+ var offsetX = (Width / scale - bounds.Width) / 2 - bounds.Left;
+ var offsetY = (Height / scale - bounds.Height) / 2 - bounds.Top;
+
+ using (context.PushTransform(Matrix.CreateTranslation(offsetX, offsetY) *
+ Matrix.CreateScale(scale, scale)))
+ {
+ glyphDrawing.Draw(context, new Point());
+ }
+ }
+ else
+ {
+ // Outline glyph
+ var glyphOutline = glyphTypeface.GetGlyphOutline(glyphId, Matrix.CreateScale(1, -1));
+
+ if (glyphOutline != null)
+ {
+ // Get tight bounds of scaled geometry
+ var bounds = glyphOutline.Bounds;
+
+ // Calculate transform based on bounds
+ var offsetX = (Width / scale - bounds.Width) / 2 - bounds.Left;
+ var offsetY = (Height / scale - bounds.Height) / 2 - bounds.Top;
+
+ // Apply transform and render
+ using (context.PushTransform(Matrix.CreateTranslation(offsetX, offsetY) *
+ Matrix.CreateScale(scale, scale)))
+ {
+ context.DrawGeometry(Foreground, null, glyphOutline);
+ }
+ }
+ }
+ }
}
}
}
diff --git a/samples/Sandbox/NotoColorEmoji-Regular.ttf b/samples/Sandbox/NotoColorEmoji-Regular.ttf
new file mode 100644
index 0000000000..93a6766df7
Binary files /dev/null and b/samples/Sandbox/NotoColorEmoji-Regular.ttf differ
diff --git a/samples/Sandbox/Program.cs b/samples/Sandbox/Program.cs
index b676992111..b05f7c0501 100644
--- a/samples/Sandbox/Program.cs
+++ b/samples/Sandbox/Program.cs
@@ -1,4 +1,5 @@
using Avalonia;
+using Avalonia.Logging;
namespace Sandbox
{
diff --git a/samples/Sandbox/Sandbox.csproj b/samples/Sandbox/Sandbox.csproj
index e372366391..0936ac773f 100644
--- a/samples/Sandbox/Sandbox.csproj
+++ b/samples/Sandbox/Sandbox.csproj
@@ -8,11 +8,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/Sandbox/noto-glyf_colr_1.ttf b/samples/Sandbox/noto-glyf_colr_1.ttf
new file mode 100644
index 0000000000..82643e6945
Binary files /dev/null and b/samples/Sandbox/noto-glyf_colr_1.ttf differ
diff --git a/samples/Sandbox/test_glyphs-glyf_colr_1_no_cliplist.ttf b/samples/Sandbox/test_glyphs-glyf_colr_1_no_cliplist.ttf
new file mode 100644
index 0000000000..addc05276a
Binary files /dev/null and b/samples/Sandbox/test_glyphs-glyf_colr_1_no_cliplist.ttf differ
diff --git a/samples/Sandbox/twemoji-glyf_colr_1.ttf b/samples/Sandbox/twemoji-glyf_colr_1.ttf
new file mode 100644
index 0000000000..d59b98d3b3
Binary files /dev/null and b/samples/Sandbox/twemoji-glyf_colr_1.ttf differ
diff --git a/src/Avalonia.Base/Media/FontVariationSettings.cs b/src/Avalonia.Base/Media/FontVariationSettings.cs
new file mode 100644
index 0000000000..831be6dfbe
--- /dev/null
+++ b/src/Avalonia.Base/Media/FontVariationSettings.cs
@@ -0,0 +1,37 @@
+using System.Collections.Generic;
+using Avalonia.Media.Fonts;
+
+namespace Avalonia.Media
+{
+ ///
+ /// Represents font variation settings, including normalized axis coordinates, optional variation instance index,
+ /// color palette selection, and pixel size for bitmap strikes.
+ ///
+ /// Use this type to specify font rendering parameters for variable fonts, color fonts, and
+ /// bitmap strikes. The settings correspond to OpenType font features such as axis variations (fvar/avar), named
+ /// instances, color palettes (COLR/CPAL), and bitmap sizes. All properties are immutable and must be set during
+ /// initialization.
+ public sealed record class FontVariationSettings
+ {
+ ///
+ /// Gets the normalized variation coordinates for each axis, derived from fvar/avar tables.
+ ///
+ public required IReadOnlyDictionary NormalizedCoordinates { get; init; }
+
+ ///
+ /// Gets the index of a predefined variation instance (optional).
+ /// If specified, NormalizedCoordinates represent that instance.
+ ///
+ public int? InstanceIndex { get; init; }
+
+ ///
+ /// Gets the color palette index for COLR/CPAL.
+ ///
+ public int PaletteIndex { get; init; }
+
+ ///
+ /// Gets the pixel size for bitmap strikes.
+ ///
+ public int PixelSize { get; init; }
+ }
+}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CharacterToGlyphMap.cs b/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CharacterToGlyphMap.cs
index 83db40ba62..bbf8fd1080 100644
--- a/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CharacterToGlyphMap.cs
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CharacterToGlyphMap.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Runtime.CompilerServices;
namespace Avalonia.Media.Fonts.Tables.Cmap
@@ -143,5 +144,18 @@ namespace Avalonia.Media.Fonts.Tables.Cmap
{
return new CodepointRangeEnumerator(Format, _format4, _format12Or13);
}
+
+ ///
+ /// Exposes the character-to-glyph map as an .
+ ///
+ /// This method returns a lightweight wrapper that provides dictionary-like access to the glyph mappings.
+ /// The wrapper does not allocate memory for storing all mappings; instead, it dynamically computes keys and values
+ /// from the underlying cmap table using the mapped code point ranges and the GetGlyph method.
+ /// An view of this character-to-glyph map.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public IReadOnlyDictionary AsReadOnlyDictionary()
+ {
+ return new CharacterToGlyphMapDictionary(this);
+ }
}
}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CharacterToGlyphMapDictionary.cs b/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CharacterToGlyphMapDictionary.cs
new file mode 100644
index 0000000000..06e805d445
--- /dev/null
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CharacterToGlyphMapDictionary.cs
@@ -0,0 +1,129 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+
+namespace Avalonia.Media.Fonts.Tables.Cmap
+{
+ ///
+ /// Provides a read-only dictionary view over a .
+ ///
+ internal sealed class CharacterToGlyphMapDictionary : IReadOnlyDictionary
+ {
+ private readonly CharacterToGlyphMap _map;
+ private List? _cachedRanges;
+
+ public CharacterToGlyphMapDictionary(CharacterToGlyphMap map)
+ {
+ _map = map;
+ }
+
+ public ushort this[int key]
+ {
+ get
+ {
+ if (!_map.ContainsGlyph(key))
+ {
+ throw new KeyNotFoundException($"The code point {key} was not found in the character map.");
+ }
+ return _map.GetGlyph(key);
+ }
+ }
+
+ public IEnumerable Keys
+ {
+ get
+ {
+ foreach (var range in GetRanges())
+ {
+ for (int codePoint = range.Start; codePoint <= range.End; codePoint++)
+ {
+ if (_map.ContainsGlyph(codePoint))
+ {
+ yield return codePoint;
+ }
+ }
+ }
+ }
+ }
+
+ public IEnumerable Values
+ {
+ get
+ {
+ foreach (var range in GetRanges())
+ {
+ for (int codePoint = range.Start; codePoint <= range.End; codePoint++)
+ {
+ if (_map.TryGetGlyph(codePoint, out var glyphId))
+ {
+ yield return glyphId;
+ }
+ }
+ }
+ }
+ }
+
+ public int Count
+ {
+ get
+ {
+ int count = 0;
+ foreach (var range in GetRanges())
+ {
+ for (int codePoint = range.Start; codePoint <= range.End; codePoint++)
+ {
+ if (_map.ContainsGlyph(codePoint))
+ {
+ count++;
+ }
+ }
+ }
+ return count;
+ }
+ }
+
+ public bool ContainsKey(int key)
+ {
+ return _map.ContainsGlyph(key);
+ }
+
+ public bool TryGetValue(int key, [MaybeNullWhen(false)] out ushort value)
+ {
+ return _map.TryGetGlyph(key, out value);
+ }
+
+ public IEnumerator> GetEnumerator()
+ {
+ foreach (var range in GetRanges())
+ {
+ for (int codePoint = range.Start; codePoint <= range.End; codePoint++)
+ {
+ if (_map.TryGetGlyph(codePoint, out var glyphId))
+ {
+ yield return new KeyValuePair(codePoint, glyphId);
+ }
+ }
+ }
+ }
+
+ IEnumerator IEnumerable.GetEnumerator()
+ {
+ return GetEnumerator();
+ }
+
+ private List GetRanges()
+ {
+ if (_cachedRanges == null)
+ {
+ _cachedRanges = new List();
+ var enumerator = _map.GetMappedRanges();
+ while (enumerator.MoveNext())
+ {
+ _cachedRanges.Add(enumerator.Current);
+ }
+ }
+ return _cachedRanges;
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Colr/ColorGlyphV1Painter.cs b/src/Avalonia.Base/Media/Fonts/Tables/Colr/ColorGlyphV1Painter.cs
new file mode 100644
index 0000000000..4122d19623
--- /dev/null
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Colr/ColorGlyphV1Painter.cs
@@ -0,0 +1,191 @@
+using System;
+using System.Collections.Generic;
+using Avalonia.Media.Immutable;
+
+namespace Avalonia.Media.Fonts.Tables.Colr
+{
+ ///
+ /// Implements painting for COLR v1 glyphs using Avalonia's DrawingContext.
+ ///
+ internal sealed class ColorGlyphV1Painter : IColorPainter
+ {
+ private readonly DrawingContext _drawingContext;
+ private readonly ColrContext _context;
+ private readonly Stack _stateStack = new Stack();
+
+ // Track the pending glyph that needs to be painted with the next fill
+ // In COLR v1, there's a 1:1 mapping between glyph and fill operations
+ private Geometry? _pendingGlyph;
+
+ // Track the accumulated transform that should be applied to geometry and brushes
+ private Matrix _accumulatedTransform = Matrix.Identity;
+ private readonly Stack _transformStack = new Stack();
+
+ public ColorGlyphV1Painter(DrawingContext drawingContext, ColrContext context)
+ {
+ _drawingContext = drawingContext;
+ _context = context;
+ }
+
+ public void PushTransform(Matrix transform)
+ {
+ _transformStack.Push(_accumulatedTransform);
+ _accumulatedTransform = transform * _accumulatedTransform;
+ }
+
+ public void PopTransform()
+ {
+ if (_transformStack.Count > 0)
+ {
+ _accumulatedTransform = _transformStack.Pop();
+ }
+ }
+
+ public void PushLayer(CompositeMode mode)
+ {
+ // COLR v1 composite modes are not fully supported in the base drawing context
+ // For now, we use opacity layers to provide basic composition support
+ // TODO: Implement proper blend mode support when available
+ _stateStack.Push(_drawingContext.PushOpacity(1.0));
+ }
+
+ public void PopLayer()
+ {
+ if (_stateStack.Count > 0)
+ {
+ _stateStack.Pop().Dispose();
+ }
+ }
+
+ public void PushClip(Rect clipBox)
+ {
+ // Transform the clip box with accumulated transforms
+ var transformedClip = clipBox.TransformToAABB(_accumulatedTransform);
+
+ _stateStack.Push(_drawingContext.PushClip(transformedClip));
+ }
+
+ public void PopClip()
+ {
+ if (_stateStack.Count > 0)
+ {
+ _stateStack.Pop().Dispose();
+ }
+ }
+
+ public void FillSolid(Color color)
+ {
+ // Render the pending glyph with this solid color
+ if (_pendingGlyph != null)
+ {
+ var brush = new ImmutableSolidColorBrush(color);
+
+ _drawingContext.DrawGeometry(brush, null, _pendingGlyph);
+
+ _pendingGlyph = null;
+ }
+ }
+
+ ///
+ /// Creates a brush transform that applies any accumulated transforms.
+ ///
+ private ImmutableTransform? CreateBrushTransform()
+ {
+ return _accumulatedTransform != Matrix.Identity ? new ImmutableTransform(_accumulatedTransform) : null;
+ }
+
+ public void FillLinearGradient(Point p0, Point p1, GradientStop[] stops, GradientSpreadMethod extend)
+ {
+ if (_pendingGlyph != null)
+ {
+ var gradientStops = new ImmutableGradientStop[stops.Length];
+
+ for (var i = 0; i < stops.Length; i++)
+ {
+ gradientStops[i] = new ImmutableGradientStop(stops[i].Offset, stops[i].Color);
+ }
+
+ var brush = new ImmutableLinearGradientBrush(
+ gradientStops: gradientStops,
+ opacity: 1.0,
+ transform: CreateBrushTransform(),
+ transformOrigin: new RelativePoint(0, 0, RelativeUnit.Absolute),
+ spreadMethod: extend,
+ startPoint: new RelativePoint(p0, RelativeUnit.Absolute),
+ endPoint: new RelativePoint(p1, RelativeUnit.Absolute));
+
+ _drawingContext.DrawGeometry(brush, null, _pendingGlyph);
+ _pendingGlyph = null;
+ }
+ }
+
+ public void FillRadialGradient(Point c0, double r0, Point c1, double r1, GradientStop[] stops, GradientSpreadMethod extend)
+ {
+ if (_pendingGlyph != null)
+ {
+ // Avalonia's RadialGradientBrush doesn't support two-point gradients with different radii
+ // We approximate by using the larger circle as the gradient
+ var center = r1 > r0 ? c1 : c0;
+ var radius = Math.Max(r0, r1);
+
+ var gradientStops = new ImmutableGradientStop[stops.Length];
+
+ for (var i = 0; i < stops.Length; i++)
+ {
+ gradientStops[i] = new ImmutableGradientStop(stops[i].Offset, stops[i].Color);
+ }
+
+ var brush = new ImmutableRadialGradientBrush(
+ gradientStops: gradientStops,
+ opacity: 1.0,
+ transform: CreateBrushTransform(),
+ transformOrigin: new RelativePoint(0, 0, RelativeUnit.Absolute),
+ spreadMethod: extend,
+ center: new RelativePoint(center, RelativeUnit.Absolute),
+ gradientOrigin: new RelativePoint(center, RelativeUnit.Absolute),
+ radiusX: new RelativeScalar(radius, RelativeUnit.Absolute),
+ radiusY: new RelativeScalar(radius, RelativeUnit.Absolute));
+
+ _drawingContext.DrawGeometry(brush, null, _pendingGlyph);
+ _pendingGlyph = null;
+ }
+ }
+
+ public void FillConicGradient(Point center, double startAngle, double endAngle, GradientStop[] stops, GradientSpreadMethod extend)
+ {
+ if (_pendingGlyph != null)
+ {
+ var gradientStops = new ImmutableGradientStop[stops.Length];
+
+ for (var i = 0; i < stops.Length; i++)
+ {
+ gradientStops[i] = new ImmutableGradientStop(stops[i].Offset, stops[i].Color);
+ }
+
+ var brush = new ImmutableConicGradientBrush(
+ gradientStops: gradientStops,
+ opacity: 1.0,
+ transform: CreateBrushTransform(),
+ transformOrigin: new RelativePoint(0, 0, RelativeUnit.Absolute),
+ spreadMethod: extend,
+ center: new RelativePoint(center, RelativeUnit.Absolute),
+ angle: startAngle);
+
+ _drawingContext.DrawGeometry(brush, null, _pendingGlyph);
+
+ _pendingGlyph = null;
+ }
+ }
+
+ public void Glyph(ushort glyphId)
+ {
+ // Store the glyph geometry to be rendered when we encounter the fill
+ var geometry = _context.GlyphTypeface.GetGlyphOutline(glyphId, _accumulatedTransform);
+
+ if (geometry != null)
+ {
+ _pendingGlyph = geometry;
+ }
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Colr/ColrContext.cs b/src/Avalonia.Base/Media/Fonts/Tables/Colr/ColrContext.cs
new file mode 100644
index 0000000000..6480b4cef4
--- /dev/null
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Colr/ColrContext.cs
@@ -0,0 +1,191 @@
+using System;
+using System.Drawing;
+using System.Linq;
+using Avalonia.Media.Immutable;
+
+namespace Avalonia.Media.Fonts.Tables.Colr
+{
+ ///
+ /// Context for parsing Paint tables and resolving ResolvedPaint, providing access to required tables and data.
+ ///
+ internal readonly struct ColrContext
+ {
+ public ColrContext(
+ GlyphTypeface glyphTypeface,
+ ColrTable colrTable,
+ CpalTable cpalTable,
+ int paletteIndex)
+ {
+ GlyphTypeface = glyphTypeface;
+ ColrData = colrTable.ColrData;
+ ColrTable = colrTable;
+ CpalTable = cpalTable;
+ PaletteIndex = paletteIndex;
+ }
+
+ public GlyphTypeface GlyphTypeface { get; }
+ public ReadOnlyMemory ColrData { get; }
+ public ColrTable ColrTable { get; }
+ public CpalTable CpalTable { get; }
+ public int PaletteIndex { get; }
+
+ ///
+ /// Applies alpha delta to a color.
+ ///
+ public Color ApplyAlphaDelta(Color color, uint varIndexBase, int deltaIndex)
+ {
+ if (!ColrTable.TryGetVariationDeltaSet(varIndexBase, out var deltaSet))
+ {
+ return color;
+ }
+
+ if (deltaIndex >= deltaSet.Count)
+ {
+ return color;
+ }
+
+ // Alpha deltas are F2DOT14 format
+ var alphaDelta = deltaSet.GetF2Dot14Delta(deltaIndex);
+
+ var newAlpha = Math.Clamp(color.A / 255.0 + alphaDelta, 0.0, 1.0);
+
+ return Color.FromArgb((byte)(newAlpha * 255), color.R, color.G, color.B);
+ }
+
+ ///
+ /// Applies affine transformation deltas to a matrix.
+ ///
+ public Matrix ApplyAffineDeltas(Matrix matrix, uint varIndexBase)
+ {
+ if (!ColrTable.TryGetVariationDeltaSet(varIndexBase, out var deltaSet))
+ {
+ return matrix;
+ }
+
+ // Affine2x3 matrix component deltas are Fixed format (16.16)
+ // Note: Depending on the spec, these might need to be treated differently
+ // For now, using the raw deltas as they might already be in the correct format
+ var m11 = matrix.M11 + (deltaSet.Count > 0 ? deltaSet.GetFixedDelta(0) : 0.0);
+ var m12 = matrix.M12 + (deltaSet.Count > 1 ? deltaSet.GetFixedDelta(1) : 0.0);
+ var m21 = matrix.M21 + (deltaSet.Count > 2 ? deltaSet.GetFixedDelta(2) : 0.0);
+ var m22 = matrix.M22 + (deltaSet.Count > 3 ? deltaSet.GetFixedDelta(3) : 0.0);
+ var m31 = matrix.M31 + (deltaSet.Count > 4 ? deltaSet.GetFixedDelta(4) : 0.0);
+ var m32 = matrix.M32 + (deltaSet.Count > 5 ? deltaSet.GetFixedDelta(5) : 0.0);
+
+ return new Matrix(m11, m12, m21, m22, m31, m32);
+ }
+
+ ///
+ /// Resolves color stops with variation deltas applied and normalized to 0-1 range.
+ /// Based on fontations ColorStops::resolve and gradient normalization logic.
+ ///
+ public GradientStop[] ResolveColorStops(
+ GradientStopVar[] stops,
+ uint? varIndexBase)
+ {
+ if (stops.Length == 0)
+ return Array.Empty();
+
+ // No variation deltas to apply, just normalize
+ if (!varIndexBase.HasValue)
+ {
+ return NormalizeColorStops(stops);
+ }
+
+ // Check if we should apply variations
+ DeltaSet deltaSet = default;
+ var shouldApplyVariations = ColrTable.TryGetVariationDeltaSet(varIndexBase.Value, out deltaSet);
+
+ GradientStop[]? resolvedStops = null;
+
+ if (shouldApplyVariations)
+ {
+ resolvedStops = new GradientStop[stops.Length];
+
+ for (int i = 0; i < stops.Length; i++)
+ {
+ var stop = stops[i];
+ var offset = stop.Offset;
+ var color = stop.Color;
+
+ if (deltaSet.Count >= 2)
+ {
+ // Stop offset and alpha deltas are F2DOT14 format
+ offset += deltaSet.GetF2Dot14Delta(0);
+
+ // Apply alpha delta
+ var alphaDelta = deltaSet.GetF2Dot14Delta(1);
+ var newAlpha = Math.Clamp(color.A / 255.0 + alphaDelta, 0.0, 1.0);
+ color = Color.FromArgb((byte)(newAlpha * 255), color.R, color.G, color.B);
+ }
+
+ resolvedStops[i] = new GradientStop(offset, color);
+ }
+ }
+ else
+ {
+ resolvedStops = stops;
+ }
+
+ return NormalizeColorStops(resolvedStops);
+ }
+
+ ///
+ /// Normalizes color stops to 0-1 range and handles edge cases.
+ /// Modifies the array in-place to avoid allocations when possible.
+ ///
+ public GradientStop[] NormalizeColorStops(GradientStop[] stops)
+ {
+ if (stops.Length == 0)
+ return stops;
+
+ // Sort by offset (ImmutableGradientStop is immutable, so we need to sort the array)
+ Array.Sort(stops, (a, b) => a.Offset.CompareTo(b.Offset));
+
+ // Get first and last stops for normalization
+ var firstStop = stops[0];
+ var lastStop = stops[stops.Length - 1];
+ var colorStopRange = lastStop.Offset - firstStop.Offset;
+
+ // If all stops are at the same position
+ if (Math.Abs(colorStopRange) < 1e-6)
+ {
+ // For Pad mode with zero range, add an extra stop
+ if (colorStopRange == 0.0)
+ {
+ var newStops = new GradientStop[stops.Length + 1];
+ Array.Copy(stops, newStops, stops.Length);
+ newStops[stops.Length] = new GradientStop(lastStop.Offset + 1.0, lastStop.Color);
+ stops = newStops;
+ colorStopRange = 1.0;
+ firstStop = stops[0];
+ }
+ else
+ {
+ return stops;
+ }
+ }
+
+ // Check if normalization is needed
+ var needsNormalization = Math.Abs(colorStopRange - 1.0) > 1e-6 || Math.Abs(firstStop.Offset) > 1e-6;
+
+ if (!needsNormalization)
+ return stops;
+
+ // Normalize stops to 0-1 range
+ var scale = 1.0 / colorStopRange;
+ var startOffset = firstStop.Offset;
+
+ // Create new array with normalized values
+ var normalizedStops = new GradientStop[stops.Length];
+ for (int i = 0; i < stops.Length; i++)
+ {
+ var stop = stops[i];
+ var normalizedOffset = (stop.Offset - startOffset) * scale;
+ normalizedStops[i] = new GradientStop(normalizedOffset, stop.Color);
+ }
+
+ return normalizedStops;
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Colr/ColrTable.cs b/src/Avalonia.Base/Media/Fonts/Tables/Colr/ColrTable.cs
new file mode 100644
index 0000000000..0f9b65281a
--- /dev/null
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Colr/ColrTable.cs
@@ -0,0 +1,765 @@
+using System;
+using System.Buffers.Binary;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using Avalonia.Platform;
+
+namespace Avalonia.Media.Fonts.Tables.Colr
+{
+ ///
+ /// Reader for the 'COLR' (Color) table. Provides access to layered color glyph data.
+ /// Supports COLR v0 and v1 formats.
+ ///
+ internal sealed class ColrTable
+ {
+ internal const string TableName = "COLR";
+
+ internal static OpenTypeTag Tag { get; } = OpenTypeTag.Parse(TableName);
+
+ private readonly ReadOnlyMemory _colrData;
+ private readonly ushort _version;
+ private readonly ushort _numBaseGlyphRecords;
+ private readonly uint _baseGlyphRecordsOffset;
+ private readonly uint _layerRecordsOffset;
+ private readonly ushort _numLayerRecords;
+
+ // COLR v1 fields
+ private readonly uint _baseGlyphV1ListOffset;
+ private readonly uint _layerV1ListOffset;
+ private readonly uint _clipListOffset;
+ private readonly uint _varIndexMapOffset;
+ private readonly uint _itemVariationStoreOffset;
+
+ // Cached DeltaSetIndexMap (loaded during construction)
+ private readonly DeltaSetIndexMap? _deltaSetIndexMap;
+
+ // Cached ItemVariationStore (loaded during construction)
+ private readonly ItemVariationStore? _itemVariationStore;
+
+ private ColrTable(
+ ReadOnlyMemory colrData,
+ ushort version,
+ ushort numBaseGlyphRecords,
+ uint baseGlyphRecordsOffset,
+ uint layerRecordsOffset,
+ ushort numLayerRecords,
+ uint baseGlyphV1ListOffset = 0,
+ uint layerV1ListOffset = 0,
+ uint clipListOffset = 0,
+ uint varIndexMapOffset = 0,
+ uint itemVariationStoreOffset = 0,
+ DeltaSetIndexMap? deltaSetIndexMap = null,
+ ItemVariationStore? itemVariationStore = null)
+ {
+ _colrData = colrData;
+ _version = version;
+ _numBaseGlyphRecords = numBaseGlyphRecords;
+ _baseGlyphRecordsOffset = baseGlyphRecordsOffset;
+ _layerRecordsOffset = layerRecordsOffset;
+ _numLayerRecords = numLayerRecords;
+ _baseGlyphV1ListOffset = baseGlyphV1ListOffset;
+ _layerV1ListOffset = layerV1ListOffset;
+ _clipListOffset = clipListOffset;
+ _varIndexMapOffset = varIndexMapOffset;
+ _itemVariationStoreOffset = itemVariationStoreOffset;
+ _deltaSetIndexMap = deltaSetIndexMap;
+ _itemVariationStore = itemVariationStore;
+ }
+
+ ///
+ /// Gets the version of the COLR table (0 or 1).
+ ///
+ public ushort Version => _version;
+
+ ///
+ /// Gets the number of base glyph records (v0).
+ ///
+ public int BaseGlyphCount => _numBaseGlyphRecords;
+
+ ///
+ /// Gets whether this table has COLR v1 data.
+ ///
+ public bool HasV1Data => _version >= 1 && _baseGlyphV1ListOffset > 0;
+
+ ///
+ /// Gets the LayerV1List offset from the COLR table header.
+ /// Returns 0 if the LayerV1List is not present (COLR v0 or no LayerV1List in v1).
+ ///
+ public uint LayerV1ListOffset => _layerV1ListOffset;
+
+ public ReadOnlyMemory ColrData => _colrData;
+
+ ///
+ /// Attempts to load the COLR (Color) table from the specified glyph typeface.
+ ///
+ /// This method supports both COLR version 0 and version 1 tables, as defined in the
+ /// OpenType specification. If the COLR table is not present or is invalid, the method returns false and sets
+ /// colrTable to null.
+ /// The glyph typeface from which to load the COLR table. Cannot be null.
+ /// When this method returns, contains the loaded COLR table if successful; otherwise, null. This parameter is
+ /// passed uninitialized.
+ /// true if the COLR table was successfully loaded; otherwise, false.
+ public static bool TryLoad(GlyphTypeface glyphTypeface, [NotNullWhen(true)] out ColrTable? colrTable)
+ {
+ colrTable = null;
+
+ if (!glyphTypeface.PlatformTypeface.TryGetTable(Tag, out var colrData))
+ {
+ return false;
+ }
+
+ if (colrData.Length < 14)
+ {
+ return false; // Minimum size for COLR v0 header
+ }
+
+ var span = colrData.Span;
+
+ // Parse COLR table header (v0)
+ // uint16 version
+ // uint16 numBaseGlyphRecords
+ // Offset32 baseGlyphRecordsOffset
+ // Offset32 layerRecordsOffset
+ // uint16 numLayerRecords
+
+ var version = BinaryPrimitives.ReadUInt16BigEndian(span);
+ var numBaseGlyphRecords = BinaryPrimitives.ReadUInt16BigEndian(span.Slice(2));
+ var baseGlyphRecordsOffset = BinaryPrimitives.ReadUInt32BigEndian(span.Slice(4));
+ var layerRecordsOffset = BinaryPrimitives.ReadUInt32BigEndian(span.Slice(8));
+ var numLayerRecords = BinaryPrimitives.ReadUInt16BigEndian(span.Slice(12));
+
+ // Validate v0 offsets
+ if (baseGlyphRecordsOffset >= colrData.Length || layerRecordsOffset >= colrData.Length)
+ {
+ return false;
+ }
+
+ // Parse COLR v1 extensions if present
+ uint baseGlyphV1ListOffset = 0;
+ uint layerV1ListOffset = 0;
+ uint clipListOffset = 0;
+ uint varIndexMapOffset = 0;
+ uint itemVariationStoreOffset = 0;
+
+ if (version >= 1)
+ {
+ // COLR v1 adds additional fields after the v0 header
+ // Offset32 baseGlyphV1ListOffset (14 bytes)
+ // Offset32 layerV1ListOffset (18 bytes)
+ // Offset32 clipListOffset (22 bytes) - optional
+ // Offset32 varIndexMapOffset (26 bytes) - optional
+ // Offset32 itemVariationStoreOffset (30 bytes) - optional
+
+ if (colrData.Length >= 22)
+ {
+ baseGlyphV1ListOffset = BinaryPrimitives.ReadUInt32BigEndian(span.Slice(14));
+ layerV1ListOffset = BinaryPrimitives.ReadUInt32BigEndian(span.Slice(18));
+ }
+
+ if (colrData.Length >= 26)
+ {
+ clipListOffset = BinaryPrimitives.ReadUInt32BigEndian(span.Slice(22));
+ }
+
+ if (colrData.Length >= 30)
+ {
+ varIndexMapOffset = BinaryPrimitives.ReadUInt32BigEndian(span.Slice(26));
+ }
+
+ if (colrData.Length >= 34)
+ {
+ itemVariationStoreOffset = BinaryPrimitives.ReadUInt32BigEndian(span.Slice(30));
+ }
+
+ // Note: 0 offset means the optional table is not present
+ }
+
+ // Load DeltaSetIndexMap if present
+ DeltaSetIndexMap? deltaSetIndexMap = null;
+ if (version >= 1 && varIndexMapOffset > 0)
+ {
+ deltaSetIndexMap = DeltaSetIndexMap.Load(colrData, varIndexMapOffset);
+ }
+
+ // Load ItemVariationStore if present
+ ItemVariationStore? itemVariationStore = null;
+ if (version >= 1 && itemVariationStoreOffset > 0)
+ {
+ itemVariationStore = ItemVariationStore.Load(colrData, itemVariationStoreOffset);
+ }
+
+ colrTable = new ColrTable(
+ colrData,
+ version,
+ numBaseGlyphRecords,
+ baseGlyphRecordsOffset,
+ layerRecordsOffset,
+ numLayerRecords,
+ baseGlyphV1ListOffset,
+ layerV1ListOffset,
+ clipListOffset,
+ varIndexMapOffset,
+ itemVariationStoreOffset,
+ deltaSetIndexMap,
+ itemVariationStore);
+
+ return true;
+ }
+
+ ///
+ /// Tries to find the base glyph record for the specified glyph ID (v0 format).
+ /// Uses binary search for efficient lookup.
+ ///
+ public bool TryGetBaseGlyphRecord(ushort glyphId, out BaseGlyphRecord record)
+ {
+ record = default;
+
+ if (_numBaseGlyphRecords == 0)
+ {
+ return false;
+ }
+
+ var span = _colrData.Span;
+ var baseRecordsSpan = span.Slice((int)_baseGlyphRecordsOffset);
+
+ // Binary search for the glyph ID
+ int low = 0;
+ int high = _numBaseGlyphRecords - 1;
+
+ while (low <= high)
+ {
+ int mid = low + (high - low) / 2;
+ int offset = mid * 6; // Each BaseGlyphRecord is 6 bytes
+
+ if (offset + 6 > baseRecordsSpan.Length)
+ {
+ return false;
+ }
+
+ var recordSpan = baseRecordsSpan.Slice(offset, 6);
+ var recordGlyphId = BinaryPrimitives.ReadUInt16BigEndian(recordSpan);
+
+ if (recordGlyphId == glyphId)
+ {
+ // Found it
+ var firstLayerIndex = BinaryPrimitives.ReadUInt16BigEndian(recordSpan.Slice(2));
+ var numLayers = BinaryPrimitives.ReadUInt16BigEndian(recordSpan.Slice(4));
+
+ record = new BaseGlyphRecord(glyphId, firstLayerIndex, numLayers);
+ return true;
+ }
+ else if (recordGlyphId < glyphId)
+ {
+ low = mid + 1;
+ }
+ else
+ {
+ high = mid - 1;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Gets the layer record at the specified index (v0 format).
+ /// /// Gets the number of base glyph records (v0).
+ /// /// Gets whether this table has COLR v1 data.
+ /// /// Gets the LayerV1List offset from the COLR table header.
+ /// Returns 0 if the LayerV1List is not present (COLR v0 or no LayerV1List in v1).
+ /// ///
+ public bool TryGetLayerRecord(int layerIndex, out LayerRecord record)
+ {
+ record = default;
+
+ if (layerIndex < 0 || layerIndex >= _numLayerRecords)
+ {
+ return false;
+ }
+
+ var span = _colrData.Span;
+ var layerRecordsSpan = span.Slice((int)_layerRecordsOffset);
+
+ int offset = layerIndex * 4; // Each LayerRecord is 4 bytes
+
+ if (offset + 4 > layerRecordsSpan.Length)
+ {
+ return false;
+ }
+
+ var recordSpan = layerRecordsSpan.Slice(offset, 4);
+ var glyphId = BinaryPrimitives.ReadUInt16BigEndian(recordSpan);
+ var paletteIndex = BinaryPrimitives.ReadUInt16BigEndian(recordSpan.Slice(2));
+
+ record = new LayerRecord(glyphId, paletteIndex);
+ return true;
+ }
+
+ ///
+ /// Gets all layers for the specified glyph ID.
+ /// Returns an empty array if the glyph has no color layers.
+ ///
+ public LayerRecord[] GetLayers(ushort glyphId)
+ {
+ if (!TryGetBaseGlyphRecord(glyphId, out var baseRecord))
+ {
+ return Array.Empty();
+ }
+
+ var layers = new LayerRecord[baseRecord.NumLayers];
+
+ for (int i = 0; i < baseRecord.NumLayers; i++)
+ {
+ if (TryGetLayerRecord(baseRecord.FirstLayerIndex + i, out var layer))
+ {
+ layers[i] = layer;
+ }
+ }
+
+ return layers;
+ }
+
+ ///
+ /// Tries to get the v1 base glyph record for the specified glyph ID.
+ ///
+ public bool TryGetBaseGlyphV1Record(ushort glyphId, out BaseGlyphV1Record record)
+ {
+ record = default;
+
+ if (!HasV1Data)
+ {
+ return false;
+ }
+
+ var span = _colrData.Span;
+ var baseGlyphV1ListSpan = span.Slice((int)_baseGlyphV1ListOffset);
+
+ // BaseGlyphV1List format:
+ // uint32 numBaseGlyphV1Records
+ // BaseGlyphV1Record[numBaseGlyphV1Records] (sorted by glyphID)
+
+ if (baseGlyphV1ListSpan.Length < 4)
+ {
+ return false;
+ }
+
+ var numRecords = BinaryPrimitives.ReadUInt32BigEndian(baseGlyphV1ListSpan);
+
+ Debug.Assert(4 + numRecords * 6 <= _colrData.Length - _baseGlyphV1ListOffset);
+
+ int low = 0;
+ int high = (int)numRecords - 1;
+ int recordOffset = 4;
+
+ while (low <= high)
+ {
+ int mid = low + (high - low) / 2;
+
+ // 6 bytes per record: 2 (glyphID) + 4 (Offset32 paintOffset)
+ int offset = recordOffset + (mid * 6);
+
+ if (offset + 6 > baseGlyphV1ListSpan.Length)
+ return false;
+
+ var recordSpan = baseGlyphV1ListSpan.Slice(offset, 6);
+ var recordGlyphId = BinaryPrimitives.ReadUInt16BigEndian(recordSpan);
+
+ if (recordGlyphId == glyphId)
+ {
+ var paintOffset = BinaryPrimitives.ReadUInt32BigEndian(recordSpan.Slice(2));
+ record = new BaseGlyphV1Record(glyphId, paintOffset);
+ return true;
+ }
+ else if (recordGlyphId < glyphId)
+ {
+ low = mid + 1;
+ }
+ else
+ {
+ high = mid - 1;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Converts a paint offset from BaseGlyphV1Record (relative to BaseGlyphV1List) to an absolute offset within the COLR table.
+ /// According to OpenType COLR v1 specification, offsets in BaseGlyphV1Records are relative to the start of the BaseGlyphV1List.
+ ///
+ /// The paint offset from a BaseGlyphV1Record, relative to the BaseGlyphV1List.
+ /// The absolute offset within the COLR table.
+ internal uint GetAbsolutePaintOffset(uint relativePaintOffset)
+ {
+ // According to the OpenType spec, paint offsets in BaseGlyphV1Records are
+ // relative to the BaseGlyphV1List, so we add the BaseGlyphV1List offset to get
+ // the absolute position in the COLR table
+ return _baseGlyphV1ListOffset + relativePaintOffset;
+ }
+
+ ///
+ /// Attempts to resolve and retrieve the paint definition for the specified glyph, if available.
+ ///
+ /// This method returns if the glyph does not have a version 1
+ /// base glyph record or if the paint cannot be parsed or resolved. The output parameter is set only when the method returns .
+ /// The context containing color and font information used to resolve the paint.
+ /// The identifier of the glyph for which to retrieve the resolved paint.
+ /// When this method returns, contains the resolved paint for the specified glyph if the operation succeeds;
+ /// otherwise, . This parameter is passed uninitialized.
+ /// if the resolved paint was successfully retrieved; otherwise, .
+ public bool TryGetResolvedPaint(ColrContext context, ushort glyphId, [NotNullWhen(true)] out Paint? paint)
+ {
+ paint = null;
+
+ if (!TryGetBaseGlyphV1Record(glyphId, out var record))
+ {
+ return false;
+ }
+
+ var absolutePaintOffset = GetAbsolutePaintOffset(record.PaintOffset);
+
+ var decycler = PaintDecycler.Rent();
+ try
+ {
+ if (!PaintParser.TryParse(_colrData.Span, absolutePaintOffset, in context, in decycler, out var parsedPaint))
+ {
+ return false;
+ }
+
+ paint = PaintResolver.ResolvePaint(parsedPaint, in context);
+
+ return true;
+ }
+ finally
+ {
+ PaintDecycler.Return(decycler);
+ }
+ }
+
+ ///
+ /// Checks if the specified glyph has color layers defined (v0 or v1).
+ ///
+ public bool HasColorLayers(ushort glyphId)
+ {
+ // Check v0 first
+ if (TryGetBaseGlyphRecord(glyphId, out _))
+ {
+ return true;
+ }
+
+ // Check v1
+ if (HasV1Data && TryGetBaseGlyphV1Record(glyphId, out _))
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Tries to get a complete delta set for the specified variation index.
+ ///
+ /// The index to look up in the DeltaSetIndexMap.
+ /// A DeltaSet ref struct providing format-aware access to deltas.
+ /// True if variation deltas were found; otherwise false.
+ ///
+ /// This method uses the DeltaSetIndexMap to map the variation index to an (outer, inner) index pair,
+ /// then retrieves the corresponding delta set from the ItemVariationStore.
+ /// If no DeltaSetIndexMap is present, an implicit 1:1 mapping is used.
+ /// The DeltaSet ref struct provides allocation-free access to both word deltas (16-bit) and byte deltas (8-bit).
+ ///
+ public bool TryGetVariationDeltaSet(uint variationIndex, out DeltaSet deltaSet)
+ {
+ deltaSet = DeltaSet.Empty;
+
+ // Magic value 0xFFFFFFFF indicates no variation deltas should be applied
+ const uint NO_VARIATION_DELTAS = 0xFFFFFFFF;
+
+ if (variationIndex == NO_VARIATION_DELTAS)
+ {
+ return false;
+ }
+
+ // ItemVariationStore is required
+ if (!HasV1Data || _itemVariationStore == null)
+ {
+ return false;
+ }
+
+ // If DeltaSetIndexMap is present, use it for mapping
+ if (_deltaSetIndexMap != null)
+ {
+ return _deltaSetIndexMap.TryGetVariationDeltaSet(_itemVariationStore, variationIndex, out deltaSet);
+ }
+
+ // Otherwise, use implicit 1:1 mapping as per OpenType spec
+ if (variationIndex > ushort.MaxValue)
+ {
+ return false; // Index too large for implicit mapping
+ }
+
+ var implicitOuterIndex = (ushort)variationIndex;
+ var implicitInnerIndex = (ushort)variationIndex;
+
+ return _itemVariationStore.TryGetDeltaSet(implicitOuterIndex, implicitInnerIndex, out deltaSet);
+ }
+
+ ///
+ /// Tries to get the clip box for a specified glyph ID from the ClipList (COLR v1).
+ ///
+ /// The glyph ID to get the clip box for.
+ /// The clip box rectangle, or null if no clip box is defined.
+ /// True if a clip box was found; otherwise false.
+ public bool TryGetClipBox(ushort glyphId, out Rect clipBox)
+ {
+ clipBox = default;
+
+ // ClipList is only available in COLR v1
+ if (!HasV1Data || _clipListOffset == 0)
+ {
+ return false;
+ }
+
+ var span = _colrData.Span;
+
+ if (_clipListOffset >= span.Length)
+ {
+ return false;
+ }
+
+ var clipListSpan = span.Slice((int)_clipListOffset);
+
+ // ClipList format:
+ // uint8 format (must be 1)
+ // uint32 numClips
+ // ClipRecord[numClips] (sorted by startGlyphID)
+
+ if (clipListSpan.Length < 5) // format (1) + numClips (4)
+ {
+ return false;
+ }
+
+ var format = clipListSpan[0];
+ if (format != 1)
+ {
+ return false; // Only format 1 is defined
+ }
+
+ var numClips = BinaryPrimitives.ReadUInt32BigEndian(clipListSpan.Slice(1));
+
+ if (numClips == 0)
+ {
+ return false;
+ }
+
+ // Binary search for the clip record
+ // ClipRecord format:
+ // uint16 startGlyphID
+ // uint16 endGlyphID
+ // Offset24 clipBoxOffset (relative to start of ClipList)
+
+ int recordSize = 7; // 2 + 2 + 3
+ int low = 0;
+ int high = (int)numClips - 1;
+ int recordsOffset = 5; // After format + numClips
+
+ while (low <= high)
+ {
+ int mid = low + (high - low) / 2;
+ int offset = recordsOffset + (mid * recordSize);
+
+ if (offset + recordSize > clipListSpan.Length)
+ {
+ return false;
+ }
+
+ var recordSpan = clipListSpan.Slice(offset, recordSize);
+ var startGlyphId = BinaryPrimitives.ReadUInt16BigEndian(recordSpan);
+ var endGlyphId = BinaryPrimitives.ReadUInt16BigEndian(recordSpan.Slice(2));
+
+ if (glyphId >= startGlyphId && glyphId <= endGlyphId)
+ {
+ // Found the clip record - parse the clip box
+ var clipBoxOffset = ReadOffset24(recordSpan.Slice(4));
+ var absoluteClipBoxOffset = _clipListOffset + clipBoxOffset;
+
+ return TryParseClipBox(span, absoluteClipBoxOffset, out clipBox);
+ }
+ else if (glyphId < startGlyphId)
+ {
+ high = mid - 1;
+ }
+ else
+ {
+ low = mid + 1;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Tries to parse a ClipBox from the specified offset.
+ ///
+ private static bool TryParseClipBox(ReadOnlySpan data, uint offset, out Rect clipBox)
+ {
+ clipBox = default;
+
+ if (offset >= data.Length)
+ {
+ return false;
+ }
+
+ var span = data.Slice((int)offset);
+
+ // ClipBox format (format 1 or 2):
+ // uint8 format
+ // For format 1 (FWORD values):
+ // FWORD xMin
+ // FWORD yMin
+ // FWORD xMax
+ // FWORD yMax
+ // For format 2 (FWORD + VarIndexBase for each):
+ // FWORD xMin, uint32 varIndexBase
+ // FWORD yMin, uint32 varIndexBase
+ // FWORD xMax, uint32 varIndexBase
+ // FWORD yMax, uint32 varIndexBase
+
+ if (span.Length < 1)
+ {
+ return false;
+ }
+
+ var format = span[0];
+
+ if (format == 1)
+ {
+ // Format 1: Fixed ClipBox
+ if (span.Length < 9) // format (1) + 4 FWORDs (8)
+ {
+ return false;
+ }
+
+ var xMin = BinaryPrimitives.ReadInt16BigEndian(span.Slice(1));
+ var yMin = BinaryPrimitives.ReadInt16BigEndian(span.Slice(3));
+ var xMax = BinaryPrimitives.ReadInt16BigEndian(span.Slice(5));
+ var yMax = BinaryPrimitives.ReadInt16BigEndian(span.Slice(7));
+
+ // Keep in font-space coordinates (Y-up)
+ // The Y-flip transformation is applied at the root level in PaintResolver
+ clipBox = new Rect(xMin, yMin, xMax - xMin, yMax - yMin);
+ return true;
+ }
+ else if (format == 2)
+ {
+ // Format 2: Variable ClipBox (ignore VarIndexBase for now)
+ if (span.Length < 25) // format (1) + 4 * (FWORD (2) + VarIndexBase (4))
+ {
+ return false;
+ }
+
+ var xMin = BinaryPrimitives.ReadInt16BigEndian(span.Slice(1));
+ // Skip VarIndexBase at span[3..7]
+ var yMin = BinaryPrimitives.ReadInt16BigEndian(span.Slice(7));
+ // Skip VarIndexBase at span[9..13]
+ var xMax = BinaryPrimitives.ReadInt16BigEndian(span.Slice(13));
+ // Skip VarIndexBase at span[15..19]
+ var yMax = BinaryPrimitives.ReadInt16BigEndian(span.Slice(19));
+ // Skip VarIndexBase at span[21..25]
+
+ // Keep in font-space coordinates (Y-up)
+ // The Y-flip transformation is applied at the root level in PaintResolver
+ clipBox = new Rect(xMin, yMin, xMax - xMin, yMax - yMin);
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Reads a 24-bit offset (3 bytes, big-endian).
+ ///
+ private static uint ReadOffset24(ReadOnlySpan span)
+ {
+ return ((uint)span[0] << 16) | ((uint)span[1] << 8) | span[2];
+ }
+ }
+
+ ///
+ /// Represents a base glyph record in the COLR table (v0).
+ /// Maps a glyph ID to its color layers.
+ ///
+ internal readonly struct BaseGlyphRecord
+ {
+ public BaseGlyphRecord(ushort glyphId, ushort firstLayerIndex, ushort numLayers)
+ {
+ GlyphId = glyphId;
+ FirstLayerIndex = firstLayerIndex;
+ NumLayers = numLayers;
+ }
+
+ ///
+ /// Gets the glyph ID of the base glyph.
+ ///
+ public ushort GlyphId { get; }
+
+ ///
+ /// Gets the index of the first layer record for this glyph.
+ ///
+ public ushort FirstLayerIndex { get; }
+
+ ///
+ /// Gets the number of color layers for this glyph.
+ ///
+ public ushort NumLayers { get; }
+ }
+
+ ///
+ /// Represents a v1 base glyph record in the COLR table.
+ /// Maps a glyph ID to a paint offset.
+ ///
+ internal readonly struct BaseGlyphV1Record
+ {
+ public BaseGlyphV1Record(ushort glyphId, uint paintOffset)
+ {
+ GlyphId = glyphId;
+ PaintOffset = paintOffset;
+ }
+
+ ///
+ /// Gets the glyph ID of the base glyph.
+ ///
+ public ushort GlyphId { get; }
+
+ ///
+ /// Gets the offset to the paint table for this glyph.
+ ///
+ public uint PaintOffset { get; }
+ }
+
+ ///
+ /// Represents a layer record in the COLR table (v0).
+ /// Each layer references a glyph and a color palette index.
+ ///
+ internal readonly struct LayerRecord
+ {
+ public LayerRecord(ushort glyphId, ushort paletteIndex)
+ {
+ GlyphId = glyphId;
+ PaletteIndex = paletteIndex;
+ }
+
+ ///
+ /// Gets the glyph ID for this layer.
+ /// This typically references a glyph in the 'glyf' or 'CFF' table.
+ ///
+ public ushort GlyphId { get; }
+
+ ///
+ /// Gets the color palette index for this layer.
+ /// References a color in the CPAL (Color Palette) table.
+ ///
+ public ushort PaletteIndex { get; }
+ }
+}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Colr/CompositeMode.cs b/src/Avalonia.Base/Media/Fonts/Tables/Colr/CompositeMode.cs
new file mode 100644
index 0000000000..e8e1a9ea3a
--- /dev/null
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Colr/CompositeMode.cs
@@ -0,0 +1,37 @@
+namespace Avalonia.Media.Fonts.Tables.Colr
+{
+ ///
+ /// Composite modes for blend operations.
+ ///
+ internal enum CompositeMode
+ {
+ Clear = 0,
+ Src = 1,
+ Dest = 2,
+ SrcOver = 3,
+ DestOver = 4,
+ SrcIn = 5,
+ DestIn = 6,
+ SrcOut = 7,
+ DestOut = 8,
+ SrcAtop = 9,
+ DestAtop = 10,
+ Xor = 11,
+ Plus = 12,
+ Screen = 13,
+ Overlay = 14,
+ Darken = 15,
+ Lighten = 16,
+ ColorDodge = 17,
+ ColorBurn = 18,
+ HardLight = 19,
+ SoftLight = 20,
+ Difference = 21,
+ Exclusion = 22,
+ Multiply = 23,
+ HslHue = 24,
+ HslSaturation = 25,
+ HslColor = 26,
+ HslLuminosity = 27
+ }
+}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Colr/CpalTable.cs b/src/Avalonia.Base/Media/Fonts/Tables/Colr/CpalTable.cs
new file mode 100644
index 0000000000..5ef3bcfa88
--- /dev/null
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Colr/CpalTable.cs
@@ -0,0 +1,230 @@
+using System;
+using System.Buffers.Binary;
+using System.Diagnostics.CodeAnalysis;
+using Avalonia.Platform;
+
+namespace Avalonia.Media.Fonts.Tables.Colr
+{
+ ///
+ /// Reader for the 'CPAL' (Color Palette) table. Provides access to color palettes used by COLR glyphs.
+ ///
+ internal sealed class CpalTable
+ {
+ internal const string TableName = "CPAL";
+
+ internal static OpenTypeTag Tag { get; } = OpenTypeTag.Parse(TableName);
+
+ private readonly ReadOnlyMemory _cpalData;
+ private readonly ushort _version;
+ private readonly ushort _numPaletteEntries;
+ private readonly ushort _numPalettes;
+ private readonly ushort _numColorRecords;
+ private readonly uint _colorRecordsArrayOffset;
+
+ private CpalTable(
+ ReadOnlyMemory cpalData,
+ ushort version,
+ ushort numPaletteEntries,
+ ushort numPalettes,
+ ushort numColorRecords,
+ uint colorRecordsArrayOffset)
+ {
+ _cpalData = cpalData;
+ _version = version;
+ _numPaletteEntries = numPaletteEntries;
+ _numPalettes = numPalettes;
+ _numColorRecords = numColorRecords;
+ _colorRecordsArrayOffset = colorRecordsArrayOffset;
+ }
+
+ ///
+ /// Gets the version of the CPAL table.
+ ///
+ public ushort Version => _version;
+
+ ///
+ /// Gets the number of palette entries in each palette.
+ ///
+ public int PaletteEntryCount => _numPaletteEntries;
+
+ ///
+ /// Gets the number of palettes.
+ ///
+ public int PaletteCount => _numPalettes;
+
+ ///
+ /// Attempts to load the CPAL (Color Palette) table from the specified glyph typeface.
+ ///
+ /// This method supports CPAL table versions 0 and 1. If the glyph typeface does not
+ /// contain a valid CPAL table, or if the table version is not supported, the method returns false and sets
+ /// cpalTable to null.
+ /// The glyph typeface from which to load the CPAL table. Cannot be null.
+ /// When this method returns, contains the loaded CPAL table if successful; otherwise, null. This parameter is
+ /// passed uninitialized.
+ /// true if the CPAL table was successfully loaded; otherwise, false.
+ public static bool TryLoad(GlyphTypeface glyphTypeface, [NotNullWhen(true)] out CpalTable? cpalTable)
+ {
+ cpalTable = null;
+
+ if (!glyphTypeface.PlatformTypeface.TryGetTable(Tag, out var cpalData))
+ {
+ return false;
+ }
+
+ if (cpalData.Length < 12)
+ {
+ return false; // Minimum size for CPAL header
+ }
+
+ var span = cpalData.Span;
+
+ // Parse CPAL table header
+ // uint16 version
+ // uint16 numPaletteEntries
+ // uint16 numPalettes
+ // uint16 numColorRecords
+ // Offset32 colorRecordsArrayOffset
+
+ var version = BinaryPrimitives.ReadUInt16BigEndian(span);
+ var numPaletteEntries = BinaryPrimitives.ReadUInt16BigEndian(span.Slice(2));
+ var numPalettes = BinaryPrimitives.ReadUInt16BigEndian(span.Slice(4));
+ var numColorRecords = BinaryPrimitives.ReadUInt16BigEndian(span.Slice(6));
+ var colorRecordsArrayOffset = BinaryPrimitives.ReadUInt32BigEndian(span.Slice(8));
+
+ // Currently support CPAL v0 and v1
+ if (version > 1)
+ {
+ return false;
+ }
+
+ // Validate offset
+ if (colorRecordsArrayOffset >= cpalData.Length)
+ {
+ return false;
+ }
+
+ cpalTable = new CpalTable(
+ cpalData,
+ version,
+ numPaletteEntries,
+ numPalettes,
+ numColorRecords,
+ colorRecordsArrayOffset);
+
+ return true;
+ }
+
+ ///
+ /// Gets the offset to the first color record for the specified palette index.
+ ///
+ private bool TryGetPaletteOffset(int paletteIndex, out int firstColorIndex)
+ {
+ firstColorIndex = 0;
+
+ if (paletteIndex < 0 || paletteIndex >= _numPalettes)
+ {
+ return false;
+ }
+
+ var span = _cpalData.Span;
+
+ // The colorRecordIndices array starts at offset 12 (after the header)
+ // Each entry is uint16
+ int offsetTableOffset = 12 + (paletteIndex * 2);
+
+ if (offsetTableOffset + 2 > span.Length)
+ {
+ return false;
+ }
+
+ firstColorIndex = BinaryPrimitives.ReadUInt16BigEndian(span.Slice(offsetTableOffset, 2));
+ return true;
+ }
+
+ ///
+ /// Tries to get the color at the specified palette index and color index.
+ ///
+ /// The palette index (0-based).
+ /// The color index within the palette (0-based).
+ /// The resulting color.
+ /// True if the color was successfully retrieved; otherwise, false.
+ public bool TryGetColor(int paletteIndex, int colorIndex, out Color color)
+ {
+ color = default;
+
+ if (!TryGetPaletteOffset(paletteIndex, out var firstColorIndex))
+ {
+ return false;
+ }
+
+ if (colorIndex < 0 || colorIndex >= _numPaletteEntries)
+ {
+ return false;
+ }
+
+ var actualColorIndex = firstColorIndex + colorIndex;
+
+ if (actualColorIndex >= _numColorRecords)
+ {
+ return false;
+ }
+
+ var span = _cpalData.Span;
+
+ // Each color record is 4 bytes: BGRA
+ int offset = (int)_colorRecordsArrayOffset + (actualColorIndex * 4);
+
+ if (offset + 4 > span.Length)
+ {
+ return false;
+ }
+
+ var colorSpan = span.Slice(offset, 4);
+
+ // Colors are stored as BGRA (little-endian uint32 when viewed as 0xAARRGGBB)
+ var b = colorSpan[0];
+ var g = colorSpan[1];
+ var r = colorSpan[2];
+ var a = colorSpan[3];
+
+ color = Color.FromArgb(a, r, g, b);
+ return true;
+ }
+
+ ///
+ /// Gets all colors in the specified palette.
+ /// Returns an empty array if the palette index is invalid.
+ ///
+ public Color[] GetPalette(int paletteIndex)
+ {
+ if (!TryGetPaletteOffset(paletteIndex, out var firstColorIndex))
+ {
+ return Array.Empty();
+ }
+
+ var colors = new Color[_numPaletteEntries];
+
+ for (int i = 0; i < _numPaletteEntries; i++)
+ {
+ if (TryGetColor(paletteIndex, i, out var color))
+ {
+ colors[i] = color;
+ }
+ else
+ {
+ colors[i] = Colors.Black; // Fallback
+ }
+ }
+
+ return colors;
+ }
+
+ ///
+ /// Tries to get a color from the default palette (palette 0).
+ ///
+ public bool TryGetColor(int colorIndex, out Color color)
+ {
+ return TryGetColor(0, colorIndex, out color);
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Colr/Decycler.cs b/src/Avalonia.Base/Media/Fonts/Tables/Colr/Decycler.cs
new file mode 100644
index 0000000000..3df03da3d4
--- /dev/null
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Colr/Decycler.cs
@@ -0,0 +1,166 @@
+using System;
+using System.Collections.Generic;
+
+namespace Avalonia.Media.Fonts.Tables.Colr
+{
+ ///
+ /// Errors that can occur during paint graph traversal with cycle detection.
+ ///
+ internal enum DecyclerError
+ {
+ ///
+ /// A cycle was detected in the paint graph.
+ ///
+ CycleDetected,
+
+ ///
+ /// The maximum depth limit was exceeded.
+ ///
+ DepthLimitExceeded
+ }
+
+ ///
+ /// Exception thrown when a decycler error occurs.
+ ///
+ internal class DecyclerException : Exception
+ {
+ public DecyclerError Error { get; }
+
+ public DecyclerException(DecyclerError error, string message) : base(message)
+ {
+ Error = error;
+ }
+ }
+
+ ///
+ /// A guard that tracks entry into a paint node and ensures proper cleanup.
+ ///
+ /// The type of the paint identifier.
+ internal ref struct CycleGuard where T : struct
+ {
+ private readonly Decycler _decycler;
+ private readonly T _id;
+ private bool _exited;
+
+ internal CycleGuard(Decycler decycler, T id)
+ {
+ _decycler = decycler;
+ _id = id;
+ _exited = false;
+ }
+
+ ///
+ /// Exits the guard, removing the paint ID from the visited set.
+ ///
+ public void Dispose()
+ {
+ if (!_exited)
+ {
+ _decycler.Exit(_id);
+ _exited = true;
+ }
+ }
+ }
+
+ ///
+ /// Tracks visited paint nodes to detect cycles in the paint graph.
+ /// Uses a depth limit to prevent stack overflow even without a HashSet in no_std builds.
+ ///
+ /// Usage example:
+ ///
+ /// var decycler = new PaintDecycler();
+ ///
+ /// void TraversePaint(Paint paint, ushort glyphId)
+ /// {
+ /// using var guard = decycler.Enter(glyphId);
+ ///
+ /// // Process the paint node here
+ /// // The guard will automatically clean up when the using block exits
+ ///
+ /// // If this paint references other paints, traverse them recursively:
+ /// if (paint is ColrGlyph colrGlyph)
+ /// {
+ /// var childPaint = GetPaint(colrGlyph.GlyphId);
+ /// TraversePaint(childPaint, (ushort)colrGlyph.GlyphId);
+ /// }
+ /// }
+ ///
+ ///
+ /// The type of the paint identifier (typically ushort for GlyphId).
+ internal class Decycler where T : struct
+ {
+ private readonly HashSet _visited;
+ private readonly int _maxDepth;
+ private int _currentDepth;
+
+ ///
+ /// Creates a new Decycler with the specified maximum depth.
+ ///
+ /// Maximum traversal depth before returning an error.
+ public Decycler(int maxDepth)
+ {
+ _visited = new HashSet();
+ _maxDepth = maxDepth;
+ _currentDepth = 0;
+ }
+
+ ///
+ /// Attempts to enter a paint node with the given ID.
+ /// Returns a guard that will automatically exit when disposed.
+ ///
+ /// The paint identifier to enter.
+ /// A guard that will clean up on disposal.
+ /// Thrown if a cycle is detected or depth limit exceeded.
+ public CycleGuard Enter(T id)
+ {
+ if (_currentDepth >= _maxDepth)
+ {
+ throw new DecyclerException(
+ DecyclerError.DepthLimitExceeded,
+ $"Paint graph depth limit of {_maxDepth} exceeded");
+ }
+
+ if (_visited.Contains(id))
+ {
+ throw new DecyclerException(
+ DecyclerError.CycleDetected,
+ "Cycle detected in paint graph");
+ }
+
+ _visited.Add(id);
+ _currentDepth++;
+
+ return new CycleGuard(this, id);
+ }
+
+ ///
+ /// Exits a paint node, removing it from the visited set.
+ /// Called automatically by CycleGuard.Dispose().
+ ///
+ /// The paint identifier to exit.
+ internal void Exit(T id)
+ {
+ _visited.Remove(id);
+ _currentDepth--;
+ }
+
+ ///
+ /// Returns the current traversal depth.
+ ///
+ public int CurrentDepth => _currentDepth;
+
+ ///
+ /// Returns the maximum allowed traversal depth.
+ ///
+ public int MaxDepth => _maxDepth;
+
+ ///
+ /// Resets the decycler to its initial state, clearing all visited nodes.
+ ///
+ public void Reset()
+ {
+ _visited.Clear();
+ _currentDepth = 0;
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Colr/DeltaSet.cs b/src/Avalonia.Base/Media/Fonts/Tables/Colr/DeltaSet.cs
new file mode 100644
index 0000000000..afcea08f37
--- /dev/null
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Colr/DeltaSet.cs
@@ -0,0 +1,247 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace Avalonia.Media.Fonts.Tables.Colr
+{
+ ///
+ /// Represents a set of variation deltas from an ItemVariationStore.
+ /// This is a ref struct for allocation-free access to delta data.
+ ///
+ ///
+ /// OpenType ItemVariationStore uses a mixed format for deltas:
+ /// - Word deltas (16-bit, int16): More significant variations
+ /// - Byte deltas (8-bit, int8): Smaller variations for space optimization
+ ///
+ /// The wordDeltaCount determines how many deltas are 16-bit vs 8-bit.
+ ///
+ /// Delta values are stored as integers but must be converted based on their target type:
+ /// - FWORD deltas (coordinates, translations): No conversion needed (design units)
+ /// - F2DOT14 deltas (scales, angles, alpha): Divide by 16384.0
+ /// - Fixed deltas (Affine2x3 components): Divide by 65536.0
+ ///
+ public ref struct DeltaSet
+ {
+ ///
+ /// Creates an empty DeltaSet.
+ ///
+ public static DeltaSet Empty => new DeltaSet(ReadOnlySpan.Empty, 0, 0);
+
+ private readonly ReadOnlySpan _data;
+ private readonly ushort _wordDeltaCount;
+ private readonly ushort _totalDeltaCount;
+
+ internal DeltaSet(ReadOnlySpan data, ushort wordDeltaCount, ushort totalDeltaCount)
+ {
+ _data = data;
+ _wordDeltaCount = wordDeltaCount;
+ _totalDeltaCount = totalDeltaCount;
+ }
+
+ ///
+ /// Gets whether this delta set is empty (no deltas available).
+ ///
+ public bool IsEmpty => _totalDeltaCount == 0;
+
+ ///
+ /// Gets the total number of deltas in this set (word + byte deltas).
+ ///
+ public int Count => _totalDeltaCount;
+
+ ///
+ /// Gets the number of word deltas (16-bit) in this set.
+ ///
+ public int WordDeltaCount => _wordDeltaCount;
+
+ ///
+ /// Gets the number of byte deltas (8-bit) in this set.
+ ///
+ public int ByteDeltaCount => _totalDeltaCount - _wordDeltaCount;
+
+ ///
+ /// Gets the word deltas (16-bit signed integers) as a ReadOnlySpan.
+ ///
+ public ReadOnlySpan WordDeltas
+ {
+ get
+ {
+ if (_wordDeltaCount == 0)
+ {
+ return ReadOnlySpan.Empty;
+ }
+
+ var wordBytes = _data.Slice(0, _wordDeltaCount * 2);
+ return MemoryMarshal.Cast(wordBytes);
+ }
+ }
+
+ ///
+ /// Gets the byte deltas (8-bit signed integers) as a ReadOnlySpan.
+ ///
+ public ReadOnlySpan ByteDeltas
+ {
+ get
+ {
+ var byteDeltaCount = _totalDeltaCount - _wordDeltaCount;
+ if (byteDeltaCount == 0)
+ {
+ return ReadOnlySpan.Empty;
+ }
+
+ var byteOffset = _wordDeltaCount * 2;
+ var byteBytes = _data.Slice(byteOffset, byteDeltaCount);
+ return MemoryMarshal.Cast(byteBytes);
+ }
+ }
+
+ ///
+ /// Gets a delta value at the specified index, converting byte deltas to short for uniform access.
+ ///
+ /// The index of the delta (0 to Count-1).
+ /// The delta value as a 16-bit signed integer.
+ /// If index is out of range.
+ public short this[int index]
+ {
+ get
+ {
+ if (index < 0 || index >= _totalDeltaCount)
+ {
+ throw new IndexOutOfRangeException($"Delta index {index} is out of range [0, {_totalDeltaCount})");
+ }
+
+ // Word deltas come first
+ if (index < _wordDeltaCount)
+ {
+ var wordBytes = _data.Slice(index * 2, 2);
+ return System.Buffers.Binary.BinaryPrimitives.ReadInt16BigEndian(wordBytes);
+ }
+
+ // Byte deltas come after word deltas
+ var byteIndex = index - _wordDeltaCount;
+ var byteOffset = (_wordDeltaCount * 2) + byteIndex;
+ return (sbyte)_data[byteOffset];
+ }
+ }
+
+ ///
+ /// Tries to get a delta value at the specified index.
+ ///
+ /// The index of the delta.
+ /// The delta value if successful.
+ /// True if the index is valid; otherwise false.
+ public bool TryGetDelta(int index, out short delta)
+ {
+ delta = 0;
+
+ if (index < 0 || index >= _totalDeltaCount)
+ {
+ return false;
+ }
+
+ delta = this[index];
+ return true;
+ }
+
+ ///
+ /// Gets a delta as an FWORD value (design units - no conversion needed).
+ ///
+ /// The index of the delta.
+ /// The delta value as a double in design units, or 0.0 if index is out of range.
+ ///
+ /// FWORD deltas are used for:
+ /// - Translation offsets (dx, dy)
+ /// - Gradient coordinates (x0, y0, x1, y1, etc.)
+ /// - Center points (centerX, centerY)
+ /// - Radii values (r0, r1)
+ ///
+ public double GetFWordDelta(int index)
+ {
+ if (index < 0 || index >= _totalDeltaCount)
+ {
+ return 0.0;
+ }
+
+ // FWORD: No conversion needed, deltas are in design units
+ return this[index];
+ }
+
+ ///
+ /// Gets a delta as an F2DOT14 value (fixed-point 2.14 format).
+ ///
+ /// The index of the delta.
+ /// The delta value as a double after F2DOT14 conversion, or 0.0 if index is out of range.
+ ///
+ /// F2DOT14 deltas are used for:
+ /// - Scale values (scaleX, scaleY, scale)
+ /// - Rotation angles (angle)
+ /// - Skew angles (xAngle, yAngle)
+ /// - Alpha values (alpha)
+ /// - Gradient stop offsets
+ ///
+ public double GetF2Dot14Delta(int index)
+ {
+ if (index < 0 || index >= _totalDeltaCount)
+ {
+ return 0.0;
+ }
+
+ // F2DOT14: Signed fixed-point with 2 integer bits and 14 fractional bits
+ // Convert by dividing by 2^14 (16384)
+ return this[index] / 16384.0;
+ }
+
+ ///
+ /// Gets a delta as a Fixed value (fixed-point 16.16 format).
+ ///
+ /// The index of the delta.
+ /// The delta value as a double after Fixed conversion, or 0.0 if index is out of range.
+ ///
+ /// Fixed deltas are used for:
+ /// - Affine2x3 matrix components (xx, yx, xy, yy, dx, dy)
+ ///
+ /// Note: This assumes the delta is stored as 16-bit but represents a 16.16 fixed-point conceptually.
+ /// In practice, Affine2x3 variations may need special handling depending on the font's encoding.
+ ///
+ public double GetFixedDelta(int index)
+ {
+ if (index < 0 || index >= _totalDeltaCount)
+ {
+ return 0.0;
+ }
+
+ // Fixed: 16.16 format
+ // Convert by dividing by 2^16 (65536)
+ return this[index] / 65536.0;
+ }
+
+ ///
+ /// Gets all deltas as 16-bit values, converting byte deltas to short.
+ /// Note: This method allocates an array.
+ ///
+ /// An array containing all deltas as 16-bit signed integers.
+ public short[] ToArray()
+ {
+ if (_totalDeltaCount == 0)
+ {
+ return Array.Empty();
+ }
+
+ var result = new short[_totalDeltaCount];
+
+ // Copy word deltas
+ var wordDeltas = WordDeltas;
+ for (int i = 0; i < wordDeltas.Length; i++)
+ {
+ result[i] = wordDeltas[i];
+ }
+
+ // Convert and copy byte deltas
+ var byteDeltas = ByteDeltas;
+ for (int i = 0; i < byteDeltas.Length; i++)
+ {
+ result[_wordDeltaCount + i] = byteDeltas[i];
+ }
+
+ return result;
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Colr/DeltaSetIndexMap.cs b/src/Avalonia.Base/Media/Fonts/Tables/Colr/DeltaSetIndexMap.cs
new file mode 100644
index 0000000000..164421dd85
--- /dev/null
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Colr/DeltaSetIndexMap.cs
@@ -0,0 +1,238 @@
+using System;
+using System.Buffers.Binary;
+
+namespace Avalonia.Media.Fonts.Tables.Colr
+{
+ ///
+ /// Represents a DeltaSetIndexMap table for COLR v1 font variations.
+ /// Maps glyph/entry indices to variation data indices.
+ ///
+ ///
+ /// The DeltaSetIndexMap provides mappings from indices (glyph IDs or other) to
+ /// (outer, inner) index pairs for ItemVariationStore lookups. This enables
+ /// efficient access to variation data.
+ ///
+ /// See OpenType spec: https://learn.microsoft.com/en-us/typography/opentype/spec/otvarcommonformats#associating-target-items-to-variation-data
+ ///
+ internal sealed class DeltaSetIndexMap
+ {
+ private readonly ReadOnlyMemory _data;
+ private readonly byte _format;
+ private readonly byte _entryFormat;
+ private readonly uint _mapCount;
+ private readonly uint _mapDataOffset;
+
+ private DeltaSetIndexMap(
+ ReadOnlyMemory data,
+ byte format,
+ byte entryFormat,
+ uint mapCount,
+ uint mapDataOffset)
+ {
+ _data = data;
+ _format = format;
+ _entryFormat = entryFormat;
+ _mapCount = mapCount;
+ _mapDataOffset = mapDataOffset;
+ }
+
+ ///
+ /// Gets the format of this DeltaSetIndexMap (0 or 1).
+ ///
+ public byte Format => _format;
+
+ ///
+ /// Gets the number of mapping entries.
+ ///
+ public uint MapCount => _mapCount;
+
+ ///
+ /// Loads a DeltaSetIndexMap from the specified data.
+ ///
+ /// The raw table data containing the DeltaSetIndexMap.
+ /// The offset within the data where the DeltaSetIndexMap starts.
+ /// A DeltaSetIndexMap instance, or null if the data is invalid.
+ public static DeltaSetIndexMap? Load(ReadOnlyMemory data, uint offset)
+ {
+ if (offset >= data.Length)
+ {
+ return null;
+ }
+
+ var span = data.Span.Slice((int)offset);
+
+ // Minimum size check: format (1) + entryFormat (1) + mapCount (varies by format)
+ if (span.Length < 2)
+ {
+ return null;
+ }
+
+ var format = span[0];
+
+ // Only formats 0 and 1 are defined
+ if (format > 1)
+ {
+ return null;
+ }
+
+ var entryFormat = span[1];
+
+ uint mapCount;
+ uint mapDataOffset;
+
+ if (format == 0)
+ {
+ // Format 0:
+ // uint8 format = 0
+ // uint8 entryFormat
+ // uint16 mapCount
+
+ if (span.Length < 4)
+ {
+ return null;
+ }
+
+ mapCount = BinaryPrimitives.ReadUInt16BigEndian(span.Slice(2));
+ mapDataOffset = 4; // Map data starts immediately after header
+ }
+ else // format == 1
+ {
+ // Format 1:
+ // uint8 format = 1
+ // uint8 entryFormat
+ // uint32 mapCount
+
+ if (span.Length < 6)
+ {
+ return null;
+ }
+
+ mapCount = BinaryPrimitives.ReadUInt32BigEndian(span.Slice(2));
+ mapDataOffset = 6; // Map data starts immediately after header
+ }
+
+ return new DeltaSetIndexMap(data.Slice((int)offset), format, entryFormat, mapCount, mapDataOffset);
+ }
+
+ ///
+ /// Gets the (outer, inner) delta set index pair for the specified map index.
+ ///
+ /// The index to look up (e.g., glyph ID).
+ /// The outer index for ItemVariationStore lookup.
+ /// The inner index for ItemVariationStore lookup.
+ /// True if the lookup was successful; otherwise false.
+ public bool TryGetDeltaSetIndex(uint mapIndex, out ushort outerIndex, out ushort innerIndex)
+ {
+ outerIndex = 0;
+ innerIndex = 0;
+
+ // If mapIndex is out of range, return false
+ if (mapIndex >= _mapCount)
+ {
+ return false;
+ }
+
+ var span = _data.Span;
+ var dataOffset = (int)_mapDataOffset;
+
+ // entryFormat specifies the size and packing of entries:
+ // Bits 0-3: Size of inner index minus 1 (in bytes)
+ // Bits 4-7: Size of outer index minus 1 (in bytes)
+ //
+ // Common values:
+ // 0x00 = 1-byte inner, 1-byte outer (2 bytes total)
+ // 0x10 = 1-byte inner, 2-byte outer (3 bytes total)
+ // 0x01 = 2-byte inner, 1-byte outer (3 bytes total)
+ // 0x11 = 2-byte inner, 2-byte outer (4 bytes total)
+
+ var innerSizeBytes = (_entryFormat & 0x0F) + 1;
+ var outerSizeBytes = ((_entryFormat >> 4) & 0x0F) + 1;
+ var entrySize = innerSizeBytes + outerSizeBytes;
+
+ var entryOffset = dataOffset + (int)(mapIndex * entrySize);
+
+ // Ensure we have enough data for this entry
+ if (entryOffset + entrySize > span.Length)
+ {
+ return false;
+ }
+
+ var entrySpan = span.Slice(entryOffset, entrySize);
+
+ // Read outer index (comes first)
+ switch (outerSizeBytes)
+ {
+ case 1:
+ outerIndex = entrySpan[0];
+ break;
+ case 2:
+ outerIndex = BinaryPrimitives.ReadUInt16BigEndian(entrySpan);
+ break;
+ case 3:
+ outerIndex = (ushort)((entrySpan[0] << 16) | (entrySpan[1] << 8) | entrySpan[2]);
+ break;
+ case 4:
+ outerIndex = (ushort)BinaryPrimitives.ReadUInt32BigEndian(entrySpan);
+ break;
+ default:
+ return false;
+ }
+
+ // Read inner index (comes after outer)
+ var innerSpan = entrySpan.Slice(outerSizeBytes);
+ switch (innerSizeBytes)
+ {
+ case 1:
+ innerIndex = innerSpan[0];
+ break;
+ case 2:
+ innerIndex = BinaryPrimitives.ReadUInt16BigEndian(innerSpan);
+ break;
+ case 3:
+ innerIndex = (ushort)((innerSpan[0] << 16) | (innerSpan[1] << 8) | innerSpan[2]);
+ break;
+ case 4:
+ innerIndex = (ushort)BinaryPrimitives.ReadUInt32BigEndian(innerSpan);
+ break;
+ default:
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Tries to get a complete delta set for the specified variation index.
+ ///
+ /// The ItemVariationStore to retrieve deltas from.
+ /// The index to look up in the DeltaSetIndexMap.
+ /// A DeltaSet ref struct providing format-aware access to deltas.
+ /// True if variation deltas were found; otherwise false.
+ ///
+ /// This method uses the DeltaSetIndexMap to map the variation index to an (outer, inner) index pair,
+ /// then retrieves the corresponding delta set from the ItemVariationStore.
+ /// The DeltaSet ref struct provides allocation-free access to both word and byte deltas.
+ ///
+ public bool TryGetVariationDeltaSet(
+ ItemVariationStore itemVariationStore,
+ uint variationIndex,
+ out DeltaSet deltaSet)
+ {
+ deltaSet = DeltaSet.Empty;
+
+ if (itemVariationStore == null)
+ {
+ return false;
+ }
+
+ // Map the variation index to (outer, inner) indices
+ if (!TryGetDeltaSetIndex(variationIndex, out var outerIndex, out var innerIndex))
+ {
+ return false;
+ }
+
+ // Delegate to ItemVariationStore
+ return itemVariationStore.TryGetDeltaSet(outerIndex, innerIndex, out deltaSet);
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Colr/GradientBrushHelper.cs b/src/Avalonia.Base/Media/Fonts/Tables/Colr/GradientBrushHelper.cs
new file mode 100644
index 0000000000..df69db6f6c
--- /dev/null
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Colr/GradientBrushHelper.cs
@@ -0,0 +1,180 @@
+using System;
+using Avalonia.Media.Immutable;
+
+namespace Avalonia.Media.Fonts.Tables.Colr
+{
+ ///
+ /// Helper class for creating gradient brushes with proper normalization.
+ /// Based on fontations gradient normalization logic from traversal.rs.
+ ///
+ internal static class GradientBrushHelper
+ {
+ ///
+ /// Creates a linear gradient brush with normalization.
+ /// Converts from P0, P1, P2 representation to a simple two-point gradient.
+ ///
+ public static IBrush CreateLinearGradient(
+ Point p0,
+ Point p1,
+ Point p2,
+ Immutable.ImmutableGradientStop[] stops,
+ GradientSpreadMethod extend)
+ {
+ // If no stops or single stop, return solid color
+ if (stops.Length == 0)
+ return new ImmutableSolidColorBrush(Colors.Transparent);
+
+ if (stops.Length == 1)
+ return new ImmutableSolidColorBrush(stops[0].Color);
+
+ // If p0p1 or p0p2 are degenerate, use first color
+ var p0ToP1 = p1 - p0;
+ var p0ToP2 = p2 - p0;
+
+ if (IsDegenerate(p0ToP1) || IsDegenerate(p0ToP2) ||
+ Math.Abs(CrossProduct(p0ToP1, p0ToP2)) < 1e-6)
+ {
+ return new ImmutableSolidColorBrush(stops[0].Color);
+ }
+
+ // Compute P3 as orthogonal projection of p0->p1 onto perpendicular to p0->p2
+ var perpToP2 = new Vector(p0ToP2.Y, -p0ToP2.X);
+ var p3 = p0 + ProjectOnto(p0ToP1, perpToP2);
+
+ // Stops are already ImmutableGradientStop[], pass directly (arrays implement IReadOnlyList)
+ return new ImmutableLinearGradientBrush(
+ gradientStops: stops,
+ startPoint: new RelativePoint(p0, RelativeUnit.Absolute),
+ endPoint: new RelativePoint(p3, RelativeUnit.Absolute),
+ spreadMethod: extend
+ );
+ }
+
+ ///
+ /// Creates a radial gradient brush with normalization.
+ ///
+ public static IBrush CreateRadialGradient(
+ Point c0,
+ double r0,
+ Point c1,
+ double r1,
+ Immutable.ImmutableGradientStop[] stops,
+ GradientSpreadMethod extend)
+ {
+ if (stops.Length == 0)
+ return new ImmutableSolidColorBrush(Colors.Transparent);
+
+ if (stops.Length == 1)
+ return new ImmutableSolidColorBrush(stops[0].Color);
+
+ // Note: Negative radii can occur after normalization
+ // The client should handle truncation at the 0 position
+
+ // Stops are already ImmutableGradientStop[], pass directly (arrays implement IReadOnlyList)
+ return new ImmutableRadialGradientBrush(
+ stops,
+ center: new RelativePoint(c0, RelativeUnit.Absolute),
+ gradientOrigin: new RelativePoint(c1, RelativeUnit.Absolute),
+ radiusX: new RelativeScalar(r0, RelativeUnit.Absolute),
+ radiusY: new RelativeScalar(r1, RelativeUnit.Absolute),
+ spreadMethod: extend
+ );
+ }
+
+ ///
+ /// Creates a conic (sweep) gradient brush with angle normalization.
+ /// Angles are converted from counter-clockwise to clockwise for the shader.
+ ///
+ public static IBrush CreateConicGradient(
+ Point center,
+ double startAngle,
+ double endAngle,
+ Immutable.ImmutableGradientStop[] stops,
+ GradientSpreadMethod extend)
+ {
+ if (stops.Length == 0)
+ return new ImmutableSolidColorBrush(Colors.Transparent);
+
+ if (stops.Length == 1)
+ return new ImmutableSolidColorBrush(stops[0].Color);
+
+ // OpenType 1.9.1 adds a shift to ease 0-360 degree specification
+ var startAngleDeg = startAngle * 180.0 + 180.0;
+ var endAngleDeg = endAngle * 180.0 + 180.0;
+
+ // Convert from counter-clockwise to clockwise
+ startAngleDeg = 360.0 - startAngleDeg;
+ endAngleDeg = 360.0 - endAngleDeg;
+
+ var finalStops = stops;
+
+ // Swap if needed to ensure start < end
+ if (startAngleDeg > endAngleDeg)
+ {
+ (startAngleDeg, endAngleDeg) = (endAngleDeg, startAngleDeg);
+
+ // Reverse stops - only allocate if we need to reverse
+ finalStops = ReverseStops(stops);
+ }
+
+ // If start == end and not Pad mode, nothing should be drawn
+ if (Math.Abs(startAngleDeg - endAngleDeg) < 1e-6 && extend != GradientSpreadMethod.Pad)
+ {
+ return new ImmutableSolidColorBrush(Colors.Transparent);
+ }
+
+ return new ImmutableConicGradientBrush(
+ finalStops,
+ center: new RelativePoint(center, RelativeUnit.Absolute),
+ angle: startAngleDeg,
+ spreadMethod: extend
+ );
+ }
+
+ ///
+ /// Reverses gradient stops without LINQ to minimize allocations.
+ /// ImmutableGradientStop constructor is (Color, double offset).
+ ///
+ private static Immutable.ImmutableGradientStop[] ReverseStops(Immutable.ImmutableGradientStop[] stops)
+ {
+ var length = stops.Length;
+ var reversed = new Immutable.ImmutableGradientStop[length];
+
+ // Reverse in-place without LINQ
+ for (int i = 0; i < length; i++)
+ {
+ var originalStop = stops[length - 1 - i];
+ // ImmutableGradientStop constructor: (Color color, double offset)
+ reversed[i] = new Immutable.ImmutableGradientStop(1.0 - originalStop.Offset, originalStop.Color);
+ }
+
+ return reversed;
+ }
+
+ private static bool IsDegenerate(Vector v)
+ {
+ return Math.Abs(v.X) < 1e-6 && Math.Abs(v.Y) < 1e-6;
+ }
+
+ private static double CrossProduct(Vector a, Vector b)
+ {
+ return a.X * b.Y - a.Y * b.X;
+ }
+
+ private static double DotProduct(Vector a, Vector b)
+ {
+ return a.X * b.X + a.Y * b.Y;
+ }
+
+ private static Vector ProjectOnto(Vector vector, Vector onto)
+ {
+ var length = Math.Sqrt(onto.X * onto.X + onto.Y * onto.Y);
+ if (length < 1e-6)
+ return new Vector(0, 0);
+
+ var normalized = onto / length;
+ var scale = DotProduct(vector, onto) / length;
+ return normalized * scale;
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Colr/IColorPainter.cs b/src/Avalonia.Base/Media/Fonts/Tables/Colr/IColorPainter.cs
new file mode 100644
index 0000000000..d0cd5fc7b2
--- /dev/null
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Colr/IColorPainter.cs
@@ -0,0 +1,118 @@
+using Avalonia.Media.Immutable;
+
+namespace Avalonia.Media.Fonts.Tables.Colr
+{
+ internal interface IColorPainter
+ {
+ ///
+ /// Pushes the specified transformation matrix onto the current transformation stack.
+ ///
+ /// Use this method to temporarily modify the coordinate system for drawing operations.
+ /// To restore the previous transformation, call the corresponding pop method if available. Transformations are
+ /// applied in a last-in, first-out manner.
+ /// The transformation matrix to apply to subsequent drawing operations. The matrix defines how coordinates are
+ /// transformed, such as by translation, rotation, or scaling.
+ void PushTransform(Matrix transform);
+
+ ///
+ /// Removes the most recently applied transformation from the transformation stack.
+ ///
+ /// Call this method to revert to the previous transformation state. Typically used in
+ /// graphics or rendering contexts to restore the coordinate system after applying temporary transformations.
+ /// Calling this method when the transformation stack is empty may result in an error or undefined behavior,
+ /// depending on the implementation.
+ void PopTransform();
+
+ ///
+ /// Pushes a new drawing layer onto the stack using the specified composite mode.
+ ///
+ /// Use this method to isolate drawing operations on a separate layer, which can then be
+ /// composited with the underlying content according to the specified mode. Layers should typically be paired
+ /// with a corresponding pop operation to restore the previous drawing state.
+ /// The blending mode to use when compositing the new layer with existing content.
+ void PushLayer(CompositeMode mode);
+
+ ///
+ /// Removes the topmost layer from the current layer stack.
+ ///
+ /// Call this method to revert to the previous layer after pushing a new one. If there
+ /// are no layers to remove, the behavior may depend on the implementation; consult the specific documentation
+ /// for details.
+ void PopLayer();
+
+ ///
+ /// Establishes a new clipping region that restricts drawing to the specified rectangle.
+ ///
+ /// Subsequent drawing operations are limited to the area defined by the clipping region
+ /// until the region is removed. Clipping regions can typically be nested; ensure that each call to this method
+ /// is balanced with a corresponding call to remove the clipping region, if required by the
+ /// implementation.
+ /// The rectangle that defines the boundaries of the clipping region. Only drawing operations within this area
+ /// will be visible.
+ void PushClip(Rect clipBox);
+
+ ///
+ /// Removes the most recently added clip from the stack.
+ ///
+ /// Call this method to revert the last PushClip operation and restore the previous
+ /// clipping region. If there are no clips on the stack, calling this method has no effect.
+ void PopClip();
+
+ ///
+ /// Fills the current path with a solid color.
+ ///
+ ///
+ void FillSolid(Color color);
+
+ ///
+ /// Fills the current path with a linear gradient defined by the specified points and gradient stops.
+ ///
+ /// The gradient is interpolated between the specified points using the provided gradient
+ /// stops. The spread method determines how the gradient is rendered outside the range defined by the start and
+ /// end points.
+ /// The starting point of the linear gradient in the coordinate space.
+ /// The ending point of the linear gradient in the coordinate space.
+ /// An array of gradient stops that define the colors and their positions along the gradient. Cannot be null or
+ /// empty.
+ /// Specifies how the gradient is extended beyond the start and end points, using the defined spread method.
+ void FillLinearGradient(Point p0, Point p1, GradientStop[] stops, GradientSpreadMethod extend);
+
+ ///
+ /// Fills the current path with a radial gradient defined by two circles and a set of gradient stops.
+ ///
+ /// The gradient transitions from the color at the starting circle to the color at the
+ /// ending circle, interpolating colors as defined by the gradient stops. The spread method determines how the
+ /// gradient is rendered outside the circles' bounds.
+ /// The center point of the starting circle for the gradient.
+ /// The radius of the starting circle. Must be non-negative.
+ /// The center point of the ending circle for the gradient.
+ /// The radius of the ending circle. Must be non-negative.
+ /// An array of gradient stops that define the colors and their positions within the gradient. Cannot be null or
+ /// empty.
+ /// Specifies how the gradient is extended beyond its normal range.
+ void FillRadialGradient(Point c0, double r0, Point c1, double r1, GradientStop[] stops, GradientSpreadMethod extend);
+
+ ///
+ /// Fills the current path with a conic gradient defined by the given center point, angle range, color stops,
+ /// and spread method.
+ ///
+ /// The conic gradient is drawn by interpolating colors between the specified stops along
+ /// the angular range from to . The behavior outside
+ /// this range is determined by the parameter.
+ /// The center point of the conic gradient, specified in the coordinate space of the drawing surface.
+ /// The starting angle, in degrees, at which the gradient begins. Measured clockwise from the positive X-axis.
+ /// The ending angle, in degrees, at which the gradient ends. Measured clockwise from the positive X-axis. Must
+ /// be greater than or equal to .
+ /// An array of objects that define the colors and their positions within the
+ /// gradient. Must contain at least two elements.
+ /// A value that specifies how the gradient is extended beyond its normal range, as defined by the enumeration.
+ void FillConicGradient(Point center, double startAngle, double endAngle, GradientStop[] stops, GradientSpreadMethod extend);
+
+ ///
+ /// Pushes a glyph outline onto the painter's current state as the active path for subsequent fill operations.
+ ///
+ ///
+ void Glyph(ushort glyphId);
+ }
+}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Colr/ItemVariationStore.cs b/src/Avalonia.Base/Media/Fonts/Tables/Colr/ItemVariationStore.cs
new file mode 100644
index 0000000000..725d2073b3
--- /dev/null
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Colr/ItemVariationStore.cs
@@ -0,0 +1,170 @@
+using System;
+using System.Buffers.Binary;
+
+namespace Avalonia.Media.Fonts.Tables.Colr
+{
+ ///
+ /// Represents an ItemVariationStore for OpenType font variations.
+ /// Stores delta values that can be applied to font data based on variation axis coordinates.
+ ///
+ ///
+ /// The ItemVariationStore is used in multiple OpenType tables (COLR, GDEF, GPOS, etc.)
+ /// to provide variation data for variable fonts. It organizes deltas into a two-level
+ /// hierarchy: ItemVariationData arrays (outer level) containing DeltaSets (inner level).
+ ///
+ /// See OpenType spec: https://learn.microsoft.com/en-us/typography/opentype/spec/otvarcommonformats#item-variation-store
+ ///
+ internal sealed class ItemVariationStore
+ {
+ private readonly ReadOnlyMemory _data;
+ private readonly uint _baseOffset;
+ private readonly ushort _format;
+ private readonly uint _variationRegionListOffset;
+ private readonly ushort _itemVariationDataCount;
+
+ private ItemVariationStore(
+ ReadOnlyMemory data,
+ uint baseOffset,
+ ushort format,
+ uint variationRegionListOffset,
+ ushort itemVariationDataCount)
+ {
+ _data = data;
+ _baseOffset = baseOffset;
+ _format = format;
+ _variationRegionListOffset = variationRegionListOffset;
+ _itemVariationDataCount = itemVariationDataCount;
+ }
+
+ ///
+ /// Gets the format of this ItemVariationStore (currently only 1 is defined).
+ ///
+ public ushort Format => _format;
+
+ ///
+ /// Gets the number of ItemVariationData arrays in this store.
+ ///
+ public ushort ItemVariationDataCount => _itemVariationDataCount;
+
+ ///
+ /// Loads an ItemVariationStore from the specified data.
+ ///
+ /// The complete table data (e.g., COLR table data).
+ /// The offset within the data where the ItemVariationStore starts.
+ /// An ItemVariationStore instance, or null if the data is invalid.
+ public static ItemVariationStore? Load(ReadOnlyMemory data, uint offset)
+ {
+ if (offset == 0 || offset >= data.Length)
+ {
+ return null;
+ }
+
+ var span = data.Span.Slice((int)offset);
+
+ // ItemVariationStore format:
+ // uint16 format (must be 1)
+ // Offset32 variationRegionListOffset
+ // uint16 itemVariationDataCount
+ // Offset32 itemVariationDataOffsets[itemVariationDataCount]
+
+ if (span.Length < 8) // format (2) + variationRegionListOffset (4) + itemVariationDataCount (2)
+ {
+ return null;
+ }
+
+ var format = BinaryPrimitives.ReadUInt16BigEndian(span);
+ if (format != 1)
+ {
+ return null; // Only format 1 is defined
+ }
+
+ var variationRegionListOffset = BinaryPrimitives.ReadUInt32BigEndian(span.Slice(2));
+ var itemVariationDataCount = BinaryPrimitives.ReadUInt16BigEndian(span.Slice(6));
+
+ return new ItemVariationStore(
+ data,
+ offset,
+ format,
+ variationRegionListOffset,
+ itemVariationDataCount);
+ }
+
+ ///
+ /// Tries to get a complete delta set for the specified (outer, inner) index pair.
+ ///
+ /// The outer index (ItemVariationData index).
+ /// The inner index (DeltaSet index within ItemVariationData).
+ /// A DeltaSet ref struct providing format-aware access to deltas.
+ /// True if the delta set was found; otherwise false.
+ ///
+ /// This method returns a DeltaSet ref struct that provides allocation-free access to both
+ /// word deltas (16-bit) and byte deltas (8-bit), along with methods for uniform access.
+ ///
+ public bool TryGetDeltaSet(ushort outerIndex, ushort innerIndex, out DeltaSet deltaSet)
+ {
+ deltaSet = DeltaSet.Empty;
+
+ // Validate outer index
+ if (outerIndex >= _itemVariationDataCount)
+ {
+ return false;
+ }
+
+ var span = _data.Span;
+ var storeSpan = span.Slice((int)_baseOffset);
+
+ // Read the offset to the ItemVariationData for the outer index
+ var offsetsStart = 8;
+ var itemDataOffsetPos = offsetsStart + (outerIndex * 4);
+
+ if (itemDataOffsetPos + 4 > storeSpan.Length)
+ {
+ return false;
+ }
+
+ var itemVariationDataOffset = BinaryPrimitives.ReadUInt32BigEndian(storeSpan.Slice(itemDataOffsetPos));
+ var absoluteItemDataOffset = _baseOffset + itemVariationDataOffset;
+
+ if (absoluteItemDataOffset >= _data.Length)
+ {
+ return false;
+ }
+
+ var itemDataSpan = span.Slice((int)absoluteItemDataOffset);
+
+ if (itemDataSpan.Length < 6)
+ {
+ return false;
+ }
+
+ var itemCount = BinaryPrimitives.ReadUInt16BigEndian(itemDataSpan);
+ var wordDeltaCount = BinaryPrimitives.ReadUInt16BigEndian(itemDataSpan.Slice(2));
+ var regionIndexCount = BinaryPrimitives.ReadUInt16BigEndian(itemDataSpan.Slice(4));
+
+ if (innerIndex >= itemCount)
+ {
+ return false;
+ }
+
+ var longWordCount = wordDeltaCount;
+ var shortDeltaCount = regionIndexCount - wordDeltaCount;
+ var deltaSetSize = (longWordCount * 2) + shortDeltaCount;
+
+ var regionIndexesSize = regionIndexCount * 2;
+ var deltaSetsStart = 6 + regionIndexesSize;
+ var targetDeltaSetOffset = deltaSetsStart + (innerIndex * deltaSetSize);
+
+ if (targetDeltaSetOffset + deltaSetSize > itemDataSpan.Length)
+ {
+ return false;
+ }
+
+ var deltaSetSpan = itemDataSpan.Slice(targetDeltaSetOffset, deltaSetSize);
+
+ // Create DeltaSet with the raw data
+ deltaSet = new DeltaSet(deltaSetSpan, wordDeltaCount, regionIndexCount);
+
+ return true;
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Colr/Paint.cs b/src/Avalonia.Base/Media/Fonts/Tables/Colr/Paint.cs
new file mode 100644
index 0000000000..4e8dfecd34
--- /dev/null
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Colr/Paint.cs
@@ -0,0 +1,1068 @@
+using System;
+using System.Buffers.Binary;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using Avalonia.Media.Immutable;
+
+namespace Avalonia.Media.Fonts.Tables.Colr
+{
+ internal record GradientStop(double Offset, Color Color);
+ internal record GradientStopVar(double Offset, Color Color, uint VarIndexBase) : GradientStop(Offset, Color);
+
+ internal abstract record Paint { }
+
+ // format 1
+ internal record ColrLayers(IReadOnlyList Layers) : Paint
+ {
+ public static bool TryParse(ReadOnlySpan span, uint paintOffset, in ColrContext context, in PaintDecycler decycler, out Paint? paint)
+ {
+ paint = null;
+
+ if (span.Length < 6)
+ return false;
+
+ var numLayers = span[1];
+ var firstLayerIndex = (int)BinaryPrimitives.ReadUInt32BigEndian(span.Slice(2));
+
+ // LayerList structure:
+ // - uint32: numLayers (count of all paint offsets in the list)
+ // - Offset32[numLayers]: array of paint offsets
+ //
+ // firstLayerIndex is the absolute index (0-based) into this array
+ // where this glyph's layers start.
+
+ var layerListOffset = context.ColrTable.LayerV1ListOffset;
+
+ // Skip the 4-byte count field at the start of LayerList
+ var paintOffsetsStart = layerListOffset + 4;
+
+ // Calculate the byte offset for the first paint offset of this glyph's layers
+ // Each offset is 4 bytes (Offset32)
+ var firstPaintOffsetPos = paintOffsetsStart + (firstLayerIndex * 4);
+
+ // Ensure we have enough data for all the paint offsets we need to read
+ var requiredBytes = firstPaintOffsetPos + (numLayers * 4);
+ if (requiredBytes > context.ColrData.Length)
+ {
+ return false;
+ }
+
+ var paints = new List((int)numLayers);
+
+ for (int i = 0; i < numLayers; i++)
+ {
+ // Read the paint offset for this layer
+ var paintOffsetPos = firstPaintOffsetPos + (i * 4);
+ var layerPaintOffset = BinaryPrimitives.ReadUInt32BigEndian(
+ context.ColrData.Span.Slice((int)paintOffsetPos, 4));
+
+ // The paint offset is relative to the start of the LayerList table
+ var absolutePaintOffset = layerListOffset + layerPaintOffset;
+
+ if (absolutePaintOffset >= context.ColrData.Length)
+ {
+ continue;
+ }
+
+ if (PaintParser.TryParse(context.ColrData.Span, absolutePaintOffset, in context, in decycler, out var childPaint) && childPaint != null)
+ {
+ paints.Add(childPaint);
+ }
+ }
+
+ paint = new ColrLayers(paints);
+ return true;
+ }
+ }
+
+ // format 2, 3
+ internal record Solid(Color Color) : Paint
+ {
+ public static bool TryParse(ReadOnlySpan span, in ColrContext context, out Paint? paint)
+ {
+ return TryParseInternal(span, in context, isVariable: false, out paint);
+ }
+
+ internal static bool TryParseInternal(ReadOnlySpan span, in ColrContext context, bool isVariable, out Paint? paint)
+ {
+ paint = null;
+
+ var minSize = isVariable ? 9 : 5;
+
+ if (span.Length < minSize)
+ {
+ return false;
+ }
+
+ var paletteIndex = BinaryPrimitives.ReadUInt16BigEndian(span.Slice(1));
+ var alphaFixed = BinaryPrimitives.ReadInt16BigEndian(span.Slice(3));
+ var alpha = PaintParsingHelpers.F2Dot14ToDouble(alphaFixed);
+
+ if (!context.CpalTable.TryGetColor(context.PaletteIndex, paletteIndex, out var color))
+ {
+ color = Colors.Black;
+ }
+
+ color = Color.FromArgb((byte)(color.A * alpha), color.R, color.G, color.B);
+
+ if (isVariable)
+ {
+ var varIndexBase = BinaryPrimitives.ReadUInt32BigEndian(span.Slice(5));
+
+ paint = new SolidVar(color, varIndexBase);
+ }
+ else
+ {
+ paint = new Solid(color);
+ }
+
+ return true;
+ }
+ }
+
+ internal record SolidVar(Color Color, uint VarIndexBase) : Paint
+ {
+ public static bool TryParse(ReadOnlySpan span, in ColrContext context, out Paint? paint)
+ {
+ return Solid.TryParseInternal(span, in context, isVariable: true, out paint);
+ }
+ }
+
+ // format 4, 5
+ internal record LinearGradient(
+ Point P0, Point P1, Point P2,
+ GradientStop[] Stops,
+ GradientSpreadMethod Extend) : Paint
+ {
+ public static bool TryParse(ReadOnlySpan span, uint paintOffset, in ColrContext context, out Paint? paint)
+ {
+ return TryParseInternal(span, paintOffset, in context, isVariable: false, out paint);
+ }
+
+ internal static bool TryParseInternal(ReadOnlySpan span, uint paintOffset, in ColrContext context, bool isVariable, out Paint? paint)
+ {
+ paint = null;
+
+ var minSize = isVariable ? 20 : 16;
+
+ if (span.Length < minSize)
+ {
+ return false;
+ }
+
+ var colorLineOffset = PaintParsingHelpers.ReadOffset24(span.Slice(1));
+ var x0 = BinaryPrimitives.ReadInt16BigEndian(span.Slice(4));
+ var y0 = BinaryPrimitives.ReadInt16BigEndian(span.Slice(6));
+ var x1 = BinaryPrimitives.ReadInt16BigEndian(span.Slice(8));
+ var y1 = BinaryPrimitives.ReadInt16BigEndian(span.Slice(10));
+ var x2 = BinaryPrimitives.ReadInt16BigEndian(span.Slice(12));
+ var y2 = BinaryPrimitives.ReadInt16BigEndian(span.Slice(14));
+
+ if (!PaintParsingHelpers.TryParseColorLine(
+ context.ColrData.Span,
+ paintOffset + colorLineOffset,
+ in context,
+ isVarColorLine: isVariable,
+ out var immutableStops,
+ out var extend))
+ {
+ return false;
+ }
+
+ if (isVariable)
+ {
+ var varIndexBase = BinaryPrimitives.ReadUInt32BigEndian(span.Slice(16));
+ var stops = new GradientStopVar[immutableStops.Length];
+
+ for (int i = 0; i < immutableStops.Length; i++)
+ {
+ stops[i] = new GradientStopVar(immutableStops[i].Offset, immutableStops[i].Color, 0);
+ }
+
+ paint = new LinearGradientVar(new Point(x0, y0), new Point(x1, y1), new Point(x2, y2), stops, extend, varIndexBase);
+ }
+ else
+ {
+ var stops = new GradientStop[immutableStops.Length];
+
+ for (int i = 0; i < immutableStops.Length; i++)
+ {
+ stops[i] = new GradientStop(immutableStops[i].Offset, immutableStops[i].Color);
+ }
+
+ paint = new LinearGradient(new Point(x0, y0), new Point(x1, y1), new Point(x2, y2), stops, extend);
+ }
+
+ return true;
+ }
+ }
+
+ internal record LinearGradientVar(Point P0, Point P1, Point P2, GradientStopVar[] Stops, GradientSpreadMethod Extend, uint VarIndexBase) : Paint
+ {
+ public static bool TryParse(ReadOnlySpan span, uint paintOffset, in ColrContext context, out Paint? paint)
+ {
+ return LinearGradient.TryParseInternal(span, paintOffset, in context, isVariable: true, out paint);
+ }
+ }
+
+ // format 6, 7
+ internal record RadialGradient(Point C0, double R0, Point C1, double R1, GradientStop[] Stops, GradientSpreadMethod Extend) : Paint
+ {
+ public static bool TryParse(ReadOnlySpan span, uint paintOffset, in ColrContext context, out Paint? paint)
+ {
+ return TryParseInternal(span, paintOffset, in context, isVariable: false, out paint);
+ }
+
+ internal static bool TryParseInternal(ReadOnlySpan span, uint paintOffset, in ColrContext context, bool isVariable, out Paint? paint)
+ {
+ paint = null;
+
+ var minSize = isVariable ? 20 : 16;
+
+ if (span.Length < minSize)
+ {
+ return false;
+ }
+
+ var colorLineOffset = PaintParsingHelpers.ReadOffset24(span.Slice(1));
+ var x0 = BinaryPrimitives.ReadInt16BigEndian(span.Slice(4));
+ var y0 = BinaryPrimitives.ReadInt16BigEndian(span.Slice(6));
+ var r0 = BinaryPrimitives.ReadUInt16BigEndian(span.Slice(8));
+ var x1 = BinaryPrimitives.ReadInt16BigEndian(span.Slice(10));
+ var y1 = BinaryPrimitives.ReadInt16BigEndian(span.Slice(12));
+ var r1 = BinaryPrimitives.ReadUInt16BigEndian(span.Slice(14));
+
+ var colorLineAbsOffset = paintOffset + colorLineOffset;
+
+ if (!PaintParsingHelpers.TryParseColorLine(context.ColrData.Span, colorLineAbsOffset, in context, isVarColorLine: isVariable,
+ out var immutableStops,
+ out var extend))
+ {
+ return false;
+ }
+
+ if (isVariable)
+ {
+ var varIndexBase = BinaryPrimitives.ReadUInt32BigEndian(span.Slice(16));
+ var stops = new GradientStopVar[immutableStops.Length];
+
+ for (int i = 0; i < immutableStops.Length; i++)
+ {
+ stops[i] = new GradientStopVar(immutableStops[i].Offset, immutableStops[i].Color, 0);
+ }
+
+ paint = new RadialGradientVar(new Point(x0, y0), r0, new Point(x1, y1), r1, stops, extend, varIndexBase);
+ }
+ else
+ {
+ var stops = new GradientStop[immutableStops.Length];
+
+ for (int i = 0; i < immutableStops.Length; i++)
+ {
+ stops[i] = new GradientStop(immutableStops[i].Offset, immutableStops[i].Color);
+ }
+
+ paint = new RadialGradient(new Point(x0, y0), r0, new Point(x1, y1), r1, stops, extend);
+ }
+
+ return true;
+ }
+ }
+
+ internal record RadialGradientVar(Point C0, double R0, Point C1, double R1, GradientStopVar[] Stops, GradientSpreadMethod Extend, uint VarIndexBase) : Paint
+ {
+ public static bool TryParse(ReadOnlySpan span, uint paintOffset, in ColrContext context, out Paint? paint)
+ {
+ return RadialGradient.TryParseInternal(span, paintOffset, in context, isVariable: true, out paint);
+ }
+ }
+
+ // format 8, 9
+ internal record SweepGradient(Point Center, double StartAngle, double EndAngle, GradientStop[] Stops, GradientSpreadMethod Extend) : Paint
+ {
+ public static bool TryParse(ReadOnlySpan span, uint paintOffset, in ColrContext context, out Paint? paint)
+ {
+ return TryParseInternal(span, paintOffset, in context, isVariable: false, out paint);
+ }
+
+ internal static bool TryParseInternal(ReadOnlySpan span, uint paintOffset, in ColrContext context, bool isVariable, out Paint? paint)
+ {
+ paint = null;
+
+ var minSize = isVariable ? 16 : 12;
+
+ if (span.Length < minSize)
+ {
+ return false;
+ }
+
+ var colorLineOffset = PaintParsingHelpers.ReadOffset24(span.Slice(1));
+ var centerX = BinaryPrimitives.ReadInt16BigEndian(span.Slice(4));
+ var centerY = BinaryPrimitives.ReadInt16BigEndian(span.Slice(6));
+ var startAngleFixed = BinaryPrimitives.ReadInt16BigEndian(span.Slice(8));
+ var endAngleFixed = BinaryPrimitives.ReadInt16BigEndian(span.Slice(10));
+
+ // F2DOT14 angles: 180° per 1.0 of value, so multiply by π (not 2π)
+ var startAngle = PaintParsingHelpers.F2Dot14ToDouble(startAngleFixed) * Math.PI;
+ var endAngle = PaintParsingHelpers.F2Dot14ToDouble(endAngleFixed) * Math.PI;
+
+ var colorLineAbsOffset = paintOffset + colorLineOffset;
+
+ if (!PaintParsingHelpers.TryParseColorLine(
+ context.ColrData.Span,
+ colorLineAbsOffset,
+ in context,
+ isVarColorLine: isVariable,
+ out var immutableStops,
+ out var extend))
+ {
+ return false;
+ }
+
+ if (isVariable)
+ {
+ var varIndexBase = BinaryPrimitives.ReadUInt32BigEndian(span.Slice(12));
+ var stops = new GradientStopVar[immutableStops.Length];
+
+ for (int i = 0; i < immutableStops.Length; i++)
+ {
+ stops[i] = new GradientStopVar(immutableStops[i].Offset, immutableStops[i].Color, 0);
+ }
+
+ paint = new SweepGradientVar(new Point(centerX, centerY), startAngle, endAngle, stops, extend, varIndexBase);
+ }
+ else
+ {
+ var stops = new GradientStop[immutableStops.Length];
+
+ for (int i = 0; i < immutableStops.Length; i++)
+ {
+ stops[i] = new GradientStop(immutableStops[i].Offset, immutableStops[i].Color);
+ }
+
+ paint = new SweepGradient(new Point(centerX, centerY), startAngle, endAngle, stops, extend);
+ }
+
+ return true;
+ }
+ }
+
+ internal record SweepGradientVar(Point Center, double StartAngle, double EndAngle, GradientStopVar[] Stops, GradientSpreadMethod Extend, uint VarIndexBase) : Paint
+ {
+ public static bool TryParse(ReadOnlySpan span, uint paintOffset, in ColrContext context, out Paint? paint)
+ {
+ return SweepGradient.TryParseInternal(span, paintOffset, in context, isVariable: true, out paint);
+ }
+ }
+
+ // format 10
+ internal record Glyph(ushort GlyphId, Paint Paint) : Paint
+ {
+ public static bool TryParse(ReadOnlySpan span, uint paintOffset, in ColrContext context, in PaintDecycler decycler, out Paint? paint)
+ {
+ paint = null;
+
+ if (span.Length < 6)
+ {
+ return false;
+ }
+
+ var subPaintOffset = paintOffset + PaintParsingHelpers.ReadOffset24(span.Slice(1));
+ var glyphId = BinaryPrimitives.ReadUInt16BigEndian(span.Slice(4));
+
+ decycler.Enter(glyphId);
+
+ if (subPaintOffset >= context.ColrData.Length)
+ {
+ return false;
+ }
+
+ if (!PaintParser.TryParse(context.ColrData.Span, subPaintOffset, in context, in decycler, out var innerPaint))
+ {
+ return false;
+ }
+
+ decycler.Exit(glyphId);
+
+ paint = new Glyph(glyphId, innerPaint);
+
+ return true;
+ }
+ }
+
+ // format 11
+ internal record ColrGlyph(ushort GlyphId, Paint Inner) : Paint
+ {
+ public static bool TryParse(ReadOnlySpan span, in ColrContext context, in PaintDecycler decycler, out Paint? paint)
+ {
+ paint = null;
+
+ if (span.Length < 3)
+ {
+ return false;
+ }
+
+ var glyphId = BinaryPrimitives.ReadUInt16BigEndian(span.Slice(1));
+
+ decycler.Enter(glyphId);
+
+ if (!context.ColrTable.TryGetBaseGlyphV1Record((ushort)glyphId, out var v1Record))
+ {
+ return false;
+ }
+
+ var absolutePaintOffset = context.ColrTable.GetAbsolutePaintOffset(v1Record.PaintOffset);
+
+ if (absolutePaintOffset >= context.ColrData.Length)
+ {
+ return false;
+ }
+
+ if (!PaintParser.TryParse(context.ColrData.Span, absolutePaintOffset, in context, in decycler, out var innerPaint))
+ {
+ return false;
+ }
+
+ decycler.Exit(glyphId);
+
+ paint = new ColrGlyph(glyphId, innerPaint);
+
+ return true;
+ }
+ }
+
+ // format 12 and 13
+ internal record Transform(Paint Inner, Matrix Matrix) : Paint
+ {
+ public static bool TryParse(ReadOnlySpan span, byte format, uint paintOffset, in ColrContext context,
+ in PaintDecycler decycler, out Paint? paint)
+ {
+ var isVariable = format == 13;
+
+ return TryParseInternal(span, paintOffset, in context, in decycler, isVariable, out paint);
+ }
+
+ internal static bool TryParseInternal(ReadOnlySpan span, uint paintOffset, in ColrContext context,
+ in PaintDecycler decycler, bool isVariable, out Paint? paint)
+ {
+ paint = null;
+
+ var minSize = isVariable ? 12 : 8; // 8 for fixed, 12 for variable (+ 4 bytes for varIndexBase)
+
+ if (span.Length < minSize)
+ {
+ return false;
+ }
+
+ var subPaintOffset = paintOffset + PaintParsingHelpers.ReadOffset24(span.Slice(1));
+
+ if (subPaintOffset >= context.ColrData.Length)
+ {
+ return false;
+ }
+
+ if (!PaintParser.TryParse(context.ColrData.Span, subPaintOffset, in context, in decycler, out var sourcePaint))
+ {
+ return false;
+ }
+
+ var transform = PaintParsingHelpers.ParseAffine2x3(span);
+
+ if (isVariable)
+ {
+ var varIndexBase = BinaryPrimitives.ReadUInt32BigEndian(span.Slice(minSize - 4));
+
+ paint = new TransformVar(sourcePaint, transform, varIndexBase);
+ }
+ else
+ {
+ paint = new Transform(sourcePaint, transform);
+ }
+
+ return true;
+ }
+ }
+
+ internal record TransformVar(Paint Inner, Matrix Matrix, uint VarIndexBase) : Paint
+ {
+ public static bool TryParse(ReadOnlySpan span, uint paintOffset, in ColrContext context, in PaintDecycler decycler, out Paint? paint)
+ {
+ return Transform.TryParseInternal(span, paintOffset, in context, in decycler, isVariable: true, out paint);
+ }
+ }
+
+ // format 14 and 15
+ internal record Translate(Paint Inner, double Dx, double Dy) : Paint
+ {
+ public static bool TryParse(ReadOnlySpan span, uint paintOffset, in ColrContext context, in PaintDecycler decycler, out Paint? paint)
+ {
+ return TryParseInternal(span, paintOffset, in context, in decycler, isVariable: false, out paint);
+ }
+
+ internal static bool TryParseInternal(ReadOnlySpan span, uint paintOffset, in ColrContext context,
+ in PaintDecycler decycler, bool isVariable, out Paint? paint)
+ {
+ paint = null;
+
+ var minSize = isVariable ? 12 : 8;
+
+ if (span.Length < minSize)
+ {
+ return false;
+ }
+
+ var subPaintOffset = paintOffset + PaintParsingHelpers.ReadOffset24(span.Slice(1));
+
+ if (subPaintOffset >= context.ColrData.Length)
+ {
+ return false;
+ }
+
+ if (!PaintParser.TryParse(context.ColrData.Span, subPaintOffset, in context, in decycler, out var sourcePaint))
+ {
+ return false;
+ }
+
+ var dx = BinaryPrimitives.ReadInt16BigEndian(span.Slice(4));
+ var dy = BinaryPrimitives.ReadInt16BigEndian(span.Slice(6));
+
+ if (isVariable)
+ {
+ var varIndexBase = BinaryPrimitives.ReadUInt32BigEndian(span.Slice(8));
+
+ paint = new TranslateVar(sourcePaint, dx, dy, varIndexBase);
+ }
+ else
+ {
+ paint = new Translate(sourcePaint, dx, dy);
+ }
+
+ return true;
+ }
+ }
+
+ internal record TranslateVar(Paint Inner, double Dx, double Dy, uint VarIndexBase) : Paint
+ {
+ public static bool TryParse(ReadOnlySpan span, uint paintOffset, in ColrContext context, in PaintDecycler decycler, out Paint? paint)
+ {
+ return Translate.TryParseInternal(span, paintOffset, in context, in decycler, isVariable: true, out paint);
+ }
+ }
+
+ // format 16 and 17
+ internal record Scale(Paint Inner, double Sx, double Sy) : Paint
+ {
+ public static bool TryParse(ReadOnlySpan span, uint paintOffset, in ColrContext context, in PaintDecycler decycler, out Paint? paint)
+ {
+ return TryParseInternal(span, paintOffset, in context, in decycler, isVariable: false, out paint);
+ }
+
+ internal static bool TryParseInternal(ReadOnlySpan span, uint paintOffset, in ColrContext context,
+ in PaintDecycler decycler, bool isVariable, out Paint? paint)
+ {
+ paint = null;
+
+ var minSize = isVariable ? 12 : 8;
+
+ if (span.Length < minSize)
+ {
+ return false;
+ }
+
+ var subPaintOffset = paintOffset + PaintParsingHelpers.ReadOffset24(span.Slice(1));
+
+ if (subPaintOffset >= context.ColrData.Length)
+ {
+ return false;
+ }
+
+ if (!PaintParser.TryParse(context.ColrData.Span, subPaintOffset, in context, in decycler, out var sourcePaint))
+ {
+ return false;
+ }
+
+ var scaleX = PaintParsingHelpers.F2Dot14ToDouble(BinaryPrimitives.ReadInt16BigEndian(span.Slice(4)));
+ var scaleY = PaintParsingHelpers.F2Dot14ToDouble(BinaryPrimitives.ReadInt16BigEndian(span.Slice(6)));
+
+ if (isVariable)
+ {
+ var varIndexBase = BinaryPrimitives.ReadUInt32BigEndian(span.Slice(8));
+
+ paint = new ScaleVar(sourcePaint, scaleX, scaleY, varIndexBase);
+ }
+ else
+ {
+ paint = new Scale(sourcePaint, scaleX, scaleY);
+ }
+
+ return true;
+ }
+ }
+
+ internal record ScaleVar(Paint Inner, double Sx, double Sy, uint VarIndexBase) : Paint
+ {
+ public static bool TryParse(ReadOnlySpan span, uint paintOffset,
+ in ColrContext context, in PaintDecycler decycler, out Paint? paint)
+ {
+ return Scale.TryParseInternal(span, paintOffset, in context, in decycler, isVariable: true, out paint);
+ }
+ }
+
+ // format 18 and 19
+ internal record ScaleAroundCenter(Paint Inner, double Sx, double Sy, Point Center) : Paint
+ {
+ public static bool TryParse(ReadOnlySpan span, uint paintOffset, in ColrContext context,
+ in PaintDecycler decycler, out Paint? paint)
+ {
+ return TryParseInternal(span, paintOffset, in context, in decycler, isVariable: false, out paint);
+ }
+
+ internal static bool TryParseInternal(ReadOnlySpan span, uint paintOffset,
+ in ColrContext context, in PaintDecycler decycler, bool isVariable, out Paint? paint)
+ {
+ paint = null;
+
+ var minSize = isVariable ? 16 : 12;
+
+ if (span.Length < minSize)
+ {
+ return false;
+ }
+
+ var subPaintOffset = paintOffset + PaintParsingHelpers.ReadOffset24(span.Slice(1));
+
+ if (subPaintOffset >= context.ColrData.Length)
+ {
+ return false;
+ }
+
+ if (!PaintParser.TryParse(context.ColrData.Span, subPaintOffset, in context, in decycler, out var sourcePaint))
+ {
+ return false;
+ }
+
+ var scaleX = PaintParsingHelpers.F2Dot14ToDouble(BinaryPrimitives.ReadInt16BigEndian(span.Slice(4)));
+ var scaleY = PaintParsingHelpers.F2Dot14ToDouble(BinaryPrimitives.ReadInt16BigEndian(span.Slice(6)));
+ var centerX = BinaryPrimitives.ReadInt16BigEndian(span.Slice(8));
+ var centerY = BinaryPrimitives.ReadInt16BigEndian(span.Slice(10));
+
+ if (isVariable)
+ {
+ var varIndexBase = BinaryPrimitives.ReadUInt32BigEndian(span.Slice(12));
+
+ paint = new ScaleAroundCenterVar(sourcePaint, scaleX, scaleY, new Point(centerX, centerY), varIndexBase);
+ }
+ else
+ {
+ paint = new ScaleAroundCenter(sourcePaint, scaleX, scaleY, new Point(centerX, centerY));
+ }
+
+ return true;
+ }
+ }
+
+ internal record ScaleAroundCenterVar(Paint Inner, double Sx, double Sy, Point Center, uint VarIndexBase) : Paint
+ {
+ public static bool TryParse(ReadOnlySpan span, uint paintOffset,
+ in ColrContext context, in PaintDecycler decycler, out Paint? paint)
+ {
+ return ScaleAroundCenter.TryParseInternal(span, paintOffset, in context, in decycler, isVariable: true, out paint);
+ }
+ }
+
+ // format 20 and 21
+ internal record ScaleUniform(Paint Inner, double Scale) : Paint
+ {
+ public static bool TryParse(ReadOnlySpan span, uint paintOffset,
+ in ColrContext context, in PaintDecycler decycler, out Paint? paint)
+ {
+ return TryParseInternal(span, paintOffset, in context, in decycler, isVariable: false, out paint);
+ }
+
+ internal static bool TryParseInternal(ReadOnlySpan span, uint paintOffset,
+ in ColrContext context, in PaintDecycler decycler, bool isVariable, out Paint? paint)
+ {
+ paint = null;
+
+ var minSize = isVariable ? 10 : 6;
+
+ if (span.Length < minSize)
+ {
+ return false;
+ }
+
+ var subPaintOffset = paintOffset + PaintParsingHelpers.ReadOffset24(span.Slice(1));
+
+ if (subPaintOffset >= context.ColrData.Length)
+ {
+ return false;
+ }
+
+ if (!PaintParser.TryParse(context.ColrData.Span, subPaintOffset, in context, in decycler, out var sourcePaint))
+ {
+ return false;
+ }
+
+ var scale = PaintParsingHelpers.F2Dot14ToDouble(BinaryPrimitives.ReadInt16BigEndian(span.Slice(4)));
+
+ if (isVariable)
+ {
+ var varIndexBase = BinaryPrimitives.ReadUInt32BigEndian(span.Slice(6));
+
+ paint = new ScaleUniformVar(sourcePaint, scale, varIndexBase);
+ }
+ else
+ {
+ paint = new ScaleUniform(sourcePaint, scale);
+ }
+
+ return true;
+ }
+ }
+
+ internal record ScaleUniformVar(Paint Inner, double Scale, uint VarIndexBase) : Paint
+ {
+ public static bool TryParse(ReadOnlySpan span, uint paintOffset,
+ in ColrContext context, in PaintDecycler decycler, out Paint? paint)
+ {
+ return ScaleUniform.TryParseInternal(span, paintOffset, in context, in decycler, isVariable: true, out paint);
+ }
+ }
+
+ // format 22 and 23
+ internal record ScaleUniformAroundCenter(Paint Inner, double Scale, Point Center) : Paint
+ {
+ public static bool TryParse(ReadOnlySpan span, uint paintOffset,
+ in ColrContext context, in PaintDecycler decycler, out Paint? paint)
+ {
+ return TryParseInternal(span, paintOffset, in context, in decycler, isVariable: false, out paint);
+ }
+
+ internal static bool TryParseInternal(ReadOnlySpan span, uint paintOffset, in ColrContext context,
+ in PaintDecycler decycler, bool isVariable, out Paint? paint)
+ {
+ paint = null;
+
+ var minSize = isVariable ? 14 : 10;
+
+ if (span.Length < minSize)
+ {
+ return false;
+ }
+
+ var subPaintOffset = paintOffset + PaintParsingHelpers.ReadOffset24(span.Slice(1));
+
+ if (subPaintOffset >= context.ColrData.Length)
+ {
+ return false;
+ }
+
+ if (!PaintParser.TryParse(context.ColrData.Span, subPaintOffset, in context, in decycler, out var sourcePaint))
+ {
+ return false;
+ }
+
+ var scale = PaintParsingHelpers.F2Dot14ToDouble(BinaryPrimitives.ReadInt16BigEndian(span.Slice(4)));
+ var centerX = BinaryPrimitives.ReadInt16BigEndian(span.Slice(6));
+ var centerY = BinaryPrimitives.ReadInt16BigEndian(span.Slice(8));
+
+ if (isVariable)
+ {
+ var varIndexBase = BinaryPrimitives.ReadUInt32BigEndian(span.Slice(10));
+
+ paint = new ScaleUniformAroundCenterVar(sourcePaint, scale, new Point(centerX, centerY), varIndexBase);
+ }
+ else
+ {
+ paint = new ScaleUniformAroundCenter(sourcePaint, scale, new Point(centerX, centerY));
+ }
+
+ return true;
+ }
+ }
+
+ internal record ScaleUniformAroundCenterVar(Paint Inner, double Scale, Point Center, uint VarIndexBase) : Paint
+ {
+ public static bool TryParse(ReadOnlySpan span, uint paintOffset, in ColrContext context,
+ in PaintDecycler decycler, out Paint? paint)
+ {
+ return ScaleUniformAroundCenter.TryParseInternal(span, paintOffset, in context, in decycler, isVariable: true, out paint);
+ }
+ }
+
+ // format 24 and 25
+ internal record Rotate(Paint Inner, double Angle) : Paint
+ {
+ public static bool TryParse(ReadOnlySpan span, uint paintOffset, in ColrContext context,
+ in PaintDecycler decycler, out Paint? paint)
+ {
+ return TryParseInternal(span, paintOffset, in context, in decycler, isVariable: false, out paint);
+ }
+
+ internal static bool TryParseInternal(ReadOnlySpan span, uint paintOffset, in ColrContext context,
+ in PaintDecycler decycler, bool isVariable, out Paint? paint)
+ {
+ paint = null;
+
+ var minSize = isVariable ? 10 : 6;
+
+ if (span.Length < minSize)
+ {
+ return false;
+ }
+
+ var subPaintOffset = paintOffset + PaintParsingHelpers.ReadOffset24(span.Slice(1));
+
+ if (subPaintOffset >= context.ColrData.Length)
+ {
+ return false;
+ }
+
+ if (!PaintParser.TryParse(context.ColrData.Span, subPaintOffset, in context, in decycler, out var sourcePaint))
+ {
+ return false;
+ }
+
+ // F2DOT14 angles: 180° per 1.0 of value, so multiply by π (not 2π)
+ var angle = PaintParsingHelpers.F2Dot14ToDouble(BinaryPrimitives.ReadInt16BigEndian(span.Slice(4))) * Math.PI;
+
+ if (isVariable)
+ {
+ var varIndexBase = BinaryPrimitives.ReadUInt32BigEndian(span.Slice(6));
+
+ paint = new RotateVar(sourcePaint, angle, varIndexBase);
+ }
+ else
+ {
+ paint = new Rotate(sourcePaint, angle);
+ }
+
+ return true;
+ }
+ }
+
+ internal record RotateVar(Paint Inner, double Angle, uint VarIndexBase) : Paint
+ {
+ public static bool TryParse(ReadOnlySpan span, uint paintOffset, in ColrContext context, in PaintDecycler decycler, out Paint? paint)
+ {
+ return Rotate.TryParseInternal(span, paintOffset, in context, in decycler, isVariable: true, out paint);
+ }
+ }
+
+ // format 26 and 27
+ internal record RotateAroundCenter(Paint Inner, double Angle, Point Center) : Paint
+ {
+ public static bool TryParse(ReadOnlySpan span, uint paintOffset, in ColrContext context, in PaintDecycler decycler, out Paint? paint)
+ {
+ return TryParseInternal(span, paintOffset, in context, in decycler, isVariable: false, out paint);
+ }
+
+ internal static bool TryParseInternal(ReadOnlySpan span, uint paintOffset, in ColrContext context,
+ in PaintDecycler decycler, bool isVariable, out Paint? paint)
+ {
+ paint = null;
+
+ var minSize = isVariable ? 14 : 10;
+
+ if (span.Length < minSize)
+ {
+ return false;
+ }
+
+ var subPaintOffset = paintOffset + PaintParsingHelpers.ReadOffset24(span.Slice(1));
+
+ if (subPaintOffset >= context.ColrData.Length)
+ {
+ return false;
+ }
+
+ if (!PaintParser.TryParse(context.ColrData.Span, subPaintOffset, in context, in decycler, out var sourcePaint))
+ {
+ return false;
+ }
+
+ // F2DOT14 angles: 180° per 1.0 of value, so multiply by π (not 2π)
+ var angle = PaintParsingHelpers.F2Dot14ToDouble(BinaryPrimitives.ReadInt16BigEndian(span.Slice(4))) * Math.PI;
+ var centerX = BinaryPrimitives.ReadInt16BigEndian(span.Slice(6));
+ var centerY = BinaryPrimitives.ReadInt16BigEndian(span.Slice(8));
+
+ if (isVariable)
+ {
+ var varIndexBase = BinaryPrimitives.ReadUInt32BigEndian(span.Slice(10));
+
+ paint = new RotateAroundCenterVar(sourcePaint, angle, new Point(centerX, centerY), varIndexBase);
+ }
+ else
+ {
+ paint = new RotateAroundCenter(sourcePaint, angle, new Point(centerX, centerY));
+ }
+
+ return true;
+ }
+ }
+
+ internal record RotateAroundCenterVar(Paint Inner, double Angle, Point Center, uint VarIndexBase) : Paint
+ {
+ public static bool TryParse(ReadOnlySpan span, uint paintOffset, in ColrContext context, in PaintDecycler decycler, out Paint? paint)
+ {
+ return RotateAroundCenter.TryParseInternal(span, paintOffset, in context, in decycler, isVariable: true, out paint);
+ }
+ }
+
+ // format 28 and 29
+ internal record Skew(Paint Inner, double XAngle, double YAngle) : Paint
+ {
+ public static bool TryParse(ReadOnlySpan span, uint paintOffset, in ColrContext context, in PaintDecycler decycler, out Paint? paint)
+ {
+ return TryParseInternal(span, paintOffset, in context, in decycler, isVariable: false, out paint);
+ }
+
+ internal static bool TryParseInternal(ReadOnlySpan span, uint paintOffset, in ColrContext context,
+ in PaintDecycler decycler, bool isVariable, out Paint? paint)
+ {
+ paint = null;
+
+ var minSize = isVariable ? 12 : 8;
+
+ if (span.Length < minSize)
+ {
+ return false;
+ }
+
+ var subPaintOffset = paintOffset + PaintParsingHelpers.ReadOffset24(span.Slice(1));
+
+ if (subPaintOffset >= context.ColrData.Length)
+ {
+ return false;
+ }
+
+ if (!PaintParser.TryParse(context.ColrData.Span, subPaintOffset, in context, in decycler, out var sourcePaint))
+ {
+ return false;
+ }
+
+ // F2DOT14 angles: 180° per 1.0 of value, so multiply by π (not 2π)
+ var xAngle = PaintParsingHelpers.F2Dot14ToDouble(BinaryPrimitives.ReadInt16BigEndian(span.Slice(4))) * Math.PI;
+ var yAngle = PaintParsingHelpers.F2Dot14ToDouble(BinaryPrimitives.ReadInt16BigEndian(span.Slice(6))) * Math.PI;
+
+ if (isVariable)
+ {
+ var varIndexBase = BinaryPrimitives.ReadUInt32BigEndian(span.Slice(8));
+
+ paint = new SkewVar(sourcePaint, xAngle, yAngle, varIndexBase);
+ }
+ else
+ {
+ paint = new Skew(sourcePaint, xAngle, yAngle);
+ }
+
+ return true;
+ }
+ }
+
+ internal record SkewVar(Paint Inner, double XAngle, double YAngle, uint VarIndexBase) : Paint
+ {
+ public static bool TryParse(ReadOnlySpan span, uint paintOffset, in ColrContext context, in PaintDecycler decycler, out Paint? paint)
+ {
+ return Skew.TryParseInternal(span, paintOffset, in context, in decycler, isVariable: true, out paint);
+ }
+ }
+
+ // format 30 and 31
+ internal record SkewAroundCenter(Paint Inner, double XAngle, double YAngle, Point Center) : Paint
+ {
+ public static bool TryParse(ReadOnlySpan span, uint paintOffset, in ColrContext context, in PaintDecycler decycler, out Paint? paint)
+ {
+ return TryParseInternal(span, paintOffset, in context, in decycler, isVariable: false, out paint);
+ }
+
+ internal static bool TryParseInternal(ReadOnlySpan span, uint paintOffset, in ColrContext context,
+ in PaintDecycler decycler, bool isVariable, out Paint? paint)
+ {
+ paint = null;
+
+ var minSize = isVariable ? 16 : 12;
+
+ if (span.Length < minSize)
+ {
+ return false;
+ }
+
+ var subPaintOffset = paintOffset + PaintParsingHelpers.ReadOffset24(span.Slice(1));
+
+ if (subPaintOffset >= context.ColrData.Length)
+ {
+ return false;
+ }
+
+ if (!PaintParser.TryParse(context.ColrData.Span, subPaintOffset, in context, in decycler, out var sourcePaint))
+ {
+ return false;
+ }
+
+ // F2DOT14 angles: 180° per 1.0 of value, so multiply by π (not 2π)
+ var xAngle = PaintParsingHelpers.F2Dot14ToDouble(BinaryPrimitives.ReadInt16BigEndian(span.Slice(4))) * Math.PI;
+ var yAngle = PaintParsingHelpers.F2Dot14ToDouble(BinaryPrimitives.ReadInt16BigEndian(span.Slice(6))) * Math.PI;
+ var centerX = BinaryPrimitives.ReadInt16BigEndian(span.Slice(8));
+ var centerY = BinaryPrimitives.ReadInt16BigEndian(span.Slice(10));
+
+ if (isVariable)
+ {
+ var varIndexBase = BinaryPrimitives.ReadUInt32BigEndian(span.Slice(12));
+
+ paint = new SkewAroundCenterVar(sourcePaint, xAngle, yAngle, new Point(centerX, centerY), varIndexBase);
+ }
+ else
+ {
+ paint = new SkewAroundCenter(sourcePaint, xAngle, yAngle, new Point(centerX, centerY));
+ }
+
+ return true;
+ }
+ }
+
+ internal record SkewAroundCenterVar(Paint Inner, double XAngle, double YAngle, Point Center, uint VarIndexBase) : Paint
+ {
+ public static bool TryParse(ReadOnlySpan span, uint paintOffset, in ColrContext context, in PaintDecycler decycler, out Paint? paint)
+ {
+ return SkewAroundCenter.TryParseInternal(span, paintOffset, in context, in decycler, isVariable: true, out paint);
+ }
+ }
+
+ // format 32
+ internal record Composite(Paint Backdrop, Paint Source, CompositeMode Mode) : Paint
+ {
+ public static bool TryParse(ReadOnlySpan span, uint paintOffset, in ColrContext context, in PaintDecycler decycler, out Paint? paint)
+ {
+ paint = null;
+
+ if (span.Length < 8)
+ {
+ return false;
+ }
+
+ var sourcePaintOffset = paintOffset + PaintParsingHelpers.ReadOffset24(span.Slice(1));
+ var mode = (CompositeMode)span[4];
+ var backdropPaintOffset = paintOffset + PaintParsingHelpers.ReadOffset24(span.Slice(5));
+
+ if (sourcePaintOffset >= context.ColrData.Length || backdropPaintOffset >= context.ColrData.Length)
+ {
+ return false;
+ }
+
+ if (!PaintParser.TryParse(context.ColrData.Span, sourcePaintOffset, in context, in decycler, out var sourcePaint))
+ {
+ return false;
+ }
+
+ if (!PaintParser.TryParse(context.ColrData.Span, backdropPaintOffset, in context, in decycler, out var backdropPaint))
+ {
+ return false;
+ }
+
+ paint = new Composite(backdropPaint, sourcePaint, mode);
+
+ return true;
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Colr/PaintDecycler.cs b/src/Avalonia.Base/Media/Fonts/Tables/Colr/PaintDecycler.cs
new file mode 100644
index 0000000000..c662e4626e
--- /dev/null
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Colr/PaintDecycler.cs
@@ -0,0 +1,47 @@
+using Avalonia.Utilities;
+
+namespace Avalonia.Media.Fonts.Tables.Colr
+{
+ ///
+ /// Type alias for the paint decycler that tracks visited glyphs.
+ ///
+ internal class PaintDecycler : Decycler
+ {
+ ///
+ /// Maximum depth for paint graph traversal.
+ /// This limit matches HB_MAX_NESTING_LEVEL used in HarfBuzz.
+ ///
+ public const int MaxTraversalDepth = 64;
+
+ private static readonly ObjectPool Pool = new ObjectPool(
+ factory: () => new PaintDecycler(),
+ validator: decycler =>
+ {
+ decycler.Reset();
+ return true;
+ },
+ maxSize: 32);
+
+ public PaintDecycler() : base(MaxTraversalDepth)
+ {
+ }
+
+ ///
+ /// Rents a PaintDecycler from the pool.
+ ///
+ /// A pooled PaintDecycler instance.
+ public static PaintDecycler Rent()
+ {
+ return Pool.Rent();
+ }
+
+ ///
+ /// Returns a PaintDecycler to the pool.
+ ///
+ /// The decycler to return to the pool.
+ public static void Return(PaintDecycler decycler)
+ {
+ Pool.Return(decycler);
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Colr/PaintParser.cs b/src/Avalonia.Base/Media/Fonts/Tables/Colr/PaintParser.cs
new file mode 100644
index 0000000000..141c68725e
--- /dev/null
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Colr/PaintParser.cs
@@ -0,0 +1,66 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+
+namespace Avalonia.Media.Fonts.Tables.Colr
+{
+ internal static class PaintParser
+ {
+ ///
+ /// Tries to parse a Paint from the given data at the specified offset.
+ ///
+ public static bool TryParse(ReadOnlySpan data, uint offset, in ColrContext context, in PaintDecycler decycler, [NotNullWhen(true)] out Paint? paint)
+ {
+ paint = null;
+
+ if (offset >= data.Length || data.Length - offset < 1)
+ {
+ return false;
+ }
+
+ var span = data.Slice((int)offset);
+
+ var format = span[0];
+
+ if (format > 32)
+ {
+ return false;
+ }
+
+ return format switch
+ {
+ 1 => ColrLayers.TryParse(span, offset, in context, in decycler, out paint),
+ 2 => Solid.TryParse(span, in context, out paint),
+ 3 => SolidVar.TryParse(span, in context, out paint),
+ 4 => LinearGradient.TryParse(span, offset, in context, out paint),
+ 5 => LinearGradientVar.TryParse(span, offset, in context, out paint),
+ 6 => RadialGradient.TryParse(span, offset, in context, out paint),
+ 7 => RadialGradientVar.TryParse(span, offset, in context, out paint),
+ 8 => SweepGradient.TryParse(span, offset, in context, out paint),
+ 9 => SweepGradientVar.TryParse(span, offset, in context, out paint),
+ 10 => Glyph.TryParse(span, offset, in context, in decycler, out paint),
+ 11 => ColrGlyph.TryParse(span, in context, in decycler, out paint),
+ 12 or 13 => Transform.TryParse(span, format, offset, in context, in decycler, out paint),
+ 14 => Translate.TryParse(span, offset, in context, in decycler, out paint),
+ 15 => TranslateVar.TryParse(span, offset, in context, in decycler, out paint),
+ 16 => Scale.TryParse(span, offset, in context, in decycler, out paint),
+ 17 => ScaleVar.TryParse(span, offset, in context, in decycler, out paint),
+ 18 => ScaleAroundCenter.TryParse(span, offset, in context, in decycler, out paint),
+ 19 => ScaleAroundCenterVar.TryParse(span, offset, in context, in decycler, out paint),
+ 20 => ScaleUniform.TryParse(span, offset, in context, in decycler, out paint),
+ 21 => ScaleUniformVar.TryParse(span, offset, in context, in decycler, out paint),
+ 22 => ScaleUniformAroundCenter.TryParse(span, offset, in context, in decycler, out paint),
+ 23 => ScaleUniformAroundCenterVar.TryParse(span, offset, in context, in decycler, out paint),
+ 24 => Rotate.TryParse(span, offset, in context, in decycler, out paint),
+ 25 => RotateVar.TryParse(span, offset, in context, in decycler, out paint),
+ 26 => RotateAroundCenter.TryParse(span, offset, in context, in decycler, out paint),
+ 27 => RotateAroundCenterVar.TryParse(span, offset, in context, in decycler, out paint),
+ 28 => Skew.TryParse(span, offset, in context, in decycler, out paint),
+ 29 => SkewVar.TryParse(span, offset, in context, in decycler, out paint),
+ 30 => SkewAroundCenter.TryParse(span, offset, in context, in decycler, out paint),
+ 31 => SkewAroundCenterVar.TryParse(span, offset, in context, in decycler, out paint),
+ 32 => Composite.TryParse(span, offset, in context, in decycler, out paint),
+ _ => false
+ };
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Colr/PaintParsingHelpers.cs b/src/Avalonia.Base/Media/Fonts/Tables/Colr/PaintParsingHelpers.cs
new file mode 100644
index 0000000000..91277cf4ec
--- /dev/null
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Colr/PaintParsingHelpers.cs
@@ -0,0 +1,203 @@
+using System;
+using System.Buffers.Binary;
+using System.Collections.Generic;
+
+namespace Avalonia.Media.Fonts.Tables.Colr
+{
+ ///
+ /// Helper methods for parsing paint data.
+ ///
+ internal static class PaintParsingHelpers
+ {
+ public static uint ReadOffset24(ReadOnlySpan span)
+ {
+ // 24-bit offset (3 bytes, BIG-ENDIAN)
+ return ((uint)span[0] << 16) | ((uint)span[1] << 8) | span[2];
+ }
+
+ public static double F2Dot14ToDouble(short value)
+ {
+ // F2DOT14: signed fixed-point number with 2 integer bits and 14 fractional bits
+ return value / 16384.0;
+ }
+
+ public static double FixedToDouble(int value)
+ {
+ // Fixed 16.16 format
+ return value / 65536.0;
+ }
+
+ ///
+ /// Specifies the numeric format used to represent delta values in font tables.
+ ///
+ /// Use this enumeration to indicate how a delta value should be interpreted or converted
+ /// when processing font data. Each member corresponds to a specific numeric representation commonly found in
+ /// OpenType and TrueType font tables.
+ public enum DeltaTargetType
+ {
+ /// FWORD - design units (int16) - no conversion needed
+ FWORD,
+ /// F2DOT14 - fixed-point with 2.14 format (divide by 16384)
+ F2Dot14,
+ /// Fixed - fixed-point with 16.16 format (divide by 65536)
+ Fixed
+ }
+
+ public static double ConvertDelta(int deltaValue, DeltaTargetType targetType)
+ {
+ return targetType switch
+ {
+ DeltaTargetType.FWORD => deltaValue,
+ DeltaTargetType.F2Dot14 => F2Dot14ToDouble((short)deltaValue),
+ DeltaTargetType.Fixed => FixedToDouble(deltaValue),
+ _ => deltaValue
+ };
+ }
+
+ public static Matrix ParseAffine2x3(ReadOnlySpan span)
+ {
+ // Format 12 layout: [format][paintOffset][transformOffset]
+ if (span.Length < 7)
+ {
+ return Matrix.Identity;
+ }
+
+ var transformOffset = PaintParsingHelpers.ReadOffset24(span.Slice(4));
+
+ if (transformOffset > (uint)span.Length || span.Length - (int)transformOffset < 24)
+ {
+ return Matrix.Identity;
+ }
+
+ var transformSpan = span.Slice((int)transformOffset, 24);
+
+ var xx = FixedToDouble(BinaryPrimitives.ReadInt32BigEndian(transformSpan));
+ var yx = FixedToDouble(BinaryPrimitives.ReadInt32BigEndian(transformSpan.Slice(4)));
+ var xy = FixedToDouble(BinaryPrimitives.ReadInt32BigEndian(transformSpan.Slice(8)));
+ var yy = FixedToDouble(BinaryPrimitives.ReadInt32BigEndian(transformSpan.Slice(12)));
+ var dx = FixedToDouble(BinaryPrimitives.ReadInt32BigEndian(transformSpan.Slice(16)));
+ var dy = FixedToDouble(BinaryPrimitives.ReadInt32BigEndian(transformSpan.Slice(20)));
+
+ return new Matrix(xx, yx, xy, yy, dx, dy);
+ }
+
+ public static bool TryParseColorLine(
+ ReadOnlySpan data,
+ uint offset,
+ in ColrContext context,
+ bool isVarColorLine,
+ out Immutable.ImmutableGradientStop[] stops,
+ out GradientSpreadMethod extend)
+ {
+ stops = Array.Empty();
+ extend = GradientSpreadMethod.Pad;
+
+ if (offset >= data.Length)
+ {
+ return false;
+ }
+
+ var span = data.Slice((int)offset);
+
+ if (span.Length < 3) // extend (1) + numStops (2)
+ {
+ return false;
+ }
+
+ extend = span[0] switch
+ {
+ 1 => GradientSpreadMethod.Repeat,
+ 2 => GradientSpreadMethod.Reflect,
+ _ => GradientSpreadMethod.Pad
+ };
+
+ var numStops = BinaryPrimitives.ReadUInt16BigEndian(span.Slice(1));
+
+ // Validate numStops is reasonable
+ // Gradients with more than 256 stops are likely corrupt data
+ if (numStops == 0)
+ {
+ return false;
+ }
+
+ // ColorStop is 6 bytes, VarColorStop is 10 bytes (each has varIndexBase)
+ int stopSize = isVarColorLine ? 10 : 6;
+
+ // Ensure we have enough data for all stops
+ var requiredLength = 3 + (numStops * stopSize);
+ if (span.Length < requiredLength)
+ {
+ return false;
+ }
+
+ var tempStops = new Immutable.ImmutableGradientStop[numStops];
+
+ int stopOffset = 3;
+ for (int i = 0; i < numStops; i++)
+ {
+ // Both ColorStop and VarColorStop have the same first 6 bytes:
+ // F2DOT14 stopOffset (2), uint16 paletteIndex (2), F2DOT14 alpha (2)
+ // VarColorStop adds: uint32 varIndexBase (4) - which we ignore for now
+
+ var stopPos = F2Dot14ToDouble(BinaryPrimitives.ReadInt16BigEndian(span.Slice(stopOffset)));
+
+ // Clamp stopPos to valid [0, 1] range
+ // According to OpenType spec, stops should be in [0,1] but font data may have issues
+ stopPos = Math.Clamp(stopPos, 0.0, 1.0);
+
+ var paletteIndex = BinaryPrimitives.ReadUInt16BigEndian(span.Slice(stopOffset + 2));
+ var alpha = F2Dot14ToDouble(BinaryPrimitives.ReadInt16BigEndian(span.Slice(stopOffset + 4)));
+
+ // Clamp alpha to valid [0, 1] range
+ alpha = Math.Clamp(alpha, 0.0, 1.0);
+
+ if (!context.CpalTable.TryGetColor(context.PaletteIndex, paletteIndex, out var color))
+ {
+ color = Colors.Black;
+ }
+
+ color = Color.FromArgb((byte)(color.A * alpha), color.R, color.G, color.B);
+ tempStops[i] = new Immutable.ImmutableGradientStop(stopPos, color);
+
+ stopOffset += stopSize;
+ }
+
+ // Sort stops by offset (required for proper gradient rendering)
+ Array.Sort(tempStops, (a, b) => a.Offset.CompareTo(b.Offset));
+
+ // Remove consecutive duplicate stops (same offset AND color)
+ // NOTE: We preserve stops with the same offset but different colors (hard color transitions)
+ var deduplicatedList = new List(numStops);
+ const double epsilon = 1e-6;
+
+ for (int i = 0; i < tempStops.Length; i++)
+ {
+ var stop = tempStops[i];
+
+ // Always add the first stop
+ if (i == 0)
+ {
+ deduplicatedList.Add(stop);
+ continue;
+ }
+
+ var previous = tempStops[i - 1];
+ bool sameOffset = Math.Abs(stop.Offset - previous.Offset) < epsilon;
+ bool sameColor = stop.Color.Equals(previous.Color);
+
+ // Only skip if BOTH offset and color are the same (true duplicate)
+ // Keep stops with same offset but different color (hard color transition)
+ if (sameOffset && sameColor)
+ {
+ // This is a true duplicate - skip it
+ continue;
+ }
+
+ deduplicatedList.Add(stop);
+ }
+
+ stops = deduplicatedList.ToArray();
+ return true;
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Colr/PaintResolver.cs b/src/Avalonia.Base/Media/Fonts/Tables/Colr/PaintResolver.cs
new file mode 100644
index 0000000000..5a4dbaa4a2
--- /dev/null
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Colr/PaintResolver.cs
@@ -0,0 +1,653 @@
+using System;
+using System.Linq;
+using Avalonia.Media.Immutable;
+
+namespace Avalonia.Media.Fonts.Tables.Colr
+{
+ ///
+ /// Resolves Paint definitions into ResolvedPaint by applying variation deltas and normalization.
+ ///
+ internal static class PaintResolver
+ {
+ ///
+ /// Resolves a paint graph by evaluating variable and composite paint nodes into their fully realized, static forms
+ /// using the provided color context.
+ ///
+ /// This method recursively traverses the paint graph, resolving all variable, transform, and
+ /// composite nodes into their static equivalents. The returned paint can be used for rendering or further
+ /// processing without requiring additional context or variation data.
+ /// The root paint node to resolve. This may be a variable, composite, or transform paint type.
+ /// The color context used to evaluate variable paint nodes and apply variation deltas.
+ /// A new paint instance representing the fully resolved, static form of the input paint. The returned paint will
+ /// not contain any variable or unresolved composite nodes.
+ /// Thrown if the type of the provided paint node is not recognized or supported for resolution.
+ public static Paint ResolvePaint(Paint paint, in ColrContext context)
+ {
+ switch (paint)
+ {
+ // Format 1: ColrLayers
+ case ColrLayers colrLayers:
+ {
+ var resolvedLayers = new Paint[colrLayers.Layers.Count];
+
+ for (int i = 0; i < colrLayers.Layers.Count; i++)
+ {
+ resolvedLayers[i] = ResolvePaint(colrLayers.Layers[i], context);
+ }
+
+ return new ColrLayers(resolvedLayers);
+ }
+
+ // Format 2: Solid
+ case Solid solid:
+ return new ResolvedSolid(solid.Color);
+
+ // Format 3: VarSolid
+ case SolidVar varSolid:
+ return new ResolvedSolid(
+ context.ApplyAlphaDelta(varSolid.Color, varSolid.VarIndexBase, 0)
+ );
+
+ // Format 4: LinearGradient
+ case LinearGradient linearGrad:
+ return ResolveLinearGradient(linearGrad, context);
+
+ // Format 5: VarLinearGradient
+ case LinearGradientVar varLinearGrad:
+ return ResolveLinearGradient(
+ varLinearGrad,
+ context,
+ varLinearGrad.VarIndexBase
+ );
+
+ // Format 6: RadialGradient
+ case RadialGradient radialGrad:
+ return ResolveRadialGradient(radialGrad, context);
+
+ // Format 7: VarRadialGradient
+ case RadialGradientVar varRadialGrad:
+ return ResolveRadialGradient(
+ varRadialGrad,
+ context,
+ varRadialGrad.VarIndexBase
+ );
+
+ // Format 8: SweepGradient
+ case SweepGradient sweepGrad:
+ return ResolveSweepGradient(sweepGrad, context);
+
+ // Format 9: VarSweepGradient
+ case SweepGradientVar varSweepGrad:
+ return ResolveSweepGradient(
+ varSweepGrad,
+ context,
+ varSweepGrad.VarIndexBase
+ );
+
+ // Format 10: Glyph
+ case Glyph glyph:
+ return new Glyph(glyph.GlyphId, ResolvePaint(glyph.Paint, context));
+
+ // Format 11: ColrGlyph
+ case ColrGlyph colrGlyph:
+ return ResolvePaintColrGlyph(colrGlyph, context);
+
+ // Format 12: Transform
+ case Transform transform:
+ return new ResolvedTransform(
+ transform.Matrix,
+ ResolvePaint(transform.Inner, context)
+ );
+
+ // Format 13: VarTransform
+ case TransformVar varTransform:
+ return new ResolvedTransform(
+ context.ApplyAffineDeltas(varTransform.Matrix, varTransform.VarIndexBase),
+ ResolvePaint(varTransform.Inner, context)
+ );
+
+ // Format 14: Translate
+ case Translate translate:
+ return new ResolvedTransform(
+ Matrix.CreateTranslation(translate.Dx, translate.Dy),
+ ResolvePaint(translate.Inner, context)
+ );
+
+ // Format 15: VarTranslate
+ case TranslateVar varTranslate:
+ {
+ var dx = varTranslate.Dx;
+ var dy = varTranslate.Dy;
+
+ if (context.ColrTable.TryGetVariationDeltaSet(varTranslate.VarIndexBase, out var deltaSet))
+ {
+ // Translate deltas are FWORD (design units)
+ if (deltaSet.Count > 0)
+ dx += deltaSet.GetFWordDelta(0);
+ if (deltaSet.Count > 1)
+ dy += deltaSet.GetFWordDelta(1);
+ }
+
+ return new ResolvedTransform(
+ Matrix.CreateTranslation(dx, dy),
+ ResolvePaint(varTranslate.Inner, context)
+ );
+ }
+
+ // Format 16: Scale
+ case Scale scale:
+ return new ResolvedTransform(
+ Matrix.CreateScale(scale.Sx, scale.Sy),
+ ResolvePaint(scale.Inner, context)
+ );
+
+ // Format 17: VarScale
+ case ScaleVar varScale:
+ {
+ var sx = varScale.Sx;
+ var sy = varScale.Sy;
+
+ if (context.ColrTable.TryGetVariationDeltaSet(varScale.VarIndexBase, out var deltaSet))
+ {
+ // Scale deltas are F2DOT14
+ if (deltaSet.Count > 0)
+ sx += deltaSet.GetF2Dot14Delta(0);
+ if (deltaSet.Count > 1)
+ sy += deltaSet.GetF2Dot14Delta(1);
+ }
+
+ return new ResolvedTransform(
+ Matrix.CreateScale(sx, sy),
+ ResolvePaint(varScale.Inner, context)
+ );
+ }
+
+ // Format 18: ScaleAroundCenter
+ case ScaleAroundCenter scaleCenter:
+ return new ResolvedTransform(
+ CreateScaleAroundCenter(scaleCenter.Sx, scaleCenter.Sy, scaleCenter.Center),
+ ResolvePaint(scaleCenter.Inner, context)
+ );
+
+ // Format 19: VarScaleAroundCenter
+ case ScaleAroundCenterVar varScaleCenter:
+ {
+ var sx = varScaleCenter.Sx;
+ var sy = varScaleCenter.Sy;
+ var centerX = varScaleCenter.Center.X;
+ var centerY = varScaleCenter.Center.Y;
+
+ if (context.ColrTable.TryGetVariationDeltaSet(varScaleCenter.VarIndexBase, out var deltaSet))
+ {
+ // Scale deltas are F2DOT14
+ if (deltaSet.Count > 0)
+ sx += deltaSet.GetF2Dot14Delta(0);
+ if (deltaSet.Count > 1)
+ sy += deltaSet.GetF2Dot14Delta(1);
+ // Center coordinate deltas are FWORD
+ if (deltaSet.Count > 2)
+ centerX += deltaSet.GetFWordDelta(2);
+ if (deltaSet.Count > 3)
+ centerY += deltaSet.GetFWordDelta(3);
+ }
+
+ return new ResolvedTransform(
+ CreateScaleAroundCenter(sx, sy, new Point(centerX, centerY)),
+ ResolvePaint(varScaleCenter.Inner, context)
+ );
+ }
+
+ // Format 20: ScaleUniform
+ case ScaleUniform scaleUniform:
+ return new ResolvedTransform(
+ Matrix.CreateScale(scaleUniform.Scale, scaleUniform.Scale),
+ ResolvePaint(scaleUniform.Inner, context)
+ );
+
+ // Format 21: VarScaleUniform
+ case ScaleUniformVar varScaleUniform:
+ {
+ var scale = varScaleUniform.Scale;
+
+ if (context.ColrTable.TryGetVariationDeltaSet(varScaleUniform.VarIndexBase, out var deltaSet))
+ {
+ // Scale delta is F2DOT14
+ if (deltaSet.Count > 0)
+ scale += deltaSet.GetF2Dot14Delta(0);
+ }
+
+ return new ResolvedTransform(
+ Matrix.CreateScale(scale, scale),
+ ResolvePaint(varScaleUniform.Inner, context)
+ );
+ }
+
+ // Format 22: ScaleUniformAroundCenter
+ case ScaleUniformAroundCenter scaleUniformCenter:
+ return new ResolvedTransform(
+ CreateScaleAroundCenter(
+ scaleUniformCenter.Scale,
+ scaleUniformCenter.Scale,
+ scaleUniformCenter.Center
+ ),
+ ResolvePaint(scaleUniformCenter.Inner, context)
+ );
+
+ // Format 23: VarScaleUniformAroundCenter
+ case ScaleUniformAroundCenterVar varScaleUniformCenter:
+ {
+ var scale = varScaleUniformCenter.Scale;
+ var centerX = varScaleUniformCenter.Center.X;
+ var centerY = varScaleUniformCenter.Center.Y;
+
+ if (context.ColrTable.TryGetVariationDeltaSet(varScaleUniformCenter.VarIndexBase, out var deltaSet))
+ {
+ // Scale delta is F2DOT14
+ if (deltaSet.Count > 0)
+ scale += deltaSet.GetF2Dot14Delta(0);
+ // Center coordinate deltas are FWORD
+ if (deltaSet.Count > 1)
+ centerX += deltaSet.GetFWordDelta(1);
+ if (deltaSet.Count > 2)
+ centerY += deltaSet.GetFWordDelta(2);
+ }
+
+ return new ResolvedTransform(
+ CreateScaleAroundCenter(scale, scale, new Point(centerX, centerY)),
+ ResolvePaint(varScaleUniformCenter.Inner, context)
+ );
+ }
+
+ // Format 24: Rotate
+ case Rotate rotate:
+ return new ResolvedTransform(
+ CreateRotation(rotate.Angle),
+ ResolvePaint(rotate.Inner, context)
+ );
+
+ // Format 25: VarRotate
+ case RotateVar varRotate:
+ {
+ var angle = varRotate.Angle;
+
+ if (context.ColrTable.TryGetVariationDeltaSet(varRotate.VarIndexBase, out var deltaSet))
+ {
+ // Angle delta is F2DOT14: 180° per 1.0, so multiply by π to convert to radians
+ if (deltaSet.Count > 0)
+ angle += deltaSet.GetF2Dot14Delta(0) * Math.PI;
+ }
+
+ return new ResolvedTransform(
+ CreateRotation(angle),
+ ResolvePaint(varRotate.Inner, context)
+ );
+ }
+
+ // Format 26: RotateAroundCenter
+ case RotateAroundCenter rotateCenter:
+ return new ResolvedTransform(
+ CreateRotation(rotateCenter.Angle, rotateCenter.Center),
+ ResolvePaint(rotateCenter.Inner, context)
+ );
+
+ // Format 27: VarRotateAroundCenter
+ case RotateAroundCenterVar varRotateCenter:
+ {
+ var angle = varRotateCenter.Angle;
+ var centerX = varRotateCenter.Center.X;
+ var centerY = varRotateCenter.Center.Y;
+
+ if (context.ColrTable.TryGetVariationDeltaSet(varRotateCenter.VarIndexBase, out var deltaSet))
+ {
+ // Angle delta is F2DOT14: 180° per 1.0, so multiply by π to convert to radians
+ if (deltaSet.Count > 0)
+ angle += deltaSet.GetF2Dot14Delta(0) * Math.PI;
+ // Center coordinate deltas are FWORD
+ if (deltaSet.Count > 1)
+ centerX += deltaSet.GetFWordDelta(1);
+ if (deltaSet.Count > 2)
+ centerY += deltaSet.GetFWordDelta(2);
+ }
+
+ return new ResolvedTransform(
+ CreateRotation(angle, new Point(centerX, centerY)),
+ ResolvePaint(varRotateCenter.Inner, context)
+ );
+ }
+
+ // Format 28: Skew
+ case Skew skew:
+ return new ResolvedTransform(
+ CreateSkew(skew.XAngle, skew.YAngle, new Point()),
+ ResolvePaint(skew.Inner, context)
+ );
+
+ // Format 29: VarSkew
+ case SkewVar varSkew:
+ {
+ var xAngle = varSkew.XAngle;
+ var yAngle = varSkew.YAngle;
+
+ if (context.ColrTable.TryGetVariationDeltaSet(varSkew.VarIndexBase, out var deltaSet))
+ {
+ // Angle deltas are F2DOT14: 180° per 1.0, so multiply by π to convert to radians
+ if (deltaSet.Count > 0)
+ xAngle += deltaSet.GetF2Dot14Delta(0) * Math.PI;
+ if (deltaSet.Count > 1)
+ yAngle += deltaSet.GetF2Dot14Delta(1) * Math.PI;
+ }
+
+ return new ResolvedTransform(
+ CreateSkew(xAngle, yAngle, new Point()),
+ ResolvePaint(varSkew.Inner, context)
+ );
+ }
+
+ // Format 30: SkewAroundCenter
+ case SkewAroundCenter skewCenter:
+ return new ResolvedTransform(
+ CreateSkew(skewCenter.XAngle, skewCenter.YAngle, skewCenter.Center),
+ ResolvePaint(skewCenter.Inner, context)
+ );
+
+ // Format 31: VarSkewAroundCenter
+ case SkewAroundCenterVar varSkewCenter:
+ {
+ var xAngle = varSkewCenter.XAngle;
+ var yAngle = varSkewCenter.YAngle;
+ var centerX = varSkewCenter.Center.X;
+ var centerY = varSkewCenter.Center.Y;
+
+ if (context.ColrTable.TryGetVariationDeltaSet(varSkewCenter.VarIndexBase, out var deltaSet))
+ {
+ // Angle deltas are F2DOT14: 180° per 1.0, so multiply by π to convert to radians
+ if (deltaSet.Count > 0)
+ xAngle += deltaSet.GetF2Dot14Delta(0) * Math.PI;
+ if (deltaSet.Count > 1)
+ yAngle += deltaSet.GetF2Dot14Delta(1) * Math.PI;
+ // Center coordinate deltas are FWORD
+ if (deltaSet.Count > 2)
+ centerX += deltaSet.GetFWordDelta(2);
+ if (deltaSet.Count > 3)
+ centerY += deltaSet.GetFWordDelta(3);
+ }
+
+ return new ResolvedTransform(
+ CreateSkew(xAngle, yAngle, new Point(centerX, centerY)),
+ ResolvePaint(varSkewCenter.Inner, context)
+ );
+ }
+
+ // Format 32: Composite
+ case Composite composite:
+ return new Composite(
+ ResolvePaint(composite.Backdrop, context),
+ ResolvePaint(composite.Source, context),
+ composite.Mode
+ );
+
+ default:
+ throw new NotSupportedException($"Unknown paint type: {paint.GetType().Name}");
+ }
+ }
+
+ internal static Paint ResolvePaintColrGlyph(ColrGlyph colrGlyph, ColrContext context)
+ {
+ var glyphId = colrGlyph.GlyphId;
+ // Resolve inner paint
+ var resolvedInner = ResolvePaint(colrGlyph.Inner, context);
+
+ // Wrap in a clip box if present
+ if (context.ColrTable.TryGetClipBox(glyphId, out var clipBox))
+ {
+ return new ResolvedClipBox(clipBox, resolvedInner);
+ }
+
+ return resolvedInner;
+ }
+
+ private static ResolvedPaint ResolveLinearGradient(LinearGradient grad, ColrContext context)
+ {
+ var stops = context.NormalizeColorStops(grad.Stops);
+
+ return NormalizeLinearGradient(grad.P0, grad.P1, grad.P2, stops, grad.Extend);
+ }
+
+ private static ResolvedPaint ResolveLinearGradient(LinearGradientVar grad, ColrContext context, uint varIndexBase)
+ {
+ var p0 = grad.P0;
+ var p1 = grad.P1;
+ var p2 = grad.P2;
+
+ if (context.ColrTable.TryGetVariationDeltaSet(varIndexBase, out var deltaSet))
+ {
+ // Gradient coordinate deltas are FWORD (design units)
+ if (deltaSet.Count > 0)
+ p0 = new Point(p0.X + deltaSet.GetFWordDelta(0), p0.Y);
+ if (deltaSet.Count > 1)
+ p0 = new Point(p0.X, p0.Y + deltaSet.GetFWordDelta(1));
+ if (deltaSet.Count > 2)
+ p1 = new Point(p1.X + deltaSet.GetFWordDelta(2), p1.Y);
+ if (deltaSet.Count > 3)
+ p1 = new Point(p1.X, p1.Y + deltaSet.GetFWordDelta(3));
+ if (deltaSet.Count > 4)
+ p2 = new Point(p2.X + deltaSet.GetFWordDelta(4), p2.Y);
+ if (deltaSet.Count > 5)
+ p2 = new Point(p2.X, p2.Y + deltaSet.GetFWordDelta(5));
+ }
+
+ var stops = context.ResolveColorStops(grad.Stops, varIndexBase);
+ return NormalizeLinearGradient(p0, p1, p2, stops, grad.Extend);
+ }
+
+ private static ResolvedPaint ResolveRadialGradient(RadialGradient grad, ColrContext context)
+ {
+ var stops = context.NormalizeColorStops(grad.Stops);
+ return new ResolvedRadialGradient(grad.C0, grad.R0, grad.C1, grad.R1, stops, grad.Extend);
+ }
+
+ private static ResolvedPaint ResolveRadialGradient(RadialGradientVar grad, ColrContext context, uint varIndexBase)
+ {
+ var c0 = grad.C0;
+ var r0 = grad.R0;
+ var c1 = grad.C1;
+ var r1 = grad.R1;
+
+ if (context.ColrTable.TryGetVariationDeltaSet(varIndexBase, out var deltaSet))
+ {
+ // Center coordinate deltas and radii deltas are FWORD (design units)
+ if (deltaSet.Count > 0)
+ c0 = new Point(c0.X + deltaSet.GetFWordDelta(0), c0.Y);
+ if (deltaSet.Count > 1)
+ c0 = new Point(c0.X, c0.Y + deltaSet.GetFWordDelta(1));
+ if (deltaSet.Count > 2)
+ r0 += deltaSet.GetFWordDelta(2);
+ if (deltaSet.Count > 3)
+ c1 = new Point(c1.X + deltaSet.GetFWordDelta(3), c1.Y);
+ if (deltaSet.Count > 4)
+ c1 = new Point(c1.X, c1.Y + deltaSet.GetFWordDelta(4));
+ if (deltaSet.Count > 5)
+ r1 += deltaSet.GetFWordDelta(5);
+ }
+
+ var stops = context.ResolveColorStops(grad.Stops, varIndexBase);
+ return new ResolvedRadialGradient(c0, r0, c1, r1, stops, grad.Extend);
+ }
+
+ private static ResolvedPaint ResolveSweepGradient(SweepGradient grad, ColrContext context)
+ {
+ var stops = context.NormalizeColorStops(grad.Stops);
+ return NormalizeConicGradient(grad.Center, grad.StartAngle, grad.EndAngle, stops, grad.Extend);
+ }
+
+ private static ResolvedPaint ResolveSweepGradient(SweepGradientVar grad, ColrContext context, uint varIndexBase)
+ {
+ var center = grad.Center;
+ var startAngle = grad.StartAngle;
+ var endAngle = grad.EndAngle;
+
+ if (context.ColrTable.TryGetVariationDeltaSet(varIndexBase, out var deltaSet))
+ {
+
+ // Center coordinate deltas are FWORD (design units)
+ if (deltaSet.Count > 0)
+ center = new Point(center.X + deltaSet.GetFWordDelta(0), center.Y);
+ if (deltaSet.Count > 1)
+ center = new Point(center.X, center.Y + deltaSet.GetFWordDelta(1));
+ // Angle deltas are F2DOT14: 180° per 1.0, so multiply by π to convert to radians
+ if (deltaSet.Count > 2)
+ startAngle += deltaSet.GetF2Dot14Delta(2) * Math.PI;
+ if (deltaSet.Count > 3)
+ endAngle += deltaSet.GetF2Dot14Delta(3) * Math.PI;
+ }
+
+ var stops = context.ResolveColorStops(grad.Stops, varIndexBase);
+ return NormalizeConicGradient(center, startAngle, endAngle, stops, grad.Extend);
+ }
+
+ private static ResolvedPaint NormalizeLinearGradient(
+ Point p0, Point p1, Point p2,
+ GradientStop[] stops,
+ GradientSpreadMethod extend)
+ {
+ // If no stops or single stop, return solid color
+ if (stops.Length == 0)
+ return new ResolvedSolid(Colors.Transparent);
+
+ if (stops.Length == 1)
+ return new ResolvedSolid(stops[0].Color);
+
+ // If p0p1 or p0p2 are degenerate, use first color
+ var p0ToP1 = p1 - p0;
+ var p0ToP2 = p2 - p0;
+
+ if (IsDegenerate(p0ToP1) || IsDegenerate(p0ToP2) ||
+ Math.Abs(CrossProduct(p0ToP1, p0ToP2)) < 1e-6)
+ {
+ return new ResolvedSolid(stops[0].Color);
+ }
+
+ // Compute P3 as orthogonal projection of p0->p1 onto perpendicular to p0->p2
+ var perpToP2 = new Vector(p0ToP2.Y, -p0ToP2.X);
+ var p3 = p0 + ProjectOnto(p0ToP1, perpToP2);
+
+ return new ResolvedLinearGradient(p0, p3, stops, extend);
+ }
+
+ private static ResolvedPaint NormalizeConicGradient(
+ Point center,
+ double startAngle,
+ double endAngle,
+ GradientStop[] stops,
+ GradientSpreadMethod extend)
+ {
+ if (stops.Length == 0)
+ return new ResolvedSolid(Colors.Transparent);
+
+ if (stops.Length == 1)
+ return new ResolvedSolid(stops[0].Color);
+
+ // OpenType 1.9.1 adds a shift to ease 0-360 degree specification
+ var startAngleDeg = startAngle * 180.0 + 180.0;
+ var endAngleDeg = endAngle * 180.0 + 180.0;
+
+ // Convert from counter-clockwise to clockwise
+ startAngleDeg = 360.0 - startAngleDeg;
+ endAngleDeg = 360.0 - endAngleDeg;
+
+ var finalStops = stops;
+
+ // Swap if needed to ensure start < end
+ if (startAngleDeg > endAngleDeg)
+ {
+ (startAngleDeg, endAngleDeg) = (endAngleDeg, startAngleDeg);
+
+ // Reverse stops - only allocate if we need to reverse
+ finalStops = ReverseStops(stops);
+ }
+
+ // If start == end and not Pad mode, nothing should be drawn
+ if (Math.Abs(startAngleDeg - endAngleDeg) < 1e-6 && extend != GradientSpreadMethod.Pad)
+ {
+ return new ResolvedSolid(Colors.Transparent);
+ }
+
+ return new ResolvedConicGradient(center, startAngleDeg, endAngleDeg, finalStops, extend);
+ }
+
+ private static GradientStop[] ReverseStops(GradientStop[] stops)
+ {
+ var length = stops.Length;
+ var reversed = new GradientStop[length];
+
+ for (int i = 0; i < length; i++)
+ {
+ var originalStop = stops[length - 1 - i];
+ reversed[i] = new GradientStop(1.0 - originalStop.Offset, originalStop.Color);
+ }
+
+ return reversed;
+ }
+
+ private static Matrix CreateScaleAroundCenter(double sx, double sy, Point center)
+ {
+ return Matrix.CreateTranslation(-center.X, -center.Y) *
+ Matrix.CreateScale(sx, sy) *
+ Matrix.CreateTranslation(center.X, center.Y);
+ }
+
+ private static Matrix CreateRotation(double angleRadians)
+ {
+ return Matrix.CreateRotation(angleRadians);
+ }
+
+ private static Matrix CreateRotation(double angleRadians, Point center)
+ {
+ return Matrix.CreateTranslation(-center.X, -center.Y) *
+ Matrix.CreateRotation(angleRadians) *
+ Matrix.CreateTranslation(center.X, center.Y);
+ }
+
+ private static Matrix CreateSkew(double xAngleRadians, double yAngleRadians, Point center)
+ {
+ var skewMatrix = new Matrix(
+ 1.0, Math.Tan(yAngleRadians),
+ Math.Tan(xAngleRadians), 1.0,
+ 0.0, 0.0
+ );
+
+ if (center == default)
+ return skewMatrix;
+
+ return Matrix.CreateTranslation(-center.X, -center.Y) *
+ skewMatrix *
+ Matrix.CreateTranslation(center.X, center.Y);
+ }
+
+ private static bool IsDegenerate(Vector v)
+ {
+ return Math.Abs(v.X) < 1e-6 && Math.Abs(v.Y) < 1e-6;
+ }
+
+ private static double CrossProduct(Vector a, Vector b)
+ {
+ return a.X * b.Y - a.Y * b.X;
+ }
+
+ private static double DotProduct(Vector a, Vector b)
+ {
+ return a.X * b.X + a.Y * b.Y;
+ }
+
+ private static Vector ProjectOnto(Vector vector, Vector onto)
+ {
+ var length = Math.Sqrt(onto.X * onto.X + onto.Y * onto.Y);
+ if (length < 1e-6)
+ return new Vector(0, 0);
+
+ var normalized = onto / length;
+ var scale = DotProduct(vector, onto) / length;
+ return normalized * scale;
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Colr/PaintTraverser.cs b/src/Avalonia.Base/Media/Fonts/Tables/Colr/PaintTraverser.cs
new file mode 100644
index 0000000000..23bec74950
--- /dev/null
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Colr/PaintTraverser.cs
@@ -0,0 +1,74 @@
+namespace Avalonia.Media.Fonts.Tables.Colr
+{
+ ///
+ /// Traverses a resolved paint tree and calls the appropriate methods on the painter.
+ ///
+ internal static class PaintTraverser
+ {
+ public static void Traverse(Paint paint, IColorPainter painter, Matrix currentMatrix)
+ {
+ switch (paint)
+ {
+ case ResolvedSolid fill:
+ painter.FillSolid(fill.Color);
+ break;
+
+ case ResolvedClipBox clip:
+ painter.PushClip(clip.Box);
+ Traverse(clip.Inner, painter, currentMatrix);
+ painter.PopClip();
+ break;
+
+ case ResolvedLinearGradient linearGrad:
+ painter.FillLinearGradient(
+ linearGrad.P0,
+ linearGrad.P1,
+ linearGrad.Stops,
+ linearGrad.Extend);
+ break;
+
+ case ResolvedRadialGradient radialGrad:
+ painter.FillRadialGradient(
+ radialGrad.C0,
+ radialGrad.R0,
+ radialGrad.C1,
+ radialGrad.R1,
+ radialGrad.Stops,
+ radialGrad.Extend);
+ break;
+
+ case ResolvedConicGradient conicGrad:
+ painter.FillConicGradient(
+ conicGrad.Center,
+ conicGrad.StartAngle,
+ conicGrad.EndAngle,
+ conicGrad.Stops,
+ conicGrad.Extend);
+ break;
+
+ case ResolvedTransform t:
+ painter.PushTransform(t.Matrix);
+ Traverse(t.Inner, painter, t.Matrix * currentMatrix);
+ painter.PopTransform();
+ break;
+
+ case ColrLayers layers:
+ foreach (var child in layers.Layers)
+ Traverse(child, painter, currentMatrix);
+ break;
+
+ case Glyph glyph:
+ painter.Glyph(glyph.GlyphId);
+ Traverse(glyph.Paint, painter, currentMatrix);
+ break;
+
+ case Composite comp:
+ painter.PushLayer(comp.Mode);
+ Traverse(comp.Backdrop, painter, currentMatrix);
+ Traverse(comp.Source, painter, currentMatrix);
+ painter.PopLayer();
+ break;
+ }
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Colr/ResolvedPaint.cs b/src/Avalonia.Base/Media/Fonts/Tables/Colr/ResolvedPaint.cs
new file mode 100644
index 0000000000..f98efbbb19
--- /dev/null
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Colr/ResolvedPaint.cs
@@ -0,0 +1,36 @@
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+using System.Text;
+
+namespace Avalonia.Media.Fonts.Tables.Colr
+{
+ internal record class ResolvedPaint : Paint;
+
+ internal record class ResolvedSolid(Color Color) : ResolvedPaint;
+
+ internal record class ResolvedLinearGradient(
+ Point P0,
+ Point P1,
+ GradientStop[] Stops,
+ GradientSpreadMethod Extend) : ResolvedPaint;
+
+ internal record class ResolvedRadialGradient(
+ Point C0,
+ double R0,
+ Point C1,
+ double R1,
+ GradientStop[] Stops,
+ GradientSpreadMethod Extend) : ResolvedPaint;
+
+ internal record class ResolvedConicGradient(
+ Point Center,
+ double StartAngle,
+ double EndAngle,
+ GradientStop[] Stops,
+ GradientSpreadMethod Extend) : ResolvedPaint;
+
+ internal record class ResolvedTransform(Matrix Matrix, Paint Inner) : ResolvedPaint;
+
+ internal record class ResolvedClipBox(Rect Box, Paint Inner) : ResolvedPaint;
+}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Glyf/CompositeFlags.cs b/src/Avalonia.Base/Media/Fonts/Tables/Glyf/CompositeFlags.cs
new file mode 100644
index 0000000000..a155790f30
--- /dev/null
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Glyf/CompositeFlags.cs
@@ -0,0 +1,22 @@
+using System;
+
+namespace Avalonia.Media.Fonts.Tables.Glyf
+{
+ [Flags]
+ internal enum CompositeFlags : ushort
+ {
+ ArgsAreWords = 0x0001,
+ ArgsAreXYValues = 0x0002,
+ RoundXYToGrid = 0x0004,
+ WeHaveAScale = 0x0008,
+ MoreComponents = 0x0020,
+ WeHaveAnXAndYScale = 0x0040,
+ WeHaveATwoByTwo = 0x0080,
+ WeHaveInstructions = 0x0100,
+ UseMyMetrics = 0x0200,
+ OverlapCompound = 0x0400,
+ Reserved = 0x1000, // must be ignored
+ ScaledComponentOffset = 0x2000,
+ UnscaledComponentOffset = 0x4000
+ }
+}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Glyf/CompositeGlyph.cs b/src/Avalonia.Base/Media/Fonts/Tables/Glyf/CompositeGlyph.cs
new file mode 100644
index 0000000000..c55f671339
--- /dev/null
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Glyf/CompositeGlyph.cs
@@ -0,0 +1,190 @@
+using System;
+using System.Buffers;
+using System.Buffers.Binary;
+using System.Runtime.InteropServices;
+
+namespace Avalonia.Media.Fonts.Tables.Glyf
+{
+ ///
+ /// Represents a composite glyph that references one or more component glyphs.
+ ///
+ /// This struct holds references to rented arrays that must be returned via Dispose.
+ /// The struct should be disposed after consuming the component data.
+ internal readonly ref struct CompositeGlyph
+ {
+ // Most composite glyphs have fewer than 8 components
+ private const int EstimatedComponentCount = 8;
+
+ // Rented buffer for glyph components
+ private readonly GlyphComponent[]? _rentedBuffer;
+
+ ///
+ /// Gets the span of glyph components that make up this composite glyph.
+ ///
+ public ReadOnlySpan Components { get; }
+
+ ///
+ /// Gets the instruction data (currently unused).
+ ///
+ public ReadOnlySpan Instructions { get; }
+
+ ///
+ /// Initializes a new instance of the CompositeGlyph class using the specified glyph components and an optional
+ /// rented buffer.
+ ///
+ /// The rented buffer, if supplied, is managed internally and should not be accessed or
+ /// modified by the caller after construction. The CompositeGlyph instance does not take ownership of the
+ /// buffer's lifetime.
+ /// A read-only span containing the glyph components that make up the composite glyph. The span must remain
+ /// valid for the lifetime of the CompositeGlyph instance.
+ /// An optional array used as a rented buffer for internal storage. If provided, the buffer may be used to
+ /// optimize memory usage.
+ private CompositeGlyph(ReadOnlySpan components, GlyphComponent[]? rentedBuffer)
+ {
+ Components = components;
+ Instructions = default;
+ _rentedBuffer = rentedBuffer;
+ }
+
+ ///
+ /// Creates a CompositeGlyph from the raw glyph data.
+ ///
+ /// The raw glyph data from the glyf table.
+ /// A CompositeGlyph instance with components backed by a rented buffer.
+ /// The caller must call Dispose() to return the rented buffer to the pool.
+ public static CompositeGlyph Create(ReadOnlySpan data)
+ {
+ // Rent a buffer for components (most composite glyphs have < 8 components)
+ var componentsBuffer = ArrayPool.Shared.Rent(EstimatedComponentCount);
+ int componentCount = 0;
+
+ try
+ {
+ int offset = 0;
+ bool moreComponents;
+
+ do
+ {
+ // Read flags and glyph index
+ CompositeFlags flags = (CompositeFlags)BinaryPrimitives.ReadUInt16BigEndian(data.Slice(offset, 2));
+ offset += 2;
+
+ ushort glyphIndex = BinaryPrimitives.ReadUInt16BigEndian(data.Slice(offset, 2));
+ offset += 2;
+
+ short arg1 = 0, arg2 = 0;
+
+ // Read arguments
+ if ((flags & CompositeFlags.ArgsAreWords) != 0)
+ {
+ arg1 = BinaryPrimitives.ReadInt16BigEndian(data.Slice(offset, 2));
+ offset += 2;
+ arg2 = BinaryPrimitives.ReadInt16BigEndian(data.Slice(offset, 2));
+ offset += 2;
+ }
+ else
+ {
+ // Arguments are indices
+ arg1 = (sbyte)data[offset++];
+ arg2 = (sbyte)data[offset++];
+ }
+
+ // Optional transformation
+ float scale = 1.0f;
+ float scaleX = 1.0f, scaleY = 1.0f;
+ float scale01 = 0.0f, scale10 = 0.0f;
+
+ // Uniform scale
+ if ((flags & CompositeFlags.WeHaveAScale) != 0)
+ {
+ scale = BinaryPrimitives.ReadInt16BigEndian(data.Slice(offset, 2)) / 16384f;
+ offset += 2;
+ }
+ // Separate x and y scales
+ else if ((flags & CompositeFlags.WeHaveAnXAndYScale) != 0)
+ {
+ scaleX = BinaryPrimitives.ReadInt16BigEndian(data.Slice(offset, 2)) / 16384f;
+ offset += 2;
+ scaleY = BinaryPrimitives.ReadInt16BigEndian(data.Slice(offset, 2)) / 16384f;
+ offset += 2;
+ }
+ // Two by two transformation matrix
+ else if ((flags & CompositeFlags.WeHaveATwoByTwo) != 0)
+ {
+ scaleX = BinaryPrimitives.ReadInt16BigEndian(data.Slice(offset, 2)) / 16384f;
+ offset += 2;
+ scale01 = BinaryPrimitives.ReadInt16BigEndian(data.Slice(offset, 2)) / 16384f;
+ offset += 2;
+ scale10 = BinaryPrimitives.ReadInt16BigEndian(data.Slice(offset, 2)) / 16384f;
+ offset += 2;
+ scaleY = BinaryPrimitives.ReadInt16BigEndian(data.Slice(offset, 2)) / 16384f;
+ offset += 2;
+ }
+
+ // Grow buffer if needed
+ if (componentCount >= componentsBuffer.Length)
+ {
+ var oldBuffer = componentsBuffer;
+ var newSize = componentsBuffer.Length * 2;
+
+ componentsBuffer = ArrayPool.Shared.Rent(newSize);
+
+ oldBuffer.AsSpan(0, componentCount).CopyTo(componentsBuffer);
+
+ ArrayPool.Shared.Return(oldBuffer);
+ }
+
+ componentsBuffer[componentCount++] = new GlyphComponent
+ {
+ Flags = flags,
+ GlyphIndex = glyphIndex,
+ Arg1 = arg1,
+ Arg2 = arg2,
+ Scale = scale,
+ ScaleX = scaleX,
+ ScaleY = scaleY,
+ Scale01 = scale01,
+ Scale10 = scale10
+ };
+
+ moreComponents = (flags & CompositeFlags.MoreComponents) != 0;
+ } while (moreComponents);
+
+ // Instructions if present (currently unused)
+ //ReadOnlySpan instructions = ReadOnlySpan.Empty;
+ //if (componentCount > 0 && (componentsBuffer[componentCount - 1].Flags & CompositeFlags.WeHaveInstructions) != 0)
+ //{
+ // ushort instrLen = BinaryPrimitives.ReadUInt16BigEndian(data.Slice(offset, 2));
+ // offset += 2;
+ // instructions = data.Slice(offset, instrLen);
+ //}
+
+ // Return a CompositeGlyph with the rented buffer
+ // The caller is responsible for calling Dispose() to return the buffer
+ return new CompositeGlyph(
+ componentsBuffer.AsSpan(0, componentCount),
+ componentsBuffer
+ );
+ }
+ catch
+ {
+ // On exception, return the buffer immediately
+ ArrayPool.Shared.Return(componentsBuffer);
+ throw;
+ }
+ }
+
+ ///
+ /// Returns the rented buffer to the ArrayPool.
+ ///
+ /// This method should be called when the CompositeGlyph is no longer needed
+ /// to ensure the rented buffer is returned to the pool.
+ public void Dispose()
+ {
+ if (_rentedBuffer != null)
+ {
+ ArrayPool.Shared.Return(_rentedBuffer);
+ }
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Glyf/GlyfTable.cs b/src/Avalonia.Base/Media/Fonts/Tables/Glyf/GlyfTable.cs
new file mode 100644
index 0000000000..a962c67116
--- /dev/null
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Glyf/GlyfTable.cs
@@ -0,0 +1,556 @@
+using System;
+using System.Buffers.Binary;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Runtime.InteropServices;
+using System.Diagnostics;
+using Avalonia.Platform;
+using Avalonia.Logging;
+using Avalonia.Utilities;
+
+using Avalonia.Media;
+using Avalonia.Media.Fonts;
+using Avalonia.Media.Fonts.Tables;
+using Avalonia.Media.Fonts.Tables.Colr;
+
+namespace Avalonia.Media.Fonts.Tables.Glyf
+{
+ ///
+ /// Reader for the 'glyf' table. Provides on-demand access to individual glyph data using the 'loca' index.
+ /// Designed for high-performance lookups on the hot path.
+ ///
+ internal sealed class GlyfTable
+ {
+ internal const string TableName = "glyf";
+
+ internal static OpenTypeTag Tag { get; } = OpenTypeTag.Parse(TableName);
+
+ private readonly ReadOnlyMemory _glyfData;
+ private readonly LocaTable _locaTable;
+
+ private GlyfTable(ReadOnlyMemory glyfData, LocaTable locaTable)
+ {
+ _glyfData = glyfData;
+ _locaTable = locaTable;
+ }
+
+ ///
+ /// Gets the total number of glyphs defined in the font.
+ ///
+ public int GlyphCount => _locaTable.GlyphCount;
+
+ ///
+ /// Attempts to load the 'glyf' table from the specified font data.
+ ///
+ /// This method does not throw an exception if the 'glyf' table cannot be loaded.
+ /// Instead, it returns and sets to .
+ /// The glyph typeface from which to retrieve the 'glyf' table.
+ /// The 'head' table containing font header information required for loading the 'glyf' table.
+ /// The 'maxp' table providing maximum profile information needed to interpret the 'glyf' table.
+ /// When this method returns, contains the loaded 'glyf' table if successful; otherwise, .
+ /// This parameter is passed uninitialized.
+ /// if the 'glyf' table was successfully loaded; otherwise, .
+ public static bool TryLoad(GlyphTypeface glyphTypeface, HeadTable head, MaxpTable maxp, out GlyfTable? glyfTable)
+ {
+ glyfTable = null;
+
+ if (!glyphTypeface.PlatformTypeface.TryGetTable(Tag, out var glyfTableData))
+ {
+ return false;
+ }
+
+ var locaTable = LocaTable.Load(glyphTypeface, head, maxp);
+
+ if (locaTable == null)
+ {
+ return false;
+ }
+
+ glyfTable = new GlyfTable(glyfTableData, locaTable);
+
+ return true;
+ }
+
+ ///
+ /// Attempts to retrieve the raw glyph data for the specified glyph index.
+ ///
+ /// If the glyph exists but has no data (for example, a missing or empty glyph), the
+ /// method returns true and sets the out parameter to an empty memory region. If the glyph index is invalid or
+ /// out of range, the method returns false and the out parameter is set to an empty memory region.
+ /// The zero-based index of the glyph to retrieve data for.
+ /// When this method returns, contains the glyph data as a read-only memory region if the glyph exists;
+ /// otherwise, contains an empty memory region.
+ /// true if the glyph data was found and assigned to the out parameter; otherwise, false.
+ public bool TryGetGlyphData(int glyphIndex, out ReadOnlyMemory data)
+ {
+ if (!_locaTable.TryGetOffsets(glyphIndex, out var start, out var end))
+ {
+ data = ReadOnlyMemory.Empty;
+ return false;
+ }
+
+ if (start == end)
+ {
+ data = ReadOnlyMemory.Empty;
+ return true;
+ }
+
+ // Additional safety check for glyf table bounds
+ if (start < 0 || end > _glyfData.Length || start > end)
+ {
+ data = ReadOnlyMemory.Empty;
+
+ return false;
+ }
+
+ data = _glyfData.Slice(start, end - start);
+
+ return true;
+ }
+
+ ///
+ /// Builds the glyph outline into the provided geometry context. Returns false for empty glyphs.
+ /// Coordinates are in font design units. Composite glyphs are supported.
+ ///
+ public bool TryBuildGlyphGeometry(int glyphIndex, Matrix transform, IGeometryContext context)
+ {
+ var decycler = GlyphDecycler.Rent();
+
+ try
+ {
+ return TryBuildGlyphGeometryInternal(glyphIndex, context, transform, decycler);
+ }
+ catch (DecyclerException ex)
+ {
+ if (Logger.TryGet(LogEventLevel.Warning, LogArea.Visual, out var log))
+ {
+ log.Log(this, "Glyph {0} processing failed: {1}", glyphIndex, ex.Message);
+ }
+ return false;
+ }
+ catch
+ {
+ return false;
+ }
+ finally
+ {
+ GlyphDecycler.Return(decycler);
+ }
+ }
+
+ ///
+ /// Builds the geometry for a simple glyph by processing its contours and converting them into geometry commands.
+ ///
+ /// The simple glyph containing contour data, flags, and coordinates.
+ /// The geometry context that receives the constructed glyph geometry.
+ /// The transformation matrix to apply to all coordinates.
+ /// true if the glyph geometry was successfully built; otherwise, false.
+ private static bool BuildSimpleGlyphGeometry(SimpleGlyph simpleGlyph, IGeometryContext context, Matrix transform)
+ {
+ try
+ {
+ var endPtsOfContours = simpleGlyph.EndPtsOfContours;
+
+ if (endPtsOfContours.Length == 0)
+ {
+ return false;
+ }
+
+ var flags = simpleGlyph.Flags;
+ var xCoords = simpleGlyph.XCoordinates;
+ var yCoords = simpleGlyph.YCoordinates;
+
+ var startPointIndex = 0;
+
+ for (var contourIndex = 0; contourIndex < endPtsOfContours.Length; contourIndex++)
+ {
+ var endPointIndex = endPtsOfContours[contourIndex];
+ var pointCount = endPointIndex - startPointIndex + 1;
+
+ if (pointCount == 0)
+ {
+ startPointIndex = endPointIndex + 1;
+ continue;
+ }
+
+ // Check if first point is on-curve
+ var firstFlag = flags[startPointIndex];
+ var firstIsOnCurve = (firstFlag & GlyphFlag.OnCurvePoint) != 0;
+
+ if (firstIsOnCurve)
+ {
+ // Normal case: start from first on-curve point
+ var firstPoint = new Point(xCoords[startPointIndex], yCoords[startPointIndex]);
+ context.BeginFigure(transform.Transform(firstPoint), true);
+
+ // Start processing from the next point (or wrap to first if only one point)
+ int i = pointCount == 1 ? startPointIndex : startPointIndex + 1;
+ int processingStartIndex = i;
+
+ var maxSegments = Math.Max(1, pointCount * 3);
+ var segmentsProcessed = 0;
+
+ while (segmentsProcessed++ < maxSegments)
+ {
+ // Wrap index to contour range
+ int currentIdx = startPointIndex + ((i - startPointIndex) % pointCount);
+
+ var curFlag = flags[currentIdx];
+ var curIsOnCurve = (curFlag & GlyphFlag.OnCurvePoint) != 0;
+ var curPoint = new Point(xCoords[currentIdx], yCoords[currentIdx]);
+
+ if (curIsOnCurve)
+ {
+ // Simple line to on-curve point
+ context.LineTo(transform.Transform(curPoint));
+ i++;
+ }
+ else
+ {
+ // Current is off-curve, look ahead
+ int nextIdx = startPointIndex + ((i + 1 - startPointIndex) % pointCount);
+ var nextFlag = flags[nextIdx];
+ var nextIsOnCurve = (nextFlag & GlyphFlag.OnCurvePoint) != 0;
+ var nextPoint = new Point(xCoords[nextIdx], yCoords[nextIdx]);
+
+ if (nextIsOnCurve)
+ {
+ // Quadratic curve to next on-curve point
+ context.QuadraticBezierTo(
+ transform.Transform(curPoint),
+ transform.Transform(nextPoint)
+ );
+ // Advance past the on-curve point
+ i += 2;
+ }
+ else
+ {
+ // Two consecutive off-curve points -> implied midpoint
+ var impliedX = (curPoint.X + nextPoint.X) / 2.0;
+ var impliedY = (curPoint.Y + nextPoint.Y) / 2.0;
+ var impliedPoint = new Point(impliedX, impliedY);
+
+ context.QuadraticBezierTo(
+ transform.Transform(curPoint),
+ transform.Transform(impliedPoint)
+ );
+ // Advance to the second off-curve point for next iteration
+ i++;
+ }
+ }
+
+ // Check if we've wrapped back to start
+ int checkIdx = startPointIndex + ((i - startPointIndex) % pointCount);
+ if (checkIdx == processingStartIndex && segmentsProcessed > 0)
+ {
+ break;
+ }
+ }
+
+ context.EndFigure(true);
+ }
+ else
+ {
+ // First point is off-curve -> create implied start between last and first
+ var lastIdx = endPointIndex;
+ var firstX = xCoords[startPointIndex];
+ var firstY = yCoords[startPointIndex];
+ var lastX = xCoords[lastIdx];
+ var lastY = yCoords[lastIdx];
+
+ var impliedStartX = (lastX + firstX) / 2.0;
+ var impliedStartY = (lastY + firstY) / 2.0;
+ var impliedStart = new Point(impliedStartX, impliedStartY);
+
+ context.BeginFigure(transform.Transform(impliedStart), true);
+
+ int idxWalker = 0; // offset from startPointIndex
+ var maxSegments = pointCount * 3;
+ var segmentsProcessed = 0;
+
+ while (segmentsProcessed++ < maxSegments)
+ {
+ int curIdx = startPointIndex + idxWalker;
+ int nextIdxOffset = idxWalker == pointCount - 1 ? 0 : idxWalker + 1;
+ int nextIdx = startPointIndex + nextIdxOffset;
+
+ var curFlag = flags[curIdx];
+ var curIsOnCurve = (curFlag & GlyphFlag.OnCurvePoint) != 0;
+ var curPoint = new Point(xCoords[curIdx], yCoords[curIdx]);
+
+ if (curIsOnCurve)
+ {
+ context.LineTo(transform.Transform(curPoint));
+ idxWalker = nextIdxOffset;
+ }
+ else
+ {
+ var nextFlag = flags[nextIdx];
+ var nextIsOnCurve = (nextFlag & GlyphFlag.OnCurvePoint) != 0;
+ var nextPoint = new Point(xCoords[nextIdx], yCoords[nextIdx]);
+
+ if (nextIsOnCurve)
+ {
+ context.QuadraticBezierTo(
+ transform.Transform(curPoint),
+ transform.Transform(nextPoint)
+ );
+ idxWalker = nextIdxOffset == pointCount - 1 ? 0 : nextIdxOffset + 1;
+ }
+ else
+ {
+ // Two consecutive off-curve points -> implied midpoint
+ var impliedX = (curPoint.X + nextPoint.X) / 2.0;
+ var impliedY = (curPoint.Y + nextPoint.Y) / 2.0;
+ var impliedPoint = new Point(impliedX, impliedY);
+
+ context.QuadraticBezierTo(
+ transform.Transform(curPoint),
+ transform.Transform(impliedPoint)
+ );
+ idxWalker = nextIdxOffset == pointCount - 1 ? 0 : nextIdxOffset + 1;
+ }
+ }
+
+ // Stop when we've wrapped back to the beginning
+ if (idxWalker == 0 && segmentsProcessed > 1)
+ {
+ break;
+ }
+ }
+
+ context.EndFigure(true);
+ }
+
+ startPointIndex = endPointIndex + 1;
+ }
+
+ return true;
+ }
+ finally
+ {
+ // Return rented buffers to pool
+ simpleGlyph.Dispose();
+ }
+ }
+
+ ///
+ /// Creates a transformation matrix for a composite glyph component based on its flags and transformation parameters.
+ ///
+ /// The glyph component containing transformation information.
+ /// A transformation matrix that should be applied to the component glyph.
+ private static Matrix CreateComponentTransform(GlyphComponent component)
+ {
+ var flags = component.Flags;
+
+ double tx = 0, ty = 0;
+
+ if ((flags & CompositeFlags.ArgsAreXYValues) != 0)
+ {
+ tx = component.Arg1;
+ ty = component.Arg2;
+ }
+
+ double m11, m12, m21, m22;
+
+ if ((flags & CompositeFlags.WeHaveAScale) != 0)
+ {
+ m11 = m22 = component.Scale;
+ m12 = m21 = 0;
+ }
+ else if ((flags & CompositeFlags.WeHaveAnXAndYScale) != 0)
+ {
+ m11 = component.ScaleX;
+ m22 = component.ScaleY;
+ m12 = m21 = 0;
+ }
+ else if ((flags & CompositeFlags.WeHaveATwoByTwo) != 0)
+ {
+ m11 = component.ScaleX;
+ m12 = component.Scale01;
+ m21 = component.Scale10;
+ m22 = component.ScaleY;
+ }
+ else
+ {
+ m11 = m22 = 1.0;
+ m12 = m21 = 0;
+ }
+
+ return new Matrix(m11, m12, m21, m22, tx, ty);
+ }
+
+ ///
+ /// Attempts to build the geometry for the specified glyph and adds it to the provided geometry context.
+ ///
+ /// This method processes both simple and composite glyphs. For composite glyphs,
+ /// recursion is used and the visited set prevents cycles. The method returns false if the glyph is empty,
+ /// invalid, or has already been processed.
+ /// The index of the glyph to process. Must correspond to a valid glyph in the font.
+ /// The geometry context that receives the constructed glyph geometry.
+ /// The transformation matrix to apply to the glyph geometry.
+ /// A instance used to prevent infinite recursion when building composite glyphs.
+ /// true if the glyph geometry was successfully built and added to the context; otherwise, false.
+ private bool TryBuildGlyphGeometryInternal(int glyphIndex, IGeometryContext context, Matrix transform, GlyphDecycler decycler)
+ {
+ using var guard = decycler.Enter(glyphIndex);
+
+ if (!TryGetGlyphData(glyphIndex, out var glyphData) || glyphData.IsEmpty)
+ {
+ return false;
+ }
+
+ var descriptor = new GlyphDescriptor(glyphData);
+
+ if (descriptor.IsSimpleGlyph)
+ {
+ return BuildSimpleGlyphGeometry(descriptor.SimpleGlyph, context, transform);
+ }
+ else
+ {
+ return BuildCompositeGlyphGeometry(descriptor.CompositeGlyph, context, transform, decycler);
+ }
+ }
+
+ ///
+ /// Builds the geometry for a composite glyph by recursively processing its components.
+ ///
+ /// The composite glyph containing component references and transformations.
+ /// The geometry context that receives the constructed glyph geometry.
+ /// The transformation matrix to apply to all component glyphs.
+ /// A instance used to prevent infinite recursion when building composite glyphs.
+ /// true if at least one component was successfully processed; otherwise, false.
+ private bool BuildCompositeGlyphGeometry(CompositeGlyph compositeGlyph, IGeometryContext context, Matrix transform, GlyphDecycler decycler)
+ {
+ try
+ {
+ var components = compositeGlyph.Components;
+
+ if (components.Length == 0)
+ {
+ return false;
+ }
+
+ var hasGeometry = false;
+
+ foreach (var component in components)
+ {
+ var componentTransform = CreateComponentTransform(component);
+ var combinedTransform = componentTransform * transform;
+
+ var wrappedContext = new TransformingGeometryContext(context, combinedTransform);
+
+ if (TryBuildGlyphGeometryInternal(component.GlyphIndex, wrappedContext, Matrix.Identity, decycler))
+ {
+ hasGeometry = true;
+ }
+ }
+
+ return hasGeometry;
+ }
+ finally
+ {
+ // Return rented buffer to pool
+ compositeGlyph.Dispose();
+ }
+ }
+
+ ///
+ /// Wrapper that applies a matrix transform to coordinates before delegating to the real context.
+ ///
+ private sealed class TransformingGeometryContext : IGeometryContext
+ {
+ private readonly IGeometryContext _inner;
+ private readonly Matrix _matrix;
+
+ public TransformingGeometryContext(IGeometryContext inner, Matrix matrix)
+ {
+ _inner = inner;
+ _matrix = matrix;
+ }
+
+ public void ArcTo(Point point, Size size, double rotationAngle, bool isLargeArc, SweepDirection sweepDirection, bool isStroked = true)
+ {
+ if (Logger.TryGet(LogEventLevel.Debug, LogArea.Visual, out var log))
+ {
+ log.Log(_inner, "ArcTo {0} {1} rot={2} large={3} sweep={4}", point, size, rotationAngle, isLargeArc, sweepDirection);
+ }
+
+ var tp = _matrix.Transform(point);
+
+ _inner.ArcTo(tp, size, rotationAngle, isLargeArc, sweepDirection, isStroked);
+ }
+
+ public void BeginFigure(Point startPoint, bool isFilled = true)
+ {
+ if (Logger.TryGet(LogEventLevel.Debug, LogArea.Visual, out var log))
+ {
+ log.Log(_inner, "BeginFigure {0} filled={1}", startPoint, isFilled);
+ }
+
+ var tp = _matrix.Transform(startPoint);
+
+ _inner.BeginFigure(tp, isFilled);
+ }
+
+ public void CubicBezierTo(Point controlPoint1, Point controlPoint2, Point endPoint, bool isStroked = true)
+ {
+ if (Logger.TryGet(LogEventLevel.Debug, LogArea.Visual, out var log))
+ {
+ log.Log(_inner, "CubicBezierTo cp1={0} cp2={1} end={2}", controlPoint1, controlPoint2, endPoint);
+ }
+
+ _inner.CubicBezierTo(_matrix.Transform(controlPoint1), _matrix.Transform(controlPoint2), _matrix.Transform(endPoint), isStroked);
+ }
+
+ public void QuadraticBezierTo(Point controlPoint, Point endPoint, bool isStroked = true)
+ {
+ if (Logger.TryGet(LogEventLevel.Debug, LogArea.Visual, out var log))
+ {
+ log.Log(_inner, "QuadraticBezierTo cp={0} end={1}", controlPoint, endPoint);
+ }
+
+ _inner.QuadraticBezierTo(_matrix.Transform(controlPoint), _matrix.Transform(endPoint), isStroked);
+ }
+
+ public void LineTo(Point endPoint, bool isStroked = true)
+ {
+ if (Logger.TryGet(LogEventLevel.Debug, LogArea.Visual, out var log))
+ {
+ log.Log(_inner, "LineTo {0}", endPoint);
+ }
+
+ _inner.LineTo(_matrix.Transform(endPoint), isStroked);
+ }
+
+ public void EndFigure(bool isClosed)
+ {
+ if (Logger.TryGet(LogEventLevel.Debug, LogArea.Visual, out var log))
+ {
+ log.Log(_inner, "EndFigure closed={0}", isClosed);
+ }
+
+ _inner.EndFigure(isClosed);
+ }
+
+ public void SetFillRule(FillRule fillRule)
+ {
+ if (Logger.TryGet(LogEventLevel.Debug, LogArea.Visual, out var log))
+ {
+ log.Log(_inner, "SetFillRule {0}", fillRule);
+ }
+
+ _inner.SetFillRule(fillRule);
+ }
+
+ public void Dispose()
+ {
+ if (Logger.TryGet(LogEventLevel.Debug, LogArea.Visual, out var log))
+ {
+ log.Log(_inner, "Dispose TransformingGeometryContext");
+ }
+ }
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Glyf/GlyphComponent.cs b/src/Avalonia.Base/Media/Fonts/Tables/Glyf/GlyphComponent.cs
new file mode 100644
index 0000000000..c8dbc7dffd
--- /dev/null
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Glyf/GlyphComponent.cs
@@ -0,0 +1,22 @@
+namespace Avalonia.Media.Fonts.Tables.Glyf
+{
+ ///
+ /// Represents a single component of a composite glyph, including its transformation and positioning information.
+ ///
+ /// A composite glyph is constructed from one or more components, each referencing another glyph
+ /// and specifying how it should be transformed and positioned within the composite. This structure encapsulates the
+ /// data required to interpret a component according to the OpenType or TrueType font specifications. It is intended
+ /// for internal use when parsing or processing composite glyph outlines.
+ internal readonly struct GlyphComponent
+ {
+ public CompositeFlags Flags { get; init; }
+ public ushort GlyphIndex { get; init; }
+ public short Arg1 { get; init; }
+ public short Arg2 { get; init; }
+ public float Scale { get; init; }
+ public float ScaleX { get; init; }
+ public float ScaleY { get; init; }
+ public float Scale01 { get; init; }
+ public float Scale10 { get; init; }
+ }
+}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Glyf/GlyphDecycler.cs b/src/Avalonia.Base/Media/Fonts/Tables/Glyf/GlyphDecycler.cs
new file mode 100644
index 0000000000..b70a0aaa78
--- /dev/null
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Glyf/GlyphDecycler.cs
@@ -0,0 +1,48 @@
+using Avalonia.Media.Fonts.Tables.Colr;
+using Avalonia.Utilities;
+
+namespace Avalonia.Media.Fonts.Tables.Glyf
+{
+ ///
+ /// Type alias for the glyph decycler that tracks visited glyphs during composite glyph processing.
+ ///
+ internal class GlyphDecycler : Decycler
+ {
+ ///
+ /// Maximum depth for glyph graph traversal.
+ /// This limit prevents stack overflow from deeply nested composite glyphs.
+ ///
+ public const int MaxTraversalDepth = 64;
+
+ private static readonly ObjectPool Pool = new ObjectPool(
+ factory: () => new GlyphDecycler(),
+ validator: decycler =>
+ {
+ decycler.Reset();
+ return true;
+ },
+ maxSize: 16);
+
+ public GlyphDecycler() : base(MaxTraversalDepth)
+ {
+ }
+
+ ///
+ /// Rents a GlyphDecycler from the pool.
+ ///
+ /// A pooled GlyphDecycler instance.
+ public static GlyphDecycler Rent()
+ {
+ return Pool.Rent();
+ }
+
+ ///
+ /// Returns a GlyphDecycler to the pool.
+ ///
+ /// The decycler to return to the pool.
+ public static void Return(GlyphDecycler decycler)
+ {
+ Pool.Return(decycler);
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Glyf/GlyphDescriptor.cs b/src/Avalonia.Base/Media/Fonts/Tables/Glyf/GlyphDescriptor.cs
new file mode 100644
index 0000000000..c98d4ba898
--- /dev/null
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Glyf/GlyphDescriptor.cs
@@ -0,0 +1,79 @@
+using System;
+using System.Buffers.Binary;
+using System.Collections.Generic;
+using System.Runtime.InteropServices;
+using System.Text;
+
+namespace Avalonia.Media.Fonts.Tables.Glyf
+{
+ ///
+ /// Represents the descriptor for a glyph in a font, providing access to its outline and bounding metrics.
+ ///
+ /// A glyph descriptor exposes contour and bounding box information for a glyph, as well as
+ /// access to its simple or composite outline data. Use the properties to determine the glyph type and retrieve the
+ /// corresponding outline representation.
+ internal class GlyphDescriptor
+ {
+ private readonly ReadOnlyMemory _glyphData;
+
+ public GlyphDescriptor(ReadOnlyMemory data)
+ {
+ var span = data.Span;
+
+ NumberOfContours = BinaryPrimitives.ReadInt16BigEndian(span.Slice(0, 2));
+
+ var xMin = BinaryPrimitives.ReadInt16BigEndian(span.Slice(2, 2));
+ var yMin = BinaryPrimitives.ReadInt16BigEndian(span.Slice(4, 2));
+ var xMax = BinaryPrimitives.ReadInt16BigEndian(span.Slice(6, 2));
+ var yMax = BinaryPrimitives.ReadInt16BigEndian(span.Slice(8, 2));
+
+ // Store as Rect - note: coordinates are in font design units
+ ConservativeBounds = new Rect(xMin, yMin, xMax - xMin, yMax - yMin);
+
+ _glyphData = data.Slice(10);
+ }
+
+ ///
+ /// Gets the number of contours in the glyph.
+ ///
+ ///
+ /// If the value is greater than or equal to zero, the glyph is a simple glyph.
+ /// If the value is negative (typically -1), the glyph is a composite glyph.
+ ///
+ public short NumberOfContours { get; }
+
+ ///
+ /// Gets the conservative bounding box for the glyph in font design units.
+ ///
+ ///
+ /// This represents the minimum bounding rectangle that contains all points in the glyph outline.
+ /// The coordinates are in the font's coordinate system (design units), not scaled to any particular size.
+ /// For proper rendering, these coordinates should be transformed by the font matrix and scaled
+ /// by the font rendering size.
+ ///
+ public Rect ConservativeBounds { get; }
+
+ ///
+ /// Gets a value indicating whether this glyph is a simple glyph (as opposed to a composite glyph).
+ ///
+ public bool IsSimpleGlyph => NumberOfContours >= 0;
+
+ ///
+ /// Gets the simple glyph outline data.
+ ///
+ ///
+ /// This property should only be accessed if is true.
+ /// The returned struct holds references to rented arrays and must be disposed.
+ ///
+ public SimpleGlyph SimpleGlyph => SimpleGlyph.Create(_glyphData.Span, NumberOfContours);
+
+ ///
+ /// Gets the composite glyph outline data.
+ ///
+ ///
+ /// This property should only be accessed if is false.
+ /// The returned struct holds references to rented arrays and must be disposed.
+ ///
+ public CompositeGlyph CompositeGlyph => CompositeGlyph.Create(_glyphData.Span);
+ }
+}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Glyf/GlyphFlag.cs b/src/Avalonia.Base/Media/Fonts/Tables/Glyf/GlyphFlag.cs
new file mode 100644
index 0000000000..19c9252af2
--- /dev/null
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Glyf/GlyphFlag.cs
@@ -0,0 +1,18 @@
+using System;
+
+namespace Avalonia.Media.Fonts.Tables.Glyf
+{
+ [Flags]
+ internal enum GlyphFlag : byte
+ {
+ None = 0x00,
+ OnCurvePoint = 0x01,
+ XShortVector = 0x02,
+ YShortVector = 0x04,
+ Repeat = 0x08,
+ XIsSameOrPositiveXShortVector = 0x10,
+ YIsSameOrPositiveYShortVector = 0x20,
+ Reserved1 = 0x40,
+ Reserved2 = 0x80
+ }
+}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Glyf/SimpleGlyph.cs b/src/Avalonia.Base/Media/Fonts/Tables/Glyf/SimpleGlyph.cs
new file mode 100644
index 0000000000..977e7a8a35
--- /dev/null
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Glyf/SimpleGlyph.cs
@@ -0,0 +1,275 @@
+using System;
+using System.Buffers;
+using System.Buffers.Binary;
+using System.Runtime.InteropServices;
+
+namespace Avalonia.Media.Fonts.Tables.Glyf
+{
+ ///
+ /// Represents a simple glyph outline as defined in the TrueType font format, including contour endpoints,
+ /// instructions, flags, and coordinate data. Provides access to the parsed glyph data for rendering or analysis.
+ ///
+ /// A simple glyph consists of one or more contours, each defined by a sequence of points. This
+ /// struct exposes the raw data necessary to interpret the glyph's shape, including the endpoints of each contour,
+ /// the TrueType instructions for hinting, per-point flags, and the X and Y coordinates of each point. The struct is
+ /// intended for low-level font processing and is not thread-safe. This struct holds references to rented arrays
+ /// that must be returned via Dispose.
+ internal readonly ref struct SimpleGlyph
+ {
+ // Rented buffers for flags
+ private readonly GlyphFlag[]? _rentedFlags;
+ // Rented buffers for y-coordinates
+ private readonly short[]? _rentedXCoords;
+ // Rented buffers for x-coordinates
+ private readonly short[]? _rentedYCoords;
+
+ ///
+ /// Gets the indices of the last point in each contour within the glyph outline.
+ ///
+ /// The indices are zero-based and correspond to positions in the glyph's point array.
+ /// The number of elements indicates the number of contours in the glyph.
+ public ReadOnlySpan EndPtsOfContours { get; }
+
+ ///
+ /// Gets the instruction data.
+ ///
+ public ReadOnlySpan Instructions { get; }
+
+ ///
+ /// Gets the collection of flags associated with the glyph.
+ ///
+ public ReadOnlySpan Flags { get; }
+
+ ///
+ /// Gets the X coordinates.
+ ///
+ public ReadOnlySpan XCoordinates { get; }
+
+ ///
+ /// Gets the Y coordinates.
+ ///
+ public ReadOnlySpan YCoordinates { get; }
+
+ ///
+ /// Initializes a new instance of the SimpleGlyph class using the specified contour endpoints, instructions,
+ /// flags, and coordinate data.
+ ///
+ /// The rented arrays, if supplied, are used to optimize memory usage and may be returned
+ /// to a pool after use. Callers should not access or modify these arrays after passing them to the
+ /// constructor.
+ /// A read-only span containing the indices of the last point in each contour. The values define the structure
+ /// of the glyph's outline.
+ /// A read-only span containing the TrueType instructions associated with the glyph. These instructions control
+ /// glyph rendering and hinting.
+ /// A read-only span of flags describing the attributes of each glyph point, such as whether a point is on-curve
+ /// or off-curve.
+ /// A read-only span containing the X coordinates for each glyph point, in font units.
+ /// A read-only span containing the Y coordinates for each glyph point, in font units.
+ /// An optional array of GlyphFlag values used for temporary storage. If provided, the array may be reused
+ /// internally to reduce allocations.
+ /// An optional array of short values used for temporary storage of X coordinates. If provided, the array may be
+ /// reused internally to reduce allocations.
+ /// An optional array of short values used for temporary storage of Y coordinates. If provided, the array may be
+ /// reused internally to reduce allocations.
+ private SimpleGlyph(
+ ReadOnlySpan endPtsOfContours,
+ ReadOnlySpan instructions,
+ ReadOnlySpan flags,
+ ReadOnlySpan xCoordinates,
+ ReadOnlySpan yCoordinates,
+ GlyphFlag[]? rentedFlags,
+ short[]? rentedXCoords,
+ short[]? rentedYCoords)
+ {
+ EndPtsOfContours = endPtsOfContours;
+ Instructions = instructions;
+ Flags = flags;
+ XCoordinates = xCoordinates;
+ YCoordinates = yCoordinates;
+ _rentedFlags = rentedFlags;
+ _rentedXCoords = rentedXCoords;
+ _rentedYCoords = rentedYCoords;
+ }
+
+ ///
+ /// Creates a new instance of the structure by parsing glyph data from the specified
+ /// byte span.
+ ///
+ /// The returned uses buffers rented from array pools for
+ /// performance. Callers are responsible for disposing or returning these buffers if required by the implementation. The method does not validate the integrity of the glyph data beyond the
+ /// contour count; malformed data may result in exceptions or undefined behavior.
+ /// A read-only span of bytes containing the raw glyph data to parse. The data must be formatted according to
+ /// the TrueType simple glyph specification.
+ /// The number of contours in the glyph. Must be greater than zero; otherwise, a default value is returned.
+ /// A instance representing the parsed glyph data. Returns the default value if
+ /// is less than or equal to zero.
+ public static SimpleGlyph Create(ReadOnlySpan data, int numberOfContours)
+ {
+ if (numberOfContours <= 0)
+ {
+ return default;
+ }
+
+ // Endpoints of contours
+ var endPtsOfContours = new ushort[numberOfContours];
+ var endPtsBytes = data.Slice(0, numberOfContours * 2);
+
+ for (int i = 0; i < numberOfContours; i++)
+ {
+ endPtsOfContours[i] = BinaryPrimitives.ReadUInt16BigEndian(endPtsBytes.Slice(i * 2, 2));
+ }
+
+ // Instructions
+ int instructionsOffset = numberOfContours * 2;
+ ushort instructionsLength = BinaryPrimitives.ReadUInt16BigEndian(data.Slice(instructionsOffset, 2));
+ var instructions = data.Slice(instructionsOffset + 2, instructionsLength);
+
+ // Number of points
+ int numPoints = endPtsOfContours[numberOfContours - 1] + 1;
+
+ // Rent buffers
+ int flagsOffset = instructionsOffset + 2 + instructionsLength;
+ var flagsBuffer = ArrayPool.Shared.Rent(numPoints);
+ var xCoordsBuffer = ArrayPool.Shared.Rent(numPoints);
+ var yCoordsBuffer = ArrayPool.Shared.Rent(numPoints);
+
+ try
+ {
+ // Decode flags
+ int flagIndex = 0;
+ int offset = flagsOffset;
+
+ while (flagIndex < numPoints)
+ {
+ var flag = (GlyphFlag)data[offset++];
+ flagsBuffer[flagIndex++] = flag;
+
+ // Repeat flag
+ if ((flag & GlyphFlag.Repeat) != 0)
+ {
+ // Read repeat count
+ byte repeatCount = data[offset++];
+
+ // Repeat the flag
+ for (int i = 0; i < repeatCount && flagIndex < numPoints; i++)
+ {
+ flagsBuffer[flagIndex++] = flag;
+ }
+ }
+ }
+
+ // Decode X coordinates
+ short x = 0;
+
+ for (int i = 0; i < numPoints; i++)
+ {
+ var flag = flagsBuffer[i];
+
+ // Short vector
+ if ((flag & GlyphFlag.XShortVector) != 0)
+ {
+ byte dx = data[offset++];
+
+ if ((flag & GlyphFlag.XIsSameOrPositiveXShortVector) != 0)
+ {
+ x += (short)dx;
+ }
+ else
+ {
+ x -= (short)dx;
+ }
+ }
+ else
+ {
+ // Not a short vector
+ if ((flag & GlyphFlag.XIsSameOrPositiveXShortVector) == 0)
+ {
+ short dx = BinaryPrimitives.ReadInt16BigEndian(data.Slice(offset, 2));
+ offset += 2;
+ x += dx;
+ }
+ }
+
+ xCoordsBuffer[i] = x;
+ }
+
+ // Decode Y coordinates
+ short y = 0;
+
+ for (int i = 0; i < numPoints; i++)
+ {
+ var flag = flagsBuffer[i];
+
+ // Short vector
+ if ((flag & GlyphFlag.YShortVector) != 0)
+ {
+ byte dy = data[offset++];
+ if ((flag & GlyphFlag.YIsSameOrPositiveYShortVector) != 0)
+ {
+ y += (short)dy;
+ }
+ else
+ {
+ y -= (short)dy;
+ }
+ }
+ else
+ {
+ // Not a short vector
+ if ((flag & GlyphFlag.YIsSameOrPositiveYShortVector) == 0)
+ {
+ short dy = BinaryPrimitives.ReadInt16BigEndian(data.Slice(offset, 2));
+ offset += 2;
+ y += dy;
+ }
+ }
+
+ yCoordsBuffer[i] = y;
+ }
+
+ return new SimpleGlyph(
+ endPtsOfContours,
+ instructions,
+ flagsBuffer.AsSpan(0, numPoints),
+ xCoordsBuffer.AsSpan(0, numPoints),
+ yCoordsBuffer.AsSpan(0, numPoints),
+ flagsBuffer,
+ xCoordsBuffer,
+ yCoordsBuffer
+ );
+ }
+ catch
+ {
+ // On exception, return buffers immediately
+ ArrayPool.Shared.Return(flagsBuffer);
+ ArrayPool.Shared.Return(xCoordsBuffer);
+ ArrayPool.Shared.Return(yCoordsBuffer);
+ throw;
+ }
+ }
+
+ ///
+ /// Returns the rented buffers to the ArrayPool.
+ ///
+ /// This method should be called when the SimpleGlyph is no longer needed
+ /// to ensure the rented buffers are returned to the pool.
+ public void Dispose()
+ {
+ if (_rentedFlags != null)
+ {
+ ArrayPool.Shared.Return(_rentedFlags);
+ }
+
+ if (_rentedXCoords != null)
+ {
+ ArrayPool.Shared.Return(_rentedXCoords);
+ }
+
+ if (_rentedYCoords != null)
+ {
+ ArrayPool.Shared.Return(_rentedYCoords);
+ }
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/LocaTable.cs b/src/Avalonia.Base/Media/Fonts/Tables/LocaTable.cs
new file mode 100644
index 0000000000..75ea426f4e
--- /dev/null
+++ b/src/Avalonia.Base/Media/Fonts/Tables/LocaTable.cs
@@ -0,0 +1,127 @@
+using System;
+using System.Buffers.Binary;
+using System.Runtime.InteropServices;
+
+namespace Avalonia.Media.Fonts.Tables
+{
+ ///
+ /// Provides efficient access to the 'loca' (Index to Location) table without pre-allocating an array.
+ /// The loca table stores offsets into the glyf table for each glyph.
+ ///
+ internal sealed class LocaTable
+ {
+ internal const string TableName = "loca";
+ internal static OpenTypeTag Tag { get; } = OpenTypeTag.Parse(TableName);
+
+ private readonly ReadOnlyMemory _data;
+ private readonly int _glyphCount;
+ private readonly bool _isShortFormat;
+
+ private LocaTable(ReadOnlyMemory data, int glyphCount, bool isShortFormat)
+ {
+ _data = data;
+ _glyphCount = glyphCount;
+ _isShortFormat = isShortFormat;
+ }
+
+ ///
+ /// Gets the number of glyphs in the font.
+ ///
+ public int GlyphCount => _glyphCount;
+
+ ///
+ /// Loads the loca table from the specified typeface.
+ ///
+ /// The glyph typeface to load from.
+ /// The head table containing the index format.
+ /// The maxp table containing the glyph count.
+ /// A LocaTable instance, or null if the table cannot be loaded.
+ public static LocaTable? Load(GlyphTypeface glyphTypeface, HeadTable head, MaxpTable maxp)
+ {
+ if (!glyphTypeface.PlatformTypeface.TryGetTable(Tag, out var locaData))
+ {
+ return null;
+ }
+
+ var isShortFormat = head.IndexToLocFormat == 0;
+ var glyphCount = maxp.NumGlyphs;
+
+ // Validate table size
+ var expectedSize = isShortFormat ? (glyphCount + 1) * 2 : (glyphCount + 1) * 4;
+
+ if (locaData.Length < expectedSize)
+ {
+ // Table is shorter than expected, but we can still work with what we have
+ // The GetOffset method will handle out-of-bounds access
+ }
+
+ return new LocaTable(locaData, glyphCount, isShortFormat);
+ }
+
+ ///
+ /// Gets the start and end offsets for the specified glyph index.
+ ///
+ /// The glyph index (0-based).
+ /// The start offset into the glyf table.
+ /// The end offset into the glyf table.
+ /// True if the offsets were retrieved successfully; otherwise, false.
+ public bool TryGetOffsets(int glyphIndex, out int start, out int end)
+ {
+ if ((uint)glyphIndex >= (uint)_glyphCount)
+ {
+ start = 0;
+ end = 0;
+ return false;
+ }
+
+ start = GetOffset(glyphIndex);
+ end = GetOffset(glyphIndex + 1);
+
+ return true;
+ }
+
+ ///
+ /// Gets the offset for the specified glyph index into the glyf table.
+ ///
+ /// The glyph index (0-based).
+ /// The offset into the glyf table, or 0 if the index is out of range.
+ private int GetOffset(int glyphIndex)
+ {
+ if ((uint)glyphIndex > (uint)_glyphCount) // Note: allows glyphCount for the end offset
+ {
+ return 0;
+ }
+
+ var span = _data.Span;
+
+ if (_isShortFormat)
+ {
+ var byteOffset = glyphIndex * 2;
+
+ if (byteOffset + 2 > span.Length)
+ {
+ return 0;
+ }
+
+ // Short format: uint16 values stored divided by 2
+ var value = BinaryPrimitives.ReadUInt16BigEndian(span.Slice(byteOffset, 2));
+ return value * 2;
+ }
+ else
+ {
+ var byteOffset = glyphIndex * 4;
+
+ if (byteOffset + 4 > span.Length)
+ {
+ return 0;
+ }
+
+ // Long format: uint32 values
+ var value = BinaryPrimitives.ReadUInt32BigEndian(span.Slice(byteOffset, 4));
+
+ // Clamp to int.MaxValue to avoid overflow
+ return value > int.MaxValue ? int.MaxValue : (int)value;
+ }
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Media/GlyphDrawingType.cs b/src/Avalonia.Base/Media/GlyphDrawingType.cs
new file mode 100644
index 0000000000..5acab58eee
--- /dev/null
+++ b/src/Avalonia.Base/Media/GlyphDrawingType.cs
@@ -0,0 +1,16 @@
+namespace Avalonia.Media
+{
+ ///
+ /// Specifies the format used to render a glyph, such as outline, color layers, SVG, or bitmap.
+ ///
+ /// Use this enumeration to determine or specify how a glyph should be drawn, depending on the
+ /// font's supported formats. The value corresponds to the glyph's representation in the font file, which may affect
+ /// rendering capabilities and visual appearance.
+ public enum GlyphDrawingType
+ {
+ Outline, // glyf / CFF / CFF2
+ ColorLayers, // COLR/CPAL
+ Svg, // SVG table
+ Bitmap // sbix / CBDT / EBDT
+ }
+}
diff --git a/src/Avalonia.Base/Media/GlyphTypeface.cs b/src/Avalonia.Base/Media/GlyphTypeface.cs
index fdbd56947e..dccb45175f 100644
--- a/src/Avalonia.Base/Media/GlyphTypeface.cs
+++ b/src/Avalonia.Base/Media/GlyphTypeface.cs
@@ -1,10 +1,13 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using Avalonia.Logging;
using Avalonia.Media.Fonts;
using Avalonia.Media.Fonts.Tables;
using Avalonia.Media.Fonts.Tables.Cmap;
+using Avalonia.Media.Fonts.Tables.Colr;
+using Avalonia.Media.Fonts.Tables.Glyf;
using Avalonia.Media.Fonts.Tables.Metrics;
using Avalonia.Media.Fonts.Tables.Name;
using Avalonia.Platform;
@@ -24,6 +27,8 @@ namespace Avalonia.Media
private static readonly IReadOnlyDictionary s_emptyStringDictionary =
new Dictionary(0);
+ private static readonly IPlatformRenderInterface _renderInterface = AvaloniaLocator.Current.GetRequiredService();
+
private bool _isDisposed;
private readonly NameTable? _nameTable;
@@ -33,6 +38,11 @@ namespace Avalonia.Media
private readonly VerticalHeaderTable _vhTable;
private readonly HorizontalMetricsTable? _hmTable;
private readonly VerticalMetricsTable? _vmTable;
+
+ private readonly GlyfTable? _glyfTable;
+ private readonly ColrTable? _colrTable;
+ private readonly CpalTable? _cpalTable;
+
private readonly bool _hasOs2Table;
private readonly bool _hasHorizontalMetrics;
private readonly bool _hasVerticalMetrics;
@@ -77,6 +87,18 @@ namespace Avalonia.Media
_vmTable = VerticalMetricsTable.Load(this, _vhTable.NumberOfVMetrics, GlyphCount);
}
+ if (HeadTable.TryLoad(this, out var headTable))
+ {
+ // Load glyf table once and cache for reuse by GetGlyphOutline
+ GlyfTable.TryLoad(this, headTable, maxpTable, out _glyfTable);
+
+ // Load COLR and CPAL tables for color glyph support
+ ColrTable.TryLoad(this, out _colrTable);
+ CpalTable.TryLoad(this, out _cpalTable);
+
+ IsLastResort = ((headTable.Flags & HeadFlags.LastResortFont) != 0) || _cmapTable.Format == CmapFormat.Format13;
+ }
+
var ascent = 0;
var descent = 0;
var lineGap = 0;
@@ -112,11 +134,6 @@ namespace Avalonia.Media
}
}
- HeadTable.TryLoad(this, out var headTable);
-
- IsLastResort = (headTable is not null && (headTable.Flags & HeadFlags.LastResortFont) != 0) ||
- _cmapTable.Format == CmapFormat.Format13;
-
var postTable = PostTable.Load(this);
var isFixedPitch = postTable.IsFixedPitch;
@@ -533,6 +550,85 @@ namespace Avalonia.Media
return true;
}
+ ///
+ /// Gets a color glyph drawing for the specified glyph ID, if color data is available.
+ ///
+ /// If the glyph does not have color data (such as COLR v1 or COLR v0 layers), this
+ /// method returns null. For outline-only glyphs, use GetGlyphOutline instead to obtain the vector
+ /// outline.
+ /// The identifier of the glyph to retrieve. Must be less than or equal to the total number of glyphs in the
+ /// font.
+ /// The font variation settings to use when selecting the glyph drawing, or null to use the default variation.
+ /// An object representing the color glyph drawing for the specified glyph ID, or null if no color drawing is
+ /// available for the glyph.
+ public IGlyphDrawing? GetGlyphDrawing(ushort glyphId, FontVariationSettings? variation = null)
+ {
+ if (glyphId > GlyphCount)
+ {
+ return null;
+ }
+
+ // Try COLR v1 first
+ if (_colrTable != null && _cpalTable != null && _colrTable.HasV1Data)
+ {
+ if (_colrTable.TryGetBaseGlyphV1Record(glyphId, out var record))
+ {
+ return new ColorGlyphV1Drawing(this, _colrTable, _cpalTable, glyphId, record);
+ }
+ }
+
+ // Fallback to COLR v0
+ if (_colrTable != null && _cpalTable != null && _colrTable.HasColorLayers(glyphId))
+ {
+ return new ColorGlyphDrawing(this, _colrTable, _cpalTable, glyphId);
+ }
+
+ // For outline-only glyphs, return null - caller should use GetGlyphOutline() instead
+ return null;
+ }
+
+ ///
+ /// Retrieves the vector outline geometry for the specified glyph, optionally applying a transformation and font
+ /// variation settings.
+ ///
+ /// The returned geometry reflects any transformation and variation settings provided. If
+ /// the font does not contain outline data for the specified glyph, or if the glyph identifier is out of range,
+ /// the method returns null.
+ /// The identifier of the glyph to retrieve. Must be less than or equal to the total number of glyphs in the
+ /// font.
+ /// A transformation matrix to apply to the glyph outline geometry.
+ /// Optional font variation settings to use when retrieving the glyph outline. If null, default font variations
+ /// are used.
+ /// A Geometry object representing the outline of the specified glyph, or null if the glyph does not exist or
+ /// the outline cannot be retrieved.
+ public Geometry? GetGlyphOutline(ushort glyphId, Matrix transform, FontVariationSettings? variation = null)
+ {
+ if (glyphId > GlyphCount)
+ {
+ return null;
+ }
+
+ if (_glyfTable is null)
+ {
+ return null;
+ }
+
+ var geometry = _renderInterface.CreateStreamGeometry();
+
+ using (var ctx = geometry.Open())
+ {
+ // Try to build the glyph geometry using the glyf table
+ if (_glyfTable.TryBuildGlyphGeometry((int)glyphId, transform, ctx))
+ {
+ var platformGeometry = new PlatformGeometry(geometry);
+
+ return platformGeometry;
+ }
+ }
+
+ return null;
+ }
+
public void Dispose()
{
Dispose(true);
@@ -691,5 +787,185 @@ namespace Avalonia.Media
PlatformTypeface.Dispose();
}
+
+ ///
+ /// Attempts to retrieve and resolve the paint definition for a base glyph using COLR v1 data.
+ ///
+ /// This method returns false if the COLR or CPAL tables are unavailable, if the glyph
+ /// does not have COLR v1 data, or if the paint data cannot be parsed or resolved.
+ /// The color rendering context used to interpret the paint data.
+ /// The base glyph record containing the paint offset information.
+ /// When this method returns, contains the resolved paint definition if successful; otherwise, null. This
+ /// parameter is passed uninitialized.
+ /// true if the paint definition was successfully retrieved and resolved; otherwise, false.
+ internal bool TryGetBaseGlyphV1Paint(ColrContext context, BaseGlyphV1Record record, [NotNullWhen(true)] out Paint? paint)
+ {
+ paint = null;
+
+ var absolutePaintOffset = _colrTable!.GetAbsolutePaintOffset(record.PaintOffset);
+
+ var decycler = PaintDecycler.Rent();
+ try
+ {
+ if (!PaintParser.TryParse(_colrTable.ColrData.Span, absolutePaintOffset, in context, in decycler, out var parsedPaint))
+ {
+ return false;
+ }
+
+ paint = PaintResolver.ResolvePaint(parsedPaint, in context);
+
+ return true;
+ }
+ finally
+ {
+ PaintDecycler.Return(decycler);
+ }
+ }
+ }
+
+ ///
+ /// Represents a color glyph drawing with multiple colored layers (COLR v0).
+ ///
+ internal sealed class ColorGlyphDrawing : IGlyphDrawing
+ {
+ private readonly GlyphTypeface _glyphTypeface;
+ private readonly ColrTable _colrTable;
+ private readonly CpalTable _cpalTable;
+ private readonly ushort _glyphId;
+ private readonly int _paletteIndex;
+
+ public ColorGlyphDrawing(GlyphTypeface glyphTypeface, ColrTable colrTable, CpalTable cpalTable, ushort glyphId, int paletteIndex = 0)
+ {
+ _glyphTypeface = glyphTypeface;
+ _colrTable = colrTable;
+ _cpalTable = cpalTable;
+ _glyphId = glyphId;
+ _paletteIndex = paletteIndex;
+ }
+
+ public GlyphDrawingType Type => GlyphDrawingType.ColorLayers;
+
+ public Rect Bounds
+ {
+ get
+ {
+ Rect? combinedBounds = null;
+ var layerRecords = _colrTable.GetLayers(_glyphId);
+
+ foreach (var layerRecord in layerRecords)
+ {
+ var geometry = _glyphTypeface.GetGlyphOutline(layerRecord.GlyphId, Matrix.CreateScale(1, -1));
+ if (geometry != null)
+ {
+ var layerBounds = geometry.Bounds;
+ combinedBounds = combinedBounds.HasValue
+ ? combinedBounds.Value.Union(layerBounds)
+ : layerBounds;
+ }
+ }
+
+ return combinedBounds ?? default;
+ }
+ }
+
+ ///
+ /// Draws the color glyph at the specified origin using the provided drawing context.
+ ///
+ /// This method renders a multi-layered color glyph by drawing each layer with its
+ /// associated color. The colors are determined by the current palette and may fall back to black if a color is
+ /// not found. The method does not apply any transformations; the glyph is drawn at the specified origin in the
+ /// current context.
+ /// The drawing context to use for rendering the glyph. Must not be null.
+ /// The point, in device-independent pixels, that specifies the origin at which to draw the glyph.
+ public void Draw(DrawingContext context, Point origin)
+ {
+ var layerRecords = _colrTable.GetLayers(_glyphId);
+
+ foreach (var layerRecord in layerRecords)
+ {
+ // Get the color for this layer from the CPAL table
+ if (!_cpalTable.TryGetColor(_paletteIndex, layerRecord.PaletteIndex, out var color))
+ {
+ color = Colors.Black; // Fallback
+ }
+
+ // Get the outline geometry for the layer glyph
+ var geometry = _glyphTypeface.GetGlyphOutline(layerRecord.GlyphId, Matrix.CreateScale(1, -1));
+
+ if (geometry != null)
+ {
+ using (context.PushTransform(Matrix.CreateTranslation(origin.X, origin.Y)))
+ {
+ context.DrawGeometry(new SolidColorBrush(color), null, geometry);
+ }
+ }
+ }
+ }
+ }
+
+ ///
+ /// Represents a COLR v1 color glyph drawing with paint-based rendering.
+ ///
+ internal sealed class ColorGlyphV1Drawing : IGlyphDrawing
+ {
+ private readonly ColrContext _context;
+ private readonly ushort _glyphId;
+ private readonly int _paletteIndex;
+
+ private readonly Rect _bounds;
+ private readonly Paint? _paint;
+
+ public ColorGlyphV1Drawing(GlyphTypeface glyphTypeface, ColrTable colrTable, CpalTable cpalTable,
+ ushort glyphId, BaseGlyphV1Record record, int paletteIndex = 0)
+ {
+ _context = new ColrContext(glyphTypeface, colrTable, cpalTable, paletteIndex);
+ _glyphId = glyphId;
+ _paletteIndex = paletteIndex;
+
+ var decycler = PaintDecycler.Rent();
+
+ try
+ {
+ if (glyphTypeface.TryGetBaseGlyphV1Paint(_context, record, out _paint))
+ {
+ if (_context.ColrTable.TryGetClipBox(_glyphId, out var clipRect))
+ {
+ // COLR v1 paint graphs operate in font-space coordinates (Y-up).
+ _bounds = clipRect.TransformToAABB(Matrix.CreateScale(1, -1));
+ }
+ }
+ }
+ finally
+ {
+ PaintDecycler.Return(decycler);
+
+ }
+ }
+
+ public GlyphDrawingType Type => GlyphDrawingType.ColorLayers;
+
+ public Rect Bounds => _bounds;
+
+ public void Draw(DrawingContext context, Point origin)
+ {
+ if (_paint == null)
+ {
+ return;
+ }
+
+ var decycler = PaintDecycler.Rent();
+
+ try
+ {
+ using (context.PushTransform(Matrix.CreateScale(1, -1) * Matrix.CreateTranslation(origin)))
+ {
+ PaintTraverser.Traverse(_paint, new ColorGlyphV1Painter(context, _context), Matrix.Identity);
+ }
+ }
+ finally
+ {
+ PaintDecycler.Return(decycler);
+ }
+ }
}
}
diff --git a/src/Avalonia.Base/Media/IGlyphDrawing.cs b/src/Avalonia.Base/Media/IGlyphDrawing.cs
new file mode 100644
index 0000000000..1763b73f04
--- /dev/null
+++ b/src/Avalonia.Base/Media/IGlyphDrawing.cs
@@ -0,0 +1,13 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace Avalonia.Media
+{
+ public interface IGlyphDrawing
+ {
+ Rect Bounds { get; }
+
+ void Draw(DrawingContext context, Point origin);
+ }
+}
diff --git a/src/Avalonia.Base/Utilities/ObjectPool.cs b/src/Avalonia.Base/Utilities/ObjectPool.cs
new file mode 100644
index 0000000000..650a2f2da3
--- /dev/null
+++ b/src/Avalonia.Base/Utilities/ObjectPool.cs
@@ -0,0 +1,80 @@
+using System;
+using System.Collections.Concurrent;
+using System.Runtime.CompilerServices;
+
+namespace Avalonia.Utilities
+{
+ ///
+ /// Provides a thread-safe object pool with size limits and object validation.
+ ///
+ /// The type of objects to pool.
+ internal sealed class ObjectPool where T : class
+ {
+ private readonly Func _factory;
+ private readonly Func? _validator;
+ private readonly ConcurrentBag _items;
+ private readonly int _maxSize;
+ private int _count;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Factory function to create new instances.
+ /// Optional validator to clean and validate objects before returning to the pool. Return false to discard the object.
+ /// Maximum number of objects to keep in the pool. Default is 32.
+ public ObjectPool(Func factory, Func? validator = null, int maxSize = 32)
+ {
+ _factory = factory ?? throw new ArgumentNullException(nameof(factory));
+ _validator = validator;
+ _maxSize = maxSize;
+ _items = new ConcurrentBag();
+ _count = 0;
+ }
+
+ ///
+ /// Rents an object from the pool or creates a new one if the pool is empty.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public T Rent()
+ {
+ if (_items.TryTake(out var item))
+ {
+ System.Threading.Interlocked.Decrement(ref _count);
+ return item;
+ }
+
+ return _factory();
+ }
+
+ ///
+ /// Returns an object to the pool if it passes validation and the pool is not full.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public void Return(T item)
+ {
+ if (item == null)
+ return;
+
+ // Validate and clean the object
+ if (_validator != null && !_validator(item))
+ return;
+
+ // Check if pool is full (fast check without lock)
+ if (_count >= _maxSize)
+ return;
+
+ // Try to increment count, but check again in case of race condition
+ var currentCount = System.Threading.Interlocked.Increment(ref _count);
+
+ if (currentCount <= _maxSize)
+ {
+ _items.Add(item);
+ }
+ else
+ {
+ // Pool is full, decrement and discard
+ System.Threading.Interlocked.Decrement(ref _count);
+ }
+ }
+ }
+}
diff --git a/src/Windows/Avalonia.Direct2D1/Media/DWriteTypeface.cs b/src/Windows/Avalonia.Direct2D1/Media/DWriteTypeface.cs
new file mode 100644
index 0000000000..83256183a8
--- /dev/null
+++ b/src/Windows/Avalonia.Direct2D1/Media/DWriteTypeface.cs
@@ -0,0 +1,111 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using Avalonia.Media;
+using Avalonia.Media.Fonts;
+using HarfBuzzSharp;
+using SharpDX.DirectWrite;
+
+namespace Avalonia.Direct2D1.Media
+{
+ internal class DWriteTypeface : IPlatformTypeface
+ {
+ private bool _isDisposed;
+
+ public DWriteTypeface(SharpDX.DirectWrite.Font font)
+ {
+ DWFont = font;
+
+ FontFace = new FontFace(DWFont).QueryInterface();
+
+ Weight = (Avalonia.Media.FontWeight)DWFont.Weight;
+
+ Style = (Avalonia.Media.FontStyle)DWFont.Style;
+
+ Stretch = (Avalonia.Media.FontStretch)DWFont.Stretch;
+ }
+
+ private static uint SwapBytes(uint x)
+ {
+ x = (x >> 16) | (x << 16);
+
+ return ((x & 0xFF00FF00) >> 8) | ((x & 0x00FF00FF) << 8);
+ }
+
+ public SharpDX.DirectWrite.Font DWFont { get; }
+
+ public FontFace1 FontFace { get; }
+
+ public Face Face { get; }
+
+ public HarfBuzzSharp.Font Font { get; }
+
+ public Avalonia.Media.FontWeight Weight { get; }
+
+ public Avalonia.Media.FontStyle Style { get; }
+
+ public Avalonia.Media.FontStretch Stretch { get; }
+
+ private void Dispose(bool disposing)
+ {
+ if (_isDisposed)
+ {
+ return;
+ }
+
+ _isDisposed = true;
+
+ if (!disposing)
+ {
+ return;
+ }
+
+ Font?.Dispose();
+ Face?.Dispose();
+ FontFace?.Dispose();
+ }
+
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ public bool TryGetTable(OpenTypeTag tag, out ReadOnlyMemory table)
+ {
+ table = default;
+
+ var dwTag = (int)SwapBytes((uint)tag);
+
+ if (FontFace.TryGetFontTable(dwTag, out var tableData, out _))
+ {
+ table = tableData.ToArray();
+
+ return true;
+ }
+
+ return false;
+ }
+
+ public bool TryGetStream([NotNullWhen(true)] out Stream stream)
+ {
+ stream = default;
+
+ var files = FontFace.GetFiles();
+
+ if (files.Length > 0)
+ {
+ var file = files[0];
+
+ var referenceKey = file.GetReferenceKey();
+
+ stream = referenceKey.ToDataStream();
+
+ return true;
+ }
+
+ return false;
+ }
+ }
+}
+
diff --git a/tests/Avalonia.Base.UnitTests/Media/Fonts/Tables/Colr/DeltaSetIndexMapTests.cs b/tests/Avalonia.Base.UnitTests/Media/Fonts/Tables/Colr/DeltaSetIndexMapTests.cs
new file mode 100644
index 0000000000..16da505ec8
--- /dev/null
+++ b/tests/Avalonia.Base.UnitTests/Media/Fonts/Tables/Colr/DeltaSetIndexMapTests.cs
@@ -0,0 +1,243 @@
+using System;
+using Avalonia.Media.Fonts.Tables.Colr;
+using Xunit;
+
+namespace Avalonia.Base.UnitTests.Media.Fonts.Tables.Colr
+{
+ public class DeltaSetIndexMapTests
+ {
+ [Fact]
+ public void Load_Format0_WithValidData_ShouldSucceed()
+ {
+ // DeltaSetIndexMap Format 0:
+ // uint8 format = 0
+ // uint8 entryFormat = 0x00 (1 byte inner, 1 byte outer)
+ // uint16 mapCount = 3
+ // Map data: [outer0, inner0], [outer1, inner1], [outer2, inner2]
+ var data = new byte[]
+ {
+ 0, // format = 0
+ 0x00, // entryFormat = 0x00 (1 byte each)
+ 0, 3, // mapCount = 3 (uint16 big-endian)
+ // Map entries (outer, inner):
+ 0, 0, // entry 0: outer=0, inner=0
+ 0, 1, // entry 1: outer=0, inner=1
+ 1, 0 // entry 2: outer=1, inner=0
+ };
+
+ var map = DeltaSetIndexMap.Load(data, 0);
+
+ Assert.NotNull(map);
+ Assert.Equal(0, map!.Format);
+ Assert.Equal(3u, map.MapCount);
+ }
+
+ [Fact]
+ public void Load_Format1_WithValidData_ShouldSucceed()
+ {
+ // DeltaSetIndexMap Format 1:
+ // uint8 format = 1
+ // uint8 entryFormat = 0x11 (2 bytes inner, 2 bytes outer)
+ // uint32 mapCount = 2
+ var data = new byte[]
+ {
+ 1, // format = 1
+ 0x11, // entryFormat = 0x11 (2 bytes each)
+ 0, 0, 0, 2, // mapCount = 2 (uint32 big-endian)
+ // Map entries (outer, inner):
+ 0, 5, 0, 10, // entry 0: outer=5, inner=10
+ 0, 6, 0, 20 // entry 1: outer=6, inner=20
+ };
+
+ var map = DeltaSetIndexMap.Load(data, 0);
+
+ Assert.NotNull(map);
+ Assert.Equal(1, map!.Format);
+ Assert.Equal(2u, map.MapCount);
+ }
+
+ [Fact]
+ public void TryGetDeltaSetIndex_WithFormat0_ShouldReturnCorrectIndices()
+ {
+ var data = new byte[]
+ {
+ 0, // format = 0
+ 0x00, // entryFormat = 0x00
+ 0, 3, // mapCount = 3
+ 0, 0, // entry 0: outer=0, inner=0
+ 0, 1, // entry 1: outer=0, inner=1
+ 1, 0 // entry 2: outer=1, inner=0
+ };
+
+ var map = DeltaSetIndexMap.Load(data, 0);
+ Assert.NotNull(map);
+
+ // Test entry 0
+ Assert.True(map!.TryGetDeltaSetIndex(0, out var outer0, out var inner0));
+ Assert.Equal(0, outer0);
+ Assert.Equal(0, inner0);
+
+ // Test entry 1
+ Assert.True(map.TryGetDeltaSetIndex(1, out var outer1, out var inner1));
+ Assert.Equal(0, outer1);
+ Assert.Equal(1, inner1);
+
+ // Test entry 2
+ Assert.True(map.TryGetDeltaSetIndex(2, out var outer2, out var inner2));
+ Assert.Equal(1, outer2);
+ Assert.Equal(0, inner2);
+ }
+
+ [Fact]
+ public void TryGetDeltaSetIndex_WithFormat1And2ByteEntries_ShouldReturnCorrectIndices()
+ {
+ var data = new byte[]
+ {
+ 1, // format = 1
+ 0x11, // entryFormat = 0x11 (2 bytes each)
+ 0, 0, 0, 2, // mapCount = 2
+ 0, 5, 0, 10, // entry 0: outer=5, inner=10
+ 0, 6, 0, 20 // entry 1: outer=6, inner=20
+ };
+
+ var map = DeltaSetIndexMap.Load(data, 0);
+ Assert.NotNull(map);
+
+ // Test entry 0
+ Assert.True(map!.TryGetDeltaSetIndex(0, out var outer0, out var inner0));
+ Assert.Equal(5, outer0);
+ Assert.Equal(10, inner0);
+
+ // Test entry 1
+ Assert.True(map.TryGetDeltaSetIndex(1, out var outer1, out var inner1));
+ Assert.Equal(6, outer1);
+ Assert.Equal(20, inner1);
+ }
+
+ [Fact]
+ public void TryGetDeltaSetIndex_WithOutOfRangeIndex_ShouldReturnFalse()
+ {
+ var data = new byte[]
+ {
+ 0, // format = 0
+ 0x00, // entryFormat = 0x00
+ 0, 2, // mapCount = 2
+ 0, 0,
+ 0, 1
+ };
+
+ var map = DeltaSetIndexMap.Load(data, 0);
+ Assert.NotNull(map);
+
+ // Try to access index 2, which is out of range (map has only 2 entries: 0 and 1)
+ Assert.False(map!.TryGetDeltaSetIndex(2, out _, out _));
+ }
+
+ [Fact]
+ public void Load_WithInvalidFormat_ShouldReturnNull()
+ {
+ var data = new byte[]
+ {
+ 2, // format = 2 (invalid)
+ 0x00,
+ 0, 1
+ };
+
+ var map = DeltaSetIndexMap.Load(data, 0);
+
+ Assert.Null(map);
+ }
+
+ [Fact]
+ public void Load_WithInsufficientData_ShouldReturnNull()
+ {
+ var data = new byte[] { 0 }; // Only format byte, missing entryFormat and mapCount
+
+ var map = DeltaSetIndexMap.Load(data, 0);
+
+ Assert.Null(map);
+ }
+
+ [Fact]
+ public void TryGetDeltaSetIndex_WithMixedEntryFormat_ShouldReturnCorrectIndices()
+ {
+ // entryFormat = 0x10 means 1-byte inner, 2-byte outer (3 bytes per entry)
+ var data = new byte[]
+ {
+ 0, // format = 0
+ 0x10, // entryFormat = 0x10
+ 0, 2, // mapCount = 2
+ 0, 5, 10, // entry 0: outer=5 (2 bytes), inner=10 (1 byte)
+ 0, 6, 20 // entry 1: outer=6 (2 bytes), inner=20 (1 byte)
+ };
+
+ var map = DeltaSetIndexMap.Load(data, 0);
+ Assert.NotNull(map);
+
+ // Test entry 0
+ Assert.True(map!.TryGetDeltaSetIndex(0, out var outer0, out var inner0));
+ Assert.Equal(5, outer0);
+ Assert.Equal(10, inner0);
+
+ // Test entry 1
+ Assert.True(map.TryGetDeltaSetIndex(1, out var outer1, out var inner1));
+ Assert.Equal(6, outer1);
+ Assert.Equal(20, inner1);
+ }
+
+ [Fact]
+ public void Load_ShouldReturnSameInstance_WhenCalledMultipleTimes()
+ {
+ // This test verifies that the same byte array always produces consistent results
+ // The DeltaSetIndexMap.Load method should be deterministic
+ var data = new byte[]
+ {
+ 0, // format = 0
+ 0x00, // entryFormat = 0x00
+ 0, 2, // mapCount = 2
+ 0, 0,
+ 0, 1
+ };
+
+ var map1 = DeltaSetIndexMap.Load(data, 0);
+ var map2 = DeltaSetIndexMap.Load(data, 0);
+
+ Assert.NotNull(map1);
+ Assert.NotNull(map2);
+
+ // Verify both instances return the same data
+ Assert.True(map1!.TryGetDeltaSetIndex(0, out var outer1, out var inner1));
+ Assert.True(map2!.TryGetDeltaSetIndex(0, out var outer2, out var inner2));
+
+ Assert.Equal(outer1, outer2);
+ Assert.Equal(inner1, inner2);
+ }
+
+ [Fact]
+ public void TryGetDeltaSetIndex_WithLargeIndices_ShouldHandleCorrectly()
+ {
+ // Test with larger outer/inner indices to verify 2-byte reading
+ var data = new byte[]
+ {
+ 0, // format = 0
+ 0x11, // entryFormat = 0x11 (2 bytes each)
+ 0, 2, // mapCount = 2
+ 0x01, 0x00, 0x02, 0x00, // entry 0: outer=256, inner=512
+ 0xFF, 0xFF, 0xFF, 0xFF // entry 1: outer=65535, inner=65535
+ };
+
+ var map = DeltaSetIndexMap.Load(data, 0);
+ Assert.NotNull(map);
+
+ // Test entry 0
+ Assert.True(map!.TryGetDeltaSetIndex(0, out var outer0, out var inner0));
+ Assert.Equal(256, outer0);
+ Assert.Equal(512, inner0);
+
+ // Test entry 1
+ Assert.True(map.TryGetDeltaSetIndex(1, out var outer1, out var inner1));
+ Assert.Equal(65535, outer1);
+ Assert.Equal(65535, inner1);
+ }
+ }
+}
diff --git a/tests/Avalonia.RenderTests/Assets/NotoColorEmoji-Regular.ttf b/tests/Avalonia.RenderTests/Assets/NotoColorEmoji-Regular.ttf
new file mode 100644
index 0000000000..5d7a86f3c6
Binary files /dev/null and b/tests/Avalonia.RenderTests/Assets/NotoColorEmoji-Regular.ttf differ
diff --git a/tests/Avalonia.Skia.UnitTests/Media/ColrTableTests.cs b/tests/Avalonia.Skia.UnitTests/Media/ColrTableTests.cs
new file mode 100644
index 0000000000..8e67e35c12
--- /dev/null
+++ b/tests/Avalonia.Skia.UnitTests/Media/ColrTableTests.cs
@@ -0,0 +1,712 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia.Media;
+using Avalonia.Media.Fonts.Tables.Colr;
+using Avalonia.UnitTests;
+using Xunit;
+using GradientStop = Avalonia.Media.Fonts.Tables.Colr.GradientStop;
+
+namespace Avalonia.Skia.UnitTests.Media
+{
+ public class ColrTableTests
+ {
+ [Fact]
+ public void Should_Load_COLR_Table_If_Present()
+ {
+ using (Start())
+ {
+ var typeface = new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Noto Color Emoji");
+
+ // Try to get glyph typeface - may or may not have COLR
+ if (FontManager.Current.TryGetGlyphTypeface(typeface, out var glyphTypeface))
+ {
+ Assert.True(ColrTable.TryLoad(glyphTypeface, out var colrTable));
+
+ Assert.True(colrTable.Version <= 1);
+ Assert.True(colrTable.BaseGlyphCount >= 0);
+ }
+ }
+ }
+
+ [Fact]
+ public void Should_Load_CPAL_Table_If_Present()
+ {
+ using (Start())
+ {
+ var typeface = new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Noto Color Emoji");
+
+ if (FontManager.Current.TryGetGlyphTypeface(typeface, out var glyphTypeface))
+ {
+ Assert.True(CpalTable.TryLoad(glyphTypeface, out var cpalTable));
+
+ Assert.True(cpalTable.Version <= 1);
+ Assert.True(cpalTable.PaletteCount > 0);
+ Assert.True(cpalTable.PaletteEntryCount > 0);
+ }
+ }
+ }
+
+ [Fact]
+ public void Should_Return_Null_For_Missing_COLR_Table()
+ {
+ using (Start())
+ {
+ // Using a font that definitely doesn't have COLR
+ var typeface = new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Noto Mono");
+
+ if (FontManager.Current.TryGetGlyphTypeface(typeface, out var glyphTypeface))
+ {
+ Assert.False(ColrTable.TryLoad(glyphTypeface, out _));
+ }
+ }
+ }
+
+ [Fact]
+ public void Should_Get_Layers_For_Color_Glyph()
+ {
+ using (Start())
+ {
+ var typeface = new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Noto Color Emoji");
+
+ if (FontManager.Current.TryGetGlyphTypeface(typeface, out var glyphTypeface))
+ {
+ Assert.True(ColrTable.TryLoad(glyphTypeface, out var colrTable));
+ Assert.True(CpalTable.TryLoad(glyphTypeface, out var cpalTable));
+
+ // Try to get layers for a hypothetical color glyph
+ ushort testGlyphId = 0;
+
+ if (colrTable.TryGetBaseGlyphRecord(testGlyphId, out var baseRecord))
+ {
+ Assert.True(baseRecord.NumLayers > 0);
+
+ // Get each layer
+ for (int i = 0; i < baseRecord.NumLayers; i++)
+ {
+ if (colrTable.TryGetLayerRecord(baseRecord.FirstLayerIndex + i, out var layer))
+ {
+ // Get the color for this layer
+ if (cpalTable.TryGetColor(0, layer.PaletteIndex, out var color))
+ {
+ Assert.NotEqual(default, color);
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+
+
+
+ [Fact]
+ public void Should_Verify_Transform_Coordinate_Conversion_Preserves_Final_Bounds()
+ {
+ using (Start())
+ {
+ var typeface = new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Noto Color Emoji");
+
+ if (!FontManager.Current.TryGetGlyphTypeface(typeface, out var glyphTypeface))
+ {
+ return;
+ }
+
+ Assert.True(ColrTable.TryLoad(glyphTypeface, out var colrTable));
+ Assert.True(CpalTable.TryLoad(glyphTypeface, out var cpalTable));
+ Assert.True(colrTable.HasV1Data);
+
+ // Test with clock emoji 🕐 (U+1F550)
+ const int clockCodepoint = 0x1F550;
+ var clockGlyphId = glyphTypeface.CharacterToGlyphMap[clockCodepoint];
+
+ if (clockGlyphId == 0 || !colrTable.TryGetBaseGlyphV1Record(clockGlyphId, out var baseGlyphRecord))
+ {
+ return;
+ }
+
+ if (!glyphTypeface.PlatformTypeface.TryGetTable(ColrTable.Tag, out var colrData))
+ {
+ return;
+ }
+
+ var context = new ColrContext(glyphTypeface, colrTable, cpalTable, 0);
+ var decycler = new PaintDecycler();
+
+ var rootPaintOffset = colrTable.GetAbsolutePaintOffset(baseGlyphRecord.PaintOffset);
+
+ if (!PaintParser.TryParse(colrData.Span, rootPaintOffset, in context, in decycler, out var rootPaint))
+ {
+ return;
+ }
+
+ // Resolve the paint (this applies coordinate space conversion)
+ var resolvedPaint = PaintResolver.ResolvePaint(rootPaint, in context);
+
+ // Create a bounds tracking painter to measure actual rendered bounds
+ var boundsPainter = new BoundsTrackingPainter(glyphTypeface);
+
+ // Traverse the paint graph
+ PaintTraverser.Traverse(resolvedPaint, boundsPainter, Matrix.Identity);
+
+ var finalBounds = boundsPainter.GetFinalBounds();
+
+ // Verify bounds are reasonable (not collapsed or inverted)
+ Assert.True(finalBounds.Width > 0, "Final bounds should have positive width");
+ Assert.True(finalBounds.Height > 0, "Final bounds should have positive height");
+
+ // The bounds should be roughly square for a clock face (allow some tolerance)
+ var aspectRatio = finalBounds.Width / finalBounds.Height;
+ Assert.True(aspectRatio > 0.5 && aspectRatio < 2.0,
+ $"Clock emoji aspect ratio should be roughly square, got {aspectRatio:F2}");
+
+ // Verify that transforms didn't cause bounds to become excessively large or small
+ // Typical emoji glyph bounds in font units are in the range of 0-2048
+ Assert.True(finalBounds.Width < 10000, "Bounds width should not be excessively large");
+ Assert.True(finalBounds.Height < 10000, "Bounds height should not be excessively large");
+ Assert.True(finalBounds.Width > 10, "Bounds width should not be too small");
+ Assert.True(finalBounds.Height > 10, "Bounds height should not be too small");
+
+ System.Diagnostics.Debug.WriteLine($"Clock emoji 🕐 final bounds: {finalBounds}");
+ System.Diagnostics.Debug.WriteLine($" Width: {finalBounds.Width:F2}, Height: {finalBounds.Height:F2}");
+ System.Diagnostics.Debug.WriteLine($" Aspect ratio: {aspectRatio:F2}");
+ System.Diagnostics.Debug.WriteLine($" Total glyphs rendered: {boundsPainter.GlyphCount}");
+ }
+ }
+
+ [Fact]
+ public void Should_Verify_Bridge_At_Night_Emoji_Transform_Coordinate_Conversion_Preserves_Final_Bounds()
+ {
+ using (Start())
+ {
+ var typeface = new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Noto Color Emoji");
+
+ if (!FontManager.Current.TryGetGlyphTypeface(typeface, out var glyphTypeface))
+ {
+ return;
+ }
+
+ Assert.True(ColrTable.TryLoad(glyphTypeface, out var colrTable));
+ Assert.True(CpalTable.TryLoad(glyphTypeface, out var cpalTable));
+ Assert.True(colrTable.HasV1Data);
+
+ // Test with bridge at night emoji 🌉 (U+1F309)
+ const int bridgeCodepoint = 0x1F309;
+ var bridgeGlyphId = glyphTypeface.CharacterToGlyphMap[bridgeCodepoint];
+
+ if (bridgeGlyphId == 0 || !colrTable.TryGetBaseGlyphV1Record(bridgeGlyphId, out var baseGlyphRecord))
+ {
+ return;
+ }
+
+ if (!glyphTypeface.PlatformTypeface.TryGetTable(ColrTable.Tag, out var colrData))
+ {
+ return;
+ }
+
+ var context = new ColrContext(glyphTypeface, colrTable, cpalTable, 0);
+ var decycler = new PaintDecycler();
+
+ var rootPaintOffset = colrTable.GetAbsolutePaintOffset(baseGlyphRecord.PaintOffset);
+
+ if (!PaintParser.TryParse(colrData.Span, rootPaintOffset, in context, in decycler, out var rootPaint))
+ {
+ return;
+ }
+
+ // Resolve the paint (this applies coordinate space conversion)
+ var resolvedPaint = PaintResolver.ResolvePaint(rootPaint, in context);
+
+ // Create a bounds tracking painter to measure actual rendered bounds
+ var boundsPainter = new BoundsTrackingPainter(glyphTypeface);
+
+ // Traverse the paint graph
+ PaintTraverser.Traverse(resolvedPaint, boundsPainter, Matrix.Identity);
+
+ var finalBounds = boundsPainter.GetFinalBounds();
+
+ // Verify bounds are reasonable (not collapsed or inverted)
+ Assert.True(finalBounds.Width > 0, "Final bounds should have positive width");
+ Assert.True(finalBounds.Height > 0, "Final bounds should have positive height");
+
+ // The bounds should be roughly square for an emoji (allow some tolerance)
+ var aspectRatio = finalBounds.Width / finalBounds.Height;
+ Assert.True(aspectRatio > 0.5 && aspectRatio < 2.0,
+ $"Bridge at night emoji aspect ratio should be roughly square, got {aspectRatio:F2}");
+
+ // Verify that transforms didn't cause bounds to become excessively large or small
+ // Typical emoji glyph bounds in font units are in the range of 0-2048
+ Assert.True(finalBounds.Width < 10000, "Bounds width should not be excessively large");
+ Assert.True(finalBounds.Height < 10000, "Bounds height should not be excessively large");
+ Assert.True(finalBounds.Width > 10, "Bounds width should not be too small");
+ Assert.True(finalBounds.Height > 10, "Bounds height should not be too small");
+
+ System.Diagnostics.Debug.WriteLine($"Bridge at night emoji 🌉 (U+{bridgeCodepoint:X4}, GlyphID: {bridgeGlyphId}) final bounds: {finalBounds}");
+ System.Diagnostics.Debug.WriteLine($" Width: {finalBounds.Width:F2}, Height: {finalBounds.Height:F2}");
+ System.Diagnostics.Debug.WriteLine($" Aspect ratio: {aspectRatio:F2}");
+ System.Diagnostics.Debug.WriteLine($" Total glyphs rendered: {boundsPainter.GlyphCount}");
+ }
+ }
+
+ [Fact]
+ public void Should_Analyze_Bridge_At_Night_Emoji_Paint_Graph_And_Verify_Transform_Accumulation()
+ {
+ using (Start())
+ {
+ var typeface = new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Noto Color Emoji");
+
+ if (!FontManager.Current.TryGetGlyphTypeface(typeface, out var glyphTypeface))
+ {
+ return;
+ }
+
+ Assert.True(ColrTable.TryLoad(glyphTypeface, out var colrTable));
+ Assert.True(CpalTable.TryLoad(glyphTypeface, out var cpalTable));
+ Assert.True(colrTable.HasV1Data);
+
+ // Parse cmap to find glyph ID for U+1F309 (🌉 Bridge at Night)
+ const int bridgeCodepoint = 0x1F309;
+ var bridgeGlyphId = glyphTypeface.CharacterToGlyphMap[bridgeCodepoint];
+ bool foundGlyph = bridgeGlyphId != 0;
+
+ if (!foundGlyph)
+ {
+ return;
+ }
+
+ // Verify this glyph has a COLR v1 record
+ if (!colrTable.TryGetBaseGlyphV1Record(bridgeGlyphId, out var baseGlyphRecord))
+ {
+ return;
+ }
+
+ // Get the COLR data for parsing
+ if (!glyphTypeface.PlatformTypeface.TryGetTable(ColrTable.Tag, out var colrData))
+ {
+ return;
+ }
+
+ // Create context for paint parsing
+ var context = new ColrContext(
+ glyphTypeface,
+ colrTable,
+ cpalTable,
+ 0);
+
+ var decycler = new PaintDecycler();
+
+ // Parse the root paint
+ var rootPaintOffset = colrTable.GetAbsolutePaintOffset(baseGlyphRecord.PaintOffset);
+
+ if (!PaintParser.TryParse(colrData.Span, rootPaintOffset, in context, in decycler, out var rootPaint))
+ {
+ Assert.Fail("Failed to parse root paint for bridge at night emoji");
+ return;
+ }
+
+ // Resolve the paint (apply deltas, normalize)
+ var resolvedPaint = PaintResolver.ResolvePaint(rootPaint, in context);
+
+ // Create a tracking painter to analyze the paint graph
+ var trackingPainter = new TransformTrackingPainter();
+
+ // Traverse the paint graph
+ PaintTraverser.Traverse(resolvedPaint, trackingPainter, Matrix.Identity);
+
+ // Analyze the results
+ Assert.NotEmpty(trackingPainter.TransformStack);
+
+ // Output diagnostic information
+ System.Diagnostics.Debug.WriteLine($"Bridge at night emoji 🌉 (U+{bridgeCodepoint:X4}, GlyphID: {bridgeGlyphId}) paint graph analysis:");
+ System.Diagnostics.Debug.WriteLine($" Total transforms encountered: {trackingPainter.AllTransforms.Count}");
+ System.Diagnostics.Debug.WriteLine($" Maximum transform stack depth: {trackingPainter.MaxStackDepth}");
+ System.Diagnostics.Debug.WriteLine($" Total glyphs encountered: {trackingPainter.GlyphCount}");
+ System.Diagnostics.Debug.WriteLine($" Total fills encountered: {trackingPainter.FillCount}");
+ System.Diagnostics.Debug.WriteLine($" Total clips encountered: {trackingPainter.ClipCount}");
+ System.Diagnostics.Debug.WriteLine($" Total layers encountered: {trackingPainter.LayerCount}");
+
+ if (trackingPainter.AllTransforms.Count > 0)
+ {
+ System.Diagnostics.Debug.WriteLine("\n Transform stack at each level:");
+ for (int i = 0; i < trackingPainter.TransformStack.Count && i < 10; i++)
+ {
+ var m = trackingPainter.TransformStack[i];
+ System.Diagnostics.Debug.WriteLine(
+ $" Level {i}: [{m.M11:F4}, {m.M12:F4}, {m.M21:F4}, {m.M22:F4}, {m.M31:F4}, {m.M32:F4}]");
+ }
+
+ // Verify transforms were properly accumulated if any exist
+ for (int i = 1; i < trackingPainter.TransformStack.Count; i++)
+ {
+ var current = trackingPainter.TransformStack[i];
+ var previous = trackingPainter.TransformStack[i - 1];
+
+ // Current transform should be different from previous (transforms accumulated)
+ // Unless the transform was identity
+ var isIdentity = current.M11 == 1 && current.M12 == 0 &&
+ current.M21 == 0 && current.M22 == 1 &&
+ current.M31 == 0 && current.M32 == 0;
+
+ if (!isIdentity && i > 0)
+ {
+ // Verify that transform changed
+ bool transformChanged =
+ Math.Abs(current.M11 - previous.M11) > 0.0001 ||
+ Math.Abs(current.M12 - previous.M12) > 0.0001 ||
+ Math.Abs(current.M21 - previous.M21) > 0.0001 ||
+ Math.Abs(current.M22 - previous.M22) > 0.0001 ||
+ Math.Abs(current.M31 - previous.M31) > 0.0001 ||
+ Math.Abs(current.M32 - previous.M32) > 0.0001;
+
+ Assert.True(transformChanged,
+ $"Transform at level {i} should differ from level {i - 1} when accumulating non-identity transforms");
+ }
+ }
+ }
+ }
+ }
+
+ [Fact]
+ public void Should_Debug_Bridge_At_Night_Emoji_Paint_Operations_In_Detail()
+ {
+ using (Start())
+ {
+ var typeface = new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Noto Color Emoji");
+
+ if (!FontManager.Current.TryGetGlyphTypeface(typeface, out var glyphTypeface))
+ {
+ return;
+ }
+
+ Assert.True(ColrTable.TryLoad(glyphTypeface, out var colrTable));
+ Assert.True(CpalTable.TryLoad(glyphTypeface, out var cpalTable));
+ Assert.True(colrTable.HasV1Data);
+
+ // Test with bridge at night emoji 🌉 (U+1F309)
+ const int bridgeCodepoint = 0x1F309;
+ var bridgeGlyphId = glyphTypeface.CharacterToGlyphMap[bridgeCodepoint];
+
+ if (bridgeGlyphId == 0 || !colrTable.TryGetBaseGlyphV1Record(bridgeGlyphId, out var baseGlyphRecord))
+ {
+ return;
+ }
+
+ if (!glyphTypeface.PlatformTypeface.TryGetTable(ColrTable.Tag, out var colrData))
+ {
+ return;
+ }
+
+ var context = new ColrContext(glyphTypeface, colrTable, cpalTable, 0);
+ var decycler = new PaintDecycler();
+
+ var rootPaintOffset = colrTable.GetAbsolutePaintOffset(baseGlyphRecord.PaintOffset);
+
+ if (!PaintParser.TryParse(colrData.Span, rootPaintOffset, in context, in decycler, out var rootPaint))
+ {
+ return;
+ }
+
+ // Resolve the paint
+ var resolvedPaint = PaintResolver.ResolvePaint(rootPaint, in context);
+
+ // Create a detailed diagnostic painter
+ var diagnosticPainter = new DetailedDiagnosticPainter(glyphTypeface);
+
+ // Traverse the paint graph
+ PaintTraverser.Traverse(resolvedPaint, diagnosticPainter, Matrix.Identity);
+
+ // Output all the collected diagnostic information
+ System.Diagnostics.Debug.WriteLine($"\n=== Bridge at Night Emoji 🌉 (U+{bridgeCodepoint:X4}, GlyphID: {bridgeGlyphId}) Detailed Paint Operations ===\n");
+
+ foreach (var op in diagnosticPainter.Operations)
+ {
+ System.Diagnostics.Debug.WriteLine(op);
+ }
+
+ System.Diagnostics.Debug.WriteLine($"\n=== Summary ===");
+ System.Diagnostics.Debug.WriteLine($"Total operations: {diagnosticPainter.Operations.Count}");
+ System.Diagnostics.Debug.WriteLine($"Glyphs rendered: {diagnosticPainter.Operations.Count(o => o.Contains("Glyph"))}");
+ System.Diagnostics.Debug.WriteLine($"Transforms: {diagnosticPainter.Operations.Count(o => o.Contains("PushTransform"))}");
+ System.Diagnostics.Debug.WriteLine($"Fills: {diagnosticPainter.Operations.Count(o => o.Contains("Fill"))}");
+ System.Diagnostics.Debug.WriteLine($"Clips: {diagnosticPainter.Operations.Count(o => o.Contains("Clip"))}");
+ System.Diagnostics.Debug.WriteLine($"Layers: {diagnosticPainter.Operations.Count(o => o.Contains("Layer"))}");
+
+ // Verify we got some operations
+ Assert.NotEmpty(diagnosticPainter.Operations);
+ }
+ }
+
+ ///
+ /// Detailed diagnostic painter that logs every operation with full details
+ ///
+ private class DetailedDiagnosticPainter : IColorPainter
+ {
+ private readonly GlyphTypeface _glyphTypeface;
+ private readonly Stack _transforms = new Stack();
+ private int _operationIndex = 0;
+ public List Operations { get; } = new List();
+
+ public DetailedDiagnosticPainter(GlyphTypeface glyphTypeface)
+ {
+ _glyphTypeface = glyphTypeface;
+ _transforms.Push(Matrix.Identity);
+ }
+
+ private string FormatMatrix(Matrix m)
+ {
+ return $"[{m.M11:F4}, {m.M12:F4}, {m.M21:F4}, {m.M22:F4}, {m.M31:F4}, {m.M32:F4}]";
+ }
+
+ private string FormatPoint(Point p)
+ {
+ return $"({p.X:F2}, {p.Y:F2})";
+ }
+
+ private string FormatRect(Rect r)
+ {
+ return $"({r.X:F2}, {r.Y:F2}, {r.Width:F2}, {r.Height:F2})";
+ }
+
+ public void PushTransform(Matrix transform)
+ {
+ var current = _transforms.Peek();
+ var accumulated = current * transform;
+ _transforms.Push(accumulated);
+
+ Operations.Add($"[{_operationIndex++}] PushTransform: {FormatMatrix(transform)}");
+ Operations.Add($" Accumulated: {FormatMatrix(accumulated)}");
+ }
+
+ public void PopTransform()
+ {
+ if (_transforms.Count > 1)
+ {
+ _transforms.Pop();
+ Operations.Add($"[{_operationIndex++}] PopTransform");
+ }
+ }
+
+ public void PushLayer(CompositeMode mode)
+ {
+ Operations.Add($"[{_operationIndex++}] PushLayer: Mode={mode}");
+ }
+
+ public void PopLayer()
+ {
+ Operations.Add($"[{_operationIndex++}] PopLayer");
+ }
+
+ public void PushClip(Rect clipBox)
+ {
+ Operations.Add($"[{_operationIndex++}] PushClip: {FormatRect(clipBox)}");
+ }
+
+ public void PopClip()
+ {
+ Operations.Add($"[{_operationIndex++}] PopClip");
+ }
+
+ public void FillSolid(Color color)
+ {
+ Operations.Add($"[{_operationIndex++}] FillSolid: Color=#{color.A:X2}{color.R:X2}{color.G:X2}{color.B:X2}");
+ }
+
+ public void FillLinearGradient(Point p0, Point p1, GradientStop[] stops, GradientSpreadMethod extend)
+ {
+ Operations.Add($"[{_operationIndex++}] FillLinearGradient: P0={FormatPoint(p0)}, P1={FormatPoint(p1)}, Stops={stops.Length}, Extend={extend}");
+ }
+
+ public void FillRadialGradient(Point c0, double r0, Point c1, double r1, GradientStop[] stops, GradientSpreadMethod extend)
+ {
+ Operations.Add($"[{_operationIndex++}] FillRadialGradient: C0={FormatPoint(c0)}, R0={r0:F2}, C1={FormatPoint(c1)}, R1={r1:F2}, Stops={stops.Length}, Extend={extend}");
+ }
+
+ public void FillConicGradient(Point center, double startAngle, double endAngle, GradientStop[] stops, GradientSpreadMethod extend)
+ {
+ Operations.Add($"[{_operationIndex++}] FillConicGradient: Center={FormatPoint(center)}, Start={startAngle:F2}°, End={endAngle:F2}°, Stops={stops.Length}, Extend={extend}");
+ }
+
+ public void Glyph(ushort glyphId)
+ {
+ var geometry = _glyphTypeface.GetGlyphOutline(glyphId, Matrix.Identity);
+ var bounds = geometry?.Bounds ?? default;
+ var transform = _transforms.Peek();
+ var transformedBounds = bounds.TransformToAABB(transform);
+
+ Operations.Add($"[{_operationIndex++}] Glyph: GlyphID={glyphId}");
+ Operations.Add($" Original Bounds: {FormatRect(bounds)}");
+ Operations.Add($" Current Transform: {FormatMatrix(transform)}");
+ Operations.Add($" Transformed Bounds: {FormatRect(transformedBounds)}");
+ }
+
+ public void ColrGlyph(ushort glyphId)
+ {
+ Operations.Add($"[{_operationIndex++}] ColrGlyph: GlyphID={glyphId}");
+ }
+ }
+
+ ///
+ /// Custom painter that tracks transform accumulation during paint graph traversal
+ ///
+ private class TransformTrackingPainter : IColorPainter
+ {
+ public List TransformStack { get; } = new List();
+ public List AllTransforms { get; } = new List();
+ public int MaxStackDepth { get; private set; }
+ public int GlyphCount { get; private set; }
+ public int FillCount { get; private set; }
+ public int ClipCount { get; private set; }
+ public int LayerCount { get; private set; }
+
+ private readonly Stack _activeTransforms = new Stack();
+
+ public TransformTrackingPainter()
+ {
+ _activeTransforms.Push(Matrix.Identity);
+ TransformStack.Add(Matrix.Identity);
+ }
+
+ public void PushTransform(Matrix transform)
+ {
+ var current = _activeTransforms.Peek();
+ var accumulated = current * transform;
+ _activeTransforms.Push(accumulated);
+ TransformStack.Add(accumulated);
+ AllTransforms.Add(transform);
+
+ MaxStackDepth = Math.Max(MaxStackDepth, _activeTransforms.Count);
+ }
+
+ public void PopTransform()
+ {
+ if (_activeTransforms.Count > 1)
+ {
+ _activeTransforms.Pop();
+ }
+ }
+
+ public void PushLayer(CompositeMode mode)
+ {
+ LayerCount++;
+ }
+
+ public void PopLayer()
+ {
+ }
+
+ public void PushClip(Rect clipBox)
+ {
+ ClipCount++;
+ }
+
+ public void PopClip()
+ {
+ }
+
+ public void FillSolid(Color color)
+ {
+ FillCount++;
+ }
+
+ public void FillLinearGradient(Point p0, Point p1, GradientStop[] stops, GradientSpreadMethod extend)
+ {
+ FillCount++;
+ }
+
+ public void FillRadialGradient(Point c0, double r0, Point c1, double r1, GradientStop[] stops, GradientSpreadMethod extend)
+ {
+ FillCount++;
+ }
+
+ public void FillConicGradient(Point center, double startAngle, double endAngle, GradientStop[] stops, GradientSpreadMethod extend)
+ {
+ FillCount++;
+ }
+
+ public void Glyph(ushort glyphId)
+ {
+ GlyphCount++;
+ }
+
+ public void ColrGlyph(ushort glyphId)
+ {
+ GlyphCount++;
+ }
+ }
+
+ ///
+ /// Custom painter that tracks the final bounds of all rendered content
+ ///
+ private class BoundsTrackingPainter : IColorPainter
+ {
+ private readonly GlyphTypeface _glyphTypeface;
+ private readonly Stack _transforms = new Stack();
+ private Rect _bounds = default;
+
+ public int GlyphCount { get; private set; }
+
+ public BoundsTrackingPainter(GlyphTypeface glyphTypeface)
+ {
+ _glyphTypeface = glyphTypeface;
+ _transforms.Push(Matrix.Identity);
+ }
+
+ public Rect GetFinalBounds() => _bounds;
+
+ public void PushTransform(Matrix transform)
+ {
+ var current = _transforms.Peek();
+ _transforms.Push(current * transform);
+ }
+
+ public void PopTransform()
+ {
+ if (_transforms.Count > 1)
+ {
+ _transforms.Pop();
+ }
+ }
+
+ public void PushLayer(CompositeMode mode) { }
+ public void PopLayer() { }
+ public void PushClip(Rect clipBox) { }
+ public void PopClip() { }
+ public void FillSolid(Color color) { }
+ public void FillLinearGradient(Point p0, Point p1, GradientStop[] stops, GradientSpreadMethod extend) { }
+ public void FillRadialGradient(Point c0, double r0, Point c1, double r1, GradientStop[] stops, GradientSpreadMethod extend) { }
+ public void FillConicGradient(Point center, double startAngle, double endAngle, GradientStop[] stops, GradientSpreadMethod extend) { }
+
+ public void Glyph(ushort glyphId)
+ {
+ GlyphCount++;
+
+ // Get glyph outline bounds
+ var geometry = _glyphTypeface.GetGlyphOutline(glyphId, Matrix.Identity);
+ if (geometry != null)
+ {
+ var glyphBounds = geometry.Bounds;
+
+ // Transform the bounds by current accumulated transform
+ var transform = _transforms.Peek();
+ var transformedBounds = glyphBounds.TransformToAABB(transform);
+
+ // Union with accumulated bounds
+ _bounds = _bounds.IsEmpty() ? transformedBounds : _bounds.Union(transformedBounds);
+ }
+ }
+
+ public void ColrGlyph(ushort glyphId)
+ {
+ GlyphCount++;
+ }
+ }
+
+ private static IDisposable Start()
+ {
+ var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface);
+
+ return disposable;
+ }
+ }
+}