diff --git a/src/ImageSharp/Compression/Zlib/ZlibInflateStream.cs b/src/ImageSharp/Compression/Zlib/ZlibInflateStream.cs index 1d743bf3a5..513171b179 100644 --- a/src/ImageSharp/Compression/Zlib/ZlibInflateStream.cs +++ b/src/ImageSharp/Compression/Zlib/ZlibInflateStream.cs @@ -52,12 +52,19 @@ internal sealed class ZlibInflateStream : Stream /// private readonly Func getData; + /// + /// When true, the inflated payload is treated as a raw DEFLATE stream with no zlib + /// CMF/FLG header (and no Adler-32 trailer). This is required to decode IDATs in + /// Apple's proprietary CgBI PNG variant. + /// + private readonly bool noHeader; + /// /// Initializes a new instance of the class. /// /// The inner raw stream. public ZlibInflateStream(BufferedReadStream innerStream) - : this(innerStream, GetDataNoOp) + : this(innerStream, GetDataNoOp, noHeader: false) { } @@ -67,9 +74,23 @@ internal sealed class ZlibInflateStream : Stream /// The inner raw stream. /// A delegate to get more data from the inner stream. public ZlibInflateStream(BufferedReadStream innerStream, Func getData) + : this(innerStream, getData, noHeader: false) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The inner raw stream. + /// A delegate to get more data from the inner stream. + /// + /// When , the payload is treated as raw DEFLATE with no zlib header. + /// + public ZlibInflateStream(BufferedReadStream innerStream, Func getData, bool noHeader) { this.innerStream = innerStream; this.getData = getData; + this.noHeader = noHeader; } /// @@ -210,6 +231,14 @@ internal sealed class ZlibInflateStream : Stream [MemberNotNullWhen(true, nameof(CompressedStream))] private bool InitializeInflateStream(bool isCriticalChunk) { + // Apple CgBI IDATs omit the zlib CMF/FLG header and the Adler-32 trailer, + // wrapping a raw DEFLATE payload directly. Skip the header parsing in that mode. + if (this.noHeader) + { + this.CompressedStream = new DeflateStream(this, CompressionMode.Decompress, true); + return true; + } + // Read the zlib header : http://tools.ietf.org/html/rfc1950 // CMF(Compression Method and flags) // This byte is divided into a 4 - bit compression method and a diff --git a/src/ImageSharp/Formats/Png/PngDecoderCore.cs b/src/ImageSharp/Formats/Png/PngDecoderCore.cs index 5b9eee1169..063c27a9a9 100644 --- a/src/ImageSharp/Formats/Png/PngDecoderCore.cs +++ b/src/ImageSharp/Formats/Png/PngDecoderCore.cs @@ -137,6 +137,13 @@ internal sealed class PngDecoderCore : ImageDecoderCore /// private bool hasImageData; + /// + /// Whether this is an Apple CgBI PNG. CgBI files store IDATs as raw DEFLATE + /// (no zlib header/Adler-32) and pixels as premultiplied BGRA, so they need + /// extra inversion steps to round-trip back to standard PNG semantics. + /// + private bool isCgbi; + /// /// Initializes a new instance of the class. /// @@ -314,7 +321,7 @@ internal sealed class PngDecoderCore : ImageDecoderCore case PngChunkType.End: goto EOF; case PngChunkType.ProprietaryApple: - PngThrowHelper.ThrowInvalidChunkType("Proprietary Apple PNG detected! This PNG file is not conform to the specification and cannot be decoded."); + this.isCgbi = true; break; } } @@ -517,6 +524,10 @@ internal sealed class PngDecoderCore : ImageDecoderCore case PngChunkType.End: goto EOF; + case PngChunkType.ProprietaryApple: + this.isCgbi = true; + break; + default: if (this.colorMetadataOnly) { @@ -766,7 +777,7 @@ internal sealed class PngDecoderCore : ImageDecoderCore CancellationToken cancellationToken) where TPixel : unmanaged, IPixel { - using ZlibInflateStream inflateStream = new(this.currentStream, getData); + using ZlibInflateStream inflateStream = new(this.currentStream, getData, noHeader: this.isCgbi); if (!inflateStream.AllocateNewBytes(chunkLength, !this.hasImageData)) { return; @@ -887,6 +898,11 @@ internal sealed class PngDecoderCore : ImageDecoderCore break; } + if (this.isCgbi) + { + ApplyCgbiTransform(scanSpan[1..], this.pngColorType); + } + this.ProcessDefilteredScanline(frameControl, currentRow, scanSpan, imageFrame, pngMetadata, blendRowBuffer); this.SwapScanlineBuffers(); currentRow++; @@ -1017,6 +1033,11 @@ internal sealed class PngDecoderCore : ImageDecoderCore break; } + if (this.isCgbi) + { + ApplyCgbiTransform(scanSpan[1..], this.pngColorType); + } + Span rowSpan = imageBuffer.DangerousGetRowSpan(currentRow); this.ProcessInterlacedDefilteredScanline( frameControl, @@ -2470,4 +2491,52 @@ internal sealed class PngDecoderCore : ImageDecoderCore private void SwapScanlineBuffers() => (this.scanline, this.previousScanline) = (this.previousScanline, this.scanline); + + /// + /// Applies the inverse of Apple's CgBI pixel mangling to a defiltered scanline. + /// CgBI PNGs are emitted by pngcrush -iphone with channel order swapped + /// from RGB(A) to BGR(A) and RGB samples premultiplied by alpha. This converts + /// the bytes back to standard PNG semantics in place so the existing scanline + /// processors can consume them unchanged. CgBI is only emitted for 8-bit + /// truecolor (with or without alpha); other color types are left alone. + /// + /// + /// See https://theapplewiki.com/wiki/PNG_CgBI_Format + /// + /// The defiltered pixel bytes (without the leading filter byte). + /// The PNG color type from IHDR. + private static void ApplyCgbiTransform(Span scanline, PngColorType colorType) + { + if (colorType == PngColorType.RgbWithAlpha) + { + for (int i = 0; i + 3 < scanline.Length; i += 4) + { + byte b = scanline[i]; + byte g = scanline[i + 1]; + byte r = scanline[i + 2]; + byte a = scanline[i + 3]; + + if (a is not 0 and not 255) + { + // Reverse: c' = c * a / 255 => c = round(c' * 255 / a) + int half = a >> 1; + r = (byte)Math.Min(255, ((r * 255) + half) / a); + g = (byte)Math.Min(255, ((g * 255) + half) / a); + b = (byte)Math.Min(255, ((b * 255) + half) / a); + } + + scanline[i] = r; + scanline[i + 1] = g; + scanline[i + 2] = b; + scanline[i + 3] = a; + } + } + else if (colorType == PngColorType.Rgb) + { + for (int i = 0; i + 2 < scanline.Length; i += 3) + { + (scanline[i], scanline[i + 2]) = (scanline[i + 2], scanline[i]); + } + } + } } diff --git a/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs index 803a12b03a..88018709ce 100644 --- a/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs @@ -714,26 +714,18 @@ public partial class PngDecoderTests Assert.Contains(metadata.ColorTable.Value.ToArray(), x => x.ToPixel().A < 255); } - // https://github.com/SixLabors/ImageSharp/issues/410 [Theory] - [WithFile(TestImages.Png.Bad.Issue410_MalformedApplePng, PixelTypes.Rgba32)] - public void Issue410_MalformedApplePng(TestImageProvider provider) + [WithFile(TestImages.Png.Cgbi.Issue410, PixelTypes.Rgba32)] + [WithFile(TestImages.Png.Cgbi.Colors, PixelTypes.Rgba32)] + [WithFile(TestImages.Png.Cgbi.Clocks, PixelTypes.Rgba32)] + [WithFile(TestImages.Png.Cgbi.Flecks, PixelTypes.Rgba32)] + [WithFile(TestImages.Png.Cgbi.Screen, PixelTypes.Rgba32)] + public void Decode_AppleCgBI(TestImageProvider provider) where TPixel : unmanaged, IPixel { - Exception ex = Record.Exception( - () => - { - using Image image = provider.GetImage(PngDecoder.Instance); - image.DebugSave(provider); - - // We don't have another x-plat reference decoder that can be compared for this image. - if (TestEnvironment.IsWindows) - { - image.CompareToOriginal(provider, ImageComparer.Exact, SystemDrawingReferenceDecoder.Png); - } - }); - Assert.NotNull(ex); - Assert.Contains("Proprietary Apple PNG detected!", ex.Message); + using Image image = provider.GetImage(PngDecoder.Instance); + image.DebugSave(provider); + image.CompareToReferenceOutput(provider, ImageComparer.Exact); } [Theory] diff --git a/tests/ImageSharp.Tests/TestImages.cs b/tests/ImageSharp.Tests/TestImages.cs index 2624d1cdad..fee6fce375 100644 --- a/tests/ImageSharp.Tests/TestImages.cs +++ b/tests/ImageSharp.Tests/TestImages.cs @@ -177,6 +177,17 @@ public static class TestImages public const string PerceptualcLUTOnly = "Png/icc-profiles/Perceptual-cLUT-only.png"; } + public static class Cgbi + { + public const string Colors = "Png/cgbi/colors.png"; + public const string Clocks = "Png/cgbi/clocks.png"; + public const string Flecks = "Png/cgbi/flecks.png"; + public const string Screen = "Png/cgbi/screen.png"; + + // Issue 410: https://github.com/SixLabors/ImageSharp/issues/410 + public const string Issue410 = "Png/issues/Issue_410.png"; + } + public static class Bad { public const string MissingDataChunk = "Png/xdtn0g01.png"; @@ -199,9 +210,6 @@ public static class TestImages // Issue 1047: https://github.com/SixLabors/ImageSharp/issues/1047 public const string Issue1047_BadEndChunk = "Png/issues/Issue_1047.png"; - // Issue 410: https://github.com/SixLabors/ImageSharp/issues/410 - public const string Issue410_MalformedApplePng = "Png/issues/Issue_410.png"; - // Bad bit depth. public const string BitDepthZero = "Png/xd0n2c08.png"; public const string BitDepthThree = "Png/xd3n2c08.png"; diff --git a/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_Issue_410_ImageThreshold-0_PerPixelManhattanThreshold-0.png b/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_Issue_410_ImageThreshold-0_PerPixelManhattanThreshold-0.png new file mode 100644 index 0000000000..020facf415 --- /dev/null +++ b/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_Issue_410_ImageThreshold-0_PerPixelManhattanThreshold-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:511cb90e72fcb837e4c9a31561a3c914f5201452d4ca63502cb5219cb4dc42be +size 619 diff --git a/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_clocks_ImageThreshold-0_PerPixelManhattanThreshold-0.png b/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_clocks_ImageThreshold-0_PerPixelManhattanThreshold-0.png new file mode 100644 index 0000000000..0f184812b4 --- /dev/null +++ b/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_clocks_ImageThreshold-0_PerPixelManhattanThreshold-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2dc73f1b4435a26125d910f005b5df7a540168a954f44c01a8e46df201adb1b6 +size 335851 diff --git a/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_colors_ImageThreshold-0_PerPixelManhattanThreshold-0.png b/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_colors_ImageThreshold-0_PerPixelManhattanThreshold-0.png new file mode 100644 index 0000000000..74647f04fb --- /dev/null +++ b/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_colors_ImageThreshold-0_PerPixelManhattanThreshold-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8cdcc80a8c662c50d2f72ad8e123d595c6e80394538c05666b8d3531d651e71a +size 11270 diff --git a/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_flecks_ImageThreshold-0_PerPixelManhattanThreshold-0.png b/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_flecks_ImageThreshold-0_PerPixelManhattanThreshold-0.png new file mode 100644 index 0000000000..06873607a1 --- /dev/null +++ b/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_flecks_ImageThreshold-0_PerPixelManhattanThreshold-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:91bbb6c87f128920d4384f3be2e85ecd176e49a7a5166c6c6aa584e60d8131ed +size 204053 diff --git a/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_screen_ImageThreshold-0_PerPixelManhattanThreshold-0.png b/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_screen_ImageThreshold-0_PerPixelManhattanThreshold-0.png new file mode 100644 index 0000000000..c3a61bde84 --- /dev/null +++ b/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_screen_ImageThreshold-0_PerPixelManhattanThreshold-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:00bb4c7b345389f5d95252c19d70fc2b654c4f0198e6a704b603da92b78e9a0a +size 102982 diff --git a/tests/Images/Input/Png/cgbi/clocks.png b/tests/Images/Input/Png/cgbi/clocks.png new file mode 100644 index 0000000000..d7cf18367e --- /dev/null +++ b/tests/Images/Input/Png/cgbi/clocks.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cc462d8c2697060cde9a2e975ffb828d822ee3b0d4d12e3c5f081114176c036b +size 389981 diff --git a/tests/Images/Input/Png/cgbi/colors.png b/tests/Images/Input/Png/cgbi/colors.png new file mode 100644 index 0000000000..9b3b371bd5 --- /dev/null +++ b/tests/Images/Input/Png/cgbi/colors.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4f34436f755e3c6d15341f29c992331045ae16ad413144ca798ede5c085c8e6a +size 12853 diff --git a/tests/Images/Input/Png/cgbi/flecks.png b/tests/Images/Input/Png/cgbi/flecks.png new file mode 100644 index 0000000000..625c82e458 --- /dev/null +++ b/tests/Images/Input/Png/cgbi/flecks.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:314a5a52996953c0c12862262b4ff3fc191d8b6f2b7bb3853cdcea3267a4142d +size 212163 diff --git a/tests/Images/Input/Png/cgbi/screen.png b/tests/Images/Input/Png/cgbi/screen.png new file mode 100644 index 0000000000..57eca3d463 --- /dev/null +++ b/tests/Images/Input/Png/cgbi/screen.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2e9bfbac37a57b71fa27b38b21314bd49dbe1cb19a2eb9d0f272ec7be3b72a33 +size 94834