From bc35ee6b37ee638c5ff491efff59b0625a19ca14 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Thu, 22 Jan 2026 15:32:06 +1000 Subject: [PATCH 1/6] Enhance XmpProfile to add XDocument normalization --- .../Metadata/Profiles/XMP/XmpProfile.cs | 118 ++++++++++++++---- .../Formats/Tiff/TiffMetadataTests.cs | 4 +- .../Metadata/ImageFrameMetadataTests.cs | 2 +- .../Metadata/Profiles/XMP/XmpProfileTests.cs | 36 +++++- 4 files changed, 129 insertions(+), 31 deletions(-) diff --git a/src/ImageSharp/Metadata/Profiles/XMP/XmpProfile.cs b/src/ImageSharp/Metadata/Profiles/XMP/XmpProfile.cs index 77ff35df0..de34a1eaa 100644 --- a/src/ImageSharp/Metadata/Profiles/XMP/XmpProfile.cs +++ b/src/ImageSharp/Metadata/Profiles/XMP/XmpProfile.cs @@ -1,8 +1,8 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. -using System.Diagnostics; using System.Text; +using System.Xml; using System.Xml.Linq; namespace SixLabors.ImageSharp.Metadata.Profiles.Xmp; @@ -25,18 +25,17 @@ public sealed class XmpProfile : IDeepCloneable /// Initializes a new instance of the class. /// /// The UTF8 encoded byte array to read the XMP profile from. - public XmpProfile(byte[]? data) => this.Data = data; + public XmpProfile(byte[]? data) => this.Data = NormalizeDataIfNeeded(data); /// - /// Initializes a new instance of the class - /// by making a copy from another XMP profile. + /// Initializes a new instance of the class from an XML document. + /// The document is serialized as UTF-8 without BOM. /// - /// The other XMP profile, from which the clone should be made from. - private XmpProfile(XmpProfile other) + /// The XMP XML document. + public XmpProfile(XDocument document) { - Guard.NotNull(other, nameof(other)); - - this.Data = other.Data; + Guard.NotNull(document, nameof(document)); + this.Data = SerializeDocument(document); } /// @@ -45,30 +44,28 @@ public sealed class XmpProfile : IDeepCloneable internal byte[]? Data { get; private set; } /// - /// Gets the raw XML document containing the XMP profile. + /// Convert the content of this into an . /// /// The - public XDocument? GetDocument() + public XDocument? ToXDocument() { - byte[]? byteArray = this.Data; - if (byteArray is null) + byte[]? data = this.Data; + if (data is null || data.Length == 0) { return null; } - // Strip leading whitespace, as the XmlReader doesn't like them. - int count = byteArray.Length; - for (int i = count - 1; i > 0; i--) + using MemoryStream stream = new(data, writable: false); + + XmlReaderSettings settings = new() { - if (byteArray[i] is 0 or 0x0f) - { - count--; - } - } + DtdProcessing = DtdProcessing.Ignore, + XmlResolver = null, + CloseInput = false + }; - using MemoryStream stream = new(byteArray, 0, count); - using StreamReader reader = new(stream, Encoding.UTF8); - return XDocument.Load(reader); + using XmlReader reader = XmlReader.Create(stream, settings); + return XDocument.Load(reader, LoadOptions.PreserveWhitespace); } /// @@ -84,5 +81,76 @@ public sealed class XmpProfile : IDeepCloneable } /// - public XmpProfile DeepClone() => new(this); + public XmpProfile DeepClone() + { + Guard.NotNull(this.Data); + + byte[] clone = new byte[this.Data.Length]; + this.Data.AsSpan().CopyTo(clone); + return new XmpProfile(clone); + } + + private static byte[] SerializeDocument(XDocument document) + { + using MemoryStream ms = new(); + + XmlWriterSettings writerSettings = new() + { + Encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), // no BOM + OmitXmlDeclaration = true, // generally safer for XMP consumers + Indent = false, + NewLineHandling = NewLineHandling.None + }; + + using (XmlWriter xw = XmlWriter.Create(ms, writerSettings)) + { + document.Save(xw); + } + + return ms.ToArray(); + } + + private static byte[]? NormalizeDataIfNeeded(byte[]? data) + { + if (data is null || data.Length == 0) + { + return data; + } + + // Allocation-free fast path for the normal case. + bool hasBom = data.Length >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF; + bool hasTrailingPad = data[^1] is 0 or 0x0F; + + if (!hasBom && !hasTrailingPad) + { + return data; + } + + int start = hasBom ? 3 : 0; + int end = data.Length; + + if (hasTrailingPad) + { + while (end > start) + { + byte b = data[end - 1]; + if (b is not 0 and not 0x0F) + { + break; + } + + end--; + } + } + + int length = end - start; + if (length <= 0) + { + return []; + } + + byte[] normalized = new byte[length]; + Buffer.BlockCopy(data, start, normalized, 0, length); + return normalized; + } } diff --git a/tests/ImageSharp.Tests/Formats/Tiff/TiffMetadataTests.cs b/tests/ImageSharp.Tests/Formats/Tiff/TiffMetadataTests.cs index 20b3ec2bb..f92d8ed59 100644 --- a/tests/ImageSharp.Tests/Formats/Tiff/TiffMetadataTests.cs +++ b/tests/ImageSharp.Tests/Formats/Tiff/TiffMetadataTests.cs @@ -157,7 +157,7 @@ public class TiffMetadataTests { Assert.NotNull(rootFrameMetaData.XmpProfile); Assert.NotNull(rootFrameMetaData.ExifProfile); - Assert.Equal(2599, rootFrameMetaData.XmpProfile.Data.Length); + Assert.Equal(2596, rootFrameMetaData.XmpProfile.Data.Length); // padding bytes are trimmed Assert.Equal(25, rootFrameMetaData.ExifProfile.Values.Count); } } @@ -186,7 +186,7 @@ public class TiffMetadataTests Assert.Equal(32, rootFrame.Width); Assert.Equal(32, rootFrame.Height); Assert.NotNull(rootFrame.Metadata.XmpProfile); - Assert.Equal(2599, rootFrame.Metadata.XmpProfile.Data.Length); + Assert.Equal(2596, rootFrame.Metadata.XmpProfile.Data.Length); // padding bytes are trimmed ExifProfile exifProfile = rootFrame.Metadata.ExifProfile; TiffFrameMetadata tiffFrameMetadata = rootFrame.Metadata.GetTiffMetadata(); diff --git a/tests/ImageSharp.Tests/Metadata/ImageFrameMetadataTests.cs b/tests/ImageSharp.Tests/Metadata/ImageFrameMetadataTests.cs index cee37ca56..e50d0b617 100644 --- a/tests/ImageSharp.Tests/Metadata/ImageFrameMetadataTests.cs +++ b/tests/ImageSharp.Tests/Metadata/ImageFrameMetadataTests.cs @@ -74,7 +74,7 @@ public class ImageFrameMetadataTests Assert.False(metaData.ExifProfile.Equals(clone.ExifProfile)); Assert.True(metaData.ExifProfile.Values.Count == clone.ExifProfile.Values.Count); Assert.False(ReferenceEquals(metaData.XmpProfile, clone.XmpProfile)); - Assert.True(metaData.XmpProfile.Data.Equals(clone.XmpProfile.Data)); + Assert.False(ReferenceEquals(metaData.XmpProfile.Data, clone.XmpProfile.Data)); Assert.False(metaData.GetGifMetadata().Equals(clone.GetGifMetadata())); Assert.False(metaData.IccProfile.Equals(clone.IccProfile)); Assert.False(metaData.IptcProfile.Equals(clone.IptcProfile)); diff --git a/tests/ImageSharp.Tests/Metadata/Profiles/XMP/XmpProfileTests.cs b/tests/ImageSharp.Tests/Metadata/Profiles/XMP/XmpProfileTests.cs index e121d24f9..8e8f89e6f 100644 --- a/tests/ImageSharp.Tests/Metadata/Profiles/XMP/XmpProfileTests.cs +++ b/tests/ImageSharp.Tests/Metadata/Profiles/XMP/XmpProfileTests.cs @@ -78,6 +78,34 @@ public class XmpProfileTests } } + [Fact] + public void XmlProfile_CtorFromXDocument_Works() + { + // arrange + XDocument document = CreateMinimalXDocument(); + + // act + XmpProfile profile = new(document); + + // assert + XmpProfileContainsExpectedValues(profile); + } + + [Fact] + public void XmpProfile_ToXDocument_ReturnsValidDocument() + { + // arrange + XmpProfile profile = CreateMinimalXmlProfile(); + + // act + XDocument document = profile.ToXDocument(); + + // assert + Assert.NotNull(document); + Assert.Equal("xmpmeta", document.Root.Name.LocalName); + Assert.Equal("adobe:ns:meta/", document.Root.Name.NamespaceName); + } + [Fact] public void XmpProfile_ToFromByteArray_ReturnsClone() { @@ -97,11 +125,11 @@ public class XmpProfileTests { // arrange XmpProfile profile = CreateMinimalXmlProfile(); - byte[] original = profile.ToByteArray(); + byte[] original = profile.Data; // act XmpProfile clone = profile.DeepClone(); - byte[] actual = clone.ToByteArray(); + byte[] actual = clone.Data; // assert Assert.False(ReferenceEquals(original, actual)); @@ -218,7 +246,7 @@ public class XmpProfileTests private static void XmpProfileContainsExpectedValues(XmpProfile xmp) { Assert.NotNull(xmp); - XDocument document = xmp.GetDocument(); + XDocument document = xmp.ToXDocument(); Assert.NotNull(document); Assert.Equal("xmpmeta", document.Root.Name.LocalName); Assert.Equal("adobe:ns:meta/", document.Root.Name.NamespaceName); @@ -232,6 +260,8 @@ public class XmpProfileTests return profile; } + private static XDocument CreateMinimalXDocument() => CreateMinimalXmlProfile().ToXDocument(); + private static Image WriteAndRead(Image image, IImageEncoder encoder) { using (MemoryStream memStream = new()) From 81d533b320816726dd3a56a634e30b3df1aed61b Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Thu, 22 Jan 2026 16:10:21 +1000 Subject: [PATCH 2/6] Improve behavior and add comments --- .../Metadata/Profiles/XMP/XmpProfile.cs | 32 +++++++++++++++---- .../Metadata/Profiles/XMP/XmpProfileTests.cs | 2 +- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/ImageSharp/Metadata/Profiles/XMP/XmpProfile.cs b/src/ImageSharp/Metadata/Profiles/XMP/XmpProfile.cs index de34a1eaa..639f09722 100644 --- a/src/ImageSharp/Metadata/Profiles/XMP/XmpProfile.cs +++ b/src/ImageSharp/Metadata/Profiles/XMP/XmpProfile.cs @@ -46,7 +46,7 @@ public sealed class XmpProfile : IDeepCloneable /// /// Convert the content of this into an . /// - /// The + /// The instance, or if no XMP data is present. public XDocument? ToXDocument() { byte[]? data = this.Data; @@ -74,8 +74,14 @@ public sealed class XmpProfile : IDeepCloneable /// The public byte[] ToByteArray() { - Guard.NotNull(this.Data); - byte[] result = new byte[this.Data.Length]; + byte[]? data = this.Data; + + if (data is null) + { + return []; + } + + byte[] result = new byte[data.Length]; this.Data.AsSpan().CopyTo(result); return result; } @@ -83,10 +89,15 @@ public sealed class XmpProfile : IDeepCloneable /// public XmpProfile DeepClone() { - Guard.NotNull(this.Data); + byte[]? data = this.Data; + if (data is null) + { + // Preserve the semantics of an "empty" profile when cloning. + return new XmpProfile(); + } - byte[] clone = new byte[this.Data.Length]; - this.Data.AsSpan().CopyTo(clone); + byte[] clone = new byte[data.Length]; + data.AsSpan().CopyTo(clone); return new XmpProfile(clone); } @@ -118,7 +129,14 @@ public sealed class XmpProfile : IDeepCloneable } // Allocation-free fast path for the normal case. + + // Check for UTF-8 BOM (0xEF,0xBB,0xBF) bool hasBom = data.Length >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF; + + // XMP metadata is commonly stored in fixed-size container blocks (e.g. TIFF tag 700). + // Producers often pad unused space so the packet can be updated in-place without + // rewriting the file. In practice this padding is either NUL (0x00) from the container + // or 0x0F used by Adobe XMP writers. Both are invalid XML and must be trimmed. bool hasTrailingPad = data[^1] is 0 or 0x0F; if (!hasBom && !hasTrailingPad) @@ -146,7 +164,7 @@ public sealed class XmpProfile : IDeepCloneable int length = end - start; if (length <= 0) { - return []; + return null; } byte[] normalized = new byte[length]; diff --git a/tests/ImageSharp.Tests/Metadata/Profiles/XMP/XmpProfileTests.cs b/tests/ImageSharp.Tests/Metadata/Profiles/XMP/XmpProfileTests.cs index 8e8f89e6f..32d4bc7cd 100644 --- a/tests/ImageSharp.Tests/Metadata/Profiles/XMP/XmpProfileTests.cs +++ b/tests/ImageSharp.Tests/Metadata/Profiles/XMP/XmpProfileTests.cs @@ -79,7 +79,7 @@ public class XmpProfileTests } [Fact] - public void XmlProfile_CtorFromXDocument_Works() + public void XmpProfile_CtorFromXDocument_Works() { // arrange XDocument document = CreateMinimalXDocument(); From ff02d0c1f0cf947f70a8c950ac294068b666f211 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Fri, 23 Jan 2026 14:30:21 +1000 Subject: [PATCH 3/6] Use IptcProfile in PNG --- src/ImageSharp/Formats/Png/PngConstants.cs | 42 ++++ src/ImageSharp/Formats/Png/PngDecoderCore.cs | 218 +++++++++++++++++- src/ImageSharp/Formats/Png/PngEncoderCore.cs | 159 +++++++++++++ .../Profiles/IPTC/IptcRecordNumber.cs | 4 +- .../Metadata/Profiles/IPTC/IptcValue.cs | 36 ++- .../Formats/Png/PngMetadataTests.cs | 50 +++- tests/ImageSharp.Tests/TestImages.cs | 1 + tests/Images/Input/Png/iptc-profile.png | 3 + 8 files changed, 499 insertions(+), 14 deletions(-) create mode 100644 tests/Images/Input/Png/iptc-profile.png diff --git a/src/ImageSharp/Formats/Png/PngConstants.cs b/src/ImageSharp/Formats/Png/PngConstants.cs index 17d13e86d..c5d887985 100644 --- a/src/ImageSharp/Formats/Png/PngConstants.cs +++ b/src/ImageSharp/Formats/Png/PngConstants.cs @@ -62,6 +62,21 @@ internal static class PngConstants /// public const int MinTextKeywordLength = 1; + /// + /// Specifies the keyword used to identify the Exif raw profile in image metadata. + /// + public const string ExifRawProfileKeyword = "Raw profile type exif"; + + /// + /// Specifies the profile keyword used to identify raw IPTC metadata within image files. + /// + public const string IptcRawProfileKeyword = "Raw profile type iptc"; + + /// + /// The IPTC resource id in Photoshop IRB. 0x0404 (big endian). + /// + public const ushort AdobeIptcResourceId = 0x0404; + /// /// Gets the header bytes identifying a Png. /// @@ -100,4 +115,31 @@ internal static class PngConstants (byte)'m', (byte)'p' ]; + + /// + /// Gets the ASCII bytes for the "Photoshop 3.0" identifier used in some PNG metadata payloads. + /// This value is null-terminated. + /// + public static ReadOnlySpan AdobePhotoshop30 => + [ + (byte)'P', + (byte)'h', + (byte)'o', + (byte)'t', + (byte)'o', + (byte)'s', + (byte)'h', + (byte)'o', + (byte)'p', + (byte)' ', + (byte)'3', + (byte)'.', + (byte)'0', + 0 + ]; + + /// + /// Gets the ASCII bytes for the "8BIM" signature used in Photoshop resources. + /// + public static ReadOnlySpan EightBim => [(byte)'8', (byte)'B', (byte)'I', (byte)'M']; } diff --git a/src/ImageSharp/Formats/Png/PngDecoderCore.cs b/src/ImageSharp/Formats/Png/PngDecoderCore.cs index bff4d30ee..271474a7e 100644 --- a/src/ImageSharp/Formats/Png/PngDecoderCore.cs +++ b/src/ImageSharp/Formats/Png/PngDecoderCore.cs @@ -21,6 +21,7 @@ using SixLabors.ImageSharp.Metadata; using SixLabors.ImageSharp.Metadata.Profiles.Cicp; using SixLabors.ImageSharp.Metadata.Profiles.Exif; using SixLabors.ImageSharp.Metadata.Profiles.Icc; +using SixLabors.ImageSharp.Metadata.Profiles.Iptc; using SixLabors.ImageSharp.Metadata.Profiles.Xmp; using SixLabors.ImageSharp.PixelFormats; @@ -1440,14 +1441,19 @@ internal sealed class PngDecoderCore : ImageDecoderCore /// object unmodified. private static bool TryReadTextChunkMetadata(ImageMetadata baseMetadata, string chunkName, string chunkText) { - if (chunkName.Equals("Raw profile type exif", StringComparison.OrdinalIgnoreCase) && + if (chunkName.Equals(PngConstants.ExifRawProfileKeyword, StringComparison.OrdinalIgnoreCase) && TryReadLegacyExifTextChunk(baseMetadata, chunkText)) { // Successfully parsed legacy exif data from text return true; } - // TODO: "Raw profile type iptc", potentially others? + if (chunkName.Equals(PngConstants.IptcRawProfileKeyword, StringComparison.OrdinalIgnoreCase) && + TryReadLegacyIptcTextChunk(baseMetadata, chunkText)) + { + // Successfully parsed legacy iptc data from text + return true; + } // No special chunk data identified return false; @@ -1571,6 +1577,214 @@ internal sealed class PngDecoderCore : ImageDecoderCore return true; } + /// + /// Reads iptc data encoded into a text chunk with the name "Raw profile type iptc". + /// This convention is used by ImageMagick/exiftool/exiv2/digiKam and stores a byte-count + /// followed by hex-encoded bytes. + /// + /// The to store the decoded iptc tags into. + /// The contents of the "Raw profile type iptc" text chunk. + private static bool TryReadLegacyIptcTextChunk(ImageMetadata metadata, string data) + { + // Preserve first IPTC found. + if (metadata.IptcProfile != null) + { + return true; + } + + ReadOnlySpan dataSpan = data.AsSpan().TrimStart(); + + // Must start with the "iptc" identifier (case-insensitive). + // Common real-world format (ImageMagick/ExifTool) is: + // "IPTC profile\n \n" + if (dataSpan.Length < 4 || !StringEqualsInsensitive(dataSpan[..4], "iptc".AsSpan())) + { + return false; + } + + // Skip the remainder of the first line ("IPTC profile", etc). + int firstLineEnd = dataSpan.IndexOf('\n'); + if (firstLineEnd < 0) + { + return false; + } + + dataSpan = dataSpan[(firstLineEnd + 1)..].TrimStart(); + + // Next line contains the decimal byte length (often indented). + int dataLengthEnd = dataSpan.IndexOf('\n'); + if (dataLengthEnd < 0) + { + return false; + } + + int dataLength; + try + { + dataLength = ParseInt32(dataSpan[..dataLengthEnd]); + } + catch + { + return false; + } + + if (dataLength <= 0) + { + return false; + } + + // Skip to the hex-encoded data. + dataSpan = dataSpan[(dataLengthEnd + 1)..].Trim(); + + byte[] iptcBlob = new byte[dataLength]; + + try + { + int written = 0; + + for (; written < dataLength;) + { + ReadOnlySpan lineSpan = dataSpan; + + int newlineIndex = dataSpan.IndexOf('\n'); + if (newlineIndex != -1) + { + lineSpan = dataSpan[..newlineIndex]; + } + + // Important: handle CRLF and any incidental whitespace. + lineSpan = lineSpan.Trim(); // removes ' ', '\t', '\r', '\n', etc. + + if (!lineSpan.IsEmpty) + { + written += HexConverter.HexStringToBytes(lineSpan, iptcBlob.AsSpan()[written..]); + } + + if (newlineIndex == -1) + { + break; + } + + dataSpan = dataSpan[(newlineIndex + 1)..]; + } + + if (written != dataLength) + { + return false; + } + } + catch + { + return false; + } + + // Prefer IRB extraction if this is Photoshop-style data (8BIM resource blocks). + byte[] iptcPayload = TryExtractIptcFromPhotoshopIrb(iptcBlob, out byte[] extracted) + ? extracted + : iptcBlob; + + metadata.IptcProfile = new IptcProfile(iptcPayload); + return true; + } + + /// + /// Attempts to extract IPTC metadata from a Photoshop Image Resource Block (IRB) contained within the specified + /// data buffer. + /// + /// This method scans the provided data for a Photoshop IRB block containing IPTC metadata and + /// extracts it if present. The method does not validate the contents of the IPTC data beyond locating the + /// appropriate resource block. + /// A read-only span of bytes containing the Photoshop IRB data to search for embedded IPTC metadata. + /// When this method returns, contains the extracted IPTC metadata as a byte array if found; otherwise, an undefined + /// value. + /// if IPTC metadata is successfully extracted from the IRB data; otherwise, . + private static bool TryExtractIptcFromPhotoshopIrb(ReadOnlySpan data, out byte[] iptcBytes) + { + iptcBytes = default!; + + ReadOnlySpan adobePhotoshop30 = PngConstants.AdobePhotoshop30; + + // Some writers include the "Photoshop 3.0\0" header, some store just IRB blocks. + if (data.Length >= adobePhotoshop30.Length && data[..adobePhotoshop30.Length].SequenceEqual(adobePhotoshop30)) + { + data = data[adobePhotoshop30.Length..]; + } + + ReadOnlySpan eightBim = PngConstants.EightBim; + ushort adobeIptcResourceId = PngConstants.AdobeIptcResourceId; + while (data.Length >= 12) + { + if (!data[..4].SequenceEqual(eightBim)) + { + return false; + } + + data = data[4..]; + + // Resource ID (2 bytes, big endian) + if (data.Length < 2) + { + return false; + } + + ushort resourceId = (ushort)((data[0] << 8) | data[1]); + data = data[2..]; + + // Pascal string name (1-byte length, then bytes), padded to even. + if (data.Length < 1) + { + return false; + } + + int nameLen = data[0]; + int nameFieldLen = 1 + nameLen; + if ((nameFieldLen & 1) != 0) + { + nameFieldLen++; // pad to even + } + + if (data.Length < nameFieldLen + 4) + { + return false; + } + + data = data[nameFieldLen..]; + + // Resource data size (4 bytes, big endian) + int size = (data[0] << 24) | (data[1] << 16) | (data[2] << 8) | data[3]; + data = data[4..]; + + if (size < 0 || data.Length < size) + { + return false; + } + + ReadOnlySpan payload = data[..size]; + + // Data is padded to even. + int advance = size; + if ((advance & 1) != 0) + { + advance++; + } + + if (resourceId == adobeIptcResourceId) + { + iptcBytes = payload.ToArray(); + return true; + } + + if (data.Length < advance) + { + return false; + } + + data = data[advance..]; + } + + return false; + } + /// /// Reads the color profile chunk. The data is stored similar to the zTXt chunk. /// diff --git a/src/ImageSharp/Formats/Png/PngEncoderCore.cs b/src/ImageSharp/Formats/Png/PngEncoderCore.cs index 2b01affea..2bb97221c 100644 --- a/src/ImageSharp/Formats/Png/PngEncoderCore.cs +++ b/src/ImageSharp/Formats/Png/PngEncoderCore.cs @@ -8,6 +8,7 @@ using System.IO.Hashing; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Runtime.Intrinsics; +using System.Text; using SixLabors.ImageSharp.Common.Helpers; using SixLabors.ImageSharp.Compression.Zlib; using SixLabors.ImageSharp.Formats.Png.Chunks; @@ -217,6 +218,7 @@ internal sealed class PngEncoderCore : IDisposable this.WritePhysicalChunk(stream, metadata); this.WriteExifChunk(stream, metadata); this.WriteXmpChunk(stream, metadata); + this.WriteIptcChunk(stream, metadata); this.WriteTextChunks(stream, pngMetadata); if (image.Frames.Count > 1) @@ -889,6 +891,163 @@ internal sealed class PngEncoderCore : IDisposable this.WriteChunk(stream, PngChunkType.InternationalText, payload); } + /// + /// Writes the IPTC metadata from the specified image metadata to the provided stream as a compressed zTXt chunk in + /// PNG format, if IPTC data is present. + /// + /// The containing image data. + /// The image metadata. + private void WriteIptcChunk(Stream stream, ImageMetadata meta) + { + if ((this.chunkFilter & PngChunkFilter.ExcludeTextChunks) == PngChunkFilter.ExcludeTextChunks) + { + return; + } + + if (meta.IptcProfile is null || !meta.IptcProfile.Values.Any()) + { + return; + } + + meta.IptcProfile.UpdateData(); + + byte[]? iptcData = meta.IptcProfile.Data; + if (iptcData?.Length is 0 or null) + { + return; + } + + // For interoperability, wrap raw IPTC (IIM) in a Photoshop IRB (8BIM, resource 0x0404), + // since "Raw profile type iptc" commonly stores IRB payloads. + using IMemoryOwner irb = this.BuildPhotoshopIrbForIptc(iptcData); + + Span irbSpan = irb.GetSpan(); + + // Build "raw profile" textual wrapper: + // "IPTC profile\n\n\n" + string rawProfileText = BuildRawProfileText("IPTC profile", irbSpan); + + byte[] compressedData = this.GetZlibCompressedBytes(PngConstants.Encoding.GetBytes(rawProfileText)); + + // zTXt layout: keyword (latin-1) + 0 + compression-method(0) + compressed-data + const string iptcRawProfileKeyword = PngConstants.IptcRawProfileKeyword; + int payloadLength = iptcRawProfileKeyword.Length + compressedData.Length + 2; + + using IMemoryOwner payload = this.memoryAllocator.Allocate(payloadLength); + Span outputBytes = payload.GetSpan(); + + PngConstants.Encoding.GetBytes(iptcRawProfileKeyword).CopyTo(outputBytes); + int bytesWritten = iptcRawProfileKeyword.Length; + outputBytes[bytesWritten++] = 0; // Null separator + outputBytes[bytesWritten++] = 0; // Compression method: deflate + compressedData.CopyTo(outputBytes[bytesWritten..]); + + this.WriteChunk(stream, PngChunkType.CompressedText, outputBytes); + } + + /// + /// Builds a Photoshop Image Resource Block (IRB) containing the specified IPTC-IIM data. + /// + /// The returned IRB uses resource ID 0x0404 and an empty Pascal string for the name, as required + /// for IPTC-NAA record embedding in Photoshop files. The data is padded to ensure even length, as specified by the + /// IRB format. + /// + /// The IPTC-IIM data to embed in the IRB, provided as a read-only span of bytes. The data is included as-is in the + /// resulting block. + /// + /// + /// A byte array representing the Photoshop IRB with the embedded IPTC-IIM data, formatted according to the + /// Photoshop specification. + /// + private IMemoryOwner BuildPhotoshopIrbForIptc(ReadOnlySpan iptcIim) + { + // IRB block: + // 4 bytes: "8BIM" + // 2 bytes: resource id 0x0404 (big endian) + // 2 bytes: pascal name (len=0) + pad to even => 0x00 0x00 + // 4 bytes: data size (big endian) + // n bytes: IPTC-IIM data + // pad to even + int pad = (iptcIim.Length & 1) != 0 ? 1 : 0; + IMemoryOwner bufferOwner = this.memoryAllocator.Allocate(4 + 2 + 2 + 4 + iptcIim.Length + pad); + Span buffer = bufferOwner.GetSpan(); + + int bytesWritten = 0; + PngConstants.EightBim.CopyTo(buffer); + bytesWritten += 4; + + buffer[bytesWritten++] = 0x04; + buffer[bytesWritten++] = 0x04; + + buffer[bytesWritten++] = 0x00; // Pascal name length + buffer[bytesWritten++] = 0x00; // pad to even + + int size = iptcIim.Length; + buffer[bytesWritten++] = (byte)((size >> 24) & 0xFF); + buffer[bytesWritten++] = (byte)((size >> 16) & 0xFF); + buffer[bytesWritten++] = (byte)((size >> 8) & 0xFF); + buffer[bytesWritten++] = (byte)(size & 0xFF); + + iptcIim.CopyTo(buffer[bytesWritten..]); + + // Final pad byte already zero-initialized if needed + return bufferOwner; + } + + /// + /// Builds a formatted text representation of a binary profile, including a header, the payload length, and the + /// payload as hexadecimal text. + /// + /// + /// The hexadecimal payload is formatted with 64 bytes per line to improve readability. The + /// output consists of the header line, a line with the payload length, and one or more lines of hexadecimal + /// text. + /// + /// The header text to include at the beginning of the profile. This is written as the first line of the output. + /// The binary payload to encode as hexadecimal text. The payload is split into lines of 64 bytes each. + /// + /// A string containing the header, the payload length, and the hexadecimal representation of the payload, each on + /// separate lines. + /// + private static string BuildRawProfileText(string header, ReadOnlySpan payload) + { + // Hex text can be multi-line + // Use 64 bytes per line (128 hex chars) to keep the chunk readable. + const int bytesPerLine = 64; + + int hexChars = payload.Length * 2; + int lineCount = (payload.Length + (bytesPerLine - 1)) / bytesPerLine; + int newlineCount = 2 + lineCount; // header line + length line + hex lines + int capacity = header.Length + 32 + hexChars + newlineCount; + + StringBuilder sb = new(capacity); + sb.Append(header).Append('\n'); + sb.Append(payload.Length).Append('\n'); + + int i = 0; + while (i < payload.Length) + { + int take = Math.Min(bytesPerLine, payload.Length - i); + AppendHex(sb, payload.Slice(i, take)); + sb.Append('\n'); + i += take; + } + + return sb.ToString(); + } + + private static void AppendHex(StringBuilder sb, ReadOnlySpan data) + { + const string hex = "0123456789ABCDEF"; + + for (int i = 0; i < data.Length; i++) + { + byte b = data[i]; + _ = sb.Append(hex[b >> 4]); + _ = sb.Append(hex[b & 0x0F]); + } + } + /// /// Writes the CICP profile chunk /// diff --git a/src/ImageSharp/Metadata/Profiles/IPTC/IptcRecordNumber.cs b/src/ImageSharp/Metadata/Profiles/IPTC/IptcRecordNumber.cs index 2d5fe6a09..bbbeb83e0 100644 --- a/src/ImageSharp/Metadata/Profiles/IPTC/IptcRecordNumber.cs +++ b/src/ImageSharp/Metadata/Profiles/IPTC/IptcRecordNumber.cs @@ -9,12 +9,12 @@ namespace SixLabors.ImageSharp.Metadata.Profiles.IPTC; internal enum IptcRecordNumber : byte { /// - /// A Envelope Record. + /// An Envelope Record. /// Envelope = 0x01, /// - /// A Application Record. + /// An Application Record. /// Application = 0x02 } diff --git a/src/ImageSharp/Metadata/Profiles/IPTC/IptcValue.cs b/src/ImageSharp/Metadata/Profiles/IPTC/IptcValue.cs index 7735810b3..65daf5936 100644 --- a/src/ImageSharp/Metadata/Profiles/IPTC/IptcValue.cs +++ b/src/ImageSharp/Metadata/Profiles/IPTC/IptcValue.cs @@ -2,6 +2,7 @@ // Licensed under the Six Labors Split License. using System.Diagnostics; +using System.Globalization; using System.Text; namespace SixLabors.ImageSharp.Metadata.Profiles.Iptc; @@ -9,7 +10,7 @@ namespace SixLabors.ImageSharp.Metadata.Profiles.Iptc; /// /// Represents a single value of the IPTC profile. /// -[DebuggerDisplay("{Tag} = {ToString(),nq} ({GetType().Name,nq})")] +[DebuggerDisplay("{Tag} = {DebuggerDisplayValue(),nq} ({GetType().Name,nq})")] public sealed class IptcValue : IDeepCloneable { private byte[] data = []; @@ -213,4 +214,37 @@ public sealed class IptcValue : IDeepCloneable return encoding.GetString(this.data); } + + private string DebuggerDisplayValue() + { + // IPTC RecordVersion (2:00) is a 2-byte binary value, commonly 0x0004. + // Showing it as UTF-8 produces control characters like "\0\u0004". + if (this.Tag == IptcTag.RecordVersion && this.data.Length == 2) + { + int version = (this.data[0] << 8) | this.data[1]; + return version.ToString(CultureInfo.InvariantCulture); + } + + // Prefer readable text if it looks like it, otherwise show hex. + // (Avoid surprising debugger output for binary payloads.) + bool printable = true; + for (int i = 0; i < this.data.Length; i++) + { + byte b = this.data[i]; + + // If any byte is an ASCII control character, treat this value as binary. + if (b is < 0x20 or 0x7F) + { + printable = false; + break; + } + } + + if (printable) + { + return this.Value; + } + + return Convert.ToHexString(this.data); + } } diff --git a/tests/ImageSharp.Tests/Formats/Png/PngMetadataTests.cs b/tests/ImageSharp.Tests/Formats/Png/PngMetadataTests.cs index a0c552a22..42048426e 100644 --- a/tests/ImageSharp.Tests/Formats/Png/PngMetadataTests.cs +++ b/tests/ImageSharp.Tests/Formats/Png/PngMetadataTests.cs @@ -6,6 +6,7 @@ using SixLabors.ImageSharp.Formats.Png; using SixLabors.ImageSharp.Formats.Png.Chunks; using SixLabors.ImageSharp.Metadata; using SixLabors.ImageSharp.Metadata.Profiles.Exif; +using SixLabors.ImageSharp.Metadata.Profiles.Iptc; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Tests.TestUtilities; @@ -31,16 +32,16 @@ public class PngMetadataTests ColorType = PngColorType.GrayscaleWithAlpha, InterlaceMethod = PngInterlaceMode.Adam7, Gamma = 2, - TextData = new List { new("name", "value", "foo", "bar") }, + TextData = [new("name", "value", "foo", "bar")], RepeatCount = 123, AnimateRootFrame = false }; - PngMetadata clone = (PngMetadata)meta.DeepClone(); + PngMetadata clone = meta.DeepClone(); - Assert.True(meta.BitDepth == clone.BitDepth); - Assert.True(meta.ColorType == clone.ColorType); - Assert.True(meta.InterlaceMethod == clone.InterlaceMethod); + Assert.Equal(meta.BitDepth, clone.BitDepth); + Assert.Equal(meta.ColorType, clone.ColorType); + Assert.Equal(meta.InterlaceMethod, clone.InterlaceMethod); Assert.True(meta.Gamma.Equals(clone.Gamma)); Assert.False(meta.TextData.Equals(clone.TextData)); Assert.True(meta.TextData.SequenceEqual(clone.TextData)); @@ -53,15 +54,47 @@ public class PngMetadataTests clone.Gamma = 1; clone.RepeatCount = 321; - Assert.False(meta.BitDepth == clone.BitDepth); - Assert.False(meta.ColorType == clone.ColorType); - Assert.False(meta.InterlaceMethod == clone.InterlaceMethod); + Assert.NotEqual(meta.BitDepth, clone.BitDepth); + Assert.NotEqual(meta.ColorType, clone.ColorType); + Assert.NotEqual(meta.InterlaceMethod, clone.InterlaceMethod); Assert.False(meta.Gamma.Equals(clone.Gamma)); Assert.False(meta.TextData.Equals(clone.TextData)); Assert.True(meta.TextData.SequenceEqual(clone.TextData)); Assert.False(meta.RepeatCount == clone.RepeatCount); } + [Theory] + [WithFile(TestImages.Png.IptcMetadata, PixelTypes.Rgba32)] + public void Decoder_CanReadIptcProfile(TestImageProvider provider) + where TPixel : unmanaged, IPixel + { + using Image image = provider.GetImage(PngDecoder.Instance); + Assert.NotNull(image.Metadata.IptcProfile); + Assert.Equal("test1, test2", image.Metadata.IptcProfile.GetValues(IptcTag.Keywords)[0].Value); + Assert.Equal("\0\u0004", image.Metadata.IptcProfile.GetValues(IptcTag.RecordVersion)[0].Value); + } + + [Theory] + [WithFile(TestImages.Png.IptcMetadata, PixelTypes.Rgba32)] + public void Encoder_CanWriteIptcProfile(TestImageProvider provider) + where TPixel : unmanaged, IPixel + { + using Image image = provider.GetImage(PngDecoder.Instance); + Assert.NotNull(image.Metadata.IptcProfile); + Assert.Equal("test1, test2", image.Metadata.IptcProfile.GetValues(IptcTag.Keywords)[0].Value); + Assert.Equal("\0\u0004", image.Metadata.IptcProfile.GetValues(IptcTag.RecordVersion)[0].Value); + + using MemoryStream memoryStream = new(); + image.Save(memoryStream, new PngEncoder()); + + memoryStream.Position = 0; + + using Image decoded = PngDecoder.Instance.Decode(DecoderOptions.Default, memoryStream); + Assert.NotNull(decoded.Metadata.IptcProfile); + Assert.Equal("test1, test2", decoded.Metadata.IptcProfile.GetValues(IptcTag.Keywords)[0].Value); + Assert.Equal("\0\u0004", decoded.Metadata.IptcProfile.GetValues(IptcTag.RecordVersion)[0].Value); + } + [Theory] [WithFile(TestImages.Png.PngWithMetadata, PixelTypes.Rgba32)] public void Decoder_CanReadTextData(TestImageProvider provider) @@ -337,7 +370,6 @@ public class PngMetadataTests Assert.Equal(42, (int)exif.GetValue(ExifTag.ImageNumber).Value); } - [Theory] [InlineData(PixelColorType.Binary, PngColorType.Palette)] [InlineData(PixelColorType.Indexed, PngColorType.Palette)] diff --git a/tests/ImageSharp.Tests/TestImages.cs b/tests/ImageSharp.Tests/TestImages.cs index f6cd776e4..6b4a86666 100644 --- a/tests/ImageSharp.Tests/TestImages.cs +++ b/tests/ImageSharp.Tests/TestImages.cs @@ -62,6 +62,7 @@ public static class TestImages public const string TestPattern31x31HalfTransparent = "Png/testpattern31x31-halftransparent.png"; public const string XmpColorPalette = "Png/xmp-colorpalette.png"; public const string AdamHeadsHlg = "Png/adamHeadsHLG.png"; + public const string IptcMetadata = "Png/iptc-profile.png"; // Animated // https://philip.html5.org/tests/apng/tests.html diff --git a/tests/Images/Input/Png/iptc-profile.png b/tests/Images/Input/Png/iptc-profile.png new file mode 100644 index 000000000..fa4199a0c --- /dev/null +++ b/tests/Images/Input/Png/iptc-profile.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ae7f5d11145762b6544b3e289fc6c3bcb13a5f4cd8511b02280da683bec4c96e +size 448011 From d9816d12b5b2040ddb5ff8a3ab1f8991770cb2d8 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Wed, 28 Jan 2026 16:02:15 +1000 Subject: [PATCH 4/6] Add support for ICC conversion to WEBP --- src/ImageSharp/Formats/Webp/WebpDecoderCore.cs | 1 + .../Formats/Png/PngDecoderTests.cs | 1 + .../Formats/WebP/WebpDecoderTests.cs | 14 ++++++++++++++ tests/ImageSharp.Tests/TestImages.cs | 6 ++++++ ...ApplyIccProfile_Rgba32_Perceptual-cLUT-only.png | 3 +++ ...IsConvert_ApplyIccProfile_Rgba32_Perceptual.png | 3 +++ .../Webp/icc-profiles/Perceptual-cLUT-only.webp | 3 +++ .../Images/Input/Webp/icc-profiles/Perceptual.webp | 3 +++ 8 files changed, 34 insertions(+) create mode 100644 tests/Images/External/ReferenceOutput/WebpDecoderTests/Decode_WhenColorProfileHandlingIsConvert_ApplyIccProfile_Rgba32_Perceptual-cLUT-only.png create mode 100644 tests/Images/External/ReferenceOutput/WebpDecoderTests/Decode_WhenColorProfileHandlingIsConvert_ApplyIccProfile_Rgba32_Perceptual.png create mode 100644 tests/Images/Input/Webp/icc-profiles/Perceptual-cLUT-only.webp create mode 100644 tests/Images/Input/Webp/icc-profiles/Perceptual.webp diff --git a/src/ImageSharp/Formats/Webp/WebpDecoderCore.cs b/src/ImageSharp/Formats/Webp/WebpDecoderCore.cs index fd31a7fad..d6a07eeca 100644 --- a/src/ImageSharp/Formats/Webp/WebpDecoderCore.cs +++ b/src/ImageSharp/Formats/Webp/WebpDecoderCore.cs @@ -122,6 +122,7 @@ internal sealed class WebpDecoderCore : ImageDecoderCore, IDisposable this.ParseOptionalChunks(stream, metadata, this.webImageInfo.Features, buffer); } + _ = this.TryConvertIccProfile(image); return image; } } diff --git a/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs index a58101a6b..a57599455 100644 --- a/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs @@ -5,6 +5,7 @@ using System.Runtime.Intrinsics.X86; using Microsoft.DotNet.RemoteExecutor; using SixLabors.ImageSharp.Formats; using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.Formats.Webp; using SixLabors.ImageSharp.Memory; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Tests.TestUtilities; diff --git a/tests/ImageSharp.Tests/Formats/WebP/WebpDecoderTests.cs b/tests/ImageSharp.Tests/Formats/WebP/WebpDecoderTests.cs index c0abed214..6744f401e 100644 --- a/tests/ImageSharp.Tests/Formats/WebP/WebpDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/WebP/WebpDecoderTests.cs @@ -3,6 +3,7 @@ using System.Runtime.Intrinsics.X86; using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Formats.Png; using SixLabors.ImageSharp.Formats.Webp; using SixLabors.ImageSharp.Metadata; using SixLabors.ImageSharp.Metadata.Profiles.Exif; @@ -608,4 +609,17 @@ public class WebpDecoderTests image.DebugSave(provider); image.CompareToOriginal(provider, ReferenceDecoder); } + + [Theory] + [WithFile(Icc.Perceptual, PixelTypes.Rgba32)] + [WithFile(Icc.PerceptualcLUTOnly, PixelTypes.Rgba32)] + public void Decode_WhenColorProfileHandlingIsConvert_ApplyIccProfile(TestImageProvider provider) + where TPixel : unmanaged, IPixel + { + using Image image = provider.GetImage(WebpDecoder.Instance, new DecoderOptions { ColorProfileHandling = ColorProfileHandling.Convert }); + + image.DebugSave(provider); + image.CompareToReferenceOutput(provider); + Assert.Null(image.Metadata.IccProfile); + } } diff --git a/tests/ImageSharp.Tests/TestImages.cs b/tests/ImageSharp.Tests/TestImages.cs index 6b4a86666..dc3275999 100644 --- a/tests/ImageSharp.Tests/TestImages.cs +++ b/tests/ImageSharp.Tests/TestImages.cs @@ -901,6 +901,12 @@ public static class TestImages public const string AlphaBlend2 = "Webp/alpha-blend-2.webp"; public const string AlphaBlend3 = "Webp/alpha-blend-3.webp"; public const string AlphaBlend4 = "Webp/alpha-blend-4.webp"; + + public static class Icc + { + public const string Perceptual = "Webp/icc-profiles/Perceptual.webp"; + public const string PerceptualcLUTOnly = "Webp/icc-profiles/Perceptual-cLUT-only.webp"; + } } public static class Tiff diff --git a/tests/Images/External/ReferenceOutput/WebpDecoderTests/Decode_WhenColorProfileHandlingIsConvert_ApplyIccProfile_Rgba32_Perceptual-cLUT-only.png b/tests/Images/External/ReferenceOutput/WebpDecoderTests/Decode_WhenColorProfileHandlingIsConvert_ApplyIccProfile_Rgba32_Perceptual-cLUT-only.png new file mode 100644 index 000000000..4a01423ee --- /dev/null +++ b/tests/Images/External/ReferenceOutput/WebpDecoderTests/Decode_WhenColorProfileHandlingIsConvert_ApplyIccProfile_Rgba32_Perceptual-cLUT-only.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9a2cbd483eed31cf7b410ef1ee45ae78e77b36e5b9c31f87764a1dfdb9c4e5c8 +size 79768 diff --git a/tests/Images/External/ReferenceOutput/WebpDecoderTests/Decode_WhenColorProfileHandlingIsConvert_ApplyIccProfile_Rgba32_Perceptual.png b/tests/Images/External/ReferenceOutput/WebpDecoderTests/Decode_WhenColorProfileHandlingIsConvert_ApplyIccProfile_Rgba32_Perceptual.png new file mode 100644 index 000000000..c46b369ef --- /dev/null +++ b/tests/Images/External/ReferenceOutput/WebpDecoderTests/Decode_WhenColorProfileHandlingIsConvert_ApplyIccProfile_Rgba32_Perceptual.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:547f3ddb5bdb3a3cb5c87440b65728be295c2b5f3c10a1b0b44299bb3d80e8d7 +size 79651 diff --git a/tests/Images/Input/Webp/icc-profiles/Perceptual-cLUT-only.webp b/tests/Images/Input/Webp/icc-profiles/Perceptual-cLUT-only.webp new file mode 100644 index 000000000..4787b792a --- /dev/null +++ b/tests/Images/Input/Webp/icc-profiles/Perceptual-cLUT-only.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7b7ef9eedd1b2a4b93f11ac52807c071f7d0a24513008c3e69b0d4a0fd9b70db +size 186596 diff --git a/tests/Images/Input/Webp/icc-profiles/Perceptual.webp b/tests/Images/Input/Webp/icc-profiles/Perceptual.webp new file mode 100644 index 000000000..b78504f43 --- /dev/null +++ b/tests/Images/Input/Webp/icc-profiles/Perceptual.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aad13221b103b60c21a19e7ecfbab047404f25269485d26fc5dee4be11188865 +size 189800 From b8d288874c746aaa5dd97ce8590c283b4a0831d9 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Wed, 28 Jan 2026 17:39:04 +1000 Subject: [PATCH 5/6] Optimize conversion with Avx --- ...ofileConverterExtensionsPixelCompatible.cs | 122 +++++++++++- src/ImageSharp/ColorProfiles/Rgb.cs | 184 +++++++++++++++++- 2 files changed, 299 insertions(+), 7 deletions(-) diff --git a/src/ImageSharp/ColorProfiles/ColorProfileConverterExtensionsPixelCompatible.cs b/src/ImageSharp/ColorProfiles/ColorProfileConverterExtensionsPixelCompatible.cs index 2780f04ba..3c6cdba4a 100644 --- a/src/ImageSharp/ColorProfiles/ColorProfileConverterExtensionsPixelCompatible.cs +++ b/src/ImageSharp/ColorProfiles/ColorProfileConverterExtensionsPixelCompatible.cs @@ -5,6 +5,8 @@ using System.Buffers; using System.Numerics; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using System.Runtime.Intrinsics; +using System.Runtime.Intrinsics.X86; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; @@ -60,8 +62,126 @@ internal static class ColorProfileConverterExtensionsPixelCompatible converter.ConvertUsingIccProfile(rgbSpan, rgbSpan); // Copy the converted Rgb pixels back to the row as TPixel. + // Important: Preserve alpha from the existing row Vector4 values. + // We merge RGB from rgbSpan into row, leaving W untouched. + ref float srcRgb = ref Unsafe.As(ref MemoryMarshal.GetReference(rgbSpan)); + ref float dstRow = ref Unsafe.As(ref MemoryMarshal.GetReference(row)); + + int count = rgbSpan.Length; + int i = 0; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static Vector512 ReadVector512(ref float f) + { + ref byte b = ref Unsafe.As(ref f); + return Unsafe.ReadUnaligned>(ref b); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static void WriteVector512(ref float f, Vector512 v) + { + ref byte b = ref Unsafe.As(ref f); + Unsafe.WriteUnaligned(ref b, v); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static Vector256 ReadVector256(ref float f) + { + ref byte b = ref Unsafe.As(ref f); + return Unsafe.ReadUnaligned>(ref b); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static void WriteVector256(ref float f, Vector256 v) + { + ref byte b = ref Unsafe.As(ref f); + Unsafe.WriteUnaligned(ref b, v); + } + + if (Avx512F.IsSupported) + { + // 4 pixels per iteration. + // + // Source layout (Rgb float stream, 12 floats): + // [r0 g0 b0 r1 g1 b1 r2 g2 b2 r3 g3 b3] + // + // Destination layout (row Vector4 float stream, 16 floats): + // [r0 g0 b0 a0 r1 g1 b1 a1 r2 g2 b2 a2 r3 g3 b3 a3] + // + // We use an overlapped load (16 floats) from the 3-float stride source. + // The permute selects the RGB we need and inserts placeholders for alpha lanes. + // + // Then we blend RGB lanes into the existing destination, preserving alpha lanes. + Vector512 rgbPerm = Vector512.Create(0, 1, 2, 0, 3, 4, 5, 0, 6, 7, 8, 0, 9, 10, 11, 0); + + // BlendVariable selects from the second operand where the sign bit of the mask lane is set. + // We want to overwrite lanes 0,1,2 then 4,5,6 then 8,9,10 then 12,13,14, and preserve lanes 3,7,11,15 (alpha). + Vector512 rgbSelect = Vector512.Create(-0F, -0F, -0F, 0F, -0F, -0F, -0F, 0F, -0F, -0F, -0F, 0F, -0F, -0F, -0F, 0F); + + int quads = count >> 2; + int simdQuads = quads - 1; // Leave the last quad for the scalar tail to avoid the final overlapped load reading past the end. + + for (int q = 0; q < simdQuads; q++) + { + Vector512 dst = ReadVector512(ref dstRow); + Vector512 src = ReadVector512(ref srcRgb); + + Vector512 rgbx = Avx512F.PermuteVar16x32(src, rgbPerm); + Vector512 merged = Avx512F.BlendVariable(dst, rgbx, rgbSelect); + + WriteVector512(ref dstRow, merged); + + // Advance input by 4 pixels (4 * 3 = 12 floats) + srcRgb = ref Unsafe.Add(ref srcRgb, 12); + + // Advance output by 4 pixels (4 * 4 = 16 floats) + dstRow = ref Unsafe.Add(ref dstRow, 16); + + i += 4; + } + } + else if (Avx2.IsSupported) + { + // 2 pixels per iteration. + // + // Same idea as AVX-512, but on 256-bit vectors. + // We permute packed RGB into rgbx layout and blend into the existing destination, + // preserving alpha lanes. + Vector256 rgbPerm = Vector256.Create(0, 1, 2, 0, 3, 4, 5, 0); + + Vector256 rgbSelect = Vector256.Create(-0F, -0F, -0F, 0F, -0F, -0F, -0F, 0F); + + int pairs = count >> 1; + int simdPairs = pairs - 1; // Leave the last pair for the scalar tail to avoid the final overlapped load reading past the end. + + for (int p = 0; p < simdPairs; p++) + { + Vector256 dst = ReadVector256(ref dstRow); + Vector256 src = ReadVector256(ref srcRgb); + + Vector256 rgbx = Avx2.PermuteVar8x32(src, rgbPerm); + Vector256 merged = Avx.BlendVariable(dst, rgbx, rgbSelect); + + WriteVector256(ref dstRow, merged); + + // Advance input by 2 pixels (2 * 3 = 6 floats) + srcRgb = ref Unsafe.Add(ref srcRgb, 6); + + // Advance output by 2 pixels (2 * 4 = 8 floats) + dstRow = ref Unsafe.Add(ref dstRow, 8); + + i += 2; + } + } + + // Scalar tail. + // Handles: + // - the last skipped SIMD block (quad or pair) + // - any remainder + // + // Preserve alpha by writing Vector3 into the Vector4 storage. ref Vector4 rowRef = ref MemoryMarshal.GetReference(row); - for (int i = 0; i < rgbSpan.Length; i++) + for (; i < count; i++) { Vector3 rgb = rgbSpan[i].AsVector3Unsafe(); Unsafe.As(ref Unsafe.Add(ref rowRef, (uint)i)) = rgb; diff --git a/src/ImageSharp/ColorProfiles/Rgb.cs b/src/ImageSharp/ColorProfiles/Rgb.cs index 42e502592..c95e54192 100644 --- a/src/ImageSharp/ColorProfiles/Rgb.cs +++ b/src/ImageSharp/ColorProfiles/Rgb.cs @@ -4,6 +4,8 @@ using System.Numerics; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using System.Runtime.Intrinsics; +using System.Runtime.Intrinsics.X86; using SixLabors.ImageSharp.ColorProfiles.WorkingSpaces; namespace SixLabors.ImageSharp.ColorProfiles; @@ -105,10 +107,87 @@ public readonly struct Rgb : IProfileConnectingSpace { Guard.DestinationShouldNotBeTooShort(source, destination, nameof(destination)); - // TODO: Optimize via SIMD - for (int i = 0; i < source.Length; i++) + int length = source.Length; + if (length == 0) { - destination[i] = source[i].ToScaledVector4(); + return; + } + + ref Rgb srcRgb = ref MemoryMarshal.GetReference(source); + ref Vector4 dstV4 = ref MemoryMarshal.GetReference(destination); + + // Float streams: + // src: r0 g0 b0 r1 g1 b1 ... + // dst: r0 g0 b0 a0 r1 g1 b1 a1 ... + ref float src = ref Unsafe.As(ref srcRgb); + ref float dst = ref Unsafe.As(ref dstV4); + + int i = 0; + + if (Avx512F.IsSupported) + { + // 4 pixels per iteration. Using overlapped 16-float loads. + Vector512 perm = Vector512.Create(0, 1, 2, 0, 3, 4, 5, 0, 6, 7, 8, 0, 9, 10, 11, 0); + Vector512 ones = Vector512.Create(1F); + + // BlendVariable selects from 'ones' where the sign-bit of mask lane is set. + // Using -0f sets only the sign bit, producing an efficient "select lane" mask. + Vector512 alphaSelect = Vector512.Create(0F, 0F, 0F, -0F, 0F, 0F, 0F, -0F, 0F, 0F, 0F, -0F, 0F, 0F, 0F, -0F); + + int quads = length >> 2; + + // Leave the last quad (4 pixels) for the scalar tail. + int simdQuads = quads - 1; + + for (int q = 0; q < simdQuads; q++) + { + Vector512 v = ReadVector512(ref src); + Vector512 rgbx = Avx512F.PermuteVar16x32(v, perm); + Vector512 rgba = Avx512F.BlendVariable(rgbx, ones, alphaSelect); + + WriteVector512(ref dst, rgba); + + src = ref Unsafe.Add(ref src, 12); + dst = ref Unsafe.Add(ref dst, 16); + + i += 4; + } + } + else if (Avx2.IsSupported) + { + // 2 pixels per iteration. Using overlapped 8-float loads. + Vector256 perm = Vector256.Create(0, 1, 2, 0, 3, 4, 5, 0); + + Vector256 ones = Vector256.Create(1F); + + // vblendps mask: bit i selects lane i from 'ones' when set. + // We want lanes 3 and 7 -> 0b10001000 = 0x88. + const byte alphaMask = 0x88; + + int pairs = length >> 1; + + // Leave the last pair (2 pixels) for the scalar tail. + int simdPairs = pairs - 1; + + for (int p = 0; p < simdPairs; p++) + { + Vector256 v = ReadVector256(ref src); + Vector256 rgbx = Avx2.PermuteVar8x32(v, perm); + Vector256 rgba = Avx.Blend(rgbx, ones, alphaMask); + + WriteVector256(ref dst, rgba); + + src = ref Unsafe.Add(ref src, 6); + dst = ref Unsafe.Add(ref dst, 8); + + i += 2; + } + } + + // Tail (and non-AVX paths) + for (; i < length; i++) + { + Unsafe.Add(ref dstV4, i) = Unsafe.Add(ref srcRgb, i).ToScaledVector4(); } } @@ -117,10 +196,75 @@ public readonly struct Rgb : IProfileConnectingSpace { Guard.DestinationShouldNotBeTooShort(source, destination, nameof(destination)); - // TODO: Optimize via SIMD - for (int i = 0; i < source.Length; i++) + int length = source.Length; + if (length == 0) { - destination[i] = FromScaledVector4(source[i]); + return; + } + + ref Vector4 srcV4 = ref MemoryMarshal.GetReference(source); + ref Rgb dstRgb = ref MemoryMarshal.GetReference(destination); + + // Float streams: + // src: r0 g0 b0 a0 r1 g1 b1 a1 ... + // dst: r0 g0 b0 r1 g1 b1 ... + ref float src = ref Unsafe.As(ref srcV4); + ref float dst = ref Unsafe.As(ref dstRgb); + + int i = 0; + + if (Avx512F.IsSupported) + { + // 4 pixels per iteration. Using overlapped 16-float stores: + Vector512 idx = Vector512.Create(0, 1, 2, 4, 5, 6, 8, 9, 10, 12, 13, 14, 3, 7, 11, 15); + + // Number of 4-pixel groups in the input. + int quads = length >> 2; + + // Leave the last quad (4 pixels) for the scalar tail. + int simdQuads = quads - 1; + + for (int q = 0; q < simdQuads; q++) + { + Vector512 v = ReadVector512(ref src); + Vector512 packed = Avx512F.PermuteVar16x32(v, idx); + + WriteVector512(ref dst, packed); + + src = ref Unsafe.Add(ref src, 16); + dst = ref Unsafe.Add(ref dst, 12); + i += 4; + } + } + else if (Avx2.IsSupported) + { + // 2 pixels per iteration, using overlapped 8-float stores: + Vector256 idx = Vector256.Create(0, 1, 2, 4, 5, 6, 0, 0); + + int pairs = length >> 1; + + // Leave the last pair (2 pixels) for the scalar tail. + int simdPairs = pairs - 1; + + int pairIndex = 0; + for (; pairIndex < simdPairs; pairIndex++) + { + Vector256 v = ReadVector256(ref src); + Vector256 packed = Avx2.PermuteVar8x32(v, idx); + + WriteVector256(ref dst, packed); + + src = ref Unsafe.Add(ref src, 8); + dst = ref Unsafe.Add(ref dst, 6); + i += 2; + } + } + + // Tail (and non-AVX paths) + for (; i < length; i++) + { + Vector4 v = Unsafe.Add(ref srcV4, i); + Unsafe.Add(ref dstRgb, i) = FromScaledVector4(v); } } @@ -288,4 +432,32 @@ public readonly struct Rgb : IProfileConnectingSpace M44 = 1F }; } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Vector512 ReadVector512(ref float src) + { + ref byte b = ref Unsafe.As(ref src); + return Unsafe.ReadUnaligned>(ref b); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Vector256 ReadVector256(ref float src) + { + ref byte b = ref Unsafe.As(ref src); + return Unsafe.ReadUnaligned>(ref b); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void WriteVector512(ref float dst, Vector512 value) + { + ref byte b = ref Unsafe.As(ref dst); + Unsafe.WriteUnaligned(ref b, value); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void WriteVector256(ref float dst, Vector256 value) + { + ref byte b = ref Unsafe.As(ref dst); + Unsafe.WriteUnaligned(ref b, value); + } } From 08dd24fbee71b6c296392c92f2000a5151d6b948 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Wed, 28 Jan 2026 17:58:50 +1000 Subject: [PATCH 6/6] Remove unused namespaces --- tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs | 1 - tests/ImageSharp.Tests/Formats/WebP/WebpDecoderTests.cs | 1 - 2 files changed, 2 deletions(-) diff --git a/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs index a57599455..a58101a6b 100644 --- a/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs @@ -5,7 +5,6 @@ using System.Runtime.Intrinsics.X86; using Microsoft.DotNet.RemoteExecutor; using SixLabors.ImageSharp.Formats; using SixLabors.ImageSharp.Formats.Png; -using SixLabors.ImageSharp.Formats.Webp; using SixLabors.ImageSharp.Memory; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Tests.TestUtilities; diff --git a/tests/ImageSharp.Tests/Formats/WebP/WebpDecoderTests.cs b/tests/ImageSharp.Tests/Formats/WebP/WebpDecoderTests.cs index 6744f401e..a3e3b81cf 100644 --- a/tests/ImageSharp.Tests/Formats/WebP/WebpDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/WebP/WebpDecoderTests.cs @@ -3,7 +3,6 @@ using System.Runtime.Intrinsics.X86; using SixLabors.ImageSharp.Formats; -using SixLabors.ImageSharp.Formats.Png; using SixLabors.ImageSharp.Formats.Webp; using SixLabors.ImageSharp.Metadata; using SixLabors.ImageSharp.Metadata.Profiles.Exif;