Skip to content

Commit 46f95cd

Browse files
EnzoSamshps951023michelebastione
authored
Render byte[] template placeholders as images (#1018)
* Render byte[] values as images in templates Template placeholders that resolve to image byte[] values (root, nested or inside collections) are now emitted as embedded images, consistently with the SaveAs pipeline and reusing ImageHelper, FileDto and ExcelXml. - Resolve nested scalar paths such as {{Company.Logo}}. - Stop treating byte[] as an IEnumerable during template resolution. - Emit media, drawing and relationship parts, and declare the drawing content type so Excel does not repair the workbook. - Merge into a pre-existing drawing and worksheet rels instead of dropping them. Refs #604, #972. * docs: document template image support Explain how byte[] template placeholders are rendered as embedded images (root, nested and collections), consistently with SaveAs, and how to opt out via EnableConvertByteArray. Refs #604, #972. * Size template images to the row height Scale template images to the height of the row they are anchored to, preserving their aspect ratio, by reading the natural dimensions from the image header (PNG, JPEG, GIF, BMP and TIFF). Rows without an explicit height keep the previous default anchor size. Refs #604, #972. * Avoid duplicate picture ids when merging template drawings Base the picture id assigned to generated anchors on the highest id already present in the reused drawing instead of on the number of existing anchors, so merged images no longer clash with the template's own pictures. * Keep image parts and relationships unique per anchor Two images captured on the same template cell, for example {{Image1}} {{Image2}}, derived the same media part, relationship id and r:embed from their sheet, row and column coordinates. The second image overwrote the first and the drawing ended up with duplicate relationship ids, which Excel repairs by dropping the picture. Give every template image a unique suffix for its derived identifiers. The SaveAs id scheme is left untouched. Refs #604, #972. * Release template image bytes once they are captured Pending images kept every resolved byte[] alive until the next template run, including values that were never rendered and every image produced by a collection. Transfer ownership of the bytes to the emitted file on first capture, reuse them for repeated captures through a lightweight reference, and drop the per-sheet and per-run bookkeeping as soon as it is no longer needed. Refs #604, #972. * Fix template image docs wording and IdSuffix comment Drop the claim that template image support has existed since v2.0.0, and describe the IdSuffix property by its actual purpose: disambiguating generated media and relationship ids when multiple image values share one anchor cell. Refs #604, #972. * Fix template drawing allocation, worksheet order, namespace and TIFF bounds - Allocate generated drawing part names independently from the worksheet relationship id, so a drawing part owned by another template sheet is never reused. - Insert the generated <drawing> before the CT_Worksheet suffix elements, including the legacyDrawing used by comments. - Write the <drawing r:id> through XmlWriter so the r namespace is declared whenever the worksheet does not already declare it. - Make the TIFF bounds checks overflow-safe so malformed offsets return null instead of aborting the export. Refs #604, #972. * test: verify template image bytes are collectable after the call * Removed legacy facade test Adding a reference to the full MiniExcel project just for testing a single facade method is a bit out of place. If in the future we create a separate test project exclusively for facade methods this will be included there. * Simplified image size construction Refactored byte manipulation operations into `BinaryPrimitives` method calls for clarity * Removed regex expressions for processing of images markers The expressions were used to filter the rows containing cells with image markers, extrapolate their height, map the cell references to the relative images and finally remove the markers using named capture groups. This approach was not very readable so I changed in favor of a clearer one: parsing the xml string into a `XElement` and manipulating it to obtain the same result. * Inlined a few single-use methods * Minor cleanup - Slightly simplified image marker processing - Removed unused OpenXmlValueExtractorHook.cs file - Changed magic string data types to corresponding strong type constants where appropriate * Fixed edge case problem in image marker processing Reintroduced a more contained regex expression to make sure only the image markers and the relative image ids are replaced with empty strings --------- Co-authored-by: Wei Lin <shps951002@gmail.com> Co-authored-by: Michele Bastione <michele.bastione@gmail.com>
1 parent b310e57 commit 46f95cd

12 files changed

Lines changed: 2000 additions & 34 deletions

File tree

‎README_V2.md‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1179,6 +1179,60 @@ Result:
11791179
<img width="890" height="999" alt="image" src="https://github.com/user-attachments/assets/fae209ec-b3e2-4f2e-94e4-3b52a37dc364" />
11801180

11811181

1182+
#### 13. Images
1183+
1184+
When a template placeholder resolves to a `byte[]` whose bytes are a recognised image,
1185+
MiniExcel inserts it as a picture anchored to that cell instead of writing the value as text. The
1186+
formats detected from the bytes are PNG, JPEG, GIF, BMP and TIFF. This mirrors the behaviour of
1187+
`SaveAs`, so the datasource does not need any MiniExcel-specific type:
1188+
1189+
```csharp
1190+
public class Company
1191+
{
1192+
public string Name { get; set; }
1193+
public byte[] Logo { get; set; }
1194+
}
1195+
```
1196+
1197+
```csharp
1198+
var templater = MiniExcelV2.Templaters.GetOpenXmlTemplater();
1199+
var value = new { Company = new { Name = "MiniExcel", Logo = File.ReadAllBytes("logo.png") } };
1200+
1201+
// Template cell: {{Company.Logo}}
1202+
templater.FillTemplate(path, templatePath, value);
1203+
```
1204+
1205+
The same applies to nested paths (`{{Customer.Profile.Avatar}}`) and to collection placeholders,
1206+
where each generated row gets its own image:
1207+
1208+
```csharp
1209+
// Template cells: {{Products.Name}} and {{Products.Image}}
1210+
var templater = MiniExcelV2.Templaters.GetOpenXmlTemplater();
1211+
var value = new
1212+
{
1213+
Products = new[]
1214+
{
1215+
new { Name = "A", Image = File.ReadAllBytes("a.png") },
1216+
new { Name = "B", Image = File.ReadAllBytes("b.png") }
1217+
}
1218+
};
1219+
templater.FillTemplate(path, templatePath, value);
1220+
```
1221+
1222+
A `byte[]` that is not a recognised image keeps the previous behaviour, so existing templates are
1223+
unaffected. To disable image embedding and keep `byte[]` values as regular values, set
1224+
`EnableConvertByteArray` to `false`:
1225+
1226+
```csharp
1227+
var config = new OpenXmlConfiguration { EnableConvertByteArray = false };
1228+
templater.FillTemplate(path, templatePath, value, configuration: config);
1229+
```
1230+
1231+
Images are scaled to the height of the row they are anchored to, preserving their aspect ratio, so
1232+
setting a row height in the template controls how large the picture is rendered. Rows without an
1233+
explicit height keep a default anchor size of 64x20 pixels.
1234+
1235+
11821236
### Editing existing workbooks <a name="docs-editing" />
11831237

11841238
> Warning: this feature is a work in progress and currently very limited!

‎src/MiniExcel.Core/Helpers/ImageHelper.cs‎

Lines changed: 180 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
namespace MiniExcelLib.Core.Helpers;
1+
using BP = System.Buffers.Binary.BinaryPrimitives;
2+
3+
namespace MiniExcelLib.Core.Helpers;
24

35
public static class ImageHelper
46
{
@@ -20,7 +22,7 @@ public enum ImageFormat
2022
Unknown
2123
}
2224

23-
public static ImageFormat GetImageFormat(byte[] bytes)
25+
public static ImageFormat GetImageFormat(ReadOnlySpan<byte> bytes)
2426
{
2527
if (bytes.StartsWith(Bmp))
2628
return ImageFormat.Bmp;
@@ -39,4 +41,180 @@ public static ImageFormat GetImageFormat(byte[] bytes)
3941

4042
return ImageFormat.Unknown;
4143
}
44+
45+
/// <summary>
46+
/// Reads the pixel dimensions of an image from its header. Returns <c>null</c> when the format is
47+
/// not recognised or the header is truncated.
48+
/// </summary>
49+
public static (int Width, int Height)? GetImageSize(byte[]? bytes)
50+
{
51+
if (bytes is null or { Length: < 8 })
52+
return null;
53+
54+
if (bytes.StartsWith(Png))
55+
return GetPngSize(bytes);
56+
57+
if (bytes.StartsWith(Gif))
58+
return GetGifSize(bytes);
59+
60+
if (bytes.StartsWith(Bmp))
61+
return GetBmpSize(bytes);
62+
63+
if (bytes.StartsWith(Jpeg) || bytes.StartsWith(Jpeg2))
64+
return GetJpegSize(bytes);
65+
66+
if (bytes.StartsWith(Tiff) || bytes.StartsWith(Tiff2))
67+
return GetTiffSize(bytes);
68+
69+
return null;
70+
}
71+
72+
private static (int, int)? GetPngSize(ReadOnlySpan<byte> bytes)
73+
{
74+
// 8-byte signature, 4-byte chunk length, then the "IHDR" chunk carrying width and height as
75+
// big-endian 32-bit integers.
76+
if (bytes.Length < 24 || bytes[12] != 'I' || bytes[13] != 'H' || bytes[14] != 'D' || bytes[15] != 'R')
77+
return null;
78+
79+
var width = BP.ReadInt32BigEndian(bytes[16..]);
80+
var height = BP.ReadInt32BigEndian(bytes[20..]);
81+
return width > 0 && height > 0 ? (width, height) : null;
82+
}
83+
84+
private static (int, int)? GetGifSize(ReadOnlySpan<byte> bytes)
85+
{
86+
// Logical screen descriptor: width and height as little-endian 16-bit integers.
87+
if (bytes.Length < 10)
88+
return null;
89+
90+
var width = BP.ReadUInt16LittleEndian(bytes[6..]);
91+
var height = BP.ReadUInt16LittleEndian(bytes[8..]);
92+
return width > 0 && height > 0 ? (width, height) : null;
93+
}
94+
95+
private static (int, int)? GetBmpSize(ReadOnlySpan<byte> bytes)
96+
{
97+
if (bytes.Length < 26)
98+
return null;
99+
100+
// A BITMAPCOREHEADER stores 16-bit dimensions; the more common BITMAPINFOHEADER family uses
101+
// 32-bit ones, with a negative height meaning a top-down bitmap.
102+
if (BP.ReadInt32LittleEndian(bytes[14..]) == 12)
103+
{
104+
var coreWidth = BP.ReadUInt16LittleEndian(bytes[18..]);
105+
var coreHeight = BP.ReadUInt16LittleEndian(bytes[20..]);
106+
return coreWidth > 0 && coreHeight > 0 ? (coreWidth, coreHeight) : null;
107+
}
108+
109+
var width = BP.ReadInt32LittleEndian(bytes[18..]);
110+
var height = Math.Abs((long)BP.ReadInt32LittleEndian(bytes[22..]));
111+
return width > 0 && height is > 0 and <= int.MaxValue ? (width, (int)height) : null;
112+
}
113+
114+
private static (int, int)? GetJpegSize(ReadOnlySpan<byte> bytes)
115+
{
116+
var index = 2;
117+
while (index + 8 < bytes.Length)
118+
{
119+
if (bytes[index] != 0xFF)
120+
{
121+
index++;
122+
continue;
123+
}
124+
125+
var marker = bytes[index + 1];
126+
if (marker == 0xFF)
127+
{
128+
index++;
129+
continue;
130+
}
131+
132+
// Standalone markers (RSTn, SOI, EOI, TEM) have no payload.
133+
if (marker == 0x01 || marker is >= 0xD0 and <= 0xD9)
134+
{
135+
index += 2;
136+
continue;
137+
}
138+
139+
// Start of scan: any frame header would have been found before this point.
140+
if (marker == 0xDA)
141+
break;
142+
143+
var segmentLength = BP.ReadUInt16BigEndian(bytes[(index + 2)..]);
144+
if (segmentLength < 2)
145+
break;
146+
147+
// SOF0..SOF15, excluding DHT (C4), JPG (C8) and DAC (CC).
148+
var isFrameHeader = marker is >= 0xC0 and <= 0xCF and not (0xC4 or 0xC8 or 0xCC);
149+
if (isFrameHeader)
150+
{
151+
var height = BP.ReadUInt16BigEndian(bytes[(index + 5)..]);
152+
var width = BP.ReadUInt16BigEndian(bytes[(index + 7)..]);
153+
return width > 0 && height > 0 ? (width, height) : null;
154+
}
155+
156+
index += 2 + segmentLength;
157+
}
158+
159+
return null;
160+
}
161+
162+
private static (int, int)? GetTiffSize(ReadOnlySpan<byte> bytes)
163+
{
164+
if (bytes.Length < 8)
165+
return null;
166+
167+
// This is the endianness of the file as specified by the TIFF specification, NOT the endianness of the system
168+
var littleEndian = bytes[0] == 'I';
169+
var ifdOffset = ReadInt32(bytes, 4, littleEndian);
170+
171+
// IFD offsets come straight from the file. Compare against bytes.Length - required rather than
172+
// computing offset + required, so a crafted offset near int.MaxValue cannot overflow the check.
173+
if (ifdOffset < 8 || ifdOffset > bytes.Length - 2)
174+
return null;
175+
176+
var entryCount = ReadUInt16(bytes, ifdOffset, littleEndian);
177+
int? width = null;
178+
int? height = null;
179+
180+
for (var i = 0; i < entryCount; i++)
181+
{
182+
// Each IFD entry takes 12 bytes. Compute the offset in 64-bit space so the bound check
183+
// cannot overflow for a crafted IFD offset or entry count.
184+
var entryOffset = (long)ifdOffset + 2 + ((long)i * 12);
185+
if (entryOffset > bytes.Length - 12)
186+
break;
187+
188+
var entry = (int)entryOffset;
189+
var tag = ReadUInt16(bytes, entry, littleEndian);
190+
if (tag != 256 && tag != 257)
191+
continue;
192+
193+
var fieldType = ReadUInt16(bytes, entry + 2, littleEndian);
194+
int value;
195+
if (fieldType == 3) // SHORT
196+
value = ReadUInt16(bytes, entry + 8, littleEndian);
197+
else if (fieldType == 4) // LONG
198+
value = ReadInt32(bytes, entry + 8, littleEndian);
199+
else
200+
continue;
201+
202+
if (tag == 256)
203+
width = value;
204+
else
205+
height = value;
206+
}
207+
208+
return width is > 0 && height is > 0 ? (width.Value, height.Value) : null;
209+
}
210+
211+
private static int ReadInt32(ReadOnlySpan<byte> bytes, int offset, bool littleEndian)
212+
=> littleEndian
213+
? BP.ReadInt32LittleEndian(bytes[offset..])
214+
: BP.ReadInt32BigEndian(bytes[offset..]);
215+
216+
private static int ReadUInt16(ReadOnlySpan<byte> bytes, int offset, bool littleEndian)
217+
=> littleEndian
218+
? BP.ReadUInt16LittleEndian(bytes[offset..])
219+
: BP.ReadUInt16BigEndian(bytes[offset..]);
42220
}

