Skip to content
Draft
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
20 changes: 20 additions & 0 deletions src/MiniExcel.OpenXml/Constants/ExcelXml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,26 @@ internal static string DrawingRelationship(int sheetIndex)
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 Table(int id, string name, string reference, bool hasHeaderRow, IReadOnlyList<string> columnNames, OpenXmlTableOptions options)
{
static string Flag(bool value) => value ? "1" : "0";

var sb = new StringBuilder();
sb.Append("""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>""");
sb.Append($"""<table xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" id="{id}" name="{name}" displayName="{name}" ref="{reference}"{(hasHeaderRow ? "" : " headerRowCount=\"0\"")} totalsRowShown="0">""");
if (hasHeaderRow && options.ShowFilterButton)
sb.Append($"""<autoFilter ref="{reference}" />""");

sb.Append($"""<tableColumns count="{columnNames.Count}">""");
for (var i = 0; i < columnNames.Count; i++)
sb.Append($"""<tableColumn id="{i + 1}" name="{XmlHelper.EncodeXml(columnNames[i])}" />""");
sb.Append("</tableColumns>");

sb.Append($"""<tableStyleInfo name="{XmlHelper.EncodeXml(options.Style)}" showFirstColumn="{Flag(options.ShowFirstColumn)}" showLastColumn="{Flag(options.ShowLastColumn)}" showRowStripes="{Flag(options.ShowRowStripes)}" showColumnStripes="{Flag(options.ShowColumnStripes)}" />""");
sb.Append("</table>");
return sb.ToString();
}

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 Down
4 changes: 4 additions & 0 deletions src/MiniExcel.OpenXml/Constants/WorksheetXml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ internal static string PaneSelection(string pane, string? activeCell, string? sq
"/>");

internal static string StartRow(int rowIndex) => $"<x:row r=\"{rowIndex}\">";
internal static string StartRow(int rowIndex, double? height) => height is { } ht
? $"<x:row r=\"{rowIndex}\" ht=\"{ht.ToString(CultureInfo.InvariantCulture)}\" customHeight=\"1\">"
: StartRow(rowIndex);
internal const string EndRow = "</x:row>";
internal const string StartCols = "<x:cols>";

Expand Down Expand Up @@ -69,4 +72,5 @@ internal static string Cell(string cellReference, string? cellType, string style

internal static string Autofilter(string dimensionRef) => $"<x:autoFilter ref=\"{dimensionRef}\" />";
internal static string Drawing(int sheetIndex) => $"<x:drawing r:id=\"rDrawing{sheetIndex}\" />";
internal static string TableParts(int sheetIndex) => $"<x:tableParts count=\"1\"><x:tablePart r:id=\"rTable{sheetIndex}\" /></x:tableParts>";
}
6 changes: 6 additions & 0 deletions src/MiniExcel.OpenXml/OpenXmlConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ public class OpenXmlConfiguration : MiniExcelBaseConfiguration
public bool FillMergedCells { get; set; }
public TableStyles TableStyles { get; set; } = TableStyles.Default;
public bool AutoFilter { get; set; } = true;

/// <summary>
/// When set, each sheet's data is written as an Excel table in this style. The sheet-level <see cref="AutoFilter"/> is then left out.
/// </summary>
public OpenXmlTableOptions? Table { get; set; }

public bool RightToLeft { get; set; } = false;
public int FreezeRowCount { get; set; } = 1;
public int FreezeColumnCount { get; set; } = 0;
Expand Down
47 changes: 47 additions & 0 deletions src/MiniExcel.OpenXml/OpenXmlTableOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
namespace MiniExcelLib.OpenXml;

/// <summary>
/// Exports each sheet's data as an Excel table. Set it on <see cref="OpenXmlConfiguration.Table"/>.
/// </summary>
/// <remarks>
/// The table covers the header row and the data, so columns must be adjacent. Combine it with
/// <see cref="TableStyles.None"/> to let the table style alone format the cells.
/// </remarks>
public class OpenXmlTableOptions
{
/// <summary>
/// The table name. The first sheet's table gets this name and later sheets append their sheet number.
/// Defaults to Table1, Table2 and so on.
/// </summary>
public string? Name { get; set; }

/// <summary>
/// A built-in table style such as TableStyleMedium2 or TableStyleLight9.
/// </summary>
public string Style { get; set; } = "TableStyleMedium2";

/// <summary>
/// Shows filter buttons in the header row.
/// </summary>
public bool ShowFilterButton { get; set; } = true;

/// <summary>
/// Shades alternate rows.
/// </summary>
public bool ShowRowStripes { get; set; } = true;

/// <summary>
/// Shades alternate columns.
/// </summary>
public bool ShowColumnStripes { get; set; }

/// <summary>
/// Highlights the first column.
/// </summary>
public bool ShowFirstColumn { get; set; }

/// <summary>
/// Highlights the last column.
/// </summary>
public bool ShowLastColumn { get; set; }
}
26 changes: 25 additions & 1 deletion src/MiniExcel.OpenXml/Styles/Builder/MinimalSheetStyleBuilder.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
namespace MiniExcelLib.OpenXml.Styles.Builder;

