Browse Source

Implemented text shaping prototype

MarcinZiabek 3 years ago
parent
commit
662a55e21c

+ 2 - 2
QuestPDF.Examples/TextExamples.cs

@@ -236,7 +236,7 @@ namespace QuestPDF.Examples
                         .Padding(10)
                         .Text(text =>
                         {
-                            text.DefaultTextStyle(TextStyle.Default);
+                            text.DefaultTextStyle(TextStyle.Default.BackgroundColor(Colors.Grey.Lighten3));
                             text.AlignLeft();
                             text.ParagraphSpacing(10);
 
@@ -251,7 +251,7 @@ namespace QuestPDF.Examples
                             
                             text.EmptyLine();
                             
-                            foreach (var i in Enumerable.Range(1, 100))
+                            foreach (var i in Enumerable.Range(1, 1000))
                             {
                                 text.Line($"{i}: {Placeholders.Paragraph()}");
 

+ 60 - 0
QuestPDF.Examples/TextShapingTests.cs

@@ -1,5 +1,6 @@
 using System;
 using System.Linq;
+using System.Text.RegularExpressions;
 using NUnit.Framework;
 using QuestPDF.Drawing;
 using QuestPDF.Examples.Engine;
@@ -97,5 +98,64 @@ namespace QuestPDF.Examples
             using var text1 = skTextBlobBuilder.Build();
             return text1.Bounds.Width;
         }
+        
+        [Test]
+        public void ShapeSingle()
+        {
+            using var textPaint = new SKPaint
+            {
+                Color = SKColors.Black,
+                Typeface = SKTypeface.CreateDefault(),
+                IsAntialias = true,
+                TextSize = 20
+            };
+
+            var text = string.Join(" ", Enumerable.Range(0, 10_000).Select(x => Placeholders.Sentence()));
+            
+            using var shaper = new SKShaper(textPaint.Typeface);
+            var result = shaper.Shape(text + " ", textPaint);
+        }
+        
+        [Test]
+        public void ShapeMany()
+        {
+            using var textPaint = new SKPaint
+            {
+                Color = SKColors.Black,
+                Typeface = SKTypeface.CreateDefault(),
+                IsAntialias = true,
+                TextSize = 20
+            };
+
+            var text = string.Join(" ", Enumerable.Range(0, 1_000).Select(x => Placeholders.Sentence()));
+            
+            foreach (var part in Regex.Split(text, "( )|(\\w+)"))
+            {
+                using var shaper = new SKShaper(textPaint.Typeface);
+                var result = shaper.Shape(part + " ", textPaint);
+            }
+        }
+        
+        [Test]
+        public void ShapeMany2()
+        {
+            using var textPaint = new SKPaint
+            {
+                Color = SKColors.Black,
+                Typeface = SKTypeface.CreateDefault(),
+                IsAntialias = true,
+                TextSize = 20
+            };
+
+            using var shaper = new SKShaper(textPaint.Typeface);
+            
+            foreach (var i in Enumerable.Range(0, 10_000))
+            {
+                var text = Placeholders.Paragraph();
+
+                textPaint.MeasureText(text);
+                var result = shaper.Shape(text + " ", textPaint);
+            }
+        }
     }
 }

+ 1 - 0
QuestPDF.UnitTests/TestEngine/MockCanvas.cs

@@ -19,6 +19,7 @@ namespace QuestPDF.UnitTests.TestEngine
 
         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 DrawShapedText(string text, Position position, TextStyle style) => DrawTextFunc(text, position, style);
         public void DrawImage(SKImage image, Position position, Size size) => DrawImageFunc(image, position, size);
 
         public void DrawHyperlink(string url, Size size) => throw new NotImplementedException();

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

@@ -16,6 +16,7 @@ namespace QuestPDF.UnitTests.TestEngine
 
         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 DrawShapedText(string text, Position position, TextStyle style) => Operations.Add(new CanvasDrawTextOperation(text, position, style));
         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();

+ 8 - 1
QuestPDF/Drawing/FontManager.cs

