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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/ai-agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ var schema = Excel.Schema<Order>()
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
Expand Down
2 changes: 1 addition & 1 deletion docs/converters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 2 additions & 0 deletions docs/formulas.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ var schema = Excel.Schema<Invoice>()
.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.
Expand Down
2 changes: 2 additions & 0 deletions docs/layout-and-styling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions docs/recipes/formulas-and-tables.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ var schema = Excel.Schema<Order>()
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).
2 changes: 2 additions & 0 deletions docs/report-layouts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ var schema = Excel.Schema<Order>()
.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).

Expand Down
2 changes: 1 addition & 1 deletion docs/styling.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ var schema = Excel.Schema<Order>()
.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

Expand Down
2 changes: 2 additions & 0 deletions docs/tables.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ var schema = Excel.Schema<Order>()
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.
Expand Down
2 changes: 1 addition & 1 deletion docs/templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/use-cases/formulas-and-calculated-columns.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/use-cases/generate-a-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions docs/use-cases/multi-sheet-workbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ var orderSchema = Excel.Schema<OrderLine>()
.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
Expand Down
9 changes: 5 additions & 4 deletions src/CellSharp/Internal/WorkbookStyleCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down Expand Up @@ -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(
Expand All @@ -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);
}

Expand Down
34 changes: 34 additions & 0 deletions tests/CellSharp.Tests/ExcelWriteTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<SheetData>()!
.Elements<Row>()
.Skip(1)
.Single()
.Elements<Cell>()
.Single();
var format = document.WorkbookPart.WorkbookStylesPart!.Stylesheet!.CellFormats!
.Elements<CellFormat>()
.ElementAt((int)cell.StyleIndex!.Value);
var numberFormat = document.WorkbookPart.WorkbookStylesPart.Stylesheet.NumberingFormats!
.Elements<NumberingFormat>()
.Single(candidate => candidate.NumberFormatId!.Value == format.NumberFormatId!.Value);

Assert.Equal("0.00", numberFormat.FormatCode!.Value);
}
finally
{
Delete(path);
}
}

[Fact]
public void WriteKeepsFormulaLikeTextAsText()
{
Expand Down Expand Up @@ -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;
Expand Down