From 173f37dd5f5cdbefd0502323d54769650f3d3853 Mon Sep 17 00:00:00 2001 From: swb <2399087410@qq.com> Date: Mon, 3 Aug 2026 11:06:26 +0800 Subject: [PATCH 1/4] 1 --- MiniExcel.slnx | 1 + README-V2.md | 14 +- README.md | 12 + README.zh-CN.md | 12 + src/MiniExcel.Core/Enums/XlsxImgType.cs | 9 +- src/MiniExcel.OpenXml/OpenXmlReader.cs | 7 + .../Picture/OpenXmlPictureImplement.cs | 37 +- .../Picture/OpenXmlPlaceInCellImplement.cs | 759 ++++++++++++++++++ .../MiniExcelPlaceInCellTests.cs | 188 +++++ .../Endpoints.cs | 98 +++ .../MiniExcel.PlaceInCell.MinimalApis.csproj | 25 + .../Program.cs | 22 + .../Properties/launchSettings.json | 13 + .../appsettings.json | 9 + 14 files changed, 1196 insertions(+), 10 deletions(-) create mode 100644 src/MiniExcel.OpenXml/Picture/OpenXmlPlaceInCellImplement.cs create mode 100644 tests/MiniExcel.OpenXml.Tests/MiniExcelPlaceInCellTests.cs create mode 100644 tests/MiniExcel.PlaceInCell.MinimalApis/Endpoints.cs create mode 100644 tests/MiniExcel.PlaceInCell.MinimalApis/MiniExcel.PlaceInCell.MinimalApis.csproj create mode 100644 tests/MiniExcel.PlaceInCell.MinimalApis/Program.cs create mode 100644 tests/MiniExcel.PlaceInCell.MinimalApis/Properties/launchSettings.json create mode 100644 tests/MiniExcel.PlaceInCell.MinimalApis/appsettings.json diff --git a/MiniExcel.slnx b/MiniExcel.slnx index 290ecf86..a732b3b3 100644 --- a/MiniExcel.slnx +++ b/MiniExcel.slnx @@ -36,5 +36,6 @@ + \ No newline at end of file diff --git a/README-V2.md b/README-V2.md index ccec8fcc..299efb58 100644 --- a/README-V2.md +++ b/README-V2.md @@ -1728,9 +1728,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) #### 7. Get Sheets Dimensions diff --git a/README.md b/README.md index 81d25bee..d960c6c4 100644 --- a/README.md +++ b/README.md @@ -1497,6 +1497,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) #### 7. Get Sheets Dimension diff --git a/README.zh-CN.md b/README.zh-CN.md index fc99c457..8d722813 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1662,6 +1662,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) #### 7. Get Sheets Dimension diff --git a/src/MiniExcel.Core/Enums/XlsxImgType.cs b/src/MiniExcel.Core/Enums/XlsxImgType.cs index 066ff335..d17ab7e0 100644 --- a/src/MiniExcel.Core/Enums/XlsxImgType.cs +++ b/src/MiniExcel.Core/Enums/XlsxImgType.cs @@ -19,5 +19,12 @@ public enum XlsxImgType /// /// The image is embedded in the cell and moves and scales with the cell (TwoCellAnchor). /// - 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/OpenXmlReader.cs b/src/MiniExcel.OpenXml/OpenXmlReader.cs index c623398d..70b8a7f9 100644 --- a/src/MiniExcel.OpenXml/OpenXmlReader.cs +++ b/src/MiniExcel.OpenXml/OpenXmlReader.cs @@ -27,6 +27,13 @@ private OpenXmlReader(OpenXmlZip archive, IMiniExcelConfiguration? configuration _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, CancellationToken cancellationToken = default) { diff --git a/src/MiniExcel.OpenXml/Picture/OpenXmlPictureImplement.cs b/src/MiniExcel.OpenXml/Picture/OpenXmlPictureImplement.cs index ab47642f..bc6086d0 100644 --- a/src/MiniExcel.OpenXml/Picture/OpenXmlPictureImplement.cs +++ b/src/MiniExcel.OpenXml/Picture/OpenXmlPictureImplement.cs @@ -16,11 +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; - using var reader = await OpenXmlReader.CreateAsync(excelStream, null, cancellationToken).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 reader.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); @@ -28,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 reader.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..24194d6a --- /dev/null +++ b/src/MiniExcel.OpenXml/Picture/OpenXmlPlaceInCellImplement.cs @@ -0,0 +1,759 @@ +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."); + + EnsureWorkbookRichDataRelationships(archive); + EnsureContentTypes(archive); + + 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"); + 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]; + if (string.IsNullOrWhiteSpace(image.CellAddress)) + throw new InvalidDataException("CellAddress is required for PlaceInCell images."); + + if (!CellReferenceConverter.TryParseCellReference(image.CellAddress, out _, out _)) + throw new InvalidDataException($"Value {image.CellAddress} is not a valid cell reference."); + + var cellAddress = image.CellAddress!.ToUpperInvariant(); + var (ext, contentType) = ResolveImageFormat(image.PictureType); + EnsureImageContentTypeDefault(archive, ext, contentType); + + 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 + var relId = $"rId{relIndex + 1}"; + 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); + sheetDocs[sheetPath] = sheetInfo; + } + + 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) + 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) + { + var max = 0; + 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 $"rId{max + 1}"; + } + + private static void EnsureContentTypes(ZipArchive archive) + { + 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"); + + SaveXml(doc, entry); + } + + private static void EnsureImageContentTypeDefault(ZipArchive archive, string ext, string contentType) + { + var entry = archive.GetEntry(ContentTypesPath)!; + var doc = LoadXml(entry); + var root = doc.DocumentElement!; + + var exists = root.ChildNodes.OfType() + .Any(n => n.LocalName == "Default" + && string.Equals(n.GetAttribute("Extension"), ext, StringComparison.OrdinalIgnoreCase)); + if (!exists) + { + var node = doc.CreateElement("Default", ContentTypesNs); + node.SetAttribute("Extension", ext); + node.SetAttribute("ContentType", contentType); + root.AppendChild(node); + 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); + } + + 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()); + + // clear children and set value + while (cell.HasChildNodes) + cell.RemoveChild(cell.FirstChild!); + + var v = sheetDoc.CreateElement("v", ns); + v.InnerText = "#VALUE!"; + cell.AppendChild(v); + } + + 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 || entry.Length == 0) + return factory(); + 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/tests/MiniExcel.OpenXml.Tests/MiniExcelPlaceInCellTests.cs b/tests/MiniExcel.OpenXml.Tests/MiniExcelPlaceInCellTests.cs new file mode 100644 index 00000000..f1ec637f --- /dev/null +++ b/tests/MiniExcel.OpenXml.Tests/MiniExcelPlaceInCellTests.cs @@ -0,0 +1,188 @@ +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 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")); + } +} diff --git a/tests/MiniExcel.PlaceInCell.MinimalApis/Endpoints.cs b/tests/MiniExcel.PlaceInCell.MinimalApis/Endpoints.cs new file mode 100644 index 00000000..3501713d --- /dev/null +++ b/tests/MiniExcel.PlaceInCell.MinimalApis/Endpoints.cs @@ -0,0 +1,98 @@ +using MiniExcelLib.Core; +using MiniExcelLib.Core.Enums; +using MiniExcelLib.OpenXml.Api; +using MiniExcelLib.OpenXml.Picture; +using CoreMiniExcel = MiniExcelLib.Core.MiniExcel; + +namespace MiniExcel.PlaceInCell.MinimalApis; + +internal static class Endpoints +{ + private static readonly OpenXmlExporter Exporter = CoreMiniExcel.Exporters.GetOpenXmlExporter(); + private static readonly OpenXmlTemplater Templater = CoreMiniExcel.Templaters.GetOpenXmlTemplater(); + + private static readonly string ExcelContentType = + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + + internal static RouteGroupBuilder MapPlaceInCellApi(this IEndpointRouteBuilder builder) + { + var group = builder.MapGroup("api/place-in-cell"); + + group.MapGet("stream", GenerateInMemoryAsync); + group.MapGet("save", SaveToDiskAsync); + + return group; + } + + /// + /// 不落地:内存生成 xlsx(Place in Cell),直接作为文件流下载。 + /// + private static async Task GenerateInMemoryAsync() + { + var stream = await CreatePlaceInCellWorkbookAsync().ConfigureAwait(false); + stream.Position = 0; + return Results.File(stream, ExcelContentType, "place-in-cell-stream.xlsx"); + } + + /// + /// 落地:保存 xlsx 到桌面,返回保存路径(可用 Excel 365 打开验证嵌入效果)。 + /// + private static async Task SaveToDiskAsync() + { + var desktop = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory); + var fileName = $"place-in-cell-{DateTime.Now:yyyyMMdd-HHmmss}.xlsx"; + var path = Path.Combine(desktop, fileName); + + await using (var fileStream = File.Create(path)) + { + await using var workbook = await CreatePlaceInCellWorkbookAsync().ConfigureAwait(false); + workbook.Position = 0; + await workbook.CopyToAsync(fileStream).ConfigureAwait(false); + } + + return Results.Ok(new + { + message = "已保存到桌面,请用 Microsoft 365 Excel 打开查看 Place in Cell 效果。", + path + }); + } + + private static async Task CreatePlaceInCellWorkbookAsync() + { + var stream = new MemoryStream(); + var rows = new[] + { + new { Product = "GitHub", Note = "logo in C2" }, + new { Product = "Google", Note = "logo in C3" } + }; + + await Exporter.ExportAsync(stream, rows).ConfigureAwait(false); + stream.Position = 0; + + var github = await File.ReadAllBytesAsync(Path.Combine(AppContext.BaseDirectory, "images", "github_logo.png")) + .ConfigureAwait(false); + var google = await File.ReadAllBytesAsync(Path.Combine(AppContext.BaseDirectory, "images", "google_logo.png")) + .ConfigureAwait(false); + + await Templater.AddPictureAsync( + stream, + CancellationToken.None, + new MiniExcelPicture + { + ImageBytes = github, + CellAddress = "C2", + ImgType = XlsxImgType.PlaceInCell, + PictureType = "image/png" + }, + new MiniExcelPicture + { + ImageBytes = google, + CellAddress = "C3", + ImgType = XlsxImgType.PlaceInCell, + PictureType = "image/png" + }).ConfigureAwait(false); + + stream.Position = 0; + return stream; + } +} diff --git a/tests/MiniExcel.PlaceInCell.MinimalApis/MiniExcel.PlaceInCell.MinimalApis.csproj b/tests/MiniExcel.PlaceInCell.MinimalApis/MiniExcel.PlaceInCell.MinimalApis.csproj new file mode 100644 index 00000000..7157c7a0 --- /dev/null +++ b/tests/MiniExcel.PlaceInCell.MinimalApis/MiniExcel.PlaceInCell.MinimalApis.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + MiniExcel.PlaceInCell.MinimalApis + false + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/tests/MiniExcel.PlaceInCell.MinimalApis/Program.cs b/tests/MiniExcel.PlaceInCell.MinimalApis/Program.cs new file mode 100644 index 00000000..2b5425d6 --- /dev/null +++ b/tests/MiniExcel.PlaceInCell.MinimalApis/Program.cs @@ -0,0 +1,22 @@ +using MiniExcel.PlaceInCell.MinimalApis; + +var builder = WebApplication.CreateBuilder(args); +var app = builder.Build(); + +app.MapGet("/", () => Results.Content( + """ + + PlaceInCell API + +

