From 76a8778752581200edf47cb6e8136b0d4e0dc538 Mon Sep 17 00:00:00 2001 From: Ben Olden-Cooligan Date: Sat, 17 Sep 2022 10:46:31 -0700 Subject: [PATCH] Gtk: Tiff saving --- NAPS2.Images.Gtk/GtkImageContext.cs | 119 +++++++++++++++++++++--- NAPS2.Images.Gtk/LibTiff.cs | 14 ++- NAPS2.Images.Gtk/LibTiffStreamClient.cs | 2 +- NAPS2.Images.Gtk/TiffCompression.cs | 11 +++ NAPS2.Images.Gtk/TiffPhotometric.cs | 7 ++ NAPS2.Images.Gtk/TiffTag.cs | 22 +++++ NAPS2.Images/Storage/ImageContext.cs | 1 + NAPS2.Sdk.Tests/Images/LoadSaveTests.cs | 84 ++++++++++------- 8 files changed, 212 insertions(+), 48 deletions(-) create mode 100644 NAPS2.Images.Gtk/TiffCompression.cs create mode 100644 NAPS2.Images.Gtk/TiffPhotometric.cs create mode 100644 NAPS2.Images.Gtk/TiffTag.cs diff --git a/NAPS2.Images.Gtk/GtkImageContext.cs b/NAPS2.Images.Gtk/GtkImageContext.cs index b89cc3941..03ef563b5 100644 --- a/NAPS2.Images.Gtk/GtkImageContext.cs +++ b/NAPS2.Images.Gtk/GtkImageContext.cs @@ -1,5 +1,6 @@ using System.Threading; using Gdk; +using NAPS2.Images.Bitwise; namespace NAPS2.Images.Gtk; @@ -63,42 +64,138 @@ public class GtkImageContext : ImageContext return new[] { Load(path) }; } - public override bool SaveTiff(IList images, string path, TiffCompressionType compression = TiffCompressionType.Auto, + public override bool SaveTiff(IList images, string path, + TiffCompressionType compression = TiffCompressionType.Auto, Action? progressCallback = null, CancellationToken cancelToken = default) { - throw new NotImplementedException(); + var tiff = LibTiff.TIFFOpen(path, "w"); + return WriteTiff(tiff, null, images, compression, progressCallback, cancelToken); } - public override bool SaveTiff(IList images, Stream stream, TiffCompressionType compression = TiffCompressionType.Auto, + public override bool SaveTiff(IList images, Stream stream, + TiffCompressionType compression = TiffCompressionType.Auto, Action? progressCallback = null, CancellationToken cancelToken = default) { - throw new NotImplementedException(); + var client = new LibTiffStreamClient(stream); + var tiff = client.TIFFClientOpen("w"); + return WriteTiff(tiff, client, images, compression, progressCallback, cancelToken); + } + + private bool WriteTiff(IntPtr tiff, LibTiffStreamClient client, IList images, + TiffCompressionType compression, Action? progressCallback, CancellationToken cancelToken) + { + try + { + int i = 0; + progressCallback?.Invoke(0, images.Count); + foreach (var image in images) + { + if (cancelToken.IsCancellationRequested) return false; + var pixelFormat = + image.LogicalPixelFormat == ImagePixelFormat.BW1 || compression == TiffCompressionType.Ccitt4 + ? ImagePixelFormat.BW1 + : image.LogicalPixelFormat; + WriteTiffMetadata(tiff, pixelFormat, compression, image); + WriteTiffImageData(tiff, pixelFormat, image); + if (images.Count > 1) + { + LibTiff.TIFFWriteDirectory(tiff); + } + progressCallback?.Invoke(++i, images.Count); + } + return true; + } + finally + { + LibTiff.TIFFClose(tiff); + } + } + + private unsafe void WriteTiffImageData(IntPtr tiff, ImagePixelFormat pixelFormat, IMemoryImage image) + { + var bufferInfo = new PixelInfo(image.Width, image.Height, pixelFormat switch + { + ImagePixelFormat.ARGB32 => SubPixelType.Rgba, + ImagePixelFormat.RGB24 => SubPixelType.Rgb, + ImagePixelFormat.Gray8 => SubPixelType.Gray, + ImagePixelFormat.BW1 => SubPixelType.Bit + }); + var buffer = new byte[bufferInfo.Length]; + new CopyBitwiseImageOp().Perform(image, buffer, bufferInfo); + fixed (byte* buf = buffer) + { + for (int i = 0; i < image.Height; i++) + { + LibTiff.TIFFWriteScanline(tiff, (IntPtr) (buf + bufferInfo.Stride * i), i, 0); + } + } + } + + private static void WriteTiffMetadata(IntPtr tiff, ImagePixelFormat pixelFormat, + TiffCompressionType compression, IMemoryImage image) + { + LibTiff.TIFFSetField(tiff, TiffTag.ImageWidth, image.Width); + LibTiff.TIFFSetField(tiff, TiffTag.ImageHeight, image.Height); + LibTiff.TIFFSetField(tiff, TiffTag.PlanarConfig, 1); + // TODO: Test setting g4 compression when it's not a BW image + LibTiff.TIFFSetField(tiff, TiffTag.Compression, (int) (compression switch + { + TiffCompressionType.Auto => pixelFormat == ImagePixelFormat.BW1 + ? TiffCompression.G4 + : TiffCompression.Lzw, + TiffCompressionType.Ccitt4 => TiffCompression.G4, + TiffCompressionType.Lzw => TiffCompression.Lzw, + TiffCompressionType.None => TiffCompression.None + })); + LibTiff.TIFFSetField(tiff, TiffTag.Orientation, 1); + LibTiff.TIFFSetField(tiff, TiffTag.BitsPerSample, pixelFormat == ImagePixelFormat.BW1 ? 1 : 8); + LibTiff.TIFFSetField(tiff, TiffTag.SamplesPerPixel, pixelFormat switch + { + ImagePixelFormat.RGB24 => 3, + ImagePixelFormat.ARGB32 => 4, + _ => 1 + }); + LibTiff.TIFFSetField(tiff, TiffTag.Photometric, (int) (pixelFormat switch + { + ImagePixelFormat.RGB24 or ImagePixelFormat.ARGB32 => TiffPhotometric.Rgb, + _ => TiffPhotometric.MinIsBlack + })); + if (pixelFormat == ImagePixelFormat.ARGB32) + { + LibTiff.TIFFSetField(tiff, TiffTag.ExtraSamples, 1); + } + if (image.HorizontalResolution != 0 && image.VerticalResolution != 0) + { + LibTiff.TIFFSetField(tiff, TiffTag.ResolutionUnit, 2); + LibTiff.TIFFSetField(tiff, TiffTag.XResolution, (int) image.HorizontalResolution); + LibTiff.TIFFSetField(tiff, TiffTag.YResolution, (int) image.VerticalResolution); + } } private IEnumerable LoadTiff(Stream stream, out int count) { - var c = new LibTiffStreamClient(stream); - var tiff = c.TIFFClientOpen("r"); + var client = new LibTiffStreamClient(stream); + var tiff = client.TIFFClientOpen("r"); count = LibTiff.TIFFNumberOfDirectories(tiff); - return EnumerateTiffFrames(tiff, c); + return EnumerateTiffFrames(tiff, client); } private IEnumerable LoadTiff(string path, out int count) { var tiff = LibTiff.TIFFOpen(path, "r"); count = LibTiff.TIFFNumberOfDirectories(tiff); - return EnumerateTiffFrames(tiff, null); + return EnumerateTiffFrames(tiff); } - private IEnumerable EnumerateTiffFrames(IntPtr tiff, LibTiffStreamClient? client) + private IEnumerable EnumerateTiffFrames(IntPtr tiff, LibTiffStreamClient? client = null) { // We keep a reference to the client to avoid garbage collection try { do { - LibTiff.TIFFGetField(tiff, 256, out var w); - LibTiff.TIFFGetField(tiff, 257, out var h); + LibTiff.TIFFGetField(tiff, TiffTag.ImageWidth, out var w); + LibTiff.TIFFGetField(tiff, TiffTag.ImageHeight, out var h); var img = Create(w, h, ImagePixelFormat.ARGB32); img.OriginalFileFormat = ImageFileFormat.Tiff; using var imageLock = img.Lock(LockMode.WriteOnly, out var data); diff --git a/NAPS2.Images.Gtk/LibTiff.cs b/NAPS2.Images.Gtk/LibTiff.cs index 524c153ca..aec39e0a2 100644 --- a/NAPS2.Images.Gtk/LibTiff.cs +++ b/NAPS2.Images.Gtk/LibTiff.cs @@ -6,7 +6,7 @@ using tdata_t = System.IntPtr; namespace NAPS2.Images.Gtk; -public static class LibTiff +internal static class LibTiff { // TODO: String marshalling? [DllImport("libtiff.so.5")] @@ -54,7 +54,17 @@ public static class LibTiff public static extern int TIFFReadDirectory(IntPtr tiff); [DllImport("libtiff.so.5")] - public static extern int TIFFGetField(IntPtr tiff, int tag, out int field); + public static extern int TIFFWriteDirectory(IntPtr tiff); + + [DllImport("libtiff.so.5")] + public static extern int TIFFGetField(IntPtr tiff, TiffTag tag, out int field); + + [DllImport("libtiff.so.5")] + public static extern int TIFFSetField(IntPtr tiff, TiffTag tag, int field); + + [DllImport("libtiff.so.5")] + public static extern int TIFFWriteScanline( + IntPtr tiff, tdata_t buf, int row, short sample); [DllImport("libtiff.so.5")] public static extern int TIFFReadRGBAImage( diff --git a/NAPS2.Images.Gtk/LibTiffStreamClient.cs b/NAPS2.Images.Gtk/LibTiffStreamClient.cs index 8155688e9..bab69515f 100644 --- a/NAPS2.Images.Gtk/LibTiffStreamClient.cs +++ b/NAPS2.Images.Gtk/LibTiffStreamClient.cs @@ -6,7 +6,7 @@ using tdata_t = System.IntPtr; namespace NAPS2.Images.Gtk; -public class LibTiffStreamClient +internal class LibTiffStreamClient { private readonly Stream _stream; private readonly LibTiff.TIFFErrorHandler _error; diff --git a/NAPS2.Images.Gtk/TiffCompression.cs b/NAPS2.Images.Gtk/TiffCompression.cs new file mode 100644 index 000000000..a1fe41dce --- /dev/null +++ b/NAPS2.Images.Gtk/TiffCompression.cs @@ -0,0 +1,11 @@ +namespace NAPS2.Images.Gtk; + +internal enum TiffCompression +{ + None = 1, + G4 = 4, + Lzw = 5, + Jpeg = 7, + Deflate = 32946, + Lzma = 34925 +} \ No newline at end of file diff --git a/NAPS2.Images.Gtk/TiffPhotometric.cs b/NAPS2.Images.Gtk/TiffPhotometric.cs new file mode 100644 index 000000000..25154cb79 --- /dev/null +++ b/NAPS2.Images.Gtk/TiffPhotometric.cs @@ -0,0 +1,7 @@ +namespace NAPS2.Images.Gtk; + +internal enum TiffPhotometric +{ + MinIsBlack = 1, + Rgb = 2 +} \ No newline at end of file diff --git a/NAPS2.Images.Gtk/TiffTag.cs b/NAPS2.Images.Gtk/TiffTag.cs new file mode 100644 index 000000000..f4f0cc49f --- /dev/null +++ b/NAPS2.Images.Gtk/TiffTag.cs @@ -0,0 +1,22 @@ +namespace NAPS2.Images.Gtk; + +internal enum TiffTag +{ + SubFileType = 254, + ImageWidth = 256, + ImageHeight = 257, + BitsPerSample = 258, + Compression = 259, + Photometric = 262, + DocumentName = 269, + ImageDescription = 270, + Make = 271, + Model = 272, + Orientation = 274, + SamplesPerPixel = 277, + XResolution = 282, + YResolution = 283, + PlanarConfig = 284, + ResolutionUnit = 296, + ExtraSamples = 338 +} \ No newline at end of file diff --git a/NAPS2.Images/Storage/ImageContext.cs b/NAPS2.Images/Storage/ImageContext.cs index f2b52b9e1..90ee4af1b 100644 --- a/NAPS2.Images/Storage/ImageContext.cs +++ b/NAPS2.Images/Storage/ImageContext.cs @@ -162,6 +162,7 @@ public abstract class ImageContext /// public abstract IEnumerable LoadFrames(string path, out int count); + // TODO: Instead of having these methods directly here, instead have a TiffWriter property public abstract bool SaveTiff(IList images, string path, TiffCompressionType compression = TiffCompressionType.Auto, Action? progressCallback = null, CancellationToken cancelToken = default); diff --git a/NAPS2.Sdk.Tests/Images/LoadSaveTests.cs b/NAPS2.Sdk.Tests/Images/LoadSaveTests.cs index e920eeadd..bf7a8cbf7 100644 --- a/NAPS2.Sdk.Tests/Images/LoadSaveTests.cs +++ b/NAPS2.Sdk.Tests/Images/LoadSaveTests.cs @@ -6,7 +6,8 @@ namespace NAPS2.Sdk.Tests.Images; public class LoadSaveTests : ContextualTests { - // TODO: Add tests for error/edge cases (e.g. invalid files, mismatched extensions/format (?), tiff progress) + // TODO: Add tests for error/edge cases (e.g. invalid files, mismatched extensions/format (?), tiff progress, saving 0/null images, unicode file names) + // TODO: Verify rough expected file sizes to ensure compression is as expected (especially for tiff saving) [Theory] [MemberData(nameof(TestCases))] @@ -89,12 +90,7 @@ public class LoadSaveTests : ContextualTests var original = LoadImage(ImageResources.color_image); ImageContext.SaveTiff(new[] { original }, path); - var actual = ImageContext.LoadFrames(path, out var count).ToArray(); - - Assert.Equal(1, count); - Assert.Single(actual); - Assert.Equal(ImageFileFormat.Tiff, actual[0].OriginalFileFormat); - ImageAsserts.Similar(ImageResources.color_image, actual[0]); + AssertTiff(path, ImageResources.color_image); } [Fact] @@ -104,21 +100,12 @@ public class LoadSaveTests : ContextualTests var original = new[] { LoadImage(ImageResources.color_image), - LoadImage(ImageResources.color_image_h_p300), + LoadImage(ImageResources.color_image_bw).PerformTransform(new BlackWhiteTransform()), LoadImage(ImageResources.stock_cat) }; ImageContext.SaveTiff(original, path); - var actual = ImageContext.LoadFrames(path, out var count).ToArray(); - - Assert.Equal(3, count); - Assert.Equal(3, original.Length); - Assert.Equal(ImageFileFormat.Tiff, actual[0].OriginalFileFormat); - ImageAsserts.Similar(ImageResources.color_image, actual[0]); - Assert.Equal(ImageFileFormat.Tiff, actual[1].OriginalFileFormat); - ImageAsserts.Similar(ImageResources.color_image_h_p300, actual[1]); - Assert.Equal(ImageFileFormat.Tiff, actual[2].OriginalFileFormat); - ImageAsserts.Similar(ImageResources.stock_cat, actual[2]); + AssertTiff(path, ImageResources.color_image, ImageResources.color_image_bw, ImageResources.stock_cat); } [Fact] @@ -128,13 +115,7 @@ public class LoadSaveTests : ContextualTests var original = LoadImage(ImageResources.color_image); ImageContext.SaveTiff(new[] { original }, stream); - stream.Seek(0, SeekOrigin.Begin); - var actual = ImageContext.LoadFrames(stream, out var count).ToArray(); - - Assert.Equal(1, count); - Assert.Single(actual); - Assert.Equal(ImageFileFormat.Tiff, actual[0].OriginalFileFormat); - ImageAsserts.Similar(ImageResources.color_image, actual[0]); + AssertTiff(stream, ImageResources.color_image); } [Fact] @@ -144,22 +125,57 @@ public class LoadSaveTests : ContextualTests var original = new[] { LoadImage(ImageResources.color_image), - LoadImage(ImageResources.color_image_h_p300), + LoadImage(ImageResources.color_image_bw).PerformTransform(new BlackWhiteTransform()), LoadImage(ImageResources.stock_cat) }; ImageContext.SaveTiff(original, stream); + AssertTiff(stream, ImageResources.color_image, ImageResources.color_image_bw, ImageResources.stock_cat); + } + + [Fact] + public void SaveBlackAndWhiteTiff() + { + var path = Path.Combine(FolderPath, "image.tiff"); + var original = LoadImage(ImageResources.color_image_bw); + original = original.PerformTransform(new BlackWhiteTransform()); + + ImageContext.SaveTiff(new[] { original }, path); + AssertTiff(path, ImageResources.color_image_bw); + } + + [Fact] + public void SaveColorTiffWithG4() + { + var path = Path.Combine(FolderPath, "image.tiff"); + var original = LoadImage(ImageResources.color_image); + + ImageContext.SaveTiff(new[] { original }, path, TiffCompressionType.Ccitt4); + AssertTiff(path, ImageResources.color_image_bw); + } + + private void AssertTiff(string path, params byte[][] expectedImages) + { + var actual = ImageContext.LoadFrames(path, out var count).ToArray(); + DoAssertTiff(actual, count, expectedImages); + } + + private void AssertTiff(Stream stream, params byte[][] expectedImages) + { stream.Seek(0, SeekOrigin.Begin); var actual = ImageContext.LoadFrames(stream, out var count).ToArray(); + DoAssertTiff(actual, count, expectedImages); + } - Assert.Equal(3, count); - Assert.Equal(3, original.Length); - Assert.Equal(ImageFileFormat.Tiff, actual[0].OriginalFileFormat); - ImageAsserts.Similar(ImageResources.color_image, actual[0]); - Assert.Equal(ImageFileFormat.Tiff, actual[1].OriginalFileFormat); - ImageAsserts.Similar(ImageResources.color_image_h_p300, actual[1]); - Assert.Equal(ImageFileFormat.Tiff, actual[2].OriginalFileFormat); - ImageAsserts.Similar(ImageResources.stock_cat, actual[2]); + private static void DoAssertTiff(IMemoryImage[] actual, int count, byte[][] expectedImages) + { + Assert.Equal(expectedImages.Length, count); + Assert.Equal(expectedImages.Length, actual.Length); + for (int i = 0; i < expectedImages.Length; i++) + { + Assert.Equal(ImageFileFormat.Tiff, actual[i].OriginalFileFormat); + ImageAsserts.Similar(expectedImages[i], actual[i]); + } } private static byte[] GetResource(string resource) =>