diff --git a/README_V2.md b/README_V2.md index 8402a915..f12b6f86 100644 --- a/README_V2.md +++ b/README_V2.md @@ -745,6 +745,28 @@ exporter.Export("Book1.xlsx", dt, configuration: config); ![image](docs/images/freeze-pane-1.png) +#### 14. Per-cell formulas and formatting + +Wrapping a value in `OpenXmlCellValue` sets a formula, number format or style for that one cell, overriding the column's settings: + +```csharp +var rows = new[] +{ + new Dictionary + { + ["Input"] = 3, + ["Share"] = new OpenXmlCellValue(0.25) { NumberFormat = "0.0%", Style = { FontColor = Color.Red } }, + ["Double"] = new OpenXmlCellValue(6) { Formula = "A2*2" } + } +}; + +var exporter = MiniExcelV2.Exporters.GetOpenXmlExporter(); +exporter.Export("Book1.xlsx", rows); +``` + +The value given with a formula is written as its cached result, which Excel shows until it recalculates. Pass `null` to leave the result for Excel to calculate. +A cell with only a style keeps the number format it would otherwise have had. + ### Fill Data To Excel Template diff --git a/src/MiniExcel.OpenXml/Constants/WorksheetXml.cs b/src/MiniExcel.OpenXml/Constants/WorksheetXml.cs index d5f14f9e..b20f0fab 100644 --- a/src/MiniExcel.OpenXml/Constants/WorksheetXml.cs +++ b/src/MiniExcel.OpenXml/Constants/WorksheetXml.cs @@ -67,6 +67,16 @@ internal static string Cell(string cellReference, string? cellType, string style }; } + /// + /// A formula cell, with its cached result when is not null. + /// + internal static string FormulaCell(string cellReference, string? cellType, string styleIndex, string formula, string? cachedValue) + { + var type = cachedValue is null ? "" : $" t=\"{cellType}\""; + var value = cachedValue is null ? "" : $"{XmlHelper.EncodeXml(cachedValue)}"; + return $"""{XmlHelper.EncodeXml(formula)}{value}"""; + } + internal static string Autofilter(string dimensionRef) => $""; internal static string Drawing(int sheetIndex) => $""; } diff --git a/src/MiniExcel.OpenXml/Models/OpenXmlCellValue.cs b/src/MiniExcel.OpenXml/Models/OpenXmlCellValue.cs new file mode 100644 index 00000000..a96d12d6 --- /dev/null +++ b/src/MiniExcel.OpenXml/Models/OpenXmlCellValue.cs @@ -0,0 +1,30 @@ +using MiniExcelLib.OpenXml.Styles; + +namespace MiniExcelLib.OpenXml.Models; + +/// +/// A value to export together with a formula or style of its own, overriding the column's settings for that cell. +/// +/// The value written to the cell. When is set, it is the cached result of the formula. +public sealed class OpenXmlCellValue(object? value) +{ + /// + /// The value written to the cell, or the cached result when is set. + /// + public object? Value { get; } = value; + + /// + /// A formula for the cell, without the leading equals sign. + /// + public string? Formula { get; set; } + + /// + /// A number format for the cell, replacing the column's format. + /// + public string? NumberFormat { get; set; } + + /// + /// The style of the cell, such as its font color. + /// + public OpenXmlCellStyle Style { get; } = new(); +} diff --git a/src/MiniExcel.OpenXml/Styles/Builder/DefaultSheetStyleBuilder.cs b/src/MiniExcel.OpenXml/Styles/Builder/DefaultSheetStyleBuilder.cs index aa6f6073..a93e1657 100644 --- a/src/MiniExcel.OpenXml/Styles/Builder/DefaultSheetStyleBuilder.cs +++ b/src/MiniExcel.OpenXml/Styles/Builder/DefaultSheetStyleBuilder.cs @@ -20,6 +20,20 @@ internal partial class DefaultSheetStyleBuilder(SheetStyleBuilderContext context private readonly OpenXmlStyleOptions _styleOptions = styleOptions; + private string CellHorizontalAlignment => _styleOptions.HorizontalAlignment switch + { + HorizontalCellAlignment.Center => "center", + HorizontalCellAlignment.Right => "right", + _ => "general" + }; + + private string CellVerticalAlignment => _styleOptions.VerticalAlignment switch + { + VerticalCellAlignment.Top => "top", + VerticalCellAlignment.Center => "center", + _ => "bottom" + }; + protected internal override SheetStyleElementInfos GetGeneratedElementInfos() { return GeneratedElementInfos; @@ -28,20 +42,7 @@ protected internal override SheetStyleElementInfos GetGeneratedElementInfos() [CreateSyncVersion] protected override async Task GenerateNumFmtAsync() { - const int numFmtIndex = 166; - var index = 0; - foreach (var map in Context.SheetStyleFormatsCache.FormatMappings) - { - index++; - - /* - * "bottom" }; - var cellHorizontalAlignment = _styleOptions.HorizontalAlignment switch - { - HorizontalCellAlignment.Center => "center", - HorizontalCellAlignment.Right => "right", - _ => "general" - }; + var cellHorizontalAlignment = CellHorizontalAlignment; + var cellVerticalAlignment = CellVerticalAlignment; - var cellVerticalAlignment = _styleOptions.VerticalAlignment switch - { - VerticalCellAlignment.Top => "top", - VerticalCellAlignment.Center => "center", - _ => "bottom" - }; - /* Empty style is required because Excel always considers the first one to be the default and ignores all its properties * * */ @@ -529,42 +521,77 @@ protected override async Task GenerateCellXfAsync() await NewWriter.WriteEndElementAsync().ConfigureAwait(false); await NewWriter.WriteEndElementAsync().ConfigureAwait(false); - const int numFmtIndex = 166; - for (var i = 1; i <= Context.CustomFormatCount; i++) - { - /* - * - * - * - */ - await NewWriter.WriteStartElementAsync(OldReader.Prefix, "xf", OldReader.NamespaceURI).ConfigureAwait(false); - await NewWriter.WriteAttributeStringAsync(null, "numFmtId", null, (numFmtIndex + i + Context.OldElementInfos.NumFmtCount).ToString()).ConfigureAwait(false); - await NewWriter.WriteAttributeStringAsync(null, "fontId", null, $"{Context.OldElementInfos.FontCount}").ConfigureAwait(false); - await NewWriter.WriteAttributeStringAsync(null, "fillId", null, $"{Context.OldElementInfos.FillCount}").ConfigureAwait(false); - await NewWriter.WriteAttributeStringAsync(null, "borderId", null, $"{Context.OldElementInfos.BorderCount + 1}").ConfigureAwait(false); - await NewWriter.WriteAttributeStringAsync(null, "xfId", null, "0").ConfigureAwait(false); - await NewWriter.WriteAttributeStringAsync(null, "applyNumberFormat", null, "1").ConfigureAwait(false); - await NewWriter.WriteAttributeStringAsync(null, "applyFill", null, "1").ConfigureAwait(false); - await NewWriter.WriteAttributeStringAsync(null, "applyBorder", null, "1").ConfigureAwait(false); - await NewWriter.WriteAttributeStringAsync(null, "applyAlignment", null, "1").ConfigureAwait(false); - await NewWriter.WriteAttributeStringAsync(null, "applyProtection", null, "1").ConfigureAwait(false); - await NewWriter.WriteStartElementAsync(OldReader.Prefix, "alignment", OldReader.NamespaceURI).ConfigureAwait(false); - await NewWriter.WriteAttributeStringAsync(null, "horizontal", null, cellHorizontalAlignment).ConfigureAwait(false); - await NewWriter.WriteAttributeStringAsync(null, "vertical", null, cellVerticalAlignment).ConfigureAwait(false); - await NewWriter.WriteAttributeStringAsync(null, "textRotation", null, "0").ConfigureAwait(false); - await NewWriter.WriteAttributeStringAsync(null, "wrapText", null, "0").ConfigureAwait(false); - await NewWriter.WriteAttributeStringAsync(null, "indent", null, "0").ConfigureAwait(false); - await NewWriter.WriteAttributeStringAsync(null, "relativeIndent", null, "0").ConfigureAwait(false); - await NewWriter.WriteAttributeStringAsync(null, "justifyLastLine", null, "0").ConfigureAwait(false); - await NewWriter.WriteAttributeStringAsync(null, "shrinkToFit", null, "0").ConfigureAwait(false); - await NewWriter.WriteAttributeStringAsync(null, "readingOrder", null, "0").ConfigureAwait(false); - await NewWriter.WriteEndElementAsync().ConfigureAwait(false); - await NewWriter.WriteStartElementAsync(OldReader.Prefix, "protection", OldReader.NamespaceURI).ConfigureAwait(false); - await NewWriter.WriteAttributeStringAsync(null, "locked", null, "1").ConfigureAwait(false); - await NewWriter.WriteAttributeStringAsync(null, "hidden", null, "0").ConfigureAwait(false); - await NewWriter.WriteEndElementAsync().ConfigureAwait(false); - await NewWriter.WriteEndElementAsync().ConfigureAwait(false); - } + await GenerateCustomCellXfsAsync().ConfigureAwait(false); + } + + [CreateSyncVersion] + protected override async Task GenerateCustomFontAsync(string argb) + { + /* + * + * + * + * + * + * + * + */ + await NewWriter.WriteStartElementAsync(OldReader.Prefix, "font", OldReader.NamespaceURI).ConfigureAwait(false); + await NewWriter.WriteStartElementAsync(OldReader.Prefix, "vertAlign", OldReader.NamespaceURI).ConfigureAwait(false); + await NewWriter.WriteAttributeStringAsync(null, "val", null, "baseline").ConfigureAwait(false); + await NewWriter.WriteEndElementAsync().ConfigureAwait(false); + await NewWriter.WriteStartElementAsync(OldReader.Prefix, "sz", OldReader.NamespaceURI).ConfigureAwait(false); + await NewWriter.WriteAttributeStringAsync(null, "val", null, "11").ConfigureAwait(false); + await NewWriter.WriteEndElementAsync().ConfigureAwait(false); + await NewWriter.WriteStartElementAsync(OldReader.Prefix, "color", OldReader.NamespaceURI).ConfigureAwait(false); + await NewWriter.WriteAttributeStringAsync(null, "rgb", null, argb).ConfigureAwait(false); + await NewWriter.WriteEndElementAsync().ConfigureAwait(false); + await NewWriter.WriteStartElementAsync(OldReader.Prefix, "name", OldReader.NamespaceURI).ConfigureAwait(false); + await NewWriter.WriteAttributeStringAsync(null, "val", null, "Calibri").ConfigureAwait(false); + await NewWriter.WriteEndElementAsync().ConfigureAwait(false); + await NewWriter.WriteStartElementAsync(OldReader.Prefix, "family", OldReader.NamespaceURI).ConfigureAwait(false); + await NewWriter.WriteAttributeStringAsync(null, "val", null, "2").ConfigureAwait(false); + await NewWriter.WriteEndElementAsync().ConfigureAwait(false); + await NewWriter.WriteEndElementAsync().ConfigureAwait(false); + } + + [CreateSyncVersion] + protected override async Task GenerateCustomCellXfAsync(string numFmtId, string? fontId) + { + /* + * + * + * + */ + await NewWriter.WriteStartElementAsync(OldReader.Prefix, "xf", OldReader.NamespaceURI).ConfigureAwait(false); + await NewWriter.WriteAttributeStringAsync(null, "numFmtId", null, numFmtId).ConfigureAwait(false); + await NewWriter.WriteAttributeStringAsync(null, "fontId", null, fontId ?? $"{Context.OldElementInfos.FontCount}").ConfigureAwait(false); + await NewWriter.WriteAttributeStringAsync(null, "fillId", null, $"{Context.OldElementInfos.FillCount}").ConfigureAwait(false); + await NewWriter.WriteAttributeStringAsync(null, "borderId", null, $"{Context.OldElementInfos.BorderCount + 1}").ConfigureAwait(false); + await NewWriter.WriteAttributeStringAsync(null, "xfId", null, "0").ConfigureAwait(false); + await NewWriter.WriteAttributeStringAsync(null, "applyNumberFormat", null, "1").ConfigureAwait(false); + if (fontId is not null) + await NewWriter.WriteAttributeStringAsync(null, "applyFont", null, "1").ConfigureAwait(false); + await NewWriter.WriteAttributeStringAsync(null, "applyFill", null, "1").ConfigureAwait(false); + await NewWriter.WriteAttributeStringAsync(null, "applyBorder", null, "1").ConfigureAwait(false); + await NewWriter.WriteAttributeStringAsync(null, "applyAlignment", null, "1").ConfigureAwait(false); + await NewWriter.WriteAttributeStringAsync(null, "applyProtection", null, "1").ConfigureAwait(false); + await NewWriter.WriteStartElementAsync(OldReader.Prefix, "alignment", OldReader.NamespaceURI).ConfigureAwait(false); + await NewWriter.WriteAttributeStringAsync(null, "horizontal", null, CellHorizontalAlignment).ConfigureAwait(false); + await NewWriter.WriteAttributeStringAsync(null, "vertical", null, CellVerticalAlignment).ConfigureAwait(false); + await NewWriter.WriteAttributeStringAsync(null, "textRotation", null, "0").ConfigureAwait(false); + await NewWriter.WriteAttributeStringAsync(null, "wrapText", null, "0").ConfigureAwait(false); + await NewWriter.WriteAttributeStringAsync(null, "indent", null, "0").ConfigureAwait(false); + await NewWriter.WriteAttributeStringAsync(null, "relativeIndent", null, "0").ConfigureAwait(false); + await NewWriter.WriteAttributeStringAsync(null, "justifyLastLine", null, "0").ConfigureAwait(false); + await NewWriter.WriteAttributeStringAsync(null, "shrinkToFit", null, "0").ConfigureAwait(false); + await NewWriter.WriteAttributeStringAsync(null, "readingOrder", null, "0").ConfigureAwait(false); + await NewWriter.WriteEndElementAsync().ConfigureAwait(false); + await NewWriter.WriteStartElementAsync(OldReader.Prefix, "protection", OldReader.NamespaceURI).ConfigureAwait(false); + await NewWriter.WriteAttributeStringAsync(null, "locked", null, "1").ConfigureAwait(false); + await NewWriter.WriteAttributeStringAsync(null, "hidden", null, "0").ConfigureAwait(false); + await NewWriter.WriteEndElementAsync().ConfigureAwait(false); + await NewWriter.WriteEndElementAsync().ConfigureAwait(false); } } diff --git a/src/MiniExcel.OpenXml/Styles/Builder/MinimalSheetStyleBuilder.cs b/src/MiniExcel.OpenXml/Styles/Builder/MinimalSheetStyleBuilder.cs index 7afce315..41d77a07 100644 --- a/src/MiniExcel.OpenXml/Styles/Builder/MinimalSheetStyleBuilder.cs +++ b/src/MiniExcel.OpenXml/Styles/Builder/MinimalSheetStyleBuilder.cs @@ -20,20 +20,7 @@ protected internal override SheetStyleElementInfos GetGeneratedElementInfos() [CreateSyncVersion] protected override async Task GenerateNumFmtAsync() { - const int numFmtIndex = 166; - var index = 0; - foreach (var map in Context.SheetStyleFormatsCache.FormatMappings) - { - index++; - - /* - * - */ - await NewWriter.WriteStartElementAsync(OldReader.Prefix, "numFmt", OldReader.NamespaceURI).ConfigureAwait(false); - await NewWriter.WriteAttributeStringAsync(null, "numFmtId", null, (numFmtIndex + index + Context.OldElementInfos.NumFmtCount).ToString()).ConfigureAwait(false); - await NewWriter.WriteAttributeStringAsync(null, "formatCode", null, map.Format).ConfigureAwait(false); - await NewWriter.WriteEndElementAsync().ConfigureAwait(false); - } + await GenerateCustomNumFmtsAsync().ConfigureAwait(false); } [CreateSyncVersion] @@ -44,6 +31,8 @@ protected override async Task GenerateFontAsync() */ await NewWriter.WriteStartElementAsync(OldReader.Prefix, "font", OldReader.NamespaceURI).ConfigureAwait(false); await NewWriter.WriteEndElementAsync().ConfigureAwait(false); + + await GenerateCustomFontsAsync().ConfigureAwait(false); } [CreateSyncVersion] @@ -119,20 +108,37 @@ protected override async Task GenerateCellXfAsync() await NewWriter.WriteAttributeStringAsync(null, "applyNumberFormat", null, "1").ConfigureAwait(false); await NewWriter.WriteEndElementAsync().ConfigureAwait(false); - const int numFmtIndex = 166; - for (var i = 1; i <= Context.CustomFormatCount; i++) - { - /* - * + */ + await NewWriter.WriteStartElementAsync(OldReader.Prefix, "font", OldReader.NamespaceURI).ConfigureAwait(false); + await NewWriter.WriteStartElementAsync(OldReader.Prefix, "color", OldReader.NamespaceURI).ConfigureAwait(false); + await NewWriter.WriteAttributeStringAsync(null, "rgb", null, argb).ConfigureAwait(false); + await NewWriter.WriteEndElementAsync().ConfigureAwait(false); + await NewWriter.WriteEndElementAsync().ConfigureAwait(false); + } + + [CreateSyncVersion] + protected override async Task GenerateCustomCellXfAsync(string numFmtId, string? fontId) + { + /* + * + */ + await NewWriter.WriteStartElementAsync(OldReader.Prefix, "xf", OldReader.NamespaceURI).ConfigureAwait(false); + await NewWriter.WriteAttributeStringAsync(null, "numFmtId", null, numFmtId).ConfigureAwait(false); + await NewWriter.WriteAttributeStringAsync(null, "fontId", null, fontId ?? "0").ConfigureAwait(false); + 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); + await NewWriter.WriteAttributeStringAsync(null, "applyNumberFormat", null, "1").ConfigureAwait(false); + if (fontId is not null) + await NewWriter.WriteAttributeStringAsync(null, "applyFont", null, "1").ConfigureAwait(false); + await NewWriter.WriteEndElementAsync().ConfigureAwait(false); } } diff --git a/src/MiniExcel.OpenXml/Styles/Builder/SheetStyleBuildContext.cs b/src/MiniExcel.OpenXml/Styles/Builder/SheetStyleBuildContext.cs index 35cd1826..00765c0a 100644 --- a/src/MiniExcel.OpenXml/Styles/Builder/SheetStyleBuildContext.cs +++ b/src/MiniExcel.OpenXml/Styles/Builder/SheetStyleBuildContext.cs @@ -28,7 +28,9 @@ internal sealed partial class SheetStyleBuilderContext(Dictionary SheetStyleFormatsCache.FormatMappingsCount; + public int CustomNumberFormatCount => SheetStyleFormatsCache.NumberFormats.Count; + public int CustomFontCount => SheetStyleFormatsCache.FontColors.Count; + public int CustomCellFormatCount => SheetStyleFormatsCache.CellFormats.Count; [CreateSyncVersion] public async Task CreateAsync(SheetStyleElementInfos generatedElementInfos, CancellationToken cancellationToken = default) @@ -115,6 +117,9 @@ public void UpdateFormatIds(ICollection mappings) { SheetStyleFormatsCache.AddMappings(mappings); } + + public int GetCellFormatIndex(string? numberFormat, int builtInNumberFormatId, System.Drawing.Color? fontColor) + => SheetStyleFormatsCache.GetCellFormatIndex(numberFormat, builtInNumberFormatId, fontColor); [CreateSyncVersion] public async Task FinalizeAndUpdateZipDictionaryAsync(CancellationToken cancellationToken = default) diff --git a/src/MiniExcel.OpenXml/Styles/Builder/SheetStyleBuilderBase.cs b/src/MiniExcel.OpenXml/Styles/Builder/SheetStyleBuilderBase.cs index 7984c02d..b4663253 100644 --- a/src/MiniExcel.OpenXml/Styles/Builder/SheetStyleBuilderBase.cs +++ b/src/MiniExcel.OpenXml/Styles/Builder/SheetStyleBuilderBase.cs @@ -106,12 +106,12 @@ protected virtual async Task WriteAttributesAsync(string element, CancellationTo { var value = element switch { - "numFmts" => (Context.OldElementInfos.NumFmtCount + Context.GeneratedElementInfos.NumFmtCount + Context.CustomFormatCount).ToString(), - "fonts" => (Context.OldElementInfos.FontCount + Context.GeneratedElementInfos.FontCount).ToString(), + "numFmts" => (Context.OldElementInfos.NumFmtCount + Context.GeneratedElementInfos.NumFmtCount + Context.CustomNumberFormatCount).ToString(), + "fonts" => (Context.OldElementInfos.FontCount + Context.GeneratedElementInfos.FontCount + Context.CustomFontCount).ToString(), "fills" => (Context.OldElementInfos.FillCount + Context.GeneratedElementInfos.FillCount).ToString(), "borders" => (Context.OldElementInfos.BorderCount + Context.GeneratedElementInfos.BorderCount).ToString(), "cellStyleXfs" => (Context.OldElementInfos.CellStyleXfCount + Context.GeneratedElementInfos.CellStyleXfCount).ToString(), - "cellXfs" => (Context.OldElementInfos.CellXfCount + Context.GeneratedElementInfos.CellXfCount + Context.CustomFormatCount).ToString(), + "cellXfs" => (Context.OldElementInfos.CellXfCount + Context.GeneratedElementInfos.CellXfCount + Context.CustomCellFormatCount).ToString(), _ => OldReader.Value }; await NewWriter.WriteStringAsync(value).ConfigureAwait(false); @@ -198,7 +198,7 @@ protected virtual async Task GenerateElementBeforEndElementAsync() protected virtual async Task GenerateNumFmtsAsync() { await NewWriter.WriteStartElementAsync(OldReader.Prefix, "numFmts", OldReader.NamespaceURI).ConfigureAwait(false); - await NewWriter.WriteAttributeStringAsync(null, "count", null, (Context.OldElementInfos.NumFmtCount + Context.GeneratedElementInfos.NumFmtCount + Context.CustomFormatCount).ToString()).ConfigureAwait(false); + await NewWriter.WriteAttributeStringAsync(null, "count", null, (Context.OldElementInfos.NumFmtCount + Context.GeneratedElementInfos.NumFmtCount + Context.CustomNumberFormatCount).ToString()).ConfigureAwait(false); await GenerateNumFmtAsync().ConfigureAwait(false); await NewWriter.WriteFullEndElementAsync().ConfigureAwait(false); @@ -215,7 +215,7 @@ protected virtual async Task GenerateNumFmtsAsync() protected virtual async Task GenerateFontsAsync() { await NewWriter.WriteStartElementAsync(OldReader.Prefix, "fonts", OldReader.NamespaceURI).ConfigureAwait(false); - await NewWriter.WriteAttributeStringAsync(null, "count", null, (Context.OldElementInfos.FontCount + Context.GeneratedElementInfos.FontCount).ToString()).ConfigureAwait(false); + await NewWriter.WriteAttributeStringAsync(null, "count", null, (Context.OldElementInfos.FontCount + Context.GeneratedElementInfos.FontCount + Context.CustomFontCount).ToString()).ConfigureAwait(false); await GenerateFontAsync().ConfigureAwait(false); await NewWriter.WriteFullEndElementAsync().ConfigureAwait(false); @@ -283,11 +283,66 @@ protected virtual async Task GenerateCellStyleXfsAsync() protected virtual async Task GenerateCellXfsAsync() { await NewWriter.WriteStartElementAsync(OldReader.Prefix, "cellXfs", OldReader.NamespaceURI).ConfigureAwait(false); - await NewWriter.WriteAttributeStringAsync(null, "count", null, (Context.OldElementInfos.CellXfCount + Context.GeneratedElementInfos.CellXfCount + Context.CustomFormatCount).ToString()).ConfigureAwait(false); + await NewWriter.WriteAttributeStringAsync(null, "count", null, (Context.OldElementInfos.CellXfCount + Context.GeneratedElementInfos.CellXfCount + Context.CustomCellFormatCount).ToString()).ConfigureAwait(false); await GenerateCellXfAsync().ConfigureAwait(false); await NewWriter.WriteFullEndElementAsync().ConfigureAwait(false); } [CreateSyncVersion] protected abstract Task GenerateCellXfAsync(); + + // Custom number formats get numFmtIds from 167 upwards, after any the workbook already had + private string GetCustomNumberFormatId(int position) => (167 + position + Context.OldElementInfos.NumFmtCount).ToString(); + + [CreateSyncVersion] + protected async Task GenerateCustomNumFmtsAsync() + { + var numberFormats = Context.SheetStyleFormatsCache.NumberFormats; + for (var i = 0; i < numberFormats.Count; i++) + { + /* + * + */ + await NewWriter.WriteStartElementAsync(OldReader.Prefix, "numFmt", OldReader.NamespaceURI).ConfigureAwait(false); + await NewWriter.WriteAttributeStringAsync(null, "numFmtId", null, GetCustomNumberFormatId(i)).ConfigureAwait(false); + await NewWriter.WriteAttributeStringAsync(null, "formatCode", null, numberFormats[i]).ConfigureAwait(false); + await NewWriter.WriteEndElementAsync().ConfigureAwait(false); + } + } + + [CreateSyncVersion] + protected async Task GenerateCustomFontsAsync() + { + foreach (var color in Context.SheetStyleFormatsCache.FontColors) + { + await GenerateCustomFontAsync($"{color.A:X2}{color.R:X2}{color.G:X2}{color.B:X2}").ConfigureAwait(false); + } + } + + /// + /// Writes a font element in the given ARGB color, otherwise like the builder's default font. + /// + [CreateSyncVersion] + protected abstract Task GenerateCustomFontAsync(string argb); + + [CreateSyncVersion] + protected async Task GenerateCustomCellXfsAsync() + { + var firstCustomFontId = Context.OldElementInfos.FontCount + Context.GeneratedElementInfos.FontCount; + foreach (var format in Context.SheetStyleFormatsCache.CellFormats) + { + var numFmtId = format.CustomNumberFormat is { } position + ? GetCustomNumberFormatId(position) + : format.BuiltInNumberFormatId.ToString(); + var fontId = format.FontColor is { } fontColor ? (firstCustomFontId + fontColor).ToString() : null; + + await GenerateCustomCellXfAsync(numFmtId, fontId).ConfigureAwait(false); + } + } + + /// + /// Writes an xf element for a regular cell with the given number format and, when not null, font. + /// + [CreateSyncVersion] + protected abstract Task GenerateCustomCellXfAsync(string numFmtId, string? fontId); } \ No newline at end of file diff --git a/src/MiniExcel.OpenXml/Styles/Builder/SheetStyleFormatsCache.cs b/src/MiniExcel.OpenXml/Styles/Builder/SheetStyleFormatsCache.cs index 2feb550b..3ec55603 100644 --- a/src/MiniExcel.OpenXml/Styles/Builder/SheetStyleFormatsCache.cs +++ b/src/MiniExcel.OpenXml/Styles/Builder/SheetStyleFormatsCache.cs @@ -1,30 +1,82 @@ +using System.Drawing; +// A cell format added on top of the builder's own. CustomNumberFormat and FontColor are positions in +// NumberFormats and FontColors, or null; BuiltInNumberFormatId applies when there is no custom number format. +using CustomCellFormat = (int? CustomNumberFormat, int BuiltInNumberFormatId, int? FontColor); + namespace MiniExcelLib.OpenXml.Styles.Builder; // todo: find a way to make it compatible with SharedStringsDiskCache internal class SheetStyleFormatsCache { - private readonly Dictionary _formatMappings = []; - private int _stylesCount; - - internal int FormatMappingsCount => _formatMappings.Count; - internal IEnumerable<(string Format, int FormatId)> FormatMappings => _formatMappings.Select(x => (x.Key, x.Value)); - + private readonly Dictionary _numberFormatIndexes = []; + private readonly List _numberFormats = []; + private readonly Dictionary _fontColorIndexes = []; + private readonly List _fontColors = []; + private readonly Dictionary _cellFormatIndexes = []; + private readonly List _cellFormats = []; + private int _firstCellFormatIndex; + + internal IReadOnlyList NumberFormats => _numberFormats; + internal IReadOnlyList FontColors => _fontColors; + internal IReadOnlyList CellFormats => _cellFormats; + public void AddMappings(IEnumerable mappings) { foreach (var mapping in mappings.Where(map => map is { ExcelIgnoreColumn: false })) { if (!string.IsNullOrWhiteSpace(mapping!.ExcelFormat) && new OpenXmlNumberFormatHelper(mapping.ExcelFormat).IsValid) { - if (!_formatMappings.TryGetValue(mapping.ExcelFormat, out var formatId)) - { - formatId = _stylesCount++; - _formatMappings.Add(mapping.ExcelFormat, formatId); - } + mapping.SetFormatId(GetCellFormatIndex(mapping.ExcelFormat, 0, null)); + } + } + } - mapping.SetFormatId(formatId); + /// + /// Gets the cellXfs index of a format, adding the format when it is new. + /// + /// A valid custom number format, or null. + /// The built-in numFmtId used when is null. + /// The font color, or null for the default font. + internal int GetCellFormatIndex(string? numberFormat, int builtInNumberFormatId, Color? fontColor) + { + int? customNumberFormat = null; + if (numberFormat is not null) + { + if (!_numberFormatIndexes.TryGetValue(numberFormat, out var position)) + { + position = _numberFormats.Count; + _numberFormats.Add(numberFormat); + _numberFormatIndexes.Add(numberFormat, position); } + + customNumberFormat = position; + builtInNumberFormatId = 0; } + + int? fontColorPosition = null; + if (fontColor is { } color) + { + var argb = color.ToArgb(); + if (!_fontColorIndexes.TryGetValue(argb, out var position)) + { + position = _fontColors.Count; + _fontColors.Add(color); + _fontColorIndexes.Add(argb, position); + } + + fontColorPosition = position; + } + + CustomCellFormat format = (customNumberFormat, builtInNumberFormatId, fontColorPosition); + if (!_cellFormatIndexes.TryGetValue(format, out var index)) + { + index = _firstCellFormatIndex + _cellFormats.Count; + _cellFormats.Add(format); + _cellFormatIndexes.Add(format, index); + } + + return index; } - - internal void SetCurrentIndex(int index) => _stylesCount = index; + + internal void SetCurrentIndex(int index) => _firstCellFormatIndex = index; } diff --git a/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.XmlGeneration.cs b/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.XmlGeneration.cs index 29e3aa00..ae476fd5 100644 --- a/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.XmlGeneration.cs +++ b/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.XmlGeneration.cs @@ -157,7 +157,7 @@ private string GetPanes() return sb.ToString(); } - private (string StyleIndex, string DataType, string? CellValue) GetCellValue(int rowIndex, int cellIndex, object value, MiniExcelColumnMapping? columnMapping, bool valueIsNull) + private (string StyleIndex, string DataType, string? CellValue) GetCellValue(int rowIndex, int cellIndex, object value, MiniExcelColumnMapping? columnMapping, bool valueIsNull, bool useRuntimeType = false) { if (valueIsNull) return (RegularCellStyleIndex, GetStringType(), string.Empty); @@ -168,7 +168,7 @@ private string GetPanes() return (styleIndex, GetStringType(), str); } - var type = GetValueType(value, columnMapping); + var type = useRuntimeType ? Nullable.GetUnderlyingType(value.GetType()) ?? value.GetType() : GetValueType(value, columnMapping); if (columnMapping is { ExcelFormat: not null, ExcelFormatId: -1 } && value is IFormattable formattableValue) { diff --git a/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.cs b/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.cs index 2c7aeaff..76ea51a0 100644 --- a/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.cs +++ b/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.cs @@ -440,6 +440,12 @@ private static async Task PrintHeaderAsync(MiniExcelStreamWriter writer, List ExportAsync(object value, OpenXmlConfiguration? configuration = null) + { + var stream = new MemoryStream(); + await _exporter.ExportAsync(stream, value, configuration: configuration); + stream.Position = 0; + return new XLWorkbook(stream); + } + + [Fact] + public async Task FormulaIsWrittenWithItsCachedResultAsync() + { + Dictionary[] rows = + [ + new() { ["A"] = 3, ["B"] = new OpenXmlCellValue(6) { Formula = "A2*2" }, ["C"] = new OpenXmlCellValue("x3") { Formula = "=\"x\"&A2" } } + ]; + + using var workbook = await ExportAsync(rows); + var sheet = workbook.Worksheet(1); + + Assert.Equal("A2*2", sheet.Cell("B2").FormulaA1); + Assert.Equal(6, sheet.Cell("B2").CachedValue.GetNumber()); + Assert.Equal("\"x\"&A2", sheet.Cell("C2").FormulaA1); + Assert.Equal("x3", sheet.Cell("C2").CachedValue.GetText()); + Assert.False(sheet.Cell("A2").HasFormula); + } + + [Fact] + public async Task FontColorAndNumberFormatApplyToTheCellOnlyAsync() + { + Dictionary[] rows = + [ + new() { ["Value"] = new OpenXmlCellValue(0.25) { NumberFormat = "0.0%", Style = { FontColor = Color.Red } } }, + new() { ["Value"] = new OpenXmlCellValue("grey") { Style = { FontColor = Color.Gray } } }, + new() { ["Value"] = 1.5 } + ]; + + using var workbook = await ExportAsync(rows); + var sheet = workbook.Worksheet(1); + + Assert.Equal(0.25, sheet.Cell("A2").GetDouble()); + Assert.Equal("0.0%", sheet.Cell("A2").Style.NumberFormat.Format); + Assert.Equal(Color.Red.ToArgb(), sheet.Cell("A2").Style.Font.FontColor.Color.ToArgb()); + Assert.Equal("grey", sheet.Cell("A3").GetString()); + Assert.Equal(Color.Gray.ToArgb(), sheet.Cell("A3").Style.Font.FontColor.Color.ToArgb()); + Assert.Equal(1.5, sheet.Cell("A4").GetDouble()); + Assert.NotEqual(Color.Red.ToArgb(), sheet.Cell("A4").Style.Font.FontColor.Color.ToArgb()); + } + + [Fact] + public async Task FontColorKeepsTheColumnFormatAsync() + { + Dictionary[] rows = + [ + new() { ["Amount"] = new OpenXmlCellValue(1.23456) { Style = { FontColor = Color.Blue } } }, + new() { ["Amount"] = 2.5 } + ]; + var configuration = new OpenXmlConfiguration { DynamicColumns = [new DynamicExcelColumn("Amount") { Format = "0.000" }] }; + + using var workbook = await ExportAsync(rows, configuration); + var sheet = workbook.Worksheet(1); + + Assert.Equal(1.23456, sheet.Cell("A2").GetDouble()); + Assert.Equal("0.000", sheet.Cell("A2").Style.NumberFormat.Format); + Assert.Equal(Color.Blue.ToArgb(), sheet.Cell("A2").Style.Font.FontColor.Color.ToArgb()); + Assert.Equal("0.000", sheet.Cell("A3").Style.NumberFormat.Format); + } + + [Fact] + public async Task WrappedValuesInTypedRowsUseTheirOwnTypeAsync() + { + FormattedRow[] rows = + [ + new() { Amount = new OpenXmlCellValue(1.23456) { Style = { FontColor = Color.Blue } }, When = new OpenXmlCellValue(new DateTime(2024, 5, 6)) { Style = { FontColor = Color.Blue } } } + ]; + + using var workbook = await ExportAsync(rows); + var sheet = workbook.Worksheet(1); + + Assert.Equal(1.23456, sheet.Cell("A2").GetDouble()); + Assert.Equal("0.000", sheet.Cell("A2").Style.NumberFormat.Format); + Assert.Equal(new DateTime(2024, 5, 6), sheet.Cell("B2").GetDateTime()); + Assert.Equal(14, sheet.Cell("B2").Style.NumberFormat.NumberFormatId); + Assert.Equal(Color.Blue.ToArgb(), sheet.Cell("B2").Style.Font.FontColor.Color.ToArgb()); + } + + [Fact] + public async Task CellsWithTheSameStyleShareOneFormatAsync() + { + var rows = Enumerable.Range(1, 50) + .Select(i => new Dictionary { ["Value"] = new OpenXmlCellValue(i) { NumberFormat = "0.00", Style = { FontColor = Color.Gray } } }) + .ToArray(); + + var stream = new MemoryStream(); + await _exporter.ExportAsync(stream, rows); + stream.Position = 0; + + using var archive = new ZipArchive(stream); + using var reader = new StreamReader(archive.GetEntry("xl/styles.xml")!.Open()); + var styles = XDocument.Parse(await reader.ReadToEndAsync()); + XNamespace ns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + + Assert.Single(styles.Descendants(ns + "numFmt"), e => (string?)e.Attribute("formatCode") == "0.00"); + Assert.Single(styles.Descendants(ns + "font"), e => (string?)e.Element(ns + "color")?.Attribute("rgb") == "FF808080"); + } + + [Fact] + public async Task NullValueKeepsItsStyleAsync() + { + Dictionary[] rows = [new() { ["Value"] = new OpenXmlCellValue(null) { Style = { FontColor = Color.Red } } }]; + + using var workbook = await ExportAsync(rows); + var cell = workbook.Worksheet(1).Cell("A2"); + + Assert.True(cell.IsEmpty()); + Assert.Equal(Color.Red.ToArgb(), cell.Style.Font.FontColor.Color.ToArgb()); + } + + [Fact] + public async Task MinimalStylesSupportCellValuesAsync() + { + Dictionary[] rows = [new() { ["Value"] = new OpenXmlCellValue(0.5) { NumberFormat = "0%", Style = { FontColor = Color.Green } } }]; + + using var workbook = await ExportAsync(rows, new OpenXmlConfiguration { TableStyles = TableStyles.None }); + var cell = workbook.Worksheet(1).Cell("A2"); + + Assert.Equal("0%", cell.Style.NumberFormat.Format); + Assert.Equal(Color.Green.ToArgb(), cell.Style.Font.FontColor.Color.ToArgb()); + } + + [Fact] + public async Task InvalidNumberFormatThrowsAsync() + { + Dictionary[] rows = [new() { ["Value"] = new OpenXmlCellValue(1) { NumberFormat = "0.0\"" } }]; + + await Assert.ThrowsAsync(() => _exporter.ExportAsync(new MemoryStream(), rows)); + } +} diff --git a/tests/MiniExcel.OpenXml.Tests/CellValues/OpenXmlCellValueTests.cs b/tests/MiniExcel.OpenXml.Tests/CellValues/OpenXmlCellValueTests.cs new file mode 100644 index 00000000..c0fb71e5 --- /dev/null +++ b/tests/MiniExcel.OpenXml.Tests/CellValues/OpenXmlCellValueTests.cs @@ -0,0 +1,31 @@ +using System.Drawing; +using ClosedXML.Excel; +using MiniExcelLib.OpenXml.Models; + +namespace MiniExcelLib.OpenXml.Tests.CellValues; + +public class OpenXmlCellValueTests +{ + private readonly OpenXmlExporter _exporter = MiniExcelV2.Exporters.GetOpenXmlExporter(); + + [Fact] + public void FormulaFontColorAndNumberFormatAreWritten() + { + Dictionary[] rows = + [ + new() { ["A"] = 3, ["B"] = new OpenXmlCellValue(0.6) { Formula = "A2/5", NumberFormat = "0.0%", Style = { FontColor = Color.Gray } } } + ]; + + var stream = new MemoryStream(); + _exporter.Export(stream, rows); + stream.Position = 0; + + using var workbook = new XLWorkbook(stream); + var cell = workbook.Worksheet(1).Cell("B2"); + + Assert.Equal("A2/5", cell.FormulaA1); + Assert.Equal(0.6, cell.CachedValue.GetNumber()); + Assert.Equal("0.0%", cell.Style.NumberFormat.Format); + Assert.Equal(Color.Gray.ToArgb(), cell.Style.Font.FontColor.Color.ToArgb()); + } +}