Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
182 changes: 180 additions & 2 deletions src/MiniExcel.Core/Helpers/ImageHelper.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
namespace MiniExcelLib.Core.Helpers;
using BP = System.Buffers.Binary.BinaryPrimitives;

namespace MiniExcelLib.Core.Helpers;

public static class ImageHelper
{
Expand All @@ -20,7 +22,7 @@ public enum ImageFormat
Unknown
}

public static ImageFormat GetImageFormat(byte[] bytes)
public static ImageFormat GetImageFormat(ReadOnlySpan<byte> bytes)
{
if (bytes.StartsWith(Bmp))
return ImageFormat.Bmp;
Expand All @@ -39,4 +41,180 @@ 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 or { 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(ReadOnlySpan<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 = BP.ReadInt32BigEndian(bytes[16..]);
var height = BP.ReadInt32BigEndian(bytes[20..]);
return width > 0 && height > 0 ? (width, height) : null;
}

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

var width = BP.ReadUInt16LittleEndian(bytes[6..]);
var height = BP.ReadUInt16LittleEndian(bytes[8..]);
return width > 0 && height > 0 ? (width, height) : null;
}

private static (int, int)? GetBmpSize(ReadOnlySpan<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 (BP.ReadInt32LittleEndian(bytes[14..]) == 12)
{
var coreWidth = BP.ReadUInt16LittleEndian(bytes[18..]);
var coreHeight = BP.ReadUInt16LittleEndian(bytes[20..]);
return coreWidth > 0 && coreHeight > 0 ? (coreWidth, coreHeight) : null;
}

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

private static (int, int)? GetJpegSize(ReadOnlySpan<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 = BP.ReadUInt16BigEndian(bytes[(index + 2)..]);
if (segmentLength < 2)
break;

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

index += 2 + segmentLength;
}

return null;
}

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

// This is the endianness of the file as specified by the TIFF specification, NOT the endianness of the system
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 ReadInt32(ReadOnlySpan<byte> bytes, int offset, bool littleEndian)
=> littleEndian
? BP.ReadInt32LittleEndian(bytes[offset..])
: BP.ReadInt32BigEndian(bytes[offset..]);

private static int ReadUInt16(ReadOnlySpan<byte> bytes, int offset, bool littleEndian)
=> littleEndian
? BP.ReadUInt16LittleEndian(bytes[offset..])
: BP.ReadUInt16BigEndian(bytes[offset..]);
}
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>
private const long DefaultImageWidthEmu = 609600;
private 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