diff --git a/src/Avalonia.Base/Media/FontVariationSettings.cs b/src/Avalonia.Base/Media/FontVariationSettings.cs
new file mode 100644
index 0000000000..2cba73f8ce
--- /dev/null
+++ b/src/Avalonia.Base/Media/FontVariationSettings.cs
@@ -0,0 +1,279 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Globalization;
+using System.Text;
+using Avalonia.Media.Fonts;
+
+namespace Avalonia.Media
+{
+ ///
+ /// A single variation axis setting in designer units, e.g. wght = 700.
+ ///
+ /// The OpenType axis tag (e.g. wght, wdth, opsz).
+ ///
+ /// The axis position in the font's user coordinate space — the same units the font's
+ /// fvar table declares for the axis (weight 100–900, width percentages, optical
+ /// sizes in points). Values are clamped to the axis range and normalized per font when
+ /// the settings are applied; axes a font does not declare are ignored.
+ ///
+ public readonly record struct FontVariation(OpenTypeTag Tag, double Value)
+ {
+ /// Returns the tag=value form, e.g. wght=700.
+ public override string ToString() =>
+ string.Create(CultureInfo.InvariantCulture, $"{Tag}={Value}");
+ }
+
+ ///
+ /// An immutable, order-independent set of user-space variation axis values that
+ /// configures variable fonts (fonts with an fvar table) for rendering.
+ ///
+ ///
+ ///
+ /// Values are expressed in the font's user coordinate space (wght = 700), the
+ /// same space CSS font-variation-settings, DirectWrite and HarfBuzz use.
+ /// Normalization to the OpenType [-1, 1] range — including the avar
+ /// mapping — happens per font when the settings are applied, so one settings value is
+ /// meaningful across fonts: each font clamps to its own axis ranges and ignores axes
+ /// it does not declare.
+ ///
+ ///
+ /// Instances have structural equality with a cached hash code and are usable as cache
+ /// keys. Variations are stored sorted by axis tag, so equality is order-independent;
+ /// when the same tag is given more than once, the last value wins (CSS behavior).
+ /// null and both mean "design defaults".
+ ///
+ ///
+ /// The string form accepted by (and produced by
+ /// ) is a comma-separated list of tag=value pairs, e.g.
+ /// "wght=700, wdth=85", usable directly in XAML.
+ ///
+ ///
+ public sealed class FontVariationSettings : IEquatable
+ {
+ private readonly ImmutableArray _variations;
+ private readonly int _hashCode;
+
+ private FontVariationSettings(ImmutableArray sortedVariations)
+ {
+ _variations = sortedVariations;
+ _hashCode = ComputeHashCode(sortedVariations);
+ }
+
+ /// Gets the empty settings — the font's design defaults.
+ public static FontVariationSettings Empty { get; } =
+ new(ImmutableArray.Empty);
+
+ ///
+ /// Gets the variations, sorted by axis tag ascending. Duplicate tags passed at
+ /// construction have already been collapsed to their last value.
+ ///
+ public ImmutableArray Variations => _variations;
+
+ /// Gets a value indicating whether no axis is set.
+ public bool IsEmpty => _variations.IsEmpty;
+
+ ///
+ /// Creates settings from variation values. When a tag appears more than once, the
+ /// last occurrence wins.
+ ///
+ /// is null.
+ /// A value is NaN.
+ public FontVariationSettings(IEnumerable variations)
+ {
+ if (variations is null)
+ {
+ throw new ArgumentNullException(nameof(variations));
+ }
+
+ var builder = ImmutableArray.CreateBuilder();
+
+ foreach (var variation in variations)
+ {
+ // Infinite values are usable — they clamp to the axis range like any other
+ // out-of-range value when the settings are applied. NaN has no such meaning.
+ if (double.IsNaN(variation.Value))
+ {
+ throw new ArgumentException(
+ $"Value for axis '{variation.Tag}' must not be NaN.",
+ nameof(variations));
+ }
+
+ // Last-wins for duplicate tags, matching CSS font-variation-settings.
+ var replaced = false;
+
+ for (var i = 0; i < builder.Count; i++)
+ {
+ if (builder[i].Tag == variation.Tag)
+ {
+ builder[i] = variation;
+ replaced = true;
+ break;
+ }
+ }
+
+ if (!replaced)
+ {
+ builder.Add(variation);
+ }
+ }
+
+ builder.Sort(static (a, b) => ((uint)a.Tag).CompareTo((uint)b.Tag));
+
+ _variations = builder.ToImmutable();
+ _hashCode = ComputeHashCode(_variations);
+ }
+
+ ///
+ /// Looks up the value for an axis.
+ ///
+ /// The axis tag.
+ /// The axis value, or 0 when the axis is not set.
+ /// true when the axis is set; false otherwise.
+ public bool TryGetValue(OpenTypeTag tag, out double value)
+ {
+ foreach (var variation in _variations)
+ {
+ if (variation.Tag == tag)
+ {
+ value = variation.Value;
+ return true;
+ }
+ }
+
+ value = 0;
+ return false;
+ }
+
+ ///
+ /// Parses a comma-separated list of tag=value pairs, e.g.
+ /// "wght=700, wdth=85". Whitespace around pairs, tags and values is
+ /// ignored; an empty string yields ; duplicate tags collapse
+ /// to the last value.
+ ///
+ /// A pair is not tag=value, a tag is not
+ /// a valid four-character OpenType tag, or a value is not an invariant number
+ /// (NaN is rejected; infinite values are accepted and clamp to the axis
+ /// range when applied).
+ public static FontVariationSettings Parse(string s)
+ {
+ if (s is null)
+ {
+ throw new ArgumentNullException(nameof(s));
+ }
+
+ if (string.IsNullOrWhiteSpace(s))
+ {
+ return Empty;
+ }
+
+ var variations = new List();
+
+ foreach (var part in s.Split(','))
+ {
+ var pair = part.AsSpan().Trim();
+
+ if (pair.IsEmpty)
+ {
+ continue;
+ }
+
+ var separator = pair.IndexOf('=');
+
+ if (separator <= 0 || separator == pair.Length - 1)
+ {
+ throw new FormatException(
+ $"Invalid font variation '{pair.ToString()}': expected tag=value.");
+ }
+
+ var tagText = pair.Slice(0, separator).Trim();
+ var valueText = pair.Slice(separator + 1).Trim();
+
+ if (tagText.IsEmpty || tagText.Length > 4)
+ {
+ throw new FormatException(
+ $"Invalid font variation axis tag '{tagText.ToString()}'.");
+ }
+
+ // The value text is already trimmed, so no whitespace styles — NumberStyles.Float
+ // would silently re-allow leading/trailing whitespace inside the number itself.
+ const NumberStyles valueStyles =
+ NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint | NumberStyles.AllowExponent;
+
+ if (!double.TryParse(valueText, valueStyles, CultureInfo.InvariantCulture, out var value) ||
+ double.IsNaN(value))
+ {
+ throw new FormatException(
+ $"Invalid font variation value '{valueText.ToString()}' for axis '{tagText.ToString()}'.");
+ }
+
+ variations.Add(new FontVariation(OpenTypeTag.Parse(tagText.ToString()), value));
+ }
+
+ return variations.Count == 0 ? Empty : new FontVariationSettings(variations);
+ }
+
+ /// Returns the parseable string form, e.g. wght=700,wdth=85.
+ public override string ToString()
+ {
+ if (_variations.IsEmpty)
+ {
+ return string.Empty;
+ }
+
+ var builder = new StringBuilder();
+
+ foreach (var variation in _variations)
+ {
+ if (builder.Length > 0)
+ {
+ builder.Append(',');
+ }
+
+ builder.Append(variation.ToString());
+ }
+
+ return builder.ToString();
+ }
+
+ ///
+ public bool Equals(FontVariationSettings? other)
+ {
+ if (other is null)
+ {
+ return false;
+ }
+
+ if (ReferenceEquals(this, other))
+ {
+ return true;
+ }
+
+ if (_hashCode != other._hashCode)
+ {
+ return false;
+ }
+
+ return _variations.AsSpan().SequenceEqual(other._variations.AsSpan());
+ }
+
+ ///
+ public override bool Equals(object? obj) => Equals(obj as FontVariationSettings);
+
+ ///
+ public override int GetHashCode() => _hashCode;
+
+ private static int ComputeHashCode(ImmutableArray variations)
+ {
+ var hash = new HashCode();
+
+ foreach (var variation in variations)
+ {
+ hash.Add(variation.Tag);
+ hash.Add(variation.Value);
+ }
+
+ return hash.ToHashCode();
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/BigEndianBinaryReader.cs b/src/Avalonia.Base/Media/Fonts/Tables/BigEndianBinaryReader.cs
index 58d162847f..a1aae385e3 100644
--- a/src/Avalonia.Base/Media/Fonts/Tables/BigEndianBinaryReader.cs
+++ b/src/Avalonia.Base/Media/Fonts/Tables/BigEndianBinaryReader.cs
@@ -167,6 +167,8 @@ namespace Avalonia.Media.Fonts.Tables
public ushort[] ReadUInt16Array(int length)
{
+ EnsureAvailable(length, sizeof(ushort));
+
ushort[] data = new ushort[length];
for (int i = 0; i < length; i++)
@@ -187,6 +189,8 @@ namespace Avalonia.Media.Fonts.Tables
public uint[] ReadUInt32Array(int length)
{
+ EnsureAvailable(length, sizeof(uint));
+
uint[] data = new uint[length];
for (int i = 0; i < length; i++)
@@ -199,6 +203,8 @@ namespace Avalonia.Media.Fonts.Tables
public byte[] ReadUInt8Array(int length)
{
+ EnsureAvailable(length, sizeof(byte));
+
byte[] data = new byte[length];
ReadBytesInternal(data, length);
@@ -208,6 +214,8 @@ namespace Avalonia.Media.Fonts.Tables
public short[] ReadInt16Array(int length)
{
+ EnsureAvailable(length, sizeof(short));
+
short[] data = new short[length];
for (int i = 0; i < length; i++)
@@ -253,9 +261,13 @@ namespace Avalonia.Media.Fonts.Tables
public uint ReadOffset32() => ReadUInt32();
+ ///
+ /// Reads up to bytes, truncating at the end of the span; the
+ /// returned array is shorter than when fewer bytes remain.
+ ///
public byte[] ReadBytes(int count)
{
- int available = Math.Min(count, _span.Length - _position);
+ int available = Math.Clamp(count, 0, _span.Length - _position);
byte[] ret = new byte[available];
@@ -307,12 +319,39 @@ namespace Avalonia.Media.Fonts.Tables
private readonly void EnsureAvailable(int size)
{
- if (_position + size > _span.Length)
+ // Unsigned comparison also rejects negative sizes and avoids the `_position + size`
+ // int overflow a hostile size would otherwise wrap past the length check.
+ if ((uint)size > (uint)(_span.Length - _position))
+ {
+ ThrowEndOfSpan(size);
+ }
+ }
+
+ // Validates that `count` elements of `elementSize` bytes are available, without
+ // overflowing and before any allocation, so that a hostile count cannot trigger a huge
+ // array allocation.
+ private readonly void EnsureAvailable(int count, int elementSize)
+ {
+ if (count < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(count), count, "count must be non-negative.");
+ }
+
+ long size = (long)count * elementSize;
+
+ if (size > _span.Length - _position)
{
- throw new InvalidOperationException($"End of span reached with {size - (_span.Length - _position)} byte{(size - (_span.Length - _position) == 1 ? "s" : string.Empty)} left to read.");
+ ThrowEndOfSpan(size);
}
}
+ private readonly void ThrowEndOfSpan(long size)
+ {
+ long missing = size - (_span.Length - _position);
+
+ throw new InvalidOperationException($"End of span reached with {missing} byte{(missing == 1 ? string.Empty : "s")} left to read.");
+ }
+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool TryConvert(T input, out TEnum value)
where T : struct, IConvertible, IFormattable, IComparable
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..9e16a1c577
--- /dev/null
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CharacterToGlyphMapDictionary.cs
@@ -0,0 +1,186 @@
+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;
+ private int _cachedCount = -1;
+
+ ///
+ /// Initializes a new instance of the class that wraps the specified .
+ ///
+ ///
+ public CharacterToGlyphMapDictionary(CharacterToGlyphMap map)
+ {
+ _map = map;
+ }
+
+ ///
+ /// Gets the glyph ID corresponding to the specified code point. If the code point does not have a corresponding glyph ID in the map, a is thrown.
+ ///
+ ///
+ ///
+ ///
+ public ushort this[int key]
+ {
+ get
+ {
+ if (!_map.TryGetGlyph(key, out var glyphId))
+ {
+ throw new KeyNotFoundException($"The code point {key} was not found in the character map.");
+ }
+
+ return glyphId;
+ }
+ }
+
+ ///
+ /// Yields the code points that have corresponding glyph IDs in the map. The order of the code points is not guaranteed to match the order of the glyph IDs returned by .
+ ///
+ public IEnumerable Keys
+ {
+ get
+ {
+ foreach (var range in GetRanges())
+ {
+ for (int codePoint = range.Start; codePoint <= range.End; codePoint++)
+ {
+ // Membership must match TryGetValue (TryGetGlyph), not ContainsGlyph:
+ // the two predicates disagree for mappings that resolve to glyph 0, and
+ // Keys yielding a key the indexer rejects breaks the dictionary contract.
+ if (_map.TryGetGlyph(codePoint, out _))
+ {
+ yield return codePoint;
+ }
+ }
+ }
+ }
+ }
+
+ ///
+ /// Yields the glyph IDs corresponding to the code points in the map. The order of the glyph IDs is not guaranteed to match the order of the code points returned by .
+ ///
+ 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;
+ }
+ }
+ }
+ }
+ }
+
+ ///
+ /// Returns the number of code point to glyph ID mappings in the map. This is computed by iterating over all code points in the mapped ranges and counting those that have a corresponding glyph ID.
+ /// The result is cached after the first computation for efficiency.
+ ///
+ public int Count
+ {
+ get
+ {
+ int cachedCount = _cachedCount;
+ if (cachedCount >= 0)
+ {
+ return cachedCount;
+ }
+
+ int count = 0;
+ foreach (var range in GetRanges())
+ {
+ for (int codePoint = range.Start; codePoint <= range.End; codePoint++)
+ {
+ if (_map.TryGetGlyph(codePoint, out _))
+ {
+ count++;
+ }
+ }
+ }
+
+ _cachedCount = count;
+ return count;
+ }
+ }
+
+ ///
+ /// Determines whether the map contains a mapping for the specified code point. This is implemented by calling and returning true if it returns true, regardless of the glyph ID returned.
+ ///
+ ///
+ ///
+ public bool ContainsKey(int key)
+ {
+ return _map.TryGetGlyph(key, out _);
+ }
+
+ ///
+ /// Attempts to get the glyph ID corresponding to the specified code point. This is implemented by calling and returning its result directly, passing through the glyph ID if found.
+ ///
+ /// The code point for which to get the glyph ID.
+ /// When this method returns, contains the glyph ID associated with the specified code point, if the code point is found; otherwise, the default value for the type of the value parameter. This parameter is passed uninitialized.
+ /// true if the map contains a mapping for the specified code point; otherwise, false.
+ public bool TryGetValue(int key, [MaybeNullWhen(false)] out ushort value)
+ {
+ return _map.TryGetGlyph(key, out value);
+ }
+
+ ///
+ /// Returns an enumerator that iterates through the code point to glyph ID mappings in the map. The enumerator yields instances where the key is a code point and the value is the corresponding glyph ID. The order of the mappings is not guaranteed to match the order of the code points or glyph IDs returned by or .
+ ///
+ /// An enumerator that can be used to iterate through the code point to glyph ID mappings.
+ 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();
+ }
+
+ ///
+ /// Gets the list of code point ranges that have mappings in the map. This is implemented by calling and caching the result for efficiency, since the ranges are immutable and expensive to compute.
+ ///
+ ///
+ private List GetRanges()
+ {
+ var cachedRanges = _cachedRanges;
+ if (cachedRanges != null)
+ {
+ return cachedRanges;
+ }
+
+ var ranges = new List();
+ var enumerator = _map.GetMappedRanges();
+ while (enumerator.MoveNext())
+ {
+ ranges.Add(enumerator.Current);
+ }
+
+ _cachedRanges = ranges;
+ return ranges;
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CmapFormat12Or13Table.cs b/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CmapFormat12Or13Table.cs
index cc20e735d5..aab041a334 100644
--- a/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CmapFormat12Or13Table.cs
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CmapFormat12Or13Table.cs
@@ -30,20 +30,28 @@ namespace Avalonia.Media.Fonts.Tables.Cmap
ushort reserved = reader.ReadUInt16();
Debug.Assert(reserved == 0, "Reserved field must be 0.");
+ // Clamp the declared length to the buffer: a corrupt/huge length (or one with the high
+ // bit set, which would cast to a negative int) must not produce an out-of-range slice.
uint length = reader.ReadUInt32();
- _table = table.Slice(0, (int)length);
-
Language = reader.ReadUInt32();
- _groupCount = (int)reader.ReadUInt32();
+ var declaredGroupCount = reader.ReadUInt32();
int groupsOffset = reader.Position;
- int groupsLength = _groupCount * 12;
+ int minimumTableLength = Math.Min(groupsOffset, table.Length);
+ int declaredTableLength = (int)Math.Min(length, (uint)table.Length);
+ int tableLength = Math.Max(declaredTableLength, minimumTableLength);
+
+ _table = table.Slice(0, tableLength);
- Debug.Assert(length >= groupsOffset + groupsLength, "Length must cover all groups.");
+ // Each SequentialMapGroup is 12 bytes. Clamp the group count to what the (length-bounded)
+ // table actually holds — computed in long to avoid the `count * 12` int overflow that a
+ // hostile count (e.g. 0x20000000) would otherwise wrap to a negative slice length.
+ long maxGroups = _table.Length > groupsOffset ? (_table.Length - groupsOffset) / 12 : 0;
+ _groupCount = (int)Math.Min(declaredGroupCount, (uint)maxGroups);
- _groups = _table.Slice(groupsOffset, groupsLength);
+ _groups = _table.Slice(groupsOffset, _groupCount * 12);
}
///
@@ -144,10 +152,24 @@ namespace Avalonia.Media.Fonts.Tables.Cmap
var groups = _groups.Span;
- int start = (int)ReadUInt32BE(groups, index, 0);
- int end = (int)ReadUInt32BE(groups, index, 4);
+ uint start = ReadUInt32BE(groups, index, 0);
+ uint end = ReadUInt32BE(groups, index, 4);
+
+ // Group contents are attacker-controlled: consumers iterate the returned range one
+ // codepoint at a time, so an end beyond the Unicode range (or end == int.MaxValue,
+ // which makes a `<= end` loop condition a tautology) must not get through. Clamp to
+ // the Unicode range and map inverted/out-of-range groups to an empty range so that
+ // enumeration continues with the remaining groups.
+ const uint maxCodepoint = 0x10FFFF;
+
+ if (start > end || start > maxCodepoint)
+ {
+ range = new CodepointRange(0, -1);
+
+ return true;
+ }
- range = new CodepointRange(start, end);
+ range = new CodepointRange((int)start, (int)Math.Min(end, maxCodepoint));
return true;
}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CmapFormat4Table.cs b/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CmapFormat4Table.cs
index 7d1fded616..dbbc7e7614 100644
--- a/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CmapFormat4Table.cs
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CmapFormat4Table.cs
@@ -34,8 +34,6 @@ namespace Avalonia.Media.Fonts.Tables.Cmap
ushort length = reader.ReadUInt16(); // length in bytes of this subtable
- _table = table.Slice(0, length);
-
Language = reader.ReadUInt16(); // language code, 0 for non-language-specific
ushort segCountX2 = reader.ReadUInt16(); // 2 * segCount
@@ -68,14 +66,28 @@ namespace Avalonia.Media.Fonts.Tables.Cmap
// Compute offsets
int endCodeOffset = reader.Position;
- int startCodeOffset = endCodeOffset + _segCount * 2 + 2; // + reservedPad
- int idDeltaOffset = startCodeOffset + _segCount * 2; // after startCodes
- int idRangeOffsetOffset = idDeltaOffset + _segCount * 2; // after idDeltas
- int glyphIdArrayOffset = idRangeOffsetOffset + _segCount * 2; // after idRangeOffsets
- // Ensure declared length is consistent
- Debug.Assert(length >= glyphIdArrayOffset,
- "Subtable length must be at least large enough to contain glyphIdArray.");
+ // Clamp the declared length to the buffer (and to at least the header just read):
+ // a corrupt/short length must not produce an out-of-range slice. Mirrors the
+ // format-12 clamp.
+ int tableLength = Math.Clamp(length, endCodeOffset, table.Length);
+
+ _table = table.Slice(0, tableLength);
+
+ // Clamp the segment count to what the (length-bounded) table actually holds — the
+ // four parallel arrays below take segCount * 8 bytes (+ 2 for reservedPad), and a
+ // hostile segCount must not produce an out-of-range slice.
+ int maxSegCount = Math.Max(0, (tableLength - endCodeOffset - 2) / 8);
+ _segCount = Math.Min(_segCount, maxSegCount);
+
+ // Clamp each derived offset to tableLength so that zero-length slices (e.g. when
+ // _segCount was driven to 0 by a too-short declared length) always start at a valid
+ // position. The reservedPad (+2) between endCodes and startCodes is what makes
+ // startCodeOffset land past the end of a 14-byte clamped table without this guard.
+ int startCodeOffset = Math.Min(endCodeOffset + _segCount * 2 + 2, tableLength); // + reservedPad
+ int idDeltaOffset = Math.Min(startCodeOffset + _segCount * 2, tableLength); // after startCodes
+ int idRangeOffsetOffset = Math.Min(idDeltaOffset + _segCount * 2, tableLength); // after idDeltas
+ int glyphIdArrayOffset = Math.Min(idRangeOffsetOffset + _segCount * 2, tableLength); // after idRangeOffsets
// Slice directly
_endCodes = _table.Slice(endCodeOffset, _segCount * 2);
@@ -86,9 +98,9 @@ namespace Avalonia.Media.Fonts.Tables.Cmap
_idRangeOffsets = _table.Slice(idRangeOffsetOffset, _segCount * 2);
- int glyphCount = (length - glyphIdArrayOffset) / 2;
-
- Debug.Assert(glyphCount >= 0, "GlyphIdArray length must not be negative.");
+ // Whatever remains belongs to glyphIdArray; a truncated table yields a shorter
+ // (possibly empty) array and lookups bounds-check against it.
+ int glyphCount = Math.Max(0, (tableLength - glyphIdArrayOffset) / 2);
_glyphIdArray = _table.Slice(glyphIdArrayOffset, glyphCount * 2);
}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CmapTable.cs b/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CmapTable.cs
index 7774294e76..c22dd89743 100644
--- a/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CmapTable.cs
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CmapTable.cs
@@ -133,10 +133,11 @@ namespace Avalonia.Media.Fonts.Tables.Cmap
return result.Format != CmapFormat.Format0;
}
- // Tries to find the best Format 4 subtable entry based on platform preferences
+ // Tries to find the best Format 4 subtable entry based on encoding then platform.
static bool TryFindFormat4Entry(CmapSubtableEntry[] entries, out CmapSubtableEntry result)
{
result = default;
+ var foundEncodingScore = int.MaxValue;
var foundPlatformScore = int.MaxValue;
foreach (var entry in entries)
@@ -146,6 +147,14 @@ namespace Avalonia.Media.Fonts.Tables.Cmap
continue;
}
+ // A Windows 'Symbol' (encoding 0) subtable maps the F000–F0FF private-use range,
+ // not real Unicode, so it must not be chosen over a Unicode subtable for normal
+ // text. Score the Symbol encoding worse than everything else.
+ var encodingScore = entry.Platform == PlatformID.Windows
+ && entry.Encoding == CmapEncoding.Microsoft_Symbol
+ ? 1
+ : 0;
+
var platformScore = entry.Platform switch
{
PlatformID.Unicode => 0,
@@ -153,16 +162,14 @@ namespace Avalonia.Media.Fonts.Tables.Cmap
_ => 2
};
- if (platformScore < foundPlatformScore)
+ // Lower is better: encoding dominates, platform breaks ties.
+ if (encodingScore < foundEncodingScore ||
+ (encodingScore == foundEncodingScore && platformScore < foundPlatformScore))
{
result = entry;
+ foundEncodingScore = encodingScore;
foundPlatformScore = platformScore;
}
-
- if (foundPlatformScore == 0)
- {
- break; // Best possible match found
- }
}
return result.Format != CmapFormat.Format0;
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Decycler.cs b/src/Avalonia.Base/Media/Fonts/Tables/Decycler.cs
new file mode 100644
index 0000000000..f8d8898217
--- /dev/null
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Decycler.cs
@@ -0,0 +1,173 @@
+using System;
+using System.Collections.Generic;
+
+namespace Avalonia.Media.Fonts.Tables
+{
+ ///
+ /// Errors that can occur during graph traversal with cycle detection.
+ ///
+ internal enum DecyclerError
+ {
+ ///
+ /// A cycle was detected in the 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 node and ensures proper cleanup.
+ ///
+ /// The type of the node 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 node ID from the visited set.
+ ///
+ public void Dispose()
+ {
+ if (!_exited)
+ {
+ _decycler.Exit(_id);
+ _exited = true;
+ }
+ }
+ }
+
+ ///
+ /// Tracks visited nodes to detect cycles in a graph (composite glyphs, paint graphs, etc.).
+ /// Uses a depth limit to prevent stack overflow during recursive traversal.
+ ///
+ ///
+ ///
+ /// Instances are not thread-safe: , , and
+ /// all mutate shared state without synchronization. The intended
+ /// usage pattern is "one instance per traversal" — rent an instance from a pool at
+ /// the start of a single-threaded walk and return it when done. Sharing one
+ /// across concurrent traversals will corrupt the visited
+ /// set.
+ ///
+ ///
+ /// uses exceptions to signal cycle / depth-limit failures.
+ /// Callers that traverse user-supplied data (e.g. font tables) should wrap the
+ /// outermost traversal in a try / catch (DecyclerException) and treat
+ /// the failure as "stop traversing this subgraph".
+ ///
+ ///
+ /// The type of the node identifier.
+ 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 throws a
+ /// . Must be at least 1.
+ /// is less than 1.
+ public Decycler(int maxDepth)
+ {
+ if (maxDepth < 1)
+ {
+ throw new ArgumentOutOfRangeException(nameof(maxDepth), maxDepth, "maxDepth must be at least 1.");
+ }
+
+ _visited = new HashSet();
+ _maxDepth = maxDepth;
+ _currentDepth = 0;
+ }
+
+ ///
+ /// Attempts to enter a node with the given ID.
+ /// Returns a guard that will automatically exit when disposed.
+ ///
+ /// The node 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,
+ $"Graph depth limit of {_maxDepth} exceeded");
+ }
+
+ // HashSet.Add returns false if the item was already in the set, which indicates a cycle.
+ if (!_visited.Add(id))
+ {
+ throw new DecyclerException(
+ DecyclerError.CycleDetected,
+ "Cycle detected in graph");
+ }
+
+ _currentDepth++;
+
+ return new CycleGuard(this, id);
+ }
+
+ ///
+ /// Exits a node, removing it from the visited set.
+ /// Called automatically by CycleGuard.Dispose().
+ ///
+ /// The node identifier to exit.
+ internal void Exit(T id)
+ {
+ // CycleGuard is a copyable ref struct, so a copied guard can double-exit; only
+ // give depth budget back for an id that was actually in the visited set.
+ if (_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/Name/NameRecord.cs b/src/Avalonia.Base/Media/Fonts/Tables/Name/NameRecord.cs
index 794113674d..b88b292569 100644
--- a/src/Avalonia.Base/Media/Fonts/Tables/Name/NameRecord.cs
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Name/NameRecord.cs
@@ -48,9 +48,29 @@ namespace Avalonia.Media.Fonts.Tables.Name
return string.Empty;
}
+ // Offset/Length come straight from the untrusted 'name' record. NameTable.Load validates
+ // the record array but not each record's storage slice, and GetValue runs later during
+ // typeface construction, so a record pointing past the string storage must degrade to an
+ // empty value rather than throw out of the GlyphTypeface constructor and deny the font.
+ // Offset and Length are both ushort, so the sum cannot overflow uint.
+ if ((uint)Offset + Length > (uint)_stringStorage.Length)
+ {
+ return string.Empty;
+ }
+
var span = _stringStorage.Span.Slice(Offset, Length);
- return Encoding.GetString(span);
+ // The encodings NameTable selects substitute U+FFFD for malformed bytes, but guard
+ // against an exception-throwing decoder fallback so a corrupt record degrades to an
+ // empty value instead of denying the font.
+ try
+ {
+ return Encoding.GetString(span);
+ }
+ catch (ArgumentException)
+ {
+ return string.Empty;
+ }
}
}
}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Name/NameTable.cs b/src/Avalonia.Base/Media/Fonts/Tables/Name/NameTable.cs
index f58d6a2551..837be1735d 100644
--- a/src/Avalonia.Base/Media/Fonts/Tables/Name/NameTable.cs
+++ b/src/Avalonia.Base/Media/Fonts/Tables/Name/NameTable.cs
@@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0.
// Ported from: https://github.com/SixLabors/Fonts/blob/034a440aece357341fcc6b02db58ffbe153e54ef/src/SixLabors.Fonts
+using System;
using System.Collections;
using System.Collections.Generic;
using Avalonia.Utilities;
@@ -130,28 +131,60 @@ namespace Avalonia.Media.Fonts.Tables.Name
return null;
}
- var reader = new BigEndianBinaryReader(table.Span);
+ try
+ {
+ var reader = new BigEndianBinaryReader(table.Span);
+
+ reader.ReadUInt16();
+ var count = reader.ReadUInt16();
+ var storageOffset = reader.ReadUInt16();
+
+ const int headerSize = 6;
+ const int recordSize = 12;
+
+ if (table.Length < headerSize)
+ {
+ return null;
+ }
+
+ var recordsSize = count * recordSize;
+ if (recordsSize > table.Length - headerSize)
+ {
+ return null;
+ }
- reader.ReadUInt16();
- var count = reader.ReadUInt16();
- var storageOffset = reader.ReadUInt16();
+ if (storageOffset > table.Length)
+ {
+ return null;
+ }
+
+ var nameStorage = table.Slice(storageOffset);
- var names = new NameRecord[count];
+ var names = new NameRecord[count];
- for (var i = 0; i < count; i++)
+ for (var i = 0; i < count; i++)
+ {
+ var platform = reader.ReadUInt16();
+ var encodingId = reader.ReadUInt16();
+ var encoding = encodingId.AsEncoding();
+ var languageID = reader.ReadUInt16();
+ var nameID = reader.ReadUInt16();
+ var length = reader.ReadUInt16();
+ var offset = reader.ReadUInt16();
+
+ names[i] = new NameRecord(nameStorage, platform, languageID, nameID, offset, length, encoding);
+ }
+
+ return new NameTable(names);
+ }
+ catch (Exception ex) when (ex is InvalidOperationException or ArgumentOutOfRangeException)
{
- var platform = reader.ReadUInt16();
- var encodingId = reader.ReadUInt16();
- var encoding = encodingId.AsEncoding();
- var languageID = reader.ReadUInt16();
- var nameID = reader.ReadUInt16();
- var length = reader.ReadUInt16();
- var offset = reader.ReadUInt16();
-
- names[i] = new NameRecord(table.Slice(storageOffset), platform, languageID, nameID, offset, length, encoding);
+ // A present-but-malformed 'name' table must not deny the font; callers fall back to a
+ // default family name, the same outcome as an absent 'name'. Only the parsing-related
+ // exceptions are swallowed (end-of-span from BigEndianBinaryReader, out-of-range from
+ // Memory.Slice) so genuine/fatal failures still surface.
+ return null;
}
-
- return new NameTable(names);
}
public IEnumerator GetEnumerator()
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/PostTable.cs b/src/Avalonia.Base/Media/Fonts/Tables/PostTable.cs
index a017faa6fc..aa551993f1 100644
--- a/src/Avalonia.Base/Media/Fonts/Tables/PostTable.cs
+++ b/src/Avalonia.Base/Media/Fonts/Tables/PostTable.cs
@@ -1,3 +1,5 @@
+using System;
+
namespace Avalonia.Media.Fonts.Tables
{
internal readonly struct PostTable
@@ -27,9 +29,19 @@ namespace Avalonia.Media.Fonts.Tables
return default;
}
- var binaryReader = new BigEndianBinaryReader(table.Span);
+ try
+ {
+ var binaryReader = new BigEndianBinaryReader(table.Span);
- return Load(ref binaryReader);
+ return Load(ref binaryReader);
+ }
+ catch (InvalidOperationException)
+ {
+ // 'post' only carries cosmetic hints (underline metrics, italic angle, fixed-pitch
+ // flag), so a present-but-malformed table must degrade to defaults rather than deny the
+ // whole font — the same outcome as an absent 'post'.
+ return default;
+ }
}
private static PostTable Load(ref BigEndianBinaryReader reader)
diff --git a/src/Avalonia.Base/Media/GlyphDrawingOptions.cs b/src/Avalonia.Base/Media/GlyphDrawingOptions.cs
new file mode 100644
index 0000000000..f591444311
--- /dev/null
+++ b/src/Avalonia.Base/Media/GlyphDrawingOptions.cs
@@ -0,0 +1,70 @@
+using System;
+
+namespace Avalonia.Media
+{
+ ///
+ /// Options that influence how a single glyph is drawn, independent of variable-font
+ /// axis configuration: which CPAL palette to use for color glyphs, and which bitmap
+ /// strike size to prefer for bitmap-based glyphs.
+ ///
+ ///
+ /// All properties are optional. null means "use the font's default" (palette 0
+ /// for color glyphs; no bitmap strike preference). Concerns specific to variable fonts
+ /// (axis coordinates, named instances) live on .
+ ///
+ public sealed record GlyphDrawingOptions
+ {
+ ///
+ /// Singleton instance representing "use the font's defaults".
+ ///
+ public static GlyphDrawingOptions Default { get; } = new();
+
+ private readonly int? _paletteIndex;
+ private readonly int? _pixelSize;
+
+ ///
+ /// Gets the optional CPAL palette index used to resolve colors for
+ /// COLR v0 / COLR v1 glyphs.
+ ///
+ ///
+ /// When null, the font's default palette (palette 0) is used.
+ ///
+ /// Set to a negative value.
+ public int? PaletteIndex
+ {
+ get => _paletteIndex;
+ init
+ {
+ if (value is { } v && v < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(value), v, "PaletteIndex must be non-negative.");
+ }
+
+ _paletteIndex = value;
+ }
+ }
+
+ ///
+ /// Gets the optional pixel size used to select a bitmap strike from sbix,
+ /// CBDT or EBDT tables.
+ ///
+ ///
+ /// When null, no bitmap strike is selected and the font's outline (or
+ /// color-layer) representation is used instead.
+ ///
+ /// Set to a value less than 1.
+ public int? PixelSize
+ {
+ get => _pixelSize;
+ init
+ {
+ if (value is { } v && v < 1)
+ {
+ throw new ArgumentOutOfRangeException(nameof(value), v, "PixelSize must be at least 1.");
+ }
+
+ _pixelSize = 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/IGlyphDrawing.cs b/src/Avalonia.Base/Media/IGlyphDrawing.cs
new file mode 100644
index 0000000000..f6284bdbc0
--- /dev/null
+++ b/src/Avalonia.Base/Media/IGlyphDrawing.cs
@@ -0,0 +1,46 @@
+namespace Avalonia.Media
+{
+ ///
+ /// Represents a glyph that knows how to draw itself into a .
+ ///
+ ///
+ /// Implementations are produced by the per-format renderers (color layers from
+ /// COLR/CPAL, bitmap strikes from sbix/CBDT, etc.) and shielded behind this contract
+ /// so callers can render any color glyph without caring which font-table format
+ /// produced it. For plain outline glyphs, use GlyphTypeface.GetGlyphOutline
+ /// instead — outlines are returned as rather than via this
+ /// interface.
+ ///
+ public interface IGlyphDrawing
+ {
+ ///
+ /// Gets the format this drawing was produced from. Callers can use this to
+ /// branch on rendering behaviour without downcasting.
+ ///
+ GlyphDrawingType Type { get; }
+
+ ///
+ /// Gets the axis-aligned bounding rectangle of the drawing, in drawing-space
+ /// coordinates (Y-down).
+ ///
+ ///
+ /// The rectangle is relative to the drawing's local origin. To get bounds at a
+ /// specific paint location, translate by the origin passed to .
+ /// Implementations are expected to compute this once (e.g. from the font's clip
+ /// box or layer extents) and cache it.
+ ///
+ Rect Bounds { get; }
+
+ ///
+ /// Draws the glyph into at .
+ ///
+ /// The drawing context to render to.
+ ///
+ /// The drawing-space point (Y-down) at which the glyph's local origin should
+ /// land. For text rendering this is typically the pen position on the baseline.
+ /// Implementations apply the Y-flip from font-space (Y-up) internally, so
+ /// callers don't need to flip themselves.
+ ///
+ void Draw(DrawingContext context, Point origin);
+ }
+}
diff --git a/src/Avalonia.Base/Media/NormalizedVariationPosition.cs b/src/Avalonia.Base/Media/NormalizedVariationPosition.cs
new file mode 100644
index 0000000000..d74108ab5a
--- /dev/null
+++ b/src/Avalonia.Base/Media/NormalizedVariationPosition.cs
@@ -0,0 +1,275 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using Avalonia.Media.Fonts;
+
+namespace Avalonia.Media
+{
+ ///
+ /// A single axis tag / normalized-coordinate pair within a
+ /// .
+ ///
+ /// The OpenType axis tag (e.g. wght, wdth).
+ ///
+ /// The axis position in the OpenType normalized range [-1.0, 1.0], as
+ /// produced by applying the font's avar table to a user-space value.
+ ///
+ internal readonly record struct NormalizedVariationCoordinate(OpenTypeTag Axis, float NormalizedValue);
+
+ ///
+ /// A variable font's position in OpenType normalized coordinate space: the internal
+ /// currency between a typeface and its variation tables.
+ ///
+ ///
+ ///
+ /// Normalized coordinates are font-relative — the same value means different
+ /// user-space positions under different fvar ranges and avar maps — so
+ /// they are deliberately not public API. The public currency is the user-space
+ /// ; a position is derived from it per font, and
+ /// keys the per-typeface variation caches.
+ ///
+ ///
+ /// is a value type with structural
+ /// equality. The all-zero value represents "no variation"
+ /// — the font's design defaults. The property tests for this
+ /// case.
+ ///
+ ///
+ /// Coordinates are stored in a single sorted by
+ /// axis tag. Equality, hash and axis lookup are all linear scans over the array;
+ /// for typical axis counts (one to a handful) this is faster than a hash-based
+ /// dictionary and allocates nothing on the lookup path. The hash code is computed
+ /// at construction and cached.
+ ///
+ ///
+ internal readonly struct NormalizedVariationPosition : IEquatable
+ {
+ private readonly ImmutableArray _coordinates;
+ private readonly int _hashCode;
+
+ private NormalizedVariationPosition(ImmutableArray sortedCoordinates)
+ {
+ _coordinates = sortedCoordinates;
+ _hashCode = ComputeHashCode(sortedCoordinates);
+ }
+
+ ///
+ /// Gets the axis coordinates, sorted by ascending.
+ ///
+ ///
+ /// Always returns a non-default (possibly empty) .
+ /// Callers can iterate, index, or pass it to span-based APIs without first
+ /// checking .
+ ///
+ public ImmutableArray Coordinates =>
+ _coordinates.IsDefault ? ImmutableArray.Empty : _coordinates;
+
+ ///
+ /// Gets a value indicating whether this is the default ("no variation")
+ /// position — equivalent to default(NormalizedVariationPosition).
+ ///
+ public bool IsDefault => _coordinates.IsDefaultOrEmpty;
+
+ ///
+ /// Creates a from an axis-tag →
+ /// normalized-coordinate map.
+ ///
+ ///
+ /// Axis coordinates in the OpenType-normalized range [-1.0, 1.0]. The
+ /// dictionary is copied into a sorted internal store.
+ ///
+ ///
+ /// default(NormalizedVariationPosition) when the input is empty or every
+ /// coordinate is 0 (the axis default); otherwise a position carrying the
+ /// sorted non-zero coordinates.
+ ///
+ /// is null.
+ ///
+ /// A coordinate value is NaN or outside [-1, 1].
+ ///
+ ///
+ /// The dictionary enumerates two entries for the same axis.
+ ///
+ public static NormalizedVariationPosition FromCoordinates(
+ IReadOnlyDictionary normalizedCoordinates)
+ {
+ if (normalizedCoordinates is null)
+ {
+ throw new ArgumentNullException(nameof(normalizedCoordinates));
+ }
+
+ if (normalizedCoordinates.Count == 0)
+ {
+ return default;
+ }
+
+ var builder = ImmutableArray.CreateBuilder(normalizedCoordinates.Count);
+
+ foreach (var kvp in normalizedCoordinates)
+ {
+ ValidateCoordinate(kvp.Value, kvp.Key, nameof(normalizedCoordinates));
+ builder.Add(new NormalizedVariationCoordinate(kvp.Key, kvp.Value));
+ }
+
+ return CreateFromValidated(builder, nameof(normalizedCoordinates));
+ }
+
+ ///
+ /// Creates a from a span of coordinates.
+ ///
+ ///
+ /// Axis coordinates in the OpenType-normalized range [-1.0, 1.0]. Each
+ /// axis must appear at most once; the span is copied into a sorted internal
+ /// store.
+ ///
+ ///
+ /// default(NormalizedVariationPosition) when the span is empty or every
+ /// coordinate is 0 (the axis default); otherwise a position carrying the
+ /// sorted non-zero coordinates.
+ ///
+ ///
+ /// A coordinate value is NaN or outside [-1, 1].
+ ///
+ ///
+ /// The span contains two entries for the same axis.
+ ///
+ public static NormalizedVariationPosition FromCoordinates(
+ ReadOnlySpan normalizedCoordinates)
+ {
+ if (normalizedCoordinates.IsEmpty)
+ {
+ return default;
+ }
+
+ var builder = ImmutableArray.CreateBuilder(normalizedCoordinates.Length);
+
+ foreach (var coord in normalizedCoordinates)
+ {
+ ValidateCoordinate(coord.NormalizedValue, coord.Axis, nameof(normalizedCoordinates));
+ builder.Add(coord);
+ }
+
+ return CreateFromValidated(builder, nameof(normalizedCoordinates));
+ }
+
+ private static NormalizedVariationPosition CreateFromValidated(
+ ImmutableArray.Builder builder, string paramName)
+ {
+ builder.Sort(static (a, b) => ((uint)a.Axis).CompareTo((uint)b.Axis));
+
+ for (var i = 1; i < builder.Count; i++)
+ {
+ if (builder[i].Axis == builder[i - 1].Axis)
+ {
+ throw new ArgumentException(
+ $"Duplicate axis '{builder[i].Axis}' in coordinates.",
+ paramName);
+ }
+ }
+
+ // A normalized value of 0 is the axis default: dropping it keeps explicitly-default
+ // positions structurally equal to positions that omit the axis, so both produce one
+ // cache key (and one variation clone) instead of two. Done after the duplicate check
+ // so that duplicates still throw regardless of their values.
+ for (var i = builder.Count - 1; i >= 0; i--)
+ {
+ if (builder[i].NormalizedValue == 0f)
+ {
+ builder.RemoveAt(i);
+ }
+ }
+
+ if (builder.Count == 0)
+ {
+ return default;
+ }
+
+ return new NormalizedVariationPosition(builder.ToImmutable());
+ }
+
+ ///
+ /// Looks up the normalized value for a single axis.
+ ///
+ /// The axis tag to look up.
+ /// The axis's normalized value, or 0 when
+ /// the axis is not present.
+ /// true when the axis is present; false otherwise.
+ public bool TryGetCoordinate(OpenTypeTag axis, out float normalizedValue)
+ {
+ if (!_coordinates.IsDefault)
+ {
+ foreach (var coord in _coordinates)
+ {
+ if (coord.Axis == axis)
+ {
+ normalizedValue = coord.NormalizedValue;
+ return true;
+ }
+ }
+ }
+
+ normalizedValue = 0f;
+ return false;
+ }
+
+ ///
+ /// Returns the normalized value for a single axis, or
+ /// if the axis is not present.
+ ///
+ public float GetCoordinateOrDefault(OpenTypeTag axis, float fallback = 0f)
+ => TryGetCoordinate(axis, out var value) ? value : fallback;
+
+ ///
+ public bool Equals(NormalizedVariationPosition other)
+ {
+ // Cheap reject via the cached hash first; then an allocation-free element-wise
+ // compare. Coordinates normalizes default → Empty, so the spans are always valid,
+ // and the span overload (not LINQ SequenceEqual) avoids boxing the ImmutableArray
+ // to IEnumerable — this type is used as a dictionary key. NormalizedVariationCoordinate
+ // is a record struct, so the per-element compare uses its (Axis, NormalizedValue)
+ // value equality.
+ if (_hashCode != other._hashCode)
+ {
+ return false;
+ }
+
+ return Coordinates.AsSpan().SequenceEqual(other.Coordinates.AsSpan());
+ }
+
+ ///
+ public override bool Equals(object? obj) => obj is NormalizedVariationPosition other && Equals(other);
+
+ ///
+ public override int GetHashCode() => _hashCode;
+
+ public static bool operator ==(NormalizedVariationPosition left, NormalizedVariationPosition right) => left.Equals(right);
+
+ public static bool operator !=(NormalizedVariationPosition left, NormalizedVariationPosition right) => !left.Equals(right);
+
+ private static void ValidateCoordinate(float value, OpenTypeTag axis, string paramName)
+ {
+ if (float.IsNaN(value) || value < -1f || value > 1f)
+ {
+ throw new ArgumentOutOfRangeException(
+ paramName, value,
+ $"Normalized coordinate for axis '{axis}' must be in [-1, 1]; was {value}.");
+ }
+ }
+
+ private static int ComputeHashCode(ImmutableArray coordinates)
+ {
+ if (coordinates.IsDefaultOrEmpty)
+ {
+ return 0;
+ }
+
+ var hash = new HashCode();
+ foreach (var coord in coordinates)
+ {
+ hash.Add(coord.Axis);
+ hash.Add(coord.NormalizedValue);
+ }
+ return hash.ToHashCode();
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Utilities/ObjectPool.cs b/src/Avalonia.Base/Utilities/ObjectPool.cs
new file mode 100644
index 0000000000..31a693615b
--- /dev/null
+++ b/src/Avalonia.Base/Utilities/ObjectPool.cs
@@ -0,0 +1,96 @@
+using System;
+using System.Collections.Concurrent;
+using System.Runtime.CompilerServices;
+using System.Threading;
+
+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. Must be at least 1. Default is 32.
+ /// is null.
+ /// is less than 1.
+ public ObjectPool(Func factory, Func? validator = null, int maxSize = 32)
+ {
+ if (maxSize < 1)
+ {
+ throw new ArgumentOutOfRangeException(nameof(maxSize), maxSize, "maxSize must be at least 1.");
+ }
+
+ _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.
+ ///
+ ///
+ /// Callers must not return the same item more than once without an intervening
+ /// . The pool does not detect duplicate returns; doing so would
+ /// place the same instance into the pool twice and let two callers rent it
+ /// concurrently. The pool also does not call on
+ /// items it drops (e.g. when full or when the validator returns false);
+ /// any cleanup belongs in the validator or in the caller.
+ ///
+ [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 (Volatile.Read(ref _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/tests/Avalonia.Base.UnitTests/Media/FontVariationSettingsTests.cs b/tests/Avalonia.Base.UnitTests/Media/FontVariationSettingsTests.cs
new file mode 100644
index 0000000000..296019a8ef
--- /dev/null
+++ b/tests/Avalonia.Base.UnitTests/Media/FontVariationSettingsTests.cs
@@ -0,0 +1,148 @@
+using System;
+using Avalonia.Media;
+using Avalonia.Media.Fonts;
+using Xunit;
+
+namespace Avalonia.Base.UnitTests.Media
+{
+ ///
+ /// The user-space variation settings value: parse round-trips, order-independent
+ /// structural equality, last-wins duplicate handling, and the lookups — the contract
+ /// that lets the type serve as a style value and a cache key.
+ ///
+ public class FontVariationSettingsTests
+ {
+ private static readonly OpenTypeTag s_wght = OpenTypeTag.Parse("wght");
+ private static readonly OpenTypeTag s_wdth = OpenTypeTag.Parse("wdth");
+ private static readonly OpenTypeTag s_opsz = OpenTypeTag.Parse("opsz");
+
+ [Fact]
+ public void Empty_Is_Empty_And_Round_Trips()
+ {
+ Assert.True(FontVariationSettings.Empty.IsEmpty);
+ Assert.Empty(FontVariationSettings.Empty.Variations);
+ Assert.Equal(string.Empty, FontVariationSettings.Empty.ToString());
+ Assert.Equal(FontVariationSettings.Empty, FontVariationSettings.Parse(""));
+ Assert.Equal(FontVariationSettings.Empty, FontVariationSettings.Parse(" "));
+ }
+
+ [Fact]
+ public void Parse_Reads_Comma_Separated_Tag_Value_Pairs()
+ {
+ var settings = FontVariationSettings.Parse(" wght = 700 , wdth=85.5 ");
+
+ Assert.Equal(2, settings.Variations.Length);
+ Assert.True(settings.TryGetValue(s_wght, out var wght));
+ Assert.Equal(700, wght);
+ Assert.True(settings.TryGetValue(s_wdth, out var wdth));
+ Assert.Equal(85.5, wdth);
+ }
+
+ [Theory]
+ [InlineData("wght")]
+ [InlineData("wght=")]
+ [InlineData("=700")]
+ [InlineData("weight=700")]
+ [InlineData("wght=seven")]
+ [InlineData("wght=NaN")]
+ public void Parse_Rejects_Malformed_Input(string input)
+ {
+ Assert.Throws(() => FontVariationSettings.Parse(input));
+ }
+
+ [Fact]
+ public void ToString_Round_Trips_Through_Parse()
+ {
+ var settings = FontVariationSettings.Parse("opsz=14.25,wght=650");
+ var roundTripped = FontVariationSettings.Parse(settings.ToString());
+
+ Assert.Equal(settings, roundTripped);
+ Assert.Equal("opsz=14.25,wght=650", settings.ToString());
+ }
+
+ [Fact]
+ public void Equality_Is_Order_Independent_With_Matching_Hashes()
+ {
+ var a = new FontVariationSettings(new[]
+ {
+ new FontVariation(s_wght, 700),
+ new FontVariation(s_opsz, 36),
+ });
+ var b = new FontVariationSettings(new[]
+ {
+ new FontVariation(s_opsz, 36),
+ new FontVariation(s_wght, 700),
+ });
+
+ Assert.Equal(a, b);
+ Assert.Equal(a.GetHashCode(), b.GetHashCode());
+ Assert.NotEqual(a, FontVariationSettings.Parse("wght=700"));
+ Assert.False(a.Equals(null));
+ }
+
+ [Fact]
+ public void Duplicate_Tags_Collapse_To_The_Last_Value()
+ {
+ // CSS font-variation-settings behavior: the last occurrence wins.
+ var settings = FontVariationSettings.Parse("wght=400,wght=700");
+
+ Assert.Equal(1, settings.Variations.Length);
+ Assert.True(settings.TryGetValue(s_wght, out var wght));
+ Assert.Equal(700, wght);
+ }
+
+ [Fact]
+ public void Variations_Are_Sorted_By_Tag()
+ {
+ var settings = FontVariationSettings.Parse("wght=700,opsz=14,wdth=85");
+
+ Assert.Equal(s_opsz, settings.Variations[0].Tag);
+ Assert.Equal(s_wdth, settings.Variations[1].Tag);
+ Assert.Equal(s_wght, settings.Variations[2].Tag);
+ }
+
+ [Fact]
+ public void Constructor_Rejects_NaN_And_Null()
+ {
+ Assert.Throws(() =>
+ new FontVariationSettings(new[] { new FontVariation(s_wght, double.NaN) }));
+ Assert.Throws(() => new FontVariationSettings(null!));
+ }
+
+ [Fact]
+ public void Infinite_Values_Are_Accepted_And_Round_Trip()
+ {
+ // Infinities clamp to the axis range when applied, like any out-of-range value.
+ var settings = new FontVariationSettings(new[]
+ {
+ new FontVariation(s_wght, double.PositiveInfinity),
+ new FontVariation(s_opsz, double.NegativeInfinity),
+ });
+
+ Assert.True(settings.TryGetValue(s_wght, out var wght));
+ Assert.Equal(double.PositiveInfinity, wght);
+
+ var roundTripped = FontVariationSettings.Parse(settings.ToString());
+
+ Assert.Equal(settings, roundTripped);
+ Assert.True(roundTripped.TryGetValue(s_opsz, out var opsz));
+ Assert.Equal(double.NegativeInfinity, opsz);
+ }
+
+ [Fact]
+ public void TryGetValue_Misses_Report_False_And_Zero()
+ {
+ var settings = FontVariationSettings.Parse("wght=700");
+
+ Assert.False(settings.TryGetValue(s_opsz, out var value));
+ Assert.Equal(0, value);
+ }
+
+ [Fact]
+ public void Variation_ToString_Is_The_Pair_Form()
+ {
+ Assert.Equal("wght=700", new FontVariation(s_wght, 700).ToString());
+ Assert.Equal("opsz=14.25", new FontVariation(s_opsz, 14.25).ToString());
+ }
+ }
+}
diff --git a/tests/Avalonia.Base.UnitTests/Media/Fonts/MalformedCmapNamePostTests.cs b/tests/Avalonia.Base.UnitTests/Media/Fonts/MalformedCmapNamePostTests.cs
new file mode 100644
index 0000000000..0d386133b9
--- /dev/null
+++ b/tests/Avalonia.Base.UnitTests/Media/Fonts/MalformedCmapNamePostTests.cs
@@ -0,0 +1,257 @@
+using Avalonia.UnitTests;
+using Xunit;
+
+namespace Avalonia.Base.UnitTests.Media.Fonts
+{
+ ///
+ /// Characterization tests for the cmap / name / post robustness of the GlyphTypeface parsers,
+ /// using the / harness. A present-but-
+ /// malformed cosmetic table must degrade to the same fallback as an absent one rather than denying
+ /// the whole font, and cmap parsing must not overflow or mis-select a subtable.
+ ///
+ public class MalformedCmapNamePostTests
+ {
+ // ── present-but-malformed cosmetic tables must not deny the font ──
+ //
+ // A *missing* 'post' / 'name' table is handled gracefully (see the two "Missing_*" tests
+ // below), and so is a *present-but-malformed* (truncated) one: the loader isolates the
+ // over-reading parse and degrades to the same fallback as an absent table.
+
+ [Fact]
+ public void Truncated_Post_Table_Does_Not_Deny_The_Font()
+ {
+ // Keep only the 4-byte version field; reading the rest of the header over-runs.
+ var font = SyntheticFont.FromAsset(SyntheticFont.Assets.InterRegular).Truncate("post", 4);
+
+ var typeface = font.TryCreateGlyphTypeface();
+
+ // A malformed 'post' (underline / italic-angle / fixed-pitch hints only) degrades to
+ // defaults; the rest of the font — including the intact 'name' — still loads.
+ Assert.NotNull(typeface);
+ Assert.Equal("Inter", typeface!.FamilyName);
+ }
+
+ [Fact]
+ public void Truncated_Name_Table_Falls_Back_To_Unknown_Family()
+ {
+ // Keep only format + count; reading stringOffset and the record array over-runs.
+ var font = SyntheticFont.FromAsset(SyntheticFont.Assets.InterRegular).Truncate("name", 4);
+
+ var typeface = font.TryCreateGlyphTypeface();
+
+ // A malformed 'name' degrades to no name table, so the family name falls back to "unknown"
+ // instead of denying a renderable font.
+ Assert.NotNull(typeface);
+ Assert.Equal("unknown", typeface!.FamilyName);
+ }
+
+ [Fact]
+ public void Missing_Post_Table_Still_Loads_The_Font()
+ {
+ // Documents the already-correct path: an *absent* cosmetic table is tolerated.
+ var font = SyntheticFont.FromAsset(SyntheticFont.Assets.InterRegular).Remove("post");
+
+ var typeface = font.TryCreateGlyphTypeface();
+
+ Assert.NotNull(typeface);
+ Assert.Equal("Inter", typeface!.FamilyName);
+ }
+
+ [Fact]
+ public void Missing_Name_Table_Falls_Back_To_Unknown_Family()
+ {
+ // Documents the already-correct path: an *absent* name table yields a usable typeface
+ // with a fallback family name.
+ var font = SyntheticFont.FromAsset(SyntheticFont.Assets.InterRegular).Remove("name");
+
+ var typeface = font.TryCreateGlyphTypeface();
+
+ Assert.NotNull(typeface);
+ Assert.Equal("unknown", typeface!.FamilyName);
+ }
+
+ // ── cmap format 12 nGroups*12 overflow denies the whole font ──
+
+ [Fact]
+ public void Cmap_Format12_NGroups_Overflow_Does_Not_Deny_The_Font()
+ {
+ // numGroups = 0x20000000 would make `numGroups * 12` overflow int32 to a negative slice
+ // length and throw out of the (throwing) CmapTable.Load.
+ var font = SyntheticFont.FromAsset(SyntheticFont.Assets.InterRegular)
+ .Replace("cmap", BuildCmapWithOverflowingFormat12(numGroups: 0x20000000));
+
+ var typeface = font.TryCreateGlyphTypeface();
+
+ // The group count is now computed in long and clamped to what the table holds, so the bad
+ // subtable yields empty coverage rather than throwing — the font still loads.
+ Assert.NotNull(typeface);
+ }
+
+ // ── cmap format 12 hostile group *contents* must not hang range enumeration ──
+
+ [Fact]
+ public void Cmap_Format12_Huge_Group_End_Is_Clamped_To_The_Unicode_Range()
+ {
+ // endCharCode = 0x7FFFFFFF used to reach the per-codepoint dictionary loops unclamped:
+ // `cp <= end` with end == int.MaxValue is a tautology, so Count / Keys / the enumerator
+ // never terminated. The group is now clamped to the Unicode range at the choke point.
+ var font = SyntheticFont.FromAsset(SyntheticFont.Assets.InterRegular)
+ .Replace("cmap", BuildFormat12Cmap(
+ (Start: 0x41u, End: 0x7FFFFFFFu, StartGlyph: 1u)));
+
+ var typeface = font.TryCreateGlyphTypeface();
+ Assert.NotNull(typeface);
+
+ var dictionary = typeface!.CharacterToGlyphMap.AsReadOnlyDictionary();
+
+ // Terminates (clamped to ≤ 0x10FFFF) and still exposes the in-range part of the group.
+ Assert.True(dictionary.Count > 0);
+ Assert.True(dictionary.ContainsKey(0x41));
+ }
+
+ [Fact]
+ public void Cmap_Format12_Inverted_Group_Is_Skipped_Not_Terminal()
+ {
+ // The second group is inverted (start > end). It must enumerate as an empty range —
+ // not throw, not end enumeration early, and not hide the first (valid) group.
+ var font = SyntheticFont.FromAsset(SyntheticFont.Assets.InterRegular)
+ .Replace("cmap", BuildFormat12Cmap(
+ (Start: 0x41u, End: 0x41u, StartGlyph: 5u),
+ (Start: 0x1000u, End: 0x100u, StartGlyph: 6u)));
+
+ var typeface = font.TryCreateGlyphTypeface();
+ Assert.NotNull(typeface);
+
+ var dictionary = typeface!.CharacterToGlyphMap.AsReadOnlyDictionary();
+
+ Assert.Equal(1, dictionary.Count);
+ Assert.True(dictionary.ContainsKey(0x41));
+ }
+
+ // ── cmap Format-4 subtable selection prefers Unicode over Symbol ──
+
+ [Theory]
+ [InlineData(true)] // Symbol subtable listed first
+ [InlineData(false)] // Unicode subtable listed first
+ public void Format4_Subtable_Selection_Prefers_Unicode_Over_Symbol(bool symbolFirst)
+ {
+ // Two Windows-platform Format-4 subtables: a Symbol (encoding 0) one that maps only the
+ // PUA codepoint 0xF041, and a Unicode-BMP (encoding 1) one that maps 'A'. The selection
+ // now scores the Symbol encoding worse than Unicode, so the Unicode subtable wins
+ // regardless of directory order.
+ var font = SyntheticFont.FromAsset(SyntheticFont.Assets.InterRegular)
+ .Replace("cmap", BuildDualFormat4Cmap(symbolFirst));
+
+ var typeface = font.TryCreateGlyphTypeface();
+ Assert.NotNull(typeface);
+
+ // ASCII 'A' now resolves in BOTH orderings — encoding, not directory order, decides.
+ Assert.True(typeface!.CharacterToGlyphMap.ContainsGlyph('A'));
+ }
+
+ private static byte[] BuildCmapWithOverflowingFormat12(uint numGroups)
+ {
+ // Format-12 subtable: format(2) reserved(2) length(4) language(4) numGroups(4) groups[…].
+ // length is honest about the 16-byte buffer, so the length-slice succeeds and the
+ // overflow surfaces at the group-array slice (the exact path under test).
+ var subtable = new BigEndianBuffer()
+ .UInt16(12) // format
+ .UInt16(0) // reserved
+ .UInt32(16) // length (header only)
+ .UInt32(0) // language
+ .UInt32(numGroups) // numGroups
+ .ToArray();
+
+ // cmap header: version(2) numTables(2), then one EncodingRecord: platform(2) encoding(2) offset(4).
+ var cmap = new BigEndianBuffer();
+ cmap.UInt16(0); // version
+ cmap.UInt16(1); // numTables
+ cmap.UInt16(3); // platformID = Windows
+ cmap.UInt16(10); // encodingID = UCS-4 (any value works; format 12 is selected regardless)
+ var offsetPos = cmap.ReserveOffset32();
+ cmap.PatchUInt32(offsetPos, (uint)cmap.Position);
+ cmap.Bytes(subtable);
+
+ return cmap.ToArray();
+ }
+
+ ///
+ /// Builds a cmap with a single format-12 subtable carrying the given sequential map groups
+ /// (which must be ordered by start code for the lookup binary search).
+ ///
+ private static byte[] BuildFormat12Cmap(params (uint Start, uint End, uint StartGlyph)[] groups)
+ {
+ var subtable = new BigEndianBuffer()
+ .UInt16(12) // format
+ .UInt16(0) // reserved
+ .UInt32((uint)(16 + groups.Length * 12)) // length
+ .UInt32(0) // language
+ .UInt32((uint)groups.Length); // numGroups
+
+ foreach (var (start, end, startGlyph) in groups)
+ {
+ subtable.UInt32(start).UInt32(end).UInt32(startGlyph);
+ }
+
+ var cmap = new BigEndianBuffer();
+ cmap.UInt16(0); // version
+ cmap.UInt16(1); // numTables
+ cmap.UInt16(3); // platformID = Windows
+ cmap.UInt16(10); // encodingID = UCS-4
+ cmap.UInt32(12); // offset: header(4) + one EncodingRecord(8)
+ cmap.Bytes(subtable.ToArray());
+
+ return cmap.ToArray();
+ }
+
+ ///
+ /// Builds a cmap with two Windows-platform Format-4 subtables — a Symbol (encoding 0) one
+ /// mapping the PUA codepoint 0xF041 and a Unicode-BMP (encoding 1) one mapping 'A' — ordered
+ /// per .
+ ///
+ private static byte[] BuildDualFormat4Cmap(bool symbolFirst)
+ {
+ var symbol = BuildSingleCharFormat4(charCode: 0xF041, glyph: 7);
+ var unicode = BuildSingleCharFormat4(charCode: 'A', glyph: 5);
+
+ var firstSub = symbolFirst ? symbol : unicode;
+ var firstEncoding = symbolFirst ? 0 : 1; // Symbol = 0, UnicodeBMP = 1
+ var secondSub = symbolFirst ? unicode : symbol;
+ var secondEncoding = symbolFirst ? 1 : 0;
+
+ var cmap = new BigEndianBuffer();
+ cmap.UInt16(0); // version
+ cmap.UInt16(2); // numTables
+
+ // Two 8-byte EncodingRecords follow the 4-byte header, so the subtables start at offset 20.
+ const int subtablesStart = 4 + 2 * 8;
+ cmap.UInt16(3); cmap.UInt16(firstEncoding); cmap.UInt32(subtablesStart);
+ cmap.UInt16(3); cmap.UInt16(secondEncoding); cmap.UInt32((uint)(subtablesStart + firstSub.Length));
+ cmap.Bytes(firstSub);
+ cmap.Bytes(secondSub);
+
+ return cmap.ToArray();
+ }
+
+ /// Builds a minimal Format-4 cmap subtable mapping a single to .
+ private static byte[] BuildSingleCharFormat4(int charCode, int glyph)
+ {
+ // Two segments: [charCode, charCode] and the mandatory terminal [0xFFFF, 0xFFFF].
+ // No glyphIdArray — the glyph comes from idDelta (idRangeOffset = 0). Total length 32.
+ return new BigEndianBuffer()
+ .UInt16(4) // format
+ .UInt16(32) // length
+ .UInt16(0) // language
+ .UInt16(4) // segCountX2 (segCount = 2)
+ .UInt16(4) // searchRange
+ .UInt16(1) // entrySelector
+ .UInt16(0) // rangeShift
+ .UInt16(charCode).UInt16(0xFFFF) // endCode[2]
+ .UInt16(0) // reservedPad
+ .UInt16(charCode).UInt16(0xFFFF) // startCode[2]
+ .UInt16((glyph - charCode) & 0xFFFF).UInt16(1) // idDelta[2]
+ .UInt16(0).UInt16(0) // idRangeOffset[2]
+ .ToArray();
+ }
+ }
+}
diff --git a/tests/Avalonia.Base.UnitTests/Media/Fonts/Tables/DecyclerTests.cs b/tests/Avalonia.Base.UnitTests/Media/Fonts/Tables/DecyclerTests.cs
new file mode 100644
index 0000000000..d18d68a993
--- /dev/null
+++ b/tests/Avalonia.Base.UnitTests/Media/Fonts/Tables/DecyclerTests.cs
@@ -0,0 +1,245 @@
+using System;
+using Avalonia.Media.Fonts.Tables;
+using Xunit;
+
+namespace Avalonia.Base.UnitTests.Media.Fonts.Tables
+{
+ public class DecyclerTests
+ {
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ [InlineData(int.MinValue)]
+ public void Constructor_Throws_When_MaxDepth_Is_Less_Than_One(int maxDepth)
+ {
+ Assert.Throws(() => new Decycler(maxDepth));
+ }
+
+ [Fact]
+ public void Constructor_Accepts_MaxDepth_Of_One()
+ {
+ var decycler = new Decycler(maxDepth: 1);
+
+ Assert.Equal(0, decycler.CurrentDepth);
+ Assert.Equal(1, decycler.MaxDepth);
+ }
+
+ [Fact]
+ public void Enter_Increments_CurrentDepth()
+ {
+ var decycler = new Decycler(maxDepth: 4);
+
+ using var guard = decycler.Enter(1);
+
+ Assert.Equal(1, decycler.CurrentDepth);
+ }
+
+ [Fact]
+ public void Disposing_Guard_Restores_CurrentDepth()
+ {
+ var decycler = new Decycler(maxDepth: 4);
+
+ using (decycler.Enter(1))
+ {
+ Assert.Equal(1, decycler.CurrentDepth);
+ }
+
+ Assert.Equal(0, decycler.CurrentDepth);
+ }
+
+ [Fact]
+ public void Nested_Enters_Stack_Depth_And_Unwind_In_Reverse()
+ {
+ var decycler = new Decycler(maxDepth: 4);
+
+ using (decycler.Enter(1))
+ {
+ Assert.Equal(1, decycler.CurrentDepth);
+
+ using (decycler.Enter(2))
+ {
+ Assert.Equal(2, decycler.CurrentDepth);
+
+ using (decycler.Enter(3))
+ {
+ Assert.Equal(3, decycler.CurrentDepth);
+ }
+
+ Assert.Equal(2, decycler.CurrentDepth);
+ }
+
+ Assert.Equal(1, decycler.CurrentDepth);
+ }
+
+ Assert.Equal(0, decycler.CurrentDepth);
+ }
+
+ [Fact]
+ public void Re_Entering_Visited_Id_Throws_CycleDetected()
+ {
+ var decycler = new Decycler(maxDepth: 4);
+
+ using var outer = decycler.Enter(1);
+
+ var ex = Assert.Throws(() => decycler.Enter(1));
+
+ Assert.Equal(DecyclerError.CycleDetected, ex.Error);
+ }
+
+ [Fact]
+ public void Same_Id_Can_Be_Re_Entered_After_Exit()
+ {
+ var decycler = new Decycler(maxDepth: 4);
+
+ using (decycler.Enter(1))
+ {
+ }
+
+ // The id is no longer in the visited set; re-entering must succeed.
+ using var guard = decycler.Enter(1);
+
+ Assert.Equal(1, decycler.CurrentDepth);
+ }
+
+ [Fact]
+ public void Enter_Beyond_MaxDepth_Throws_DepthLimitExceeded()
+ {
+ var decycler = new Decycler(maxDepth: 2);
+
+ using var a = decycler.Enter(1);
+ using var b = decycler.Enter(2);
+
+ var ex = Assert.Throws(() => decycler.Enter(3));
+
+ Assert.Equal(DecyclerError.DepthLimitExceeded, ex.Error);
+ }
+
+ [Fact]
+ public void DepthLimit_Check_Runs_Before_Cycle_Check()
+ {
+ // When both conditions could apply (depth is exhausted AND the id is
+ // already visited), the depth check fires first. This matters because
+ // it means a misconfigured depth cap surfaces as a depth error rather
+ // than masquerading as a cycle.
+ var decycler = new Decycler(maxDepth: 1);
+
+ using var guard = decycler.Enter(1);
+
+ var ex = Assert.Throws(() => decycler.Enter(1));
+
+ Assert.Equal(DecyclerError.DepthLimitExceeded, ex.Error);
+ }
+
+ [Fact]
+ public void Failed_Enter_Does_Not_Mutate_State()
+ {
+ var decycler = new Decycler(maxDepth: 1);
+
+ using var guard = decycler.Enter(1);
+
+ Assert.Throws(() => decycler.Enter(2));
+
+ // The failed Enter must not have incremented depth or registered the id.
+ Assert.Equal(1, decycler.CurrentDepth);
+ }
+
+ [Fact]
+ public void Reset_Clears_Visited_And_Depth()
+ {
+ var decycler = new Decycler(maxDepth: 4);
+
+ var guard = decycler.Enter(1);
+ // Skip the disposal: simulate an abandoned traversal that needs to be
+ // cleaned up by Reset (the validator path on the pool).
+ _ = guard;
+
+ decycler.Reset();
+
+ Assert.Equal(0, decycler.CurrentDepth);
+
+ // The previously visited id must be enterable again after reset.
+ using var fresh = decycler.Enter(1);
+
+ Assert.Equal(1, decycler.CurrentDepth);
+ }
+
+ [Fact]
+ public void Reset_Is_Safe_To_Call_On_Empty_Decycler()
+ {
+ var decycler = new Decycler(maxDepth: 4);
+
+ decycler.Reset();
+ decycler.Reset();
+
+ Assert.Equal(0, decycler.CurrentDepth);
+ }
+
+ [Fact]
+ public void Guard_Dispose_Is_Idempotent()
+ {
+ var decycler = new Decycler(maxDepth: 4);
+
+ var guard = decycler.Enter(1);
+
+ guard.Dispose();
+ guard.Dispose(); // second call must not double-decrement.
+
+ Assert.Equal(0, decycler.CurrentDepth);
+
+ // Depth must not be negative; entering a new node still works.
+ using var next = decycler.Enter(2);
+
+ Assert.Equal(1, decycler.CurrentDepth);
+ }
+
+ [Fact]
+ public void Copied_Guard_Cannot_Double_Exit()
+ {
+ // CycleGuard is a copyable ref struct, so each copy carries its own _exited flag —
+ // the idempotence of Dispose on one copy doesn't protect against the other copy
+ // exiting again. Exit only returns depth budget for ids actually in the visited set.
+ var decycler = new Decycler(maxDepth: 4);
+
+ var guard = decycler.Enter(1);
+ var copy = guard;
+
+ guard.Dispose();
+ copy.Dispose(); // second exit for id 1, via an un-exited copy.
+
+ Assert.Equal(0, decycler.CurrentDepth);
+ }
+
+ [Fact]
+ public void MaxDepth_Property_Reflects_Constructor_Argument()
+ {
+ var decycler = new Decycler(maxDepth: 17);
+
+ Assert.Equal(17, decycler.MaxDepth);
+ }
+
+ [Fact]
+ public void DecyclerException_Carries_Error_Code_And_Message()
+ {
+ var ex = new DecyclerException(DecyclerError.CycleDetected, "boom");
+
+ Assert.Equal(DecyclerError.CycleDetected, ex.Error);
+ Assert.Equal("boom", ex.Message);
+ }
+
+ [Fact]
+ public void Works_With_Other_Struct_Types()
+ {
+ // Decycler is constrained to struct; the typical instantiations are
+ // int (composite-glyph ids) and ushort (paint-graph glyph ids). Verify
+ // ushort works end-to-end.
+ var decycler = new Decycler(maxDepth: 4);
+
+ using (decycler.Enter(1))
+ {
+ Assert.Throws(() => decycler.Enter((ushort)1));
+ }
+
+ Assert.Equal(0, decycler.CurrentDepth);
+ }
+ }
+}
diff --git a/tests/Avalonia.Base.UnitTests/Media/GlyphDrawingOptionsTests.cs b/tests/Avalonia.Base.UnitTests/Media/GlyphDrawingOptionsTests.cs
new file mode 100644
index 0000000000..ad96a484fb
--- /dev/null
+++ b/tests/Avalonia.Base.UnitTests/Media/GlyphDrawingOptionsTests.cs
@@ -0,0 +1,148 @@
+using System;
+using Avalonia.Media;
+using Xunit;
+
+namespace Avalonia.Base.UnitTests.Media
+{
+ public class GlyphDrawingOptionsTests
+ {
+ [Fact]
+ public void Default_Has_Null_PaletteIndex_And_Null_PixelSize()
+ {
+ var options = GlyphDrawingOptions.Default;
+
+ Assert.Null(options.PaletteIndex);
+ Assert.Null(options.PixelSize);
+ }
+
+ [Fact]
+ public void Default_Is_A_Singleton()
+ {
+ Assert.Same(GlyphDrawingOptions.Default, GlyphDrawingOptions.Default);
+ }
+
+ [Fact]
+ public void Parameterless_Constructor_Produces_An_Instance_Equal_To_Default()
+ {
+ // The record's equality contract should treat a freshly-constructed
+ // instance with no overrides as equal to the Default singleton, even
+ // though they're distinct instances. This keeps callers from having
+ // to compare against Default by reference.
+ var fresh = new GlyphDrawingOptions();
+
+ Assert.NotSame(GlyphDrawingOptions.Default, fresh);
+ Assert.Equal(GlyphDrawingOptions.Default, fresh);
+ Assert.Equal(GlyphDrawingOptions.Default.GetHashCode(), fresh.GetHashCode());
+ }
+
+ [Fact]
+ public void PaletteIndex_Accepts_Null()
+ {
+ var options = new GlyphDrawingOptions { PaletteIndex = null };
+
+ Assert.Null(options.PaletteIndex);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(1)]
+ [InlineData(99)]
+ public void PaletteIndex_Accepts_NonNegative_Values(int value)
+ {
+ var options = new GlyphDrawingOptions { PaletteIndex = value };
+
+ Assert.Equal(value, options.PaletteIndex);
+ }
+
+ [Theory]
+ [InlineData(-1)]
+ [InlineData(int.MinValue)]
+ public void PaletteIndex_Rejects_Negative_Values(int value)
+ {
+ Assert.Throws(
+ () => new GlyphDrawingOptions { PaletteIndex = value });
+ }
+
+ [Fact]
+ public void PixelSize_Accepts_Null()
+ {
+ var options = new GlyphDrawingOptions { PixelSize = null };
+
+ Assert.Null(options.PixelSize);
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(16)]
+ [InlineData(256)]
+ public void PixelSize_Accepts_Positive_Values(int value)
+ {
+ var options = new GlyphDrawingOptions { PixelSize = value };
+
+ Assert.Equal(value, options.PixelSize);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ [InlineData(int.MinValue)]
+ public void PixelSize_Rejects_Values_Below_One(int value)
+ {
+ // A pixel size of zero is meaningless for a bitmap strike; reject it at
+ // construction time rather than letting it propagate to renderer code.
+ Assert.Throws(
+ () => new GlyphDrawingOptions { PixelSize = value });
+ }
+
+ [Fact]
+ public void Equality_Is_Structural_Across_Two_Records_With_Same_Values()
+ {
+ var a = new GlyphDrawingOptions { PaletteIndex = 1, PixelSize = 16 };
+ var b = new GlyphDrawingOptions { PaletteIndex = 1, PixelSize = 16 };
+
+ Assert.NotSame(a, b);
+ Assert.Equal(a, b);
+ Assert.Equal(a.GetHashCode(), b.GetHashCode());
+ }
+
+ [Fact]
+ public void Equality_Distinguishes_Different_PaletteIndex()
+ {
+ var a = new GlyphDrawingOptions { PaletteIndex = 0 };
+ var b = new GlyphDrawingOptions { PaletteIndex = 1 };
+
+ Assert.NotEqual(a, b);
+ }
+
+ [Fact]
+ public void Equality_Distinguishes_Different_PixelSize()
+ {
+ var a = new GlyphDrawingOptions { PixelSize = 16 };
+ var b = new GlyphDrawingOptions { PixelSize = 32 };
+
+ Assert.NotEqual(a, b);
+ }
+
+ [Fact]
+ public void With_Expression_Produces_A_Modified_Copy()
+ {
+ // `record` participation is part of the public contract; ensure callers
+ // can use `with` to derive a tweaked instance.
+ var original = new GlyphDrawingOptions { PaletteIndex = 1, PixelSize = 16 };
+ var tweaked = original with { PaletteIndex = 2 };
+
+ Assert.Equal(1, original.PaletteIndex);
+ Assert.Equal(2, tweaked.PaletteIndex);
+ Assert.Equal(16, tweaked.PixelSize);
+ Assert.NotEqual(original, tweaked);
+ }
+
+ [Fact]
+ public void With_Expression_Validates_New_Values()
+ {
+ var original = new GlyphDrawingOptions { PaletteIndex = 1 };
+
+ Assert.Throws(() => original with { PaletteIndex = -5 });
+ }
+ }
+}
diff --git a/tests/Avalonia.Base.UnitTests/Media/GlyphTypefaceTests.cs b/tests/Avalonia.Base.UnitTests/Media/GlyphTypefaceTests.cs
index 5292395659..626838a6a0 100644
--- a/tests/Avalonia.Base.UnitTests/Media/GlyphTypefaceTests.cs
+++ b/tests/Avalonia.Base.UnitTests/Media/GlyphTypefaceTests.cs
@@ -1,5 +1,6 @@
using System;
using System.Buffers;
+using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
@@ -380,6 +381,167 @@ namespace Avalonia.Base.UnitTests.Media
}
}
+ [Fact]
+ public void AsReadOnlyDictionary_Returns_NonNull_Dictionary()
+ {
+ var dict = LoadInterCharacterToGlyphMap().AsReadOnlyDictionary();
+
+ Assert.NotNull(dict);
+ }
+
+ [Fact]
+ public void AsReadOnlyDictionary_ContainsKey_Matches_Underlying_Map()
+ {
+ var map = LoadInterCharacterToGlyphMap();
+ var dict = map.AsReadOnlyDictionary();
+
+ // 'A' is in Inter.
+ Assert.True(map.ContainsGlyph('A'));
+ Assert.True(dict.ContainsKey('A'));
+
+ // U+10FFFD is the last code point of the supplementary private-use
+ // Plane 16 — Inter does not map it and a Format 4 cmap cannot.
+ Assert.False(map.ContainsGlyph(0x10FFFD));
+ Assert.False(dict.ContainsKey(0x10FFFD));
+ }
+
+ [Theory]
+ [InlineData('A')]
+ [InlineData('z')]
+ [InlineData('0')]
+ [InlineData(' ')]
+ public void AsReadOnlyDictionary_Indexer_Returns_Same_GlyphId_As_Map(int codePoint)
+ {
+ var map = LoadInterCharacterToGlyphMap();
+ var dict = map.AsReadOnlyDictionary();
+
+ Assert.Equal(map.GetGlyph(codePoint), dict[codePoint]);
+ }
+
+ [Fact]
+ public void AsReadOnlyDictionary_Indexer_Throws_For_Unmapped_CodePoint()
+ {
+ var dict = LoadInterCharacterToGlyphMap().AsReadOnlyDictionary();
+
+ Assert.Throws(() => _ = dict[0x10FFFD]);
+ }
+
+ [Fact]
+ public void AsReadOnlyDictionary_TryGetValue_Returns_True_With_GlyphId_For_Known_CodePoint()
+ {
+ var map = LoadInterCharacterToGlyphMap();
+ var dict = map.AsReadOnlyDictionary();
+
+ Assert.True(dict.TryGetValue('A', out var glyphId));
+ Assert.Equal(map.GetGlyph('A'), glyphId);
+ Assert.NotEqual(0, glyphId);
+ }
+
+ [Fact]
+ public void AsReadOnlyDictionary_TryGetValue_Returns_False_For_Unmapped_CodePoint()
+ {
+ var dict = LoadInterCharacterToGlyphMap().AsReadOnlyDictionary();
+
+ Assert.False(dict.TryGetValue(0x10FFFD, out var glyphId));
+ Assert.Equal((ushort)0, glyphId);
+ }
+
+ [Fact]
+ public void AsReadOnlyDictionary_Count_Is_Positive_And_Matches_Enumeration()
+ {
+ var dict = LoadInterCharacterToGlyphMap().AsReadOnlyDictionary();
+
+ Assert.True(dict.Count > 0);
+
+ var enumerated = 0;
+ foreach (var _ in dict)
+ {
+ enumerated++;
+ }
+
+ Assert.Equal(dict.Count, enumerated);
+ }
+
+ [Fact]
+ public void AsReadOnlyDictionary_Enumeration_Yields_Pairs_That_Round_Trip_Through_The_Map()
+ {
+ var map = LoadInterCharacterToGlyphMap();
+ var dict = map.AsReadOnlyDictionary();
+
+ var checkedPairs = 0;
+ foreach (var kvp in dict)
+ {
+ // Every (key, value) the dictionary yields must agree with the
+ // underlying map. The dictionary is a view, not a snapshot.
+ Assert.Equal(map.GetGlyph(kvp.Key), kvp.Value);
+ Assert.True(map.ContainsGlyph(kvp.Key));
+
+ if (++checkedPairs >= 500)
+ {
+ // Inter has thousands of mappings; sampling the first 500
+ // is enough to exercise the enumerator without making the
+ // test prohibitively slow.
+ break;
+ }
+ }
+
+ Assert.True(checkedPairs > 0);
+ }
+
+ [Fact]
+ public void AsReadOnlyDictionary_Keys_Match_Dictionary_Enumeration_Keys()
+ {
+ var dict = LoadInterCharacterToGlyphMap().AsReadOnlyDictionary();
+
+ var keysFromEnumeration = new HashSet();
+ var pairsKeysFromEnumeration = new HashSet();
+
+ foreach (var key in dict.Keys)
+ {
+ keysFromEnumeration.Add(key);
+ if (keysFromEnumeration.Count >= 500)
+ {
+ break;
+ }
+ }
+
+ foreach (var kvp in dict)
+ {
+ pairsKeysFromEnumeration.Add(kvp.Key);
+ if (pairsKeysFromEnumeration.Count >= 500)
+ {
+ break;
+ }
+ }
+
+ Assert.True(keysFromEnumeration.Count > 0);
+ Assert.Equal(pairsKeysFromEnumeration, keysFromEnumeration);
+ }
+
+ [Fact]
+ public void AsReadOnlyDictionary_Returns_A_Fresh_View_That_Is_Functionally_Equivalent()
+ {
+ var map = LoadInterCharacterToGlyphMap();
+
+ var first = map.AsReadOnlyDictionary();
+ var second = map.AsReadOnlyDictionary();
+
+ // The dictionary is a lightweight wrapper that may or may not be
+ // the same instance; what matters is that two views of the same
+ // map agree on lookups.
+ Assert.True(first.ContainsKey('A'));
+ Assert.True(second.ContainsKey('A'));
+ Assert.Equal(first['A'], second['A']);
+ }
+
+ private static Avalonia.Media.Fonts.Tables.Cmap.CharacterToGlyphMap LoadInterCharacterToGlyphMap()
+ {
+ var assetLoader = new StandardAssetLoader();
+ using var stream = assetLoader.Open(new Uri(InterFontUri));
+ var typeface = new GlyphTypeface(new CustomPlatformTypeface(stream));
+ return typeface.CharacterToGlyphMap;
+ }
+
[Fact]
public void FamilyNames_Should_Contain_InvariantCulture_Entry()
{
diff --git a/tests/Avalonia.Base.UnitTests/Media/NormalizedVariationPositionTests.cs b/tests/Avalonia.Base.UnitTests/Media/NormalizedVariationPositionTests.cs
new file mode 100644
index 0000000000..5c18ad1e25
--- /dev/null
+++ b/tests/Avalonia.Base.UnitTests/Media/NormalizedVariationPositionTests.cs
@@ -0,0 +1,401 @@
+using System;
+using System.Collections.Generic;
+using Avalonia.Media;
+using Avalonia.Media.Fonts;
+using Xunit;
+
+namespace Avalonia.Base.UnitTests.Media
+{
+ public class NormalizedVariationPositionTests
+ {
+ private static readonly OpenTypeTag Wght = OpenTypeTag.Parse("wght");
+ private static readonly OpenTypeTag Wdth = OpenTypeTag.Parse("wdth");
+ private static readonly OpenTypeTag Ital = OpenTypeTag.Parse("ital");
+
+ [Fact]
+ public void Default_Struct_Is_The_No_Variation_Case()
+ {
+ var settings = default(NormalizedVariationPosition);
+
+ Assert.True(settings.IsDefault);
+ Assert.Empty(settings.Coordinates);
+ Assert.Equal(0, settings.GetHashCode());
+ }
+
+ [Fact]
+ public void Default_Structs_Are_Equal()
+ {
+ // Two zero-initialized structs must compare equal, even though they're
+ // distinct values on the stack. This is the substitute for the previous
+ // singleton Default.
+ Assert.Equal(default(NormalizedVariationPosition), default(NormalizedVariationPosition));
+ Assert.True(default(NormalizedVariationPosition) == default(NormalizedVariationPosition));
+ }
+
+ [Fact]
+ public void Coordinates_Property_Returns_Empty_Not_Default_For_Default_Struct()
+ {
+ // The IsDefault → Empty normalization in the property lets callers iterate
+ // / index without first checking ImmutableArray.IsDefault.
+ var settings = default(NormalizedVariationPosition);
+
+ Assert.False(settings.Coordinates.IsDefault);
+ Assert.Equal(0, settings.Coordinates.Length);
+ }
+
+ [Fact]
+ public void FromCoordinates_Dictionary_Throws_On_Null()
+ {
+ Assert.Throws(
+ () => NormalizedVariationPosition.FromCoordinates((IReadOnlyDictionary)null!));
+ }
+
+ [Theory]
+ [InlineData(float.NaN)]
+ [InlineData(-1.0001f)]
+ [InlineData(1.0001f)]
+ [InlineData(float.PositiveInfinity)]
+ [InlineData(float.NegativeInfinity)]
+ public void FromCoordinates_Dictionary_Rejects_Out_Of_Range_Or_NaN(float value)
+ {
+ var coords = new Dictionary { [Wght] = value };
+
+ Assert.Throws(
+ () => NormalizedVariationPosition.FromCoordinates(coords));
+ }
+
+ [Theory]
+ [InlineData(-1f)]
+ [InlineData(1f)]
+ public void FromCoordinates_Dictionary_Accepts_Boundary_Values(float value)
+ {
+ var coords = new Dictionary { [Wght] = value };
+
+ var settings = NormalizedVariationPosition.FromCoordinates(coords);
+
+ Assert.Single(settings.Coordinates);
+ Assert.Equal(Wght, settings.Coordinates[0].Axis);
+ Assert.Equal(value, settings.Coordinates[0].NormalizedValue);
+ }
+
+ [Fact]
+ public void FromCoordinates_Drops_Zero_Coordinates()
+ {
+ // 0 is the axis default: an explicit wght=0 must produce the same value (and the
+ // same variation-cache key downstream) as settings that omit the axis entirely.
+ var fromDictionary = NormalizedVariationPosition.FromCoordinates(
+ new Dictionary { [Wght] = 0f });
+
+ Assert.True(fromDictionary.IsDefault);
+ Assert.Equal(default(NormalizedVariationPosition), fromDictionary);
+
+ Span coords =
+ [
+ new NormalizedVariationCoordinate(Wght, 0f),
+ new NormalizedVariationCoordinate(Wdth, -0.25f),
+ ];
+
+ var fromSpan = NormalizedVariationPosition.FromCoordinates(coords);
+
+ Assert.Single(fromSpan.Coordinates);
+ Assert.Equal(Wdth, fromSpan.Coordinates[0].Axis);
+ }
+
+ [Fact]
+ public void FromCoordinates_Span_Rejects_Duplicate_Axes_Even_When_Zero_Valued()
+ {
+ // Canonicalization must not weaken validation: the duplicate check runs before
+ // zero-valued coordinates are dropped.
+ Assert.Throws(static () =>
+ {
+ Span coords =
+ [
+ new NormalizedVariationCoordinate(OpenTypeTag.Parse("wght"), 0f),
+ new NormalizedVariationCoordinate(OpenTypeTag.Parse("wght"), 0.5f),
+ ];
+ NormalizedVariationPosition.FromCoordinates(coords);
+ });
+ }
+
+ [Fact]
+ public void FromCoordinates_Dictionary_Empty_Returns_Default_Struct()
+ {
+ var settings = NormalizedVariationPosition.FromCoordinates(new Dictionary());
+
+ Assert.True(settings.IsDefault);
+ Assert.Equal(default(NormalizedVariationPosition), settings);
+ }
+
+ [Fact]
+ public void FromCoordinates_Dictionary_Sorts_By_Axis_Tag()
+ {
+ // Insertion order shouldn't matter — coordinates land sorted by (uint)tag
+ // so equality and hashing are insertion-order-independent.
+ var unordered = new Dictionary
+ {
+ [Wght] = 0.5f,
+ [Ital] = 1f,
+ [Wdth] = -0.25f,
+ };
+
+ var settings = NormalizedVariationPosition.FromCoordinates(unordered);
+
+ Assert.Equal(3, settings.Coordinates.Length);
+ // Sorted: ital (0x6974616c), wdth (0x77647468), wght (0x77676874).
+ // Lexically by the 4-char ASCII tag uint, that's ital < wdth < wght.
+ Assert.Equal(Ital, settings.Coordinates[0].Axis);
+ Assert.Equal(Wdth, settings.Coordinates[1].Axis);
+ Assert.Equal(Wght, settings.Coordinates[2].Axis);
+ }
+
+ [Fact]
+ public void FromCoordinates_Dictionary_Defensively_Copies_The_Input()
+ {
+ var mutable = new Dictionary { [Wght] = 0.5f };
+
+ var settings = NormalizedVariationPosition.FromCoordinates(mutable);
+
+ mutable[Wght] = 0.9f;
+ mutable[Wdth] = -0.25f;
+
+ Assert.Single(settings.Coordinates);
+ Assert.Equal(0.5f, settings.Coordinates[0].NormalizedValue);
+ }
+
+ [Fact]
+ public void FromCoordinates_Span_Empty_Returns_Default_Struct()
+ {
+ var settings = NormalizedVariationPosition.FromCoordinates(ReadOnlySpan.Empty);
+
+ Assert.True(settings.IsDefault);
+ }
+
+ [Fact]
+ public void FromCoordinates_Span_Sorts_And_Validates()
+ {
+ Span coords =
+ [
+ new NormalizedVariationCoordinate(Wght, 0.5f),
+ new NormalizedVariationCoordinate(Ital, 1f),
+ new NormalizedVariationCoordinate(Wdth, -0.25f),
+ ];
+
+ var settings = NormalizedVariationPosition.FromCoordinates(coords);
+
+ Assert.Equal(3, settings.Coordinates.Length);
+ Assert.Equal(Ital, settings.Coordinates[0].Axis);
+ Assert.Equal(Wdth, settings.Coordinates[1].Axis);
+ Assert.Equal(Wght, settings.Coordinates[2].Axis);
+ }
+
+ [Fact]
+ public void FromCoordinates_Span_Rejects_Duplicate_Axes()
+ {
+ Assert.Throws(static () =>
+ {
+ Span coords =
+ [
+ new NormalizedVariationCoordinate(OpenTypeTag.Parse("wght"), 0.5f),
+ new NormalizedVariationCoordinate(OpenTypeTag.Parse("wght"), -0.5f),
+ ];
+ NormalizedVariationPosition.FromCoordinates(coords);
+ });
+ }
+
+ [Fact]
+ public void FromCoordinates_Span_Rejects_Out_Of_Range_Value()
+ {
+ Assert.Throws(static () =>
+ {
+ Span coords = [new NormalizedVariationCoordinate(OpenTypeTag.Parse("wght"), 2f)];
+ NormalizedVariationPosition.FromCoordinates(coords);
+ });
+ }
+
+ [Fact]
+ public void TryGetCoordinate_Returns_True_For_Present_Axis()
+ {
+ var settings = NormalizedVariationPosition.FromCoordinates(
+ new Dictionary { [Wght] = 0.5f, [Wdth] = -0.25f });
+
+ Assert.True(settings.TryGetCoordinate(Wght, out var w));
+ Assert.Equal(0.5f, w);
+
+ Assert.True(settings.TryGetCoordinate(Wdth, out var wd));
+ Assert.Equal(-0.25f, wd);
+ }
+
+ [Fact]
+ public void TryGetCoordinate_Returns_False_For_Absent_Axis()
+ {
+ var settings = NormalizedVariationPosition.FromCoordinates(
+ new Dictionary { [Wght] = 0.5f });
+
+ Assert.False(settings.TryGetCoordinate(Ital, out var v));
+ Assert.Equal(0f, v);
+ }
+
+ [Fact]
+ public void TryGetCoordinate_Returns_False_For_Default_Struct()
+ {
+ var settings = default(NormalizedVariationPosition);
+
+ Assert.False(settings.TryGetCoordinate(Wght, out var v));
+ Assert.Equal(0f, v);
+ }
+
+ [Fact]
+ public void GetCoordinateOrDefault_Returns_Fallback_For_Absent_Axis()
+ {
+ var settings = NormalizedVariationPosition.FromCoordinates(
+ new Dictionary { [Wght] = 0.5f });
+
+ Assert.Equal(0.5f, settings.GetCoordinateOrDefault(Wght));
+ Assert.Equal(0f, settings.GetCoordinateOrDefault(Ital));
+ Assert.Equal(-1f, settings.GetCoordinateOrDefault(Ital, -1f));
+ }
+
+ [Fact]
+ public void Equality_Is_Reflexive()
+ {
+ var settings = NormalizedVariationPosition.FromCoordinates(
+ new Dictionary { [Wght] = 0.5f });
+
+ Assert.True(settings.Equals(settings));
+ }
+
+ [Fact]
+ public void Equality_Is_Structural_For_Identical_Coordinates()
+ {
+ var a = NormalizedVariationPosition.FromCoordinates(
+ new Dictionary { [Wght] = 0.5f, [Wdth] = -0.25f });
+ var b = NormalizedVariationPosition.FromCoordinates(
+ new Dictionary { [Wght] = 0.5f, [Wdth] = -0.25f });
+
+ Assert.True(a.Equals(b));
+ Assert.True(b.Equals(a));
+ Assert.Equal(a.GetHashCode(), b.GetHashCode());
+ }
+
+ [Fact]
+ public void Equality_Ignores_Insertion_Order()
+ {
+ var a = NormalizedVariationPosition.FromCoordinates(
+ new Dictionary { [Wght] = 0.5f, [Wdth] = -0.25f });
+ var b = NormalizedVariationPosition.FromCoordinates(
+ new Dictionary { [Wdth] = -0.25f, [Wght] = 0.5f });
+
+ Assert.True(a.Equals(b));
+ Assert.Equal(a.GetHashCode(), b.GetHashCode());
+ }
+
+ [Fact]
+ public void Equality_Differs_When_A_Coordinate_Value_Differs()
+ {
+ var a = NormalizedVariationPosition.FromCoordinates(
+ new Dictionary { [Wght] = 0.5f });
+ var b = NormalizedVariationPosition.FromCoordinates(
+ new Dictionary { [Wght] = 0.6f });
+
+ Assert.False(a.Equals(b));
+ }
+
+ [Fact]
+ public void Equality_Differs_When_A_Coordinate_Key_Differs()
+ {
+ var a = NormalizedVariationPosition.FromCoordinates(
+ new Dictionary { [Wght] = 0.5f });
+ var b = NormalizedVariationPosition.FromCoordinates(
+ new Dictionary { [Wdth] = 0.5f });
+
+ Assert.False(a.Equals(b));
+ }
+
+ [Fact]
+ public void Equality_Differs_When_Coordinate_Counts_Differ()
+ {
+ // The second coordinate must be non-zero: zero coordinates canonicalize away in
+ // FromCoordinates, which would make these two values deliberately equal.
+ var a = NormalizedVariationPosition.FromCoordinates(
+ new Dictionary { [Wght] = 0.5f });
+ var b = NormalizedVariationPosition.FromCoordinates(
+ new Dictionary { [Wght] = 0.5f, [Wdth] = -0.25f });
+
+ Assert.False(a.Equals(b));
+ }
+
+ [Fact]
+ public void Equality_Differs_Between_Default_And_Populated()
+ {
+ var a = default(NormalizedVariationPosition);
+ var b = NormalizedVariationPosition.FromCoordinates(
+ new Dictionary { [Wght] = 0.5f });
+
+ Assert.False(a.Equals(b));
+ Assert.False(b.Equals(a));
+ }
+
+ [Fact]
+ public void Hash_Is_Cached_And_Stable_Across_Equal_Instances()
+ {
+ // The hash is computed once at construction. Two structurally-equal settings
+ // must have the same hash regardless of how the coordinates were inserted.
+ var a = NormalizedVariationPosition.FromCoordinates(
+ new Dictionary { [Wght] = 0.5f, [Wdth] = -0.25f, [Ital] = 1f });
+ var b = NormalizedVariationPosition.FromCoordinates(
+ new Dictionary { [Ital] = 1f, [Wght] = 0.5f, [Wdth] = -0.25f });
+
+ var hashA = a.GetHashCode();
+ // Subsequent calls return the same cached value.
+ Assert.Equal(hashA, a.GetHashCode());
+ Assert.Equal(hashA, b.GetHashCode());
+ }
+
+ [Fact]
+ public void Equality_Operators_Match_Equals()
+ {
+ var a = NormalizedVariationPosition.FromCoordinates(
+ new Dictionary { [Ital] = 1f });
+ var b = NormalizedVariationPosition.FromCoordinates(
+ new Dictionary { [Ital] = 1f });
+ var c = NormalizedVariationPosition.FromCoordinates(
+ new Dictionary { [Ital] = 0f });
+
+ Assert.True(a == b);
+ Assert.False(a != b);
+ Assert.False(a == c);
+ Assert.True(a != c);
+ }
+
+ [Fact]
+ public void Equality_With_Boxed_Object()
+ {
+ // Cache-key paths box rarely, but Equals(object) must still be correct.
+ var a = NormalizedVariationPosition.FromCoordinates(
+ new Dictionary { [Wght] = 0.5f });
+ object boxed = NormalizedVariationPosition.FromCoordinates(
+ new Dictionary { [Wght] = 0.5f });
+
+ Assert.True(a.Equals(boxed));
+ Assert.False(a.Equals((object?)null));
+ Assert.False(a.Equals("not a settings"));
+ }
+
+ [Fact]
+ public void NormalizedVariationCoordinate_Has_Structural_Equality()
+ {
+ // The coordinate record-struct provides equality for free; verify it
+ // behaves as expected so callers can use it in their own comparisons.
+ var a = new NormalizedVariationCoordinate(Wght, 0.5f);
+ var b = new NormalizedVariationCoordinate(Wght, 0.5f);
+ var c = new NormalizedVariationCoordinate(Wght, 0.6f);
+ var d = new NormalizedVariationCoordinate(Wdth, 0.5f);
+
+ Assert.Equal(a, b);
+ Assert.NotEqual(a, c);
+ Assert.NotEqual(a, d);
+ Assert.True(a == b);
+ Assert.True(a != c);
+ }
+ }
+}
diff --git a/tests/Avalonia.Base.UnitTests/Utilities/ObjectPoolTests.cs b/tests/Avalonia.Base.UnitTests/Utilities/ObjectPoolTests.cs
new file mode 100644
index 0000000000..564b73d3b5
--- /dev/null
+++ b/tests/Avalonia.Base.UnitTests/Utilities/ObjectPoolTests.cs
@@ -0,0 +1,244 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Avalonia.Utilities;
+using Xunit;
+
+namespace Avalonia.Base.UnitTests.Utilities
+{
+ public class ObjectPoolTests
+ {
+ private sealed class Item
+ {
+ public int State;
+ }
+
+ [Fact]
+ public void Constructor_Throws_When_Factory_Is_Null()
+ {
+ Assert.Throws(() => new ObjectPool- (factory: null!));
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ [InlineData(int.MinValue)]
+ public void Constructor_Throws_When_MaxSize_Is_Less_Than_One(int maxSize)
+ {
+ Assert.Throws(
+ () => new ObjectPool
- (() => new Item(), maxSize: maxSize));
+ }
+
+ [Fact]
+ public void Constructor_Accepts_MaxSize_Of_One()
+ {
+ var pool = new ObjectPool
- (() => new Item(), maxSize: 1);
+
+ var item = pool.Rent();
+
+ Assert.NotNull(item);
+ }
+
+ [Fact]
+ public void Rent_Creates_New_Item_When_Pool_Is_Empty()
+ {
+ var pool = new ObjectPool
- (() => new Item());
+
+ var first = pool.Rent();
+ var second = pool.Rent();
+
+ Assert.NotNull(first);
+ Assert.NotNull(second);
+ Assert.NotSame(first, second);
+ }
+
+ [Fact]
+ public void Returned_Item_Is_Reused_By_Subsequent_Rent()
+ {
+ var pool = new ObjectPool
- (() => new Item());
+
+ var item = pool.Rent();
+ pool.Return(item);
+
+ var rented = pool.Rent();
+
+ Assert.Same(item, rented);
+ }
+
+ [Fact]
+ public void Return_Ignores_Null()
+ {
+ var pool = new ObjectPool
- (() => new Item());
+
+ pool.Return(null!);
+
+ // Pool stays empty, so the next Rent must produce a fresh item.
+ var item = pool.Rent();
+
+ Assert.NotNull(item);
+ }
+
+ [Fact]
+ public void Return_Drops_Item_When_Pool_Is_Full()
+ {
+ var pool = new ObjectPool
- (() => new Item(), maxSize: 2);
+
+ var a = new Item();
+ var b = new Item();
+ var c = new Item();
+
+ pool.Return(a);
+ pool.Return(b);
+ pool.Return(c); // pool full -> dropped
+
+ var rented = new HashSet
-
+ {
+ pool.Rent(),
+ pool.Rent(),
+ };
+
+ // The two rented items must come from {a, b}; c was dropped.
+ Assert.Contains(a, rented);
+ Assert.Contains(b, rented);
+ Assert.DoesNotContain(c, rented);
+ }
+
+ [Fact]
+ public void Validator_Is_Invoked_On_Return()
+ {
+ var validatorCalls = 0;
+ var pool = new ObjectPool
- (
+ factory: () => new Item(),
+ validator: _ =>
+ {
+ validatorCalls++;
+ return true;
+ });
+
+ var item = pool.Rent();
+ pool.Return(item);
+
+ Assert.Equal(1, validatorCalls);
+ }
+
+ [Fact]
+ public void Validator_Is_Not_Invoked_On_Rent()
+ {
+ // The validator's job is to prepare an item for re-use *before* it goes back
+ // into the pool. Running it on Rent would either duplicate the work or imply
+ // a different contract (validate-on-take). Pin the current contract.
+ var validatorCalls = 0;
+ var pool = new ObjectPool
- (
+ factory: () => new Item(),
+ validator: _ =>
+ {
+ validatorCalls++;
+ return true;
+ });
+
+ _ = pool.Rent(); // fresh from factory; validator must not run
+ var item = pool.Rent();
+ pool.Return(item); // one validator call here
+ _ = pool.Rent(); // pulled from pool; validator must not run again
+
+ Assert.Equal(1, validatorCalls);
+ }
+
+ [Fact]
+ public void Return_Drops_Item_When_Validator_Returns_False()
+ {
+ var pool = new ObjectPool
- (
+ factory: () => new Item(),
+ validator: _ => false);
+
+ var item = pool.Rent();
+ pool.Return(item);
+
+ // Validator rejected the item, so the pool is empty and the next
+ // Rent produces a fresh instance.
+ var rented = pool.Rent();
+
+ Assert.NotSame(item, rented);
+ }
+
+ [Fact]
+ public void Validator_Can_Reset_Item_State_Before_Pooling()
+ {
+ var pool = new ObjectPool
- (
+ factory: () => new Item(),
+ validator: i =>
+ {
+ i.State = 0;
+ return true;
+ });
+
+ var item = pool.Rent();
+ item.State = 42;
+ pool.Return(item);
+
+ var rented = pool.Rent();
+
+ Assert.Same(item, rented);
+ Assert.Equal(0, rented.State);
+ }
+
+ [Fact]
+ public void Pool_Stays_Within_MaxSize_Under_Concurrent_Returns()
+ {
+ const int maxSize = 8;
+ const int returnsPerThread = 100;
+ const int threadCount = 16;
+
+ var pool = new ObjectPool
- (() => new Item(), maxSize: maxSize);
+
+ Parallel.For(0, threadCount, _ =>
+ {
+ for (var i = 0; i < returnsPerThread; i++)
+ {
+ pool.Return(new Item { State = 1 });
+ }
+ });
+
+ // Drain the pool. Items that came from the pool will still have State==1;
+ // factory-created items will have the default State==0.
+ var pooled = 0;
+ var seen = new HashSet
- ();
+
+ for (var i = 0; i < maxSize * 2; i++)
+ {
+ var item = pool.Rent();
+ if (!seen.Add(item))
+ {
+ Assert.Fail("ObjectPool returned the same instance twice without an intervening Return.");
+ }
+
+ if (item.State == 1)
+ {
+ pooled++;
+ }
+ }
+
+ Assert.True(pooled <= maxSize,
+ $"Expected to observe at most {maxSize} pooled items, observed {pooled}.");
+ }
+
+ [Fact]
+ public void Rent_And_Return_Survive_Parallel_Use_Without_Losing_Or_Duplicating_Items()
+ {
+ const int iterations = 5_000;
+ const int threadCount = 8;
+
+ var pool = new ObjectPool
- (() => new Item(), maxSize: 32);
+
+ Parallel.For(0, threadCount, _ =>
+ {
+ for (var i = 0; i < iterations; i++)
+ {
+ var item = pool.Rent();
+ Assert.NotNull(item);
+ pool.Return(item);
+ }
+ });
+ }
+ }
+}
diff --git a/tests/Avalonia.UnitTests/Fonts/BigEndianBuffer.cs b/tests/Avalonia.UnitTests/Fonts/BigEndianBuffer.cs
new file mode 100644
index 0000000000..65d4103dc4
--- /dev/null
+++ b/tests/Avalonia.UnitTests/Fonts/BigEndianBuffer.cs
@@ -0,0 +1,175 @@
+using System;
+using System.Buffers.Binary;
+
+namespace Avalonia.UnitTests
+{
+ ///
+ /// A growable big-endian byte writer for hand-crafting OpenType table sub-structures
+ /// (ItemVariationStores, cmap subtables, COLR paint graphs, ...) in tests.
+ ///
+ ///
+ /// OpenType is big-endian and pervasively offset-based: a header field holds the byte
+ /// offset of a sub-table that is written later. /
+ /// write a placeholder and return its position so the
+ /// real value can be back-patched with /
+ /// once the target's position is known (via ). All multi-byte
+ /// writes are big-endian.
+ ///
+ public sealed class BigEndianBuffer
+ {
+ private byte[] _buffer;
+ private int _length;
+
+ public BigEndianBuffer(int initialCapacity = 64)
+ {
+ _buffer = new byte[Math.Max(4, initialCapacity)];
+ }
+
+ /// The number of bytes written so far — also the offset the next write lands at.
+ public int Position => _length;
+
+ public BigEndianBuffer UInt8(int value)
+ {
+ EnsureCapacity(1);
+ _buffer[_length++] = checked((byte)value);
+ return this;
+ }
+
+ public BigEndianBuffer Int8(int value)
+ {
+ EnsureCapacity(1);
+ _buffer[_length++] = unchecked((byte)(sbyte)value);
+ return this;
+ }
+
+ public BigEndianBuffer UInt16(int value)
+ {
+ EnsureCapacity(2);
+ BinaryPrimitives.WriteUInt16BigEndian(_buffer.AsSpan(_length), checked((ushort)value));
+ _length += 2;
+ return this;
+ }
+
+ public BigEndianBuffer Int16(int value)
+ {
+ EnsureCapacity(2);
+ BinaryPrimitives.WriteInt16BigEndian(_buffer.AsSpan(_length), checked((short)value));
+ _length += 2;
+ return this;
+ }
+
+ public BigEndianBuffer UInt24(int value)
+ {
+ EnsureCapacity(3);
+ _buffer[_length++] = (byte)((value >> 16) & 0xFF);
+ _buffer[_length++] = (byte)((value >> 8) & 0xFF);
+ _buffer[_length++] = (byte)(value & 0xFF);
+ return this;
+ }
+
+ public BigEndianBuffer UInt32(uint value)
+ {
+ EnsureCapacity(4);
+ BinaryPrimitives.WriteUInt32BigEndian(_buffer.AsSpan(_length), value);
+ _length += 4;
+ return this;
+ }
+
+ public BigEndianBuffer Int32(int value)
+ {
+ EnsureCapacity(4);
+ BinaryPrimitives.WriteInt32BigEndian(_buffer.AsSpan(_length), value);
+ _length += 4;
+ return this;
+ }
+
+ /// Writes an F2DOT14 fixed-point value (the variation-coordinate / region format).
+ public BigEndianBuffer F2Dot14(double value)
+ => Int16((int)Math.Round(value * 16384.0));
+
+ /// Writes a 16.16 fixed-point value.
+ public BigEndianBuffer Fixed(double value)
+ => Int32((int)Math.Round(value * 65536.0));
+
+ /// Writes a 4-character tag (space-padded / truncated to 4 bytes).
+ public BigEndianBuffer Tag(string tag)
+ {
+ Span bytes = stackalloc byte[4] { 0x20, 0x20, 0x20, 0x20 };
+ var n = Math.Min(4, tag.Length);
+ for (var i = 0; i < n; i++)
+ {
+ bytes[i] = (byte)tag[i];
+ }
+
+ return Bytes(bytes);
+ }
+
+ public BigEndianBuffer Bytes(ReadOnlySpan bytes)
+ {
+ EnsureCapacity(bytes.Length);
+ bytes.CopyTo(_buffer.AsSpan(_length));
+ _length += bytes.Length;
+ return this;
+ }
+
+ /// Writes zero bytes (padding / placeholder data).
+ public BigEndianBuffer Zeros(int count)
+ {
+ if (count < 0)
+ throw new ArgumentOutOfRangeException(nameof(count), count, "count must be non-negative.");
+
+ EnsureCapacity(count);
+ _length += count; // already zero-initialized
+ return this;
+ }
+
+ /// Writes a placeholder big-endian uint16 and returns its position for later patching.
+ public int ReserveOffset16()
+ {
+ var pos = _length;
+ UInt16(0);
+ return pos;
+ }
+
+ /// Writes a placeholder big-endian uint32 and returns its position for later patching.
+ public int ReserveOffset32()
+ {
+ var pos = _length;
+ UInt32(0);
+ return pos;
+ }
+
+ /// Back-patches a big-endian uint16 at a previously reserved position.
+ public BigEndianBuffer PatchUInt16(int position, int value)
+ {
+ BinaryPrimitives.WriteUInt16BigEndian(_buffer.AsSpan(position, 2), checked((ushort)value));
+ return this;
+ }
+
+ /// Back-patches a big-endian uint32 at a previously reserved position.
+ public BigEndianBuffer PatchUInt32(int position, uint value)
+ {
+ BinaryPrimitives.WriteUInt32BigEndian(_buffer.AsSpan(position, 4), value);
+ return this;
+ }
+
+ public byte[] ToArray() => _buffer.AsSpan(0, _length).ToArray();
+
+ private void EnsureCapacity(int additional)
+ {
+ var required = _length + additional;
+ if (required <= _buffer.Length)
+ {
+ return;
+ }
+
+ var newCapacity = _buffer.Length * 2;
+ while (newCapacity < required)
+ {
+ newCapacity *= 2;
+ }
+
+ Array.Resize(ref _buffer, newCapacity);
+ }
+ }
+}
diff --git a/tests/Avalonia.UnitTests/Fonts/SyntheticFont.cs b/tests/Avalonia.UnitTests/Fonts/SyntheticFont.cs
new file mode 100644
index 0000000000..49b8370573
--- /dev/null
+++ b/tests/Avalonia.UnitTests/Fonts/SyntheticFont.cs
@@ -0,0 +1,366 @@
+using System;
+using System.Buffers.Binary;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Linq;
+using Avalonia.Media;
+using Avalonia.Media.Fonts;
+using Avalonia.Platform;
+
+namespace Avalonia.UnitTests
+{
+ ///
+ /// An editable, in-memory sfnt font for robustness / malformed-input tests.
+ ///
+ ///
+ ///
+ /// The font subsystem reads every table through
+ /// , and each OpenType table is self-contained
+ /// (its internal offsets are relative to the table start, never to the file). That
+ /// means a faithful test font is just a tag → bytes map:
+ /// parses a real font's sfnt table directory into one, lets a test mutate a single
+ /// table (truncate it, remove it, or patch a specific offset / count to a hostile
+ /// value), and hands the result back as an that serves
+ /// the (possibly corrupted) tables verbatim.
+ ///
+ ///
+ /// Seeding from a real font (rather than hand-building every required table) keeps the
+ /// base font valid for free, so a test can corrupt exactly one thing and attribute any
+ /// behaviour change to that corruption. Use for the
+ /// common path (it drives and the table parsers directly);
+ /// use when a test specifically needs the real
+ /// UnmanagedFontMemory sfnt-directory parser in the loop.
+ ///
+ ///
+ public sealed class SyntheticFont
+ {
+ // Tables in declaration order is irrelevant to the consumers (they look up by tag),
+ // but a stable order keeps ToBytes() deterministic.
+ private readonly Dictionary _tables;
+ private readonly uint _sfntVersion;
+
+ private SyntheticFont(uint sfntVersion, Dictionary tables)
+ {
+ _sfntVersion = sfntVersion;
+ _tables = tables;
+ }
+
+ /// Well-known embedded test font asset URIs, all in Avalonia.Base.UnitTests.
+ public static class Assets
+ {
+ private const string Prefix = "resm:Avalonia.Base.UnitTests.Assets.";
+ private const string Suffix = "?assembly=Avalonia.Base.UnitTests";
+
+ /// Static TrueType (glyf) font.
+ public const string InterRegular = Prefix + "Inter-Regular.ttf" + Suffix;
+
+ /// Variable TrueType font: carries fvar/avar/gvar/HVAR/MVAR.
+ public const string InterVariable = Prefix + "InterVariable.ttf" + Suffix;
+
+ /// PostScript (CFF / Type2) .otf.
+ public const string CffTest = Prefix + "CffTest.otf" + Suffix;
+
+ /// CID-keyed CFF .otf (FDSelect / FDArray).
+ public const string CidTest = Prefix + "CidTest.otf" + Suffix;
+
+ /// Variable CFF2 .otf (blend / vstore).
+ public const string AdobeVfPrototype = Prefix + "AdobeVFPrototype-Subset.otf" + Suffix;
+ }
+
+ /// Loads and parses one of the embedded test font assets.
+ public static SyntheticFont FromAsset(string assetUri)
+ {
+ var assetLoader = new StandardAssetLoader();
+ using var stream = assetLoader.Open(new Uri(assetUri));
+ return FromStream(stream);
+ }
+
+ /// Parses a font from a stream.
+ public static SyntheticFont FromStream(Stream stream)
+ {
+ using var ms = new MemoryStream();
+ stream.CopyTo(ms);
+ return FromBytes(ms.ToArray());
+ }
+
+ ///
+ /// Parses the sfnt table directory of into an editable
+ /// table set. Each table is copied into its own array so later mutation can't
+ /// alias the source bytes. TrueType Collections (ttcf) are not supported.
+ ///
+ public static SyntheticFont FromBytes(ReadOnlySpan font)
+ {
+ if (font.Length < 12)
+ {
+ throw new ArgumentException("Not a valid sfnt: shorter than the offset table.", nameof(font));
+ }
+
+ var sfntVersion = BinaryPrimitives.ReadUInt32BigEndian(font);
+
+ // 'ttcf' — a font collection. Out of scope: callers pass single-font assets.
+ if (sfntVersion == 0x74746366)
+ {
+ throw new NotSupportedException("TrueType Collections are not supported by SyntheticFont.");
+ }
+
+ int numTables = BinaryPrimitives.ReadUInt16BigEndian(font.Slice(4));
+
+ var tables = new Dictionary(numTables);
+
+ for (var i = 0; i < numTables; i++)
+ {
+ var record = 12 + i * 16;
+ if (record + 16 > font.Length)
+ {
+ throw new ArgumentException("Not a valid sfnt: table directory exceeds the file.", nameof(font));
+ }
+
+ var tag = new OpenTypeTag(BinaryPrimitives.ReadUInt32BigEndian(font.Slice(record)));
+ var offset = (int)BinaryPrimitives.ReadUInt32BigEndian(font.Slice(record + 8));
+ var length = (int)BinaryPrimitives.ReadUInt32BigEndian(font.Slice(record + 12));
+
+ if (offset < 0 || length < 0 || (long)offset + length > font.Length)
+ {
+ throw new ArgumentException($"Not a valid sfnt: table '{tag}' is out of range.", nameof(font));
+ }
+
+ tables[tag] = font.Slice(offset, length).ToArray();
+ }
+
+ return new SyntheticFont(sfntVersion, tables);
+ }
+
+ /// The tags present in the font.
+ public IReadOnlyCollection Tags => _tables.Keys;
+
+ /// Whether the named table is present.
+ public bool Contains(string tag) => _tables.ContainsKey(OpenTypeTag.Parse(tag));
+
+ /// Returns a copy of the named table's current bytes.
+ public byte[] GetTable(string tag)
+ {
+ var bytes = Require(tag);
+ return (byte[])bytes.Clone();
+ }
+
+ /// The current byte length of the named table.
+ public int TableLength(string tag) => Require(tag).Length;
+
+ /// Replaces the named table's bytes wholesale (adding it if absent).
+ public SyntheticFont Replace(string tag, byte[] bytes)
+ {
+ _tables[OpenTypeTag.Parse(tag)] = bytes ?? throw new ArgumentNullException(nameof(bytes));
+ return this;
+ }
+
+ /// Removes the named table from the font (no-op if absent).
+ public SyntheticFont Remove(string tag)
+ {
+ _tables.Remove(OpenTypeTag.Parse(tag));
+ return this;
+ }
+
+ ///
+ /// Truncates the named table to bytes — the canonical
+ /// "table is shorter than its header claims" corruption.
+ ///
+ public SyntheticFont Truncate(string tag, int newLength)
+ {
+ var bytes = Require(tag);
+ if (newLength < 0 || newLength > bytes.Length)
+ {
+ throw new ArgumentOutOfRangeException(nameof(newLength), newLength,
+ $"Truncation length must be in [0, {bytes.Length}] for table '{tag}'.");
+ }
+
+ _tables[OpenTypeTag.Parse(tag)] = bytes.AsSpan(0, newLength).ToArray();
+ return this;
+ }
+
+ /// Overwrites a single byte at within the named table.
+ public SyntheticFont PatchUInt8(string tag, int offset, byte value)
+ {
+ EditableSlice(tag, offset, 1)[0] = value;
+ return this;
+ }
+
+ /// Overwrites a big-endian uint16 at within the named table.
+ public SyntheticFont PatchUInt16(string tag, int offset, ushort value)
+ {
+ BinaryPrimitives.WriteUInt16BigEndian(EditableSlice(tag, offset, 2), value);
+ return this;
+ }
+
+ /// Overwrites a big-endian uint32 at within the named table.
+ public SyntheticFont PatchUInt32(string tag, int offset, uint value)
+ {
+ BinaryPrimitives.WriteUInt32BigEndian(EditableSlice(tag, offset, 4), value);
+ return this;
+ }
+
+ /// Runs an arbitrary edit against the named table's backing array.
+ public SyntheticFont Mutate(string tag, Action edit)
+ {
+ edit(Require(tag));
+ return this;
+ }
+
+ ///
+ /// Returns an that serves the current (possibly
+ /// corrupted) tables. This is the primary seam: it drives the
+ /// constructor and every table parser without an sfnt
+ /// round-trip. Each call snapshots the current table set, so a typeface is
+ /// unaffected by later mutation of this .
+ ///
+ public IPlatformTypeface ToPlatformTypeface(string familyName = "Synthetic")
+ {
+ var snapshot = new Dictionary(_tables.Count);
+ foreach (var kvp in _tables)
+ {
+ snapshot[kvp.Key] = (byte[])kvp.Value.Clone();
+ }
+
+ return new SyntheticPlatformTypeface(snapshot, familyName);
+ }
+
+ ///
+ /// Attempts to build a over the current tables via
+ /// — returns null when the font is
+ /// rejected (the swallow-and-deny path: any parse exception during construction is caught
+ /// and turned into a null result).
+ ///
+ public GlyphTypeface? TryCreateGlyphTypeface(FontSimulations simulations = FontSimulations.None)
+ => GlyphTypeface.TryCreate(ToPlatformTypeface(), simulations);
+
+ ///
+ /// Builds a via the public constructor, which does
+ /// not swallow parse exceptions — use this to assert whether a corruption
+ /// throws out of construction (vs. degrading gracefully).
+ ///
+ public GlyphTypeface CreateGlyphTypeface(FontSimulations simulations = FontSimulations.None)
+ => new GlyphTypeface(ToPlatformTypeface(), simulations);
+
+ ///
+ /// Re-assembles the current table set into a valid sfnt byte array (offset table +
+ /// 4-byte-aligned tables, directory sorted by tag). Table checksums are written as
+ /// zero — the loader does not validate them. Use this only when a test needs the
+ /// real UnmanagedFontMemory directory parser in the loop; otherwise prefer
+ /// .
+ ///
+ public byte[] ToBytes()
+ {
+ var ordered = _tables.OrderBy(kvp => (uint)kvp.Key).ToArray();
+ var numTables = ordered.Length;
+
+ var directorySize = 12 + numTables * 16;
+ var totalSize = directorySize;
+ foreach (var kvp in ordered)
+ {
+ totalSize += Align4(kvp.Value.Length);
+ }
+
+ var font = new byte[totalSize];
+ var span = font.AsSpan();
+
+ // Offset table.
+ BinaryPrimitives.WriteUInt32BigEndian(span, _sfntVersion);
+ BinaryPrimitives.WriteUInt16BigEndian(span.Slice(4), (ushort)numTables);
+
+ var maxPow2 = 1;
+ var entrySelector = 0;
+ while (maxPow2 * 2 <= numTables)
+ {
+ maxPow2 *= 2;
+ entrySelector++;
+ }
+ var searchRange = maxPow2 * 16;
+ BinaryPrimitives.WriteUInt16BigEndian(span.Slice(6), (ushort)searchRange);
+ BinaryPrimitives.WriteUInt16BigEndian(span.Slice(8), (ushort)entrySelector);
+ BinaryPrimitives.WriteUInt16BigEndian(span.Slice(10), (ushort)(numTables * 16 - searchRange));
+
+ // Directory records + table bodies.
+ var dataOffset = directorySize;
+ for (var i = 0; i < numTables; i++)
+ {
+ var (tag, bytes) = ordered[i];
+ var record = 12 + i * 16;
+
+ BinaryPrimitives.WriteUInt32BigEndian(span.Slice(record), (uint)tag);
+ BinaryPrimitives.WriteUInt32BigEndian(span.Slice(record + 4), 0u); // checksum (not validated)
+ BinaryPrimitives.WriteUInt32BigEndian(span.Slice(record + 8), (uint)dataOffset);
+ BinaryPrimitives.WriteUInt32BigEndian(span.Slice(record + 12), (uint)bytes.Length);
+
+ bytes.AsSpan().CopyTo(span.Slice(dataOffset));
+ dataOffset += Align4(bytes.Length);
+ }
+
+ return font;
+ }
+
+ private byte[] Require(string tag)
+ {
+ if (!_tables.TryGetValue(OpenTypeTag.Parse(tag), out var bytes))
+ {
+ throw new InvalidOperationException($"Font has no '{tag}' table.");
+ }
+
+ return bytes;
+ }
+
+ private Span EditableSlice(string tag, int offset, int size)
+ {
+ var bytes = Require(tag);
+ if (offset < 0 || (long)offset + size > bytes.Length)
+ {
+ throw new ArgumentOutOfRangeException(nameof(offset), offset,
+ $"[{offset}, {offset + size}) is out of range for table '{tag}' (length {bytes.Length}).");
+ }
+
+ return bytes.AsSpan(offset, size);
+ }
+
+ private static int Align4(int length) => (length + 3) & ~3;
+
+ ///
+ /// An that serves a fixed tag → bytes map.
+ /// No SkiaSharp face, no real stream — just enough for the managed font pipeline.
+ ///
+ private sealed class SyntheticPlatformTypeface : IPlatformTypeface
+ {
+ private readonly Dictionary _tables;
+
+ public SyntheticPlatformTypeface(Dictionary tables, string familyName)
+ {
+ _tables = tables;
+ FamilyName = familyName;
+ }
+
+ public string FamilyName { get; }
+ public FontWeight Weight => FontWeight.Normal;
+ public FontStyle Style => FontStyle.Normal;
+ public FontStretch Stretch => FontStretch.Normal;
+ public FontSimulations FontSimulations => FontSimulations.None;
+
+ public bool TryGetTable(OpenTypeTag tag, out ReadOnlyMemory table)
+ {
+ if (_tables.TryGetValue(tag, out var bytes))
+ {
+ table = bytes;
+ return true;
+ }
+
+ table = default;
+ return false;
+ }
+
+ public bool TryGetStream([NotNullWhen(true)] out Stream? stream)
+ {
+ stream = null;
+ return false;
+ }
+
+ public void Dispose() { }
+ }
+ }
+}