Compare commits
11 Commits
text-shaping
...
2022.2.7
| Author | SHA1 | Date | |
|---|---|---|---|
| ba4be521aa | |||
| f6967dfe8b | |||
| ff3dfe7831 | |||
| 5a1990d857 | |||
| 9c64a6bc8d | |||
| 5402e5fedc | |||
| fdf8e3eb1a | |||
| 456c7a735b | |||
| 5bf2a9a3d3 | |||
| 69e268733c | |||
| 657bbe2aac |
@@ -23,7 +23,7 @@ namespace QuestPDF.Examples
|
||||
.PageSize(PageSizes.A4)
|
||||
.ProducePdf()
|
||||
.ShowResults()
|
||||
.Render(x => ComposeBook(x, chapters));
|
||||
.RenderDocument(x => ComposeBook(x, chapters));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -42,8 +42,8 @@ namespace QuestPDF.Examples
|
||||
RenderingTest
|
||||
.Create()
|
||||
.PageSize(PageSizes.A4)
|
||||
.ProducePdf()
|
||||
.Render(x => ComposeBook(x, chapters));
|
||||
.ProducePdf()
|
||||
.RenderDocument(x => ComposeBook(x, chapters));
|
||||
}
|
||||
|
||||
IEnumerable<float> PerformTest(int attempts)
|
||||
@@ -102,37 +102,30 @@ namespace QuestPDF.Examples
|
||||
}
|
||||
}
|
||||
|
||||
private void ComposeBook(IContainer container, ICollection<BookChapter> chapters)
|
||||
private void ComposeBook(IDocumentContainer container, ICollection<BookChapter> chapters)
|
||||
{
|
||||
var subtitleStyle = TextStyle.Default.Size(24).SemiBold().Color(Colors.Blue.Medium);
|
||||
var normalStyle = TextStyle.Default.Size(14);
|
||||
|
||||
ComposePage(container);
|
||||
|
||||
void ComposePage(IContainer container)
|
||||
container.Page(page =>
|
||||
{
|
||||
container
|
||||
.Padding(50)
|
||||
.Decoration(decoration =>
|
||||
{
|
||||
decoration
|
||||
.Content()
|
||||
.Column(column =>
|
||||
{
|
||||
column.Item().Element(Title);
|
||||
column.Item().PageBreak();
|
||||
column.Item().Element(TableOfContents);
|
||||
column.Item().PageBreak();
|
||||
page.Margin(50);
|
||||
|
||||
page.Content().Column(column =>
|
||||
{
|
||||
column.Item().Element(Title);
|
||||
column.Item().PageBreak();
|
||||
column.Item().Element(TableOfContents);
|
||||
column.Item().PageBreak();
|
||||
|
||||
Chapters(column);
|
||||
Chapters(column);
|
||||
|
||||
column.Item().Element(Acknowledgements);
|
||||
});
|
||||
column.Item().Element(Acknowledgements);
|
||||
});
|
||||
|
||||
page.Footer().Element(Footer);
|
||||
});
|
||||
|
||||
decoration.After().Element(Footer);
|
||||
});
|
||||
}
|
||||
|
||||
void Title(IContainer container)
|
||||
{
|
||||
container
|
||||
|
||||
@@ -31,7 +31,8 @@ namespace QuestPDF.Examples
|
||||
{
|
||||
text.DefaultTextStyle(TextStyle.Default.Size(20));
|
||||
text.Span("This is a normal text, followed by an ");
|
||||
text.Span("underlined red text.", TextStyle.Default.Size(20).Color(Colors.Red.Medium).Underline());
|
||||
text.Span("underlined red text", TextStyle.Default.Color(Colors.Red.Medium).Underline());
|
||||
text.Span(".");
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -189,7 +190,7 @@ namespace QuestPDF.Examples
|
||||
text.EmptyLine();
|
||||
|
||||
text.Span("The new text element also supports injecting custom content between words: ");
|
||||
text.Element().PaddingBottom(-10).Height(16).Width(32).Image(Placeholders.Image);
|
||||
text.Element().PaddingBottom(-4).Height(16).Width(32).Image(Placeholders.Image);
|
||||
text.Span(".");
|
||||
|
||||
text.EmptyLine();
|
||||
@@ -246,7 +247,7 @@ namespace QuestPDF.Examples
|
||||
text.Span("and this is slightly bigger text.", TextStyle.Default.Size(16));
|
||||
|
||||
text.Span("The new text element also supports injecting custom content between words: ");
|
||||
text.Element().PaddingBottom(-10).Height(16).Width(32).Image(Placeholders.Image);
|
||||
text.Element().PaddingBottom(-4).Height(16).Width(32).Image(Placeholders.Image);
|
||||
text.Span(".");
|
||||
|
||||
text.EmptyLine();
|
||||
@@ -267,5 +268,80 @@ namespace QuestPDF.Examples
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MeasureIssueWhenSpaceAtLineEnd()
|
||||
{
|
||||
// issue 135
|
||||
|
||||
RenderingTest
|
||||
.Create()
|
||||
.ProduceImages()
|
||||
.ShowResults()
|
||||
.RenderDocument(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Margin(50);
|
||||
page.Background(Colors.White);
|
||||
|
||||
page.Size(PageSizes.A4);
|
||||
|
||||
page.Content().Text(
|
||||
"This is a specially crafted sentence with a specially chosen length for demonstration of the bug that occurs ;;;;;. ",
|
||||
TextStyle.Default.Size(11).BackgroundColor(Colors.Red.Lighten3));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EmptyText()
|
||||
{
|
||||
// issue 135
|
||||
|
||||
RenderingTest
|
||||
.Create()
|
||||
.ProduceImages()
|
||||
.ShowResults()
|
||||
.RenderDocument(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Margin(50);
|
||||
page.Background(Colors.White);
|
||||
|
||||
page.Size(PageSizes.A4);
|
||||
|
||||
page.Content().Text(
|
||||
" ",
|
||||
TextStyle.Default.Size(11).BackgroundColor(Colors.Red.Lighten3));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Whitespaces()
|
||||
{
|
||||
// issue 135
|
||||
|
||||
RenderingTest
|
||||
.Create()
|
||||
.ProduceImages()
|
||||
.ShowResults()
|
||||
.RenderDocument(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Margin(50);
|
||||
page.Background(Colors.White);
|
||||
|
||||
page.Size(PageSizes.A4);
|
||||
|
||||
page.Content().Text(
|
||||
" x ",
|
||||
TextStyle.Default.Size(11).BackgroundColor(Colors.Red.Lighten3));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,19 +17,30 @@ namespace QuestPDF.Drawing
|
||||
{
|
||||
internal static void GeneratePdf(Stream stream, IDocument document)
|
||||
{
|
||||
CheckIfStreamIsCompatible(stream);
|
||||
|
||||
var metadata = document.GetMetadata();
|
||||
var writeOnlyStream = new WriteOnlyStream(stream);
|
||||
var canvas = new PdfCanvas(writeOnlyStream, metadata);
|
||||
var canvas = new PdfCanvas(stream, metadata);
|
||||
RenderDocument(canvas, document);
|
||||
}
|
||||
|
||||
internal static void GenerateXps(Stream stream, IDocument document)
|
||||
{
|
||||
CheckIfStreamIsCompatible(stream);
|
||||
|
||||
var metadata = document.GetMetadata();
|
||||
var writeOnlyStream = new WriteOnlyStream(stream);
|
||||
var canvas = new XpsCanvas(writeOnlyStream, metadata);
|
||||
var canvas = new XpsCanvas(stream, metadata);
|
||||
RenderDocument(canvas, document);
|
||||
}
|
||||
|
||||
private static void CheckIfStreamIsCompatible(Stream stream)
|
||||
{
|
||||
if (!stream.CanWrite)
|
||||
throw new ArgumentException("The library requires a Stream object with the 'write' capability available (the CanWrite flag). Please consider using the MemoryStream class.");
|
||||
|
||||
if (!stream.CanSeek)
|
||||
throw new ArgumentException("The library requires a Stream object with the 'seek' capability available (the CanSeek flag). Please consider using the MemoryStream class.");
|
||||
}
|
||||
|
||||
internal static ICollection<byte[]> GenerateImages(IDocument document)
|
||||
{
|
||||
|
||||
@@ -6,16 +6,16 @@ namespace QuestPDF.Drawing
|
||||
{
|
||||
internal class SkiaDocumentCanvasBase : SkiaCanvasBase
|
||||
{
|
||||
private SKDocument Document { get; }
|
||||
private SKDocument? Document { get; }
|
||||
|
||||
public SkiaDocumentCanvasBase(SKDocument document)
|
||||
protected SkiaDocumentCanvasBase(SKDocument document)
|
||||
{
|
||||
Document = document;
|
||||
}
|
||||
|
||||
~SkiaDocumentCanvasBase()
|
||||
{
|
||||
Document.Dispose();
|
||||
Document?.Dispose();
|
||||
}
|
||||
|
||||
public override void BeginDocument()
|
||||
@@ -25,7 +25,7 @@ namespace QuestPDF.Drawing
|
||||
|
||||
public override void EndDocument()
|
||||
{
|
||||
Canvas.Dispose();
|
||||
Canvas?.Dispose();
|
||||
|
||||
Document.Close();
|
||||
Document.Dispose();
|
||||
|
||||
@@ -25,8 +25,8 @@ namespace QuestPDF.Elements
|
||||
{
|
||||
return Type switch
|
||||
{
|
||||
LineType.Vertical when availableSpace.Width >= Size => SpacePlan.FullRender(Size, 0),
|
||||
LineType.Horizontal when availableSpace.Height >= Size => SpacePlan.FullRender(0, Size),
|
||||
LineType.Vertical when availableSpace.Width + Infrastructure.Size.Epsilon >= Size => SpacePlan.FullRender(Size, 0),
|
||||
LineType.Horizontal when availableSpace.Height + Infrastructure.Size.Epsilon >= Size => SpacePlan.FullRender(0, Size),
|
||||
_ => SpacePlan.Wrap()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,8 +17,10 @@ namespace QuestPDF.Elements
|
||||
if (perfectScale == null)
|
||||
return SpacePlan.Wrap();
|
||||
|
||||
var targetSpace = ScaleSize(availableSpace, perfectScale.Value);
|
||||
return SpacePlan.FullRender(targetSpace);
|
||||
var scaledSpace = ScaleSize(availableSpace, 1 / perfectScale.Value);
|
||||
var childSizeInScale = Child.Measure(scaledSpace);
|
||||
var childSizeInOriginalScale = ScaleSize(childSizeInScale, perfectScale.Value);
|
||||
return SpacePlan.FullRender(childSizeInOriginalScale);
|
||||
}
|
||||
|
||||
internal override void Draw(Size availableSpace)
|
||||
|
||||
@@ -9,6 +9,6 @@ namespace QuestPDF.Elements.Text.Calculation
|
||||
|
||||
public int StartIndex { get; set; }
|
||||
public float AvailableWidth { get; set; }
|
||||
public bool IsFirstLineElement { get; set; }
|
||||
public bool IsFirstElementInLine { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,6 @@ namespace QuestPDF.Elements.Text.Calculation
|
||||
public int NextIndex { get; set; }
|
||||
public int TotalIndex { get; set; }
|
||||
|
||||
public bool IsLast => EndIndex == TotalIndex;
|
||||
public bool IsLast => NextIndex == EndIndex;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using QuestPDF.Drawing;
|
||||
using QuestPDF.Elements.Text.Calculation;
|
||||
using QuestPDF.Infrastructure;
|
||||
@@ -11,13 +12,12 @@ namespace QuestPDF.Elements.Text.Items
|
||||
public string Text { get; set; }
|
||||
public TextStyle Style { get; set; } = new TextStyle();
|
||||
|
||||
private Dictionary<(int startIndex, float availableWidth), TextMeasurementResult?> MeasureCache =
|
||||
new Dictionary<(int startIndex, float availableWidth), TextMeasurementResult?>();
|
||||
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);
|
||||
|
||||
@@ -33,13 +33,15 @@ namespace QuestPDF.Elements.Text.Items
|
||||
|
||||
var startIndex = request.StartIndex;
|
||||
|
||||
if (request.IsFirstLineElement)
|
||||
// if the element is the first one within the line,
|
||||
// ignore leading spaces
|
||||
if (request.IsFirstElementInLine)
|
||||
{
|
||||
while (startIndex + 1 < Text.Length && Text[startIndex] == space)
|
||||
while (startIndex < Text.Length && Text[startIndex] == space)
|
||||
startIndex++;
|
||||
}
|
||||
|
||||
if (Text.Length == 0)
|
||||
|
||||
if (Text.Length == 0 || startIndex == Text.Length)
|
||||
{
|
||||
return new TextMeasurementResult
|
||||
{
|
||||
@@ -52,38 +54,28 @@ namespace QuestPDF.Elements.Text.Items
|
||||
}
|
||||
|
||||
// start breaking text from requested position
|
||||
var text = Text.Substring(startIndex);
|
||||
var text = Text.AsSpan().Slice(startIndex);
|
||||
|
||||
var textLength = (int)paint.BreakText(text, request.AvailableWidth);
|
||||
var textLength = (int)paint.BreakText(text, request.AvailableWidth + Size.Epsilon);
|
||||
|
||||
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;
|
||||
var wrappedTextLength = WrapText(text, textLength, request.IsFirstElementInLine);
|
||||
|
||||
if (lastSpaceIndex <= 0)
|
||||
{
|
||||
if (!request.IsFirstLineElement)
|
||||
return null;
|
||||
}
|
||||
else
|
||||
{
|
||||
textLength = lastSpaceIndex + 1;
|
||||
}
|
||||
}
|
||||
if (wrappedTextLength == null)
|
||||
return null;
|
||||
|
||||
text = text.Substring(0, textLength);
|
||||
textLength = wrappedTextLength.Value;
|
||||
|
||||
text = text.Slice(0, textLength);
|
||||
|
||||
var endIndex = startIndex + textLength;
|
||||
var nextIndex = endIndex;
|
||||
|
||||
while (nextIndex + 1 < Text.Length && Text[nextIndex] == space)
|
||||
// when breaking text, omit spaces at the end of the line
|
||||
while (nextIndex < Text.Length && Text[nextIndex] == space)
|
||||
nextIndex++;
|
||||
|
||||
// measure final text
|
||||
@@ -103,6 +95,31 @@ namespace QuestPDF.Elements.Text.Items
|
||||
NextIndex = nextIndex,
|
||||
TotalIndex = Text.Length
|
||||
};
|
||||
|
||||
static int? WrapText(ReadOnlySpan<char> text, int textLength, bool isFirstElementInLine)
|
||||
{
|
||||
// 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;
|
||||
|
||||
// current line ends at word, next character is space, perfect place to wrap
|
||||
if (text[textLength - 1] != space && text[textLength] == space)
|
||||
return textLength;
|
||||
|
||||
// find last space within the available text to wrap
|
||||
var lastSpaceIndex = text.Slice(0, textLength).LastIndexOf(space);
|
||||
|
||||
// text contains space that can be used to wrap
|
||||
if (lastSpaceIndex > 0)
|
||||
return lastSpaceIndex;
|
||||
|
||||
// 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 : null;
|
||||
}
|
||||
}
|
||||
|
||||
public virtual void Draw(TextDrawingRequest request)
|
||||
|
||||
@@ -170,7 +170,7 @@ namespace QuestPDF.Elements.Text
|
||||
|
||||
StartIndex = currentItemIndex,
|
||||
AvailableWidth = availableWidth - currentWidth,
|
||||
IsFirstLineElement = !currentLineElements.Any()
|
||||
IsFirstElementInLine = !currentLineElements.Any()
|
||||
};
|
||||
|
||||
var measurementResponse = currentElement.Measure(measurementRequest);
|
||||
|
||||
@@ -29,6 +29,8 @@ namespace QuestPDF.Fluent
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region XPS
|
||||
|
||||
public static byte[] GenerateXps(this IDocument document)
|
||||
{
|
||||
@@ -48,8 +50,6 @@ namespace QuestPDF.Fluent
|
||||
DocumentGenerator.GenerateXps(stream, document);
|
||||
}
|
||||
|
||||
#region XPS
|
||||
|
||||
#endregion
|
||||
|
||||
#region Images
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace QuestPDF.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// SkiaSharp calls the Position property when generating target document file.
|
||||
/// If the output stream does not support the Position property, the NullReferenceException is thrown.
|
||||
/// This wrapper fixes this issue by providing cached Position value (always the end of the stream / current length).
|
||||
/// Example stream affected: HttpContext.Response.Body
|
||||
/// </summary>
|
||||
internal class WriteOnlyStream : Stream
|
||||
{
|
||||
private readonly Stream InnerStream;
|
||||
private long StreamLength { get; set; }
|
||||
|
||||
public WriteOnlyStream(Stream stream)
|
||||
{
|
||||
if (!stream.CanWrite)
|
||||
throw new NotSupportedException("Stream cannot be written");
|
||||
|
||||
InnerStream = stream;
|
||||
}
|
||||
|
||||
public override bool CanRead => false;
|
||||
|
||||
public override bool CanSeek => false;
|
||||
|
||||
public override bool CanWrite => true;
|
||||
|
||||
public override long Length => StreamLength;
|
||||
|
||||
public override long Position
|
||||
{
|
||||
get => StreamLength;
|
||||
set => throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public override void Flush() => InnerStream.Flush();
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public override void SetLength(long value)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public override void Write(byte[] buffer, int offset, int count)
|
||||
{
|
||||
InnerStream.Write(buffer, offset, count);
|
||||
StreamLength += count;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
<Authors>MarcinZiabek</Authors>
|
||||
<Company>CodeFlint</Company>
|
||||
<PackageId>QuestPDF</PackageId>
|
||||
<Version>2022.2.3</Version>
|
||||
<Version>2022.2.7</Version>
|
||||
<PackageDescription>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.</PackageDescription>
|
||||
<PackageReleaseNotes>$([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/Resources/ReleaseNotes.txt"))</PackageReleaseNotes>
|
||||
<LangVersion>9</LangVersion>
|
||||
@@ -18,7 +18,7 @@
|
||||
<PackageTags>pdf report file export generate generation tool create creation render portable document format quest html library converter open source free standard core</PackageTags>
|
||||
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||
<Nullable>enable</Nullable>
|
||||
<TargetFrameworks>net462;netstandard2.0;netcoreapp2.0;netcoreapp3.0</TargetFrameworks>
|
||||
<TargetFrameworks>net462;netstandard2.0;netcoreapp2.0;netcoreapp3.0;net6.0</TargetFrameworks>
|
||||
<IncludeSymbols>true</IncludeSymbols>
|
||||
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
<a href="https://www.questpdf.com/">
|
||||
<img src="https://github.com/QuestPDF/example-invoice/raw/main/images/logo.svg" width="400px">
|
||||
<a href="https://www.questpdf.com/" target="_blank">
|
||||
<img src="https://github.com/QuestPDF/example-invoice/raw/main/images/logo.svg" width="300">
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
[](https://github.com/QuestPDF/QuestPDF/stargazers)
|
||||
[](https://www.nuget.org/packages/QuestPDF/)
|
||||
[](https://www.nuget.org/packages/QuestPDF/)
|
||||
[](https://github.com/QuestPDF/QuestPDF/blob/main/LICENSE)
|
||||
[](https://github.com/sponsors/QuestPDF)
|
||||
|
||||
QuestPDF presents a new approach to PDF document generation. Unlike other libraries, it does not rely on the HTML-to-PDF conversion which in many cases is not reliable. Instead, it implements its own layouting engine that is optimized to cover all paging-related requirements. Then, everything is rendered using the SkiaSharp library (a Skia port for .NET, used in Chrome, Android, MAUI, etc.).
|
||||
|
||||
I have designed this layouting engine with full paging support in mind. The document consists of many simple elements (e.g. border, background, image, text, padding, table, grid etc.) that are composed together to create more complex structures. This way, as a developer, you can understand the behaviour of every element and use them with full confidence. Additionally, the document and all its elements support paging functionality. For example, an element can be moved to the next page (if there is not enough space) or even be split between pages like table's rows.
|
||||
|
||||
Reference in New Issue
Block a user