Browse Source

Merge 912db0e8f4 into 658afb8717

pull/20304/merge
Benedikt Stebner 15 hours ago
committed by GitHub
parent
commit
05c9085f71
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      external/Numerge
  2. 185
      samples/Sandbox/MainWindow.axaml.cs
  3. BIN
      samples/Sandbox/NotoColorEmoji-Regular.ttf
  4. 1
      samples/Sandbox/Program.cs
  5. 18
      samples/Sandbox/Sandbox.csproj
  6. BIN
      samples/Sandbox/noto-glyf_colr_1.ttf
  7. BIN
      samples/Sandbox/test_glyphs-glyf_colr_1_no_cliplist.ttf
  8. BIN
      samples/Sandbox/twemoji-glyf_colr_1.ttf
  9. 37
      src/Avalonia.Base/Media/FontVariationSettings.cs
  10. 14
      src/Avalonia.Base/Media/Fonts/Tables/Cmap/CharacterToGlyphMap.cs
  11. 129
      src/Avalonia.Base/Media/Fonts/Tables/Cmap/CharacterToGlyphMapDictionary.cs
  12. 191
      src/Avalonia.Base/Media/Fonts/Tables/Colr/ColorGlyphV1Painter.cs
  13. 191
      src/Avalonia.Base/Media/Fonts/Tables/Colr/ColrContext.cs
  14. 765
      src/Avalonia.Base/Media/Fonts/Tables/Colr/ColrTable.cs
  15. 37
      src/Avalonia.Base/Media/Fonts/Tables/Colr/CompositeMode.cs
  16. 230
      src/Avalonia.Base/Media/Fonts/Tables/Colr/CpalTable.cs
  17. 166
      src/Avalonia.Base/Media/Fonts/Tables/Colr/Decycler.cs
  18. 247
      src/Avalonia.Base/Media/Fonts/Tables/Colr/DeltaSet.cs
  19. 238
      src/Avalonia.Base/Media/Fonts/Tables/Colr/DeltaSetIndexMap.cs
  20. 180
      src/Avalonia.Base/Media/Fonts/Tables/Colr/GradientBrushHelper.cs
  21. 118
      src/Avalonia.Base/Media/Fonts/Tables/Colr/IColorPainter.cs
  22. 170
      src/Avalonia.Base/Media/Fonts/Tables/Colr/ItemVariationStore.cs
  23. 1068
      src/Avalonia.Base/Media/Fonts/Tables/Colr/Paint.cs
  24. 47
      src/Avalonia.Base/Media/Fonts/Tables/Colr/PaintDecycler.cs
  25. 66
      src/Avalonia.Base/Media/Fonts/Tables/Colr/PaintParser.cs
  26. 203
      src/Avalonia.Base/Media/Fonts/Tables/Colr/PaintParsingHelpers.cs
  27. 653
      src/Avalonia.Base/Media/Fonts/Tables/Colr/PaintResolver.cs
  28. 74
      src/Avalonia.Base/Media/Fonts/Tables/Colr/PaintTraverser.cs
  29. 36
      src/Avalonia.Base/Media/Fonts/Tables/Colr/ResolvedPaint.cs
  30. 22
      src/Avalonia.Base/Media/Fonts/Tables/Glyf/CompositeFlags.cs
  31. 190
      src/Avalonia.Base/Media/Fonts/Tables/Glyf/CompositeGlyph.cs
  32. 556
      src/Avalonia.Base/Media/Fonts/Tables/Glyf/GlyfTable.cs
  33. 22
      src/Avalonia.Base/Media/Fonts/Tables/Glyf/GlyphComponent.cs
  34. 48
      src/Avalonia.Base/Media/Fonts/Tables/Glyf/GlyphDecycler.cs
  35. 79
      src/Avalonia.Base/Media/Fonts/Tables/Glyf/GlyphDescriptor.cs
  36. 18
      src/Avalonia.Base/Media/Fonts/Tables/Glyf/GlyphFlag.cs
  37. 275
      src/Avalonia.Base/Media/Fonts/Tables/Glyf/SimpleGlyph.cs
  38. 127
      src/Avalonia.Base/Media/Fonts/Tables/LocaTable.cs
  39. 16
      src/Avalonia.Base/Media/GlyphDrawingType.cs
  40. 286
      src/Avalonia.Base/Media/GlyphTypeface.cs
  41. 13
      src/Avalonia.Base/Media/IGlyphDrawing.cs
  42. 80
      src/Avalonia.Base/Utilities/ObjectPool.cs
  43. 111
      src/Windows/Avalonia.Direct2D1/Media/DWriteTypeface.cs
  44. 243
      tests/Avalonia.Base.UnitTests/Media/Fonts/Tables/Colr/DeltaSetIndexMapTests.cs
  45. BIN
      tests/Avalonia.RenderTests/Assets/NotoColorEmoji-Regular.ttf
  46. 712
      tests/Avalonia.Skia.UnitTests/Media/ColrTableTests.cs

1
external/Numerge

@ -0,0 +1 @@
Subproject commit 5530e1cbe9e105ff4ebc9da1f4af3253a8756754

185
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 };
}
}
/// <summary>
/// Custom control that renders a single glyph using GetGlyphDrawing for color glyphs
/// and GetGlyphOutline for outline glyphs.
/// </summary>
public class GlyphControl : Control
{
public static readonly StyledProperty<GlyphTypeface?> GlyphTypefaceProperty =
AvaloniaProperty.Register<GlyphControl, GlyphTypeface?>(nameof(GlyphTypeface));
public static readonly StyledProperty<ushort> GlyphIdProperty =
AvaloniaProperty.Register<GlyphControl, ushort>(nameof(GlyphId));
public static readonly StyledProperty<IBrush?> ForegroundProperty =
AvaloniaProperty.Register<GlyphControl, IBrush?>(nameof(Foreground), Brushes.Black);
static GlyphControl()
{
AffectsRender<GlyphControl>(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);
}
}
}
}
}
}
}

BIN
samples/Sandbox/NotoColorEmoji-Regular.ttf

Binary file not shown.

1
samples/Sandbox/Program.cs

@ -1,4 +1,5 @@
using Avalonia;
using Avalonia.Logging;
namespace Sandbox
{

18
samples/Sandbox/Sandbox.csproj

@ -8,11 +8,29 @@
<!-- <AvaloniaXamlIlDebuggerLaunch>true</AvaloniaXamlIlDebuggerLaunch>-->
</PropertyGroup>
<ItemGroup>
<None Remove="noto-glyf_colr_1.ttf" />
<None Remove="test_glyphs-glyf_colr_1_no_cliplist.ttf" />
<None Remove="twemoji-glyf_colr_1.ttf" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="noto-glyf_colr_1.ttf" />
<EmbeddedResource Include="test_glyphs-glyf_colr_1_no_cliplist.ttf" />
<EmbeddedResource Include="twemoji-glyf_colr_1.ttf" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Avalonia.Controls.ColorPicker\Avalonia.Controls.ColorPicker.csproj" />
<ProjectReference Include="..\..\src\Avalonia.Fonts.Inter\Avalonia.Fonts.Inter.csproj" />
<ProjectReference Include="..\..\src\Avalonia.Themes.Fluent\Avalonia.Themes.Fluent.csproj" />
<ProjectReference Include="..\..\src\Avalonia.Themes.Simple\Avalonia.Themes.Simple.csproj" />
<ProjectReference Include="..\..\src\Skia\Avalonia.Skia\Avalonia.Skia.csproj" />
</ItemGroup>
<ItemGroup>
<None Remove="NotoColorEmoji-Regular.ttf" />
<EmbeddedResource Include="NotoColorEmoji-Regular.ttf" />
</ItemGroup>
<Import Project="..\..\build\SampleApp.props" />

BIN
samples/Sandbox/noto-glyf_colr_1.ttf

Binary file not shown.

BIN
samples/Sandbox/test_glyphs-glyf_colr_1_no_cliplist.ttf

Binary file not shown.

BIN
samples/Sandbox/twemoji-glyf_colr_1.ttf

Binary file not shown.

37
src/Avalonia.Base/Media/FontVariationSettings.cs

@ -0,0 +1,37 @@
using System.Collections.Generic;
using Avalonia.Media.Fonts;
namespace Avalonia.Media
{
/// <summary>
/// Represents font variation settings, including normalized axis coordinates, optional variation instance index,
/// color palette selection, and pixel size for bitmap strikes.
/// </summary>
/// <remarks>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.</remarks>
public sealed record class FontVariationSettings
{
/// <summary>
/// Gets the normalized variation coordinates for each axis, derived from fvar/avar tables.
/// </summary>
public required IReadOnlyDictionary<OpenTypeTag, float> NormalizedCoordinates { get; init; }
/// <summary>
/// Gets the index of a predefined variation instance (optional).
/// If specified, NormalizedCoordinates represent that instance.
/// </summary>
public int? InstanceIndex { get; init; }
/// <summary>
/// Gets the color palette index for COLR/CPAL.
/// </summary>
public int PaletteIndex { get; init; }
/// <summary>
/// Gets the pixel size for bitmap strikes.
/// </summary>
public int PixelSize { get; init; }
}
}

14
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);
}
/// <summary>
/// Exposes the character-to-glyph map as an <see cref="IReadOnlyDictionary{TKey, TValue}"/>.
/// </summary>
/// <remarks>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.</remarks>
/// <returns>An <see cref="IReadOnlyDictionary{TKey, TValue}"/> view of this character-to-glyph map.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public IReadOnlyDictionary<int, ushort> AsReadOnlyDictionary()
{
return new CharacterToGlyphMapDictionary(this);
}
}
}

