diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/Data/DW-40 MixedDepthMultiPage.tif b/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/Data/DW-40 MixedDepthMultiPage.tif new file mode 100644 index 0000000..96846cc Binary files /dev/null and b/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/Data/DW-40 MixedDepthMultiPage.tif differ diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/AnyBitmapFunctionality.cs b/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/AnyBitmapFunctionality.cs index 66a5b5d..979f520 100644 --- a/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/AnyBitmapFunctionality.cs +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/AnyBitmapFunctionality.cs @@ -977,7 +977,7 @@ public void Should_Return_BitsPerPixel() [InlineData("ScanDev_BW.tif", 1)] [InlineData("ScanDev_Gray.tif", 8)] [InlineData("ScanDev_Color.tif", 24)] - public void DW_9_LoadImage_ShouldReturnOriginalBitsPerPixel(string fileName, int expectedBitsPerPixel) + public void LoadImage_ShouldReturnOriginalBitsPerPixel(string fileName, int expectedBitsPerPixel) { string imagePath = GetRelativeFilePath(fileName); @@ -987,7 +987,7 @@ public void DW_9_LoadImage_ShouldReturnOriginalBitsPerPixel(string fileName, int } [IgnoreOnUnixFact] - public void DW_9_LoadBlackAndWhiteTiff_ShouldReturnOriginalBitsPerPixel_AndAllowChangingBpp() + public void LoadBlackAndWhiteTiff_ShouldReturnOriginalBitsPerPixel_AndAllowChangingBpp() { string imagePath = GetRelativeFilePath("tifimg.tif"); @@ -1004,7 +1004,7 @@ public void DW_9_LoadBlackAndWhiteTiff_ShouldReturnOriginalBitsPerPixel_AndAllow } [FactWithAutomaticDisplayName] - public void DW_9_LoadImage_NotPreservingOriginalFormat_ShouldReturn32BitsPerPixel() + public void LoadImage_NotPreservingOriginalFormat_ShouldReturn32BitsPerPixel() { string imagePath = GetRelativeFilePath("ScanDev_BW.tif"); @@ -1017,7 +1017,7 @@ public void DW_9_LoadImage_NotPreservingOriginalFormat_ShouldReturn32BitsPerPixe [InlineData(8)] [InlineData(24)] [InlineData(32)] - public void DW_9_ChangeBitsPerPixel_ShouldReturnRequestedColorDepth(int targetBitsPerPixel) + public void ChangeBitsPerPixel_ShouldReturnRequestedColorDepth(int targetBitsPerPixel) { string imagePath = GetRelativeFilePath("ScanDev_BW.tif"); var bitmap = AnyBitmap.FromFile(imagePath); @@ -1030,7 +1030,7 @@ public void DW_9_ChangeBitsPerPixel_ShouldReturnRequestedColorDepth(int targetBi } [FactWithAutomaticDisplayName] - public void DW_9_ChangeBitsPerPixel_WithUnsupportedDepth_ShouldThrow() + public void ChangeBitsPerPixel_WithUnsupportedDepth_ShouldThrow() { string imagePath = GetRelativeFilePath("ScanDev_BW.tif"); var bitmap = AnyBitmap.FromFile(imagePath); @@ -1039,7 +1039,7 @@ public void DW_9_ChangeBitsPerPixel_WithUnsupportedDepth_ShouldThrow() } [FactWithAutomaticDisplayName] - public void DW_9_BitsPerPixel_IsIntentionallyDecoupledFromStrideAndScan0() + public void BitsPerPixel_IsIntentionallyDecoupledFromStrideAndScan0() { // A 1bpp source is decoded into a 32bpp buffer in memory. BitsPerPixel reports the // original depth (1), but Stride and Scan0 must describe the decoded 32bpp buffer. @@ -1059,7 +1059,7 @@ public void DW_9_BitsPerPixel_IsIntentionallyDecoupledFromStrideAndScan0() } [FactWithAutomaticDisplayName] - public void DW_9_ChangeBitsPerPixel_DurabilityDependsOnEncoder() + public void ChangeBitsPerPixel_DurabilityDependsOnEncoder() { string imagePath = GetRelativeFilePath("ScanDev_BW.tif"); var converted = AnyBitmap.FromFile(imagePath).ChangeBitsPerPixel(8); @@ -1079,6 +1079,104 @@ public void DW_9_ChangeBitsPerPixel_DurabilityDependsOnEncoder() CleanResultFile("dw9_roundtrip.bmp"); } + [FactWithAutomaticDisplayName] + public void GetAllFrames_ShouldReturnOriginalBitsPerPixelPerFrame() + { + var singleFrame = AnyBitmap.FromFile(GetRelativeFilePath("ScanDev_BW.tif")).GetAllFrames.ToList(); + Assert.Single(singleFrame); + Assert.Equal(1, singleFrame[0].BitsPerPixel); + + // Multi-page TIFF whose pages differ in depth: each frame reports its own source depth. + var frames = AnyBitmap.FromFile(GetRelativeFilePath("DW-40 MixedDepthMultiPage.tif")).GetAllFrames.ToList(); + Assert.Equal(new[] { 1, 8, 24 }, frames.Select(f => f.BitsPerPixel)); + } + + [FactWithAutomaticDisplayName] + public void CloneRectangle_ShouldPreserveOriginalBitsPerPixel() + { + var bitmap = AnyBitmap.FromFile(GetRelativeFilePath("ScanDev_BW.tif")); + + var cropped = bitmap.Clone(new Rectangle(0, 0, 100, 100)); + + Assert.Equal(1, cropped.BitsPerPixel); + } + + [FactWithAutomaticDisplayName] + public void RotateFlip_ShouldPreserveOriginalBitsPerPixel() + { + var bitmap = AnyBitmap.FromFile(GetRelativeFilePath("ScanDev_BW.tif")); + + var rotated = bitmap.RotateFlip(AnyBitmap.RotateMode.Rotate90, AnyBitmap.FlipMode.None); + + Assert.Equal(1, rotated.BitsPerPixel); + } + + [FactWithAutomaticDisplayName] + public void Redact_ShouldPreserveOriginalBitsPerPixel() + { + var bitmap = AnyBitmap.FromFile(GetRelativeFilePath("ScanDev_BW.tif")); + + var redacted = bitmap.Redact(new Rectangle(0, 0, 10, 10), Color.Black); + + Assert.Equal(1, redacted.BitsPerPixel); + } + + [FactWithAutomaticDisplayName] + public void Resize_ShouldReportHonestDepthForSubEightBppSource() + { + var bitmap = AnyBitmap.FromFile(GetRelativeFilePath("ScanDev_BW.tif")); + Assert.Equal(1, bitmap.BitsPerPixel); + + var resized = new AnyBitmap(bitmap, 50, 50); + + Assert.Equal(32, resized.BitsPerPixel); + } + + [FactWithAutomaticDisplayName] + public void Resize_ShouldPreserveDepthForRepresentableFormats() + { + var rgb24 = AnyBitmap.FromFile(GetRelativeFilePath("24_bit.png")); + Assert.Equal(24, rgb24.BitsPerPixel); + Assert.Equal(24, new AnyBitmap(rgb24, 20, 20).BitsPerPixel); + + string path64 = "dw40_tmp64.png"; + using (var img64 = new Image(30, 30)) { img64.SaveAsPng(path64); } + try + { + var rgba64 = AnyBitmap.FromFile(path64); + Assert.Equal(64, rgba64.BitsPerPixel); + Assert.Equal(64, new AnyBitmap(rgba64, 15, 15).BitsPerPixel); + } + finally + { + CleanResultFile(path64); + } + } + + [FactWithAutomaticDisplayName] + public void Resize_ShouldPreserveFrameCountForMultiPageTiff() + { + // Resizing a multi-page TIFF resizes every page, so the frame count is preserved. + var multiPage = AnyBitmap.FromFile(GetRelativeFilePath("DW-40 MixedDepthMultiPage.tif")); + Assert.Equal(3, multiPage.FrameCount); + + var resized = new AnyBitmap(multiPage, 8, 8); + + Assert.Equal(3, resized.FrameCount); + Assert.Equal(3, resized.GetAllFrames.Count()); + } + + [FactWithAutomaticDisplayName] + public void CloneRectangle_ShouldPreservePerFrameDepthForMultiPageTiff() + { + // Cropping keeps frames 1:1, so each cropped frame still reports its own source depth. + var multiPage = AnyBitmap.FromFile(GetRelativeFilePath("DW-40 MixedDepthMultiPage.tif")); + + var cropped = multiPage.Clone(new Rectangle(0, 0, 10, 10)); + + Assert.Equal(new[] { 1, 8, 24 }, cropped.GetAllFrames.Select(f => f.BitsPerPixel)); + } + [TheoryWithAutomaticDisplayName()] [InlineData("mountainclimbers.jpg", "image/jpeg", AnyBitmap.ImageFormat.Jpeg)] [InlineData("watermark.deployment.png", "image/png", AnyBitmap.ImageFormat.Png)] diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common/AnyBitmap.cs b/IronSoftware.Drawing/IronSoftware.Drawing.Common/AnyBitmap.cs index dea6ec0..70c4dc3 100644 --- a/IronSoftware.Drawing/IronSoftware.Drawing.Common/AnyBitmap.cs +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common/AnyBitmap.cs @@ -241,7 +241,12 @@ public AnyBitmap Clone() public AnyBitmap Clone(Rectangle rectangle) { var cloned = GetInternalImages().Select(img => img.Clone(x => x.Crop(rectangle))); - return new AnyBitmap(cloned); + var result = new AnyBitmap(cloned); + // Cropping is lossless (it only removes pixels) and keeps frames 1:1 with GetInternalImages, + // so carry both the scalar and the per-frame source depths over. + result._originalBitsPerPixel = _originalBitsPerPixel; + result._framesOriginalBitsPerPixel = _framesOriginalBitsPerPixel; + return result; } /// @@ -711,9 +716,17 @@ public AnyBitmap(Stream stream, bool preserveOriginalFormat) } /// - /// + /// Creates a new by resizing to the + /// given dimensions. + /// Color depth: resizing resamples (blends) pixels, so + /// of the result reflects the resized image and may differ from the source. In particular a + /// source whose original depth is below 8bpp (e.g. a 1bpp black & white image) cannot exist + /// below 8bpp in memory, so the resized result reports its actual decoded depth rather than the + /// original low depth. The depth of 8/24/32/64bpp sources is otherwise preserved. + /// Multi-page sources: every page/frame is resized, so a multi-page TIFF keeps its + /// frame count. /// - /// The from which to + /// The from which to /// create the new . /// The width of the new AnyBitmap. /// The height of the new AnyBitmap. @@ -836,6 +849,19 @@ internal AnyBitmap(Image image) : this([image]) { } + /// + /// Wraps an already-decoded image while carrying over the original source color depth. + /// Used by derived operations (RotateFlip, Redact) so the cast operator + /// stays free of any assumed original depth. + /// + /// The decoded image to wrap. + /// The original source color depth to report from + /// , or null to report the in-memory decoded depth. + private AnyBitmap(Image image, int? originalBitsPerPixel) : this(image) + { + _originalBitsPerPixel = originalBitsPerPixel; + } + /// /// Note: This only use for Casting It won't create new object Image /// @@ -1055,6 +1081,14 @@ public static AnyBitmap LoadAnyBitmapFromRGBBuffer(byte[] buffer, int width, int /// private int? _originalBitsPerPixel = null; + /// + /// The original color depth (bits per pixel) of each frame of a multi-page TIFF, in the same + /// order as the decoded frames. Populated while decoding the TIFF so that + /// can report each frame's true source depth (pages of a TIFF may differ in depth). Remains + /// null for non-TIFF sources. + /// + private IReadOnlyList _framesOriginalBitsPerPixel = null; + //cache of the bits per pixel of the in-memory (decoded) image private int InMemoryBitsPerPixel => _bitsPerPixel ??= GetFirstInternalImage().PixelType.BitsPerPixel; @@ -1150,14 +1184,26 @@ public IEnumerable GetAllFrames get { var images = GetInternalImages(); - if (images.Count == 1) + IEnumerable frames = images.Count == 1 + ? ImageFrameCollectionToImages(images[0].Frames).Select(x => (AnyBitmap)x) + : images.Select(x => (AnyBitmap)x); + + // Each frame is a freshly-cast AnyBitmap. Carry the captured original source depth + // onto each frame. Only do this when this object captured an original depth (i.e. a + // TIFF loaded preserving its original format); otherwise leave frames as-is. + if (_originalBitsPerPixel == null) { - return ImageFrameCollectionToImages(images[0].Frames).Select(x => (AnyBitmap)x); + return frames; } - else + + IReadOnlyList perFrame = _framesOriginalBitsPerPixel; + return frames.Select((frame, index) => { - return images.Select(x => (AnyBitmap)x); - } + frame._originalBitsPerPixel = perFrame != null && index < perFrame.Count + ? perFrame[index] + : _originalBitsPerPixel; + return frame; + }); } } @@ -1440,7 +1486,9 @@ public static AnyBitmap RotateFlip( image.Mutate(x => x.RotateFlip(rotateModeImgSharp, flipModeImgSharp)); - return new AnyBitmap(image); + // Rotating/flipping is lossless (it only moves pixels), so the source color depth is + // carried over. + return new AnyBitmap(image, bitmap._originalBitsPerPixel); } /// @@ -1476,8 +1524,13 @@ public static AnyBitmap Redact( Rectangle rectangle = Rectangle; var brush = new SolidBrush(color); image.Mutate(ctx => ctx.Fill(brush, rectangle)); - - return new AnyBitmap(image); + + // Redact fills a region but leaves the rest of the image untouched, so it carries the + // source's declared color depth as the (decoupled, in-memory) BitsPerPixel label, + // consistent with the loaded image. This is a label only: if the redaction color cannot + // exist at that depth it is not literally representable, and the label is dropped on + // re-encode. (Unlike resize, which resamples the whole image and is reported honestly.) + return new AnyBitmap(image, bitmap._originalBitsPerPixel); } /// @@ -3024,18 +3077,12 @@ public override void WarningHandlerExt(Tiff tif, object clientData, string metho using var tiff = Tiff.ClientOpen("in-memory", "r", tiffStream, new TiffStream()); if (tiff == null) return (1, null); // Default to single frame if can't read - // Read frame-0 fields before NumberOfDirectories(), which may move the active directory. - FieldValue[] bitsPerSampleField = tiff.GetField(TiffTag.BITSPERSAMPLE); - FieldValue[] samplesPerPixelField = tiff.GetField(TiffTag.SAMPLESPERPIXEL); - - // BitsPerSample defaults to 1 and SamplesPerPixel defaults to 1 per the TIFF spec. - int bitsPerSample = bitsPerSampleField != null ? bitsPerSampleField[0].ToInt() : 1; - int samplesPerPixel = samplesPerPixelField != null ? samplesPerPixelField[0].ToInt() : 1; - int bitsPerPixel = bitsPerSample * samplesPerPixel; + // Read frame-0 depth before NumberOfDirectories(), which may move the active directory. + int? bitsPerPixel = ReadDirectoryBitsPerPixel(tiff); int frameCount = tiff.NumberOfDirectories(); - return (frameCount, bitsPerPixel > 0 ? bitsPerPixel : (int?)null); + return (frameCount, bitsPerPixel); } catch { @@ -3043,6 +3090,24 @@ public override void WarningHandlerExt(Tiff tif, object clientData, string metho } } + /// + /// Reads the original bits per pixel (BitsPerSample x SamplesPerPixel) of the TIFF directory + /// that is currently active on . + /// + /// The bits per pixel, or null if it cannot be determined. + private static int? ReadDirectoryBitsPerPixel(Tiff tiff) + { + FieldValue[] bitsPerSampleField = tiff.GetField(TiffTag.BITSPERSAMPLE); + FieldValue[] samplesPerPixelField = tiff.GetField(TiffTag.SAMPLESPERPIXEL); + + // BitsPerSample defaults to 1 and SamplesPerPixel defaults to 1 per the TIFF spec. + int bitsPerSample = bitsPerSampleField != null ? bitsPerSampleField[0].ToInt() : 1; + int samplesPerPixel = samplesPerPixelField != null ? samplesPerPixelField[0].ToInt() : 1; + int bitsPerPixel = bitsPerSample * samplesPerPixel; + + return bitsPerPixel > 0 ? bitsPerPixel : (int?)null; + } + private Lazy> OpenTiffToImageSharp() { return new Lazy>(() => @@ -3105,6 +3170,9 @@ private List ReadTiffFrames(Tiff tiff) SetTiffCompression(tiff); List images = new(); + // Original source depth per decoded frame, kept in the same order as 'images' (thumbnails + // are skipped in both) so GetAllFrames can report each frame's true source depth. + List framesBitsPerPixel = new(); int index = 0; do @@ -3125,6 +3193,9 @@ private List ReadTiffFrames(Tiff tiff) "Split this page into smaller images before loading."); } + // Capture this page's original depth before ReadRGBAImage decodes it to 32bpp. + int? frameBitsPerPixel = ReadDirectoryBitsPerPixel(tiff); + // Read the image into the memory buffer int[] raster = new int[height * width]; if (!tiff.ReadRGBAImage(width, height, raster)) @@ -3139,6 +3210,7 @@ private List ReadTiffFrames(Tiff tiff) image.Metadata.HorizontalResolution = horizontalResolution; image.Metadata.VerticalResolution = verticalResolution; images.Add(image); + framesBitsPerPixel.Add(frameBitsPerPixel); //Note1: it might be some case that the bytes of current Image is smaller/bigger than the original tiff //Note2: 'yield return' make it super slow @@ -3148,6 +3220,7 @@ private List ReadTiffFrames(Tiff tiff) } while (tiff.ReadDirectory()); + _framesOriginalBitsPerPixel = framesBitsPerPixel; return images; } @@ -3617,21 +3690,25 @@ private void SetPixelColor(int x, int y, Color color) private void LoadAndResizeImage(AnyBitmap original, int width, int height) { - //this prevent case when original is changed before Lazy is loaded - Binary = original.Binary; + // Capture the source's decoded images up front so we are independent of the original's + // lifetime. Every page/frame is resized so multi-page TIFFs keep their frame count. + IReadOnlyList sourceImages = original.GetInternalImages(); _lazyImage = new Lazy>(() => { + // Resize a clone of each page/frame so the source pixel type (and therefore its + // color depth) is preserved. + var resized = sourceImages + .Select(img => img.Clone(c => c.Resize(width, height))) + .ToList(); - var image = Image.Load(Binary); - image.Mutate(img => img.Resize(width, height)); - - //update Binary - using var memoryStream = new MemoryStream(); - image.Save(memoryStream, GetDefaultImageEncoder(image.Width, image.Height)); - Binary = memoryStream.ToArray(); + using (var memoryStream = new MemoryStream()) + { + resized[0].Save(memoryStream, GetDefaultImageEncoder(resized[0].Width, resized[0].Height)); + Binary = memoryStream.ToArray(); + } - return [image]; + return resized; }); ForceLoadLazyImage();