From b08be16fe55066618d69e4550219560314696cf4 Mon Sep 17 00:00:00 2001 From: Sawraz Date: Thu, 9 Jul 2026 15:17:40 +0600 Subject: [PATCH] DW-40: carry original BitsPerPixel through GetAllFrames/Clone/RotateFlip/Redact and preserve depth on resize --- .../Data/DW-40 MixedDepthMultiPage.tif | Bin 0 -> 1456 bytes .../UnitTests/AnyBitmapFunctionality.cs | 74 ++++++++++++ .../IronSoftware.Drawing.Common/AnyBitmap.cs | 109 +++++++++++++----- 3 files changed, 157 insertions(+), 26 deletions(-) create mode 100644 IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/Data/DW-40 MixedDepthMultiPage.tif 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 0000000000000000000000000000000000000000..96846cc0d67177fdaf2e72cdf76ca720f7b0e2ff GIT binary patch literal 1456 zcmah}*HXhk47_vi1#q0=K>a!90e^kbzW?XU> z5GRPU6@|GZE2^d&re!;>=Mxw-vq9J(2jKxe3-Az?67XnOIfqgGpaPDn=aw)o+o<5U z=&FX3yvZG$ik=ai5tuZy$-F^M=EuY88z9n<`di=(sV~Na)nBRm>f<+o=1vx;XXh7} zSJyYU%e&S6!y}!JP&gXJZ^Mo;PV2Iv``Zh!S`O{d@6OtyZa?&#|s=+D$WJ+CV0 zFVtP#%H;G{>b_1P3;G*%XP53J{hhkEV@wtOgSxwYsx|$Sy1&%~JNg&hjuwlhi2hBt yXWx+x`VZZ%Jp(uCzx~@M!-D+5mb7i31i9+lw*pXYpE%R@?PHL@+Xpme f.BitsPerPixel)); + } + + [FactWithAutomaticDisplayName] + public void DW_40_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 DW_40_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 DW_40_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 DW_40_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 DW_40_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); + } + } + [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 90936aa..0415099 100644 --- a/IronSoftware.Drawing/IronSoftware.Drawing.Common/AnyBitmap.cs +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common/AnyBitmap.cs @@ -225,7 +225,11 @@ 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 preserves the source color depth, so carry the original depth over (otherwise + // it would fall back to the decoded 32bpp value). + result._originalBitsPerPixel = _originalBitsPerPixel; + return result; } /// @@ -695,9 +699,15 @@ 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. /// - /// The from which to + /// The from which to /// create the new . /// The width of the new AnyBitmap. /// The height of the new AnyBitmap. @@ -990,6 +1000,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; @@ -1085,14 +1103,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; + }); } } @@ -1375,7 +1405,11 @@ public static AnyBitmap RotateFlip( image.Mutate(x => x.RotateFlip(rotateModeImgSharp, flipModeImgSharp)); - return new AnyBitmap(image); + var result = new AnyBitmap(image); + // Rotating/flipping preserves the source color depth, so carry the original depth over + // (otherwise it would fall back to the decoded 32bpp value). + result._originalBitsPerPixel = bitmap._originalBitsPerPixel; + return result; } /// @@ -1411,8 +1445,12 @@ public static AnyBitmap Redact( Rectangle rectangle = Rectangle; var brush = new SolidBrush(color); image.Mutate(ctx => ctx.Fill(brush, rectangle)); - - return new AnyBitmap(image); + + var result = new AnyBitmap(image); + // Redacting a region preserves the source color depth, so carry the original depth over + // (otherwise it would fall back to the decoded 32bpp value). + result._originalBitsPerPixel = bitmap._originalBitsPerPixel; + return result; } /// @@ -2959,18 +2997,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 { @@ -2978,6 +3010,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>(() => @@ -3011,6 +3061,8 @@ private IReadOnlyList InternalLoadTiff() // Disable warning messages Tiff.SetErrorHandler(new DisableErrorHandler()); List images = new(); + // Original source depth per decoded frame. + List framesBitsPerPixel = new(); // open a TIFF stored in the stream using (Tiff tiff = Tiff.ClientOpen("in-memory", "r", tiffStream, new TiffStream())) { @@ -3026,6 +3078,9 @@ private IReadOnlyList InternalLoadTiff() continue; } + // Capture this page's original depth before ReadRGBAImage decodes it to 32bpp. + int? frameBitsPerPixel = ReadDirectoryBitsPerPixel(tiff); + var (width, height, horizontalResolution, verticalResolution) = SetWidthHeight(tiff, i, ref imageWidth, ref imageHeight, ref imageXResolution, ref imageYResolution); // Read the image into the memory buffer @@ -3036,18 +3091,20 @@ private IReadOnlyList InternalLoadTiff() } var bits = PrepareByteArray(raster, width, height, 32); - + var image = Image.LoadPixelData(bits, width, height); 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 } } + _framesOriginalBitsPerPixel = framesBitsPerPixel; return images; } @@ -3386,14 +3443,14 @@ 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 image up front so we are independent of the original's lifetime. + Image sourceImage = original.GetFirstInternalImage(); _lazyImage = new Lazy>(() => { - - var image = Image.Load(Binary); - image.Mutate(img => img.Resize(width, height)); + // Resize a clone of the already-decoded image so its pixel type (and therefore its + // color depth) is preserved. + var image = sourceImage.Clone(img => img.Resize(width, height)); //update Binary using var memoryStream = new MemoryStream();