Skip to content

Commit 5634228

Browse files
authored
Merge pull request #2110 from SixLabors/bp/png-iccp
Preserve color profile when encoding PNG images
2 parents fcac74b + a0e38c8 commit 5634228

File tree

4 files changed

+166
-46
lines changed

4 files changed

+166
-46
lines changed

src/ImageSharp/Formats/Png/PngDecoderCore.cs

Lines changed: 80 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
using SixLabors.ImageSharp.Memory;
2020
using SixLabors.ImageSharp.Metadata;
2121
using SixLabors.ImageSharp.Metadata.Profiles.Exif;
22+
using SixLabors.ImageSharp.Metadata.Profiles.Icc;
2223
using SixLabors.ImageSharp.Metadata.Profiles.Xmp;
2324
using SixLabors.ImageSharp.PixelFormats;
2425

@@ -205,6 +206,9 @@ public Image<TPixel> Decode<TPixel>(BufferedReadStream stream, CancellationToken
205206
this.MergeOrSetExifProfile(metadata, new ExifProfile(exifData), replaceExistingKeys: true);
206207
}
207208

209+
break;
210+
case PngChunkType.EmbeddedColorProfile:
211+
this.ReadColorProfileChunk(metadata, chunk.Data.GetSpan());
208212
break;
209213
case PngChunkType.End:
210214
goto EOF;
@@ -1174,6 +1178,76 @@ private bool TryReadLegacyExifTextChunk(ImageMetadata metadata, string data)
11741178
return true;
11751179
}
11761180