@@ -4,6 +4,7 @@ using System.IO;
 using System.Linq;
 using QuestPDF.Infrastructure;
 using SkiaSharp;
+using SkiaSharp.HarfBuzz;
 
 namespace QuestPDF.Drawing
 {
@@ -13,6 +14,7 @@ namespace QuestPDF.Drawing
         private static ConcurrentDictionary<object, SKFontMetrics> FontMetrics = new();
         private static ConcurrentDictionary<object, SKPaint> Paints = new();
         private static ConcurrentDictionary<object, SKFont> Fonts = new();
+        private static ConcurrentDictionary<object, SKShaper> Shapers = new();
         private static ConcurrentDictionary<string, SKPaint> ColorPaint = new();
 
         private static void RegisterFontType(SKData fontData, string? customName = null)
@@ -99,10 +101,15 @@ namespace QuestPDF.Drawing
         {
             return Fonts.GetOrAdd(style.FontMetricsKey, _ => style.ToPaint().Typeface.ToFont());
         }
-        
+
         internal static SKFontMetrics ToFontMetrics(this TextStyle style)
         {
             return FontMetrics.GetOrAdd(style.FontMetricsKey, key => style.ToPaint().FontMetrics);
         }
+        
+        internal static SKShaper ToShaper(this TextStyle style)
+        {
+            return Shapers.GetOrAdd(style.FontMetricsKey, _ => new SKShaper(style.ToFont().Typeface));
+        }
     }
 }

+ 5 - 0
QuestPDF/Drawing/FreeCanvas.cs

@@ -44,6 +44,11 @@ namespace QuestPDF.Drawing
         public void DrawText(string text, Position position, TextStyle style)
         {
             
+        }
+        
+        public void DrawShapedText(string text, Position position, TextStyle style)
+        {
+            
         }
 
         public void DrawImage(SKImage image, Position position, Size size)

+ 6 - 1
QuestPDF/Drawing/SkiaCanvasBase.cs

@@ -30,9 +30,14 @@ namespace QuestPDF.Drawing
 
         public void DrawText(string text, Position vector, TextStyle style)
         {
-            Canvas.DrawShapedText(text, vector.X, vector.Y, style.ToPaint());
+            Canvas.DrawText(text, vector.X, vector.Y, style.ToPaint());
         }
 
