From 0b15822951fa90fa943a55f77bb836ee95b44e75 Mon Sep 17 00:00:00 2001 From: Nikolai Norum Hansen <35102735+Discolai@users.noreply.github.com> Date: Fri, 22 Nov 2024 15:19:53 +0100 Subject: [PATCH] Write auto column width (#695) * Add AutoWidth functionality for async write * Add MiniExcelAutoAdjustWidthTests * Implement autowidth for sync write --- .../OpenXml/Constants/WorksheetXml.cs | 14 +- .../OpenXml/ExcelOpenXmlSheetWriter.Async.cs | 105 ++++++++-- .../OpenXml/ExcelOpenXmlSheetWriter.cs | 114 ++++++++-- src/MiniExcel/OpenXml/ExcelWidthCollection.cs | 74 +++++++ .../OpenXml/MiniExcelAsyncStreamWriter.cs | 5 + .../OpenXml/MiniExcelStreamWriter.cs | 5 + src/MiniExcel/OpenXml/OpenXmlConfiguration.cs | 9 + .../MiniExcelAutoAdjustWidthTests.cs | 194 ++++++++++++++++++ .../MiniExcelOpenXmlAsyncTests.cs | 1 - tests/MiniExcelTests/Utils/Db.cs | 45 ++++ 10 files changed, 523 insertions(+), 43 deletions(-) create mode 100644 src/MiniExcel/OpenXml/ExcelWidthCollection.cs create mode 100644 tests/MiniExcelTests/MiniExcelAutoAdjustWidthTests.cs diff --git a/src/MiniExcel/OpenXml/Constants/WorksheetXml.cs b/src/MiniExcel/OpenXml/Constants/WorksheetXml.cs index c300cbf0..7ec24856 100644 --- a/src/MiniExcel/OpenXml/Constants/WorksheetXml.cs +++ b/src/MiniExcel/OpenXml/Constants/WorksheetXml.cs @@ -1,5 +1,6 @@ using System.Globalization; using MiniExcelLibs.Attributes; +using System.Linq; namespace MiniExcelLibs.OpenXml.Constants { @@ -46,8 +47,17 @@ internal static string StartRow(int rowIndex) internal const string EndRow = ""; internal const string StartCols = ""; - internal static string Column(int? colIndex, double? columnWidth) - => $@""; + internal static string Column(int colIndex, double columnWidth) + => $@""; + + + private static readonly int _maxColumnLength = Column(int.MaxValue, double.MaxValue).Length; + + public static int GetColumnPlaceholderLength(int columnCount) + { + return StartCols.Length + (_maxColumnLength * columnCount) + EndCols.Length; + } + internal const string EndCols = ""; internal static string EmptyCell(string cellReference, string styleIndex) diff --git a/src/MiniExcel/OpenXml/ExcelOpenXmlSheetWriter.Async.cs b/src/MiniExcel/OpenXml/ExcelOpenXmlSheetWriter.Async.cs index d852a4d7..7df601ec 100644 --- a/src/MiniExcel/OpenXml/ExcelOpenXmlSheetWriter.Async.cs +++ b/src/MiniExcel/OpenXml/ExcelOpenXmlSheetWriter.Async.cs @@ -6,6 +6,7 @@ using System.Collections; using System.Collections.Generic; using System.Data; +using System.Globalization; using System.IO.Compression; using System.Linq; using System.Text; @@ -104,6 +105,8 @@ private async Task GenerateSheetByIDataReaderAsync(MiniExcelAsyncStreamWriter wr var yIndex = 1; int maxColumnIndex; int maxRowIndex; + ExcelWidthCollection widths = null; + long columnWidthsPlaceholderPosition = 0; { if (_configuration.FastMode) { @@ -122,7 +125,15 @@ private async Task GenerateSheetByIDataReaderAsync(MiniExcelAsyncStreamWriter wr //sheet view await writer.WriteAsync(GetSheetViews()); - await WriteColumnsWidthsAsync(writer, props); + if (_configuration.EnableAutoWidth) + { + columnWidthsPlaceholderPosition = await WriteColumnWidthPlaceholdersAsync(writer, props); + widths = new ExcelWidthCollection(_configuration.MinWidth, _configuration.MaxWidth, props); + } + else + { + await WriteColumnsWidthsAsync(writer, ExcelColumnWidth.FromProps(props)); + } await writer.WriteAsync(WorksheetXml.StartSheetData); int fieldCount = reader.FieldCount; @@ -139,7 +150,7 @@ private async Task GenerateSheetByIDataReaderAsync(MiniExcelAsyncStreamWriter wr for (int i = 0; i < fieldCount; i++) { var cellValue = reader.GetValue(i); - await WriteCellAsync(writer, yIndex, xIndex, cellValue, props[i]); + await WriteCellAsync(writer, yIndex, xIndex, cellValue, props[i], widths); xIndex++; } await writer.WriteAsync(WorksheetXml.EndRow); @@ -163,6 +174,10 @@ private async Task GenerateSheetByIDataReaderAsync(MiniExcelAsyncStreamWriter wr { await WriteDimensionAsync(writer, maxRowIndex, maxColumnIndex, dimensionPlaceholderPostition); } + if (_configuration.EnableAutoWidth) + { + await OverWriteColumnWidthPlaceholdersAsync(writer, columnWidthsPlaceholderPosition, widths.Columns); + } } private async Task GenerateSheetByEnumerableAsync(MiniExcelAsyncStreamWriter writer, IEnumerable values) @@ -248,7 +263,17 @@ private async Task GenerateSheetByEnumerableAsync(MiniExcelAsyncStreamWriter wri await writer.WriteAsync(GetSheetViews()); //cols:width - await WriteColumnsWidthsAsync(writer, props); + ExcelWidthCollection widths = null; + long columnWidthsPlaceholderPosition = 0; + if (_configuration.EnableAutoWidth) + { + columnWidthsPlaceholderPosition = await WriteColumnWidthPlaceholdersAsync(writer, props); + widths = new ExcelWidthCollection(_configuration.MinWidth, _configuration.MaxWidth, props); + } + else + { + await WriteColumnsWidthsAsync(writer, ExcelColumnWidth.FromProps(props)); + } //header await writer.WriteAsync(WorksheetXml.StartSheetData); @@ -266,13 +291,13 @@ private async Task GenerateSheetByEnumerableAsync(MiniExcelAsyncStreamWriter wri switch (mode) { case "IDictionary": //Dapper Row - maxRowIndex = await GenerateSheetByColumnInfoAsync>(writer, enumerator, props, xIndex, yIndex); + maxRowIndex = await GenerateSheetByColumnInfoAsync>(writer, enumerator, props, widths, xIndex, yIndex); break; case "IDictionary": - maxRowIndex = await GenerateSheetByColumnInfoAsync(writer, enumerator, props, xIndex, yIndex); + maxRowIndex = await GenerateSheetByColumnInfoAsync(writer, enumerator, props, widths, xIndex, yIndex); break; case "Properties": - maxRowIndex = await GenerateSheetByColumnInfoAsync(writer, enumerator, props, xIndex, yIndex); + maxRowIndex = await GenerateSheetByColumnInfoAsync(writer, enumerator, props, widths, xIndex, yIndex); break; default: throw new NotImplementedException($"Type {values.GetType().FullName} is not implemented. Please open an issue."); @@ -293,6 +318,10 @@ private async Task GenerateSheetByEnumerableAsync(MiniExcelAsyncStreamWriter wri { await WriteDimensionAsync(writer, maxRowIndex, maxColumnIndex, dimensionPlaceholderPostition); } + if (_configuration.EnableAutoWidth) + { + await OverWriteColumnWidthPlaceholdersAsync(writer, columnWidthsPlaceholderPosition, widths.Columns); + } } private async Task GenerateSheetByDataTableAsync(MiniExcelAsyncStreamWriter writer, DataTable value) @@ -318,7 +347,17 @@ private async Task GenerateSheetByDataTableAsync(MiniExcelAsyncStreamWriter writ //sheet view await writer.WriteAsync(GetSheetViews()); - await WriteColumnsWidthsAsync(writer, props); + ExcelWidthCollection widths = null; + long columnWidthsPlaceholderPosition = 0; + if (_configuration.EnableAutoWidth) + { + columnWidthsPlaceholderPosition = await WriteColumnWidthPlaceholdersAsync(writer, props); + widths = new ExcelWidthCollection(_configuration.MinWidth, _configuration.MaxWidth, props); + } + else + { + await WriteColumnsWidthsAsync(writer, ExcelColumnWidth.FromProps(props)); + } await writer.WriteAsync(WorksheetXml.StartSheetData); if (_printHeader) @@ -344,7 +383,7 @@ private async Task GenerateSheetByDataTableAsync(MiniExcelAsyncStreamWriter writ for (int j = 0; j < value.Columns.Count; j++) { var cellValue = value.Rows[i][j]; - await WriteCellAsync(writer, yIndex, xIndex, cellValue, props[j]); + await WriteCellAsync(writer, yIndex, xIndex, cellValue, props[j], widths); xIndex++; } await writer.WriteAsync(WorksheetXml.EndRow); @@ -357,25 +396,48 @@ private async Task GenerateSheetByDataTableAsync(MiniExcelAsyncStreamWriter writ { await writer.WriteAsync(WorksheetXml.Autofilter(GetDimensionRef(maxRowIndex, maxColumnIndex))); } + if (_configuration.EnableAutoWidth) + { + await OverWriteColumnWidthPlaceholdersAsync(writer, columnWidthsPlaceholderPosition, widths.Columns); + } await writer.WriteAsync(WorksheetXml.EndWorksheet); } - private static async Task WriteColumnsWidthsAsync(MiniExcelAsyncStreamWriter writer, IEnumerable props) + private async Task WriteColumnWidthPlaceholdersAsync(MiniExcelAsyncStreamWriter writer, ICollection props) { - var ecwProps = props.Where(x => x?.ExcelColumnWidth != null).ToList(); - if (ecwProps.Count <= 0) - { - return; - } + var placeholderPosition = await writer.FlushAsync(); + await writer.WriteWhitespaceAsync(WorksheetXml.GetColumnPlaceholderLength(props.Count)); + return placeholderPosition; + } - await writer.WriteAsync(WorksheetXml.StartCols); + private async Task OverWriteColumnWidthPlaceholdersAsync(MiniExcelAsyncStreamWriter writer, long placeholderPosition, IEnumerable columnWidths) + { + var position = await writer.FlushAsync(); - foreach (var p in ecwProps) + writer.SetPosition(placeholderPosition); + await WriteColumnsWidthsAsync(writer, columnWidths); + + await writer.FlushAsync(); + writer.SetPosition(position); + } + + private async Task WriteColumnsWidthsAsync(MiniExcelAsyncStreamWriter writer, IEnumerable columnWidths) + { + var hasWrittenStart = false; + foreach (var column in columnWidths) { - await writer.WriteAsync(WorksheetXml.Column(p.ExcelColumnIndex, p.ExcelColumnWidth)); + if (!hasWrittenStart) + { + await writer.WriteAsync(WorksheetXml.StartCols); + hasWrittenStart = true; + } + await writer.WriteAsync(WorksheetXml.Column(column.Index, column.Width)); + } + if (!hasWrittenStart) + { + return; } - await writer.WriteAsync(WorksheetXml.EndCols); } @@ -401,7 +463,7 @@ private static async Task PrintHeaderAsync(MiniExcelAsyncStreamWriter writer, Li await writer.WriteAsync(WorksheetXml.EndRow); } - private async Task GenerateSheetByColumnInfoAsync(MiniExcelAsyncStreamWriter writer, IEnumerator value, List props, int xIndex = 1, int yIndex = 1) + private async Task GenerateSheetByColumnInfoAsync(MiniExcelAsyncStreamWriter writer, IEnumerator value, List props, ExcelWidthCollection widthCollection, int xIndex = 1, int yIndex = 1) { var isDic = typeof(T) == typeof(IDictionary); var isDapperRow = typeof(T) == typeof(IDictionary); @@ -436,7 +498,7 @@ private async Task GenerateSheetByColumnInfoAsync(MiniExcelAsyncStreamWr cellValue = p.Property.GetValue(v); } - await WriteCellAsync(writer, yIndex, cellIndex, cellValue, p); + await WriteCellAsync(writer, yIndex, cellIndex, cellValue, p, widthCollection); cellIndex++; } @@ -453,7 +515,7 @@ private static async Task WriteCellAsync(MiniExcelAsyncStreamWriter writer, stri await writer.WriteAsync(WorksheetXml.Cell(cellReference, "str", "1", ExcelOpenXmlUtils.EncodeXML(columnName))); } - private async Task WriteCellAsync(MiniExcelAsyncStreamWriter writer, int rowIndex, int cellIndex, object value, ExcelColumnInfo p) + private async Task WriteCellAsync(MiniExcelAsyncStreamWriter writer, int rowIndex, int cellIndex, object value, ExcelColumnInfo p, ExcelWidthCollection widthCollection) { var columnReference = ExcelOpenXmlUtils.ConvertXyToCell(cellIndex, rowIndex); var valueIsNull = value is null || value is DBNull; @@ -474,6 +536,7 @@ private async Task WriteCellAsync(MiniExcelAsyncStreamWriter writer, int rowInde /*Prefix and suffix blank space will lost after SaveAs #294*/ var preserveSpace = cellValue != null && (cellValue.StartsWith(" ", StringComparison.Ordinal) || cellValue.EndsWith(" ", StringComparison.Ordinal)); await writer.WriteAsync(WorksheetXml.Cell(columnReference, dataType, styleIndex, cellValue, preserveSpace: preserveSpace, columnType: columnType)); + widthCollection?.AdjustWidth(cellIndex, cellValue); } private async Task GenerateEndXmlAsync(CancellationToken cancellationToken) diff --git a/src/MiniExcel/OpenXml/ExcelOpenXmlSheetWriter.cs b/src/MiniExcel/OpenXml/ExcelOpenXmlSheetWriter.cs index 82b95328..4fdfa949 100644 --- a/src/MiniExcel/OpenXml/ExcelOpenXmlSheetWriter.cs +++ b/src/MiniExcel/OpenXml/ExcelOpenXmlSheetWriter.cs @@ -11,6 +11,7 @@ using System.IO.Compression; using System.Linq; using System.Text; +using System.Threading.Tasks; namespace MiniExcelLibs.OpenXml { @@ -33,6 +34,11 @@ public ExcelOpenXmlSheetWriter(Stream stream, object value, string sheetName, IC // Why ZipArchiveMode.Update not ZipArchiveMode.Create? // R : Mode create - ZipArchiveEntry does not support seeking.' this._configuration = configuration as OpenXmlConfiguration ?? OpenXmlConfiguration.DefaultConfig; + if (_configuration.EnableAutoWidth && !_configuration.FastMode) + { + throw new InvalidOperationException("Auto width requires fast mode to be enabled"); + } + if (_configuration.FastMode) this._archive = new MiniExcelZipArchive(_stream, ZipArchiveMode.Update, true, _utf8WithBom); else @@ -136,6 +142,8 @@ private void GenerateSheetByIDataReader(MiniExcelStreamWriter writer, IDataReade var yIndex = 1; int maxColumnIndex; int maxRowIndex; + ExcelWidthCollection widths = null; + long columnWidthsPlaceholderPosition = 0; { if (_configuration.FastMode) { @@ -154,7 +162,15 @@ private void GenerateSheetByIDataReader(MiniExcelStreamWriter writer, IDataReade //sheet view writer.Write(GetSheetViews()); - WriteColumnsWidths(writer, props); + if (_configuration.EnableAutoWidth) + { + columnWidthsPlaceholderPosition = WriteColumnWidthPlaceholders(writer, props); + widths = new ExcelWidthCollection(_configuration.MinWidth, _configuration.MaxWidth, props); + } + else + { + WriteColumnsWidths(writer, ExcelColumnWidth.FromProps(props)); + } writer.Write(WorksheetXml.StartSheetData); int fieldCount = reader.FieldCount; @@ -174,7 +190,7 @@ private void GenerateSheetByIDataReader(MiniExcelStreamWriter writer, IDataReade for (int i = 0; i < fieldCount; i++) { var cellValue = reader.GetValue(i); - WriteCell(writer, yIndex, xIndex, cellValue, columnInfo: props?.FirstOrDefault(x => x?.ExcelColumnIndex == xIndex - 1)); + WriteCell(writer, yIndex, xIndex, cellValue, columnInfo: props?.FirstOrDefault(x => x?.ExcelColumnIndex == xIndex - 1), widths); xIndex++; } writer.Write(WorksheetXml.EndRow); @@ -197,6 +213,12 @@ private void GenerateSheetByIDataReader(MiniExcelStreamWriter writer, IDataReade { WriteDimension(writer, maxRowIndex, maxColumnIndex, dimensionPlaceholderPosition); } + + if (_configuration.EnableAutoWidth) + { + OverWriteColumnWidthPlaceholders(writer, columnWidthsPlaceholderPosition, widths.Columns); + } + } private void GenerateSheetByEnumerable(MiniExcelStreamWriter writer, IEnumerable values) @@ -282,7 +304,17 @@ private void GenerateSheetByEnumerable(MiniExcelStreamWriter writer, IEnumerable writer.Write(GetSheetViews()); //cols:width - WriteColumnsWidths(writer, props); + ExcelWidthCollection widths = null; + long columnWidthsPlaceholderPosition = 0; + if (_configuration.EnableAutoWidth) + { + columnWidthsPlaceholderPosition = WriteColumnWidthPlaceholders(writer, props); + widths = new ExcelWidthCollection(_configuration.MinWidth, _configuration.MaxWidth, props); + } + else + { + WriteColumnsWidths(writer, ExcelColumnWidth.FromProps(props)); + } //header writer.Write(WorksheetXml.StartSheetData); @@ -300,13 +332,13 @@ private void GenerateSheetByEnumerable(MiniExcelStreamWriter writer, IEnumerable switch (mode) { case "IDictionary": //Dapper Row - maxRowIndex = GenerateSheetByColumnInfo>(writer, enumerator, props, xIndex, yIndex); + maxRowIndex = GenerateSheetByColumnInfo>(writer, enumerator, props, widths, xIndex, yIndex); break; case "IDictionary": - maxRowIndex = GenerateSheetByColumnInfo(writer, enumerator, props, xIndex, yIndex); + maxRowIndex = GenerateSheetByColumnInfo(writer, enumerator, props, widths, xIndex, yIndex); break; case "Properties": - maxRowIndex = GenerateSheetByColumnInfo(writer, enumerator, props, xIndex, yIndex); + maxRowIndex = GenerateSheetByColumnInfo(writer, enumerator, props, widths, xIndex, yIndex); break; default: throw new NotImplementedException($"Type {values.GetType().FullName} is not implemented. Please open an issue."); @@ -327,6 +359,11 @@ private void GenerateSheetByEnumerable(MiniExcelStreamWriter writer, IEnumerable { WriteDimension(writer, maxRowIndex, maxColumnIndex, dimensionPlaceholderPostition); } + + if (_configuration.EnableAutoWidth) + { + OverWriteColumnWidthPlaceholders(writer, columnWidthsPlaceholderPosition, widths.Columns); + } } private void GenerateSheetByDataTable(MiniExcelStreamWriter writer, DataTable value) @@ -354,7 +391,17 @@ private void GenerateSheetByDataTable(MiniExcelStreamWriter writer, DataTable va //sheet view writer.Write(GetSheetViews()); - WriteColumnsWidths(writer, props); + ExcelWidthCollection widths = null; + long columnWidthsPlaceholderPosition = 0; + if (_configuration.EnableAutoWidth) + { + columnWidthsPlaceholderPosition = WriteColumnWidthPlaceholders(writer, props); + widths = new ExcelWidthCollection(_configuration.MinWidth, _configuration.MaxWidth, props); + } + else + { + WriteColumnsWidths(writer, ExcelColumnWidth.FromProps(props)); + } writer.Write(WorksheetXml.StartSheetData); if (_printHeader) @@ -380,7 +427,7 @@ private void GenerateSheetByDataTable(MiniExcelStreamWriter writer, DataTable va for (int j = 0; j < value.Columns.Count; j++) { var cellValue = value.Rows[i][j]; - WriteCell(writer, yIndex, xIndex, cellValue, columnInfo: props?.FirstOrDefault(x => x?.ExcelColumnIndex == xIndex - 1)); + WriteCell(writer, yIndex, xIndex, cellValue, columnInfo: props?.FirstOrDefault(x => x?.ExcelColumnIndex == xIndex - 1), widths); xIndex++; } writer.Write(WorksheetXml.EndRow); @@ -394,20 +441,48 @@ private void GenerateSheetByDataTable(MiniExcelStreamWriter writer, DataTable va writer.Write(WorksheetXml.Autofilter(GetDimensionRef(maxRowIndex, maxColumnIndex))); } + if (_configuration.EnableAutoWidth) + { + OverWriteColumnWidthPlaceholders(writer, columnWidthsPlaceholderPosition, widths.Columns); + } + writer.Write(WorksheetXml.EndWorksheet); } - private static void WriteColumnsWidths(MiniExcelStreamWriter writer, IEnumerable props) + private long WriteColumnWidthPlaceholders(MiniExcelStreamWriter writer, ICollection props) { - var ecwProps = props.Where(x => x?.ExcelColumnWidth != null).ToList(); - if (ecwProps.Count <= 0) - return; - writer.Write(WorksheetXml.StartCols); - foreach (var p in ecwProps) + var placeholderPosition = writer.Flush(); + writer.WriteWhitespace(WorksheetXml.GetColumnPlaceholderLength(props.Count)); + return placeholderPosition; + } + + private void OverWriteColumnWidthPlaceholders(MiniExcelStreamWriter writer, long placeholderPosition, IEnumerable columnWidths) + { + var position = writer.Flush(); + + writer.SetPosition(placeholderPosition); + WriteColumnsWidths(writer, columnWidths); + + writer.Flush(); + writer.SetPosition(position); + } + + private void WriteColumnsWidths(MiniExcelStreamWriter writer, IEnumerable columnWidths) + { + var hasWrittenStart = false; + foreach (var column in columnWidths) { - writer.Write(WorksheetXml.Column(p.ExcelColumnIndex, p.ExcelColumnWidth)); + if (!hasWrittenStart) + { + writer.Write(WorksheetXml.StartCols); + hasWrittenStart = true; + } + writer.Write(WorksheetXml.Column(column.Index, column.Width)); + } + if (!hasWrittenStart) + { + return; } - writer.Write(WorksheetXml.EndCols); } @@ -433,7 +508,7 @@ private static void PrintHeader(MiniExcelStreamWriter writer, List(MiniExcelStreamWriter writer, IEnumerator value, List props, int xIndex = 1, int yIndex = 1) + private int GenerateSheetByColumnInfo(MiniExcelStreamWriter writer, IEnumerator value, List props, ExcelWidthCollection widthCollection, int xIndex = 1, int yIndex = 1) { var isDic = typeof(T) == typeof(IDictionary); var isDapperRow = typeof(T) == typeof(IDictionary); @@ -467,7 +542,7 @@ private int GenerateSheetByColumnInfo(MiniExcelStreamWriter writer, IEnumerat cellValue = columnInfo.Property.GetValue(v); } - WriteCell(writer, yIndex, cellIndex, cellValue, columnInfo); + WriteCell(writer, yIndex, cellIndex, cellValue, columnInfo, widthCollection); cellIndex++; } @@ -479,7 +554,7 @@ private int GenerateSheetByColumnInfo(MiniExcelStreamWriter writer, IEnumerat return yIndex - 1; } - private void WriteCell(MiniExcelStreamWriter writer, int rowIndex, int cellIndex, object value, ExcelColumnInfo columnInfo) + private void WriteCell(MiniExcelStreamWriter writer, int rowIndex, int cellIndex, object value, ExcelColumnInfo columnInfo, ExcelWidthCollection widthCollection) { var columnReference = ExcelOpenXmlUtils.ConvertXyToCell(cellIndex, rowIndex); var valueIsNull = value is null || value is DBNull; @@ -500,6 +575,7 @@ private void WriteCell(MiniExcelStreamWriter writer, int rowIndex, int cellIndex /*Prefix and suffix blank space will lost after SaveAs #294*/ var preserveSpace = cellValue != null && (cellValue.StartsWith(" ", StringComparison.Ordinal) || cellValue.EndsWith(" ", StringComparison.Ordinal)); writer.Write(WorksheetXml.Cell(columnReference, dataType, styleIndex, cellValue, preserveSpace: preserveSpace, columnType: columnType)); + widthCollection?.AdjustWidth(cellIndex, cellValue); } private static void WriteCell(MiniExcelStreamWriter writer, string cellReference, string columnName) diff --git a/src/MiniExcel/OpenXml/ExcelWidthCollection.cs b/src/MiniExcel/OpenXml/ExcelWidthCollection.cs new file mode 100644 index 00000000..c8eaca08 --- /dev/null +++ b/src/MiniExcel/OpenXml/ExcelWidthCollection.cs @@ -0,0 +1,74 @@ +using MiniExcelLibs.Utils; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace MiniExcelLibs.OpenXml +{ + public sealed class ExcelColumnWidth + { + public int Index { get; set; } + public double Width { get; set; } + + internal static IEnumerable FromProps(IEnumerable props, double? minWidth = null) + { + var i = 1; + foreach (var p in props) + { + if (p == null || (p.ExcelColumnWidth == null && minWidth == null)) + { + i++; + continue; + } + var colIndex = p.ExcelColumnIndex == null ? i : p.ExcelColumnIndex.GetValueOrDefault() + 1; + yield return new ExcelColumnWidth + { + Index = colIndex, + Width = p.ExcelColumnWidth ?? minWidth.Value, + }; + i++; + } + } + } + + public sealed class ExcelWidthCollection + { + private readonly Dictionary _columnWidths; + private readonly double _maxWidth; + + public IEnumerable Columns => _columnWidths.Values; + + internal ExcelWidthCollection(double minWidth, double maxWidth, IEnumerable props) + { + _maxWidth = maxWidth; + _columnWidths = ExcelColumnWidth.FromProps(props, minWidth).ToDictionary(x => x.Index); + } + + public void AdjustWidth(int columnIndex, string columnValue) + { + if (string.IsNullOrEmpty(columnValue) || !_columnWidths.TryGetValue(columnIndex, out var currentWidth)) + { + return; + } + + var adjustedWidth = Math.Max(currentWidth.Width, GetApproximateRequiredCalibriWidth(columnValue.Length)); + currentWidth.Width = Math.Min(_maxWidth, adjustedWidth); + } + + /// + /// Get the approximate width of the given text for Calibri 11pt + /// + /// + /// Rounds the result to 2 decimal places. + /// + public static double GetApproximateRequiredCalibriWidth(int textLength) + { + double characterWidthFactor = 1.2; // Estimated factor for Calibri, 11pt + double padding = 2; // Add some padding for extra spacing + + double excelColumnWidth = (textLength * characterWidthFactor) + padding; + + return Math.Round(excelColumnWidth, 2); + } + } +} diff --git a/src/MiniExcel/OpenXml/MiniExcelAsyncStreamWriter.cs b/src/MiniExcel/OpenXml/MiniExcelAsyncStreamWriter.cs index ccc59a62..828830f1 100644 --- a/src/MiniExcel/OpenXml/MiniExcelAsyncStreamWriter.cs +++ b/src/MiniExcel/OpenXml/MiniExcelAsyncStreamWriter.cs @@ -39,6 +39,11 @@ public async Task WriteAndFlushAsync(string content) return await this.FlushAsync(); } + public async Task WriteWhitespaceAsync(int length) + { + await _streamWriter.WriteAsync(new string(' ', length)); + } + public async Task FlushAsync() { this._cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/MiniExcel/OpenXml/MiniExcelStreamWriter.cs b/src/MiniExcel/OpenXml/MiniExcelStreamWriter.cs index 1cc00185..a6a66968 100644 --- a/src/MiniExcel/OpenXml/MiniExcelStreamWriter.cs +++ b/src/MiniExcel/OpenXml/MiniExcelStreamWriter.cs @@ -30,6 +30,11 @@ public long WriteAndFlush(string content) return this._streamWriter.BaseStream.Position; } + public void WriteWhitespace(int length) + { + _streamWriter.Write(new string(' ', length)); + } + public long Flush() { this._streamWriter.Flush(); diff --git a/src/MiniExcel/OpenXml/OpenXmlConfiguration.cs b/src/MiniExcel/OpenXml/OpenXmlConfiguration.cs index 218d9f42..c473903b 100644 --- a/src/MiniExcel/OpenXml/OpenXmlConfiguration.cs +++ b/src/MiniExcel/OpenXml/OpenXmlConfiguration.cs @@ -16,5 +16,14 @@ public class OpenXmlConfiguration : Configuration public bool EnableSharedStringCache { get; set; } = true; public long SharedStringCacheSize { get; set; } = 5 * 1024 * 1024; public DynamicExcelSheet[] DynamicSheets { get; set; } + + /// + /// Calculate column widths automatically from each column value. + /// + public bool EnableAutoWidth { get; set; } + + public double MinWidth { get; set; } = 9.28515625; + + public double MaxWidth { get; set; } = 200; } } \ No newline at end of file diff --git a/tests/MiniExcelTests/MiniExcelAutoAdjustWidthTests.cs b/tests/MiniExcelTests/MiniExcelAutoAdjustWidthTests.cs new file mode 100644 index 00000000..c7919a33 --- /dev/null +++ b/tests/MiniExcelTests/MiniExcelAutoAdjustWidthTests.cs @@ -0,0 +1,194 @@ +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Spreadsheet; +using MiniExcelLibs; +using MiniExcelLibs.OpenXml; +using MiniExcelLibs.Tests.Utils; +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.SQLite; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace MiniExcelTests +{ + public class MiniExcelAutoAdjustWidthTests + { + [Fact] + public async Task AutoAdjustWidthThrowsExceptionWithoutFastMode_Async() + { + var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.xlsx"); + await Assert.ThrowsAsync(() => MiniExcel.SaveAsAsync(path, AutoAdjustTestParameters.GetDictionaryTestData(), configuration: new OpenXmlConfiguration + { + EnableAutoWidth = true, + })); + } + + [Fact] + public void AutoAdjustWidthThrowsExceptionWithoutFastMode() + { + var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.xlsx"); + Assert.Throws(() => MiniExcel.SaveAs(path, AutoAdjustTestParameters.GetDictionaryTestData(), configuration: new OpenXmlConfiguration + { + EnableAutoWidth = true, + })); + } + + [Fact] + public async Task AutoAdjustWidthEnumerable_Async() + { + var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.xlsx"); + var configuration = AutoAdjustTestParameters.GetConfiguration(); + + await MiniExcel.SaveAsAsync(path, AutoAdjustTestParameters.GetDictionaryTestData(), configuration: configuration); + + AssertExpectedWidth(path, configuration); + } + + [Fact] + public void AutoAdjustWidthEnumerable() + { + var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.xlsx"); + var configuration = AutoAdjustTestParameters.GetConfiguration(); + + MiniExcel.SaveAs(path, AutoAdjustTestParameters.GetDictionaryTestData(), configuration: configuration); + + AssertExpectedWidth(path, configuration); + } + + [Fact] + public async Task AutoAdjustWidthDataReader_Async() + { + var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.xlsx"); + var configuration = AutoAdjustTestParameters.GetConfiguration(); + + using (var connection = Db.GetConnection("Data Source=:memory:")) + { + using var command = new SQLiteCommand(Db.GenerateDummyQuery(AutoAdjustTestParameters.GetDictionaryTestData()), connection); + connection.Open(); + using var reader = command.ExecuteReader(); + await MiniExcel.SaveAsAsync(path, reader, configuration: configuration); + } + + AssertExpectedWidth(path, configuration); + } + + [Fact] + public void AutoAdjustWidthDataReader() + { + var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.xlsx"); + var configuration = AutoAdjustTestParameters.GetConfiguration(); + + using (var connection = Db.GetConnection("Data Source=:memory:")) + { + using var command = new SQLiteCommand(Db.GenerateDummyQuery(AutoAdjustTestParameters.GetDictionaryTestData()), connection); + connection.Open(); + using var reader = command.ExecuteReader(); + MiniExcel.SaveAs(path, reader, configuration: configuration); + } + + AssertExpectedWidth(path, configuration); + } + + + [Fact] + public async Task AutoAdjustWidthDataTable_Async() + { + var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.xlsx"); + var table = new DataTable(); + table.Columns.Add("Column1", typeof(string)); + table.Columns.Add("Column2", typeof(string)); + table.Columns.Add("Column3", typeof(string)); + table.Columns.Add("Column4", typeof(string)); + + foreach (var row in AutoAdjustTestParameters.GetTestData()) + { + table.Rows.Add(row); + } + + var configuration = AutoAdjustTestParameters.GetConfiguration(); + await MiniExcel.SaveAsAsync(path, table, configuration: configuration); + + AssertExpectedWidth(path, configuration); + } + + [Fact] + public void AutoAdjustWidthDataTable() + { + var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.xlsx"); + var table = new DataTable(); + table.Columns.Add("Column1", typeof(string)); + table.Columns.Add("Column2", typeof(string)); + table.Columns.Add("Column3", typeof(string)); + table.Columns.Add("Column4", typeof(string)); + + foreach (var row in AutoAdjustTestParameters.GetTestData()) + { + table.Rows.Add(row); + } + + var configuration = AutoAdjustTestParameters.GetConfiguration(); + MiniExcel.SaveAs(path, table, configuration: configuration); + + AssertExpectedWidth(path, configuration); + } + + private void AssertExpectedWidth(string path, OpenXmlConfiguration configuration) + { + using var document = SpreadsheetDocument.Open(path, false); + var worksheetPart = document.WorkbookPart.WorksheetParts.First(); + + var columns = worksheetPart.Worksheet.GetFirstChild(); + Assert.False(columns == null, "No column width information was written."); + foreach (Column column in columns.Elements()) + { + var expectedWidth = column.Min.Value switch + { + 1 => ExcelWidthCollection.GetApproximateRequiredCalibriWidth(AutoAdjustTestParameters.column1MaxStringLength), + 2 => ExcelWidthCollection.GetApproximateRequiredCalibriWidth(AutoAdjustTestParameters.column2MaxStringLength), + 3 => configuration.MinWidth, + 4 => configuration.MaxWidth, + _ => throw new Exception("Unexpected column"), + }; + + Assert.Equal(expectedWidth, column.Width?.Value); + } + } + + private static class AutoAdjustTestParameters + { + public const int column1MaxStringLength = 32; + public const int column2MaxStringLength = 16; + public const int column3MaxStringLength = 2; + public const int column4MaxStringLength = 100; + public const int minStringLength = 8; + public const int maxStringLength = 50; + + public static List GetTestData() + { + return new List + { + new string[] { new ('1', column1MaxStringLength), new ('2', column2MaxStringLength / 2), new ('3', column3MaxStringLength / 2), new ('4', column1MaxStringLength) }, + new string[] { new ('1', column1MaxStringLength / 2), new('2', column2MaxStringLength), new ('3', column3MaxStringLength), new ('4', column4MaxStringLength) } + }; + } + + public static List> GetDictionaryTestData() + { + return GetTestData() + .Select(row => row.Select((value, i) => (value, i)).ToDictionary(x => $"Column{x.i}", x => (object)x.value)) + .ToList(); + } + + public static OpenXmlConfiguration GetConfiguration() => new OpenXmlConfiguration + { + EnableAutoWidth = true, + FastMode = true, + MinWidth = ExcelWidthCollection.GetApproximateRequiredCalibriWidth(minStringLength), + MaxWidth = ExcelWidthCollection.GetApproximateRequiredCalibriWidth(maxStringLength) + }; + } + } +} diff --git a/tests/MiniExcelTests/MiniExcelOpenXmlAsyncTests.cs b/tests/MiniExcelTests/MiniExcelOpenXmlAsyncTests.cs index 364aafc0..b49e73c9 100644 --- a/tests/MiniExcelTests/MiniExcelOpenXmlAsyncTests.cs +++ b/tests/MiniExcelTests/MiniExcelOpenXmlAsyncTests.cs @@ -1489,6 +1489,5 @@ public async Task DynamicColumnsConfigurationIsUsedWhenCreatingExcelUsingDataTab Assert.Equal(onlyDate.ToDateTime(TimeOnly.MinValue), (DateTime)rows[1]["Column4"]); } } - } } \ No newline at end of file diff --git a/tests/MiniExcelTests/Utils/Db.cs b/tests/MiniExcelTests/Utils/Db.cs index 26813325..19555f83 100644 --- a/tests/MiniExcelTests/Utils/Db.cs +++ b/tests/MiniExcelTests/Utils/Db.cs @@ -1,6 +1,9 @@ namespace MiniExcelLibs.Tests.Utils { + using System.Collections.Generic; + using System; using System.Data.SQLite; + using System.Text; internal static class Db { @@ -8,5 +11,47 @@ internal static SQLiteConnection GetConnection(string connectionString = "Data S { return new SQLiteConnection(connectionString); } + + internal static string GenerateDummyQuery(List> data) + { + if (data == null || data.Count == 0) + throw new ArgumentException("The data list cannot be null or empty."); + + var queryBuilder = new StringBuilder(); + + for (int i = 0; i < data.Count; i++) + { + var row = data[i]; + var selectStatement = new StringBuilder("SELECT "); + + foreach (var kvp in row) + { + string columnName = kvp.Key; + object value = kvp.Value; + + // Format value based on its type + string formattedValue = value switch + { + string str => $"'{str.Replace("'", "''")}'", // Escape single quotes in strings + DateTime dt => $"'{dt:yyyy-MM-dd HH:mm:ss}'", // Format datetime as string + bool b => b ? "1" : "0", // Convert boolean to 1 or 0 + _ => value.ToString() // Use value as-is for numbers and other types + }; + + selectStatement.Append($"{formattedValue} AS {columnName}, "); + } + + // Remove the trailing comma and space + selectStatement.Length -= 2; + + // Add UNION ALL between each row, except for the last one + if (i < data.Count - 1) + selectStatement.Append(" UNION ALL "); + + queryBuilder.AppendLine(selectStatement.ToString()); + } + + return queryBuilder.ToString(); + } } }