129
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
{
/// <summary>
/// Provides a read-only dictionary view over a <see cref="CharacterToGlyphMap"/>.
/// </summary>
internal sealed class CharacterToGlyphMapDictionary : IReadOnlyDictionary<int, ushort>
{
private readonly CharacterToGlyphMap _map;
private List<CodepointRange>? _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<int> 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<ushort> 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<KeyValuePair<int, ushort>> 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<int, ushort>(codePoint, glyphId);
}
}
}
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
private List<CodepointRange> GetRanges()
{
if (_cachedRanges == null)
{
_cachedRanges = new List<CodepointRange>();
var enumerator = _map.GetMappedRanges();
while (enumerator.MoveNext())
{
_cachedRanges.Add(enumerator.Current);
}
}
return _cachedRanges;
}
}
}

191
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
{
/// <summary>
/// Implements painting for COLR v1 glyphs using Avalonia's DrawingContext.
/// </summary>
internal sealed class ColorGlyphV1Painter : IColorPainter
{
private readonly DrawingContext _drawingContext;
private readonly ColrContext _context;
private readonly Stack<IDisposable> _stateStack = new Stack<IDisposable>();
// 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<Matrix> _transformStack = new Stack<Matrix>();
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;
}
}
/// <summary>
/// Creates a brush transform that applies any accumulated transforms.
/// </summary>
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;
}
}
}
}

191
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
{
/// <summary>
/// Context for parsing Paint tables and resolving ResolvedPaint, providing access to required tables and data.
/// </summary>
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<byte> ColrData { get; }
public ColrTable ColrTable { get; }
public CpalTable CpalTable { get; }
public int PaletteIndex { get; }
/// <summary>
/// Applies alpha delta to a color.
/// </summary>
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);
}
/// <summary>
/// Applies affine transformation deltas to a matrix.
/// </summary>
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);
}
/// <summary>
/// Resolves color stops with variation deltas applied and normalized to 0-1 range.
/// Based on fontations ColorStops::resolve and gradient normalization logic.
/// </summary>
public GradientStop[] ResolveColorStops(
GradientStopVar[] stops,
uint? varIndexBase)
{
if (stops.Length == 0)
return Array.Empty<GradientStop>();
// 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);
}
/// <summary>
/// Normalizes color stops to 0-1 range and handles edge cases.
/// Modifies the array in-place to avoid allocations when possible.
/// </summary>
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;
}
}
}

765
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
{
/// <summary>
/// Reader for the 'COLR' (Color) table. Provides access to layered color glyph data.
/// Supports COLR v0 and v1 formats.
/// </summary>
internal sealed class ColrTable
{
internal const string TableName = "COLR";
internal static OpenTypeTag Tag { get; } = OpenTypeTag.Parse(TableName);
private readonly ReadOnlyMemory<byte> _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<byte> 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;
}
/// <summary>
/// Gets the version of the COLR table (0 or 1).
/// </summary>
public ushort Version => _version;
/// <summary>
/// Gets the number of base glyph records (v0).
/// </summary>
public int BaseGlyphCount => _numBaseGlyphRecords;
/// <summary>
/// Gets whether this table has COLR v1 data.
/// </summary>
public bool HasV1Data => _version >= 1 && _baseGlyphV1ListOffset > 0;
/// <summary>
/// Gets the LayerV1List offset from the COLR table header.
/// Returns 0 if the LayerV1List is not present (COLR v0 or no LayerV1List in v1).
/// </summary>
public uint LayerV1ListOffset => _layerV1ListOffset;
public ReadOnlyMemory<byte> ColrData => _colrData;
/// <summary>
/// Attempts to load the COLR (Color) table from the specified glyph typeface.
/// </summary>
/// <remarks>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.</remarks>
/// <param name="glyphTypeface">The glyph typeface from which to load the COLR table. Cannot be null.</param>
/// <param name="colrTable">When this method returns, contains the loaded COLR table if successful; otherwise, null. This parameter is
/// passed uninitialized.</param>
/// <returns>true if the COLR table was successfully loaded; otherwise, false.</returns>
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;
}
/// <summary>
/// Tries to find the base glyph record for the specified glyph ID (v0 format).
/// Uses binary search for efficient lookup.
/// </summary>
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;
}
/// <summary>
/// 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).
/// /// </summary>
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;
}
/// <summary>
/// Gets all layers for the specified glyph ID.
/// Returns an empty array if the glyph has no color layers.
/// </summary>
public LayerRecord[] GetLayers(ushort glyphId)
{
if (!TryGetBaseGlyphRecord(glyphId, out var baseRecord))
{
return Array.Empty<LayerRecord>();
}
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;
}
/// <summary>
/// Tries to get the v1 base glyph record for the specified glyph ID.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="relativePaintOffset">The paint offset from a BaseGlyphV1Record, relative to the BaseGlyphV1List.</param>
/// <returns>The absolute offset within the COLR table.</returns>
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;
}
/// <summary>
/// Attempts to resolve and retrieve the paint definition for the specified glyph, if available.
/// </summary>
/// <remarks>This method returns <see langword="false"/> if the glyph does not have a version 1
/// base glyph record or if the paint cannot be parsed or resolved. The output parameter <paramref
/// name="paint"/> is set only when the method returns <see langword="true"/>.</remarks>
/// <param name="context">The context containing color and font information used to resolve the paint.</param>
/// <param name="glyphId">The identifier of the glyph for which to retrieve the resolved paint.</param>
/// <param name="paint">When this method returns, contains the resolved paint for the specified glyph if the operation succeeds;
/// otherwise, <see langword="null"/>. This parameter is passed uninitialized.</param>
/// <returns><see langword="true"/> if the resolved paint was successfully retrieved; otherwise, <see langword="false"/>.</returns>
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);
}
}
/// <summary>
/// Checks if the specified glyph has color layers defined (v0 or v1).
/// </summary>
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;
}
/// <summary>
/// Tries to get a complete delta set for the specified variation index.
/// </summary>
/// <param name="variationIndex">The index to look up in the DeltaSetIndexMap.</param>
/// <param name="deltaSet">A DeltaSet ref struct providing format-aware access to deltas.</param>
/// <returns>True if variation deltas were found; otherwise false.</returns>
/// <remarks>
/// 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).
/// </remarks>
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);
}
/// <summary>
/// Tries to get the clip box for a specified glyph ID from the ClipList (COLR v1).
/// </summary>
/// <param name="glyphId">The glyph ID to get the clip box for.</param>
/// <param name="clipBox">The clip box rectangle, or null if no clip box is defined.</param>
/// <returns>True if a clip box was found; otherwise false.</returns>
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;
}
/// <summary>
/// Tries to parse a ClipBox from the specified offset.
/// </summary>
private static bool TryParseClipBox(ReadOnlySpan<byte> 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;
}
/// <summary>
/// Reads a 24-bit offset (3 bytes, big-endian).
/// </summary>
private static uint ReadOffset24(ReadOnlySpan<byte> span)
{
return ((uint)span[0] << 16) | ((uint)span[1] << 8) | span[2];
}
}
/// <summary>
/// Represents a base glyph record in the COLR table (v0).
/// Maps a glyph ID to its color layers.
/// </summary>
internal readonly struct BaseGlyphRecord
{
public BaseGlyphRecord(ushort glyphId, ushort firstLayerIndex, ushort numLayers)
{
GlyphId = glyphId;
FirstLayerIndex = firstLayerIndex;
NumLayers = numLayers;
}
/// <summary>
/// Gets the glyph ID of the base glyph.
/// </summary>
public ushort GlyphId { get; }
/// <summary>
/// Gets the index of the first layer record for this glyph.
/// </summary>
public ushort FirstLayerIndex { get; }
/// <summary>
/// Gets the number of color layers for this glyph.
/// </summary>
public ushort NumLayers { get; }
}
/// <summary>
/// Represents a v1 base glyph record in the COLR table.
/// Maps a glyph ID to a paint offset.
/// </summary>
internal readonly struct BaseGlyphV1Record
{
public BaseGlyphV1Record(ushort glyphId, uint paintOffset)
{
GlyphId = glyphId;
PaintOffset = paintOffset;
}
/// <summary>
/// Gets the glyph ID of the base glyph.
/// </summary>
public ushort GlyphId { get; }
/// <summary>
/// Gets the offset to the paint table for this glyph.
/// </summary>
public uint PaintOffset { get; }
}
/// <summary>
/// Represents a layer record in the COLR table (v0).
/// Each layer references a glyph and a color palette index.
/// </summary>
internal readonly struct LayerRecord
{
public LayerRecord(ushort glyphId, ushort paletteIndex)
{
GlyphId = glyphId;
PaletteIndex = paletteIndex;
}
/// <summary>
/// Gets the glyph ID for this layer.
/// This typically references a glyph in the 'glyf' or 'CFF' table.
/// </summary>
public ushort GlyphId { get; }
/// <summary>
/// Gets the color palette index for this layer.
/// References a color in the CPAL (Color Palette) table.
/// </summary>
public ushort PaletteIndex { get; }
}
}

