diff --git a/docs/ai-agent-guide.md b/docs/ai-agent-guide.md index 42ff0a4..478106b 100644 --- a/docs/ai-agent-guide.md +++ b/docs/ai-agent-guide.md @@ -71,6 +71,8 @@ var schema = Excel.Schema() Excel.CreateTemplate("orders-template.xlsx", schema); ``` +`#,##0.00` is an invariant XLSX format code: Excel with an Italian locale displays `1` as `1,00` (and `1234.5` as `1.234,50`). Use `.` for the decimal placeholder, not `,`. + `context.Row` is the physical, one-based Excel row. Do not pass a formula-looking string to `Value(...)` and expect a formula. ### Multiple worksheets and layout diff --git a/docs/converters.md b/docs/converters.md index be61322..b3a1376 100644 --- a/docs/converters.md +++ b/docs/converters.md @@ -38,7 +38,7 @@ Blank cells bypass the converter. A nullable property receives `null`; non-nulla ## Format and validation -Converters and `Format(...)` are separate. For example, a `Money` converter may return `decimal`, after which `Format("#,##0.00")` controls only Excel presentation. +Converters and `Format(...)` are separate. For example, a `Money` converter may return `decimal`, after which `Format("#,##0.00")` controls only Excel presentation. Format codes are invariant XLSX syntax: the same code displays `1` as `1,00` in Italian Excel. The runtime order is: diff --git a/docs/formulas.md b/docs/formulas.md index 58e17a4..6b1da9a 100644 --- a/docs/formulas.md +++ b/docs/formulas.md @@ -17,6 +17,8 @@ var schema = Excel.Schema() .Build(); ``` +Numeric format codes are locale-invariant. For example, `#,##0.00` displays `1` as `1,00` in Excel with an Italian locale; use `.` for the decimal placeholder. + The callback also exposes the one-based output `Column` and `SheetName`. Formula text may include one leading `=` or omit it; CellSharp writes the normalized XLSX expression without that prefix. Empty formulas and a double `==` prefix fail during export. `Formula(...)` is an explicit executable-content boundary. Do not concatenate user-controlled values, imported workbook text, or other untrusted data into the returned expression: spreadsheet applications may evaluate network-capable or otherwise dangerous functions. Write untrusted data through a normal column and reference that cell from a constant, application-authored formula instead. diff --git a/docs/layout-and-styling.md b/docs/layout-and-styling.md index 97a8223..3ada2e6 100644 --- a/docs/layout-and-styling.md +++ b/docs/layout-and-styling.md @@ -48,6 +48,8 @@ var title = ExcelStyle.Create(s => s.Bold().FontSize(18).AlignCenter()); sheet.Range("A1:D1").Style(title); ``` +`NumberFormat(...)` uses the same invariant XLSX codes as schema `Format(...)`: use `.` for decimal placeholders. For example, `€ #,##0.00` displays a whole value as `€ 1,00` in Italian Excel. + ## Style composition and precedence Styles are sparse: a builder only overrides the properties it configures. The resolved order is workbook/theme, schema property, column, row, range, then cell. The last level that sets a property wins; properties it does not set continue to inherit. Explicit `false` is an override, so `Bold(false)` and `WrapText(false)` reliably clear an earlier setting. Borders compose independently on each side. diff --git a/docs/recipes/formulas-and-tables.md b/docs/recipes/formulas-and-tables.md index 2156195..c3b4ee5 100644 --- a/docs/recipes/formulas-and-tables.md +++ b/docs/recipes/formulas-and-tables.md @@ -16,6 +16,8 @@ var schema = Excel.Schema() Excel.Write("orders.xlsx", orders, schema); ``` +The `#,##0.00` format is invariant XLSX syntax and displays two decimal places using Excel's local separators, such as `1,00` in Italian Excel. + The first data row is Excel row 2. CellSharp requests recalculation only when it has actually written formula cells. It also stores the property's converted value as the formula's initial cache, allowing immediate import; Excel, LibreOffice, or another calculation-capable consumer replaces that cache on recalculation. For an executable calculated-column example, see [Formulas and calculated columns](../use-cases/formulas-and-calculated-columns.md). diff --git a/docs/report-layouts.md b/docs/report-layouts.md index 0116fbb..686cd09 100644 --- a/docs/report-layouts.md +++ b/docs/report-layouts.md @@ -52,6 +52,8 @@ margin.Value.Formula("SUM(Profit) / SUM(Revenue)") Component defaults are intentionally neutral and never change row height or column width. Apply `Style(...)`, `Row(...).Height(...)`, or `Column(...).Width(...)` when the report needs a stronger visual treatment or explicit sizing. User-applied range and cell styles compose with the defaults using the normal precedence rules. +`NumberFormat(...)` uses invariant XLSX format codes. Use `.` for decimal placeholders; Excel applies the user's locale when it displays values, so `0.00%` renders with a comma decimal separator in Italian Excel. + ## Full report ```csharp diff --git a/docs/schemas.md b/docs/schemas.md index fb1545b..0111399 100644 --- a/docs/schemas.md +++ b/docs/schemas.md @@ -112,7 +112,7 @@ var schema = Excel.Schema() .Build(); ``` -`Format` accepts an Excel format-code string and writes it to the XLSX style table; CellSharp does not parse it. `Width` must be greater than zero and no greater than 255. `Align` accepts the deliberately small `General`, `Left`, `Center`, and `Right` enum. These settings are consumed by `Write` and `CreateTemplate`; the same schema stays reusable for `Read`. See [styling](styling.md) and [generated templates](templates.md) for precedence and workbook-level options. +`Format` accepts an Excel format-code string and writes it to the XLSX style table; CellSharp does not parse it. Format codes are invariant: `#,##0.00` displays `1` as `1,00` in Italian Excel, so decimal placeholders must use `.` rather than `,`. `Width` must be greater than zero and no greater than 255. `Align` accepts the deliberately small `General`, `Left`, `Center`, and `Right` enum. These settings are consumed by `Write` and `CreateTemplate`; the same schema stays reusable for `Read`. See [styling](styling.md) and [generated templates](templates.md) for precedence and workbook-level options. `Formula(...)` is a separate write-time setting: it produces a native formula cell for each data row. The property's converted value is stored as that cell's initial cached result, while the formula remains authoritative when a spreadsheet recalculates it. It can be combined with format, width, alignment, converters, and custom `.Validate(...)` predicates, but not native declarative validation. See [formulas](formulas.md). diff --git a/docs/styling.md b/docs/styling.md index fb9057a..9c455db 100644 --- a/docs/styling.md +++ b/docs/styling.md @@ -81,7 +81,7 @@ var schema = Excel.Schema() .Build(); ``` -`Format` is an Excel format-code string. CellSharp stores it in the XLSX style table and deliberately does not parse it or use it for input conversion. Equivalent format codes are reused in the style catalog. `Width` must be greater than zero and no greater than 255. `Align` supports the small `General`, `Left`, `Center`, and `Right` enum. +`Format` is an Excel format-code string. CellSharp stores it in the XLSX style table and deliberately does not parse it or use it for input conversion. Decimal properties default to `0.00`, which Excel displays as `1,00` when the workbook is opened with an Italian locale. Use `.` in format codes even for localized output: Excel applies the user's decimal separator when it renders the value. Equivalent format codes are reused in the style catalog. `Width` must be greater than zero and no greater than 255. `Align` supports the small `General`, `Left`, `Center`, and `Right` enum. ## Widths and frozen headers diff --git a/docs/tables.md b/docs/tables.md index 182d264..10e634d 100644 --- a/docs/tables.md +++ b/docs/tables.md @@ -17,6 +17,8 @@ var schema = Excel.Schema() Excel.Write("orders.xlsx", orders, schema); ``` +`#,##0.00` uses the invariant XLSX decimal placeholder. Excel renders it with the local separator, so an Italian installation shows `1,00` for a value of `1`. + ## Names and styles `AsTable()` generates a deterministic name from the worksheet name, such as `OrdersTable`. `AsTable("OrdersTable")` preserves an explicit name. Names are validated as Excel identifiers and are unique case-insensitively across a multi-sheet workbook; an explicit duplicate fails the write. diff --git a/docs/templates.md b/docs/templates.md index 6b5aaa6..3094a45 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -44,7 +44,7 @@ Excel.CreateTemplate( The generated worksheet uses the schema's declared order and headers. `Optional()` columns are included: optional means an imported workbook may omit that header, not that the recommended blank template should omit it. `Ignore()` columns are excluded. -`Format(...)`, `Width(...)`, and `Align(...)` are carried into the template. CellSharp stores a default XLSX style on each column rather than adding empty placeholder rows. This keeps the workbook empty while letting cells entered manually inherit column format and alignment in spreadsheet applications. `DateTime` columns receive CellSharp's usual date display format when no explicit format is configured. +`Format(...)`, `Width(...)`, and `Align(...)` are carried into the template. CellSharp stores a default XLSX style on each column rather than adding empty placeholder rows. This keeps the workbook empty while letting cells entered manually inherit column format and alignment in spreadsheet applications. `DateTime` columns receive CellSharp's usual date display format and `decimal` columns receive `0.00` when no explicit format is configured. Format codes use invariant XLSX syntax, so `0.00` displays as `1,00` for a value of `1` in Italian Excel. ## Write options on empty workbooks diff --git a/docs/use-cases/formulas-and-calculated-columns.md b/docs/use-cases/formulas-and-calculated-columns.md index e406b7d..0deb8bf 100644 --- a/docs/use-cases/formulas-and-calculated-columns.md +++ b/docs/use-cases/formulas-and-calculated-columns.md @@ -34,7 +34,7 @@ Excel.Write("order-report.xlsx", lines, schema); ## Result -`Total` is a native Excel formula cell. CellSharp writes the property value as its initial cached result and requests recalculation when formula cells are exported. +`Total` is a native Excel formula cell. CellSharp writes the property value as its initial cached result and requests recalculation when formula cells are exported. `#,##0.00` is invariant XLSX syntax, so Excel renders whole totals as `1,00` in an Italian locale. ## Why this approach diff --git a/docs/use-cases/generate-a-report.md b/docs/use-cases/generate-a-report.md index 07ce507..6893eca 100644 --- a/docs/use-cases/generate-a-report.md +++ b/docs/use-cases/generate-a-report.md @@ -37,7 +37,7 @@ Excel.Write("sales-report.xlsx", report, schema, options => options.AlternatingR ## Result -The workbook has an Excel Table with filters, readable number/date formats, frozen headers, sized columns, alternating rows, and basic print settings. +The workbook has an Excel Table with filters, readable number/date formats, frozen headers, sized columns, alternating rows, and basic print settings. The `#,##0.00` revenue format is locale-invariant XLSX syntax and appears as `1,00` for a value of `1` in Italian Excel. ## Why this approach diff --git a/docs/use-cases/multi-sheet-workbook.md b/docs/use-cases/multi-sheet-workbook.md index bff3722..335384a 100644 --- a/docs/use-cases/multi-sheet-workbook.md +++ b/docs/use-cases/multi-sheet-workbook.md @@ -23,6 +23,8 @@ var orderSchema = Excel.Schema() .Build(); ``` +The `#,##0.00` code is written in invariant XLSX syntax. Excel applies the locale when displaying it, so it renders `1` as `1,00` in Italian. + ## Write / Read ```csharp diff --git a/src/CellSharp/Internal/WorkbookStyleCatalog.cs b/src/CellSharp/Internal/WorkbookStyleCatalog.cs index cbcf509..9db56b2 100644 --- a/src/CellSharp/Internal/WorkbookStyleCatalog.cs +++ b/src/CellSharp/Internal/WorkbookStyleCatalog.cs @@ -7,6 +7,7 @@ namespace CellSharp.Internal; internal sealed class WorkbookStyleCatalog { private const string DefaultDateFormat = "yyyy-mm-dd hh:mm:ss"; + private const string DefaultDecimalFormat = "0.00"; private readonly Fonts _fonts = new(new Font()) { Count = 1U }; private readonly Fills _fills = new( new Fill(new PatternFill { PatternType = PatternValues.None }), @@ -54,16 +55,16 @@ internal uint HeaderStyleIndex(ExcelWriteOptions options) internal uint DataStyleIndex(ExportProperty property, object? value, int dataRowIndex, ExcelWriteOptions options) { - return DataStyleIndex(property, value is DateTime, dataRowIndex, options); + return DataStyleIndex(property, value is DateTime, value is decimal, dataRowIndex, options); } internal uint TemplateDataStyleIndex(ExportProperty property, ExcelWriteOptions options) { var propertyType = Nullable.GetUnderlyingType(property.CellType) ?? property.CellType; - return DataStyleIndex(property, propertyType == typeof(DateTime), 0, options); + return DataStyleIndex(property, propertyType == typeof(DateTime), propertyType == typeof(decimal), 0, options); } - private uint DataStyleIndex(ExportProperty property, bool isDate, int dataRowIndex, ExcelWriteOptions options) + private uint DataStyleIndex(ExportProperty property, bool isDate, bool isDecimal, int dataRowIndex, ExcelWriteOptions options) { var template = options.Template ?? WorkbookTheme.For(options.Theme); var dataStyle = new CellStyleDefinition( @@ -81,7 +82,7 @@ private uint DataStyleIndex(ExportProperty property, bool isDate, int dataRowInd ? dataStyle.WithBackground(alternateBackground) : dataStyle; style = style.WithAlignment(property.Alignment); - var numberFormat = property.Format ?? (isDate ? DefaultDateFormat : null); + var numberFormat = property.Format ?? (isDate ? DefaultDateFormat : isDecimal ? DefaultDecimalFormat : null); return Register(style, numberFormat); } diff --git a/tests/CellSharp.Tests/ExcelWriteTests.cs b/tests/CellSharp.Tests/ExcelWriteTests.cs index 577568f..c544dcb 100644 --- a/tests/CellSharp.Tests/ExcelWriteTests.cs +++ b/tests/CellSharp.Tests/ExcelWriteTests.cs @@ -69,6 +69,38 @@ public void WriteCreatesHeadersForAnEmptySequence() } } + [Fact] + public void WriteUsesTwoDecimalPlacesForDecimalValuesByDefault() + { + var path = TemporaryPath(); + + try + { + Excel.Write(path, [new DecimalValue(1M)]); + + using var document = SpreadsheetDocument.Open(path, false); + var cell = document.WorkbookPart!.WorksheetParts.Single().Worksheet! + .GetFirstChild()! + .Elements() + .Skip(1) + .Single() + .Elements() + .Single(); + var format = document.WorkbookPart.WorkbookStylesPart!.Stylesheet!.CellFormats! + .Elements() + .ElementAt((int)cell.StyleIndex!.Value); + var numberFormat = document.WorkbookPart.WorkbookStylesPart.Stylesheet.NumberingFormats! + .Elements() + .Single(candidate => candidate.NumberFormatId!.Value == format.NumberFormatId!.Value); + + Assert.Equal("0.00", numberFormat.FormatCode!.Value); + } + finally + { + Delete(path); + } + } + [Fact] public void WriteKeepsFormulaLikeTextAsText() { @@ -157,6 +189,8 @@ private static void Delete(string path) private sealed record Customer(int Id, string Name, bool IsActive, DateTime CreatedAt, Guid ExternalId, decimal? Balance); + private sealed record DecimalValue(decimal Value); + private sealed record FormulaCandidate(string Value); private sealed class Empty;