diff --git a/src/ImageSharp/Formats/Tiff/Constants/TiffConstants.cs b/src/ImageSharp/Formats/Tiff/Constants/TiffConstants.cs index c24eee484b..6a41c05ca7 100644 --- a/src/ImageSharp/Formats/Tiff/Constants/TiffConstants.cs +++ b/src/ImageSharp/Formats/Tiff/Constants/TiffConstants.cs @@ -68,11 +68,6 @@ internal static class TiffConstants /// public const TiffBitsPerPixel DefaultBitsPerPixel = TiffBitsPerPixel.Bit24; - /// - /// The default bits per sample for color images with 8 bits for each color channel. - /// - public static readonly TiffBitsPerSample DefaultBitsPerSample = BitsPerSampleRgb8Bit; - /// /// The default compression is None. /// @@ -108,6 +103,13 @@ internal static class TiffConstants /// public static readonly TiffBitsPerSample BitsPerSampleRgb8Bit = new(8, 8, 8); + // Initialize this alias after the value it references. Static fields initialize in declaration order. + + /// + /// The default bits per sample for color images with 8 bits for each color channel. + /// + public static readonly TiffBitsPerSample DefaultBitsPerSample = BitsPerSampleRgb8Bit; + /// /// The list of mime types that equate to a tiff. /// diff --git a/src/ImageSharp/Formats/Tiff/TiffFrameMetadata.cs b/src/ImageSharp/Formats/Tiff/TiffFrameMetadata.cs index d977b5f29f..d6daaef93e 100644 --- a/src/ImageSharp/Formats/Tiff/TiffFrameMetadata.cs +++ b/src/ImageSharp/Formats/Tiff/TiffFrameMetadata.cs @@ -216,10 +216,32 @@ private static void Parse(TiffFrameMetadata meta, ExifProfile profile) meta.EncodingWidth = GetImageWidth(profile); meta.EncodingHeight = GetImageHeight(profile); - if (profile.TryGetValue(ExifTag.BitsPerSample, out IExifValue? bitsPerSampleValue) - && TiffBitsPerSample.TryParse(bitsPerSampleValue.Value, out TiffBitsPerSample bitsPerSample)) + if (profile.TryGetValue(ExifTag.BitsPerSample, out IExifValue? bitsPerSampleValue)) { - meta.BitsPerSample = bitsPerSample; + // TryParse returns zero sample depths on failure. A present invalid entry must not inherit the RGB defaults. + meta.BitsPerSample = TiffBitsPerSample.TryParse(bitsPerSampleValue.Value, out TiffBitsPerSample bitsPerSample) + ? bitsPerSample + : default; + } + else + { + // TIFF 6.0 defines one bit per sample when BitsPerSample is absent (page 29). + // SamplesPerPixel defaults to one (page 39). These file defaults differ from new metadata's RGB defaults. + int samplesPerPixel = profile.TryGetValue(ExifTag.SamplesPerPixel, out IExifValue? samplesPerPixelValue) + ? samplesPerPixelValue.Value + : 1; + + // This metadata type stores at most four sample depths. Do not reduce a different count to a supported layout. + if (samplesPerPixel is < 1 or > 4) + { + TiffThrowHelper.ThrowNotSupported("Only TIFF images with one to four samples per pixel are supported."); + } + + meta.BitsPerSample = new TiffBitsPerSample( + 1, + (ushort)(samplesPerPixel > 1 ? 1 : 0), + (ushort)(samplesPerPixel > 2 ? 1 : 0), + (ushort)(samplesPerPixel > 3 ? 1 : 0)); } meta.BitsPerPixel = meta.BitsPerSample.BitsPerPixel(); diff --git a/tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderTests.cs index bab79d8ecf..bc2595c98f 100644 --- a/tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderTests.cs @@ -1006,6 +1006,78 @@ public void Decode_MalformedIccProfile_ThrowsWithStrict() }); } + /// + /// Missing sample-depth tags use the TIFF default in Decode and Identify under every integrity mode. + /// + /// The stored bilevel TIFF without BitsPerSample, with or without SamplesPerPixel. + [Theory] + [InlineData(BilevelWithoutBitsPerSampleUncompressed)] + [InlineData(BilevelWithoutBitsPerSampleFax4)] + [InlineData(BilevelWithoutSampleTags)] + public void TiffDecoder_CanDecode_BilevelWithoutBitsPerSample(string path) + { + using Image expected = Image.Load(TestFile.Create(Fax4Compressed).Bytes); + byte[] data = TestFile.Create(path).Bytes; + + foreach (SegmentIntegrityHandling integrity in Enum.GetValues()) + { + DecoderOptions options = new() { SegmentIntegrityHandling = integrity }; + using Image actual = Image.Load(options, data); + ImageInfo info = Image.Identify(options, data); + + ImageComparer.Exact.VerifySimilarity(expected, actual); + TiffFrameMetadata metadata = actual.Frames.RootFrame.Metadata.GetTiffMetadata(); + Assert.Equal(new TiffBitsPerSample(1, 0, 0), metadata.BitsPerSample); + Assert.Equal(TiffBitsPerPixel.Bit1, metadata.BitsPerPixel); + Assert.Equal(1, info.PixelType.BitsPerPixel); + Assert.Equal(TiffBitsPerPixel.Bit1, info.FrameMetadataCollection[0].GetTiffMetadata().BitsPerPixel); + } + } + + /// + /// A missing sample-depth tag must not reduce an unsupported sample count to a supported layout. + /// + /// The stored TIFF with zero or five declared samples per pixel. + [Theory] + [InlineData(MissingBitsPerSampleWithZeroSamples)] + [InlineData(MissingBitsPerSampleWithFiveSamples)] + public void TiffDecoder_WithoutBitsPerSample_RejectsUnsupportedSampleCounts(string path) + { + byte[] data = TestFile.Create(path).Bytes; + + foreach (SegmentIntegrityHandling integrity in Enum.GetValues()) + { + DecoderOptions options = new() { SegmentIntegrityHandling = integrity }; + Assert.Throws(() => Image.Identify(options, data)); + Assert.Throws(() => + { + using Image image = Image.Load(options, data); + }); + } + } + + /// + /// A present but unusable sample-depth tag must not inherit a valid default layout. + /// + /// The stored TIFF with an empty or zero-valued BitsPerSample entry. + [Theory] + [InlineData(EmptyBitsPerSample)] + [InlineData(ZeroBitsPerSample)] + public void TiffDecoder_PresentInvalidBitsPerSample_IsNotDefaulted(string path) + { + byte[] data = TestFile.Create(path).Bytes; + + foreach (SegmentIntegrityHandling integrity in Enum.GetValues()) + { + DecoderOptions options = new() { SegmentIntegrityHandling = integrity }; + Assert.Throws(() => + { + using Image image = Image.Load(options, data); + }); + } + } + private static byte[] CreateTiffWithMalformedIccProfile() => CorruptedMetadataImageFactory.CreateImageWithMalformedIccProfile(new TiffEncoder()); + } diff --git a/tests/ImageSharp.Tests/Formats/Tiff/TiffMetadataTests.cs b/tests/ImageSharp.Tests/Formats/Tiff/TiffMetadataTests.cs index d181cacb5a..0fa6467ca9 100644 --- a/tests/ImageSharp.Tests/Formats/Tiff/TiffMetadataTests.cs +++ b/tests/ImageSharp.Tests/Formats/Tiff/TiffMetadataTests.cs @@ -27,6 +27,22 @@ private class NumberComparer : IEqualityComparer public int GetHashCode(Number obj) => obj.GetHashCode(); } + /// + /// New image and frame metadata use eight-bit RGB, independently of the defaults for absent file tags. + /// + [Fact] + public void TiffMetadata_DefaultsToEightBitRgb() + { + TiffMetadata imageMetadata = new(); + TiffFrameMetadata frameMetadata = new(); + TiffBitsPerSample expected = new(8, 8, 8); + + Assert.Equal(expected, imageMetadata.BitsPerSample); + Assert.Equal(expected, frameMetadata.BitsPerSample); + Assert.Equal(TiffBitsPerPixel.Bit24, imageMetadata.BitsPerPixel); + Assert.Equal(TiffBitsPerPixel.Bit24, frameMetadata.BitsPerPixel); + } + [Fact] public void TiffMetadata_CloneIsDeep() { diff --git a/tests/ImageSharp.Tests/TestImages.cs b/tests/ImageSharp.Tests/TestImages.cs index b15f7ae0fb..e4c3fffff6 100644 --- a/tests/ImageSharp.Tests/TestImages.cs +++ b/tests/ImageSharp.Tests/TestImages.cs @@ -1002,6 +1002,14 @@ public static class Tiff public const string Benchmark_RgbPackbits = "medium_rgb_packbits.tiff"; public const string Benchmark_RgbUncompressed = "medium_rgb_uncompressed.tiff"; + public const string BilevelWithoutBitsPerSampleUncompressed = "Tiff/defaults/bilevel-no-bitspersample-none.tiff"; + public const string BilevelWithoutBitsPerSampleFax4 = "Tiff/defaults/bilevel-no-bitspersample-g4.tiff"; + public const string BilevelWithoutSampleTags = "Tiff/defaults/bilevel-no-bitspersample-or-samplesperpixel.tiff"; + public const string MissingBitsPerSampleWithZeroSamples = "Tiff/defaults/no-bitspersample-samplesperpixel-zero.tiff"; + public const string MissingBitsPerSampleWithFiveSamples = "Tiff/defaults/no-bitspersample-samplesperpixel-five.tiff"; + public const string EmptyBitsPerSample = "Tiff/defaults/empty-bitspersample.tiff"; + public const string ZeroBitsPerSample = "Tiff/defaults/zero-bitspersample.tiff"; + public const string Calliphora_GrayscaleUncompressed = "Tiff/Calliphora_grayscale_uncompressed.tiff"; public const string Calliphora_GrayscaleDeflate_Predictor = "Tiff/Calliphora_gray_deflate_predictor.tiff"; public const string Calliphora_GrayscaleLzw_Predictor = "Tiff/Calliphora_gray_lzw_predictor.tiff"; diff --git a/tests/Images/Input/Tiff/defaults/bilevel-no-bitspersample-g4.tiff b/tests/Images/Input/Tiff/defaults/bilevel-no-bitspersample-g4.tiff new file mode 100644 index 0000000000..4ee08baf73 --- /dev/null +++ b/tests/Images/Input/Tiff/defaults/bilevel-no-bitspersample-g4.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e25f990d596ef9f0a50a7e891dbda11d9912f61f6f9c5fc67aa6a6bb26ddda2e +size 468 diff --git a/tests/Images/Input/Tiff/defaults/bilevel-no-bitspersample-none.tiff b/tests/Images/Input/Tiff/defaults/bilevel-no-bitspersample-none.tiff new file mode 100644 index 0000000000..7761945900 --- /dev/null +++ b/tests/Images/Input/Tiff/defaults/bilevel-no-bitspersample-none.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:27acb91aec7b5e4a90602129bb17f04bd149d5ab55564d09ba298a95a1a828cf +size 440 diff --git a/tests/Images/Input/Tiff/defaults/bilevel-no-bitspersample-or-samplesperpixel.tiff b/tests/Images/Input/Tiff/defaults/bilevel-no-bitspersample-or-samplesperpixel.tiff new file mode 100644 index 0000000000..1c37372676 --- /dev/null +++ b/tests/Images/Input/Tiff/defaults/bilevel-no-bitspersample-or-samplesperpixel.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b4d3e75163b6fc2afb689501a6a249f22ab8029012786d9a8917173f591fbd43 +size 428 diff --git a/tests/Images/Input/Tiff/defaults/empty-bitspersample.tiff b/tests/Images/Input/Tiff/defaults/empty-bitspersample.tiff new file mode 100644 index 0000000000..21189ebde7 --- /dev/null +++ b/tests/Images/Input/Tiff/defaults/empty-bitspersample.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:392157cdd816ce57ef33aeb585c70d27d20cd31364807cb9750bea09495943f1 +size 440 diff --git a/tests/Images/Input/Tiff/defaults/no-bitspersample-samplesperpixel-five.tiff b/tests/Images/Input/Tiff/defaults/no-bitspersample-samplesperpixel-five.tiff new file mode 100644 index 0000000000..8b693687af --- /dev/null +++ b/tests/Images/Input/Tiff/defaults/no-bitspersample-samplesperpixel-five.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:878be6433aeebcfe1f69d2099cf0186b1d48a79306c3aa5034122e094d808e0c +size 590 diff --git a/tests/Images/Input/Tiff/defaults/no-bitspersample-samplesperpixel-zero.tiff b/tests/Images/Input/Tiff/defaults/no-bitspersample-samplesperpixel-zero.tiff new file mode 100644 index 0000000000..b6cfe767f7 --- /dev/null +++ b/tests/Images/Input/Tiff/defaults/no-bitspersample-samplesperpixel-zero.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:84caa139af2d4bc5b61b89fcc4c06a90f464c26c6c91d019bccd3836f51ddd16 +size 590 diff --git a/tests/Images/Input/Tiff/defaults/zero-bitspersample.tiff b/tests/Images/Input/Tiff/defaults/zero-bitspersample.tiff new file mode 100644 index 0000000000..7ea562ed96 --- /dev/null +++ b/tests/Images/Input/Tiff/defaults/zero-bitspersample.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fde04a56b5010542fdd79c3b55bf6bf93559fbc03e03197686036451c0691a92 +size 440