37
src/Avalonia.Base/Media/Fonts/Tables/Colr/CompositeMode.cs

@ -0,0 +1,37 @@
namespace Avalonia.Media.Fonts.Tables.Colr
{
/// <summary>
/// Composite modes for blend operations.
/// </summary>
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
}
}

230
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
{
/// <summary>
/// Reader for the 'CPAL' (Color Palette) table. Provides access to color palettes used by COLR glyphs.
/// </summary>
internal sealed class CpalTable
{
internal const string TableName = "CPAL";
internal static OpenTypeTag Tag { get; } = OpenTypeTag.Parse(TableName);
private readonly ReadOnlyMemory<byte> _cpalData;
private readonly ushort _version;
private readonly ushort _numPaletteEntries;
private readonly ushort _numPalettes;
private readonly ushort _numColorRecords;
private readonly uint _colorRecordsArrayOffset;
private CpalTable(
ReadOnlyMemory<byte> cpalData,
ushort version,
ushort numPaletteEntries,
ushort numPalettes,
ushort numColorRecords,
uint colorRecordsArrayOffset)
{
_cpalData = cpalData;
_version = version;
_numPaletteEntries = numPaletteEntries;
_numPalettes = numPalettes;
_numColorRecords = numColorRecords;
_colorRecordsArrayOffset = colorRecordsArrayOffset;
}
/// <summary>
/// Gets the version of the CPAL table.
/// </summary>
public ushort Version => _version;
/// <summary>
/// Gets the number of palette entries in each palette.
/// </summary>
public int PaletteEntryCount => _numPaletteEntries;
/// <summary>
/// Gets the number of palettes.
/// </summary>
public int PaletteCount => _numPalettes;
/// <summary>
/// Attempts to load the CPAL (Color Palette) table from the specified glyph typeface.
/// </summary>
/// <remarks>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.</remarks>
/// <param name="glyphTypeface">The glyph typeface from which to load the CPAL table. Cannot be null.</param>
/// <param name="cpalTable">When this method returns, contains the loaded CPAL table if successful; otherwise, null. This parameter is
/// passed uninitialized.</param>
/// <returns>true if the CPAL table was successfully loaded; otherwise, false.</returns>
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;
}
/// <summary>
/// Gets the offset to the first color record for the specified palette index.
/// </summary>
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;
}
/// <summary>
/// Tries to get the color at the specified palette index and color index.
/// </summary>
/// <param name="paletteIndex">The palette index (0-based).</param>
/// <param name="colorIndex">The color index within the palette (0-based).</param>
/// <param name="color">The resulting color.</param>
/// <returns>True if the color was successfully retrieved; otherwise, false.</returns>
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;
}
/// <summary>
/// Gets all colors in the specified palette.
/// Returns an empty array if the palette index is invalid.
/// </summary>
public Color[] GetPalette(int paletteIndex)
{
if (!TryGetPaletteOffset(paletteIndex, out var firstColorIndex))
{
return Array.Empty<Color>();
}
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;
}
/// <summary>
/// Tries to get a color from the default palette (palette 0).
/// </summary>
public bool TryGetColor(int colorIndex, out Color color)
{
return TryGetColor(0, colorIndex, out color);
}
}
}

166
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
{
/// <summary>
/// Errors that can occur during paint graph traversal with cycle detection.
/// </summary>
internal enum DecyclerError
{
/// <summary>
/// A cycle was detected in the paint graph.
/// </summary>
CycleDetected,
/// <summary>
/// The maximum depth limit was exceeded.
/// </summary>
DepthLimitExceeded
}
/// <summary>
/// Exception thrown when a decycler error occurs.
/// </summary>
internal class DecyclerException : Exception
{
public DecyclerError Error { get; }
public DecyclerException(DecyclerError error, string message) : base(message)
{
Error = error;
}
}
/// <summary>
/// A guard that tracks entry into a paint node and ensures proper cleanup.
/// </summary>
/// <typeparam name="T">The type of the paint identifier.</typeparam>
internal ref struct CycleGuard<T> where T : struct
{
private readonly Decycler<T> _decycler;
private readonly T _id;
private bool _exited;
internal CycleGuard(Decycler<T> decycler, T id)
{
_decycler = decycler;
_id = id;
_exited = false;
}
/// <summary>
/// Exits the guard, removing the paint ID from the visited set.
/// </summary>
public void Dispose()
{
if (!_exited)
{
_decycler.Exit(_id);
_exited = true;
}
}
}
/// <summary>
/// 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:
/// <code>
/// 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);
/// }
/// }
/// </code>
/// </summary>
/// <typeparam name="T">The type of the paint identifier (typically ushort for GlyphId).</typeparam>
internal class Decycler<T> where T : struct
{
private readonly HashSet<T> _visited;
private readonly int _maxDepth;
private int _currentDepth;
/// <summary>
/// Creates a new Decycler with the specified maximum depth.
/// </summary>
/// <param name="maxDepth">Maximum traversal depth before returning an error.</param>
public Decycler(int maxDepth)
{
_visited = new HashSet<T>();
_maxDepth = maxDepth;
_currentDepth = 0;
}
/// <summary>
/// Attempts to enter a paint node with the given ID.
/// Returns a guard that will automatically exit when disposed.
/// </summary>
/// <param name="id">The paint identifier to enter.</param>
/// <returns>A guard that will clean up on disposal.</returns>
/// <exception cref="DecyclerException">Thrown if a cycle is detected or depth limit exceeded.</exception>
public CycleGuard<T> 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<T>(this, id);
}
/// <summary>
/// Exits a paint node, removing it from the visited set.
/// Called automatically by CycleGuard.Dispose().
/// </summary>
/// <param name="id">The paint identifier to exit.</param>
internal void Exit(T id)
{
_visited.Remove(id);
_currentDepth--;
}
/// <summary>
/// Returns the current traversal depth.
/// </summary>
public int CurrentDepth => _currentDepth;
/// <summary>
/// Returns the maximum allowed traversal depth.
/// </summary>
public int MaxDepth => _maxDepth;
/// <summary>
/// Resets the decycler to its initial state, clearing all visited nodes.
/// </summary>
public void Reset()
{
_visited.Clear();
_currentDepth = 0;
}
}
}

