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: image +#### 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]+)""[^>]*>(?:(?!).)*?@@@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(); + _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($""); 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(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..874e5a97 100644 --- a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs +++ b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs @@ -48,6 +48,8 @@ public async Task SaveAsByTemplateAsync(Stream templateStream, object value, Can if (!templateStream.CanSeek) 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); @@ -80,6 +82,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 +114,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 +174,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 +188,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 +220,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/Helpers/ImageHelperTests.cs b/tests/MiniExcel.OpenXml.Tests/Helpers/ImageHelperTests.cs new file mode 100644 index 00000000..c0427ad9 --- /dev/null +++ b/tests/MiniExcel.OpenXml.Tests/Helpers/ImageHelperTests.cs @@ -0,0 +1,153 @@ +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_ReturnsNullForTiffWithIfdOffsetNearIntegerBoundary() + { + // Little-endian TIFF ('II', 42) whose IFD offset is int.MaxValue. A naive "offset + 2" bounds + // check overflows to a negative value and lets a later out-of-range array access throw. + byte[] tiff = [(byte)'I', (byte)'I', 0x2A, 0x00, 0xFF, 0xFF, 0xFF, 0x7F]; + + Assert.Null(ImageHelper.GetImageSize(tiff)); + } + + [Fact] + public void GetImageSize_ReturnsNullForTiffWithLargeIfdOffset() + { + // 0x7FFFFFFE is still far beyond the array and must be rejected without overflow. + byte[] tiff = [(byte)'I', (byte)'I', 0x2A, 0x00, 0xFE, 0xFF, 0xFF, 0x7F]; + + Assert.Null(ImageHelper.GetImageSize(tiff)); + } + + [Fact] + public void GetImageSize_ReturnsNullForBigEndianTiffWithHugeIfdOffset() + { + // Big-endian header path (detected from the 'MM' signature) with an int.MaxValue offset. + byte[] tiff = [(byte)'M', (byte)'M', 0x2A, 0x00, 0x7F, 0xFF, 0xFF, 0xFF]; + + Assert.Null(ImageHelper.GetImageSize(tiff)); + } + + [Fact] + public void GetImageSize_ReturnsNullForTiffWhoseEntryTableExceedsTheData() + { + var tiff = new byte[16]; + tiff[0] = (byte)'I'; + tiff[1] = (byte)'I'; + tiff[2] = 0x2A; + tiff[3] = 0x00; + tiff[4] = 0x0F; // valid-looking IFD offset, but the entry table does not fit in the array + tiff[15] = 0xFF; + + Assert.Null(ImageHelper.GetImageSize(tiff)); + } + + [Fact] + public void GetImageSize_ReturnsNullForNullBytes() + => Assert.Null(ImageHelper.GetImageSize(null)); +} 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..12df0f3b --- /dev/null +++ b/tests/MiniExcel.OpenXml.Tests/Templates/TemplateImageTests.cs @@ -0,0 +1,892 @@ +using System.Reflection; +using System.Xml.Linq; +using ClosedXML.Excel; +using MiniExcelLib.OpenXml.Templates; +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); + } + + 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")!); + } + + private static byte[] OtherPng() => File.ReadAllBytes(PathHelper.GetFile("images/github_logo.png")); + + /// + /// Builds a large, unique image payload whose magic bytes classify it as a PNG. The size makes a + /// leak meaningful and the uniqueness guarantees the array is not shared with any fixture. + /// + private static byte[] CreateLargeRecognisableImage(int size = 1_000_000) + { + var image = new byte[size]; + // PNG signature: enough for ImageHelper.GetImageFormat to recognise the bytes as an image. + image[0] = 137; image[1] = 80; image[2] = 78; image[3] = 71; + image[4] = 13; image[5] = 10; image[6] = 26; image[7] = 10; + return image; + } + + private static void CollectGarbage() + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + + /// + /// Fills a template with a large image and returns only a to its + /// bytes. Every strong reference to the bytes lives and dies inside this method, so the caller + /// can verify through garbage collection that the template pipeline did not retain them. + /// + private static WeakReference FillTemplateAndTrackImageBytes(string templatePath) + { + var image = CreateLargeRecognisableImage(); + var weakReference = new WeakReference(image); + + using var output = new MemoryStream(); + var openXmlTemplate = new OpenXmlTemplate(output, null, new OpenXmlValueExtractor()); + openXmlTemplate.SaveAsByTemplate(templatePath, new { Logo = image }); + + return weakReference; + } + + 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)); + } + } + + /// + /// Resolves the drawing part a worksheet relationship points at, normalizing both the relative + /// (../drawings/..) and absolute (/xl/drawings/..) target forms. + /// + private static string GetDrawingPartForSheet(string xlsxPath, int sheetIndex) + { + using var zip = ZipFile.OpenRead(xlsxPath); + using var relsStream = zip.GetEntry($"xl/worksheets/_rels/sheet{sheetIndex}.xml.rels")!.Open(); + var target = XDocument.Load(relsStream).Descendants() + .Where(element => element.Name.LocalName == "Relationship") + .First(element => element.Attribute("Type")?.Value.EndsWith("/drawing", StringComparison.Ordinal) is true) + .Attribute("Target")!.Value + .Replace('\\', '/'); + + return target.StartsWith("../", StringComparison.Ordinal) + ? "xl/" + target[3..] + : target.TrimStart('/'); + } + + private static int GetPictureCount(string xlsxPath, string drawingPath) + { + using var zip = ZipFile.OpenRead(xlsxPath); + using var drawingStream = zip.GetEntry(drawingPath)!.Open(); + return XDocument.Load(drawingStream).Descendants() + .Count(element => element.Name.LocalName == "blip"); + } + + /// + /// Drops the xmlns:r declaration from a template worksheet that does not otherwise use the + /// relationships namespace, to exercise the generated r:id binding. + /// + private static void RemoveWorksheetRelationshipsNamespace(string xlsxPath) + { + using var zip = ZipFile.Open(xlsxPath, ZipArchiveMode.Update); + var entry = zip.GetEntry("xl/worksheets/sheet1.xml")!; + + string content; + using (var reader = new StreamReader(entry.Open())) + { + content = reader.ReadToEnd(); + } + + entry.Delete(); + + var updated = zip.CreateEntry("xl/worksheets/sheet1.xml"); + using var writer = new StreamWriter(updated.Open()); + writer.Write(content.Replace( + " xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\"", + string.Empty, + StringComparison.Ordinal)); + } + + [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 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() + { + 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 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 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 ImageBytes_AreCollectableAfterTheCallCompletes() + { + using var template = AutoDeletingPath.Create(); + using (var wb = new XLWorkbook()) + { + var ws = wb.AddWorksheet("Sheet1"); + ws.Cell("A1").Value = "{{Logo}}"; + wb.SaveAs(template.FilePath); + } + + // The helper owns every strong reference to the image bytes and hands back only a weak one. + // A leak anywhere in the template pipeline (media capture, pending/captured dictionaries, + // emission state, or the output archive) would keep the bytes alive and fail this assertion. + var weakReference = FillTemplateAndTrackImageBytes(template.FilePath); + + CollectGarbage(); + + Assert.False(weakReference.IsAlive, + "The template pipeline retained the image bytes after the call completed."); + } + + [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()); + + AssertDrawingReferencesIntegrity(path.ToString()); + } + + [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 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()); + + AssertDrawingReferencesIntegrity(path.ToString()); + } + + [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 + } + + [Fact] + public void ExistingPictureOnAnotherSheet_DoesNotReuseItsDrawingPart() + { + using var template = AutoDeletingPath.Create(); + using (var wb = new XLWorkbook()) + { + // Sheet 1 needs a generated drawing while sheet 2 already owns a template drawing + // (xl/drawings/drawing1.xml). The generated part must not reuse that filename. + var first = wb.AddWorksheet("First"); + first.Cell("A1").Value = "{{Logo}}"; + + var second = wb.AddWorksheet("Second"); + using (var stream = new MemoryStream(TestPng())) + second.AddPicture(stream).MoveTo(second.Cell("A1")); + + wb.SaveAs(template.FilePath); + } + + using var path = AutoDeletingPath.Create(); + _templater.FillTemplate(path.ToString(), template.FilePath, new { Logo = TestPng() }); + + var firstDrawing = GetDrawingPartForSheet(path.ToString(), 1); + var secondDrawing = GetDrawingPartForSheet(path.ToString(), 2); + + // Each sheet must reference its own drawing part, and both parts must exist. + Assert.NotEqual(firstDrawing, secondDrawing); + using (var zip = ZipFile.OpenRead(path.ToString())) + { + Assert.NotNull(zip.GetEntry(firstDrawing)); + Assert.NotNull(zip.GetEntry(secondDrawing)); + } + + // The template picture stays on sheet 2 and the generated image lands on sheet 1. + Assert.Equal(1, GetPictureCount(path.ToString(), firstDrawing)); + Assert.Equal(1, GetPictureCount(path.ToString(), secondDrawing)); + + AssertDrawingReferencesIntegrity(path.ToString(), firstDrawing); + AssertDrawingReferencesIntegrity(path.ToString(), secondDrawing); + + using var package = new ExcelPackage(new FileInfo(path.ToString())); + Assert.Single(package.Workbook.Worksheets[0].Drawings.OfType()); + Assert.Single(package.Workbook.Worksheets[1].Drawings.OfType()); + } + + [Fact] + public void SheetWithComment_PlacesDrawingBeforeLegacyDrawing() + { + using var template = AutoDeletingPath.Create(); + using (var wb = new XLWorkbook()) + { + var ws = wb.AddWorksheet("Sheet1"); + ws.Cell("A1").Value = "{{Logo}}"; + ws.Cell("A1").GetComment().AddText("a comment"); + 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 sheetStream = zip.GetEntry("xl/worksheets/sheet1.xml")!.Open(); + var worksheet = XDocument.Load(sheetStream).Root!; + var afterSheetData = worksheet.Element(SpreadsheetNs + "sheetData")! + .ElementsAfterSelf() + .Select(element => element.Name.LocalName) + .ToList(); + + // The worksheet schema requires before (used by comments). + Assert.Contains("drawing", afterSheetData); + Assert.Contains("legacyDrawing", afterSheetData); + Assert.True( + afterSheetData.IndexOf("drawing") < afterSheetData.IndexOf("legacyDrawing"), + $"Expected before but got: {string.Join(", ", afterSheetData)}"); + + // Both the image and the comment relationship survive. + Assert.Single(GetMediaEntries(path.ToString())); + using var relsStream = zip.GetEntry("xl/worksheets/_rels/sheet1.xml.rels")!.Open(); + var rels = XDocument.Load(relsStream).ToString(); + Assert.Contains("relationships/drawing", rels); + Assert.Contains("relationships/vmlDrawing", rels); + } + + [Fact] + public void WorksheetWithoutRelationshipsNamespace_DeclaresItForGeneratedDrawing() + { + using var template = AutoDeletingPath.Create(); + using (var wb = new XLWorkbook()) + { + var ws = wb.AddWorksheet("Sheet1"); + ws.Cell("A1").Value = "{{Logo}}"; + wb.SaveAs(template.FilePath); + } + + // A template may not declare xmlns:r at all; the generated r:id must still be bound. + RemoveWorksheetRelationshipsNamespace(template.FilePath); + + using var path = AutoDeletingPath.Create(); + _templater.FillTemplate(path.ToString(), template.FilePath, new { Logo = TestPng() }); + + var relationshipNs = XNamespace.Get("http://schemas.openxmlformats.org/officeDocument/2006/relationships"); + + using var zip = ZipFile.OpenRead(path.ToString()); + using var sheetStream = zip.GetEntry("xl/worksheets/sheet1.xml")!.Open(); + + // XDocument.Load throws on an unbound prefix, so this also proves the declaration is present. + var drawing = XDocument.Load(sheetStream).Descendants(SpreadsheetNs + "drawing").Single(); + Assert.Equal("rDrawing1", (string?)drawing.Attribute(relationshipNs + "id")); + + AssertPackageIsValidAndHasImages(path.ToString(), expectedImages: 1); + } + + [Fact] + public void MalformedTiffBytes_DoNotAbortTemplateRendering() + { + using var template = AutoDeletingPath.Create(); + using (var wb = new XLWorkbook()) + { + var ws = wb.AddWorksheet("Sheet1"); + ws.Cell("A1").Value = "{{Logo}}"; + wb.SaveAs(template.FilePath); + } + + // TIFF header whose IFD offset is int.MaxValue: the size parser must return null instead of + // overflowing, and the export must still complete. + byte[] malformedTiff = [(byte)'I', (byte)'I', 0x2A, 0x00, 0xFF, 0xFF, 0xFF, 0x7F]; + + using var path = AutoDeletingPath.Create(); + var exception = Record.Exception(() => + _templater.FillTemplate(path.ToString(), template.FilePath, new { Logo = malformedTiff })); + + Assert.Null(exception); + + // The bytes are still recognised as an image, so a picture is emitted with the default size. + Assert.Single(GetMediaEntries(path.ToString())); + Assert.DoesNotContain("System.Byte[]", GetSheetXml(path.ToString())); + } +}