diff --git a/.gitignore b/.gitignore index 3c4efe20..a35345fb 100644 --- a/.gitignore +++ b/.gitignore @@ -258,4 +258,7 @@ paket-files/ # Python Tools for Visual Studio (PTVS) __pycache__/ -*.pyc \ No newline at end of file +*.pyc + +# OSX +.DS_Store diff --git a/PdfSharpCore.Test/Assets/Drawing/Layout/DrawMultiLineStringWithOverflow_1.png b/PdfSharpCore.Test/Assets/Drawing/Layout/DrawMultiLineStringWithOverflow_1.png new file mode 100644 index 00000000..e3ddee48 Binary files /dev/null and b/PdfSharpCore.Test/Assets/Drawing/Layout/DrawMultiLineStringWithOverflow_1.png differ diff --git a/PdfSharpCore.Test/Assets/Drawing/Layout/DrawMultiLineStringsWithAlignment_1.png b/PdfSharpCore.Test/Assets/Drawing/Layout/DrawMultiLineStringsWithAlignment_1.png new file mode 100644 index 00000000..00b316f8 Binary files /dev/null and b/PdfSharpCore.Test/Assets/Drawing/Layout/DrawMultiLineStringsWithAlignment_1.png differ diff --git a/PdfSharpCore.Test/Assets/Drawing/Layout/DrawMultiLineStringsWithLineHeight_1.png b/PdfSharpCore.Test/Assets/Drawing/Layout/DrawMultiLineStringsWithLineHeight_1.png new file mode 100644 index 00000000..abdf51fb Binary files /dev/null and b/PdfSharpCore.Test/Assets/Drawing/Layout/DrawMultiLineStringsWithLineHeight_1.png differ diff --git a/PdfSharpCore.Test/Assets/Drawing/Layout/DrawMultilineStringWithTruncate_1.png b/PdfSharpCore.Test/Assets/Drawing/Layout/DrawMultilineStringWithTruncate_1.png new file mode 100644 index 00000000..d3ad5d59 Binary files /dev/null and b/PdfSharpCore.Test/Assets/Drawing/Layout/DrawMultilineStringWithTruncate_1.png differ diff --git a/PdfSharpCore.Test/Assets/Drawing/Layout/DrawSingleLineString_1.png b/PdfSharpCore.Test/Assets/Drawing/Layout/DrawSingleLineString_1.png new file mode 100644 index 00000000..f0064184 Binary files /dev/null and b/PdfSharpCore.Test/Assets/Drawing/Layout/DrawSingleLineString_1.png differ diff --git a/PdfSharpCore.Test/Drawing/Layout/XTextFormatterTest.cs b/PdfSharpCore.Test/Drawing/Layout/XTextFormatterTest.cs new file mode 100644 index 00000000..9fa8e0ab --- /dev/null +++ b/PdfSharpCore.Test/Drawing/Layout/XTextFormatterTest.cs @@ -0,0 +1,127 @@ +using System.IO; +using FluentAssertions; +using ImageMagick; +using PdfSharpCore.Drawing; +using PdfSharpCore.Drawing.Layout; +using PdfSharpCore.Drawing.Layout.enums; +using PdfSharpCore.Pdf; +using PdfSharpCore.Test.Helpers; +using Xunit; + +namespace PdfSharpCore.Test.Drawing.Layout +{ + public class XTextFormatterTest + { + private static readonly string _outDir = "TestResults/XTextFormatterTest"; + private static readonly string _expectedImagesPath = Path.Combine("Drawing", "Layout"); + + private PdfDocument _document; + private XGraphics _renderer; + private XTextFormatter _textFormatter; + + // Run before each test + public XTextFormatterTest() + { + _document = new PdfDocument(); + var page = _document.AddPage(); + page.Size = PageSize.A6; // 295 x 417 pts + _renderer = XGraphics.FromPdfPage(page); + _textFormatter = new XTextFormatter(_renderer); + } + + [Fact] + public void DrawSingleLineString() + { + var layout = new XRect(12, 12, 200, 50); + _textFormatter.DrawString("This is a simple single line test", new XFont("Arial", 12), XBrushes.Black, layout); + + var diffResult = DiffPage(_document, "DrawSingleLineString", 1); + + diffResult.DiffValue.Should().Be(0); + } + + [Fact] + public void DrawMultilineStringWithTruncate() + { + var layout = new XRect(12, 12, 200, 40); + _renderer.DrawRectangle(XBrushes.LightGray, layout); + _textFormatter.DrawString("This is text\nspanning 3 lines\nbut only space for 2", new XFont("Arial", 12), XBrushes.Black, layout); + + var diffResult = DiffPage(_document, "DrawMultilineStringWithTruncate", 1); + + diffResult.DiffValue.Should().Be(0); + } + + [Fact] + public void DrawMultiLineStringWithOverflow() + { + var layout = new XRect(12, 12, 200, 40); + _renderer.DrawRectangle(XBrushes.LightGray, layout); + _textFormatter.AllowVerticalOverflow = true; + _textFormatter.DrawString("This is text\nspanning 3 lines\nand overflow shows all three", new XFont("Arial", 12), XBrushes.Black, layout); + + var diffResult = DiffPage(_document, "DrawMultiLineStringWithOverflow", 1); + + diffResult.DiffValue.Should().Be(0); + } + + [Fact] + public void DrawMultiLineStringsWithAlignment() + { + var layout1 = new XRect(12, 12, 200, 80); + _renderer.DrawRectangle(XBrushes.LightGray, layout1); + _textFormatter.DrawString("This is text\naligned to the top-left", new XFont("Arial", 12), XBrushes.Black, layout1); + + var layout2 = new XRect(12, 100, 200, 80); + _renderer.DrawRectangle(XBrushes.LightGray, layout2); + _textFormatter.SetAlignment(new TextFormatAlignment { Horizontal = XParagraphAlignment.Center, Vertical = XVerticalAlignment.Middle}); + _textFormatter.DrawString("This is text\naligned to the middle-center", new XFont("Arial", 12), XBrushes.Black, layout2); + + var layout3 = new XRect(12, 200, 200, 80); + _renderer.DrawRectangle(XBrushes.LightGray, layout3); + _textFormatter.SetAlignment(new TextFormatAlignment { Horizontal = XParagraphAlignment.Right, Vertical = XVerticalAlignment.Bottom}); + _textFormatter.DrawString("This is text\naligned to the bottom-right", new XFont("Arial", 12), XBrushes.Black, layout3); + + var diffResult = DiffPage(_document, "DrawMultiLineStringsWithAlignment", 1); + + diffResult.DiffValue.Should().Be(0); + } + + [Fact] + public void DrawMultiLineStringsWithLineHeight() + { + var font = new XFont("Arial", 12); + + var layout1 = new XRect(10, 10, 200, 80); + _renderer.DrawRectangle(XBrushes.LightGray, layout1); + _textFormatter.DrawString("This is text\naligned to the top-left\nand a custom line height", font, XBrushes.Black, layout1, 16); + + var layout2 = new XRect(10, 110, 200, 80); + _renderer.DrawRectangle(XBrushes.LightGray, layout2); + _textFormatter.SetAlignment(new TextFormatAlignment { Horizontal = XParagraphAlignment.Center, Vertical = XVerticalAlignment.Middle}); + _textFormatter.DrawString("This is text\naligned to the middle-center\nand a custom line height", font, XBrushes.Black, layout2, 16); + + var layout3 = new XRect(10, 210, 200, 80); + _renderer.DrawRectangle(XBrushes.LightGray, layout3); + _textFormatter.SetAlignment(new TextFormatAlignment { Horizontal = XParagraphAlignment.Right, Vertical = XVerticalAlignment.Bottom}); + _textFormatter.DrawString("This is text\naligned to the bottom-right\nand a custom line height", font, XBrushes.Black, layout3, 16); + + var layout4 = new XRect(10, 310, 200, 80); + _renderer.DrawRectangle(XBrushes.LightGray, layout4); + _textFormatter.SetAlignment(new TextFormatAlignment { Horizontal = XParagraphAlignment.Center, Vertical = XVerticalAlignment.Middle}); + _textFormatter.DrawString("This is text\nwith a very small\nline height", font, XBrushes.Black, layout4, 6); + + var diffResult = DiffPage(_document, "DrawMultiLineStringsWithLineHeight", 1); + + diffResult.DiffValue.Should().Be(0); + } + + private static DiffOutput DiffPage(PdfDocument document, string filePrefix, int pageNum) + { + var rasterized = PdfHelper.Rasterize(document); + var rasterizedFiles = PdfHelper.WriteImageCollection(rasterized.ImageCollection, _outDir, filePrefix); + var expectedImagePath = PathHelper.GetInstance().GetAssetPath(_expectedImagesPath, $"{filePrefix}_{pageNum}.png"); + return PdfHelper.Diff(rasterizedFiles[pageNum-1], expectedImagePath, _outDir, filePrefix); + } + } +} \ No newline at end of file diff --git a/PdfSharpCore.Test/Helpers/PathHelper.cs b/PdfSharpCore.Test/Helpers/PathHelper.cs index d16f9a08..e77ca865 100644 --- a/PdfSharpCore.Test/Helpers/PathHelper.cs +++ b/PdfSharpCore.Test/Helpers/PathHelper.cs @@ -1,4 +1,5 @@ -using System.IO; +using System.Collections.Generic; +using System.IO; using System.Reflection; namespace PdfSharpCore.Test.Helpers @@ -10,9 +11,11 @@ public PathHelper() RootDir = Path.GetDirectoryName(GetType().GetTypeInfo().Assembly.Location); } - public string GetAssetPath(string name) + public string GetAssetPath(params string[] names) { - return Path.Combine(RootDir, "Assets", name); + var segments = new List { RootDir, "Assets" }; + segments.AddRange(names); + return Path.Combine(segments.ToArray()); } public string RootDir { get; } @@ -24,4 +27,4 @@ public static PathHelper GetInstance() private static PathHelper _instance; } -} \ No newline at end of file +} diff --git a/PdfSharpCore.Test/Helpers/PdfHelper.cs b/PdfSharpCore.Test/Helpers/PdfHelper.cs new file mode 100644 index 00000000..e4c1781f --- /dev/null +++ b/PdfSharpCore.Test/Helpers/PdfHelper.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.IO; +using ImageMagick; +using PdfSharpCore.Pdf; + +namespace PdfSharpCore.Test.Helpers +{ + public class PdfHelper + { + private static readonly string _rootPath = PathHelper.GetInstance().RootDir; + + /// + /// Rasterize all pages within a PDF to PNG images + /// + /// + /// + public static RasterizeOutput Rasterize(PdfDocument document) + { + var readerSettings = new MagickReadSettings + { + Density = new Density(300, 300), + BackgroundColor = MagickColors.White + }; + var images = new MagickImageCollection(); + + // Add all pages to the collection + using var ms = new MemoryStream(); + document.Save(ms); + + try + { + images.Read(ms, readerSettings); + } + catch (MagickDelegateErrorException ex) + { + throw new Exception("Ghostscript is not installed or is an incompatible version, unable to rasterize PDF", ex); + } + + // Remove transparency to guarantee a standard white background + foreach (var img in images) + { + img.Alpha(AlphaOption.Deactivate); + img.BackgroundColor = MagickColors.White; + } + + return new RasterizeOutput + { + ImageCollection = images, + }; + } + + public static List WriteImageCollection(MagickImageCollection images, string outDir, string filePrefix) + { + var outPaths = new List(); + for (var pageNum = 0; pageNum < images.Count; pageNum++) + { + var outPath = GetOutFilePath(outDir, $"{filePrefix}_{pageNum+1}.png"); + images[pageNum].Write(outPath); + outPaths.Add(outPath); + } + + return outPaths; + } + + public static string WriteImage(IMagickImage image, string outDir, string fileNameWithoutExtension) + { + var outPath = GetOutFilePath(outDir, $"{fileNameWithoutExtension}.png"); + image.Write(outPath); + return outPath; + } + + // Note: For diff to function properly, it requires the underlying image to be in the proper format + // For instance, actual and expected must both be sourced from .png files + public static DiffOutput Diff(string actualImagePath, string expectedImagePath, string outputPath = null, string filePrefix = null, int fuzzPct = 4) + { + var diffImg = new MagickImage(); + var actual = new MagickImage(actualImagePath); + var expected = new MagickImage(expectedImagePath); + + // Allow for subtle differences due to cross-platform rendering of the PDF fonts + actual.ColorFuzz = new Percentage(fuzzPct); + var diffVal = actual.Compare(expected, ErrorMetric.Absolute, diffImg); + + if (diffVal > 0 && outputPath != null && filePrefix != null) + { + WriteImage(diffImg, outputPath, $"{filePrefix}_diff"); + } + + return new DiffOutput + { + DiffValue = diffVal, + DiffImage = diffImg + }; + } + + private static string GetOutFilePath(string outDir, string name) + { + var dir = Path.Combine(_rootPath, outDir); + Directory.CreateDirectory(dir); + return Path.Combine(dir, name); + } + } + + public class RasterizeOutput + { + public List OutputPaths; + public MagickImageCollection ImageCollection; + } + + public class DiffOutput + { + public IMagickImage DiffImage; + public double DiffValue; + } +} diff --git a/PdfSharpCore.Test/PdfSharpCore.Test.csproj b/PdfSharpCore.Test/PdfSharpCore.Test.csproj index 5db969bc..844f5748 100644 --- a/PdfSharpCore.Test/PdfSharpCore.Test.csproj +++ b/PdfSharpCore.Test/PdfSharpCore.Test.csproj @@ -16,6 +16,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + @@ -31,31 +32,10 @@ - + PreserveNewest - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - + PreserveNewest diff --git a/PdfSharpCore/Drawing.Layout/XTextFormatter.cs b/PdfSharpCore/Drawing.Layout/XTextFormatter.cs index 6e699209..b4e1e5bf 100644 --- a/PdfSharpCore/Drawing.Layout/XTextFormatter.cs +++ b/PdfSharpCore/Drawing.Layout/XTextFormatter.cs @@ -29,6 +29,7 @@ using System; using System.Collections.Generic; +using System.Linq; using PdfSharpCore.Drawing.Layout.enums; using PdfSharpCore.Pdf.IO; @@ -89,6 +90,10 @@ public XFont Font double _cyAscent; double _cyDescent; double _spaceWidth; + double _lineHeight; + + // Bounding box of the formatted text after layout + private XRect _textLayout; /// /// Gets or sets the bounding box of the layout. @@ -101,15 +106,31 @@ public XRect LayoutRectangle XRect _layoutRectangle; /// - /// Gets or sets the alignment of the text. + /// When true, ignore the height of text areas when rendering multiline strings + /// + public bool AllowVerticalOverflow { get; set; } = false; + + /// + /// Gets or sets the horizontal alignment of the text. + /// + public XParagraphAlignment Alignment { get; set; } = XParagraphAlignment.Left; + + /// + /// Gets or sets the vertical alignment of the text. /// - public XParagraphAlignment Alignment + public XVerticalAlignment VerticalAlignment { get; set; } = XVerticalAlignment.Top; + + /// + /// Set vertical and horizontal alignment + /// + /// + public void SetAlignment(TextFormatAlignment alignments) { - get { return _alignment; } - set { _alignment = value; } + Alignment = alignments.Horizontal; + VerticalAlignment = alignments.Vertical; } - XParagraphAlignment _alignment = XParagraphAlignment.Left; - + + /// /// Draws the text. /// @@ -117,9 +138,9 @@ public XParagraphAlignment Alignment /// The font. /// The text brush. /// The layout rectangle. - public void DrawString(string text, XFont font, XBrush brush, XRect layoutRectangle) + public void DrawString(string text, XFont font, XBrush brush, XRect layoutRectangle, XUnit? lineHeight = null) { - DrawString(text, font, brush, layoutRectangle, XStringFormats.TopLeft); + DrawString(text, font, brush, layoutRectangle, XStringFormats.TopLeft, lineHeight); } /// @@ -130,7 +151,7 @@ public void DrawString(string text, XFont font, XBrush brush, XRect layoutRectan /// The text brush. /// The layout rectangle. /// The format. Must be XStringFormat.TopLeft - public void DrawString(string text, XFont font, XBrush brush, XRect layoutRectangle, XStringFormat format) + public void DrawString(string text, XFont font, XBrush brush, XRect layoutRectangle, XStringFormat format, XUnit? lineHeight = null) { if (text == null) throw new ArgumentNullException("text"); @@ -144,6 +165,8 @@ public void DrawString(string text, XFont font, XBrush brush, XRect layoutRectan Text = text; Font = font; LayoutRectangle = layoutRectangle; + + _lineHeight = lineHeight?.Point ?? _lineSpace; if (text.Length == 0) return; @@ -154,6 +177,16 @@ public void DrawString(string text, XFont font, XBrush brush, XRect layoutRectan double dx = layoutRectangle.Location.X; double dy = layoutRectangle.Location.Y + _cyAscent; + + if (VerticalAlignment == XVerticalAlignment.Middle) + { + dy += layoutRectangle.Height / 2 - _layoutRectangle.Height / 2 - _cyDescent; + } + else if (VerticalAlignment == XVerticalAlignment.Bottom) + { + dy = layoutRectangle.Location.Y + layoutRectangle.Height - _layoutRectangle.Height + _lineHeight - _cyDescent; + } + int count = _blocks.Count; for (int idx = 0; idx < count; idx++) { @@ -238,11 +271,11 @@ void CreateLayout() { if (Alignment == XParagraphAlignment.Justify) _blocks[firstIndex].Alignment = XParagraphAlignment.Left; - AlignLine(firstIndex, idx - 1, rectWidth); + HorizontalAlignLine(firstIndex, idx - 1, rectWidth); firstIndex = idx + 1; x = 0; - y += _lineSpace; - if (y > rectHeight) + y += _lineHeight; + if (!AllowVerticalOverflow && y > rectHeight) { block.Stop = true; break; @@ -258,10 +291,12 @@ void CreateLayout() } else { - AlignLine(firstIndex, idx - 1, rectWidth); + HorizontalAlignLine(firstIndex, idx - 1, rectWidth); + + // Begin implicit line break firstIndex = idx; - y += _lineSpace; - if (y > rectHeight) + y += _lineHeight; + if (!AllowVerticalOverflow && y > rectHeight) { block.Stop = true; break; @@ -272,16 +307,28 @@ void CreateLayout() } } if (firstIndex < count && Alignment != XParagraphAlignment.Justify) - AlignLine(firstIndex, count - 1, rectWidth); + HorizontalAlignLine(firstIndex, count - 1, rectWidth); + + var minY = _blocks.Min(b => b.Location.Y); + var maxY = _blocks.Max(b => b.Location.Y + _lineHeight); + var minX = _blocks.Min(b => b.Location.X); + var maxX = _blocks.Max(b => b.Location.X + b.Width); + _layoutRectangle = new XRect + { + X = minX, + Y = minY, + Height = maxY - minY, + Width = maxX - minX + }; } /// /// Align center, right, or justify. /// - void AlignLine(int firstIndex, int lastIndex, double layoutWidth) + void HorizontalAlignLine(int firstIndex, int lastIndex, double layoutWidth) { XParagraphAlignment blockAlignment = _blocks[firstIndex].Alignment; - if (_alignment == XParagraphAlignment.Left || blockAlignment == XParagraphAlignment.Left) + if (Alignment == XParagraphAlignment.Left || blockAlignment == XParagraphAlignment.Left) return; int count = lastIndex - firstIndex + 1; @@ -294,9 +341,9 @@ void AlignLine(int firstIndex, int lastIndex, double layoutWidth) double dx = Math.Max(layoutWidth - totalWidth, 0); //Debug.Assert(dx >= 0); - if (_alignment != XParagraphAlignment.Justify) + if (Alignment != XParagraphAlignment.Justify) { - if (_alignment == XParagraphAlignment.Center) + if (Alignment == XParagraphAlignment.Center) dx /= 2; for (int idx = firstIndex; idx <= lastIndex; idx++) { @@ -319,7 +366,6 @@ void AlignLine(int firstIndex, int lastIndex, double layoutWidth) // TODO: // - more XStringFormat variations - // - calculate bounding box // - left and right indent // - first line indent // - margins and paddings @@ -329,9 +375,14 @@ void AlignLine(int firstIndex, int lastIndex, double layoutWidth) // - hyphens, soft hyphens, hyphenation // - kerning // - change font, size, text color etc. - // - line spacing // - underline and strike-out variation // - super- and sub-script // - ... } + + public class TextFormatAlignment + { + public XParagraphAlignment Horizontal { get; set; } = XParagraphAlignment.Left; + public XVerticalAlignment Vertical { get; set; } = XVerticalAlignment.Top; + } } diff --git a/PdfSharpCore/Drawing.Layout/enums/XVerticalAlignment.cs b/PdfSharpCore/Drawing.Layout/enums/XVerticalAlignment.cs new file mode 100644 index 00000000..c204864b --- /dev/null +++ b/PdfSharpCore/Drawing.Layout/enums/XVerticalAlignment.cs @@ -0,0 +1,9 @@ +namespace PdfSharpCore.Drawing.Layout.enums +{ + public enum XVerticalAlignment + { + Top, + Middle, + Bottom, + } +} \ No newline at end of file