internal partial class MinimalSheetStyleBuilder(SheetStyleBuilderContext context) : SheetStyleBuilderBase(context)
internal partial class MinimalSheetStyleBuilder(SheetStyleBuilderContext context, OpenXmlStyleOptions? styleOptions = null) : SheetStyleBuilderBase(context)
{
private static readonly SheetStyleElementInfos GenerateElementInfos = new()
{
Expand Down Expand Up @@ -88,6 +88,8 @@ protected override async Task GenerateCellXfAsync()
* <x:xf numFmtId="21" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" />
* */

// The second xf is the header's, which takes the alignment of a header style but not its fill
var headerStyle = styleOptions?.HeaderStyle;
for (int i = 0; i < 3; i++)
{
await NewWriter.WriteStartElementAsync(OldReader.Prefix, "xf", OldReader.NamespaceURI).ConfigureAwait(false);
Expand All @@ -96,6 +98,28 @@ protected override async Task GenerateCellXfAsync()
await NewWriter.WriteAttributeStringAsync(null, "fillId", null, "0").ConfigureAwait(false);
await NewWriter.WriteAttributeStringAsync(null, "borderId", null, "0").ConfigureAwait(false);
await NewWriter.WriteAttributeStringAsync(null, "xfId", null, "0").ConfigureAwait(false);
if (i == 1 && headerStyle is not null)
{
/*
* <x:alignment horizontal="center" vertical="center" wrapText="1" />
*/
await NewWriter.WriteAttributeStringAsync(null, "applyAlignment", null, "1").ConfigureAwait(false);
await NewWriter.WriteStartElementAsync(OldReader.Prefix, "alignment", OldReader.NamespaceURI).ConfigureAwait(false);
await NewWriter.WriteAttributeStringAsync(null, "horizontal", null, headerStyle.HorizontalAlignment switch
{
HorizontalCellAlignment.Center => "center",
HorizontalCellAlignment.Right => "right",
_ => "general"
}).ConfigureAwait(false);
await NewWriter.WriteAttributeStringAsync(null, "vertical", null, headerStyle.VerticalAlignment switch
{
VerticalCellAlignment.Top => "top",
VerticalCellAlignment.Center => "center",
_ => "bottom"
}).ConfigureAwait(false);
await NewWriter.WriteAttributeStringAsync(null, "wrapText", null, headerStyle.WrapText ? "1" : "0").ConfigureAwait(false);
await NewWriter.WriteEndElementAsync().ConfigureAwait(false);
}
await NewWriter.WriteEndElementAsync().ConfigureAwait(false);
}

Expand Down
5 changes: 5 additions & 0 deletions src/MiniExcel.OpenXml/Styles/OpenXmlHeaderStyle.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,9 @@ public class OpenXmlHeaderStyle
/// Vertical alignment
/// </summary>
public VerticalCellAlignment VerticalAlignment { get; set; } = VerticalCellAlignment.Bottom;

/// <summary>
/// The height of the header row in points, or null for the default height
/// </summary>
public double? RowHeight { get; set; }
}
2 changes: 1 addition & 1 deletion src/MiniExcel.OpenXml/Writer/OpenXmlWriter.CopyInsert.cs
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ private async Task<TempSheetStylesBuilderUtils> CopySheetStylesAndGetBuilderUtil
oldStylesContext = new SheetStyleBuilderContext(_zipContentsMap, copiedArchive, Utf8WithBom);
SheetStyleBuilderBase builder = _configuration.TableStyles switch
{
TableStyles.None => new MinimalSheetStyleBuilder(oldStylesContext),
TableStyles.None => new MinimalSheetStyleBuilder(oldStylesContext, _configuration.StyleOptions),
TableStyles.Default => new DefaultSheetStyleBuilder(oldStylesContext, _configuration.StyleOptions),
_ => throw new InvalidEnumArgumentException(nameof(_configuration.TableStyles), (int)_configuration.TableStyles, typeof(TableStyles))
};
Expand Down
38 changes: 37 additions & 1 deletion src/MiniExcel.OpenXml/Writer/OpenXmlWriter.XmlGeneration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,39 @@ private string GetDrawingXml(int sheetIndex)
return drawing.ToString();
}

