Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Happy-path only. ChangeBitsPerPixel(32) here asserts a clone is 32bpp (it already was — proves nothing), and no test round-trips a converted bitmap through SaveAs/GetBytes and reloads — which is exactly why the non-durable-depth issue above is invisible. Suggest adding a round-trip test (it will currently fail, documenting the gap) and a Stride/Scan0-vs-BitsPerPixel consistency assertion so the intentional decoupling is locked against future re-coupling.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added DW_9_BitsPerPixel_IsIntentionallyDecoupledFromStrideAndScan0 (locks the decoupling) and DW_9_ChangeBitsPerPixel_DurabilityDependsOnEncoder (PNG→8, BMP/GetBytes→32).

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<NotSupportedException>(() => 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)]
Expand Down
123 changes: 110 additions & 13 deletions IronSoftware.Drawing/IronSoftware.Drawing.Common/AnyBitmap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -980,13 +980,71 @@ public static AnyBitmap LoadAnyBitmapFromRGBBuffer(byte[] buffer, int width, int

//cache
private int? _bitsPerPixel = null;

/// <summary>
/// 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 &amp; white TIFF that is
/// decoded to a 32bpp Rgba32 image). When set, this is reported by <see cref="BitsPerPixel"/>.
/// </summary>
private int? _originalBitsPerPixel = null;

//cache of the bits per pixel of the in-memory (decoded) image
private int InMemoryBitsPerPixel => _bitsPerPixel ??= GetFirstInternalImage().PixelType.BitsPerPixel;

/// <summary>
/// Gets colors depth, in number of bits per pixel.
/// <para>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 &amp; white
/// image) rather than the bits per pixel of the in-memory decoded representation.</para>
/// <para><b>Important:</b> this value is intentionally decoupled from <see cref="Stride"/>
/// and <see cref="Scan0"/>. SixLabors.ImageSharp has no pixel format below 8bpp, so a
/// source with a lower color depth (e.g. a 1bpp black &amp; white TIFF) is always decoded
/// into a 32bpp buffer in memory. <see cref="BitsPerPixel"/> reports the <i>original</i>
/// depth, whereas <see cref="Stride"/> and <see cref="Scan0"/> describe the <i>decoded</i>
/// 32bpp BGRA buffer. Do not size a <see cref="Scan0"/> buffer using
/// <c>Width * Height * BitsPerPixel / 8</c>; use <see cref="Stride"/> instead.</para>
/// <br/><para><b>Further Documentation:</b><br/>
/// <a href="https://ironsoftware.com/open-source/csharp/drawing/examples/get-color-depth/">
/// Code Example</a></para>
/// </summary>
public int BitsPerPixel => _bitsPerPixel ??= GetFirstInternalImage().PixelType.BitsPerPixel;
public int BitsPerPixel => _originalBitsPerPixel ?? InMemoryBitsPerPixel;

/// <summary>
/// Creates a new <see cref="AnyBitmap"/> with the pixel data converted to the requested
/// color depth, in number of bits per pixel. This is analogous to changing the
/// <c>PixelFormat</c> of a <see cref="System.Drawing.Bitmap"/>.
/// <para><b>Single frame:</b> 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.</para>
/// <para><b>Durability:</b> 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 <see cref="GetBytes()"/> re-encodes via the default 32bpp BMP encoder and the
/// reduced depth is lost.</para>
/// </summary>
/// <param name="bitsPerPixel">The target color depth. Supported values are
/// <c>8</c> (grayscale), <c>24</c> (RGB) and <c>32</c> (RGBA).</param>
/// <returns>A new <see cref="AnyBitmap"/> whose <see cref="BitsPerPixel"/> equals
/// <paramref name="bitsPerPixel"/>.</returns>
/// <exception cref="NotSupportedException">Thrown when <paramref name="bitsPerPixel"/>
/// is not one of the supported values.</exception>
public AnyBitmap ChangeBitsPerPixel(int bitsPerPixel)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major: The converted depth only lives in the in-memory PixelType; it is not durable across serialization. new AnyBitmap(converted) has no Binary, so the first Binary/SaveAs/GetBytes access re-encodes via GetDefaultImageExportEncoderGetDefaultImageEncoder → a 32bpp BmpEncoder (AnyBitmap.cs:3335). So bmp.ChangeBitsPerPixel(8).SaveAs("x.bmp") produces a 32bpp file. For a feature billed as the System.Drawing ChangeBpp equivalent (where the point is to save at that depth), either make the exporter honor the converted pixel type, or document clearly that this only affects the in-memory representation.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified empirically (PNG is durable → 8; default BMP/GetBytes → 32). Documented the durability behavior precisely in the XML doc and locked it with a round-trip test.

{
// Note: GetFirstInternalImage() only exposes frame 0, so this converts the first frame only.
Image source = GetFirstInternalImage();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: GetFirstInternalImage() converts only frame 0, so calling this on a multi-page TIFF (the very format this PR targets) silently drops frames 2..N. Document the single-frame behavior or map over all frames.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented single-frame behavior in the XML doc + inline comment.

Image converted = bitsPerPixel switch
{
8 => source.CloneAs<L8>(),
24 => source.CloneAs<Rgb24>(),
32 => source.CloneAs<Rgba32>(),
_ => 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;
Expand Down Expand Up @@ -1300,8 +1358,11 @@ public static AnyBitmap Redact(
}

/// <summary>
/// Gets the stride width (also called scan width) of the
/// Gets the stride width (also called scan width) of the
/// <see cref="AnyBitmap"/> object.
/// <para>This describes the in-memory decoded 32bpp BGRA buffer (see <see cref="Scan0"/>)
/// and is therefore independent of <see cref="BitsPerPixel"/>, which may report the lower
/// original color depth of the source image.</para>
/// </summary>
public int Stride
{
Expand All @@ -1312,11 +1373,15 @@ public int Stride
}

/// <summary>
/// Gets the address of the first pixel data in the
/// <see cref="AnyBitmap"/>. This can also be thought of as the first
/// Gets the address of the first pixel data in the
/// <see cref="AnyBitmap"/>. This can also be thought of as the first
/// scan line in the <see cref="AnyBitmap"/>.
/// <para>The pixel data is always the in-memory 32bpp BGRA representation, regardless of
/// the value reported by <see cref="BitsPerPixel"/> (which may be the lower original
/// color depth of the source image). Pair this with <see cref="Stride"/>, not
/// <see cref="BitsPerPixel"/>, when computing buffer sizes.</para>
/// </summary>
/// <returns>The address of the first 32bpp BGRA pixel data in the
/// <returns>The address of the first 32bpp BGRA pixel data in the
/// <see cref="AnyBitmap"/>.</returns>
public IntPtr Scan0
{
Expand Down Expand Up @@ -2610,9 +2675,21 @@ private void LoadImage(Stream stream, bool preserveOriginalFormat)
private void LoadImage(ReadOnlySpan<byte> 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();
}
Expand All @@ -2621,7 +2698,7 @@ private void LoadImage(ReadOnlySpan<byte> span, bool preserveOriginalFormat)
// ImageSharp can load some single frame tiff, if failed we try again with LibTiff
_lazyImage = OpenImageToImageSharp(preserveOriginalFormat, tryWithLibTiff : true);
}

}
else
{
Expand Down Expand Up @@ -2805,7 +2882,14 @@ public override void WarningHandlerExt(Tiff tif, object clientData, string metho
}
}

private int GetTiffFrameCountFast()
/// <summary>
/// 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.
/// </summary>
/// <returns>A tuple of the frame count (defaults to 1 if it cannot be read) and the original
/// bits per pixel of the first frame (<c>null</c> if it cannot be determined).</returns>
private (int FrameCount, int? BitsPerPixel) ReadTiffMetadataFast()
{
try
{
Expand All @@ -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
}
}

Expand Down Expand Up @@ -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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major: This decoupling is correct internally (Stride stays 32-based, consistent with the 32bpp BGRA buffer from GetFirstPixelData), but it creates a public-API inconsistency. After this PR a 1bpp TIFF reports BitsPerPixel == 1 while Stride and Scan0 still describe 32bpp data — System.Drawing never does that (a Format1bppIndexed bitmap has 1bpp BitsPerPixel, Stride, and Scan0, all consistent). Any external caller sizing a Scan0 buffer as Height * Width * BitsPerPixel / 8 now under-allocates 32x. Either expose a separate OriginalBitsPerPixel and leave BitsPerPixel reporting in-memory depth, or document the divergence explicitly as a breaking change and audit downstream consumers.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented the divergence explicitly on BitsPerPixel, Stride, and Scan0 XML docs (including "use Stride, not BitsPerPixel, for buffer math"). Did not take your suggested alternative (revert BitsPerPixel to in-memory depth + add OriginalBitsPerPixel) because the DW-9 acceptance criteria explicitly require BitsPerPixel == 1.

}
else
{
Expand Down
Loading