From c9d6efbf67aa2bd26cc5132d9080ccf0cba281be Mon Sep 17 00:00:00 2001 From: Dong Bin <14807942+rabbitism@users.noreply.github.com> Date: Fri, 20 Feb 2026 19:10:56 +0800 Subject: [PATCH] Add boundary check to HarfBuzzTextShaper (#20716) * feat: add boundary check. * feat: add empty string handling and unit tests for HarfBuzzTextShaper --- .../Avalonia.HarfBuzz/HarfBuzzTextShaper.cs | 5 + .../Media/Fonts/Tables/HeadTableTests.cs | 80 ++++++------ .../TextFormatting/HarfbuzzTextShaperTests.cs | 120 ++++++++++++++++++ 3 files changed, 165 insertions(+), 40 deletions(-) create mode 100644 tests/Avalonia.Base.UnitTests/Media/TextFormatting/HarfbuzzTextShaperTests.cs diff --git a/src/HarfBuzz/Avalonia.HarfBuzz/HarfBuzzTextShaper.cs b/src/HarfBuzz/Avalonia.HarfBuzz/HarfBuzzTextShaper.cs index cd763cb1ef..ee8ee2a531 100644 --- a/src/HarfBuzz/Avalonia.HarfBuzz/HarfBuzzTextShaper.cs +++ b/src/HarfBuzz/Avalonia.HarfBuzz/HarfBuzzTextShaper.cs @@ -24,6 +24,9 @@ namespace Avalonia.Harfbuzz { var textSpan = text.Span; + if (text.Length == 0) + return new ShapedBuffer(text, 0, options.GlyphTypeface, options.FontRenderingEmSize, options.BidiLevel); + var glyphTypeface = options.GlyphTypeface; if (glyphTypeface.TextShaperTypeface is not HarfBuzzTypeface harfBuzzTypeface) @@ -121,6 +124,8 @@ namespace Avalonia.Harfbuzz { var length = buffer.Length; + if (length == 0) return; + var glyphInfos = buffer.GetGlyphInfoSpan(); var second = glyphInfos[length - 1]; diff --git a/tests/Avalonia.Base.UnitTests/Media/Fonts/Tables/HeadTableTests.cs b/tests/Avalonia.Base.UnitTests/Media/Fonts/Tables/HeadTableTests.cs index 5e5daa2988..742ae4a78c 100644 --- a/tests/Avalonia.Base.UnitTests/Media/Fonts/Tables/HeadTableTests.cs +++ b/tests/Avalonia.Base.UnitTests/Media/Fonts/Tables/HeadTableTests.cs @@ -191,66 +191,66 @@ namespace Avalonia.Base.UnitTests.Media.Fonts.Tables Assert.True(HeadTable.TryLoad(typeface, out var headTable)); Assert.Equal(FontDirectionHint.LeftToRightWithNeutrals, headTable.FontDirectionHint); } + } + + internal class CustomPlatformTypeface : IPlatformTypeface + { + private readonly UnmanagedFontMemory _fontMemory; - private class CustomPlatformTypeface : IPlatformTypeface + public CustomPlatformTypeface(Stream stream, string fontFamily = "Custom") { - private readonly UnmanagedFontMemory _fontMemory; + _fontMemory = UnmanagedFontMemory.LoadFromStream(stream); + FamilyName = fontFamily; + } - public CustomPlatformTypeface(Stream stream, string fontFamily = "Custom") - { - _fontMemory = UnmanagedFontMemory.LoadFromStream(stream); - FamilyName = fontFamily; - } + public FontWeight Weight => FontWeight.Normal; - public FontWeight Weight => FontWeight.Normal; + public FontStyle Style => FontStyle.Normal; - public FontStyle Style => FontStyle.Normal; + public FontStretch Stretch => FontStretch.Normal; - public FontStretch Stretch => FontStretch.Normal; + public string FamilyName { get; } - public string FamilyName { get; } + public FontSimulations FontSimulations => FontSimulations.None; - public FontSimulations FontSimulations => FontSimulations.None; + public void Dispose() + { + ((IDisposable)_fontMemory).Dispose(); + } - public void Dispose() - { - ((IDisposable)_fontMemory).Dispose(); - } + public unsafe bool TryGetStream([NotNullWhen(true)] out Stream stream) + { + var memory = _fontMemory.Memory; - public unsafe bool TryGetStream([NotNullWhen(true)] out Stream stream) - { - var memory = _fontMemory.Memory; + var handle = memory.Pin(); + stream = new PinnedUnmanagedMemoryStream(handle, memory.Length); - var handle = memory.Pin(); - stream = new PinnedUnmanagedMemoryStream(handle, memory.Length); + return true; + } - return true; - } + private sealed class PinnedUnmanagedMemoryStream : UnmanagedMemoryStream + { + private MemoryHandle _handle; - private sealed class PinnedUnmanagedMemoryStream : UnmanagedMemoryStream + public unsafe PinnedUnmanagedMemoryStream(MemoryHandle handle, long length) + : base((byte*)handle.Pointer, length) { - private MemoryHandle _handle; + _handle = handle; + } - public unsafe PinnedUnmanagedMemoryStream(MemoryHandle handle, long length) - : base((byte*)handle.Pointer, length) + protected override void Dispose(bool disposing) + { + try { - _handle = handle; + base.Dispose(disposing); } - - protected override void Dispose(bool disposing) + finally { - try - { - base.Dispose(disposing); - } - finally - { - _handle.Dispose(); - } + _handle.Dispose(); } } - - public bool TryGetTable(OpenTypeTag tag, out ReadOnlyMemory table) => _fontMemory.TryGetTable(tag, out table); } + + public bool TryGetTable(OpenTypeTag tag, out ReadOnlyMemory table) => _fontMemory.TryGetTable(tag, out table); } } diff --git a/tests/Avalonia.Base.UnitTests/Media/TextFormatting/HarfbuzzTextShaperTests.cs b/tests/Avalonia.Base.UnitTests/Media/TextFormatting/HarfbuzzTextShaperTests.cs new file mode 100644 index 0000000000..6467d9a13f --- /dev/null +++ b/tests/Avalonia.Base.UnitTests/Media/TextFormatting/HarfbuzzTextShaperTests.cs @@ -0,0 +1,120 @@ +using System; +using System.Linq; +using Avalonia.Base.UnitTests.Media.Fonts.Tables; +using Avalonia.Harfbuzz; +using Avalonia.Media; +using Avalonia.Media.TextFormatting; +using Avalonia.Platform; +using Avalonia.UnitTests; +using Xunit; + +namespace Avalonia.Base.UnitTests.Media.TextFormatting; + +public class HarfBuzzTextShaperTests +{ + private static readonly string s_InterFontUri = + "resm:Avalonia.Base.UnitTests.Assets.Inter-Regular.ttf?assembly=Avalonia.Base.UnitTests"; + + private readonly HarfBuzzTextShaper _shaper; + + private TestServices Services => TestServices.MockThreadingInterface.With( + textShaperImpl: _shaper); + + public HarfBuzzTextShaperTests() + { + _shaper = new HarfBuzzTextShaper(); + } + + [Fact] + public void ShapeText_WithValidInput_ReturnsShapedBuffer() + { + using (UnitTestApplication.Start(Services)) + { + var text = "Hello World".AsMemory(); + var options = CreateTextShaperOptions(); + + var result = _shaper.ShapeText(text, options); + + Assert.NotNull(result); + Assert.Equal(text.Length, result.Length); + } + } + + [Fact] + public void ShapeText_WithEmptyString_ReturnsEmptyShapedBuffer() + { + using (UnitTestApplication.Start(Services)) + { + var text = "".AsMemory(); + var options = CreateTextShaperOptions(); + + var result = _shaper.ShapeText(text, options); + + Assert.NotNull(result); + Assert.Equal(0, result.Length); + } + } + + [Fact] + public void ShapeText_WithTabCharacter_ReplacesWithSpace() + { + using (UnitTestApplication.Start(Services)) + { + var text = "Hello\tWorld".AsMemory(); + var options = CreateTextShaperOptions(); + + var result = _shaper.ShapeText(text, options); + + Assert.NotNull(result); + Assert.True(result.Length == 11); + } + } + + [Fact] + public void ShapeText_WithCRLF_MergesBreakPair() + { + using (UnitTestApplication.Start(Services)) + { + var text = "Line1\r\nLine2".AsMemory(); + var options = CreateTextShaperOptions(); + + var result = _shaper.ShapeText(text, options); + + Assert.NotNull(result); + Assert.NotEqual(0.0, result[5].GlyphAdvance); + } + } + + [Fact] + public void ShapeText_EndWithCRLF_MergesBreakPair() + { + using (UnitTestApplication.Start(Services)) + { + var text = "Line1\r\n".AsMemory(); + var options = CreateTextShaperOptions(); + + var result = _shaper.ShapeText(text, options); + + Assert.NotNull(result); + Assert.Equal(0.0, result[5].GlyphAdvance); + } + } + + private TextShaperOptions CreateTextShaperOptions( + sbyte bidiLevel = 0, + double letterSpacing = 0, + double fontSize = 16) + { + var assetLoader = new StandardAssetLoader(); + + using var stream = assetLoader.Open(new Uri(s_InterFontUri)); + + var typeface = new GlyphTypeface(new CustomPlatformTypeface(stream)); + + return new TextShaperOptions( + typeface, + fontSize, + bidiLevel, + letterSpacing: letterSpacing); + } +}