diff --git a/benchmarks/SkiaSharp.Benchmarks/Benchmarks/SKShaperGlyphExtractionBenchmark.cs b/benchmarks/SkiaSharp.Benchmarks/Benchmarks/SKShaperGlyphExtractionBenchmark.cs
new file mode 100644
index 00000000000..355532cd7b1
--- /dev/null
+++ b/benchmarks/SkiaSharp.Benchmarks/Benchmarks/SKShaperGlyphExtractionBenchmark.cs
@@ -0,0 +1,177 @@
+using System;
+using System.Reflection;
+using System.Text;
+using BenchmarkDotNet.Attributes;
+using BenchmarkDotNet.Configs;
+using BenchmarkDotNet.Jobs;
+using BenchmarkDotNet.Toolchains.InProcess.Emit;
+using HarfBuzzSharp;
+using SkiaSharp.HarfBuzz;
+using Buffer = HarfBuzzSharp.Buffer;
+
+namespace SkiaSharp.Benchmarks;
+
+// SKShaper.Shape(...) is the per-shaped-run text path behind SKCanvas.DrawShapedText. After native
+// HarfBuzz shaping it extracts the results into the SKShaper.Result arrays. The shipped code reads
+// the shaping output through buffer.GlyphInfos and buffer.GlyphPositions — and each of those getters
+// does a .ToArray() copy of the native glyph buffer (Buffer.cs: GetGlyphInfoSpan().ToArray()) — then
+// iterates each array exactly once. Those two copies are pure per-shape waste: the zero-copy
+// buffer.GetGlyphInfoSpan()/GetGlyphPositionSpan() expose the very same data with no allocation.
+//
+// This benchmark isolates the result-extraction step (the only part the fix changes) against an
+// already-shaped buffer, so native shaping is held constant and the delta is exactly the two removed
+// glyph arrays. Old = the shipped array-getter extraction copied verbatim; New = the same loop over
+// the zero-copy spans. Both still allocate the three real Result arrays (points/clusters/codepoints),
+// because the fix does not — and must not — remove those.
+[Config(typeof(Config))]
+[MemoryDiagnoser]
+public class SKShaperGlyphExtractionBenchmark
+{
+ // Runs in-process so BenchmarkDotNet does not spawn a generated project that rebuilds the
+ // multi-targeted binding projects (their mobile TFMs need SDK workloads that are not installed
+ // on this desktop/CI host). MemoryDiagnoser and the New-vs-Old timing are fully supported
+ // in-process; this only changes where the benchmark executes, not what is measured.
+ private class Config : ManualConfig
+ {
+ public Config() =>
+ AddJob(Job.Default.WithToolchain(InProcessEmitToolchain.Instance));
+ }
+
+ // Mirrors SKShaper.FONT_SIZE_SCALE (internal to SkiaSharp.HarfBuzz).
+ private const int FontSizeScale = 512;
+
+ // Approximate shaped-glyph counts: a word, a short line, a paragraph-ish run.
+ [Params(3, 24, 96)]
+ public int Glyphs { get; set; }
+
+ private SKTypeface typeface;
+ private Blob blob;
+ private Face face;
+ private Font hbFont;
+ private Buffer buffer;
+
+ private int len;
+ private float textSizeX;
+ private float textSizeY;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ var fontBytes = LoadFont();
+ using (var data = SKData.CreateCopy(fontBytes))
+ typeface = SKTypeface.FromData(data);
+
+ // Build the HarfBuzz font exactly as SKShaper's constructor does.
+ blob = typeface.OpenStream(out var index).ToHarfBuzzBlob();
+ face = new Face(blob, index)
+ {
+ Index = index,
+ UnitsPerEm = typeface.UnitsPerEm,
+ };
+ hbFont = new Font(face);
+ hbFont.SetScale(FontSizeScale, FontSizeScale);
+ hbFont.SetFunctionsOpenType();
+
+ // A realistic complex-script (Arabic) run of roughly `Glyphs` glyphs.
+ var reps = Math.Max(1, Glyphs / 3);
+ var sb = new StringBuilder(reps * 4);
+ for (var i = 0; i < reps; i++)
+ {
+ if (i > 0)
+ sb.Append(' ');
+ sb.Append("متن");
+ }
+
+ buffer = new Buffer();
+ buffer.AddUtf8(sb.ToString());
+ buffer.GuessSegmentProperties();
+ hbFont.Shape(buffer);
+
+ len = buffer.Length;
+
+ // SKShaper computes these from font.Size / font.ScaleX (default ScaleX == 1).
+ const float fontSize = 64f;
+ textSizeY = fontSize / FontSizeScale;
+ textSizeX = textSizeY * 1f;
+ }
+
+ [GlobalCleanup]
+ public void Cleanup()
+ {
+ buffer?.Dispose();
+ hbFont?.Dispose();
+ face?.Dispose();
+ blob?.Dispose();
+ typeface?.Dispose();
+ }
+
+ // Old: the shipped SKShaper.Shape extraction — buffer.GlyphInfos/GlyphPositions each allocate a
+ // GlyphInfo[]/GlyphPosition[] copy (via ToArray) that is read once and discarded.
+ [Benchmark(Baseline = true)]
+ public int Old()
+ {
+ var info = buffer.GlyphInfos;
+ var pos = buffer.GlyphPositions;
+
+ var points = new SKPoint[len];
+ var clusters = new uint[len];
+ var codepoints = new uint[len];
+
+ float xOffset = 0, yOffset = 0;
+ for (var i = 0; i < len; i++)
+ {
+ codepoints[i] = info[i].Codepoint;
+ clusters[i] = info[i].Cluster;
+ points[i] = new SKPoint(
+ xOffset + pos[i].XOffset * textSizeX,
+ yOffset - pos[i].YOffset * textSizeY);
+ xOffset += pos[i].XAdvance * textSizeX;
+ yOffset += pos[i].YAdvance * textSizeY;
+ }
+
+ return points.Length + clusters.Length + codepoints.Length;
+ }
+
+ // New: identical extraction over the zero-copy spans — no GlyphInfo[]/GlyphPosition[] copy.
+ [Benchmark]
+ public int New()
+ {
+ var info = buffer.GetGlyphInfoSpan();
+ var pos = buffer.GetGlyphPositionSpan();
+
+ var points = new SKPoint[len];
+ var clusters = new uint[len];
+ var codepoints = new uint[len];
+
+ float xOffset = 0, yOffset = 0;
+ for (var i = 0; i < len; i++)
+ {
+ codepoints[i] = info[i].Codepoint;
+ clusters[i] = info[i].Cluster;
+ points[i] = new SKPoint(
+ xOffset + pos[i].XOffset * textSizeX,
+ yOffset - pos[i].YOffset * textSizeY);
+ xOffset += pos[i].XAdvance * textSizeX;
+ yOffset += pos[i].YAdvance * textSizeY;
+ }
+
+ return points.Length + clusters.Length + codepoints.Length;
+ }
+
+ private static byte[] LoadFont()
+ {
+ var asm = typeof(SKShaperGlyphExtractionBenchmark).Assembly;
+ using var stream = asm.GetManifestResourceStream("content-font.ttf")
+ ?? throw new InvalidOperationException("Embedded font 'content-font.ttf' was not found.");
+ var bytes = new byte[stream.Length];
+ var read = 0;
+ while (read < bytes.Length)
+ {
+ var n = stream.Read(bytes, read, bytes.Length - read);
+ if (n == 0)
+ break;
+ read += n;
+ }
+ return bytes;
+ }
+}
diff --git a/benchmarks/SkiaSharp.Benchmarks/SkiaSharp.Benchmarks.csproj b/benchmarks/SkiaSharp.Benchmarks/SkiaSharp.Benchmarks.csproj
index c71f6dfa9f4..587651d884b 100644
--- a/benchmarks/SkiaSharp.Benchmarks/SkiaSharp.Benchmarks.csproj
+++ b/benchmarks/SkiaSharp.Benchmarks/SkiaSharp.Benchmarks.csproj
@@ -28,6 +28,13 @@
+
+
+
+
+
diff --git a/source/SkiaSharp.HarfBuzz/SkiaSharp.HarfBuzz/SKShaper.cs b/source/SkiaSharp.HarfBuzz/SkiaSharp.HarfBuzz/SKShaper.cs
index 05cb568c5c8..b428d6984d7 100644
--- a/source/SkiaSharp.HarfBuzz/SkiaSharp.HarfBuzz/SKShaper.cs
+++ b/source/SkiaSharp.HarfBuzz/SkiaSharp.HarfBuzz/SKShaper.cs
@@ -68,8 +68,8 @@ public Result Shape(Buffer buffer, float xOffset, float yOffset, SKFont font)
// get the shaping results
var len = buffer.Length;
- var info = buffer.GlyphInfos;
- var pos = buffer.GlyphPositions;
+ var info = buffer.GetGlyphInfoSpan();
+ var pos = buffer.GetGlyphPositionSpan();
// get the sizes
float textSizeY = font.Size / FONT_SIZE_SCALE;
diff --git a/tests/Tests/SkiaSharp/SKShaperGlyphExtractionTests.cs b/tests/Tests/SkiaSharp/SKShaperGlyphExtractionTests.cs
new file mode 100644
index 00000000000..f87fd8d4e46
--- /dev/null
+++ b/tests/Tests/SkiaSharp/SKShaperGlyphExtractionTests.cs
@@ -0,0 +1,121 @@
+using System.IO;
+using HarfBuzzSharp;
+using SkiaSharp.Tests;
+using Xunit;
+using Buffer = HarfBuzzSharp.Buffer;
+
+namespace SkiaSharp.HarfBuzz.Tests
+{
+ // Guards the SKShaper.Shape glyph-extraction optimization: reading the shaping results through the
+ // zero-copy buffer.GetGlyphInfoSpan()/GetGlyphPositionSpan() instead of the allocating
+ // buffer.GlyphInfos/GlyphPositions array getters must produce a byte-for-byte identical
+ // SKShaper.Result. These tests pin that equivalence so the two accessors can never silently drift.
+ public class SKShaperGlyphExtractionTests : SKTest
+ {
+ // The zero-copy span accessors must expose exactly the same glyph data as the array getters —
+ // this is the invariant the SKShaper.Shape substitution relies on.
+ [Fact]
+ public void GlyphSpanAccessorsMatchArrayAccessors()
+ {
+ using var tf = SKTypeface.FromFile(Path.Combine(PathToFonts, "content-font.ttf"));
+ using var shaper = new SKShaper(tf);
+ using var font = new SKFont { Size = 64, Typeface = tf };
+ using var buffer = new Buffer();
+
+ buffer.AddUtf8("متن متن متن");
+ buffer.GuessSegmentProperties();
+
+ // Shape in place using the shaper's own font (this is what SKShaper.Shape does internally).
+ shaper.Shape(buffer, font);
+
+ var infoArray = buffer.GlyphInfos;
+ var infoSpan = buffer.GetGlyphInfoSpan();
+ var posArray = buffer.GlyphPositions;
+ var posSpan = buffer.GetGlyphPositionSpan();
+
+ Assert.True(infoArray.Length > 0);
+ Assert.Equal(infoArray.Length, infoSpan.Length);
+ Assert.Equal(posArray.Length, posSpan.Length);
+
+ for (var i = 0; i < infoArray.Length; i++)
+ {
+ Assert.Equal(infoArray[i], infoSpan[i]);
+ Assert.Equal(posArray[i], posSpan[i]);
+ }
+ }
+
+ // The shipped SKShaper.Shape(Buffer, ...) result must equal, bit-for-bit, an independent oracle
+ // computed from the array getters (the pre-optimization data source). Passes trivially before the
+ // change (array vs array), must still pass after it (span vs array — proving the data is identical),
+ // and turns red if the extraction ever diverges.
+ [Theory]
+ [InlineData("content-font.ttf", "متن", 0f, 0f)]
+ [InlineData("content-font.ttf", "متن", 100f, 200f)]
+ [InlineData("content-font.ttf", "متن متن متن", 12.5f, -7.25f)]
+ [InlineData("content-font.ttf", " متن ", 3f, 3f)]
+ [InlineData("segoeui.ttf", "SkiaSharp", 0f, 0f)]
+ [InlineData("segoeui.ttf", "SkiaSharp shaping!", 300f, 100f)]
+ public void ShapeResultMatchesArrayGetterOracle(string fontFile, string text, float xOffset, float yOffset)
+ {
+ using var tf = SKTypeface.FromFile(Path.Combine(PathToFonts, fontFile));
+ using var shaper = new SKShaper(tf);
+ using var font = new SKFont { Size = 64, Typeface = tf };
+ using var buffer = new Buffer();
+
+ buffer.AddUtf8(text);
+ buffer.GuessSegmentProperties();
+
+ // Subject: the shipped extraction (reads the glyph spans after the optimization).
+ var result = shaper.Shape(buffer, xOffset, yOffset, font);
+
+ // Oracle: recompute from the SAME now-shaped buffer via the array getters, mirroring the exact
+ // arithmetic in SKShaper.Shape so any behavioural divergence is caught.
+ var info = buffer.GlyphInfos;
+ var pos = buffer.GlyphPositions;
+ var len = buffer.Length;
+
+ const int fontSizeScale = 512; // SKShaper.FONT_SIZE_SCALE (internal)
+ var textSizeY = font.Size / fontSizeScale;
+ var textSizeX = textSizeY * font.ScaleX;
+
+ var expectedCodepoints = new uint[len];
+ var expectedClusters = new uint[len];
+ var expectedPoints = new SKPoint[len];
+ var x = xOffset;
+ var y = yOffset;
+ for (var i = 0; i < len; i++)
+ {
+ expectedCodepoints[i] = info[i].Codepoint;
+ expectedClusters[i] = info[i].Cluster;
+ expectedPoints[i] = new SKPoint(
+ x + pos[i].XOffset * textSizeX,
+ y - pos[i].YOffset * textSizeY);
+ x += pos[i].XAdvance * textSizeX;
+ y += pos[i].YAdvance * textSizeY;
+ }
+ var expectedWidth = x - xOffset;
+
+ Assert.True(len > 0);
+ Assert.Equal(expectedCodepoints, result.Codepoints);
+ Assert.Equal(expectedClusters, result.Clusters);
+ Assert.Equal(expectedPoints, result.Points);
+ Assert.Equal(expectedWidth, result.Width);
+ }
+
+ // The empty-input boundary must remain an empty result (unchanged by the extraction change).
+ [Fact]
+ public void EmptyTextReturnsEmptyResult()
+ {
+ using var tf = SKTypeface.FromFile(Path.Combine(PathToFonts, "content-font.ttf"));
+ using var shaper = new SKShaper(tf);
+ using var font = new SKFont { Size = 64, Typeface = tf };
+
+ var result = shaper.Shape(string.Empty, font);
+
+ Assert.Empty(result.Codepoints);
+ Assert.Empty(result.Clusters);
+ Assert.Empty(result.Points);
+ Assert.Equal(0f, result.Width);
+ }
+ }
+}