Browse Source

Image scaling dpi (#540)

* DPI-based image scaling prototype (not ready for production)

* Added ImageDpi API

* Added support for image DPI scaling
Marcin Ziąbek 2 years ago
parent
commit
9637dff1dc

+ 15 - 19
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");
         }
     }
 }

+ 7 - 1
Source/QuestPDF.Examples/QuestPDF.Examples.csproj

@@ -1,7 +1,7 @@
 <Project Sdk="Microsoft.NET.Sdk">
 
     <PropertyGroup>
-        <TargetFramework>netcoreapp3.1</TargetFramework>
+        <TargetFramework>net6.0</TargetFramework>
         <IsPackable>false</IsPackable>
     </PropertyGroup>
 
@@ -32,6 +32,12 @@
       <None Update="pdf-icon.svg">
         <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
       </None>
+      <None Update="large-image.png">
+        <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+      </None>
+      <None Update="large-image.jpg">
+        <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+      </None>
     </ItemGroup>
 
 </Project>

BIN
Source/QuestPDF.Examples/large-image.jpg


+ 54 - 1
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<IContainer> container)
+        {
+            return Document
+                .Create(document =>
+                {
+                    document.Page(page =>
+                    {
+                        page.Content().Element(container);
+                    });
+                })
+                .GeneratePdf()
+                .Length;
+        }
     }
 }

+ 1 - 1
Source/QuestPDF.UnitTests/QuestPDF.UnitTests.csproj

@@ -1,7 +1,7 @@
 <Project Sdk="Microsoft.NET.Sdk">
 
     <PropertyGroup>
-        <TargetFramework>netcoreapp3.1</TargetFramework>
+        <TargetFramework>net6.0</TargetFramework>
         <IsPackable>false</IsPackable>
     </PropertyGroup>
 

+ 17 - 5
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<PreviewerPicture> 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>(TCanvas canvas, IDocument document)
+        internal static void RenderDocument<TCanvas>(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)
         {

+ 3 - 1
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; }

+ 1 - 1
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);
         }
     }
 }

+ 76 - 7
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
     }
 }