247
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
{
/// <summary>
/// Represents a set of variation deltas from an ItemVariationStore.
/// This is a ref struct for allocation-free access to delta data.
/// </summary>
/// <remarks>
/// 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
/// </remarks>
public ref struct DeltaSet
{
/// <summary>
/// Creates an empty DeltaSet.
/// </summary>
public static DeltaSet Empty => new DeltaSet(ReadOnlySpan<byte>.Empty, 0, 0);
private readonly ReadOnlySpan<byte> _data;
private readonly ushort _wordDeltaCount;
private readonly ushort _totalDeltaCount;
internal DeltaSet(ReadOnlySpan<byte> data, ushort wordDeltaCount, ushort totalDeltaCount)
{
_data = data;
_wordDeltaCount = wordDeltaCount;
_totalDeltaCount = totalDeltaCount;
}
/// <summary>
/// Gets whether this delta set is empty (no deltas available).
/// </summary>
public bool IsEmpty => _totalDeltaCount == 0;
/// <summary>
/// Gets the total number of deltas in this set (word + byte deltas).
/// </summary>
public int Count => _totalDeltaCount;
/// <summary>
/// Gets the number of word deltas (16-bit) in this set.
/// </summary>
public int WordDeltaCount => _wordDeltaCount;
/// <summary>
/// Gets the number of byte deltas (8-bit) in this set.
/// </summary>
public int ByteDeltaCount => _totalDeltaCount - _wordDeltaCount;
/// <summary>
/// Gets the word deltas (16-bit signed integers) as a ReadOnlySpan.
/// </summary>
public ReadOnlySpan<short> WordDeltas
{
get
{
if (_wordDeltaCount == 0)
{
return ReadOnlySpan<short>.Empty;
}
var wordBytes = _data.Slice(0, _wordDeltaCount * 2);
return MemoryMarshal.Cast<byte, short>(wordBytes);
}
}
/// <summary>
/// Gets the byte deltas (8-bit signed integers) as a ReadOnlySpan.
/// </summary>
public ReadOnlySpan<sbyte> ByteDeltas
{
get
{
var byteDeltaCount = _totalDeltaCount - _wordDeltaCount;
if (byteDeltaCount == 0)
{
return ReadOnlySpan<sbyte>.Empty;
}
var byteOffset = _wordDeltaCount * 2;
var byteBytes = _data.Slice(byteOffset, byteDeltaCount);
return MemoryMarshal.Cast<byte, sbyte>(byteBytes);
}
}
/// <summary>
/// Gets a delta value at the specified index, converting byte deltas to short for uniform access.
/// </summary>
/// <param name="index">The index of the delta (0 to Count-1).</param>
/// <returns>The delta value as a 16-bit signed integer.</returns>
/// <exception cref="IndexOutOfRangeException">If index is out of range.</exception>
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];
}
}
/// <summary>
/// Tries to get a delta value at the specified index.
/// </summary>
/// <param name="index">The index of the delta.</param>
/// <param name="delta">The delta value if successful.</param>
/// <returns>True if the index is valid; otherwise false.</returns>
public bool TryGetDelta(int index, out short delta)
{
delta = 0;
if (index < 0 || index >= _totalDeltaCount)
{
return false;
}
delta = this[index];
return true;
}
/// <summary>
/// Gets a delta as an FWORD value (design units - no conversion needed).
/// </summary>
/// <param name="index">The index of the delta.</param>
/// <returns>The delta value as a double in design units, or 0.0 if index is out of range.</returns>
/// <remarks>
/// FWORD deltas are used for:
/// - Translation offsets (dx, dy)
/// - Gradient coordinates (x0, y0, x1, y1, etc.)
/// - Center points (centerX, centerY)
/// - Radii values (r0, r1)
/// </remarks>
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];
}
/// <summary>
/// Gets a delta as an F2DOT14 value (fixed-point 2.14 format).
/// </summary>
/// <param name="index">The index of the delta.</param>
/// <returns>The delta value as a double after F2DOT14 conversion, or 0.0 if index is out of range.</returns>
/// <remarks>
/// F2DOT14 deltas are used for:
/// - Scale values (scaleX, scaleY, scale)
/// - Rotation angles (angle)
/// - Skew angles (xAngle, yAngle)
/// - Alpha values (alpha)
/// - Gradient stop offsets
/// </remarks>
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;
}
/// <summary>
/// Gets a delta as a Fixed value (fixed-point 16.16 format).
/// </summary>
/// <param name="index">The index of the delta.</param>
/// <returns>The delta value as a double after Fixed conversion, or 0.0 if index is out of range.</returns>
/// <remarks>
/// 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.
/// </remarks>
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;
}
/// <summary>
/// Gets all deltas as 16-bit values, converting byte deltas to short.
/// Note: This method allocates an array.
/// </summary>
/// <returns>An array containing all deltas as 16-bit signed integers.</returns>
public short[] ToArray()
{
if (_totalDeltaCount == 0)
{
return Array.Empty<short>();
}
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;
}
}
}

238
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
{
/// <summary>
/// Represents a DeltaSetIndexMap table for COLR v1 font variations.
/// Maps glyph/entry indices to variation data indices.
/// </summary>
/// <remarks>
/// 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
/// </remarks>
internal sealed class DeltaSetIndexMap
{
private readonly ReadOnlyMemory<byte> _data;
private readonly byte _format;
private readonly byte _entryFormat;
private readonly uint _mapCount;
private readonly uint _mapDataOffset;
private DeltaSetIndexMap(
ReadOnlyMemory<byte> data,
byte format,
byte entryFormat,
uint mapCount,
uint mapDataOffset)
{
_data = data;
_format = format;
_entryFormat = entryFormat;
_mapCount = mapCount;
_mapDataOffset = mapDataOffset;
}
/// <summary>
/// Gets the format of this DeltaSetIndexMap (0 or 1).
/// </summary>
public byte Format => _format;
/// <summary>
/// Gets the number of mapping entries.
/// </summary>
public uint MapCount => _mapCount;
/// <summary>
/// Loads a DeltaSetIndexMap from the specified data.
/// </summary>
/// <param name="data">The raw table data containing the DeltaSetIndexMap.</param>
/// <param name="offset">The offset within the data where the DeltaSetIndexMap starts.</param>
/// <returns>A DeltaSetIndexMap instance, or null if the data is invalid.</returns>
public static DeltaSetIndexMap? Load(ReadOnlyMemory<byte> 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);
}
/// <summary>
/// Gets the (outer, inner) delta set index pair for the specified map index.
/// </summary>
/// <param name="mapIndex">The index to look up (e.g., glyph ID).</param>
/// <param name="outerIndex">The outer index for ItemVariationStore lookup.</param>
/// <param name="innerIndex">The inner index for ItemVariationStore lookup.</param>
/// <returns>True if the lookup was successful; otherwise false.</returns>
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;
}
/// <summary>
/// Tries to get a complete delta set for the specified variation index.
/// </summary>
/// <param name="itemVariationStore">The ItemVariationStore to retrieve deltas from.</param>
/// <param name="variationIndex">The index to look up in the DeltaSetIndexMap.</param>
/// <param name="deltaSet">A DeltaSet ref struct providing format-aware access to deltas.</param>
/// <returns>True if variation deltas were found; otherwise false.</returns>
/// <remarks>
/// 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.
/// </remarks>
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);
}
}
}

180
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
{
/// <summary>
/// Helper class for creating gradient brushes with proper normalization.
/// Based on fontations gradient normalization logic from traversal.rs.
/// </summary>
internal static class GradientBrushHelper
{
/// <summary>
/// Creates a linear gradient brush with normalization.
/// Converts from P0, P1, P2 representation to a simple two-point gradient.
/// </summary>
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
);
}
/// <summary>
/// Creates a radial gradient brush with normalization.
/// </summary>
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
);
}
/// <summary>
/// Creates a conic (sweep) gradient brush with angle normalization.
/// Angles are converted from counter-clockwise to clockwise for the shader.
/// </summary>
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
);
}
/// <summary>
/// Reverses gradient stops without LINQ to minimize allocations.
/// ImmutableGradientStop constructor is (Color, double offset).
/// </summary>
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;
}
}
}