1181+
/// <summary>
1182+
/// Reads the color profile chunk. The data is stored similar to the zTXt chunk.
1183+
/// </summary>
1184+
/// <param name="metadata">The metadata.</param>
1185+
/// <param name="data">The bytes containing the profile.</param>
1186+
private void ReadColorProfileChunk(ImageMetadata metadata, ReadOnlySpan<byte> data)
1187+
{
1188+
int zeroIndex = data.IndexOf((byte)0);
1189+
if (zeroIndex is < PngConstants.MinTextKeywordLength or > PngConstants.MaxTextKeywordLength)
1190+
{
1191+
return;
1192+
}
1193+
1194+
byte compressionMethod = data[zeroIndex + 1];
1195+
if (compressionMethod != 0)
1196+
{
1197+
// Only compression method 0 is supported (zlib datastream with deflate compression).
1198+
return;
1199+
}
1200+
1201+
ReadOnlySpan<byte> keywordBytes = data.Slice(0, zeroIndex);
1202+
if (!this.TryReadTextKeyword(keywordBytes, out string name))
1203+
{
1204+
return;
1205+
}
1206+
1207+
ReadOnlySpan<byte> compressedData = data.Slice(zeroIndex + 2);
1208+
1209+
if (this.TryUncompressZlibData(compressedData, out byte[] iccpProfileBytes))
1210+
{
1211+
metadata.IccProfile = new IccProfile(iccpProfileBytes);
1212+
}
1213+
}
1214+
1215+
/// <summary>
1216+
/// Tries to un-compress zlib compressed data.
1217+
/// </summary>
1218+
/// <param name="compressedData">The compressed data.</param>
1219+
/// <param name="uncompressedBytesArray">The uncompressed bytes array.</param>
1220+
/// <returns>True, if de-compressing was successful.</returns>
1221+
private unsafe bool TryUncompressZlibData(ReadOnlySpan<byte> compressedData, out byte[] uncompressedBytesArray)
1222+
{
1223+
fixed (byte* compressedDataBase = compressedData)
1224+
{
1225+
using (IMemoryOwner<byte> destBuffer = this.memoryAllocator.Allocate<byte>(this.Configuration.StreamProcessingBufferSize))
1226+
using (var memoryStreamOutput = new MemoryStream(compressedData.Length))
1227+
using (var memoryStreamInput = new UnmanagedMemoryStream(compressedDataBase, compressedData.Length))
1228+
using (var bufferedStream = new BufferedReadStream(this.Configuration, memoryStreamInput))
1229+
using (var inflateStream = new ZlibInflateStream(bufferedStream))
1230+
{
1231+
Span<byte> destUncompressedData = destBuffer.GetSpan();
1232+
if (!inflateStream.AllocateNewBytes(compressedData.Length, false))
1233+
{
1234+
uncompressedBytesArray = Array.Empty<byte>();
1235+
return false;
1236+
}
1237+
1238+
int bytesRead = inflateStream.CompressedStream.Read(destUncompressedData, 0, destUncompressedData.Length);
1239+
while (bytesRead != 0)
1240+
{
1241+
memoryStreamOutput.Write(destUncompressedData.Slice(0, bytesRead));
1242+
bytesRead = inflateStream.CompressedStream.Read(destUncompressedData, 0, destUncompressedData.Length);
1243+
}
1244+
1245+
uncompressedBytesArray = memoryStreamOutput.ToArray();
1246+
return true;
1247+
}
1248+
}
1249+
}
1250+
11771251
/// <summary>
11781252
/// Compares two ReadOnlySpan&lt;char&gt;s in a case-insensitive method.
11791253
/// This is only needed because older frameworks are missing the extension method.
@@ -1306,7 +1380,7 @@ private void ReadInternationalTextChunk(ImageMetadata metadata, ReadOnlySpan<byt
13061380
}
13071381
else if (this.IsXmpTextData(keywordBytes))
13081382
{
1309-
XmpProfile xmpProfile = new XmpProfile(data.Slice(dataStartIdx).ToArray());
1383+
var xmpProfile = new XmpProfile(data.Slice(dataStartIdx).ToArray());
13101384
metadata.XmpProfile = xmpProfile;
13111385
}
13121386
else
@@ -1325,29 +1399,14 @@ private void ReadInternationalTextChunk(ImageMetadata metadata, ReadOnlySpan<byt
13251399
/// <returns>The <see cref="bool"/>.</returns>
13261400
private bool TryUncompressTextData(ReadOnlySpan<byte> compressedData, Encoding encoding, out string value)
13271401
{
1328-
using (var memoryStream = new MemoryStream(compressedData.ToArray()))
1329-
using (var bufferedStream = new BufferedReadStream(this.Configuration, memoryStream))
1330-
using (var inflateStream = new ZlibInflateStream(bufferedStream))
1402+
if (this.TryUncompressZlibData(compressedData, out byte[] uncompressedData))
13311403
{
1332-
if (!inflateStream.AllocateNewBytes(compressedData.Length, false))
1333-
{
1334-
value = null;
1335-
return false;
1336-
}
1337-
1338-
var uncompressedBytes = new List<byte>();
1339-
1340-
// Note: this uses a buffer which is only 4 bytes long to read the stream, maybe allocating a larger buffer makes sense here.
1341-
int bytesRead = inflateStream.CompressedStream.Read(this.buffer, 0, this.buffer.Length);
1342-
while (bytesRead != 0)
1343-
{
1344-
uncompressedBytes.AddRange(this.buffer.AsSpan(0, bytesRead).ToArray());
1345-
bytesRead = inflateStream.CompressedStream.Read(this.buffer, 0, this.buffer.Length);
1346-
}
1347-
1348-
value = encoding.GetString(uncompressedBytes.ToArray());
1404+
value = encoding.GetString(uncompressedData);
13491405
return true;
13501406
}
1407+
1408+
value = null;
1409+
return false;
13511410
}
13521411

13531412
/// <summary>

src/ImageSharp/Formats/Png/PngEncoderCore.cs

Lines changed: 57 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ internal sealed class PngEncoderCore : IImageEncoderInternals, IDisposable
8787
/// </summary>
8888
private IMemoryOwner<byte> currentScanline;
8989

90+
/// <summary>
91+
/// The color profile name.
92+
/// </summary>
93+
private const string ColorProfileName = "ICC Profile";
94+
9095
/// <summary>
9196
/// Initializes a new instance of the <see cref="PngEncoderCore" /> class.
9297
/// </summary>
@@ -134,6 +139,7 @@ public void Encode<TPixel>(Image<TPixel> image, Stream stream, CancellationToken
134139

135140
this.WriteHeaderChunk(stream);
136141
this.WriteGammaChunk(stream);
142+
this.WriteColorProfileChunk(stream, metadata);
137143
this.WritePaletteChunk(stream, quantized);
138144
this.WriteTransparencyChunk(stream, pngMetadata);
139145
this.WritePhysicalChunk(stream, metadata);
@@ -656,7 +662,7 @@ private void WriteExifChunk(Stream stream, ImageMetadata meta)
656662
}
657663

658664
/// <summary>
659-
/// Writes an iTXT chunk, containing the XMP metdata to the stream, if such profile is present in the metadata.
665+
/// Writes an iTXT chunk, containing the XMP metadata to the stream, if such profile is present in the metadata.
660666
/// </summary>
661667
/// <param name="stream">The <see cref="Stream"/> containing image data.</param>
662668
/// <param name="meta">The image metadata.</param>
@@ -673,7 +679,7 @@ private void WriteXmpChunk(Stream stream, ImageMetadata meta)
673679
return;
674680
}
675681

