diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Glyf/GlyfTable.cs b/src/Avalonia.Base/Media/Fonts/Tables/Glyf/GlyfTable.cs index 227a780feea..97647e8ef22 100644 --- a/src/Avalonia.Base/Media/Fonts/Tables/Glyf/GlyfTable.cs +++ b/src/Avalonia.Base/Media/Fonts/Tables/Glyf/GlyfTable.cs @@ -1,5 +1,6 @@ using System; using System.Buffers; +using System.Buffers.Binary; using Avalonia.Platform; using Avalonia.Logging; @@ -99,6 +100,126 @@ public bool TryGetGlyphData(int glyphIndex, out ReadOnlyMemory data) return true; } + /// + /// Reads a glyph's bounding box from its 'glyf' header without parsing contours. + /// + /// + /// The values are the control-point bounding box stored in the glyph header + /// (the min/max of all on- and off-curve points), in font design units. This is a + /// slight superset of the rendered ink bounds for glyphs with off-curve points. + /// Composite glyphs carry their overall bounding box in the header too, so no + /// recursion is needed. Returns with all-zero bounds for + /// empty glyphs (e.g. whitespace); returns when the glyph + /// index is out of range or the glyph data is too short to contain a header. + /// + /// The zero-based glyph index. + /// The minimum x coordinate of the bounding box. + /// The minimum y coordinate of the bounding box. + /// The maximum x coordinate of the bounding box. + /// The maximum y coordinate of the bounding box. + /// if bounds were resolved (including empty glyphs); otherwise . + public bool TryGetGlyphBounds(int glyphIndex, out short xMin, out short yMin, out short xMax, out short yMax) + { + xMin = 0; + yMin = 0; + xMax = 0; + yMax = 0; + + if (!TryGetGlyphData(glyphIndex, out var data)) + { + // Out of range. + return false; + } + + if (data.IsEmpty) + { + // Empty glyph (e.g. whitespace): valid, zero bounds. + return true; + } + + var span = data.Span; + + // Glyph header: int16 numberOfContours, then int16 xMin, yMin, xMax, yMax. + if (span.Length < 10) + { + return false; + } + + xMin = BinaryPrimitives.ReadInt16BigEndian(span.Slice(2, 2)); + yMin = BinaryPrimitives.ReadInt16BigEndian(span.Slice(4, 2)); + xMax = BinaryPrimitives.ReadInt16BigEndian(span.Slice(6, 2)); + yMax = BinaryPrimitives.ReadInt16BigEndian(span.Slice(8, 2)); + + return true; + } + + /// + /// Reads bounding boxes for a batch of glyphs into . + /// + /// + /// The hot path for ink-bounds computation. The glyf and loca spans are + /// fetched once for the whole batch (not per glyph), and offsets and headers are read + /// directly — no per-glyph conversion, no + /// intermediate slices, no nested call chain. Out-of-range, empty, or malformed + /// glyphs are written as the default (zero) box. + /// + /// The glyph indices to read. + /// Output; must be at least as long as . + public void GetGlyphBounds(ReadOnlySpan glyphIndices, Span bounds) + { + var glyf = _glyfData.Span; + var loca = _locaTable.RawData; + var shortFormat = _locaTable.IsShortFormat; + var glyphCount = _locaTable.GlyphCount; + var entrySize = shortFormat ? 2 : 4; + + for (var i = 0; i < glyphIndices.Length; i++) + { + bounds[i] = default; + + int gid = glyphIndices[i]; + + if ((uint)gid >= (uint)glyphCount) + { + continue; + } + + var locaOffset = gid * entrySize; + + // Need both loca[gid] and loca[gid + 1]. + if (locaOffset + (2 * entrySize) > loca.Length) + { + continue; + } + + int start, end; + + if (shortFormat) + { + // Short format: uint16 values stored divided by 2 + start = BinaryPrimitives.ReadUInt16BigEndian(loca.Slice(locaOffset)) * 2; + end = BinaryPrimitives.ReadUInt16BigEndian(loca.Slice(locaOffset + 2)) * 2; + } + else + { + start = (int)BinaryPrimitives.ReadUInt32BigEndian(loca.Slice(locaOffset)); + end = (int)BinaryPrimitives.ReadUInt32BigEndian(loca.Slice(locaOffset + 4)); + } + + // Empty (start == end) or malformed glyph → leave the zero box. + if (end - start < 10 || start < 0 || (uint)end > (uint)glyf.Length) + { + continue; + } + + bounds[i] = new GlyphBounds( + BinaryPrimitives.ReadInt16BigEndian(glyf.Slice(start + 2)), + BinaryPrimitives.ReadInt16BigEndian(glyf.Slice(start + 4)), + BinaryPrimitives.ReadInt16BigEndian(glyf.Slice(start + 6)), + BinaryPrimitives.ReadInt16BigEndian(glyf.Slice(start + 8))); + } + } + /// /// Builds the glyph outline into the provided geometry context. Returns false for empty glyphs. /// Coordinates are in font design units. Composite glyphs are supported. diff --git a/src/Avalonia.Base/Media/Fonts/Tables/LocaTable.cs b/src/Avalonia.Base/Media/Fonts/Tables/LocaTable.cs index 511a9c4409f..eff238b9381 100644 --- a/src/Avalonia.Base/Media/Fonts/Tables/LocaTable.cs +++ b/src/Avalonia.Base/Media/Fonts/Tables/LocaTable.cs @@ -37,6 +37,19 @@ private LocaTable(ReadOnlyMemory data, int glyphCount, bool isShortFormat) /// public int GlyphCount => _glyphCount; + /// + /// Gets the raw table bytes. Exposed so batch readers can fetch the span once and + /// read offsets directly, avoiding a per-glyph + /// conversion. + /// + internal ReadOnlySpan RawData => _data.Span; + + /// + /// Gets a value indicating whether offsets are stored in the short (uint16×2) + /// format; otherwise the long (uint32) format is used. + /// + internal bool IsShortFormat => _isShortFormat; + /// /// Loads the loca table from the specified typeface. /// diff --git a/src/Avalonia.Base/Media/GlyphBounds.cs b/src/Avalonia.Base/Media/GlyphBounds.cs new file mode 100644 index 00000000000..29f8463c313 --- /dev/null +++ b/src/Avalonia.Base/Media/GlyphBounds.cs @@ -0,0 +1,29 @@ +using System; + +namespace Avalonia.Media +{ + /// + /// A glyph's control-point bounding box in font design units, as stored in the + /// glyf header. Used by the batch bounds path + /// () where only the ink extent is + /// needed and advances are already known by the caller. + /// + internal readonly record struct GlyphBounds(short XMin, short YMin, short XMax, short YMax) + { + /// + /// Width of the bounding box (), clamped to a + /// non-negative value. A malformed header with < + /// yields 0 rather than wrapping when narrowed to an unsigned extent. The maximum + /// possible extent for coordinates is 65535, so the result always + /// fits in a . + /// + public int Width => Math.Max(0, XMax - XMin); + + /// + /// Height of the bounding box (), clamped to a + /// non-negative value. A malformed header with < + /// yields 0 rather than wrapping when narrowed to an unsigned extent. + /// + public int Height => Math.Max(0, YMax - YMin); + } +} diff --git a/src/Avalonia.Base/Media/GlyphMetrics.cs b/src/Avalonia.Base/Media/GlyphMetrics.cs index 11a4a3fa23a..9fbeeacd998 100644 --- a/src/Avalonia.Base/Media/GlyphMetrics.cs +++ b/src/Avalonia.Base/Media/GlyphMetrics.cs @@ -1,24 +1,54 @@ -namespace Avalonia.Media; +namespace Avalonia.Media; public readonly record struct GlyphMetrics { /// - /// Distance from the x-origin to the left extremum of the glyph. + /// Distance from the x-origin to the leftmost outline point. /// public int XBearing { get; init; } /// - /// Distance from the top extremum of the glyph to the y-origin. + /// Distance from the topmost outline point to the y-origin. /// public int YBearing { get; init; } /// - /// Distance from the left extremum of the glyph to the right extremum. + /// Width of the glyph's outline bounding box. /// public ushort Width { get; init; } /// - /// Distance from the top extremum of the glyph to the bottom extremum. + /// Height of the glyph's outline bounding box. /// public ushort Height { get; init; } + + /// + /// Horizontal advance width (distance to the next glyph's origin). + /// + public ushort AdvanceWidth { get; init; } + + /// + /// Vertical advance height (distance to the next glyph's origin in vertical layout). + /// + public ushort AdvanceHeight { get; init; } + + /// + /// Horizontal offset from the glyph's origin to the leftmost outline point (used for bitmap glyphs). + /// + public ushort XOffset { get; init; } + + /// + /// Vertical offset from the glyph's origin to the topmost outline point (used for bitmap glyphs). + /// + public ushort YOffset { get; init; } + + /// + /// X coordinate of the vertical origin (used for vertical layout). + /// + public ushort VerticalOriginX { get; init; } + + /// + /// Y coordinate of the vertical origin (used for vertical layout). + /// + public ushort VerticalOriginY { get; init; } } diff --git a/src/Avalonia.Base/Media/GlyphTypeface.cs b/src/Avalonia.Base/Media/GlyphTypeface.cs index 217b37bb40c..9abc8ed5b73 100644 --- a/src/Avalonia.Base/Media/GlyphTypeface.cs +++ b/src/Avalonia.Base/Media/GlyphTypeface.cs @@ -713,6 +713,53 @@ public bool TryGetHorizontalGlyphAdvances(ReadOnlySpan glyphIndices, Spa return _hmTable.TryGetAdvances(glyphIndices, advances); } + /// + /// Attempts to retrieve the vertical advance height for the specified glyph. + /// + /// Returns false if vertical metrics are not available (the font has no + /// vmtx table — the common case for Latin fonts) or if the specified glyph + /// is not present in the metrics table. + /// The identifier of the glyph for which to obtain the vertical advance height. + /// When this method returns, contains the vertical advance height of the glyph if found; otherwise, zero. This + /// parameter is passed uninitialized. + /// true if the vertical advance height was successfully retrieved; otherwise, false. + public bool TryGetVerticalGlyphAdvance(ushort glyphIndex, out ushort advance) + { + advance = default; + + if (!_hasVerticalMetrics || _vmTable is null) + { + return false; + } + + if (!_vmTable.TryGetAdvance(glyphIndex, out advance)) + { + return false; + } + + return true; + } + + /// + /// Attempts to retrieve vertical advance heights for multiple glyphs in a single operation. + /// + /// This method is significantly more efficient than calling + /// multiple times as it minimizes memory access overhead and exploits data locality. This is the preferred method + /// for batch vertical-layout scenarios (CJK, Mongolian). Returns false if vertical metrics + /// are not available. + /// Read-only span of glyph identifiers for which to retrieve advance heights. + /// Output span to write the advance heights. Must be at least as long as . + /// true if vertical metrics are available and all advances were successfully retrieved; otherwise, false. + public bool TryGetVerticalGlyphAdvances(ReadOnlySpan glyphIndices, Span advances) + { + if (!_hasVerticalMetrics || _vmTable is null) + { + return false; + } + + return _vmTable.TryGetAdvances(glyphIndices, advances); + } + /// /// Attempts to retrieve the metrics for the specified glyph. /// @@ -743,17 +790,31 @@ public bool TryGetGlyphMetrics(ushort glyph, out GlyphMetrics metrics) hasVertical = _vmTable.TryGetMetrics(glyph, out vMetric); } - if (!hasHorizontal && !hasVertical) + short xMin = 0, yMin = 0, xMax = 0, yMax = 0; + var hasBounds = _glyfTable != null + && _glyfTable.TryGetGlyphBounds(glyph, out xMin, out yMin, out xMax, out yMax); + + if (!hasHorizontal && !hasVertical && !hasBounds) { return false; } + // Funnel the raw header values through GlyphBounds so the ink extent is computed + // (and clamped to non-negative) the same way as the batch path below — a malformed + // header with xMax < xMin must not wrap when narrowed to the ushort Width/Height. + var box = new GlyphBounds(xMin, yMin, xMax, yMax); + metrics = new GlyphMetrics { - XBearing = hMetric.LeftSideBearing, - YBearing = vMetric.TopSideBearing, - Width = hMetric.AdvanceWidth, - Height = vMetric.AdvanceHeight + // Bounding box (ink extent) from the glyf header; side bearings fall back + // to hmtx/vmtx when the glyph has no outline data. + XBearing = hasBounds ? box.XMin : (hasHorizontal ? hMetric.LeftSideBearing : (short)0), + YBearing = hasBounds ? box.YMax : (hasVertical ? vMetric.TopSideBearing : (short)0), + Width = hasBounds ? (ushort)box.Width : (ushort)0, + Height = hasBounds ? (ushort)box.Height : (ushort)0, + // Advances come from the metrics tables. + AdvanceWidth = hasHorizontal ? hMetric.AdvanceWidth : (ushort)0, + AdvanceHeight = hasVertical ? vMetric.AdvanceHeight : (ushort)0, }; return true; @@ -814,18 +875,79 @@ public bool TryGetGlyphMetrics(ReadOnlySpan glyphIndices, Span bounds = glyphIndices.Length <= 256 + ? stackalloc GlyphBounds[glyphIndices.Length] + : new GlyphBounds[glyphIndices.Length]; + + _glyfTable.GetGlyphBounds(glyphIndices, bounds); + + for (int i = 0; i < glyphIndices.Length; i++) { - XBearing = hasHorizontal ? hMetrics[i].LeftSideBearing : (short)0, - YBearing = hasVertical ? vMetrics[i].TopSideBearing : (short)0, - Width = hasHorizontal ? hMetrics[i].AdvanceWidth : (ushort)0, - Height = hasVertical ? vMetrics[i].AdvanceHeight : (ushort)0 - }; + var b = bounds[i]; + + metrics[i] = new GlyphMetrics + { + XBearing = b.XMin, + YBearing = b.YMax, + Width = (ushort)b.Width, + Height = (ushort)b.Height, + AdvanceWidth = hasHorizontal ? hMetrics[i].AdvanceWidth : (ushort)0, + AdvanceHeight = hasVertical ? vMetrics[i].AdvanceHeight : (ushort)0, + }; + } + } + else + { + // No glyf table (CFF / CFF2): there are no ink bounds to read, so bearings fall + // back to hmtx/vmtx and the box stays zero. No bounds buffer is allocated either. + for (int i = 0; i < glyphIndices.Length; i++) + { + metrics[i] = new GlyphMetrics + { + XBearing = hasHorizontal ? hMetrics[i].LeftSideBearing : (short)0, + YBearing = hasVertical ? vMetrics[i].TopSideBearing : (short)0, + Width = 0, + Height = 0, + AdvanceWidth = hasHorizontal ? hMetrics[i].AdvanceWidth : (ushort)0, + AdvanceHeight = hasVertical ? vMetrics[i].AdvanceHeight : (ushort)0, + }; + } + } + + return true; + } + + /// + /// Reads ink bounding boxes for a batch of glyphs from the font's glyf table. + /// + /// + /// Allocation-free hot path for glyph ink-bounds computation: the glyf and + /// loca spans are fetched once for the whole batch. Use this rather than + /// when + /// only bounds are needed and advances are already known (e.g. from shaping). + /// + /// Glyph identifiers to read. + /// Output; must be at least as long as . + /// Out-of-range, empty, or malformed glyphs are written as the default (zero) box. + /// true if the font has a glyf table; otherwise false. + internal bool TryGetGlyphBounds(ReadOnlySpan glyphIndices, Span bounds) + { + if (bounds.Length < glyphIndices.Length) + { + throw new ArgumentException("Output span must be at least as long as input span", nameof(bounds)); } + if (_glyfTable is null) + { + return false; + } + + _glyfTable.GetGlyphBounds(glyphIndices, bounds); + return true; } diff --git a/tests/Avalonia.Base.UnitTests/Media/Fonts/Tables/GlyfTableTests.cs b/tests/Avalonia.Base.UnitTests/Media/Fonts/Tables/GlyfTableTests.cs index aa0b5de5e8e..4f252d3827d 100644 --- a/tests/Avalonia.Base.UnitTests/Media/Fonts/Tables/GlyfTableTests.cs +++ b/tests/Avalonia.Base.UnitTests/Media/Fonts/Tables/GlyfTableTests.cs @@ -189,6 +189,65 @@ public void TryBuildGlyphGeometry_Builds_Composite_Glyph() Assert.True(context.AllFiguresClosed); } + [Theory] + [InlineData(-1)] + [InlineData(int.MaxValue)] + public void TryGetGlyphBounds_Returns_False_For_Out_Of_Range(int glyphIndex) + { + var glyf = LoadGlyf(LoadInter()); + + Assert.False(glyf.TryGetGlyphBounds(glyphIndex, out _, out _, out _, out _)); + } + + [Fact] + public void TryGetGlyphBounds_Returns_Zero_For_Empty_Glyph() + { + var typeface = LoadInter(); + var glyf = LoadGlyf(typeface); + + var spaceGlyph = GlyphFor(typeface, ' '); + + // Empty glyph is valid but carries no box. + Assert.True(glyf.TryGetGlyphBounds(spaceGlyph, out var xMin, out var yMin, out var xMax, out var yMax)); + Assert.Equal(0, xMin); + Assert.Equal(0, yMin); + Assert.Equal(0, xMax); + Assert.Equal(0, yMax); + } + + [Fact] + public void TryGetGlyphBounds_Returns_NonEmpty_Box_For_Letter() + { + var typeface = LoadInter(); + var glyf = LoadGlyf(typeface); + + var letterGlyph = GlyphFor(typeface, 'A'); + + Assert.True(glyf.TryGetGlyphBounds(letterGlyph, out var xMin, out var yMin, out var xMax, out var yMax)); + Assert.True(xMax > xMin, "Letter glyph should have a positive-width box."); + Assert.True(yMax > yMin, "Letter glyph should have a positive-height box."); + } + + [Fact] + public void TryGetGlyphBounds_Matches_GlyphDescriptor_Header() + { + var typeface = LoadInter(); + var glyf = LoadGlyf(typeface); + + var letterGlyph = GlyphFor(typeface, 'A'); + + Assert.True(glyf.TryGetGlyphData(letterGlyph, out var data)); + var descriptor = new GlyphDescriptor(data); + + Assert.True(glyf.TryGetGlyphBounds(letterGlyph, out var xMin, out var yMin, out var xMax, out var yMax)); + + // The header-only read must agree with the descriptor's parsed bounds. + Assert.Equal((double)xMin, descriptor.ConservativeBounds.X); + Assert.Equal((double)yMin, descriptor.ConservativeBounds.Y); + Assert.Equal((double)(xMax - xMin), descriptor.ConservativeBounds.Width); + Assert.Equal((double)(yMax - yMin), descriptor.ConservativeBounds.Height); + } + private static int FindCompositeGlyph(GlyfTable glyf) { for (var i = 0; i < glyf.GlyphCount; i++) diff --git a/tests/Avalonia.Base.UnitTests/Media/GlyphBoundsTests.cs b/tests/Avalonia.Base.UnitTests/Media/GlyphBoundsTests.cs new file mode 100644 index 00000000000..19cac9bf40a --- /dev/null +++ b/tests/Avalonia.Base.UnitTests/Media/GlyphBoundsTests.cs @@ -0,0 +1,43 @@ +using Avalonia.Media; +using Xunit; + +namespace Avalonia.Base.UnitTests.Media +{ + public class GlyphBoundsTests + { + [Fact] + public void Width_And_Height_Are_Extents_For_A_Well_Formed_Header() + { + var bounds = new GlyphBounds(XMin: 10, YMin: 20, XMax: 110, YMax: 220); + + Assert.Equal(100, bounds.Width); + Assert.Equal(200, bounds.Height); + } + + [Fact] + public void Width_And_Height_Clamp_To_Zero_When_Max_Is_Below_Min() + { + // A malformed glyf header (xMax < xMin / yMax < yMin) must not produce a + // negative extent that wraps to a huge value when narrowed to a ushort. + var bounds = new GlyphBounds(XMin: 100, YMin: 100, XMax: 50, YMax: 40); + + Assert.Equal(0, bounds.Width); + Assert.Equal(0, bounds.Height); + + // The clamp also keeps the value inside ushort range. + Assert.True(bounds.Width <= ushort.MaxValue); + Assert.True(bounds.Height <= ushort.MaxValue); + } + + [Fact] + public void Maximum_Extent_For_Int16_Coordinates_Fits_In_Ushort() + { + // short.MinValue..short.MaxValue gives the widest possible extent, 65535, + // which is exactly ushort.MaxValue — so narrowing the clamped value never overflows. + var bounds = new GlyphBounds(short.MinValue, short.MinValue, short.MaxValue, short.MaxValue); + + Assert.Equal(ushort.MaxValue, bounds.Width); + Assert.Equal(ushort.MaxValue, bounds.Height); + } + } +} diff --git a/tests/Avalonia.Base.UnitTests/Media/GlyphTypefaceTests.cs b/tests/Avalonia.Base.UnitTests/Media/GlyphTypefaceTests.cs index 71011ae4bc2..75e855c968d 100644 --- a/tests/Avalonia.Base.UnitTests/Media/GlyphTypefaceTests.cs +++ b/tests/Avalonia.Base.UnitTests/Media/GlyphTypefaceTests.cs @@ -16,6 +16,7 @@ public class GlyphTypefaceTests private const string InterFontUri = "resm:Avalonia.Base.UnitTests.Assets.Inter-Regular.ttf?assembly=Avalonia.Base.UnitTests"; private const string BlankFontUri = "resm:Avalonia.Base.UnitTests.Assets.AdobeBlank2VF.ttf?assembly=Avalonia.Base.UnitTests"; private const string GB18030FontUri = "resm:Avalonia.Base.UnitTests.Assets.NISC18030.ttf?assembly=Avalonia.Base.UnitTests"; + private const string MiSansFontUri = "resm:Avalonia.Base.UnitTests.Assets.MiSans-Normal.ttf?assembly=Avalonia.Base.UnitTests"; [Fact] public void Should_Load_Inter_Font() @@ -73,8 +74,8 @@ public void GetGlyphAdvance_Should_Return_Advance_For_GlyphId() // Ensure advance can be retrieved Assert.True(typeface.TryGetHorizontalGlyphAdvance(glyphIndex, out var advance)); - // Advance returned by GetGlyphAdvance should match the metrics width - Assert.Equal(metrics.Width, advance); + // The advance lives on AdvanceWidth; Width is the ink bounding-box width. + Assert.Equal(metrics.AdvanceWidth, advance); } [Theory] @@ -277,6 +278,134 @@ public void TryGetGlyphMetrics_Should_Return_Valid_Metrics() Assert.True(metrics.Width > 0); } + [Fact] + public void TryGetGlyphMetrics_Width_Is_Ink_Box_Not_Advance() + { + var assetLoader = new StandardAssetLoader(); + + using var stream = assetLoader.Open(new Uri(InterFontUri)); + + var typeface = new GlyphTypeface(new CustomPlatformTypeface(stream)); + + var glyphIndex = typeface.CharacterToGlyphMap['A']; + + Assert.True(typeface.TryGetGlyphMetrics(glyphIndex, out var metrics)); + Assert.True(typeface.TryGetHorizontalGlyphAdvance(glyphIndex, out var advance)); + + // The advance belongs on AdvanceWidth... + Assert.Equal(advance, metrics.AdvanceWidth); + + // ...and Width is the ink bounding-box width, a distinct value. + Assert.True(metrics.Width > 0); + Assert.NotEqual(metrics.AdvanceWidth, metrics.Width); + } + + [Fact] + public void TryGetGlyphMetrics_Empty_Glyph_Has_Advance_But_No_Ink() + { + var assetLoader = new StandardAssetLoader(); + + using var stream = assetLoader.Open(new Uri(InterFontUri)); + + var typeface = new GlyphTypeface(new CustomPlatformTypeface(stream)); + + var spaceGlyph = typeface.CharacterToGlyphMap[' ']; + + Assert.True(typeface.TryGetGlyphMetrics(spaceGlyph, out var metrics)); + + // The space glyph has a horizontal advance but no ink. + Assert.True(metrics.AdvanceWidth > 0); + Assert.Equal((ushort)0, metrics.Width); + Assert.Equal((ushort)0, metrics.Height); + } + + [Fact] + public void TryGetGlyphMetrics_Batch_Matches_Single() + { + var assetLoader = new StandardAssetLoader(); + + using var stream = assetLoader.Open(new Uri(InterFontUri)); + + var typeface = new GlyphTypeface(new CustomPlatformTypeface(stream)); + + var map = typeface.CharacterToGlyphMap; + var glyphIndices = new ushort[] { map['A'], map['B'], map['g'], map[' '] }; + + var batch = new GlyphMetrics[glyphIndices.Length]; + Assert.True(typeface.TryGetGlyphMetrics(glyphIndices, batch)); + + for (var i = 0; i < glyphIndices.Length; i++) + { + Assert.True(typeface.TryGetGlyphMetrics(glyphIndices[i], out var single)); + + // GlyphMetrics is a record struct, so this is structural equality. + Assert.Equal(single, batch[i]); + } + } + + [Fact] + public void TryGetVerticalGlyphAdvance_Returns_False_For_Latin_Font() + { + var assetLoader = new StandardAssetLoader(); + using var stream = assetLoader.Open(new Uri(InterFontUri)); + var typeface = new GlyphTypeface(new CustomPlatformTypeface(stream)); + + var glyphIndex = typeface.CharacterToGlyphMap['A']; + + // Latin fonts typically carry no vmtx table — the call returns false and + // leaves the advance at zero. + Assert.False(typeface.TryGetVerticalGlyphAdvance(glyphIndex, out var advance)); + Assert.Equal((ushort)0, advance); + } + + [Fact] + public void TryGetVerticalGlyphAdvances_Batch_Returns_False_For_Latin_Font() + { + var assetLoader = new StandardAssetLoader(); + using var stream = assetLoader.Open(new Uri(InterFontUri)); + var typeface = new GlyphTypeface(new CustomPlatformTypeface(stream)); + + var map = typeface.CharacterToGlyphMap; + var glyphIndices = new ushort[] { map['A'], map['B'], map['g'] }; + var advances = new ushort[glyphIndices.Length]; + + Assert.False(typeface.TryGetVerticalGlyphAdvances(glyphIndices, advances)); + } + + [Fact] + public void TryGetVerticalGlyphAdvance_Returns_True_For_CJK_Font() + { + var assetLoader = new StandardAssetLoader(); + using var stream = assetLoader.Open(new Uri(MiSansFontUri)); + var typeface = new GlyphTypeface(new CustomPlatformTypeface(stream)); + + // CJK glyph: U+4E2D ("中"). MiSans is a CJK font with a vmtx table. + var glyphIndex = typeface.CharacterToGlyphMap['中']; + + Assert.True(typeface.TryGetVerticalGlyphAdvance(glyphIndex, out var advance)); + Assert.True(advance > 0, "Expected a positive vertical advance for a CJK glyph."); + } + + [Fact] + public void TryGetVerticalGlyphAdvances_Batch_Matches_Single_For_CJK_Font() + { + var assetLoader = new StandardAssetLoader(); + using var stream = assetLoader.Open(new Uri(MiSansFontUri)); + var typeface = new GlyphTypeface(new CustomPlatformTypeface(stream)); + + var map = typeface.CharacterToGlyphMap; + var glyphIndices = new ushort[] { map['中'], map['文'], map['字'], map[' '] }; + + var batch = new ushort[glyphIndices.Length]; + Assert.True(typeface.TryGetVerticalGlyphAdvances(glyphIndices, batch)); + + for (var i = 0; i < glyphIndices.Length; i++) + { + Assert.True(typeface.TryGetVerticalGlyphAdvance(glyphIndices[i], out var single)); + Assert.Equal(single, batch[i]); + } + } + [Fact] public void Should_Have_Valid_PlatformTypeface() { diff --git a/tests/Avalonia.Benchmarks/Text/GlyphBoundsBenchmark.cs b/tests/Avalonia.Benchmarks/Text/GlyphBoundsBenchmark.cs new file mode 100644 index 00000000000..c5a5b4dc615 --- /dev/null +++ b/tests/Avalonia.Benchmarks/Text/GlyphBoundsBenchmark.cs @@ -0,0 +1,117 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using Avalonia.Media; +using Avalonia.Skia; +using Avalonia.UnitTests; +using BenchmarkDotNet.Attributes; +using SkiaSharp; + +namespace Avalonia.Benchmarks.Text; + +/// +/// Compares obtaining glyph ink bounds via the table-based +/// GlyphTypeface.TryGetGlyphBounds against Skia's +/// SKFont.GetGlyphWidths(..., bounds) — the path GlyphRunImpl uses today. +/// Both sides compute bounds only (Skia is passed a null widths array), so the +/// comparison is like-for-like. Two scenario pairs isolate the pure bounds-read cost +/// (font pre-created) from the realistic per-run cost (Skia pays SKFont creation, which +/// the table path avoids entirely). +/// +[MemoryDiagnoser] +public class GlyphBoundsBenchmark : IDisposable +{ + private const float Size = 16f; + + private readonly IDisposable _app; + private readonly GlyphTypeface _glyphTypeface; + private readonly SkiaTypeface _skiaTypeface; + private readonly SKFont _font; + private readonly ushort[] _glyphPool; + + private ushort[] _glyphIndices = Array.Empty(); + private SKRect[] _skBounds = Array.Empty(); + private GlyphBounds[] _bounds = Array.Empty(); + + [Params(1, 16, 256)] + public int GlyphCount { get; set; } + + public GlyphBoundsBenchmark() + { + _app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface.With( + renderInterface: new PlatformRenderInterface(), + fontManagerImpl: new FontManagerImpl())); + + _glyphTypeface = Typeface.Default.GlyphTypeface; + _skiaTypeface = (SkiaTypeface)_glyphTypeface.PlatformTypeface; + _font = _skiaTypeface.CreateSKFont(Size); + + var map = _glyphTypeface.CharacterToGlyphMap; + var pool = new List(); + for (var c = '!'; c <= '~'; c++) + { + if (map.ContainsGlyph(c)) + { + pool.Add(map[c]); + } + } + + _glyphPool = pool.ToArray(); + } + + [GlobalSetup] + public void Setup() + { + _glyphIndices = new ushort[GlyphCount]; + + for (var i = 0; i < GlyphCount; i++) + { + _glyphIndices[i] = _glyphPool[i % _glyphPool.Length]; + } + + _skBounds = new SKRect[GlyphCount]; + _bounds = new GlyphBounds[GlyphCount]; + } + + // ---- Kernel: SKFont already created; measure only the bounds query. ---- + + [Benchmark(Baseline = true)] + public void Skia_BoundsOnly() + { + _font.GetGlyphWidths(_glyphIndices, null, _skBounds.AsSpan(0, GlyphCount)); + } + + [Benchmark] + public void Table_BoundsOnly() + { + _glyphTypeface.TryGetGlyphBounds(_glyphIndices, _bounds.AsSpan(0, GlyphCount)); + } + + // ---- Realistic per-run: Skia pays SKFont creation; the table path doesn't. ---- + + [Benchmark] + public void Skia_PerRun() + { + using var font = _skiaTypeface.CreateSKFont(Size); + + var bounds = ArrayPool.Shared.Rent(GlyphCount); + font.GetGlyphWidths(_glyphIndices, null, bounds.AsSpan(0, GlyphCount)); + ArrayPool.Shared.Return(bounds); + } + + [Benchmark] + public void Table_PerRun() + { + Span bounds = GlyphCount <= 256 + ? stackalloc GlyphBounds[GlyphCount] + : new GlyphBounds[GlyphCount]; + + _glyphTypeface.TryGetGlyphBounds(_glyphIndices, bounds); + } + + public void Dispose() + { + _font.Dispose(); + _app?.Dispose(); + } +}