118
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
{
/// <summary>
/// Pushes the specified transformation matrix onto the current transformation stack.
/// </summary>
/// <remarks>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.</remarks>
/// <param name="transform">The transformation matrix to apply to subsequent drawing operations. The matrix defines how coordinates are
/// transformed, such as by translation, rotation, or scaling.</param>
void PushTransform(Matrix transform);
/// <summary>
/// Removes the most recently applied transformation from the transformation stack.
/// </summary>
/// <remarks>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.</remarks>
void PopTransform();
/// <summary>
/// Pushes a new drawing layer onto the stack using the specified composite mode.
/// </summary>
/// <remarks>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.</remarks>
/// <param name="mode">The blending mode to use when compositing the new layer with existing content.</param>
void PushLayer(CompositeMode mode);
/// <summary>
/// Removes the topmost layer from the current layer stack.
/// </summary>
/// <remarks>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.</remarks>
void PopLayer();
/// <summary>
/// Establishes a new clipping region that restricts drawing to the specified rectangle.
/// </summary>
/// <remarks>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.</remarks>
/// <param name="clipBox">The rectangle that defines the boundaries of the clipping region. Only drawing operations within this area
/// will be visible.</param>
void PushClip(Rect clipBox);
/// <summary>
/// Removes the most recently added clip from the stack.
/// </summary>
/// <remarks>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.</remarks>
void PopClip();
/// <summary>
/// Fills the current path with a solid color.
/// </summary>
/// <param name="color"></param>
void FillSolid(Color color);
/// <summary>
/// Fills the current path with a linear gradient defined by the specified points and gradient stops.
/// </summary>
/// <remarks>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.</remarks>
/// <param name="p0">The starting point of the linear gradient in the coordinate space.</param>
/// <param name="p1">The ending point of the linear gradient in the coordinate space.</param>
/// <param name="stops">An array of gradient stops that define the colors and their positions along the gradient. Cannot be null or
/// empty.</param>
/// <param name="extend">Specifies how the gradient is extended beyond the start and end points, using the defined spread method.</param>
void FillLinearGradient(Point p0, Point p1, GradientStop[] stops, GradientSpreadMethod extend);
/// <summary>
/// Fills the current path with a radial gradient defined by two circles and a set of gradient stops.
/// </summary>
/// <remarks>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.</remarks>
/// <param name="c0">The center point of the starting circle for the gradient.</param>
/// <param name="r0">The radius of the starting circle. Must be non-negative.</param>
/// <param name="c1">The center point of the ending circle for the gradient.</param>
/// <param name="r1">The radius of the ending circle. Must be non-negative.</param>
/// <param name="stops">An array of gradient stops that define the colors and their positions within the gradient. Cannot be null or
/// empty.</param>
/// <param name="extend">Specifies how the gradient is extended beyond its normal range.</param>
void FillRadialGradient(Point c0, double r0, Point c1, double r1, GradientStop[] stops, GradientSpreadMethod extend);
/// <summary>
/// Fills the current path with a conic gradient defined by the given center point, angle range, color stops,
/// and spread method.
/// </summary>
/// <remarks>The conic gradient is drawn by interpolating colors between the specified stops along
/// the angular range from <paramref name="startAngle"/> to <paramref name="endAngle"/>. The behavior outside
/// this range is determined by the <paramref name="extend"/> parameter.</remarks>
/// <param name="center">The center point of the conic gradient, specified in the coordinate space of the drawing surface.</param>
/// <param name="startAngle">The starting angle, in degrees, at which the gradient begins. Measured clockwise from the positive X-axis.</param>
/// <param name="endAngle">The ending angle, in degrees, at which the gradient ends. Measured clockwise from the positive X-axis. Must
/// be greater than or equal to <paramref name="startAngle"/>.</param>
/// <param name="stops">An array of <see cref="GradientStop"/> objects that define the colors and their positions within the
/// gradient. Must contain at least two elements.</param>
/// <param name="extend">A value that specifies how the gradient is extended beyond its normal range, as defined by the <see
/// cref="GradientSpreadMethod"/> enumeration.</param>
void FillConicGradient(Point center, double startAngle, double endAngle, GradientStop[] stops, GradientSpreadMethod extend);
/// <summary>
/// Pushes a glyph outline onto the painter's current state as the active path for subsequent fill operations.
/// </summary>
/// <param name="glyphId"></param>
void Glyph(ushort glyphId);
}
}

170
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
{
/// <summary>
/// Represents an ItemVariationStore for OpenType font variations.
/// Stores delta values that can be applied to font data based on variation axis coordinates.
/// </summary>
/// <remarks>
/// 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
/// </remarks>
internal sealed class ItemVariationStore
{
private readonly ReadOnlyMemory<byte> _data;
private readonly uint _baseOffset;
private readonly ushort _format;
private readonly uint _variationRegionListOffset;
private readonly ushort _itemVariationDataCount;
private ItemVariationStore(
ReadOnlyMemory<byte> data,
uint baseOffset,
ushort format,
uint variationRegionListOffset,
ushort itemVariationDataCount)
{
_data = data;
_baseOffset = baseOffset;
_format = format;
_variationRegionListOffset = variationRegionListOffset;
_itemVariationDataCount = itemVariationDataCount;
}
/// <summary>
/// Gets the format of this ItemVariationStore (currently only 1 is defined).
/// </summary>
public ushort Format => _format;
/// <summary>
/// Gets the number of ItemVariationData arrays in this store.
/// </summary>
public ushort ItemVariationDataCount => _itemVariationDataCount;
/// <summary>
/// Loads an ItemVariationStore from the specified data.
/// </summary>
/// <param name="data">The complete table data (e.g., COLR table data).</param>
/// <param name="offset">The offset within the data where the ItemVariationStore starts.</param>
/// <returns>An ItemVariationStore instance, or null if the data is invalid.</returns>
public static ItemVariationStore? Load(ReadOnlyMemory<byte> 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);
}
/// <summary>
/// Tries to get a complete delta set for the specified (outer, inner) index pair.
/// </summary>
/// <param name="outerIndex">The outer index (ItemVariationData index).</param>
/// <param name="innerIndex">The inner index (DeltaSet index within ItemVariationData).</param>
/// <param name="deltaSet">A DeltaSet ref struct providing format-aware access to deltas.</param>
/// <returns>True if the delta set was found; otherwise false.</returns>
/// <remarks>
/// 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.
/// </remarks>
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;
}
}
}

1068
src/Avalonia.Base/Media/Fonts/Tables/Colr/Paint.cs

File diff suppressed because it is too large

47
src/Avalonia.Base/Media/Fonts/Tables/Colr/PaintDecycler.cs

@ -0,0 +1,47 @@
using Avalonia.Utilities;
namespace Avalonia.Media.Fonts.Tables.Colr
{
/// <summary>
/// Type alias for the paint decycler that tracks visited glyphs.
/// </summary>
internal class PaintDecycler : Decycler<ushort>
{
/// <summary>
/// Maximum depth for paint graph traversal.
/// This limit matches HB_MAX_NESTING_LEVEL used in HarfBuzz.
/// </summary>
public const int MaxTraversalDepth = 64;
private static readonly ObjectPool<PaintDecycler> Pool = new ObjectPool<PaintDecycler>(
factory: () => new PaintDecycler(),
validator: decycler =>
{
decycler.Reset();
return true;
},
maxSize: 32);
public PaintDecycler() : base(MaxTraversalDepth)
{
}
/// <summary>
/// Rents a PaintDecycler from the pool.
/// </summary>
/// <returns>A pooled PaintDecycler instance.</returns>
public static PaintDecycler Rent()
{
return Pool.Rent();
}
/// <summary>
/// Returns a PaintDecycler to the pool.
/// </summary>
/// <param name="decycler">The decycler to return to the pool.</param>
public static void Return(PaintDecycler decycler)
{
Pool.Return(decycler);
}
}
}

66
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
{
/// <summary>
/// Tries to parse a Paint from the given data at the specified offset.
/// </summary>
public static bool TryParse(ReadOnlySpan<byte> 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
};
}
}
}

