diff --git a/QuestPDF.Examples/TextBenchmark.cs b/QuestPDF.Examples/TextBenchmark.cs index fb4c105..a5d1fb9 100644 --- a/QuestPDF.Examples/TextBenchmark.cs +++ b/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}"); diff --git a/QuestPDF.Examples/TextExamples.cs b/QuestPDF.Examples/TextExamples.cs index 5f4ee30..c562b10 100644 --- a/QuestPDF.Examples/TextExamples.cs +++ b/QuestPDF.Examples/TextExamples.cs @@ -12,11 +12,11 @@ namespace QuestPDF.Examples public class TextExamples { [Test] - public void SimpleTextBlock() + public void SimpleText() { RenderingTest .Create() - .PageSize(500, 300) + .PageSize(500, 100) .ProduceImages() .ShowResults() @@ -27,6 +27,27 @@ namespace QuestPDF.Examples .MinimalBox() .Border(1) .Padding(10) + .Text(Placeholders.Paragraph()); + }); + } + + [Test] + public void SimpleTextBlock() + { + RenderingTest + .Create() + .PageSize(600, 300) + + .ProduceImages() + .ShowResults() + .Render(container => + { + container + .Padding(5) + .MinimalBox() + .Border(1) + .MaxWidth(300) + .Padding(10) .Text(text => { text.DefaultTextStyle(TextStyle.Default.FontSize(20)); @@ -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()); }); }); } @@ -388,7 +403,7 @@ namespace QuestPDF.Examples .Padding(10) .Text(text => { - text.DefaultTextStyle(TextStyle.Default.FontSize(20)); + text.DefaultTextStyle(TextStyle.Default.FontSize(20).BackgroundColor(Colors.Red.Lighten4)); text.AlignLeft(); text.ParagraphSpacing(10); @@ -407,7 +422,7 @@ namespace QuestPDF.Examples { text.Line($"{i}: {Placeholders.Paragraph()}"); - text.Hyperlink("Please visit QuestPDF website", "https://www.questpdf.com"); + text.Hyperlink("Please visit QuestPDF website. ", "https://www.questpdf.com"); text.Span("This is page number "); text.CurrentPageNumber(); diff --git a/QuestPDF.Examples/TextShapingTests.cs b/QuestPDF.Examples/TextShapingTests.cs new file mode 100644 index 0000000..90fa8a8 --- /dev/null +++ b/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); + } + } +} \ No newline at end of file diff --git a/QuestPDF.Previewer/CommunicationService.cs b/QuestPDF.Previewer/CommunicationService.cs index 0a78ef5..d988fa1 100644 --- a/QuestPDF.Previewer/CommunicationService.cs +++ b/QuestPDF.Previewer/CommunicationService.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -29,6 +30,7 @@ class CommunicationService { var builder = WebApplication.CreateBuilder(); builder.Services.AddLogging(x => x.ClearProviders()); + builder.WebHost.UseKestrel(options => options.Limits.MaxRequestBodySize = null); Application = builder.Build(); Application.MapGet("ping", HandlePing); diff --git a/QuestPDF.Previewer/QuestPDF.Previewer.csproj b/QuestPDF.Previewer/QuestPDF.Previewer.csproj index 5aeb9fa..9ada0f5 100644 --- a/QuestPDF.Previewer/QuestPDF.Previewer.csproj +++ b/QuestPDF.Previewer/QuestPDF.Previewer.csproj @@ -4,7 +4,7 @@ MarcinZiabek CodeFlint QuestPDF.Previewer - 2022.5.0 + 2022.6.0 true questpdf-previewer QuestPDF is an open-source, modern and battle-tested library that can help you with generating PDF documents by offering friendly, discoverable and predictable C# fluent API. diff --git a/QuestPDF.UnitTests/TestEngine/MockCanvas.cs b/QuestPDF.UnitTests/TestEngine/MockCanvas.cs index 6e4926e..68bbc07 100644 --- a/QuestPDF.UnitTests/TestEngine/MockCanvas.cs +++ b/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 RotateFunc { get; set; } public Action ScaleFunc { get; set; } public Action DrawImageFunc { get; set; } - public Action DrawTextFunc { get; set; } public Action 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(); diff --git a/QuestPDF.UnitTests/TestEngine/OperationRecordingCanvas.cs b/QuestPDF.UnitTests/TestEngine/OperationRecordingCanvas.cs index 4fbedfe..5ca55aa 100644 --- a/QuestPDF.UnitTests/TestEngine/OperationRecordingCanvas.cs +++ b/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(); diff --git a/QuestPDF.UnitTests/TestEngine/TestPlan.cs b/QuestPDF.UnitTests/TestEngine/TestPlan.cs index 1ec5fe9..bd803e5 100644 --- a/QuestPDF.UnitTests/TestEngine/TestPlan.cs +++ b/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(); - - 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(); @@ -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)); diff --git a/QuestPDF/Drawing/DocumentGenerator.cs b/QuestPDF/Drawing/DocumentGenerator.cs index cd9b339..3fe373c 100644 --- a/QuestPDF/Drawing/DocumentGenerator.cs +++ b/QuestPDF/Drawing/DocumentGenerator.cs @@ -135,11 +135,10 @@ namespace QuestPDF.Drawing $"In this case, please increase the value {nameof(DocumentMetadata)}.{nameof(DocumentMetadata.DocumentLayoutExceptionThreshold)} property configured in the {nameof(IDocument.GetMetadata)} method. " + $"2) The layout configuration of your document is invalid. Some of the elements require more space than is provided." + $"Please analyze your documents structure to detect this element and fix its size constraints."; - - throw new DocumentLayoutException(message) - { - ElementTrace = debuggingState?.BuildTrace() ?? "Debug trace is available only in the DEBUG mode." - }; + + var elementTrace = debuggingState?.BuildTrace() ?? "Debug trace is available only in the DEBUG mode."; + + throw new DocumentLayoutException(message, elementTrace); } } diff --git a/QuestPDF/Drawing/Exceptions/DocumentComposeException.cs b/QuestPDF/Drawing/Exceptions/DocumentComposeException.cs index 972dbe8..eeefb4e 100644 --- a/QuestPDF/Drawing/Exceptions/DocumentComposeException.cs +++ b/QuestPDF/Drawing/Exceptions/DocumentComposeException.cs @@ -4,17 +4,7 @@ namespace QuestPDF.Drawing.Exceptions { public class DocumentComposeException : Exception { - public DocumentComposeException() - { - - } - - public DocumentComposeException(string message) : base(message) - { - - } - - public DocumentComposeException(string message, Exception inner) : base(message, inner) + internal DocumentComposeException(string message) : base(message) { } diff --git a/QuestPDF/Drawing/Exceptions/DocumentDrawingException.cs b/QuestPDF/Drawing/Exceptions/DocumentDrawingException.cs index cebb76c..a650d90 100644 --- a/QuestPDF/Drawing/Exceptions/DocumentDrawingException.cs +++ b/QuestPDF/Drawing/Exceptions/DocumentDrawingException.cs @@ -4,17 +4,7 @@ namespace QuestPDF.Drawing.Exceptions { public class DocumentDrawingException : Exception { - public DocumentDrawingException() - { - - } - - public DocumentDrawingException(string message) : base(message) - { - - } - - public DocumentDrawingException(string message, Exception inner) : base(message, inner) + internal DocumentDrawingException(string message, Exception inner) : base(message, inner) { } diff --git a/QuestPDF/Drawing/Exceptions/DocumentLayoutException.cs b/QuestPDF/Drawing/Exceptions/DocumentLayoutException.cs index ff83755..acdc1f0 100644 --- a/QuestPDF/Drawing/Exceptions/DocumentLayoutException.cs +++ b/QuestPDF/Drawing/Exceptions/DocumentLayoutException.cs @@ -4,21 +4,11 @@ namespace QuestPDF.Drawing.Exceptions { public class DocumentLayoutException : Exception { - public string ElementTrace { get; set; } - - public DocumentLayoutException() - { - - } + public string? ElementTrace { get; } - public DocumentLayoutException(string message) : base(message) + internal DocumentLayoutException(string message, string? elementTrace = null) : base(message) { - - } - - public DocumentLayoutException(string message, Exception inner) : base(message, inner) - { - + ElementTrace = elementTrace; } } } \ No newline at end of file diff --git a/QuestPDF/Drawing/Exceptions/InitializationException.cs b/QuestPDF/Drawing/Exceptions/InitializationException.cs new file mode 100644 index 0000000..5be5dd2 --- /dev/null +++ b/QuestPDF/Drawing/Exceptions/InitializationException.cs @@ -0,0 +1,20 @@ +using System; + +namespace QuestPDF.Drawing.Exceptions +{ + public class InitializationException : Exception + { + internal InitializationException(string documentType, Exception innerException) : base(CreateMessage(documentType), innerException) + { + + } + + private static string CreateMessage(string documentType) + { + return $"Cannot create the {documentType} document using the SkiaSharp library. " + + $"This exception usually means that, on your operating system where you run the application, SkiaSharp requires installing additional dependencies. " + + $"Such dependencies are available as additional nuget packages, for example SkiaSharp.NativeAssets.Linux. " + + $"Please refer to the SkiaSharp documentation for more details."; + } + } +} \ No newline at end of file diff --git a/QuestPDF/Drawing/FontManager.cs b/QuestPDF/Drawing/FontManager.cs index 3c699a9..fe37924 100644 --- a/QuestPDF/Drawing/FontManager.cs +++ b/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,11 @@ namespace QuestPDF.Drawing { private static ConcurrentDictionary StyleSets = new(); private static ConcurrentDictionary FontMetrics = new(); - private static ConcurrentDictionary Paints = new(); - private static ConcurrentDictionary ColorPaint = new(); + private static ConcurrentDictionary FontPaints = new(); + private static ConcurrentDictionary ColorPaints = new(); + private static ConcurrentDictionary ShaperFonts = new(); + private static ConcurrentDictionary Fonts = new(); + private static ConcurrentDictionary TextShapers = new(); private static void RegisterFontType(SKData fontData, string? customName = null) { @@ -47,7 +52,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 +66,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 +127,38 @@ 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)); + } + + internal static SKFont FoFont(this TextStyle style) + { + return Fonts.GetOrAdd(style.PaintKey, _ => style.ToPaint().ToFont()); + } } } \ No newline at end of file diff --git a/QuestPDF/Drawing/FreeCanvas.cs b/QuestPDF/Drawing/FreeCanvas.cs index 251c9d1..bc2c68e 100644 --- a/QuestPDF/Drawing/FreeCanvas.cs +++ b/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) { } diff --git a/QuestPDF/Drawing/PdfCanvas.cs b/QuestPDF/Drawing/PdfCanvas.cs index ac703eb..cdc6a2d 100644 --- a/QuestPDF/Drawing/PdfCanvas.cs +++ b/QuestPDF/Drawing/PdfCanvas.cs @@ -1,4 +1,6 @@ -using System.IO; +using System; +using System.IO; +using QuestPDF.Drawing.Exceptions; using QuestPDF.Helpers; using SkiaSharp; @@ -7,11 +9,23 @@ namespace QuestPDF.Drawing internal class PdfCanvas : SkiaDocumentCanvasBase { public PdfCanvas(Stream stream, DocumentMetadata documentMetadata) - : base(SKDocument.CreatePdf(stream, MapMetadata(documentMetadata))) + : base(CreatePdf(stream, documentMetadata)) { } - + + private static SKDocument CreatePdf(Stream stream, DocumentMetadata documentMetadata) + { + try + { + return SKDocument.CreatePdf(stream, MapMetadata(documentMetadata)); + } + catch (TypeInitializationException exception) + { + throw new InitializationException("PDF", exception); + } + } + private static SKDocumentPdfMetadata MapMetadata(DocumentMetadata metadata) { return new SKDocumentPdfMetadata diff --git a/QuestPDF/Drawing/SkiaCanvasBase.cs b/QuestPDF/Drawing/SkiaCanvasBase.cs index aaf8e25..9d1851a 100644 --- a/QuestPDF/Drawing/SkiaCanvasBase.cs +++ b/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) diff --git a/QuestPDF/Drawing/TextShaper.cs b/QuestPDF/Drawing/TextShaper.cs new file mode 100644 index 0000000..8867f80 --- /dev/null +++ b/QuestPDF/Drawing/TextShaper.cs @@ -0,0 +1,155 @@ +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(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 + { + public ShapedGlyph[] Glyphs { get; } + + public TextShapingResult(ShapedGlyph[] glyphs) + { + Glyphs = glyphs; + } + + public int BreakText(int startIndex, float maxWidth) + { + var index = startIndex; + maxWidth += Glyphs[startIndex].Position.X; + + while (index < Glyphs.Length) + { + var glyph = Glyphs[index]; + + if (glyph.Position.X + glyph.Width > maxWidth + Size.Epsilon) + break; + + index++; + } + + return index - 1; + } + + public float MeasureWidth(int startIndex, int endIndex) + { + if (Glyphs.Length == 0) + return 0; + + var start = Glyphs[startIndex]; + var end = Glyphs[endIndex]; + + return end.Position.X - start.Position.X + end.Width; + } + + public DrawTextCommand? PositionText(int startIndex, int endIndex, TextStyle textStyle) + { + if (Glyphs.Length == 0) + return null; + + using var skTextBlobBuilder = new SKTextBlobBuilder(); + + var positionedRunBuffer = skTextBlobBuilder.AllocatePositionedRun(textStyle.FoFont(), 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 + }; + } + } + +} \ No newline at end of file diff --git a/QuestPDF/Drawing/XpsCanvas.cs b/QuestPDF/Drawing/XpsCanvas.cs index 2c3f65e..2d08f48 100644 --- a/QuestPDF/Drawing/XpsCanvas.cs +++ b/QuestPDF/Drawing/XpsCanvas.cs @@ -1,4 +1,6 @@ -using System.IO; +using System; +using System.IO; +using QuestPDF.Drawing.Exceptions; using QuestPDF.Helpers; using SkiaSharp; @@ -7,9 +9,21 @@ namespace QuestPDF.Drawing internal class XpsCanvas : SkiaDocumentCanvasBase { public XpsCanvas(Stream stream, DocumentMetadata documentMetadata) - : base(SKDocument.CreateXps(stream, documentMetadata.RasterDpi)) + : base(CreateXps(stream, documentMetadata)) { } + + private static SKDocument CreateXps(Stream stream, DocumentMetadata documentMetadata) + { + try + { + return SKDocument.CreateXps(stream, documentMetadata.RasterDpi); + } + catch (TypeInitializationException exception) + { + throw new InitializationException("XPS", exception); + } + } } } \ No newline at end of file diff --git a/QuestPDF/Elements/Text/Calculation/TextDrawingRequest.cs b/QuestPDF/Elements/Text/Calculation/TextDrawingRequest.cs index ef16075..5b08b6f 100644 --- a/QuestPDF/Elements/Text/Calculation/TextDrawingRequest.cs +++ b/QuestPDF/Elements/Text/Calculation/TextDrawingRequest.cs @@ -1,4 +1,5 @@ -using QuestPDF.Infrastructure; +using QuestPDF.Drawing; +using QuestPDF.Infrastructure; namespace QuestPDF.Elements.Text.Calculation { diff --git a/QuestPDF/Elements/Text/Calculation/TextMeasurementResult.cs b/QuestPDF/Elements/Text/Calculation/TextMeasurementResult.cs index 6cded5b..4643763 100644 --- a/QuestPDF/Elements/Text/Calculation/TextMeasurementResult.cs +++ b/QuestPDF/Elements/Text/Calculation/TextMeasurementResult.cs @@ -1,4 +1,5 @@ using System; +using QuestPDF.Drawing; namespace QuestPDF.Elements.Text.Calculation { diff --git a/QuestPDF/Elements/Text/Items/TextBlockPageNumber.cs b/QuestPDF/Elements/Text/Items/TextBlockPageNumber.cs index a4ab822..1b12b3c 100644 --- a/QuestPDF/Elements/Text/Items/TextBlockPageNumber.cs +++ b/QuestPDF/Elements/Text/Items/TextBlockPageNumber.cs @@ -8,6 +8,7 @@ namespace QuestPDF.Elements.Text.Items { public const string PageNumberPlaceholder = "123"; public Func Source { get; set; } = _ => PageNumberPlaceholder; + protected override bool EnableTextCache => false; public override TextMeasurementResult? Measure(TextMeasurementRequest request) { diff --git a/QuestPDF/Elements/Text/Items/TextBlockSpan.cs b/QuestPDF/Elements/Text/Items/TextBlockSpan.cs index 26807d3..c9702b3 100644 --- a/QuestPDF/Elements/Text/Items/TextBlockSpan.cs +++ b/QuestPDF/Elements/Text/Items/TextBlockSpan.cs @@ -1,20 +1,24 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using QuestPDF.Drawing; using QuestPDF.Elements.Text.Calculation; +using QuestPDF.Helpers; 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 +29,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 +47,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 +64,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); - if (textLength <= 0) + if (endIndex < 0) return null; // break text only on spaces - var wrappedTextLength = WrapText(text, textLength, request.IsFirstElementInLine); + var wrappedText = WrapText(startIndex, 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, wrappedText.Value.endIndex); return new TextMeasurementResult { @@ -86,56 +88,70 @@ 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 text, int textLength, bool isFirstElementInLine) + // TODO: consider introducing text wrapping abstraction (basic, english-like, asian-like) + private (int endIndex, int nextIndex)? WrapText(int startIndex, 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].Codepoint != spaceCodepoint && TextShapingResult.Glyphs[endIndex + 1].Codepoint == spaceCodepoint) + return (endIndex, endIndex + 2); // find last space within the available text to wrap - var lastSpaceIndex = text.Slice(0, textLength).LastIndexOf(Space); + var lastSpaceIndex = endIndex; + + while (lastSpaceIndex >= startIndex) + { + if (TextShapingResult.Glyphs[lastSpaceIndex].Codepoint == spaceCodepoint) + break; + + lastSpaceIndex--; + } // text contains space that can be used to wrap - if (lastSpaceIndex > 0) - return (lastSpaceIndex, lastSpaceIndex + 1); + if (lastSpaceIndex >= startIndex) + return (lastSpaceIndex - 1, lastSpaceIndex + 1); // 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 + 1) : 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(); - 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); + var textDrawingCommand = TextShapingResult.PositionText(request.StartIndex, request.EndIndex, Style); + + if (Style.BackgroundColor != Colors.Transparent) + request.Canvas.DrawRectangle(new Position(0, request.TotalAscent), new Size(request.TextSize.Width, request.TextSize.Height), Style.BackgroundColor); + + 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 +161,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) diff --git a/QuestPDF/Helpers/Placeholders.cs b/QuestPDF/Helpers/Placeholders.cs index 5fa1e64..3383784 100644 --- a/QuestPDF/Helpers/Placeholders.cs +++ b/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 diff --git a/QuestPDF/Infrastructure/ICanvas.cs b/QuestPDF/Infrastructure/ICanvas.cs index 65b470b..8a65ef1 100644 --- a/QuestPDF/Infrastructure/ICanvas.cs +++ b/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); diff --git a/QuestPDF/Previewer/PreviewerService.cs b/QuestPDF/Previewer/PreviewerService.cs index bce0826..14a94c1 100644 --- a/QuestPDF/Previewer/PreviewerService.cs +++ b/QuestPDF/Previewer/PreviewerService.cs @@ -94,7 +94,7 @@ namespace QuestPDF.Previewer private void CheckVersionCompatibility(Version version) { - if (version.Major == 2022 && version.Minor == 5) + if (version.Major == 2022 && version.Minor == 6) return; throw new Exception($"Previewer version is not compatible. Possible solutions: " + diff --git a/QuestPDF/QuestPDF.csproj b/QuestPDF/QuestPDF.csproj index a4a3d93..9f3c0cf 100644 --- a/QuestPDF/QuestPDF.csproj +++ b/QuestPDF/QuestPDF.csproj @@ -1,10 +1,9 @@  - MarcinZiabek CodeFlint QuestPDF - 2022.5.0 + 2022.6.0-prerelease QuestPDF is an open-source, modern and battle-tested library that can help you with generating PDF documents by offering friendly, discoverable and predictable C# fluent API. $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/Resources/ReleaseNotes.txt")) 9 @@ -24,7 +23,8 @@ - + + @@ -45,5 +45,4 @@ \ - diff --git a/QuestPDF/Resources/ReleaseNotes.txt b/QuestPDF/Resources/ReleaseNotes.txt index d295164..0679b9f 100644 --- a/QuestPDF/Resources/ReleaseNotes.txt +++ b/QuestPDF/Resources/ReleaseNotes.txt @@ -1,4 +1,4 @@ -- Implemented the DynamicComponent element (useful when you want to generate dynamic and conditional content that is page aware, e.g. per-page totals), -- Extended text rendering capabilities by adding subscript and superscript effects, -- Improved table rendering performance, -- Previewer tool stability fixes. +Implemented support for the text shaping algorithm that fixes rendering more advanced languages such as Arabic. +Improved exception message when SkiaSharp throws the TypeInitializationException (when additional dependencies are needed). +Fixed: a rare case when the Row.AutoItem does not calculate properly the width of its content. +Fixed: the QuestPDF Previewer does not work with content-rich documents.