Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
31 changes: 30 additions & 1 deletion src/ImageSharp/Compression/Zlib/ZlibInflateStream.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,19 @@ internal sealed class ZlibInflateStream : Stream
/// </summary>
private readonly Func<int> getData;

/// <summary>
/// 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.
/// </summary>
private readonly bool noHeader;

/// <summary>
/// Initializes a new instance of the <see cref="ZlibInflateStream"/> class.
/// </summary>
/// <param name="innerStream">The inner raw stream.</param>
public ZlibInflateStream(BufferedReadStream innerStream)
: this(innerStream, GetDataNoOp)
: this(innerStream, GetDataNoOp, noHeader: false)
{
}

Expand All @@ -67,9 +74,23 @@ public ZlibInflateStream(BufferedReadStream innerStream)
/// <param name="innerStream">The inner raw stream.</param>
/// <param name="getData">A delegate to get more data from the inner stream.</param>
public ZlibInflateStream(BufferedReadStream innerStream, Func<int> getData)
: this(innerStream, getData, noHeader: false)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="ZlibInflateStream"/> class.
/// </summary>
/// <param name="innerStream">The inner raw stream.</param>
/// <param name="getData">A delegate to get more data from the inner stream.</param>
/// <param name="noHeader">
/// When <see langword="true"/>, the payload is treated as raw DEFLATE with no zlib header.
/// </param>
public ZlibInflateStream(BufferedReadStream innerStream, Func<int> getData, bool noHeader)
{
this.innerStream = innerStream;
this.getData = getData;
this.noHeader = noHeader;
}