203
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
{
/// <summary>
/// Helper methods for parsing paint data.
/// </summary>
internal static class PaintParsingHelpers
{
public static uint ReadOffset24(ReadOnlySpan<byte> 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;
}
/// <summary>
/// Specifies the numeric format used to represent delta values in font tables.
/// </summary>
/// <remarks>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.</remarks>
public enum DeltaTargetType
{
/// <summary>FWORD - design units (int16) - no conversion needed</summary>
FWORD,
/// <summary>F2DOT14 - fixed-point with 2.14 format (divide by 16384)</summary>
F2Dot14,
/// <summary>Fixed - fixed-point with 16.16 format (divide by 65536)</summary>
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<byte> 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<byte> data,
uint offset,
in ColrContext context,
bool isVarColorLine,
out Immutable.ImmutableGradientStop[] stops,
out GradientSpreadMethod extend)
{
stops = Array.Empty<Immutable.ImmutableGradientStop>();
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<Immutable.ImmutableGradientStop>(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;
}
}
}

653
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
{
/// <summary>
/// Resolves Paint definitions into ResolvedPaint by applying variation deltas and normalization.
/// </summary>
internal static class PaintResolver
{
/// <summary>
/// Resolves a paint graph by evaluating variable and composite paint nodes into their fully realized, static forms
/// using the provided color context.
/// </summary>
/// <remarks>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.</remarks>
/// <param name="paint">The root paint node to resolve. This may be a variable, composite, or transform paint type.</param>
/// <param name="context">The color context used to evaluate variable paint nodes and apply variation deltas.</param>
/// <returns>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.</returns>
/// <exception cref="NotSupportedException">Thrown if the type of the provided paint node is not recognized or supported for resolution.</exception>
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;
}
}
}

74
src/Avalonia.Base/Media/Fonts/Tables/Colr/PaintTraverser.cs

@ -0,0 +1,74 @@
namespace Avalonia.Media.Fonts.Tables.Colr
{
/// <summary>
/// Traverses a resolved paint tree and calls the appropriate methods on the painter.
/// </summary>
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;
}
}
}
}

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

22
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
}
}

190
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
{
/// <summary>
/// Represents a composite glyph that references one or more component glyphs.
/// </summary>
/// <remarks>This struct holds references to rented arrays that must be returned via Dispose.
/// The struct should be disposed after consuming the component data.</remarks>
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;
/// <summary>
/// Gets the span of glyph components that make up this composite glyph.
/// </summary>
public ReadOnlySpan<GlyphComponent> Components { get; }
/// <summary>
/// Gets the instruction data (currently unused).
/// </summary>
public ReadOnlySpan<byte> Instructions { get; }
/// <summary>
/// Initializes a new instance of the CompositeGlyph class using the specified glyph components and an optional
/// rented buffer.
/// </summary>
/// <remarks>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.</remarks>
/// <param name="components">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.</param>
/// <param name="rentedBuffer">An optional array used as a rented buffer for internal storage. If provided, the buffer may be used to
/// optimize memory usage.</param>
private CompositeGlyph(ReadOnlySpan<GlyphComponent> components, GlyphComponent[]? rentedBuffer)
{
Components = components;
Instructions = default;
_rentedBuffer = rentedBuffer;
}
/// <summary>
/// Creates a CompositeGlyph from the raw glyph data.
/// </summary>
/// <param name="data">The raw glyph data from the glyf table.</param>
/// <returns>A CompositeGlyph instance with components backed by a rented buffer.</returns>
/// <remarks>The caller must call Dispose() to return the rented buffer to the pool.</remarks>
public static CompositeGlyph Create(ReadOnlySpan<byte> data)
{
// Rent a buffer for components (most composite glyphs have < 8 components)
var componentsBuffer = ArrayPool<GlyphComponent>.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<GlyphComponent>.Shared.Rent(newSize);
oldBuffer.AsSpan(0, componentCount).CopyTo(componentsBuffer);
ArrayPool<GlyphComponent>.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<byte> instructions = ReadOnlySpan<byte>.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<GlyphComponent>.Shared.Return(componentsBuffer);
throw;
}
}
/// <summary>
/// Returns the rented buffer to the ArrayPool.
/// </summary>
/// <remarks>This method should be called when the CompositeGlyph is no longer needed
/// to ensure the rented buffer is returned to the pool.</remarks>
public void Dispose()
{
if (_rentedBuffer != null)
{
ArrayPool<GlyphComponent>.Shared.Return(_rentedBuffer);
}
}
}
}

556
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
{
/// <summary>
/// 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.
/// </summary>
internal sealed class GlyfTable
{
internal const string TableName = "glyf";
internal static OpenTypeTag Tag { get; } = OpenTypeTag.Parse(TableName);
private readonly ReadOnlyMemory<byte> _glyfData;
private readonly LocaTable _locaTable;
private GlyfTable(ReadOnlyMemory<byte> glyfData, LocaTable locaTable)
{
_glyfData = glyfData;
_locaTable = locaTable;
}
/// <summary>
/// Gets the total number of glyphs defined in the font.
/// </summary>
public int GlyphCount => _locaTable.GlyphCount;
/// <summary>
/// Attempts to load the 'glyf' table from the specified font data.
/// </summary>
/// <remarks>This method does not throw an exception if the 'glyf' table cannot be loaded.
/// Instead, it returns <see langword="false"/> and sets <paramref name="glyfTable"/> to <see
/// langword="null"/>.</remarks>
/// <param name="glyphTypeface">The glyph typeface from which to retrieve the 'glyf' table.</param>
/// <param name="head">The 'head' table containing font header information required for loading the 'glyf' table.</param>
/// <param name="maxp">The 'maxp' table providing maximum profile information needed to interpret the 'glyf' table.</param>
/// <param name="glyfTable">When this method returns, contains the loaded 'glyf' table if successful; otherwise, <see langword="null"/>.
/// This parameter is passed uninitialized.</param>
/// <returns><see langword="true"/> if the 'glyf' table was successfully loaded; otherwise, <see langword="false"/>.</returns>
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;
}
/// <summary>
/// Attempts to retrieve the raw glyph data for the specified glyph index.
/// </summary>
/// <remarks>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.</remarks>
/// <param name="glyphIndex">The zero-based index of the glyph to retrieve data for.</param>
/// <param name="data">When this method returns, contains the glyph data as a read-only memory region if the glyph exists;
/// otherwise, contains an empty memory region.</param>
/// <returns>true if the glyph data was found and assigned to the out parameter; otherwise, false.</returns>
public bool TryGetGlyphData(int glyphIndex, out ReadOnlyMemory<byte> data)
{
if (!_locaTable.TryGetOffsets(glyphIndex, out var start, out var end))
{
data = ReadOnlyMemory<byte>.Empty;
return false;
}
if (start == end)
{
data = ReadOnlyMemory<byte>.Empty;
return true;
}
// Additional safety check for glyf table bounds
if (start < 0 || end > _glyfData.Length || start > end)
{
data = ReadOnlyMemory<byte>.Empty;
return false;
}
data = _glyfData.Slice(start, end - start);
return true;
}
/// <summary>
/// Builds the glyph outline into the provided geometry context. Returns false for empty glyphs.
/// Coordinates are in font design units. Composite glyphs are supported.
/// </summary>
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);
}
}
/// <summary>
/// Builds the geometry for a simple glyph by processing its contours and converting them into geometry commands.
/// </summary>
/// <param name="simpleGlyph">The simple glyph containing contour data, flags, and coordinates.</param>
/// <param name="context">The geometry context that receives the constructed glyph geometry.</param>
/// <param name="transform">The transformation matrix to apply to all coordinates.</param>
/// <returns>true if the glyph geometry was successfully built; otherwise, false.</returns>
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();
}
}
/// <summary>
/// Creates a transformation matrix for a composite glyph component based on its flags and transformation parameters.
/// </summary>
/// <param name="component">The glyph component containing transformation information.</param>
/// <returns>A transformation matrix that should be applied to the component glyph.</returns>
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);
}
/// <summary>
/// Attempts to build the geometry for the specified glyph and adds it to the provided geometry context.
/// </summary>
/// <remarks>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.</remarks>
/// <param name="glyphIndex">The index of the glyph to process. Must correspond to a valid glyph in the font.</param>
/// <param name="context">The geometry context that receives the constructed glyph geometry.</param>
/// <param name="transform">The transformation matrix to apply to the glyph geometry.</param>
/// <param name="decycler">A <see cref="GlyphDecycler"/> instance used to prevent infinite recursion when building composite glyphs.</param>
/// <returns>true if the glyph geometry was successfully built and added to the context; otherwise, false.</returns>
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);
}
}
/// <summary>
/// Builds the geometry for a composite glyph by recursively processing its components.
/// </summary>
/// <param name="compositeGlyph">The composite glyph containing component references and transformations.</param>
/// <param name="context">The geometry context that receives the constructed glyph geometry.</param>
/// <param name="transform">The transformation matrix to apply to all component glyphs.</param>
/// <param name="decycler">A <see cref="GlyphDecycler"/> instance used to prevent infinite recursion when building composite glyphs.</param>
/// <returns>true if at least one component was successfully processed; otherwise, false.</returns>
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();
}
}
/// <summary>
/// Wrapper that applies a matrix transform to coordinates before delegating to the real context.
/// </summary>
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");
}
}
}
}
}

