From 662a55e21c3f96c39e973fdbb49db318b8cc82e5 Mon Sep 17 00:00:00 2001 From: MarcinZiabek Date: Tue, 1 Mar 2022 23:22:14 +0100 Subject: [PATCH] Implemented text shaping prototype --- QuestPDF.Examples/TextExamples.cs | 4 +- QuestPDF.Examples/TextShapingTests.cs | 60 ++++++ QuestPDF.UnitTests/TestEngine/MockCanvas.cs | 1 + .../TestEngine/OperationRecordingCanvas.cs | 1 + QuestPDF/Drawing/FontManager.cs | 9 +- QuestPDF/Drawing/FreeCanvas.cs | 5 + QuestPDF/Drawing/SkiaCanvasBase.cs | 7 +- ...tMeasurementResult.cs => TextBlockSize.cs} | 9 +- .../Text/Calculation/TextDrawingRequest.cs | 8 +- .../Text/Calculation/TextLineElement.cs | 2 +- .../Calculation/TextMeasurementRequest.cs | 14 -- .../Elements/Text/Items/ITextBlockItem.cs | 5 +- .../Elements/Text/Items/TextBlockElement.cs | 23 ++- .../Elements/Text/Items/TextBlockHyperlink.cs | 11 +- .../Text/Items/TextBlockPageNumber.cs | 12 +- .../Text/Items/TextBlockSectionLink.cs | 19 ++ .../Text/Items/TextBlockSectionlLink.cs | 24 --- QuestPDF/Elements/Text/Items/TextBlockSpan.cs | 142 ++++++-------- QuestPDF/Elements/Text/TextBlock.cs | 173 ++++++++++-------- QuestPDF/Fluent/TextExtensions.cs | 2 +- QuestPDF/Infrastructure/ICanvas.cs | 1 + 21 files changed, 286 insertions(+), 246 deletions(-) rename QuestPDF/Elements/Text/Calculation/{TextMeasurementResult.cs => TextBlockSize.cs} (53%) delete mode 100644 QuestPDF/Elements/Text/Calculation/TextMeasurementRequest.cs create mode 100644 QuestPDF/Elements/Text/Items/TextBlockSectionLink.cs delete mode 100644 QuestPDF/Elements/Text/Items/TextBlockSectionlLink.cs diff --git a/QuestPDF.Examples/TextExamples.cs b/QuestPDF.Examples/TextExamples.cs index ef12e96..73cc3d1 100644 --- a/QuestPDF.Examples/TextExamples.cs +++ b/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()}"); diff --git a/QuestPDF.Examples/TextShapingTests.cs b/QuestPDF.Examples/TextShapingTests.cs index df3e636..f25a9fb 100644 --- a/QuestPDF.Examples/TextShapingTests.cs +++ b/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); + } + } } } \ No newline at end of file diff --git a/QuestPDF.UnitTests/TestEngine/MockCanvas.cs b/QuestPDF.UnitTests/TestEngine/MockCanvas.cs index 6e4926e..dcf215a 100644 --- a/QuestPDF.UnitTests/TestEngine/MockCanvas.cs +++ b/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(); diff --git a/QuestPDF.UnitTests/TestEngine/OperationRecordingCanvas.cs b/QuestPDF.UnitTests/TestEngine/OperationRecordingCanvas.cs index 4fbedfe..3247afe 100644 --- a/QuestPDF.UnitTests/TestEngine/OperationRecordingCanvas.cs +++ b/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(); diff --git a/QuestPDF/Drawing/FontManager.cs b/QuestPDF/Drawing/FontManager.cs index f151909..56e9bb0 100644 --- a/QuestPDF/Drawing/FontManager.cs +++ b/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 FontMetrics = new(); private static ConcurrentDictionary Paints = new(); private static ConcurrentDictionary Fonts = new(); + private static ConcurrentDictionary Shapers = new(); private static ConcurrentDictionary 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)); + } } } \ No newline at end of file diff --git a/QuestPDF/Drawing/FreeCanvas.cs b/QuestPDF/Drawing/FreeCanvas.cs index 251c9d1..17f5874 100644 --- a/QuestPDF/Drawing/FreeCanvas.cs +++ b/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) diff --git a/QuestPDF/Drawing/SkiaCanvasBase.cs b/QuestPDF/Drawing/SkiaCanvasBase.cs index 3d2d568..acb75d8 100644 --- a/QuestPDF/Drawing/SkiaCanvasBase.cs +++ b/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)); diff --git a/QuestPDF/Elements/Text/Calculation/TextMeasurementResult.cs b/QuestPDF/Elements/Text/Calculation/TextBlockSize.cs similarity index 53% rename from QuestPDF/Elements/Text/Calculation/TextMeasurementResult.cs rename to QuestPDF/Elements/Text/Calculation/TextBlockSize.cs index 6cded5b..6856274 100644 --- a/QuestPDF/Elements/Text/Calculation/TextMeasurementResult.cs +++ b/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; } } \ No newline at end of file diff --git a/QuestPDF/Elements/Text/Calculation/TextDrawingRequest.cs b/QuestPDF/Elements/Text/Calculation/TextDrawingRequest.cs index ef16075..0d1ed50 100644 --- a/QuestPDF/Elements/Text/Calculation/TextDrawingRequest.cs +++ b/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; } } diff --git a/QuestPDF/Elements/Text/Calculation/TextLineElement.cs b/QuestPDF/Elements/Text/Calculation/TextLineElement.cs index a62df24..a7a9392 100644 --- a/QuestPDF/Elements/Text/Calculation/TextLineElement.cs +++ b/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; } } } \ No newline at end of file diff --git a/QuestPDF/Elements/Text/Calculation/TextMeasurementRequest.cs b/QuestPDF/Elements/Text/Calculation/TextMeasurementRequest.cs deleted file mode 100644 index 1b358d3..0000000 --- a/QuestPDF/Elements/Text/Calculation/TextMeasurementRequest.cs +++ /dev/null @@ -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; } - } -} \ No newline at end of file diff --git a/QuestPDF/Elements/Text/Items/ITextBlockItem.cs b/QuestPDF/Elements/Text/Items/ITextBlockItem.cs index 570087b..146f2ab 100644 --- a/QuestPDF/Elements/Text/Items/ITextBlockItem.cs +++ b/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); } } \ No newline at end of file diff --git a/QuestPDF/Elements/Text/Items/TextBlockElement.cs b/QuestPDF/Elements/Text/Items/TextBlockElement.cs index feb76b9..c025958 100644 --- a/QuestPDF/Elements/Text/Items/TextBlockElement.cs +++ b/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)); } } } \ No newline at end of file diff --git a/QuestPDF/Elements/Text/Items/TextBlockHyperlink.cs b/QuestPDF/Elements/Text/Items/TextBlockHyperlink.cs index bd8228b..83c282e 100644 --- a/QuestPDF/Elements/Text/Items/TextBlockHyperlink.cs +++ b/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); } diff --git a/QuestPDF/Elements/Text/Items/TextBlockPageNumber.cs b/QuestPDF/Elements/Text/Items/TextBlockPageNumber.cs index b48b65d..466e6e8 100644 --- a/QuestPDF/Elements/Text/Items/TextBlockPageNumber.cs +++ b/QuestPDF/Elements/Text/Items/TextBlockPageNumber.cs @@ -9,21 +9,21 @@ namespace QuestPDF.Elements.Text.Items public const string PageNumberPlaceholder = "123"; public Func 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; } } } \ No newline at end of file diff --git a/QuestPDF/Elements/Text/Items/TextBlockSectionLink.cs b/QuestPDF/Elements/Text/Items/TextBlockSectionLink.cs new file mode 100644 index 0000000..b10ef57 --- /dev/null +++ b/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); + } + } +} \ No newline at end of file diff --git a/QuestPDF/Elements/Text/Items/TextBlockSectionlLink.cs b/QuestPDF/Elements/Text/Items/TextBlockSectionlLink.cs deleted file mode 100644 index 5c41ca0..0000000 --- a/QuestPDF/Elements/Text/Items/TextBlockSectionlLink.cs +++ /dev/null @@ -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); - } - } -} \ No newline at end of file diff --git a/QuestPDF/Elements/Text/Items/TextBlockSpan.cs b/QuestPDF/Elements/Text/Items/TextBlockSpan.cs index 0011a16..7d14f4b 100644 --- a/QuestPDF/Elements/Text/Items/TextBlockSpan.cs +++ b/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 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) - { - var cacheKey = (request.StartIndex, request.AvailableWidth); - - if (!MeasureCache.ContainsKey(cacheKey)) - MeasureCache[cacheKey] = MeasureWithoutCache(request); - - return MeasureCache[cacheKey]; - } + public ICanvas Canvas { get; set; } + public IPageContext PageContext { get; set; } - internal TextMeasurementResult? MeasureWithoutCache(TextMeasurementRequest request) + public string Text { get; set; } + public TextStyle Style { get; set; } + + protected TextBlockSize? Size { get; set; } + protected bool RequiresShaping { get; set; } + + public virtual TextBlockSize? Measure() + { + Size ??= Text == " " ? GetSizeForSpace() : GetSizeForWord(); + return Size; + } + + 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 - }; - } + // 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); - // start breaking text from requested position - var text = Text.Substring(startIndex); + // 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); + + RequiresShaping = result.Points.Length != Text.Length + 1; - 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); - - var endIndex = startIndex + textLength; - var nextIndex = endIndex; - - while (nextIndex + 1 < Text.Length && Text[nextIndex] == space) - nextIndex++; - - // 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, - - StartIndex = startIndex, - EndIndex = endIndex, - NextIndex = nextIndex, - TotalIndex = Text.Length + LineHeight = Style.LineHeight ?? 1 }; } + + private TextBlockSize GetSizeForSpace() + { + var paint = Style.ToPaint(); + var fontMetrics = Style.ToFontMetrics(); + + return new TextBlockSize + { + Width = paint.MeasureText(" "), + + 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); } } } diff --git a/QuestPDF/Elements/Text/TextBlock.cs b/QuestPDF/Elements/Text/TextBlock.cs index 0b19928..68aded6 100644 --- a/QuestPDF/Elements/Text/TextBlock.cs +++ b/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 Items { get; set; } = new List(); + public List Items { get; set; } = new(); public string Text => string.Join(" ", Items.Where(x => x is TextBlockSpan).Cast().Select(x => x.Text)); - private Queue 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(Items); CurrentElementIndex = 0; } + private IEnumerable 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)); - - 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; + Canvas.Translate(new Position(0, -heightOffset)); + CurrentElementIndex += lines.Sum(x => x.Elements.Count); - 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 DivideTextItemsIntoLines(float availableWidth, float availableHeight) + private IEnumerable DivideTextItemsIntoLines(float availableWidth, float availableHeight) { - var queue = new Queue(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(); - + 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); diff --git a/QuestPDF/Fluent/TextExtensions.cs b/QuestPDF/Fluent/TextExtensions.cs index e069871..76fce65 100644 --- a/QuestPDF/Fluent/TextExtensions.cs +++ b/QuestPDF/Fluent/TextExtensions.cs @@ -150,7 +150,7 @@ namespace QuestPDF.Fluent style ??= TextStyle.Default; - AddItemToLastTextBlock(new TextBlockSectionlLink + AddItemToLastTextBlock(new TextBlockSectionLink { Style = style, Text = text, diff --git a/QuestPDF/Infrastructure/ICanvas.cs b/QuestPDF/Infrastructure/ICanvas.cs index 65b470b..bd52006 100644 --- a/QuestPDF/Infrastructure/ICanvas.cs +++ b/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);