Skip to content
54 changes: 54 additions & 0 deletions README_V2.md
Original file line number Diff line number Diff line change
Expand Up @@ -1179,6 +1179,60 @@ Result:
<img width="890" height="999" alt="image" src="https://github.com/user-attachments/assets/fae209ec-b3e2-4f2e-94e4-3b52a37dc364" />


#### 13. Images

When a template placeholder resolves to a `byte[]` whose bytes are a recognised image,
MiniExcel inserts it as a picture anchored to that cell instead of writing the value as text. The
formats detected from the bytes are PNG, JPEG, GIF, BMP and TIFF. This mirrors the behaviour of
`SaveAs`, so the datasource does not need any MiniExcel-specific type:

```csharp
public class Company
{
public string Name { get; set; }
public byte[] Logo { get; set; }
}
```

```csharp
var templater = MiniExcelV2.Templaters.GetOpenXmlTemplater();
var value = new { Company = new { Name = "MiniExcel", Logo = File.ReadAllBytes("logo.png") } };

// Template cell: {{Company.Logo}}
templater.FillTemplate(path, templatePath, value);
```

The same applies to nested paths (`{{Customer.Profile.Avatar}}`) and to collection placeholders,
where each generated row gets its own image:

```csharp
// Template cells: {{Products.Name}} and {{Products.Image}}
var templater = MiniExcelV2.Templaters.GetOpenXmlTemplater();
var value = new
{
Products = new[]
{
new { Name = "A", Image = File.ReadAllBytes("a.png") },
new { Name = "B", Image = File.ReadAllBytes("b.png") }
}
};
templater.FillTemplate(path, templatePath, value);
```

A `byte[]` that is not a recognised image keeps the previous behaviour, so existing templates are
unaffected. To disable image embedding and keep `byte[]` values as regular values, set
`EnableConvertByteArray` to `false`:

```csharp
var config = new OpenXmlConfiguration { EnableConvertByteArray = false };
templater.FillTemplate(path, templatePath, value, configuration: config);
```

Images are scaled to the height of the row they are anchored to, preserving their aspect ratio, so
setting a row height in the template controls how large the picture is rendered. Rows without an
explicit height keep a default anchor size of 64x20 pixels.


### Editing existing workbooks <a name="docs-editing" />

> Warning: this feature is a work in progress and currently very limited!
Expand Down
179 changes: 179 additions & 0 deletions src/MiniExcel.Core/Helpers/ImageHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,183 @@ public static ImageFormat GetImageFormat(byte[] bytes)

return ImageFormat.Unknown;
}

/// <summary>
/// Reads the pixel dimensions of an image from its header. Returns <c>null</c> when the format is
/// not recognised or the header is truncated.
/// </summary>
public static (int Width, int Height)? GetImageSize(byte[]? bytes)
{
if (bytes is null || bytes.Length < 8)
return null;

if (bytes.StartsWith(Png))
return GetPngSize(bytes);

if (bytes.StartsWith(Gif))
return GetGifSize(bytes);

if (bytes.StartsWith(Bmp))
return GetBmpSize(bytes);

if (bytes.StartsWith(Jpeg) || bytes.StartsWith(Jpeg2))
return GetJpegSize(bytes);

if (bytes.StartsWith(Tiff) || bytes.StartsWith(Tiff2))
return GetTiffSize(bytes);

return null;
}

private static (int, int)? GetPngSize(byte[] bytes)
{
// 8-byte signature, 4-byte chunk length, then the "IHDR" chunk carrying width and height as
// big-endian 32-bit integers.
if (bytes.Length < 24 || bytes[12] != 'I' || bytes[13] != 'H' || bytes[14] != 'D' || bytes[15] != 'R')
return null;

var width = ReadInt32BigEndian(bytes, 16);
var height = ReadInt32BigEndian(bytes, 20);
return width > 0 && height > 0 ? (width, height) : null;
}

private static (int, int)? GetGifSize(byte[] bytes)
{
// Logical screen descriptor: width and height as little-endian 16-bit integers.
if (bytes.Length < 10)
return null;

var width = bytes[6] | (bytes[7] << 8);
var height = bytes[8] | (bytes[9] << 8);
return width > 0 && height > 0 ? (width, height) : null;
}