22
src/Avalonia.Base/Media/Fonts/Tables/Glyf/GlyphComponent.cs

@ -0,0 +1,22 @@
namespace Avalonia.Media.Fonts.Tables.Glyf
{
/// <summary>
/// Represents a single component of a composite glyph, including its transformation and positioning information.
/// </summary>
/// <remarks>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.</remarks>
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; }
}
}

48
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
{
/// <summary>
/// Type alias for the glyph decycler that tracks visited glyphs during composite glyph processing.
/// </summary>
internal class GlyphDecycler : Decycler<int>
{
/// <summary>
/// Maximum depth for glyph graph traversal.
/// This limit prevents stack overflow from deeply nested composite glyphs.
/// </summary>
public const int MaxTraversalDepth = 64;
private static readonly ObjectPool<GlyphDecycler> Pool = new ObjectPool<GlyphDecycler>(
factory: () => new GlyphDecycler(),
validator: decycler =>
{
decycler.Reset();
return true;
},
maxSize: 16);
public GlyphDecycler() : base(MaxTraversalDepth)
{
}
/// <summary>
/// Rents a GlyphDecycler from the pool.
/// </summary>
/// <returns>A pooled GlyphDecycler instance.</returns>
public static GlyphDecycler Rent()
{
return Pool.Rent();
}
/// <summary>
/// Returns a GlyphDecycler to the pool.
/// </summary>
/// <param name="decycler">The decycler to return to the pool.</param>
public static void Return(GlyphDecycler decycler)
{
Pool.Return(decycler);
}
}
}

79
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
{
/// <summary>
/// Represents the descriptor for a glyph in a font, providing access to its outline and bounding metrics.
/// </summary>
/// <remarks>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.</remarks>
internal class GlyphDescriptor
{
private readonly ReadOnlyMemory<byte> _glyphData;
public GlyphDescriptor(ReadOnlyMemory<byte> 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);
}
/// <summary>
/// Gets the number of contours in the glyph.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public short NumberOfContours { get; }
/// <summary>
/// Gets the conservative bounding box for the glyph in font design units.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public Rect ConservativeBounds { get; }
/// <summary>
/// Gets a value indicating whether this glyph is a simple glyph (as opposed to a composite glyph).
/// </summary>
public bool IsSimpleGlyph => NumberOfContours >= 0;
/// <summary>
/// Gets the simple glyph outline data.
/// </summary>
/// <remarks>
/// This property should only be accessed if <see cref="IsSimpleGlyph"/> is true.
/// The returned struct holds references to rented arrays and must be disposed.
/// </remarks>
public SimpleGlyph SimpleGlyph => SimpleGlyph.Create(_glyphData.Span, NumberOfContours);
/// <summary>
/// Gets the composite glyph outline data.
/// </summary>
/// <remarks>
/// This property should only be accessed if <see cref="IsSimpleGlyph"/> is false.
/// The returned struct holds references to rented arrays and must be disposed.
/// </remarks>
public CompositeGlyph CompositeGlyph => CompositeGlyph.Create(_glyphData.Span);
}
}

18
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
}
}

275
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
{
/// <summary>
/// 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.
/// </summary>
/// <remarks>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.</remarks>
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;
/// <summary>
/// Gets the indices of the last point in each contour within the glyph outline.
/// </summary>
/// <remarks>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.</remarks>
public ReadOnlySpan<ushort> EndPtsOfContours { get; }
/// <summary>
/// Gets the instruction data.
/// </summary>
public ReadOnlySpan<byte> Instructions { get; }
/// <summary>
/// Gets the collection of flags associated with the glyph.
/// </summary>
public ReadOnlySpan<GlyphFlag> Flags { get; }
/// <summary>
/// Gets the X coordinates.
/// </summary>
public ReadOnlySpan<short> XCoordinates { get; }
/// <summary>
/// Gets the Y coordinates.
/// </summary>
public ReadOnlySpan<short> YCoordinates { get; }
/// <summary>
/// Initializes a new instance of the SimpleGlyph class using the specified contour endpoints, instructions,
/// flags, and coordinate data.
/// </summary>
/// <remarks>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.</remarks>
/// <param name="endPtsOfContours">A read-only span containing the indices of the last point in each contour. The values define the structure
/// of the glyph's outline.</param>
/// <param name="instructions">A read-only span containing the TrueType instructions associated with the glyph. These instructions control
/// glyph rendering and hinting.</param>
/// <param name="flags">A read-only span of flags describing the attributes of each glyph point, such as whether a point is on-curve
/// or off-curve.</param>
/// <param name="xCoordinates">A read-only span containing the X coordinates for each glyph point, in font units.</param>
/// <param name="yCoordinates">A read-only span containing the Y coordinates for each glyph point, in font units.</param>
/// <param name="rentedFlags">An optional array of GlyphFlag values used for temporary storage. If provided, the array may be reused
/// internally to reduce allocations.</param>
/// <param name="rentedXCoords">An optional array of short values used for temporary storage of X coordinates. If provided, the array may be
/// reused internally to reduce allocations.</param>
/// <param name="rentedYCoords">An optional array of short values used for temporary storage of Y coordinates. If provided, the array may be
/// reused internally to reduce allocations.</param>
private SimpleGlyph(
ReadOnlySpan<ushort> endPtsOfContours,
ReadOnlySpan<byte> instructions,
ReadOnlySpan<GlyphFlag> flags,
ReadOnlySpan<short> xCoordinates,
ReadOnlySpan<short> yCoordinates,
GlyphFlag[]? rentedFlags,
short[]? rentedXCoords,
short[]? rentedYCoords)
{
EndPtsOfContours = endPtsOfContours;
Instructions = instructions;
Flags = flags;
XCoordinates = xCoordinates;
YCoordinates = yCoordinates;
_rentedFlags = rentedFlags;
_rentedXCoords = rentedXCoords;
_rentedYCoords = rentedYCoords;
}
/// <summary>
/// Creates a new instance of the <see cref="SimpleGlyph"/> structure by parsing glyph data from the specified
/// byte span.
/// </summary>
/// <remarks>The returned <see cref="SimpleGlyph"/> uses buffers rented from array pools for
/// performance. Callers are responsible for disposing or returning these buffers if required by the <see
/// cref="SimpleGlyph"/> 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.</remarks>
/// <param name="data">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.</param>
/// <param name="numberOfContours">The number of contours in the glyph. Must be greater than zero; otherwise, a default value is returned.</param>
/// <returns>A <see cref="SimpleGlyph"/> instance representing the parsed glyph data. Returns the default value if
/// <paramref name="numberOfContours"/> is less than or equal to zero.</returns>
public static SimpleGlyph Create(ReadOnlySpan<byte> 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<GlyphFlag>.Shared.Rent(numPoints);
var xCoordsBuffer = ArrayPool<short>.Shared.Rent(numPoints);
var yCoordsBuffer = ArrayPool<short>.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<GlyphFlag>.Shared.Return(flagsBuffer);
ArrayPool<short>.Shared.Return(xCoordsBuffer);
ArrayPool<short>.Shared.Return(yCoordsBuffer);
throw;
}
}
/// <summary>
/// Returns the rented buffers to the ArrayPool.
/// </summary>
/// <remarks>This method should be called when the SimpleGlyph is no longer needed
/// to ensure the rented buffers are returned to the pool.</remarks>
public void Dispose()
{
if (_rentedFlags != null)
{
ArrayPool<GlyphFlag>.Shared.Return(_rentedFlags);
}
if (_rentedXCoords != null)
{
ArrayPool<short>.Shared.Return(_rentedXCoords);
}
if (_rentedYCoords != null)
{
ArrayPool<short>.Shared.Return(_rentedYCoords);
}
}
}
}