+        public void DrawShapedText(string text, Position vector, TextStyle style)
+        {
+            Canvas.DrawShapedText(text, vector.X, vector.Y, style.ToPaint());
+        }
+        
         public void DrawImage(SKImage image, Position vector, Size size)
         {
             Canvas.DrawImage(image, new SKRect(vector.X, vector.Y, size.Width, size.Height));

+ 1 - 8
QuestPDF/Elements/Text/Calculation/TextMeasurementResult.cs → QuestPDF/Elements/Text/Calculation/TextBlockSize.cs

@@ -2,7 +2,7 @@
 
 namespace QuestPDF.Elements.Text.Calculation
 {
-    internal class TextMeasurementResult
+    internal class TextBlockSize
     {
         public float Width { get; set; }
         public float Height => Math.Abs(Descent) + Math.Abs(Ascent);
@@ -11,12 +11,5 @@ namespace QuestPDF.Elements.Text.Calculation
         public float Descent { get; set; }
 
         public float LineHeight { get; set; }
-        
-        public int StartIndex { get; set; }
-        public int EndIndex { get; set; }
-        public int NextIndex { get; set; }
-        public int TotalIndex { get; set; }
-
-        public bool IsLast => EndIndex == TotalIndex;
     }
 }

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

@@ -2,14 +2,8 @@
 
 namespace QuestPDF.Elements.Text.Calculation
 {
-    internal class TextDrawingRequest
+    internal struct TextDrawingRequest
     {
-        public ICanvas Canvas { get; set; }
-        public IPageContext PageContext { get; set; }
-        
-        public int StartIndex { get; set; }
-        public int EndIndex { get; set; }
-        
         public float TotalAscent { get; set; }
         public Size TextSize { get; set; }
     }

+ 1 - 1
QuestPDF/Elements/Text/Calculation/TextLineElement.cs

@@ -5,6 +5,6 @@ namespace QuestPDF.Elements.Text.Calculation
     internal class TextLineElement
     {
         public ITextBlockItem Item { get; set; }
-        public TextMeasurementResult Measurement { get; set; }
+        public TextBlockSize Measurement { get; set; }
     }
 }

+ 0 - 14
QuestPDF/Elements/Text/Calculation/TextMeasurementRequest.cs

@@ -1,14 +0,0 @@
-using QuestPDF.Infrastructure;
-
-namespace QuestPDF.Elements.Text.Calculation
-{
-    internal class TextMeasurementRequest
-    {
-        public ICanvas Canvas { get; set; }
-        public IPageContext PageContext { get; set; }
-        
-        public int StartIndex { get; set; }
-        public float AvailableWidth { get; set; }
-        public bool IsFirstLineElement { get; set; }
-    }
-}

+ 4 - 1
QuestPDF/Elements/Text/Items/ITextBlockItem.cs

@@ -5,7 +5,10 @@ namespace QuestPDF.Elements.Text.Items
 {
     internal interface ITextBlockItem
     {
-        TextMeasurementResult? Measure(TextMeasurementRequest request);
+        ICanvas Canvas { get; set; }
+        IPageContext PageContext { get; set; }
+        
+        TextBlockSize? Measure();
         void Draw(TextDrawingRequest request);
     }
 }

+ 11 - 12
QuestPDF/Elements/Text/Items/TextBlockElement.cs

@@ -7,41 +7,40 @@ namespace QuestPDF.Elements.Text.Items
 {
     internal class TextBlockElement : ITextBlockItem
     {
+        public ICanvas Canvas { get; set; }
+        public IPageContext PageContext { get; set; }
+        
         public Element Element { get; set; } = Empty.Instance;
         
-        public TextMeasurementResult? Measure(TextMeasurementRequest request)
+        public TextBlockSize? Measure()
         {
             Element.VisitChildren(x => (x as IStateResettable)?.ResetState());
-            Element.VisitChildren(x => x.Initialize(request.PageContext, request.Canvas));
+            Element.VisitChildren(x => x.Initialize(PageContext, Canvas));
 
-            var measurement = Element.Measure(new Size(request.AvailableWidth, Size.Max.Height));
+            var measurement = Element.Measure(Size.Max);
 
             if (measurement.Type != SpacePlanType.FullRender)
                 return null;
             
-            return new TextMeasurementResult
+            return new TextBlockSize
             {
                 Width = measurement.Width,
                 
                 Ascent = -measurement.Height,
                 Descent = 0,
                 
-                LineHeight = 1,
-                
-                StartIndex = 0,
-                EndIndex = 0,
-                TotalIndex = 0
+                LineHeight = 1
             };
         }
 
         public void Draw(TextDrawingRequest request)
         {
             Element.VisitChildren(x => (x as IStateResettable)?.ResetState());
-            Element.VisitChildren(x => x.Initialize(request.PageContext, request.Canvas));
+            Element.VisitChildren(x => x.Initialize(PageContext, Canvas));
             
-            request.Canvas.Translate(new Position(0, request.TotalAscent));
+            Canvas.Translate(new Position(0, request.TotalAscent));
             Element.Draw(new Size(request.TextSize.Width, -request.TotalAscent));
-            request.Canvas.Translate(new Position(0, -request.TotalAscent));
+            Canvas.Translate(new Position(0, -request.TotalAscent));
         }
     }
 }

+ 3 - 8
QuestPDF/Elements/Text/Items/TextBlockHyperlink.cs

@@ -6,17 +6,12 @@ namespace QuestPDF.Elements.Text.Items
     internal class TextBlockHyperlink : TextBlockSpan
     {
         public string Url { get; set; }
-        
-        public override TextMeasurementResult? Measure(TextMeasurementRequest request)
-        {
-            return MeasureWithoutCache(request);
-        }
 
         public override void Draw(TextDrawingRequest request)
         {
-            request.Canvas.Translate(new Position(0, request.TotalAscent));
-            request.Canvas.DrawHyperlink(Url, new Size(request.TextSize.Width, request.TextSize.Height));
-            request.Canvas.Translate(new Position(0, -request.TotalAscent));
+            Canvas.Translate(new Position(0, request.TotalAscent));
+            Canvas.DrawHyperlink(Url, new Size(request.TextSize.Width, request.TextSize.Height));
+            Canvas.Translate(new Position(0, -request.TotalAscent));
             
             base.Draw(request);
         }

+ 6 - 6
QuestPDF/Elements/Text/Items/TextBlockPageNumber.cs

@@ -9,21 +9,21 @@ namespace QuestPDF.Elements.Text.Items
         public const string PageNumberPlaceholder = "123";
         public Func<IPageContext, string> Source { get; set; } = _ => PageNumberPlaceholder;
         
-        public override TextMeasurementResult? Measure(TextMeasurementRequest request)
+        public override TextBlockSize? Measure()
         {
-            SetPageNumber(request.PageContext);
-            return MeasureWithoutCache(request);
+            SetPageNumber();
+            return base.Measure();
         }
 
         public override void Draw(TextDrawingRequest request)
         {
-            SetPageNumber(request.PageContext);
+            SetPageNumber();
             base.Draw(request);
         }
 
-        private void SetPageNumber(IPageContext context)
+        private void SetPageNumber()
         {
-            Text = Source(context) ?? PageNumberPlaceholder;
+            Text = Source(PageContext) ?? PageNumberPlaceholder;
         }
     }
 }

