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)")]