Place in Cell 测试接口

+ + + + """, + "text/html; charset=utf-8")); + +app.MapPlaceInCellApi(); +app.Run(); diff --git a/tests/MiniExcel.PlaceInCell.MinimalApis/Properties/launchSettings.json b/tests/MiniExcel.PlaceInCell.MinimalApis/Properties/launchSettings.json new file mode 100644 index 00000000..b67d05dd --- /dev/null +++ b/tests/MiniExcel.PlaceInCell.MinimalApis/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5188", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/tests/MiniExcel.PlaceInCell.MinimalApis/appsettings.json b/tests/MiniExcel.PlaceInCell.MinimalApis/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/tests/MiniExcel.PlaceInCell.MinimalApis/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} From 538731ffb08d1e8d80dd43f2e144ee1861a5f178 Mon Sep 17 00:00:00 2001 From: swb <2399087410@qq.com> Date: Mon, 3 Aug 2026 11:10:28 +0800 Subject: [PATCH 2/4] 1 --- .../Endpoints.cs | 112 ++++++++++-------- .../MiniExcel.PlaceInCell.MinimalApis.csproj | 56 +++++---- .../Program.cs | 21 ++-- .../Properties/launchSettings.json | 1 + 4 files changed, 104 insertions(+), 86 deletions(-) diff --git a/tests/MiniExcel.PlaceInCell.MinimalApis/Endpoints.cs b/tests/MiniExcel.PlaceInCell.MinimalApis/Endpoints.cs index 3501713d..8a538503 100644 --- a/tests/MiniExcel.PlaceInCell.MinimalApis/Endpoints.cs +++ b/tests/MiniExcel.PlaceInCell.MinimalApis/Endpoints.cs @@ -2,81 +2,101 @@ using MiniExcelLib.Core.Enums; using MiniExcelLib.OpenXml.Api; using MiniExcelLib.OpenXml.Picture; -using CoreMiniExcel = MiniExcelLib.Core.MiniExcel; namespace MiniExcel.PlaceInCell.MinimalApis; internal static class Endpoints { - private static readonly OpenXmlExporter Exporter = CoreMiniExcel.Exporters.GetOpenXmlExporter(); - private static readonly OpenXmlTemplater Templater = CoreMiniExcel.Templaters.GetOpenXmlTemplater(); + private static readonly OpenXmlExporter Exporter = MiniExcelLib.Core.MiniExcel.Exporters.GetOpenXmlExporter(); + private static readonly OpenXmlTemplater Templater = MiniExcelLib.Core.MiniExcel.Templaters.GetOpenXmlTemplater(); - private static readonly string ExcelContentType = + private const string ExcelContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; internal static RouteGroupBuilder MapPlaceInCellApi(this IEndpointRouteBuilder builder) { var group = builder.MapGroup("api/place-in-cell"); - group.MapGet("stream", GenerateInMemoryAsync); - group.MapGet("save", SaveToDiskAsync); + group.MapStreamExport(); + group.MapSaveToDisk(); return group; } /// - /// 不落地:内存生成 xlsx(Place in Cell),直接作为文件流下载。 + /// Generate xlsx in memory (no disk) and return as download stream. /// - private static async Task GenerateInMemoryAsync() + private static RouteHandlerBuilder MapStreamExport(this IEndpointRouteBuilder builder) { - var stream = await CreatePlaceInCellWorkbookAsync().ConfigureAwait(false); - stream.Position = 0; - return Results.File(stream, ExcelContentType, "place-in-cell-stream.xlsx"); + return builder.MapGet("stream", async (CancellationToken cancellationToken) => + { + var memoryStream = new MemoryStream(); + await CreatePlaceInCellWorkbookAsync(memoryStream, cancellationToken); + + memoryStream.Seek(0, SeekOrigin.Begin); + return Results.Stream(memoryStream, ExcelContentType, "place-in-cell-stream.xlsx"); + }) + .WithName("PlaceInCellStream") + .WithSummary("Generate Place in Cell xlsx in memory and download") + .WithDescription("Exports a workbook with PlaceInCell images using MemoryStream only (no file on disk).") + .Produces(StatusCodes.Status200OK, contentType: ExcelContentType); } /// - /// 落地:保存 xlsx 到桌面,返回保存路径(可用 Excel 365 打开验证嵌入效果)。 + /// Save xlsx to the desktop and return the file path. /// - private static async Task SaveToDiskAsync() + private static RouteHandlerBuilder MapSaveToDisk(this IEndpointRouteBuilder builder) { - var desktop = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory); - var fileName = $"place-in-cell-{DateTime.Now:yyyyMMdd-HHmmss}.xlsx"; - var path = Path.Combine(desktop, fileName); - - await using (var fileStream = File.Create(path)) + return builder.MapGet("save", async (CancellationToken cancellationToken) => { - await using var workbook = await CreatePlaceInCellWorkbookAsync().ConfigureAwait(false); - workbook.Position = 0; - await workbook.CopyToAsync(fileStream).ConfigureAwait(false); - } + var desktop = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory); + var path = Path.Combine(desktop, $"place-in-cell-{DateTime.Now:yyyyMMdd-HHmmss}.xlsx"); - return Results.Ok(new - { - message = "已保存到桌面,请用 Microsoft 365 Excel 打开查看 Place in Cell 效果。", - path - }); + // Prefer path-based APIs (same pattern as unit tests / OpenXmlTemplater file overloads) + var rows = CreateDemoRows(); + await Exporter.ExportAsync(path, rows, cancellationToken: cancellationToken); + + var images = await CreateDemoPicturesAsync(cancellationToken); + await Templater.AddPictureAsync(path, cancellationToken, images); + + return Results.Ok(new + { + message = "Saved to desktop. Open with Microsoft 365 Excel to verify Place in Cell.", + path + }); + }) + .WithName("PlaceInCellSave") + .WithSummary("Save Place in Cell xlsx to desktop") + .WithDescription("Writes the workbook to the desktop via file-path Export + AddPicture, then returns the saved path.") + .Produces(StatusCodes.Status200OK); } - private static async Task CreatePlaceInCellWorkbookAsync() + private static async Task CreatePlaceInCellWorkbookAsync(Stream stream, CancellationToken cancellationToken) { - var stream = new MemoryStream(); - var rows = new[] - { - new { Product = "GitHub", Note = "logo in C2" }, - new { Product = "Google", Note = "logo in C3" } - }; + var rows = CreateDemoRows(); + await Exporter.ExportAsync(stream, rows, cancellationToken: cancellationToken); + stream.Seek(0, SeekOrigin.Begin); + + var images = await CreateDemoPicturesAsync(cancellationToken); + await Templater.AddPictureAsync(stream, cancellationToken, images); + stream.Seek(0, SeekOrigin.Begin); + } - await Exporter.ExportAsync(stream, rows).ConfigureAwait(false); - stream.Position = 0; + private static object[] CreateDemoRows() => + [ + new { Product = "GitHub", Note = "logo in C2" }, + new { Product = "Google", Note = "logo in C3" } + ]; - var github = await File.ReadAllBytesAsync(Path.Combine(AppContext.BaseDirectory, "images", "github_logo.png")) - .ConfigureAwait(false); - var google = await File.ReadAllBytesAsync(Path.Combine(AppContext.BaseDirectory, "images", "google_logo.png")) - .ConfigureAwait(false); + private static async Task CreateDemoPicturesAsync(CancellationToken cancellationToken) + { + var github = await File.ReadAllBytesAsync( + Path.Combine(AppContext.BaseDirectory, "images", "github_logo.png"), cancellationToken); + var google = await File.ReadAllBytesAsync( + Path.Combine(AppContext.BaseDirectory, "images", "google_logo.png"), cancellationToken); - await Templater.AddPictureAsync( - stream, - CancellationToken.None, + return + [ new MiniExcelPicture { ImageBytes = github, @@ -90,9 +110,7 @@ await Templater.AddPictureAsync( CellAddress = "C3", ImgType = XlsxImgType.PlaceInCell, PictureType = "image/png" - }).ConfigureAwait(false); - - stream.Position = 0; - return stream; + } + ]; } } diff --git a/tests/MiniExcel.PlaceInCell.MinimalApis/MiniExcel.PlaceInCell.MinimalApis.csproj b/tests/MiniExcel.PlaceInCell.MinimalApis/MiniExcel.PlaceInCell.MinimalApis.csproj index 7157c7a0..aedff8ef 100644 --- a/tests/MiniExcel.PlaceInCell.MinimalApis/MiniExcel.PlaceInCell.MinimalApis.csproj +++ b/tests/MiniExcel.PlaceInCell.MinimalApis/MiniExcel.PlaceInCell.MinimalApis.csproj @@ -1,25 +1,31 @@ - - - - net10.0 - enable - enable - MiniExcel.PlaceInCell.MinimalApis - false - - - - - - - - - - PreserveNewest - - - PreserveNewest - - - - + + + + net10.0 + enable + enable + MiniExcel.PlaceInCell.MinimalApis + false + + $(NoWarn);NU1903 + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/tests/MiniExcel.PlaceInCell.MinimalApis/Program.cs b/tests/MiniExcel.PlaceInCell.MinimalApis/Program.cs index 2b5425d6..fd5ae6f5 100644 --- a/tests/MiniExcel.PlaceInCell.MinimalApis/Program.cs +++ b/tests/MiniExcel.PlaceInCell.MinimalApis/Program.cs @@ -1,22 +1,15 @@ using MiniExcel.PlaceInCell.MinimalApis; var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + var app = builder.Build(); -app.MapGet("/", () => Results.Content( - """ - - PlaceInCell API - -