676-
var xmpData = meta.XmpProfile.Data;
682+
byte[] xmpData = meta.XmpProfile.Data;
677683

678684
if (xmpData.Length == 0)
679685
{
@@ -687,19 +693,49 @@ private void WriteXmpChunk(Stream stream, ImageMetadata meta)
687693
PngConstants.XmpKeyword.CopyTo(payload);
688694
int bytesWritten = PngConstants.XmpKeyword.Length;
689695

690-
// Write the iTxt header (all zeros in this case)
691-
payload[bytesWritten++] = 0;
692-
payload[bytesWritten++] = 0;
693-
payload[bytesWritten++] = 0;
694-
payload[bytesWritten++] = 0;
695-
payload[bytesWritten++] = 0;
696+
// Write the iTxt header (all zeros in this case).
697+
Span<byte> iTxtHeader = payload.Slice(bytesWritten);
698+
iTxtHeader[4] = 0;
699+
iTxtHeader[3] = 0;
700+
iTxtHeader[2] = 0;
701+
iTxtHeader[1] = 0;
702+
iTxtHeader[0] = 0;
703+
bytesWritten += 5;
696704

697-
// And the XMP data itself
705+
// And the XMP data itself.
698706
xmpData.CopyTo(payload.Slice(bytesWritten));
699707
this.WriteChunk(stream, PngChunkType.InternationalText, payload);
700708
}
701709
}
702710

711+
/// <summary>
712+
/// Writes the color profile chunk.
713+
/// </summary>
714+
/// <param name="stream">The stream to write to.</param>
715+
/// <param name="metaData">The image meta data.</param>
716+
private void WriteColorProfileChunk(Stream stream, ImageMetadata metaData)
717+
{
718+
if (metaData.IccProfile is null)
719+
{
720+
return;
721+
}
722+
723+
byte[] iccProfileBytes = metaData.IccProfile.ToByteArray();
724+
725+
byte[] compressedData = this.GetZlibCompressedBytes(iccProfileBytes);
726+
int payloadLength = ColorProfileName.Length + compressedData.Length + 2;
727+
using (IMemoryOwner<byte> owner = this.memoryAllocator.Allocate<byte>(payloadLength))
728+
{
729+
Span<byte> outputBytes = owner.GetSpan();
730+
PngConstants.Encoding.GetBytes(ColorProfileName).CopyTo(outputBytes);
731+
int bytesWritten = ColorProfileName.Length;
732+
outputBytes[bytesWritten++] = 0; // Null separator.
733+
outputBytes[bytesWritten++] = 0; // Compression.
734+
compressedData.CopyTo(outputBytes.Slice(bytesWritten));
735+
this.WriteChunk(stream, PngChunkType.EmbeddedColorProfile, outputBytes);
736+
}
737+
}
738+
703739
/// <summary>
704740
/// Writes a text chunk to the stream. Can be either a tTXt, iTXt or zTXt chunk,
705741
/// depending whether the text contains any latin characters or should be compressed.
@@ -727,13 +763,12 @@ private void WriteTextChunks(Stream stream, PngMetadata meta)
727763
}
728764
}
729765

