diff --git a/Source/QuestPDF.Examples/ImageExamples.cs b/Source/QuestPDF.Examples/ImageExamples.cs index 27c97ca..bf9043e 100644 --- a/Source/QuestPDF.Examples/ImageExamples.cs +++ b/Source/QuestPDF.Examples/ImageExamples.cs @@ -66,28 +66,24 @@ namespace QuestPDF.Examples }); } + + [Test] - public void ReusingTheSameImageFileShouldBePossible() + public void ImageResolutionScaling() { - var image = Image.FromBinaryData(Placeholders.Image(300, 100)).DisposeAfterDocumentGeneration(); - - RenderingTest - .Create() - .ProducePdf() - .PageSize(PageSizes.A4) - .ShowResults() - .Render(container => + var image = Image.FromFile("large-image.jpg"); + + Document + .Create(document => { - container - .Padding(20) - .Column(column => - { - column.Spacing(20); - - foreach (var i in Enumerable.Range(0, 1000)) - column.Item().Image(image); - }); - }); + document.Page(page => + { + page.Size(210, 210); + page.Margin(50); + page.Content().Image(image); + }); + }) + .GeneratePdf($"test.pdf"); } } } \ No newline at end of file diff --git a/Source/QuestPDF.Examples/QuestPDF.Examples.csproj b/Source/QuestPDF.Examples/QuestPDF.Examples.csproj index a053de7..647b4d1 100644 --- a/Source/QuestPDF.Examples/QuestPDF.Examples.csproj +++ b/Source/QuestPDF.Examples/QuestPDF.Examples.csproj @@ -1,7 +1,7 @@ - netcoreapp3.1 + net6.0 false @@ -32,6 +32,12 @@ PreserveNewest + + PreserveNewest + + + PreserveNewest + diff --git a/Source/QuestPDF.Examples/large-image.jpg b/Source/QuestPDF.Examples/large-image.jpg new file mode 100644 index 0000000..96b8ace Binary files /dev/null and b/Source/QuestPDF.Examples/large-image.jpg differ diff --git a/Source/QuestPDF.UnitTests/ImageTests.cs b/Source/QuestPDF.UnitTests/ImageTests.cs index b4db73b..9a7dc1b 100644 --- a/Source/QuestPDF.UnitTests/ImageTests.cs +++ b/Source/QuestPDF.UnitTests/ImageTests.cs @@ -1,7 +1,12 @@ -using NUnit.Framework; +using System; +using System.Linq; +using System.Net.Mime; +using FluentAssertions; +using NUnit.Framework; using QuestPDF.Drawing; using QuestPDF.Elements; using QuestPDF.Fluent; +using QuestPDF.Helpers; using QuestPDF.Infrastructure; using QuestPDF.UnitTests.TestEngine; using SkiaSharp; @@ -59,5 +64,53 @@ namespace QuestPDF.UnitTests using var surface = SKSurface.Create(imageInfo); return surface.Snapshot(); } + + [Test] + public void UsingSharedImageShouldNotDrasticallyIncreaseDocumentSize() + { + var placeholderImage = Placeholders.Image(1000, 200); + + var documentWithSingleImageSize = GetDocumentSize(container => + { + container.Image(placeholderImage); + }); + + var documentWithMultipleImagesSize = GetDocumentSize(container => + { + container.Column(column => + { + foreach (var i in Enumerable.Range(0, 100)) + column.Item().Image(placeholderImage); + }); + }); + + var documentWithSingleImageUsedMultipleTimesSize = GetDocumentSize(container => + { + container.Column(column => + { + var sharedImage = Image.FromBinaryData(placeholderImage).DisposeAfterDocumentGeneration(); + + foreach (var i in Enumerable.Range(0, 100)) + column.Item().Image(sharedImage); + }); + }); + + (documentWithMultipleImagesSize / (float)documentWithSingleImageSize).Should().BeInRange(90, 100); + (documentWithSingleImageUsedMultipleTimesSize / (float)documentWithSingleImageSize).Should().BeInRange(1f, 1.5f); + } + + private static int GetDocumentSize(Action container) + { + return Document + .Create(document => + { + document.Page(page => + { + page.Content().Element(container); + }); + }) + .GeneratePdf() + .Length; + } } } \ No newline at end of file diff --git a/Source/QuestPDF.UnitTests/QuestPDF.UnitTests.csproj b/Source/QuestPDF.UnitTests/QuestPDF.UnitTests.csproj index e062de1..7cd338f 100644 --- a/Source/QuestPDF.UnitTests/QuestPDF.UnitTests.csproj +++ b/Source/QuestPDF.UnitTests/QuestPDF.UnitTests.csproj @@ -1,7 +1,7 @@ - netcoreapp3.1 + net6.0 false diff --git a/Source/QuestPDF/Drawing/DocumentGenerator.cs b/Source/QuestPDF/Drawing/DocumentGenerator.cs index d982e3b..2727b15 100644 --- a/Source/QuestPDF/Drawing/DocumentGenerator.cs +++ b/Source/QuestPDF/Drawing/DocumentGenerator.cs @@ -10,6 +10,7 @@ using QuestPDF.Elements.Text.Items; using QuestPDF.Fluent; using QuestPDF.Helpers; using QuestPDF.Infrastructure; +using Image = QuestPDF.Elements.Image; namespace QuestPDF.Drawing { @@ -21,7 +22,7 @@ namespace QuestPDF.Drawing var metadata = document.GetMetadata(); var canvas = new PdfCanvas(stream, metadata); - RenderDocument(canvas, document); + RenderDocument(canvas, document, metadata); } internal static void GenerateXps(Stream stream, IDocument document) @@ -30,7 +31,7 @@ namespace QuestPDF.Drawing var metadata = document.GetMetadata(); var canvas = new XpsCanvas(stream, metadata); - RenderDocument(canvas, document); + RenderDocument(canvas, document, metadata); } private static void CheckIfStreamIsCompatible(Stream stream) @@ -46,24 +47,26 @@ namespace QuestPDF.Drawing { var metadata = document.GetMetadata(); var canvas = new ImageCanvas(metadata); - RenderDocument(canvas, document); + RenderDocument(canvas, document, metadata); return canvas.Images; } internal static ICollection GeneratePreviewerPictures(IDocument document) { + var metadata = document.GetMetadata(); var canvas = new SkiaPictureCanvas(); - RenderDocument(canvas, document); + RenderDocument(canvas, document, metadata); return canvas.Pictures; } - internal static void RenderDocument(TCanvas canvas, IDocument document) + internal static void RenderDocument(TCanvas canvas, IDocument document, DocumentMetadata metadata) where TCanvas : ICanvas, IRenderingCanvas { var container = new DocumentContainer(); document.Compose(container); var content = container.Compose(); + ApplyDefaultImageDpi(content, metadata.RasterDpi); ApplyDefaultTextStyle(content, TextStyle.LibraryDefault); ApplyContentDirection(content, ContentDirection.LeftToRight); @@ -173,6 +176,15 @@ namespace QuestPDF.Drawing return debuggingState; } + + internal static void ApplyDefaultImageDpi(this Element? content, int targetDpi) + { + content.VisitChildren(x => + { + if (x is Image { DocumentImage: { } image }) + image.TargetDpi ??= targetDpi; + }); + } internal static void ApplyContentDirection(this Element? content, ContentDirection direction) { diff --git a/Source/QuestPDF/Drawing/DocumentMetadata.cs b/Source/QuestPDF/Drawing/DocumentMetadata.cs index 43420f6..5b77ba6 100644 --- a/Source/QuestPDF/Drawing/DocumentMetadata.cs +++ b/Source/QuestPDF/Drawing/DocumentMetadata.cs @@ -5,8 +5,10 @@ namespace QuestPDF.Drawing { public class DocumentMetadata { + public const int DefaultPdfDpi = 72; + public int ImageQuality { get; set; } = 101; - public int RasterDpi { get; set; } = 72; + public int RasterDpi { get; set; } = DefaultPdfDpi; public bool PdfA { get; set; } public string? Title { get; set; } diff --git a/Source/QuestPDF/Elements/Image.cs b/Source/QuestPDF/Elements/Image.cs index 74ba5a6..d2ea731 100644 --- a/Source/QuestPDF/Elements/Image.cs +++ b/Source/QuestPDF/Elements/Image.cs @@ -27,7 +27,7 @@ namespace QuestPDF.Elements if (DocumentImage == null) return; - Canvas.DrawImage(DocumentImage.SkImage, Position.Zero, availableSpace); + Canvas.DrawImage(DocumentImage.GetVersionOfSize(availableSpace), Position.Zero, availableSpace); } } } \ No newline at end of file diff --git a/Source/QuestPDF/Infrastructure/Image.cs b/Source/QuestPDF/Infrastructure/Image.cs index 64f5906..61898c8 100644 --- a/Source/QuestPDF/Infrastructure/Image.cs +++ b/Source/QuestPDF/Infrastructure/Image.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.IO; +using QuestPDF.Drawing; using QuestPDF.Drawing.Exceptions; using SkiaSharp; @@ -7,26 +9,71 @@ namespace QuestPDF.Infrastructure { public class Image : IDisposable { - internal SKImage SkImage { get; } + private SKImage SkImage { get; } + internal List<(Size size, SKImage image)>? ScaledImageCache { get; set; } + + internal int? TargetDpi { get; set; } + internal bool PerformScalingToTargetDpi { get; set; } = true; internal bool IsDocumentScoped { get; set; } public int Width => SkImage.Width; public int Height => SkImage.Height; + + private const float ImageSizeSimilarityToleranceMax = 1.1f; + private const float ImageSizeSimilarityToleranceMin = 1 / ImageSizeSimilarityToleranceMax; private Image(SKImage image) { SkImage = image; } - public Image DisposeAfterDocumentGeneration() - { - IsDocumentScoped = true; - return this; - } - public void Dispose() { SkImage.Dispose(); + ScaledImageCache?.ForEach(x => x.image.Dispose()); + } + + internal SKImage GetVersionOfSize(Size size) + { + if (!PerformScalingToTargetDpi) + return SkImage; + + var scalingFactor = TargetDpi.Value / (float)DocumentMetadata.DefaultPdfDpi; + var targetResolution = new Size(size.Width * scalingFactor, size.Height * scalingFactor); + + if (targetResolution.Width > Width || targetResolution.Height > Height) + return SkImage; + + ScaledImageCache ??= new List<(Size size, SKImage image)>(); + + foreach (var imageCache in ScaledImageCache) + { + if (HasSimilarSize(imageCache.size, targetResolution)) + return imageCache.image; + } + + var scaledImage = ScaleImage(SkImage, targetResolution); + ScaledImageCache.Add((targetResolution, scaledImage)); + + return scaledImage; + + static SKImage ScaleImage(SKImage originalImage, Size targetSize) + { + var imageInfo = new SKImageInfo((int)targetSize.Width, (int)targetSize.Height); + var target = SKImage.Create(imageInfo); + originalImage.ScalePixels(target.PeekPixels(), SKFilterQuality.High); + + return target; + } + + static bool HasSimilarSize(Size a, Size b) + { + var widthRatio = a.Width / b.Width; + var heightRatio = a.Height / b.Height; + + return widthRatio is > ImageSizeSimilarityToleranceMin and < ImageSizeSimilarityToleranceMax && + heightRatio is > ImageSizeSimilarityToleranceMin and < ImageSizeSimilarityToleranceMax; + } } #region public constructors @@ -60,5 +107,27 @@ namespace QuestPDF.Infrastructure } #endregion + + #region configuration API + + public Image DisposeAfterDocumentGeneration() + { + IsDocumentScoped = true; + return this; + } + + public Image WithTargetDpi(int dpi = DocumentMetadata.DefaultPdfDpi) + { + TargetDpi = dpi; + return this; + } + + public Image ScaleToTargetDpi(bool value = true) + { + PerformScalingToTargetDpi = value; + return this; + } + + #endregion } } \ No newline at end of file