Place in Cell 测试接口

- - - - """, - "text/html; charset=utf-8")); +app.UseSwagger(); +app.UseSwaggerUI(); +app.MapGet("/", () => Results.Redirect("/swagger")); app.MapPlaceInCellApi(); app.Run(); diff --git a/tests/MiniExcel.PlaceInCell.MinimalApis/Properties/launchSettings.json b/tests/MiniExcel.PlaceInCell.MinimalApis/Properties/launchSettings.json index b67d05dd..653f2046 100644 --- a/tests/MiniExcel.PlaceInCell.MinimalApis/Properties/launchSettings.json +++ b/tests/MiniExcel.PlaceInCell.MinimalApis/Properties/launchSettings.json @@ -4,6 +4,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, + "launchUrl": "swagger", "applicationUrl": "http://localhost:5188", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" From 32c0a727f4500f4b211e9ea49743932e783846df Mon Sep 17 00:00:00 2001 From: swb <2399087410@qq.com> Date: Mon, 3 Aug 2026 11:15:40 +0800 Subject: [PATCH 3/4] 1 --- src/MiniExcel.Core/Enums/XlsxImgType.cs | 3 +- .../Picture/OpenXmlPicture.cs | 41 +++-- .../Picture/OpenXmlPlaceInCellImplement.cs | 156 ++++++++++++++---- .../MiniExcelPlaceInCellTests.cs | 6 + 4 files changed, 159 insertions(+), 47 deletions(-) diff --git a/src/MiniExcel.Core/Enums/XlsxImgType.cs b/src/MiniExcel.Core/Enums/XlsxImgType.cs index d17ab7e0..6a3ec603 100644 --- a/src/MiniExcel.Core/Enums/XlsxImgType.cs +++ b/src/MiniExcel.Core/Enums/XlsxImgType.cs @@ -17,7 +17,8 @@ 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, diff --git a/src/MiniExcel.OpenXml/Picture/OpenXmlPicture.cs b/src/MiniExcel.OpenXml/Picture/OpenXmlPicture.cs index d0c38039..e12747e9 100644 --- a/src/MiniExcel.OpenXml/Picture/OpenXmlPicture.cs +++ b/src/MiniExcel.OpenXml/Picture/OpenXmlPicture.cs @@ -9,22 +9,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/OpenXmlPlaceInCellImplement.cs b/src/MiniExcel.OpenXml/Picture/OpenXmlPlaceInCellImplement.cs index 24194d6a..57244970 100644 --- a/src/MiniExcel.OpenXml/Picture/OpenXmlPlaceInCellImplement.cs +++ b/src/MiniExcel.OpenXml/Picture/OpenXmlPlaceInCellImplement.cs @@ -45,8 +45,23 @@ public static async Task AddAsync( 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); + EnsureContentTypes(archive, imageFormats); var structureDoc = LoadOrCreateXml(archive, RdRichValueStructurePath, CreateRichValueStructureXml); EnsureLocalImageStructure(structureDoc); @@ -65,32 +80,28 @@ public static async Task AddAsync( 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); + var sheetDocs = new Dictionary( + StringComparer.OrdinalIgnoreCase); for (var i = 0; i < images.Count; i++) { cancellationToken.ThrowIfCancellationRequested(); var image = images[i]; - if (string.IsNullOrWhiteSpace(image.CellAddress)) - throw new InvalidDataException("CellAddress is required for PlaceInCell images."); - - if (!CellReferenceConverter.TryParseCellReference(image.CellAddress, out _, out _)) - throw new InvalidDataException($"Value {image.CellAddress} is not a valid cell reference."); - var cellAddress = image.CellAddress!.ToUpperInvariant(); - var (ext, contentType) = ResolveImageFormat(image.PictureType); - EnsureImageContentTypeDefault(archive, ext, contentType); + 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 - var relId = $"rId{relIndex + 1}"; + 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 @@ -107,10 +118,20 @@ public static async Task AddAsync( { var sheetEntry = archive.GetEntry(sheetPath) ?? throw new InvalidOperationException($"Worksheet part '{sheetPath}' was not found."); - sheetInfo = (LoadXml(sheetEntry), sheetEntry); + 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); } @@ -120,7 +141,10 @@ public static async Task AddAsync( 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) @@ -226,8 +250,15 @@ private static void EnsureRelationship(XmlElement root, string type, string targ } 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"); @@ -238,10 +269,10 @@ private static string GetNextRelationshipId(XmlElement root) } } - return $"rId{max + 1}"; + return max; } - private static void EnsureContentTypes(ZipArchive archive) + 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."); @@ -256,26 +287,28 @@ private static void EnsureContentTypes(ZipArchive archive) EnsureOverride(root, "/xl/richData/rdRichValueTypes.xml", "application/vnd.ms-excel.rdrichvaluetypes+xml"); EnsureOverride(root, "/xl/richData/richValueRel.xml", "application/vnd.ms-excel.richvaluerel+xml"); - SaveXml(doc, entry); - } - - private static void EnsureImageContentTypeDefault(ZipArchive archive, string ext, string contentType) - { - var entry = archive.GetEntry(ContentTypesPath)!; - var doc = LoadXml(entry); - var root = doc.DocumentElement!; + // 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")); + } - var exists = root.ChildNodes.OfType() - .Any(n => n.LocalName == "Default" - && string.Equals(n.GetAttribute("Extension"), ext, StringComparison.OrdinalIgnoreCase)); - if (!exists) + 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); - SaveXml(doc, entry); + existingDefaults.Add(ext); } + + // One save for Overrides + any new image Defaults. + SaveXml(doc, entry); } private static void EnsureOverride(XmlElement root, string partName, string contentType) @@ -491,6 +524,10 @@ private static void AppendRelationship(XmlDocument relsDoc, string relId, string 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; @@ -509,8 +546,8 @@ private static void UpsertImageCell(XmlDocument sheetDoc, string cellAddress, in InsertSheetData(worksheet, sheetData, nsMgr); } - var row = FindOrCreateRow(sheetDoc, sheetData, rowNumber, ns); - var cell = FindCell(row, cellAddress); + var row = FindOrCreateRow(sheetDoc, sheetData, rowNumber, ns); + var cell = FindCell(row, cellAddress); if (cell is null) { @@ -527,7 +564,6 @@ private static void UpsertImageCell(XmlDocument sheetDoc, string cellAddress, in cell.SetAttribute("t", "e"); cell.SetAttribute("vm", vm.ToString()); - // clear children and set value while (cell.HasChildNodes) cell.RemoveChild(cell.FirstChild!); @@ -536,6 +572,64 @@ private static void UpsertImageCell(XmlDocument sheetDoc, string cellAddress, in 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 diff --git a/tests/MiniExcel.OpenXml.Tests/MiniExcelPlaceInCellTests.cs b/tests/MiniExcel.OpenXml.Tests/MiniExcelPlaceInCellTests.cs index f1ec637f..ad3b6612 100644 --- a/tests/MiniExcel.OpenXml.Tests/MiniExcelPlaceInCellTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/MiniExcelPlaceInCellTests.cs @@ -90,6 +90,12 @@ public void PlaceInCell_WritesRichDataPartsAndCellValue() 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"), From b30e05f439d30fb587cb0f81ca11d8e9d5ddd659 Mon Sep 17 00:00:00 2001 From: swb <2399087410@qq.com> Date: Mon, 3 Aug 2026 11:49:04 +0800 Subject: [PATCH 4/4] 1 --- MiniExcel.slnx | 1 - src/MiniExcel.OpenXml/OpenXmlConfiguration.cs | 8 ++ .../OpenXmlWriter.DefaultOpenXml.cs | 11 +- src/MiniExcel.OpenXml/OpenXmlWriter.cs | 63 +++++++++- .../Picture/OpenXmlPlaceInCellImplement.cs | 14 ++- .../MiniExcelPlaceInCellTests.cs | 56 +++++++++ .../Endpoints.cs | 116 ------------------ .../MiniExcel.PlaceInCell.MinimalApis.csproj | 31 ----- .../Program.cs | 15 --- .../Properties/launchSettings.json | 14 --- .../appsettings.json | 9 -- 11 files changed, 149 insertions(+), 189 deletions(-) delete mode 100644 tests/MiniExcel.PlaceInCell.MinimalApis/Endpoints.cs delete mode 100644 tests/MiniExcel.PlaceInCell.MinimalApis/MiniExcel.PlaceInCell.MinimalApis.csproj delete mode 100644 tests/MiniExcel.PlaceInCell.MinimalApis/Program.cs delete mode 100644 tests/MiniExcel.PlaceInCell.MinimalApis/Properties/launchSettings.json delete mode 100644 tests/MiniExcel.PlaceInCell.MinimalApis/appsettings.json diff --git a/MiniExcel.slnx b/MiniExcel.slnx index a732b3b3..290ecf86 100644 --- a/MiniExcel.slnx +++ b/MiniExcel.slnx @@ -36,6 +36,5 @@ - \ No newline at end of file diff --git a/src/MiniExcel.OpenXml/OpenXmlConfiguration.cs b/src/MiniExcel.OpenXml/OpenXmlConfiguration.cs index 9b3ceeaa..9f3d75dd 100644 --- a/src/MiniExcel.OpenXml/OpenXmlConfiguration.cs +++ b/src/MiniExcel.OpenXml/OpenXmlConfiguration.cs @@ -15,6 +15,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/OpenXmlWriter.DefaultOpenXml.cs b/src/MiniExcel.OpenXml/OpenXmlWriter.DefaultOpenXml.cs index bd7bc3ac..c94c96f8 100644 --- a/src/MiniExcel.OpenXml/OpenXmlWriter.DefaultOpenXml.cs +++ b/src/MiniExcel.OpenXml/OpenXmlWriter.DefaultOpenXml.cs @@ -209,6 +209,13 @@ private string GetPanes() if (type == typeof(byte[]) && _configuration.EnableConvertByteArray) { + if (_configuration.EmbedImagesInCell) + { + // Collect image; Place in Cell richData is written in GenerateEndXml. + GetFileValue(rowIndex, cellIndex, value); + return ("2", "str", ""); + } + if (!_configuration.EnableWriteFilePath) return ("4", "str", ""); @@ -391,7 +398,9 @@ private void GenerateWorkBookXmls( //TODO: support multiple drawing //TODO: ../drawings/drawing1.xml or /xl/drawings/drawing1.xml - sheetsRelsXml.Add(sheetDto.SheetIdx, ExcelXml.DrawingRelationship(sheetId)); + // Place-in-cell images do not use Drawing parts. + if (!_configuration.EmbedImagesInCell) + sheetsRelsXml.Add(sheetDto.SheetIdx, ExcelXml.DrawingRelationship(sheetId)); } } diff --git a/src/MiniExcel.OpenXml/OpenXmlWriter.cs b/src/MiniExcel.OpenXml/OpenXmlWriter.cs index 19ff839f..fad5141a 100644 --- a/src/MiniExcel.OpenXml/OpenXmlWriter.cs +++ b/src/MiniExcel.OpenXml/OpenXmlWriter.cs @@ -1,8 +1,10 @@ using System.ComponentModel; using System.Xml.Linq; using MiniExcelLib.Core; +using MiniExcelLib.Core.Enums; using MiniExcelLib.Core.WriteAdapters; using MiniExcelLib.OpenXml.Constants; +using MiniExcelLib.OpenXml.Picture; using MiniExcelLib.OpenXml.Styles.Builder; namespace MiniExcelLib.OpenXml; @@ -44,6 +46,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; @@ -338,7 +344,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 (_configuration.FastMode && dimensionPlaceholderPostition != 0) @@ -482,6 +491,21 @@ private async Task WriteCellAsync(MiniExcelStreamWriter writer, int rowIndex, in [CreateSyncVersion] private async Task GenerateEndXmlAsync(CancellationToken cancellationToken) { + 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(); + await CreateZipEntryAsync(item.Path, item.Byte, cancellationToken).ConfigureAwait(false); + } + + await GenerateWorkbookXmlAsync(cancellationToken).ConfigureAwait(false); + await GenerateContentTypesXmlAsync(cancellationToken).ConfigureAwait(false); + await GeneratePlaceInCellImagesAsync(cancellationToken).ConfigureAwait(false); + return; + } + await AddFilesToZipAsync(cancellationToken).ConfigureAwait(false); await GenerateDrawinRelXmlAsync(cancellationToken).ConfigureAwait(false); await GenerateDrawingXmlAsync(cancellationToken).ConfigureAwait(false); @@ -489,6 +513,43 @@ private async Task GenerateEndXmlAsync(CancellationToken cancellationToken) await GenerateContentTypesXmlAsync(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.SheetId)?.Name; + var ext = (f.Extension ?? "png").ToLowerInvariant(); + if (ext == "jpg") + ext = "jpeg"; + + return new MiniExcelPicture + { + ImageBytes = f.Byte, + 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/src/MiniExcel.OpenXml/Picture/OpenXmlPlaceInCellImplement.cs b/src/MiniExcel.OpenXml/Picture/OpenXmlPlaceInCellImplement.cs index 57244970..f33fc4b2 100644 --- a/src/MiniExcel.OpenXml/Picture/OpenXmlPlaceInCellImplement.cs +++ b/src/MiniExcel.OpenXml/Picture/OpenXmlPlaceInCellImplement.cs @@ -734,8 +734,20 @@ private static ZipArchiveEntry GetOrCreateEntry(ZipArchive archive, string path) private static XmlDocument LoadOrCreateXml(ZipArchive archive, string path, Func factory) { var entry = archive.GetEntry(path); - if (entry is null || entry.Length == 0) + 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); } diff --git a/tests/MiniExcel.OpenXml.Tests/MiniExcelPlaceInCellTests.cs b/tests/MiniExcel.OpenXml.Tests/MiniExcelPlaceInCellTests.cs index ad3b6612..87df3548 100644 --- a/tests/MiniExcel.OpenXml.Tests/MiniExcelPlaceInCellTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/MiniExcelPlaceInCellTests.cs @@ -191,4 +191,60 @@ public void PlaceInCell_CanMixWithDrawingAnchor() 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.PlaceInCell.MinimalApis/Endpoints.cs b/tests/MiniExcel.PlaceInCell.MinimalApis/Endpoints.cs deleted file mode 100644 index 8a538503..00000000 --- a/tests/MiniExcel.PlaceInCell.MinimalApis/Endpoints.cs +++ /dev/null @@ -1,116 +0,0 @@ -using MiniExcelLib.Core; -using MiniExcelLib.Core.Enums; -using MiniExcelLib.OpenXml.Api; -using MiniExcelLib.OpenXml.Picture; - -namespace MiniExcel.PlaceInCell.MinimalApis; - -internal static class Endpoints -{ - private static readonly OpenXmlExporter Exporter = MiniExcelLib.Core.MiniExcel.Exporters.GetOpenXmlExporter(); - private static readonly OpenXmlTemplater Templater = MiniExcelLib.Core.MiniExcel.Templaters.GetOpenXmlTemplater(); - - private const string ExcelContentType = - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; - - internal static RouteGroupBuilder MapPlaceInCellApi(this IEndpointRouteBuilder builder) - { - var group = builder.MapGroup("api/place-in-cell"); - - group.MapStreamExport(); - group.MapSaveToDisk(); - - return group; - } - - /// - /// Generate xlsx in memory (no disk) and return as download stream. - /// - private static RouteHandlerBuilder MapStreamExport(this IEndpointRouteBuilder builder) - { - return builder.MapGet("stream", async (CancellationToken cancellationToken) => - { - var memoryStream = new MemoryStream(); - await CreatePlaceInCellWorkbookAsync(memoryStream, cancellationToken); - - memoryStream.Seek(0, SeekOrigin.Begin); - return Results.Stream(memoryStream, ExcelContentType, "place-in-cell-stream.xlsx"); - }) - .WithName("PlaceInCellStream") - .WithSummary("Generate Place in Cell xlsx in memory and download") - .WithDescription("Exports a workbook with PlaceInCell images using MemoryStream only (no file on disk).") - .Produces(StatusCodes.Status200OK, contentType: ExcelContentType); - } - - /// - /// Save xlsx to the desktop and return the file path. - /// - private static RouteHandlerBuilder MapSaveToDisk(this IEndpointRouteBuilder builder) - { - return builder.MapGet("save", async (CancellationToken cancellationToken) => - { - var desktop = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory); - var path = Path.Combine(desktop, $"place-in-cell-{DateTime.Now:yyyyMMdd-HHmmss}.xlsx"); - - // Prefer path-based APIs (same pattern as unit tests / OpenXmlTemplater file overloads) - var rows = CreateDemoRows(); - await Exporter.ExportAsync(path, rows, cancellationToken: cancellationToken); - - var images = await CreateDemoPicturesAsync(cancellationToken); - await Templater.AddPictureAsync(path, cancellationToken, images); - - return Results.Ok(new - { - message = "Saved to desktop. Open with Microsoft 365 Excel to verify Place in Cell.", - path - }); - }) - .WithName("PlaceInCellSave") - .WithSummary("Save Place in Cell xlsx to desktop") - .WithDescription("Writes the workbook to the desktop via file-path Export + AddPicture, then returns the saved path.") - .Produces(StatusCodes.Status200OK); - } - - private static async Task CreatePlaceInCellWorkbookAsync(Stream stream, CancellationToken cancellationToken) - { - var rows = CreateDemoRows(); - await Exporter.ExportAsync(stream, rows, cancellationToken: cancellationToken); - stream.Seek(0, SeekOrigin.Begin); - - var images = await CreateDemoPicturesAsync(cancellationToken); - await Templater.AddPictureAsync(stream, cancellationToken, images); - stream.Seek(0, SeekOrigin.Begin); - } - - private static object[] CreateDemoRows() => - [ - new { Product = "GitHub", Note = "logo in C2" }, - new { Product = "Google", Note = "logo in C3" } - ]; - - private static async Task CreateDemoPicturesAsync(CancellationToken cancellationToken) - { - var github = await File.ReadAllBytesAsync( - Path.Combine(AppContext.BaseDirectory, "images", "github_logo.png"), cancellationToken); - var google = await File.ReadAllBytesAsync( - Path.Combine(AppContext.BaseDirectory, "images", "google_logo.png"), cancellationToken); - - return - [ - new MiniExcelPicture - { - ImageBytes = github, - CellAddress = "C2", - ImgType = XlsxImgType.PlaceInCell, - PictureType = "image/png" - }, - new MiniExcelPicture - { - ImageBytes = google, - CellAddress = "C3", - ImgType = XlsxImgType.PlaceInCell, - PictureType = "image/png" - } - ]; - } -} diff --git a/tests/MiniExcel.PlaceInCell.MinimalApis/MiniExcel.PlaceInCell.MinimalApis.csproj b/tests/MiniExcel.PlaceInCell.MinimalApis/MiniExcel.PlaceInCell.MinimalApis.csproj deleted file mode 100644 index aedff8ef..00000000 --- a/tests/MiniExcel.PlaceInCell.MinimalApis/MiniExcel.PlaceInCell.MinimalApis.csproj +++ /dev/null @@ -1,31 +0,0 @@ - - - - net10.0 - enable - enable - MiniExcel.PlaceInCell.MinimalApis - false - - $(NoWarn);NU1903 - - - - - - - - - - - - - - PreserveNewest - - - PreserveNewest - - - - diff --git a/tests/MiniExcel.PlaceInCell.MinimalApis/Program.cs b/tests/MiniExcel.PlaceInCell.MinimalApis/Program.cs deleted file mode 100644 index fd5ae6f5..00000000 --- a/tests/MiniExcel.PlaceInCell.MinimalApis/Program.cs +++ /dev/null @@ -1,15 +0,0 @@ -using MiniExcel.PlaceInCell.MinimalApis; - -var builder = WebApplication.CreateBuilder(args); - -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); - -var app = builder.Build(); - -app.UseSwagger(); -app.UseSwaggerUI(); - -app.MapGet("/", () => Results.Redirect("/swagger")); -app.MapPlaceInCellApi(); -app.Run(); diff --git a/tests/MiniExcel.PlaceInCell.MinimalApis/Properties/launchSettings.json b/tests/MiniExcel.PlaceInCell.MinimalApis/Properties/launchSettings.json deleted file mode 100644 index 653f2046..00000000 --- a/tests/MiniExcel.PlaceInCell.MinimalApis/Properties/launchSettings.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "swagger", - "applicationUrl": "http://localhost:5188", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/tests/MiniExcel.PlaceInCell.MinimalApis/appsettings.json b/tests/MiniExcel.PlaceInCell.MinimalApis/appsettings.json deleted file mode 100644 index 10f68b8c..00000000 --- a/tests/MiniExcel.PlaceInCell.MinimalApis/appsettings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" -}