private static (int, int)? GetBmpSize(byte[] bytes)
{
if (bytes.Length < 26)
return null;

// A BITMAPCOREHEADER stores 16-bit dimensions; the more common BITMAPINFOHEADER family uses
// 32-bit ones, with a negative height meaning a top-down bitmap.
if (ReadInt32LittleEndian(bytes, 14) == 12)
{
var coreWidth = bytes[18] | (bytes[19] << 8);
var coreHeight = bytes[20] | (bytes[21] << 8);
return coreWidth > 0 && coreHeight > 0 ? (coreWidth, coreHeight) : null;
}

var width = ReadInt32LittleEndian(bytes, 18);
var height = Math.Abs((long)ReadInt32LittleEndian(bytes, 22));
return width > 0 && height is > 0 and <= int.MaxValue ? (width, (int)height) : null;
}

private static (int, int)? GetJpegSize(byte[] bytes)
{
var index = 2;
while (index + 8 < bytes.Length)
{
if (bytes[index] != 0xFF)
{
index++;
continue;
}

var marker = bytes[index + 1];
if (marker == 0xFF)
{
index++;
continue;
}

// Standalone markers (RSTn, SOI, EOI, TEM) have no payload.
if (marker == 0x01 || marker is >= 0xD0 and <= 0xD9)
{
index += 2;
continue;
}

// Start of scan: any frame header would have been found before this point.
if (marker == 0xDA)
break;

var segmentLength = (bytes[index + 2] << 8) | bytes[index + 3];
if (segmentLength < 2)
break;

// SOF0..SOF15, excluding DHT (C4), JPG (C8) and DAC (CC).
var isFrameHeader = marker is >= 0xC0 and <= 0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC;
if (isFrameHeader)
{
var height = (bytes[index + 5] << 8) | bytes[index + 6];
var width = (bytes[index + 7] << 8) | bytes[index + 8];
return width > 0 && height > 0 ? (width, height) : null;
}

index += 2 + segmentLength;
}

return null;
}

private static (int, int)? GetTiffSize(byte[] bytes)
{
if (bytes.Length < 8)
return null;

var littleEndian = bytes[0] == 'I';
var ifdOffset = ReadInt32(bytes, 4, littleEndian);

// IFD offsets come straight from the file. Compare against bytes.Length - required rather than
// computing offset + required, so a crafted offset near int.MaxValue cannot overflow the check.
if (ifdOffset < 8 || ifdOffset > bytes.Length - 2)
return null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

var entryCount = ReadUInt16(bytes, ifdOffset, littleEndian);
int? width = null;
int? height = null;

for (var i = 0; i < entryCount; i++)
{
// Each IFD entry takes 12 bytes. Compute the offset in 64-bit space so the bound check
// cannot overflow for a crafted IFD offset or entry count.
var entryOffset = (long)ifdOffset + 2 + ((long)i * 12);
if (entryOffset > bytes.Length - 12)
break;

var entry = (int)entryOffset;
var tag = ReadUInt16(bytes, entry, littleEndian);
if (tag != 256 && tag != 257)
continue;

var fieldType = ReadUInt16(bytes, entry + 2, littleEndian);
int value;
if (fieldType == 3) // SHORT
value = ReadUInt16(bytes, entry + 8, littleEndian);
else if (fieldType == 4) // LONG
value = ReadInt32(bytes, entry + 8, littleEndian);
else
continue;

if (tag == 256)
width = value;
else
height = value;
}

return width is > 0 && height is > 0 ? (width.Value, height.Value) : null;
}

private static int ReadInt32BigEndian(byte[] bytes, int offset)
=> (bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3];

private static int ReadInt32LittleEndian(byte[] bytes, int offset)
=> bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16) | (bytes[offset + 3] << 24);

private static int ReadInt32(byte[] bytes, int offset, bool littleEndian)
=> littleEndian ? ReadInt32LittleEndian(bytes, offset) : ReadInt32BigEndian(bytes, offset);