/// <inheritdoc/>
Expand Down Expand Up @@ -210,6 +231,14 @@ protected override void Dispose(bool disposing)
[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
Expand Down
74 changes: 72 additions & 2 deletions src/ImageSharp/Formats/Png/PngDecoderCore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,13 @@ internal sealed class PngDecoderCore : ImageDecoderCore
/// </summary>
private bool hasImageData;

/// <summary>
/// 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.
/// </summary>
private bool isCgbi;

/// <summary>
/// Initializes a new instance of the <see cref="PngDecoderCore"/> class.
/// </summary>
Expand Down Expand Up @@ -314,7 +321,7 @@ protected override Image<TPixel> Decode<TPixel>(BufferedReadStream stream, Cance
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;
}
}
Expand Down Expand Up @@ -517,6 +524,10 @@ protected override ImageInfo Identify(BufferedReadStream stream, CancellationTok
case PngChunkType.End:
goto EOF;

case PngChunkType.ProprietaryApple:
this.isCgbi = true;
break;

default:
if (this.colorMetadataOnly)
{
Expand Down Expand Up @@ -766,7 +777,7 @@ private void ReadScanlines<TPixel>(
CancellationToken cancellationToken)
where TPixel : unmanaged, IPixel<TPixel>
{
using ZlibInflateStream inflateStream = new(this.currentStream, getData);
using ZlibInflateStream inflateStream = new(this.currentStream, getData, noHeader: this.isCgbi);
if (!inflateStream.AllocateNewBytes(chunkLength, !this.hasImageData))
{
return;
Expand Down Expand Up @@ -887,6 +898,11 @@ private void DecodePixelDataCore<TPixel>(
break;
}

if (this.isCgbi)
{
ApplyCgbiTransform(scanSpan[1..], this.pngColorType);
}

this.ProcessDefilteredScanline(frameControl, currentRow, scanSpan, imageFrame, pngMetadata, blendRowBuffer);
this.SwapScanlineBuffers();
currentRow++;
Expand Down Expand Up @@ -1017,6 +1033,11 @@ private void DecodeInterlacedPixelDataCore<TPixel>(
break;
}

if (this.isCgbi)
{
ApplyCgbiTransform(scanSpan[1..], this.pngColorType);
}

Span<TPixel> rowSpan = imageBuffer.DangerousGetRowSpan(currentRow);
this.ProcessInterlacedDefilteredScanline(
frameControl,
Expand Down Expand Up @@ -2470,4 +2491,53 @@ private static bool IsXmpTextData(ReadOnlySpan<byte> keywordBytes)

private void SwapScanlineBuffers()
=> (this.scanline, this.previousScanline) = (this.previousScanline, this.scanline);

/// <summary>
/// Applies the inverse of Apple's CgBI pixel mangling to a defiltered scanline.
/// CgBI PNGs are emitted by <c>pngcrush -iphone</c> 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.
/// </summary>
/// <remarks>
/// See https://theapplewiki.com/wiki/PNG_CgBI_Format
/// </remarks>
/// <param name="scanline">The defiltered pixel bytes (without the leading filter byte).</param>
/// <param name="colorType">The PNG color type from IHDR.</param>
private static void ApplyCgbiTransform(Span<byte> scanline, PngColorType colorType)
{
if (colorType == PngColorType.RgbWithAlpha)
{
Span<Rgba32> pixels = MemoryMarshal.Cast<byte, Rgba32>(scanline);

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.

You can use PixelOperations to do bulk transforms here.

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.

Well for the 3-channel version anyway. We can probably SIMDify the funk bit for 4 channels

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I had a look at this, but the scalar path is not safe for in-place swaps. I could update IShuffle3.Shuffle to use temporary variables when swapping if that seems like a good option?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Also, for RGBA I could try and come up with some utility function (since there isn't an existing one that I can see), or do you have any suggestions?

@JimBobSquarePants JimBobSquarePants May 27, 2026

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.

I couldn't help myself! 🤣 SIMD optimization has been added. I also fixed the scalar shuffling to make them safe.

for (int i = 0; i < pixels.Length; i++)
{
ref Rgba32 p = ref pixels[i];
byte r = p.B;
byte g = p.G;
byte b = p.R;
byte a = p.A;

if (a is not 0 and not byte.MaxValue)
{
// Reverse: c' = c * a / 255 => c = round(c' * 255 / a)
int half = a >> 1;
r = (byte)Math.Min(byte.MaxValue, ((r * byte.MaxValue) + half) / a);
g = (byte)Math.Min(byte.MaxValue, ((g * byte.MaxValue) + half) / a);
b = (byte)Math.Min(byte.MaxValue, ((b * byte.MaxValue) + half) / a);
}

p = new Rgba32(r, g, b, a);
}
}
else if (colorType == PngColorType.Rgb)
{
Span<Rgb24> pixels = MemoryMarshal.Cast<byte, Rgb24>(scanline);
for (int i = 0; i < pixels.Length; i++)
{
ref Rgb24 p = ref pixels[i];
(p.R, p.B) = (p.B, p.R);
}
}
}
}
40 changes: 24 additions & 16 deletions tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -714,26 +714,34 @@ public void Issue2209_Identify_HasTransparencyIsTrue(string imagePath)
Assert.Contains(metadata.ColorTable.Value.ToArray(), x => x.ToPixel<Rgba32>().A < 255);
}

// https://github.com/SixLabors/ImageSharp/issues/410
[Theory]
[WithFile(TestImages.Png.Bad.Issue410_MalformedApplePng, PixelTypes.Rgba32)]
public void Issue410_MalformedApplePng<TPixel>(TestImageProvider<TPixel> 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<TPixel>(TestImageProvider<TPixel> provider)
where TPixel : unmanaged, IPixel<TPixel>
{
Exception ex = Record.Exception(
() =>
{
using Image<TPixel> image = provider.GetImage(PngDecoder.Instance);
image.DebugSave(provider);
using Image<TPixel> image = provider.GetImage(PngDecoder.Instance);
image.DebugSave(provider);
image.CompareToReferenceOutput(provider, ImageComparer.Exact);
}

// 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);
[Theory]
[InlineData(TestImages.Png.Cgbi.Colors, 120, 120)]
[InlineData(TestImages.Png.Cgbi.Issue410, 42, 26)]
public void Identify_AppleCgBI(string imagePath, int expectedWidth, int expectedHeight)
{
TestFile testFile = TestFile.Create(imagePath);
using MemoryStream stream = new(testFile.Bytes, false);

ImageInfo imageInfo = Image.Identify(stream);

Assert.NotNull(imageInfo);
Assert.Equal(PngFormat.Instance, imageInfo.Metadata.DecodedImageFormat);
Assert.Equal(expectedWidth, imageInfo.Width);
Assert.Equal(expectedHeight, imageInfo.Height);
}

[Theory]
Expand Down
14 changes: 11 additions & 3 deletions tests/ImageSharp.Tests/TestImages.cs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,17 @@ public static class Icc
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";
Expand All @@ -199,9 +210,6 @@ public static class Bad
// 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";
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions tests/Images/Input/Png/cgbi/clocks.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions tests/Images/Input/Png/cgbi/colors.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions tests/Images/Input/Png/cgbi/flecks.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions tests/Images/Input/Png/cgbi/screen.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.