diff --git a/README.md b/README.md
index 595c82a0..a76e747a 100644
--- a/README.md
+++ b/README.md
@@ -1500,6 +1500,18 @@ var images = new[]
};
MiniExcel.AddPicture(path, images);
```
+
+To embed an image **inside** a cell (Excel 365 "Place in Cell"), set `ImgType = XlsxImgType.PlaceInCell`. The image becomes the cell value and resizes with the cell. This requires Microsoft 365; older Excel versions show `#VALUE!`. `WidthPx`, `HeightPx`, and `Location` are ignored in this mode:
+
+```csharp
+MiniExcel.AddPicture(path, new MiniExcelPicture
+{
+ ImageBytes = File.ReadAllBytes("logo.png"),
+ CellAddress = "C3",
+ ImgType = XlsxImgType.PlaceInCell,
+});
+```
+

#### 8. Get Sheets Dimension
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 16bd6519..74f8f9bc 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -1666,6 +1666,18 @@ var images = new[]
};
MiniExcel.AddPicture(path, images);
```
+
+若要真正**嵌入单元格**(Excel 365「Place in Cell」),设置 `ImgType = XlsxImgType.PlaceInCell`。图片会作为单元格值,并随单元格缩放。需要 Microsoft 365;旧版 Excel 会显示 `#VALUE!`。此模式下 `WidthPx` / `HeightPx` / `Location` 无效:
+
+```csharp
+MiniExcel.AddPicture(path, new MiniExcelPicture
+{
+ ImageBytes = File.ReadAllBytes("logo.png"),
+ CellAddress = "C3",
+ ImgType = XlsxImgType.PlaceInCell,
+});
+```
+

#### 8. Get Sheets Dimension
diff --git a/README_V2.md b/README_V2.md
index 450e1fe6..11a5ef9c 100644
--- a/README_V2.md
+++ b/README_V2.md
@@ -1790,9 +1790,21 @@ MiniExcelPicture[] images =
},
];
-var templater = MiniExcel.Exporters.GetOpenXmlExporter();
+var templater = MiniExcel.Templaters.GetOpenXmlTemplater();
templater.AddPicture(path, images);
```
+
+To embed an image **inside** a cell (Excel 365 "Place in Cell"), set `ImgType = XlsxImgType.PlaceInCell`. The image becomes the cell value and resizes with the cell. This requires Microsoft 365; older Excel versions show `#VALUE!`. `WidthPx`, `HeightPx`, and `Location` are ignored in this mode:
+
+```csharp
+templater.AddPicture(path, new MiniExcelPicture
+{
+ ImageBytes = File.ReadAllBytes("logo.png"),
+ CellAddress = "C3",
+ ImgType = XlsxImgType.PlaceInCell,
+});
+```
+

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