diff --git a/docs/KNOWN-ISSUES.md b/docs/KNOWN-ISSUES.md
index e730cd2..57c6f52 100644
--- a/docs/KNOWN-ISSUES.md
+++ b/docs/KNOWN-ISSUES.md
@@ -200,3 +200,11 @@ Read back through this library, xlsx and ods follow the same rules and add no ex
Reading ods back with this library, an all-empty row is passed over rather than handed out, so the import does not count it as skipped; the rows after it keep their numbers.
TabularExport picks a column type by overload resolution, and some lambdas do not resolve: a char property becomes an integer column (its code point); ulong, `p => default` and a lambda returning null are ambiguous; an int or long with a DecimalImportField is ambiguous between decimal and double; TimeSpan, DateTimeOffset, Guid and enums have no overload and fail with a misleading "cannot convert to string" (CS0029). Convert explicitly (`.ToString()`, `.UtcDateTime`, …); write enums and identifiers as text.
+
+## Styles
+
+- Styles are for xlsx and ods; csv ignores them, so one code path writes every format.
+- Format codes are a subset of Excel's (see `NumberFormat` and `DateFormat`); anything else is refused when parsed, never at write time.
+- Separators in a date format show as written (`dd/mm/yyyy` shows slashes in every locale); the decimal point and thousands separator of a number format follow the reader's locale.
+- A `StyleId` belongs to the writer that returned it; passing it to another writer is refused with an `ArgumentException` (the default `StyleId`, the unstyled cell, is accepted by every writer).
+- At most 4096 distinct styles per file. Declare the styles once (`static readonly`) and register each once per writer.
diff --git a/docs/superpowers/plans/2026-10-04-styles-part-1-cell-styles.md b/docs/superpowers/plans/2026-10-04-styles-part-1-cell-styles.md
new file mode 100644
index 0000000..3ad9999
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-04-styles-part-1-cell-styles.md
@@ -0,0 +1,2264 @@
+# Styled export, part 1 — cell styles Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Cells written by `TabularWriter` can carry a style — fill, font colour, bold, italic, number format, date format, horizontal alignment, wrap, thin border — in xlsx and ods, with nothing allocated per cell and the round trip through our import unchanged.
+
+**Architecture:** A public immutable `CellStyle` (value equality) is registered once per writer with `writer.Style(style)`, which returns a `StyleId` (an int). Every `Write` gains a `StyleId` overload; `ISheetWriter` methods take the style index (0 = unstyled, the existing fast path). Each workbook format resolves a `(style index, value kind)` pair lazily into its own style (xlsx `cellXfs` index, ods common cell style name), caches it in an array, and writes its whole style part at `Complete`. Csv ignores styles.
+
+**Tech Stack:** C# / .NET (net8.0 + net10.0), BCL only; xunit v3 on Microsoft.Testing.Platform; DocumentFormat.OpenXml validator (tests only); LibreOffice headless (interop tests).
+
+**Spec:** `docs/superpowers/specs/2026-10-04-styled-columnar-export-design.md` §1 (the earlier `docs/superpowers/specs/2026-10-03-writing-design.md` still holds where §1 is silent). Issue #86.
+
+## Global Constraints
+
+- One package, no dependencies: `src/TriasDev.Tabular` uses the base class library only.
+- Public types live in namespace `TriasDev.Tabular` (files under `src/TriasDev.Tabular/Writing/`); format internals in `TriasDev.Tabular.Xlsx` / `TriasDev.Tabular.Ods`.
+- Every public member is listed in `src/TriasDev.Tabular/PublicAPI.Unshipped.txt` (RS0016/RS0017 fail the build otherwise — copy the exact line the analyzer reports).
+- Analyzers are warnings-as-errors; `dotnet format --verify-no-changes` must pass (CI runs it).
+- Nothing allocated per cell on the styled path once a `(style, kind)` pair is resolved; the unstyled path (style index 0) stays as it is.
+- At most **4096** distinct styles per file; the next `Style` call throws `TabularLimitException` (limit name `"MaxStyles"`).
+- Number and date formats are separate properties: `Number` applies to integer, decimal and double cells, `Date` to date and date-time cells; each is ignored elsewhere. A date cell without `Date` keeps the writer's default date / date-time format.
+- Supported format codes (anything else → `ArgumentException` from `Parse`, naming the unsupported part):
+ - `NumberFormat`: optional quoted prefix, integer part of `#`, `0`, `,` (all `#` before all `0`; a comma only between placeholders = thousands grouping), optional `.` followed by `0`s then `#`s (1–30), optional `%`, optional quoted suffix.
+ - `DateFormat`: `yyyy`, `yy`, `m`, `mm`, `d`, `dd`, `h`, `hh`, `s`, `ss` (case-insensitive), separators `/ - . : , space`, quoted literals, backslash-escaped characters. `m`/`mm` is minutes when the previous non-literal part is an hour or the next non-literal part is a second, months otherwise. At least one non-literal part.
+- xlsx: custom number formats start at id **165** (164 is the default date-time format); the four fixed cell formats 0 General, 1 date (14), 2 date-time (164), 3 integer (1) keep their indices.
+- ods: cell styles are common styles in `styles.xml` (`office:styles`), with their data styles there too; `content.xml`'s automatic styles stay as they are.
+- The repository is public: no product or customer names in code, docs or commits.
+- Tests: `TestContext.Current.CancellationToken` as token; LibreOffice tests go through `Fixtures/LibreOffice.cs` (skips without soffice unless `TABULAR_REQUIRE_SOFFICE=1`).
+
+Commands (from the repo root):
+
+- Build: `dotnet build -c Release`
+- One test class: `dotnet test --project tests/TriasDev.Tabular.Tests -c Release -- --filter-class "*ClassName"`
+- All tests: `dotnet test -c Release`
+- Format: `dotnet format --verify-no-changes`
+
+## Review Focus
+
+1. A style registered after the sheet began, in the middle of the data (row 1000), is written correctly — styles are lazy in both workbook formats. (Task 4, Task 5)
+2. One `CellStyle` used on text, a number and a date in the same row: the number format reaches only the number, the date keeps a date format and reads back as a date. (Task 4, Task 5)
+3. Literal text in a format code holding `&`, `<`, `"` (escaped) is written as valid XML in both formats. (Task 4, Task 5)
+4. A `StyleId` that this writer did not hand out (taken from another writer with more styles) is refused with `ArgumentException`, not written as a wrong or dangling style index. (Task 3)
+5. Styled empty cells and styled booleans read back as empty and boolean — the style does not turn them into text or drop the cell's type. (Task 4, Task 5)
+
+---
+
+### Task 1: Public style model
+
+**Files:**
+- Create: `src/TriasDev.Tabular/Writing/CellColor.cs`, `CellFont.cs`, `CellBorder.cs`, `HorizontalAlignment.cs`, `CellStyle.cs`, `StyleId.cs`
+- Modify: `src/TriasDev.Tabular/PublicAPI.Unshipped.txt`
+- Test: `tests/TriasDev.Tabular.Tests/Writing/CellStyleTests.cs`
+
+**Interfaces:**
+- Produces: `CellColor` (`FromRgb(int)`, `Parse(string)`, `Rgb`, `ToString()` → `#RRGGBB`), `CellFont { Color, Bold, Italic }`, `CellBorder.Thin(CellColor)` with `Color`, `HorizontalAlignment { General, Left, Center, Right }`, `CellStyle { Fill, Font, Horizontal, Wrap, Border }`, `StyleId` (internal `Value`, internal ctor). `CellStyle.Number` and `CellStyle.Date` are added by Task 2, which creates their types.
+
+- [ ] **Step 1: Write the failing tests**
+
+```csharp
+using Xunit;
+
+namespace TriasDev.Tabular.Tests.Writing;
+
+/// The style model: colours, fonts, borders and styles compare by value.
+public sealed class CellStyleTests
+{
+ [Theory]
+ [InlineData(0x000000, "#000000")]
+ [InlineData(0xF8696B, "#F8696B")]
+ [InlineData(0xFFFFFF, "#FFFFFF")]
+ public void AColourIsTwentyFourBitRgb(int rgb, string text)
+ {
+ CellColor color = CellColor.FromRgb(rgb);
+
+ Assert.Equal(rgb, color.Rgb);
+ Assert.Equal(text, color.ToString());
+ Assert.Equal(color, CellColor.Parse(text));
+ Assert.Equal(color, CellColor.Parse(text.ToLowerInvariant()));
+ }
+
+ [Theory]
+ [InlineData(-1)]
+ [InlineData(0x1000000)]
+ public void AColourOutsideRgbIsRefused(int rgb) =>
+ Assert.Throws(() => CellColor.FromRgb(rgb));
+
+ [Theory]
+ [InlineData("F8696B")]
+ [InlineData("#F8696")]
+ [InlineData("#F8696B0")]
+ [InlineData("#G8696B")]
+ [InlineData("")]
+ public void AColourTextOtherThanHashAndSixHexDigitsIsRefused(string text) =>
+ Assert.Throws(() => CellColor.Parse(text));
+
+ [Fact]
+ public void StylesWithTheSameSettingsAreEqual()
+ {
+ CellStyle a = new() { Fill = CellColor.FromRgb(0xFF0000), Font = new CellFont { Bold = true, Color = CellColor.FromRgb(0xFFFFFF) }, Border = CellBorder.Thin(CellColor.FromRgb(0x808080)), Horizontal = HorizontalAlignment.Center, Wrap = true };
+ CellStyle b = new() { Fill = CellColor.FromRgb(0xFF0000), Font = new CellFont { Bold = true, Color = CellColor.FromRgb(0xFFFFFF) }, Border = CellBorder.Thin(CellColor.FromRgb(0x808080)), Horizontal = HorizontalAlignment.Center, Wrap = true };
+
+ Assert.Equal(a, b);
+ Assert.Equal(a.GetHashCode(), b.GetHashCode());
+ Assert.NotEqual(a, b with { Wrap = false });
+ }
+
+ [Fact]
+ public void AnEmptyStyleChangesNothing()
+ {
+ CellStyle style = new();
+
+ Assert.Null(style.Fill);
+ Assert.Null(style.Font);
+ Assert.Null(style.Border);
+ Assert.Equal(HorizontalAlignment.General, style.Horizontal);
+ Assert.False(style.Wrap);
+ }
+
+ [Fact]
+ public void TheDefaultStyleIdIsTheUnstyledCell() => Assert.Equal(default, new StyleId());
+}
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `dotnet build -c Release`
+Expected: compile errors — `CellColor`, `CellStyle`, … do not exist.
+
+- [ ] **Step 3: Implement**
+
+`CellColor.cs`:
+
+```csharp
+using System.Globalization;
+
+namespace TriasDev.Tabular;
+
+/// A colour for a cell's fill, font or border: 24-bit RGB.
+public readonly record struct CellColor
+{
+ private CellColor(int rgb) => Rgb = rgb;
+
+ /// The colour as 0xRRGGBB.
+ public int Rgb { get; }
+
+ /// A colour from 0xRRGGBB.
+ /// is not between 0 and 0xFFFFFF.
+ public static CellColor FromRgb(int rgb)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(rgb);
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(rgb, 0xFFFFFF);
+ return new CellColor(rgb);
+ }
+
+ /// A colour from #RRGGBB, hex digits in either case.
+ /// The text is not # and six hex digits.
+ public static CellColor Parse(string text)
+ {
+ ArgumentNullException.ThrowIfNull(text);
+
+ if (text.Length != 7 || text[0] != '#' || !int.TryParse(text.AsSpan(1), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out int rgb))
+ {
+ throw new FormatException($"\"{text}\" is not a colour; write it as #RRGGBB.");
+ }
+
+ return new CellColor(rgb);
+ }
+
+ /// The colour as #RRGGBB.
+ public override string ToString() => string.Create(CultureInfo.InvariantCulture, $"#{Rgb:X6}");
+}
+```
+
+(`int.TryParse` with `AllowHexSpecifier` rejects a sign and accepts at most what fits; six digits always fit, and the length check makes "six" exact.)
+
+`CellFont.cs`:
+
+```csharp
+namespace TriasDev.Tabular;
+
+/// How a cell's text looks: its colour, bold, italic. Family and size stay the format's default.
+public sealed record CellFont
+{
+ /// The text colour; null keeps the default (black).
+ public CellColor? Color { get; init; }
+
+ /// Bold text.
+ public bool Bold { get; init; }
+
+ /// Italic text.
+ public bool Italic { get; init; }
+}
+```
+
+`CellBorder.cs`:
+
+```csharp
+namespace TriasDev.Tabular;
+
+/// A border on all four sides of a cell.
+public sealed record CellBorder
+{
+ private CellBorder(CellColor color) => Color = color;
+
+ /// The border's colour.
+ public CellColor Color { get; }
+
+ /// A thin line in a colour on all four sides.
+ public static CellBorder Thin(CellColor color) => new(color);
+}
+```
+
+`HorizontalAlignment.cs`:
+
+```csharp
+namespace TriasDev.Tabular;
+
+/// Where a cell's content sits across the cell.
+public enum HorizontalAlignment
+{
+ /// The format's default: text left, numbers and dates right.
+ General,
+
+ /// Left.
+ Left,
+
+ /// Centred.
+ Center,
+
+ /// Right.
+ Right,
+}
+```
+
+`CellStyle.cs` (Task 2 adds `Number` and `Date`):
+
+```csharp
+namespace TriasDev.Tabular;
+
+///
+/// How a cell looks in a workbook. Every setting is optional; one left unset keeps the format's
+/// default. Csv ignores styles.
+///
+///
+/// Compares by value: two styles with the same settings are the same style, and a file holds it
+/// once. Declare the styles an export uses once (static readonly) and register each with
+/// .
+///
+public sealed record CellStyle
+{
+ /// The cell's background colour; null leaves it unfilled.
+ public CellColor? Fill { get; init; }
+
+ /// The text's colour, bold and italic; null keeps the default font.
+ public CellFont? Font { get; init; }
+
+ /// Where the content sits across the cell.
+ public HorizontalAlignment Horizontal { get; init; }
+
+ /// Wraps text onto several lines within the column's width.
+ public bool Wrap { get; init; }
+
+ /// A border on all four sides; null draws none.
+ public CellBorder? Border { get; init; }
+}
+```
+
+`StyleId.cs`:
+
+```csharp
+using System.Globalization;
+
+namespace TriasDev.Tabular;
+
+///
+/// A style registered with one by , to
+/// pass with each cell written in it. Valid only with the writer that returned it. The default value
+/// is the unstyled cell.
+///
+public readonly record struct StyleId
+{
+ internal StyleId(int value) => Value = value;
+
+ /// The style's index in its writer's table; 0 is no style.
+ internal int Value { get; }
+
+ ///
+ public override string ToString() => string.Create(CultureInfo.InvariantCulture, $"StyleId({Value})");
+}
+```
+
+`TabularWriter.Style` does not exist yet: the `` to it fails to compile until Task 3. Write the remark text without the cref in this task (`registered with a writer's Style method`) and turn it into the cref in Task 3.
+
+- [ ] **Step 4: Add the public API lines and run**
+
+Run `dotnet build -c Release`, copy every RS0016 member line into `src/TriasDev.Tabular/PublicAPI.Unshipped.txt` (keep the file sorted as it is), rebuild.
+Run: `dotnet test --project tests/TriasDev.Tabular.Tests -c Release -- --filter-class "*CellStyleTests"`
+Expected: PASS (both target frameworks).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/TriasDev.Tabular/Writing src/TriasDev.Tabular/PublicAPI.Unshipped.txt tests/TriasDev.Tabular.Tests/Writing/CellStyleTests.cs
+git commit -m "feat(write): the cell style model — colour, font, border, alignment, wrap; StyleId"
+```
+
+---
+
+### Task 2: Number and date format codes
+
+**Files:**
+- Create: `src/TriasDev.Tabular/Writing/NumberFormat.cs`, `src/TriasDev.Tabular/Writing/DateFormat.cs`
+- Modify: `src/TriasDev.Tabular/Writing/CellStyle.cs` (add `Number`, `Date`), `PublicAPI.Unshipped.txt`
+- Test: `tests/TriasDev.Tabular.Tests/Writing/FormatCodeTests.cs`
+
+**Interfaces:**
+- Consumes: `TextRules.Check(string)` (internal, returns an error code or null) for literal text.
+- Produces (used by Tasks 4 and 5):
+ - `public sealed record NumberFormat` — `public static NumberFormat Parse(string code)`, `public string Code` (canonical Excel code), internal `bool Grouping`, `int MinIntegerDigits`, `int DecimalPlaces`, `int MinDecimalPlaces`, `bool Percent`, `string Prefix`, `string Suffix`.
+ - `public sealed record DateFormat` — `public static DateFormat Parse(string code)`, `public string Code` (canonical Excel code: literals escaped with `\`, except `:`), internal `IReadOnlyList Parts`; equality and hash by `Code` only.
+ - `internal enum DatePartKind { Literal, Year, Month, Day, Hour, Minute, Second }`, `internal readonly record struct DatePart(DatePartKind Kind, bool Long, string Text)` (in `DateFormat.cs`).
+ - `CellStyle.Number` (`NumberFormat?`), `CellStyle.Date` (`DateFormat?`).
+
+- [ ] **Step 1: Write the failing tests**
+
+```csharp
+using Xunit;
+
+namespace TriasDev.Tabular.Tests.Writing;
+
+/// The supported subset of Excel format codes: parsed into what both workbook formats can state.
+public sealed class FormatCodeTests
+{
+ [Theory]
+ [InlineData("0", false, 1, 0, 0, false, "", "")]
+ [InlineData("0.00", false, 1, 2, 2, false, "", "")]
+ [InlineData("#,##0", true, 1, 0, 0, false, "", "")]
+ [InlineData("#,##0.00", true, 1, 2, 2, false, "", "")]
+ [InlineData("0.0#", false, 1, 2, 1, false, "", "")]
+ [InlineData("#.##", false, 0, 2, 0, false, "", "")]
+ [InlineData("0%", false, 1, 0, 0, true, "", "")]
+ [InlineData("0.0%", false, 1, 1, 1, true, "", "")]
+ [InlineData("\"€ \"#,##0.00", true, 1, 2, 2, false, "€ ", "")]
+ [InlineData("0\" kg\"", false, 1, 0, 0, false, "", " kg")]
+ public void ParsesANumberFormat(string code, bool grouping, int minInteger, int decimals, int minDecimals, bool percent, string prefix, string suffix)
+ {
+ NumberFormat format = NumberFormat.Parse(code);
+
+ Assert.Equal(code, format.Code);
+ Assert.Equal(grouping, format.Grouping);
+ Assert.Equal(minInteger, format.MinIntegerDigits);
+ Assert.Equal(decimals, format.DecimalPlaces);
+ Assert.Equal(minDecimals, format.MinDecimalPlaces);
+ Assert.Equal(percent, format.Percent);
+ Assert.Equal(prefix, format.Prefix);
+ Assert.Equal(suffix, format.Suffix);
+ }
+
+ [Theory]
+ [InlineData("", "empty")]
+ [InlineData("General", "'G'")]
+ [InlineData("0;-0", "';'")]
+ [InlineData("[Red]0", "'['")]
+ [InlineData("0.00E+00", "'E'")]
+ [InlineData("# ?/?", "' '")]
+ [InlineData("@", "'@'")]
+ [InlineData("0#", "a # after a 0")]
+ [InlineData("0.#0", "a 0 after a #")]
+ [InlineData("0.", "decimal point")]
+ [InlineData("0,", "comma")]
+ [InlineData(",0", "comma")]
+ [InlineData("\"open", "quote")]
+ [InlineData("0.0000000000000000000000000000000", "30")]
+ public void RefusesANumberFormatOutsideTheSubset(string code, string named)
+ {
+ ArgumentException refused = Assert.Throws(() => NumberFormat.Parse(code));
+ Assert.Contains(named, refused.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void NumberFormatsWithTheSameCodeAreEqual() =>
+ Assert.Equal(NumberFormat.Parse("#,##0.00"), NumberFormat.Parse("#,##0.00"));
+
+ [Theory]
+ [InlineData("dd/mm/yyyy", "dd\\/mm\\/yyyy", "Day,Literal,Month,Literal,Year")]
+ [InlineData("yyyy-mm-dd hh:mm:ss", "yyyy\\-mm\\-dd\\ hh:mm:ss", "Year,Literal,Month,Literal,Day,Literal,Hour,Literal,Minute,Literal,Second")]
+ [InlineData("d.m.yy", "d\\.m\\.yy", "Day,Literal,Month,Literal,Year")]
+ [InlineData("hh:mm", "hh:mm", "Hour,Literal,Minute")]
+ [InlineData("mm:ss", "mm:ss", "Minute,Literal,Second")]
+ [InlineData("h \"h\" m", "h\\ \\h\\ m", "Hour,Literal,Minute")]
+ [InlineData("DD/MM/YYYY", "dd\\/mm\\/yyyy", "Day,Literal,Month,Literal,Year")]
+ [InlineData("yyyy\\Wdd", "yyyy\\Wdd", "Year,Literal,Day")]
+ public void ParsesADateFormat(string code, string canonical, string kinds)
+ {
+ DateFormat format = DateFormat.Parse(code);
+
+ Assert.Equal(canonical, format.Code);
+ Assert.Equal(kinds, string.Join(',', format.Parts.Select(p => p.Kind)));
+ }
+
+ [Fact]
+ public void MonthAndMinuteFollowExcelsRule()
+ {
+ DateFormat format = DateFormat.Parse("mm/dd hh:mm");
+
+ Assert.Equal(DatePartKind.Month, format.Parts[0].Kind);
+ Assert.Equal(DatePartKind.Minute, format.Parts[^1].Kind);
+ }
+
+ [Theory]
+ [InlineData("", "empty")]
+ [InlineData("mmm yyyy", "mmm")]
+ [InlineData("dddd", "dddd")]
+ [InlineData("yyy", "yyy")]
+ [InlineData("hh:mm AM/PM", "'A'")]
+ [InlineData("[h]:mm", "'['")]
+ [InlineData("hh:mm:ss.000", "'0'")]
+ [InlineData("\"only text\"", "no date or time part")]
+ [InlineData("dd\\", "backslash")]
+ [InlineData("dd\"open", "quote")]
+ public void RefusesADateFormatOutsideTheSubset(string code, string named)
+ {
+ ArgumentException refused = Assert.Throws(() => DateFormat.Parse(code));
+ Assert.Contains(named, refused.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void DateFormatsCompareByTheirCode()
+ {
+ Assert.Equal(DateFormat.Parse("dd/mm/yyyy"), DateFormat.Parse("DD/MM/YYYY"));
+ Assert.Equal(DateFormat.Parse("dd/mm/yyyy").GetHashCode(), DateFormat.Parse("DD/MM/YYYY").GetHashCode());
+ Assert.NotEqual(DateFormat.Parse("dd/mm/yyyy"), DateFormat.Parse("dd.mm.yyyy"));
+ }
+
+ [Fact]
+ public void ALiteralWithAForbiddenCharacterIsRefused() =>
+ Assert.Throws(() => NumberFormat.Parse("0\"\u0007\""));
+
+ [Fact]
+ public void AStyleCarriesBothFormats()
+ {
+ CellStyle style = new() { Number = NumberFormat.Parse("0.00"), Date = DateFormat.Parse("dd/mm/yyyy") };
+
+ Assert.Equal(style, new CellStyle { Number = NumberFormat.Parse("0.00"), Date = DateFormat.Parse("dd/mm/yyyy") });
+ }
+}
+```
+
+`DatePart` and `DatePartKind` are internal; the test project already sees internals (it uses `TextRules`, `XlsxCursor`).
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `dotnet build -c Release`
+Expected: compile errors — `NumberFormat`, `DateFormat` do not exist.
+
+- [ ] **Step 3: Implement `NumberFormat`**
+
+```csharp
+using System.Globalization;
+
+namespace TriasDev.Tabular;
+
+///
+/// How a numeric cell shows its value, as an Excel format code from a subset both workbook formats
+/// can state: 0, 0.00, #,##0, #,##0.00, 0.0#, 0%, and
+/// quoted text before or after, as in "€ "#,##0.00.
+///
+///
+/// The value stays the number written; only its display changes. The decimal point and the
+/// thousands separator show as the reader's locale has them.
+///
+public sealed record NumberFormat
+{
+ private const int MaxDecimals = 30;
+
+ private NumberFormat(string code, bool grouping, int minIntegerDigits, int decimalPlaces, int minDecimalPlaces, bool percent, string prefix, string suffix)
+ {
+ Code = code;
+ Grouping = grouping;
+ MinIntegerDigits = minIntegerDigits;
+ DecimalPlaces = decimalPlaces;
+ MinDecimalPlaces = minDecimalPlaces;
+ Percent = percent;
+ Prefix = prefix;
+ Suffix = suffix;
+ }
+
+ /// The format code, as parsed.
+ public string Code { get; }
+
+ internal bool Grouping { get; }
+
+ internal int MinIntegerDigits { get; }
+
+ internal int DecimalPlaces { get; }
+
+ internal int MinDecimalPlaces { get; }
+
+ internal bool Percent { get; }
+
+ internal string Prefix { get; }
+
+ internal string Suffix { get; }
+
+ /// Parses a format code.
+ /// The code is outside the supported subset; the message names the part.
+ public static NumberFormat Parse(string code)
+ {
+ ArgumentNullException.ThrowIfNull(code);
+
+ if (code.Length == 0)
+ {
+ throw Refuse(code, "the code is empty");
+ }
+
+ int i = 0;
+ string prefix = FormatCodes.ReadQuoted(code, ref i);
+
+ int integerStart = i;
+ int digits = 0;
+ int zeros = 0;
+ bool grouping = false;
+
+ for (; i < code.Length && code[i] is '#' or '0' or ','; i++)
+ {
+ switch (code[i])
+ {
+ case '#' when zeros > 0:
+ throw Refuse(code, "a # after a 0 in the integer part");
+ case '#':
+ digits++;
+ break;
+ case '0':
+ zeros++;
+ digits++;
+ break;
+ default:
+ if (digits == 0 || i + 1 >= code.Length || code[i + 1] is not ('#' or '0'))
+ {
+ throw Refuse(code, "a comma that is not a thousands separator between digit placeholders");
+ }
+
+ grouping = true;
+ break;
+ }
+ }
+
+ if (digits == 0)
+ {
+ throw Refuse(code, i < code.Length ? FormatCodes.At(code, i) : "no digit placeholder (0 or #) in the integer part");
+ }
+
+ string integer = code[integerStart..i];
+ string decimalsText = string.Empty;
+ int decimals = 0;
+ int minDecimals = 0;
+
+ if (i < code.Length && code[i] == '.')
+ {
+ i++;
+ int decimalStart = i;
+ bool optional = false;
+
+ for (; i < code.Length && code[i] is '0' or '#'; i++)
+ {
+ if (code[i] == '0' && optional)
+ {
+ throw Refuse(code, "a 0 after a # in the decimals");
+ }
+
+ optional |= code[i] == '#';
+ minDecimals += code[i] == '0' ? 1 : 0;
+ decimals++;
+ }
+
+ if (decimals == 0)
+ {
+ throw Refuse(code, "a decimal point without digit placeholders after it");
+ }
+
+ if (decimals > MaxDecimals)
+ {
+ throw Refuse(code, string.Create(CultureInfo.InvariantCulture, $"more than {MaxDecimals} decimal places"));
+ }
+
+ decimalsText = code[decimalStart..i];
+ }
+
+ bool percent = i < code.Length && code[i] == '%';
+ i += percent ? 1 : 0;
+
+ string suffix = FormatCodes.ReadQuoted(code, ref i);
+
+ if (i < code.Length)
+ {
+ throw Refuse(code, FormatCodes.At(code, i));
+ }
+
+ string canonical = FormatCodes.Quote(prefix) + integer + (decimals > 0 ? "." + decimalsText : string.Empty) + (percent ? "%" : string.Empty) + FormatCodes.Quote(suffix);
+ return new NumberFormat(canonical, grouping, zeros, decimals, minDecimals, percent, prefix, suffix);
+ }
+
+ ///
+ public override string ToString() => Code;
+
+ private static ArgumentException Refuse(string code, string what) =>
+ new($"The number format \"{code}\" is not supported: {what}.", nameof(code));
+}
+```
+
+The shared helpers go into `DateFormat.cs` as an internal static class (both parsers use them):
+
+```csharp
+/// The pieces of format-code parsing both parsers share.
+internal static class FormatCodes
+{
+ /// Reads a double-quoted literal at , if one starts there; "" otherwise.
+ public static string ReadQuoted(string code, ref int i)
+ {
+ if (i >= code.Length || code[i] != '"')
+ {
+ return string.Empty;
+ }
+
+ int end = code.IndexOf('"', i + 1);
+
+ if (end < 0)
+ {
+ throw new ArgumentException($"The format \"{code}\" is not supported: a quote at position {i + 1} that never closes.", nameof(code));
+ }
+
+ string text = code[(i + 1)..end];
+
+ if (TextRules.Check(text) is { } problem)
+ {
+ throw new ArgumentException($"The format \"{code}\" is not supported: its quoted text cannot be written ({problem}).", nameof(code));
+ }
+
+ i = end + 1;
+ return text;
+ }
+
+ /// The literal as a quoted run of a format code, or "" for none.
+ public static string Quote(string text) => text.Length == 0 ? string.Empty : "\"" + text + "\"";
+
+ /// Names the character at a position, for a refusal.
+ public static string At(string code, int i) => string.Create(CultureInfo.InvariantCulture, $"'{code[i]}' at position {i + 1}");
+}
+```
+
+- [ ] **Step 4: Implement `DateFormat`**
+
+Add to `DateFormat.cs` (with `using System.Globalization; using System.Text;`):
+
+```csharp
+/// What a part of a date format shows.
+internal enum DatePartKind
+{
+ Literal,
+ Year,
+ Month,
+ Day,
+ Hour,
+ Minute,
+ Second,
+}
+
+/// One part of a date format: a field (long = two digits, or four for the year) or literal text.
+internal readonly record struct DatePart(DatePartKind Kind, bool Long, string Text);
+
+///
+/// How a date or date-time cell shows its value, as an Excel format code from a subset both workbook
+/// formats can state: yyyy yy m mm d dd h hh
+/// s ss, separators / - . : , space, quoted or backslash-escaped text — as in
+/// dd/mm/yyyy or yyyy-mm-dd hh:mm.
+///
+///
+///
+/// m and mm are minutes right after an hour or right before a second, months
+/// otherwise — Excel's rule. The separators are written as literal text, so / shows as a
+/// slash whatever the reader's locale.
+///
+/// The value stays the date written; only its display changes. Compares by .
+///
+public sealed record DateFormat
+{
+ private DateFormat(string code, DatePart[] parts)
+ {
+ Code = code;
+ Parts = parts;
+ }
+
+ /// The format code as written into a workbook: lower-case fields, separators escaped.
+ public string Code { get; }
+
+ internal IReadOnlyList Parts { get; }
+
+ /// Parses a format code.
+ /// The code is outside the supported subset; the message names the part.
+ public static DateFormat Parse(string code)
+ {
+ ArgumentNullException.ThrowIfNull(code);
+
+ if (code.Length == 0)
+ {
+ throw Refuse(code, "the code is empty");
+ }
+
+ List parts = [];
+ StringBuilder literal = new();
+ int i = 0;
+
+ while (i < code.Length)
+ {
+ char c = code[i];
+
+ if (c == '"')
+ {
+ literal.Append(FormatCodes.ReadQuoted(code, ref i));
+ continue;
+ }
+
+ if (c == '\\')
+ {
+ if (i + 1 >= code.Length)
+ {
+ throw Refuse(code, "a backslash at the end, escaping nothing");
+ }
+
+ literal.Append(code[i + 1]);
+ i += 2;
+ continue;
+ }
+
+ if (c is '/' or '-' or '.' or ':' or ',' or ' ')
+ {
+ literal.Append(c);
+ i++;
+ continue;
+ }
+
+ char letter = char.ToLowerInvariant(c);
+
+ if (letter is not ('y' or 'm' or 'd' or 'h' or 's'))
+ {
+ throw Refuse(code, FormatCodes.At(code, i));
+ }
+
+ int run = 1;
+
+ while (i + run < code.Length && char.ToLowerInvariant(code[i + run]) == letter)
+ {
+ run++;
+ }
+
+ DatePart field = Field(code, letter, run);
+ Flush(parts, literal);
+ parts.Add(field);
+ i += run;
+ }
+
+ Flush(parts, literal);
+
+ if (parts.TrueForAll(p => p.Kind == DatePartKind.Literal))
+ {
+ throw Refuse(code, "no date or time part");
+ }
+
+ ResolveMinutes(parts);
+ return new DateFormat(Canonical(parts), [.. parts]);
+ }
+
+ ///
+ public bool Equals(DateFormat? other) => other is not null && string.Equals(Code, other.Code, StringComparison.Ordinal);
+
+ ///
+ public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Code);
+
+ ///
+ public override string ToString() => Code;
+
+ private static DatePart Field(string code, char letter, int run)
+ {
+ string token = new(letter, run);
+
+ return (letter, run) switch
+ {
+ ('y', 2) => new DatePart(DatePartKind.Year, false, token),
+ ('y', 4) => new DatePart(DatePartKind.Year, true, token),
+ ('m', <= 2) => new DatePart(DatePartKind.Month, run == 2, token),
+ ('d', <= 2) => new DatePart(DatePartKind.Day, run == 2, token),
+ ('h', <= 2) => new DatePart(DatePartKind.Hour, run == 2, token),
+ ('s', <= 2) => new DatePart(DatePartKind.Second, run == 2, token),
+ _ => throw Refuse(code, $"\"{token}\" (month and weekday names, and years other than yy or yyyy, are not supported)"),
+ };
+ }
+
+ private static void Flush(List parts, StringBuilder literal)
+ {
+ if (literal.Length > 0)
+ {
+ parts.Add(new DatePart(DatePartKind.Literal, false, literal.ToString()));
+ literal.Clear();
+ }
+ }
+
+ /// Excel's rule: m right after an hour, or right before a second, is minutes.
+ private static void ResolveMinutes(List parts)
+ {
+ for (int i = 0; i < parts.Count; i++)
+ {
+ if (parts[i].Kind != DatePartKind.Month)
+ {
+ continue;
+ }
+
+ DatePartKind before = Neighbour(parts, i, -1);
+ DatePartKind after = Neighbour(parts, i, +1);
+
+ if (before == DatePartKind.Hour || after == DatePartKind.Second)
+ {
+ parts[i] = parts[i] with { Kind = DatePartKind.Minute };
+ }
+ }
+ }
+
+ private static DatePartKind Neighbour(List parts, int i, int step)
+ {
+ for (int j = i + step; j >= 0 && j < parts.Count; j += step)
+ {
+ if (parts[j].Kind != DatePartKind.Literal)
+ {
+ return parts[j].Kind;
+ }
+ }
+
+ return DatePartKind.Literal;
+ }
+
+ /// Fields in lower case; literal characters escaped with a backslash, except ':', which Excel's own date-time formats leave bare.
+ private static string Canonical(List parts)
+ {
+ StringBuilder code = new();
+
+ foreach (DatePart part in parts)
+ {
+ if (part.Kind != DatePartKind.Literal)
+ {
+ code.Append(part.Text);
+ continue;
+ }
+
+ foreach (char c in part.Text)
+ {
+ if (c != ':')
+ {
+ code.Append('\\');
+ }
+
+ code.Append(c);
+ }
+ }
+
+ return code.ToString();
+ }
+
+ private static ArgumentException Refuse(string code, string what) =>
+ new($"The date format \"{code}\" is not supported: {what}.", nameof(code));
+}
+```
+
+The refusal tests match these messages: `Field` names the token (`mmm`, `dddd`, `yyy`), `FormatCodes.At` the character (`'['`, `'A'`, `'0'`), and the backslash and unclosed-quote messages contain `backslash` and `quote`.
+
+- [ ] **Step 5: Add `Number` and `Date` to `CellStyle`**
+
+Insert after `Font` in `CellStyle.cs`:
+
+```csharp
+ /// How an integer, decimal or double cell shows its value; ignored for other cells. Null keeps the default.
+ public NumberFormat? Number { get; init; }
+
+ /// How a date or date-time cell shows its value; ignored for other cells. Null keeps the writer's default date format.
+ public DateFormat? Date { get; init; }
+```
+
+- [ ] **Step 6: Public API and run**
+
+Add the RS0016 lines to `PublicAPI.Unshipped.txt` (record-generated members included: `Equals`, `GetHashCode`, `$`, `EqualityContract`, operators, `PrintMembers`, `ToString` as the analyzer lists them).
+Run: `dotnet test --project tests/TriasDev.Tabular.Tests -c Release -- --filter-class "*FormatCodeTests"` and `--filter-class "*CellStyleTests"`
+Expected: PASS.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/TriasDev.Tabular/Writing src/TriasDev.Tabular/PublicAPI.Unshipped.txt tests/TriasDev.Tabular.Tests/Writing/FormatCodeTests.cs
+git commit -m "feat(write): number and date format codes — a supported subset of Excel's, refused when outside it"
+```
+
+---
+
+### Task 3: Writer plumbing — `Style`, styled `Write` overloads, style index through every format
+
+**Files:**
+- Create: `src/TriasDev.Tabular/Writing/StyleTable.cs`
+- Modify: `src/TriasDev.Tabular/Writing/TabularWriter.cs`, `src/TriasDev.Tabular/Writing/ISheetWriter.cs`, `src/TriasDev.Tabular/Csv/CsvSheetWriter.cs`, `src/TriasDev.Tabular/Xlsx/XlsxSheetWriter.cs`, `src/TriasDev.Tabular/Ods/OdsSheetWriter.cs`, `src/TriasDev.Tabular/Writing/StyleId.cs` (remark cref), `PublicAPI.Unshipped.txt`
+- Test: `tests/TriasDev.Tabular.Tests/Writing/StyledWriterTests.cs`
+
+**Interfaces:**
+- Consumes: `CellStyle`, `StyleId` (Task 1), `NumberFormat`, `DateFormat` (Task 2).
+- Produces (Tasks 4–5 rely on these exact names):
+ - `internal enum ValueKind { Text, Integer, Number, Date, DateTime, Boolean, Empty }` and `internal const int ValueKinds = 7` on `StyleTable`.
+ - `internal sealed class StyleTable` — `public const int MaxStyles = 4096;` `public int Count` (registered styles + 1 for index 0), `public CellStyle this[int index]` (index ≥ 1), `public int Add(CellStyle style)` (returns the index; same value → same index; throws `TabularLimitException("MaxStyles", 4096, …)` on the 4097th distinct style; `ArgumentOutOfRangeException` for an undefined `Horizontal`).
+ - `TabularWriter`: `public StyleId Style(CellStyle style)`; `Write(string? value, StyleId style)`, `Write(long value, StyleId style)`, `Write(decimal value, StyleId style)`, `Write(double value, StyleId style)`, `Write(DateTime value, StyleId style)`, `Write(DateOnly value, StyleId style)`, `Write(bool value, StyleId style)`, `WriteEmpty(StyleId style)`.
+ - `ISheetWriter` (every implementation): `string? WriteText(string value, int column, int style)`, `string? WriteLong(long value, int style)`, `string? WriteDecimal(decimal value, int style)`, `string? WriteDouble(double value, int style)`, `string? WriteDate(DateTime value, bool hasTime, int style)`, `void WriteBoolean(bool value, int style)`, `void WriteEmpty(int style)`; style 0 = unstyled.
+ - Format writers receive the table in their constructor: `new CsvSheetWriter(buffer, csv)` unchanged (csv ignores styles); `new XlsxSheetWriter(workbook, xlsx, styles)`, `new OdsSheetWriter(spreadsheet, ods, styles)`. In this task xlsx and ods accept the parameter and **ignore it** (they write every cell as unstyled); Tasks 4 and 5 use it.
+
+- [ ] **Step 1: Write the failing tests**
+
+```csharp
+using TriasDev.Tabular.Tests.Fixtures;
+
+using Xunit;
+
+namespace TriasDev.Tabular.Tests.Writing;
+
+/// Registering styles and writing styled cells, independent of the format.
+public sealed class StyledWriterTests
+{
+ private static readonly CellStyle Red = new() { Fill = CellColor.FromRgb(0xFF0000) };
+
+ private static CancellationToken Token => TestContext.Current.CancellationToken;
+
+ [Fact]
+ public async Task TheSameStyleGetsTheSameId()
+ {
+ await using TabularWriter writer = TabularWriter.Create(new WriteTarget(), TabularFormat.Xlsx);
+
+ StyleId first = writer.Style(Red);
+ StyleId again = writer.Style(new CellStyle { Fill = CellColor.FromRgb(0xFF0000) });
+ StyleId other = writer.Style(Red with { Wrap = true });
+
+ Assert.Equal(first, again);
+ Assert.NotEqual(first, other);
+ Assert.NotEqual(default, first);
+ }
+
+ [Fact]
+ public async Task TheStyleAfterTheLimitIsRefused()
+ {
+ await using TabularWriter writer = TabularWriter.Create(new WriteTarget(), TabularFormat.Xlsx);
+
+ for (int i = 0; i < 4096; i++)
+ {
+ writer.Style(new CellStyle { Fill = CellColor.FromRgb(i) });
+ }
+
+ writer.Style(new CellStyle { Fill = CellColor.FromRgb(0) }); // already registered: no new style
+ TabularLimitException refused = Assert.Throws(() => writer.Style(new CellStyle { Fill = CellColor.FromRgb(4096) }));
+ Assert.Contains("4096", refused.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task AnUndefinedAlignmentIsRefused()
+ {
+ await using TabularWriter writer = TabularWriter.Create(new WriteTarget(), TabularFormat.Xlsx);
+
+ Assert.Throws(() => writer.Style(new CellStyle { Horizontal = (HorizontalAlignment)9 }));
+ }
+
+ [Fact]
+ public async Task AStyleIdFromAnotherWriterIsRefused()
+ {
+ await using TabularWriter other = TabularWriter.Create(new WriteTarget(), TabularFormat.Xlsx);
+ other.Style(Red);
+ StyleId foreign = other.Style(Red with { Wrap = true }); // index 2 there
+
+ await using TabularWriter writer = TabularWriter.Create(new WriteTarget(), TabularFormat.Xlsx);
+ writer.Style(Red); // only index 1 here
+ writer.BeginSheet("data", [new("x")]);
+ writer.BeginRow();
+
+ Assert.Throws(() => writer.Write(1.5, foreign));
+ }
+
+ [Fact]
+ public async Task CsvIgnoresStyles()
+ {
+ async Task Write(bool styled)
+ {
+ WriteTarget target = new();
+
+ await using (TabularWriter writer = TabularWriter.Create(target, TabularFormat.Csv))
+ {
+ StyleId style = styled ? writer.Style(Red with { Number = NumberFormat.Parse("0.00"), Date = DateFormat.Parse("dd/mm/yyyy") }) : default;
+ writer.BeginSheet("data", [new("text"), new("long"), new("decimal"), new("double"), new("date"), new("day"), new("flag"), new("none")]);
+ writer.BeginRow();
+ writer.Write("a", style);
+ writer.Write(12L, style);
+ writer.Write(1.25m, style);
+ writer.Write(2.5, style);
+ writer.Write(new DateTime(2026, 10, 4, 9, 5, 0, DateTimeKind.Unspecified), style);
+ writer.Write(new DateOnly(2026, 10, 4), style);
+ writer.Write(true, style);
+ writer.WriteEmpty(style);
+ writer.EndRow();
+ await writer.CompleteAsync(Token);
+ }
+
+ return target.ToArray();
+ }
+
+ Assert.Equal(await Write(styled: false), await Write(styled: true));
+ }
+
+ [Fact]
+ public async Task ANullStyleIsRefused()
+ {
+ await using TabularWriter writer = TabularWriter.Create(new WriteTarget(), TabularFormat.Xlsx);
+
+ Assert.Throws(() => writer.Style(null!));
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `dotnet build -c Release`
+Expected: compile errors — `TabularWriter.Style`, `Write(…, StyleId)` do not exist.
+
+- [ ] **Step 3: Implement `StyleTable`**
+
+```csharp
+namespace TriasDev.Tabular;
+
+/// What a cell holds, as far as its style is concerned: each kind may need its own format style.
+internal enum ValueKind
+{
+ Text,
+ Integer,
+ Number,
+ Date,
+ DateTime,
+ Boolean,
+ Empty,
+}
+
+///
+/// The styles one writer has handed out, by index: 0 is the unstyled cell, 1 onwards the registered
+/// styles. Each format resolves an index, per , into its own style lazily.
+///
+internal sealed class StyleTable
+{
+ /// The most distinct styles a file holds.
+ public const int MaxStyles = 4096;
+
+ /// The number of values, for per-kind caches.
+ public const int ValueKinds = 7;
+
+ private readonly Dictionary _indices = [];
+ private readonly List _styles = [new CellStyle()];
+
+ /// The indices in use: the registered styles plus index 0.
+ public int Count => _styles.Count;
+
+ /// The style at an index (1 onwards; 0 is the empty style).
+ public CellStyle this[int index] => _styles[index];
+
+ /// Registers a style, or finds it registered already; returns its index.
+ public int Add(CellStyle style)
+ {
+ ArgumentNullException.ThrowIfNull(style);
+
+ if (_indices.TryGetValue(style, out int index))
+ {
+ return index;
+ }
+
+ if (!Enum.IsDefined(style.Horizontal))
+ {
+ throw new ArgumentOutOfRangeException(nameof(style), style.Horizontal, "The horizontal alignment is not one HorizontalAlignment defines.");
+ }
+
+ if (_indices.Count == MaxStyles)
+ {
+ throw new TabularLimitException("MaxStyles", MaxStyles, $"A file holds at most {MaxStyles} distinct styles.");
+ }
+
+ index = _styles.Count;
+ _styles.Add(style);
+ _indices.Add(style, index);
+ return index;
+ }
+}
+```
+
+- [ ] **Step 4: Wire `TabularWriter`**
+
+1. Field: `private readonly StyleTable _styles = new();`. Pass it to the format writers in `Create` (constructors change: `XlsxSheetWriter(SpillBuffer output, XlsxWriterOptions options, StyleTable styles)`, `OdsSheetWriter(SpillBuffer output, OdsWriterOptions options, StyleTable styles)`). Because `Create` builds the sheet writer before the `TabularWriter` constructor runs, create the table in `Create` and pass it to the constructor too: add a `StyleTable styles` constructor parameter and assign `_styles = styles`.
+2. Add:
+
+```csharp
+ ///
+ /// Registers a style for this writer's cells and returns its id, to pass to the Write
+ /// overloads. The same style, by value, returns the same id. Call it before or during any sheet;
+ /// csv files ignore styles.
+ ///
+ /// The file already holds 4096 distinct styles.
+ /// The style's alignment is not a defined value.
+ public StyleId Style(CellStyle style)
+ {
+ ExpectWritable();
+
+ try
+ {
+ return new StyleId(_styles.Add(style));
+ }
+ catch (Exception refused) when (refused is ArgumentException or TabularLimitException)
+ {
+ MarkFaulted();
+ throw;
+ }
+ }
+```
+
+3. Turn each existing `Write(x)` into a call of the new overload with `default`, and move the body into the new overload. Example for double (do the same for every overload; text checks `TextRules` exactly as now; `Write(bool, StyleId)` passes the index; `WriteEmpty(StyleId)`):
+
+```csharp
+ public void Write(double value) => Write(value, default);
+
+ /// Writes the next cell as a number, in a style this writer handed out.
+ /// was not handed out by this writer.
+ public void Write(double value, StyleId style)
+ {
+ int column = NextCell();
+ Check(ValueChecks.Double(value) ?? _sheet.WriteDouble(value, Index(style)), column);
+ }
+```
+
+Keep each existing overload's XML doc on the unstyled overload and give the styled one `` plus the `` and the `ArgumentException` line.
+
+4. The index check — one compare per cell:
+
+```csharp
+ /// The style's index, refused unless this writer handed it out.
+ private int Index(StyleId style)
+ {
+ int index = style.Value;
+
+ if ((uint)index >= (uint)_styles.Count)
+ {
+ throw Faulting(new ArgumentException($"{style} was not handed out by this writer's Style method.", nameof(style)));
+ }
+
+ return index;
+ }
+```
+
+5. `ISheetWriter`: add `int style` as the last parameter of `WriteText`, `WriteLong`, `WriteDecimal`, `WriteDouble`, `WriteDate`, `WriteBoolean`, `WriteEmpty`, with the doc line `The style index in the writer's ; 0 is unstyled.`. `FinishRow` calls `_sheet.WriteEmpty(0)`; `BeginSheet`'s header loop is unchanged (`WriteHeader` takes no style in this part).
+6. `CsvSheetWriter`: add the parameter to the seven methods and ignore it (name it `style` and add `_ = style;` only if an analyzer requires it — IDE0060 is suppressed for interface implementations).
+7. `XlsxSheetWriter` and `OdsSheetWriter`: add the constructor parameter and the method parameters; ignore them for now (store the table in a field `_styles` — Task 4/5 use it).
+8. `StyleId.cs`: restore the `` in the summary.
+
+- [ ] **Step 5: Public API, run**
+
+Add the RS0016 lines. Run: `dotnet test --project tests/TriasDev.Tabular.Tests -c Release -- --filter-class "*StyledWriterTests"` → PASS; then `dotnet test -c Release` → all PASS (every existing writer test runs through the changed signatures).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src tests/TriasDev.Tabular.Tests/Writing/StyledWriterTests.cs
+git commit -m "feat(write): register styles and write styled cells; the style index reaches every format, csv ignores it"
+```
+
+---
+
+### Task 4: xlsx styles
+
+**Files:**
+- Create: `src/TriasDev.Tabular/Xlsx/XlsxStyles.cs`
+- Modify: `src/TriasDev.Tabular/Xlsx/XlsxSheetWriter.cs`, `src/TriasDev.Tabular/Xlsx/XlsxParts.cs` (remove the fixed `Styles` constant; keep its doc comment's content on `XlsxStyles`), `tests/TriasDev.Tabular.Tests/Fixtures/LibreOffice.cs`
+- Test: `tests/TriasDev.Tabular.Tests/Writing/XlsxStyleTests.cs`, `tests/TriasDev.Tabular.Tests/Writing/StyledInteropTests.cs`
+
+**Interfaces:**
+- Consumes: `StyleTable`, `ValueKind`, `StyleTable.ValueKinds` (Task 3); `NumberFormat.Code`, `DateFormat.Code` (Task 2); `CellStyle`, `CellColor.Rgb`, `CellFont`, `CellBorder`, `HorizontalAlignment` (Task 1).
+- Produces: `internal sealed class XlsxStyles(StyleTable table)` with `public int Xf(int style, ValueKind kind)` (style ≥ 1; returns the `cellXfs` index) and `public byte[] Build()` (the whole `xl/styles.xml`). `LibreOffice.Convert(byte[] file, string extension, string filter, string outputExtension) → byte[]` (Task 5 uses it too).
+
+- [ ] **Step 1: Generalise the LibreOffice fixture**
+
+In `Fixtures/LibreOffice.cs` split `ConvertToCsv` into a general `Convert` and a csv wrapper:
+
+```csharp
+ /// Converts the file's first sheet to csv with LibreOffice and returns its lines, values as shown.
+ public static string[] ConvertToCsv(byte[] file, string extension) =>
+ Encoding.UTF8.GetString(Convert(file, extension, "csv:Text - txt - csv (StarCalc):44,34,76,1,,0,true", "csv"))
+ .Split('\n')
+ .Select(line => line.TrimEnd('\r'))
+ .Where((line, i) => line.Length > 0 || i == 0)
+ .ToArray();
+
+ /// Converts a file with LibreOffice, by an export filter, and returns the converted file.
+ public static byte[] Convert(byte[] file, string extension, string filter, string outputExtension)
+```
+
+`Convert` holds today's body with `"--convert-to", filter`, the expected output `Path.Combine(folder, "file." + outputExtension)`, and returns `File.ReadAllBytes(output)`. Keep `ConvertToCsv`'s old return exactly: it used `File.ReadAllLines`, which drops the trailing empty line — the `Where` above does the same for a trailing newline; check with the existing `LibreOfficeInteropTests` (Step 6) that they pass unchanged.
+
+- [ ] **Step 2: Write the failing structural tests**
+
+`XlsxStyleTests.cs`:
+
+```csharp
+using System.IO.Compression;
+using System.Text;
+
+using TriasDev.Tabular.Tests.Fixtures;
+using TriasDev.Tabular.Xlsx;
+
+using Xunit;
+
+namespace TriasDev.Tabular.Tests.Writing;
+
+/// Styles in the workbook: valid to the schema, deduplicated, and invisible to the import.
+public sealed class XlsxStyleTests
+{
+ private static readonly CellStyle Legend = new()
+ {
+ Fill = CellColor.FromRgb(0xF8696B),
+ Font = new CellFont { Color = CellColor.FromRgb(0xFFFFFF), Bold = true, Italic = true },
+ Number = NumberFormat.Parse("#,##0.00"),
+ Date = DateFormat.Parse("dd/mm/yyyy"),
+ Horizontal = HorizontalAlignment.Center,
+ Wrap = true,
+ Border = CellBorder.Thin(CellColor.FromRgb(0xBFBFBF)),
+ };
+
+ private static CancellationToken Token => TestContext.Current.CancellationToken;
+
+ private static async Task Workbook(Action write)
+ {
+ WriteTarget target = new();
+
+ await using (TabularWriter writer = TabularWriter.Create(target, TabularFormat.Xlsx))
+ {
+ write(writer);
+ await writer.CompleteAsync(Token);
+ }
+
+ return target.ToArray();
+ }
+
+ private static string Part(byte[] xlsx, string name)
+ {
+ using ZipArchive archive = new(new MemoryStream(xlsx, writable: false), ZipArchiveMode.Read);
+ using StreamReader reader = new(archive.GetEntry(name)!.Open(), Encoding.UTF8);
+ return reader.ReadToEnd();
+ }
+
+ private static List Rows(byte[] xlsx)
+ {
+ using XlsxCursor cursor = new(new MemoryStream(xlsx, writable: false), cancellationToken: Token);
+ Assert.True(cursor.MoveToSheet(0, Token));
+ List rows = [];
+
+ while (cursor.ReadRow(Token))
+ {
+ rows.Add(cursor.CurrentRow.ToArray());
+ }
+
+ return rows;
+ }
+
+ /// One row of every kind, all in one style (or unstyled).
+ private static void EveryKind(TabularWriter writer, StyleId style)
+ {
+ writer.BeginSheet("data", [new("text"), new("long"), new("decimal"), new("double"), new("date"), new("stamp"), new("flag"), new("none")]);
+ writer.BeginRow();
+ writer.Write("R&D", style);
+ writer.Write(1234567L, style);
+ writer.Write(1234.5m, style);
+ writer.Write(0.125, style);
+ writer.Write(new DateOnly(2026, 10, 4), style);
+ writer.Write(new DateTime(2026, 10, 4, 9, 5, 0, DateTimeKind.Unspecified), style);
+ writer.Write(true, style);
+ writer.WriteEmpty(style);
+ writer.EndRow();
+ }
+
+ [Fact]
+ public async Task AStyledWorkbookIsValidAndStatesTheStyle()
+ {
+ byte[] xlsx = await Workbook(writer => EveryKind(writer, writer.Style(Legend)));
+
+ Assert.Empty(OoxmlValidation.Errors(xlsx));
+
+ string styles = Part(xlsx, "xl/styles.xml");
+ Assert.Contains("", styles, StringComparison.Ordinal);
+ Assert.Contains("", styles, StringComparison.Ordinal);
+ Assert.Contains("", styles, StringComparison.Ordinal);
+ Assert.Contains("", styles, StringComparison.Ordinal);
+ Assert.Contains("", styles, StringComparison.Ordinal);
+ Assert.Contains("", styles, StringComparison.Ordinal);
+ Assert.Contains("", styles, StringComparison.Ordinal);
+
+ string sheet = Part(xlsx, "xl/worksheets/sheet1.xml");
+ Assert.Contains(" EveryKind(writer, default));
+ byte[] styled = await Workbook(writer => EveryKind(writer, writer.Style(Legend)));
+
+ Assert.Equal(Rows(plain).Select(r => r.ToArray()), Rows(styled).Select(r => r.ToArray()));
+ Assert.Equal(new DateTime(2026, 10, 4, 0, 0, 0, DateTimeKind.Unspecified), Rows(styled)[1][4].DateTime);
+ Assert.Equal(RawCell.FromBoolean(true), Rows(styled)[1][6]);
+ Assert.True(Rows(styled)[1][7].IsEmpty);
+ }
+
+ [Fact]
+ public async Task OneStyleOnSeveralKindsTakesAFormatPerKind()
+ {
+ byte[] xlsx = await Workbook(writer => EveryKind(writer, writer.Style(Legend)));
+ string styles = Part(xlsx, "xl/styles.xml");
+
+ // text/flag/none share one xf (General), long/decimal/double share the number format, date and stamp the date format.
+ Assert.Contains("", styles, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task EqualStylesAreWrittenOnce()
+ {
+ byte[] xlsx = await Workbook(writer =>
+ {
+ StyleId a = writer.Style(new CellStyle { Fill = CellColor.FromRgb(0x00FF00) });
+ StyleId b = writer.Style(new CellStyle { Fill = CellColor.FromRgb(0x00FF00) });
+ StyleId c = writer.Style(new CellStyle { Fill = CellColor.FromRgb(0x00FF00), Wrap = true });
+ writer.BeginSheet("data", [new("a"), new("b"), new("c")]);
+ writer.BeginRow();
+ writer.Write("x", a);
+ writer.Write("y", b);
+ writer.Write("z", c);
+ writer.EndRow();
+ });
+
+ string styles = Part(xlsx, "xl/styles.xml");
+ Assert.Contains("", styles, StringComparison.Ordinal); // none, gray125, one green
+ Assert.Contains("", styles, StringComparison.Ordinal); // four fixed + two
+ }
+
+ [Fact]
+ public async Task AStyleRegisteredInTheMiddleOfTheDataIsWritten()
+ {
+ byte[] xlsx = await Workbook(writer =>
+ {
+ writer.BeginSheet("data", [new("n")]);
+
+ for (int i = 1; i <= 1000; i++)
+ {
+ writer.BeginRow();
+ writer.Write(i, i == 1000 ? writer.Style(new CellStyle { Fill = CellColor.FromRgb(0x0000FF) }) : default);
+ writer.EndRow();
+ }
+ });
+
+ Assert.Empty(OoxmlValidation.Errors(xlsx));
+ Assert.Contains("", Part(xlsx, "xl/styles.xml"), StringComparison.Ordinal);
+ Assert.Contains("1000", Part(xlsx, "xl/worksheets/sheet1.xml"), StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task LiteralTextInAFormatIsEscaped()
+ {
+ byte[] xlsx = await Workbook(writer =>
+ {
+ StyleId style = writer.Style(new CellStyle { Number = NumberFormat.Parse("\"R&D <\"0") });
+ writer.BeginSheet("data", [new("n")]);
+ writer.BeginRow();
+ writer.Write(5L, style);
+ writer.EndRow();
+ });
+
+ Assert.Empty(OoxmlValidation.Errors(xlsx));
+ Assert.Contains("formatCode=\""R&D <"0\"", Part(xlsx, "xl/styles.xml"), StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task AnUnstyledWorkbookKeepsItsFourFormats()
+ {
+ byte[] xlsx = await Workbook(writer => EveryKind(writer, default));
+
+ Assert.Contains("", Part(xlsx, "xl/styles.xml"), StringComparison.Ordinal);
+ Assert.Empty(OoxmlValidation.Errors(xlsx));
+ }
+}
+```
+
+- [ ] **Step 3: Run to verify they fail**
+
+Run: `dotnet test --project tests/TriasDev.Tabular.Tests -c Release -- --filter-class "*XlsxStyleTests"`
+Expected: FAIL — styles.xml still the fixed four formats, no `s` on styled cells.
+
+- [ ] **Step 4: Implement `XlsxStyles`**
+
+```csharp
+using System.Globalization;
+using System.Text;
+
+namespace TriasDev.Tabular.Xlsx;
+
+///
+/// The workbook's styles: four fixed cell formats every unstyled cell uses, then one per registered
+/// style and value kind actually written, resolved on first use and cached.
+///
+///
+/// The fixed formats, by index: 0 General, 1 a date (built-in 14), 2 a date and time (custom 164),
+/// 3 an integer (built-in 1, so an id of twelve digits does not show as 1.23457E+11). The
+/// reader takes formats 14 and 164 — and any custom code with a date token — for dates. Custom
+/// number formats start at 165.
+///
+internal sealed class XlsxStyles
+{
+ public const int DateFormat = 14;
+ public const int DateTimeFormat = 164;
+ public const int IntegerFormat = 1;
+ private const int FirstCustomFormat = 165;
+ private const string XmlDeclaration = "";
+
+ private readonly StyleTable _table;
+ private readonly List _xfs = [new(0, 0, 0, 0, HorizontalAlignment.General, false), new(DateFormat, 0, 0, 0, HorizontalAlignment.General, false), new(DateTimeFormat, 0, 0, 0, HorizontalAlignment.General, false), new(IntegerFormat, 0, 0, 0, HorizontalAlignment.General, false)];
+ private readonly Dictionary _xfIndices = [];
+ private readonly List _fonts = []; // index + 1 in ; 0 is the default font
+ private readonly Dictionary _fontIndices = [];
+ private readonly List _fills = []; // index + 2 in ; 0 none, 1 gray125
+ private readonly Dictionary _fillIndices = [];
+ private readonly List _borders = []; // index + 1 in ; 0 none
+ private readonly Dictionary _borderIndices = [];
+ private readonly List _formats = []; // id FirstCustomFormat + index
+ private readonly Dictionary _formatIds = new(StringComparer.Ordinal);
+ private int[] _resolved = []; // xf index + 1 per (style, kind); 0 = not yet
+
+ public XlsxStyles(StyleTable table)
+ {
+ _table = table;
+
+ for (int i = 0; i < _xfs.Count; i++)
+ {
+ _xfIndices.Add(_xfs[i], i);
+ }
+ }
+
+ /// The cell format for a registered style (index 1 onwards) on a kind of value.
+ public int Xf(int style, ValueKind kind)
+ {
+ int slot = (style * StyleTable.ValueKinds) + (int)kind;
+ int[] resolved = _resolved;
+ return slot < resolved.Length && resolved[slot] != 0 ? resolved[slot] - 1 : Resolve(slot, style, kind);
+ }
+
+ /// The whole xl/styles.xml.
+ public byte[] Build()
+ {
+ StringBuilder xml = new(XmlDeclaration + "");
+
+ xml.Append(CultureInfo.InvariantCulture, $"");
+
+ for (int i = 0; i < _formats.Count; i++)
+ {
+ xml.Append(CultureInfo.InvariantCulture, $"");
+ }
+
+ xml.Append(CultureInfo.InvariantCulture, $"");
+
+ foreach (CellFont font in _fonts)
+ {
+ xml.Append("");
+ xml.Append(font.Bold ? "" : string.Empty);
+ xml.Append(font.Italic ? "" : string.Empty);
+ xml.Append("");
+
+ if (font.Color is { } color)
+ {
+ xml.Append("");
+ }
+
+ xml.Append("");
+ }
+
+ xml.Append(CultureInfo.InvariantCulture, $"");
+
+ foreach (CellColor fill in _fills)
+ {
+ xml.Append("");
+ }
+
+ xml.Append(CultureInfo.InvariantCulture, $"");
+
+ foreach (CellColor border in _borders)
+ {
+ string side = "style=\"thin\">";
+ xml.Append("");
+ }
+
+ xml.Append("");
+ xml.Append(CultureInfo.InvariantCulture, $"");
+
+ foreach (Xf xf in _xfs)
+ {
+ AppendXf(xml, xf);
+ }
+
+ xml.Append("");
+ return Encoding.UTF8.GetBytes(xml.ToString());
+ }
+
+ private int Resolve(int slot, int style, ValueKind kind)
+ {
+ CellStyle cell = _table[style];
+
+ int format = kind switch
+ {
+ ValueKind.Integer => cell.Number is { } number ? FormatId(number.Code) : IntegerFormat,
+ ValueKind.Number => cell.Number is { } number ? FormatId(number.Code) : 0,
+ ValueKind.Date => cell.Date is { } date ? FormatId(date.Code) : DateFormat,
+ ValueKind.DateTime => cell.Date is { } date ? FormatId(date.Code) : DateTimeFormat,
+ _ => 0,
+ };
+
+ int font = cell.Font is { } f ? Index(_fonts, _fontIndices, f) + 1 : 0;
+ int fill = cell.Fill is { } c ? Index(_fills, _fillIndices, c) + 2 : 0;
+ int border = cell.Border is { } b ? Index(_borders, _borderIndices, b.Color) + 1 : 0;
+ int xf = Index(_xfs, _xfIndices, new Xf(format, font, fill, border, cell.Horizontal, cell.Wrap));
+
+ if (slot >= _resolved.Length)
+ {
+ Array.Resize(ref _resolved, Math.Max(slot + 1, _table.Count * StyleTable.ValueKinds));
+ }
+
+ _resolved[slot] = xf + 1;
+ return xf;
+ }
+
+ private int FormatId(string code)
+ {
+ if (!_formatIds.TryGetValue(code, out int id))
+ {
+ id = FirstCustomFormat + _formats.Count;
+ _formats.Add(code);
+ _formatIds.Add(code, id);
+ }
+
+ return id;
+ }
+
+ private static int Index(List list, Dictionary indices, T value)
+ where T : notnull
+ {
+ if (!indices.TryGetValue(value, out int index))
+ {
+ index = list.Count;
+ list.Add(value);
+ indices.Add(value, index);
+ }
+
+ return index;
+ }
+
+ private static void AppendXf(StringBuilder xml, Xf xf)
+ {
+ xml.Append(CultureInfo.InvariantCulture, $"");
+ return;
+ }
+
+ xml.Append(" applyAlignment=\"1\"> "left", HorizontalAlignment.Center => "center", _ => "right" }).Append('"');
+ }
+
+ xml.Append(xf.Wrap ? " wrapText=\"1\"" : string.Empty).Append("/>");
+ }
+
+ private static string Argb(CellColor color) => string.Create(CultureInfo.InvariantCulture, $"FF{color.Rgb:X6}");
+
+ private static void AppendEscaped(StringBuilder xml, string value)
+ {
+ foreach (char c in value)
+ {
+ _ = c switch
+ {
+ '&' => xml.Append("&"),
+ '<' => xml.Append("<"),
+ '>' => xml.Append(">"),
+ '"' => xml.Append("""),
+ _ => xml.Append(c),
+ };
+ }
+ }
+
+ private readonly record struct Xf(int Format, int Font, int Fill, int Border, HorizontalAlignment Horizontal, bool Wrap);
+}
+```
+
+Check the counts the tests expect against this code before running: `EveryKind` in one style yields three new xfs (General-with-style for text/flag/none, number format 165 for long/decimal/double, date format 166 for date and stamp) → `cellXfs count="7"`. `EqualStylesAreWrittenOnce`: fills `none, gray125, green` → 3; xfs 4 + (green) + (green, wrap) → 6. `AStyleRegisteredInTheMiddle…`: the first styled xf is index 4 → `s="4"`. The `AStyledWorkbook…` test's font assertion `` matches the order above.
+
+- [ ] **Step 5: Use it in `XlsxSheetWriter`**
+
+1. Field `private readonly XlsxStyles _styles;` set from the constructor's `StyleTable` (`_styles = new XlsxStyles(styles)`).
+2. `StartCell()` becomes `StartCell(int xf)`: after the reference attribute, when `xf != 0`, append ` s="` + `AppendFormatted(xf, default, CultureInfo.InvariantCulture)` + `"`. `WriteNumber(int style)` becomes `WriteNumber(int xf)` → `StartCell(xf); _row.Append(">");` (the single-digit `(char)('0' + style)` goes away).
+3. Pick the xf per method, unstyled first so index 0 costs one compare:
+
+```csharp
+ public string? WriteLong(long value, int style)
+ {
+ if (ValueChecks.LongInDouble(value) is { } code)
+ {
+ return code;
+ }
+
+ WriteNumber(style == 0 ? IntegerStyle : _styles.Xf(style, ValueKind.Integer));
+ _row.AppendFormatted(value, default, CultureInfo.InvariantCulture);
+ _row.Append(ValueEnd);
+ return null;
+ }
+```
+
+ Decimal and double: `style == 0 ? 0 : _styles.Xf(style, ValueKind.Number)`. Date: `style == 0 ? (hasTime ? DateTimeStyle : DateStyle) : _styles.Xf(style, hasTime ? ValueKind.DateTime : ValueKind.Date)`. Text (`WriteText`) and header: `WriteInline(value, xf)` with `xf = style == 0 ? 0 : _styles.Xf(style, ValueKind.Text)` (header passes 0). Boolean: `StartCell(style == 0 ? 0 : _styles.Xf(style, ValueKind.Boolean))`.
+4. `WriteEmpty(int style)`: `if (style == 0) { _column++; return; } StartCell(_styles.Xf(style, ValueKind.Empty)); _row.Append("/>");`
+5. `Complete()`: `_zip.AddStored("xl/styles.xml", _styles.Build());`. Delete `XlsxParts.Styles` (its content now comes from `Build`).
+
+- [ ] **Step 6: Run the structural tests and the existing suite**
+
+Run: `dotnet test --project tests/TriasDev.Tabular.Tests -c Release -- --filter-class "*XlsxStyleTests"` → PASS. Then `--filter-class "*Xlsx*"` and `--filter-class "*LibreOfficeInteropTests"` → PASS unchanged.
+
+- [ ] **Step 7: Write the LibreOffice interop tests (xlsx half)**
+
+`StyledInteropTests.cs`:
+
+```csharp
+using System.IO.Compression;
+using System.Text;
+
+using TriasDev.Tabular.Tests.Fixtures;
+
+using Xunit;
+
+namespace TriasDev.Tabular.Tests.Writing;
+
+/// LibreOffice shows our formats and keeps our fills and fonts.
+public sealed class StyledInteropTests
+{
+ private static CancellationToken Token => TestContext.Current.CancellationToken;
+
+ private static readonly CellStyle Red = new()
+ {
+ Fill = CellColor.FromRgb(0xF8696B),
+ Font = new CellFont { Bold = true },
+ };
+
+ private static async Task Write(TabularFormat format)
+ {
+ WriteTarget target = new();
+
+ await using (TabularWriter writer = TabularWriter.Create(target, format))
+ {
+ StyleId twoPlaces = writer.Style(new CellStyle { Number = NumberFormat.Parse("0.00") });
+ StyleId percent = writer.Style(new CellStyle { Number = NumberFormat.Parse("0.0%") });
+ StyleId prefixed = writer.Style(new CellStyle { Number = NumberFormat.Parse("\"EUR \"0") });
+ StyleId day = writer.Style(new CellStyle { Date = DateFormat.Parse("dd/mm/yyyy") });
+ StyleId stamp = writer.Style(new CellStyle { Date = DateFormat.Parse("dd.mm.yyyy hh:mm") });
+ StyleId red = writer.Style(Red);
+
+ writer.BeginSheet("data", [new("a"), new("b"), new("c"), new("d"), new("e"), new("f")]);
+ writer.BeginRow();
+ writer.Write(12.5, twoPlaces);
+ writer.Write(0.125, percent);
+ writer.Write(42L, prefixed);
+ writer.Write(new DateOnly(2026, 10, 4), day);
+ writer.Write(new DateTime(2026, 10, 4, 9, 5, 0, DateTimeKind.Unspecified), stamp);
+ writer.Write("hot", red);
+ writer.EndRow();
+ await writer.CompleteAsync(Token);
+ }
+
+ return target.ToArray();
+ }
+
+ private static string Entry(byte[] zip, string name)
+ {
+ using ZipArchive archive = new(new MemoryStream(zip, writable: false), ZipArchiveMode.Read);
+ using StreamReader reader = new(archive.GetEntry(name)!.Open(), Encoding.UTF8);
+ return reader.ReadToEnd();
+ }
+
+ [Theory]
+ [InlineData(TabularFormat.Xlsx, "xlsx")]
+ public async Task ShowsTheFormats(TabularFormat format, string extension)
+ {
+ string[] lines = LibreOffice.ConvertToCsv(await Write(format), extension);
+
+ // The decimal separator is the LibreOffice profile's locale; the rest is what the codes state.
+ Assert.Matches("^12[.,]50,12[.,]5%,EUR 42,04/10/2026,04\\.10\\.2026 09:05,\"hot\"$", lines[1]);
+ }
+
+ [Fact]
+ public async Task KeepsTheFillAndFontOfAnXlsx()
+ {
+ byte[] ods = LibreOffice.Convert(await Write(TabularFormat.Xlsx), "xlsx", "ods", "ods");
+ string styles = Entry(ods, "content.xml") + Entry(ods, "styles.xml");
+
+ Assert.Contains("fo:background-color=\"#f8696b\"", styles, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("fo:font-weight=\"bold\"", styles, StringComparison.Ordinal);
+ }
+}
+```
+
+If `ShowsTheFormats` fails only because LibreOffice quotes a field (for example the percent with a locale comma), change the regex to accept an optional quote around that field — the assertion is about the shown text, not csv quoting.
+
+- [ ] **Step 8: Run interop**
+
+Run: `TABULAR_REQUIRE_SOFFICE=1 dotnet test --project tests/TriasDev.Tabular.Tests -c Release -- --filter-class "*StyledInteropTests"` (LibreOffice is installed on the development machine and in CI's Linux job).
+Expected: PASS.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add src tests
+git commit -m "feat(write): xlsx styles — fills, fonts, borders, alignment, number and date formats, resolved per kind on first use"
+```
+
+---
+
+### Task 5: ods styles
+
+**Files:**
+- Create: `src/TriasDev.Tabular/Ods/OdsStyles.cs`
+- Modify: `src/TriasDev.Tabular/Ods/OdsSheetWriter.cs`, `src/TriasDev.Tabular/Ods/OdsParts.cs` (make `Namespaces` and the `XmlDeclaration` internal; expose the two default date data-style bodies as constants; remove the fixed `Styles` bytes)
+- Test: `tests/TriasDev.Tabular.Tests/Writing/OdsStyleTests.cs`, `tests/TriasDev.Tabular.Tests/Writing/StyledInteropTests.cs`
+
+**Interfaces:**
+- Consumes: as Task 4, plus `NumberFormat` internals (`Grouping`, `MinIntegerDigits`, `DecimalPlaces`, `MinDecimalPlaces`, `Percent`, `Prefix`, `Suffix`) and `DateFormat.Parts` / `DatePart` / `DatePartKind` (Task 2); `LibreOffice.Convert` (Task 4).
+- Produces: `internal sealed class OdsStyles(StyleTable table)` with `public OdsCellStyle Cell(int style, ValueKind kind)` and `public byte[] Build()`; `internal readonly record struct OdsCellStyle(string Name, bool Percent)`.
+
+- [ ] **Step 1: Probe that LibreOffice applies a common cell style referenced from content**
+
+This is the design's one assumption about ods (spec §1). Write the probe as the first test in `OdsStyleTests.cs`, by hand-building nothing: write it against the finished writer, so it fails now and passes after Step 4:
+
+```csharp
+ [Fact]
+ public async Task LibreOfficeAppliesTheCommonStyle()
+ {
+ byte[] ods = await Spreadsheet(writer =>
+ {
+ StyleId red = writer.Style(new CellStyle { Fill = CellColor.FromRgb(0xF8696B), Font = new CellFont { Bold = true } });
+ writer.BeginSheet("data", [new("a")]);
+ writer.BeginRow();
+ writer.Write("hot", red);
+ writer.EndRow();
+ });
+
+ byte[] xlsx = LibreOffice.Convert(ods, "ods", "xlsx:Calc MS Excel 2007 XML", "xlsx");
+ string styles = Entry(xlsx, "xl/styles.xml");
+
+ Assert.Contains("rgb=\"FFF8696B\"", styles, StringComparison.OrdinalIgnoreCase);
+ Assert.Matches("", styles);
+ }
+```
+
+If, after Step 4, this test fails because LibreOffice ignores a common style referenced directly by `table:style-name` (and only then), stop and report BLOCKED with the converted file's `content.xml` — the controller rules on the fallback (declaring styles before the body); do not invent one.
+
+- [ ] **Step 2: Write the failing structural tests**
+
+Rest of `OdsStyleTests.cs` — the helpers mirror `OdsWriterTests` (`Spreadsheet(Action)`, `Rows(byte[] ods)` with `OdsCursor`, `Entry(byte[] zip, string name)`); copy them from `tests/TriasDev.Tabular.Tests/Writing/OdsWriterTests.cs` lines 16–50 and add `Entry` as in `StyledInteropTests`.
+
+```csharp
+ private static readonly CellStyle Legend = new()
+ {
+ Fill = CellColor.FromRgb(0xF8696B),
+ Font = new CellFont { Color = CellColor.FromRgb(0xFFFFFF), Bold = true, Italic = true },
+ Number = NumberFormat.Parse("#,##0.00"),
+ Date = DateFormat.Parse("dd/mm/yyyy"),
+ Horizontal = HorizontalAlignment.Center,
+ Wrap = true,
+ Border = CellBorder.Thin(CellColor.FromRgb(0xBFBFBF)),
+ };
+
+ private static void EveryKind(TabularWriter writer, StyleId style)
+ {
+ writer.BeginSheet("data", [new("text"), new("long"), new("decimal"), new("double"), new("date"), new("stamp"), new("flag"), new("none")]);
+ writer.BeginRow();
+ writer.Write("R&D", style);
+ writer.Write(1234567L, style);
+ writer.Write(1234.5m, style);
+ writer.Write(0.125, style);
+ writer.Write(new DateOnly(2026, 10, 4), style);
+ writer.Write(new DateTime(2026, 10, 4, 9, 5, 0, DateTimeKind.Unspecified), style);
+ writer.Write(true, style);
+ writer.WriteEmpty(style);
+ writer.EndRow();
+ }
+
+ [Fact]
+ public async Task StatesTheStyleAsACommonStyle()
+ {
+ byte[] ods = await Spreadsheet(writer => EveryKind(writer, writer.Style(Legend)));
+ string styles = Entry(ods, "styles.xml");
+ string content = Entry(ods, "content.xml");
+
+ Assert.Contains("", styles, StringComparison.Ordinal);
+ Assert.Contains("//", styles, StringComparison.Ordinal);
+ Assert.Contains("fo:background-color=\"#F8696B\"", styles, StringComparison.Ordinal);
+ Assert.Contains("fo:border=\"0.06pt solid #BFBFBF\"", styles, StringComparison.Ordinal);
+ Assert.Contains("fo:wrap-option=\"wrap\"", styles, StringComparison.Ordinal);
+ Assert.Contains("", styles, StringComparison.Ordinal);
+ Assert.Contains("fo:color=\"#FFFFFF\" fo:font-weight=\"bold\"", styles, StringComparison.Ordinal);
+ Assert.Contains("fo:font-style=\"italic\"", styles, StringComparison.Ordinal);
+ Assert.Contains("table:style-name=\"ts", content, StringComparison.Ordinal);
+ Assert.Contains("", content, StringComparison.Ordinal); // the styled empty cell
+ }
+
+ [Fact]
+ public async Task TheImportReadsAStyledRowAsItReadsAnUnstyledOne()
+ {
+ byte[] plain = await Spreadsheet(writer => EveryKind(writer, default));
+ byte[] styled = await Spreadsheet(writer => EveryKind(writer, writer.Style(Legend)));
+
+ Assert.Equal(Rows(plain).Select(r => r.ToArray()), Rows(styled).Select(r => r.ToArray()));
+ Assert.Equal(RawCell.FromBoolean(true), Rows(styled)[1][6]);
+ }
+
+ [Fact]
+ public async Task APercentFormatMakesAPercentageCell()
+ {
+ byte[] ods = await Spreadsheet(writer =>
+ {
+ StyleId percent = writer.Style(new CellStyle { Number = NumberFormat.Parse("0.0%") });
+ writer.BeginSheet("data", [new("p")]);
+ writer.BeginRow();
+ writer.Write(0.125, percent);
+ writer.EndRow();
+ });
+
+ Assert.Contains("office:value-type=\"percentage\" office:value=\"0.125\"", Entry(ods, "content.xml"), StringComparison.Ordinal);
+ Assert.Equal(0.125, Rows(ods)[1][0].Number);
+ }
+
+ [Fact]
+ public async Task AStyleRegisteredInTheMiddleOfTheDataIsWritten()
+ {
+ byte[] ods = await Spreadsheet(writer =>
+ {
+ writer.BeginSheet("data", [new("n")]);
+
+ for (int i = 1; i <= 1000; i++)
+ {
+ writer.BeginRow();
+ writer.Write(i, i == 1000 ? writer.Style(new CellStyle { Fill = CellColor.FromRgb(0x0000FF) }) : default);
+ writer.EndRow();
+ }
+ });
+
+ Assert.Contains("fo:background-color=\"#0000FF\"", Entry(ods, "styles.xml"), StringComparison.Ordinal);
+ Assert.Equal(1000, Rows(ods)[1000][0].Number);
+ }
+
+ [Fact]
+ public async Task LiteralTextInAFormatIsEscaped()
+ {
+ byte[] ods = await Spreadsheet(writer =>
+ {
+ StyleId style = writer.Style(new CellStyle { Number = NumberFormat.Parse("\"R&D <\"0") });
+ writer.BeginSheet("data", [new("n")]);
+ writer.BeginRow();
+ writer.Write(5L, style);
+ writer.EndRow();
+ });
+
+ Assert.Contains("R&D <", Entry(ods, "styles.xml"), StringComparison.Ordinal);
+ Assert.Equal(5, Rows(ods)[1][0].Number);
+ }
+```
+
+`RawCell.Number` — use whatever accessor `OdsWriterTests` uses for numeric cells (open the file and match it; if the property is named differently, use that name).
+
+- [ ] **Step 3: Run to verify they fail**
+
+Run: `dotnet test --project tests/TriasDev.Tabular.Tests -c Release -- --filter-class "*OdsStyleTests"`
+Expected: FAIL.
+
+- [ ] **Step 4: Implement `OdsStyles` and use it**
+
+```csharp
+using System.Globalization;
+using System.Text;
+
+namespace TriasDev.Tabular.Ods;
+
+/// A resolved cell style: its name in styles.xml, and whether its numbers are percentages.
+internal readonly record struct OdsCellStyle(string Name, bool Percent);
+
+///
+/// The spreadsheet's registered styles as common cell styles in styles.xml, with their data
+/// styles, written at the end — so they may appear while content.xml streams. One per style
+/// and data style actually used, resolved on first use and cached.
+///
+internal sealed class OdsStyles
+{
+ private const string DateData = "tnd";
+ private const string DateTimeData = "tndt";
+ private const string BooleanData = "tnb";
+
+ private readonly StyleTable _table;
+ private readonly List _cellStyles = []; // markup of each ts{n}, n = index + 1
+ private readonly Dictionary<(int Style, string? Data), string> _cellNames = [];
+ private readonly List _dataStyles = []; // markup of each tn{n}
+ private readonly Dictionary _dataNames = new(StringComparer.Ordinal); // code + kind → name
+ private OdsCellStyle[] _resolved = []; // per (style, kind); Name null = not yet
+
+ public OdsStyles(StyleTable table) => _table = table;
+
+ /// The cell style for a registered style (index 1 onwards) on a kind of value.
+ public OdsCellStyle Cell(int style, ValueKind kind)
+ {
+ int slot = (style * StyleTable.ValueKinds) + (int)kind;
+ OdsCellStyle[] resolved = _resolved;
+ return slot < resolved.Length && resolved[slot].Name is not null ? resolved[slot] : Resolve(slot, style, kind);
+ }
+
+ /// The whole styles.xml.
+ public byte[] Build()
+ {
+ StringBuilder xml = new(OdsParts.XmlDeclaration + "");
+
+ foreach (string data in _dataStyles)
+ {
+ xml.Append(data);
+ }
+
+ foreach (string cell in _cellStyles)
+ {
+ xml.Append(cell);
+ }
+
+ return Encoding.UTF8.GetBytes(xml.Append("").ToString());
+ }
+
+ private OdsCellStyle Resolve(int slot, int style, ValueKind kind)
+ {
+ CellStyle cell = _table[style];
+ bool percent = kind is ValueKind.Integer or ValueKind.Number && cell.Number is { Percent: true };
+
+ string? data = kind switch
+ {
+ ValueKind.Integer or ValueKind.Number when cell.Number is { } number => DataStyle("n:" + number.Code, name => NumberStyle(name, number)),
+ ValueKind.Date or ValueKind.DateTime when cell.Date is { } date => DataStyle("d:" + date.Code, name => DateStyle(name, date)),
+ ValueKind.Date => FixedDataStyle(DateData, OdsParts.DateDataStyle),
+ ValueKind.DateTime => FixedDataStyle(DateTimeData, OdsParts.DateTimeDataStyle),
+ ValueKind.Boolean => FixedDataStyle(BooleanData, name => $""),
+ _ => null,
+ };
+
+ if (!_cellNames.TryGetValue((style, data), out string? cellName))
+ {
+ cellName = string.Create(CultureInfo.InvariantCulture, $"ts{_cellStyles.Count + 1}");
+ _cellStyles.Add(CellStyleMarkup(cellName, data, cell));
+ _cellNames.Add((style, data), cellName);
+ }
+
+ if (slot >= _resolved.Length)
+ {
+ Array.Resize(ref _resolved, Math.Max(slot + 1, _table.Count * StyleTable.ValueKinds));
+ }
+
+ return _resolved[slot] = new OdsCellStyle(cellName, percent);
+ }
+
+ /// The data style for a format code — keys "n:…" and "d:…" keep numbers and dates apart — numbered tn1, tn2, … on first use.
+ private string DataStyle(string key, Func markup)
+ {
+ if (!_dataNames.TryGetValue(key, out string? name))
+ {
+ name = string.Create(CultureInfo.InvariantCulture, $"tn{++_numbered}");
+ _dataStyles.Add(markup(name));
+ _dataNames.Add(key, name);
+ }
+
+ return name;
+ }
+
+ /// A data style with a fixed name (the default date, date-time and boolean ones), declared on first use.
+ private string FixedDataStyle(string name, Func markup)
+ {
+ if (_dataNames.TryAdd(name, name))
+ {
+ _dataStyles.Add(markup(name));
+ }
+
+ return name;
+ }
+}
+```
+
+Add the field `private int _numbered;` next to `_dataNames`. The lambdas in `Resolve` allocate, but `Resolve` runs once per `(style, kind)` pair, never per cell. With `EveryKind` in one style the number style is `tn1` (the long cell comes before the date) and the date style `tn2`; text and empty share one cell style by `(style, data)`, so `ts1` serves both.
+
+The markup helpers (private static in `OdsStyles`):
+
+```csharp
+ private static string NumberStyle(string name, NumberFormat format)
+ {
+ StringBuilder xml = new();
+ xml.Append(format.Percent ? "");
+ AppendText(xml, format.Prefix);
+ xml.Append(CultureInfo.InvariantCulture, $"" : "/>");
+ AppendText(xml, format.Percent ? "%" : string.Empty);
+ AppendText(xml, format.Suffix);
+ return xml.Append(format.Percent ? "" : "").ToString();
+ }
+
+ private static string DateStyle(string name, DateFormat format)
+ {
+ StringBuilder xml = new($"");
+
+ foreach (DatePart part in format.Parts)
+ {
+ string? element = part.Kind switch
+ {
+ DatePartKind.Year => "year",
+ DatePartKind.Month => "month",
+ DatePartKind.Day => "day",
+ DatePartKind.Hour => "hours",
+ DatePartKind.Minute => "minutes",
+ DatePartKind.Second => "seconds",
+ _ => null,
+ };
+
+ if (element is null)
+ {
+ AppendText(xml, part.Text);
+ continue;
+ }
+
+ xml.Append("" : "/>");
+ }
+
+ return xml.Append("").ToString();
+ }
+
+ private static string CellStyleMarkup(string name, string? data, CellStyle cell)
+ {
+ StringBuilder xml = new($"" : $" style:data-style-name=\"{data}\">");
+
+ xml.Append("");
+
+ if (cell.Horizontal != HorizontalAlignment.General)
+ {
+ xml.Append(" "start", HorizontalAlignment.Center => "center", _ => "end" }).Append("\"/>");
+ }
+
+ if (cell.Font is { } font)
+ {
+ xml.Append("");
+ }
+
+ return xml.Append("").ToString();
+ }
+
+ private static void AppendText(StringBuilder xml, string text)
+ {
+ if (text.Length == 0)
+ {
+ return;
+ }
+
+ xml.Append("");
+
+ foreach (char c in text)
+ {
+ _ = c switch
+ {
+ '&' => xml.Append("&"),
+ '<' => xml.Append("<"),
+ '>' => xml.Append(">"),
+ _ => xml.Append(c),
+ };
+ }
+
+ xml.Append("");
+ }
+```
+
+`CellColor.ToString()` gives `#RRGGBB` (upper case), which the structural test expects. The test's font assertion `fo:color="#FFFFFF" fo:font-weight="bold"` matches the order above.
+
+`OdsParts` changes: make `XmlDeclaration` and `Namespaces` `internal const`; move the two default date data-style bodies into `public static string DateDataStyle(string name)` and `public static string DateTimeDataStyle(string name)` returning the `…` markup now inlined in `BuildContentStart` (which calls them with `"N1"` and `"N2"`, so `content.xml` is unchanged); delete `Styles` (now `OdsStyles.Build()`).
+
+`OdsSheetWriter`:
+
+1. Field `private readonly OdsStyles _styles;` from the constructor's table.
+2. Numbers: `StartFloat()` stays for style 0. Styled:
+
+```csharp
+ private void StartNumber(int style, ValueKind kind)
+ {
+ if (style == 0)
+ {
+ StartFloat();
+ return;
+ }
+
+ OdsCellStyle cell = _styles.Cell(style, kind);
+ _row.Append("` as now; otherwise `` with `ValueKind.Empty`.
+7. `Complete()`: `_zip.AddStored("styles.xml", _styles.Build());`.
+
+- [ ] **Step 5: Run**
+
+Run: `dotnet test --project tests/TriasDev.Tabular.Tests -c Release -- --filter-class "*OdsStyleTests"` (with `TABULAR_REQUIRE_SOFFICE=1` so the probe cannot skip) → PASS; then `--filter-class "*Ods*"` and `--filter-class "*LibreOfficeInteropTests"` → PASS unchanged.
+
+- [ ] **Step 6: Add the ods half of the interop tests**
+
+In `StyledInteropTests`: add `[InlineData(TabularFormat.Ods, "ods")]` to `ShowsTheFormats`, and:
+
+```csharp
+ [Fact]
+ public async Task KeepsTheFillAndFontOfAnOds()
+ {
+ byte[] xlsx = LibreOffice.Convert(await Write(TabularFormat.Ods), "ods", "xlsx:Calc MS Excel 2007 XML", "xlsx");
+ string styles = Entry(xlsx, "xl/styles.xml");
+
+ Assert.Contains("rgb=\"FFF8696B\"", styles, StringComparison.OrdinalIgnoreCase);
+ Assert.Matches("", styles);
+ }
+```
+
+Run: `TABULAR_REQUIRE_SOFFICE=1 dotnet test --project tests/TriasDev.Tabular.Tests -c Release -- --filter-class "*StyledInteropTests"` → PASS.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src tests
+git commit -m "feat(write): ods styles — common cell styles and data styles in styles.xml, written at the end"
+```
+
+---
+
+### Task 6: No allocation per styled cell; documentation
+
+**Files:**
+- Modify: `tests/TriasDev.Tabular.Tests/Writing/StyledWriterTests.cs`, `docs/KNOWN-ISSUES.md`, `README.md` (the writing section, if one exists — otherwise only KNOWN-ISSUES)
+- Test: `StyledWriterTests.AllocatesNothingPerStyledCell`
+
+**Interfaces:**
+- Consumes: everything above.
+
+- [ ] **Step 1: Write the test**
+
+```csharp
+ [Theory]
+ [InlineData(TabularFormat.Csv)]
+ [InlineData(TabularFormat.Xlsx)]
+ [InlineData(TabularFormat.Ods)]
+ public async Task AllocatesNothingPerStyledCell(TabularFormat format)
+ {
+ CellStyle[] palette = [.. Enumerable.Range(0, 8).Select(i => new CellStyle { Fill = CellColor.FromRgb(i * 0x101010), Number = NumberFormat.Parse("0.00"), Date = DateFormat.Parse("dd/mm/yyyy") })];
+
+ async ValueTask Allocated(int rows)
+ {
+ long before = GC.GetAllocatedBytesForCurrentThread();
+
+ await using (TabularWriter writer = TabularWriter.Create(Stream.Null, format, new TabularWriterOptions { LeaveOpen = true }))
+ {
+ StyleId[] styles = [.. palette.Select(writer.Style)];
+ writer.BeginSheet("data", [new("text"), new("number"), new("integer"), new("date"), new("flag")]);
+
+ for (int i = 0; i < rows; i++)
+ {
+ StyleId style = styles[i % styles.Length];
+ writer.BeginRow();
+ writer.Write("text", style);
+ writer.Write(i * 0.5, style);
+ writer.Write((long)i, style);
+ writer.Write(new DateOnly(2026, 10, 4), style);
+ writer.Write(i % 2 == 0, style);
+ writer.EndRow();
+
+ if (writer.FlushRecommended)
+ {
+ await writer.FlushAsync(Token);
+ }
+ }
+
+ await writer.CompleteAsync(Token);
+ }
+
+ return GC.GetAllocatedBytesForCurrentThread() - before;
+ }
+
+ await Allocated(10_000); // warm-up: static state, pools, JIT
+ long tenThousand = await Allocated(10_000);
+ long hundredThousand = await Allocated(100_000);
+
+ // 90,000 more rows of five styled cells: what grows with them is under a byte a row.
+ Assert.True(hundredThousand - tenThousand < 90_000, $"{format}: {tenThousand:N0} bytes for 10k rows, {hundredThousand:N0} for 100k");
+ }
+```
+
+- [ ] **Step 2: Run**
+
+Run: `dotnet test --project tests/TriasDev.Tabular.Tests -c Release -- --filter-class "*StyledWriterTests"`
+Expected: PASS. If a format fails, measure (dotnet-counters or a loop with `GC.GetAllocatedBytesForCurrentThread` around single calls) and fix the allocation in the library — never loosen the bound. Typical culprits: a closure or `Func` allocated in `Resolve` on the hot path (must only run on first use), string formatting of the style index (use `AppendFormatted`), boxing of `ValueKind`.
+
+- [ ] **Step 3: Documentation**
+
+`docs/KNOWN-ISSUES.md` — add under the export section:
+
+```markdown
+### Styles
+
+- Styles are for xlsx and ods; csv (and a zip of csv sheets) ignores them, so one code path writes every format.
+- Format codes are a subset of Excel's (see `NumberFormat` and `DateFormat`); anything else is refused when parsed, never at write time.
+- Separators in a date format show as written (`dd/mm/yyyy` shows slashes in every locale); the decimal point and thousands separator of a number format follow the reader's locale.
+- A `StyleId` belongs to the writer that returned it; passing it to another writer is refused.
+- At most 4096 distinct styles per file. Declare the styles once (`static readonly`) and register each once per writer.
+```
+
+Read `README.md`; if it has a section on writing/exporting, add a three-line styled example there (`writer.Style(...)`, `writer.Write(value, style)`); if not, leave README to part 5.
+
+- [ ] **Step 4: Full suite, format, commit**
+
+Run: `dotnet test -c Release` → all PASS. Run `dotnet format --verify-no-changes` → no output.
+
+```bash
+git add tests docs README.md
+git commit -m "test(write): nothing allocated per styled cell in any format; document styles"
+```
diff --git a/docs/superpowers/specs/2026-10-04-styled-columnar-export-design.md b/docs/superpowers/specs/2026-10-04-styled-columnar-export-design.md
new file mode 100644
index 0000000..2c86105
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-04-styled-columnar-export-design.md
@@ -0,0 +1,220 @@
+# Styled and columnar export — design
+
+Status: approved in brainstorming, 2026-10-04. Extends
+[2026-10-03-writing-design.md](2026-10-03-writing-design.md), whose rules still hold unless this
+document says otherwise. Tracker: #67.
+
+## Why
+
+The write side (csv, xlsx, ods, `TabularExport`) exports data. A real consumer that should move
+its exports onto Tabular needs more, and its export libraries today are a streaming xlsx writer and a
+csv writer:
+
+- **Data arrives by column.** A server stream delivers batches; each batch is a row count plus one
+ typed vector per column (doubles, longs, strings, dates, nullable wrappers). Thousands of columns
+ are normal: typical exports are 1–10k rows × 4–5k columns, the worst case 5M rows.
+- **Minimal formatting is used**: fill and font colour per cell (colour by value, from a legend),
+ a styled header, a legend sheet with merged cells, date and number formats, column widths.
+- **The csv form of a multi-sheet export is a zip of csv files**, one per sheet.
+
+This document supersedes the earlier spec's "no styling" exclusion: **minimal formatting is in scope
+for xlsx and ods**. Still excluded: formulas, hyperlinks, images, charts, comments, conditional
+formatting rules stored in the file, rich text within a cell, row heights.
+
+## Goals
+
+1. Replace the consumer's export libraries without losing a feature it uses.
+2. Speed not worse than those libraries on the same data and the same styling; memory flat in the
+ number of rows; nothing allocated per cell.
+3. The round trip holds: a styled export imports back through Tabular with the same values and
+ types as an unstyled one.
+
+## 1. Cell styles
+
+```csharp
+static readonly CellStyle Red = new()
+{
+ Fill = CellColor.FromRgb(0xF8696B),
+ Font = new CellFont { Color = CellColor.FromRgb(0xFFFFFF), Bold = true, Italic = false },
+ Number = NumberFormat.Parse("#,##0.00"),
+ Date = DateFormat.Parse("dd/mm/yyyy"),
+ Horizontal = HorizontalAlignment.Center,
+ Wrap = true,
+ Border = CellBorder.Thin(CellColor.FromRgb(0xBFBFBF)),
+};
+
+StyleId red = writer.Style(Red);
+writer.Write(12.5, red);
+```
+
+- `CellStyle` is an immutable `sealed record` (value equality). Every property is optional; an
+ unset property means the format's default. `CellColor` is our own RGB struct (`FromRgb(int)`,
+ `Parse("#RRGGBB")`) — no `System.Drawing`.
+- `CellFont`: `Color`, `Bold`, `Italic`. No font family or size.
+- `HorizontalAlignment`: `General` (default), `Left`, `Center`, `Right`. `Wrap`: wrap text.
+- `CellBorder`: none (default) or `Thin(color)` on all four sides.
+- **Number and date formats are separate properties.** `Number` applies to numeric cells (long,
+ decimal, double), `Date` to date and date-time cells; each is ignored for other kinds. A date cell
+ without a `Date` format gets the writer's default date or date-time format, so a date always
+ carries a date format and the xlsx reader still recognises it. A number can never get a date format.
+- **Formats are Excel format codes, a supported subset** that translates to ODF number styles:
+ - `NumberFormat`: digits `0` and `#`, one decimal point, a thousands separator `,` in the integer
+ part, a trailing `%`, literal text in double quotes before or after. Examples: `0`, `0.00`,
+ `#,##0`, `#,##0.00`, `0%`, `0.0%`, `"€ "#,##0.00`.
+ - `DateFormat`: `yyyy`, `yy`, `mm`, `m`, `dd`, `d`, `hh`, `h`, `ss`, and separators
+ `/ - . : space` or quoted literals. `mm`/`m` means minutes when it follows `h`/`hh` or precedes
+ `ss`, months otherwise (Excel's rule).
+ - Anything else — colours, conditions, sections (`;`), fractions, scientific, AM/PM, `@` — is
+ refused by `Parse` with an `ArgumentException` naming the unsupported part. Nothing fails later,
+ at write time.
+- `writer.Style(CellStyle)` returns a `StyleId` (a struct holding an int). It may be called before or
+ during any sheet; the same style (by value) returns the same id. One dictionary lookup per call, none
+ per cell.
+- Every `Write(value)` overload gains `Write(value, StyleId style)`; `WriteEmpty(StyleId)` writes a
+ styled empty cell. `default(StyleId)` is the unstyled cell.
+- The writer resolves a `(StyleId, value kind)` pair to the format's style index lazily and caches it
+ in an array: one array read per styled cell.
+- At most **4096** distinct styles per file; the next `Style` call throws `TabularLimitException`.
+- **xlsx:** `styles.xml` is written at `CompleteAsync` (it is already a separate part). Fonts, fills,
+ borders and number formats are deduplicated; custom number formats start at id 164 as now.
+- **ods:** cell styles become common styles in `styles.xml` (`office:styles`), written at the end,
+ each with its own data style. The automatic styles in `content.xml` (column widths, default date
+ styles) stay as they are, so content still streams.
+- **csv and zip:** styles are accepted and ignored, so one code path writes every format.
+
+## 2. Sheet layout
+
+```csharp
+writer.BeginSheet("Legend", columns, new SheetOptions
+{
+ HeaderStyle = headerStyle, // a CellStyle, applied to the header row
+ FreezeRows = 1,
+ FreezeColumns = 0,
+ AutoFilter = true,
+});
+
+writer.BeginRow();
+writer.Write("Hazard legend", title, span: new CellSpan(Rows: 1, Columns: 4));
+writer.EndRow();
+```
+
+- `BeginSheet(name, columns, SheetOptions? options)` is a new overload; the existing one means
+ default options.
+- **Merged cells** are declared on the top-left cell through the optional `span` parameter of the
+ styled `Write` overloads, because ods must know them when that
+ cell is written. Covered positions are skipped automatically: the next write in the row lands at
+ column + `Columns`; in the rows below, the writer emits the covered cells itself when the row
+ reaches them. The caller never writes placeholders.
+ - Errors, thrown at the write that causes them (`ArgumentException` for an invalid span,
+ `InvalidOperationException` for the state): a span overlapping another, reaching past the sheet's
+ columns, rows or columns < 1, a 1 × 1 span; a sheet ended (`BeginSheet`, `CompleteAsync`) while
+ a span still has rows to cover.
+ - xlsx: ranges are kept in memory and written as `` after the sheet data; at most
+ 65,536 merges per sheet, the next throws `TabularLimitException`.
+ - ods: `table:number-columns-spanned` / `table:number-rows-spanned` and `table:covered-table-cell`.
+ - csv: the value in the top-left cell, the covered positions empty, so columns stay aligned.
+ - Import reads a merged range as its value in the top-left cell and empty cells elsewhere.
+- **Freeze panes** (`FreezeRows`, `FreezeColumns`): xlsx `` in `sheetViews` before the data;
+ ods `settings.xml` config items, written at the end.
+- **Auto-filter** on the header row through the last row written: xlsx `` after the data
+ and the hidden defined name `_xlnm._FilterDatabase` in `workbook.xml`, as Excel writes it; ods
+ `table:database-ranges` at the end of `content.xml`. Requires a header (columns given).
+- **Column limit:** 16,384 columns per sheet for xlsx and ods (Excel; LibreOffice since 7.4),
+ checked by `BeginSheet` with `TabularLimitException`. csv and zip have none.
+- **Empty runs in ods** are written as one cell with `table:number-columns-repeated`, so wide sheets
+ with gaps stay small. xlsx already omits empty cells.
+- Widths stay as today (`WriteColumn.Width`). No row heights: Excel and LibreOffice size rows,
+ including wrapped ones, themselves. No per-column default style: a style travels with the cell.
+
+## 3. `ColumnBatch` — writing a batch by column
+
+```csharp
+writer.BeginSheet("Locations", columns, options);
+
+var batch = new ColumnBatch(); // reused for every batch
+await foreach (var chunk in source.WithCancellation(ct))
+{
+ batch.Reset(chunk.Count);
+ foreach (var column in chunk.Columns)
+ switch (column.Type)
+ {
+ case Kind.Double: batch.Add(column.Doubles, style: v => Legend(column, v)); break;
+ case Kind.Long: batch.Add(column.Longs); break;
+ case Kind.Text: batch.Add(column.Texts); break;
+ case Kind.NullableDouble: batch.Add(column.NullableDoubles, w => w.HasValue ? w.Value : (double?)null); break;
+ case Kind.Timestamp: batch.Add(column.Timestamps, t => t.ToDateTime()); break;
+ }
+ await writer.WriteBatchAsync(batch, ct);
+}
+await writer.CompleteAsync(ct);
+```
+
+- `Add(IReadOnlyList values, …)` for `string`, `long`, `int`, `short`, `double`, `decimal`,
+ `bool`, `DateTime`, `DateOnly` and the nullable value types. Arrays, `List` and protobuf
+ `RepeatedField` are taken as they are, never copied; arrays and lists take a span fast path.
+- `Add(IReadOnlyList items, Func value, …)` for wrappers (nullable messages,
+ timestamps): one delegate call per cell.
+- A `DateTime` column takes `hasTime` (default: from the values, as `Write(DateTime)` decides today).
+- Style per column, one of: a constant `StyleId`; a vector `IReadOnlyList` computed by the
+ caller; a rule `Func`. A rule's result is turned into a `StyleId` through a
+ reference-keyed cache with a last-hit shortcut per column, so a palette held in `static readonly`
+ styles costs a reference compare per cell in the common case.
+- Checks are per column, not per cell: the batch's column count equals the sheet's, every vector's
+ length equals the batch's row count — otherwise `ArgumentException` naming the column. Values are
+ checked exactly as by `Write` (precision, characters, limits) and a failure reports sheet, row,
+ column and header.
+- `WriteBatch(batch)` writes synchronously into the buffer; `WriteBatchAsync(batch, ct)` does the
+ same and then flushes whenever `FlushRecommended`, also inside the batch, so memory stays flat
+ with 5k columns.
+- Inside: one typed slot per column, reused by index across batches (nothing allocated per batch
+ once warm); rows traversed row by column with one virtual call per cell.
+- Merges are not available through a batch; a batch writes data rows.
+- `TabularExport` gains the same rule per column:
+ `.Column("Score", l => l.Score, style: v => v > 5 ? Red : null)`.
+
+## 4. Zip of csv sheets
+
+- `TabularWriter.Create(stream, TabularFormat.Zip)` writes every sheet as the entry
+ `.csv` of one zip, through our `ZipWriter` (deflate, streamed, zip64 when large). Each
+ entry follows the csv rules and `CsvWriterOptions` (BOM, quoting, `FormulaGuard`, culture,
+ delimiter).
+- Styles, merges, freeze and filter are ignored, as for csv. No row limit: this is the format for 5M
+ rows and several sheets.
+- Re-import through `TabularFile.Open` → `ArchiveCursor` gives the same sheets: the sheet name is the
+ entry name without `.csv`. The reader orders sheets by path, so the round trip promises the same
+ sheets by name, not their order; documented.
+
+## 5. Performance and verification
+
+- **Benchmarks** (net10, `benchmarks` project; third-party libraries only there, never in the
+ package):
+
+ | Scenario | Formats | Compared with |
+ |---|---|---|
+ | 10k × 5k: ~20 ordinary columns + 5k doubles, ~10 % empty, legend colouring by rule | csv, zip, xlsx, ods | LargeXlsx, SpreadCheetah (xlsx); CsvHelper, Sep (csv) |
+ | 5M × 30 | csv, zip | CsvHelper, Sep |
+ | 1M × 30, styled | xlsx, ods | LargeXlsx, SpreadCheetah |
+
+- **Targets:** not slower than LargeXlsx (xlsx) and CsvHelper (csv) on the same data and styling;
+ peak memory independent of the row count; zero bytes allocated per cell.
+- **Size:** 10k × 5k xlsx is about 1.5 GB of XML uncompressed, near our own reader's default
+ `MaxUncompressedBytes` (2 GB). The writer does not limit; the docs say such a file imports with a
+ raised limit, and the 10k × 5k re-import check runs with one.
+- **In the test suite:** 1k × 5k per format with re-import; round trip of styled and unstyled exports
+ giving the same values; merge and span errors; style limit; format-code parser (accepted and
+ refused codes, the `mm` rule); allocation tests per cell for styled writes and batches;
+ OpenXmlValidator for every xlsx test; LibreOffice opens styled xlsx and ods, keeping merges, freeze
+ and filter (checked by conversion), under the existing interop fixture.
+- **Final manual check:** 10k × 5k per format with re-import, and the 5M-row csv and zip.
+
+## Delivery
+
+Each part is its own plan and pull request:
+
+1. Styles: `CellStyle`, `CellColor`, `CellFont`, `CellBorder`, `NumberFormat`, `DateFormat`,
+ `StyleId`, `Write(value, style)`, xlsx and ods styles.
+2. Sheet layout: `SheetOptions` (header style, freeze, filter), merges, column limit, ods
+ repeated empties.
+3. `ColumnBatch`, `WriteBatch`/`WriteBatchAsync`, style rules (batch and `TabularExport`).
+4. Zip of csv sheets.
+5. Benchmarks and comparison, docs (`docs/exporting.md`, README), ADR-0002 — absorbs #74.
diff --git a/src/TriasDev.Tabular/Csv/CsvSheetWriter.cs b/src/TriasDev.Tabular/Csv/CsvSheetWriter.cs
index 16923f1..bb2d135 100644
--- a/src/TriasDev.Tabular/Csv/CsvSheetWriter.cs
+++ b/src/TriasDev.Tabular/Csv/CsvSheetWriter.cs
@@ -78,7 +78,7 @@ public void BeginRow()
public string? WriteHeader(string value) => WriteField(value, guard: false, column: -1);
- public string? WriteText(string value, int column)
+ public string? WriteText(string value, int column, int style)
{
bool guard = _format.FormulaGuard && value.Length > 0 && value[0] is '=' or '+' or '-' or '@' or '\t' or '\r';
return WriteField(value, guard, column);
@@ -169,28 +169,28 @@ private static int CountLineBreaks(ReadOnlySpan text)
return count;
}
- public string? WriteLong(long value)
+ public string? WriteLong(long value, int style)
{
Separate();
AppendFormatted(value, default);
return null;
}
- public string? WriteDecimal(decimal value)
+ public string? WriteDecimal(decimal value, int style)
{
Separate();
AppendFormatted(value, default);
return null;
}
- public string? WriteDouble(double value)
+ public string? WriteDouble(double value, int style)
{
Separate();
AppendFormatted(value, "R");
return null;
}
- public string? WriteDate(DateTime value, bool hasTime)
+ public string? WriteDate(DateTime value, bool hasTime, int style)
{
// The import's date reader refuses year 1 — it cannot tell it from a date with no year.
if (value.Year == 1)
@@ -203,13 +203,13 @@ private static int CountLineBreaks(ReadOnlySpan text)
return null;
}
- public void WriteBoolean(bool value)
+ public void WriteBoolean(bool value, int style)
{
Separate();
_row.Append(value ? "true" : "false");
}
- public void WriteEmpty() => Separate();
+ public void WriteEmpty(int style) => Separate();
public void EndRow()
{
diff --git a/src/TriasDev.Tabular/Ods/OdsParts.cs b/src/TriasDev.Tabular/Ods/OdsParts.cs
index ae226c7..02d937b 100644
--- a/src/TriasDev.Tabular/Ods/OdsParts.cs
+++ b/src/TriasDev.Tabular/Ods/OdsParts.cs
@@ -20,9 +20,9 @@ internal static class OdsParts
public const string ContentEnd = "";
- private const string XmlDeclaration = "";
+ internal const string XmlDeclaration = "";
- private const string Namespaces =
+ internal const string Namespaces =
" xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\""
+ " xmlns:style=\"urn:oasis:names:tc:opendocument:xmlns:style:1.0\""
+ " xmlns:text=\"urn:oasis:names:tc:opendocument:xmlns:text:1.0\""
@@ -47,13 +47,22 @@ internal static class OdsParts
+ ""
+ "");
- public static readonly byte[] Styles = Encoding.UTF8.GetBytes(
- XmlDeclaration + "");
-
/// The column style for a width in characters, rounded to the nearest whole one.
public static string ColumnStyleFor(double width) =>
ColumnStyleNames[Math.Clamp((int)Math.Round(width, MidpointRounding.AwayFromZero), 1, WidestColumn)];
+ /// The default date data style, named .
+ public static string DateDataStyle(string name) =>
+ $"-"
+ + "-";
+
+ /// The default date and time data style, named .
+ public static string DateTimeDataStyle(string name) =>
+ $"-"
+ + "-"
+ + " :"
+ + ":";
+
private static string BuildContentStart()
{
StringBuilder xml = new(XmlDeclaration + "");
@@ -67,12 +76,8 @@ private static string BuildContentStart()
}
xml.Append(
- "-"
- + "-"
- + "-"
- + "-"
- + " :"
- + ":"
+ DateDataStyle("N1")
+ + DateTimeDataStyle("N2")
+ ""
+ ""
+ ""
diff --git a/src/TriasDev.Tabular/Ods/OdsSheetWriter.cs b/src/TriasDev.Tabular/Ods/OdsSheetWriter.cs
index 308dc36..c7de0b5 100644
--- a/src/TriasDev.Tabular/Ods/OdsSheetWriter.cs
+++ b/src/TriasDev.Tabular/Ods/OdsSheetWriter.cs
@@ -32,17 +32,21 @@ internal sealed class OdsSheetWriter : ISheetWriter
private const string NumberEnd = "\"/>";
+ private const string StyledCell = " NeedsMarkup = SearchValues.Create("&<> \t\n\r");
private readonly ZipWriter _zip;
+ private readonly OdsStyles _styles;
private readonly RowText _row = new();
private ArrayBufferWriter _bytes = new(16 * 1024);
private Stream? _content;
- public OdsSheetWriter(SpillBuffer output, OdsWriterOptions options)
+ public OdsSheetWriter(SpillBuffer output, OdsWriterOptions options, StyleTable styles)
{
+ _styles = new OdsStyles(styles);
_zip = new ZipWriter(output, options.CompressionLevel);
// OpenDocument's rule: the mimetype is the first entry, stored, so a reader knows the file
@@ -83,59 +87,59 @@ public void BeginRow()
_row.Append("");
}
- public string? WriteHeader(string value) => WriteString(value);
+ public string? WriteHeader(string value) => WriteString(value, 0);
- public string? WriteText(string value, int column) => WriteString(value);
+ public string? WriteText(string value, int column, int style) => WriteString(value, style);
- public string? WriteLong(long value)
+ public string? WriteLong(long value, int style)
{
if (ValueChecks.LongInDouble(value) is { } code)
{
return code;
}
- StartFloat();
+ StartNumber(style, ValueKind.Integer);
_row.AppendFormatted(value, default, CultureInfo.InvariantCulture);
_row.Append(NumberEnd);
return null;
}
- public string? WriteDecimal(decimal value)
+ public string? WriteDecimal(decimal value, int style)
{
if (ValueChecks.DecimalInDouble(value) is { } code)
{
return code;
}
- StartFloat();
+ StartNumber(style, ValueKind.Number);
_row.AppendFormatted(value, default, CultureInfo.InvariantCulture);
_row.Append(NumberEnd);
return null;
}
- public string? WriteDouble(double value)
+ public string? WriteDouble(double value, int style)
{
- StartFloat();
+ StartNumber(style, ValueKind.Number);
_row.AppendFormatted(value, "R", CultureInfo.InvariantCulture);
_row.Append(NumberEnd);
return null;
}
- public string? WriteDate(DateTime value, bool hasTime)
+ public string? WriteDate(DateTime value, bool hasTime, int style)
{
// ISO in an attribute: no serial, so no 1900 floor and no leap-year bug — any year reads back.
- _row.Append("");
return null;
}
- public void WriteBoolean(bool value)
+ public void WriteBoolean(bool value, int style)
{
- _row.Append("");
@@ -143,7 +147,18 @@ public void WriteBoolean(bool value)
_row.Append("");
}
- public void WriteEmpty() => _row.Append("");
+ public void WriteEmpty(int style)
+ {
+ if (style == 0)
+ {
+ _row.Append("");
+ return;
+ }
+
+ _row.Append(StyledCell);
+ _row.Append(_styles.Cell(style, ValueKind.Empty).Name);
+ _row.Append("\"/>");
+ }
public void EndRow()
{
@@ -163,7 +178,7 @@ public void Complete()
}
_zip.AddStored("META-INF/manifest.xml", OdsParts.Manifest);
- _zip.AddStored("styles.xml", OdsParts.Styles);
+ _zip.AddStored("styles.xml", _styles.Build());
_zip.Complete();
}
@@ -177,14 +192,47 @@ public void Dispose()
/// Opens a number cell up to its value; LibreOffice formats the display from the value.
private void StartFloat() => _row.Append(" MaxTextChars || (value.Length * 6L) + TokenMargin > SheetScanner.MaxBufferChars && LongestToken(value) > SheetScanner.MaxBufferChars - TokenMargin)
{
return ErrorCodes.Write.TextTooLong;
}
- _row.Append("A resolved cell style: its name in styles.xml, and whether its numbers are percentages.
+internal readonly record struct OdsCellStyle(string Name, bool Percent);
+
+///
+/// The spreadsheet's registered styles as common cell styles in styles.xml, with their data
+/// styles, written at the end — so they may appear while content.xml streams. One per style
+/// and data style actually used, resolved on first use and cached.
+///
+internal sealed class OdsStyles
+{
+ private const string DateData = "tnd";
+ private const string DateTimeData = "tndt";
+ private const string BooleanData = "tnb";
+
+ private readonly StyleTable _table;
+ private readonly List _cellStyles = []; // markup of each ts{n}, n = index + 1
+ private readonly Dictionary<(int Style, string? Data), string> _cellNames = [];
+ private readonly List _dataStyles = []; // markup of each tn{n}
+ private readonly Dictionary _dataNames = new(StringComparer.Ordinal); // code + kind → name
+ private int _numbered;
+ private OdsCellStyle[] _resolved = []; // per (style, kind); Name null = not yet
+
+ public OdsStyles(StyleTable table) => _table = table;
+
+ /// The cell style for a registered style (index 1 onwards) on a kind of value.
+ public OdsCellStyle Cell(int style, ValueKind kind)
+ {
+ int slot = (style * StyleTable.ValueKinds) + (int)kind;
+ OdsCellStyle[] resolved = _resolved;
+ return slot < resolved.Length && resolved[slot].Name is not null ? resolved[slot] : Resolve(slot, style, kind);
+ }
+
+ /// The whole styles.xml.
+ public byte[] Build()
+ {
+ StringBuilder xml = new(OdsParts.XmlDeclaration + "");
+
+ foreach (string data in _dataStyles)
+ {
+ xml.Append(data);
+ }
+
+ foreach (string cell in _cellStyles)
+ {
+ xml.Append(cell);
+ }
+
+ return Encoding.UTF8.GetBytes(xml.Append("").ToString());
+ }
+
+ private OdsCellStyle Resolve(int slot, int style, ValueKind kind)
+ {
+ CellStyle cell = _table[style];
+ bool percent = kind is ValueKind.Integer or ValueKind.Number && cell.Number is { Percent: true };
+
+ string? data = kind switch
+ {
+ ValueKind.Integer or ValueKind.Number when cell.Number is { } number => DataStyle("n:" + number.Code, name => NumberStyle(name, number)),
+ ValueKind.Date or ValueKind.DateTime when cell.Date is { } date => DataStyle("d:" + date.Code, name => DateStyle(name, date)),
+ ValueKind.Date => FixedDataStyle(DateData, OdsParts.DateDataStyle),
+ ValueKind.DateTime => FixedDataStyle(DateTimeData, OdsParts.DateTimeDataStyle),
+ ValueKind.Boolean => FixedDataStyle(BooleanData, name => $""),
+ _ => null,
+ };
+
+ if (!_cellNames.TryGetValue((style, data), out string? cellName))
+ {
+ cellName = string.Create(CultureInfo.InvariantCulture, $"ts{_cellStyles.Count + 1}");
+ _cellStyles.Add(CellStyleMarkup(cellName, data, cell));
+ _cellNames.Add((style, data), cellName);
+ }
+
+ if (slot >= _resolved.Length)
+ {
+ Array.Resize(ref _resolved, Math.Max(slot + 1, _table.Count * StyleTable.ValueKinds));
+ }
+
+ return _resolved[slot] = new OdsCellStyle(cellName, percent);
+ }
+
+ /// The data style for a format code — keys "n:…" and "d:…" keep numbers and dates apart — numbered tn1, tn2, … on first use.
+ private string DataStyle(string key, Func markup)
+ {
+ if (!_dataNames.TryGetValue(key, out string? name))
+ {
+ name = string.Create(CultureInfo.InvariantCulture, $"tn{++_numbered}");
+ _dataStyles.Add(markup(name));
+ _dataNames.Add(key, name);
+ }
+
+ return name;
+ }
+
+ /// A data style with a fixed name (the default date, date-time and boolean ones), declared on first use.
+ private string FixedDataStyle(string name, Func markup)
+ {
+ if (_dataNames.TryAdd(name, name))
+ {
+ _dataStyles.Add(markup(name));
+ }
+
+ return name;
+ }
+
+ private static string NumberStyle(string name, NumberFormat format)
+ {
+ StringBuilder xml = new();
+ xml.Append(format.Percent ? "");
+ AppendText(xml, format.Prefix);
+ xml.Append(CultureInfo.InvariantCulture, $"" : "/>");
+ AppendText(xml, format.Percent ? "%" : string.Empty);
+ AppendText(xml, format.Suffix);
+ return xml.Append(format.Percent ? "" : "").ToString();
+ }
+
+ private static string DateStyle(string name, DateFormat format)
+ {
+ StringBuilder xml = new($"");
+
+ foreach (DatePart part in format.Parts)
+ {
+ string? element = part.Kind switch
+ {
+ DatePartKind.Year => "year",
+ DatePartKind.Month => "month",
+ DatePartKind.Day => "day",
+ DatePartKind.Hour => "hours",
+ DatePartKind.Minute => "minutes",
+ DatePartKind.Second => "seconds",
+ _ => null,
+ };
+
+ if (element is null)
+ {
+ AppendText(xml, part.Text);
+ continue;
+ }
+
+ xml.Append("" : "/>");
+ }
+
+ return xml.Append("").ToString();
+ }
+
+ private static string CellStyleMarkup(string name, string? data, CellStyle cell)
+ {
+ StringBuilder xml = new($"" : $" style:data-style-name=\"{data}\">");
+
+ xml.Append("");
+
+ if (cell.Horizontal != HorizontalAlignment.General)
+ {
+ xml.Append(" "start", HorizontalAlignment.Center => "center", _ => "end" }).Append("\"/>");
+ }
+
+ if (cell.Font is { } font)
+ {
+ xml.Append("");
+ }
+
+ return xml.Append("").ToString();
+ }
+
+ private static void AppendText(StringBuilder xml, string text)
+ {
+ if (text.Length == 0)
+ {
+ return;
+ }
+
+ xml.Append("");
+
+ foreach (char c in text)
+ {
+ _ = c switch
+ {
+ '&' => xml.Append("&"),
+ '<' => xml.Append("<"),
+ '>' => xml.Append(">"),
+ _ => xml.Append(c),
+ };
+ }
+
+ xml.Append("");
+ }
+}
diff --git a/src/TriasDev.Tabular/PublicAPI.Unshipped.txt b/src/TriasDev.Tabular/PublicAPI.Unshipped.txt
index feaafe3..0de8ab3 100644
--- a/src/TriasDev.Tabular/PublicAPI.Unshipped.txt
+++ b/src/TriasDev.Tabular/PublicAPI.Unshipped.txt
@@ -41,6 +41,74 @@ static TriasDev.Tabular.TabularWriterOptions.operator ==(TriasDev.Tabular.Tabula
override TriasDev.Tabular.TabularWriterOptions.Equals(object? obj) -> bool
override TriasDev.Tabular.TabularWriterOptions.GetHashCode() -> int
override TriasDev.Tabular.TabularWriterOptions.ToString() -> string!
+TriasDev.Tabular.CellBorder
+TriasDev.Tabular.CellBorder.$() -> TriasDev.Tabular.CellBorder!
+TriasDev.Tabular.CellBorder.Color.get -> TriasDev.Tabular.CellColor
+TriasDev.Tabular.CellBorder.Equals(TriasDev.Tabular.CellBorder? other) -> bool
+TriasDev.Tabular.CellColor
+TriasDev.Tabular.CellColor.CellColor() -> void
+TriasDev.Tabular.CellColor.Equals(TriasDev.Tabular.CellColor other) -> bool
+TriasDev.Tabular.CellColor.Rgb.get -> int
+TriasDev.Tabular.CellFont
+TriasDev.Tabular.CellFont.$() -> TriasDev.Tabular.CellFont!
+TriasDev.Tabular.CellFont.Bold.get -> bool
+TriasDev.Tabular.CellFont.Bold.init -> void
+TriasDev.Tabular.CellFont.CellFont() -> void
+TriasDev.Tabular.CellFont.Color.get -> TriasDev.Tabular.CellColor?
+TriasDev.Tabular.CellFont.Color.init -> void
+TriasDev.Tabular.CellFont.Equals(TriasDev.Tabular.CellFont? other) -> bool
+TriasDev.Tabular.CellFont.Italic.get -> bool
+TriasDev.Tabular.CellFont.Italic.init -> void
+TriasDev.Tabular.CellStyle
+TriasDev.Tabular.CellStyle.$() -> TriasDev.Tabular.CellStyle!
+TriasDev.Tabular.CellStyle.Border.get -> TriasDev.Tabular.CellBorder?
+TriasDev.Tabular.CellStyle.Border.init -> void
+TriasDev.Tabular.CellStyle.CellStyle() -> void
+TriasDev.Tabular.CellStyle.Equals(TriasDev.Tabular.CellStyle? other) -> bool
+TriasDev.Tabular.CellStyle.Fill.get -> TriasDev.Tabular.CellColor?
+TriasDev.Tabular.CellStyle.Fill.init -> void
+TriasDev.Tabular.CellStyle.Font.get -> TriasDev.Tabular.CellFont?
+TriasDev.Tabular.CellStyle.Font.init -> void
+TriasDev.Tabular.CellStyle.Horizontal.get -> TriasDev.Tabular.HorizontalAlignment
+TriasDev.Tabular.CellStyle.Horizontal.init -> void
+TriasDev.Tabular.CellStyle.Wrap.get -> bool
+TriasDev.Tabular.CellStyle.Wrap.init -> void
+TriasDev.Tabular.HorizontalAlignment
+TriasDev.Tabular.HorizontalAlignment.Center = 2 -> TriasDev.Tabular.HorizontalAlignment
+TriasDev.Tabular.HorizontalAlignment.General = 0 -> TriasDev.Tabular.HorizontalAlignment
+TriasDev.Tabular.HorizontalAlignment.Left = 1 -> TriasDev.Tabular.HorizontalAlignment
+TriasDev.Tabular.HorizontalAlignment.Right = 3 -> TriasDev.Tabular.HorizontalAlignment
+TriasDev.Tabular.StyleId
+TriasDev.Tabular.StyleId.Equals(TriasDev.Tabular.StyleId other) -> bool
+TriasDev.Tabular.StyleId.StyleId() -> void
+override TriasDev.Tabular.CellBorder.Equals(object? obj) -> bool
+override TriasDev.Tabular.CellBorder.GetHashCode() -> int
+override TriasDev.Tabular.CellBorder.ToString() -> string!
+override TriasDev.Tabular.CellColor.GetHashCode() -> int
+override TriasDev.Tabular.CellColor.ToString() -> string!
+override TriasDev.Tabular.CellFont.Equals(object? obj) -> bool
+override TriasDev.Tabular.CellFont.GetHashCode() -> int
+override TriasDev.Tabular.CellFont.ToString() -> string!
+override TriasDev.Tabular.CellStyle.Equals(object? obj) -> bool
+override TriasDev.Tabular.CellStyle.GetHashCode() -> int
+override TriasDev.Tabular.CellStyle.ToString() -> string!
+override TriasDev.Tabular.StyleId.GetHashCode() -> int
+override TriasDev.Tabular.StyleId.ToString() -> string!
+~override TriasDev.Tabular.CellColor.Equals(object obj) -> bool
+~override TriasDev.Tabular.StyleId.Equals(object obj) -> bool
+static TriasDev.Tabular.CellBorder.Thin(TriasDev.Tabular.CellColor color) -> TriasDev.Tabular.CellBorder!
+static TriasDev.Tabular.CellBorder.operator !=(TriasDev.Tabular.CellBorder? left, TriasDev.Tabular.CellBorder? right) -> bool
+static TriasDev.Tabular.CellBorder.operator ==(TriasDev.Tabular.CellBorder? left, TriasDev.Tabular.CellBorder? right) -> bool
+static TriasDev.Tabular.CellColor.FromRgb(int rgb) -> TriasDev.Tabular.CellColor
+static TriasDev.Tabular.CellColor.Parse(string! text) -> TriasDev.Tabular.CellColor
+static TriasDev.Tabular.CellColor.operator !=(TriasDev.Tabular.CellColor left, TriasDev.Tabular.CellColor right) -> bool
+static TriasDev.Tabular.CellColor.operator ==(TriasDev.Tabular.CellColor left, TriasDev.Tabular.CellColor right) -> bool
+static TriasDev.Tabular.CellFont.operator !=(TriasDev.Tabular.CellFont? left, TriasDev.Tabular.CellFont? right) -> bool
+static TriasDev.Tabular.CellFont.operator ==(TriasDev.Tabular.CellFont? left, TriasDev.Tabular.CellFont? right) -> bool
+static TriasDev.Tabular.CellStyle.operator !=(TriasDev.Tabular.CellStyle? left, TriasDev.Tabular.CellStyle? right) -> bool
+static TriasDev.Tabular.CellStyle.operator ==(TriasDev.Tabular.CellStyle? left, TriasDev.Tabular.CellStyle? right) -> bool
+static TriasDev.Tabular.StyleId.operator !=(TriasDev.Tabular.StyleId left, TriasDev.Tabular.StyleId right) -> bool
+static TriasDev.Tabular.StyleId.operator ==(TriasDev.Tabular.StyleId left, TriasDev.Tabular.StyleId right) -> bool
TriasDev.Tabular.Csv.CsvWriterOptions
TriasDev.Tabular.Csv.CsvWriterOptions.ByteOrderMark.get -> bool
TriasDev.Tabular.Csv.CsvWriterOptions.ByteOrderMark.init -> void
@@ -155,3 +223,36 @@ TriasDev.Tabular.TabularExport.WriteSheetAsync(TriasDev.Tabular.TabularWriter
TriasDev.Tabular.TabularExport.WriteSheetAsync(TriasDev.Tabular.TabularWriter! writer, string! sheetName, System.Collections.Generic.IAsyncEnumerable! items, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
TriasDev.Tabular.TabularExport.WriteSheetAsync(TriasDev.Tabular.TabularWriter! writer, string! sheetName, System.Collections.Generic.IEnumerable! items, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
TriasDev.Tabular.TabularExport.WriteSheetAsync(TriasDev.Tabular.TabularWriter! writer, string! sheetName, System.Collections.Generic.IAsyncEnumerable! chunks, System.Func!>! itemsOf, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
+override TriasDev.Tabular.DateFormat.Equals(object? obj) -> bool
+override TriasDev.Tabular.DateFormat.GetHashCode() -> int
+override TriasDev.Tabular.DateFormat.ToString() -> string!
+override TriasDev.Tabular.NumberFormat.Equals(object? obj) -> bool
+override TriasDev.Tabular.NumberFormat.GetHashCode() -> int
+override TriasDev.Tabular.NumberFormat.ToString() -> string!
+static TriasDev.Tabular.DateFormat.operator !=(TriasDev.Tabular.DateFormat? left, TriasDev.Tabular.DateFormat? right) -> bool
+static TriasDev.Tabular.DateFormat.operator ==(TriasDev.Tabular.DateFormat? left, TriasDev.Tabular.DateFormat? right) -> bool
+static TriasDev.Tabular.DateFormat.Parse(string! code) -> TriasDev.Tabular.DateFormat!
+static TriasDev.Tabular.NumberFormat.operator !=(TriasDev.Tabular.NumberFormat? left, TriasDev.Tabular.NumberFormat? right) -> bool
+static TriasDev.Tabular.NumberFormat.operator ==(TriasDev.Tabular.NumberFormat? left, TriasDev.Tabular.NumberFormat? right) -> bool
+static TriasDev.Tabular.NumberFormat.Parse(string! code) -> TriasDev.Tabular.NumberFormat!
+TriasDev.Tabular.CellStyle.Date.get -> TriasDev.Tabular.DateFormat?
+TriasDev.Tabular.CellStyle.Date.init -> void
+TriasDev.Tabular.CellStyle.Number.get -> TriasDev.Tabular.NumberFormat?
+TriasDev.Tabular.CellStyle.Number.init -> void
+TriasDev.Tabular.DateFormat
+TriasDev.Tabular.DateFormat.$() -> TriasDev.Tabular.DateFormat!
+TriasDev.Tabular.DateFormat.Code.get -> string!
+TriasDev.Tabular.DateFormat.Equals(TriasDev.Tabular.DateFormat? other) -> bool
+TriasDev.Tabular.NumberFormat
+TriasDev.Tabular.NumberFormat.$() -> TriasDev.Tabular.NumberFormat!
+TriasDev.Tabular.NumberFormat.Code.get -> string!
+TriasDev.Tabular.NumberFormat.Equals(TriasDev.Tabular.NumberFormat? other) -> bool
+TriasDev.Tabular.TabularWriter.Style(TriasDev.Tabular.CellStyle! style) -> TriasDev.Tabular.StyleId
+TriasDev.Tabular.TabularWriter.Write(string? value, TriasDev.Tabular.StyleId style) -> void
+TriasDev.Tabular.TabularWriter.Write(long value, TriasDev.Tabular.StyleId style) -> void
+TriasDev.Tabular.TabularWriter.Write(decimal value, TriasDev.Tabular.StyleId style) -> void
+TriasDev.Tabular.TabularWriter.Write(double value, TriasDev.Tabular.StyleId style) -> void
+TriasDev.Tabular.TabularWriter.Write(System.DateTime value, TriasDev.Tabular.StyleId style) -> void
+TriasDev.Tabular.TabularWriter.Write(System.DateOnly value, TriasDev.Tabular.StyleId style) -> void
+TriasDev.Tabular.TabularWriter.Write(bool value, TriasDev.Tabular.StyleId style) -> void
+TriasDev.Tabular.TabularWriter.WriteEmpty(TriasDev.Tabular.StyleId style) -> void
diff --git a/src/TriasDev.Tabular/Writing/CellBorder.cs b/src/TriasDev.Tabular/Writing/CellBorder.cs
new file mode 100644
index 0000000..3d403d1
--- /dev/null
+++ b/src/TriasDev.Tabular/Writing/CellBorder.cs
@@ -0,0 +1,13 @@
+namespace TriasDev.Tabular;
+
+/// A border on all four sides of a cell.
+public sealed record CellBorder
+{
+ private CellBorder(CellColor color) => Color = color;
+
+ /// The border's colour.
+ public CellColor Color { get; }
+
+ /// A thin line in a colour on all four sides.
+ public static CellBorder Thin(CellColor color) => new(color);
+}
diff --git a/src/TriasDev.Tabular/Writing/CellColor.cs b/src/TriasDev.Tabular/Writing/CellColor.cs
new file mode 100644
index 0000000..6ba5669
--- /dev/null
+++ b/src/TriasDev.Tabular/Writing/CellColor.cs
@@ -0,0 +1,38 @@
+using System.Globalization;
+
+namespace TriasDev.Tabular;
+
+/// A colour for a cell's fill, font or border: 24-bit RGB.
+public readonly record struct CellColor
+{
+ private CellColor(int rgb) => Rgb = rgb;
+
+ /// The colour as 0xRRGGBB.
+ public int Rgb { get; }
+
+ /// A colour from 0xRRGGBB.
+ /// is not between 0 and 0xFFFFFF.
+ public static CellColor FromRgb(int rgb)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(rgb);
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(rgb, 0xFFFFFF);
+ return new CellColor(rgb);
+ }
+
+ /// A colour from #RRGGBB, hex digits in either case.
+ /// The text is not # and six hex digits.
+ public static CellColor Parse(string text)
+ {
+ ArgumentNullException.ThrowIfNull(text);
+
+ if (text.Length != 7 || text[0] != '#' || !int.TryParse(text.AsSpan(1), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out int rgb))
+ {
+ throw new FormatException($"\"{text}\" is not a colour; write it as #RRGGBB.");
+ }
+
+ return new CellColor(rgb);
+ }
+
+ /// The colour as #RRGGBB.
+ public override string ToString() => string.Create(CultureInfo.InvariantCulture, $"#{Rgb:X6}");
+}
diff --git a/src/TriasDev.Tabular/Writing/CellFont.cs b/src/TriasDev.Tabular/Writing/CellFont.cs
new file mode 100644
index 0000000..b260055
--- /dev/null
+++ b/src/TriasDev.Tabular/Writing/CellFont.cs
@@ -0,0 +1,14 @@
+namespace TriasDev.Tabular;
+
+/// How a cell's text looks: its colour, bold, italic. Family and size stay the format's default.
+public sealed record CellFont
+{
+ /// The text colour; null keeps the default (black).
+ public CellColor? Color { get; init; }
+
+ /// Bold text.
+ public bool Bold { get; init; }
+
+ /// Italic text.
+ public bool Italic { get; init; }
+}
diff --git a/src/TriasDev.Tabular/Writing/CellStyle.cs b/src/TriasDev.Tabular/Writing/CellStyle.cs
new file mode 100644
index 0000000..2bb8bb1
--- /dev/null
+++ b/src/TriasDev.Tabular/Writing/CellStyle.cs
@@ -0,0 +1,34 @@
+namespace TriasDev.Tabular;
+
+///
+/// How a cell looks in a workbook. Every setting is optional; one left unset keeps the format's
+/// default. Csv ignores styles.
+///
+///
+/// Compares by value: two styles with the same settings are the same style, and a file holds it
+/// once. Declare the styles an export uses once (static readonly) and register each with
+/// a writer's Style method.
+///
+public sealed record CellStyle
+{
+ /// The cell's background colour; null leaves it unfilled.
+ public CellColor? Fill { get; init; }
+
+ /// The text's colour, bold and italic; null keeps the default font.
+ public CellFont? Font { get; init; }
+
+ /// How an integer, decimal or double cell shows its value; ignored for other cells. Null keeps the default.
+ public NumberFormat? Number { get; init; }
+
+ /// How a date or date-time cell shows its value; ignored for other cells. Null keeps the writer's default date format.
+ public DateFormat? Date { get; init; }
+
+ /// Where the content sits across the cell.
+ public HorizontalAlignment Horizontal { get; init; }
+
+ /// Wraps text onto several lines within the column's width.
+ public bool Wrap { get; init; }
+
+ /// A border on all four sides; null draws none.
+ public CellBorder? Border { get; init; }
+}
diff --git a/src/TriasDev.Tabular/Writing/DateFormat.cs b/src/TriasDev.Tabular/Writing/DateFormat.cs
new file mode 100644
index 0000000..206aeb1
--- /dev/null
+++ b/src/TriasDev.Tabular/Writing/DateFormat.cs
@@ -0,0 +1,285 @@
+using System.Globalization;
+using System.Text;
+
+namespace TriasDev.Tabular;
+
+/// What a part of a date format shows.
+internal enum DatePartKind
+{
+ Literal,
+ Year,
+ Month,
+ Day,
+ Hour,
+ Minute,
+ Second,
+}
+
+/// One part of a date format: a field (long = two digits, or four for the year) or literal text.
+internal readonly record struct DatePart(DatePartKind Kind, bool Long, string Text);
+
+///
+/// How a date or date-time cell shows its value, as an Excel format code from a subset both workbook
+/// formats can state: yyyy yy m mm d dd h hh
+/// s ss, separators / - . : , space, quoted or backslash-escaped text — as in
+/// dd/mm/yyyy or yyyy-mm-dd hh:mm.
+///
+///
+///
+/// m and mm are minutes right after an hour or right before a second, months
+/// otherwise — Excel's rule. The separators are written as literal text, so / shows as a
+/// slash whatever the reader's locale.
+///
+/// The value stays the date written; only its display changes. Compares by .
+///
+public sealed record DateFormat
+{
+ private DateFormat(string code, DatePart[] parts)
+ {
+ Code = code;
+ Parts = parts;
+ }
+
+ /// The format code as written into a workbook: lower-case fields, separators escaped.
+ public string Code { get; }
+
+ internal IReadOnlyList Parts { get; }
+
+ /// Parses a format code.
+ /// The code is outside the supported subset; the message names the part.
+ public static DateFormat Parse(string code)
+ {
+ ArgumentNullException.ThrowIfNull(code);
+
+ if (code.Length == 0)
+ {
+ throw Refuse(code, "the code is empty");
+ }
+
+ List parts = [];
+ StringBuilder literal = new();
+ int i = 0;
+
+ while (i < code.Length)
+ {
+ if (TryReadLiteral(code, ref i, literal))
+ {
+ continue;
+ }
+
+ char c = code[i];
+ char letter = char.ToLowerInvariant(c);
+
+ if (letter is not ('y' or 'm' or 'd' or 'h' or 's'))
+ {
+ throw Refuse(code, FormatCodes.At(code, i));
+ }
+
+ int run = 1;
+
+ while (i + run < code.Length && char.ToLowerInvariant(code[i + run]) == letter)
+ {
+ run++;
+ }
+
+ DatePart field = Field(code, letter, run);
+ Flush(code, parts, literal);
+ parts.Add(field);
+ i += run;
+ }
+
+ Flush(code, parts, literal);
+
+ if (parts.TrueForAll(p => p.Kind == DatePartKind.Literal))
+ {
+ throw Refuse(code, "no date or time part");
+ }
+
+ ResolveMinutes(parts);
+ return new DateFormat(Canonical(parts), [.. parts]);
+ }
+
+ ///
+ public bool Equals(DateFormat? other) => other is not null && string.Equals(Code, other.Code, StringComparison.Ordinal);
+
+ ///
+ public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Code);
+
+ ///
+ public override string ToString() => Code;
+
+ /// Reads a quoted, escaped or separator literal at into , if one starts there.
+ private static bool TryReadLiteral(string code, ref int i, StringBuilder literal)
+ {
+ char c = code[i];
+
+ if (c == '"')
+ {
+ literal.Append(FormatCodes.ReadQuoted(code, ref i));
+ return true;
+ }
+
+ if (c == '\\')
+ {
+ if (i + 1 >= code.Length)
+ {
+ throw Refuse(code, "a backslash at the end, escaping nothing");
+ }
+
+ literal.Append(code[i + 1]);
+ i += 2;
+ return true;
+ }
+
+ if (c is '/' or '-' or '.' or ':' or ',' or ' ')
+ {
+ literal.Append(c);
+ i++;
+ return true;
+ }
+
+ return false;
+ }
+
+ private static DatePart Field(string code, char letter, int run)
+ {
+ string token = new(letter, run);
+
+ return (letter, run) switch
+ {
+ ('y', 2) => new DatePart(DatePartKind.Year, false, token),
+ ('y', 4) => new DatePart(DatePartKind.Year, true, token),
+ ('m', <= 2) => new DatePart(DatePartKind.Month, run == 2, token),
+ ('d', <= 2) => new DatePart(DatePartKind.Day, run == 2, token),
+ ('h', <= 2) => new DatePart(DatePartKind.Hour, run == 2, token),
+ ('s', <= 2) => new DatePart(DatePartKind.Second, run == 2, token),
+ _ => throw Refuse(code, $"\"{token}\" (month and weekday names, and years other than yy or yyyy, are not supported)"),
+ };
+ }
+
+ private static void Flush(string code, List parts, StringBuilder literal)
+ {
+ if (literal.Length > 0)
+ {
+ if (TextRules.Check(literal.ToString()) is { } problem)
+ {
+ throw Refuse(code, $"its literal text cannot be written ({problem})");
+ }
+
+ parts.Add(new DatePart(DatePartKind.Literal, false, literal.ToString()));
+ literal.Clear();
+ }
+ }
+
+ /// Excel's rule: m right after an hour, or right before a second, is minutes.
+ private static void ResolveMinutes(List parts)
+ {
+ for (int i = 0; i < parts.Count; i++)
+ {
+ if (parts[i].Kind != DatePartKind.Month)
+ {
+ continue;
+ }
+
+ DatePartKind before = Neighbour(parts, i, -1);
+ DatePartKind after = Neighbour(parts, i, +1);
+
+ if (before == DatePartKind.Hour || after == DatePartKind.Second)
+ {
+ parts[i] = parts[i] with { Kind = DatePartKind.Minute };
+ }
+ }
+ }
+
+ private static DatePartKind Neighbour(List parts, int i, int step)
+ {
+ for (int j = i + step; j >= 0 && j < parts.Count; j += step)
+ {
+ if (parts[j].Kind != DatePartKind.Literal)
+ {
+ return parts[j].Kind;
+ }
+ }
+
+ return DatePartKind.Literal;
+ }
+
+ /// Fields in lower case; literal characters escaped with a backslash, except ':', which Excel's own date-time formats leave bare.
+ private static string Canonical(List parts)
+ {
+ StringBuilder code = new();
+
+ foreach (DatePart part in parts)
+ {
+ if (part.Kind != DatePartKind.Literal)
+ {
+ code.Append(part.Text);
+ continue;
+ }
+
+ string text = part.Text;
+
+ int i = 0;
+
+ while (i < text.Length)
+ {
+ char c = text[i++];
+
+ if (char.IsHighSurrogate(c) && i < text.Length && char.IsLowSurrogate(text[i]))
+ {
+ // one unit: a backslash would escape only the high half, so the pair goes in quotes
+ code.Append('"').Append(c).Append(text[i++]).Append('"');
+ continue;
+ }
+
+ if (c != ':')
+ {
+ code.Append('\\');
+ }
+
+ code.Append(c);
+ }
+ }
+
+ return code.ToString();
+ }
+
+ private static ArgumentException Refuse(string code, string what) =>
+ new($"The date format \"{code}\" is not supported: {what}.", nameof(code));
+}
+
+/// The pieces of format-code parsing both parsers share.
+internal static class FormatCodes
+{
+ /// Reads a double-quoted literal at , if one starts there; "" otherwise.
+ public static string ReadQuoted(string code, ref int i)
+ {
+ if (i >= code.Length || code[i] != '"')
+ {
+ return string.Empty;
+ }
+
+ int end = code.IndexOf('"', i + 1);
+
+ if (end < 0)
+ {
+ throw new ArgumentException($"The format \"{code}\" is not supported: a quote at position {i + 1} that never closes.", nameof(code));
+ }
+
+ string text = code[(i + 1)..end];
+
+ if (TextRules.Check(text) is { } problem)
+ {
+ throw new ArgumentException($"The format \"{code}\" is not supported: its quoted text cannot be written ({problem}).", nameof(code));
+ }
+
+ i = end + 1;
+ return text;
+ }
+
+ /// The literal as a quoted run of a format code, or "" for none.
+ public static string Quote(string text) => text.Length == 0 ? string.Empty : "\"" + text + "\"";
+
+ /// Names the character at a position, for a refusal.
+ public static string At(string code, int i) => string.Create(CultureInfo.InvariantCulture, $"'{code[i]}' at position {i + 1}");
+}
diff --git a/src/TriasDev.Tabular/Writing/HorizontalAlignment.cs b/src/TriasDev.Tabular/Writing/HorizontalAlignment.cs
new file mode 100644
index 0000000..a0f6cdc
--- /dev/null
+++ b/src/TriasDev.Tabular/Writing/HorizontalAlignment.cs
@@ -0,0 +1,17 @@
+namespace TriasDev.Tabular;
+
+/// Where a cell's content sits across the cell.
+public enum HorizontalAlignment
+{
+ /// The format's default: text left, numbers and dates right.
+ General,
+
+ /// Left.
+ Left,
+
+ /// Centred.
+ Center,
+
+ /// Right.
+ Right,
+}
diff --git a/src/TriasDev.Tabular/Writing/ISheetWriter.cs b/src/TriasDev.Tabular/Writing/ISheetWriter.cs
index 9a9ebd5..6033773 100644
--- a/src/TriasDev.Tabular/Writing/ISheetWriter.cs
+++ b/src/TriasDev.Tabular/Writing/ISheetWriter.cs
@@ -32,27 +32,29 @@ internal interface ISheetWriter : IDisposable
///
string? WriteHeader(string value);
+ // Every Write* method below takes `style`: the cell's index in the writer's StyleTable; 0 is unstyled.
+
///
/// Writes text already checked by into the zero-based
/// ; returns a code if the format cannot hold it.
///
- string? WriteText(string value, int column);
+ string? WriteText(string value, int column, int style);
/// Writes an integer; returns a code if the format cannot hold it exactly.
- string? WriteLong(long value);
+ string? WriteLong(long value, int style);
/// Writes a decimal; returns a code if the format cannot hold it exactly.
- string? WriteDecimal(decimal value);
+ string? WriteDecimal(decimal value, int style);
/// Writes a double already checked by .
- string? WriteDouble(double value);
+ string? WriteDouble(double value, int style);
/// Writes a date already truncated to the millisecond; says whether it has a time of day.
- string? WriteDate(DateTime value, bool hasTime);
+ string? WriteDate(DateTime value, bool hasTime, int style);
- void WriteBoolean(bool value);
+ void WriteBoolean(bool value, int style);
- void WriteEmpty();
+ void WriteEmpty(int style);
void EndRow();
diff --git a/src/TriasDev.Tabular/Writing/NumberFormat.cs b/src/TriasDev.Tabular/Writing/NumberFormat.cs
new file mode 100644
index 0000000..5e23e0f
--- /dev/null
+++ b/src/TriasDev.Tabular/Writing/NumberFormat.cs
@@ -0,0 +1,161 @@
+using System.Globalization;
+
+namespace TriasDev.Tabular;
+
+///
+/// How a numeric cell shows its value, as an Excel format code from a subset both workbook formats
+/// can state: 0, 0.00, #,##0, #,##0.00, 0.0#, 0%, and
+/// quoted text before or after, as in "€ "#,##0.00.
+///
+///
+/// The value stays the number written; only its display changes. The decimal point and the
+/// thousands separator show as the reader's locale has them.
+///
+public sealed record NumberFormat
+{
+ private const int MaxDecimals = 30;
+
+ private NumberFormat(string code, bool grouping, int minIntegerDigits, int decimalPlaces, int minDecimalPlaces, bool percent, string prefix, string suffix)
+ {
+ Code = code;
+ Grouping = grouping;
+ MinIntegerDigits = minIntegerDigits;
+ DecimalPlaces = decimalPlaces;
+ MinDecimalPlaces = minDecimalPlaces;
+ Percent = percent;
+ Prefix = prefix;
+ Suffix = suffix;
+ }
+
+ /// The format code as written into a workbook: the canonical form of what was parsed.
+ public string Code { get; }
+
+ internal bool Grouping { get; }
+
+ internal int MinIntegerDigits { get; }
+
+ internal int DecimalPlaces { get; }
+
+ internal int MinDecimalPlaces { get; }
+
+ internal bool Percent { get; }
+
+ internal string Prefix { get; }
+
+ internal string Suffix { get; }
+
+ /// Parses a format code.
+ /// The code is outside the supported subset; the message names the part.
+ public static NumberFormat Parse(string code)
+ {
+ ArgumentNullException.ThrowIfNull(code);
+
+ if (code.Length == 0)
+ {
+ throw Refuse(code, "the code is empty");
+ }
+
+ int i = 0;
+ string prefix = FormatCodes.ReadQuoted(code, ref i);
+
+ int integerStart = i;
+ (int zeros, bool grouping) = ReadInteger(code, ref i);
+ string integer = code[integerStart..i];
+ (string decimalsText, int decimals, int minDecimals) = ReadDecimals(code, ref i);
+
+ bool percent = i < code.Length && code[i] == '%';
+ i += percent ? 1 : 0;
+
+ string suffix = FormatCodes.ReadQuoted(code, ref i);
+
+ if (i < code.Length)
+ {
+ throw Refuse(code, FormatCodes.At(code, i));
+ }
+
+ string canonical = FormatCodes.Quote(prefix) + integer + (decimals > 0 ? "." + decimalsText : string.Empty) + (percent ? "%" : string.Empty) + FormatCodes.Quote(suffix);
+ return new NumberFormat(canonical, grouping, zeros, decimals, minDecimals, percent, prefix, suffix);
+ }
+
+ ///
+ public override string ToString() => Code;
+
+ private static (int Zeros, bool Grouping) ReadInteger(string code, ref int i)
+ {
+ int digits = 0;
+ int zeros = 0;
+ bool grouping = false;
+
+ for (; i < code.Length && code[i] is '#' or '0' or ','; i++)
+ {
+ switch (code[i])
+ {
+ case '#' when zeros > 0:
+ throw Refuse(code, "a # after a 0 in the integer part");
+ case '#':
+ digits++;
+ break;
+ case '0':
+ zeros++;
+ digits++;
+ break;
+ default:
+ if (digits == 0 || i + 1 >= code.Length || code[i + 1] is not ('#' or '0'))
+ {
+ throw Refuse(code, "a comma that is not a thousands separator between digit placeholders");
+ }
+
+ grouping = true;
+ break;
+ }
+ }
+
+ if (digits == 0)
+ {
+ throw Refuse(code, i < code.Length ? FormatCodes.At(code, i) : "no digit placeholder (0 or #) in the integer part");
+ }
+
+ return (zeros, grouping);
+ }
+
+ private static (string Text, int Places, int MinPlaces) ReadDecimals(string code, ref int i)
+ {
+ if (i >= code.Length || code[i] != '.')
+ {
+ return (string.Empty, 0, 0);
+ }
+
+ i++;
+ int start = i;
+ int decimals = 0;
+ int minDecimals = 0;
+ bool optional = false;
+
+ for (; i < code.Length && code[i] is '0' or '#'; i++)
+ {
+ if (code[i] == '0' && optional)
+ {
+ throw Refuse(code, "a 0 after a # in the decimals");
+ }
+
+ optional |= code[i] == '#';
+ minDecimals += code[i] == '0' ? 1 : 0;
+ decimals++;
+ }
+
+ if (decimals == 0)
+ {
+ throw Refuse(code, "a decimal point without digit placeholders after it");
+ }
+
+ if (decimals > MaxDecimals)
+ {
+ throw Refuse(code, string.Create(CultureInfo.InvariantCulture, $"more than {MaxDecimals} decimal places"));
+ }
+
+ return (code[start..i], decimals, minDecimals);
+ }
+
+ private static ArgumentException Refuse(string code, string what) =>
+ new($"The number format \"{code}\" is not supported: {what}.", nameof(code));
+}
diff --git a/src/TriasDev.Tabular/Writing/StyleId.cs b/src/TriasDev.Tabular/Writing/StyleId.cs
new file mode 100644
index 0000000..f8eaeca
--- /dev/null
+++ b/src/TriasDev.Tabular/Writing/StyleId.cs
@@ -0,0 +1,25 @@
+using System.Globalization;
+
+namespace TriasDev.Tabular;
+
+///
+/// A style registered with one writer by , to pass with each cell written in it.
+/// Valid only with the writer that returned it. The default value is the unstyled cell.
+///
+public readonly record struct StyleId
+{
+ internal StyleId(int writer, int value)
+ {
+ Writer = writer;
+ Value = value;
+ }
+
+ /// The stamp of the writer that handed this id out; 0 for the default, which every writer accepts.
+ internal int Writer { get; }
+
+ /// The style's index in its writer's table; 0 is no style.
+ internal int Value { get; }
+
+ ///
+ public override string ToString() => string.Create(CultureInfo.InvariantCulture, $"StyleId({Value}, writer {Writer})");
+}
diff --git a/src/TriasDev.Tabular/Writing/StyleTable.cs b/src/TriasDev.Tabular/Writing/StyleTable.cs
new file mode 100644
index 0000000..65f522f
--- /dev/null
+++ b/src/TriasDev.Tabular/Writing/StyleTable.cs
@@ -0,0 +1,61 @@
+namespace TriasDev.Tabular;
+
+/// What a cell holds, as far as its style is concerned: each kind may need its own format style.
+internal enum ValueKind
+{
+ Text,
+ Integer,
+ Number,
+ Date,
+ DateTime,
+ Boolean,
+ Empty,
+}
+
+///
+/// The styles one writer has handed out, by index: 0 is the unstyled cell, 1 onwards the registered
+/// styles. Each format resolves an index, per , into its own style lazily.
+///
+internal sealed class StyleTable
+{
+ /// The most distinct styles a file holds.
+ public const int MaxStyles = 4096;
+
+ /// The number of values, for per-kind caches.
+ public const int ValueKinds = 7;
+
+ private readonly Dictionary _indices = [];
+ private readonly List _styles = [new CellStyle()];
+
+ /// The indices in use: the registered styles plus index 0.
+ public int Count => _styles.Count;
+
+ /// The style at an index (1 onwards; 0 is the empty style).
+ public CellStyle this[int index] => _styles[index];
+
+ /// Registers a style, or finds it registered already; returns its index.
+ public int Add(CellStyle style)
+ {
+ ArgumentNullException.ThrowIfNull(style);
+
+ if (_indices.TryGetValue(style, out int index))
+ {
+ return index;
+ }
+
+ if (!Enum.IsDefined(style.Horizontal))
+ {
+ throw new ArgumentOutOfRangeException(nameof(style), style.Horizontal, "The horizontal alignment is not one HorizontalAlignment defines.");
+ }
+
+ if (_indices.Count == MaxStyles)
+ {
+ throw new TabularLimitException("MaxStyles", MaxStyles, $"A file holds at most {MaxStyles} distinct styles.");
+ }
+
+ index = _styles.Count;
+ _styles.Add(style);
+ _indices.Add(style, index);
+ return index;
+ }
+}
diff --git a/src/TriasDev.Tabular/Writing/TabularWriter.cs b/src/TriasDev.Tabular/Writing/TabularWriter.cs
index 23d7dc1..5e1df9b 100644
--- a/src/TriasDev.Tabular/Writing/TabularWriter.cs
+++ b/src/TriasDev.Tabular/Writing/TabularWriter.cs
@@ -36,6 +36,9 @@ public sealed class TabularWriter : IAsyncDisposable
private readonly Stream _target;
private readonly SpillBuffer _buffer;
private readonly ISheetWriter _sheet;
+ private readonly StyleTable _styles;
+ private static int s_stamps;
+ private readonly int _stamp = NextStamp();
private readonly bool _leaveOpen;
private State _state = State.Open;
private WriteColumn[] _columns = [];
@@ -45,12 +48,13 @@ public sealed class TabularWriter : IAsyncDisposable
private long _rowNumber;
private int _column;
- private TabularWriter(Stream target, TabularFormat format, SpillBuffer buffer, ISheetWriter sheet, bool leaveOpen)
+ private TabularWriter(Stream target, TabularFormat format, SpillBuffer buffer, ISheetWriter sheet, StyleTable styles, bool leaveOpen)
{
_target = target;
Format = format;
_buffer = buffer;
_sheet = sheet;
+ _styles = styles;
_leaveOpen = leaveOpen;
}
@@ -115,20 +119,22 @@ public static TabularWriter Create(Stream stream, TabularFormat format, TabularW
#pragma warning restore S3928
}
+ StyleTable styles = new();
+
switch (format)
{
case TabularFormat.Csv:
CsvFormat csv = effective.Csv.Resolve();
SpillBuffer buffer = new();
- return new TabularWriter(stream, format, buffer, new CsvSheetWriter(buffer, csv), effective.LeaveOpen);
+ return new TabularWriter(stream, format, buffer, new CsvSheetWriter(buffer, csv), styles, effective.LeaveOpen);
case TabularFormat.Xlsx:
XlsxWriterOptions xlsx = effective.Xlsx.Checked();
SpillBuffer workbook = new();
- return new TabularWriter(stream, format, workbook, new XlsxSheetWriter(workbook, xlsx), effective.LeaveOpen);
+ return new TabularWriter(stream, format, workbook, new XlsxSheetWriter(workbook, xlsx, styles), styles, effective.LeaveOpen);
case TabularFormat.Ods:
OdsWriterOptions ods = effective.Ods.Checked();
SpillBuffer spreadsheet = new();
- return new TabularWriter(stream, format, spreadsheet, new OdsSheetWriter(spreadsheet, ods), effective.LeaveOpen);
+ return new TabularWriter(stream, format, spreadsheet, new OdsSheetWriter(spreadsheet, ods, styles), styles, effective.LeaveOpen);
default:
throw new ArgumentOutOfRangeException(nameof(format), format, $"Writing {format} is not supported.");
}
@@ -222,19 +228,49 @@ public void BeginRow()
StartRow();
}
+ ///
+ /// Registers a style for this writer's cells and returns its id, to pass to the Write
+ /// overloads. The same style, by value, returns the same id. Call it before or during any sheet;
+ /// csv files ignore styles.
+ ///
+ /// is null.
+ /// The file already holds 4096 distinct styles.
+ /// The style's alignment is not a defined value.
+ public StyleId Style(CellStyle style)
+ {
+ ExpectWritable();
+
+ try
+ {
+ return new StyleId(_stamp, _styles.Add(style));
+ }
+ catch (Exception refused) when (refused is ArgumentException or TabularLimitException)
+ {
+ MarkFaulted();
+ throw;
+ }
+ }
+
/// Writes the next cell as text; null writes an empty cell.
/// The import trims text and reads empty or whitespace-only text as no value.
- public void Write(string? value)
+ public void Write(string? value) => Write(value, default);
+
+ ///
+ /// The text; null writes an empty cell.
+ /// A style this writer handed out, or the default for none.
+ /// was not handed out by this writer.
+ public void Write(string? value, StyleId style)
{
int column = NextCell();
+ int index = Index(style);
if (value is null)
{
- _sheet.WriteEmpty();
+ _sheet.WriteEmpty(index);
return;
}
- Check(TextRules.Check(value) ?? _sheet.WriteText(value, column), column);
+ Check(TextRules.Check(value) ?? _sheet.WriteText(value, column, index), column);
}
/// Writes the next cell as an integer. Narrower integers arrive here by implicit conversion.
@@ -244,59 +280,100 @@ public void Write(string? value)
/// implicitly to both and , so the call is ambiguous and
/// does not compile; convert it explicitly, to or .
///
- public void Write(long value)
+ public void Write(long value) => Write(value, default);
+
+ ///
+ /// The integer.
+ /// A style this writer handed out, or the default for none.
+ /// was not handed out by this writer.
+ public void Write(long value, StyleId style)
{
int column = NextCell();
- Check(_sheet.WriteLong(value), column);
+ Check(_sheet.WriteLong(value, Index(style)), column);
}
/// Writes the next cell as a decimal number.
- public void Write(decimal value)
+ public void Write(decimal value) => Write(value, default);
+
+ ///
+ /// The number.
+ /// A style this writer handed out, or the default for none.
+ /// was not handed out by this writer.
+ public void Write(decimal value, StyleId style)
{
int column = NextCell();
- Check(_sheet.WriteDecimal(value), column);
+ Check(_sheet.WriteDecimal(value, Index(style)), column);
}
///
/// Writes the next cell as a number. Refused when not finite, or when it has more than 15
/// significant digits — which a workbook would not give back.
///
- public void Write(double value)
+ public void Write(double value) => Write(value, default);
+
+ ///
+ /// The number.
+ /// A style this writer handed out, or the default for none.
+ /// was not handed out by this writer.
+ public void Write(double value, StyleId style)
{
int column = NextCell();
- Check(ValueChecks.Double(value) ?? _sheet.WriteDouble(value), column);
+ Check(ValueChecks.Double(value) ?? _sheet.WriteDouble(value, Index(style)), column);
}
///
/// Writes the next cell as a date, with its time of day when it has one. Anything finer than a
/// millisecond is dropped, and the kind is not kept: the import returns the wall-clock value.
///
- public void Write(DateTime value)
+ public void Write(DateTime value) => Write(value, default);
+
+ ///
+ /// The date and time.
+ /// A style this writer handed out, or the default for none.
+ /// was not handed out by this writer.
+ public void Write(DateTime value, StyleId style)
{
int column = NextCell();
DateTime truncated = ValueChecks.Truncated(value);
- Check(_sheet.WriteDate(truncated, truncated.TimeOfDay != TimeSpan.Zero), column);
+ Check(_sheet.WriteDate(truncated, truncated.TimeOfDay != TimeSpan.Zero, Index(style)), column);
}
/// Writes the next cell as a date. The import returns it as a at midnight.
- public void Write(DateOnly value)
+ public void Write(DateOnly value) => Write(value, default);
+
+ ///
+ /// The date.
+ /// A style this writer handed out, or the default for none.
+ /// was not handed out by this writer.
+ public void Write(DateOnly value, StyleId style)
{
int column = NextCell();
- Check(_sheet.WriteDate(value.ToDateTime(TimeOnly.MinValue), hasTime: false), column);
+ Check(_sheet.WriteDate(value.ToDateTime(TimeOnly.MinValue), hasTime: false, Index(style)), column);
}
/// Writes the next cell as a boolean.
- public void Write(bool value)
+ public void Write(bool value) => Write(value, default);
+
+ ///
+ /// The boolean.
+ /// A style this writer handed out, or the default for none.
+ /// was not handed out by this writer.
+ public void Write(bool value, StyleId style)
{
NextCell();
- _sheet.WriteBoolean(value);
+ _sheet.WriteBoolean(value, Index(style));
}
/// Writes the next cell empty.
- public void WriteEmpty()
+ public void WriteEmpty() => WriteEmpty(default);
+
+ ///
+ /// A style this writer handed out, or the default for none.
+ /// was not handed out by this writer.
+ public void WriteEmpty(StyleId style)
{
NextCell();
- _sheet.WriteEmpty();
+ _sheet.WriteEmpty(Index(style));
}
/// Ends the row; columns it did not reach are written empty.
@@ -470,13 +547,33 @@ private void FinishRow()
{
for (; _column < _columns.Length; _column++)
{
- _sheet.WriteEmpty();
+ _sheet.WriteEmpty(0);
}
_sheet.EndRow();
_state = State.InSheet;
}
+ /// A number no other writer in the process holds, never 0.
+ private static int NextStamp()
+ {
+ int stamp = Interlocked.Increment(ref s_stamps);
+ return stamp != 0 ? stamp : Interlocked.Increment(ref s_stamps);
+ }
+
+ /// The style's index, refused unless this writer handed it out.
+ private int Index(StyleId style)
+ {
+ int index = style.Value;
+
+ if ((uint)index >= (uint)_styles.Count || (index != 0 && style.Writer != _stamp))
+ {
+ throw Faulting(new ArgumentException($"{style} was not handed out by this writer's Style method.", nameof(style)));
+ }
+
+ return index;
+ }
+
private int NextCell()
{
ExpectWritable();
diff --git a/src/TriasDev.Tabular/Xlsx/XlsxCursor.cs b/src/TriasDev.Tabular/Xlsx/XlsxCursor.cs
index 165b947..8823ebf 100644
--- a/src/TriasDev.Tabular/Xlsx/XlsxCursor.cs
+++ b/src/TriasDev.Tabular/Xlsx/XlsxCursor.cs
@@ -1432,10 +1432,16 @@ private static bool LooksLikeDateFormat(string code)
char c = code[i];
i++;
+ if (inQuotes)
+ {
+ inQuotes = c != '"'; // inside quotes everything is literal, a backslash and a bracket too
+ continue;
+ }
+
switch (c)
{
case '"':
- inQuotes = !inQuotes;
+ inQuotes = true;
continue;
case '[':
inBrackets = true;
@@ -1448,7 +1454,7 @@ private static bool LooksLikeDateFormat(string code)
continue;
}
- if (inQuotes || inBrackets)
+ if (inBrackets)
{
continue;
}
diff --git a/src/TriasDev.Tabular/Xlsx/XlsxParts.cs b/src/TriasDev.Tabular/Xlsx/XlsxParts.cs
index 2b6b504..a36ba33 100644
--- a/src/TriasDev.Tabular/Xlsx/XlsxParts.cs
+++ b/src/TriasDev.Tabular/Xlsx/XlsxParts.cs
@@ -15,28 +15,6 @@ internal static class XlsxParts
public const string WorksheetEnd = "";
- ///
- /// Four cell formats, by index: 0 General, 1 a date, 2 a date and time, 3 an integer (format
- /// 0, so an id of twelve digits does not show as 1.23457E+11). The reader takes
- /// formats 14 and 164 for dates, 0 and 1 for numbers.
- ///
- public static readonly byte[] Styles = Encoding.UTF8.GetBytes(
- XmlDeclaration
- + ""
- + ""
- + ""
- + ""
- + ""
- + ""
- + ""
- + ""
- + ""
- + ""
- + ""
- + ""
- + ""
- + "");
-
public static readonly byte[] PackageRelationships = Encoding.UTF8.GetBytes(
XmlDeclaration
+ ""
diff --git a/src/TriasDev.Tabular/Xlsx/XlsxSheetWriter.cs b/src/TriasDev.Tabular/Xlsx/XlsxSheetWriter.cs
index 9066afa..1d70e53 100644
--- a/src/TriasDev.Tabular/Xlsx/XlsxSheetWriter.cs
+++ b/src/TriasDev.Tabular/Xlsx/XlsxSheetWriter.cs
@@ -28,9 +28,6 @@ internal sealed class XlsxSheetWriter : ISheetWriter
private const int RetainedBytes = 3 * RowText.RetainedChars;
private const string ValueEnd = "";
- private const int DateStyle = 1;
- private const int DateTimeStyle = 2;
- private const int IntegerStyle = 3;
/// The first day a workbook holds, as the reader reads serials.
private static readonly DateTime FirstDay = new(1900, 1, 1, 0, 0, 0, DateTimeKind.Unspecified);
@@ -45,6 +42,7 @@ internal sealed class XlsxSheetWriter : ISheetWriter
private static readonly SearchValues NeedsEscape = SearchValues.Create("&<>\r_");
private readonly ZipWriter _zip;
+ private readonly XlsxStyles _styles;
private readonly List _sheetNames = [];
private readonly RowText _row = new();
private ArrayBufferWriter _bytes = new(16 * 1024);
@@ -53,8 +51,9 @@ internal sealed class XlsxSheetWriter : ISheetWriter
private long _rowNumber;
private int _column;
- public XlsxSheetWriter(SpillBuffer output, XlsxWriterOptions options)
+ public XlsxSheetWriter(SpillBuffer output, XlsxWriterOptions options, StyleTable styles)
{
+ _styles = new XlsxStyles(styles);
_zip = new ZipWriter(output, options.CompressionLevel);
}
@@ -104,66 +103,77 @@ public void BeginRow()
_row.Append("\">");
}
- public string? WriteHeader(string value) => WriteInline(value);
+ public string? WriteHeader(string value) => WriteInline(value, 0);
- public string? WriteText(string value, int column) => WriteInline(value);
+ public string? WriteText(string value, int column, int style) => WriteInline(value, style == 0 ? 0 : _styles.Xf(style, ValueKind.Text));
- public string? WriteLong(long value)
+ public string? WriteLong(long value, int style)
{
if (ValueChecks.LongInDouble(value) is { } code)
{
return code;
}
- WriteNumber(IntegerStyle);
+ WriteNumber(style == 0 ? XlsxStyles.IntegerXf : _styles.Xf(style, ValueKind.Integer));
_row.AppendFormatted(value, default, CultureInfo.InvariantCulture);
_row.Append(ValueEnd);
return null;
}
- public string? WriteDecimal(decimal value)
+ public string? WriteDecimal(decimal value, int style)
{
if (ValueChecks.DecimalInDouble(value) is { } code)
{
return code;
}
- WriteNumber(style: 0);
+ WriteNumber(style == 0 ? 0 : _styles.Xf(style, ValueKind.Number));
_row.AppendFormatted(value, default, CultureInfo.InvariantCulture);
_row.Append(ValueEnd);
return null;
}
- public string? WriteDouble(double value)
+ public string? WriteDouble(double value, int style)
{
- WriteNumber(style: 0);
+ WriteNumber(style == 0 ? 0 : _styles.Xf(style, ValueKind.Number));
_row.AppendFormatted(value, "R", CultureInfo.InvariantCulture);
_row.Append(ValueEnd);
return null;
}
- public string? WriteDate(DateTime value, bool hasTime)
+ public string? WriteDate(DateTime value, bool hasTime, int style)
{
if (value < FirstDay)
{
return ErrorCodes.Write.DateOutOfRange;
}
- WriteNumber(hasTime ? DateTimeStyle : DateStyle);
+ int xf = style == 0 ? DefaultDateXf(hasTime) : _styles.Xf(style, DateKind(hasTime));
+ WriteNumber(xf);
_row.AppendFormatted(Serial(value), "R", CultureInfo.InvariantCulture);
_row.Append(ValueEnd);
return null;
}
- public void WriteBoolean(bool value)
+ public void WriteBoolean(bool value, int style)
{
- StartCell();
+ StartCell(style == 0 ? 0 : _styles.Xf(style, ValueKind.Boolean));
_row.Append(" t=\"b\">");
_row.Append(value ? '1' : '0');
_row.Append(ValueEnd);
}
- public void WriteEmpty() => _column++;
+ public void WriteEmpty(int style)
+ {
+ if (style == 0)
+ {
+ _column++;
+ return;
+ }
+
+ StartCell(_styles.Xf(style, ValueKind.Empty));
+ _row.Append("/>");
+ }
public void EndRow()
{
@@ -178,18 +188,18 @@ public void Complete()
_zip.AddStored("_rels/.rels", XlsxParts.PackageRelationships);
_zip.AddStored("xl/workbook.xml", XlsxParts.Workbook(_sheetNames));
_zip.AddStored("xl/_rels/workbook.xml.rels", XlsxParts.WorkbookRelationships(_sheetNames.Count));
- _zip.AddStored("xl/styles.xml", XlsxParts.Styles);
+ _zip.AddStored("xl/styles.xml", _styles.Build());
_zip.Complete();
}
- private string? WriteInline(string value)
+ private string? WriteInline(string value, int xf)
{
if (value.Length > MaxTextChars)
{
return ErrorCodes.Write.TextTooLong;
}
- StartCell();
+ StartCell(xf);
_row.Append(" t=\"inlineStr\"> 0 && (char.IsWhiteSpace(value[0]) || char.IsWhiteSpace(value[^1])))
@@ -203,18 +213,14 @@ public void Complete()
return null;
}
- /// Opens a number cell in a style, up to its value.
- private void WriteNumber(int style)
- {
- StartCell();
+ private static int DefaultDateXf(bool hasTime) => hasTime ? XlsxStyles.DateTimeXf : XlsxStyles.DateXf;
- if (style != 0)
- {
- _row.Append(" s=\"");
- _row.Append((char)('0' + style));
- _row.Append('"');
- }
+ private static ValueKind DateKind(bool hasTime) => hasTime ? ValueKind.DateTime : ValueKind.Date;
+ /// Opens a number cell in a cell format, up to its value.
+ private void WriteNumber(int xf)
+ {
+ StartCell(xf);
_row.Append(">");
}
@@ -235,12 +241,20 @@ private static double Serial(DateTime value)
}
/// Opens a cell at the current column, with its reference; the caller writes the rest.
- private void StartCell()
+ private void StartCell(int xf)
{
_row.Append("
+/// The workbook's styles: four fixed cell formats every unstyled cell uses, then one per registered
+/// style and value kind actually written, resolved on first use and cached.
+///
+///
+/// The fixed formats, by index: 0 General, 1 a date (built-in 14), 2 a date and time (custom 164),
+/// 3 an integer (built-in 1, so an id of twelve digits does not show as 1.23457E+11). The
+/// reader takes formats 14 and 164 — and any custom code with a date token — for dates. Custom
+/// number formats start at 165.
+///
+internal sealed class XlsxStyles
+{
+ /// The cell format index of the fixed date format; the order of _xfs below.
+ internal const int DateXf = 1;
+
+ /// The cell format index of the fixed date-and-time format.
+ internal const int DateTimeXf = 2;
+
+ /// The cell format index of the fixed integer format.
+ internal const int IntegerXf = 3;
+
+ private const int BuiltInDate = 14;
+ private const int BuiltInDateTime = 164;
+ private const int BuiltInInteger = 1;
+ private const int FirstCustomFormat = 165;
+ private const string XmlDeclaration = "";
+
+ private readonly StyleTable _table;
+ private readonly List _xfs = [new(0, 0, 0, 0, HorizontalAlignment.General, false), new(BuiltInDate, 0, 0, 0, HorizontalAlignment.General, false), new(BuiltInDateTime, 0, 0, 0, HorizontalAlignment.General, false), new(BuiltInInteger, 0, 0, 0, HorizontalAlignment.General, false)];
+ private readonly Dictionary _xfIndices = [];
+ private readonly List _fonts = []; // index + 1 in ; 0 is the default font
+ private readonly Dictionary _fontIndices = [];
+ private readonly List _fills = []; // index + 2 in ; 0 none, 1 gray125
+ private readonly Dictionary _fillIndices = [];
+ private readonly List _borders = []; // index + 1 in ; 0 none
+ private readonly Dictionary _borderIndices = [];
+ private readonly List _formats = []; // id FirstCustomFormat + index
+ private readonly Dictionary _formatIds = new(StringComparer.Ordinal);
+ private int[] _resolved = []; // xf index + 1 per (style, kind); 0 = not yet
+
+ public XlsxStyles(StyleTable table)
+ {
+ _table = table;
+
+ for (int i = 0; i < _xfs.Count; i++)
+ {
+ _xfIndices.Add(_xfs[i], i);
+ }
+ }
+
+ /// The cell format for a registered style (index 1 onwards) on a kind of value.
+ public int Xf(int style, ValueKind kind)
+ {
+ int slot = (style * StyleTable.ValueKinds) + (int)kind;
+ int[] resolved = _resolved;
+ return slot < resolved.Length && resolved[slot] != 0 ? resolved[slot] - 1 : Resolve(slot, style, kind);
+ }
+
+ /// The whole xl/styles.xml.
+ public byte[] Build()
+ {
+ StringBuilder xml = new(XmlDeclaration + "");
+
+ xml.Append(CultureInfo.InvariantCulture, $"");
+
+ for (int i = 0; i < _formats.Count; i++)
+ {
+ xml.Append(CultureInfo.InvariantCulture, $"");
+ }
+
+ xml.Append(CultureInfo.InvariantCulture, $"");
+
+ foreach (CellFont font in _fonts)
+ {
+ xml.Append("");
+ xml.Append(font.Bold ? "" : string.Empty);
+ xml.Append(font.Italic ? "" : string.Empty);
+ xml.Append("");
+
+ if (font.Color is { } color)
+ {
+ xml.Append("");
+ }
+
+ xml.Append("");
+ }
+
+ xml.Append(CultureInfo.InvariantCulture, $"");
+
+ foreach (CellColor fill in _fills)
+ {
+ xml.Append("");
+ }
+
+ xml.Append(CultureInfo.InvariantCulture, $"");
+
+ foreach (CellColor border in _borders)
+ {
+ string side = "style=\"thin\">";
+ xml.Append("");
+ }
+
+ xml.Append("");
+ xml.Append(CultureInfo.InvariantCulture, $"");
+
+ foreach (CellFormat xf in _xfs)
+ {
+ AppendXf(xml, xf);
+ }
+
+ xml.Append("");
+ return Encoding.UTF8.GetBytes(xml.ToString());
+ }
+
+ private int Resolve(int slot, int style, ValueKind kind)
+ {
+ CellStyle cell = _table[style];
+
+ int format = kind switch
+ {
+ ValueKind.Integer => cell.Number is { } number ? FormatId(number.Code) : BuiltInInteger,
+ ValueKind.Number => cell.Number is { } number ? FormatId(number.Code) : 0,
+ ValueKind.Date => cell.Date is { } date ? FormatId(date.Code) : BuiltInDate,
+ ValueKind.DateTime => cell.Date is { } date ? FormatId(date.Code) : BuiltInDateTime,
+ _ => 0,
+ };
+
+ int font = cell.Font is { } f ? Index(_fonts, _fontIndices, f) + 1 : 0;
+ int fill = cell.Fill is { } c ? Index(_fills, _fillIndices, c) + 2 : 0;
+ int border = cell.Border is { } b ? Index(_borders, _borderIndices, b.Color) + 1 : 0;
+ int xf = Index(_xfs, _xfIndices, new CellFormat(format, font, fill, border, cell.Horizontal, cell.Wrap));
+
+ if (slot >= _resolved.Length)
+ {
+ Array.Resize(ref _resolved, Math.Max(slot + 1, _table.Count * StyleTable.ValueKinds));
+ }
+
+ _resolved[slot] = xf + 1;
+ return xf;
+ }
+
+ private int FormatId(string code)
+ {
+ if (!_formatIds.TryGetValue(code, out int id))
+ {
+ id = FirstCustomFormat + _formats.Count;
+ _formats.Add(code);
+ _formatIds.Add(code, id);
+ }
+
+ return id;
+ }
+
+ private static int Index(List list, Dictionary indices, T value)
+ where T : notnull
+ {
+ if (!indices.TryGetValue(value, out int index))
+ {
+ index = list.Count;
+ list.Add(value);
+ indices.Add(value, index);
+ }
+
+ return index;
+ }
+
+ private static void AppendXf(StringBuilder xml, CellFormat xf)
+ {
+ xml.Append(CultureInfo.InvariantCulture, $"");
+ return;
+ }
+
+ xml.Append(" applyAlignment=\"1\"> "left", HorizontalAlignment.Center => "center", _ => "right" }).Append('"');
+ }
+
+ xml.Append(xf.Wrap ? " wrapText=\"1\"" : string.Empty).Append("/>");
+ }
+
+ private static string Argb(CellColor color) => string.Create(CultureInfo.InvariantCulture, $"FF{color.Rgb:X6}");
+
+ private static void AppendEscaped(StringBuilder xml, string value)
+ {
+ foreach (char c in value)
+ {
+ _ = c switch
+ {
+ '&' => xml.Append("&"),
+ '<' => xml.Append("<"),
+ '>' => xml.Append(">"),
+ '"' => xml.Append("""),
+ '\t' => xml.Append(" "),
+ '\n' => xml.Append("
"),
+ '\r' => xml.Append("
"),
+ _ => xml.Append(c),
+ };
+ }
+ }
+
+ private readonly record struct CellFormat(int Format, int Font, int Fill, int Border, HorizontalAlignment Horizontal, bool Wrap);
+}
diff --git a/tests/TriasDev.Tabular.Tests/AllocationMeasurementCollection.cs b/tests/TriasDev.Tabular.Tests/AllocationMeasurementCollection.cs
new file mode 100644
index 0000000..3d4d6a2
--- /dev/null
+++ b/tests/TriasDev.Tabular.Tests/AllocationMeasurementCollection.cs
@@ -0,0 +1,13 @@
+using Xunit;
+
+namespace TriasDev.Tabular.Tests;
+
+///
+/// Tests that count allocated bytes run alone: Gen2 collections caused by tests running beside them
+/// trim the shared array pool, and the buffers re-rented afterwards are counted as if they were per-row cost.
+///
+[CollectionDefinition(Name, DisableParallelization = true)]
+public static class AllocationMeasurementCollection
+{
+ public const string Name = "Allocation measurement";
+}
diff --git a/tests/TriasDev.Tabular.Tests/Fixtures/LibreOffice.cs b/tests/TriasDev.Tabular.Tests/Fixtures/LibreOffice.cs
index aed4c8e..7b19d90 100644
--- a/tests/TriasDev.Tabular.Tests/Fixtures/LibreOffice.cs
+++ b/tests/TriasDev.Tabular.Tests/Fixtures/LibreOffice.cs
@@ -16,8 +16,18 @@ public static class LibreOffice
{
private static readonly string? Soffice = Find();
- /// Converts the file's first sheet to csv with LibreOffice and returns its lines.
- public static string[] ConvertToCsv(byte[] file, string extension)
+ /// Converts the file's first sheet to csv with LibreOffice and returns its lines, values as shown.
+ public static string[] ConvertToCsv(byte[] file, string extension) =>
+ Encoding.UTF8.GetString(Convert(file, extension, "csv:Text - txt - csv (StarCalc):44,34,76,1,,0,true", "csv"))
+ .Split('\n')
+ .Select(line => line.TrimEnd('\r'))
+ .Reverse()
+ .SkipWhile((line, i) => i == 0 && line.Length == 0)
+ .Reverse()
+ .ToArray();
+
+ /// Converts a file with LibreOffice, by an export filter, and returns the converted file.
+ public static byte[] Convert(byte[] file, string extension, string filter, string outputExtension)
{
if (Soffice is null)
{
@@ -40,7 +50,7 @@ public static string[] ConvertToCsv(byte[] file, string extension)
"--headless",
$"-env:UserInstallation={new Uri(Path.Combine(folder, "profile")).AbsoluteUri}",
"--convert-to",
- "csv:Text - txt - csv (StarCalc):44,34,76,1,,0,true",
+ filter,
"--outdir",
folder,
input,
@@ -66,10 +76,10 @@ public static string[] ConvertToCsv(byte[] file, string extension)
// The pipes close with the process; read after it exited so a full pipe cannot stall it.
string output = standardOutput.GetAwaiter().GetResult() + standardError.GetAwaiter().GetResult();
- string csv = Path.Combine(folder, "file.csv");
- Assert.True(File.Exists(csv), $"LibreOffice could not convert the file: {output}");
+ string converted = Path.Combine(folder, "file." + outputExtension);
+ Assert.True(File.Exists(converted), $"LibreOffice could not convert the file: {output}");
- return File.ReadAllLines(csv, Encoding.UTF8);
+ return File.ReadAllBytes(converted);
}
finally
{
diff --git a/tests/TriasDev.Tabular.Tests/Writing/CellStyleTests.cs b/tests/TriasDev.Tabular.Tests/Writing/CellStyleTests.cs
new file mode 100644
index 0000000..77ec1cc
--- /dev/null
+++ b/tests/TriasDev.Tabular.Tests/Writing/CellStyleTests.cs
@@ -0,0 +1,62 @@
+using Xunit;
+
+namespace TriasDev.Tabular.Tests.Writing;
+
+/// The style model: colours, fonts, borders and styles compare by value.
+public sealed class CellStyleTests
+{
+ [Theory]
+ [InlineData(0x000000, "#000000")]
+ [InlineData(0xF8696B, "#F8696B")]
+ [InlineData(0xFFFFFF, "#FFFFFF")]
+ public void AColourIsTwentyFourBitRgb(int rgb, string text)
+ {
+ CellColor color = CellColor.FromRgb(rgb);
+
+ Assert.Equal(rgb, color.Rgb);
+ Assert.Equal(text, color.ToString());
+ Assert.Equal(color, CellColor.Parse(text));
+ Assert.Equal(color, CellColor.Parse(text.ToLowerInvariant()));
+ }
+
+ [Theory]
+ [InlineData(-1)]
+ [InlineData(0x1000000)]
+ public void AColourOutsideRgbIsRefused(int rgb) =>
+ Assert.Throws(() => CellColor.FromRgb(rgb));
+
+ [Theory]
+ [InlineData("F8696B")]
+ [InlineData("#F8696")]
+ [InlineData("#F8696B0")]
+ [InlineData("#G8696B")]
+ [InlineData("")]
+ public void AColourTextOtherThanHashAndSixHexDigitsIsRefused(string text) =>
+ Assert.Throws(() => CellColor.Parse(text));
+
+ [Fact]
+ public void StylesWithTheSameSettingsAreEqual()
+ {
+ CellStyle a = new() { Fill = CellColor.FromRgb(0xFF0000), Font = new CellFont { Bold = true, Color = CellColor.FromRgb(0xFFFFFF) }, Border = CellBorder.Thin(CellColor.FromRgb(0x808080)), Horizontal = HorizontalAlignment.Center, Wrap = true };
+ CellStyle b = new() { Fill = CellColor.FromRgb(0xFF0000), Font = new CellFont { Bold = true, Color = CellColor.FromRgb(0xFFFFFF) }, Border = CellBorder.Thin(CellColor.FromRgb(0x808080)), Horizontal = HorizontalAlignment.Center, Wrap = true };
+
+ Assert.Equal(a, b);
+ Assert.Equal(a.GetHashCode(), b.GetHashCode());
+ Assert.NotEqual(a, b with { Wrap = false });
+ }
+
+ [Fact]
+ public void AnEmptyStyleChangesNothing()
+ {
+ CellStyle style = new();
+
+ Assert.Null(style.Fill);
+ Assert.Null(style.Font);
+ Assert.Null(style.Border);
+ Assert.Equal(HorizontalAlignment.General, style.Horizontal);
+ Assert.False(style.Wrap);
+ }
+
+ [Fact]
+ public void TheDefaultStyleIdIsTheUnstyledCell() => Assert.Equal(default, new StyleId());
+}
diff --git a/tests/TriasDev.Tabular.Tests/Writing/CsvTextTests.cs b/tests/TriasDev.Tabular.Tests/Writing/CsvTextTests.cs
index 4a7f26f..a560f86 100644
--- a/tests/TriasDev.Tabular.Tests/Writing/CsvTextTests.cs
+++ b/tests/TriasDev.Tabular.Tests/Writing/CsvTextTests.cs
@@ -175,7 +175,7 @@ public async Task AHugeRowDoesNotKeepItsBufferForTheRestOfTheFile()
sheet.BeginSheet("data", [new("v")]);
sheet.BeginRow();
- Assert.Null(sheet.WriteText(new string('x', 10_000_000), 0));
+ Assert.Null(sheet.WriteText(new string('x', 10_000_000), 0, 0));
sheet.EndRow();
Assert.True(sheet.RowBufferLength <= 4 * 1024);
diff --git a/tests/TriasDev.Tabular.Tests/Writing/FormatCodeTests.cs b/tests/TriasDev.Tabular.Tests/Writing/FormatCodeTests.cs
new file mode 100644
index 0000000..766a8fd
--- /dev/null
+++ b/tests/TriasDev.Tabular.Tests/Writing/FormatCodeTests.cs
@@ -0,0 +1,124 @@
+using Xunit;
+
+namespace TriasDev.Tabular.Tests.Writing;
+
+/// The supported subset of Excel format codes: parsed into what both workbook formats can state.
+public sealed class FormatCodeTests
+{
+ [Theory]
+ [InlineData("0", false, 1, 0, 0, false, "", "")]
+ [InlineData("0.00", false, 1, 2, 2, false, "", "")]
+ [InlineData("#,##0", true, 1, 0, 0, false, "", "")]
+ [InlineData("#,##0.00", true, 1, 2, 2, false, "", "")]
+ [InlineData("0.0#", false, 1, 2, 1, false, "", "")]
+ [InlineData("#.##", false, 0, 2, 0, false, "", "")]
+ [InlineData("0%", false, 1, 0, 0, true, "", "")]
+ [InlineData("0.0%", false, 1, 1, 1, true, "", "")]
+ [InlineData("\"€ \"#,##0.00", true, 1, 2, 2, false, "€ ", "")]
+ [InlineData("0\" kg\"", false, 1, 0, 0, false, "", " kg")]
+ public void ParsesANumberFormat(string code, bool grouping, int minInteger, int decimals, int minDecimals, bool percent, string prefix, string suffix)
+ {
+ NumberFormat format = NumberFormat.Parse(code);
+
+ Assert.Equal(code, format.Code);
+ Assert.Equal(grouping, format.Grouping);
+ Assert.Equal(minInteger, format.MinIntegerDigits);
+ Assert.Equal(decimals, format.DecimalPlaces);
+ Assert.Equal(minDecimals, format.MinDecimalPlaces);
+ Assert.Equal(percent, format.Percent);
+ Assert.Equal(prefix, format.Prefix);
+ Assert.Equal(suffix, format.Suffix);
+ }
+
+ [Theory]
+ [InlineData("", "empty")]
+ [InlineData("General", "'G'")]
+ [InlineData("0;-0", "';'")]
+ [InlineData("[Red]0", "'['")]
+ [InlineData("0.00E+00", "'E'")]
+ [InlineData("# ?/?", "' '")]
+ [InlineData("@", "'@'")]
+ [InlineData("0#", "a # after a 0")]
+ [InlineData("0.#0", "a 0 after a #")]
+ [InlineData("0.", "decimal point")]
+ [InlineData("0,", "comma")]
+ [InlineData(",0", "comma")]
+ [InlineData("\"open", "quote")]
+ [InlineData("0.0000000000000000000000000000000", "30")]
+ public void RefusesANumberFormatOutsideTheSubset(string code, string named)
+ {
+ ArgumentException refused = Assert.Throws(() => NumberFormat.Parse(code));
+ Assert.Contains(named, refused.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void NumberFormatsWithTheSameCodeAreEqual() =>
+ Assert.Equal(NumberFormat.Parse("#,##0.00"), NumberFormat.Parse("#,##0.00"));
+
+ [Theory]
+ [InlineData("dd/mm/yyyy", "dd\\/mm\\/yyyy", "Day,Literal,Month,Literal,Year")]
+ [InlineData("yyyy-mm-dd hh:mm:ss", "yyyy\\-mm\\-dd\\ hh:mm:ss", "Year,Literal,Month,Literal,Day,Literal,Hour,Literal,Minute,Literal,Second")]
+ [InlineData("d.m.yy", "d\\.m\\.yy", "Day,Literal,Month,Literal,Year")]
+ [InlineData("hh:mm", "hh:mm", "Hour,Literal,Minute")]
+ [InlineData("mm:ss", "mm:ss", "Minute,Literal,Second")]
+ [InlineData("h \"h\" m", "h\\ \\h\\ m", "Hour,Literal,Minute")]
+ [InlineData("DD/MM/YYYY", "dd\\/mm\\/yyyy", "Day,Literal,Month,Literal,Year")]
+ [InlineData("yyyy\\Wdd", "yyyy\\Wdd", "Year,Literal,Day")]
+ public void ParsesADateFormat(string code, string canonical, string kinds)
+ {
+ DateFormat format = DateFormat.Parse(code);
+
+ Assert.Equal(canonical, format.Code);
+ Assert.Equal(kinds, string.Join(',', format.Parts.Select(p => p.Kind)));
+ }
+
+ [Fact]
+ public void MonthAndMinuteFollowExcelsRule()
+ {
+ DateFormat format = DateFormat.Parse("mm/dd hh:mm");
+
+ Assert.Equal(DatePartKind.Month, format.Parts[0].Kind);
+ Assert.Equal(DatePartKind.Minute, format.Parts[^1].Kind);
+ }
+
+ [Theory]
+ [InlineData("", "empty")]
+ [InlineData("mmm yyyy", "mmm")]
+ [InlineData("dddd", "dddd")]
+ [InlineData("yyy", "yyy")]
+ [InlineData("hh:mm AM/PM", "'A'")]
+ [InlineData("[h]:mm", "'['")]
+ [InlineData("hh:mm:ss.000", "'0'")]
+ [InlineData("\"only text\"", "no date or time part")]
+ [InlineData("dd\\", "backslash")]
+ [InlineData("dd\"open", "quote")]
+ public void RefusesADateFormatOutsideTheSubset(string code, string named)
+ {
+ ArgumentException refused = Assert.Throws(() => DateFormat.Parse(code));
+ Assert.Contains(named, refused.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void DateFormatsCompareByTheirCode()
+ {
+ Assert.Equal(DateFormat.Parse("dd/mm/yyyy"), DateFormat.Parse("DD/MM/YYYY"));
+ Assert.Equal(DateFormat.Parse("dd/mm/yyyy").GetHashCode(), DateFormat.Parse("DD/MM/YYYY").GetHashCode());
+ Assert.NotEqual(DateFormat.Parse("dd/mm/yyyy"), DateFormat.Parse("dd.mm.yyyy"));
+ }
+
+ [Fact]
+ public void ALiteralWithAForbiddenCharacterIsRefused() =>
+ Assert.Throws(() => NumberFormat.Parse("0\"\u0007\""));
+
+ [Fact]
+ public void AnEscapedCharacterThatIsForbiddenIsRefused() =>
+ Assert.Throws(() => DateFormat.Parse("dd\\" + "\u0007"));
+
+ [Fact]
+ public void AStyleCarriesBothFormats()
+ {
+ CellStyle style = new() { Number = NumberFormat.Parse("0.00"), Date = DateFormat.Parse("dd/mm/yyyy") };
+
+ Assert.Equal(style, new CellStyle { Number = NumberFormat.Parse("0.00"), Date = DateFormat.Parse("dd/mm/yyyy") });
+ }
+}
diff --git a/tests/TriasDev.Tabular.Tests/Writing/OdsStyleTests.cs b/tests/TriasDev.Tabular.Tests/Writing/OdsStyleTests.cs
new file mode 100644
index 0000000..ad92cd3
--- /dev/null
+++ b/tests/TriasDev.Tabular.Tests/Writing/OdsStyleTests.cs
@@ -0,0 +1,175 @@
+using System.IO.Compression;
+using System.Text;
+
+using TriasDev.Tabular.Ods;
+using TriasDev.Tabular.Tests.Fixtures;
+
+using Xunit;
+
+namespace TriasDev.Tabular.Tests.Writing;
+
+/// The common cell styles and data styles the ods writer states in styles.xml.
+public sealed class OdsStyleTests
+{
+ private static CancellationToken Token => TestContext.Current.CancellationToken;
+
+ private static async Task Spreadsheet(Action write)
+ {
+ WriteTarget target = new();
+
+ await using (TabularWriter writer = TabularWriter.Create(target, TabularFormat.Ods))
+ {
+ write(writer);
+ await writer.CompleteAsync(Token);
+ }
+
+ return target.ToArray();
+ }
+
+ private static List Rows(byte[] ods)
+ {
+ using OdsCursor cursor = new(new MemoryStream(ods, writable: false), cancellationToken: Token);
+ Assert.True(cursor.MoveToSheet(0, Token));
+
+ List rows = [];
+
+ while (cursor.ReadRow(Token))
+ {
+ rows.Add(cursor.CurrentRow.ToArray());
+ }
+
+ return rows;
+ }
+
+ private static string Entry(byte[] zip, string name)
+ {
+ using ZipArchive archive = new(new MemoryStream(zip, writable: false), ZipArchiveMode.Read);
+ using StreamReader reader = new(archive.GetEntry(name)!.Open(), Encoding.UTF8);
+ return reader.ReadToEnd();
+ }
+
+ [Fact]
+ public async Task LibreOfficeAppliesTheCommonStyle()
+ {
+ byte[] ods = await Spreadsheet(writer =>
+ {
+ StyleId red = writer.Style(new CellStyle { Fill = CellColor.FromRgb(0xF8696B), Font = new CellFont { Bold = true } });
+ writer.BeginSheet("data", [new("a")]);
+ writer.BeginRow();
+ writer.Write("hot", red);
+ writer.EndRow();
+ });
+
+ byte[] xlsx = LibreOffice.Convert(ods, "ods", "xlsx:Calc MS Excel 2007 XML", "xlsx");
+ string styles = Entry(xlsx, "xl/styles.xml");
+
+ Assert.Contains("rgb=\"FFF8696B\"", styles, StringComparison.OrdinalIgnoreCase);
+ Assert.Matches("", styles);
+ }
+
+ private static readonly CellStyle Legend = new()
+ {
+ Fill = CellColor.FromRgb(0xF8696B),
+ Font = new CellFont { Color = CellColor.FromRgb(0xFFFFFF), Bold = true, Italic = true },
+ Number = NumberFormat.Parse("#,##0.00"),
+ Date = DateFormat.Parse("dd/mm/yyyy"),
+ Horizontal = HorizontalAlignment.Center,
+ Wrap = true,
+ Border = CellBorder.Thin(CellColor.FromRgb(0xBFBFBF)),
+ };
+
+ private static void EveryKind(TabularWriter writer, StyleId style)
+ {
+ writer.BeginSheet("data", [new("text"), new("long"), new("decimal"), new("double"), new("date"), new("stamp"), new("flag"), new("none")]);
+ writer.BeginRow();
+ writer.Write("R&D", style);
+ writer.Write(1234567L, style);
+ writer.Write(1234.5m, style);
+ writer.Write(0.125, style);
+ writer.Write(new DateOnly(2026, 10, 4), style);
+ writer.Write(new DateTime(2026, 10, 4, 9, 5, 0, DateTimeKind.Unspecified), style);
+ writer.Write(true, style);
+ writer.WriteEmpty(style);
+ writer.EndRow();
+ }
+
+ [Fact]
+ public async Task StatesTheStyleAsACommonStyle()
+ {
+ byte[] ods = await Spreadsheet(writer => EveryKind(writer, writer.Style(Legend)));
+ string styles = Entry(ods, "styles.xml");
+ string content = Entry(ods, "content.xml");
+
+ Assert.Contains("", styles, StringComparison.Ordinal);
+ Assert.Contains("//", styles, StringComparison.Ordinal);
+ Assert.Contains("fo:background-color=\"#F8696B\"", styles, StringComparison.Ordinal);
+ Assert.Contains("fo:border=\"0.06pt solid #BFBFBF\"", styles, StringComparison.Ordinal);
+ Assert.Contains("fo:wrap-option=\"wrap\"", styles, StringComparison.Ordinal);
+ Assert.Contains("", styles, StringComparison.Ordinal);
+ Assert.Contains("fo:color=\"#FFFFFF\" fo:font-weight=\"bold\"", styles, StringComparison.Ordinal);
+ Assert.Contains("fo:font-style=\"italic\"", styles, StringComparison.Ordinal);
+ Assert.Contains("table:style-name=\"ts", content, StringComparison.Ordinal);
+ Assert.Contains("", content, StringComparison.Ordinal); // the styled empty cell
+ }
+
+ [Fact]
+ public async Task TheImportReadsAStyledRowAsItReadsAnUnstyledOne()
+ {
+ byte[] plain = await Spreadsheet(writer => EveryKind(writer, default));
+ byte[] styled = await Spreadsheet(writer => EveryKind(writer, writer.Style(Legend)));
+
+ Assert.Equal(Rows(plain).Select(r => r.ToArray()), Rows(styled).Select(r => r.ToArray()));
+ Assert.Equal(RawCell.FromBoolean(true), Rows(styled)[1][6]);
+ }
+
+ [Fact]
+ public async Task APercentFormatMakesAPercentageCell()
+ {
+ byte[] ods = await Spreadsheet(writer =>
+ {
+ StyleId percent = writer.Style(new CellStyle { Number = NumberFormat.Parse("0.0%") });
+ writer.BeginSheet("data", [new("p")]);
+ writer.BeginRow();
+ writer.Write(0.125, percent);
+ writer.EndRow();
+ });
+
+ Assert.Contains("office:value-type=\"percentage\" office:value=\"0.125\"", Entry(ods, "content.xml"), StringComparison.Ordinal);
+ Assert.Equal(0.125, Rows(ods)[1][0].Number);
+ }
+
+ [Fact]
+ public async Task AStyleRegisteredInTheMiddleOfTheDataIsWritten()
+ {
+ byte[] ods = await Spreadsheet(writer =>
+ {
+ writer.BeginSheet("data", [new("n")]);
+
+ for (int i = 1; i <= 1000; i++)
+ {
+ writer.BeginRow();
+ writer.Write(i, i == 1000 ? writer.Style(new CellStyle { Fill = CellColor.FromRgb(0x0000FF) }) : default);
+ writer.EndRow();
+ }
+ });
+
+ Assert.Contains("fo:background-color=\"#0000FF\"", Entry(ods, "styles.xml"), StringComparison.Ordinal);
+ Assert.Equal(1000, Rows(ods)[1000][0].Number);
+ }
+
+ [Fact]
+ public async Task LiteralTextInAFormatIsEscaped()
+ {
+ byte[] ods = await Spreadsheet(writer =>
+ {
+ StyleId style = writer.Style(new CellStyle { Number = NumberFormat.Parse("\"R&D <\"0") });
+ writer.BeginSheet("data", [new("n")]);
+ writer.BeginRow();
+ writer.Write(5L, style);
+ writer.EndRow();
+ });
+
+ Assert.Contains("R&D <", Entry(ods, "styles.xml"), StringComparison.Ordinal);
+ Assert.Equal(5, Rows(ods)[1][0].Number);
+ }
+}
diff --git a/tests/TriasDev.Tabular.Tests/Writing/StyledInteropTests.cs b/tests/TriasDev.Tabular.Tests/Writing/StyledInteropTests.cs
new file mode 100644
index 0000000..3989edd
--- /dev/null
+++ b/tests/TriasDev.Tabular.Tests/Writing/StyledInteropTests.cs
@@ -0,0 +1,86 @@
+using System.IO.Compression;
+using System.Text;
+
+using TriasDev.Tabular.Tests.Fixtures;
+
+using Xunit;
+
+namespace TriasDev.Tabular.Tests.Writing;
+
+/// LibreOffice shows our formats and keeps our fills and fonts.
+public sealed class StyledInteropTests
+{
+ private static CancellationToken Token => TestContext.Current.CancellationToken;
+
+ private static readonly CellStyle Red = new()
+ {
+ Fill = CellColor.FromRgb(0xF8696B),
+ Font = new CellFont { Bold = true },
+ };
+
+ private static async Task Write(TabularFormat format)
+ {
+ WriteTarget target = new();
+
+ await using (TabularWriter writer = TabularWriter.Create(target, format))
+ {
+ StyleId twoPlaces = writer.Style(new CellStyle { Number = NumberFormat.Parse("0.00") });
+ StyleId percent = writer.Style(new CellStyle { Number = NumberFormat.Parse("0.0%") });
+ StyleId prefixed = writer.Style(new CellStyle { Number = NumberFormat.Parse("\"EUR \"0") });
+ StyleId day = writer.Style(new CellStyle { Date = DateFormat.Parse("dd/mm/yyyy") });
+ StyleId stamp = writer.Style(new CellStyle { Date = DateFormat.Parse("dd.mm.yyyy hh:mm") });
+ StyleId red = writer.Style(Red);
+
+ writer.BeginSheet("data", [new("a"), new("b"), new("c"), new("d"), new("e"), new("f")]);
+ writer.BeginRow();
+ writer.Write(12.5, twoPlaces);
+ writer.Write(0.125, percent);
+ writer.Write(42L, prefixed);
+ writer.Write(new DateOnly(2026, 10, 4), day);
+ writer.Write(new DateTime(2026, 10, 4, 9, 5, 0, DateTimeKind.Unspecified), stamp);
+ writer.Write("hot", red);
+ writer.EndRow();
+ await writer.CompleteAsync(Token);
+ }
+
+ return target.ToArray();
+ }
+
+ private static string Entry(byte[] zip, string name)
+ {
+ using ZipArchive archive = new(new MemoryStream(zip, writable: false), ZipArchiveMode.Read);
+ using StreamReader reader = new(archive.GetEntry(name)!.Open(), Encoding.UTF8);
+ return reader.ReadToEnd();
+ }
+
+ [Theory]
+ [InlineData(TabularFormat.Xlsx, "xlsx")]
+ [InlineData(TabularFormat.Ods, "ods")]
+ public async Task ShowsTheFormats(TabularFormat format, string extension)
+ {
+ string[] lines = LibreOffice.ConvertToCsv(await Write(format), extension);
+
+ // The decimal separator is the LibreOffice profile's locale; the rest is what the codes state.
+ Assert.Matches("^\"?12[.,]50\"?,\"?12[.,]5%\"?,\"?EUR 42\"?,04/10/2026,04\\.10\\.2026 09:05,\"?hot\"?$", lines[1]);
+ }
+
+ [Fact]
+ public async Task KeepsTheFillAndFontOfAnXlsx()
+ {
+ byte[] ods = LibreOffice.Convert(await Write(TabularFormat.Xlsx), "xlsx", "ods", "ods");
+ string styles = Entry(ods, "content.xml") + Entry(ods, "styles.xml");
+
+ Assert.Contains("fo:background-color=\"#f8696b\"", styles, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("fo:font-weight=\"bold\"", styles, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task KeepsTheFillAndFontOfAnOds()
+ {
+ byte[] xlsx = LibreOffice.Convert(await Write(TabularFormat.Ods), "ods", "xlsx:Calc MS Excel 2007 XML", "xlsx");
+ string styles = Entry(xlsx, "xl/styles.xml");
+
+ Assert.Contains("rgb=\"FFF8696B\"", styles, StringComparison.OrdinalIgnoreCase);
+ Assert.Matches("", styles);
+ }
+}
diff --git a/tests/TriasDev.Tabular.Tests/Writing/StyledWriterTests.cs b/tests/TriasDev.Tabular.Tests/Writing/StyledWriterTests.cs
new file mode 100644
index 0000000..c505b64
--- /dev/null
+++ b/tests/TriasDev.Tabular.Tests/Writing/StyledWriterTests.cs
@@ -0,0 +1,183 @@
+using TriasDev.Tabular.Tests.Fixtures;
+
+using Xunit;
+
+namespace TriasDev.Tabular.Tests.Writing;
+
+/// Registering styles and writing styled cells, independent of the format.
+[Collection(AllocationMeasurementCollection.Name)]
+public sealed class StyledWriterTests
+{
+ private static readonly CellStyle Red = new() { Fill = CellColor.FromRgb(0xFF0000) };
+
+ private static CancellationToken Token => TestContext.Current.CancellationToken;
+
+ [Fact]
+ public async Task TheSameStyleGetsTheSameId()
+ {
+ await using TabularWriter writer = TabularWriter.Create(new WriteTarget(), TabularFormat.Xlsx);
+
+ StyleId first = writer.Style(Red);
+ StyleId again = writer.Style(new CellStyle { Fill = CellColor.FromRgb(0xFF0000) });
+ StyleId other = writer.Style(Red with { Wrap = true });
+
+ Assert.Equal(first, again);
+ Assert.NotEqual(first, other);
+ Assert.NotEqual(default, first);
+ }
+
+ [Fact]
+ public async Task TheStyleAfterTheLimitIsRefused()
+ {
+ await using TabularWriter writer = TabularWriter.Create(new WriteTarget(), TabularFormat.Xlsx);
+
+ for (int i = 0; i < 4096; i++)
+ {
+ writer.Style(new CellStyle { Fill = CellColor.FromRgb(i) });
+ }
+
+ writer.Style(new CellStyle { Fill = CellColor.FromRgb(0) }); // already registered: no new style
+ TabularLimitException refused = Assert.Throws(() => writer.Style(new CellStyle { Fill = CellColor.FromRgb(4096) }));
+ Assert.Contains("4096", refused.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task AnUndefinedAlignmentIsRefused()
+ {
+ await using TabularWriter writer = TabularWriter.Create(new WriteTarget(), TabularFormat.Xlsx);
+
+ Assert.Throws(() => writer.Style(new CellStyle { Horizontal = (HorizontalAlignment)9 }));
+ }
+
+ [Fact]
+ public async Task AStyleIdFromAnotherWriterIsRefused()
+ {
+ await using TabularWriter other = TabularWriter.Create(new WriteTarget(), TabularFormat.Xlsx);
+ other.Style(Red);
+ StyleId foreign = other.Style(Red with { Wrap = true }); // index 2 there
+
+ await using TabularWriter writer = TabularWriter.Create(new WriteTarget(), TabularFormat.Xlsx);
+ writer.Style(Red); // only index 1 here
+ writer.BeginSheet("data", [new("x")]);
+ writer.BeginRow();
+
+ Assert.Throws(() => writer.Write(1.5, foreign));
+ }
+
+ [Fact]
+ public async Task AStyleIdFromAnotherWriterWithTheSameIndexIsRefused()
+ {
+ await using TabularWriter other = TabularWriter.Create(new WriteTarget(), TabularFormat.Xlsx);
+ other.Style(Red);
+ StyleId foreign = other.Style(Red with { Wrap = true }); // index 2 there
+
+ await using TabularWriter writer = TabularWriter.Create(new WriteTarget(), TabularFormat.Xlsx);
+ writer.Style(Red);
+ StyleId own = writer.Style(Red with { Wrap = true }); // index 2 here too
+ writer.BeginSheet("data", [new("x")]);
+ writer.BeginRow();
+
+ Assert.NotEqual(own, foreign);
+ Assert.Throws(() => writer.Write(1.5, foreign));
+ }
+
+ [Fact]
+ public async Task TheDefaultStyleIdIsTheUnstyledCellOnEveryWriter()
+ {
+ await using TabularWriter writer = TabularWriter.Create(new WriteTarget(), TabularFormat.Xlsx);
+ writer.BeginSheet("data", [new("x")]);
+ writer.BeginRow();
+
+ writer.Write(1.5, default(StyleId));
+ writer.EndRow();
+
+ Assert.Equal(default, default(StyleId));
+ }
+
+ [Fact]
+ public async Task CsvIgnoresStyles()
+ {
+ async Task Write(bool styled)
+ {
+ WriteTarget target = new();
+
+ await using (TabularWriter writer = TabularWriter.Create(target, TabularFormat.Csv))
+ {
+ StyleId style = styled ? writer.Style(Red with { Number = NumberFormat.Parse("0.00"), Date = DateFormat.Parse("dd/mm/yyyy") }) : default;
+ writer.BeginSheet("data", [new("text"), new("long"), new("decimal"), new("double"), new("date"), new("day"), new("flag"), new("none")]);
+ writer.BeginRow();
+ writer.Write("a", style);
+ writer.Write(12L, style);
+ writer.Write(1.25m, style);
+ writer.Write(2.5, style);
+ writer.Write(new DateTime(2026, 10, 4, 9, 5, 0, DateTimeKind.Unspecified), style);
+ writer.Write(new DateOnly(2026, 10, 4), style);
+ writer.Write(true, style);
+ writer.WriteEmpty(style);
+ writer.EndRow();
+ await writer.CompleteAsync(Token);
+ }
+
+ return target.ToArray();
+ }
+
+ Assert.Equal(await Write(styled: false), await Write(styled: true));
+ }
+
+ [Fact]
+ public async Task ANullStyleIsRefused()
+ {
+ await using TabularWriter writer = TabularWriter.Create(new WriteTarget(), TabularFormat.Xlsx);
+
+ Assert.Throws(() => writer.Style(null!));
+ }
+
+ [Theory]
+ [InlineData(TabularFormat.Csv)]
+ [InlineData(TabularFormat.Xlsx)]
+ [InlineData(TabularFormat.Ods)]
+ public async Task AllocatesNothingPerStyledCell(TabularFormat format)
+ {
+ CellStyle[] palette = [.. Enumerable.Range(0, 8).Select(i => new CellStyle { Fill = CellColor.FromRgb(i * 0x101010), Number = NumberFormat.Parse("0.00"), Date = DateFormat.Parse("dd/mm/yyyy") })];
+
+ async ValueTask Allocated(int rows)
+ {
+ long before = GC.GetAllocatedBytesForCurrentThread();
+
+ await using (TabularWriter writer = TabularWriter.Create(Stream.Null, format, new TabularWriterOptions { LeaveOpen = true }))
+ {
+ StyleId[] styles = [.. palette.Select(writer.Style)];
+ writer.BeginSheet("data", [new("text"), new("number"), new("integer"), new("date"), new("flag")]);
+
+ for (int i = 0; i < rows; i++)
+ {
+ StyleId style = styles[i % styles.Length];
+ writer.BeginRow();
+ writer.Write("text", style);
+ writer.Write(i * 0.5, style);
+ writer.Write((long)i, style);
+ writer.Write(new DateOnly(2026, 10, 4), style);
+ writer.Write(i % 2 == 0, style);
+ writer.EndRow();
+
+ if (writer.FlushRecommended)
+ {
+ await writer.FlushAsync(Token);
+ }
+ }
+
+ await writer.CompleteAsync(Token);
+ }
+
+ return GC.GetAllocatedBytesForCurrentThread() - before;
+ }
+
+ // Warm-up at the full size: static state, JIT, and the array pool reaching the 1 MB working set one flush holds.
+ await Allocated(100_000);
+ long tenThousand = await Allocated(10_000);
+ long hundredThousand = await Allocated(100_000);
+
+ // 90,000 more rows of five styled cells: what grows with them is under a byte a row.
+ Assert.True(hundredThousand - tenThousand < 90_000, $"{format}: {tenThousand:N0} bytes for 10k rows, {hundredThousand:N0} for 100k");
+ }
+}
diff --git a/tests/TriasDev.Tabular.Tests/Writing/TabularExportRoundTripTests.cs b/tests/TriasDev.Tabular.Tests/Writing/TabularExportRoundTripTests.cs
index b16580b..9d5d5b4 100644
--- a/tests/TriasDev.Tabular.Tests/Writing/TabularExportRoundTripTests.cs
+++ b/tests/TriasDev.Tabular.Tests/Writing/TabularExportRoundTripTests.cs
@@ -5,6 +5,7 @@
namespace TriasDev.Tabular.Tests.Writing;
/// An export declared with the import's fields writes files the import maps back by header.
+[Collection(AllocationMeasurementCollection.Name)]
public sealed class TabularExportRoundTripTests
{
private static readonly IntegerImportField IdField = ImportField.Integer("Id");
diff --git a/tests/TriasDev.Tabular.Tests/Writing/TextRulesTests.cs b/tests/TriasDev.Tabular.Tests/Writing/TextRulesTests.cs
index 38f3b08..c9db46f 100644
--- a/tests/TriasDev.Tabular.Tests/Writing/TextRulesTests.cs
+++ b/tests/TriasDev.Tabular.Tests/Writing/TextRulesTests.cs
@@ -4,6 +4,7 @@ namespace TriasDev.Tabular.Tests.Writing;
/// The characters no format may carry, checked on every text cell.
/// Surrogates are built in code: a lone one in attribute data does not survive the test framework.
+[Collection(AllocationMeasurementCollection.Name)]
public sealed class TextRulesTests
{
[Theory]
diff --git a/tests/TriasDev.Tabular.Tests/Writing/XlsxStyleTests.cs b/tests/TriasDev.Tabular.Tests/Writing/XlsxStyleTests.cs
new file mode 100644
index 0000000..b8b4292
--- /dev/null
+++ b/tests/TriasDev.Tabular.Tests/Writing/XlsxStyleTests.cs
@@ -0,0 +1,251 @@
+using System.IO.Compression;
+using System.Text;
+
+using TriasDev.Tabular.Tests.Fixtures;
+using TriasDev.Tabular.Xlsx;
+
+using Xunit;
+
+namespace TriasDev.Tabular.Tests.Writing;
+
+/// Styles in the workbook: valid to the schema, deduplicated, and invisible to the import.
+public sealed class XlsxStyleTests
+{
+ private static readonly CellStyle Legend = new()
+ {
+ Fill = CellColor.FromRgb(0xF8696B),
+ Font = new CellFont { Color = CellColor.FromRgb(0xFFFFFF), Bold = true, Italic = true },
+ Number = NumberFormat.Parse("#,##0.00"),
+ Date = DateFormat.Parse("dd/mm/yyyy"),
+ Horizontal = HorizontalAlignment.Center,
+ Wrap = true,
+ Border = CellBorder.Thin(CellColor.FromRgb(0xBFBFBF)),
+ };
+
+ private static CancellationToken Token => TestContext.Current.CancellationToken;
+
+ private static async Task Workbook(Action write)
+ {
+ WriteTarget target = new();
+
+ await using (TabularWriter writer = TabularWriter.Create(target, TabularFormat.Xlsx))
+ {
+ write(writer);
+ await writer.CompleteAsync(Token);
+ }
+
+ return target.ToArray();
+ }
+
+ private static string Part(byte[] xlsx, string name)
+ {
+ using ZipArchive archive = new(new MemoryStream(xlsx, writable: false), ZipArchiveMode.Read);
+ using StreamReader reader = new(archive.GetEntry(name)!.Open(), Encoding.UTF8);
+ return reader.ReadToEnd();
+ }
+
+ private static List Rows(byte[] xlsx)
+ {
+ using XlsxCursor cursor = new(new MemoryStream(xlsx, writable: false), cancellationToken: Token);
+ Assert.True(cursor.MoveToSheet(0, Token));
+ List rows = [];
+
+ while (cursor.ReadRow(Token))
+ {
+ rows.Add(cursor.CurrentRow.ToArray());
+ }
+
+ return rows;
+ }
+
+ /// One row of every kind, all in one style (or unstyled).
+ private static void EveryKind(TabularWriter writer, StyleId style)
+ {
+ writer.BeginSheet("data", [new("text"), new("long"), new("decimal"), new("double"), new("date"), new("stamp"), new("flag"), new("none")]);
+ writer.BeginRow();
+ writer.Write("R&D", style);
+ writer.Write(1234567L, style);
+ writer.Write(1234.5m, style);
+ writer.Write(0.125, style);
+ writer.Write(new DateOnly(2026, 10, 4), style);
+ writer.Write(new DateTime(2026, 10, 4, 9, 5, 0, DateTimeKind.Unspecified), style);
+ writer.Write(true, style);
+ writer.WriteEmpty(style);
+ writer.EndRow();
+ }
+
+ [Fact]
+ public async Task AStyledWorkbookIsValidAndStatesTheStyle()
+ {
+ byte[] xlsx = await Workbook(writer => EveryKind(writer, writer.Style(Legend)));
+
+ Assert.Empty(OoxmlValidation.Errors(xlsx));
+
+ string styles = Part(xlsx, "xl/styles.xml");
+ Assert.Contains("", styles, StringComparison.Ordinal);
+ Assert.Contains("", styles, StringComparison.Ordinal);
+ Assert.Contains("", styles, StringComparison.Ordinal);
+ Assert.Contains("", styles, StringComparison.Ordinal);
+ Assert.Contains("", styles, StringComparison.Ordinal);
+ Assert.Contains("", styles, StringComparison.Ordinal);
+ Assert.Contains("", styles, StringComparison.Ordinal);
+
+ string sheet = Part(xlsx, "xl/worksheets/sheet1.xml");
+ Assert.Contains(" EveryKind(writer, default));
+ byte[] styled = await Workbook(writer => EveryKind(writer, writer.Style(Legend)));
+
+ // The unstyled empty cell is left out; the styled one is written, and reads as an empty cell at the end of the row.
+ Assert.Equal(Rows(plain)[0], Rows(styled)[0]);
+ Assert.Equal(Rows(plain)[1], Rows(styled)[1].Take(7));
+ Assert.Equal(8, Rows(styled)[1].Length);
+ Assert.Equal(RawCell.FromDate(new DateTime(2026, 10, 4, 0, 0, 0, DateTimeKind.Unspecified)), Rows(styled)[1][4]);
+ Assert.Equal(RawCell.FromBoolean(true), Rows(styled)[1][6]);
+ Assert.True(Rows(styled)[1][7].IsEmpty);
+ }
+
+ [Fact]
+ public async Task OneStyleOnSeveralKindsTakesAFormatPerKind()
+ {
+ byte[] xlsx = await Workbook(writer => EveryKind(writer, writer.Style(Legend)));
+ string styles = Part(xlsx, "xl/styles.xml");
+
+ // text/flag/none share one xf (General), long/decimal/double share the number format, date and stamp the date format.
+ Assert.Contains("", styles, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task EqualStylesAreWrittenOnce()
+ {
+ byte[] xlsx = await Workbook(writer =>
+ {
+ StyleId a = writer.Style(new CellStyle { Fill = CellColor.FromRgb(0x00FF00) });
+ StyleId b = writer.Style(new CellStyle { Fill = CellColor.FromRgb(0x00FF00) });
+ StyleId c = writer.Style(new CellStyle { Fill = CellColor.FromRgb(0x00FF00), Wrap = true });
+ writer.BeginSheet("data", [new("a"), new("b"), new("c")]);
+ writer.BeginRow();
+ writer.Write("x", a);
+ writer.Write("y", b);
+ writer.Write("z", c);
+ writer.EndRow();
+ });
+
+ string styles = Part(xlsx, "xl/styles.xml");
+ Assert.Contains("", styles, StringComparison.Ordinal); // none, gray125, one green
+ Assert.Contains("", styles, StringComparison.Ordinal); // four fixed + two
+ }
+
+ [Fact]
+ public async Task AStyleRegisteredInTheMiddleOfTheDataIsWritten()
+ {
+ byte[] xlsx = await Workbook(writer =>
+ {
+ writer.BeginSheet("data", [new("n")]);
+
+ for (int i = 1; i <= 1000; i++)
+ {
+ writer.BeginRow();
+ writer.Write(i, i == 1000 ? writer.Style(new CellStyle { Fill = CellColor.FromRgb(0x0000FF) }) : default);
+ writer.EndRow();
+ }
+ });
+
+ Assert.Empty(OoxmlValidation.Errors(xlsx));
+ Assert.Contains("", Part(xlsx, "xl/styles.xml"), StringComparison.Ordinal);
+ Assert.Contains("1000", Part(xlsx, "xl/worksheets/sheet1.xml"), StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task LiteralTextInAFormatIsEscaped()
+ {
+ byte[] xlsx = await Workbook(writer =>
+ {
+ StyleId style = writer.Style(new CellStyle { Number = NumberFormat.Parse("\"R&D <\"0") });
+ writer.BeginSheet("data", [new("n")]);
+ writer.BeginRow();
+ writer.Write(5L, style);
+ writer.EndRow();
+ });
+
+ Assert.Empty(OoxmlValidation.Errors(xlsx));
+ Assert.Contains("formatCode=\""R&D <"0\"", Part(xlsx, "xl/styles.xml"), StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData("\"x\\\"0\" days\"")]
+ [InlineData("\"d m y h s\"0")]
+ [InlineData("0\" dd mm yyyy hh ss\"")]
+ [InlineData("\"\\d\"0\"\\y\"")]
+ [InlineData("\"[\"0\"]\"")]
+ [InlineData("\"[d]\"0")]
+ [InlineData("\"%\"0.0")]
+ [InlineData("0.0%\" d\\\"")]
+ [InlineData("\"\\\"#,##0\" s\"")]
+ public async Task EveryAcceptedNumberFormatShapeReadsBackAsTheNumber(string code)
+ {
+ byte[] xlsx = await Workbook(writer =>
+ {
+ StyleId style = writer.Style(new CellStyle { Number = NumberFormat.Parse(code) });
+ writer.BeginSheet("data", [new("a"), new("b"), new("c")]);
+ writer.BeginRow();
+ writer.Write(42.0, style);
+ writer.Write(42L, style);
+ writer.Write(42m, style);
+ writer.EndRow();
+ });
+
+ RawCell[] row = Rows(xlsx)[1];
+
+ Assert.All(row, cell => Assert.Equal(RawCell.FromNumber(42), cell));
+ }
+
+ [Fact]
+ public async Task AnEmojiLiteralInADateFormatSurvivesIntact()
+ {
+ byte[] xlsx = await Workbook(writer =>
+ {
+ StyleId style = writer.Style(new CellStyle { Date = DateFormat.Parse("dd \"\U0001F4C5\" yyyy") });
+ writer.BeginSheet("data", [new("d")]);
+ writer.BeginRow();
+ writer.Write(new DateOnly(2026, 10, 4), style);
+ writer.EndRow();
+ });
+
+ Assert.Empty(OoxmlValidation.Errors(xlsx));
+ Assert.Contains("\U0001F4C5", Part(xlsx, "xl/styles.xml"), StringComparison.Ordinal);
+ Assert.DoesNotContain('\uFFFD', Part(xlsx, "xl/styles.xml"));
+ Assert.Equal(RawCell.FromDate(new DateTime(2026, 10, 4, 0, 0, 0, DateTimeKind.Unspecified)), Rows(xlsx)[1][0]);
+ }
+
+ [Fact]
+ public async Task ATabInALiteralIsWrittenAsACharacterReference()
+ {
+ byte[] xlsx = await Workbook(writer =>
+ {
+ StyleId style = writer.Style(new CellStyle { Number = NumberFormat.Parse("\"a\tb\"0") });
+ writer.BeginSheet("data", [new("n")]);
+ writer.BeginRow();
+ writer.Write(5L, style);
+ writer.EndRow();
+ });
+
+ string styles = Part(xlsx, "xl/styles.xml");
+
+ Assert.Contains("a b", styles, StringComparison.Ordinal);
+ Assert.DoesNotContain('\t', styles);
+ }
+
+ [Fact]
+ public async Task AnUnstyledWorkbookKeepsItsFourFormats()
+ {
+ byte[] xlsx = await Workbook(writer => EveryKind(writer, default));
+
+ Assert.Contains("", Part(xlsx, "xl/styles.xml"), StringComparison.Ordinal);
+ Assert.Empty(OoxmlValidation.Errors(xlsx));
+ }
+}
diff --git a/tests/TriasDev.Tabular.Tests/Xlsx/CustomDateFormatTests.cs b/tests/TriasDev.Tabular.Tests/Xlsx/CustomDateFormatTests.cs
index ab2d828..62cba97 100644
--- a/tests/TriasDev.Tabular.Tests/Xlsx/CustomDateFormatTests.cs
+++ b/tests/TriasDev.Tabular.Tests/Xlsx/CustomDateFormatTests.cs
@@ -37,6 +37,7 @@ private static RawCellKind KindUnder(string formatCode)
[InlineData("[h]:mm:ss")] // elapsed time: the bracket is skipped, :mm:ss is not
[InlineData("mm:ss.0")]
[InlineData("YYYY-MM-DD")] // upper case, as some producers write it
+ [InlineData("\"[\" yyyy")] // a bracket inside quotes opens no section, so the year after it counts
[InlineData("dd\\.mm\\.yyyy")] // escaped separators around real tokens
[InlineData("[$-411]ggge\"年\"m\"月\"d\"日\"")] // a Japanese era date: quoted CJK text between tokens
public void ReadsAFormatWithDateTokensAsADate(string formatCode)
@@ -48,6 +49,7 @@ public void ReadsAFormatWithDateTokensAsADate(string formatCode)
[InlineData("0.00")]
[InlineData("#,##0 \"Dm\"")] // a currency word in quotes, with a d in it
[InlineData("0 \"days\"")]
+ [InlineData("\"x\\\"0\" days\"")] // a backslash inside a quoted literal does not escape the closing quote
[InlineData("\\d0")] // an escaped d is a literal character
[InlineData("[Red]0.00")]
[InlineData("#,##0_);[Red](#,##0)")]