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,
+});
+```
+

#### 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,
+});
+```
+

#### 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,
+});
+```
+

#### 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": "*"
-}