127
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
{
/// <summary>
/// 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.
/// </summary>
internal sealed class LocaTable
{
internal const string TableName = "loca";
internal static OpenTypeTag Tag { get; } = OpenTypeTag.Parse(TableName);
private readonly ReadOnlyMemory<byte> _data;
private readonly int _glyphCount;
private readonly bool _isShortFormat;
private LocaTable(ReadOnlyMemory<byte> data, int glyphCount, bool isShortFormat)
{
_data = data;
_glyphCount = glyphCount;
_isShortFormat = isShortFormat;
}
/// <summary>
/// Gets the number of glyphs in the font.
/// </summary>
public int GlyphCount => _glyphCount;
/// <summary>
/// Loads the loca table from the specified typeface.
/// </summary>
/// <param name="glyphTypeface">The glyph typeface to load from.</param>
/// <param name="head">The head table containing the index format.</param>
/// <param name="maxp">The maxp table containing the glyph count.</param>
/// <returns>A LocaTable instance, or null if the table cannot be loaded.</returns>
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);
}
/// <summary>
/// Gets the start and end offsets for the specified glyph index.
/// </summary>
/// <param name="glyphIndex">The glyph index (0-based).</param>
/// <param name="start">The start offset into the glyf table.</param>
/// <param name="end">The end offset into the glyf table.</param>
/// <returns>True if the offsets were retrieved successfully; otherwise, false.</returns>
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;
}
/// <summary>
/// Gets the offset for the specified glyph index into the glyf table.
/// </summary>
/// <param name="glyphIndex">The glyph index (0-based).</param>
/// <returns>The offset into the glyf table, or 0 if the index is out of range.</returns>
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;
}
}
}
}

16
src/Avalonia.Base/Media/GlyphDrawingType.cs

@ -0,0 +1,16 @@
namespace Avalonia.Media
{
/// <summary>
/// Specifies the format used to render a glyph, such as outline, color layers, SVG, or bitmap.
/// </summary>
/// <remarks>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.</remarks>
public enum GlyphDrawingType
{
Outline, // glyf / CFF / CFF2
ColorLayers, // COLR/CPAL
Svg, // SVG table
Bitmap // sbix / CBDT / EBDT
}
}

286
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<CultureInfo, string> s_emptyStringDictionary =
new Dictionary<CultureInfo, string>(0);
private static readonly IPlatformRenderInterface _renderInterface = AvaloniaLocator.Current.GetRequiredService<IPlatformRenderInterface>();
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;
}
/// <summary>
/// Gets a color glyph drawing for the specified glyph ID, if color data is available.
/// </summary>
/// <remarks>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.</remarks>
/// <param name="glyphId">The identifier of the glyph to retrieve. Must be less than or equal to the total number of glyphs in the
/// font.</param>
/// <param name="variation">The font variation settings to use when selecting the glyph drawing, or null to use the default variation.</param>
/// <returns>An object representing the color glyph drawing for the specified glyph ID, or null if no color drawing is
/// available for the glyph.</returns>
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;
}
/// <summary>
/// Retrieves the vector outline geometry for the specified glyph, optionally applying a transformation and font
/// variation settings.
/// </summary>
/// <remarks>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.</remarks>
/// <param name="glyphId">The identifier of the glyph to retrieve. Must be less than or equal to the total number of glyphs in the
/// font.</param>
/// <param name="transform">A transformation matrix to apply to the glyph outline geometry.</param>
/// <param name="variation">Optional font variation settings to use when retrieving the glyph outline. If null, default font variations
/// are used.</param>
/// <returns>A Geometry object representing the outline of the specified glyph, or null if the glyph does not exist or
/// the outline cannot be retrieved.</returns>
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();
}
/// <summary>
/// Attempts to retrieve and resolve the paint definition for a base glyph using COLR v1 data.
/// </summary>
/// <remarks>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.</remarks>
/// <param name="context">The color rendering context used to interpret the paint data.</param>
/// <param name="record">The base glyph record containing the paint offset information.</param>
/// <param name="paint">When this method returns, contains the resolved paint definition if successful; otherwise, null. This
/// parameter is passed uninitialized.</param>
/// <returns>true if the paint definition was successfully retrieved and resolved; otherwise, false.</returns>
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);
}
}
}
/// <summary>
/// Represents a color glyph drawing with multiple colored layers (COLR v0).
/// </summary>
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;
}
}
/// <summary>
/// Draws the color glyph at the specified origin using the provided drawing context.
/// </summary>
/// <remarks>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.</remarks>
/// <param name="context">The drawing context to use for rendering the glyph. Must not be null.</param>
/// <param name="origin">The point, in device-independent pixels, that specifies the origin at which to draw the glyph.</param>
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);
}
}
}
}
}
/// <summary>
/// Represents a COLR v1 color glyph drawing with paint-based rendering.
/// </summary>
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);
}
}
}
}

13
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);
}
}

80
src/Avalonia.Base/Utilities/ObjectPool.cs

@ -0,0 +1,80 @@
using System;
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
namespace Avalonia.Utilities
{
/// <summary>
/// Provides a thread-safe object pool with size limits and object validation.
/// </summary>
/// <typeparam name="T">The type of objects to pool.</typeparam>
internal sealed class ObjectPool<T> where T : class
{
private readonly Func<T> _factory;
private readonly Func<T, bool>? _validator;
private readonly ConcurrentBag<T> _items;
private readonly int _maxSize;
private int _count;
/// <summary>
/// Initializes a new instance of the <see cref="ObjectPool{T}"/> class.
/// </summary>
/// <param name="factory">Factory function to create new instances.</param>
/// <param name="validator">Optional validator to clean and validate objects before returning to the pool. Return false to discard the object.</param>
/// <param name="maxSize">Maximum number of objects to keep in the pool. Default is 32.</param>
public ObjectPool(Func<T> factory, Func<T, bool>? validator = null, int maxSize = 32)
{
_factory = factory ?? throw new ArgumentNullException(nameof(factory));
_validator = validator;
_maxSize = maxSize;
_items = new ConcurrentBag<T>();
_count = 0;
}
/// <summary>
/// Rents an object from the pool or creates a new one if the pool is empty.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public T Rent()
{
if (_items.TryTake(out var item))
{
System.Threading.Interlocked.Decrement(ref _count);
return item;
}
return _factory();
}
/// <summary>
/// Returns an object to the pool if it passes validation and the pool is not full.
/// </summary>
[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);
}
}
}
}

111
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<FontFace1>();
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<byte> 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;
}
}
}

243
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);
}
}
}

BIN
tests/Avalonia.RenderTests/Assets/NotoColorEmoji-Regular.ttf

Binary file not shown.

712
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);
}
}
/// <summary>
/// Detailed diagnostic painter that logs every operation with full details
/// </summary>
private class DetailedDiagnosticPainter : IColorPainter
{
private readonly GlyphTypeface _glyphTypeface;
private readonly Stack<Matrix> _transforms = new Stack<Matrix>();
private int _operationIndex = 0;
public List<string> Operations { get; } = new List<string>();
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}");
}
}
/// <summary>
/// Custom painter that tracks transform accumulation during paint graph traversal
/// </summary>
private class TransformTrackingPainter : IColorPainter
{
public List<Matrix> TransformStack { get; } = new List<Matrix>();
public List<Matrix> AllTransforms { get; } = new List<Matrix>();
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<Matrix> _activeTransforms = new Stack<Matrix>();
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++;
}
}
/// <summary>
/// Custom painter that tracks the final bounds of all rendered content
/// </summary>
private class BoundsTrackingPainter : IColorPainter
{
private readonly GlyphTypeface _glyphTypeface;
private readonly Stack<Matrix> _transforms = new Stack<Matrix>();
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;
}
}
}
Loading…
Cancel
Save