+ 19 - 0
QuestPDF/Elements/Text/Items/TextBlockSectionLink.cs

@@ -0,0 +1,19 @@
+using QuestPDF.Elements.Text.Calculation;
+using QuestPDF.Infrastructure;
+
+namespace QuestPDF.Elements.Text.Items
+{
+    internal class TextBlockSectionLink : TextBlockSpan
+    {
+        public string SectionName { get; set; }
+
+        public override void Draw(TextDrawingRequest request)
+        {
+            Canvas.Translate(new Position(0, request.TotalAscent));
+            Canvas.DrawSectionLink(SectionName, new Size(request.TextSize.Width, request.TextSize.Height));
+            Canvas.Translate(new Position(0, -request.TotalAscent));
+            
+            base.Draw(request);
+        }
+    }
+}

+ 0 - 24
QuestPDF/Elements/Text/Items/TextBlockSectionlLink.cs

@@ -1,24 +0,0 @@
-using QuestPDF.Elements.Text.Calculation;
-using QuestPDF.Infrastructure;
-
-namespace QuestPDF.Elements.Text.Items
-{
-    internal class TextBlockSectionlLink : TextBlockSpan
-    {
-        public string SectionName { get; set; }
-        
-        public override TextMeasurementResult? Measure(TextMeasurementRequest request)
-        {
-            return MeasureWithoutCache(request);
-        }
-
-        public override void Draw(TextDrawingRequest request)
-        {
-            request.Canvas.Translate(new Position(0, request.TotalAscent));
-            request.Canvas.DrawSectionLink(SectionName, new Size(request.TextSize.Width, request.TextSize.Height));
-            request.Canvas.Translate(new Position(0, -request.TotalAscent));
-            
-            base.Draw(request);
-        }
-    }
-}

+ 53 - 81
QuestPDF/Elements/Text/Items/TextBlockSpan.cs

@@ -1,116 +1,88 @@
-using System.Collections.Generic;
+using System;
+using System.Collections.Generic;
+using System.Linq;
 using QuestPDF.Drawing;
 using QuestPDF.Elements.Text.Calculation;
 using QuestPDF.Infrastructure;
