diff --git a/Source/QuestPDF.UnitTests/ImageTests.cs b/Source/QuestPDF.UnitTests/ImageTests.cs index 785abb4..5e1e700 100644 --- a/Source/QuestPDF.UnitTests/ImageTests.cs +++ b/Source/QuestPDF.UnitTests/ImageTests.cs @@ -1,10 +1,17 @@ -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 ImageElement = QuestPDF.Elements.Image; +using DocumentImage = QuestPDF.Infrastructure.Image; namespace QuestPDF.UnitTests { @@ -15,9 +22,9 @@ namespace QuestPDF.UnitTests public void Measure_TakesAvailableSpaceRegardlessOfSize() { TestPlan - .For(x => new Image + .For(x => new ImageElement { - InternalImage = GenerateImage(400, 300) + DocumentImage = GenerateDocumentImage(400, 300) }) .MeasureElement(new Size(300, 200)) .CheckMeasureResult(SpacePlan.FullRender(300, 200)); @@ -27,9 +34,9 @@ namespace QuestPDF.UnitTests public void Draw_TakesAvailableSpaceRegardlessOfSize() { TestPlan - .For(x => new Image + .For(x => new ImageElement { - InternalImage = GenerateImage(400, 300) + DocumentImage = GenerateDocumentImage(400, 300) }) .DrawElement(new Size(300, 200)) .ExpectCanvasDrawImage(new Position(0, 0), new Size(300, 200)) @@ -39,7 +46,7 @@ namespace QuestPDF.UnitTests [Test] public void Fluent_RecognizesImageProportions() { - var image = GenerateImage(600, 200).Encode(SKEncodedImageFormat.Png, 100).ToArray(); + var image = GenerateSkiaImage(600, 200).Encode(SKEncodedImageFormat.Png, 100).ToArray(); TestPlan .For(x => @@ -52,11 +59,73 @@ namespace QuestPDF.UnitTests .CheckMeasureResult(SpacePlan.FullRender(300, 100));; } - SKImage GenerateImage(int width, int height) + [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 = DocumentImage.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); + } + + [Test] + public void ImageShouldNotBeScaledAboveItsNativeResolution() + { + var image = Placeholders.Image(200, 200); + + // TODO + } + + private static int GetDocumentSize(Action container) + { + return Document + .Create(document => + { + document.Page(page => + { + page.Content().Element(container); + }); + }) + .GeneratePdf() + .Length; + } + + SKImage GenerateSkiaImage(int width, int height) { var imageInfo = new SKImageInfo(width, height); using var surface = SKSurface.Create(imageInfo); return surface.Snapshot(); } + + DocumentImage GenerateDocumentImage(int width, int height) + { + var skiaImage = GenerateSkiaImage(width, height); + return DocumentImage.FromSkImage(skiaImage); + } } } \ No newline at end of file diff --git a/Source/QuestPDF.UnitTests/LicenseSetup.cs b/Source/QuestPDF.UnitTests/LicenseSetup.cs new file mode 100644 index 0000000..c6bf21e --- /dev/null +++ b/Source/QuestPDF.UnitTests/LicenseSetup.cs @@ -0,0 +1,15 @@ +using NUnit.Framework; +using QuestPDF.Infrastructure; + +namespace QuestPDF.UnitTests +{ + [SetUpFixture] + public class LicenseSetup + { + [OneTimeSetUp] + public static void Setup() + { + QuestPDF.Settings.License = LicenseType.Community; + } + } +} \ No newline at end of file diff --git a/Source/QuestPDF/Drawing/DocumentGenerator.cs b/Source/QuestPDF/Drawing/DocumentGenerator.cs index 8d031d6..93e5574 100644 --- a/Source/QuestPDF/Drawing/DocumentGenerator.cs +++ b/Source/QuestPDF/Drawing/DocumentGenerator.cs @@ -23,7 +23,7 @@ namespace QuestPDF.Drawing var metadata = document.GetMetadata(); var settings = document.GetSettings(); var canvas = new PdfCanvas(stream, metadata, settings); - RenderDocument(canvas, document); + RenderDocument(canvas, document, settings); } internal static void GenerateXps(Stream stream, IDocument document) @@ -33,7 +33,7 @@ namespace QuestPDF.Drawing var settings = document.GetSettings(); var canvas = new XpsCanvas(stream, settings); - RenderDocument(canvas, document); + RenderDocument(canvas, document, settings); } private static void CheckIfStreamIsCompatible(Stream stream) @@ -51,7 +51,7 @@ namespace QuestPDF.Drawing var settings = document.GetSettings(); var canvas = new ImageCanvas(settings); - RenderDocument(canvas, document); + RenderDocument(canvas, document, settings); return canvas.Images; } @@ -88,18 +88,20 @@ namespace QuestPDF.Drawing internal static ICollection GeneratePreviewerPictures(IDocument document) { var canvas = new SkiaPictureCanvas(); - RenderDocument(canvas, document); + RenderDocument(canvas, document, DocumentSettings.Default); return canvas.Pictures; } - internal static void RenderDocument(TCanvas canvas, IDocument document) + internal static void RenderDocument(TCanvas canvas, IDocument document, DocumentSettings settings) where TCanvas : ICanvas, IRenderingCanvas { var container = new DocumentContainer(); document.Compose(container); var content = container.Compose(); + ApplyInheritedAndGlobalTexStyle(content, TextStyle.Default); - ApplyContentDirection(content, ContentDirection.LeftToRight); + ApplyContentDirection(content, settings.ContentDirection); + ApplyDefaultImageConfiguration(content, settings); var debuggingState = Settings.EnableDebugging ? ApplyDebugging(content) : null; @@ -225,6 +227,18 @@ namespace QuestPDF.Drawing foreach (var child in content.GetChildren()) ApplyContentDirection(child, direction); } + + internal static void ApplyDefaultImageConfiguration(this Element? content, DocumentSettings settings) + { + content.VisitChildren(x => + { + if (x is not QuestPDF.Elements.Image { DocumentImage: { } image }) + return; + + image.TargetDpi ??= settings.RasterDpi; + image.TargetQuality ??= settings.ImageQuality; + }); + } internal static void ApplyInheritedAndGlobalTexStyle(this Element? content, TextStyle documentDefaultTextStyle) { diff --git a/Source/QuestPDF/Elements/Image.cs b/Source/QuestPDF/Elements/Image.cs index e38475c..74ba5a6 100644 --- a/Source/QuestPDF/Elements/Image.cs +++ b/Source/QuestPDF/Elements/Image.cs @@ -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.SkImage, Position.Zero, availableSpace); } } } \ No newline at end of file diff --git a/Source/QuestPDF/Fluent/ImageExtensions.cs b/Source/QuestPDF/Fluent/ImageExtensions.cs index 7d46a26..e739efe 100644 --- a/Source/QuestPDF/Fluent/ImageExtensions.cs +++ b/Source/QuestPDF/Fluent/ImageExtensions.cs @@ -11,30 +11,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) + internal 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) diff --git a/Source/QuestPDF/Infrastructure/ContentDirection.cs b/Source/QuestPDF/Infrastructure/ContentDirection.cs index 81a17f2..02b6759 100644 --- a/Source/QuestPDF/Infrastructure/ContentDirection.cs +++ b/Source/QuestPDF/Infrastructure/ContentDirection.cs @@ -1,6 +1,6 @@ namespace QuestPDF.Infrastructure { - internal enum ContentDirection + public enum ContentDirection { LeftToRight, RightToLeft diff --git a/Source/QuestPDF/Infrastructure/DocumentSettings.cs b/Source/QuestPDF/Infrastructure/DocumentSettings.cs index adf7455..1419c94 100644 --- a/Source/QuestPDF/Infrastructure/DocumentSettings.cs +++ b/Source/QuestPDF/Infrastructure/DocumentSettings.cs @@ -4,6 +4,8 @@ namespace QuestPDF.Infrastructure { public class DocumentSettings { + public const int DefaultRasterDpi = 72; + /// /// Gets or sets a value indicating whether or not make the document PDF/A-2b conformant. /// If true, include XMP metadata, a document UUID, and sRGB output intent information. @@ -23,8 +25,11 @@ namespace QuestPDF.Infrastructure /// The DPI (pixels-per-inch) at which images and features without native PDF support will be rasterized. /// A larger DPI would create a PDF that reflects the original intent with better fidelity, but it can make for larger PDF files too, which would use more memory while rendering, and it would be slower to be processed or sent online or to printer. /// When generating images, this parameter also controls the resolution of the generated content. + /// Default value is 72. /// - public int RasterDpi { get; set; } = 72; + public int RasterDpi { get; set; } = DefaultRasterDpi; + + public ContentDirection ContentDirection { get; set; } = ContentDirection.LeftToRight; public static DocumentSettings Default => new DocumentSettings(); } diff --git a/Source/QuestPDF/Infrastructure/Image.cs b/Source/QuestPDF/Infrastructure/Image.cs new file mode 100644 index 0000000..496ba65 --- /dev/null +++ b/Source/QuestPDF/Infrastructure/Image.cs @@ -0,0 +1,101 @@ +using System; +using System.IO; +using QuestPDF.Drawing.Exceptions; +using SkiaSharp; + +namespace QuestPDF.Infrastructure +{ + public class Image : IDisposable + { + internal SKImage SkImage { get; } + + internal int? TargetDpi { get; set; } + internal int? TargetQuality { get; set; } + internal bool IsDocumentScoped { get; set; } + + public int Width => SkImage.Width; + public int Height => SkImage.Height; + + private Image(SKImage image) + { + SkImage = image; + } + + public void Dispose() + { + SkImage.Dispose(); + } + + #region Fluent API + + /// + /// When enabled, the image object is disposed automatically after document generation, and you don't need to call the Dispose method yourself. + /// + public Image DisposeAfterDocumentGeneration() + { + IsDocumentScoped = true; + return this; + } + + /// + /// The DPI (pixels-per-inch) at which images and features without native PDF support will be rasterized. + /// A larger DPI would create a PDF that reflects the original intent with better fidelity, but it can make for larger PDF files too, which would use more memory while rendering, and it would be slower to be processed or sent online or to printer. + /// When generating images, this parameter also controls the resolution of the generated content. + /// Default value is 72. + /// + public Image WithRasterDpi(int dpi) + { + TargetDpi = dpi; + return this; + } + + /// + /// Encoding quality controls the trade-off between size and quality. + /// The value 101 corresponds to lossless encoding. + /// If this value is set to a value between 1 and 100, and the image is opaque, it will be encoded using the JPEG format with that quality setting. + /// The default value is 90 (very high quality). + /// + public Image WithQuality(int value) + { + if (value is not (>= 1 and <= 101)) + throw new DocumentComposeException("Image quality must be between 1 and 101."); + + TargetQuality = value; + return this; + } + + #endregion + + #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 + } +} \ No newline at end of file