private static int ReadUInt16(byte[] bytes, int offset, bool littleEndian)
=> littleEndian
? bytes[offset] | (bytes[offset + 1] << 8)
: (bytes[offset] << 8) | bytes[offset + 1];
}
1 change: 1 addition & 0 deletions src/MiniExcel.OpenXml/Constants/ExcelFileNames.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,6 @@ internal static class ExcelFileNames
internal static string SheetRels(int sheetId) => $"xl/worksheets/_rels/sheet{sheetId}.xml.rels";
internal static string Drawing(int sheetIndex) => $"xl/drawings/drawing{sheetIndex}.xml";
internal static string DrawingRels(int sheetIndex) => $"xl/drawings/_rels/drawing{sheetIndex}.xml.rels";
internal static string DrawingRels(string drawingFileName) => $"xl/drawings/_rels/{drawingFileName}.rels";
internal static string Table(int tableIndex) => $"xl/tables/table{tableIndex}.xml";
}
11 changes: 9 additions & 2 deletions src/MiniExcel.OpenXml/Constants/ExcelXml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

internal static class ExcelXml
{
/// <summary>Default picture anchor size used when no explicit size is provided (64x20 px).</summary>
internal const long DefaultImageWidthEmu = 609600;
internal const long DefaultImageHeightEmu = 190500;

internal static readonly string EmptySheetXml = XmlHelper.MinifyXml("""
<?xml version="1.0" encoding="utf-8"?>
<x:worksheet xmlns:x="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
Expand Down Expand Up @@ -111,7 +115,10 @@ internal static string ImageRelationship(FileDto image)
=> $"""<Relationship Id="{image.Id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="/{image.Path}" />""";

internal static string DrawingRelationship(int sheetIndex)
=> $"""<Relationship Id="rDrawing{sheetIndex}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing{sheetIndex}.xml" />""";
=> DrawingRelationship(sheetIndex, $"drawing{sheetIndex}.xml");

internal static string DrawingRelationship(int sheetIndex, string drawingFileName)
=> $"""<Relationship Id="rDrawing{sheetIndex}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/{drawingFileName}" />""";

internal static string TableRelationship(int sheetIndex)
=> $"""<Relationship Id="rTable{sheetIndex}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/table" Target="../tables/table{sheetIndex}.xml"/>""";
Expand All @@ -125,7 +132,7 @@ internal static string DrawingXml(FileDto file, int fileIndex)
<xdr:row>{file.RowIndex - 1}</xdr:row>
<xdr:rowOff>0</xdr:rowOff>
</xdr:from>
<xdr:ext cx="609600" cy="190500" />
<xdr:ext cx="{file.ImageWidthEmu ?? DefaultImageWidthEmu}" cy="{file.ImageHeightEmu ?? DefaultImageHeightEmu}" />
<xdr:pic>
<xdr:nvPicPr>
<xdr:cNvPr id="{fileIndex + 1}" descr="" name="2a3f9147-58ea-4a79-87da-7d6114c4877b" />
Expand Down
18 changes: 17 additions & 1 deletion src/MiniExcel.OpenXml/Models/FileDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,25 @@ internal class FileDto
internal int SheetIndex { get; set; }
internal int RowIndex { get; set; }
internal int CellIndex { get; set; }
internal string Id => $"rFileId_{SheetIndex}_{RowIndex + 1}_{CellIndex + 1}";

/// <summary>
/// Disambiguates the generated media and relationship ids when multiple image values share the same
/// anchor cell. Left unset by the regular <c>SaveAs</c> pipeline, which never places two images on
/// one cell.
/// </summary>
internal string? IdSuffix { get; set; }

internal string Id => string.IsNullOrEmpty(IdSuffix)
? $"rFileId_{SheetIndex}_{RowIndex + 1}_{CellIndex + 1}"
: $"rFileId_{SheetIndex}_{RowIndex + 1}_{CellIndex + 1}_{IdSuffix}";
internal string Path => $"xl/media/{Id}.{Extension}";
internal bool IsImage { get; set; }
internal string Extension { get; set; }
internal byte[] Contents { get; set; }

/// <summary>
/// Anchor size in EMUs. When unset, the drawing falls back to the default image size.
/// </summary>
internal long? ImageWidthEmu { get; set; }
internal long? ImageHeightEmu { get; set; }
}
Loading
Loading