+using SkiaSharp.HarfBuzz;
 using Size = QuestPDF.Infrastructure.Size;
 
 namespace QuestPDF.Elements.Text.Items
 {
     internal class TextBlockSpan : ITextBlockItem
     {
+        public ICanvas Canvas { get; set; }
+        public IPageContext PageContext { get; set; }
+        
         public string Text { get; set; }
-        public TextStyle Style { get; set; } = new TextStyle();
-
-        private Dictionary<(int startIndex, float availableWidth), TextMeasurementResult?> MeasureCache = new();
-
-        public virtual TextMeasurementResult? Measure(TextMeasurementRequest request)
+        public TextStyle Style { get; set; }
+        
+        protected TextBlockSize? Size { get; set; }
+        protected bool RequiresShaping { get; set; }
+        
+        public virtual TextBlockSize? Measure()
         {
-            var cacheKey = (request.StartIndex, request.AvailableWidth);
-            
-            if (!MeasureCache.ContainsKey(cacheKey))
-                MeasureCache[cacheKey] = MeasureWithoutCache(request);
-            
-            return MeasureCache[cacheKey];
+            Size ??= Text == " " ? GetSizeForSpace() : GetSizeForWord();
+            return Size;
         }
-        
-        internal TextMeasurementResult? MeasureWithoutCache(TextMeasurementRequest request)
+
+        private TextBlockSize GetSizeForWord()
         {
-            const char space = ' ';
-            
             var paint = Style.ToPaint();
             var fontMetrics = Style.ToFontMetrics();
 
-            var startIndex = request.StartIndex;
+            var shaper = Style.ToShaper();
             
-            if (request.IsFirstLineElement)
-            {
-                while (startIndex + 1 < Text.Length && Text[startIndex] == space)
-                    startIndex++;
-            }
-
-            if (Text.Length == 0)
-            {
-                return new TextMeasurementResult
-                {
-                    Width = 0,
-                    
-                    LineHeight = Style.LineHeight ?? 1,
-                    Ascent = fontMetrics.Ascent,
-                    Descent = fontMetrics.Descent
-                };
-            }
-            
-            // start breaking text from requested position
-            var text = Text.Substring(startIndex);
+            // shaper returns positions of all glyphs,
+            // by adding a space, it is possible to capture width of the last original character
+            var result = shaper.Shape(Text + " ", paint); 
             
-            var textLength = (int)paint.BreakText(text, request.AvailableWidth);
-
-            if (textLength <= 0)
-                return null;
-
-            if (textLength < text.Length && text[textLength] == space)
-                textLength++;
-            
-            // break text only on spaces
-            if (textLength < text.Length)
-            {
-                var lastSpaceIndex = text.Substring(0, textLength).LastIndexOf(space) - 1;
-
-                if (lastSpaceIndex <= 0)
-                {
-                    if (!request.IsFirstLineElement)
-                        return null;
-                }
-                else
-                {
-                    textLength = lastSpaceIndex + 1;
-                }
-            }
-
-            text = text.Substring(0, textLength);
+            // when text is left-to-right: last value corresponds to text width
+            // when text is right-to-left: glyphs are in the reverse order, first value represents text width
+            var width = Math.Max(result.Points.First().X, result.Points.Last().X);
 
-            var endIndex = startIndex + textLength;
-            var nextIndex = endIndex;
-
-            while (nextIndex + 1 < Text.Length && Text[nextIndex] == space)
-                nextIndex++;
+            RequiresShaping = result.Points.Length != Text.Length + 1;
             
-            // measure final text
-            var width = paint.MeasureText(text);
-  
-            return new TextMeasurementResult
+            return new TextBlockSize
             {
                 Width = width,
                 
                 Ascent = fontMetrics.Ascent,
                 Descent = fontMetrics.Descent,
      
-                LineHeight = Style.LineHeight ?? 1,
+                LineHeight = Style.LineHeight ?? 1
+            };
+        }
+        
+        private TextBlockSize GetSizeForSpace()
+        {
+            var paint = Style.ToPaint();
+            var fontMetrics = Style.ToFontMetrics();
+            
+            return new TextBlockSize
+            {
+                Width = paint.MeasureText(" "),
                 
-                StartIndex = startIndex,
-                EndIndex = endIndex,
-                NextIndex = nextIndex,
-                TotalIndex = Text.Length
+                Ascent = fontMetrics.Ascent,
+                Descent = fontMetrics.Descent,
+     
+                LineHeight = Style.LineHeight ?? 1
             };
         }
+
         public virtual void Draw(TextDrawingRequest request)
         {
             var fontMetrics = Style.ToFontMetrics();
 
-            var text = Text.Substring(request.StartIndex, request.EndIndex - request.StartIndex);
-            
-            request.Canvas.DrawRectangle(new Position(0, request.TotalAscent), new Size(request.TextSize.Width, request.TextSize.Height), Style.BackgroundColor);
-            request.Canvas.DrawText(text, Position.Zero, Style);
+            Canvas.DrawRectangle(new Position(0, request.TotalAscent), new Size(request.TextSize.Width, request.TextSize.Height), Style.BackgroundColor);
+
+            if (!string.IsNullOrWhiteSpace(Text))
+            {
+                if (RequiresShaping)
+                    Canvas.DrawShapedText(Text, Position.Zero, Style);
+                else
+                    Canvas.DrawText(Text, Position.Zero, Style);
+            }
 
             // draw underline
             if ((Style.HasUnderline ?? false) && fontMetrics.UnderlinePosition.HasValue)
@@ -122,7 +94,7 @@ namespace QuestPDF.Elements.Text.Items
 
             void DrawLine(float offset, float thickness)
             {
-                request.Canvas.DrawRectangle(new Position(0, offset - thickness / 2f), new Size(request.TextSize.Width, thickness), Style.Color);
+                Canvas.DrawRectangle(new Position(0, offset - thickness / 2f), new Size(request.TextSize.Width, thickness), Style.Color);
             }
         }
     }

+ 98 - 75
QuestPDF/Elements/Text/TextBlock.cs

@@ -1,6 +1,7 @@
 using System;
 using System.Collections.Generic;
 using System.Linq;
+using System.Text.RegularExpressions;
 using QuestPDF.Drawing;
 using QuestPDF.Elements.Text.Calculation;
 using QuestPDF.Elements.Text.Items;
@@ -11,108 +12,145 @@ namespace QuestPDF.Elements.Text
     internal class TextBlock : Element, IStateResettable
     {
         public HorizontalAlignment Alignment { get; set; } = HorizontalAlignment.Left;
-        public List<ITextBlockItem> Items { get; set; } = new List<ITextBlockItem>();
+        public List<ITextBlockItem> Items { get; set; } = new();
 
         public string Text => string.Join(" ", Items.Where(x => x is TextBlockSpan).Cast<TextBlockSpan>().Select(x => x.Text));
         
-        private Queue<ITextBlockItem> RenderingQueue { get; set; }
         private int CurrentElementIndex { get; set; }
 
+        internal override void Initialize(IPageContext pageContext, ICanvas canvas)
+        {
+            Items = SplitElementsBySpace().ToList();
+            
+            Items.ForEach(x =>
+            {
+                x.PageContext = pageContext;
+                x.Canvas = canvas;
+            });
+            
+            base.Initialize(pageContext, canvas);
+        }
+        
         public void ResetState()
         {
-            RenderingQueue = new Queue<ITextBlockItem>(Items);
             CurrentElementIndex = 0;
         }
 
+        private IEnumerable<ITextBlockItem> SplitElementsBySpace()
+        {
+            foreach (var item in Items)
+            {
+                if (item is TextBlockSpan span)
+                {
+                    span.Text ??= "";
+                    
+                    foreach (var spanItem in Regex.Split(span.Text, "( )|([^ ]+)"))
+                    {
+                        yield return new TextBlockSpan
+                        {
+                            Text = spanItem,
+                            Style = span.Style
+                        };
+                    }
+                }
+            }
+        }
+        
         internal override SpacePlan Measure(Size availableSpace)
         {
-            if (!RenderingQueue.Any())
+            if (!Items.Any())
                 return SpacePlan.FullRender(Size.Zero);
-            
+
             var lines = DivideTextItemsIntoLines(availableSpace.Width, availableSpace.Height).ToList();
 
             if (!lines.Any())
                 return SpacePlan.Wrap();
-            
+
             var width = lines.Max(x => x.Width);
             var height = lines.Sum(x => x.LineHeight);
 
             if (width > availableSpace.Width + Size.Epsilon || height > availableSpace.Height + Size.Epsilon)
                 return SpacePlan.Wrap();
-
-            var fullyRenderedItemsCount = lines
-                .SelectMany(x => x.Elements)
-                .GroupBy(x => x.Item)
-                .Count(x => x.Any(y => y.Measurement.IsLast));
             
-            if (fullyRenderedItemsCount == RenderingQueue.Count)
+            if (CurrentElementIndex + lines.Sum(x => x.Elements.Count) == Items.Count)
                 return SpacePlan.FullRender(width, height);
-            
+
             return SpacePlan.PartialRender(width, height);
         }
 
         internal override void Draw(Size availableSpace)
         {
             var lines = DivideTextItemsIntoLines(availableSpace.Width, availableSpace.Height).ToList();
-            
+
             if (!lines.Any())
                 return;
-            
+
             var heightOffset = 0f;
             var widthOffset = 0f;
-            
-            foreach (var line in lines)
+
+            var isLastPart = lines.Sum(x => x.Elements.Count) + CurrentElementIndex == Items.Count;
+
+            foreach (var sourceLine in lines)
             {
                 widthOffset = 0f;
 
-                var alignmentOffset = GetAlignmentOffset(line.Width);
+                var isLastLine = sourceLine == lines.Last();
+
+                var line = sourceLine;
+
+                if (!(isLastPart && isLastLine))
+                {
+                    var spans = sourceLine.Elements
+                        .Where(x => x.Item is TextBlockSpan)
+                        .SkipWhile(x => string.IsNullOrWhiteSpace((x.Item as TextBlockSpan).Text))
+                        .Reverse()
+                        .SkipWhile(x => string.IsNullOrWhiteSpace((x.Item as TextBlockSpan).Text))
+                        .Reverse()
+                        .ToList();
+
+                    var wordsWidth = spans
+                        .Where(x => !string.IsNullOrWhiteSpace((x.Item as TextBlockSpan).Text))
+                        .Sum(x => x.Measurement.Width);
+
+                    var spaceSpans = spans.Where(x => string.IsNullOrWhiteSpace((x.Item as TextBlockSpan).Text)).ToList();
+                    var spaceWidth = (availableSpace.Width - wordsWidth) / spaceSpans.Count;
+                    spaceSpans.ForEach(x => x.Measurement.Width = spaceWidth);
+                
+                    line = TextLine.From(spans);
+                }
                 
+                var alignmentOffset = GetAlignmentOffset(line.Width);
+
                 Canvas.Translate(new Position(alignmentOffset, 0));
                 Canvas.Translate(new Position(0, -line.Ascent));
-            
+
                 foreach (var item in line.Elements)
                 {
                     var textDrawingRequest = new TextDrawingRequest
                     {
-                        Canvas = Canvas,
-                        PageContext = PageContext,
-                        
-                        StartIndex = item.Measurement.StartIndex,
-                        EndIndex = item.Measurement.EndIndex,
-                        
                         TextSize = new Size(item.Measurement.Width, line.LineHeight),
                         TotalAscent = line.Ascent
                     };
-                
+
                     item.Item.Draw(textDrawingRequest);
-                
+
                     Canvas.Translate(new Position(item.Measurement.Width, 0));
                     widthOffset += item.Measurement.Width;
                 }
-            
+
                 Canvas.Translate(new Position(-alignmentOffset, 0));
                 Canvas.Translate(new Position(-line.Width, line.Ascent));
                 Canvas.Translate(new Position(0, line.LineHeight));
-                
+
                 heightOffset += line.LineHeight;
             }
-            
+
             Canvas.Translate(new Position(0, -heightOffset));
+            CurrentElementIndex += lines.Sum(x => x.Elements.Count);
             
-            lines
-                .SelectMany(x => x.Elements)
-                .GroupBy(x => x.Item)
-                .Where(x => x.Any(y => y.Measurement.IsLast))
-                .Select(x => x.Key)
-                .ToList()
-                .ForEach(x => RenderingQueue.Dequeue());
-
-            var lastElementMeasurement = lines.Last().Elements.Last().Measurement;
-            CurrentElementIndex = lastElementMeasurement.IsLast ? 0 : lastElementMeasurement.NextIndex;
-            
-            if (!RenderingQueue.Any())
+            if (CurrentElementIndex == Items.Count)
                 ResetState();
-            
+
             float GetAlignmentOffset(float lineWidth)
             {
                 if (Alignment == HorizontalAlignment.Left)
@@ -130,19 +168,18 @@ namespace QuestPDF.Elements.Text
             }
         }
 
-        public IEnumerable<TextLine> DivideTextItemsIntoLines(float availableWidth, float availableHeight)
+        private IEnumerable<TextLine> DivideTextItemsIntoLines(float availableWidth, float availableHeight)
         {
-            var queue = new Queue<ITextBlockItem>(RenderingQueue);
             var currentItemIndex = CurrentElementIndex;
             var currentHeight = 0f;
-
-            while (queue.Any())
+            
+            while (true)
             {
                 var line = GetNextLine();
-                
+
                 if (!line.Elements.Any())
                     yield break;
-                
+
                 if (currentHeight + line.LineHeight > availableHeight + Size.Epsilon)
                     yield break;
 
@@ -155,43 +192,29 @@ namespace QuestPDF.Elements.Text
                 var currentWidth = 0f;
 
                 var currentLineElements = new List<TextLineElement>();
-            
+
                 while (true)
                 {
-                    if (!queue.Any())
+                    if (currentItemIndex == Items.Count)
                         break;
 
-                    var currentElement = queue.Peek();
-                    
-                    var measurementRequest = new TextMeasurementRequest
-                    {
-                        Canvas = Canvas,
-                        PageContext = PageContext,
-                        
-                        StartIndex = currentItemIndex,
-                        AvailableWidth = availableWidth - currentWidth,
-                        IsFirstLineElement = !currentLineElements.Any()
-                    };
-                
-                    var measurementResponse = currentElement.Measure(measurementRequest);
-                
-                    if (measurementResponse == null)
+                    var currentElement = Items[currentItemIndex];
+                    var textBlockSize = currentElement.Measure();
+
+                    if (textBlockSize == null)
                         break;
                     
+                    if (currentWidth + textBlockSize.Width > availableWidth + Size.Epsilon)
+                        break;
+
                     currentLineElements.Add(new TextLineElement
                     {
                         Item = currentElement,
-                        Measurement = measurementResponse
+                        Measurement = textBlockSize
                     });
 
-                    currentWidth += measurementResponse.Width;
-                    currentItemIndex = measurementResponse.NextIndex;
-                    
-                    if (!measurementResponse.IsLast)
-                        break;
-
-                    currentItemIndex = 0;
-                    queue.Dequeue();
+                    currentWidth += textBlockSize.Width;
+                    currentItemIndex ++;
                 }
 
                 return TextLine.From(currentLineElements);

+ 1 - 1
QuestPDF/Fluent/TextExtensions.cs

@@ -150,7 +150,7 @@ namespace QuestPDF.Fluent
 
             style ??= TextStyle.Default;
             
-            AddItemToLastTextBlock(new TextBlockSectionlLink
+            AddItemToLastTextBlock(new TextBlockSectionLink
             {
                 Style = style,
                 Text = text,

+ 1 - 0
QuestPDF/Infrastructure/ICanvas.cs

@@ -8,6 +8,7 @@ namespace QuestPDF.Infrastructure
         
         void DrawRectangle(Position vector, Size size, string color);
         void DrawText(string text, Position position, TextStyle style);
+        void DrawShapedText(string text, Position position, TextStyle style);
         void DrawImage(SKImage image, Position position, Size size);
 
         void DrawHyperlink(string url, Size size);