Browse Source

Implemented working text shaping

MarcinZiabek 3 years ago
parent
commit
6d122c784d

+ 1 - 1
QuestPDF.Examples/TextBenchmark.cs

@@ -31,7 +31,7 @@ namespace QuestPDF.Examples
         {
             var chapters = GetBookChapters().ToList();
   
-            var results = PerformTest(16).ToList();
+            var results = PerformTest(128).ToList();
  
             Console.WriteLine($"Min: {results.Min():F}");
             Console.WriteLine($"Max: {results.Max():F}");

+ 24 - 9
QuestPDF.Examples/TextExamples.cs

@@ -11,12 +11,32 @@ namespace QuestPDF.Examples
 {
     public class TextExamples
     {
+        [Test]
+        public void SimpleText()
+        {
+            RenderingTest
+                .Create()
+                .PageSize(500, 100)
+                
+                .ProduceImages()
+                .ShowResults()
+                .Render(container =>
+                {
+                    container
+                        .Padding(5)
+                        .MinimalBox()
+                        .Border(1)
+                        .Padding(10)
+                        .Text(Placeholders.Paragraph());
+                });
+        }
+        
         [Test]
         public void SimpleTextBlock()
         {
             RenderingTest
                 .Create()
-                .PageSize(500, 300)
+                .PageSize(600, 300)
                 
                 .ProduceImages()
                 .ShowResults()
@@ -26,6 +46,7 @@ namespace QuestPDF.Examples
                         .Padding(5)
                         .MinimalBox()
                         .Border(1)
+                        .MaxWidth(300)
                         .Padding(10)
                         .Text(text =>
                         {
@@ -191,7 +212,7 @@ namespace QuestPDF.Examples
         {
             RenderingTest
                 .Create()
-                .PageSize(500, 300)
+                .PageSize(500, 500)
                 .ProduceImages()
                 .ShowResults()
                 .Render(container =>
@@ -203,13 +224,7 @@ namespace QuestPDF.Examples
                         .Padding(10)
                         .Text(text =>
                         {
-                            text.ParagraphSpacing(10);
-    
-                            foreach (var i in Enumerable.Range(1, 3))
-                            {
-                                text.Span($"Paragraph {i}: ").SemiBold();
-                                text.Line(Placeholders.Paragraph());
-                            }
+                            text.Line(Placeholders.Paragraph());
                         });
                 });
         }

+ 79 - 0
QuestPDF.Examples/TextShapingTests.cs

@@ -0,0 +1,79 @@
+using NUnit.Framework;
+using QuestPDF.Examples.Engine;
+using QuestPDF.Fluent;
+using SkiaSharp;
+using SkiaSharp.HarfBuzz;
+
+namespace QuestPDF.Examples
+{
+    public class TextShapingTests
+    {
+        // [Test]
+        // public void ShapeText()
+        // {
+        //     using var textPaint = new SKPaint
+        //     {
+        //         Color = SKColors.Black,
+        //         Typeface = SKTypeface.CreateDefault(),
+        //         IsAntialias = true,
+        //         TextSize = 20
+        //     };
+        //
+        //     using var backgroundPaint = new SKPaint
+        //     {
+        //         Color = SKColors.LightGray
+        //     };
+        //
+        //     RenderingTest
+        //         .Create()
+        //         .PageSize(550, 250)
+        //         .ProduceImages()
+        //         .ShowResults()
+        //         .Render(container =>
+        //         {
+        //             //var lorem = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec odio ipsum, aliquam a neque a, lacinia vehicula lectus.";
+        //             //var arabic = "ينا الألم. في بعض الأحيان ونظراً للالتزامات التي يفرضها علينا الواجب والعمل سنتنازل غالباً ونرفض الشعور";
+        //
+        //             var lorem = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.";
+        //             var arabic = "ينا الألم. في بعض الأحيان ونظراً للالتزامات التي يفرضها علينا";
+        //
+        //             var text = arabic;
+        //             var metrics = textPaint.FontMetrics;
+        //
+        //             container
+        //                 .Padding(25)
+        //                 .Canvas((canvas, space) =>
+        //                 {
+        //                     canvas.Translate(0, 20);
+        //
+        //                     var width = MeasureText(text, textPaint);
+        //                     var widthReal = textPaint.MeasureText(text);
+        //                     canvas.DrawRect(0, metrics.Descent, width, metrics.Ascent - metrics.Descent, backgroundPaint);
+        //
+        //                     canvas.DrawShapedText(text, 0, 0, textPaint);
+        //
+        //                     canvas.Translate(0, 40);
+        //                     canvas.DrawText(text, 0, 0, textPaint);
+        //                 });
+        //         });
+        // }
+
+        [Test]
+        public void MeasureTest()
+        {
+            using var textPaint = new SKPaint
+            {
+                Color = SKColors.Black,
+                Typeface = SKTypeface.CreateDefault(),
+                IsAntialias = true,
+                TextSize = 20
+            };
+            
+            var lorem = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec odio ipsum, aliquam a neque a, lacinia vehicula lectus.";
+            var arabic = "ينا الألم. في بعض الأحيان ونظراً للالتزامات التي يفرضها علينا";
+            //            012345678901234567890123456789012345678901234567890123456
+            var shaper = new SKShaper(textPaint.Typeface);
+            var result = shaper.Shape(lorem, textPaint);
+        } 
+    }
+}

+ 2 - 2
QuestPDF.UnitTests/TestEngine/MockCanvas.cs

@@ -1,4 +1,5 @@
 using System;
+using QuestPDF.Drawing;
 using QuestPDF.Infrastructure;
 using SkiaSharp;
 
@@ -10,7 +11,6 @@ namespace QuestPDF.UnitTests.TestEngine
         public Action<float> RotateFunc { get; set; }
         public Action<float, float> ScaleFunc { get; set; }
         public Action<SKImage, Position, Size> DrawImageFunc { get; set; }
-        public Action<string, Position, TextStyle> DrawTextFunc { get; set; }
         public Action<Position, Size, string> DrawRectFunc { get; set; }
 
         public void Translate(Position vector) => TranslateFunc(vector);
@@ -18,7 +18,7 @@ namespace QuestPDF.UnitTests.TestEngine
         public void Scale(float scaleX, float scaleY) => ScaleFunc(scaleX, scaleY);
 
         public void DrawRectangle(Position vector, Size size, string color) => DrawRectFunc(vector, size, color);
-        public void DrawText(string text, Position position, TextStyle style) => DrawTextFunc(text, position, style);
+        public void DrawText(SKTextBlob skTextBlob, Position position, TextStyle style) => throw new NotImplementedException();
         public void DrawImage(SKImage image, Position position, Size size) => DrawImageFunc(image, position, size);
 
         public void DrawHyperlink(string url, Size size) => throw new NotImplementedException();

+ 2 - 1
QuestPDF.UnitTests/TestEngine/OperationRecordingCanvas.cs

@@ -1,5 +1,6 @@
 using System;
 using System.Collections.Generic;
+using QuestPDF.Drawing;
 using QuestPDF.Infrastructure;
 using QuestPDF.UnitTests.TestEngine.Operations;
 using SkiaSharp;
@@ -15,7 +16,7 @@ namespace QuestPDF.UnitTests.TestEngine
         public void Scale(float scaleX, float scaleY) => Operations.Add(new CanvasScaleOperation(scaleX, scaleY));
 
         public void DrawRectangle(Position vector, Size size, string color) => Operations.Add(new CanvasDrawRectangleOperation(vector, size, color));
-        public void DrawText(string text, Position position, TextStyle style) => Operations.Add(new CanvasDrawTextOperation(text, position, style));
+        public void DrawText(SKTextBlob skTextBlob, Position position, TextStyle style) => throw new NotImplementedException();
         public void DrawImage(SKImage image, Position position, Size size) => Operations.Add(new CanvasDrawImageOperation(position, size));
         
         public void DrawHyperlink(string url, Size size) => throw new NotImplementedException();

+ 0 - 18
QuestPDF.UnitTests/TestEngine/TestPlan.cs

@@ -82,19 +82,6 @@ namespace QuestPDF.UnitTests.TestEngine
                     
                     Assert.AreEqual(expected.Color, color, "Draw rectangle: color");
                 },
-                DrawTextFunc = (text, position, style) => 
-                {
-                    var expected = GetExpected<CanvasDrawTextOperation>();
-                    
-                    Assert.AreEqual(expected.Text, text);
-                    
-                    Assert.AreEqual(expected.Position.X, position.X, "Draw text: X");
-                    Assert.AreEqual(expected.Position.Y, position.Y, "Draw text: Y");
-                    
-                    Assert.AreEqual(expected.Style.Color, style.Color, "Draw text: color");
-                    Assert.AreEqual(expected.Style.FontFamily, style.FontFamily, "Draw text: font");
-                    Assert.AreEqual(expected.Style.Size, style.Size, "Draw text: size");
-                },
                 DrawImageFunc = (image, position, size) =>
                 {
                     var expected = GetExpected<CanvasDrawImageOperation>();
@@ -201,11 +188,6 @@ namespace QuestPDF.UnitTests.TestEngine
             return AddOperation(new CanvasDrawRectangleOperation(position, size, color));
         }
         
-        public TestPlan ExpectCanvasDrawText(string text, Position position, TextStyle style)
-        {
-            return AddOperation(new CanvasDrawTextOperation(text, position, style));
-        }
-        
         public TestPlan ExpectCanvasDrawImage(Position position, Size size)
         {
             return AddOperation(new CanvasDrawImageOperation(position, size));

+ 36 - 4
QuestPDF/Drawing/FontManager.cs

@@ -2,9 +2,11 @@
 using System.Collections.Concurrent;
 using System.IO;
 using System.Linq;
+using HarfBuzzSharp;
 using QuestPDF.Fluent;
 using QuestPDF.Infrastructure;
 using SkiaSharp;
+using SkiaSharp.HarfBuzz;
 
 namespace QuestPDF.Drawing
 {
@@ -12,8 +14,10 @@ namespace QuestPDF.Drawing
     {
         private static ConcurrentDictionary<string, FontStyleSet> StyleSets = new();
         private static ConcurrentDictionary<object, SKFontMetrics> FontMetrics = new();
-        private static ConcurrentDictionary<object, SKPaint> Paints = new();
-        private static ConcurrentDictionary<string, SKPaint> ColorPaint = new();
+        private static ConcurrentDictionary<object, SKPaint> FontPaints = new();
+        private static ConcurrentDictionary<string, SKPaint> ColorPaints = new();
+        private static ConcurrentDictionary<object, Font> ShaperFonts = new();
+        private static ConcurrentDictionary<object, TextShaper> TextShapers = new();
 
         private static void RegisterFontType(SKData fontData, string? customName = null)
         {
@@ -47,7 +51,7 @@ namespace QuestPDF.Drawing
 
         internal static SKPaint ColorToPaint(this string color)
         {
-            return ColorPaint.GetOrAdd(color, Convert);
+            return ColorPaints.GetOrAdd(color, Convert);
 
             static SKPaint Convert(string color)
             {
@@ -61,7 +65,7 @@ namespace QuestPDF.Drawing
 
         internal static SKPaint ToPaint(this TextStyle style)
         {
-            return Paints.GetOrAdd(style.PaintKey, key => Convert(style));
+            return FontPaints.GetOrAdd(style.PaintKey, key => Convert(style));
 
             static SKPaint Convert(TextStyle style)
             {
@@ -122,5 +126,33 @@ namespace QuestPDF.Drawing
         {
             return FontMetrics.GetOrAdd(style.FontMetricsKey, key => style.NormalPosition().ToPaint().FontMetrics);
         }
+
+        internal static Font ToShaperFont(this TextStyle style)
+        {
+            return ShaperFonts.GetOrAdd(style.PaintKey, _ =>
+            {
+                var typeface = style.ToPaint().Typeface;
+
+                using var harfBuzzBlob = typeface.OpenStream(out var ttcIndex).ToHarfBuzzBlob();
+                
+                using var face = new Face(harfBuzzBlob, ttcIndex)
+                {
+                    Index = ttcIndex,
+                    UnitsPerEm = typeface.UnitsPerEm,
+                    GlyphCount = typeface.GlyphCount
+                };
+                
+                var font = new Font(face);
+                font.SetScale(TextShaper.FontShapingScale, TextShaper.FontShapingScale);
+                font.SetFunctionsOpenType();
+
+                return font;
+            });
+        }
+        
+        internal static TextShaper ToTextShaper(this TextStyle style)
+        {
+            return TextShapers.GetOrAdd(style.PaintKey, _ => new TextShaper(style));
+        }
     }
 }

+ 1 - 1
QuestPDF/Drawing/FreeCanvas.cs

@@ -41,7 +41,7 @@ namespace QuestPDF.Drawing
             
         }
 
-        public void DrawText(string text, Position position, TextStyle style)
+        public void DrawText(SKTextBlob skTextBlob, Position position, TextStyle style)
         {
             
         }

+ 3 - 2
QuestPDF/Drawing/SkiaCanvasBase.cs

@@ -1,5 +1,6 @@
 using QuestPDF.Infrastructure;
 using SkiaSharp;
+using SkiaSharp.HarfBuzz;
 
 namespace QuestPDF.Drawing
 {
@@ -27,9 +28,9 @@ namespace QuestPDF.Drawing
             Canvas.DrawRect(vector.X, vector.Y, size.Width, size.Height, paint);
         }
 
-        public void DrawText(string text, Position vector, TextStyle style)
+        public void DrawText(SKTextBlob skTextBlob, Position position, TextStyle style)
         {
-            Canvas.DrawText(text, vector.X, vector.Y, style.ToPaint());
+            Canvas.DrawText(skTextBlob, position.X, position.Y, style.ToPaint());
         }
 
         public void DrawImage(SKImage image, Position vector, Size size)

+ 154 - 0
QuestPDF/Drawing/TextShaper.cs

@@ -0,0 +1,154 @@
+using System;
+using HarfBuzzSharp;
+using QuestPDF.Infrastructure;
+using SkiaSharp;
+using Buffer = HarfBuzzSharp.Buffer;
+
+namespace QuestPDF.Drawing
+{
+    internal class TextShaper
+    {
+        public const int FontShapingScale = 512;
+     
+        private Font Font { get; }
+        private SKPaint Paint { get; }
+        
+        public TextShaper(TextStyle style)
+        {
+            Font = style.ToShaperFont();
+            Paint = style.ToPaint();
+        }
+
+        public TextShapingResult Shape(string text)
+        {
+            var buffer = new Buffer();
+            
+            PopulateBufferWithText(buffer, text);
+            buffer.GuessSegmentProperties();
+
+            Font.Shape(buffer);
+            
+            var length = buffer.Length;
+            var glyphInfos = buffer.GlyphInfos;
+            var glyphPositions = buffer.GlyphPositions;
+            
+            var scaleY = Paint.TextSize / FontShapingScale;
+            var scaleX = scaleY * Paint.TextScaleX;
+            
+            var xOffset = 0f;
+            var yOffset = 0f;
+            
+            var glyphs = new ShapedGlyph[length];
+            
+            for (var i = 0; i < length; i++)
+            {
+                glyphs[i] = new ShapedGlyph
+                {
+                    Codepoint = (ushort)glyphInfos[i].Codepoint,
+                    Position = new SKPoint(xOffset + glyphPositions[i].XOffset * scaleX, yOffset - glyphPositions[i].YOffset * scaleY),
+                    Width = glyphPositions[i].XAdvance * scaleX
+                };
+                
+                xOffset += glyphPositions[i].XAdvance * scaleX;
+                yOffset += glyphPositions[i].YAdvance * scaleY;
+            }
+            
+            return new TextShapingResult(Paint, glyphs);
+        }
+        
+        void PopulateBufferWithText(Buffer buffer, string text)
+        {
+            var encoding = Paint.TextEncoding;
+
+            if (encoding == SKTextEncoding.Utf8)
+                buffer.AddUtf8(text);
+                
+            else if (encoding == SKTextEncoding.Utf16)
+                buffer.AddUtf16(text);
+
+            else if (encoding == SKTextEncoding.Utf32)
+                buffer.AddUtf32(text);
+
+            else
+                throw new NotSupportedException("TextEncoding of type GlyphId is not supported.");
+        }
+    }
+    
+    internal struct ShapedGlyph
+    {
+        public ushort Codepoint;
+        public SKPoint Position;
+        public float Width;
+    }
+
+    internal struct DrawTextCommand
+    {
+        public SKTextBlob SkTextBlob;
+        public float TextOffsetX;
+    }
+    
+    internal class TextShapingResult
+    {
+        private SKPaint Paint { get; }
+        public ShapedGlyph[] Glyphs { get; }
+
+        public TextShapingResult(SKPaint paint, ShapedGlyph[] glyphs)
+        {
+            Paint = paint;
+            Glyphs = glyphs;
+        }
+
+        public int BreakText(int startIndex, float maxWidth)
+        {
+            var index = startIndex;
+            var currentWidth = 0f;
+
+            while (index < Glyphs.Length)
+            {
+                currentWidth += Glyphs[index].Width;
+
+                if (currentWidth > maxWidth)
+                    break;
+                
+                index++;
+            }
+
+            return index - 1;
+        }
+        
+        public float MeasureWidth(int startGlyphIndex, int endIndex)
+        {
+            var start = Glyphs[startGlyphIndex];
+            var end = Glyphs[endIndex];
+
+            return end.Position.X - start.Position.X + end.Width;
+        }
+        
+        public DrawTextCommand? PositionText(int startIndex, int endIndex)
+        {
+            if (Glyphs.Length == 0)
+                return null;
+            
+            using var skTextBlobBuilder = new SKTextBlobBuilder();
+            
+            var positionedRunBuffer = skTextBlobBuilder.AllocatePositionedRun(Paint.ToFont(), endIndex - startIndex + 1);
+            var glyphSpan = positionedRunBuffer.GetGlyphSpan();
+            var positionSpan = positionedRunBuffer.GetPositionSpan();
+                
+            for (var sourceIndex = startIndex; sourceIndex <= endIndex; sourceIndex++)
+            {
+                var runIndex = sourceIndex - startIndex;
+                
+                glyphSpan[runIndex] = Glyphs[sourceIndex].Codepoint;
+                positionSpan[runIndex] = Glyphs[sourceIndex].Position;
+            }
+            
+            return new DrawTextCommand
+            {
+                SkTextBlob = skTextBlobBuilder.Build(),
+                TextOffsetX = -Glyphs[startIndex].Position.X
+            };
+        }
+    }
+    
+}

+ 2 - 1
QuestPDF/Elements/Text/Calculation/TextDrawingRequest.cs

@@ -1,4 +1,5 @@
-using QuestPDF.Infrastructure;
+using QuestPDF.Drawing;
+using QuestPDF.Infrastructure;
 
 namespace QuestPDF.Elements.Text.Calculation
 {

+ 1 - 0
QuestPDF/Elements/Text/Calculation/TextMeasurementResult.cs

@@ -1,4 +1,5 @@
 using System;
+using QuestPDF.Drawing;
 
 namespace QuestPDF.Elements.Text.Calculation
 {

+ 1 - 0
QuestPDF/Elements/Text/Items/TextBlockPageNumber.cs

@@ -8,6 +8,7 @@ namespace QuestPDF.Elements.Text.Items
     {
         public const string PageNumberPlaceholder = "123";
         public Func<IPageContext, string> Source { get; set; } = _ => PageNumberPlaceholder;
+        protected override bool EnableTextCache => false;
         
         public override TextMeasurementResult? Measure(TextMeasurementRequest request)
         {

+ 51 - 37
QuestPDF/Elements/Text/Items/TextBlockSpan.cs

@@ -1,20 +1,23 @@
 using System;
 using System.Collections.Generic;
+using System.Diagnostics;
 using QuestPDF.Drawing;
 using QuestPDF.Elements.Text.Calculation;
 using QuestPDF.Infrastructure;
+using SkiaSharp;
+using SkiaSharp.HarfBuzz;
 using Size = QuestPDF.Infrastructure.Size;
 
 namespace QuestPDF.Elements.Text.Items
 {
     internal class TextBlockSpan : ITextBlockItem
     {
-        private const char Space = ' ';
-        
         public string Text { get; set; }
         public TextStyle Style { get; set; } = new();
+        public TextShapingResult? TextShapingResult { get; set; }
 
         private Dictionary<(int startIndex, float availableWidth), TextMeasurementResult?> MeasureCache = new ();
+        protected virtual bool EnableTextCache => true; 
 
         public virtual TextMeasurementResult? Measure(TextMeasurementRequest request)
         {
@@ -25,11 +28,17 @@ namespace QuestPDF.Elements.Text.Items
             
             return MeasureCache[cacheKey];
         }
-        
+
         internal TextMeasurementResult? MeasureWithoutCache(TextMeasurementRequest request)
         {
+            if (!EnableTextCache)
+                TextShapingResult = null;
+            
+            TextShapingResult ??= Style.ToTextShaper().Shape(Text);
+            
             var paint = Style.ToPaint();
             var fontMetrics = Style.ToFontMetrics();
+            var spaceCodepoint = paint.ToFont().Typeface.GetGlyphs(" ")[0];
 
             var startIndex = request.StartIndex;
             
@@ -37,11 +46,11 @@ namespace QuestPDF.Elements.Text.Items
             // ignore leading spaces
             if (!request.IsFirstElementInBlock && request.IsFirstElementInLine)
             {
-                while (startIndex < Text.Length && Text[startIndex] == Space)
+                while (startIndex < TextShapingResult.Glyphs.Length && Text[startIndex] == spaceCodepoint)
                     startIndex++;
             }
-            
-            if (Text.Length == 0 || startIndex == Text.Length)
+
+            if (TextShapingResult.Glyphs.Length == 0 || startIndex == TextShapingResult.Glyphs.Length)
             {
                 return new TextMeasurementResult
                 {
@@ -54,27 +63,19 @@ namespace QuestPDF.Elements.Text.Items
             }
             
             // start breaking text from requested position
-            var text = Text.AsSpan().Slice(startIndex);
-            
-            var textLength = (int)paint.BreakText(text, request.AvailableWidth + Size.Epsilon);
+            var endIndex = TextShapingResult.BreakText(startIndex, request.AvailableWidth + Size.Epsilon);
 
-            if (textLength <= 0)
+            if (endIndex < 0)
                 return null;
   
             // break text only on spaces
-            var wrappedTextLength = WrapText(text, textLength, request.IsFirstElementInLine);
+            var wrappedText = WrapText(endIndex, request.IsFirstElementInLine);
 
-            if (wrappedTextLength == null)
+            if (wrappedText == null)
                 return null;
-
-            textLength = wrappedTextLength.Value.fragmentLength;
-
-            text = text.Slice(0, textLength);
-
-            var endIndex = startIndex + textLength;
-
+            
             // measure final text
-            var width = paint.MeasureText(text);
+            var width = TextShapingResult.MeasureWidth(startIndex, endIndex);
             
             return new TextMeasurementResult
             {
@@ -86,31 +87,41 @@ namespace QuestPDF.Elements.Text.Items
                 LineHeight = Style.LineHeight ?? 1,
                 
                 StartIndex = startIndex,
-                EndIndex = endIndex,
-                NextIndex = startIndex + wrappedTextLength.Value.nextIndex,
-                TotalIndex = Text.Length
+                EndIndex = wrappedText.Value.endIndex,
+                NextIndex = wrappedText.Value.nextIndex,
+                TotalIndex = TextShapingResult.Glyphs.Length - 1
             };
         }
         
         // TODO: consider introduce text wrapping abstraction (basic, english-like, asian-like)
-        private (int fragmentLength, int nextIndex)? WrapText(ReadOnlySpan<char> text, int textLength, bool isFirstElementInLine)
+        private (int endIndex, int nextIndex)? WrapText(int endIndex, bool isFirstElementInLine)
         {
+            var spaceCodepoint = Style.ToPaint().ToFont().Typeface.GetGlyphs(" ")[0];
+            
             // textLength - length of the part of the text that fits in available width (creating a line)
-                
+
             // entire text fits, no need to wrap
-            if (textLength == text.Length)
-                return (textLength, textLength + 1);
+            if (endIndex == TextShapingResult.Glyphs.Length - 1)
+                return (endIndex, endIndex);
 
             // breaking anywhere
             if (Style.WrapAnywhere ?? false)
-                return (textLength, textLength);
+                return (endIndex, endIndex + 1);
                 
             // current line ends at word, next character is space, perfect place to wrap
-            if (text[textLength - 1] != Space && text[textLength] == Space)
-                return (textLength, textLength + 1);
+            if (TextShapingResult.Glyphs[endIndex - 1].Codepoint != spaceCodepoint && TextShapingResult.Glyphs[endIndex].Codepoint == spaceCodepoint)
+                return (endIndex, endIndex + 1);
                 
             // find last space within the available text to wrap
-            var lastSpaceIndex = text.Slice(0, textLength).LastIndexOf(Space);
+            var lastSpaceIndex = endIndex;
+
+            while (lastSpaceIndex > 0)
+            {
+                if (TextShapingResult.Glyphs[lastSpaceIndex].Codepoint == spaceCodepoint)
+                    break;
+
+                lastSpaceIndex--;
+            }
 
             // text contains space that can be used to wrap
             if (lastSpaceIndex > 0)
@@ -119,23 +130,26 @@ namespace QuestPDF.Elements.Text.Items
             // there is no available space to wrap text
             // if the item is first within the line, perform safe mode and chop the word
             // otherwise, move the item into the next line
-            return isFirstElementInLine ? (textLength, textLength) : null;
+            return isFirstElementInLine ? (endIndex, endIndex) : null;
         }
         
         public virtual void Draw(TextDrawingRequest request)
         {
             var fontMetrics = Style.ToFontMetrics();
 
-            var glyphOffset = GetGlyphOffset();
-            var text = Text.Substring(request.StartIndex, request.EndIndex - request.StartIndex);
+            var glyphOffsetY = GetGlyphOffset();
+            
+            var textDrawingCommand = TextShapingResult.PositionText(request.StartIndex, request.EndIndex);
             
             request.Canvas.DrawRectangle(new Position(0, request.TotalAscent), new Size(request.TextSize.Width, request.TextSize.Height), Style.BackgroundColor);
-            request.Canvas.DrawText(text, new Position(0, glyphOffset), Style);
+            
+            if (textDrawingCommand.HasValue)
+                request.Canvas.DrawText(textDrawingCommand.Value.SkTextBlob, new Position(textDrawingCommand.Value.TextOffsetX, glyphOffsetY), Style);
 
             // draw underline
             if ((Style.HasUnderline ?? false) && fontMetrics.UnderlinePosition.HasValue)
             {
-                var underlineOffset = Style.FontPosition == FontPosition.Superscript ? 0 : glyphOffset;
+                var underlineOffset = Style.FontPosition == FontPosition.Superscript ? 0 : glyphOffsetY;
                 DrawLine(fontMetrics.UnderlinePosition.Value + underlineOffset, fontMetrics.UnderlineThickness ?? 1);
             }
             
@@ -145,7 +159,7 @@ namespace QuestPDF.Elements.Text.Items
                 var strikeoutThickness = fontMetrics.StrikeoutThickness ?? 1;
                 strikeoutThickness *= Style.FontPosition == FontPosition.Normal ? 1f : 0.625f;
                 
-                DrawLine(fontMetrics.StrikeoutPosition.Value + glyphOffset, strikeoutThickness);
+                DrawLine(fontMetrics.StrikeoutPosition.Value + glyphOffsetY, strikeoutThickness);
             }
             
             void DrawLine(float offset, float thickness)

+ 1 - 1
QuestPDF/Helpers/Placeholders.cs

@@ -7,7 +7,7 @@ namespace QuestPDF.Helpers
 {
     public static class Placeholders
     {
-        public static readonly Random Random = new();
+        public static readonly Random Random = new Random(0);
         
         #region Word Cache
 

+ 2 - 1
QuestPDF/Infrastructure/ICanvas.cs

@@ -1,3 +1,4 @@
+using QuestPDF.Drawing;
 using SkiaSharp;
 
 namespace QuestPDF.Infrastructure
@@ -7,7 +8,7 @@ namespace QuestPDF.Infrastructure
         void Translate(Position vector);
         
         void DrawRectangle(Position vector, Size size, string color);
-        void DrawText(string text, Position position, TextStyle style);
+        void DrawText(SKTextBlob skTextBlob, Position position, TextStyle style);
         void DrawImage(SKImage image, Position position, Size size);
 
         void DrawHyperlink(string url, Size size);

+ 2 - 1
QuestPDF/QuestPDF.csproj

@@ -24,7 +24,8 @@
     </PropertyGroup>
 
     <ItemGroup>
-      <PackageReference Include="SkiaSharp" Version="2.80.*" />
+      <PackageReference Include="SkiaSharp" Version="2.80.3" />
+      <PackageReference Include="SkiaSharp.HarfBuzz" Version="2.80.3" />
     </ItemGroup>
 
     <ItemGroup>