private static readonly Regex TableNameRegex = new(@"^[A-Za-z_\][A-Za-z0-9_.]*$");

private string? GetTableXml(OpenXmlTableOptions options, List<MiniExcelColumnMapping?> mappings, int maxRowIndex, int maxColumnIndex)
{
// Null mappings are gaps between columns placed by index, which would leave a table column without a header
if (mappings.Any(m => m is null))
throw new NotSupportedException("A table needs adjacent columns, so it can't be used with column indexes that leave gaps");

if (maxColumnIndex == 0 || (!_printHeader && maxRowIndex == 0))
return null;

List<string> columnNames = _printHeader
? [.. mappings.Where(m => !m!.ExcelIgnoreColumn).Select(m => m!.ExcelColumnName ?? "")]
: [.. Enumerable.Range(1, maxColumnIndex).Select(i => $"Column{i}")];

if (columnNames.Any(string.IsNullOrEmpty))
throw new InvalidOperationException("Table column names can't be empty");
if (columnNames.Distinct(StringComparer.OrdinalIgnoreCase).Count() != columnNames.Count)
throw new InvalidOperationException("Table column names must be unique");

var name = options.Name is null ? $"Table{_currentSheetIndex}"
: _currentSheetIndex == 1 ? options.Name
: $"{options.Name}{_currentSheetIndex}";
if (!TableNameRegex.IsMatch(name))
throw new ArgumentException($"'{name}' is not a valid table name");

// A table needs at least one data row, so a header without data takes in the empty row below it
var lastRow = _printHeader ? Math.Max(maxRowIndex, 2) : maxRowIndex;
var reference = $"A1:{CellReferenceConverter.GetCellFromCoordinates(maxColumnIndex, lastRow)}";

return ExcelXml.Table(_currentSheetIndex, name, reference, _printHeader, columnNames, options);
}

private (string WorkbookContents, string WorkbookRelsContents, Dictionary<int, string> SheetRelsContents) GenerateWorkbookItems()
{
var workbookContents = new StringBuilder();
Expand All @@ -419,7 +452,10 @@ private string GetDrawingXml(int sheetIndex)
workbookRelsContents.AppendLine(ExcelXml.WorksheetRelationship(sheetDto));

//TODO: support multiple drawing
sheetsRelsDict.Add(sheetDto.SheetIdx, ExcelXml.DrawingRelationship(sheetId));
var sheetRels = ExcelXml.DrawingRelationship(sheetId);
if (_tables.ContainsKey(sheetDto.SheetIdx))
sheetRels += ExcelXml.TableRelationship(sheetDto.SheetIdx);
sheetsRelsDict.Add(sheetDto.SheetIdx, sheetRels);
}

return (workbookContents.ToString(), workbookRelsContents.ToString(), sheetsRelsDict);
Expand Down
33 changes: 28 additions & 5 deletions src/MiniExcel.OpenXml/Writer/OpenXmlWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ internal sealed partial class OpenXmlWriter : IMiniExcelWriter
private readonly OpenXmlConfiguration _configuration;
private readonly List<SheetDto> _sheets = [];
private readonly List<FileDto> _files = [];

// Table parts by sheet index, written once all sheets are done
private readonly Dictionary<int, string> _tables = [];

