diff --git a/PngSharp.Tests/ExifChunkTests.cs b/PngSharp.Tests/ExifChunkTests.cs new file mode 100644 index 0000000..10046c9 --- /dev/null +++ b/PngSharp.Tests/ExifChunkTests.cs @@ -0,0 +1,72 @@ +using PngSharp.Api; +using PngSharp.Spec.Chunks.IHDR; +using PngSharp.Spec.Chunks.eXIf; +using Xunit; +using static PngSharp.Tests.PngTestHelpers; + +namespace PngSharp.Tests; + +public class ExifChunkTests +{ + [Fact] + public void RoundTrip_Exif_BigEndian_Preserved() + { + byte[] exifData = [0x4D, 0x4D, 0x00, 0x2A, 0x00, 0x00, 0x00, 0x08]; + var png = Png.Builder() + .WithIhdr(CreateIhdr(ColorType.TrueColorWithAlpha)) + .WithExif(new ExifChunkData { Data = exifData }) + .WithPixelData(new byte[2 * 2 * 4]) + .Build(); + + var decoded = RoundTrip(png); + + Assert.NotNull(decoded.Exif); + Assert.Equal(exifData, decoded.Exif.Value.Data); + } + + [Fact] + public void RoundTrip_Exif_LittleEndian_Preserved() + { + byte[] exifData = [0x49, 0x49, 0x2A, 0x00, 0x08, 0x00, 0x00, 0x00]; + var png = Png.Builder() + .WithIhdr(CreateIhdr(ColorType.TrueColorWithAlpha)) + .WithExif(new ExifChunkData { Data = exifData }) + .WithPixelData(new byte[2 * 2 * 4]) + .Build(); + + var decoded = RoundTrip(png); + + Assert.NotNull(decoded.Exif); + Assert.Equal(exifData, decoded.Exif.Value.Data); + } + + [Fact] + public void Builder_Exif_TooShort_Throws() + { + Assert.Throws(() => + Png.Builder() + .WithIhdr(CreateIhdr(ColorType.TrueColorWithAlpha)) + .WithExif(new ExifChunkData { Data = [0x4D, 0x4D, 0x00] }) // 3 bytes, min is 4 + .WithPixelData(new byte[2 * 2 * 4]) + .Build()); + } + + [Fact] + public void Builder_Exif_InvalidByteOrderMark_Throws() + { + Assert.Throws(() => + Png.Builder() + .WithIhdr(CreateIhdr(ColorType.TrueColorWithAlpha)) + .WithExif(new ExifChunkData { Data = [0x00, 0x00, 0x00, 0x00] }) + .WithPixelData(new byte[2 * 2 * 4]) + .Build()); + } + + [Fact] + public void RoundTrip_NoExif_ReturnsNull() + { + var png = Png.CreateRgba(2, 2, new byte[2 * 2 * 4]); + var decoded = RoundTrip(png); + Assert.Null(decoded.Exif); + } +} diff --git a/PngSharp.Tests/IccpChunkTests.cs b/PngSharp.Tests/IccpChunkTests.cs new file mode 100644 index 0000000..bce02fa --- /dev/null +++ b/PngSharp.Tests/IccpChunkTests.cs @@ -0,0 +1,102 @@ +using PngSharp.Api; +using PngSharp.Spec.Chunks.IHDR; +using PngSharp.Spec.Chunks.iCCP; +using PngSharp.Spec.Chunks.sRGB; +using Xunit; +using static PngSharp.Tests.PngTestHelpers; + +namespace PngSharp.Tests; + +public class IccpChunkTests +{ + private static readonly byte[] SampleProfile = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]; + + [Fact] + public void RoundTrip_Iccp_Preserved() + { + var content = new IccpChunkContent { ProfileName = "TestProfile", RawProfile = SampleProfile }; + var iccp = IccpChunkData.Encode(content); + var png = Png.Builder() + .WithIhdr(CreateIhdr(ColorType.TrueColorWithAlpha)) + .WithIccp(iccp) + .WithPixelData(new byte[2 * 2 * 4]) + .Build(); + + var decoded = RoundTrip(png); + + Assert.NotNull(decoded.Iccp); + var decodedContent = decoded.Iccp.Value.Decode(); + Assert.Equal("TestProfile", decodedContent.ProfileName); + Assert.Equal(SampleProfile, decodedContent.RawProfile); + } + + [Fact] + public void RoundTrip_Iccp_CompressedDataPreserved() + { + var iccp = IccpChunkData.Encode(new IccpChunkContent { ProfileName = "MyProfile", RawProfile = SampleProfile }); + var png = Png.Builder() + .WithIhdr(CreateIhdr(ColorType.TrueColor)) + .WithIccp(iccp) + .WithPixelData(new byte[2 * 2 * 3]) + .Build(); + + var decoded = RoundTrip(png); + + Assert.NotNull(decoded.Iccp); + Assert.Equal(iccp.CompressedProfile, decoded.Iccp.Value.CompressedProfile); + } + + [Fact] + public void Builder_Iccp_WithSrgb_Throws() + { + var iccp = IccpChunkData.Encode(new IccpChunkContent { ProfileName = "Test", RawProfile = SampleProfile }); + Assert.Throws(() => + Png.Builder() + .WithIhdr(CreateIhdr(ColorType.TrueColorWithAlpha)) + .WithIccp(iccp) + .WithSrgb(new SrgbChunkData { RenderingIntent = RenderingIntent.Perceptual }) + .WithPixelData(new byte[2 * 2 * 4]) + .Build()); + } + + [Fact] + public void Builder_Iccp_EmptyProfileName_Throws() + { + Assert.Throws(() => + Png.Builder() + .WithIhdr(CreateIhdr(ColorType.TrueColorWithAlpha)) + .WithIccp(new IccpChunkData { ProfileName = "", CompressedProfile = [1, 2, 3] }) + .WithPixelData(new byte[2 * 2 * 4]) + .Build()); + } + + [Fact] + public void Builder_Iccp_ProfileNameTooLong_Throws() + { + Assert.Throws(() => + Png.Builder() + .WithIhdr(CreateIhdr(ColorType.TrueColorWithAlpha)) + .WithIccp(new IccpChunkData { ProfileName = new string('A', 80), CompressedProfile = [1, 2, 3] }) + .WithPixelData(new byte[2 * 2 * 4]) + .Build()); + } + + [Fact] + public void Builder_Iccp_EmptyCompressedData_Throws() + { + Assert.Throws(() => + Png.Builder() + .WithIhdr(CreateIhdr(ColorType.TrueColorWithAlpha)) + .WithIccp(new IccpChunkData { ProfileName = "Test", CompressedProfile = [] }) + .WithPixelData(new byte[2 * 2 * 4]) + .Build()); + } + + [Fact] + public void RoundTrip_NoIccp_ReturnsNull() + { + var png = Png.CreateRgba(2, 2, new byte[2 * 2 * 4]); + var decoded = RoundTrip(png); + Assert.Null(decoded.Iccp); + } +} diff --git a/PngSharp.Tests/SbitChunkTests.cs b/PngSharp.Tests/SbitChunkTests.cs new file mode 100644 index 0000000..a6e514c --- /dev/null +++ b/PngSharp.Tests/SbitChunkTests.cs @@ -0,0 +1,133 @@ +using PngSharp.Api; +using PngSharp.Spec.Chunks.IHDR; +using PngSharp.Spec.Chunks.PLTE; +using PngSharp.Spec.Chunks.sBIT; +using Xunit; +using static PngSharp.Tests.PngTestHelpers; + +namespace PngSharp.Tests; + +public class SbitChunkTests +{ + [Fact] + public void RoundTrip_Sbit_TrueColorWithAlpha_Preserved() + { + byte[] sbitData = [8, 8, 8, 8]; + var png = Png.Builder() + .WithIhdr(CreateIhdr(ColorType.TrueColorWithAlpha)) + .WithSbit(new SbitChunkData { Data = sbitData }) + .WithPixelData(new byte[2 * 2 * 4]) + .Build(); + + var decoded = RoundTrip(png); + + Assert.NotNull(decoded.Sbit); + Assert.Equal(sbitData, decoded.Sbit.Value.Data); + } + + [Fact] + public void RoundTrip_Sbit_TrueColor_Preserved() + { + byte[] sbitData = [5, 6, 5]; + var png = Png.Builder() + .WithIhdr(CreateIhdr(ColorType.TrueColor)) + .WithSbit(new SbitChunkData { Data = sbitData }) + .WithPixelData(new byte[2 * 2 * 3]) + .Build(); + + var decoded = RoundTrip(png); + + Assert.NotNull(decoded.Sbit); + Assert.Equal(sbitData, decoded.Sbit.Value.Data); + } + + [Fact] + public void RoundTrip_Sbit_Grayscale_Preserved() + { + byte[] sbitData = [5]; + var png = Png.Builder() + .WithIhdr(CreateIhdr(ColorType.Grayscale)) + .WithSbit(new SbitChunkData { Data = sbitData }) + .WithPixelData(new byte[2 * 2]) + .Build(); + + var decoded = RoundTrip(png); + + Assert.NotNull(decoded.Sbit); + Assert.Equal(sbitData, decoded.Sbit.Value.Data); + } + + [Fact] + public void RoundTrip_Sbit_GrayscaleWithAlpha_Preserved() + { + byte[] sbitData = [5, 8]; + var png = Png.Builder() + .WithIhdr(CreateIhdr(ColorType.GrayscaleWithAlpha)) + .WithSbit(new SbitChunkData { Data = sbitData }) + .WithPixelData(new byte[2 * 2 * 2]) + .Build(); + + var decoded = RoundTrip(png); + + Assert.NotNull(decoded.Sbit); + Assert.Equal(sbitData, decoded.Sbit.Value.Data); + } + + [Fact] + public void RoundTrip_Sbit_IndexedColor_Preserved() + { + byte[] sbitData = [5, 6, 5]; + var png = Png.Builder() + .WithIhdr(CreateIhdr(ColorType.IndexedColor)) + .WithPlte(new PlteChunkData { Entries = [255, 0, 0, 0, 255, 0, 0, 0, 255, 128, 128, 128] }) + .WithSbit(new SbitChunkData { Data = sbitData }) + .WithPixelData(new byte[2 * 2]) + .Build(); + + var decoded = RoundTrip(png); + + Assert.NotNull(decoded.Sbit); + Assert.Equal(sbitData, decoded.Sbit.Value.Data); + } + + [Fact] + public void Builder_Sbit_WrongSizeForColorType_Throws() + { + Assert.Throws(() => + Png.Builder() + .WithIhdr(CreateIhdr(ColorType.TrueColor)) + .WithSbit(new SbitChunkData { Data = [8, 8] }) // should be 3 bytes + .WithPixelData(new byte[2 * 2 * 3]) + .Build()); + } + + [Fact] + public void Builder_Sbit_ZeroValue_Throws() + { + Assert.Throws(() => + Png.Builder() + .WithIhdr(CreateIhdr(ColorType.TrueColor)) + .WithSbit(new SbitChunkData { Data = [0, 8, 8] }) + .WithPixelData(new byte[2 * 2 * 3]) + .Build()); + } + + [Fact] + public void Builder_Sbit_ExceedsBitDepth_Throws() + { + Assert.Throws(() => + Png.Builder() + .WithIhdr(CreateIhdr(ColorType.Grayscale)) + .WithSbit(new SbitChunkData { Data = [9] }) // 8-bit depth, max is 8 + .WithPixelData(new byte[2 * 2]) + .Build()); + } + + [Fact] + public void RoundTrip_NoSbit_ReturnsNull() + { + var png = Png.CreateRgba(2, 2, new byte[2 * 2 * 4]); + var decoded = RoundTrip(png); + Assert.Null(decoded.Sbit); + } +} diff --git a/PngSharp/Api/IRawPng.cs b/PngSharp/Api/IRawPng.cs index c9a1a6f..35be05a 100644 --- a/PngSharp/Api/IRawPng.cs +++ b/PngSharp/Api/IRawPng.cs @@ -8,6 +8,9 @@ using PngSharp.Spec.Chunks.cHRM; using PngSharp.Spec.Chunks.tIME; using PngSharp.Spec.Chunks.tRNS; +using PngSharp.Spec.Chunks.sBIT; +using PngSharp.Spec.Chunks.iCCP; +using PngSharp.Spec.Chunks.eXIf; namespace PngSharp.Api; @@ -29,6 +32,9 @@ public interface IRawPng ChrmChunkData? Chrm { get; } TimeChunkData? Time { get; } BkgdChunkData? Bkgd { get; } + SbitChunkData? Sbit { get; } + IccpChunkData? Iccp { get; } + ExifChunkData? Exif { get; } IReadOnlyList TxtChunks { get; } IReadOnlyList ZTxtChunks { get; } IReadOnlyList ITxtChunks { get; } diff --git a/PngSharp/Api/IRawPngBuilder.cs b/PngSharp/Api/IRawPngBuilder.cs index b23eb0f..393951c 100644 --- a/PngSharp/Api/IRawPngBuilder.cs +++ b/PngSharp/Api/IRawPngBuilder.cs @@ -8,6 +8,9 @@ using PngSharp.Spec.Chunks.cHRM; using PngSharp.Spec.Chunks.tIME; using PngSharp.Spec.Chunks.tRNS; +using PngSharp.Spec.Chunks.sBIT; +using PngSharp.Spec.Chunks.iCCP; +using PngSharp.Spec.Chunks.eXIf; namespace PngSharp.Api; @@ -23,6 +26,9 @@ public interface IRawPngBuilder IRawPngBuilder WithChrm(ChrmChunkData chrm); IRawPngBuilder WithTime(TimeChunkData time); IRawPngBuilder WithBkgd(BkgdChunkData bkgd); + IRawPngBuilder WithSbit(SbitChunkData sbit); + IRawPngBuilder WithIccp(IccpChunkData iccp); + IRawPngBuilder WithExif(ExifChunkData exif); IRawPngBuilder WithTxtChunk(TextChunk textChunk); IRawPngBuilder WithZTxtChunk(ZTextChunk textChunk); IRawPngBuilder WithITxtChunk(ITextChunk textChunk); diff --git a/PngSharp/Api/Png.cs b/PngSharp/Api/Png.cs index c6a9dcb..e9a521a 100644 --- a/PngSharp/Api/Png.cs +++ b/PngSharp/Api/Png.cs @@ -67,6 +67,9 @@ public static IRawPng DecodeFromStream(Stream inputStream) Chrm = decoder.Chrm, Time = decoder.Time, Bkgd = decoder.Bkgd, + Sbit = decoder.Sbit, + Iccp = decoder.Iccp, + Exif = decoder.Exif, TxtChunks = decoder.TxtChunks, ZTxtChunks = decoder.ZTxtChunks, ITxtChunks = decoder.ITxtChunks, diff --git a/PngSharp/Decoder/PngDecoder.cs b/PngSharp/Decoder/PngDecoder.cs index b5347e2..ed69f17 100644 --- a/PngSharp/Decoder/PngDecoder.cs +++ b/PngSharp/Decoder/PngDecoder.cs @@ -10,6 +10,9 @@ using PngSharp.Spec.Chunks.cHRM; using PngSharp.Spec.Chunks.tIME; using PngSharp.Spec.Chunks.tRNS; +using PngSharp.Spec.Chunks.sBIT; +using PngSharp.Spec.Chunks.iCCP; +using PngSharp.Spec.Chunks.eXIf; namespace PngSharp.Decoder; @@ -28,6 +31,9 @@ internal sealed class PngDecoder : IDisposable, IAsyncDisposable public ChrmChunkData? Chrm { get; set; } public TimeChunkData? Time { get; set; } public BkgdChunkData? Bkgd { get; set; } + public SbitChunkData? Sbit { get; set; } + public IccpChunkData? Iccp { get; set; } + public ExifChunkData? Exif { get; set; } public List TxtChunks { get; } = []; public List ZTxtChunks { get; } = []; public List ITxtChunks { get; } = []; diff --git a/PngSharp/Decoder/PngReader.cs b/PngSharp/Decoder/PngReader.cs index 3bf894e..b22d520 100644 --- a/PngSharp/Decoder/PngReader.cs +++ b/PngSharp/Decoder/PngReader.cs @@ -11,6 +11,9 @@ using PngSharp.Spec.Chunks.cHRM; using PngSharp.Spec.Chunks.tIME; using PngSharp.Spec.Chunks.tRNS; +using PngSharp.Spec.Chunks.sBIT; +using PngSharp.Spec.Chunks.iCCP; +using PngSharp.Spec.Chunks.eXIf; namespace PngSharp.Decoder; @@ -265,6 +268,34 @@ public BkgdChunkData ReadBkgdChunkData(int chunkSize) return new BkgdChunkData { Data = data }; } + public SbitChunkData ReadSbitChunkData(int chunkSize) + { + var data = new byte[chunkSize]; + ReadBytes(data); + return new SbitChunkData { Data = data }; + } + + public IccpChunkData ReadIccpChunkData(int chunkSize) + { + var data = new byte[chunkSize]; + ReadBytes(data); + + var nullIndex = Array.IndexOf(data, (byte)0); + var profileName = Encoding.Latin1.GetString(data, 0, nullIndex); + // byte after null is compression method (must be 0 = deflate), then compressed data + var compressedStart = nullIndex + 2; + var compressedData = data[compressedStart..]; + + return new IccpChunkData { ProfileName = profileName, CompressedProfile = compressedData }; + } + + public ExifChunkData ReadExifChunkData(int chunkSize) + { + var data = new byte[chunkSize]; + ReadBytes(data); + return new ExifChunkData { Data = data }; + } + public PhysChunkData ReadPhysChunkData() { var xAxisPpu = ReadUInt32(); diff --git a/PngSharp/Decoder/States/ReadChunkState.cs b/PngSharp/Decoder/States/ReadChunkState.cs index 0b8cfb5..4d31eb8 100644 --- a/PngSharp/Decoder/States/ReadChunkState.cs +++ b/PngSharp/Decoder/States/ReadChunkState.cs @@ -75,6 +75,20 @@ public void Execute() return; } + if (header.Id == HeaderIds.ICCP) + { + if (decoder.Iccp.HasValue) + throw new PngFormatException("Multiple iCCP chunks are not allowed."); + if (m_SeenPlte) + throw new PngFormatException("iCCP chunk must appear before PLTE."); + if (m_SeenIdat) + throw new PngFormatException("iCCP chunk must appear before IDAT."); + var iccpData = reader.ReadIccpChunkData(header.ChunkSizeInBytes); + decoder.Iccp = iccpData; + reader.ReadAndValidateCrc(HeaderIds.ICCP); + return; + } + if (header.Id == HeaderIds.SRGB) { if (decoder.Srgb.HasValue) @@ -129,6 +143,20 @@ public void Execute() return; } + if (header.Id == HeaderIds.SBIT) + { + if (decoder.Sbit.HasValue) + throw new PngFormatException("Multiple sBIT chunks are not allowed."); + if (m_SeenPlte) + throw new PngFormatException("sBIT chunk must appear before PLTE."); + if (m_SeenIdat) + throw new PngFormatException("sBIT chunk must appear before IDAT."); + var sbitData = reader.ReadSbitChunkData(header.ChunkSizeInBytes); + decoder.Sbit = sbitData; + reader.ReadAndValidateCrc(HeaderIds.SBIT); + return; + } + if (header.Id == HeaderIds.TIME) { if (decoder.Time.HasValue) @@ -151,6 +179,18 @@ public void Execute() return; } + if (header.Id == HeaderIds.EXIF) + { + if (decoder.Exif.HasValue) + throw new PngFormatException("Multiple eXIf chunks are not allowed."); + if (m_SeenIdat) + throw new PngFormatException("eXIf chunk must appear before IDAT."); + var exifData = reader.ReadExifChunkData(header.ChunkSizeInBytes); + decoder.Exif = exifData; + reader.ReadAndValidateCrc(HeaderIds.EXIF); + return; + } + if (header.Id == HeaderIds.TEXT) { var textData = reader.ReadTxtChunkData(header.ChunkSizeInBytes); diff --git a/PngSharp/Encoder/PngEncoder.cs b/PngSharp/Encoder/PngEncoder.cs index fb52fe6..4f69ea8 100644 --- a/PngSharp/Encoder/PngEncoder.cs +++ b/PngSharp/Encoder/PngEncoder.cs @@ -34,6 +34,12 @@ public void Encode() writer.WriteSRGBChunk(png.Srgb.Value); } + if (png.Iccp.HasValue) + { + m_Logger.Debug("Has iCCP Data"); + writer.WriteICCPChunk(png.Iccp.Value); + } + if (png.Gama.HasValue) { m_Logger.Debug($"Has Gama data: {png.Gama.Value}"); @@ -46,6 +52,12 @@ public void Encode() writer.WriteCHRMChunk(png.Chrm.Value); } + if (png.Sbit.HasValue) + { + m_Logger.Debug("Has sBIT Data"); + writer.WriteSBITChunk(png.Sbit.Value); + } + if (png.Phys.HasValue) { m_Logger.Debug($"Has Phys Data: {png.Phys.Value}"); @@ -83,6 +95,12 @@ public void Encode() writer.WriteTIMEChunk(png.Time.Value); } + if (png.Exif.HasValue) + { + m_Logger.Debug($"Has eXIf Data: {png.Exif.Value.Data.Length} bytes"); + writer.WriteEXIFChunk(png.Exif.Value); + } + m_Logger.Debug($"Uncompressed Size: {png.PixelData.Length} bytes"); using var pixelDataStream = new MemoryStream(png.PixelData); using var compressedDataStream = new MemoryStream(); diff --git a/PngSharp/Encoder/PngWriter.cs b/PngSharp/Encoder/PngWriter.cs index c94a75e..5a33c7d 100644 --- a/PngSharp/Encoder/PngWriter.cs +++ b/PngSharp/Encoder/PngWriter.cs @@ -12,6 +12,9 @@ using PngSharp.Spec.Chunks.cHRM; using PngSharp.Spec.Chunks.tIME; using PngSharp.Spec.Chunks.tRNS; +using PngSharp.Spec.Chunks.sBIT; +using PngSharp.Spec.Chunks.iCCP; +using PngSharp.Spec.Chunks.eXIf; namespace PngSharp.Encoder; @@ -211,6 +214,45 @@ public void WriteBKGDChunk(BkgdChunkData bkgdChunkData) WriteCrc32(); } + public void WriteSBITChunk(SbitChunkData sbitChunkData) + { + WriteChunkHeader(new ChunkHeader + { + Id = HeaderIds.SBIT, + ChunkSizeInBytes = sbitChunkData.Data.Length + }); + WriteBytes(sbitChunkData.Data); + WriteCrc32(); + } + + public void WriteICCPChunk(IccpChunkData iccpChunkData) + { + ArgumentOutOfRangeException.ThrowIfGreaterThan( + iccpChunkData.ProfileName.Length, MaxKeywordLength, nameof(iccpChunkData.ProfileName)); + + Span profileName = stackalloc byte[iccpChunkData.ProfileName.Length]; + Encoding.Latin1.GetBytes(iccpChunkData.ProfileName, profileName); + + var size = profileName.Length + 1 + 1 + iccpChunkData.CompressedProfile.Length; + WriteChunkHeader(new ChunkHeader { Id = HeaderIds.ICCP, ChunkSizeInBytes = size }); + WriteBytes(profileName); + WriteByte(0); // null separator + WriteByte(0); // compression method = deflate + WriteBytes(iccpChunkData.CompressedProfile); + WriteCrc32(); + } + + public void WriteEXIFChunk(ExifChunkData exifChunkData) + { + WriteChunkHeader(new ChunkHeader + { + Id = HeaderIds.EXIF, + ChunkSizeInBytes = exifChunkData.Data.Length + }); + WriteBytes(exifChunkData.Data); + WriteCrc32(); + } + public void WritePHYSChunk(PhysChunkData physChunkData) { WriteChunkHeader(new ChunkHeader diff --git a/PngSharp/Spec/Chunks/eXIf/ExifChunkData.cs b/PngSharp/Spec/Chunks/eXIf/ExifChunkData.cs new file mode 100644 index 0000000..e05506f --- /dev/null +++ b/PngSharp/Spec/Chunks/eXIf/ExifChunkData.cs @@ -0,0 +1,11 @@ +namespace PngSharp.Spec.Chunks.eXIf; + +/// +/// eXIf chunk: raw EXIF metadata blob. +/// Data starts with "MM" (big-endian) or "II" (little-endian) TIFF byte order mark. +/// Minimum 4 bytes. +/// +public readonly record struct ExifChunkData +{ + public byte[] Data { get; init; } +} diff --git a/PngSharp/Spec/Chunks/iCCP/IccpChunkContent.cs b/PngSharp/Spec/Chunks/iCCP/IccpChunkContent.cs new file mode 100644 index 0000000..7ad2ca2 --- /dev/null +++ b/PngSharp/Spec/Chunks/iCCP/IccpChunkContent.cs @@ -0,0 +1,7 @@ +namespace PngSharp.Spec.Chunks.iCCP; + +public readonly record struct IccpChunkContent +{ + public string ProfileName { get; init; } + public byte[] RawProfile { get; init; } +} diff --git a/PngSharp/Spec/Chunks/iCCP/IccpChunkData.cs b/PngSharp/Spec/Chunks/iCCP/IccpChunkData.cs new file mode 100644 index 0000000..770a4b2 --- /dev/null +++ b/PngSharp/Spec/Chunks/iCCP/IccpChunkData.cs @@ -0,0 +1,41 @@ +using System.IO.Compression; + +namespace PngSharp.Spec.Chunks.iCCP; + +/// +/// iCCP chunk: embedded ICC color profile. +/// ProfileName is 1-79 Latin-1 characters. +/// CompressedProfile is the deflate-compressed ICC profile bytes. +/// +public readonly record struct IccpChunkData +{ + public string ProfileName { get; init; } + public byte[] CompressedProfile { get; init; } + + public IccpChunkContent Decode() + { + using var compressedStream = new MemoryStream(CompressedProfile); + using var deflateStream = new ZLibStream(compressedStream, CompressionMode.Decompress); + using var resultStream = new MemoryStream(); + deflateStream.CopyTo(resultStream); + return new IccpChunkContent + { + ProfileName = ProfileName, + RawProfile = resultStream.ToArray(), + }; + } + + public static IccpChunkData Encode(IccpChunkContent content) + { + using var compressedStream = new MemoryStream(); + using (var zlibStream = new ZLibStream(compressedStream, CompressionLevel.Optimal, true)) + { + zlibStream.Write(content.RawProfile); + } + return new IccpChunkData + { + ProfileName = content.ProfileName, + CompressedProfile = compressedStream.ToArray(), + }; + } +} diff --git a/PngSharp/Spec/Chunks/sBIT/SbitChunkData.cs b/PngSharp/Spec/Chunks/sBIT/SbitChunkData.cs new file mode 100644 index 0000000..3ae2ad7 --- /dev/null +++ b/PngSharp/Spec/Chunks/sBIT/SbitChunkData.cs @@ -0,0 +1,16 @@ +namespace PngSharp.Spec.Chunks.sBIT; + +/// +/// sBIT chunk: records the original number of significant bits per channel. +/// Data length depends on color type: +/// Type 0 (Grayscale): 1 byte +/// Type 2 (TrueColor): 3 bytes (R, G, B) +/// Type 3 (IndexedColor): 3 bytes (R, G, B) +/// Type 4 (GrayscaleWithAlpha): 2 bytes (grey, alpha) +/// Type 6 (TrueColorWithAlpha): 4 bytes (R, G, B, A) +/// Each value must be > 0 and <= bit depth (or 8 for indexed). +/// +public readonly record struct SbitChunkData +{ + public byte[] Data { get; init; } +} diff --git a/PngSharp/Spec/HeaderIds.cs b/PngSharp/Spec/HeaderIds.cs index 7762a6e..3a094ce 100644 --- a/PngSharp/Spec/HeaderIds.cs +++ b/PngSharp/Spec/HeaderIds.cs @@ -16,4 +16,7 @@ internal static class HeaderIds public const string TEXT = "tEXt"; public const string ZTXT = "zTXt"; public const string ITXT = "iTXt"; + public const string SBIT = "sBIT"; + public const string ICCP = "iCCP"; + public const string EXIF = "eXIf"; } \ No newline at end of file diff --git a/PngSharp/Spec/RawPng.cs b/PngSharp/Spec/RawPng.cs index 4241df9..9614d9e 100644 --- a/PngSharp/Spec/RawPng.cs +++ b/PngSharp/Spec/RawPng.cs @@ -9,6 +9,9 @@ using PngSharp.Spec.Chunks.cHRM; using PngSharp.Spec.Chunks.tIME; using PngSharp.Spec.Chunks.tRNS; +using PngSharp.Spec.Chunks.sBIT; +using PngSharp.Spec.Chunks.iCCP; +using PngSharp.Spec.Chunks.eXIf; namespace PngSharp.Spec; @@ -24,6 +27,9 @@ internal sealed class RawPng : IRawPng public ChrmChunkData? Chrm { get; init; } public TimeChunkData? Time { get; init; } public BkgdChunkData? Bkgd { get; init; } + public SbitChunkData? Sbit { get; init; } + public IccpChunkData? Iccp { get; init; } + public ExifChunkData? Exif { get; init; } public required IReadOnlyList TxtChunks { get; init; } public required IReadOnlyList ZTxtChunks { get; init; } public required IReadOnlyList ITxtChunks { get; init; } diff --git a/PngSharp/Spec/RawPngBuilder.cs b/PngSharp/Spec/RawPngBuilder.cs index e1afced..27ca09c 100644 --- a/PngSharp/Spec/RawPngBuilder.cs +++ b/PngSharp/Spec/RawPngBuilder.cs @@ -9,6 +9,9 @@ using PngSharp.Spec.Chunks.cHRM; using PngSharp.Spec.Chunks.tIME; using PngSharp.Spec.Chunks.tRNS; +using PngSharp.Spec.Chunks.sBIT; +using PngSharp.Spec.Chunks.iCCP; +using PngSharp.Spec.Chunks.eXIf; namespace PngSharp.Spec; @@ -24,6 +27,9 @@ internal sealed class RawPngBuilder : IRawPngBuilder private ChrmChunkData? m_Chrm; private TimeChunkData? m_Time; private BkgdChunkData? m_Bkgd; + private SbitChunkData? m_Sbit; + private IccpChunkData? m_Iccp; + private ExifChunkData? m_Exif; private readonly List m_TxtChunks = []; private readonly List m_ZTxtChunks = []; private readonly List m_ITxtChunks = []; @@ -88,6 +94,24 @@ public IRawPngBuilder WithBkgd(BkgdChunkData bkgd) return this; } + public IRawPngBuilder WithSbit(SbitChunkData sbit) + { + m_Sbit = sbit; + return this; + } + + public IRawPngBuilder WithIccp(IccpChunkData iccp) + { + m_Iccp = iccp; + return this; + } + + public IRawPngBuilder WithExif(ExifChunkData exif) + { + m_Exif = exif; + return this; + } + public IRawPngBuilder WithTxtChunk(TextChunk textChunk) { m_TxtChunks.Add(textChunk); @@ -124,6 +148,9 @@ public IRawPng Build() ValidatePlte(ihdr, m_Plte); ValidateTrns(ihdr, m_Trns, m_Plte); ValidateBkgd(ihdr, m_Bkgd); + ValidateSbit(ihdr, m_Sbit); + ValidateIccp(m_Iccp, m_Srgb); + ValidateExif(m_Exif); ValidateTime(m_Time); ValidateTextKeywords(m_TxtChunks, m_ZTxtChunks, m_ITxtChunks); @@ -145,6 +172,9 @@ public IRawPng Build() Chrm = m_Chrm, Time = m_Time, Bkgd = m_Bkgd, + Sbit = m_Sbit, + Iccp = m_Iccp, + Exif = m_Exif, TxtChunks = m_TxtChunks, ZTxtChunks = m_ZTxtChunks, ITxtChunks = m_ITxtChunks, @@ -271,6 +301,74 @@ private static void ValidateTime(TimeChunkData? time) throw new InvalidOperationException($"tIME second must be 0-60, got {t.Second}."); } + private static void ValidateSbit(IhdrChunkData ihdr, SbitChunkData? sbit) + { + if (!sbit.HasValue) + return; + + var data = sbit.Value.Data; + var expectedLength = ihdr.ColorType switch + { + ColorType.Grayscale => 1, + ColorType.TrueColor => 3, + ColorType.IndexedColor => 3, + ColorType.GrayscaleWithAlpha => 2, + ColorType.TrueColorWithAlpha => 4, + _ => throw new InvalidOperationException($"Unknown ColorType: {ihdr.ColorType}."), + }; + + if (data.Length != expectedLength) + throw new InvalidOperationException( + $"sBIT data length for {ihdr.ColorType} must be {expectedLength}, got {data.Length}."); + + var maxBits = ihdr.ColorType == ColorType.IndexedColor ? (byte)8 : ihdr.BitDepth; + + for (var i = 0; i < data.Length; i++) + { + if (data[i] == 0) + throw new InvalidOperationException( + $"sBIT value at index {i} must be greater than 0."); + if (data[i] > maxBits) + throw new InvalidOperationException( + $"sBIT value {data[i]} at index {i} exceeds maximum of {maxBits}."); + } + } + + private static void ValidateIccp(IccpChunkData? iccp, SrgbChunkData? srgb) + { + if (!iccp.HasValue) + return; + + if (srgb.HasValue) + throw new InvalidOperationException( + "iCCP and sRGB chunks are mutually exclusive. Only one may be present."); + + var name = iccp.Value.ProfileName; + if (string.IsNullOrEmpty(name)) + throw new InvalidOperationException("iCCP profile name must not be empty."); + if (name.Length > 79) + throw new InvalidOperationException( + $"iCCP profile name exceeds maximum length of 79 bytes."); + + if (iccp.Value.CompressedProfile is null || iccp.Value.CompressedProfile.Length == 0) + throw new InvalidOperationException("iCCP compressed profile data must not be empty."); + } + + private static void ValidateExif(ExifChunkData? exif) + { + if (!exif.HasValue) + return; + + var data = exif.Value.Data; + if (data.Length < 4) + throw new InvalidOperationException( + $"eXIf data must be at least 4 bytes, got {data.Length}."); + + if (!((data[0] == 0x4D && data[1] == 0x4D) || (data[0] == 0x49 && data[1] == 0x49))) + throw new InvalidOperationException( + "eXIf data must start with 'MM' (0x4D4D) or 'II' (0x4949) byte order mark."); + } + private static void ValidateBitDepth(byte bitDepth, ColorType colorType) { byte[] allowed = colorType switch