‎src/MiniExcel.OpenXml/Constants/ExcelFileNames.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,6 @@ internal static class ExcelFileNames
1818
internal static string SheetRels(int sheetId) => $"xl/worksheets/_rels/sheet{sheetId}.xml.rels";
1919
internal static string Drawing(int sheetIndex) => $"xl/drawings/drawing{sheetIndex}.xml";
2020
internal static string DrawingRels(int sheetIndex) => $"xl/drawings/_rels/drawing{sheetIndex}.xml.rels";
21+
internal static string DrawingRels(string drawingFileName) => $"xl/drawings/_rels/{drawingFileName}.rels";
2122
internal static string Table(int tableIndex) => $"xl/tables/table{tableIndex}.xml";
2223
}

‎src/MiniExcel.OpenXml/Constants/ExcelXml.cs‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
internal static class ExcelXml
44
{
5+
/// <summary>Default picture anchor size used when no explicit size is provided (64x20 px).</summary>
6+
private const long DefaultImageWidthEmu = 609600;
7+
private const long DefaultImageHeightEmu = 190500;
8+
59
internal static readonly string EmptySheetXml = XmlHelper.MinifyXml("""
610
<?xml version="1.0" encoding="utf-8"?>
711
<x:worksheet xmlns:x="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
@@ -111,7 +115,10 @@ internal static string ImageRelationship(FileDto image)
111115
=> $"""<Relationship Id="{image.Id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="/{image.Path}" />""";
112116

113117
internal static string DrawingRelationship(int sheetIndex)
114-
=> $"""<Relationship Id="rDrawing{sheetIndex}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing{sheetIndex}.xml" />""";
118+
=> DrawingRelationship(sheetIndex, $"drawing{sheetIndex}.xml");
119+
120+
internal static string DrawingRelationship(int sheetIndex, string drawingFileName)
121+
=> $"""<Relationship Id="rDrawing{sheetIndex}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/{drawingFileName}" />""";
115122

116123
internal static string TableRelationship(int sheetIndex)
117124
=> $"""<Relationship Id="rTable{sheetIndex}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/table" Target="../tables/table{sheetIndex}.xml"/>""";
@@ -125,7 +132,7 @@ internal static string DrawingXml(FileDto file, int fileIndex)
125132
<xdr:row>{file.RowIndex - 1}</xdr:row>
126133
<xdr:rowOff>0</xdr:rowOff>
127134
</xdr:from>
128-
<xdr:ext cx="609600" cy="190500" />
135+
<xdr:ext cx="{file.ImageWidthEmu ?? DefaultImageWidthEmu}" cy="{file.ImageHeightEmu ?? DefaultImageHeightEmu}" />
129136
<xdr:pic>
130137
<xdr:nvPicPr>
131138
<xdr:cNvPr id="{fileIndex + 1}" descr="" name="2a3f9147-58ea-4a79-87da-7d6114c4877b" />

‎src/MiniExcel.OpenXml/Models/FileDto.cs‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,25 @@ internal class FileDto
55
internal int SheetIndex { get; set; }
66
internal int RowIndex { get; set; }
77
internal int CellIndex { get; set; }
8-
internal string Id => $"rFileId_{SheetIndex}_{RowIndex + 1}_{CellIndex + 1}";
8+
9+
/// <summary>
10+
/// Disambiguates the generated media and relationship ids when multiple image values share the same
11+
/// anchor cell. Left unset by the regular <c>SaveAs</c> pipeline, which never places two images on
12+
/// one cell.
13+
/// </summary>
14+
internal string? IdSuffix { get; set; }
15+
16+
internal string Id => string.IsNullOrEmpty(IdSuffix)
17+
? $"rFileId_{SheetIndex}_{RowIndex + 1}_{CellIndex + 1}"
18+
: $"rFileId_{SheetIndex}_{RowIndex + 1}_{CellIndex + 1}_{IdSuffix}";
919
internal string Path => $"xl/media/{Id}.{Extension}";
1020
internal bool IsImage { get; set; }
1121
internal string Extension { get; set; }
1222
internal byte[] Contents { get; set; }
23+
24+
/// <summary>
25+
/// Anchor size in EMUs. When unset, the drawing falls back to the default image size.
26+
/// </summary>
27+
internal long? ImageWidthEmu { get; set; }
28+
internal long? ImageHeightEmu { get; set; }
1329
}

0 commit comments

Comments
 (0)