diff --git a/README.md b/README.md index 595c82a0..a76e747a 100644 --- a/README.md +++ b/README.md @@ -1500,6 +1500,18 @@ var images = new[] }; MiniExcel.AddPicture(path, images); ``` + +To embed an image **inside** a cell (Excel 365 "Place in Cell"), set `ImgType = XlsxImgType.PlaceInCell`. The image becomes the cell value and resizes with the cell. This requires Microsoft 365; older Excel versions show `#VALUE!`. `WidthPx`, `HeightPx`, and `Location` are ignored in this mode: + +```csharp +MiniExcel.AddPicture(path, new MiniExcelPicture +{ + ImageBytes = File.ReadAllBytes("logo.png"), + CellAddress = "C3", + ImgType = XlsxImgType.PlaceInCell, +}); +``` + ![Image](https://github.com/user-attachments/assets/19c4d241-9753-4ede-96c8-f810c1a22247) #### 8. Get Sheets Dimension diff --git a/README.zh-CN.md b/README.zh-CN.md index 16bd6519..74f8f9bc 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1666,6 +1666,18 @@ var images = new[] }; MiniExcel.AddPicture(path, images); ``` + +若要真正**嵌入单元格**(Excel 365「Place in Cell」),设置 `ImgType = XlsxImgType.PlaceInCell`。图片会作为单元格值,并随单元格缩放。需要 Microsoft 365;旧版 Excel 会显示 `#VALUE!`。此模式下 `WidthPx` / `HeightPx` / `Location` 无效: + +```csharp +MiniExcel.AddPicture(path, new MiniExcelPicture +{ + ImageBytes = File.ReadAllBytes("logo.png"), + CellAddress = "C3", + ImgType = XlsxImgType.PlaceInCell, +}); +``` + ![Image](https://github.com/user-attachments/assets/19c4d241-9753-4ede-96c8-f810c1a22247) #### 8. Get Sheets Dimension diff --git a/README_V2.md b/README_V2.md index 450e1fe6..11a5ef9c 100644 --- a/README_V2.md +++ b/README_V2.md @@ -1790,9 +1790,21 @@ MiniExcelPicture[] images = }, ]; -var templater = MiniExcel.Exporters.GetOpenXmlExporter(); +var templater = MiniExcel.Templaters.GetOpenXmlTemplater(); templater.AddPicture(path, images); ``` + +To embed an image **inside** a cell (Excel 365 "Place in Cell"), set `ImgType = XlsxImgType.PlaceInCell`. The image becomes the cell value and resizes with the cell. This requires Microsoft 365; older Excel versions show `#VALUE!`. `WidthPx`, `HeightPx`, and `Location` are ignored in this mode: + +```csharp +templater.AddPicture(path, new MiniExcelPicture +{ + ImageBytes = File.ReadAllBytes("logo.png"), + CellAddress = "C3", + ImgType = XlsxImgType.PlaceInCell, +}); +``` + ![Image](https://github.com/user-attachments/assets/19c4d241-9753-4ede-96c8-f810c1a22247) #### 8. Get Sheets Dimensions diff --git a/src/MiniExcel.Core/Enums/XlsxImgType.cs b/src/MiniExcel.Core/Enums/XlsxImgType.cs index 066ff335..6a3ec603 100644 --- a/src/MiniExcel.Core/Enums/XlsxImgType.cs +++ b/src/MiniExcel.Core/Enums/XlsxImgType.cs @@ -17,7 +17,15 @@ public enum XlsxImgType AbsoluteAnchor, /// - /// The image is embedded in the cell and moves and scales with the cell (TwoCellAnchor). + /// Drawing anchored between two cells; moves and scales with the cells (TwoCellAnchor). + /// Still a floating drawing — not Excel 365 Place in Cell. Prefer for true in-cell images. /// - TwoCellAnchor + TwoCellAnchor, + + /// + /// Excel 365 "Place in Cell": the image is stored as the cell value via richData (not a drawing). + /// Requires Microsoft 365; older Excel versions show #VALUE! in the cell. + /// WidthPx, HeightPx, and Location are ignored — size follows the cell. + /// + PlaceInCell } \ No newline at end of file diff --git a/src/MiniExcel.OpenXml/OpenXmlConfiguration.cs b/src/MiniExcel.OpenXml/OpenXmlConfiguration.cs index b6f15f4d..b12a8fb7 100644 --- a/src/MiniExcel.OpenXml/OpenXmlConfiguration.cs +++ b/src/MiniExcel.OpenXml/OpenXmlConfiguration.cs @@ -14,6 +14,14 @@ public class OpenXmlConfiguration : MiniExcelBaseConfiguration public int FreezeColumnCount { get; set; } = 0; public bool EnableConvertByteArray { get; set; } = true; public bool EnableWriteFilePath{ get; set; } = true; + + /// + /// When true, byte[] image columns are written as Excel 365 Place in Cell + /// (embedded cell values via richData) instead of floating Drawing pictures. + /// Requires = true and = true. + /// Needs Microsoft 365; older Excel may show #VALUE!. + /// + public bool EmbedImagesInCell { get; set; } public bool IgnoreTemplateParameterMissing { get; set; } = true; public bool EnableWriteNullValueCell { get; set; } = true; public bool WriteEmptyStringAsNull { get; set; } = false; diff --git a/src/MiniExcel.OpenXml/Picture/OpenXmlPicture.cs b/src/MiniExcel.OpenXml/Picture/OpenXmlPicture.cs index cda090d3..934346f2 100644 --- a/src/MiniExcel.OpenXml/Picture/OpenXmlPicture.cs +++ b/src/MiniExcel.OpenXml/Picture/OpenXmlPicture.cs @@ -8,22 +8,33 @@ public class MiniExcelPicture public string? SheetName { get; set; } public string? PictureType { get; set; } public string? CellAddress { get; set; } - + + /// + /// Only takes effect when the image is in AbsoluteAnchor floating mode. + /// Ignored for . + /// + public Point Location { get; set; } + + /// + /// Drawing anchor mode, or for Excel 365 in-cell images. + /// + public XlsxImgType ImgType { get; set; } + + internal int ColumnNumber => CellReferenceConverter.TryParseCellReference(CellAddress, out var column, out _) + ? column - 1 + : throw new InvalidDataException($"Value {CellAddress} is not a valid cell reference."); + + internal int RowNumber => CellReferenceConverter.TryParseCellReference(CellAddress, out var _, out var row) + ? row - 1 + : throw new InvalidDataException($"Value {CellAddress} is not a valid cell reference."); + /// - /// Only takes effect when the image is in AbsoluteAnchor floating mode + /// Image width in pixels for drawing anchors. Ignored for . /// - public Point Location { get; set; } - public XlsxImgType ImgType { get; set; } - - internal int ColumnNumber => CellReferenceConverter.TryParseCellReference(CellAddress, out var column, out _) - ? column - 1 - : throw new InvalidDataException($"Value {CellAddress} is not a valid cell reference."); - - internal int RowNumber => CellReferenceConverter.TryParseCellReference(CellAddress, out var _, out var row) - ? row - 1 - : throw new InvalidDataException($"Value {CellAddress} is not a valid cell reference."); - - public int WidthPx { get; set; } = 80; + + /// + /// Image height in pixels for drawing anchors. Ignored for . + /// public int HeightPx { get; set; } = 24; -} \ No newline at end of file +} diff --git a/src/MiniExcel.OpenXml/Picture/OpenXmlPictureImplement.cs b/src/MiniExcel.OpenXml/Picture/OpenXmlPictureImplement.cs index 8c0a8604..d3d40058 100644 --- a/src/MiniExcel.OpenXml/Picture/OpenXmlPictureImplement.cs +++ b/src/MiniExcel.OpenXml/Picture/OpenXmlPictureImplement.cs @@ -16,12 +16,25 @@ private static XmlNamespaceManager GetRNamespaceManager(XmlDocument doc) [CreateSyncVersion] public static async Task AddPictureAsync(Stream excelStream, CancellationToken cancellationToken = default, params MiniExcelPicture[] images) { - // get sheets - var excelArchive = await OpenXmlZip.CreateAsync(excelStream, cancellationToken: cancellationToken).ConfigureAwait(false); - await using var disposableExcelArchive = excelArchive.ConfigureAwait(false); + if (images.Length == 0) + return; - var reader = await OpenXmlReader.CreateAsync(excelStream, null, false, cancellationToken).ConfigureAwait(false); - await using var dispoableReader = reader.ConfigureAwait(false); + var placeInCellImages = images.Where(i => i.ImgType == XlsxImgType.PlaceInCell).ToArray(); + var drawingImages = images.Where(i => i.ImgType != XlsxImgType.PlaceInCell).ToArray(); + + // Read sheet list first (leaveOpen so callers can keep using MemoryStream) + List sheetEntries; + { + var excelArchive = await OpenXmlZip.CreateAsync(excelStream, leaveOpen: true, cancellationToken: cancellationToken) + .ConfigureAwait(false); + await using var disposableExcelArchive = excelArchive.ConfigureAwait(false); + using var reader = OpenXmlReader.CreateFromArchive(excelArchive, null); + var rels = await OpenXmlReader.GetWorkbookRelsAsync(excelArchive.EntryCollection, cancellationToken).ConfigureAwait(false); + sheetEntries = rels?.ToList() ?? []; + } + + if (excelStream.CanSeek) + excelStream.Position = 0; #if NET10_0_OR_GREATER var archive = await ZipArchive.CreateAsync(excelStream, ZipArchiveMode.Update, true, null, cancellationToken).ConfigureAwait(false); @@ -29,11 +42,18 @@ public static async Task AddPictureAsync(Stream excelStream, CancellationToken c #else using var archive = new ZipArchive(excelStream, ZipArchiveMode.Update, true); #endif - var rels = await OpenXmlReader.GetWorkbookRelsAsync(excelArchive.EntryCollection, cancellationToken).ConfigureAwait(false); - var sheetEntries = rels?.ToList() ?? []; - // Group images by sheet - var imagesBySheet = images.GroupBy(img => img.SheetName ?? sheetEntries[0].Name); + if (placeInCellImages.Length > 0) + { + await OpenXmlPlaceInCellImplement.AddAsync(archive, sheetEntries, placeInCellImages, cancellationToken) + .ConfigureAwait(false); + } + + if (drawingImages.Length == 0) + return; + + // Group drawing images by sheet + var imagesBySheet = drawingImages.GroupBy(img => img.SheetName ?? sheetEntries[0].Name); foreach (var sheetGroup in imagesBySheet) { var sheetName = sheetGroup.Key; diff --git a/src/MiniExcel.OpenXml/Picture/OpenXmlPlaceInCellImplement.cs b/src/MiniExcel.OpenXml/Picture/OpenXmlPlaceInCellImplement.cs new file mode 100644 index 00000000..f33fc4b2 --- /dev/null +++ b/src/MiniExcel.OpenXml/Picture/OpenXmlPlaceInCellImplement.cs @@ -0,0 +1,865 @@ +using MiniExcelLib.OpenXml.Models; + +namespace MiniExcelLib.OpenXml.Picture; + +/// +/// Writes Excel 365 "Place in Cell" images via workbook richData (not drawings). +/// +internal static partial class OpenXmlPlaceInCellImplement +{ + private const string SpreadsheetMlNs = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + private const string PackageRelsNs = "http://schemas.openxmlformats.org/package/2006/relationships"; + private const string OfficeRelsNs = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + private const string ContentTypesNs = "http://schemas.openxmlformats.org/package/2006/content-types"; + private const string RichDataNs = "http://schemas.microsoft.com/office/spreadsheetml/2017/richdata"; + private const string RichData2Ns = "http://schemas.microsoft.com/office/spreadsheetml/2017/richdata2"; + private const string RichValueRelNs = "http://schemas.microsoft.com/office/spreadsheetml/2022/richvaluerel"; + private const string RelSheetMetadata = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/sheetMetadata"; + private const string RelRdRichValue = "http://schemas.microsoft.com/office/2017/06/relationships/rdRichValue"; + private const string RelRdRichValueStructure = "http://schemas.microsoft.com/office/2017/06/relationships/rdRichValueStructure"; + private const string RelRdRichValueTypes = "http://schemas.microsoft.com/office/2017/06/relationships/rdRichValueTypes"; + private const string RelRichValueRel = "http://schemas.microsoft.com/office/2022/10/relationships/richValueRel"; + private const string RelImage = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"; + + private const string MetadataPath = "xl/metadata.xml"; + private const string RdRichValuePath = "xl/richData/rdrichvalue.xml"; + private const string RdRichValueStructurePath = "xl/richData/rdrichvaluestructure.xml"; + private const string RdRichValueTypesPath = "xl/richData/rdRichValueTypes.xml"; + private const string RichValueRelPath = "xl/richData/richValueRel.xml"; + private const string RichValueRelRelsPath = "xl/richData/_rels/richValueRel.xml.rels"; + private const string WorkbookRelsPath = "xl/_rels/workbook.xml.rels"; + private const string ContentTypesPath = "[Content_Types].xml"; + + private const string RvbExtUri = "{3e2802c4-a4d2-4d8b-9148-e3be6c30e623}"; + + [CreateSyncVersion] + public static async Task AddAsync( + ZipArchive archive, + IReadOnlyList sheetEntries, + IReadOnlyList images, + CancellationToken cancellationToken = default) + { + if (images.Count == 0) + return; + + if (sheetEntries.Count == 0) + throw new InvalidOperationException("Workbook has no worksheets."); + + // Validate once up-front; collect content-type defaults without touching the zip repeatedly. + var imageFormats = new HashSet<(string Ext, string ContentType)>(); + for (var i = 0; i < images.Count; i++) + { + var image = images[i]; + if (image.ImageBytes is null || image.ImageBytes.Length == 0) + throw new InvalidDataException($"ImageBytes is required for PlaceInCell image at index {i}."); + if (string.IsNullOrWhiteSpace(image.CellAddress)) + throw new InvalidDataException($"CellAddress is required for PlaceInCell image at index {i}."); + if (!CellReferenceConverter.TryParseCellReference(image.CellAddress, out _, out _)) + throw new InvalidDataException($"Value {image.CellAddress} is not a valid cell reference."); + + imageFormats.Add(ResolveImageFormat(image.PictureType)); + } + + EnsureWorkbookRichDataRelationships(archive); + EnsureContentTypes(archive, imageFormats); + + var structureDoc = LoadOrCreateXml(archive, RdRichValueStructurePath, CreateRichValueStructureXml); + EnsureLocalImageStructure(structureDoc); + var localImageStructureIndex = GetLocalImageStructureIndex(structureDoc); + SaveXml(structureDoc, GetOrCreateEntry(archive, RdRichValueStructurePath)); + + var typesEntry = archive.GetEntry(RdRichValueTypesPath); + if (typesEntry is null) + SaveXml(CreateRichValueTypesXml(), archive.CreateEntry(RdRichValueTypesPath)); + + var rvDataDoc = LoadOrCreateXml(archive, RdRichValuePath, CreateRichValueDataXml); + var richValueRelDoc = LoadOrCreateXml(archive, RichValueRelPath, CreateRichValueRelXml); + var richValueRelsDoc = LoadOrCreateXml(archive, RichValueRelRelsPath, CreateRelationshipsXml); + var metadataDoc = LoadOrCreateXml(archive, MetadataPath, CreateMetadataXml); + + var nextMediaIndex = GetNextMediaIndex(archive); + var existingRelCount = CountChildElements(richValueRelDoc.DocumentElement, "rel"); + var existingRvCount = CountChildElements(rvDataDoc.DocumentElement, "rv"); + var nextRelIdNumber = GetMaxRelationshipIdNumber(richValueRelsDoc.DocumentElement) + 1; + EnsureXlRichValueMetadataType(metadataDoc); + var xlRichValueTypeIndex = GetMetadataTypeIndex(metadataDoc, "XLRICHVALUE"); + + var sheetDocs = new Dictionary( + StringComparer.OrdinalIgnoreCase); + + for (var i = 0; i < images.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + + var image = images[i]; + var cellAddress = image.CellAddress!.ToUpperInvariant(); + CellReferenceConverter.TryParseCellReference(cellAddress, out var column, out var row); + + var (ext, _) = ResolveImageFormat(image.PictureType); + var mediaFileName = $"image{nextMediaIndex++}.{ext}"; + var mediaPath = $"xl/media/{mediaFileName}"; + await WriteMediaAsync(archive, mediaPath, image.ImageBytes, cancellationToken).ConfigureAwait(false); + + var relIndex = existingRelCount + i; // 0-based LocalImageIdentifier + var relId = $"rId{nextRelIdNumber++}"; + var rvIndex = existingRvCount + i; // 0-based + var vm = AppendValueMetadata(metadataDoc, rvIndex, xlRichValueTypeIndex); // 1-based + + AppendRichValueRel(richValueRelDoc, relId); + AppendRelationship(richValueRelsDoc, relId, RelImage, $"../media/{mediaFileName}"); + AppendRichValue(rvDataDoc, relIndex, localImageStructureIndex); + + var sheetEnt = sheetEntries.FirstOrDefault(x => + string.Equals(x.Name, image.SheetName ?? sheetEntries[0].Name, StringComparison.OrdinalIgnoreCase)) + ?? sheetEntries[0]; + var sheetPath = ResolveSheetPath(sheetEnt); + + if (!sheetDocs.TryGetValue(sheetPath, out var sheetInfo)) + { + var sheetEntry = archive.GetEntry(sheetPath) + ?? throw new InvalidOperationException($"Worksheet part '{sheetPath}' was not found."); + sheetInfo = (LoadXml(sheetEntry), sheetEntry, column, row); + sheetDocs[sheetPath] = sheetInfo; + } + else + { + sheetInfo = ( + sheetInfo.Doc, + sheetInfo.Entry, + Math.Max(sheetInfo.MaxCol, column), + Math.Max(sheetInfo.MaxRow, row)); + sheetDocs[sheetPath] = sheetInfo; + } + + // Overwrites any existing value/formula in the target cell (Place in Cell is the cell value). + UpsertImageCell(sheetInfo.Doc, cellAddress, vm); + } + + SaveXml(rvDataDoc, GetOrCreateEntry(archive, RdRichValuePath)); + SaveXml(richValueRelDoc, GetOrCreateEntry(archive, RichValueRelPath)); + SaveXml(richValueRelsDoc, GetOrCreateEntry(archive, RichValueRelRelsPath)); + SaveXml(metadataDoc, GetOrCreateEntry(archive, MetadataPath)); + + foreach (var pair in sheetDocs) + { + ExpandDimension(pair.Value.Doc, pair.Value.MaxCol, pair.Value.MaxRow); + SaveXml(pair.Value.Doc, pair.Value.Entry); + } + } + + private static string ResolveSheetPath(SheetRecord sheetEnt) + { + if (!string.IsNullOrEmpty(sheetEnt.Path)) + { + var path = sheetEnt.Path.Replace('\\', '/'); + if (path.StartsWith("/", StringComparison.Ordinal)) + path = path.Substring(1); + if (path.StartsWith("xl/", StringComparison.OrdinalIgnoreCase)) + return path; + return $"xl/{path.TrimStart('/')}"; + } + + return $"xl/worksheets/sheet{sheetEnt.Id}.xml"; + } + + [CreateSyncVersion] + private static async Task WriteMediaAsync(ZipArchive archive, string mediaPath, byte[] imageBytes, CancellationToken cancellationToken) + { + var imageEntry = archive.GetEntry(mediaPath) ?? archive.CreateEntry(mediaPath); +#if NET8_0_OR_GREATER + var entryStream = await imageEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableStream = entryStream.ConfigureAwait(false); + entryStream.SetLength(0); + await entryStream.WriteAsync(imageBytes.AsMemory(), cancellationToken).ConfigureAwait(false); +#else + using var entryStream = imageEntry.Open(); + entryStream.SetLength(0); + await entryStream.WriteAsync(imageBytes, 0, imageBytes.Length, cancellationToken).ConfigureAwait(false); +#endif + } + + private static (string Ext, string ContentType) ResolveImageFormat(string? pictureType) + { + var type = (pictureType ?? "png").Trim().ToLowerInvariant(); + if (type.StartsWith("image/", StringComparison.Ordinal)) + type = type.Substring("image/".Length); + + return type switch + { + "jpg" or "jpeg" => ("jpeg", "image/jpeg"), + "gif" => ("gif", "image/gif"), + "bmp" => ("bmp", "image/bmp"), + "webp" => ("webp", "image/webp"), + "tif" or "tiff" => ("tiff", "image/tiff"), + _ => ("png", "image/png") + }; + } + + private static int GetNextMediaIndex(ZipArchive archive) + { + var max = 0; + foreach (var entry in archive.Entries) + { + if (!entry.FullName.StartsWith("xl/media/image", StringComparison.OrdinalIgnoreCase)) + continue; + + var name = Path.GetFileNameWithoutExtension(entry.FullName); + if (name.StartsWith("image", StringComparison.OrdinalIgnoreCase) + && int.TryParse(name.Substring("image".Length), out var index)) + { + max = Math.Max(max, index); + } + } + + return max + 1; + } + + private static void EnsureWorkbookRichDataRelationships(ZipArchive archive) + { + var entry = GetOrCreateEntry(archive, WorkbookRelsPath); + var doc = LoadOrCreateXml(archive, WorkbookRelsPath, CreateRelationshipsXml); + var root = doc.DocumentElement + ?? throw new InvalidOperationException("workbook.xml.rels is missing a root element."); + + EnsureRelationship(root, RelSheetMetadata, "metadata.xml"); + EnsureRelationship(root, RelRdRichValue, "richData/rdrichvalue.xml"); + EnsureRelationship(root, RelRdRichValueStructure, "richData/rdrichvaluestructure.xml"); + EnsureRelationship(root, RelRdRichValueTypes, "richData/rdRichValueTypes.xml"); + EnsureRelationship(root, RelRichValueRel, "richData/richValueRel.xml"); + + SaveXml(doc, entry); + } + + private static void EnsureRelationship(XmlElement root, string type, string target) + { + foreach (XmlElement rel in root.ChildNodes.OfType()) + { + if (string.Equals(rel.GetAttribute("Type"), type, StringComparison.Ordinal) + && string.Equals(rel.GetAttribute("Target"), target, StringComparison.OrdinalIgnoreCase)) + { + return; + } + } + + var nextId = GetNextRelationshipId(root); + var node = root.OwnerDocument!.CreateElement("Relationship", PackageRelsNs); + node.SetAttribute("Id", nextId); + node.SetAttribute("Type", type); + node.SetAttribute("Target", target); + root.AppendChild(node); + } + + private static string GetNextRelationshipId(XmlElement root) + => $"rId{GetMaxRelationshipIdNumber(root) + 1}"; + + /// Returns the highest numeric suffix among rIdN relationships (0 if none). + private static int GetMaxRelationshipIdNumber(XmlElement? root) + { + var max = 0; + if (root is null) + return max; + + foreach (XmlElement rel in root.ChildNodes.OfType()) + { + var id = rel.GetAttribute("Id"); + if (id.StartsWith("rId", StringComparison.OrdinalIgnoreCase) + && int.TryParse(id.Substring(3), out var n)) + { + max = Math.Max(max, n); + } + } + + return max; + } + + private static void EnsureContentTypes(ZipArchive archive, HashSet<(string Ext, string ContentType)> imageFormats) + { + var entry = archive.GetEntry(ContentTypesPath) + ?? throw new InvalidOperationException("[Content_Types].xml was not found."); + var doc = LoadXml(entry); + var root = doc.DocumentElement + ?? throw new InvalidOperationException("[Content_Types].xml is missing a root element."); + + EnsureOverride(root, "/xl/metadata.xml", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheetMetadata+xml"); + EnsureOverride(root, "/xl/richData/rdrichvalue.xml", "application/vnd.ms-excel.rdrichvalue+xml"); + EnsureOverride(root, "/xl/richData/rdrichvaluestructure.xml", "application/vnd.ms-excel.rdrichvaluestructure+xml"); + EnsureOverride(root, "/xl/richData/rdRichValueTypes.xml", "application/vnd.ms-excel.rdrichvaluetypes+xml"); + EnsureOverride(root, "/xl/richData/richValueRel.xml", "application/vnd.ms-excel.richvaluerel+xml"); + + // Single pass over Defaults for all image extensions used in this batch. + var existingDefaults = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (XmlElement child in root.ChildNodes.OfType()) + { + if (child.LocalName == "Default") + existingDefaults.Add(child.GetAttribute("Extension")); + } + + foreach (var (ext, contentType) in imageFormats) + { + if (existingDefaults.Contains(ext)) + continue; + + var node = doc.CreateElement("Default", ContentTypesNs); + node.SetAttribute("Extension", ext); + node.SetAttribute("ContentType", contentType); + root.AppendChild(node); + existingDefaults.Add(ext); + } + + // One save for Overrides + any new image Defaults. + SaveXml(doc, entry); + } + + private static void EnsureOverride(XmlElement root, string partName, string contentType) + { + foreach (XmlElement child in root.ChildNodes.OfType()) + { + if (child.LocalName == "Override" + && string.Equals(child.GetAttribute("PartName"), partName, StringComparison.OrdinalIgnoreCase)) + { + return; + } + } + + var node = root.OwnerDocument!.CreateElement("Override", ContentTypesNs); + node.SetAttribute("PartName", partName); + node.SetAttribute("ContentType", contentType); + root.AppendChild(node); + } + + private static void EnsureLocalImageStructure(XmlDocument structureDoc) + { + var root = structureDoc.DocumentElement + ?? throw new InvalidOperationException("rdrichvaluestructure.xml is missing a root element."); + + var nsMgr = CreateNs(structureDoc, "xlrd", RichDataNs); + if (root.SelectSingleNode("xlrd:s[@t='_localImage']", nsMgr) is not null) + return; + + var s = structureDoc.CreateElement("s", RichDataNs); + s.SetAttribute("t", "_localImage"); + + var k1 = structureDoc.CreateElement("k", RichDataNs); + k1.SetAttribute("n", "_rvRel:LocalImageIdentifier"); + k1.SetAttribute("t", "i"); + s.AppendChild(k1); + + var k2 = structureDoc.CreateElement("k", RichDataNs); + k2.SetAttribute("n", "CalcOrigin"); + k2.SetAttribute("t", "i"); + s.AppendChild(k2); + + root.AppendChild(s); + root.SetAttribute("count", CountChildElements(root, "s").ToString()); + } + + private static void EnsureXlRichValueMetadataType(XmlDocument metadataDoc) + { + var root = metadataDoc.DocumentElement + ?? throw new InvalidOperationException("metadata.xml is missing a root element."); + + EnsureNamespace(root, "xlrd", RichDataNs); + + var nsMgr = CreateNs(metadataDoc, "x", SpreadsheetMlNs); + var types = root.SelectSingleNode("x:metadataTypes", nsMgr) as XmlElement; + if (types is null) + { + types = metadataDoc.CreateElement("metadataTypes", SpreadsheetMlNs); + root.InsertBefore(types, root.FirstChild); + } + + if (types.SelectSingleNode("x:metadataType[@name='XLRICHVALUE']", nsMgr) is null) + { + var type = metadataDoc.CreateElement("metadataType", SpreadsheetMlNs); + type.SetAttribute("name", "XLRICHVALUE"); + type.SetAttribute("minSupportedVersion", "120000"); + type.SetAttribute("copy", "1"); + type.SetAttribute("pasteAll", "1"); + type.SetAttribute("pasteValues", "1"); + type.SetAttribute("merge", "1"); + type.SetAttribute("splitFirst", "1"); + type.SetAttribute("rowColShift", "1"); + type.SetAttribute("clearFormats", "1"); + type.SetAttribute("clearComments", "1"); + type.SetAttribute("assign", "1"); + type.SetAttribute("coerce", "1"); + types.AppendChild(type); + } + + types.SetAttribute("count", CountChildElements(types, "metadataType").ToString()); + + var future = root.SelectSingleNode("x:futureMetadata[@name='XLRICHVALUE']", nsMgr) as XmlElement; + if (future is null) + { + future = metadataDoc.CreateElement("futureMetadata", SpreadsheetMlNs); + future.SetAttribute("name", "XLRICHVALUE"); + future.SetAttribute("count", "0"); + + var valueMetadata = root.SelectSingleNode("x:valueMetadata", nsMgr); + if (valueMetadata is not null) + root.InsertBefore(future, valueMetadata); + else + root.AppendChild(future); + } + + if (root.SelectSingleNode("x:valueMetadata", nsMgr) is null) + { + var valueMetadata = metadataDoc.CreateElement("valueMetadata", SpreadsheetMlNs); + valueMetadata.SetAttribute("count", "0"); + root.AppendChild(valueMetadata); + } + } + + private static int GetMetadataTypeIndex(XmlDocument metadataDoc, string name) + { + var nsMgr = CreateNs(metadataDoc, "x", SpreadsheetMlNs); + var types = metadataDoc.SelectSingleNode("/x:metadata/x:metadataTypes", nsMgr) as XmlElement + ?? throw new InvalidOperationException("metadataTypes was not found."); + + var index = 0; + foreach (XmlElement type in types.ChildNodes.OfType().Where(e => e.LocalName == "metadataType")) + { + index++; + if (string.Equals(type.GetAttribute("name"), name, StringComparison.Ordinal)) + return index; + } + + throw new InvalidOperationException($"metadataType '{name}' was not found."); + } + + /// Appends futureMetadata + valueMetadata and returns 1-based vm index. + private static int AppendValueMetadata(XmlDocument metadataDoc, int rvIndex, int xlRichValueTypeIndex) + { + var nsMgr = CreateNs(metadataDoc, "x", SpreadsheetMlNs); + nsMgr.AddNamespace("xlrd", RichDataNs); + + var future = metadataDoc.SelectSingleNode("/x:metadata/x:futureMetadata[@name='XLRICHVALUE']", nsMgr) as XmlElement + ?? throw new InvalidOperationException("futureMetadata XLRICHVALUE was not found."); + var valueMetadata = metadataDoc.SelectSingleNode("/x:metadata/x:valueMetadata", nsMgr) as XmlElement + ?? throw new InvalidOperationException("valueMetadata was not found."); + + var xlrdPrefix = metadataDoc.DocumentElement!.GetPrefixOfNamespace(RichDataNs); + if (string.IsNullOrEmpty(xlrdPrefix)) + { + metadataDoc.DocumentElement.SetAttribute("xmlns:xlrd", RichDataNs); + xlrdPrefix = "xlrd"; + } + + var bk = metadataDoc.CreateElement("bk", SpreadsheetMlNs); + var extLst = metadataDoc.CreateElement("extLst", SpreadsheetMlNs); + var ext = metadataDoc.CreateElement("ext", SpreadsheetMlNs); + ext.SetAttribute("uri", RvbExtUri); + var rvb = metadataDoc.CreateElement(xlrdPrefix, "rvb", RichDataNs); + rvb.SetAttribute("i", rvIndex.ToString()); + ext.AppendChild(rvb); + extLst.AppendChild(ext); + bk.AppendChild(extLst); + future.AppendChild(bk); + future.SetAttribute("count", CountChildElements(future, "bk").ToString()); + + var vmBk = metadataDoc.CreateElement("bk", SpreadsheetMlNs); + var rc = metadataDoc.CreateElement("rc", SpreadsheetMlNs); + rc.SetAttribute("t", xlRichValueTypeIndex.ToString()); + rc.SetAttribute("v", rvIndex.ToString()); + vmBk.AppendChild(rc); + valueMetadata.AppendChild(vmBk); + + var vmCount = CountChildElements(valueMetadata, "bk"); + valueMetadata.SetAttribute("count", vmCount.ToString()); + return vmCount; // 1-based + } + + private static void AppendRichValue(XmlDocument rvDataDoc, int relIndex, int localImageStructureIndex) + { + var root = rvDataDoc.DocumentElement!; + var rv = rvDataDoc.CreateElement("rv", RichDataNs); + rv.SetAttribute("s", localImageStructureIndex.ToString()); + + var v1 = rvDataDoc.CreateElement("v", RichDataNs); + v1.InnerText = relIndex.ToString(); + rv.AppendChild(v1); + + var v2 = rvDataDoc.CreateElement("v", RichDataNs); + v2.InnerText = "5"; // CalcOrigin = Standalone + rv.AppendChild(v2); + + root.AppendChild(rv); + root.SetAttribute("count", CountChildElements(root, "rv").ToString()); + } + + private static int GetLocalImageStructureIndex(XmlDocument structureDoc) + { + var root = structureDoc.DocumentElement + ?? throw new InvalidOperationException("rdrichvaluestructure.xml is missing a root element."); + + var index = 0; + foreach (var s in root.ChildNodes.OfType().Where(e => e.LocalName == "s")) + { + if (string.Equals(s.GetAttribute("t"), "_localImage", StringComparison.Ordinal)) + return index; + index++; + } + + return 0; + } + + private static void AppendRichValueRel(XmlDocument richValueRelDoc, string relId) + { + var root = richValueRelDoc.DocumentElement!; + EnsureNamespace(root, "r", OfficeRelsNs); + + var rel = richValueRelDoc.CreateElement("rel", RichValueRelNs); + rel.SetAttribute("id", OfficeRelsNs, relId); + root.AppendChild(rel); + } + + private static void AppendRelationship(XmlDocument relsDoc, string relId, string type, string target) + { + var root = relsDoc.DocumentElement!; + var node = relsDoc.CreateElement("Relationship", PackageRelsNs); + node.SetAttribute("Id", relId); + node.SetAttribute("Type", type); + node.SetAttribute("Target", target); + root.AppendChild(node); + } + + /// + /// Writes an in-cell image placeholder into the sheet. + /// Replaces any existing cell value/formula; keeps style index s when present. + /// + private static void UpsertImageCell(XmlDocument sheetDoc, string cellAddress, int vm) + { + var ns = sheetDoc.DocumentElement?.NamespaceURI ?? SpreadsheetMlNs; + var nsMgr = CreateNs(sheetDoc, "x", ns); + + if (!CellReferenceConverter.TryParseCellReference(cellAddress, out _, out var rowNumber)) + throw new InvalidDataException($"Value {cellAddress} is not a valid cell reference."); + + var worksheet = sheetDoc.DocumentElement + ?? throw new InvalidOperationException("Worksheet root was not found."); + + var sheetData = worksheet.SelectSingleNode("x:sheetData", nsMgr) as XmlElement; + if (sheetData is null) + { + sheetData = sheetDoc.CreateElement("sheetData", ns); + InsertSheetData(worksheet, sheetData, nsMgr); + } + + var row = FindOrCreateRow(sheetDoc, sheetData, rowNumber, ns); + var cell = FindCell(row, cellAddress); + + if (cell is null) + { + cell = sheetDoc.CreateElement("c", ns); + cell.SetAttribute("r", cellAddress); + InsertCellInRow(row, cell, cellAddress); + } + + var style = cell.GetAttribute("s"); + cell.RemoveAllAttributes(); + cell.SetAttribute("r", cellAddress); + if (!string.IsNullOrEmpty(style)) + cell.SetAttribute("s", style); + cell.SetAttribute("t", "e"); + cell.SetAttribute("vm", vm.ToString()); + + while (cell.HasChildNodes) + cell.RemoveChild(cell.FirstChild!); + + var v = sheetDoc.CreateElement("v", ns); + v.InnerText = "#VALUE!"; + cell.AppendChild(v); + } + + /// + /// Expands worksheet dimension to include the given 1-based cell when needed (O(1)). + /// + private static void ExpandDimension(XmlDocument sheetDoc, int maxCol, int maxRow) + { + var ns = sheetDoc.DocumentElement?.NamespaceURI ?? SpreadsheetMlNs; + var nsMgr = CreateNs(sheetDoc, "x", ns); + var worksheet = sheetDoc.DocumentElement; + if (worksheet is null) + return; + + var dimension = worksheet.SelectSingleNode("x:dimension", nsMgr) as XmlElement; + var endRef = CellReferenceConverter.GetCellFromCoordinates(maxCol, maxRow); + + if (dimension is null) + { + dimension = sheetDoc.CreateElement("dimension", ns); + dimension.SetAttribute("ref", $"A1:{endRef}"); + var sheetData = worksheet.SelectSingleNode("x:sheetData", nsMgr); + if (sheetData is not null) + worksheet.InsertBefore(dimension, sheetData); + else + worksheet.AppendChild(dimension); + return; + } + + var current = dimension.GetAttribute("ref"); + if (string.IsNullOrEmpty(current)) + { + dimension.SetAttribute("ref", $"A1:{endRef}"); + return; + } + + var parts = current.Split(':'); + var start = parts[0]; + var end = parts.Length > 1 ? parts[1] : parts[0]; + + if (!CellReferenceConverter.TryParseCellReference(start, out var startCol, out var startRow) + || !CellReferenceConverter.TryParseCellReference(end, out var endCol, out var endRow)) + { + dimension.SetAttribute("ref", $"A1:{endRef}"); + return; + } + + var newEndCol = Math.Max(endCol, maxCol); + var newEndRow = Math.Max(endRow, maxRow); + if (newEndCol == endCol && newEndRow == endRow) + return; + + var newStart = CellReferenceConverter.GetCellFromCoordinates(Math.Min(startCol, maxCol), Math.Min(startRow, maxRow)); + // Keep original start if image is within/after the existing range start + if (startCol <= maxCol && startRow <= maxRow) + newStart = start; + + var newEnd = CellReferenceConverter.GetCellFromCoordinates(newEndCol, newEndRow); + dimension.SetAttribute("ref", newStart == newEnd ? newStart : $"{newStart}:{newEnd}"); + } + + private static void InsertSheetData(XmlElement worksheet, XmlElement sheetData, XmlNamespaceManager nsMgr) + { + // sheetData typically follows sheetFormatPr / cols + var after = worksheet.SelectSingleNode("x:cols", nsMgr) + ?? worksheet.SelectSingleNode("x:sheetFormatPr", nsMgr) + ?? worksheet.SelectSingleNode("x:sheetViews", nsMgr) + ?? worksheet.SelectSingleNode("x:dimension", nsMgr) + ?? worksheet.SelectSingleNode("x:sheetPr", nsMgr); + + if (after?.NextSibling is not null) + worksheet.InsertAfter(sheetData, after); + else if (after is not null) + worksheet.AppendChild(sheetData); + else if (worksheet.FirstChild is not null) + worksheet.InsertBefore(sheetData, worksheet.FirstChild); + else + worksheet.AppendChild(sheetData); + } + + private static XmlElement FindOrCreateRow(XmlDocument sheetDoc, XmlElement sheetData, int rowNumber, string ns) + { + XmlElement? insertBefore = null; + foreach (var row in sheetData.ChildNodes.OfType().Where(e => e.LocalName == "row")) + { + if (!int.TryParse(row.GetAttribute("r"), out var r)) + continue; + + if (r == rowNumber) + return row; + if (r > rowNumber) + { + insertBefore = row; + break; + } + } + + var newRow = sheetDoc.CreateElement("row", ns); + newRow.SetAttribute("r", rowNumber.ToString()); + if (insertBefore is not null) + sheetData.InsertBefore(newRow, insertBefore); + else + sheetData.AppendChild(newRow); + return newRow; + } + + private static XmlElement? FindCell(XmlElement row, string cellAddress) + { + foreach (var cell in row.ChildNodes.OfType().Where(e => e.LocalName == "c")) + { + if (string.Equals(cell.GetAttribute("r"), cellAddress, StringComparison.OrdinalIgnoreCase)) + return cell; + } + + return null; + } + + private static void InsertCellInRow(XmlElement row, XmlElement cell, string cellAddress) + { + CellReferenceConverter.TryParseCellReference(cellAddress, out var column, out _); + XmlElement? insertBefore = null; + foreach (var existing in row.ChildNodes.OfType().Where(e => e.LocalName == "c")) + { + if (!CellReferenceConverter.TryParseCellReference(existing.GetAttribute("r"), out var col, out _)) + continue; + if (col > column) + { + insertBefore = existing; + break; + } + } + + if (insertBefore is not null) + row.InsertBefore(cell, insertBefore); + else + row.AppendChild(cell); + } + + private static int CountChildElements(XmlElement? parent, string localName) + { + if (parent is null) + return 0; + return parent.ChildNodes.OfType().Count(e => e.LocalName == localName); + } + + private static void EnsureNamespace(XmlElement element, string prefix, string ns) + { + if (!string.IsNullOrEmpty(element.GetPrefixOfNamespace(ns))) + return; + element.SetAttribute($"xmlns:{prefix}", ns); + } + + private static XmlNamespaceManager CreateNs(XmlDocument doc, string prefix, string ns) + { + var mgr = new XmlNamespaceManager(doc.NameTable); + mgr.AddNamespace(prefix, ns); + return mgr; + } + + private static ZipArchiveEntry GetOrCreateEntry(ZipArchive archive, string path) + => archive.GetEntry(path) ?? archive.CreateEntry(path); + + private static XmlDocument LoadOrCreateXml(ZipArchive archive, string path, Func factory) + { + var entry = archive.GetEntry(path); + if (entry is null) + return factory(); + + // Length is unavailable after an entry was opened for write in Update mode. + try + { + if (entry.Length == 0) + return factory(); + } + catch (InvalidOperationException) + { + // Fall through and try to load from the entry stream. + } + + return LoadXml(entry); + } + + private static XmlDocument LoadXml(ZipArchiveEntry entry) + { + var doc = new XmlDocument { PreserveWhitespace = false }; + using var stream = entry.Open(); + if (stream.Length == 0) + return new XmlDocument(); + + doc.Load(stream); + return doc; + } + + private static void SaveXml(XmlDocument doc, ZipArchiveEntry entry) + { + using var stream = entry.Open(); + stream.SetLength(0); + using var writer = XmlWriter.Create(stream, new XmlWriterSettings + { + Encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + Indent = false, + OmitXmlDeclaration = false + }); + doc.Save(writer); + } + + private static XmlDocument CreateRelationshipsXml() + { + var doc = new XmlDocument(); + doc.LoadXml(""""""); + return doc; + } + + private static XmlDocument CreateRichValueStructureXml() + { + var doc = new XmlDocument(); + doc.LoadXml( + """ + + + """); + return doc; + } + + private static XmlDocument CreateRichValueDataXml() + { + var doc = new XmlDocument(); + doc.LoadXml( + """ + + + """); + return doc; + } + + private static XmlDocument CreateRichValueRelXml() + { + var doc = new XmlDocument(); + doc.LoadXml( + """ + + + """); + return doc; + } + + private static XmlDocument CreateMetadataXml() + { + var doc = new XmlDocument(); + doc.LoadXml( + """ + + + + + + """); + return doc; + } + + private static XmlDocument CreateRichValueTypesXml() + { + var doc = new XmlDocument(); + doc.LoadXml( + """ + + + + + + + + + + + + + + + + + + + + + """); + return doc; + } +} diff --git a/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs index 1f9aa619..98981e91 100644 --- a/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs +++ b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs @@ -25,6 +25,13 @@ protected OpenXmlReader(OpenXmlZip archive, IMiniExcelConfiguration? configurati _config = (OpenXmlConfiguration?)configuration ?? OpenXmlConfiguration.Default; } + /// + /// Create a reader over an already-opened archive without loading shared strings. + /// Used by AddPicture to resolve sheet paths without closing the caller's stream. + /// + internal static OpenXmlReader CreateFromArchive(OpenXmlZip archive, IMiniExcelConfiguration? configuration) + => new(archive, configuration); + [CreateSyncVersion] internal static async Task CreateAsync(Stream stream, IMiniExcelConfiguration? configuration, bool leaveOpen = false, CancellationToken cancellationToken = default) { diff --git a/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.XmlGeneration.cs b/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.XmlGeneration.cs index 29e3aa00..c98ac4ea 100644 --- a/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.XmlGeneration.cs +++ b/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.XmlGeneration.cs @@ -225,6 +225,13 @@ private string GetPanes() if (type == typeof(byte[]) && _configuration.EnableConvertByteArray) { + if (_configuration.EmbedImagesInCell) + { + // Collect image; Place in Cell richData is written after workbook/content types. + GetFileValue(rowIndex, cellIndex, value); + return (RegularCellStyleIndex, ExcelDataTypes.CalculatedString, ""); + } + if (!_configuration.EnableWriteFilePath) return (FillCellStyleIndex, ExcelDataTypes.CalculatedString, ""); @@ -419,7 +426,10 @@ private string GetDrawingXml(int sheetIndex) workbookRelsContents.AppendLine(ExcelXml.WorksheetRelationship(sheetDto)); //TODO: support multiple drawing - sheetsRelsDict.Add(sheetDto.SheetIdx, ExcelXml.DrawingRelationship(sheetId)); + //TODO: ../drawings/drawing1.xml or /xl/drawings/drawing1.xml + // Place-in-cell images do not use Drawing parts. + if (!_configuration.EmbedImagesInCell) + sheetsRelsDict.Add(sheetDto.SheetIdx, ExcelXml.DrawingRelationship(sheetId)); } return (workbookContents.ToString(), workbookRelsContents.ToString(), sheetsRelsDict); diff --git a/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.cs b/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.cs index 4f36f42d..971d069f 100644 --- a/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.cs +++ b/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.cs @@ -1,4 +1,5 @@ using MiniExcelLib.Core.WriteAdapters; +using MiniExcelLib.OpenXml.Picture; using MiniExcelLib.OpenXml.Reader; using MiniExcelLib.OpenXml.Styles.Builder; @@ -47,6 +48,10 @@ internal static async ValueTask CreateAsync(Stream stream, object if (conf is { EnableAutoWidth: true, FastMode: false }) throw new InvalidOperationException("Auto width requires fast mode to be enabled"); + // Place-in-cell rewriting needs ZipArchiveMode.Update (seekable entry rewrite). + if (conf is { EmbedImagesInCell: true, FastMode: false }) + throw new InvalidOperationException("EmbedImagesInCell requires FastMode to be enabled"); + // A. Why ZipArchiveMode.Update and not ZipArchiveMode.Create? // R. ZipArchiveEntry does not support seeking when Mode is Create. var archiveMode = conf.FastMode ? ZipArchiveMode.Update : ZipArchiveMode.Create; @@ -82,12 +87,37 @@ public async Task SaveAsAsync(IProgress? progress = null, Cancellati await styleBuilder.BuildAsync(cancellationToken).ConfigureAwait(false); - await AddFilesToZipAsync(cancellationToken).ConfigureAwait(false); - await GenerateSharedStringsAsync(cancellationToken).ConfigureAwait(false); - await GenerateDrawingRelsAsync(cancellationToken).ConfigureAwait(false); - await GenerateDrawingsAsync(cancellationToken).ConfigureAwait(false); - await GenerateWorkbookAsync(false, cancellationToken).ConfigureAwait(false); - await GenerateContentTypesAsync(cancellationToken).ConfigureAwait(false); + if (_configuration.EmbedImagesInCell) + { + // Non-image binaries still go into the package; images are written by PlaceInCell. + foreach (var item in _files.Where(f => !f.IsImage)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var entry = _archive.CreateEntry(item.Path, CompressionLevel.Fastest); + var zipStream = await entry.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableZipStream = zipStream.ConfigureAwait(false); +#if NET + await zipStream.WriteAsync(item.Contents, cancellationToken).ConfigureAwait(false); +#else + await zipStream.WriteAsync(item.Contents, 0, item.Contents.Length, cancellationToken).ConfigureAwait(false); +#endif + } + + await GenerateSharedStringsAsync(cancellationToken).ConfigureAwait(false); + await GenerateWorkbookAsync(false, cancellationToken).ConfigureAwait(false); + await GenerateContentTypesAsync(cancellationToken).ConfigureAwait(false); + await GeneratePlaceInCellImagesAsync(cancellationToken).ConfigureAwait(false); + } + else + { + await AddFilesToZipAsync(cancellationToken).ConfigureAwait(false); + await GenerateSharedStringsAsync(cancellationToken).ConfigureAwait(false); + await GenerateDrawingRelsAsync(cancellationToken).ConfigureAwait(false); + await GenerateDrawingsAsync(cancellationToken).ConfigureAwait(false); + await GenerateWorkbookAsync(false, cancellationToken).ConfigureAwait(false); + await GenerateContentTypesAsync(cancellationToken).ConfigureAwait(false); + } return rowsWritten.ToArray(); } @@ -336,7 +366,10 @@ private async Task WriteValuesAsync(MiniExcelStreamWriter writer, object va await writer.WriteAsync(WorksheetXml.Autofilter(GetDimensionRef(maxRowIndex, maxColumnIndex)), cancellationToken).ConfigureAwait(false); } - await writer.WriteAsync(WorksheetXml.Drawing(_currentSheetIndex), cancellationToken).ConfigureAwait(false); + // Place-in-cell images are cell values (richData), not Drawing anchors. + if (!_configuration.EmbedImagesInCell) + await writer.WriteAsync(WorksheetXml.Drawing(_currentSheetIndex), cancellationToken).ConfigureAwait(false); + await writer.WriteAsync(WorksheetXml.EndWorksheet, cancellationToken).ConfigureAwait(false); if (_archive.Mode == ZipArchiveMode.Update && dimensionPlaceholderPostition != 0) @@ -486,6 +519,43 @@ private async Task WriteCellAsync(MiniExcelStreamWriter writer, int rowIndex, in await writer.WriteAsync(WorksheetXml.Cell(columnReference, dataType, styleIndex, cellValue, preserveSpace: preserveSpace, columnType: columnType), cancellationToken).ConfigureAwait(false); } + [CreateSyncVersion] + private async Task GeneratePlaceInCellImagesAsync(CancellationToken cancellationToken) + { + var images = _files + .Where(f => f.IsImage) + .Select(f => + { + var sheetName = _sheets.FirstOrDefault(s => s.SheetIdx == f.SheetIndex)?.Name; + var ext = (f.Extension ?? "png").ToLowerInvariant(); + if (ext == "jpg") + ext = "jpeg"; + + return new MiniExcelPicture + { + ImageBytes = f.Contents, + CellAddress = CellReferenceConverter.GetCellFromCoordinates(f.CellIndex, f.RowIndex), + SheetName = sheetName, + ImgType = XlsxImgType.PlaceInCell, + PictureType = $"image/{ext}" + }; + }) + .ToArray(); + + if (images.Length == 0) + return; + + var sheetEntries = _sheets + .Select(s => new SheetRecord(s.Name ?? "Sheet1", s.State ?? string.Empty, (uint)s.SheetIdx, s.Id, false) + { + Path = s.Path + }) + .ToList(); + + await OpenXmlPlaceInCellImplement.AddAsync(_archive, sheetEntries, images, cancellationToken) + .ConfigureAwait(false); + } + [CreateSyncVersion] private async Task AddFilesToZipAsync(CancellationToken cancellationToken) { diff --git a/tests/MiniExcel.OpenXml.Tests/MiniExcelPlaceInCellTests.cs b/tests/MiniExcel.OpenXml.Tests/MiniExcelPlaceInCellTests.cs new file mode 100644 index 00000000..87df3548 --- /dev/null +++ b/tests/MiniExcel.OpenXml.Tests/MiniExcelPlaceInCellTests.cs @@ -0,0 +1,250 @@ +using System.Xml.Linq; +using MiniExcelLib.Core.Enums; +using MiniExcelLib.OpenXml.Picture; +using MiniExcelLib.Tests.Common.Utils; + +namespace MiniExcelLib.OpenXml.Tests; + +public class MiniExcelPlaceInCellTests +{ + private readonly OpenXmlExporter _excelExporter = MiniExcel.Exporters.GetOpenXmlExporter(); + private readonly OpenXmlTemplater _excelTemplater = MiniExcel.Templaters.GetOpenXmlTemplater(); + + [Fact] + public void PlaceInCell_OnMemoryStream_DoesNotWriteToDisk() + { + using var stream = new MemoryStream(); + _excelExporter.Export(stream, new[] { new { Name = "A" } }); + stream.Position = 0; + + var imageBytes = File.ReadAllBytes(PathHelper.GetFile("images/github_logo.png")); + _excelTemplater.AddPicture(stream, + new MiniExcelPicture + { + ImageBytes = imageBytes, + CellAddress = "C3", + ImgType = XlsxImgType.PlaceInCell, + PictureType = "image/png" + }); + + stream.Position = 0; + using var zip = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true); + + Assert.Contains(zip.Entries, e => e.FullName.StartsWith("xl/media/image", StringComparison.OrdinalIgnoreCase)); + Assert.NotNull(zip.GetEntry("xl/metadata.xml")); + Assert.NotNull(zip.GetEntry("xl/richData/rdrichvalue.xml")); + + XNamespace ns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + var sheet = XDocument.Load(zip.GetEntry("xl/worksheets/sheet1.xml")!.Open()); + var cell = sheet.Descendants(ns + "c").First(c => (string?)c.Attribute("r") == "C3"); + Assert.Equal("e", (string?)cell.Attribute("t")); + Assert.Equal("1", (string?)cell.Attribute("vm")); + Assert.Equal("#VALUE!", (string?)cell.Element(ns + "v")); + } + + [Fact] + public void PlaceInCell_WritesRichDataPartsAndCellValue() + { + using var path = AutoDeletingPath.Create(); + _excelExporter.Export(path.FilePath, new[] { new { Name = "A" } }); + + var imageBytes = File.ReadAllBytes(PathHelper.GetFile("images/github_logo.png")); + _excelTemplater.AddPicture(path.FilePath, + new MiniExcelPicture + { + ImageBytes = imageBytes, + CellAddress = "C3", + ImgType = XlsxImgType.PlaceInCell, + PictureType = "image/png" + }); + + using var zip = ZipFile.OpenRead(path.FilePath); + Assert.Contains(zip.Entries, e => e.FullName.StartsWith("xl/media/image", StringComparison.OrdinalIgnoreCase)); + Assert.NotNull(zip.GetEntry("xl/metadata.xml")); + Assert.NotNull(zip.GetEntry("xl/richData/rdrichvalue.xml")); + Assert.NotNull(zip.GetEntry("xl/richData/rdrichvaluestructure.xml")); + Assert.NotNull(zip.GetEntry("xl/richData/rdRichValueTypes.xml")); + Assert.NotNull(zip.GetEntry("xl/richData/richValueRel.xml")); + Assert.NotNull(zip.GetEntry("xl/richData/_rels/richValueRel.xml.rels")); + + var contentTypes = XDocument.Load(zip.GetEntry("[Content_Types].xml")!.Open()); + Assert.Contains(contentTypes.Root!.Elements(), + e => (string?)e.Attribute("PartName") == "/xl/metadata.xml"); + Assert.Contains(contentTypes.Root!.Elements(), + e => (string?)e.Attribute("PartName") == "/xl/richData/rdrichvalue.xml"); + + var workbookRels = XDocument.Load(zip.GetEntry("xl/_rels/workbook.xml.rels")!.Open()); + var relTypes = workbookRels.Root!.Elements() + .Select(e => (string?)e.Attribute("Type")) + .ToHashSet(StringComparer.Ordinal); + Assert.Contains("http://schemas.openxmlformats.org/officeDocument/2006/relationships/sheetMetadata", relTypes); + Assert.Contains("http://schemas.microsoft.com/office/2017/06/relationships/rdRichValue", relTypes); + Assert.Contains("http://schemas.microsoft.com/office/2022/10/relationships/richValueRel", relTypes); + + var sheet = XDocument.Load(zip.GetEntry("xl/worksheets/sheet1.xml")!.Open()); + XNamespace ns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + var cell = sheet.Descendants(ns + "c") + .FirstOrDefault(c => (string?)c.Attribute("r") == "C3"); + Assert.NotNull(cell); + Assert.Equal("e", (string?)cell.Attribute("t")); + Assert.Equal("1", (string?)cell.Attribute("vm")); + Assert.Equal("#VALUE!", (string?)cell.Element(ns + "v")); + + var dimension = sheet.Root!.Element(ns + "dimension"); + Assert.NotNull(dimension); + var dimRef = (string?)dimension.Attribute("ref"); + Assert.False(string.IsNullOrEmpty(dimRef)); + Assert.Contains("C3", dimRef, StringComparison.OrdinalIgnoreCase); + + var structure = XDocument.Load(zip.GetEntry("xl/richData/rdrichvaluestructure.xml")!.Open()); + XNamespace rd = "http://schemas.microsoft.com/office/spreadsheetml/2017/richdata"; + Assert.Contains(structure.Root!.Elements(rd + "s"), + s => (string?)s.Attribute("t") == "_localImage"); + } + + [Fact] + public void PlaceInCell_MultipleImages_IncrementVmAndRelIndexes() + { + using var path = AutoDeletingPath.Create(); + _excelExporter.Export(path.FilePath, new[] { new { Name = "A" } }); + + var img1 = File.ReadAllBytes(PathHelper.GetFile("images/github_logo.png")); + var img2 = File.ReadAllBytes(PathHelper.GetFile("images/google_logo.png")); + + _excelTemplater.AddPicture(path.FilePath, + new MiniExcelPicture + { + ImageBytes = img1, + CellAddress = "A1", + ImgType = XlsxImgType.PlaceInCell + }, + new MiniExcelPicture + { + ImageBytes = img2, + CellAddress = "B2", + ImgType = XlsxImgType.PlaceInCell + }); + + using var zip = ZipFile.OpenRead(path.FilePath); + XNamespace ns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + XNamespace rd = "http://schemas.microsoft.com/office/spreadsheetml/2017/richdata"; + XNamespace rvr = "http://schemas.microsoft.com/office/spreadsheetml/2022/richvaluerel"; + XNamespace pkg = "http://schemas.openxmlformats.org/package/2006/relationships"; + + var sheet = XDocument.Load(zip.GetEntry("xl/worksheets/sheet1.xml")!.Open()); + var a1 = sheet.Descendants(ns + "c").First(c => (string?)c.Attribute("r") == "A1"); + var b2 = sheet.Descendants(ns + "c").First(c => (string?)c.Attribute("r") == "B2"); + Assert.Equal("1", (string?)a1.Attribute("vm")); + Assert.Equal("2", (string?)b2.Attribute("vm")); + + var rvData = XDocument.Load(zip.GetEntry("xl/richData/rdrichvalue.xml")!.Open()); + var rvs = rvData.Root!.Elements(rd + "rv").ToList(); + Assert.Equal(2, rvs.Count); + Assert.Equal("0", rvs[0].Elements(rd + "v").First().Value); + Assert.Equal("5", rvs[0].Elements(rd + "v").Skip(1).First().Value); + Assert.Equal("1", rvs[1].Elements(rd + "v").First().Value); + + var richValueRel = XDocument.Load(zip.GetEntry("xl/richData/richValueRel.xml")!.Open()); + Assert.Equal(2, richValueRel.Root!.Elements(rvr + "rel").Count()); + + var rels = XDocument.Load(zip.GetEntry("xl/richData/_rels/richValueRel.xml.rels")!.Open()); + Assert.Equal(2, rels.Root!.Elements(pkg + "Relationship").Count()); + + var metadata = XDocument.Load(zip.GetEntry("xl/metadata.xml")!.Open()); + var valueMetadata = metadata.Root!.Element(ns + "valueMetadata"); + Assert.Equal("2", (string?)valueMetadata!.Attribute("count")); + } + + [Fact] + public void PlaceInCell_CanMixWithDrawingAnchor() + { + using var path = AutoDeletingPath.Create(); + _excelExporter.Export(path.FilePath, new[] { new { Name = "A" } }); + + var img1 = File.ReadAllBytes(PathHelper.GetFile("images/github_logo.png")); + var img2 = File.ReadAllBytes(PathHelper.GetFile("images/google_logo.png")); + + _excelTemplater.AddPicture(path.FilePath, + new MiniExcelPicture + { + ImageBytes = img1, + CellAddress = "C3", + ImgType = XlsxImgType.PlaceInCell + }, + new MiniExcelPicture + { + ImageBytes = img2, + CellAddress = "E5", + ImgType = XlsxImgType.OneCellAnchor, + WidthPx = 50, + HeightPx = 50 + }); + + using var zip = ZipFile.OpenRead(path.FilePath); + Assert.NotNull(zip.GetEntry("xl/metadata.xml")); + Assert.NotNull(zip.GetEntry("xl/richData/rdrichvalue.xml")); + Assert.Contains(zip.Entries, e => e.FullName.StartsWith("xl/drawings/drawing", StringComparison.OrdinalIgnoreCase)); + + XNamespace ns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + var sheet = XDocument.Load(zip.GetEntry("xl/worksheets/sheet1.xml")!.Open()); + var c3 = sheet.Descendants(ns + "c").First(c => (string?)c.Attribute("r") == "C3"); + Assert.Equal("e", (string?)c3.Attribute("t")); + Assert.NotNull(sheet.Root!.Element(ns + "drawing")); + } + + [Fact] + public void EmbedImagesInCell_ExportConfig_WritesPlaceInCellNotDrawing() + { + using var stream = new MemoryStream(); + var value = new[] + { + new { Name = "github", Image = File.ReadAllBytes(PathHelper.GetFile("images/github_logo.png")) }, + new { Name = "google", Image = File.ReadAllBytes(PathHelper.GetFile("images/google_logo.png")) }, + }; + + _excelExporter.Export(stream, value, configuration: new OpenXmlConfiguration + { + FastMode = true, + EnableConvertByteArray = true, + EmbedImagesInCell = true + }); + + stream.Position = 0; + using var zip = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true); + + Assert.NotNull(zip.GetEntry("xl/metadata.xml")); + Assert.NotNull(zip.GetEntry("xl/richData/rdrichvalue.xml")); + Assert.DoesNotContain(zip.Entries, e => e.FullName.StartsWith("xl/drawings/", StringComparison.OrdinalIgnoreCase)); + + XNamespace ns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + var sheet = XDocument.Load(zip.GetEntry("xl/worksheets/sheet1.xml")!.Open()); + Assert.Null(sheet.Root!.Element(ns + "drawing")); + + var b2 = sheet.Descendants(ns + "c").First(c => (string?)c.Attribute("r") == "B2"); + Assert.Equal("e", (string?)b2.Attribute("t")); + Assert.Equal("#VALUE!", (string?)b2.Element(ns + "v")); + } + + [Fact] + public void EmbedImagesInCell_False_KeepsFloatingDrawing() + { + using var stream = new MemoryStream(); + var value = new[] + { + new { Name = "github", Image = File.ReadAllBytes(PathHelper.GetFile("images/github_logo.png")) }, + }; + + _excelExporter.Export(stream, value, configuration: new OpenXmlConfiguration + { + FastMode = true, + EnableConvertByteArray = true, + EmbedImagesInCell = false + }); + + stream.Position = 0; + using var zip = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true); + + Assert.Null(zip.GetEntry("xl/metadata.xml")); + Assert.Contains(zip.Entries, e => e.FullName.StartsWith("xl/drawings/", StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/tests/MiniExcel.Tests.Common/Localization.Designer.cs b/tests/MiniExcel.Tests.Common/Localization.Designer.cs index 4d082a59..bfdb9e88 100644 --- a/tests/MiniExcel.Tests.Common/Localization.Designer.cs +++ b/tests/MiniExcel.Tests.Common/Localization.Designer.cs @@ -1,10 +1,10 @@ //------------------------------------------------------------------------------ // -// This code was generated by a tool. -// Runtime Version:4.0.30319.42000 +// 此代码由工具生成。 +// 运行时版本:4.0.30319.42000 // -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. +// 对此文件的更改可能会导致不正确的行为,并且如果 +// 重新生成代码,这些更改将会丢失。 // //------------------------------------------------------------------------------ @@ -13,12 +13,12 @@ namespace MiniExcelLib.Tests.Common { /// - /// A strongly-typed resource class, for looking up localized strings, etc. + /// 一个强类型的资源类,用于查找本地化的字符串等。 /// - // This class was auto-generated by the StronglyTypedResourceBuilder - // class via a tool like ResGen or Visual Studio. - // To add or remove a member, edit your .ResX file then rerun ResGen - // with the /str option, or rebuild your VS project. + // 此类是由 StronglyTypedResourceBuilder + // 类通过类似于 ResGen 或 Visual Studio 的工具自动生成的。 + // 若要添加或移除成员,请编辑 .ResX 文件,然后重新运行 ResGen + // (以 /str 作为命令选项),或重新生成 VS 项目。 [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] @@ -33,7 +33,7 @@ internal Localization() { } /// - /// Returns the cached ResourceManager instance used by this class. + /// 返回此类使用的缓存的 ResourceManager 实例。 /// [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] public static global::System.Resources.ResourceManager ResourceManager { @@ -47,8 +47,8 @@ internal Localization() { } /// - /// Overrides the current thread's CurrentUICulture property for all - /// resource lookups using this strongly typed resource class. + /// 重写当前线程的 CurrentUICulture 属性,对 + /// 使用此强类型资源类的所有资源查找执行重写。 /// [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] public static global::System.Globalization.CultureInfo Culture { @@ -61,7 +61,7 @@ internal Localization() { } /// - /// Looks up a localized string similar to Address. + /// 查找类似 Address 的本地化字符串。 /// public static string Address { get { @@ -70,7 +70,7 @@ public static string Address { } /// - /// Looks up a localized string similar to Age. + /// 查找类似 Age 的本地化字符串。 /// public static string Age { get { @@ -79,7 +79,7 @@ public static string Age { } /// - /// Looks up a localized string similar to Name. + /// 查找类似 Name 的本地化字符串。 /// public static string FirstName { get { @@ -88,7 +88,7 @@ public static string FirstName { } /// - /// Looks up a localized string similar to Surname. + /// 查找类似 Surname 的本地化字符串。 /// public static string LastName { get {