From 50c39a2c153c7d2a4bc56bdb092163c6451e2f5d Mon Sep 17 00:00:00 2001 From: Enzo Samudio Date: Fri, 25 Sep 2026 16:30:55 -0300 Subject: [PATCH 1/7] Render byte[] values as images in templates Template placeholders that resolve to image byte[] values (root, nested or inside collections) are now emitted as embedded images, consistently with the SaveAs pipeline and reusing ImageHelper, FileDto and ExcelXml. - Resolve nested scalar paths such as {{Company.Logo}}. - Stop treating byte[] as an IEnumerable during template resolution. - Emit media, drawing and relationship parts, and declare the drawing content type so Excel does not repair the workbook. - Merge into a pre-existing drawing and worksheet rels instead of dropping them. Refs #604, #972. --- .../Templates/OpenXmlTemplate.Images.cs | 458 ++++++++++++++++++ .../Templates/OpenXmlTemplate.Impl.cs | 49 +- .../OpenXmlTemplate.ValueExtractorHook.cs | 10 +- .../Templates/OpenXmlTemplate.cs | 36 +- .../MiniExcel.OpenXml.Tests.csproj | 1 + .../Templates/TemplateImageTests.cs | 352 ++++++++++++++ 6 files changed, 891 insertions(+), 15 deletions(-) create mode 100644 src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Images.cs create mode 100644 tests/MiniExcel.OpenXml.Tests/Templates/TemplateImageTests.cs diff --git a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Images.cs b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Images.cs new file mode 100644 index 00000000..867198a2 --- /dev/null +++ b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Images.cs @@ -0,0 +1,458 @@ +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 _sheetTemplateDrawings = []; + private readonly Dictionary _sheetTemplateRels = []; + private readonly List _createdDrawingParts = []; + private int _currentSheetIndex; + private int _nextImageId; + + private sealed class PendingImage(byte[] bytes, string extension) + { + internal byte[] Bytes { get; } = bytes; + internal string Extension { get; } = extension; + } + +#if NET + [GeneratedRegex(@"<[A-Za-z0-9:]*c\b[^>]*\br=""(?[A-Z]+[0-9]+)""[^>]*>(?:(?!).)*?@@@imageid@@@,(?[0-9]+)(?:(?!).)*?", RegexOptions.Singleline)] + private static partial Regex ImageMarkerCellRegex(); + + private static readonly Regex ImageMarkerCellRegexImpl = ImageMarkerCellRegex(); +#else + private static readonly Regex ImageMarkerCellRegexImpl = new( + @"<[A-Za-z0-9:]*c\b[^>]*\br=""(?[A-Z]+[0-9]+)""[^>]*>(?:(?!).)*?@@@imageid@@@,(?[0-9]+)(?:(?!).)*?", + RegexOptions.Compiled | RegexOptions.Singleline); +#endif + + private void ResetImageState() + { + _files.Clear(); + _pendingImages.Clear(); + _sheetTemplateDrawings.Clear(); + _sheetTemplateRels.Clear(); + _createdDrawingParts.Clear(); + _nextImageId = 0; + _currentSheetIndex = 0; + } + + /// + /// 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()); + 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 || !rowXml.Contains(ImageMarkerPrefix)) + return 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) && + _pendingImages.TryGetValue(id, out var pending)) + { + _files.Add(new FileDto + { + SheetIndex = sheetIndex, + RowIndex = row, + CellIndex = column, + Contents = pending.Bytes, + Extension = pending.Extension, + IsImage = true + }); + } + + var clearedCell = match.Value.Replace(ImageMarkerPrefix + id, string.Empty); + rowXml = rowXml.Remove(match.Index, match.Length).Insert(match.Index, clearedCell); + } + } + + private static bool IsDrawingPrecedingElement(XElement element) + => element.Name.LocalName is "tableParts" or "oleObjects" or "controls" or "extLst"; + + [CreateSyncVersion] + private static async Task WriteDrawingReferenceAsync(XmlWriter writer, string? prefix, int sheetIndex) + { + var prefixSeparator = string.IsNullOrEmpty(prefix) ? string.Empty : prefix + ":"; + await writer.WriteRawAsync($"<{prefixSeparator}drawing r:id=\"rDrawing{sheetIndex}\" />").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 templateDrawingPaths = new HashSet(templateDrawings.Values.Select(value => value.DrawingPath), StringComparer.OrdinalIgnoreCase); + var writtenSheetRels = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var sheetGroup in imageFiles.GroupBy(file => file.SheetIndex)) + { + var sheetIndex = sheetGroup.Key; + var files = sheetGroup.ToList(); + + if (_sheetTemplateDrawings.TryGetValue(sheetIndex, out var templateDrawing)) + { + wrappedDrawings.Add(templateDrawing.DrawingPath); + await MergeIntoExistingDrawingAsync(templateArchive, outputArchive, templateDrawing, files, cancellationToken).ConfigureAwait(false); + } + else if (!templateDrawingPaths.Contains(ExcelFileNames.Drawing(sheetIndex))) + { + await EmitNewDrawingAsync(outputArchive, sheetIndex, 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. + var sheetRelsPath = ExcelFileNames.SheetRels(sheetIndex); + if (_sheetTemplateRels.TryGetValue(sheetIndex, out var templateRelsPath)) + { + var relsDoc = await LoadXmlAsync(templateArchive, templateRelsPath, cancellationToken).ConfigureAwait(false); + EnsureDrawingRelationship(relsDoc, sheetIndex); + await SaveXmlToZipAsync(outputArchive.ZipFile, sheetRelsPath, relsDoc, cancellationToken).ConfigureAwait(false); + writtenSheetRels.Add(templateRelsPath); + } + else + { + await WriteTextEntryAsync(outputArchive.ZipFile, sheetRelsPath, ExcelXml.DefaultSheetRelXml(ExcelXml.DrawingRelationship(sheetIndex)), 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, int sheetIndex, IReadOnlyList files, CancellationToken cancellationToken) + { + var drawingPath = ExcelFileNames.Drawing(sheetIndex); + _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(sheetIndex), ExcelXml.DefaultDrawingXmlRels(drawingRels.ToString()), cancellationToken).ConfigureAwait(false); + } + + [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 existingAnchorCount = drawingRoot.Elements() + .Count(element => element.Name.LocalName is "oneCellAnchor" or "twoCellAnchor" or "absoluteAnchor"); + + 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, existingAnchorCount + 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) + { + 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))); + 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..064d7c84 100644 --- a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Impl.cs +++ b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Impl.cs @@ -450,7 +450,8 @@ var s when s.StartsWith("@header") => SpecialCellType.Header, .Append($""); 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 +503,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 +521,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 +689,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 +745,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 +1190,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 +1378,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 +1401,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 +1419,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(ReferenceEqualityComparer.Instance); @@ -28,7 +28,7 @@ private static void FlattenAndFormatValues(Dictionary replacemen // // The internal recursive method that performs the actual object traversal, flattening, and formatting. // - static void TraverseAndFlatten( + void TraverseAndFlatten( Dictionary replacements, string key, object? value, @@ -48,9 +48,10 @@ static void TraverseAndFlatten( if (type.IsPrimitive || type.IsEnum || type == typeof(string) || type == typeof(decimal) || type == typeof(DateTime) || type == typeof(Guid) || + type == typeof(byte[]) || Nullable.GetUnderlyingType(type) != null) { - replacements[key] = GetFormattedValue(propInfo, value, type); + replacements[key] = GetFormattedValueWithImages(propInfo, value, type); return; } @@ -76,7 +77,7 @@ static void TraverseAndFlatten( return; } - replacements[key] = GetFormattedValue(propInfo, value, type); + replacements[key] = GetFormattedValueWithImages(propInfo, value, type); // 5. Object property recursion: Get public instance properties filtering out indexers and write-only properties. var properties = type @@ -156,6 +157,7 @@ static void TraverseAndFlatten( await using var disposableSheetStream = newSheetStream.ConfigureAwait(false); // Generate the sheet content based on the template and current sub-values + _currentSheetIndex = sheetIndex; await GenerateSheetByCreateModeAsync(templateSheet, newSheetStream, subValues, templateSharedStrings, cancellationToken: cancellationToken).ConfigureAwait(false); // Append calculation chain content for the newly created sheet diff --git a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs index be31791c..7d37ffcb 100644 --- a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs +++ b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs @@ -48,6 +48,7 @@ public async Task SaveAsByTemplateAsync(Stream templateStream, object value, Can if (!templateStream.CanSeek) throw new ArgumentException("The template stream must be seekable"); + ResetImageState(); templateStream.Seek(0, SeekOrigin.Begin); var templateReader = await OpenXmlReader.CreateAsync(templateStream, null, cancellationToken: cancellationToken).ConfigureAwait(false); await using var disposableTemplateReader = templateReader.ConfigureAwait(false); @@ -80,6 +81,29 @@ public async Task SaveAsByTemplateAsync(Stream templateStream, object value, Can #endif // sheet name map var sheetNamesMap = await GetSheetNameMapAsync(originalArchive, cancellationToken).ConfigureAwait(false); + var templateDrawings = await GetTemplateDrawingPathsAsync(originalArchive, sheetNamesMap, cancellationToken).ConfigureAwait(false); + var replacedPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var templateDrawing in templateDrawings.Values) + { + replacedPaths.Add(templateDrawing.DrawingPath); + replacedPaths.Add(templateDrawing.DrawingRelsPath); + } + + // Template worksheet rels are merged with our drawing relationship (or written back verbatim), + // so they must not be copied as-is. + var templateSheetRels = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var (sheetPath, sheetName) in sheetNamesMap) + { + if (ParametrizedSheetRegexImpl.IsMatch(sheetName)) + continue; + + var relsPath = $"xl/worksheets/_rels/{Path.GetFileName(sheetPath)}.rels"; + if (originalArchive.GetEntry(relsPath) is not null) + { + templateSheetRels.Add(relsPath); + replacedPaths.Add(relsPath); + } + } // Iterate through each entry in the original archive foreach (var entry in originalArchive.Entries) @@ -89,7 +113,8 @@ public async Task SaveAsByTemplateAsync(Stream templateStream, object value, Can entryName.Equals(ExcelFileNames.CalcChain, StringComparison.OrdinalIgnoreCase) || entryName.Equals(ExcelFileNames.ContentTypes, StringComparison.OrdinalIgnoreCase) || entryName.Equals(ExcelFileNames.Workbook, StringComparison.OrdinalIgnoreCase) || - entryName.Equals(ExcelFileNames.WorkbookRels, StringComparison.OrdinalIgnoreCase)) + entryName.Equals(ExcelFileNames.WorkbookRels, StringComparison.OrdinalIgnoreCase) || + replacedPaths.Contains(entryName)) { continue; } @@ -148,6 +173,12 @@ await originalEntryStream.CopyToAsync(newEntryStream var outputZipSheetEntryStream = await outputZipEntry.OpenAsync(cancellationToken).ConfigureAwait(false); await using var disposableSheetEntryStream = outputZipSheetEntryStream.ConfigureAwait(false); + _currentSheetIndex = sheetIdx; + if (templateDrawings.TryGetValue(templateSheetPath, out var templateDrawing)) + _sheetTemplateDrawings[sheetIdx] = templateDrawing; + var templateRelsPath = $"xl/worksheets/_rels/{Path.GetFileName(templateSheetPath)}.rels"; + if (templateSheetRels.Contains(templateRelsPath)) + _sheetTemplateRels[sheetIdx] = templateRelsPath; await GenerateSheetByCreateModeAsync(templateSheet, outputZipSheetEntryStream, inputValues, templateSharedStrings, cancellationToken: cancellationToken).ConfigureAwait(false); // disposing writer disposes streams as well, read and parse calc functions before that @@ -156,6 +187,8 @@ await originalEntryStream.CopyToAsync(newEntryStream } } + await EmitTemplateImagesAsync(originalArchive, outputFileArchive, templateDrawings, templateSheetRels, cancellationToken).ConfigureAwait(false); + // The template's own calcChain cannot be reused: row insertion shifts formula cells and its // entries would point at the old addresses. It is regenerated from the rendered formulas — // and when none were rendered, dropped entirely, because a calcChain with no entries is @@ -186,6 +219,7 @@ await originalEntryStream.CopyToAsync(newEntryStream } // saving the (possibly edited) [Content_Types].xml entry + EnsureImageContentTypes(contentTypesDoc); await SaveXmlToZipAsync(outputFileArchive.ZipFile, ExcelFileNames.ContentTypes, contentTypesDoc, cancellationToken).ConfigureAwait(false); // editing the workbook and its rels to reflect the new worksheets' metadata diff --git a/tests/MiniExcel.OpenXml.Tests/MiniExcel.OpenXml.Tests.csproj b/tests/MiniExcel.OpenXml.Tests/MiniExcel.OpenXml.Tests.csproj index c13050e3..cd7d3e6e 100644 --- a/tests/MiniExcel.OpenXml.Tests/MiniExcel.OpenXml.Tests.csproj +++ b/tests/MiniExcel.OpenXml.Tests/MiniExcel.OpenXml.Tests.csproj @@ -35,6 +35,7 @@ + diff --git a/tests/MiniExcel.OpenXml.Tests/Templates/TemplateImageTests.cs b/tests/MiniExcel.OpenXml.Tests/Templates/TemplateImageTests.cs new file mode 100644 index 00000000..0bd7bd27 --- /dev/null +++ b/tests/MiniExcel.OpenXml.Tests/Templates/TemplateImageTests.cs @@ -0,0 +1,352 @@ +using System.Xml.Linq; +using ClosedXML.Excel; +using MiniExcelLib.Tests.Common.Utils; +using OfficeOpenXml.Drawing; + +namespace MiniExcelLib.OpenXml.Tests.Templates; + +/// +/// Template image support: a byte[] property resolved from a template placeholder must be rendered +/// as an embedded image, consistently with the SaveAs pipeline (issue #972 / #604). +/// +public class TemplateImageTests(ITestOutputHelper output) +{ + private static readonly XNamespace SpreadsheetNs = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + private static readonly XNamespace DrawingNs = "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing"; + + private readonly OpenXmlTemplater _templater = MiniExcelV2.Templaters.GetOpenXmlTemplater(); + private readonly ITestOutputHelper _output = output; + + private static byte[] TestPng() => File.ReadAllBytes(PathHelper.GetFile("xlsx/Issue327/TestIssue327.png")); + + private static string GetSheetXml(string xlsxPath, int sheetIndex = 1) + { + using var zip = ZipFile.OpenRead(xlsxPath); + var entry = zip.GetEntry($"xl/worksheets/sheet{sheetIndex}.xml"); + Assert.NotNull(entry); + using var reader = new StreamReader(entry!.Open()); + return reader.ReadToEnd(); + } + + private static IReadOnlyList GetMediaEntries(string xlsxPath) + { + using var zip = ZipFile.OpenRead(xlsxPath); + return zip.Entries + .Where(e => e.FullName.StartsWith("xl/media/", StringComparison.OrdinalIgnoreCase)) + .Select(e => e.FullName) + .ToList(); + } + + private static void AssertPackageIsValid(string xlsxPath) + { + using var zip = ZipFile.OpenRead(xlsxPath); + + // The worksheet must reference the drawing part. + using (var sheetStream = zip.GetEntry("xl/worksheets/sheet1.xml")!.Open()) + { + var sheetDoc = XDocument.Load(sheetStream); + Assert.NotNull(sheetDoc.Descendants(SpreadsheetNs + "drawing").FirstOrDefault()); + } + + // The persisted content types must declare the image extension and the drawing part. + using (var contentTypesStream = zip.GetEntry("[Content_Types].xml")!.Open()) + { + var contentTypes = XDocument.Load(contentTypesStream).ToString(); + Assert.Contains("image/png", contentTypes); + Assert.Contains("/xl/drawings/drawing1.xml", contentTypes); + Assert.Contains("application/vnd.openxmlformats-officedocument.drawing+xml", contentTypes); + } + } + + private static void AssertPackageIsValidAndHasImages(string xlsxPath, int expectedImages) + { + AssertPackageIsValid(xlsxPath); + + // A real OpenXML consumer must be able to open the workbook and see the images. + using (var package = new ExcelPackage(new FileInfo(xlsxPath))) + { + Assert.Equal(expectedImages, package.Workbook.Worksheets[0].Drawings.OfType().Count()); + } + + // OPC-level check: the drawing part must resolve to a declared content type, otherwise Excel + // repairs the file by dropping the drawing (the failure EPPlus does not surface). + using var opcPackage = Package.Open(xlsxPath, FileMode.Open, FileAccess.Read, FileShare.Read); + var drawingPart = opcPackage.GetPart(new Uri("/xl/drawings/drawing1.xml", UriKind.Relative)); + Assert.Equal("application/vnd.openxmlformats-officedocument.drawing+xml", drawingPart.ContentType); + } + + [Fact] + public void ScalarByteArray_IsRenderedAsImage() + { + using var template = AutoDeletingPath.Create(); + using (var wb = new XLWorkbook()) + { + var ws = wb.AddWorksheet("Sheet1"); + ws.Cell("A1").Value = "{{Logo}}"; + wb.SaveAs(template.FilePath); + } + + using var path = AutoDeletingPath.Create(); + _templater.FillTemplate(path.ToString(), template.FilePath, new { Logo = TestPng() }); + + var sheet = GetSheetXml(path.ToString()); + _output.WriteLine(sheet); + + Assert.Single(GetMediaEntries(path.ToString())); + Assert.DoesNotContain("System.Byte[]", sheet); + Assert.DoesNotContain("{{Logo}}", sheet); + AssertPackageIsValidAndHasImages(path.ToString(), expectedImages: 1); + } + + [Fact] + public void NestedByteArray_IsRenderedAsImage() + { + using var template = AutoDeletingPath.Create(); + using (var wb = new XLWorkbook()) + { + var ws = wb.AddWorksheet("Sheet1"); + ws.Cell("A1").Value = "{{Company.Logo}}"; + wb.SaveAs(template.FilePath); + } + + using var path = AutoDeletingPath.Create(); + _templater.FillTemplate(path.ToString(), template.FilePath, new { Company = new { Logo = TestPng() } }); + + var sheet = GetSheetXml(path.ToString()); + _output.WriteLine(sheet); + + Assert.Single(GetMediaEntries(path.ToString())); + Assert.DoesNotContain("System.Byte[]", sheet); + Assert.DoesNotContain("{{Company.Logo}}", sheet); + AssertPackageIsValidAndHasImages(path.ToString(), expectedImages: 1); + } + + [Fact] + public void CollectionByteArray_IsRenderedAsImagePerRow() + { + using var template = AutoDeletingPath.Create(); + using (var wb = new XLWorkbook()) + { + var ws = wb.AddWorksheet("Sheet1"); + ws.Cell("A1").Value = "{{Products.Name}}"; + ws.Cell("B1").Value = "{{Products.Image}}"; + wb.SaveAs(template.FilePath); + } + + using var path = AutoDeletingPath.Create(); + var image = TestPng(); + _templater.FillTemplate(path.ToString(), template.FilePath, new + { + Products = new[] + { + new { Name = "A", Image = image }, + new { Name = "B", Image = image }, + new { Name = "C", Image = image }, + } + }); + + var sheet = GetSheetXml(path.ToString()); + _output.WriteLine(sheet); + + Assert.Equal(3, GetMediaEntries(path.ToString()).Count); + Assert.DoesNotContain("System.Byte[]", sheet); + + // Each expanded row must have an image anchored to its final row. + using (var zip = ZipFile.OpenRead(path.ToString())) + using (var drawingStream = zip.GetEntry("xl/drawings/drawing1.xml")!.Open()) + { + var drawingXml = XDocument.Load(drawingStream); + var anchors = drawingXml.Descendants(DrawingNs + "oneCellAnchor").ToList(); + Assert.Equal(3, anchors.Count); + + var anchorRows = anchors + .Select(a => (int)a.Element(DrawingNs + "from")!.Element(DrawingNs + "row")!) + .OrderBy(r => r) + .ToList(); + Assert.Equal([0, 1, 2], anchorRows); + } + + AssertPackageIsValidAndHasImages(path.ToString(), expectedImages: 3); + } + + [Fact] + public void NullImage_DoesNotProduceImageOrPlaceholder() + { + using var template = AutoDeletingPath.Create(); + using (var wb = new XLWorkbook()) + { + var ws = wb.AddWorksheet("Sheet1"); + ws.Cell("A1").Value = "{{Logo}}"; + wb.SaveAs(template.FilePath); + } + + using var path = AutoDeletingPath.Create(); + _templater.FillTemplate(path.ToString(), template.FilePath, new { Logo = (byte[]?)null }); + + var sheet = GetSheetXml(path.ToString()); + Assert.Empty(GetMediaEntries(path.ToString())); + Assert.DoesNotContain("System.Byte[]", sheet); + Assert.DoesNotContain("{{Logo}}", sheet); + } + + [Fact] + public void DisabledByteArrayConversion_DoesNotProduceImage() + { + using var template = AutoDeletingPath.Create(); + using (var wb = new XLWorkbook()) + { + var ws = wb.AddWorksheet("Sheet1"); + ws.Cell("A1").Value = "{{Logo}}"; + wb.SaveAs(template.FilePath); + } + + using var path = AutoDeletingPath.Create(); + _templater.FillTemplate(path.ToString(), template.FilePath, new { Logo = TestPng() }, + configuration: new OpenXmlConfiguration { EnableConvertByteArray = false }); + + Assert.Empty(GetMediaEntries(path.ToString())); + } + + [Fact] + public void MultipleImageProperties_AreRenderedIndependently() + { + using var template = AutoDeletingPath.Create(); + using (var wb = new XLWorkbook()) + { + var ws = wb.AddWorksheet("Sheet1"); + ws.Cell("A1").Value = "{{Photo}}"; + ws.Cell("B1").Value = "{{Signature}}"; + wb.SaveAs(template.FilePath); + } + + using var path = AutoDeletingPath.Create(); + _templater.FillTemplate(path.ToString(), template.FilePath, new { Photo = TestPng(), Signature = TestPng() }); + + Assert.Equal(2, GetMediaEntries(path.ToString()).Count); + + using var zip = ZipFile.OpenRead(path.ToString()); + using var drawingStream = zip.GetEntry("xl/drawings/drawing1.xml")!.Open(); + var anchors = XDocument.Load(drawingStream).Descendants(DrawingNs + "oneCellAnchor").ToList(); + Assert.Equal(2, anchors.Count); + Assert.Equal([0, 1], anchors.Select(a => (int)a.Element(DrawingNs + "from")!.Element(DrawingNs + "col")!).OrderBy(c => c).ToList()); + } + + [Fact] + public void MultipleSheetsWithImages_EmitOneDrawingPerSheet() + { + using var template = AutoDeletingPath.Create(); + using (var wb = new XLWorkbook()) + { + var first = wb.AddWorksheet("First"); + first.Cell("A1").Value = "{{Logo}}"; + var second = wb.AddWorksheet("Second"); + second.Cell("A1").Value = "{{Logo}}"; + wb.SaveAs(template.FilePath); + } + + using var path = AutoDeletingPath.Create(); + _templater.FillTemplate(path.ToString(), template.FilePath, new { Logo = TestPng() }); + + Assert.Equal(2, GetMediaEntries(path.ToString()).Count); + + using var zip = ZipFile.OpenRead(path.ToString()); + Assert.NotNull(zip.GetEntry("xl/drawings/drawing1.xml")); + Assert.NotNull(zip.GetEntry("xl/drawings/drawing2.xml")); + Assert.NotNull(zip.GetEntry("xl/worksheets/_rels/sheet1.xml.rels")); + Assert.NotNull(zip.GetEntry("xl/worksheets/_rels/sheet2.xml.rels")); + } + + [Fact] + public void TemplateWithExistingImage_PreservesItAndAddsNewImage() + { + using var template = AutoDeletingPath.Create(); + using (var wb = new XLWorkbook()) + { + var ws = wb.AddWorksheet("Sheet1"); + ws.Cell("A1").Value = "{{Logo}}"; + using var stream = new MemoryStream(TestPng()); + ws.AddPicture(stream).MoveTo(ws.Cell("D10")); + wb.SaveAs(template.FilePath); + } + + using var path = AutoDeletingPath.Create(); + _templater.FillTemplate(path.ToString(), template.FilePath, new { Logo = TestPng() }); + + Assert.Equal(2, GetMediaEntries(path.ToString()).Count); + + using var package = new ExcelPackage(new FileInfo(path.ToString())); + Assert.Equal(2, package.Workbook.Worksheets[0].Drawings.OfType().Count()); + } + + [Fact] + public void ParametrizedSheets_RenderImagesPerGeneratedSheet() + { + using var template = AutoDeletingPath.Create(); + using (var wb = new XLWorkbook()) + { + var ws = wb.AddWorksheet("$Items$"); + ws.Cell("A1").Value = "{{Name}}"; + ws.Cell("B1").Value = "{{Image}}"; + wb.SaveAs(template.FilePath); + } + + using var path = AutoDeletingPath.Create(); + var image = TestPng(); + _templater.FillTemplate(path.ToString(), template.FilePath, new + { + Items = new[] + { + new { Name = "A", Image = image }, + new { Name = "B", Image = image }, + } + }); + + Assert.Equal(2, GetMediaEntries(path.ToString()).Count); + + using var zip = ZipFile.OpenRead(path.ToString()); + Assert.NotNull(zip.GetEntry("xl/drawings/drawing1.xml")); + Assert.NotNull(zip.GetEntry("xl/drawings/drawing2.xml")); + } + + [Fact] + public void LegacyFacade_SaveAsByTemplate_RendersImages() + { + using var template = AutoDeletingPath.Create(); + using (var wb = new XLWorkbook()) + { + var ws = wb.AddWorksheet("Sheet1"); + ws.Cell("A1").Value = "{{Logo}}"; + wb.SaveAs(template.FilePath); + } + + using var path = AutoDeletingPath.Create(); + MiniExcelLibs.MiniExcel.SaveAsByTemplate(path.ToString(), template.FilePath, new { Logo = TestPng() }); + + Assert.Single(GetMediaEntries(path.ToString())); + AssertPackageIsValidAndHasImages(path.ToString(), expectedImages: 1); + } + + [Fact] + public void TemplateWithExistingSheetRels_MergesDrawingRelationship() + { + using var template = AutoDeletingPath.Create(); + using (var wb = new XLWorkbook()) + { + var ws = wb.AddWorksheet("Sheet1"); + ws.Cell("A1").Value = "{{Logo}}"; + ws.Cell("B1").SetHyperlink(new XLHyperlink("https://example.com")); + wb.SaveAs(template.FilePath); + } + + using var path = AutoDeletingPath.Create(); + _templater.FillTemplate(path.ToString(), template.FilePath, new { Logo = TestPng() }); + + Assert.Single(GetMediaEntries(path.ToString())); + + using var zip = ZipFile.OpenRead(path.ToString()); + using var relsStream = zip.GetEntry("xl/worksheets/_rels/sheet1.xml.rels")!.Open(); + var rels = XDocument.Load(relsStream).ToString(); + Assert.Contains("relationships/hyperlink", rels); // pre-existing relationship preserved + Assert.Contains("relationships/drawing", rels); // our drawing relationship merged in + } +} From 20d2a99acf30cc756150e55f43a4d976910ed840 Mon Sep 17 00:00:00 2001 From: Enzo Samudio Date: Fri, 25 Sep 2026 16:42:31 -0300 Subject: [PATCH 2/7] docs: document template image support Explain how byte[] template placeholders are rendered as embedded images (root, nested and collections), consistently with SaveAs, and how to opt out via EnableConvertByteArray. Refs #604, #972. --- README_V2.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/README_V2.md b/README_V2.md index 7e061d3a..5839d511 100644 --- a/README_V2.md +++ b/README_V2.md @@ -1179,6 +1179,58 @@ Result: image +#### 13. Images + +Since v2.0.0, 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); +``` + +> Note: images are anchored to the cell with a default size. Configurable width and height are not available yet. + + ### Editing existing workbooks > Warning: this feature is a work in progress and currently very limited! From 8456f882839a9c2e894e074aeaab0e45577c78c2 Mon Sep 17 00:00:00 2001 From: Enzo Samudio Date: Fri, 25 Sep 2026 18:14:12 -0300 Subject: [PATCH 3/7] Size template images to the row height Scale template images to the height of the row they are anchored to, preserving their aspect ratio, by reading the natural dimensions from the image header (PNG, JPEG, GIF, BMP and TIFF). Rows without an explicit height keep the previous default anchor size. Refs #604, #972. --- README_V2.md | 4 +- src/MiniExcel.Core/Helpers/ImageHelper.cs | 173 ++++++++++++++++++ src/MiniExcel.OpenXml/Constants/ExcelXml.cs | 6 +- src/MiniExcel.OpenXml/Models/FileDto.cs | 6 + .../Templates/OpenXmlTemplate.Images.cs | 48 ++++- .../Helpers/ImageHelperTests.cs | 111 +++++++++++ .../Templates/TemplateImageTests.cs | 86 +++++++++ 7 files changed, 428 insertions(+), 6 deletions(-) create mode 100644 tests/MiniExcel.OpenXml.Tests/Helpers/ImageHelperTests.cs diff --git a/README_V2.md b/README_V2.md index 5839d511..45e104ba 100644 --- a/README_V2.md +++ b/README_V2.md @@ -1228,7 +1228,9 @@ var config = new OpenXmlConfiguration { EnableConvertByteArray = false }; templater.FillTemplate(path, templatePath, value, configuration: config); ``` -> Note: images are anchored to the cell with a default size. Configurable width and height are not available yet. +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 diff --git a/src/MiniExcel.Core/Helpers/ImageHelper.cs b/src/MiniExcel.Core/Helpers/ImageHelper.cs index 1d89ac61..18484b7d 100644 --- a/src/MiniExcel.Core/Helpers/ImageHelper.cs +++ b/src/MiniExcel.Core/Helpers/ImageHelper.cs @@ -39,4 +39,177 @@ 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); + if (ifdOffset < 8 || ifdOffset + 2 > bytes.Length) + return null; + + var entryCount = ReadUInt16(bytes, ifdOffset, littleEndian); + int? width = null; + int? height = null; + + for (var i = 0; i < entryCount; i++) + { + var entryOffset = ifdOffset + 2 + (i * 12); + if (entryOffset + 12 > bytes.Length) + break; + + var tag = ReadUInt16(bytes, entryOffset, littleEndian); + if (tag != 256 && tag != 257) + continue; + + var fieldType = ReadUInt16(bytes, entryOffset + 2, littleEndian); + int value; + if (fieldType == 3) // SHORT + value = ReadUInt16(bytes, entryOffset + 8, littleEndian); + else if (fieldType == 4) // LONG + value = ReadInt32(bytes, entryOffset + 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/ExcelXml.cs b/src/MiniExcel.OpenXml/Constants/ExcelXml.cs index 511ab860..07671470 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(""" @@ -125,7 +129,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..c425e9f9 100644 --- a/src/MiniExcel.OpenXml/Models/FileDto.cs +++ b/src/MiniExcel.OpenXml/Models/FileDto.cs @@ -10,4 +10,10 @@ internal class FileDto 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 index 867198a2..ffc6f06a 100644 --- a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Images.cs +++ b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Images.cs @@ -17,23 +17,34 @@ internal partial class OpenXmlTemplate private int _currentSheetIndex; private int _nextImageId; - private sealed class PendingImage(byte[] bytes, string extension) + 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]+)""[^>]*>(?:(?!).)*?@@@imageid@@@,(?[0-9]+)(?:(?!).)*?", 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]+)""[^>]*>(?:(?!).)*?@@@imageid@@@,(?[0-9]+)(?:(?!).)*?", 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(); @@ -61,7 +72,7 @@ private void ResetImageState() var id = _nextImageId.ToString(CultureInfo.InvariantCulture); _nextImageId++; - _pendingImages[id] = new PendingImage(bytes, format.ToString().ToLowerInvariant()); + _pendingImages[id] = new PendingImage(bytes, format.ToString().ToLowerInvariant(), ImageHelper.GetImageSize(bytes)); return ImageMarkerPrefix + id; } @@ -116,6 +127,8 @@ private string CaptureAndClearImageMarkers(string rowXml, int sheetIndex) if (_pendingImages.Count == 0 || !rowXml.Contains(ImageMarkerPrefix)) return rowXml; + var rowHeightPoints = GetRowHeightPoints(rowXml); + while (true) { var match = ImageMarkerCellRegexImpl.Match(rowXml); @@ -126,7 +139,7 @@ private string CaptureAndClearImageMarkers(string rowXml, int sheetIndex) if (CellReferenceConverter.TryParseCellReference(match.Groups["ref"].Value, out var column, out var row) && _pendingImages.TryGetValue(id, out var pending)) { - _files.Add(new FileDto + var file = new FileDto { SheetIndex = sheetIndex, RowIndex = row, @@ -134,7 +147,10 @@ private string CaptureAndClearImageMarkers(string rowXml, int sheetIndex) Contents = pending.Bytes, Extension = pending.Extension, IsImage = true - }); + }; + + ApplyRowHeightSize(file, pending, rowHeightPoints); + _files.Add(file); } var clearedCell = match.Value.Replace(ImageMarkerPrefix + id, string.Empty); @@ -142,6 +158,30 @@ private string CaptureAndClearImageMarkers(string rowXml, int sheetIndex) } } + /// + /// 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 "tableParts" or "oleObjects" or "controls" or "extLst"; diff --git a/tests/MiniExcel.OpenXml.Tests/Helpers/ImageHelperTests.cs b/tests/MiniExcel.OpenXml.Tests/Helpers/ImageHelperTests.cs new file mode 100644 index 00000000..865d2d21 --- /dev/null +++ b/tests/MiniExcel.OpenXml.Tests/Helpers/ImageHelperTests.cs @@ -0,0 +1,111 @@ +using MiniExcelLib.Core.Helpers; +using MiniExcelLib.Tests.Common.Utils; + +namespace MiniExcelLib.OpenXml.Tests.Helpers; + +public class ImageHelperTests +{ + private static byte[] TestPng() => File.ReadAllBytes(PathHelper.GetFile("xlsx/Issue327/TestIssue327.png")); + + [Fact] + public void GetImageSize_ReadsPngHeader() + { + var size = ImageHelper.GetImageSize(TestPng()); + + Assert.NotNull(size); + Assert.Equal(1920, size!.Value.Width); + Assert.Equal(1032, size.Value.Height); + } + + [Fact] + public void GetImageSize_ReadsGifHeader() + { + byte[] gif = [(byte)'G', (byte)'I', (byte)'F', (byte)'8', (byte)'9', (byte)'a', 100, 0, 50, 0]; + + var size = ImageHelper.GetImageSize(gif); + + Assert.NotNull(size); + Assert.Equal(100, size!.Value.Width); + Assert.Equal(50, size.Value.Height); + } + + [Fact] + public void GetImageSize_ReadsBmpHeader() + { + var bmp = new byte[26]; + bmp[0] = (byte)'B'; + bmp[1] = (byte)'M'; + bmp[14] = 40; // BITMAPINFOHEADER + bmp[18] = 100; // width + bmp[22] = 50; // height + + var size = ImageHelper.GetImageSize(bmp); + + Assert.NotNull(size); + Assert.Equal(100, size!.Value.Width); + Assert.Equal(50, size.Value.Height); + } + + [Fact] + public void GetImageSize_ReadsJpegHeader() + { + var jpeg = new byte[20]; + jpeg[0] = 0xFF; + jpeg[1] = 0xD8; + jpeg[2] = 0xFF; + jpeg[3] = 0xE0; // APP0: matches the signature used by GetImageFormat + jpeg[4] = 0x00; + jpeg[5] = 0x04; // APP0 segment length + jpeg[8] = 0xFF; + jpeg[9] = 0xC0; // SOF0 + jpeg[10] = 0x00; + jpeg[11] = 0x11; // segment length + jpeg[12] = 0x08; // sample precision + jpeg[13] = 0x00; + jpeg[14] = 50; // height + jpeg[15] = 0x00; + jpeg[16] = 100; // width + + var size = ImageHelper.GetImageSize(jpeg); + + Assert.NotNull(size); + Assert.Equal(100, size!.Value.Width); + Assert.Equal(50, size.Value.Height); + } + + [Fact] + public void GetImageSize_ReadsTiffHeader() + { + var tiff = new byte[34]; + tiff[0] = (byte)'I'; + tiff[1] = (byte)'I'; + tiff[2] = 0x2A; + tiff[3] = 0x00; + tiff[4] = 0x08; // offset to the first IFD + tiff[8] = 0x02; // two entries + tiff[10] = 0x00; + tiff[11] = 0x01; // tag 256: image width + tiff[12] = 0x03; // SHORT + tiff[14] = 0x01; // count + tiff[18] = 0x64; // 100 + tiff[22] = 0x01; + tiff[23] = 0x01; // tag 257: image height + tiff[24] = 0x03; // SHORT + tiff[26] = 0x01; // count + tiff[30] = 0x32; // 50 + + var size = ImageHelper.GetImageSize(tiff); + + Assert.NotNull(size); + Assert.Equal(100, size!.Value.Width); + Assert.Equal(50, size.Value.Height); + } + + [Fact] + public void GetImageSize_ReturnsNullForUnknownData() + => Assert.Null(ImageHelper.GetImageSize([1, 2, 3, 4, 5, 6, 7, 8])); + + [Fact] + public void GetImageSize_ReturnsNullForNullBytes() + => Assert.Null(ImageHelper.GetImageSize(null)); +} diff --git a/tests/MiniExcel.OpenXml.Tests/Templates/TemplateImageTests.cs b/tests/MiniExcel.OpenXml.Tests/Templates/TemplateImageTests.cs index 0bd7bd27..440674a1 100644 --- a/tests/MiniExcel.OpenXml.Tests/Templates/TemplateImageTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/Templates/TemplateImageTests.cs @@ -75,6 +75,14 @@ private static void AssertPackageIsValidAndHasImages(string xlsxPath, int expect Assert.Equal("application/vnd.openxmlformats-officedocument.drawing+xml", drawingPart.ContentType); } + private static (long Width, long Height) GetAnchorSize(string xlsxPath) + { + using var zip = ZipFile.OpenRead(xlsxPath); + using var drawingStream = zip.GetEntry("xl/drawings/drawing1.xml")!.Open(); + var ext = XDocument.Load(drawingStream).Descendants(DrawingNs + "ext").First(); + return ((long)ext.Attribute("cx")!, (long)ext.Attribute("cy")!); + } + [Fact] public void ScalarByteArray_IsRenderedAsImage() { @@ -98,6 +106,84 @@ public void ScalarByteArray_IsRenderedAsImage() AssertPackageIsValidAndHasImages(path.ToString(), expectedImages: 1); } + [Fact] + public void DefaultImageSize_IsUsedWhenRowHasNoExplicitHeight() + { + using var template = AutoDeletingPath.Create(); + using (var wb = new XLWorkbook()) + { + var ws = wb.AddWorksheet("Sheet1"); + ws.Cell("A1").Value = "{{Logo}}"; + wb.SaveAs(template.FilePath); + } + + using var path = AutoDeletingPath.Create(); + _templater.FillTemplate(path.ToString(), template.FilePath, new { Logo = TestPng() }); + + var (width, height) = GetAnchorSize(path.ToString()); + Assert.Equal(609600L, width); + Assert.Equal(190500L, height); + } + + [Fact] + public void RowHeight_ScalesImagePreservingAspectRatio() + { + using var template = AutoDeletingPath.Create(); + using (var wb = new XLWorkbook()) + { + var ws = wb.AddWorksheet("Sheet1"); + ws.Cell("A1").Value = "{{Logo}}"; + ws.Row(1).Height = 30; + wb.SaveAs(template.FilePath); + } + + using var path = AutoDeletingPath.Create(); + _templater.FillTemplate(path.ToString(), template.FilePath, new { Logo = TestPng() }); + + Assert.Single(GetMediaEntries(path.ToString())); + + // The fixture is 1920x1032; a 30pt row is 30 * 12700 = 381000 EMU tall and the width keeps the ratio. + var (width, height) = GetAnchorSize(path.ToString()); + Assert.Equal(381000L, height); + Assert.Equal((long)Math.Round(381000 * (1920.0 / 1032.0)), width); + + AssertPackageIsValidAndHasImages(path.ToString(), expectedImages: 1); + } + + [Fact] + public void CollectionRowHeight_ScalesEachImage() + { + using var template = AutoDeletingPath.Create(); + using (var wb = new XLWorkbook()) + { + var ws = wb.AddWorksheet("Sheet1"); + ws.Cell("A1").Value = "{{Products.Name}}"; + ws.Cell("B1").Value = "{{Products.Image}}"; + ws.Row(1).Height = 40; + wb.SaveAs(template.FilePath); + } + + using var path = AutoDeletingPath.Create(); + var image = TestPng(); + _templater.FillTemplate(path.ToString(), template.FilePath, new + { + Products = new[] + { + new { Name = "A", Image = image }, + new { Name = "B", Image = image }, + } + }); + + using var zip = ZipFile.OpenRead(path.ToString()); + using var drawingStream = zip.GetEntry("xl/drawings/drawing1.xml")!.Open(); + var sizes = XDocument.Load(drawingStream).Descendants(DrawingNs + "ext") + .Select(ext => ((long)ext.Attribute("cx")!, (long)ext.Attribute("cy")!)) + .ToList(); + + Assert.Equal(2, sizes.Count); + Assert.All(sizes, size => Assert.Equal(40L * 12700, size.Item2)); + } + [Fact] public void NestedByteArray_IsRenderedAsImage() { From c0d8cc394a916b3d5fe017514de8ea614131cc96 Mon Sep 17 00:00:00 2001 From: Enzo Samudio Date: Fri, 25 Sep 2026 18:14:57 -0300 Subject: [PATCH 4/7] Avoid duplicate picture ids when merging template drawings Base the picture id assigned to generated anchors on the highest id already present in the reused drawing instead of on the number of existing anchors, so merged images no longer clash with the template's own pictures. --- .../Templates/OpenXmlTemplate.Images.cs | 9 ++++-- .../Templates/TemplateImageTests.cs | 29 +++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Images.cs b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Images.cs index ffc6f06a..360d5f20 100644 --- a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Images.cs +++ b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Images.cs @@ -344,8 +344,11 @@ private static async Task MergeIntoExistingDrawingAsync( if (drawingRoot is null) return; - var existingAnchorCount = drawingRoot.Elements() - .Count(element => element.Name.LocalName is "oneCellAnchor" or "twoCellAnchor" or "absoluteAnchor"); + 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(); @@ -353,7 +356,7 @@ private static async Task MergeIntoExistingDrawingAsync( foreach (var file in files) { await WriteBinaryEntryAsync(outputArchive.ZipFile, file.Path, file.Contents, cancellationToken).ConfigureAwait(false); - anchors.Append(ExcelXml.DrawingXml(file, existingAnchorCount + index)); + anchors.Append(ExcelXml.DrawingXml(file, maxPictureId + index)); index++; drawingRels.AppendLine(ExcelXml.ImageRelationship(file)); } diff --git a/tests/MiniExcel.OpenXml.Tests/Templates/TemplateImageTests.cs b/tests/MiniExcel.OpenXml.Tests/Templates/TemplateImageTests.cs index 440674a1..d421f2e5 100644 --- a/tests/MiniExcel.OpenXml.Tests/Templates/TemplateImageTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/Templates/TemplateImageTests.cs @@ -364,6 +364,35 @@ public void TemplateWithExistingImage_PreservesItAndAddsNewImage() Assert.Equal(2, package.Workbook.Worksheets[0].Drawings.OfType().Count()); } + [Fact] + public void TemplateWithExistingPictures_AssignsUniquePictureIds() + { + using var template = AutoDeletingPath.Create(); + using (var wb = new XLWorkbook()) + { + var ws = wb.AddWorksheet("Sheet1"); + ws.Cell("A1").Value = "{{Logo}}"; + using (var first = new MemoryStream(TestPng())) + ws.AddPicture(first).MoveTo(ws.Cell("D10")); + using (var second = new MemoryStream(TestPng())) + ws.AddPicture(second).MoveTo(ws.Cell("D20")); + wb.SaveAs(template.FilePath); + } + + using var path = AutoDeletingPath.Create(); + _templater.FillTemplate(path.ToString(), template.FilePath, new { Logo = TestPng() }); + + using var zip = ZipFile.OpenRead(path.ToString()); + using var drawingStream = zip.GetEntry("xl/drawings/drawing1.xml")!.Open(); + var pictureIds = XDocument.Load(drawingStream).Descendants() + .Where(element => element.Name.LocalName == "cNvPr") + .Select(element => (int)element.Attribute("id")!) + .ToList(); + + Assert.Equal(3, pictureIds.Count); + Assert.Equal(pictureIds.Count, pictureIds.Distinct().Count()); + } + [Fact] public void ParametrizedSheets_RenderImagesPerGeneratedSheet() { From 9d2cbeb2b4991d7b05fa70cc6ae1e368eb9f78de Mon Sep 17 00:00:00 2001 From: Enzo Samudio Date: Sat, 26 Sep 2026 10:43:14 -0300 Subject: [PATCH 5/7] Keep image parts and relationships unique per anchor Two images captured on the same template cell, for example {{Image1}} {{Image2}}, derived the same media part, relationship id and r:embed from their sheet, row and column coordinates. The second image overwrote the first and the drawing ended up with duplicate relationship ids, which Excel repairs by dropping the picture. Give every template image a unique suffix for its derived identifiers. The SaveAs id scheme is left untouched. Refs #604, #972. --- src/MiniExcel.OpenXml/Models/FileDto.cs | 12 +- .../Templates/OpenXmlTemplate.Images.cs | 7 +- .../Templates/TemplateImageTests.cs | 111 ++++++++++++++++++ 3 files changed, 128 insertions(+), 2 deletions(-) diff --git a/src/MiniExcel.OpenXml/Models/FileDto.cs b/src/MiniExcel.OpenXml/Models/FileDto.cs index c425e9f9..819647ce 100644 --- a/src/MiniExcel.OpenXml/Models/FileDto.cs +++ b/src/MiniExcel.OpenXml/Models/FileDto.cs @@ -5,7 +5,17 @@ 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 derived part and relationship ids when more than one image shares the same + /// anchor cell (for example two placeholders in a single cell, or the same placeholder repeated). + /// 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; } diff --git a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Images.cs b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Images.cs index 360d5f20..6846ef7e 100644 --- a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Images.cs +++ b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Images.cs @@ -146,7 +146,12 @@ private string CaptureAndClearImageMarkers(string rowXml, int sheetIndex) CellIndex = column, Contents = pending.Bytes, Extension = pending.Extension, - IsImage = true + 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); diff --git a/tests/MiniExcel.OpenXml.Tests/Templates/TemplateImageTests.cs b/tests/MiniExcel.OpenXml.Tests/Templates/TemplateImageTests.cs index d421f2e5..7b040a7f 100644 --- a/tests/MiniExcel.OpenXml.Tests/Templates/TemplateImageTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/Templates/TemplateImageTests.cs @@ -83,6 +83,61 @@ private static (long Width, long Height) GetAnchorSize(string xlsxPath) return ((long)ext.Attribute("cx")!, (long)ext.Attribute("cy")!); } + private static byte[] OtherPng() => File.ReadAllBytes(PathHelper.GetFile("images/github_logo.png")); + + private static IReadOnlyList GetEmbedIds(string xlsxPath, string drawingPath = "xl/drawings/drawing1.xml") + { + var relationshipNs = XNamespace.Get("http://schemas.openxmlformats.org/officeDocument/2006/relationships"); + using var zip = ZipFile.OpenRead(xlsxPath); + using var drawingStream = zip.GetEntry(drawingPath)!.Open(); + return XDocument.Load(drawingStream).Descendants() + .Where(element => element.Name.LocalName == "blip") + .Select(element => (string?)element.Attribute(relationshipNs + "embed")) + .ToList(); + } + + /// + /// Verifies the complete drawing relationship chain for a drawing part: every r:embed + /// resolves to a declared relationship, every image relationship targets an existing media part, + /// the picture ids are unique and the generated media parts are not overwritten. + /// + private static void AssertDrawingReferencesIntegrity(string xlsxPath, string drawingPath = "xl/drawings/drawing1.xml") + { + using var zip = ZipFile.OpenRead(xlsxPath); + var relsPath = $"xl/drawings/_rels/{Path.GetFileName(drawingPath)}.rels"; + Assert.NotNull(zip.GetEntry(relsPath)); + + using (var drawingStream = zip.GetEntry(drawingPath)!.Open()) + { + var pictureIds = XDocument.Load(drawingStream).Descendants() + .Where(element => element.Name.LocalName == "cNvPr") + .Select(element => (string?)element.Attribute("id")) + .ToList(); + Assert.Equal(pictureIds.Count, pictureIds.Distinct().Count()); + } + + var embeds = GetEmbedIds(xlsxPath, drawingPath); + + using var relsStream = zip.GetEntry(relsPath)!.Open(); + var imageRelationships = XDocument.Load(relsStream).Descendants() + .Where(element => element.Name.LocalName == "Relationship") + .Where(element => element.Attribute("Type")?.Value.EndsWith("/image", StringComparison.Ordinal) is true) + .ToList(); + + var relationshipIds = imageRelationships.Select(rel => (string?)rel.Attribute("Id")).ToList(); + Assert.Equal(relationshipIds.Count, relationshipIds.Distinct().Count()); + + // Every anchor must resolve to a declared relationship ... + Assert.All(embeds, embed => Assert.Contains(embed, relationshipIds)); + + // ... and every relationship must resolve to a media part that exists in the package. + Assert.Equal(embeds.Count, imageRelationships.Count); + foreach (var target in imageRelationships.Select(rel => rel.Attribute("Target")!.Value.TrimStart('/'))) + { + Assert.NotNull(zip.GetEntry(target)); + } + } + [Fact] public void ScalarByteArray_IsRenderedAsImage() { @@ -255,6 +310,58 @@ public void CollectionByteArray_IsRenderedAsImagePerRow() AssertPackageIsValidAndHasImages(path.ToString(), expectedImages: 3); } + [Fact] + public void TwoImagesInSameCell_AreRenderedAsDistinctPictures() + { + using var template = AutoDeletingPath.Create(); + using (var wb = new XLWorkbook()) + { + var ws = wb.AddWorksheet("Sheet1"); + ws.Cell("A1").Value = "{{Image1}} {{Image2}}"; + wb.SaveAs(template.FilePath); + } + + using var path = AutoDeletingPath.Create(); + _templater.FillTemplate(path.ToString(), template.FilePath, new { Image1 = TestPng(), Image2 = OtherPng() }); + + // Both images share the same anchor cell; each one must still get its own media part and + // relationship instead of overwriting the other. + Assert.Equal(2, GetMediaEntries(path.ToString()).Count); + Assert.Equal(2, GetEmbedIds(path.ToString()).Distinct().Count()); + + AssertDrawingReferencesIntegrity(path.ToString()); + AssertPackageIsValidAndHasImages(path.ToString(), expectedImages: 2); + } + + [Fact] + public void CollectionWithMultipleImageColumns_ProducesDistinctParts() + { + using var template = AutoDeletingPath.Create(); + using (var wb = new XLWorkbook()) + { + var ws = wb.AddWorksheet("Sheet1"); + ws.Cell("A1").Value = "{{Products.Name}}"; + ws.Cell("B1").Value = "{{Products.Image1}}"; + ws.Cell("C1").Value = "{{Products.Image2}}"; + wb.SaveAs(template.FilePath); + } + + using var path = AutoDeletingPath.Create(); + _templater.FillTemplate(path.ToString(), template.FilePath, new + { + Products = new[] + { + new { Name = "A", Image1 = TestPng(), Image2 = OtherPng() }, + new { Name = "B", Image1 = OtherPng(), Image2 = TestPng() }, + } + }); + + Assert.Equal(4, GetMediaEntries(path.ToString()).Count()); + + AssertDrawingReferencesIntegrity(path.ToString()); + AssertPackageIsValidAndHasImages(path.ToString(), expectedImages: 4); + } + [Fact] public void NullImage_DoesNotProduceImageOrPlaceholder() { @@ -315,6 +422,8 @@ public void MultipleImageProperties_AreRenderedIndependently() var anchors = XDocument.Load(drawingStream).Descendants(DrawingNs + "oneCellAnchor").ToList(); Assert.Equal(2, anchors.Count); Assert.Equal([0, 1], anchors.Select(a => (int)a.Element(DrawingNs + "from")!.Element(DrawingNs + "col")!).OrderBy(c => c).ToList()); + + AssertDrawingReferencesIntegrity(path.ToString()); } [Fact] @@ -391,6 +500,8 @@ public void TemplateWithExistingPictures_AssignsUniquePictureIds() Assert.Equal(3, pictureIds.Count); Assert.Equal(pictureIds.Count, pictureIds.Distinct().Count()); + + AssertDrawingReferencesIntegrity(path.ToString()); } [Fact] From cc2b5e21f77db6bdba5f7bc33db7a29bae8f8d33 Mon Sep 17 00:00:00 2001 From: Enzo Samudio Date: Sat, 26 Sep 2026 10:44:33 -0300 Subject: [PATCH 6/7] Release template image bytes once they are captured Pending images kept every resolved byte[] alive until the next template run, including values that were never rendered and every image produced by a collection. Transfer ownership of the bytes to the emitted file on first capture, reuse them for repeated captures through a lightweight reference, and drop the per-sheet and per-run bookkeeping as soon as it is no longer needed. Refs #604, #972. --- .../Templates/OpenXmlTemplate.Images.cs | 54 ++++++++++++++- .../Templates/OpenXmlTemplate.Impl.cs | 2 + .../Templates/OpenXmlTemplate.cs | 1 + .../Templates/TemplateImageTests.cs | 66 +++++++++++++++++++ 4 files changed, 121 insertions(+), 2 deletions(-) diff --git a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Images.cs b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Images.cs index 6846ef7e..70d413b1 100644 --- a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Images.cs +++ b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Images.cs @@ -11,6 +11,7 @@ internal partial class OpenXmlTemplate private readonly List _files = []; private readonly Dictionary _pendingImages = []; + private readonly Dictionary _capturedImages = []; private readonly Dictionary _sheetTemplateDrawings = []; private readonly Dictionary _sheetTemplateRels = []; private readonly List _createdDrawingParts = []; @@ -49,6 +50,7 @@ private void ResetImageState() { _files.Clear(); _pendingImages.Clear(); + _capturedImages.Clear(); _sheetTemplateDrawings.Clear(); _sheetTemplateRels.Clear(); _createdDrawingParts.Clear(); @@ -56,6 +58,28 @@ private void ResetImageState() _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 @@ -124,7 +148,7 @@ private bool HasImagesForSheet(int sheetIndex) /// private string CaptureAndClearImageMarkers(string rowXml, int sheetIndex) { - if (_pendingImages.Count == 0 || !rowXml.Contains(ImageMarkerPrefix)) + if ((_pendingImages.Count == 0 && _capturedImages.Count == 0) || !rowXml.Contains(ImageMarkerPrefix)) return rowXml; var rowHeightPoints = GetRowHeightPoints(rowXml); @@ -137,7 +161,7 @@ private string CaptureAndClearImageMarkers(string rowXml, int sheetIndex) var id = match.Groups["id"].Value; if (CellReferenceConverter.TryParseCellReference(match.Groups["ref"].Value, out var column, out var row) && - _pendingImages.TryGetValue(id, out var pending)) + TryResolvePendingImage(id, out var pending)) { var file = new FileDto { @@ -156,6 +180,7 @@ private string CaptureAndClearImageMarkers(string rowXml, int sheetIndex) ApplyRowHeightSize(file, pending, rowHeightPoints); _files.Add(file); + _capturedImages[id] = file; } var clearedCell = match.Value.Replace(ImageMarkerPrefix + id, string.Empty); @@ -163,6 +188,31 @@ private string CaptureAndClearImageMarkers(string rowXml, int sheetIndex) } } + /// + /// 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. diff --git a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Impl.cs b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Impl.cs index 064d7c84..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, diff --git a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs index 7d37ffcb..874e5a97 100644 --- a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs +++ b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs @@ -49,6 +49,7 @@ public async Task SaveAsByTemplateAsync(Stream templateStream, object value, Can throw new ArgumentException("The template stream must be seekable"); ResetImageState(); + using var imageStateScope = BeginImageStateScope(); templateStream.Seek(0, SeekOrigin.Begin); var templateReader = await OpenXmlReader.CreateAsync(templateStream, null, cancellationToken: cancellationToken).ConfigureAwait(false); await using var disposableTemplateReader = templateReader.ConfigureAwait(false); diff --git a/tests/MiniExcel.OpenXml.Tests/Templates/TemplateImageTests.cs b/tests/MiniExcel.OpenXml.Tests/Templates/TemplateImageTests.cs index 7b040a7f..2c7695cf 100644 --- a/tests/MiniExcel.OpenXml.Tests/Templates/TemplateImageTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/Templates/TemplateImageTests.cs @@ -1,5 +1,7 @@ +using System.Reflection; using System.Xml.Linq; using ClosedXML.Excel; +using MiniExcelLib.OpenXml.Templates; using MiniExcelLib.Tests.Common.Utils; using OfficeOpenXml.Drawing; @@ -362,6 +364,70 @@ public void CollectionWithMultipleImageColumns_ProducesDistinctParts() AssertPackageIsValidAndHasImages(path.ToString(), expectedImages: 4); } + [Fact] + public void LargeCollectionWithImages_EmitsEveryImage() + { + using var template = AutoDeletingPath.Create(); + using (var wb = new XLWorkbook()) + { + var ws = wb.AddWorksheet("Sheet1"); + ws.Cell("A1").Value = "{{Products.Name}}"; + ws.Cell("B1").Value = "{{Products.Image}}"; + wb.SaveAs(template.FilePath); + } + + var image = TestPng(); + var products = Enumerable.Range(0, 64) + .Select(i => new { Name = $"P{i}", Image = image }) + .ToArray(); + + using var path = AutoDeletingPath.Create(); + _templater.FillTemplate(path.ToString(), template.FilePath, new { Products = products }); + + // Progressive collection expansion must not drop or overwrite any image. + Assert.Equal(64, GetMediaEntries(path.ToString()).Count()); + + var sheet = GetSheetXml(path.ToString()); + Assert.DoesNotContain("@@@imageid@@@", sheet); + + AssertDrawingReferencesIntegrity(path.ToString()); + AssertPackageIsValidAndHasImages(path.ToString(), expectedImages: 64); + } + + [Fact] + public void ImageState_IsReleasedWhenTheCallCompletes() + { + using var template = AutoDeletingPath.Create(); + using (var wb = new XLWorkbook()) + { + var ws = wb.AddWorksheet("Sheet1"); + ws.Cell("A1").Value = "{{Products.Name}}"; + ws.Cell("B1").Value = "{{Products.Image}}"; + wb.SaveAs(template.FilePath); + } + + var image = TestPng(); + var products = Enumerable.Range(0, 8).Select(i => new { Name = $"P{i}", Image = image }).ToArray(); + + using var output = new MemoryStream(); + var openXmlTemplate = new OpenXmlTemplate(output, null, new OpenXmlValueExtractor()); + openXmlTemplate.SaveAsByTemplate(template.FilePath, new { Products = products }); + + // Exactly measuring peak memory is not reliable in a test; instead this checks the ownership + // invariant of the image pipeline: once the call returns, the pending, reuse and emission + // collections are empty, so the template instance no longer pins any image bytes. + var type = typeof(OpenXmlTemplate); + foreach (var fieldName in new[] { "_pendingImages", "_capturedImages", "_files" }) + { + var field = type.GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(field); + + var collection = field!.GetValue(openXmlTemplate); + Assert.NotNull(collection); + Assert.Equal(0, (int)collection!.GetType().GetProperty("Count")!.GetValue(collection)!); + } + } + [Fact] public void NullImage_DoesNotProduceImageOrPlaceholder() { From 1c1294578d8eb904e2eac7707339c9b9cc7f8a5d Mon Sep 17 00:00:00 2001 From: Enzo Samudio Date: Sat, 26 Sep 2026 11:11:15 -0300 Subject: [PATCH 7/7] Fix template image docs wording and IdSuffix comment Drop the claim that template image support has existed since v2.0.0, and describe the IdSuffix property by its actual purpose: disambiguating generated media and relationship ids when multiple image values share one anchor cell. Refs #604, #972. --- README_V2.md | 2 +- src/MiniExcel.OpenXml/Models/FileDto.cs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README_V2.md b/README_V2.md index 45e104ba..8402a915 100644 --- a/README_V2.md +++ b/README_V2.md @@ -1181,7 +1181,7 @@ Result: #### 13. Images -Since v2.0.0, when a template placeholder resolves to a `byte[]` whose bytes are a recognised image, +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: diff --git a/src/MiniExcel.OpenXml/Models/FileDto.cs b/src/MiniExcel.OpenXml/Models/FileDto.cs index 819647ce..82fd1d83 100644 --- a/src/MiniExcel.OpenXml/Models/FileDto.cs +++ b/src/MiniExcel.OpenXml/Models/FileDto.cs @@ -7,9 +7,9 @@ internal class FileDto internal int CellIndex { get; set; } /// - /// Disambiguates the derived part and relationship ids when more than one image shares the same - /// anchor cell (for example two placeholders in a single cell, or the same placeholder repeated). - /// Left unset by the regular SaveAs pipeline, which never places two images on one cell. + /// 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; }