Compare commits

...

4 Commits

Author SHA1 Message Date
MarcinZiabek 41ba1ac1ce Added support for image DPI scaling 2023-04-30 11:26:55 +02:00
MarcinZiabek dc0a21b8e7 Added ImageDpi API 2023-04-28 09:56:27 +02:00
MarcinZiabek 8a358a8fa2 DPI-based image scaling prototype (not ready for production) 2023-04-22 09:30:46 +02:00
MarcinZiabek 1c9bccbeb2 Feature: added support for shared/global images 2023-04-17 02:08:04 +02:00
10 changed files with 246 additions and 55 deletions
+14 -30
View File
@@ -1,10 +1,12 @@
using System;
using System.IO;
using System.Linq;
using NUnit.Framework;
using QuestPDF.Drawing.Exceptions;
using QuestPDF.Examples.Engine;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
namespace QuestPDF.Examples
{
@@ -64,42 +66,24 @@ namespace QuestPDF.Examples
});
}
[Test]
public void ReusingTheSameImageFileShouldBePossible()
public void ImageResolutionScaling()
{
var fileName = Path.GetTempFileName() + ".jpg";
var image = Image.FromFile("large-image.jpg");
try
Document
.Create(document =>
{
var image = Placeholders.Image(300, 100);
using var file = File.Create(fileName);
file.Write(image);
file.Dispose();
RenderingTest
.Create()
.ProducePdf()
.PageSize(PageSizes.A4)
.ShowResults()
.Render(container =>
document.Page(page =>
{
container
.Padding(20)
.Column(column =>
{
column.Spacing(20);
column.Item().Image(fileName);
column.Item().Image(fileName);
column.Item().Image(fileName);
page.Size(210, 210);
page.Margin(50);
page.Content().Image(image);
});
});
}
finally
{
File.Delete(fileName);
}
})
.GeneratePdf($"test.pdf");
}
}
}
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

+59 -5
View File
@@ -1,10 +1,16 @@
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;
using Image = QuestPDF.Infrastructure.Image;
namespace QuestPDF.UnitTests
{
@@ -15,9 +21,9 @@ namespace QuestPDF.UnitTests
public void Measure_TakesAvailableSpaceRegardlessOfSize()
{
TestPlan
.For(x => new Image
.For(x => new QuestPDF.Elements.Image
{
InternalImage = GenerateImage(400, 300)
DocumentImage = Image.FromSkImage(GenerateImage(400, 300))
})
.MeasureElement(new Size(300, 200))
.CheckMeasureResult(SpacePlan.FullRender(300, 200));
@@ -27,9 +33,9 @@ namespace QuestPDF.UnitTests
public void Draw_TakesAvailableSpaceRegardlessOfSize()
{
TestPlan
.For(x => new Image
.For(x => new QuestPDF.Elements.Image
{
InternalImage = GenerateImage(400, 300)
DocumentImage = Image.FromSkImage(GenerateImage(400, 300))
})
.DrawElement(new Size(300, 200))
.ExpectCanvasDrawImage(new Position(0, 0), new Size(300, 200))
@@ -58,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,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<TargetFramework>net6.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
+17 -5
View File
@@ -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);
@@ -174,6 +177,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)
{
if (content == null)
+3 -1
View File
@@ -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; }
+5 -4
View File
@@ -7,11 +7,12 @@ namespace QuestPDF.Elements
{
internal class Image : Element, ICacheable
{
public SKImage? InternalImage { get; set; }
public Infrastructure.Image? DocumentImage { get; set; }
~Image()
{
InternalImage?.Dispose();
if (DocumentImage is { IsDocumentScoped: true })
DocumentImage?.Dispose();
}
internal override SpacePlan Measure(Size availableSpace)
@@ -23,10 +24,10 @@ namespace QuestPDF.Elements
internal override void Draw(Size availableSpace)
{
if (InternalImage == null)
if (DocumentImage == null)
return;
Canvas.DrawImage(InternalImage, Position.Zero, availableSpace);
Canvas.DrawImage(DocumentImage.GetVersionOfSize(availableSpace), Position.Zero, availableSpace);
}
}
}
+6 -7
View File
@@ -3,7 +3,6 @@ using System.IO;
using QuestPDF.Drawing.Exceptions;
using QuestPDF.Elements;
using QuestPDF.Infrastructure;
using SkiaSharp;
namespace QuestPDF.Fluent
{
@@ -11,30 +10,30 @@ namespace QuestPDF.Fluent
{
public static void Image(this IContainer parent, byte[] imageData, ImageScaling scaling = ImageScaling.FitWidth)
{
var image = SKImage.FromEncodedData(imageData);
var image = Infrastructure.Image.FromBinaryData(imageData).DisposeAfterDocumentGeneration();
parent.Image(image, scaling);
}
public static void Image(this IContainer parent, string filePath, ImageScaling scaling = ImageScaling.FitWidth)
{
var image = SKImage.FromEncodedData(filePath);
var image = Infrastructure.Image.FromFile(filePath).DisposeAfterDocumentGeneration();
parent.Image(image, scaling);
}
public static void Image(this IContainer parent, Stream fileStream, ImageScaling scaling = ImageScaling.FitWidth)
{
var image = SKImage.FromEncodedData(fileStream);
var image = Infrastructure.Image.FromStream(fileStream).DisposeAfterDocumentGeneration();
parent.Image(image, scaling);
}
private static void Image(this IContainer parent, SKImage image, ImageScaling scaling = ImageScaling.FitWidth)
public static void Image(this IContainer parent, Infrastructure.Image image, ImageScaling scaling = ImageScaling.FitWidth)
{
if (image == null)
throw new DocumentComposeException("Cannot load or decode provided image.");
var imageElement = new Image
var imageElement = new QuestPDF.Elements.Image
{
InternalImage = image
DocumentImage = image
};
if (scaling != ImageScaling.Resize)
+133
View File
@@ -0,0 +1,133 @@
using System;
using System.Collections.Generic;
using System.IO;
using QuestPDF.Drawing;
using QuestPDF.Drawing.Exceptions;
using SkiaSharp;
namespace QuestPDF.Infrastructure
{
public class Image : IDisposable
{
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 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
internal static Image FromSkImage(SKImage image)
{
return CreateImage(image);
}
public static Image FromBinaryData(byte[] imageData)
{
return CreateImage(SKImage.FromEncodedData(imageData));
}
public static Image FromFile(string filePath)
{
return CreateImage(SKImage.FromEncodedData(filePath));
}
public static Image FromStream(Stream fileStream)
{
return CreateImage(SKImage.FromEncodedData(fileStream));
}
private static Image CreateImage(SKImage? image)
{
if (image == null)
throw new DocumentComposeException("Cannot load or decode provided image.");
return new Image(image);
}
#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
}
}