730-
if (hasUnicodeCharacters || (!string.IsNullOrWhiteSpace(textData.LanguageTag) ||
731-
!string.IsNullOrWhiteSpace(textData.TranslatedKeyword)))
766+
if (hasUnicodeCharacters || (!string.IsNullOrWhiteSpace(textData.LanguageTag) || !string.IsNullOrWhiteSpace(textData.TranslatedKeyword)))
732767
{
733768
// Write iTXt chunk.
734769
byte[] keywordBytes = PngConstants.Encoding.GetBytes(textData.Keyword);
735770
byte[] textBytes = textData.Value.Length > this.options.TextCompressionThreshold
736-
? this.GetCompressedTextBytes(PngConstants.TranslatedEncoding.GetBytes(textData.Value))
771+
? this.GetZlibCompressedBytes(PngConstants.TranslatedEncoding.GetBytes(textData.Value))
737772
: PngConstants.TranslatedEncoding.GetBytes(textData.Value);
738773

739774
byte[] translatedKeyword = PngConstants.TranslatedEncoding.GetBytes(textData.TranslatedKeyword);
@@ -772,18 +807,17 @@ private void WriteTextChunks(Stream stream, PngMetadata meta)
772807
if (textData.Value.Length > this.options.TextCompressionThreshold)
773808
{
774809
// Write zTXt chunk.
775-
byte[] compressedData =
776-
this.GetCompressedTextBytes(PngConstants.Encoding.GetBytes(textData.Value));
810+
byte[] compressedData = this.GetZlibCompressedBytes(PngConstants.Encoding.GetBytes(textData.Value));
777811
int payloadLength = textData.Keyword.Length + compressedData.Length + 2;
778812
using (IMemoryOwner<byte> owner = this.memoryAllocator.Allocate<byte>(payloadLength))
779813
{
780814
Span<byte> outputBytes = owner.GetSpan();
781815
PngConstants.Encoding.GetBytes(textData.Keyword).CopyTo(outputBytes);
782816
int bytesWritten = textData.Keyword.Length;
783-
outputBytes[bytesWritten++] = 0;
784-
outputBytes[bytesWritten++] = 0;
817+
outputBytes[bytesWritten++] = 0; // Null separator.
818+
outputBytes[bytesWritten++] = 0; // Compression.
785819
compressedData.CopyTo(outputBytes.Slice(bytesWritten));
786-
this.WriteChunk(stream, PngChunkType.CompressedText, outputBytes.ToArray());
820+
this.WriteChunk(stream, PngChunkType.CompressedText, outputBytes);
787821
}
788822
}
789823
else
@@ -796,9 +830,8 @@ private void WriteTextChunks(Stream stream, PngMetadata meta)
796830
PngConstants.Encoding.GetBytes(textData.Keyword).CopyTo(outputBytes);
797831
int bytesWritten = textData.Keyword.Length;
798832
outputBytes[bytesWritten++] = 0;
799-
PngConstants.Encoding.GetBytes(textData.Value)
800-
.CopyTo(outputBytes.Slice(bytesWritten));
801-
this.WriteChunk(stream, PngChunkType.Text, outputBytes.ToArray());
833+
PngConstants.Encoding.GetBytes(textData.Value).CopyTo(outputBytes.Slice(bytesWritten));
834+
this.WriteChunk(stream, PngChunkType.Text, outputBytes);
802835
}
803836
}
804837
}
@@ -808,15 +841,15 @@ private void WriteTextChunks(Stream stream, PngMetadata meta)
808841
/// <summary>
809842
/// Compresses a given text using Zlib compression.
810843
/// </summary>
811-
/// <param name="textBytes">The text bytes to compress.</param>
812-
/// <returns>The compressed text byte array.</returns>
813-
private byte[] GetCompressedTextBytes(byte[] textBytes)
844+
/// <param name="dataBytes">The bytes to compress.</param>
845+
/// <returns>The compressed byte array.</returns>
846+
private byte[] GetZlibCompressedBytes(byte[] dataBytes)
814847
{
815848
using (var memoryStream = new MemoryStream())
816849
{
817850
using (var deflateStream = new ZlibDeflateStream(this.memoryAllocator, memoryStream, this.options.CompressionLevel))
818851
{
819-
deflateStream.Write(textBytes);
852+
deflateStream.Write(dataBytes);
820853
}
821854

822855
return memoryStream.ToArray();

tests/ImageSharp.Tests/Formats/Png/PngEncoderTests.Chunks.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,7 @@ public void ExcludeFilter_WithNone_DoesNotExcludeChunks()
302302
{
303303
PngChunkType.Header,
304304
PngChunkType.Gamma,
305+
PngChunkType.EmbeddedColorProfile,
305306
PngChunkType.Palette,
306307
PngChunkType.InternationalText,
307308
PngChunkType.Text,

tests/ImageSharp.Tests/Formats/Png/PngMetadataTests.cs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ namespace SixLabors.ImageSharp.Tests.Formats.Png
1717
public class PngMetadataTests
1818
{
1919
public static readonly TheoryData<string, int, int, PixelResolutionUnit> RatioFiles =
20-
new TheoryData<string, int, int, PixelResolutionUnit>
20+
new()
2121
{
2222
{ TestImages.Png.Splash, 11810, 11810, PixelResolutionUnit.PixelsPerMeter },
2323
{ TestImages.Png.Ratio1x4, 1, 4, PixelResolutionUnit.AspectRatio },
@@ -222,6 +222,33 @@ public void Decode_VerifyRatio(string imagePath, int xResolution, int yResolutio
222222
}
223223
}
224224

225+
[Theory]
226+
[WithFile(TestImages.Png.PngWithMetadata, PixelTypes.Rgba32)]
227+
public void Encode_PreservesColorProfile<TPixel>(TestImageProvider<TPixel> provider)
228+
where TPixel : unmanaged, IPixel<TPixel>
229+
{
230+
using (Image<TPixel> input = provider.GetImage(new PngDecoder()))
231+
{
232+
ImageSharp.Metadata.Profiles.Icc.IccProfile expectedProfile = input.Metadata.IccProfile;
233+
byte[] expectedProfileBytes = expectedProfile.ToByteArray();
234+
235+
using (var memStream = new MemoryStream())
236+
{
237+
input.Save(memStream, new PngEncoder());
238+
239+
memStream.Position = 0;
240+
using (var output = Image.Load<Rgba32>(memStream))
241+
{
242+
ImageSharp.Metadata.Profiles.Icc.IccProfile actualProfile = output.Metadata.IccProfile;
243+
byte[] actualProfileBytes = actualProfile.ToByteArray();
244+
245+
Assert.NotNull(actualProfile);
246+
Assert.Equal(expectedProfileBytes, actualProfileBytes);
247+
}
248+
}
249+
}
250+
}
251+
225252
[Theory]
226253
[MemberData(nameof(RatioFiles))]
227254
public void Identify_VerifyRatio(string imagePath, int xResolution, int yResolution, PixelResolutionUnit resolutionUnit)

0 commit comments

Comments
 (0)