diff --git a/README_V2.md b/README_V2.md
index 7e061d3a..8402a915 100644
--- a/README_V2.md
+++ b/README_V2.md
@@ -1179,6 +1179,60 @@ Result:
+#### 13. Images
+
+When a template placeholder resolves to a `byte[]` whose bytes are a recognised image,
+MiniExcel inserts it as a picture anchored to that cell instead of writing the value as text. The
+formats detected from the bytes are PNG, JPEG, GIF, BMP and TIFF. This mirrors the behaviour of
+`SaveAs`, so the datasource does not need any MiniExcel-specific type:
+
+```csharp
+public class Company
+{
+ public string Name { get; set; }
+ public byte[] Logo { get; set; }
+}
+```
+
+```csharp
+var templater = MiniExcelV2.Templaters.GetOpenXmlTemplater();
+var value = new { Company = new { Name = "MiniExcel", Logo = File.ReadAllBytes("logo.png") } };
+
+// Template cell: {{Company.Logo}}
+templater.FillTemplate(path, templatePath, value);
+```
+
+The same applies to nested paths (`{{Customer.Profile.Avatar}}`) and to collection placeholders,
+where each generated row gets its own image:
+
+```csharp
+// Template cells: {{Products.Name}} and {{Products.Image}}
+var templater = MiniExcelV2.Templaters.GetOpenXmlTemplater();
+var value = new
+{
+ Products = new[]
+ {
+ new { Name = "A", Image = File.ReadAllBytes("a.png") },
+ new { Name = "B", Image = File.ReadAllBytes("b.png") }
+ }
+};
+templater.FillTemplate(path, templatePath, value);
+```
+
+A `byte[]` that is not a recognised image keeps the previous behaviour, so existing templates are
+unaffected. To disable image embedding and keep `byte[]` values as regular values, set
+`EnableConvertByteArray` to `false`:
+
+```csharp
+var config = new OpenXmlConfiguration { EnableConvertByteArray = false };
+templater.FillTemplate(path, templatePath, value, configuration: config);
+```
+
+Images are scaled to the height of the row they are anchored to, preserving their aspect ratio, so
+setting a row height in the template controls how large the picture is rendered. Rows without an
+explicit height keep a default anchor size of 64x20 pixels.
+
+
### Editing existing workbooks
> Warning: this feature is a work in progress and currently very limited!
diff --git a/src/MiniExcel.Core/Helpers/ImageHelper.cs b/src/MiniExcel.Core/Helpers/ImageHelper.cs
index 1d89ac61..98cdc447 100644
--- a/src/MiniExcel.Core/Helpers/ImageHelper.cs
+++ b/src/MiniExcel.Core/Helpers/ImageHelper.cs
@@ -39,4 +39,183 @@ public static ImageFormat GetImageFormat(byte[] bytes)
return ImageFormat.Unknown;
}
+
+ ///
+ /// Reads the pixel dimensions of an image from its header. Returns null when the format is
+ /// not recognised or the header is truncated.
+ ///
+ public static (int Width, int Height)? GetImageSize(byte[]? bytes)
+ {
+ if (bytes is null || bytes.Length < 8)
+ return null;
+
+ if (bytes.StartsWith(Png))
+ return GetPngSize(bytes);
+
+ if (bytes.StartsWith(Gif))
+ return GetGifSize(bytes);
+
+ if (bytes.StartsWith(Bmp))
+ return GetBmpSize(bytes);
+
+ if (bytes.StartsWith(Jpeg) || bytes.StartsWith(Jpeg2))
+ return GetJpegSize(bytes);
+
+ if (bytes.StartsWith(Tiff) || bytes.StartsWith(Tiff2))
+ return GetTiffSize(bytes);
+
+ return null;
+ }
+
+ private static (int, int)? GetPngSize(byte[] bytes)
+ {
+ // 8-byte signature, 4-byte chunk length, then the "IHDR" chunk carrying width and height as
+ // big-endian 32-bit integers.
+ if (bytes.Length < 24 || bytes[12] != 'I' || bytes[13] != 'H' || bytes[14] != 'D' || bytes[15] != 'R')
+ return null;
+
+ var width = ReadInt32BigEndian(bytes, 16);
+ var height = ReadInt32BigEndian(bytes, 20);
+ return width > 0 && height > 0 ? (width, height) : null;
+ }
+
+ private static (int, int)? GetGifSize(byte[] bytes)
+ {
+ // Logical screen descriptor: width and height as little-endian 16-bit integers.
+ if (bytes.Length < 10)
+ return null;
+
+ var width = bytes[6] | (bytes[7] << 8);
+ var height = bytes[8] | (bytes[9] << 8);
+ return width > 0 && height > 0 ? (width, height) : null;
+ }
+
+ private static (int, int)? GetBmpSize(byte[] bytes)
+ {
+ if (bytes.Length < 26)
+ return null;
+
+ // A BITMAPCOREHEADER stores 16-bit dimensions; the more common BITMAPINFOHEADER family uses
+ // 32-bit ones, with a negative height meaning a top-down bitmap.
+ if (ReadInt32LittleEndian(bytes, 14) == 12)
+ {
+ var coreWidth = bytes[18] | (bytes[19] << 8);
+ var coreHeight = bytes[20] | (bytes[21] << 8);
+ return coreWidth > 0 && coreHeight > 0 ? (coreWidth, coreHeight) : null;
+ }
+
+ var width = ReadInt32LittleEndian(bytes, 18);
+ var height = Math.Abs((long)ReadInt32LittleEndian(bytes, 22));
+ return width > 0 && height is > 0 and <= int.MaxValue ? (width, (int)height) : null;
+ }
+
+ private static (int, int)? GetJpegSize(byte[] bytes)
+ {
+ var index = 2;
+ while (index + 8 < bytes.Length)
+ {
+ if (bytes[index] != 0xFF)
+ {
+ index++;
+ continue;
+ }
+
+ var marker = bytes[index + 1];
+ if (marker == 0xFF)
+ {
+ index++;
+ continue;
+ }
+
+ // Standalone markers (RSTn, SOI, EOI, TEM) have no payload.
+ if (marker == 0x01 || marker is >= 0xD0 and <= 0xD9)
+ {
+ index += 2;
+ continue;
+ }
+
+ // Start of scan: any frame header would have been found before this point.
+ if (marker == 0xDA)
+ break;
+
+ var segmentLength = (bytes[index + 2] << 8) | bytes[index + 3];
+ if (segmentLength < 2)
+ break;
+
+ // SOF0..SOF15, excluding DHT (C4), JPG (C8) and DAC (CC).
+ var isFrameHeader = marker is >= 0xC0 and <= 0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC;
+ if (isFrameHeader)
+ {
+ var height = (bytes[index + 5] << 8) | bytes[index + 6];
+ var width = (bytes[index + 7] << 8) | bytes[index + 8];
+ return width > 0 && height > 0 ? (width, height) : null;
+ }
+
+ index += 2 + segmentLength;
+ }
+
+ return null;
+ }
+
+ private static (int, int)? GetTiffSize(byte[] bytes)
+ {
+ if (bytes.Length < 8)
+ return null;
+
+ var littleEndian = bytes[0] == 'I';
+ var ifdOffset = ReadInt32(bytes, 4, littleEndian);
+
+ // IFD offsets come straight from the file. Compare against bytes.Length - required rather than
+ // computing offset + required, so a crafted offset near int.MaxValue cannot overflow the check.
+ if (ifdOffset < 8 || ifdOffset > bytes.Length - 2)
+ return null;
+
+ var entryCount = ReadUInt16(bytes, ifdOffset, littleEndian);
+ int? width = null;
+ int? height = null;
+
+ for (var i = 0; i < entryCount; i++)
+ {
+ // Each IFD entry takes 12 bytes. Compute the offset in 64-bit space so the bound check
+ // cannot overflow for a crafted IFD offset or entry count.
+ var entryOffset = (long)ifdOffset + 2 + ((long)i * 12);
+ if (entryOffset > bytes.Length - 12)
+ break;
+
+ var entry = (int)entryOffset;
+ var tag = ReadUInt16(bytes, entry, littleEndian);
+ if (tag != 256 && tag != 257)
+ continue;
+
+ var fieldType = ReadUInt16(bytes, entry + 2, littleEndian);
+ int value;
+ if (fieldType == 3) // SHORT
+ value = ReadUInt16(bytes, entry + 8, littleEndian);
+ else if (fieldType == 4) // LONG
+ value = ReadInt32(bytes, entry + 8, littleEndian);
+ else
+ continue;
+
+ if (tag == 256)
+ width = value;
+ else
+ height = value;
+ }
+
+ return width is > 0 && height is > 0 ? (width.Value, height.Value) : null;
+ }
+
+ private static int ReadInt32BigEndian(byte[] bytes, int offset)
+ => (bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3];
+
+ private static int ReadInt32LittleEndian(byte[] bytes, int offset)
+ => bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16) | (bytes[offset + 3] << 24);
+
+ private static int ReadInt32(byte[] bytes, int offset, bool littleEndian)
+ => littleEndian ? ReadInt32LittleEndian(bytes, offset) : ReadInt32BigEndian(bytes, offset);
+
+ private static int ReadUInt16(byte[] bytes, int offset, bool littleEndian)
+ => littleEndian
+ ? bytes[offset] | (bytes[offset + 1] << 8)
+ : (bytes[offset] << 8) | bytes[offset + 1];
}
diff --git a/src/MiniExcel.OpenXml/Constants/ExcelFileNames.cs b/src/MiniExcel.OpenXml/Constants/ExcelFileNames.cs
index e38c5add..9ee84b1c 100644
--- a/src/MiniExcel.OpenXml/Constants/ExcelFileNames.cs
+++ b/src/MiniExcel.OpenXml/Constants/ExcelFileNames.cs
@@ -18,5 +18,6 @@ internal static class ExcelFileNames
internal static string SheetRels(int sheetId) => $"xl/worksheets/_rels/sheet{sheetId}.xml.rels";
internal static string Drawing(int sheetIndex) => $"xl/drawings/drawing{sheetIndex}.xml";
internal static string DrawingRels(int sheetIndex) => $"xl/drawings/_rels/drawing{sheetIndex}.xml.rels";
+ internal static string DrawingRels(string drawingFileName) => $"xl/drawings/_rels/{drawingFileName}.rels";
internal static string Table(int tableIndex) => $"xl/tables/table{tableIndex}.xml";
}
diff --git a/src/MiniExcel.OpenXml/Constants/ExcelXml.cs b/src/MiniExcel.OpenXml/Constants/ExcelXml.cs
index 511ab860..9984c78f 100644
--- a/src/MiniExcel.OpenXml/Constants/ExcelXml.cs
+++ b/src/MiniExcel.OpenXml/Constants/ExcelXml.cs
@@ -2,6 +2,10 @@
internal static class ExcelXml
{
+ /// Default picture anchor size used when no explicit size is provided (64x20 px).
+ internal const long DefaultImageWidthEmu = 609600;
+ internal const long DefaultImageHeightEmu = 190500;
+
internal static readonly string EmptySheetXml = XmlHelper.MinifyXml("""
@@ -111,7 +115,10 @@ internal static string ImageRelationship(FileDto image)
=> $"""""";
internal static string DrawingRelationship(int sheetIndex)
- => $"""""";
+ => DrawingRelationship(sheetIndex, $"drawing{sheetIndex}.xml");
+
+ internal static string DrawingRelationship(int sheetIndex, string drawingFileName)
+ => $"""""";
internal static string TableRelationship(int sheetIndex)
=> $"""""";
@@ -125,7 +132,7 @@ internal static string DrawingXml(FileDto file, int fileIndex)
{file.RowIndex - 1}
0
-
+
diff --git a/src/MiniExcel.OpenXml/Models/FileDto.cs b/src/MiniExcel.OpenXml/Models/FileDto.cs
index 087be2ee..82fd1d83 100644
--- a/src/MiniExcel.OpenXml/Models/FileDto.cs
+++ b/src/MiniExcel.OpenXml/Models/FileDto.cs
@@ -5,9 +5,25 @@ internal class FileDto
internal int SheetIndex { get; set; }
internal int RowIndex { get; set; }
internal int CellIndex { get; set; }
- internal string Id => $"rFileId_{SheetIndex}_{RowIndex + 1}_{CellIndex + 1}";
+
+ ///
+ /// Disambiguates the generated media and relationship ids when multiple image values share the same
+ /// anchor cell. Left unset by the regular SaveAs pipeline, which never places two images on
+ /// one cell.
+ ///
+ internal string? IdSuffix { get; set; }
+
+ internal string Id => string.IsNullOrEmpty(IdSuffix)
+ ? $"rFileId_{SheetIndex}_{RowIndex + 1}_{CellIndex + 1}"
+ : $"rFileId_{SheetIndex}_{RowIndex + 1}_{CellIndex + 1}_{IdSuffix}";
internal string Path => $"xl/media/{Id}.{Extension}";
internal bool IsImage { get; set; }
internal string Extension { get; set; }
internal byte[] Contents { get; set; }
+
+ ///
+ /// Anchor size in EMUs. When unset, the drawing falls back to the default image size.
+ ///
+ internal long? ImageWidthEmu { get; set; }
+ internal long? ImageHeightEmu { get; set; }
}
\ No newline at end of file
diff --git a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Images.cs b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Images.cs
new file mode 100644
index 00000000..88441cd2
--- /dev/null
+++ b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Images.cs
@@ -0,0 +1,609 @@
+namespace MiniExcelLib.OpenXml.Templates;
+
+///
+/// Template image support: byte[] values resolved from template placeholders are emitted as embedded
+/// images, reusing the same OpenXML primitives as the SaveAs pipeline (ImageHelper format detection,
+/// FileDto and the ExcelXml drawing builders). See issue #972 / #604.
+///
+internal partial class OpenXmlTemplate
+{
+ internal const string ImageMarkerPrefix = "@@@imageid@@@,";
+
+ private readonly List _files = [];
+ private readonly Dictionary _pendingImages = [];
+ private readonly Dictionary _capturedImages = [];
+ private readonly Dictionary _sheetTemplateDrawings = [];
+ private readonly Dictionary _sheetTemplateRels = [];
+ private readonly List _createdDrawingParts = [];
+ private int _currentSheetIndex;
+ private int _nextImageId;
+
+ private sealed class PendingImage(byte[] bytes, string extension, (int Width, int Height)? size)
+ {
+ internal byte[] Bytes { get; } = bytes;
+ internal string Extension { get; } = extension;
+ internal (int Width, int Height)? Size { get; } = size;
+ }
+
+#if NET
+ [GeneratedRegex(@"<[A-Za-z0-9:]*c\b[^>]*\br=""(?[[A-Z]+[0-9]+)""[^>]*>(?:(?![A-Za-z0-9:]*c>).)*?@@@imageid@@@,(?[0-9]+)(?:(?![A-Za-z0-9:]*c>).)*?[A-Za-z0-9:]*c>", RegexOptions.Singleline)]
+ private static partial Regex ImageMarkerCellRegex();
+
+ [GeneratedRegex(@"^\s*<[A-Za-z0-9:]*row\b[^>]*\sht=""(?[0-9]+(?:\.[0-9]+)?)""")]
+ private static partial Regex ImageRowHeightRegex();
+
+ private static readonly Regex ImageMarkerCellRegexImpl = ImageMarkerCellRegex();
+ private static readonly Regex ImageRowHeightRegexImpl = ImageRowHeightRegex();
+#else
+ private static readonly Regex ImageMarkerCellRegexImpl = new(
+ @"<[A-Za-z0-9:]*c\b[^>]*\br=""(?][[A-Z]+[0-9]+)""[^>]*>(?:(?![A-Za-z0-9:]*c>).)*?@@@imageid@@@,(?[0-9]+)(?:(?![A-Za-z0-9:]*c>).)*?[A-Za-z0-9:]*c>",
+ RegexOptions.Compiled | RegexOptions.Singleline);
+
+ private static readonly Regex ImageRowHeightRegexImpl = new(
+ @"^\s*<[A-Za-z0-9:]*row\b[^>]*\sht=""(?[0-9]+(?:\.[0-9]+)?)""",
+ RegexOptions.Compiled);
+#endif
+
+ private const long EmuPerPoint = 12700;
+
+ private void ResetImageState()
+ {
+ _files.Clear();
+ _pendingImages.Clear();
+ _capturedImages.Clear();
+ _sheetTemplateDrawings.Clear();
+ _sheetTemplateRels.Clear();
+ _createdDrawingParts.Clear();
+ _nextImageId = 0;
+ _currentSheetIndex = 0;
+ }
+
+ ///
+ /// Releases the per-sheet image bookkeeping once a worksheet has been rendered. Image bytes that
+ /// were never captured (values resolved speculatively but not emitted) are dropped here, and
+ /// captured entries are no longer needed because markers never cross worksheets.
+ ///
+ private void ReleaseSheetImageState()
+ {
+ _pendingImages.Clear();
+ _capturedImages.Clear();
+ }
+
+ ///
+ /// Clears the per-run image state when the template call ends, so a reused templater does not keep
+ /// the last run's image bytes alive.
+ ///
+ private ImageStateScope BeginImageStateScope() => new(this);
+
+ private sealed class ImageStateScope(OpenXmlTemplate template) : IDisposable
+ {
+ public void Dispose() => template.ResetImageState();
+ }
+
+ ///
+ /// Returns an inline marker for array values that are recognised images and
+ /// registers their bytes for later emission. Values that are not recognised images fall back to
+ /// the regular scalar formatting, mirroring SaveAs' byte[] handling as closely as possible.
+ ///
+ private string? GetImageMarker(object? value)
+ {
+ if (value is not byte[] bytes || !_configuration.EnableConvertByteArray)
+ return null;
+
+ var format = ImageHelper.GetImageFormat(bytes);
+ if (format == ImageHelper.ImageFormat.Unknown)
+ return null;
+
+ var id = _nextImageId.ToString(CultureInfo.InvariantCulture);
+ _nextImageId++;
+ _pendingImages[id] = new PendingImage(bytes, format.ToString().ToLowerInvariant(), ImageHelper.GetImageSize(bytes));
+ return ImageMarkerPrefix + id;
+ }
+
+ private string GetFormattedValueWithImages(PropertyInfo? propInfo, object? cellValue, Type? type)
+ => GetImageMarker(cellValue) ?? GetFormattedValue(propInfo, cellValue, type);
+
+ ///
+ /// Walks the remaining segments of a dotted placeholder expression from an already resolved root
+ /// value, so that nested scalars (e.g. {{Company.Logo}}) can be resolved. Index 0 is the root
+ /// property, which is the value itself.
+ ///
+ private static bool TryResolvePropertyPath(object? root, string[] segments, out object? value)
+ {
+ value = root;
+ for (var i = 1; i < segments.Length; i++)
+ {
+ if (value is null)
+ return false;
+
+ var type = value.GetType();
+ var property = type.GetProperty(segments[i], BindingFlags.Public | BindingFlags.Instance);
+ if (property is not null && property.CanRead && property.GetIndexParameters().Length == 0)
+ {
+ value = property.GetValue(value);
+ continue;
+ }
+
+ var field = type.GetField(segments[i], BindingFlags.Public | BindingFlags.Instance);
+ if (field is not null)
+ {
+ value = field.GetValue(value);
+ continue;
+ }
+
+ value = null;
+ return false;
+ }
+
+ return true;
+ }
+
+ private bool HasImagesForSheet(int sheetIndex)
+ => _files.Exists(file => file.SheetIndex == sheetIndex && file.IsImage);
+
+ ///
+ /// Replaces image markers embedded in a rendered row with empty cells, registering each image
+ /// against its final cell reference. Coordinates are taken from the cell reference itself, which
+ /// the surrounding code has already rewritten to the final (post collection expansion) row.
+ ///
+ private string CaptureAndClearImageMarkers(string rowXml, int sheetIndex)
+ {
+ if ((_pendingImages.Count == 0 && _capturedImages.Count == 0) || !rowXml.Contains(ImageMarkerPrefix))
+ return rowXml;
+
+ var rowHeightPoints = GetRowHeightPoints(rowXml);
+
+ while (true)
+ {
+ var match = ImageMarkerCellRegexImpl.Match(rowXml);
+ if (!match.Success)
+ return rowXml;
+
+ var id = match.Groups["id"].Value;
+ if (CellReferenceConverter.TryParseCellReference(match.Groups["ref"].Value, out var column, out var row) &&
+ TryResolvePendingImage(id, out var pending))
+ {
+ var file = new FileDto
+ {
+ SheetIndex = sheetIndex,
+ RowIndex = row,
+ CellIndex = column,
+ Contents = pending.Bytes,
+ Extension = pending.Extension,
+ IsImage = true,
+
+ // Two images can share the same anchor cell (two placeholders in one cell, or the
+ // same placeholder repeated), which would otherwise derive the same media part and
+ // relationship id. The per-file suffix keeps every derived identifier unique.
+ IdSuffix = (_files.Count + 1).ToString(CultureInfo.InvariantCulture)
+ };
+
+ ApplyRowHeightSize(file, pending, rowHeightPoints);
+ _files.Add(file);
+ _capturedImages[id] = file;
+ }
+
+ var clearedCell = match.Value.Replace(ImageMarkerPrefix + id, string.Empty);
+ rowXml = rowXml.Remove(match.Index, match.Length).Insert(match.Index, clearedCell);
+ }
+ }
+
+ ///
+ /// Resolves a pending marker to its image. The bytes are owned by _pendingImages until the
+ /// first capture and are then released to the created ; repeated captures
+ /// (for example a grouped row rendered several times) reuse them through _capturedImages
+ /// rather than keeping a second copy alive.
+ ///
+ private bool TryResolvePendingImage(string id, out PendingImage pending)
+ {
+ if (_pendingImages.TryGetValue(id, out var registered))
+ {
+ _pendingImages.Remove(id);
+ pending = registered;
+ return true;
+ }
+
+ if (_capturedImages.TryGetValue(id, out var captured))
+ {
+ pending = new PendingImage(captured.Contents, captured.Extension, ImageHelper.GetImageSize(captured.Contents));
+ return true;
+ }
+
+ pending = null!;
+ return false;
+ }
+
+ ///
+ /// Sizes an image to the height of the row it is anchored to, preserving its aspect ratio. Rows
+ /// without an explicit height keep the default anchor size.
+ ///
+ private static void ApplyRowHeightSize(FileDto file, PendingImage pending, double rowHeightPoints)
+ {
+ if (pending.Size is not { } size || size.Width <= 0 || size.Height <= 0 || rowHeightPoints <= 0)
+ return;
+
+ var heightEmu = (long)Math.Round(rowHeightPoints * EmuPerPoint);
+ var widthEmu = (long)Math.Round(heightEmu * (size.Width / (double)size.Height));
+ file.ImageWidthEmu = widthEmu;
+ file.ImageHeightEmu = heightEmu;
+ }
+
+ private static double GetRowHeightPoints(string rowXml)
+ {
+ var match = ImageRowHeightRegexImpl.Match(rowXml);
+ return match.Success &&
+ double.TryParse(match.Groups["ht"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var height)
+ ? height
+ : 0;
+ }
+
+ private static bool IsDrawingPrecedingElement(XElement element)
+ => element.Name.LocalName is "legacyDrawing" or "legacyDrawingHF" or "drawingHF" or "picture"
+ or "oleObjects" or "controls" or "webPublishItems" or "tableParts" or "extLst";
+
+ [CreateSyncVersion]
+ private static async Task WriteDrawingReferenceAsync(XmlWriter writer, string? prefix, int sheetIndex)
+ {
+ // Use the writer's namespace tracking instead of a raw string so that the r prefix referenced
+ // by r:id is declared on the element whenever the worksheet does not already declare it.
+ await writer.WriteStartElementAsync(prefix, "drawing", Schemas.SpreadsheetmlXmlMain).ConfigureAwait(false);
+ await writer.WriteAttributeStringAsync("r", "id", Schemas.SpreadsheetmlXmlRelationships, $"rDrawing{sheetIndex}").ConfigureAwait(false);
+ await writer.WriteEndElementAsync().ConfigureAwait(false);
+ }
+
+ ///
+ /// Resolves, for every non-parametrized template worksheet that already contains a drawing, the
+ /// drawing and drawing-relationships paths so they can be merged with the generated images.
+ ///
+ [CreateSyncVersion]
+ private static async Task> GetTemplateDrawingPathsAsync(
+ ZipArchive templateArchive, IDictionary sheetNamesMap, CancellationToken cancellationToken)
+ {
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var packageRelNs = (XNamespace)Schemas.OpenXmlPackageRelationships;
+ var sheetNs = (XNamespace)Schemas.SpreadsheetmlXmlMain;
+ var relNs = (XNamespace)Schemas.SpreadsheetmlXmlRelationships;
+
+ foreach (var sheetPath in sheetNamesMap.Keys)
+ {
+ if (ParametrizedSheetRegexImpl.IsMatch(sheetNamesMap[sheetPath]))
+ continue;
+
+ if (templateArchive.GetEntry(sheetPath) is null)
+ continue;
+
+ var sheetDoc = await LoadXmlAsync(templateArchive, sheetPath, cancellationToken).ConfigureAwait(false);
+ var rId = sheetDoc.Descendants(sheetNs + "drawing").FirstOrDefault()?.Attribute(relNs + "id")?.Value;
+ if (string.IsNullOrEmpty(rId))
+ continue;
+
+ var relsPath = $"xl/worksheets/_rels/{Path.GetFileName(sheetPath)}.rels";
+ if (templateArchive.GetEntry(relsPath) is null)
+ continue;
+
+ var relsDoc = await LoadXmlAsync(templateArchive, relsPath, cancellationToken).ConfigureAwait(false);
+ var target = relsDoc.Descendants(packageRelNs + "Relationship")
+ .FirstOrDefault(rel => rel.Attribute("Id")?.Value == rId)
+ ?.Attribute("Target")?.Value;
+
+ if (string.IsNullOrEmpty(target))
+ continue;
+
+ var normalized = target!.Replace('\\', '/');
+ var drawingPath = normalized.StartsWith("../", StringComparison.Ordinal)
+ ? "xl/" + normalized[3..]
+ : normalized.TrimStart('/');
+
+ result[sheetPath] = (drawingPath, $"xl/drawings/_rels/{Path.GetFileName(drawingPath)}.rels");
+ }
+
+ return result;
+ }
+
+ ///
+ /// Emits the image parts (media, drawing, drawing relationships and worksheet relationship) for
+ /// every sheet that produced images. Sheets whose template already declared a drawing reuse and
+ /// extend that drawing instead of creating a second, unreferenced one.
+ ///
+ [CreateSyncVersion]
+ private async Task EmitTemplateImagesAsync(
+ ZipArchive templateArchive,
+ OpenXmlZip outputArchive,
+ Dictionary templateDrawings,
+ HashSet templateSheetRels,
+ CancellationToken cancellationToken = default)
+ {
+ var imageFiles = _files.Where(file => file.IsImage).ToList();
+ var wrappedDrawings = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var writtenSheetRels = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ // Drawing parts that already exist in the template keep their filename even when they belong
+ // to a different sheet, so a generated drawing must never reuse one of them.
+ var occupiedDrawingParts = GetTemplateDrawingPartNames(templateArchive);
+
+ foreach (var sheetGroup in imageFiles.GroupBy(file => file.SheetIndex))
+ {
+ var sheetIndex = sheetGroup.Key;
+ var files = sheetGroup.ToList();
+
+ string drawingFileName;
+ if (_sheetTemplateDrawings.TryGetValue(sheetIndex, out var templateDrawing))
+ {
+ wrappedDrawings.Add(templateDrawing.DrawingPath);
+ await MergeIntoExistingDrawingAsync(templateArchive, outputArchive, templateDrawing, files, cancellationToken).ConfigureAwait(false);
+ drawingFileName = Path.GetFileName(templateDrawing.DrawingPath);
+ }
+ else
+ {
+ // ExcelFileNames.Drawing(sheetIndex) may already be taken by an unrelated template
+ // sheet, so allocate the first free deterministic drawing part name instead.
+ var drawingPath = AllocateDrawingPart(sheetIndex, occupiedDrawingParts);
+ occupiedDrawingParts.Add(drawingPath);
+ drawingFileName = Path.GetFileName(drawingPath);
+ await EmitNewDrawingAsync(outputArchive, drawingPath, files, cancellationToken).ConfigureAwait(false);
+ }
+
+ // Worksheet relationships: merge the drawing relationship into the template's rels, or
+ // create a fresh rels part. Without this the would dangle and Excel would
+ // repair the workbook by dropping the drawing. The relationship id keeps the per-sheet
+ // convention while its target points at the drawing part allocated above.
+ var sheetRelsPath = ExcelFileNames.SheetRels(sheetIndex);
+ if (_sheetTemplateRels.TryGetValue(sheetIndex, out var templateRelsPath))
+ {
+ var relsDoc = await LoadXmlAsync(templateArchive, templateRelsPath, cancellationToken).ConfigureAwait(false);
+ EnsureDrawingRelationship(relsDoc, sheetIndex, drawingFileName);
+ await SaveXmlToZipAsync(outputArchive.ZipFile, sheetRelsPath, relsDoc, cancellationToken).ConfigureAwait(false);
+ writtenSheetRels.Add(templateRelsPath);
+ }
+ else
+ {
+ await WriteTextEntryAsync(outputArchive.ZipFile, sheetRelsPath, ExcelXml.DefaultSheetRelXml(ExcelXml.DrawingRelationship(sheetIndex, drawingFileName)), cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ // Template parts we deliberately did not copy must be written back when they were not reused.
+ foreach (var relsPath in templateSheetRels)
+ {
+ if (!writtenSheetRels.Contains(relsPath))
+ await CopyEntryAsync(templateArchive, outputArchive.ZipFile, relsPath, cancellationToken).ConfigureAwait(false);
+ }
+
+ foreach (var templateDrawing in templateDrawings.Values.Distinct())
+ {
+ if (wrappedDrawings.Contains(templateDrawing.DrawingPath))
+ continue;
+
+ await CopyEntryAsync(templateArchive, outputArchive.ZipFile, templateDrawing.DrawingPath, cancellationToken).ConfigureAwait(false);
+ if (templateArchive.GetEntry(templateDrawing.DrawingRelsPath) is not null)
+ await CopyEntryAsync(templateArchive, outputArchive.ZipFile, templateDrawing.DrawingRelsPath, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ [CreateSyncVersion]
+ private async Task EmitNewDrawingAsync(OpenXmlZip outputArchive, string drawingPath, IReadOnlyList files, CancellationToken cancellationToken)
+ {
+ _createdDrawingParts.Add(drawingPath);
+
+ var anchors = new StringBuilder();
+ var drawingRels = new StringBuilder();
+
+ var index = 0;
+ foreach (var file in files)
+ {
+ await WriteBinaryEntryAsync(outputArchive.ZipFile, file.Path, file.Contents, cancellationToken).ConfigureAwait(false);
+ anchors.Append(ExcelXml.DrawingXml(file, index));
+ index++;
+ drawingRels.AppendLine(ExcelXml.ImageRelationship(file));
+ }
+
+ await WriteTextEntryAsync(outputArchive.ZipFile, drawingPath, ExcelXml.DefaultDrawing(anchors.ToString()), cancellationToken).ConfigureAwait(false);
+ await WriteTextEntryAsync(outputArchive.ZipFile, ExcelFileNames.DrawingRels(Path.GetFileName(drawingPath)), ExcelXml.DefaultDrawingXmlRels(drawingRels.ToString()), cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Collects the drawing part names already present in the template so generated drawings never
+ /// overwrite a part that belongs to another sheet.
+ ///
+ private static HashSet GetTemplateDrawingPartNames(ZipArchive templateArchive)
+ {
+ var names = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var entry in templateArchive.Entries)
+ {
+ var name = entry.FullName.TrimStart('/');
+ if (name.StartsWith("xl/drawings/", StringComparison.OrdinalIgnoreCase) &&
+ name.EndsWith(".xml", StringComparison.OrdinalIgnoreCase))
+ {
+ names.Add(name);
+ }
+ }
+
+ return names;
+ }
+
+ ///
+ /// Returns the deterministic drawing part name to use for a generated sheet, preferring
+ /// drawing{sheetIndex}.xml and only falling back to the first free number when that name is
+ /// already taken by a template drawing part.
+ ///
+ private static string AllocateDrawingPart(int sheetIndex, ISet occupied)
+ {
+ var preferred = ExcelFileNames.Drawing(sheetIndex);
+ if (!occupied.Contains(preferred))
+ return preferred;
+
+ for (var candidateIndex = 1; ; candidateIndex++)
+ {
+ var candidate = ExcelFileNames.Drawing(candidateIndex);
+ if (!occupied.Contains(candidate))
+ return candidate;
+ }
+ }
+
+ [CreateSyncVersion]
+ private static async Task MergeIntoExistingDrawingAsync(
+ ZipArchive templateArchive,
+ OpenXmlZip outputArchive,
+ (string DrawingPath, string DrawingRelsPath) templateDrawing,
+ IReadOnlyList files,
+ CancellationToken cancellationToken)
+ {
+ var drawingDoc = await LoadXmlAsync(templateArchive, templateDrawing.DrawingPath, cancellationToken).ConfigureAwait(false);
+ var drawingRoot = drawingDoc.Root;
+ if (drawingRoot is null)
+ return;
+
+ var maxPictureId = drawingRoot.Descendants()
+ .Where(element => element.Name.LocalName == "cNvPr")
+ .Select(element => int.TryParse(element.Attribute("id")?.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var id) ? id : 0)
+ .DefaultIfEmpty(0)
+ .Max();
+
+ var anchors = new StringBuilder();
+ var drawingRels = new StringBuilder();
+ var index = 0;
+ foreach (var file in files)
+ {
+ await WriteBinaryEntryAsync(outputArchive.ZipFile, file.Path, file.Contents, cancellationToken).ConfigureAwait(false);
+ anchors.Append(ExcelXml.DrawingXml(file, maxPictureId + index));
+ index++;
+ drawingRels.AppendLine(ExcelXml.ImageRelationship(file));
+ }
+
+ var ourAnchors = XDocument.Parse(ExcelXml.DefaultDrawing(anchors.ToString()));
+ if (ourAnchors.Root is not null)
+ {
+ foreach (var anchor in ourAnchors.Root.Elements())
+ drawingRoot.Add(anchor);
+ }
+
+ await SaveXmlToZipAsync(outputArchive.ZipFile, templateDrawing.DrawingPath, drawingDoc, cancellationToken).ConfigureAwait(false);
+
+ var relsDoc = templateArchive.GetEntry(templateDrawing.DrawingRelsPath) is not null
+ ? await LoadXmlAsync(templateArchive, templateDrawing.DrawingRelsPath, cancellationToken).ConfigureAwait(false)
+ : XDocument.Parse(ExcelXml.DefaultDrawingXmlRels(string.Empty));
+
+ var ourRels = XDocument.Parse(ExcelXml.DefaultDrawingXmlRels(drawingRels.ToString()));
+ if (relsDoc.Root is not null && ourRels.Root is not null)
+ {
+ foreach (var relationship in ourRels.Root.Elements())
+ relsDoc.Root.Add(relationship);
+ }
+
+ await SaveXmlToZipAsync(outputArchive.ZipFile, templateDrawing.DrawingRelsPath, relsDoc, cancellationToken).ConfigureAwait(false);
+ }
+
+ private static void EnsureDrawingRelationship(XDocument relsDoc, int sheetIndex, string drawingFileName)
+ {
+ var root = relsDoc.Root;
+ if (root is null)
+ return;
+
+ var hasDrawingRelationship = root.Elements()
+ .Any(element => element.Attribute("Type")?.Value == Schemas.SpreadsheetmlXmlDrawingRelationship);
+
+ if (hasDrawingRelationship)
+ return;
+
+ var drawingRelationship = XDocument.Parse(ExcelXml.DefaultSheetRelXml(ExcelXml.DrawingRelationship(sheetIndex, drawingFileName)));
+ if (drawingRelationship.Root is not null)
+ {
+ foreach (var relationship in drawingRelationship.Root.Elements())
+ root.Add(relationship);
+ }
+ }
+
+ [CreateSyncVersion]
+ private static async Task CopyEntryAsync(ZipArchive templateArchive, ZipArchive outputArchive, string path, CancellationToken cancellationToken)
+ {
+ var sourceEntry = templateArchive.GetEntry(path);
+ if (sourceEntry is null)
+ return;
+
+ var targetEntry = outputArchive.CreateEntry(path);
+ var sourceStream = await sourceEntry.OpenAsync(cancellationToken).ConfigureAwait(false);
+ await using var disposableSource = sourceStream.ConfigureAwait(false);
+ var targetStream = await targetEntry.OpenAsync(cancellationToken).ConfigureAwait(false);
+ await using var disposableTarget = targetStream.ConfigureAwait(false);
+ await sourceStream.CopyToAsync(targetStream
+#if NET
+ , cancellationToken
+#endif
+ ).ConfigureAwait(false);
+ }
+
+ ///
+ /// Ensures the persisted [Content_Types].xml declares a Default entry for every emitted image
+ /// extension and an Override for every newly created drawing part.
+ ///
+ private void EnsureImageContentTypes(XDocument contentTypesDoc)
+ {
+ var root = contentTypesDoc.Root;
+ if (root is null)
+ return;
+
+ var ns = root.Name.Namespace;
+ foreach (var extension in _files.Where(file => file.IsImage).Select(file => file.Extension).Distinct(StringComparer.OrdinalIgnoreCase))
+ {
+ var alreadyDeclared = root.Elements(ns + "Default")
+ .Any(element => string.Equals(element.Attribute("Extension")?.Value, extension, StringComparison.OrdinalIgnoreCase));
+
+ if (!alreadyDeclared)
+ {
+ root.Add(new XElement(ns + "Default",
+ new XAttribute("Extension", extension),
+ new XAttribute("ContentType", GetImageContentType(extension))));
+ }
+ }
+
+ foreach (var drawingPath in _createdDrawingParts)
+ {
+ var partName = "/" + drawingPath;
+ var alreadyDeclared = root.Elements(ns + "Override")
+ .Any(element => string.Equals(element.Attribute("PartName")?.Value, partName, StringComparison.OrdinalIgnoreCase));
+
+ if (!alreadyDeclared)
+ {
+ root.Add(new XElement(ns + "Override",
+ new XAttribute("PartName", partName),
+ new XAttribute("ContentType", ExcelContentTypes.Drawing)));
+ }
+ }
+ }
+
+ private static string GetImageContentType(string extension) => extension.ToLowerInvariant() switch
+ {
+ "png" => "image/png",
+ "jpg" => "image/jpeg",
+ "gif" => "image/gif",
+ "bmp" => "image/bmp",
+ "tiff" => "image/tiff",
+ _ => "application/octet-stream"
+ };
+
+ [CreateSyncVersion]
+ private static async Task WriteBinaryEntryAsync(ZipArchive zip, string path, byte[] contents, CancellationToken cancellationToken)
+ {
+ var entry = zip.CreateEntry(path);
+ var stream = await entry.OpenAsync(cancellationToken).ConfigureAwait(false);
+ await using var disposableStream = stream.ConfigureAwait(false);
+#if NET
+ await stream.WriteAsync(contents.AsMemory(), cancellationToken).ConfigureAwait(false);
+#else
+ await stream.WriteAsync(contents, 0, contents.Length, cancellationToken).ConfigureAwait(false);
+#endif
+ }
+
+ [CreateSyncVersion]
+ private static async Task WriteTextEntryAsync(ZipArchive zip, string path, string content, CancellationToken cancellationToken)
+ {
+ var entry = zip.CreateEntry(path);
+ var stream = await entry.OpenAsync(cancellationToken).ConfigureAwait(false);
+ await using var disposableStream = stream.ConfigureAwait(false);
+ var bytes = Encoding.UTF8.GetBytes(content);
+#if NET
+ await stream.WriteAsync(bytes.AsMemory(), cancellationToken).ConfigureAwait(false);
+#else
+ await stream.WriteAsync(bytes, 0, bytes.Length, cancellationToken).ConfigureAwait(false);
+#endif
+ }
+}
diff --git a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Impl.cs b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Impl.cs
index 46f565e7..9d0a1b4f 100644
--- a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Impl.cs
+++ b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Impl.cs
@@ -63,6 +63,7 @@ private async Task GenerateSheetByUpdateModeAsync(ZipArchiveEntry sheetZipEntry,
#endif
await WriteSheetXmlAsync(writer, worksheet, sheetData, mergeCells, cancellationToken).ConfigureAwait(false);
+ ReleaseSheetImageState();
}
[CreateSyncVersion]
@@ -97,6 +98,7 @@ private async Task GenerateSheetByCreateModeAsync(ZipArchiveEntry templateSheetZ
using var writer = XmlWriter.Create(outputZipSheetEntryStream, DocXmlWriterSettings);
#endif
await WriteSheetXmlAsync(writer, worksheet, sheetData, mergeCells, cancellationToken).ConfigureAwait(false);
+ ReleaseSheetImageState();
}
// "r" is optional on rows and cells (ECMA-376 18.3.1.73, 18.3.1.4); without it they follow the previous one,
@@ -450,7 +452,8 @@ var s when s.StartsWith("@header") => SpecialCellType.Header,
.Append($"{fullPrefix}{row.Name.LocalName}>");
ProcessFormulas(rowXml, newRowIndex);
- await writer.WriteRawAsync(CleanXml(rowXml, prefix).ToString()).ConfigureAwait(false);
+ var capturedRowXml = CaptureAndClearImageMarkers(rowXml.ToString(), _currentSheetIndex);
+ await writer.WriteRawAsync(CleanXml(capturedRowXml, prefix)).ConfigureAwait(false);
//mergecells
if (rowInfo.RowMercells is null)
@@ -502,8 +505,17 @@ var s when s.StartsWith("@header") => SpecialCellType.Header,
await writer.WriteRawAsync(CleanXml(string.Join("", nodes), prefix)).ConfigureAwait(false);
}
+ var shouldWriteDrawing = HasImagesForSheet(_currentSheetIndex) && !worksheet.Elements(SpreadsheetNs + "drawing").Any();
+ var drawingWritten = false;
+
foreach (var afterElement in afterSheetData)
{
+ if (shouldWriteDrawing && !drawingWritten && IsDrawingPrecedingElement(afterElement))
+ {
+ await WriteDrawingReferenceAsync(writer, prefix, _currentSheetIndex).ConfigureAwait(false);
+ drawingWritten = true;
+ }
+
#if NET
await afterElement.WriteToAsync(writer, cancellationToken).ConfigureAwait(false);
#else
@@ -511,6 +523,11 @@ var s when s.StartsWith("@header") => SpecialCellType.Header,
#endif
}
+ if (shouldWriteDrawing && !drawingWritten)
+ {
+ await WriteDrawingReferenceAsync(writer, prefix, _currentSheetIndex).ConfigureAwait(false);
+ }
+
await writer.WriteEndElementAsync().ConfigureAwait(false);
}
@@ -674,10 +691,11 @@ private async Task GenerateCellValuesAsync(
? prop.Value.UnderlyingMemberType
: Nullable.GetUnderlyingType(propInfo.PropertyType) ?? propInfo.PropertyType;
- var replacementValue = GetFormattedValue(propInfo, cellValue, type);
+ var replacementValue = GetFormattedValueWithImages(propInfo, cellValue, type);
replacements[key] = replacementValue;
- FlattenAndFormatValues(replacements, key, cellValue, _configuration.RecursivePropertiesMaxDepth, propInfo);
+ if (cellValue is not byte[])
+ FlattenAndFormatValues(replacements, key, cellValue, _configuration.RecursivePropertiesMaxDepth, propInfo);
rowXml.Replace($"@header{{{{{key}}}}}", replacementValue);
@@ -729,7 +747,7 @@ private async Task GenerateCellValuesAsync(
// replace formulas
ProcessFormulas(rowXml, newRowIndex);
- var finalXml = CleanXml(rowXml, endPrefix).ToString();
+ var finalXml = CaptureAndClearImageMarkers(CleanXml(rowXml, endPrefix).ToString(), _currentSheetIndex);
await writer.WriteRawAsync(finalXml).ConfigureAwait(false);
//mergecells
@@ -1174,7 +1192,7 @@ private void UpdateDimensionAndGetRowsInfo(IDictionary inputMap
}
//cellValue = inputMaps[propNames[0]] - 1. From left to right, only the first set is used as the basis for the list
- if (cellValue is IEnumerable value and not string)
+ if (cellValue is IEnumerable value and not string and not byte[])
{
if (xRowInfo.IEnumerableMercell is null && _xMergeCellInfos.TryGetValue(r, out var info))
{
@@ -1362,8 +1380,21 @@ private void UpdateDimensionAndGetRowsInfo(IDictionary inputMap
}
else
{
- var cellValueStr = cellValue?.ToString(); // value did encodexml, so don't duplicate encode value (https://gitee.com/dotnetchina/MiniExcel/issues/I4DQUN)
- if (isMultiMatch || cellValue is string) // if matchs count over 1 need to set type=str (https://user-images.githubusercontent.com/12729184/114530109-39d46d00-9c7d-11eb-8f6b-52ad8600aca3.png)
+ // Resolve the full property path so that nested scalars such as {{Company.Logo}} work.
+ var resolvedValue = cellValue;
+ if (propNames.Length > 1 && !TryResolvePropertyPath(cellValue, propNames, out resolvedValue))
+ continue;
+
+ if (GetImageMarker(resolvedValue) is { } imageMarker)
+ {
+ SetCellType(cell, "str");
+ v = cell.Element(SpreadsheetNs + "v") ?? cell.Element(SpreadsheetNs + "is")?.Element(SpreadsheetNs + "t");
+ v?.SetValue(v.Value.Replace($"{{{{{formatText}}}}}", imageMarker));
+ continue;
+ }
+
+ var cellValueStr = resolvedValue?.ToString(); // value did encodexml, so don't duplicate encode value (https://gitee.com/dotnetchina/MiniExcel/issues/I4DQUN)
+ if (isMultiMatch || resolvedValue is string) // if matchs count over 1 need to set type=str (https://user-images.githubusercontent.com/12729184/114530109-39d46d00-9c7d-11eb-8f6b-52ad8600aca3.png)
{
SetCellType(cell, "str");
}
@@ -1372,12 +1403,12 @@ private void UpdateDimensionAndGetRowsInfo(IDictionary inputMap
SetCellType(cell, "n");
cellValueStr = outV.ToString(CultureInfo.InvariantCulture);
}
- else if (cellValue is bool b)
+ else if (resolvedValue is bool b)
{
SetCellType(cell, "b");
cellValueStr = b ? "1" : "0";
}
- else if (cellValue is DateTime timestamp)
+ else if (resolvedValue is DateTime timestamp)
{
//c.SetAttribute("t", "d");
cellValueStr = timestamp.ToString("yyyy-MM-dd HH:mm:ss");
@@ -1390,7 +1421,7 @@ private void UpdateDimensionAndGetRowsInfo(IDictionary inputMap
// Re-acquire v after SetCellType may have changed DOM structure
v = cell.Element(SpreadsheetNs + "v") ?? cell.Element(SpreadsheetNs + "is")?.Element(SpreadsheetNs + "t");
- v?.SetValue(v.Value.Replace($"{{{{{propNames[0]}}}}}", cellValueStr)); //TODO: auto check type and set value
+ v?.SetValue(v.Value.Replace($"{{{{{formatText}}}}}", cellValueStr)); //TODO: auto check type and set value
}
}
//if (xRowInfo.CellIEnumerableValues is not null) //2. From left to right, only the first set is used as the basis for the list
diff --git a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.ValueExtractorHook.cs b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.ValueExtractorHook.cs
index 8e3fbac8..21d16a61 100644
--- a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.ValueExtractorHook.cs
+++ b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.ValueExtractorHook.cs
@@ -16,7 +16,7 @@ internal partial class OpenXmlTemplate
/// Recursively flattens an object graph into a dictionary of "key.subkey" pairs and fully formats the values.
/// Includes protection against circular references and stack overflow via depth limiting.
///
- private static void FlattenAndFormatValues(Dictionary replacements, string key, object? value, int maxDepth, PropertyInfo? propInfo = null)
+ private void FlattenAndFormatValues(Dictionary replacements, string key, object? value, int maxDepth, PropertyInfo? propInfo = null)
{
// Initialize a HashSet with reference equality comparer to track visited objects and prevent infinite loops from circular references.
var visited = new HashSet]