private readonly string _sheetName;
private readonly bool _printHeader;
Expand Down Expand Up @@ -86,6 +89,7 @@ public async Task<int[]> SaveAsAsync(IProgress<int>? progress = null, Cancellati
await GenerateSharedStringsAsync(cancellationToken).ConfigureAwait(false);
await GenerateDrawingRelsAsync(cancellationToken).ConfigureAwait(false);
await GenerateDrawingsAsync(cancellationToken).ConfigureAwait(false);
await GenerateTablesAsync(cancellationToken).ConfigureAwait(false);
await GenerateWorkbookAsync(false, cancellationToken).ConfigureAwait(false);
await GenerateContentTypesAsync(cancellationToken).ConfigureAwait(false);

Expand All @@ -97,6 +101,8 @@ public async Task<int> InsertAsync(bool overwriteSheet = false, IProgress<int>?
{
if (!_configuration.FastMode)
throw new InvalidOperationException("Insert requires fast mode to be enabled");
if (_configuration.Table is not null)
throw new NotSupportedException("Tables can't be added when inserting a sheet into an existing workbook");

#if NET10_0_OR_GREATER
await using var disposableArchive = _archive.ConfigureAwait(false);
Expand Down Expand Up @@ -290,7 +296,7 @@ private async Task<int> WriteValuesAsync(MiniExcelStreamWriter writer, object va
var currentRowIndex = 0;
if (_printHeader)
{
await PrintHeaderAsync(writer, mappings!, cancellationToken).ConfigureAwait(false);
await PrintHeaderAsync(writer, mappings!, _configuration.StyleOptions.HeaderStyle?.RowHeight, cancellationToken).ConfigureAwait(false);
currentRowIndex++;
}

Expand Down Expand Up @@ -331,12 +337,19 @@ private async Task<int> WriteValuesAsync(MiniExcelStreamWriter writer, object va

await writer.WriteAsync(WorksheetXml.EndSheetData, cancellationToken).ConfigureAwait(false);

if (_configuration.AutoFilter)
// A sheet can't have its own autofilter on top of a table's
var table = _configuration.Table is { } tableOptions ? GetTableXml(tableOptions, mappings, maxRowIndex, maxColumnIndex) : null;
if (_configuration.AutoFilter && _configuration.Table is null)
{
await writer.WriteAsync(WorksheetXml.Autofilter(GetDimensionRef(maxRowIndex, maxColumnIndex)), cancellationToken).ConfigureAwait(false);
}

await writer.WriteAsync(WorksheetXml.Drawing(_currentSheetIndex), cancellationToken).ConfigureAwait(false);
if (table is not null)
{
_tables[_currentSheetIndex] = table;
await writer.WriteAsync(WorksheetXml.TableParts(_currentSheetIndex), cancellationToken).ConfigureAwait(false);
}
await writer.WriteAsync(WorksheetXml.EndWorksheet, cancellationToken).ConfigureAwait(false);

if (_archive.Mode == ZipArchiveMode.Update && dimensionPlaceholderPostition != 0)
Expand Down Expand Up @@ -414,10 +427,10 @@ private static async Task WriteColumnsWidthsAsync(MiniExcelStreamWriter writer,
}

[CreateSyncVersion]
private static async Task PrintHeaderAsync(MiniExcelStreamWriter writer, List<MiniExcelColumnMapping?> mappings, CancellationToken cancellationToken = default)
private static async Task PrintHeaderAsync(MiniExcelStreamWriter writer, List<MiniExcelColumnMapping?> mappings, double? rowHeight, CancellationToken cancellationToken = default)
{
const int yIndex = 1;
await writer.WriteAsync(WorksheetXml.StartRow(yIndex), cancellationToken).ConfigureAwait(false);
await writer.WriteAsync(WorksheetXml.StartRow(yIndex, rowHeight), cancellationToken).ConfigureAwait(false);

var xIndex = 1;
foreach (var map in mappings)
Expand Down Expand Up @@ -511,7 +524,7 @@ private async Task<ISheetStyleBuilder> GetSheetStyleBuilderAsync(CancellationTok
{
SheetStyleBuilderBase builder = _configuration.TableStyles switch
{
TableStyles.None => new MinimalSheetStyleBuilder(_sheetStyleBuilderContext),
TableStyles.None => new MinimalSheetStyleBuilder(_sheetStyleBuilderContext, _configuration.StyleOptions),
TableStyles.Default => new DefaultSheetStyleBuilder(_sheetStyleBuilderContext, _configuration.StyleOptions),
_ => throw new InvalidEnumArgumentException(nameof(_configuration.TableStyles), (int)_configuration.TableStyles, typeof(TableStyles))
};
Expand Down Expand Up @@ -564,6 +577,16 @@ await CreateZipEntryAsync(
cancellationToken).ConfigureAwait(false);
}

[CreateSyncVersion]
private async Task GenerateTablesAsync(CancellationToken cancellationToken)
{
foreach (var (sheetIndex, table) in _tables)
{
cancellationToken.ThrowIfCancellationRequested();
await CreateZipEntryAsync(ExcelFileNames.Table(sheetIndex), ExcelContentTypes.Table, table, cancellationToken).ConfigureAwait(false);
}
}

[CreateSyncVersion]
private async Task GenerateWorkbookAsync(bool removeOriginalEntry = false, CancellationToken cancellationToken = default)
{
Expand Down
Loading
Loading