diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/Data/ScanDev_BW.tif b/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/Data/ScanDev_BW.tif new file mode 100644 index 0000000..6adaad6 Binary files /dev/null and b/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/Data/ScanDev_BW.tif differ diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/Data/ScanDev_Color.tif b/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/Data/ScanDev_Color.tif new file mode 100644 index 0000000..5c6563f Binary files /dev/null and b/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/Data/ScanDev_Color.tif differ diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/Data/ScanDev_Gray.tif b/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/Data/ScanDev_Gray.tif new file mode 100644 index 0000000..018402c Binary files /dev/null and b/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/Data/ScanDev_Gray.tif differ diff --git a/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/Data/tifimg.tif b/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/Data/tifimg.tif new file mode 100644 index 0000000..8eb12c4 Binary files /dev/null and b/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/Data/tifimg.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 ef91a05..a327fab 100644 --- a/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/AnyBitmapFunctionality.cs +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common.Tests/UnitTests/AnyBitmapFunctionality.cs @@ -700,6 +700,112 @@ public void Should_Return_BitsPerPixel() Assert.Equal(32, bitmap.BitsPerPixel); } + [TheoryWithAutomaticDisplayName] + [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) + { + string imagePath = GetRelativeFilePath(fileName); + + var bitmap = AnyBitmap.FromFile(imagePath); + + Assert.Equal(expectedBitsPerPixel, bitmap.BitsPerPixel); + } + + [IgnoreOnUnixFact] + public void DW_9_LoadBlackAndWhiteTiff_ShouldReturnOriginalBitsPerPixel_AndAllowChangingBpp() + { + string imagePath = GetRelativeFilePath("tifimg.tif"); + + var bitmap = AnyBitmap.FromFile(imagePath); + + Assert.Equal(1, bitmap.BitsPerPixel); + Assert.Equal(PixelFormat.Format1bppIndexed, new Bitmap(imagePath).PixelFormat); + + var converted = bitmap.ChangeBitsPerPixel(24); + + Assert.Equal(24, converted.BitsPerPixel); + Assert.Equal(bitmap.Width, converted.Width); + Assert.Equal(bitmap.Height, converted.Height); + } + + [FactWithAutomaticDisplayName] + public void DW_9_LoadImage_NotPreservingOriginalFormat_ShouldReturn32BitsPerPixel() + { + string imagePath = GetRelativeFilePath("ScanDev_BW.tif"); + + var bitmap = AnyBitmap.FromFile(imagePath, preserveOriginalFormat: false); + + Assert.Equal(32, bitmap.BitsPerPixel); + } + + [TheoryWithAutomaticDisplayName] + [InlineData(8)] + [InlineData(24)] + [InlineData(32)] + public void DW_9_ChangeBitsPerPixel_ShouldReturnRequestedColorDepth(int targetBitsPerPixel) + { + string imagePath = GetRelativeFilePath("ScanDev_BW.tif"); + var bitmap = AnyBitmap.FromFile(imagePath); + + var converted = bitmap.ChangeBitsPerPixel(targetBitsPerPixel); + + Assert.Equal(targetBitsPerPixel, converted.BitsPerPixel); + Assert.Equal(bitmap.Width, converted.Width); + Assert.Equal(bitmap.Height, converted.Height); + } + + [FactWithAutomaticDisplayName] + public void DW_9_ChangeBitsPerPixel_WithUnsupportedDepth_ShouldThrow() + { + string imagePath = GetRelativeFilePath("ScanDev_BW.tif"); + var bitmap = AnyBitmap.FromFile(imagePath); + + Assert.Throws(() => bitmap.ChangeBitsPerPixel(16)); + } + + [FactWithAutomaticDisplayName] + public void DW_9_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. + // This locks that intentional decoupling against future re-coupling. + string imagePath = GetRelativeFilePath("ScanDev_BW.tif"); + var bitmap = AnyBitmap.FromFile(imagePath); + + Assert.Equal(1, bitmap.BitsPerPixel); + + int strideFor32Bpp = 4 * (((bitmap.Width * 32) + 31) / 32); + int strideForReportedBpp = 4 * (((bitmap.Width * bitmap.BitsPerPixel) + 31) / 32); + + // Stride follows the in-memory 32bpp buffer, not the reported BitsPerPixel. + Assert.Equal(strideFor32Bpp, bitmap.Stride); + Assert.NotEqual(strideForReportedBpp, bitmap.Stride); + Assert.NotEqual(IntPtr.Zero, bitmap.Scan0); + } + + [FactWithAutomaticDisplayName] + public void DW_9_ChangeBitsPerPixel_DurabilityDependsOnEncoder() + { + string imagePath = GetRelativeFilePath("ScanDev_BW.tif"); + var converted = AnyBitmap.FromFile(imagePath).ChangeBitsPerPixel(8); + + // In-memory the conversion is honored. + Assert.Equal(8, converted.BitsPerPixel); + + // PNG preserves the 8bpp depth. + converted.SaveAs("dw9_roundtrip.png", AnyBitmap.ImageFormat.Png); + Assert.Equal(8, AnyBitmap.FromFile("dw9_roundtrip.png").BitsPerPixel); + + converted.SaveAs("dw9_roundtrip.bmp"); + Assert.Equal(32, AnyBitmap.FromFile("dw9_roundtrip.bmp").BitsPerPixel); + Assert.Equal(32, AnyBitmap.FromBytes(converted.GetBytes()).BitsPerPixel); + + CleanResultFile("dw9_roundtrip.png"); + CleanResultFile("dw9_roundtrip.bmp"); + } + [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 b323b3c..b293ebc 100644 --- a/IronSoftware.Drawing/IronSoftware.Drawing.Common/AnyBitmap.cs +++ b/IronSoftware.Drawing/IronSoftware.Drawing.Common/AnyBitmap.cs @@ -980,13 +980,71 @@ public static AnyBitmap LoadAnyBitmapFromRGBBuffer(byte[] buffer, int width, int //cache private int? _bitsPerPixel = null; + + /// + /// The color depth (bits per pixel) of the original source image when it can be + /// determined from the source metadata (e.g. TIFF). SixLabors.ImageSharp has no + /// pixel format below 8bpp, so indexed/bilevel sources would otherwise misreport + /// their depth once decoded into memory (e.g. a 1bpp black & white TIFF that is + /// decoded to a 32bpp Rgba32 image). When set, this is reported by . + /// + private int? _originalBitsPerPixel = null; + + //cache of the bits per pixel of the in-memory (decoded) image + private int InMemoryBitsPerPixel => _bitsPerPixel ??= GetFirstInternalImage().PixelType.BitsPerPixel; + /// /// Gets colors depth, in number of bits per pixel. + /// When the image is loaded preserving its original format, this reports the + /// bits per pixel of the original source image (for example, 1 for a black & white + /// image) rather than the bits per pixel of the in-memory decoded representation. + /// Important: this value is intentionally decoupled from + /// and . SixLabors.ImageSharp has no pixel format below 8bpp, so a + /// source with a lower color depth (e.g. a 1bpp black & white TIFF) is always decoded + /// into a 32bpp buffer in memory. reports the original + /// depth, whereas and describe the decoded + /// 32bpp BGRA buffer. Do not size a buffer using + /// Width * Height * BitsPerPixel / 8; use instead. ///
Further Documentation:
/// /// Code Example
///
- public int BitsPerPixel => _bitsPerPixel ??= GetFirstInternalImage().PixelType.BitsPerPixel; + public int BitsPerPixel => _originalBitsPerPixel ?? InMemoryBitsPerPixel; + + /// + /// Creates a new with the pixel data converted to the requested + /// color depth, in number of bits per pixel. This is analogous to changing the + /// PixelFormat of a . + /// Single frame: only the first frame is converted. For a multi-page TIFF or + /// animated GIF the additional frames are not carried over to the returned image. + /// Durability: the conversion changes the in-memory pixel representation. + /// Whether the new depth survives a save depends on the target encoder: formats that honor + /// the pixel type (e.g. PNG) preserve it, whereas saving without an explicit format or + /// calling re-encodes via the default 32bpp BMP encoder and the + /// reduced depth is lost. + /// + /// The target color depth. Supported values are + /// 8 (grayscale), 24 (RGB) and 32 (RGBA). + /// A new whose equals + /// . + /// Thrown when + /// is not one of the supported values. + public AnyBitmap ChangeBitsPerPixel(int bitsPerPixel) + { + // Note: GetFirstInternalImage() only exposes frame 0, so this converts the first frame only. + Image source = GetFirstInternalImage(); + Image converted = bitsPerPixel switch + { + 8 => source.CloneAs(), + 24 => source.CloneAs(), + 32 => source.CloneAs(), + _ => throw new NotSupportedException( + $"Changing bits per pixel to {bitsPerPixel} is not supported. " + + $"Supported values are 8, 24 and 32.") + }; + + return new AnyBitmap(converted); + } //cache private int? _frameCount = null; @@ -1300,8 +1358,11 @@ public static AnyBitmap Redact( } /// - /// Gets the stride width (also called scan width) of the + /// Gets the stride width (also called scan width) of the /// object. + /// This describes the in-memory decoded 32bpp BGRA buffer (see ) + /// and is therefore independent of , which may report the lower + /// original color depth of the source image. /// public int Stride { @@ -1312,11 +1373,15 @@ public int Stride } /// - /// Gets the address of the first pixel data in the - /// . This can also be thought of as the first + /// Gets the address of the first pixel data in the + /// . This can also be thought of as the first /// scan line in the . + /// The pixel data is always the in-memory 32bpp BGRA representation, regardless of + /// the value reported by (which may be the lower original + /// color depth of the source image). Pair this with , not + /// , when computing buffer sizes. /// - /// The address of the first 32bpp BGRA pixel data in the + /// The address of the first 32bpp BGRA pixel data in the /// . public IntPtr Scan0 { @@ -2610,9 +2675,21 @@ private void LoadImage(Stream stream, bool preserveOriginalFormat) private void LoadImage(ReadOnlySpan span, bool preserveOriginalFormat) { Binary = span.ToArray(); - if (Format is TiffFormat) + if (Format is TiffFormat) { - if(GetTiffFrameCountFast() > 1) + // Read frame count and original bits per pixel in a single metadata pass. + var (frameCount, originalBitsPerPixel) = ReadTiffMetadataFast(); + + // TIFFs are decoded into a 32bpp Rgba32 image (via LibTiff or ImageSharp), which + // loses the original color depth. When preserving the original format, capture the + // source bits per pixel from the TIFF metadata so BitsPerPixel reports it faithfully + // (e.g. 1 for a black & white image) instead of the decoded 32bpp value. + if (preserveOriginalFormat) + { + _originalBitsPerPixel = originalBitsPerPixel; + } + + if (frameCount > 1) { _lazyImage = OpenTiffToImageSharp(); } @@ -2621,7 +2698,7 @@ private void LoadImage(ReadOnlySpan span, bool preserveOriginalFormat) // ImageSharp can load some single frame tiff, if failed we try again with LibTiff _lazyImage = OpenImageToImageSharp(preserveOriginalFormat, tryWithLibTiff : true); } - + } else { @@ -2805,7 +2882,14 @@ public override void WarningHandlerExt(Tiff tif, object clientData, string metho } } - private int GetTiffFrameCountFast() + /// + /// Reads lightweight TIFF metadata, i.e. the number of frames (directories) and the original + /// bits per pixel of the first frame (BitsPerSample x SamplesPerPixel), in a single pass, + /// without fully decoding the image. + /// + /// A tuple of the frame count (defaults to 1 if it cannot be read) and the original + /// bits per pixel of the first frame (null if it cannot be determined). + private (int FrameCount, int? BitsPerPixel) ReadTiffMetadataFast() { try { @@ -2815,13 +2899,24 @@ private int GetTiffFrameCountFast() Tiff.SetErrorHandler(new DisableErrorHandler()); using var tiff = Tiff.ClientOpen("in-memory", "r", tiffStream, new TiffStream()); - if (tiff == null) return 1; // Default to single frame if can't read + 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; + + int frameCount = tiff.NumberOfDirectories(); - return tiff.NumberOfDirectories(); + return (frameCount, bitsPerPixel > 0 ? bitsPerPixel : (int?)null); } catch { - return 1; // Default to single frame on any error + return (1, null); // Default to single frame / unknown depth on any error } } @@ -3114,7 +3209,9 @@ private int GetStride(Image source = null) { if (source == null) { - return 4 * (((Width * BitsPerPixel) + 31) / 32); + // Use the in-memory pixel depth (not the reported original BitsPerPixel) so the + // stride stays consistent with the decoded pixel data exposed by GetFirstPixelData. + return 4 * (((Width * InMemoryBitsPerPixel) + 31) / 32); } else {