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