From 05a1614f8e6571362cec7d0fa82f224ec9809564 Mon Sep 17 00:00:00 2001 From: Sawraz Date: Mon, 8 Jun 2026 11:09:45 +0600 Subject: [PATCH 1/4] PDF-2231: Fixed Multi Page TIFF Mixed Orientation --- .../UnitTests/AnyBitmapFunctionality.cs | 79 +++++++++++++++++++ .../IronSoftware.Drawing.Common/AnyBitmap.cs | 58 ++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/AnyBitmapFunctionality.cs b/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/AnyBitmapFunctionality.cs index ef91a05..092e80d 100644 --- a/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/AnyBitmapFunctionality.cs +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/AnyBitmapFunctionality.cs @@ -1,5 +1,7 @@ +using BitMiracle.LibTiff.Classic; using FluentAssertions; using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Metadata; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; using System; @@ -624,6 +626,83 @@ public void Create_Multi_page_Tiff_Paths() File.Delete(outputImagePath); } + [FactWithAutomaticDisplayName] + public void CreateMultiFrameTiffStream_Preserves_Mixed_Orientation() + { + // A multi-page TIFF must keep each page's native dimensions. + // Pages of differing orientation must not be scaled to a common size. + using var portrait = CreateSolidBitmap(120, 200, new Rgb24(220, 30, 30), 150); + using var landscape = CreateSolidBitmap(200, 120, new Rgb24(30, 30, 220), 150); + + using var stream = AnyBitmap.CreateMultiFrameTiffStream(new[] { portrait, landscape }); + var pages = ReadTiffDirectories(stream.ToArray()); + + pages.Should().HaveCount(2, "each source image becomes one TIFF page"); + pages[0].Width.Should().Be(120); + pages[0].Height.Should().Be(200); + pages[1].Width.Should().Be(200); + pages[1].Height.Should().Be(120); + } + + [FactWithAutomaticDisplayName] + public void CreateMultiFrameTiffBytes_Preserves_Per_Page_Dimensions_And_Resolution() + { + using var first = CreateSolidBitmap(300, 400, new Rgb24(10, 200, 10), 150); + using var second = CreateSolidBitmap(640, 360, new Rgb24(200, 200, 10), 150); + using var third = CreateSolidBitmap(200, 200, new Rgb24(10, 10, 200), 150); + + byte[] tiff = AnyBitmap.CreateMultiFrameTiffBytes(new[] { first, second, third }); + var pages = ReadTiffDirectories(tiff); + + pages.Should().HaveCount(3); + pages[0].Width.Should().Be(300); + pages[0].Height.Should().Be(400); + pages[1].Width.Should().Be(640); + pages[1].Height.Should().Be(360); + pages[2].Width.Should().Be(200); + pages[2].Height.Should().Be(200); + + // A resolution tag is written for every page and is consistent across pages. + pages.Should().OnlyContain(p => p.XResolution > 0f); + pages.Select(p => p.XResolution).Distinct().Should().ContainSingle(); + } + + [FactWithAutomaticDisplayName] + public void CreateMultiFrameTiffStream_Empty_Sequence_Throws() + { + Action act = () => AnyBitmap.CreateMultiFrameTiffStream(new List()); + act.Should().Throw(); + } + + private static AnyBitmap CreateSolidBitmap(int width, int height, Rgb24 color, int dpi) + { + var image = new SixLabors.ImageSharp.Image(width, height, color); + image.Metadata.HorizontalResolution = dpi; + image.Metadata.VerticalResolution = dpi; + image.Metadata.ResolutionUnits = PixelResolutionUnit.PixelsPerInch; + return image; + } + + private static List<(int Width, int Height, float XResolution)> ReadTiffDirectories(byte[] tiffData) + { + var result = new List<(int, int, float)>(); + using var ms = new MemoryStream(tiffData); + using var tiff = Tiff.ClientOpen("in-memory", "r", ms, new TiffStream()); + tiff.Should().NotBeNull("the produced bytes should be a valid TIFF"); + + short directoryCount = tiff.NumberOfDirectories(); + for (short i = 0; i < directoryCount; i++) + { + tiff.SetDirectory(i); + int width = tiff.GetField(TiffTag.IMAGEWIDTH)[0].ToInt(); + int height = tiff.GetField(TiffTag.IMAGELENGTH)[0].ToInt(); + FieldValue[] xres = tiff.GetField(TiffTag.XRESOLUTION); + result.Add((width, height, xres != null ? xres[0].ToFloat() : 0f)); + } + + return result; + } + [FactWithAutomaticDisplayName] public void Create_Multi_page_Gif() { diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common/AnyBitmap.cs b/IronSoftware.Drawing/IronSoftware.Drawing.Common/AnyBitmap.cs index b323b3c..acb69e0 100644 --- a/IronSoftware.Drawing/IronSoftware.Drawing.Common/AnyBitmap.cs +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common/AnyBitmap.cs @@ -1073,6 +1073,64 @@ public static AnyBitmap CreateMultiFrameTiff(IEnumerable images) return FromStream(stream); } + /// + /// Combines multiple images into a multi-page TIFF and returns the raw TIFF + /// as a . + /// Unlike , each + /// page keeps its own dimensions, orientation and resolution. Pages are not + /// scaled to a common size. This makes it suitable for mixed-orientation + /// documents (for example portrait + landscape pages). + /// The result is the encoded TIFF itself, not an , + /// so it avoids the lossy round-trip through a single ImageSharp image (which + /// cannot represent frames of differing sizes). + /// + /// Images to combine, one per TIFF page. + /// A positioned at 0 containing the multi-page TIFF. + public static MemoryStream CreateMultiFrameTiffStream(IEnumerable images) + { + if (images == null) + throw new ArgumentNullException(nameof(images)); + + var frames = new List(); + try + { + foreach (var image in images) + { + if (image == null) + throw new ArgumentException("The image sequence contains a null element.", nameof(images)); + + frames.Add(((Image)image).CloneAs()); + } + + if (frames.Count == 0) + throw new ArgumentException("No images provided to create multi-frame TIFF.", nameof(images)); + + var stream = new MemoryStream(); + InternalSaveAsMultiPageTiff(frames, stream); // already resets stream.Position to 0 + return stream; + } + finally + { + foreach (var frame in frames) + { + frame.Dispose(); + } + } + } + + /// + /// Combines multiple images into a multi-page TIFF and returns the raw TIFF bytes. + /// Each page keeps its own dimensions, orientation and resolution. Pages are + /// not scaled to a common size, making it suitable for mixed-orientation documents. + /// + /// Images to combine, one per TIFF page. + /// A byte array containing the multi-page TIFF. + public static byte[] CreateMultiFrameTiffBytes(IEnumerable images) + { + using var stream = CreateMultiFrameTiffStream(images); + return stream.ToArray(); + } + /// /// Creates a multi-frame GIF image from multiple AnyBitmaps. /// All images should have the same dimension. From 85fdaf0a339841d395e0a7f2605904b523ad5062 Mon Sep 17 00:00:00 2001 From: Sawraz Date: Mon, 8 Jun 2026 11:20:41 +0600 Subject: [PATCH 2/4] Fixed resolution unit-conversion bug --- .../UnitTests/AnyBitmapFunctionality.cs | 33 +++++++++++++++---- .../IronSoftware.Drawing.Common/AnyBitmap.cs | 4 +-- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/AnyBitmapFunctionality.cs b/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/AnyBitmapFunctionality.cs index 092e80d..43fbf55 100644 --- a/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/AnyBitmapFunctionality.cs +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/AnyBitmapFunctionality.cs @@ -662,9 +662,11 @@ public void CreateMultiFrameTiffBytes_Preserves_Per_Page_Dimensions_And_Resoluti pages[2].Width.Should().Be(200); pages[2].Height.Should().Be(200); - // A resolution tag is written for every page and is consistent across pages. - pages.Should().OnlyContain(p => p.XResolution > 0f); - pages.Select(p => p.XResolution).Distinct().Should().ContainSingle(); + foreach (var page in pages) + { + ToDotsPerInch(page.XResolution, page.ResolutionUnit) + .Should().BeApproximately(150d, 2d); + } } [FactWithAutomaticDisplayName] @@ -683,9 +685,9 @@ private static AnyBitmap CreateSolidBitmap(int width, int height, Rgb24 color, i return image; } - private static List<(int Width, int Height, float XResolution)> ReadTiffDirectories(byte[] tiffData) + private static List<(int Width, int Height, float XResolution, ResUnit ResolutionUnit)> ReadTiffDirectories(byte[] tiffData) { - var result = new List<(int, int, float)>(); + var result = new List<(int, int, float, ResUnit)>(); using var ms = new MemoryStream(tiffData); using var tiff = Tiff.ClientOpen("in-memory", "r", ms, new TiffStream()); tiff.Should().NotBeNull("the produced bytes should be a valid TIFF"); @@ -697,12 +699,31 @@ private static AnyBitmap CreateSolidBitmap(int width, int height, Rgb24 color, i int width = tiff.GetField(TiffTag.IMAGEWIDTH)[0].ToInt(); int height = tiff.GetField(TiffTag.IMAGELENGTH)[0].ToInt(); FieldValue[] xres = tiff.GetField(TiffTag.XRESOLUTION); - result.Add((width, height, xres != null ? xres[0].ToFloat() : 0f)); + FieldValue[] unit = tiff.GetField(TiffTag.RESOLUTIONUNIT); + result.Add(( + width, + height, + xres != null ? xres[0].ToFloat() : 0f, + unit != null ? (ResUnit)unit[0].ToInt() : ResUnit.NONE)); } return result; } + /// + /// Normalises a TIFF page's resolution back to dots-per-inch, regardless of the + /// unit the value was stored in. + /// + private static double ToDotsPerInch(float xResolution, ResUnit unit) + { + return unit switch + { + ResUnit.INCH => xResolution, + ResUnit.CENTIMETER => xResolution * 2.54, + _ => xResolution + }; + } + [FactWithAutomaticDisplayName] public void Create_Multi_page_Gif() { diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common/AnyBitmap.cs b/IronSoftware.Drawing/IronSoftware.Drawing.Common/AnyBitmap.cs index acb69e0..c4384da 100644 --- a/IronSoftware.Drawing/IronSoftware.Drawing.Common/AnyBitmap.cs +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common/AnyBitmap.cs @@ -3451,8 +3451,8 @@ private static void InternalSaveAsMultiPageTiff(IEnumerable images, Strea output.SetField(TiffTag.RESOLUTIONUNIT, ResUnit.CENTIMETER); break; case SixLabors.ImageSharp.Metadata.PixelResolutionUnit.PixelsPerMeter: - output.SetField(TiffTag.XRESOLUTION, image.Metadata.HorizontalResolution * 100); - output.SetField(TiffTag.YRESOLUTION, image.Metadata.VerticalResolution * 100); + output.SetField(TiffTag.XRESOLUTION, image.Metadata.HorizontalResolution / 100); + output.SetField(TiffTag.YRESOLUTION, image.Metadata.VerticalResolution / 100); output.SetField(TiffTag.RESOLUTIONUNIT, ResUnit.CENTIMETER); break; } From 6c3a9c9f2c9b7ddd5a4d87011e0fcd101fb1958b Mon Sep 17 00:00:00 2001 From: Sawraz Date: Mon, 8 Jun 2026 11:33:24 +0600 Subject: [PATCH 3/4] Fixed corruption of true 3-byte Rgb24 input --- .../UnitTests/AnyBitmapFunctionality.cs | 31 +++++++++++++++++++ .../IronSoftware.Drawing.Common/AnyBitmap.cs | 18 +++++++++-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/AnyBitmapFunctionality.cs b/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/AnyBitmapFunctionality.cs index 43fbf55..13c3abe 100644 --- a/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/AnyBitmapFunctionality.cs +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/AnyBitmapFunctionality.cs @@ -676,6 +676,37 @@ public void CreateMultiFrameTiffStream_Empty_Sequence_Throws() act.Should().Throw(); } + [FactWithAutomaticDisplayName] + public void CreateMultiFrameTiff_Preserves_Rgb24_Pixels() + { + string jpgPath = GetRelativeFilePath("mountainclimbers.jpg"); + using var expected = SixLabors.ImageSharp.Image.Load(jpgPath); + + using var result = AnyBitmap.CreateMultiFrameTiff(new List { jpgPath }); + + result.Width.Should().Be(expected.Width); + result.Height.Should().Be(expected.Height); + + var points = new[] + { + (1, 0), + (expected.Width - 1, 0), + (expected.Width / 3, expected.Height / 2), + (expected.Width / 2, expected.Height / 3), + (0, expected.Height - 1), + (expected.Width - 1, expected.Height - 1) + }; + + foreach (var (x, y) in points) + { + Rgb24 e = expected[x, y]; + var a = result.GetPixel(x, y); + a.R.Should().Be(e.R, $"red channel at ({x},{y})"); + a.G.Should().Be(e.G, $"green channel at ({x},{y})"); + a.B.Should().Be(e.B, $"blue channel at ({x},{y})"); + } + } + private static AnyBitmap CreateSolidBitmap(int width, int height, Rgb24 color, int dpi) { var image = new SixLabors.ImageSharp.Image(width, height, color); diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common/AnyBitmap.cs b/IronSoftware.Drawing/IronSoftware.Drawing.Common/AnyBitmap.cs index c4384da..71cadb0 100644 --- a/IronSoftware.Drawing/IronSoftware.Drawing.Common/AnyBitmap.cs +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common/AnyBitmap.cs @@ -3409,7 +3409,15 @@ private static void InternalSaveAsMultiPageTiff(IEnumerable images, Strea switch (image) { case Image imageAsFormat: - imageAsFormat.CopyPixelDataTo(buffer); + { + // Rgb24 is 3 bytes/pixel, but the buffer/stride above and the + // TIFF tags below are 4 samples/pixel (RGBA). Copying the 3-bpp + // data directly would leave each row short by 'width' bytes, + // shifting every subsequent row and corrupting the page. Convert + // to Rgba32 so the bytes line up with the 4-bpp layout. + using var rgba = imageAsFormat.CloneAs(); + rgba.CopyPixelDataTo(buffer); + } break; case Image imageAsFormat: imageAsFormat.CopyPixelDataTo(buffer); @@ -3418,7 +3426,13 @@ private static void InternalSaveAsMultiPageTiff(IEnumerable images, Strea imageAsFormat.CopyPixelDataTo(buffer); break; case Image imageAsFormat: - imageAsFormat.CopyPixelDataTo(buffer); + { + // Bgr24 is likewise 3 bytes/pixel; convert to Rgba32 for the + // same reason as Rgb24 above (this also yields the correct + // R,G,B sample order under PHOTOMETRIC.RGB). + using var rgba = imageAsFormat.CloneAs(); + rgba.CopyPixelDataTo(buffer); + } break; case Image imageAsFormat: imageAsFormat.CopyPixelDataTo(buffer); From 9abef964b01d46858b5a49f449c88593daa1b6e9 Mon Sep 17 00:00:00 2001 From: Sawraz Date: Thu, 11 Jun 2026 12:12:16 +0600 Subject: [PATCH 4/4] update(PDF-2231): Address PR Review Feedbacks --- .../UnitTests/AnyBitmapFunctionality.cs | 79 ++++++++++++++++++- .../IronSoftware.Drawing.Common/AnyBitmap.cs | 21 ++++- 2 files changed, 94 insertions(+), 6 deletions(-) diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/AnyBitmapFunctionality.cs b/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/AnyBitmapFunctionality.cs index 13c3abe..d181e7c 100644 --- a/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/AnyBitmapFunctionality.cs +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/AnyBitmapFunctionality.cs @@ -676,6 +676,23 @@ public void CreateMultiFrameTiffStream_Empty_Sequence_Throws() act.Should().Throw(); } + [FactWithAutomaticDisplayName] + public void CreateMultiFrameTiffBytes_PreservesResolution_FromPixelsPerMeterSource() + { + const double dpi = 300d; + double pixelsPerMetre = dpi / 0.0254d; // 300 DPI expressed in pixels per metre + + using var page = MakeBitmapWithResolution(220, 300, new Rgb24(40, 90, 160), + pixelsPerMetre, PixelResolutionUnit.PixelsPerMeter); + + byte[] tiff = AnyBitmap.CreateMultiFrameTiffBytes(new[] { page }); + var pages = ReadTiffDirectories(tiff); + + pages.Should().ContainSingle(); + ToDotsPerInch(pages[0].XResolution, pages[0].ResolutionUnit) + .Should().BeApproximately(dpi, 2d); + } + [FactWithAutomaticDisplayName] public void CreateMultiFrameTiff_Preserves_Rgb24_Pixels() { @@ -707,12 +724,68 @@ public void CreateMultiFrameTiff_Preserves_Rgb24_Pixels() } } + [TheoryWithAutomaticDisplayName] + [InlineData("Rgb24")] + [InlineData("Bgr24")] + [InlineData("Rgba32")] + [InlineData("Bgra32")] + [InlineData("Abgr32")] + [InlineData("Argb32")] + public void CreateMultiFrameTiff_PreservesColors_ForAllPixelFormats(string pixelFormat) + { + const byte r = 10, g = 120, b = 240; + using var bmp = MakeSolidBitmapOfFormat(pixelFormat, 64, 48, r, g, b); + + using var result = AnyBitmap.CreateMultiFrameTiff(new[] { bmp }); + + result.Width.Should().Be(64); + result.Height.Should().Be(48); + + foreach (var (x, y) in new[] { (0, 0), (63, 0), (0, 47), (32, 24), (63, 47) }) + { + var px = result.GetPixel(x, y); + px.R.Should().Be(r, $"R at ({x},{y}) for {pixelFormat}"); + px.G.Should().Be(g, $"G at ({x},{y}) for {pixelFormat}"); + px.B.Should().Be(b, $"B at ({x},{y}) for {pixelFormat}"); + } + } + + /// + /// Builds a solid whose backing image uses the requested + /// ImageSharp pixel format. The colour is given in logical R,G,B order regardless of + /// the format's in-memory byte layout. The image is force-loaded so the original + /// pixel format (not a re-encoded copy) reaches the TIFF writer. + /// + private static AnyBitmap MakeSolidBitmapOfFormat(string format, int width, int height, byte r, byte g, byte b) + { + Image image = format switch + { + "Rgb24" => new Image(width, height, new Rgb24(r, g, b)), + "Bgr24" => new Image(width, height, new Bgr24(r, g, b)), + "Rgba32" => new Image(width, height, new Rgba32(r, g, b, 255)), + "Bgra32" => new Image(width, height, new Bgra32(r, g, b, 255)), + "Abgr32" => new Image(width, height, new Abgr32(r, g, b, 255)), + "Argb32" => new Image(width, height, new Argb32(r, g, b, 255)), + _ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unsupported pixel format") + }; + + var bitmap = (AnyBitmap)image; + _ = bitmap.Width; // materialise so the original pixel format reaches the writer + return bitmap; + } + private static AnyBitmap CreateSolidBitmap(int width, int height, Rgb24 color, int dpi) + { + return MakeBitmapWithResolution(width, height, color, dpi, PixelResolutionUnit.PixelsPerInch); + } + + private static AnyBitmap MakeBitmapWithResolution(int width, int height, Rgb24 color, + double resolution, PixelResolutionUnit unit) { var image = new SixLabors.ImageSharp.Image(width, height, color); - image.Metadata.HorizontalResolution = dpi; - image.Metadata.VerticalResolution = dpi; - image.Metadata.ResolutionUnits = PixelResolutionUnit.PixelsPerInch; + image.Metadata.HorizontalResolution = resolution; + image.Metadata.VerticalResolution = resolution; + image.Metadata.ResolutionUnits = unit; return image; } diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common/AnyBitmap.cs b/IronSoftware.Drawing/IronSoftware.Drawing.Common/AnyBitmap.cs index 71cadb0..32fb94e 100644 --- a/IronSoftware.Drawing/IronSoftware.Drawing.Common/AnyBitmap.cs +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common/AnyBitmap.cs @@ -3420,10 +3420,20 @@ private static void InternalSaveAsMultiPageTiff(IEnumerable images, Strea } break; case Image imageAsFormat: - imageAsFormat.CopyPixelDataTo(buffer); + { + // 4 bytes/pixel, but the bytes are A,B,G,R. The wrong sample + // order for PHOTOMETRIC.RGB (which expects R,G,B,A). Convert to + // Rgba32 so the channels are not written swapped. + using var rgba = imageAsFormat.CloneAs(); + rgba.CopyPixelDataTo(buffer); + } break; case Image imageAsFormat: - imageAsFormat.CopyPixelDataTo(buffer); + { + // Bytes are A,R,G,B; convert to Rgba32 for correct channel order. + using var rgba = imageAsFormat.CloneAs(); + rgba.CopyPixelDataTo(buffer); + } break; case Image imageAsFormat: { @@ -3435,7 +3445,12 @@ private static void InternalSaveAsMultiPageTiff(IEnumerable images, Strea } break; case Image imageAsFormat: - imageAsFormat.CopyPixelDataTo(buffer); + { + // Bytes are B,G,R,A; convert to Rgba32 so they are not written + // channel-swapped under PHOTOMETRIC.RGB. + using var rgba = imageAsFormat.CloneAs(); + rgba.CopyPixelDataTo(buffer); + } break; default: (image as Image).CopyPixelDataTo(buffer);