diff --git a/docs/KNOWN-ISSUES.md b/docs/KNOWN-ISSUES.md index 57c6f52..4bd7a83 100644 --- a/docs/KNOWN-ISSUES.md +++ b/docs/KNOWN-ISSUES.md @@ -208,3 +208,10 @@ TabularExport picks a column type by overload resolution, and some lambdas do no - 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. + +### Sheet layout + +- `SheetOptions` sets a header style, frozen rows and columns, and an auto-filter on the header row through the last row written; csv ignores it. +- `writer.Merge(rows, columns)` makes the next cell the top-left of a merged range. The writer skips the covered positions — the row's next write lands after the range, later rows skip it too — and writes them itself. The import reads a merged range as its value in the top-left cell and empty cells elsewhere; csv writes exactly that. +- A merge must end inside the sheet: ending a sheet (or the file) while a range still has rows to cover is refused. +- xlsx holds at most 65,536 merged ranges per sheet. diff --git a/docs/superpowers/plans/2026-10-04-styles-part-2-sheet-layout.md b/docs/superpowers/plans/2026-10-04-styles-part-2-sheet-layout.md new file mode 100644 index 0000000..174b111 --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-styles-part-2-sheet-layout.md @@ -0,0 +1,1353 @@ +# Styled export, part 2 — sheet layout 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:** A sheet can have a styled header row, frozen rows and columns, an auto-filter on its header, and merged cells; ods writes runs of empty cells as one repeated cell. + +**Architecture:** `BeginSheet(name, columns, SheetOptions?)` carries the header style, freeze and filter; the format writers state them where their file format wants them (xlsx `sheetViews` before the data, `autoFilter` and a defined name after; ods `settings.xml` and `table:database-ranges` at the end). `writer.Merge(rows, columns)` declares that the next cell is the top-left of a merged range; `TabularWriter` keeps one covered interval per column, skips covered positions automatically and tells the format writer to write each one (`WriteCovered`), so merges cost nothing per cell when a sheet has none. + +**Tech Stack:** C# / .NET (net8.0 + net10.0), BCL only; xunit v3 on Microsoft.Testing.Platform; OpenXml validator (tests); LibreOffice headless (interop tests). + +**Spec:** `docs/superpowers/specs/2026-10-04-styled-columnar-export-design.md` §2 (part 1, §1, is merged: `CellStyle`, `StyleId`, `StyleTable`, `XlsxStyles`, `OdsStyles`). Issue #87. + +## Global Constraints + +- One package, no dependencies; public types in namespace `TriasDev.Tabular` under `src/TriasDev.Tabular/Writing/`; every public member in `src/TriasDev.Tabular/PublicAPI.Unshipped.txt` (copy the RS0016 lines). +- Analyzers are warnings-as-errors; `dotnet format --verify-no-changes` must pass. +- Nothing allocated per cell; a sheet without merges pays at most one null check per cell for them. +- `Merge(rows, columns)`: rows ≥ 1, columns ≥ 1, not 1 × 1 → otherwise `ArgumentOutOfRangeException`; a second `Merge` before a cell, a `Merge` no cell followed (at `EndRow`), a range overlapping another or reaching past the sheet's columns (at the top-left cell's write), a sheet ended while a range still has rows to cover (at `BeginSheet` / `CompleteAsync`) → `InvalidOperationException`. Every refusal faults the writer, as every writer refusal does today. +- Covered positions are skipped automatically: after the top-left cell the next write lands at column + `columns`; in later rows, covered positions are written by the writer when the row reaches them, and by `EndRow` for the rest of the row. +- xlsx: at most **65,536** merges per sheet → `TabularLimitException` (limit name `"MaxMerges"`); `` after `` after ``; freeze is `` before ``; the filter also gets the hidden defined name `_xlnm._FilterDatabase` (`localSheetId` = zero-based sheet index) in `workbook.xml`. +- ods: spans are `table:number-columns-spanned` / `table:number-rows-spanned` on the top-left cell and `` for covered positions; freeze in `settings.xml` (listed in the manifest only when written); filters as `table:database-ranges` after the last table in `content.xml`; a run of unstyled empty cells is one ``. +- csv: header style, freeze, filter ignored; a merge writes its value in the top-left cell and empty fields for covered positions. +- `SheetOptions`: `FreezeRows` 0 … (format's max rows − 1), `FreezeColumns` 0 … the sheet's column count, otherwise `ArgumentOutOfRangeException` from `BeginSheet`. `HeaderStyle` is registered like any style (counts toward the 4096). +- The repository is public: no product or customer names anywhere. + +Commands: build `dotnet build -c Release`; one class `dotnet test --project tests/TriasDev.Tabular.Tests -c Release -- --filter-class "*Name"`; full suite `TABULAR_REQUIRE_SOFFICE=1 dotnet test -c Release`; format `dotnet format --verify-no-changes`. + +## Review Focus + +1. A vertical merge in a column the caller never reaches in the rows below (the row ends early with `EndRow`): the covered cells are still written, the file valid, the import reads empties there. (Task 3, Task 4, Task 5) +2. A merge reaching past the last row written: `CompleteAsync` and the next `BeginSheet` refuse instead of writing a broken file. (Task 3) +3. A sheet name with an apostrophe and a space under an auto-filter: the xlsx defined name and the ods range address quote it correctly and the file opens in LibreOffice with the filter. (Task 2) +4. A merged range whose top-left cell is styled with a fill: the import reads the value once and empties elsewhere; LibreOffice keeps the merge. (Task 4, Task 5) +5. Two sheets with different freeze and filter settings: each sheet keeps its own. (Task 1, Task 2) + +--- + +### Task 1: `SheetOptions` — header style and freeze panes + +**Files:** +- Create: `src/TriasDev.Tabular/Writing/SheetOptions.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/Ods/OdsParts.cs`, `PublicAPI.Unshipped.txt` +- Test: `tests/TriasDev.Tabular.Tests/Writing/SheetLayoutTests.cs` + +**Interfaces:** +- Consumes: `StyleTable.Add(CellStyle)` (returns the index), `XlsxStyles.Xf(int, ValueKind)`, `OdsStyles.Cell(int, ValueKind)`, `ValueKind.Text`. +- Produces: + - `public sealed record SheetOptions { CellStyle? HeaderStyle; int FreezeRows; int FreezeColumns; bool AutoFilter; }` (all `init`; `AutoFilter` is used by Task 2). + - `TabularWriter.BeginSheet(string name, ReadOnlySpan columns, SheetOptions? options)`; the old two-argument overload calls it with `null`. + - `ISheetWriter.BeginSheet(string name, ReadOnlySpan columns, SheetOptions options)` (never null: the writer passes `SheetOptions.Default`, an internal static instance) and `ISheetWriter.WriteHeader(string value, int style)`. + - `OdsParts.Manifest(bool settings)` (method replacing the `Manifest` bytes) and `OdsParts.Settings(IReadOnlyList<(string Name, int Rows, int Columns)> frozen)`. + +- [ ] **Step 1: Write the failing tests** + +```csharp +using System.IO.Compression; +using System.Text; + +using TriasDev.Tabular.Tests.Fixtures; + +using Xunit; + +namespace TriasDev.Tabular.Tests.Writing; + +/// How a sheet is laid out: header style, frozen panes, filter, merges. +public sealed class SheetLayoutTests +{ + private static readonly CellStyle Header = new() { Fill = CellColor.FromRgb(0x1F4E78), Font = new CellFont { Color = CellColor.FromRgb(0xFFFFFF), Bold = true } }; + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + internal static async Task Write(TabularFormat format, Action write) + { + WriteTarget target = new(); + + await using (TabularWriter writer = TabularWriter.Create(target, format)) + { + write(writer); + await writer.CompleteAsync(Token); + } + + return target.ToArray(); + } + + internal static string Entry(byte[] zip, string name) + { + using ZipArchive archive = new(new MemoryStream(zip, writable: false), ZipArchiveMode.Read); + ZipArchiveEntry? entry = archive.GetEntry(name); + Assert.NotNull(entry); + using StreamReader reader = new(entry.Open(), Encoding.UTF8); + return reader.ReadToEnd(); + } + + internal static bool HasEntry(byte[] zip, string name) + { + using ZipArchive archive = new(new MemoryStream(zip, writable: false), ZipArchiveMode.Read); + return archive.GetEntry(name) is not null; + } + + internal static List Rows(byte[] file, int sheet = 0) + { + using ITabularCursor cursor = TabularFile.Open(new MemoryStream(file, writable: false), "file", cancellationToken: Token); + Assert.True(cursor.MoveToSheet(sheet, Token)); + List rows = []; + + while (cursor.ReadRow(Token)) + { + rows.Add(cursor.CurrentRow.ToArray()); + } + + return rows; + } + + private static void Frozen(TabularWriter writer) + { + writer.BeginSheet("data", [new("a"), new("b"), new("c")], new SheetOptions { HeaderStyle = Header, FreezeRows = 1, FreezeColumns = 1 }); + writer.BeginRow(); + writer.Write("x"); + writer.Write(1L); + writer.Write(2L); + writer.EndRow(); + writer.BeginSheet("rows only", [new("a")], new SheetOptions { FreezeRows = 2 }); + writer.BeginSheet("plain", [new("a")]); + } + + [Fact] + public async Task XlsxFreezesEachSheetAsAsked() + { + byte[] xlsx = await Write(TabularFormat.Xlsx, Frozen); + + Assert.Empty(OoxmlValidation.Errors(xlsx)); + Assert.Contains("", Entry(xlsx, "xl/worksheets/sheet1.xml"), StringComparison.Ordinal); + Assert.Contains("", Entry(xlsx, "xl/worksheets/sheet2.xml"), StringComparison.Ordinal); + Assert.DoesNotContain("", Entry(xlsx, "xl/worksheets/sheet3.xml"), StringComparison.Ordinal); + } + + [Fact] + public async Task OdsFreezesEachSheetInItsSettings() + { + byte[] ods = await Write(TabularFormat.Ods, Frozen); + string settings = Entry(ods, "settings.xml"); + + Assert.Contains("2211", settings, StringComparison.Ordinal); + Assert.Contains("config:name=\"rows only\">02", settings, StringComparison.Ordinal); + Assert.DoesNotContain("config:name=\"plain\"", settings, StringComparison.Ordinal); + Assert.Contains("manifest:full-path=\"settings.xml\"", Entry(ods, "META-INF/manifest.xml"), StringComparison.Ordinal); + } + + [Fact] + public async Task OdsWithoutFrozenPanesHasNoSettings() + { + byte[] ods = await Write(TabularFormat.Ods, writer => writer.BeginSheet("plain", [new("a")])); + + Assert.False(HasEntry(ods, "settings.xml")); + Assert.DoesNotContain("settings.xml", Entry(ods, "META-INF/manifest.xml"), StringComparison.Ordinal); + } + + [Theory] + [InlineData(TabularFormat.Xlsx)] + [InlineData(TabularFormat.Ods)] + [InlineData(TabularFormat.Csv)] + public async Task AStyledHeaderReadsBackAsTheSameHeader(TabularFormat format) + { + byte[] styled = await Write(format, writer => + { + writer.BeginSheet("data", [new("Name"), new("Count")], new SheetOptions { HeaderStyle = Header }); + writer.BeginRow(); + writer.Write("x"); + writer.Write(1L); + writer.EndRow(); + }); + + List rows = Rows(styled); + Assert.Equal([RawCell.FromText("Name"), RawCell.FromText("Count")], rows[0]); + Assert.Equal(RawCell.FromNumber(1), rows[1][1]); + } + + [Fact] + public async Task TheXlsxHeaderCarriesItsStyle() + { + byte[] xlsx = await Write(TabularFormat.Xlsx, writer => writer.BeginSheet("data", [new("Name")], new SheetOptions { HeaderStyle = Header })); + + Assert.Contains("", Entry(xlsx, "xl/worksheets/sheet1.xml"), StringComparison.Ordinal); + Assert.Contains("", Entry(xlsx, "xl/styles.xml"), StringComparison.Ordinal); + } + + [Fact] + public async Task TheOdsHeaderCarriesItsStyle() + { + byte[] ods = await Write(TabularFormat.Ods, writer => writer.BeginSheet("data", [new("Name")], new SheetOptions { HeaderStyle = Header })); + + Assert.Contains("", Entry(ods, "content.xml"), StringComparison.Ordinal); + Assert.Contains("fo:background-color=\"#1F4E78\"", Entry(ods, "styles.xml"), StringComparison.Ordinal); + } + + [Theory] + [InlineData(-1, 0)] + [InlineData(0, -1)] + [InlineData(0, 4)] + [InlineData(1_048_576, 0)] + public async Task AFreezeOutsideTheSheetIsRefused(int rows, int columns) + { + await using TabularWriter writer = TabularWriter.Create(new WriteTarget(), TabularFormat.Xlsx); + + Assert.Throws(() => writer.BeginSheet("data", [new("a"), new("b"), new("c")], new SheetOptions { FreezeRows = rows, FreezeColumns = columns })); + } + + [Fact] + public async Task CsvIgnoresTheLayout() + { + byte[] plain = await Write(TabularFormat.Csv, writer => writer.BeginSheet("data", [new("a"), new("b")])); + byte[] laidOut = await Write(TabularFormat.Csv, writer => writer.BeginSheet("data", [new("a"), new("b")], new SheetOptions { HeaderStyle = Header, FreezeRows = 1, FreezeColumns = 1, AutoFilter = true })); + + Assert.Equal(plain, laidOut); + } +} +``` + +`[InlineData(0, 4)]`: four frozen columns on a three-column sheet is refused (`FreezeColumns` may equal the column count, not exceed it). `[InlineData(1_048_576, 0)]`: xlsx holds 1,048,576 rows, so at most 1,048,575 can be frozen. + +The xlsx header assertion expects `s="4"`: the header style is the first registered, on a text cell, and the four fixed cell formats take 0–3. + +- [ ] **Step 2: Run to verify it fails** + +Run: `dotnet build -c Release` → compile errors (`SheetOptions` missing). + +- [ ] **Step 3: Implement `SheetOptions`** + +```csharp +namespace TriasDev.Tabular; + +/// +/// How a sheet is laid out beyond its values: a style for its header row, rows and columns frozen +/// in view, an auto-filter on its header. Csv ignores all of it. +/// +public sealed record SheetOptions +{ + /// The options a sheet has when none are given. + internal static readonly SheetOptions Default = new(); + + /// The header row's style; null leaves it unstyled. + public CellStyle? HeaderStyle { get; init; } + + /// How many rows from the top stay in view while scrolling — 1 keeps the header. 0 freezes none. + public int FreezeRows { get; init; } + + /// How many columns from the left stay in view while scrolling. 0 freezes none. + public int FreezeColumns { get; init; } + + /// An auto-filter on the header row, covering every row written. + public bool AutoFilter { get; init; } +} +``` + +- [ ] **Step 4: Wire `TabularWriter.BeginSheet`** + +Replace the two-argument `BeginSheet` with a forwarder and move the body into the new overload: + +```csharp + /// Begins a sheet and writes its header row. + /// (keep the existing docs for name and columns) + public void BeginSheet(string name, ReadOnlySpan columns) => BeginSheet(name, columns, null); + + /// Begins a sheet laid out by and writes its header row. + /// (as above) + /// (as above) + /// The header style, frozen rows and columns, and filter; null for none. + /// A freeze outside the sheet: rows from 0 to the format's row limit less one, columns from 0 to the column count. + public void BeginSheet(string name, ReadOnlySpan columns, SheetOptions? options) +``` + +In the body, after `CheckColumns(columns);`: + +```csharp + SheetOptions layout = options ?? SheetOptions.Default; + + if (layout.FreezeRows < 0 || layout.FreezeRows >= _sheet.MaxRows) + { + throw Faulting(new ArgumentOutOfRangeException(nameof(options), layout.FreezeRows, $"A sheet freezes 0 to {_sheet.MaxRows - 1} rows.")); + } + + if (layout.FreezeColumns < 0 || layout.FreezeColumns > columns.Length) + { + throw Faulting(new ArgumentOutOfRangeException(nameof(options), layout.FreezeColumns, $"A sheet of {columns.Length} columns freezes 0 to {columns.Length} of them.")); + } + + int headerStyle = layout.HeaderStyle is { } header ? RegisterStyle(header) : 0; +``` + +`RegisterStyle` is the body of `Style()` (the `_styles.Add` call with its fault-on-refusal `try/catch`), extracted so both use it; `Style()` becomes `ExpectWritable(); return new StyleId(_stamp, RegisterStyle(style));`. + +Then `_sheet.BeginSheet(name, _columns, layout);` and the header loop calls `_sheet.WriteHeader(header, headerStyle)`. + +- [ ] **Step 5: Format writers** + +`ISheetWriter`: `void BeginSheet(string name, ReadOnlySpan columns, SheetOptions options);` and `string? WriteHeader(string value, int style);` (doc: "options is never null; csv ignores it"; "style: the header row's style index, 0 for none"). + +Csv: add the parameters, ignore them. + +Xlsx (`XlsxSheetWriter`): +- `WriteHeader(string value, int style) => WriteInline(value, style == 0 ? 0 : _styles.Xf(style, ValueKind.Text));` +- In `BeginSheet`, between `_row.Append(XlsxParts.WorksheetStart);` and `AppendWidths(columns);`, call `AppendFreeze(options.FreezeRows, options.FreezeColumns);`: + +```csharp + /// Freezes the top rows and left columns: a split pane, in the state Excel writes for "Freeze Panes". + private void AppendFreeze(int rows, int columns) + { + if (rows == 0 && columns == 0) + { + return; + } + + string pane = (rows, columns) switch + { + (> 0, > 0) => "bottomRight", + (> 0, _) => "bottomLeft", + _ => "topRight", + }; + + _row.Append(" 0) + { + _row.Append(" xSplit=\""); + _row.AppendFormatted(columns, default, CultureInfo.InvariantCulture); + _row.Append('"'); + } + + if (rows > 0) + { + _row.Append(" ySplit=\""); + _row.AppendFormatted(rows, default, CultureInfo.InvariantCulture); + _row.Append('"'); + } + + _row.Append(" topLeftCell=\""); + _row.Append(XlsxParts.ColumnName(columns)); + _row.AppendFormatted(rows + 1, default, CultureInfo.InvariantCulture); + _row.Append("\" activePane=\""); + _row.Append(pane); + _row.Append("\" state=\"frozen\"/>"); + } +``` + + `XlsxParts.ColumnName(columns)` when `columns` equals the sheet's column count and that is 16,384 would be out of range: `ColumnName` accepts 0–16,383. Clamp: `XlsxParts.ColumnName(Math.Min(columns, 16_383))` — a pane whose top-left cell is the last column is what Excel writes for that case. + +Ods (`OdsSheetWriter`): +- `WriteHeader(string value, int style) => WriteString(value, style);` +- A field `private readonly List<(string Name, int Rows, int Columns)> _frozen = [];`; in `BeginSheet`, when `options.FreezeRows > 0 || options.FreezeColumns > 0`, add `(name, options.FreezeRows, options.FreezeColumns)`. +- `Complete()`: write `settings.xml` (stored) when `_frozen.Count > 0`: `_zip.AddStored("settings.xml", OdsParts.Settings(_frozen));`, and the manifest as `OdsParts.Manifest(settings: _frozen.Count > 0)`. + +`OdsParts`: replace the `Manifest` bytes with + +```csharp + public static byte[] Manifest(bool settings) => Encoding.UTF8.GetBytes( + XmlDeclaration + + "" + + "" + + "" + + "" + + (settings ? "" : string.Empty) + + ""); +``` + +and add + +```csharp + /// + /// The view settings that freeze panes, as LibreOffice stores them: per sheet, a split mode of 2 + /// (frozen) and the split position in rows or columns, the bottom-right part active. + /// + public static byte[] Settings(IReadOnlyList<(string Name, int Rows, int Columns)> frozen) + { + StringBuilder xml = new( + XmlDeclaration + + "" + + "" + + "view1"); + + foreach ((string name, int rows, int columns) in frozen) + { + xml.Append(""); + Item(xml, "HorizontalSplitMode", "short", columns > 0 ? 2 : 0); + Item(xml, "VerticalSplitMode", "short", rows > 0 ? 2 : 0); + Item(xml, "HorizontalSplitPosition", "int", columns); + Item(xml, "VerticalSplitPosition", "int", rows); + Item(xml, "ActiveSplitRange", "short", 2); + Item(xml, "PositionLeft", "int", 0); + Item(xml, "PositionRight", "int", columns); + Item(xml, "PositionTop", "int", 0); + Item(xml, "PositionBottom", "int", rows); + xml.Append(""); + } + + xml.Append(""); + return Encoding.UTF8.GetBytes(xml.ToString()); + } + + private static void Item(StringBuilder xml, string name, string type, int value) => + xml.Append(CultureInfo.InvariantCulture, $"{value}"); + + private static void AppendAttribute(StringBuilder xml, string value) + { + foreach (char c in value) + { + _ = c switch + { + '&' => xml.Append("&"), + '<' => xml.Append("<"), + '>' => xml.Append(">"), + '"' => xml.Append("""), + _ => xml.Append(c), + }; + } + } +``` + +(Sheet names never hold control characters — `SheetNames.Problem` refuses them — so no character references are needed here.) + +- [ ] **Step 6: Run, then add the LibreOffice check** + +Run `--filter-class "*SheetLayoutTests"` → PASS; then add to `tests/TriasDev.Tabular.Tests/Writing/StyledInteropTests.cs`: + +```csharp + [Fact] + public async Task LibreOfficeKeepsTheFrozenPanesOfAnOds() + { + byte[] ods = await SheetLayoutTests.Write(TabularFormat.Ods, writer => writer.BeginSheet("data", [new("a"), new("b")], new SheetOptions { FreezeRows = 1, FreezeColumns = 1 })); + string sheet = Entry(LibreOffice.Convert(ods, "ods", "xlsx:Calc MS Excel 2007 XML", "xlsx"), "xl/worksheets/sheet1.xml"); + + Assert.Contains("state=\"frozen\"", sheet, StringComparison.Ordinal); + Assert.Matches("ySplit=\"1(\\.0)?\"", sheet); + } + + [Fact] + public async Task LibreOfficeKeepsTheFrozenPanesOfAnXlsx() + { + byte[] xlsx = await SheetLayoutTests.Write(TabularFormat.Xlsx, writer => writer.BeginSheet("data", [new("a"), new("b")], new SheetOptions { FreezeRows = 1 })); + string settings = Entry(LibreOffice.Convert(xlsx, "xlsx", "ods", "ods"), "settings.xml"); + + Assert.Contains("1", settings, StringComparison.Ordinal); + } +``` + +Run `TABULAR_REQUIRE_SOFFICE=1 … --filter-class "*StyledInteropTests"` → PASS, then the full suite. + +- [ ] **Step 7: Public API, format, commit** + +```bash +git add src tests +git commit -m "feat(write): SheetOptions — a styled header row and frozen panes for xlsx and ods" +``` + +--- + +### Task 2: Auto-filter + +**Files:** +- Modify: `src/TriasDev.Tabular/Xlsx/XlsxSheetWriter.cs`, `src/TriasDev.Tabular/Xlsx/XlsxParts.cs`, `src/TriasDev.Tabular/Ods/OdsSheetWriter.cs`, `src/TriasDev.Tabular/Ods/OdsParts.cs` +- Test: `tests/TriasDev.Tabular.Tests/Writing/SheetLayoutTests.cs`, `tests/TriasDev.Tabular.Tests/Writing/StyledInteropTests.cs` + +**Interfaces:** +- Consumes: `SheetOptions.AutoFilter` (Task 1); `ISheetWriter.BeginSheet(…, SheetOptions)`. +- Produces: `XlsxParts.WorksheetEnd` split into `XlsxParts.SheetDataEnd = ""` and `XlsxParts.WorksheetClose = ""`; `XlsxParts.Workbook(IReadOnlyList sheetNames, IReadOnlyList<(int Sheet, string Range)> filters)`; `OdsParts.ContentEnd` split so the database ranges go between the last `` and ``. Task 4 inserts `` after the `autoFilter` in the same `CloseSheet`. + +- [ ] **Step 1: Write the failing tests** (append to `SheetLayoutTests`) + +```csharp + private static void Filtered(TabularWriter writer) + { + writer.BeginSheet("Bob's data", [new("a"), new("b"), new("c")], new SheetOptions { AutoFilter = true }); + + for (int i = 0; i < 3; i++) + { + writer.BeginRow(); + writer.Write((long)i); + writer.EndRow(); + } + + writer.BeginSheet("plain", [new("a")]); + writer.BeginSheet("second", [new("a"), new("b")], new SheetOptions { AutoFilter = true }); + } + + [Fact] + public async Task XlsxFiltersTheHeaderThroughTheLastRow() + { + byte[] xlsx = await Write(TabularFormat.Xlsx, Filtered); + + Assert.Empty(OoxmlValidation.Errors(xlsx)); + Assert.Contains("", Entry(xlsx, "xl/worksheets/sheet1.xml"), StringComparison.Ordinal); + Assert.DoesNotContain("autoFilter", Entry(xlsx, "xl/worksheets/sheet2.xml"), StringComparison.Ordinal); + Assert.Contains("", Entry(xlsx, "xl/worksheets/sheet3.xml"), StringComparison.Ordinal); + + string workbook = Entry(xlsx, "xl/workbook.xml"); + Assert.Contains("", workbook, StringComparison.Ordinal); + } + + [Fact] + public async Task OdsFiltersTheHeaderThroughTheLastRow() + { + byte[] ods = await Write(TabularFormat.Ods, Filtered); + string content = Entry(ods, "content.xml"); + + Assert.Contains("", content, StringComparison.Ordinal); + Assert.Equal(4, Rows(ods).Count); + } +``` + +The xlsx workbook test pins the order inside ``: `……` (CT_Workbook puts `definedNames` after `sheets`). + +- [ ] **Step 2: Run to verify they fail** → FAIL. + +- [ ] **Step 3: Xlsx** + +- Per sheet keep `_filter` (bool) and the column count (`_columnNames.Length`). `CloseSheet()` writes `XlsxParts.SheetDataEnd`, then, when filtering, `` where the last row is `_rowNumber` (the header alone gives row 1), then — Task 4 — merges, then `XlsxParts.WorksheetClose`. Record the filter for the workbook: `_filters.Add((_sheetNames.Count - 1, $"${XlsxParts.ColumnName(0)}$1:${lastColumn}${_rowNumber}"))` — written as `$A$1:$C$4`. +- `XlsxParts.Workbook(sheetNames, filters)`: after ``, when `filters.Count > 0`, append `` and for each `` then ``. Keep the existing escaping helper for the name. + +- [ ] **Step 4: Ods** + +- Count rows per sheet in `OdsSheetWriter` (`_rowNumber`, reset in `BeginSheet`, incremented in `BeginRow`), and the sheet index. +- On a sheet's end (the next `BeginSheet` before writing ``, and `Complete`) record a filtered sheet: `_filters.Add((index, name, columns, _rowNumber))`. +- `OdsParts`: replace `ContentEnd` with `TableEnd = ""` and `SpreadsheetEnd = ""`, and add + +```csharp + /// The auto-filters, as LibreOffice's sheet-local anonymous database ranges, header through last row. + public static string DatabaseRanges(IReadOnlyList<(int Sheet, string Name, int Columns, long Rows)> filters) + { + if (filters.Count == 0) + { + return string.Empty; + } + + StringBuilder xml = new(""); + + foreach ((int sheet, string name, int columns, long rows) in filters) + { + string quoted = "'" + name.Replace("'", "''", StringComparison.Ordinal) + "'"; + xml.Append(CultureInfo.InvariantCulture, $""); + } + + return xml.Append("").ToString(); + } +``` + + (`using TriasDev.Tabular.Xlsx;` is already in `OdsSheetWriter`; in `OdsParts` qualify or add the using.) `Complete()` writes `TableEnd + DatabaseRanges(_filters) + SpreadsheetEnd`. + +- [ ] **Step 5: LibreOffice** (append to `StyledInteropTests`) + +```csharp + [Fact] + public async Task LibreOfficeKeepsTheFilterOfAnOds() + { + byte[] ods = await SheetLayoutTests.Write(TabularFormat.Ods, writer => + { + writer.BeginSheet("Bob's data", [new("a"), new("b")], new SheetOptions { AutoFilter = true }); + writer.BeginRow(); + writer.Write(1L); + writer.EndRow(); + }); + + Assert.Contains(" + { + writer.BeginSheet("Bob's data", [new("a"), new("b")], new SheetOptions { AutoFilter = true }); + writer.BeginRow(); + writer.Write(1L); + writer.EndRow(); + }); + + Assert.Contains("table:display-filter-buttons=\"true\"", Entry(LibreOffice.Convert(xlsx, "xlsx", "ods", "ods"), "content.xml"), StringComparison.Ordinal); + } +``` + +- [ ] **Step 6: Run** the two classes (`TABULAR_REQUIRE_SOFFICE=1`) and the full suite → PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src tests +git commit -m "feat(write): auto-filter on the header row for xlsx and ods" +``` + +--- + +### Task 3: Merges in the writer, and csv + +**Files:** +- 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`, `PublicAPI.Unshipped.txt` +- Test: `tests/TriasDev.Tabular.Tests/Writing/MergeTests.cs` + +**Interfaces:** +- Produces: + - `public void TabularWriter.Merge(int rows, int columns)`. + - `ISheetWriter`: `int MaxMerges { get; }` (csv and ods `int.MaxValue`, xlsx 65,536), `void Merge(int rows, int columns)` (the next cell written is the top-left of the range; called right before that cell), `void WriteCovered()` (a position inside a range, not its top-left). + - In this task xlsx and ods implement `Merge` as a no-op and `WriteCovered` as `WriteEmpty(0)` — Tasks 4 and 5 replace both. Csv: `Merge` no-op, `WriteCovered() => Separate();`. + +- [ ] **Step 1: Write the failing tests** + +```csharp +using TriasDev.Tabular.Tests.Fixtures; + +using Xunit; + +namespace TriasDev.Tabular.Tests.Writing; + +/// Merged cells: declared before their top-left cell, covered positions skipped and written by the writer. +public sealed class MergeTests +{ + private static CancellationToken Token => TestContext.Current.CancellationToken; + + private static readonly WriteColumn[] Four = [new("a"), new("b"), new("c"), new("d")]; + + /// + /// Row 2: a 1 × 2 title in a–b, then c, d. Rows 3–4: a 2 × 2 block in b–c (b3 top-left), a and d around it. + /// + internal static void Legend(TabularWriter writer) + { + writer.BeginSheet("legend", Four); + writer.BeginRow(); + writer.Merge(1, 2); + writer.Write("title"); + writer.Write("c2"); + writer.Write("d2"); + writer.EndRow(); + writer.BeginRow(); + writer.Write("a3"); + writer.Merge(2, 2); + writer.Write("block"); + writer.Write("d3"); + writer.EndRow(); + writer.BeginRow(); + writer.Write("a4"); + writer.Write("d4"); // b4 and c4 are covered: this lands in d + writer.EndRow(); + } + + [Fact] + public async Task CsvWritesTheValueInTheTopLeftCellAndEmptiesElsewhere() + { + byte[] csv = await SheetLayoutTests.Write(TabularFormat.Csv, Legend); + List rows = SheetLayoutTests.Rows(csv); + + Assert.Equal(["title", "", "c2", "d2"], rows[1].Select(c => c.Text ?? string.Empty)); + Assert.Equal(["a3", "block", "", "d3"], rows[2].Select(c => c.Text ?? string.Empty)); + Assert.Equal(["a4", "", "", "d4"], rows[3].Select(c => c.Text ?? string.Empty)); + } + + [Fact] + public async Task ARowThatEndsEarlyStillCoversItsMergedColumns() + { + byte[] csv = await SheetLayoutTests.Write(TabularFormat.Csv, writer => + { + writer.BeginSheet("data", Four); + writer.BeginRow(); + writer.Write("a2"); + writer.Merge(2, 3); + writer.Write("wide"); + writer.EndRow(); + writer.BeginRow(); + writer.Write("a3"); + writer.EndRow(); // b3–d3 covered, written by EndRow + }); + + Assert.Equal(["a3", "", "", ""], SheetLayoutTests.Rows(csv)[2].Select(c => c.Text ?? string.Empty)); + } + + [Theory] + [InlineData(0, 2)] + [InlineData(2, 0)] + [InlineData(1, 1)] + [InlineData(-1, 2)] + public async Task AnEmptyOrSingleCellRangeIsRefused(int rows, int columns) + { + await using TabularWriter writer = TabularWriter.Create(new WriteTarget(), TabularFormat.Csv); + writer.BeginSheet("data", Four); + writer.BeginRow(); + + Assert.Throws(() => writer.Merge(rows, columns)); + } + + [Fact] + public async Task ASecondMergeBeforeACellIsRefused() + { + await using TabularWriter writer = TabularWriter.Create(new WriteTarget(), TabularFormat.Csv); + writer.BeginSheet("data", Four); + writer.BeginRow(); + writer.Merge(1, 2); + + Assert.Throws(() => writer.Merge(1, 2)); + } + + [Fact] + public async Task AMergeNoCellFollowedIsRefusedAtTheEndOfTheRow() + { + await using TabularWriter writer = TabularWriter.Create(new WriteTarget(), TabularFormat.Csv); + writer.BeginSheet("data", Four); + writer.BeginRow(); + writer.Merge(1, 2); + + Assert.Throws(writer.EndRow); + } + + [Fact] + public async Task ARangePastTheLastColumnIsRefused() + { + await using TabularWriter writer = TabularWriter.Create(new WriteTarget(), TabularFormat.Csv); + writer.BeginSheet("data", Four); + writer.BeginRow(); + writer.Write("a"); + writer.Write("b"); + writer.Merge(1, 3); + + Assert.Throws(() => writer.Write("c")); + } + + [Fact] + public async Task ARangeOverlappingAnotherIsRefused() + { + await using TabularWriter writer = TabularWriter.Create(new WriteTarget(), TabularFormat.Csv); + writer.BeginSheet("data", Four); + writer.BeginRow(); + writer.Write("a2"); + writer.Write("b2"); + writer.Merge(3, 1); // c2–c4 + writer.Write("tall"); + writer.EndRow(); + writer.BeginRow(); + writer.Merge(1, 3); // a3–c3 would cover c3, which the tall range covers + + Assert.Throws(() => writer.Write("wide")); + } + + [Fact] + public async Task ASheetEndedWhileARangeStillCoversRowsIsRefused() + { + await using TabularWriter writer = TabularWriter.Create(new WriteTarget(), TabularFormat.Xlsx); + writer.BeginSheet("data", Four); + writer.BeginRow(); + writer.Merge(3, 1); + writer.Write("tall"); + writer.EndRow(); + + Assert.Throws(() => writer.BeginSheet("next", Four)); + } + + [Fact] + public async Task CompletingWhileARangeStillCoversRowsIsRefused() + { + await using TabularWriter writer = TabularWriter.Create(new WriteTarget(), TabularFormat.Csv); + writer.BeginSheet("data", Four); + writer.BeginRow(); + writer.Merge(2, 1); + writer.Write("tall"); + writer.EndRow(); + + await Assert.ThrowsAsync(async () => await writer.CompleteAsync(Token)); + } + + [Fact] + public async Task AMergeOutsideARowIsRefused() + { + await using TabularWriter writer = TabularWriter.Create(new WriteTarget(), TabularFormat.Csv); + writer.BeginSheet("data", Four); + + Assert.Throws(() => writer.Merge(1, 2)); + } + + [Fact] + public async Task AWriteIntoAFullyCoveredRowIsRefused() + { + await using TabularWriter writer = TabularWriter.Create(new WriteTarget(), TabularFormat.Csv); + writer.BeginSheet("data", [new("a"), new("b")]); + writer.BeginRow(); + writer.Merge(2, 2); + writer.Write("all"); + writer.EndRow(); + writer.BeginRow(); + + Assert.Throws(() => writer.Write("x")); + } +} +``` + +`RawCell.Text` — use the property `RawCell` exposes for text (read `src/TriasDev.Tabular/Abstractions/RawCell.cs`; if it is named differently, use that name and keep the assertions' meaning). The csv reader trims and reads empty fields as empty cells; if the trailing empty of a row is not returned as a cell at all, compare with the row padded to four, and say so in the report. + +- [ ] **Step 2: Run to verify it fails** → compile errors (`Merge` missing). + +- [ ] **Step 3: Implement in `TabularWriter`** + +Fields (with the other per-sheet fields): + +```csharp + // Merges: per column, the rows a range covers there (first..last, 0 when none). Allocated on a + // sheet's first merge, so a sheet without merges pays one null check per cell. + private long[]? _coveredFrom; + private long[]? _coveredThrough; + private long _lastCoveredRow; + private int _pendingRows; + private int _pendingColumns; + private int _merges; +``` + +`Merge`: + +```csharp + /// + /// Merges the next cell written with the cells right of and below it: it becomes the top-left of + /// a range high and wide, which shows its value. + /// The writer skips the covered positions — the row's next write lands after the range, and later + /// rows skip it too — and writes them itself. Csv writes the value in the top-left cell and empty + /// fields elsewhere. + /// + /// Rows or columns below 1, or a single cell. + /// Outside a row, or a merge already waits for its cell. + /// The sheet already holds as many merges as its format allows. + public void Merge(int rows, int columns) + { + ExpectWritable(); + + if (_state != State.InRow) + { + throw Refuse("A merge is declared inside a row, right before its top-left cell."); + } + + if (_pendingColumns != 0) + { + throw Refuse("A merge already waits for its top-left cell."); + } + + if (rows < 1 || columns < 1 || (rows == 1 && columns == 1)) + { + throw Faulting(new ArgumentOutOfRangeException(rows < 1 ? nameof(rows) : nameof(columns), $"A merged range is at least 1 × 1 and more than one cell; {rows} × {columns} is not.")); + } + + if (_merges == _sheet.MaxMerges) + { + throw Faulting(new TabularLimitException("MaxMerges", _sheet.MaxMerges, $"A {Format} sheet holds at most {_sheet.MaxMerges} merged ranges.")); + } + + _pendingRows = rows; + _pendingColumns = columns; + } +``` + +`NextCell` becomes: + +```csharp + private int NextCell() + { + ExpectWritable(); + + if (_state != State.InRow) + { + throw Refuse("A value is written between BeginRow and EndRow."); + } + + if (_coveredFrom is not null) + { + SkipCovered(); + } + + if (_column == _columns.Length) + { + throw Refuse($"The row already has a value for each of the sheet's {_columns.Length} columns."); + } + + if (_pendingColumns != 0) + { + BeginMerge(_column); + } + + return _column++; + } + + /// Writes the covered positions the row has reached. + private void SkipCovered() + { + while (_column < _columns.Length && IsCovered(_column)) + { + _sheet.WriteCovered(); + _column++; + } + } + + private bool IsCovered(int column) => + _coveredFrom![column] <= _rowNumber && _rowNumber <= _coveredThrough![column]; + + /// Checks the waiting merge against the sheet and earlier ranges, records it, and tells the format. + private void BeginMerge(int column) + { + int rows = _pendingRows; + int columns = _pendingColumns; + _pendingRows = 0; + _pendingColumns = 0; + + if (columns > _columns.Length - column) + { + throw Refuse($"A merge of {columns} columns from column {column + 1} reaches past the sheet's {_columns.Length} columns."); + } + + _coveredFrom ??= new long[_columns.Length]; + _coveredThrough ??= new long[_columns.Length]; + long last = _rowNumber + rows - 1; + + for (int c = column; c < column + columns; c++) + { + if (_coveredThrough[c] >= _rowNumber && _coveredFrom[c] <= last) + { + throw Refuse($"The merge from row {_rowNumber}, column {column + 1} overlaps a range declared earlier."); + } + } + + for (int c = column; c < column + columns; c++) + { + // The top-left cell holds the value; every other position in the range is covered. + _coveredFrom[c] = c == column ? _rowNumber + 1 : _rowNumber; + _coveredThrough[c] = c == column && rows == 1 ? 0 : last; + } + + _lastCoveredRow = Math.Max(_lastCoveredRow, last); + _merges++; + _sheet.Merge(rows, columns); + } +``` + +Note on `_coveredThrough[c] = 0` for the top-left column of a one-row range: `IsCovered` then needs `from <= row <= 0`, never true. An earlier finished range in that column leaves stale values; they never match later rows because `through < _rowNumber`, and the overlap check uses the same comparison. + +`FinishRow`: + +```csharp + private void FinishRow() + { + for (; _column < _columns.Length; _column++) + { + if (_coveredFrom is not null && IsCovered(_column)) + { + _sheet.WriteCovered(); + } + else + { + _sheet.WriteEmpty(0); + } + } + + _sheet.EndRow(); + _state = State.InSheet; + } +``` + +`EndRow`, before `FinishRow()`: `if (_pendingColumns != 0) throw Refuse("A merge was declared but no cell followed it in the row.");` + +Sheet end: a helper used at the top of `BeginSheet` (after the state checks, before anything is reset — only when a sheet was begun) and of `CompleteAsync` (after the existing checks): + +```csharp + private void ExpectRangesClosed() + { + if (_lastCoveredRow > _rowNumber) + { + throw Refuse($"A merged range reaches row {_lastCoveredRow}, but sheet \"{_sheetName}\" ends at row {_rowNumber}."); + } + } +``` + +`BeginSheet` resets per sheet: `_coveredFrom = null; _coveredThrough = null; _lastCoveredRow = 0; _merges = 0; _pendingRows = 0; _pendingColumns = 0;`. + +- [ ] **Step 4: `ISheetWriter` and the three format writers** + +Add to `ISheetWriter`: + +```csharp + /// The most merged ranges a sheet holds. + int MaxMerges { get; } + + /// The next cell written is the top-left of a range this many rows high and columns wide; checked by the writer. + void Merge(int rows, int columns); + + /// Writes a position a merged range covers, other than its top-left cell. + void WriteCovered(); +``` + +Csv: `MaxMerges => int.MaxValue`, `Merge` does nothing, `WriteCovered() => Separate();`. Xlsx (for now): `MaxMerges => 65_536`, `Merge` does nothing, `WriteCovered() => WriteEmpty(0);`. Ods (for now): `MaxMerges => int.MaxValue`, `Merge` does nothing, `WriteCovered() => WriteEmpty(0);`. + +- [ ] **Step 5: Run** `--filter-class "*MergeTests"` and the full suite → PASS. Public API, format. + +- [ ] **Step 6: Commit** + +```bash +git add src tests +git commit -m "feat(write): merged cells — declared before the top-left cell, covered positions skipped and written by the writer; csv" +``` + +--- + +### Task 4: Xlsx merges + +**Files:** +- Modify: `src/TriasDev.Tabular/Xlsx/XlsxSheetWriter.cs` +- Test: `tests/TriasDev.Tabular.Tests/Writing/MergeTests.cs`, `tests/TriasDev.Tabular.Tests/Writing/StyledInteropTests.cs` + +**Interfaces:** +- Consumes: `ISheetWriter.Merge`/`WriteCovered`/`MaxMerges` (Task 3), `CloseSheet` with `SheetDataEnd` / `autoFilter` / `WorksheetClose` (Task 2). + +- [ ] **Step 1: Write the failing tests** (append to `MergeTests`) + +```csharp + [Fact] + public async Task XlsxListsTheRangesAfterTheData() + { + byte[] xlsx = await SheetLayoutTests.Write(TabularFormat.Xlsx, Legend); + string sheet = SheetLayoutTests.Entry(xlsx, "xl/worksheets/sheet1.xml"); + + Assert.Empty(OoxmlValidation.Errors(xlsx)); + Assert.Contains("", sheet, StringComparison.Ordinal); + Assert.Contains("c2", sheet, StringComparison.Ordinal); + Assert.Contains("d4", sheet, StringComparison.Ordinal); + } + + [Fact] + public async Task XlsxPutsTheRangesAfterTheFilter() + { + byte[] xlsx = await SheetLayoutTests.Write(TabularFormat.Xlsx, writer => + { + writer.BeginSheet("data", Four, new SheetOptions { AutoFilter = true }); + writer.BeginRow(); + writer.Merge(1, 2); + writer.Write("x"); + writer.EndRow(); + }); + + Assert.Empty(OoxmlValidation.Errors(xlsx)); + Assert.Contains("", SheetLayoutTests.Entry(xlsx, "xl/worksheets/sheet1.xml"), StringComparison.Ordinal); + } + + [Theory] + [InlineData(TabularFormat.Xlsx)] + [InlineData(TabularFormat.Ods)] + [InlineData(TabularFormat.Csv)] + public async Task TheImportReadsAMergeAsItsValueAndEmpties(TabularFormat format) + { + byte[] file = await SheetLayoutTests.Write(format, Legend); + List rows = SheetLayoutTests.Rows(file); + + Assert.Equal(RawCell.FromText("title"), rows[1][0]); + Assert.True(rows[1][1].IsEmpty); + Assert.Equal(RawCell.FromText("c2"), rows[1][2]); + Assert.Equal(RawCell.FromText("block"), rows[2][1]); + Assert.True(rows[3][1].IsEmpty); + Assert.True(rows[3][2].IsEmpty); + Assert.Equal(RawCell.FromText("d4"), rows[3][3]); + } + + [Fact] + public async Task TheMergeLimitIsTheFormats() + { + await using TabularWriter writer = TabularWriter.Create(new WriteTarget(), TabularFormat.Xlsx); + writer.BeginSheet("data", [new("a"), new("b")]); + + for (int i = 0; i < 65_536; i++) + { + writer.BeginRow(); + writer.Merge(1, 2); + writer.Write(i); + writer.EndRow(); + } + + writer.BeginRow(); + Assert.Throws(() => writer.Merge(1, 2)); + } +``` + +(`TheImportReadsAMergeAsItsValueAndEmpties` also covers ods; it passes for ods only after Task 5 if Task 3's ods stub writes plain empties — it does, so it passes now too, and Task 5 keeps it green with real covered cells.) + +Interop (append to `StyledInteropTests`): + +```csharp + [Fact] + public async Task LibreOfficeKeepsTheMergesOfAnXlsx() + { + byte[] xlsx = await SheetLayoutTests.Write(TabularFormat.Xlsx, MergeTests.Legend); + string content = Entry(LibreOffice.Convert(xlsx, "xlsx", "ods", "ods"), "content.xml"); + + Assert.Contains("table:number-columns-spanned=\"2\"", content, StringComparison.Ordinal); + Assert.Contains("table:number-rows-spanned=\"2\"", content, StringComparison.Ordinal); + } +``` + +- [ ] **Step 2: Run to verify they fail** (`XlsxListsTheRangesAfterTheData`, `XlsxPutsTheRangesAfterTheFilter`, the interop test) → FAIL. + +- [ ] **Step 3: Implement** + +- A per-sheet `private readonly List _merges = [];` cleared in `BeginSheet`. +- `Merge(int rows, int columns)`: the top-left is the next cell, at `_column` in row `_rowNumber`: add `$"{_columnNames[_column]}{_rowNumber}:{_columnNames[_column + columns - 1]}{_rowNumber + rows - 1}"` (format the numbers with `CultureInfo.InvariantCulture`). One string per merge is allocated — per merge, not per cell. +- `WriteCovered() => _column++;` (a covered cell is simply absent, as an empty one). +- `CloseSheet`: after the `autoFilter`, when `_merges.Count > 0`: `` + `` each + ``, then `WorksheetClose`. + +- [ ] **Step 4: Run** `--filter-class "*MergeTests"`, `"*StyledInteropTests"` (soffice required) and the full suite → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src tests +git commit -m "feat(write): xlsx merged cells" +``` + +--- + +### Task 5: Ods merges and repeated empty cells + +**Files:** +- Modify: `src/TriasDev.Tabular/Ods/OdsSheetWriter.cs` +- Test: `tests/TriasDev.Tabular.Tests/Writing/MergeTests.cs`, `tests/TriasDev.Tabular.Tests/Writing/OdsWriterTests.cs` (only where an existing assertion pinned one `` per empty cell), `tests/TriasDev.Tabular.Tests/Writing/StyledInteropTests.cs` + +**Interfaces:** +- Consumes: Task 3's `ISheetWriter` members. + +- [ ] **Step 1: Write the failing tests** (append to `MergeTests`) + +```csharp + [Fact] + public async Task OdsSpansTheTopLeftCellAndCoversTheRest() + { + byte[] ods = await SheetLayoutTests.Write(TabularFormat.Ods, Legend); + string content = SheetLayoutTests.Entry(ods, "content.xml"); + + Assert.Contains("title", content, StringComparison.Ordinal); + Assert.Contains("block", content, StringComparison.Ordinal); + Assert.Contains("a4d4", content, StringComparison.Ordinal); + } + + [Fact] + public async Task OdsSpansAStyledAndAnEmptyTopLeftCell() + { + byte[] ods = await SheetLayoutTests.Write(TabularFormat.Ods, writer => + { + StyleId fill = writer.Style(new CellStyle { Fill = CellColor.FromRgb(0xF8696B) }); + writer.BeginSheet("data", Four); + writer.BeginRow(); + writer.Merge(1, 2); + writer.Write(5.5, fill); + writer.Merge(1, 2); + writer.WriteEmpty(fill); + writer.EndRow(); + }); + + string content = SheetLayoutTests.Entry(ods, "content.xml"); + Assert.Contains("", content, StringComparison.Ordinal); + Assert.Contains("", content, StringComparison.Ordinal); + } + + [Fact] + public async Task OdsWritesARunOfEmptyCellsAsOne() + { + byte[] ods = await SheetLayoutTests.Write(TabularFormat.Ods, writer => + { + writer.BeginSheet("data", [new("a"), new("b"), new("c"), new("d"), new("e")]); + writer.BeginRow(); + writer.Write("a2"); + writer.WriteEmpty(); + writer.WriteEmpty(); + writer.WriteEmpty(); + writer.Write("e2"); + writer.EndRow(); + writer.BeginRow(); + writer.Write("a3"); + writer.EndRow(); + }); + + string content = SheetLayoutTests.Entry(ods, "content.xml"); + Assert.Contains("a2e2", content, StringComparison.Ordinal); + Assert.Contains("a3", content, StringComparison.Ordinal); + + List rows = SheetLayoutTests.Rows(ods); + Assert.Equal(RawCell.FromText("e2"), rows[1][4]); + Assert.True(rows[1][2].IsEmpty); + } +``` + +Interop (append to `StyledInteropTests`): + +```csharp + [Fact] + public async Task LibreOfficeKeepsTheMergesOfAnOds() + { + byte[] ods = await SheetLayoutTests.Write(TabularFormat.Ods, MergeTests.Legend); + string sheet = Entry(LibreOffice.Convert(ods, "ods", "xlsx:Calc MS Excel 2007 XML", "xlsx"), "xl/worksheets/sheet1.xml"); + + Assert.Contains("", sheet, StringComparison.Ordinal); + Assert.Contains("", sheet, StringComparison.Ordinal); + } +``` + +- [ ] **Step 2: Run to verify they fail** → FAIL. + +- [ ] **Step 3: One place opens every cell** + +Every value cell's start tag is built in one helper, so a pending span is written once: + +```csharp + private string? _span; // the pending span attributes for the next cell, or null + private int _emptyRun; // unstyled empty cells not yet written + + /// Opens a value cell: writes any pending empty run, then <table:table-cell and a pending span. + private void OpenCell() + { + FlushEmpties(); + _row.Append(""); + } + else + { + _row.Append(""); + } + + _emptyRun = 0; + } +``` + +Rewrite each cell start through `OpenCell()`: the `StyledCell` constant becomes `OpenCell(); _row.Append(" table:style-name=\"");`; `StartFloat()` becomes `OpenCell(); _row.Append(" office:value-type=\"float\" office:value=\"");`; the boolean, date, text and styled-empty starts likewise. Every existing expected string stays the same when no span is pending — run the existing `Ods*` tests after the refactor and before adding spans to prove it. + +- `Merge(int rows, int columns)`: `_span = string.Create(CultureInfo.InvariantCulture, $" table:number-columns-spanned=\"{columns}\" table:number-rows-spanned=\"{rows}\"");` — one string per merge. +- `WriteCovered()`: `FlushEmpties(); _row.Append("");`. +- `WriteEmpty(int style)`: style 0 and no pending span → `_emptyRun++`; otherwise `OpenCell()` then ` table:style-name="NAME"/>` for a styled one, or `/>` for an unstyled empty top-left cell with a span. +- `EndRow()`: `FlushEmpties()` before ``. + +- [ ] **Step 4: Run** `--filter-class "*MergeTests"`, `"*Ods*"`, `"*StyledInteropTests"` (soffice required) and the full suite → PASS. If an existing ods test pinned ``, update its expectation to the repeated form and name it in the report. + +- [ ] **Step 5: Commit** + +```bash +git add src tests +git commit -m "feat(write): ods merged cells and runs of empty cells as one repeated cell" +``` + +--- + +### Task 6: A laid-out workbook end to end; documentation + +**Files:** +- Modify: `tests/TriasDev.Tabular.Tests/Writing/StyledInteropTests.cs`, `docs/KNOWN-ISSUES.md` +- Test: `StyledInteropTests.ALaidOutWorkbookOpensAndReadsBack` + +- [ ] **Step 1: The test** + +```csharp + [Theory] + [InlineData(TabularFormat.Xlsx, "xlsx")] + [InlineData(TabularFormat.Ods, "ods")] + public async Task ALaidOutWorkbookOpensAndReadsBack(TabularFormat format, string extension) + { + CellStyle header = new() { Fill = CellColor.FromRgb(0x1F4E78), Font = new CellFont { Color = CellColor.FromRgb(0xFFFFFF), Bold = true } }; + CellStyle[] legend = [new() { Fill = CellColor.FromRgb(0x63BE7B) }, new() { Fill = CellColor.FromRgb(0xFFEB84) }, new() { Fill = CellColor.FromRgb(0xF8696B) }]; + + byte[] file = await SheetLayoutTests.Write(format, writer => + { + StyleId[] colours = [.. legend.Select(writer.Style)]; + StyleId title = writer.Style(new CellStyle { Font = new CellFont { Bold = true }, Horizontal = HorizontalAlignment.Center }); + + writer.BeginSheet("Data", [new("Id"), new("Score"), new("Date", 12)], new SheetOptions { HeaderStyle = header, FreezeRows = 1, AutoFilter = true }); + + for (int i = 1; i <= 100; i++) + { + writer.BeginRow(); + writer.Write((long)i); + writer.Write(i / 10.0, colours[i % 3]); + writer.Write(new DateOnly(2026, 1, 1).AddDays(i)); + writer.EndRow(); + } + + writer.BeginSheet("Legend", [new("Range"), new("Colour"), new("Meaning")], new SheetOptions { HeaderStyle = header }); + writer.BeginRow(); + writer.Merge(1, 3); + writer.Write("Score legend", title); + writer.EndRow(); + + for (int i = 0; i < legend.Length; i++) + { + writer.BeginRow(); + writer.Write($"{i * 3}–{(i * 3) + 3}"); + writer.WriteEmpty(colours[i]); + writer.Write(i switch { 0 => "low", 1 => "medium", _ => "high" }); + writer.EndRow(); + } + }); + + if (format == TabularFormat.Xlsx) + { + Assert.Empty(OoxmlValidation.Errors(file)); + } + + string[] lines = LibreOffice.ConvertToCsv(file, extension); + Assert.Equal(101, lines.Length); + + List data = SheetLayoutTests.Rows(file, 0); + Assert.Equal(101, data.Count); + Assert.Equal(RawCell.FromNumber(5), data[50][1]); + + List legendRows = SheetLayoutTests.Rows(file, 1); + Assert.Equal(RawCell.FromText("Score legend"), legendRows[1][0]); + Assert.Equal(RawCell.FromText("high"), legendRows[4][2]); + } +``` + +`lines.Length == 101`: `ConvertToCsv` converts the first sheet only (header + 100 rows). + +- [ ] **Step 2: Run** with `TABULAR_REQUIRE_SOFFICE=1` → PASS (if it fails, the failure is a real bug in Tasks 1–5: find it and fix it in the library, with a focused test next to the code's own tests). + +- [ ] **Step 3: Documentation** — under the `## Styles` heading in `docs/KNOWN-ISSUES.md`, add: + +```markdown +### Sheet layout + +- `SheetOptions` sets a header style, frozen rows and columns, and an auto-filter on the header row through the last row written; csv ignores it. +- `writer.Merge(rows, columns)` makes the next cell the top-left of a merged range. The writer skips the covered positions — the row's next write lands after the range, later rows skip it too — and writes them itself. The import reads a merged range as its value in the top-left cell and empty cells elsewhere; csv writes exactly that. +- A merge must end inside the sheet: ending a sheet (or the file) while a range still has rows to cover is refused. +- xlsx holds at most 65,536 merged ranges per sheet. +``` + +- [ ] **Step 4: Full suite, format, commit** + +```bash +git add tests docs +git commit -m "test(write): a laid-out workbook opens in LibreOffice and reads back; document sheet layout" +``` 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 index 2c86105..740a994 100644 --- a/docs/superpowers/specs/2026-10-04-styled-columnar-export-design.md +++ b/docs/superpowers/specs/2026-10-04-styled-columnar-export-design.md @@ -94,21 +94,23 @@ writer.BeginSheet("Legend", columns, new SheetOptions }); writer.BeginRow(); -writer.Write("Hazard legend", title, span: new CellSpan(Rows: 1, Columns: 4)); +writer.Merge(rows: 1, columns: 4); // the next cell is the top-left of a 1 × 4 range +writer.Write("Hazard legend", title); 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 +- **Merged cells** are declared with `writer.Merge(rows, columns)` right before the top-left cell's + `Write` (any overload, styled or not, or `WriteEmpty`), because ods must know them when that cell + is written. One method instead of a `span` parameter on every `Write` overload. 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. + - Errors: `Merge` refuses rows or columns < 1 and a 1 × 1 range (`ArgumentOutOfRangeException`), + and a second `Merge` before a cell (`InvalidOperationException`); the top-left cell's write + refuses a range overlapping another or reaching past the sheet's columns, `EndRow` a `Merge` + no cell followed, and `BeginSheet` / `CompleteAsync` a sheet ended while a range still has rows + to cover (`InvalidOperationException`). - 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`. @@ -119,8 +121,9 @@ writer.EndRow(); - **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. +- **Column limit:** 16,384 columns per sheet (Excel; LibreOffice since 7.4) — already enforced by + `BeginSheet` for every format (`ArgumentOutOfRangeException`), since our csv reader has the same + ceiling. - **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, diff --git a/src/TriasDev.Tabular/Csv/CsvSheetWriter.cs b/src/TriasDev.Tabular/Csv/CsvSheetWriter.cs index bb2d135..aeb021a 100644 --- a/src/TriasDev.Tabular/Csv/CsvSheetWriter.cs +++ b/src/TriasDev.Tabular/Csv/CsvSheetWriter.cs @@ -60,7 +60,7 @@ public void Dispose() /// The row buffer's current size, for the test that a huge row does not keep it. internal int RowBufferLength => _row.Capacity; - public void BeginSheet(string name, ReadOnlySpan columns) + public void BeginSheet(string name, ReadOnlySpan columns, SheetOptions options) { _columnCount = columns.Length; @@ -76,7 +76,7 @@ public void BeginRow() _firstCell = true; } - public string? WriteHeader(string value) => WriteField(value, guard: false, column: -1); + public string? WriteHeader(string value, int style) => WriteField(value, guard: false, column: -1); public string? WriteText(string value, int column, int style) { @@ -209,6 +209,14 @@ public void WriteBoolean(bool value, int style) _row.Append(value ? "true" : "false"); } + public int MaxMerges => int.MaxValue; + + public void Merge(int rows, int columns) + { + } + + public void WriteCovered() => 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 02d937b..ceb2e5d 100644 --- a/src/TriasDev.Tabular/Ods/OdsParts.cs +++ b/src/TriasDev.Tabular/Ods/OdsParts.cs @@ -18,7 +18,30 @@ internal static class OdsParts public const string BooleanStyle = "ce3"; - public const string ContentEnd = ""; + public const string TableEnd = ""; + + public const string SpreadsheetEnd = ""; + + /// The auto-filters, as LibreOffice's sheet-local anonymous database ranges, header through last row. + public static string DatabaseRanges(IReadOnlyList<(int Sheet, string Name, int Columns, long Rows)> filters) + { + if (filters.Count == 0) + { + return string.Empty; + } + + StringBuilder xml = new(""); + + foreach ((int sheet, string name, int columns, long rows) in filters) + { + string quoted = "'" + name.Replace("'", "''", StringComparison.Ordinal) + "'"; + xml.Append(CultureInfo.InvariantCulture, $""); + } + + return xml.Append("").ToString(); + } internal const string XmlDeclaration = ""; @@ -39,14 +62,67 @@ internal static class OdsParts /// public static readonly string ContentStart = BuildContentStart(); - public static readonly byte[] Manifest = Encoding.UTF8.GetBytes( + public static byte[] Manifest(bool settings) => Encoding.UTF8.GetBytes( XmlDeclaration + "" + "" + "" + "" + + (settings ? "" : string.Empty) + ""); + /// + /// The view settings that freeze panes, as LibreOffice stores them: per sheet, a split mode of 2 + /// (frozen) and the split position in rows or columns, the bottom-right part active. The root declares + /// ooo: the view-settings set's name is a QName, and LibreOffice ignores the set without it. + /// + public static byte[] Settings(IReadOnlyList<(string Name, int Rows, int Columns)> frozen) + { + StringBuilder xml = new( + XmlDeclaration + + "" + + "" + + "view1"); + + foreach ((string name, int rows, int columns) in frozen) + { + xml.Append(""); + Item(xml, "HorizontalSplitMode", "short", columns > 0 ? 2 : 0); + Item(xml, "VerticalSplitMode", "short", rows > 0 ? 2 : 0); + Item(xml, "HorizontalSplitPosition", "int", columns); + Item(xml, "VerticalSplitPosition", "int", rows); + Item(xml, "ActiveSplitRange", "short", 2); + Item(xml, "PositionLeft", "int", 0); + Item(xml, "PositionRight", "int", columns); + Item(xml, "PositionTop", "int", 0); + Item(xml, "PositionBottom", "int", rows); + xml.Append(""); + } + + xml.Append(""); + return Encoding.UTF8.GetBytes(xml.ToString()); + } + + private static void Item(StringBuilder xml, string name, string type, int value) => + xml.Append(CultureInfo.InvariantCulture, $"{value}"); + + private static void AppendAttribute(StringBuilder xml, string value) + { + foreach (char c in value) + { + _ = c switch + { + '&' => xml.Append("&"), + '<' => xml.Append("<"), + '>' => xml.Append(">"), + '"' => xml.Append("""), + _ => xml.Append(c), + }; + } + } + /// 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)]; diff --git a/src/TriasDev.Tabular/Ods/OdsSheetWriter.cs b/src/TriasDev.Tabular/Ods/OdsSheetWriter.cs index c7de0b5..92e00cf 100644 --- a/src/TriasDev.Tabular/Ods/OdsSheetWriter.cs +++ b/src/TriasDev.Tabular/Ods/OdsSheetWriter.cs @@ -30,9 +30,9 @@ internal sealed class OdsSheetWriter : ISheetWriter private const int TagOverhead = 80; - private const string NumberEnd = "\"/>"; + private const string StyleAttribute = " table:style-name=\""; - private const string StyledCell = ""; private static readonly int MaxTextChars = OdsCursorOptions.Default.MaxValueChars; @@ -41,8 +41,17 @@ internal sealed class OdsSheetWriter : ISheetWriter private readonly ZipWriter _zip; private readonly OdsStyles _styles; private readonly RowText _row = new(); + private readonly List<(string Name, int Rows, int Columns)> _frozen = []; private ArrayBufferWriter _bytes = new(16 * 1024); + private readonly List<(int Sheet, string Name, int Columns, long Rows)> _filters = []; private Stream? _content; + private string _name = string.Empty; + private int _columns; + private int _sheets; + private long _rowNumber; + private bool _filter; + private string? _span; // the pending span attributes for the next cell, or null + private int _emptyRun; // unstyled empty cells not yet written public OdsSheetWriter(SpillBuffer output, OdsWriterOptions options, StyleTable styles) { @@ -60,8 +69,19 @@ public OdsSheetWriter(SpillBuffer output, OdsWriterOptions options, StyleTable s public bool NamesSheets => true; - public void BeginSheet(string name, ReadOnlySpan columns) + public void BeginSheet(string name, ReadOnlySpan columns, SheetOptions options) { + if (options.FreezeRows > 0 || options.FreezeColumns > 0) + { + _frozen.Add((name, options.FreezeRows, options.FreezeColumns)); + } + + RecordFilter(); + _name = name; + _columns = columns.Length; + _filter = options.AutoFilter; + _rowNumber = 0; + _sheets++; _row.Clear(); if (_content is null) @@ -71,7 +91,7 @@ public void BeginSheet(string name, ReadOnlySpan columns) } else { - _row.Append(""); + _row.Append(OdsParts.TableEnd); } _row.Append(" columns) public void BeginRow() { + _rowNumber++; _row.Clear(); _row.Append(""); } - public string? WriteHeader(string value) => WriteString(value, 0); + public string? WriteHeader(string value, int style) => WriteString(value, style); public string? WriteText(string value, int column, int style) => WriteString(value, style); @@ -128,7 +149,8 @@ public void BeginRow() 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(StyledCell); + OpenCell(); + _row.Append(StyleAttribute); _row.Append(DateStyleName(style, hasTime)); _row.Append("\" office:value-type=\"date\" office:date-value=\""); _row.AppendFormatted(value, hasTime ? "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff" : "yyyy'-'MM'-'dd", CultureInfo.InvariantCulture); @@ -138,7 +160,8 @@ public void BeginRow() public void WriteBoolean(bool value, int style) { - _row.Append(StyledCell); + OpenCell(); + _row.Append(StyleAttribute); _row.Append(style == 0 ? OdsParts.BooleanStyle : _styles.Cell(style, ValueKind.Boolean).Name); _row.Append("\" office:value-type=\"boolean\" office:boolean-value=\""); _row.Append(value ? "true" : "false"); @@ -147,21 +170,40 @@ public void WriteBoolean(bool value, int style) _row.Append(""); } + public int MaxMerges => int.MaxValue; + + public void Merge(int rows, int columns) => + _span = string.Create(CultureInfo.InvariantCulture, $" table:number-columns-spanned=\"{columns}\" table:number-rows-spanned=\"{rows}\""); + + public void WriteCovered() + { + FlushEmpties(); + _row.Append(""); + } + public void WriteEmpty(int style) { - if (style == 0) + if (style == 0 && _span is null) { - _row.Append(""); + _emptyRun++; return; } - _row.Append(StyledCell); - _row.Append(_styles.Cell(style, ValueKind.Empty).Name); - _row.Append("\"/>"); + OpenCell(); + + if (style != 0) + { + _row.Append(StyleAttribute); + _row.Append(_styles.Cell(style, ValueKind.Empty).Name); + _row.Append('"'); + } + + _row.Append("/>"); } public void EndRow() { + FlushEmpties(); _row.Append(""); Emit(); } @@ -171,14 +213,23 @@ public void Complete() if (_content is not null) { _row.Clear(); - _row.Append(OdsParts.ContentEnd); + RecordFilter(); + _row.Append(OdsParts.TableEnd); + _row.Append(OdsParts.DatabaseRanges(_filters)); + _row.Append(OdsParts.SpreadsheetEnd); Emit(); _zip.EndEntry(); _content = null; } - _zip.AddStored("META-INF/manifest.xml", OdsParts.Manifest); + _zip.AddStored("META-INF/manifest.xml", OdsParts.Manifest(settings: _frozen.Count > 0)); _zip.AddStored("styles.xml", _styles.Build()); + + if (_frozen.Count > 0) + { + _zip.AddStored("settings.xml", OdsParts.Settings(_frozen)); + } + _zip.Complete(); } @@ -189,8 +240,48 @@ public void Dispose() _content = null; } - /// Opens a number cell up to its value; LibreOffice formats the display from the value. - private void StartFloat() => _row.Append("At a sheet's end: notes its filter, if it has one, for the database ranges written after the last sheet. + private void RecordFilter() + { + if (_filter) + { + _filters.Add((_sheets - 1, _name, _columns, _rowNumber)); + } + } + + /// Opens a value cell: writes any pending empty run, then <table:table-cell and a pending span. + private void OpenCell() + { + FlushEmpties(); + _row.Append(""); + } + else + { + _row.Append(""); + } + + _emptyRun = 0; + } private string DateStyleName(int style, bool hasTime) { @@ -206,12 +297,14 @@ private void StartNumber(int style, ValueKind kind) { if (style == 0) { - StartFloat(); + OpenCell(); + _row.Append(" office:value-type=\"float\" office:value=\""); return; } OdsCellStyle cell = _styles.Cell(style, kind); - _row.Append(StyledCell); + OpenCell(); + _row.Append(StyleAttribute); _row.Append(cell.Name); _row.Append(cell.Percent ? "\" office:value-type=\"percentage\" office:value=\"" : "\" office:value-type=\"float\" office:value=\""); } @@ -223,11 +316,11 @@ private void StartNumber(int style, ValueKind kind) return ErrorCodes.Write.TextTooLong; } - _row.Append(" void TriasDev.Tabular.TabularWriter.Write(bool value, TriasDev.Tabular.StyleId style) -> void TriasDev.Tabular.TabularWriter.WriteEmpty(TriasDev.Tabular.StyleId style) -> void +override TriasDev.Tabular.SheetOptions.Equals(object? obj) -> bool +override TriasDev.Tabular.SheetOptions.GetHashCode() -> int +override TriasDev.Tabular.SheetOptions.ToString() -> string! +static TriasDev.Tabular.SheetOptions.operator !=(TriasDev.Tabular.SheetOptions? left, TriasDev.Tabular.SheetOptions? right) -> bool +static TriasDev.Tabular.SheetOptions.operator ==(TriasDev.Tabular.SheetOptions? left, TriasDev.Tabular.SheetOptions? right) -> bool +TriasDev.Tabular.SheetOptions +TriasDev.Tabular.SheetOptions.$() -> TriasDev.Tabular.SheetOptions! +TriasDev.Tabular.SheetOptions.AutoFilter.get -> bool +TriasDev.Tabular.SheetOptions.AutoFilter.init -> void +TriasDev.Tabular.SheetOptions.Equals(TriasDev.Tabular.SheetOptions? other) -> bool +TriasDev.Tabular.SheetOptions.FreezeColumns.get -> int +TriasDev.Tabular.SheetOptions.FreezeColumns.init -> void +TriasDev.Tabular.SheetOptions.FreezeRows.get -> int +TriasDev.Tabular.SheetOptions.FreezeRows.init -> void +TriasDev.Tabular.SheetOptions.HeaderStyle.get -> TriasDev.Tabular.CellStyle? +TriasDev.Tabular.SheetOptions.HeaderStyle.init -> void +TriasDev.Tabular.SheetOptions.SheetOptions() -> void +TriasDev.Tabular.TabularWriter.BeginSheet(string! name, System.ReadOnlySpan columns, TriasDev.Tabular.SheetOptions? options) -> void +TriasDev.Tabular.TabularWriter.Merge(int rows, int columns) -> void diff --git a/src/TriasDev.Tabular/Writing/ISheetWriter.cs b/src/TriasDev.Tabular/Writing/ISheetWriter.cs index 6033773..502de3b 100644 --- a/src/TriasDev.Tabular/Writing/ISheetWriter.cs +++ b/src/TriasDev.Tabular/Writing/ISheetWriter.cs @@ -21,7 +21,8 @@ internal interface ISheetWriter : IDisposable /// Whether the format stores sheet names, which must then meet ' rules. bool NamesSheets { get; } - void BeginSheet(string name, ReadOnlySpan columns); + /// Begins a sheet. is never null; csv ignores it. + void BeginSheet(string name, ReadOnlySpan columns, SheetOptions options); void BeginRow(); @@ -30,7 +31,8 @@ internal interface ISheetWriter : IDisposable /// import compares headers as written — and never judged against a record's width, which the /// reader learns from the header. Returns a code if the format cannot hold it. /// - string? WriteHeader(string value); + /// is the header row's style index, 0 for none. + string? WriteHeader(string value, int style); // Every Write* method below takes `style`: the cell's index in the writer's StyleTable; 0 is unstyled. @@ -56,6 +58,15 @@ internal interface ISheetWriter : IDisposable void WriteEmpty(int style); + /// The most merged ranges a sheet holds. + int MaxMerges { get; } + + /// The next cell written is the top-left of a range this many rows high and columns wide; checked by the writer. + void Merge(int rows, int columns); + + /// Writes a position a merged range covers, other than its top-left cell. + void WriteCovered(); + void EndRow(); /// Writes whatever ends the file. Called once, after the last row. diff --git a/src/TriasDev.Tabular/Writing/SheetOptions.cs b/src/TriasDev.Tabular/Writing/SheetOptions.cs new file mode 100644 index 0000000..60969c7 --- /dev/null +++ b/src/TriasDev.Tabular/Writing/SheetOptions.cs @@ -0,0 +1,23 @@ +namespace TriasDev.Tabular; + +/// +/// How a sheet is laid out beyond its values: a style for its header row, rows and columns frozen +/// in view, an auto-filter on its header. Csv ignores all of it. +/// +public sealed record SheetOptions +{ + /// The options a sheet has when none are given. + internal static readonly SheetOptions Default = new(); + + /// The header row's style; null leaves it unstyled. + public CellStyle? HeaderStyle { get; init; } + + /// How many rows from the top stay in view while scrolling — 1 keeps the header. 0 freezes none. + public int FreezeRows { get; init; } + + /// How many columns from the left stay in view while scrolling. 0 freezes none. + public int FreezeColumns { get; init; } + + /// An auto-filter on the header row, covering every row written. + public bool AutoFilter { get; init; } +} diff --git a/src/TriasDev.Tabular/Writing/TabularWriter.cs b/src/TriasDev.Tabular/Writing/TabularWriter.cs index 5e1df9b..cf359d0 100644 --- a/src/TriasDev.Tabular/Writing/TabularWriter.cs +++ b/src/TriasDev.Tabular/Writing/TabularWriter.cs @@ -48,6 +48,15 @@ public sealed class TabularWriter : IAsyncDisposable private long _rowNumber; private int _column; + // Merges: per column, the rows a range covers there (first..last, 0 when none). Allocated on a + // sheet's first merge, so a sheet without merges pays one null check per cell. + private long[]? _coveredFrom; + private long[]? _coveredThrough; + private long _lastCoveredRow; + private int _pendingRows; + private int _pendingColumns; + private int _merges; + private TabularWriter(Stream target, TabularFormat format, SpillBuffer buffer, ISheetWriter sheet, StyleTable styles, bool leaveOpen) { _target = target; @@ -166,7 +175,17 @@ private static void CloseAfterFailedCreate(Stream stream) /// Begins a sheet and writes its header row. /// The sheet's name. A workbook's names are 1 to 31 characters, none of [ ] : * ? / \, no apostrophe at either end, not "History", unique ignoring case. A csv file has one sheet, whose name is not written. /// The columns: 1 to 16,384, each with a header that is not empty, neither starts nor ends with whitespace, and is unique in the sheet, ignoring case. - public void BeginSheet(string name, ReadOnlySpan columns) + public void BeginSheet(string name, ReadOnlySpan columns) => BeginSheet(name, columns, null); + + /// Begins a sheet laid out by and writes its header row. + /// The sheet's name. A workbook's names are 1 to 31 characters, none of [ ] : * ? / \, no apostrophe at either end, not "History", unique ignoring case. A csv file has one sheet, whose name is not written. + /// The columns: 1 to 16,384, each with a header that is not empty, neither starts nor ends with whitespace, and is unique in the sheet, ignoring case. + /// The header style, frozen rows and columns, and filter; null for none. + /// A freeze outside the sheet: rows from 0 to the format's row limit less one, columns from 0 to the column count. + /// A merged range of the previous sheet still covers rows the sheet did not write. + /// The has an alignment that is not a defined value, as refuses. + /// The would be the file's 4097th distinct style, as refuses. + public void BeginSheet(string name, ReadOnlySpan columns, SheetOptions? options) { ExpectWritable(); @@ -175,6 +194,8 @@ public void BeginSheet(string name, ReadOnlySpan columns) throw Refuse("A sheet begins outside a row."); } + ExpectRangesClosed(); + if (_sheets > 0 && !_sheet.AllowsSeveralSheets) { throw Refuse($"A {Format} file holds one sheet."); @@ -192,20 +213,40 @@ public void BeginSheet(string name, ReadOnlySpan columns) CheckColumns(columns); + SheetOptions layout = options ?? SheetOptions.Default; + + if (layout.FreezeRows < 0 || layout.FreezeRows >= _sheet.MaxRows) + { + throw Faulting(new ArgumentOutOfRangeException(nameof(options), layout.FreezeRows, $"A sheet freezes 0 to {_sheet.MaxRows - 1} rows.")); + } + + if (layout.FreezeColumns < 0 || layout.FreezeColumns > columns.Length) + { + throw Faulting(new ArgumentOutOfRangeException(nameof(options), layout.FreezeColumns, $"A sheet of {columns.Length} columns freezes 0 to {columns.Length} of them.")); + } + + int headerStyle = layout.HeaderStyle is { } header ? RegisterStyle(header) : 0; + _columns = columns.ToArray(); _sheetName = name; _sheetNames.Add(name); _sheets++; _rowNumber = 0; - _sheet.BeginSheet(name, _columns); + _coveredFrom = null; + _coveredThrough = null; + _lastCoveredRow = 0; + _merges = 0; + _pendingRows = 0; + _pendingColumns = 0; + _sheet.BeginSheet(name, _columns, layout); StartRow(); - foreach (string header in _columns.Select(column => column.Header)) + foreach (string headerText in _columns.Select(column => column.Header)) { - if (_sheet.WriteHeader(header) is { } code) + if (_sheet.WriteHeader(headerText, headerStyle) is { } code) { - throw Faulting(new ArgumentException($"The header \"{header}\" cannot be written: {code}.", nameof(columns))); + throw Faulting(new ArgumentException($"The header \"{headerText}\" cannot be written: {code}.", nameof(columns))); } _column++; @@ -239,10 +280,14 @@ public void BeginRow() public StyleId Style(CellStyle style) { ExpectWritable(); + return new StyleId(_stamp, RegisterStyle(style)); + } + private int RegisterStyle(CellStyle style) + { try { - return new StyleId(_stamp, _styles.Add(style)); + return _styles.Add(style); } catch (Exception refused) when (refused is ArgumentException or TabularLimitException) { @@ -253,12 +298,14 @@ public StyleId Style(CellStyle style) /// 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. + /// A waits and this cell would overlap a range declared earlier, or the range reaches past the sheet's columns. This applies to every Write overload and . 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. + /// A waits and this cell would overlap a range declared earlier, or the range reaches past the sheet's columns. public void Write(string? value, StyleId style) { int column = NextCell(); @@ -280,12 +327,14 @@ public void Write(string? value, StyleId style) /// implicitly to both and , so the call is ambiguous and /// does not compile; convert it explicitly, to or . /// + /// A waits and this cell would overlap a range declared earlier, or the range reaches past the sheet's columns. 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. + /// A waits and this cell would overlap a range declared earlier, or the range reaches past the sheet's columns. public void Write(long value, StyleId style) { int column = NextCell(); @@ -293,12 +342,14 @@ public void Write(long value, StyleId style) } /// Writes the next cell as a decimal number. + /// A waits and this cell would overlap a range declared earlier, or the range reaches past the sheet's columns. 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. + /// A waits and this cell would overlap a range declared earlier, or the range reaches past the sheet's columns. public void Write(decimal value, StyleId style) { int column = NextCell(); @@ -309,12 +360,14 @@ public void Write(decimal value, StyleId style) /// 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. /// + /// A waits and this cell would overlap a range declared earlier, or the range reaches past the sheet's columns. 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. + /// A waits and this cell would overlap a range declared earlier, or the range reaches past the sheet's columns. public void Write(double value, StyleId style) { int column = NextCell(); @@ -325,12 +378,14 @@ public void Write(double value, StyleId style) /// 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. /// + /// A waits and this cell would overlap a range declared earlier, or the range reaches past the sheet's columns. 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. + /// A waits and this cell would overlap a range declared earlier, or the range reaches past the sheet's columns. public void Write(DateTime value, StyleId style) { int column = NextCell(); @@ -339,12 +394,14 @@ public void Write(DateTime value, StyleId style) } /// Writes the next cell as a date. The import returns it as a at midnight. + /// A waits and this cell would overlap a range declared earlier, or the range reaches past the sheet's columns. 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. + /// A waits and this cell would overlap a range declared earlier, or the range reaches past the sheet's columns. public void Write(DateOnly value, StyleId style) { int column = NextCell(); @@ -352,12 +409,14 @@ public void Write(DateOnly value, StyleId style) } /// Writes the next cell as a boolean. + /// A waits and this cell would overlap a range declared earlier, or the range reaches past the sheet's columns. 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. + /// A waits and this cell would overlap a range declared earlier, or the range reaches past the sheet's columns. public void Write(bool value, StyleId style) { NextCell(); @@ -365,17 +424,61 @@ public void Write(bool value, StyleId style) } /// Writes the next cell empty. + /// A waits and this cell would overlap a range declared earlier, or the range reaches past the sheet's columns. public void WriteEmpty() => WriteEmpty(default); /// /// A style this writer handed out, or the default for none. /// was not handed out by this writer. + /// A waits and this cell would overlap a range declared earlier, or the range reaches past the sheet's columns. public void WriteEmpty(StyleId style) { NextCell(); _sheet.WriteEmpty(Index(style)); } + /// + /// Merges the next cell written with the cells right of and below it: it becomes the top-left of + /// a range high and wide, which shows its value. + /// The writer skips the covered positions — the row's next write lands after the range, and later + /// rows skip it too — and writes them itself. Csv writes the value in the top-left cell and empty + /// fields elsewhere. + /// + /// Rows or columns below 1, or a single cell. + /// Outside a row, or a merge already waits for its cell. + /// The sheet already holds as many merges as its format allows. + public void Merge(int rows, int columns) + { + ExpectWritable(); + + if (_state != State.InRow) + { + throw Refuse("A merge is declared inside a row, right before its top-left cell."); + } + + if (_pendingColumns != 0) + { + throw Refuse("A merge already waits for its top-left cell."); + } + + if (rows < 1 || columns < 1 || (rows == 1 && columns == 1)) + { + throw Faulting(new ArgumentOutOfRangeException(rows < 1 ? nameof(rows) : nameof(columns), $"A merged range is at least 1 × 1 and more than one cell; {rows} × {columns} is not.")); + } + + if (_merges == _sheet.MaxMerges) + { + throw Faulting(new TabularLimitException("MaxMerges", _sheet.MaxMerges, $"A {Format} sheet holds at most {_sheet.MaxMerges} merged ranges.")); + } + + // Allocated here, on the sheet's first merge, so that a merge waiting implies the arrays exist + // and a cell of a sheet without merges pays a single null check. + _coveredFrom ??= new long[_columns.Length]; + _coveredThrough ??= new long[_columns.Length]; + _pendingRows = rows; + _pendingColumns = columns; + } + /// Ends the row; columns it did not reach are written empty. public void EndRow() { @@ -386,6 +489,11 @@ public void EndRow() throw Refuse("A row ends after it began."); } + if (_pendingColumns != 0) + { + throw Refuse("A merge was declared but no cell followed it in the row."); + } + FinishRow(); } @@ -408,6 +516,7 @@ public async ValueTask FlushAsync(CancellationToken cancellationToken = default) } /// Ends the file, writes what is pending and flushes the stream. Only now is the file valid. + /// A row has not ended, no sheet was begun, or a merged range still covers rows the sheet did not write. public async ValueTask CompleteAsync(CancellationToken cancellationToken = default) { ExpectWritable(); @@ -422,6 +531,8 @@ public async ValueTask CompleteAsync(CancellationToken cancellationToken = defau throw Refuse("A file has at least one sheet."); } + ExpectRangesClosed(); + try { cancellationToken.ThrowIfCancellationRequested(); @@ -547,7 +658,14 @@ private void FinishRow() { for (; _column < _columns.Length; _column++) { - _sheet.WriteEmpty(0); + if (_coveredFrom is not null && IsCovered(_column)) + { + _sheet.WriteCovered(); + } + else + { + _sheet.WriteEmpty(0); + } } _sheet.EndRow(); @@ -583,14 +701,95 @@ private int NextCell() throw Refuse("A value is written between BeginRow and EndRow."); } + if (_coveredFrom is not null) + { + return NextCellOfMergedSheet(); + } + + if (_column == _columns.Length) + { + throw Refuse($"The row already has a value for each of the sheet's {_columns.Length} columns."); + } + + return _column++; + } + + /// The next cell on a sheet that has a merge: covered positions are written first, and a waiting merge begins here. + private int NextCellOfMergedSheet() + { + SkipCovered(); + if (_column == _columns.Length) { throw Refuse($"The row already has a value for each of the sheet's {_columns.Length} columns."); } + if (_pendingColumns != 0) + { + BeginMerge(_column); + } + return _column++; } + /// Writes the covered positions the row has reached. + private void SkipCovered() + { + while (_column < _columns.Length && IsCovered(_column)) + { + _sheet.WriteCovered(); + _column++; + } + } + + private bool IsCovered(int column) => + _coveredFrom![column] <= _rowNumber && _rowNumber <= _coveredThrough![column]; + + /// Checks the waiting merge against the sheet and earlier ranges, records it, and tells the format. + private void BeginMerge(int column) + { + int rows = _pendingRows; + int columns = _pendingColumns; + _pendingRows = 0; + _pendingColumns = 0; + + if (columns > _columns.Length - column) + { + throw Refuse($"A merge of {columns} columns from column {column + 1} reaches past the sheet's {_columns.Length} columns."); + } + + long[] coveredFrom = _coveredFrom!; + long[] coveredThrough = _coveredThrough!; + long last = _rowNumber + rows - 1; + + for (int c = column; c < column + columns; c++) + { + if (coveredThrough[c] >= _rowNumber && coveredFrom[c] <= last) + { + throw Refuse($"The merge from row {_rowNumber}, column {column + 1} overlaps a range declared earlier."); + } + } + + for (int c = column; c < column + columns; c++) + { + // The top-left cell holds the value; every other position in the range is covered. + coveredFrom[c] = c == column ? _rowNumber + 1 : _rowNumber; + coveredThrough[c] = c == column && rows == 1 ? 0 : last; + } + + _lastCoveredRow = Math.Max(_lastCoveredRow, last); + _merges++; + _sheet.Merge(rows, columns); + } + + private void ExpectRangesClosed() + { + if (_lastCoveredRow > _rowNumber) + { + throw Refuse($"A merged range reaches row {_lastCoveredRow}, but sheet \"{_sheetName}\" ends at row {_rowNumber}."); + } + } + private void Check(string? code, int column) { if (code is null) diff --git a/src/TriasDev.Tabular/Xlsx/XlsxParts.cs b/src/TriasDev.Tabular/Xlsx/XlsxParts.cs index a36ba33..2aaa076 100644 --- a/src/TriasDev.Tabular/Xlsx/XlsxParts.cs +++ b/src/TriasDev.Tabular/Xlsx/XlsxParts.cs @@ -13,7 +13,9 @@ internal static class XlsxParts + ""; - public const string WorksheetEnd = ""; + public const string SheetDataEnd = ""; + + public const string WorksheetClose = ""; public static readonly byte[] PackageRelationships = Encoding.UTF8.GetBytes( XmlDeclaration @@ -41,7 +43,7 @@ public static byte[] ContentTypes(int sheets) return Encoding.UTF8.GetBytes(xml.Append("").ToString()); } - public static byte[] Workbook(IReadOnlyList sheetNames) + public static byte[] Workbook(IReadOnlyList sheetNames, IReadOnlyList<(int Sheet, string Range)> filters) { StringBuilder xml = new( XmlDeclaration @@ -55,7 +57,23 @@ public static byte[] Workbook(IReadOnlyList sheetNames) xml.Append(CultureInfo.InvariantCulture, $"\" sheetId=\"{i + 1}\" r:id=\"rId{i + 1}\"/>"); } - return Encoding.UTF8.GetBytes(xml.Append("").ToString()); + xml.Append(""); + + if (filters.Count > 0) + { + xml.Append(""); + + foreach ((int sheet, string range) in filters) + { + xml.Append(CultureInfo.InvariantCulture, $""); + } + + xml.Append(""); + } + + return Encoding.UTF8.GetBytes(xml.Append("").ToString()); } public static byte[] WorkbookRelationships(int sheets) diff --git a/src/TriasDev.Tabular/Xlsx/XlsxSheetWriter.cs b/src/TriasDev.Tabular/Xlsx/XlsxSheetWriter.cs index 1d70e53..dbfbcc9 100644 --- a/src/TriasDev.Tabular/Xlsx/XlsxSheetWriter.cs +++ b/src/TriasDev.Tabular/Xlsx/XlsxSheetWriter.cs @@ -44,13 +44,23 @@ internal sealed class XlsxSheetWriter : ISheetWriter private readonly ZipWriter _zip; private readonly XlsxStyles _styles; private readonly List _sheetNames = []; + private readonly List<(int Sheet, string Range)> _filters = []; + private readonly List _merges = []; private readonly RowText _row = new(); private ArrayBufferWriter _bytes = new(16 * 1024); private Stream? _sheet; private string[] _columnNames = []; private long _rowNumber; + private bool _filter; private int _column; + // A merged range's covered positions are written empty in the top-left cell's style, so Excel + // finds a bordered range's right and bottom edges on the cells that sit there. Per column, the + // style of the latest range over it; allocated on the first styled range, so a sheet without + // styled merges pays nothing. + private int[]? _coverXf; + private int _pendingColumns; + public XlsxSheetWriter(SpillBuffer output, XlsxWriterOptions options, StyleTable styles) { _styles = new XlsxStyles(styles); @@ -73,7 +83,7 @@ public void Dispose() public bool NamesSheets => true; - public void BeginSheet(string name, ReadOnlySpan columns) + public void BeginSheet(string name, ReadOnlySpan columns, SheetOptions options) { CloseSheet(); _sheetNames.Add(name); @@ -86,8 +96,13 @@ public void BeginSheet(string name, ReadOnlySpan columns) } _rowNumber = 0; + _merges.Clear(); + _coverXf = null; + _pendingColumns = 0; + _filter = options.AutoFilter; _row.Clear(); _row.Append(XlsxParts.WorksheetStart); + AppendFreeze(options.FreezeRows, options.FreezeColumns); AppendWidths(columns); _row.Append(""); Emit(); @@ -103,7 +118,7 @@ public void BeginRow() _row.Append("\">"); } - public string? WriteHeader(string value) => WriteInline(value, 0); + public string? WriteHeader(string value, int style) => WriteInline(value, style == 0 ? 0 : _styles.Xf(style, ValueKind.Text)); public string? WriteText(string value, int column, int style) => WriteInline(value, style == 0 ? 0 : _styles.Xf(style, ValueKind.Text)); @@ -163,10 +178,35 @@ public void WriteBoolean(bool value, int style) _row.Append(ValueEnd); } + public int MaxMerges => 65_536; + + public void Merge(int rows, int columns) + { + _merges.Add(string.Create(CultureInfo.InvariantCulture, $"{_columnNames[_column]}{_rowNumber}:{_columnNames[_column + columns - 1]}{_rowNumber + rows - 1}")); + _pendingColumns = columns; + } + + public void WriteCovered() + { + if (_coverXf is { } cover && cover[_column] != 0) + { + StartCell(cover[_column]); + _row.Append("/>"); + return; + } + + _column++; + } + public void WriteEmpty(int style) { if (style == 0) { + if (_pendingColumns != 0) + { + TakeRange(0); + } + _column++; return; } @@ -186,7 +226,7 @@ public void Complete() CloseSheet(); _zip.AddStored("[Content_Types].xml", XlsxParts.ContentTypes(_sheetNames.Count)); _zip.AddStored("_rels/.rels", XlsxParts.PackageRelationships); - _zip.AddStored("xl/workbook.xml", XlsxParts.Workbook(_sheetNames)); + _zip.AddStored("xl/workbook.xml", XlsxParts.Workbook(_sheetNames, _filters)); _zip.AddStored("xl/_rels/workbook.xml.rels", XlsxParts.WorkbookRelationships(_sheetNames.Count)); _zip.AddStored("xl/styles.xml", _styles.Build()); _zip.Complete(); @@ -243,6 +283,11 @@ private static double Serial(DateTime value) /// Opens a cell at the current column, with its reference; the caller writes the rest. private void StartCell(int xf) { + if (_pendingColumns != 0) + { + TakeRange(xf); + } + _row.Append("The waiting merge's top-left cell is at the current column: its style is the range's. + private void TakeRange(int xf) + { + int columns = _pendingColumns; + _pendingColumns = 0; + + if (xf == 0 && _coverXf is null) + { + return; + } + + _coverXf ??= new int[_columnNames.Length]; + Array.Fill(_coverXf, xf, _column, columns); + } + private void AppendEscaped(ReadOnlySpan text) { while (true) @@ -296,6 +356,47 @@ private void AppendEscaped(ReadOnlySpan text) } } + /// Freezes the top rows and left columns: a split pane, in the state Excel writes for "Freeze Panes". + private void AppendFreeze(int rows, int columns) + { + if (rows == 0 && columns == 0) + { + return; + } + + string pane = (rows, columns) switch + { + ( > 0, > 0) => "bottomRight", + ( > 0, _) => "bottomLeft", + _ => "topRight", + }; + + _row.Append(" 0) + { + _row.Append(" xSplit=\""); + _row.AppendFormatted(columns, default, CultureInfo.InvariantCulture); + _row.Append('"'); + } + + if (rows > 0) + { + _row.Append(" ySplit=\""); + _row.AppendFormatted(rows, default, CultureInfo.InvariantCulture); + _row.Append('"'); + } + + _row.Append(" topLeftCell=\""); + _row.Append(XlsxParts.ColumnName(Math.Min(columns, 16_383))); + _row.AppendFormatted(rows + 1, default, CultureInfo.InvariantCulture); + _row.Append("\" activePane=\""); + _row.Append(pane); + _row.Append("\" state=\"frozen\"/>"); + } + private void AppendWidths(ReadOnlySpan columns) { bool any = false; @@ -342,6 +443,43 @@ private void Emit() } } + /// The filter over the header through the last row; the workbook names the same range for Excel. + private void AppendAutoFilter() + { + if (!_filter) + { + return; + } + + string last = _columnNames[^1]; + _row.Append(""); + _filters.Add((_sheetNames.Count - 1, string.Create(CultureInfo.InvariantCulture, $"$A$1:${last}${_rowNumber}"))); + } + + private void AppendMerges() + { + if (_merges.Count == 0) + { + return; + } + + _row.Append(""); + + foreach (string range in _merges) + { + _row.Append(""); + } + + _row.Append(""); + } + private void CloseSheet() { if (_sheet is null) @@ -350,7 +488,10 @@ private void CloseSheet() } _row.Clear(); - _row.Append(XlsxParts.WorksheetEnd); + _row.Append(XlsxParts.SheetDataEnd); + AppendAutoFilter(); + AppendMerges(); + _row.Append(XlsxParts.WorksheetClose); Emit(); _zip.EndEntry(); _sheet = null; diff --git a/tests/TriasDev.Tabular.Tests/Fixtures/LibreOffice.cs b/tests/TriasDev.Tabular.Tests/Fixtures/LibreOffice.cs index 7b19d90..4dd2d39 100644 --- a/tests/TriasDev.Tabular.Tests/Fixtures/LibreOffice.cs +++ b/tests/TriasDev.Tabular.Tests/Fixtures/LibreOffice.cs @@ -26,15 +26,70 @@ public static string[] ConvertToCsv(byte[] file, string extension) => .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) + /// + /// Opens a file in LibreOffice through a macro, so the document has a view, and stores it again as ods. + /// A headless conversion drops view state such as frozen panes; a document opened by a macro keeps it, + /// and the resaved file states what LibreOffice understood of the original. + /// + public static byte[] Resave(byte[] file, string extension) { - if (Soffice is null) + string soffice = Require(); + string folder = Path.Combine(Path.GetTempPath(), "tabular-soffice-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(folder); + + try { - Assert.False(Environment.GetEnvironmentVariable("TABULAR_REQUIRE_SOFFICE") == "1", "LibreOffice is required here but soffice was not found."); - Assert.Skip("LibreOffice is not installed."); + string input = Path.Combine(folder, "file." + extension); + string output = Path.Combine(folder, "resaved.ods"); + string profile = Path.Combine(folder, "profile"); + File.WriteAllBytes(input, file); + + string[] profileArguments = ["--headless", "--invisible", $"-env:UserInstallation={new Uri(profile).AbsoluteUri}"]; + + // The first start creates the profile and overwrites its Standard library, so it runs before the macro is written. + Run(soffice, [.. profileArguments, "--terminate_after_init"]); + + string library = Path.Combine(profile, "user", "basic"); + string standard = Path.Combine(library, "Standard"); + Directory.CreateDirectory(standard); + const string Header = "\n"; + const string Namespace = "xmlns:script=\"http://openoffice.org/2000/script\""; + const string Libraries = ""; + static string Library(string elements) => + "" + elements + ""; + File.WriteAllText(Path.Combine(library, "script.xlc"), Header + "\n" + Libraries); + File.WriteAllText(Path.Combine(library, "dialog.xlc"), Header + "\n" + Libraries); + File.WriteAllText(Path.Combine(standard, "script.xlb"), Header + "\n" + Library("")); + File.WriteAllText(Path.Combine(standard, "dialog.xlb"), Header + "\n" + Library(string.Empty)); + + string code = "Sub Main\n" + + " Dim noArgs()\n" + + " Dim doc As Object\n" + + $" doc = StarDesktop.loadComponentFromURL(\"{new Uri(input).AbsoluteUri}\", \"_blank\", 0, noArgs())\n" + + " Dim args(0) As New com.sun.star.beans.PropertyValue\n" + + " args(0).Name = \"FilterName\"\n" + + " args(0).Value = \"calc8\"\n" + + $" doc.storeToURL(\"{new Uri(output).AbsoluteUri}\", args())\n" + + " doc.close(True)\n" + + "End Sub\n"; + File.WriteAllText(Path.Combine(standard, "Module1.xba"), Header + "\n" + + $"{System.Security.SecurityElement.Escape(code)}"); + + string log = Run(soffice, [.. profileArguments, "macro:///Standard.Module1.Main"]); + Assert.True(File.Exists(output), $"LibreOffice could not open and resave the file: {log}"); + + return File.ReadAllBytes(output); + } + finally + { + Cleanup(folder); } + } + /// 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) + { + string soffice = Require(); string folder = Path.Combine(Path.GetTempPath(), "tabular-soffice-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(folder); @@ -43,10 +98,9 @@ public static byte[] Convert(byte[] file, string extension, string filter, strin string input = Path.Combine(folder, "file." + extension); File.WriteAllBytes(input, file); - ProcessStartInfo start = new(Soffice) - { - ArgumentList = - { + string output = Run( + soffice, + [ "--headless", $"-env:UserInstallation={new Uri(Path.Combine(folder, "profile")).AbsoluteUri}", "--convert-to", @@ -54,59 +108,87 @@ public static byte[] Convert(byte[] file, string extension, string filter, strin "--outdir", folder, input, - }, - RedirectStandardOutput = true, - RedirectStandardError = true, - }; + ]); - using Process process = Process.Start(start)!; + string converted = Path.Combine(folder, "file." + outputExtension); + Assert.True(File.Exists(converted), $"LibreOffice could not convert the file: {output}"); - try - { - Task standardOutput = process.StandardOutput.ReadToEndAsync(); - Task standardError = process.StandardError.ReadToEndAsync(); + return File.ReadAllBytes(converted); + } + finally + { + Cleanup(folder); + } + } + + private static string Require() + { + if (Soffice is null) + { + Assert.False(Environment.GetEnvironmentVariable("TABULAR_REQUIRE_SOFFICE") == "1", "LibreOffice is required here but soffice was not found."); + Assert.Skip("LibreOffice is not installed."); + } - if (!process.WaitForExit(120_000)) - { - process.Kill(entireProcessTree: true); - process.WaitForExit(); - Assert.Fail("LibreOffice did not finish within two minutes and was killed."); - } + return Soffice; + } - // 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(); + /// Runs LibreOffice to its end, killed after two minutes, and returns what it wrote to its output. + private static string Run(string soffice, IEnumerable arguments) + { + ProcessStartInfo start = new(soffice) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + }; - string converted = Path.Combine(folder, "file." + outputExtension); - Assert.True(File.Exists(converted), $"LibreOffice could not convert the file: {output}"); + foreach (string argument in arguments) + { + start.ArgumentList.Add(argument); + } - return File.ReadAllBytes(converted); - } - finally + using Process process = Process.Start(start)!; + + try + { + Task standardOutput = process.StandardOutput.ReadToEndAsync(); + Task standardError = process.StandardError.ReadToEndAsync(); + + if (!process.WaitForExit(120_000)) { - if (!process.HasExited) - { - process.Kill(entireProcessTree: true); - process.WaitForExit(); - } + process.Kill(entireProcessTree: true); + process.WaitForExit(); + Assert.Fail("LibreOffice did not finish within two minutes and was killed."); } + + // The pipes close with the process; read after it exited so a full pipe cannot stall it. + return standardOutput.GetAwaiter().GetResult() + standardError.GetAwaiter().GetResult(); } finally { - try + if (!process.HasExited) { - Directory.Delete(folder, recursive: true); - } - catch (IOException) - { - // A leftover temp folder must not mask the test's own outcome. - } - catch (UnauthorizedAccessException) - { - // Same. + process.Kill(entireProcessTree: true); + process.WaitForExit(); } } } + private static void Cleanup(string folder) + { + try + { + Directory.Delete(folder, recursive: true); + } + catch (IOException) + { + // A leftover temp folder must not mask the test's own outcome. + } + catch (UnauthorizedAccessException) + { + // Same. + } + } + private static string? Find() { string[] candidates = diff --git a/tests/TriasDev.Tabular.Tests/Writing/CsvTextTests.cs b/tests/TriasDev.Tabular.Tests/Writing/CsvTextTests.cs index a560f86..ef887ed 100644 --- a/tests/TriasDev.Tabular.Tests/Writing/CsvTextTests.cs +++ b/tests/TriasDev.Tabular.Tests/Writing/CsvTextTests.cs @@ -172,7 +172,7 @@ public async Task AHugeRowDoesNotKeepItsBufferForTheRestOfTheFile() { using SpillBuffer buffer = new(); CsvSheetWriter sheet = new(buffer, CsvWriterOptions.Default.Resolve()); - sheet.BeginSheet("data", [new("v")]); + sheet.BeginSheet("data", [new("v")], SheetOptions.Default); sheet.BeginRow(); Assert.Null(sheet.WriteText(new string('x', 10_000_000), 0, 0)); diff --git a/tests/TriasDev.Tabular.Tests/Writing/MergeTests.cs b/tests/TriasDev.Tabular.Tests/Writing/MergeTests.cs new file mode 100644 index 0000000..e847e3d --- /dev/null +++ b/tests/TriasDev.Tabular.Tests/Writing/MergeTests.cs @@ -0,0 +1,394 @@ +using System.Text.RegularExpressions; + +using TriasDev.Tabular.Tests.Fixtures; + +using Xunit; + +namespace TriasDev.Tabular.Tests.Writing; + +/// Merged cells: declared before their top-left cell, covered positions skipped and written by the writer. +public sealed class MergeTests +{ + private static CancellationToken Token => TestContext.Current.CancellationToken; + + private static readonly WriteColumn[] Four = [new("a"), new("b"), new("c"), new("d")]; + + /// + /// Row 2: a 1 × 2 title in a–b, then c, d. Rows 3–4: a 2 × 2 block in b–c (b3 top-left), a and d around it. + /// + internal static void Legend(TabularWriter writer) + { + writer.BeginSheet("legend", Four); + writer.BeginRow(); + writer.Merge(1, 2); + writer.Write("title"); + writer.Write("c2"); + writer.Write("d2"); + writer.EndRow(); + writer.BeginRow(); + writer.Write("a3"); + writer.Merge(2, 2); + writer.Write("block"); + writer.Write("d3"); + writer.EndRow(); + writer.BeginRow(); + writer.Write("a4"); + writer.Write("d4"); // b4 and c4 are covered: this lands in d + writer.EndRow(); + } + + [Fact] + public async Task CsvWritesTheValueInTheTopLeftCellAndEmptiesElsewhere() + { + byte[] csv = await SheetLayoutTests.Write(TabularFormat.Csv, Legend); + List rows = SheetLayoutTests.Rows(csv); + + Assert.Equal(["title", "", "c2", "d2"], rows[1].Select(c => c.Text ?? string.Empty)); + Assert.Equal(["a3", "block", "", "d3"], rows[2].Select(c => c.Text ?? string.Empty)); + Assert.Equal(["a4", "", "", "d4"], rows[3].Select(c => c.Text ?? string.Empty)); + } + + /// + /// Row 2: a2, an unstyled empty, then a 2 × 2 "wide" in c–d. Row 3 ends after a3: b3 is an empty and + /// c3–d3 are covered, all written by EndRow. + /// + internal static void EndsEarly(TabularWriter writer) + { + writer.BeginSheet("data", Four); + writer.BeginRow(); + writer.Write("a2"); + writer.WriteEmpty(); + writer.Merge(2, 2); + writer.Write("wide"); + writer.EndRow(); + writer.BeginRow(); + writer.Write("a3"); + writer.EndRow(); + } + + [Theory] + [InlineData(TabularFormat.Csv)] + [InlineData(TabularFormat.Xlsx)] + [InlineData(TabularFormat.Ods)] + public async Task ARowThatEndsEarlyStillCoversItsMergedColumns(TabularFormat format) + { + byte[] file = await SheetLayoutTests.Write(format, EndsEarly); + List rows = SheetLayoutTests.Rows(file); + + Assert.Equal(RawCell.FromText("wide"), rows[1][2]); + Assert.Equal(RawCell.FromText("a3"), rows[2][0]); + + // A reader may drop a row's trailing empties; whatever it keeps after a3 is empty. + Assert.All(rows[2].Skip(1), cell => Assert.True(cell.IsEmpty)); + + if (format == TabularFormat.Xlsx) + { + Assert.Empty(OoxmlValidation.Errors(file)); + } + + if (format == TabularFormat.Ods) + { + string content = SheetLayoutTests.Entry(file, "content.xml"); + Assert.Contains("a2wide", content, StringComparison.Ordinal); + Assert.Contains("a3", content, StringComparison.Ordinal); + } + } + + [Theory] + [InlineData(0, 2)] + [InlineData(2, 0)] + [InlineData(1, 1)] + [InlineData(-1, 2)] + public async Task AnEmptyOrSingleCellRangeIsRefused(int rows, int columns) + { + await using TabularWriter writer = TabularWriter.Create(new WriteTarget(), TabularFormat.Csv); + writer.BeginSheet("data", Four); + writer.BeginRow(); + + Assert.Throws(() => writer.Merge(rows, columns)); + } + + [Fact] + public async Task ASecondMergeBeforeACellIsRefused() + { + await using TabularWriter writer = TabularWriter.Create(new WriteTarget(), TabularFormat.Csv); + writer.BeginSheet("data", Four); + writer.BeginRow(); + writer.Merge(1, 2); + + Assert.Throws(() => writer.Merge(1, 2)); + } + + [Fact] + public async Task AMergeNoCellFollowedIsRefusedAtTheEndOfTheRow() + { + await using TabularWriter writer = TabularWriter.Create(new WriteTarget(), TabularFormat.Csv); + writer.BeginSheet("data", Four); + writer.BeginRow(); + writer.Merge(1, 2); + + Assert.Throws(writer.EndRow); + } + + [Fact] + public async Task ARangePastTheLastColumnIsRefused() + { + await using TabularWriter writer = TabularWriter.Create(new WriteTarget(), TabularFormat.Csv); + writer.BeginSheet("data", Four); + writer.BeginRow(); + writer.Write("a"); + writer.Write("b"); + writer.Merge(1, 3); + + Assert.Throws(() => writer.Write("c")); + } + + [Fact] + public async Task ARangeOverlappingAnotherIsRefused() + { + await using TabularWriter writer = TabularWriter.Create(new WriteTarget(), TabularFormat.Csv); + writer.BeginSheet("data", Four); + writer.BeginRow(); + writer.Write("a2"); + writer.Write("b2"); + writer.Merge(3, 1); // c2–c4 + writer.Write("tall"); + writer.EndRow(); + writer.BeginRow(); + writer.Merge(1, 3); // a3–c3 would cover c3, which the tall range covers + + Assert.Throws(() => writer.Write("wide")); + } + + [Fact] + public async Task ASheetEndedWhileARangeStillCoversRowsIsRefused() + { + await using TabularWriter writer = TabularWriter.Create(new WriteTarget(), TabularFormat.Xlsx); + writer.BeginSheet("data", Four); + writer.BeginRow(); + writer.Merge(3, 1); + writer.Write("tall"); + writer.EndRow(); + + Assert.Throws(() => writer.BeginSheet("next", Four)); + } + + [Fact] + public async Task CompletingWhileARangeStillCoversRowsIsRefused() + { + await using TabularWriter writer = TabularWriter.Create(new WriteTarget(), TabularFormat.Csv); + writer.BeginSheet("data", Four); + writer.BeginRow(); + writer.Merge(2, 1); + writer.Write("tall"); + writer.EndRow(); + + await Assert.ThrowsAsync(async () => await writer.CompleteAsync(Token)); + } + + [Fact] + public async Task AMergeOutsideARowIsRefused() + { + await using TabularWriter writer = TabularWriter.Create(new WriteTarget(), TabularFormat.Csv); + writer.BeginSheet("data", Four); + + Assert.Throws(() => writer.Merge(1, 2)); + } + + [Fact] + public async Task AWriteIntoAFullyCoveredRowIsRefused() + { + await using TabularWriter writer = TabularWriter.Create(new WriteTarget(), TabularFormat.Csv); + writer.BeginSheet("data", [new("a"), new("b")]); + writer.BeginRow(); + writer.Merge(2, 2); + writer.Write("all"); + writer.EndRow(); + writer.BeginRow(); + + Assert.Throws(() => writer.Write("x")); + } + + [Fact] + public async Task XlsxListsTheRangesAfterTheData() + { + byte[] xlsx = await SheetLayoutTests.Write(TabularFormat.Xlsx, Legend); + string sheet = SheetLayoutTests.Entry(xlsx, "xl/worksheets/sheet1.xml"); + + Assert.Empty(OoxmlValidation.Errors(xlsx)); + Assert.Contains("", sheet, StringComparison.Ordinal); + Assert.Contains("c2", sheet, StringComparison.Ordinal); + Assert.Contains("d4", sheet, StringComparison.Ordinal); + } + + [Fact] + public async Task XlsxPutsTheRangesAfterTheFilter() + { + byte[] xlsx = await SheetLayoutTests.Write(TabularFormat.Xlsx, writer => + { + writer.BeginSheet("data", Four, new SheetOptions { AutoFilter = true }); + writer.BeginRow(); + writer.Merge(1, 2); + writer.Write("x"); + writer.EndRow(); + }); + + Assert.Empty(OoxmlValidation.Errors(xlsx)); + Assert.Contains("", SheetLayoutTests.Entry(xlsx, "xl/worksheets/sheet1.xml"), StringComparison.Ordinal); + } + + [Theory] + [InlineData(TabularFormat.Xlsx)] + [InlineData(TabularFormat.Ods)] + [InlineData(TabularFormat.Csv)] + public async Task TheImportReadsAMergeAsItsValueAndEmpties(TabularFormat format) + { + byte[] file = await SheetLayoutTests.Write(format, Legend); + List rows = SheetLayoutTests.Rows(file); + + Assert.Equal(RawCell.FromText("title"), rows[1][0]); + Assert.True(rows[1][1].IsEmpty); + Assert.Equal(RawCell.FromText("c2"), rows[1][2]); + Assert.Equal(RawCell.FromText("block"), rows[2][1]); + Assert.True(rows[3][1].IsEmpty); + Assert.True(rows[3][2].IsEmpty); + Assert.Equal(RawCell.FromText("d4"), rows[3][3]); + } + + [Fact] + public async Task XlsxWritesTheCoveredPositionsOfABorderedRangeInTheTopLeftStyle() + { + byte[] xlsx = await SheetLayoutTests.Write(TabularFormat.Xlsx, writer => + { + StyleId boxed = writer.Style(new CellStyle { Border = CellBorder.Thin(CellColor.FromRgb(0x000000)) }); + writer.BeginSheet("data", Four); + writer.BeginRow(); + writer.Write("a2"); + writer.Merge(2, 2); + writer.Write("box", boxed); + writer.Write("d2"); + writer.EndRow(); + writer.BeginRow(); + writer.Write("a3"); + writer.EndRow(); + }); + + string sheet = SheetLayoutTests.Entry(xlsx, "xl/worksheets/sheet1.xml"); + Match top = Regex.Match(sheet, ""); + + Assert.True(top.Success); + string s = top.Groups[1].Value; + Assert.Contains($"", sheet, StringComparison.Ordinal); + Assert.Contains($"", sheet, StringComparison.Ordinal); + Assert.Empty(OoxmlValidation.Errors(xlsx)); + + List rows = SheetLayoutTests.Rows(xlsx); + Assert.Equal(RawCell.FromText("box"), rows[1][1]); + Assert.True(rows[1][2].IsEmpty); + Assert.True(rows[2][1].IsEmpty); + Assert.True(rows[2][2].IsEmpty); + } + + [Fact] + public async Task XlsxLeavesTheCoveredPositionsOfAnUnstyledRangeOut() + { + byte[] xlsx = await SheetLayoutTests.Write(TabularFormat.Xlsx, writer => + { + StyleId boxed = writer.Style(new CellStyle { Border = CellBorder.Thin(CellColor.FromRgb(0x000000)) }); + writer.BeginSheet("data", Four); + writer.BeginRow(); + writer.Merge(2, 2); + writer.Write("box", boxed); // a2–b3 + writer.EndRow(); + writer.BeginRow(); + writer.EndRow(); + writer.BeginRow(); + writer.Merge(2, 2); // a4–b5, unstyled: the boxed range's style must not leak into it + writer.Write("plain"); + writer.EndRow(); + writer.BeginRow(); + writer.Write("c5"); + writer.EndRow(); + }); + + string sheet = SheetLayoutTests.Entry(xlsx, "xl/worksheets/sheet1.xml"); + Assert.Contains("c5", sheet, StringComparison.Ordinal); + Assert.Empty(OoxmlValidation.Errors(xlsx)); + } + + [Fact] + public async Task TheMergeLimitIsTheFormats() + { + await using TabularWriter writer = TabularWriter.Create(new WriteTarget(), TabularFormat.Xlsx); + writer.BeginSheet("data", [new("a"), new("b")]); + + for (int i = 0; i < 65_536; i++) + { + writer.BeginRow(); + writer.Merge(1, 2); + writer.Write(i); + writer.EndRow(); + } + + writer.BeginRow(); + Assert.Throws(() => writer.Merge(1, 2)); + } + + [Fact] + public async Task OdsSpansTheTopLeftCellAndCoversTheRest() + { + byte[] ods = await SheetLayoutTests.Write(TabularFormat.Ods, Legend); + string content = SheetLayoutTests.Entry(ods, "content.xml"); + + Assert.Contains("title", content, StringComparison.Ordinal); + Assert.Contains("block", content, StringComparison.Ordinal); + Assert.Contains("a4d4", content, StringComparison.Ordinal); + } + + [Fact] + public async Task OdsSpansAStyledAndAnEmptyTopLeftCell() + { + byte[] ods = await SheetLayoutTests.Write(TabularFormat.Ods, writer => + { + StyleId fill = writer.Style(new CellStyle { Fill = CellColor.FromRgb(0xF8696B) }); + writer.BeginSheet("data", Four); + writer.BeginRow(); + writer.Merge(1, 2); + writer.Write(5.5, fill); + writer.Merge(1, 2); + writer.WriteEmpty(fill); + writer.EndRow(); + }); + + string content = SheetLayoutTests.Entry(ods, "content.xml"); + Assert.Contains("", content, StringComparison.Ordinal); + Assert.Contains("", content, StringComparison.Ordinal); + } + + [Fact] + public async Task OdsWritesARunOfEmptyCellsAsOne() + { + byte[] ods = await SheetLayoutTests.Write(TabularFormat.Ods, writer => + { + writer.BeginSheet("data", [new("a"), new("b"), new("c"), new("d"), new("e")]); + writer.BeginRow(); + writer.Write("a2"); + writer.WriteEmpty(); + writer.WriteEmpty(); + writer.WriteEmpty(); + writer.Write("e2"); + writer.EndRow(); + writer.BeginRow(); + writer.Write("a3"); + writer.EndRow(); + }); + + string content = SheetLayoutTests.Entry(ods, "content.xml"); + Assert.Contains("a2e2", content, StringComparison.Ordinal); + Assert.Contains("a3", content, StringComparison.Ordinal); + + List rows = SheetLayoutTests.Rows(ods); + Assert.Equal(RawCell.FromText("e2"), rows[1][4]); + Assert.True(rows[1][2].IsEmpty); + } +} diff --git a/tests/TriasDev.Tabular.Tests/Writing/OdsRoundTripTests.cs b/tests/TriasDev.Tabular.Tests/Writing/OdsRoundTripTests.cs index 6d2cbc9..ad38f57 100644 --- a/tests/TriasDev.Tabular.Tests/Writing/OdsRoundTripTests.cs +++ b/tests/TriasDev.Tabular.Tests/Writing/OdsRoundTripTests.cs @@ -202,7 +202,7 @@ public async Task AMillionRowSheetReadsBackToItsLastRowAndTheNextIsRefused() Assert.Equal(RawCell.FromNumber(1_048_575), last); await using TabularWriter full = TabularWriter.Create(new WriteTarget(), TabularFormat.Ods); - full.BeginSheet("data", [new("n")]); + full.BeginSheet("data", [new("n")], SheetOptions.Default); for (long n = 1; n < 1_048_576; n++) { diff --git a/tests/TriasDev.Tabular.Tests/Writing/SheetLayoutTests.cs b/tests/TriasDev.Tabular.Tests/Writing/SheetLayoutTests.cs new file mode 100644 index 0000000..7a5e3fc --- /dev/null +++ b/tests/TriasDev.Tabular.Tests/Writing/SheetLayoutTests.cs @@ -0,0 +1,203 @@ +using System.IO.Compression; +using System.Text; + +using TriasDev.Tabular.Tests.Fixtures; + +using Xunit; + +namespace TriasDev.Tabular.Tests.Writing; + +/// How a sheet is laid out: header style, frozen panes, filter, merges. +public sealed class SheetLayoutTests +{ + private static readonly CellStyle Header = new() { Fill = CellColor.FromRgb(0x1F4E78), Font = new CellFont { Color = CellColor.FromRgb(0xFFFFFF), Bold = true } }; + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + internal static async Task Write(TabularFormat format, Action write) + { + WriteTarget target = new(); + + await using (TabularWriter writer = TabularWriter.Create(target, format)) + { + write(writer); + await writer.CompleteAsync(Token); + } + + return target.ToArray(); + } + + internal static string Entry(byte[] zip, string name) + { + using ZipArchive archive = new(new MemoryStream(zip, writable: false), ZipArchiveMode.Read); + ZipArchiveEntry? entry = archive.GetEntry(name); + Assert.NotNull(entry); + using StreamReader reader = new(entry.Open(), Encoding.UTF8); + return reader.ReadToEnd(); + } + + internal static bool HasEntry(byte[] zip, string name) + { + using ZipArchive archive = new(new MemoryStream(zip, writable: false), ZipArchiveMode.Read); + return archive.GetEntry(name) is not null; + } + + internal static List Rows(byte[] file, int sheet = 0) + { + using ITabularCursor cursor = TabularFile.Open(new MemoryStream(file, writable: false), "file", cancellationToken: Token); + Assert.True(cursor.MoveToSheet(sheet, Token)); + List rows = []; + + while (cursor.ReadRow(Token)) + { + rows.Add(cursor.CurrentRow.ToArray()); + } + + return rows; + } + + private static void Frozen(TabularWriter writer) + { + writer.BeginSheet("data", [new("a"), new("b"), new("c")], new SheetOptions { HeaderStyle = Header, FreezeRows = 1, FreezeColumns = 1 }); + writer.BeginRow(); + writer.Write("x"); + writer.Write(1L); + writer.Write(2L); + writer.EndRow(); + writer.BeginSheet("rows only", [new("a")], new SheetOptions { FreezeRows = 2 }); + writer.BeginSheet("plain", [new("a")]); + } + + [Fact] + public async Task XlsxFreezesEachSheetAsAsked() + { + byte[] xlsx = await Write(TabularFormat.Xlsx, Frozen); + + Assert.Empty(OoxmlValidation.Errors(xlsx)); + Assert.Contains("", Entry(xlsx, "xl/worksheets/sheet1.xml"), StringComparison.Ordinal); + Assert.Contains("", Entry(xlsx, "xl/worksheets/sheet2.xml"), StringComparison.Ordinal); + Assert.DoesNotContain("", Entry(xlsx, "xl/worksheets/sheet3.xml"), StringComparison.Ordinal); + } + + [Fact] + public async Task OdsFreezesEachSheetInItsSettings() + { + byte[] ods = await Write(TabularFormat.Ods, Frozen); + string settings = Entry(ods, "settings.xml"); + + Assert.Contains("2211", settings, StringComparison.Ordinal); + Assert.Contains("config:name=\"rows only\">02", settings, StringComparison.Ordinal); + Assert.DoesNotContain("config:name=\"plain\"", settings, StringComparison.Ordinal); + Assert.Contains("manifest:full-path=\"settings.xml\"", Entry(ods, "META-INF/manifest.xml"), StringComparison.Ordinal); + + // The "ooo:" in the view-settings name is a QName; without its declaration LibreOffice ignores the set. + Assert.Contains("xmlns:ooo=\"http://openoffice.org/2004/office\"", settings, StringComparison.Ordinal); + } + + [Fact] + public async Task OdsWithoutFrozenPanesHasNoSettings() + { + byte[] ods = await Write(TabularFormat.Ods, writer => writer.BeginSheet("plain", [new("a")])); + + Assert.False(HasEntry(ods, "settings.xml")); + Assert.DoesNotContain("settings.xml", Entry(ods, "META-INF/manifest.xml"), StringComparison.Ordinal); + } + + [Theory] + [InlineData(TabularFormat.Xlsx)] + [InlineData(TabularFormat.Ods)] + [InlineData(TabularFormat.Csv)] + public async Task AStyledHeaderReadsBackAsTheSameHeader(TabularFormat format) + { + byte[] styled = await Write(format, writer => + { + writer.BeginSheet("data", [new("Name"), new("Count")], new SheetOptions { HeaderStyle = Header }); + writer.BeginRow(); + writer.Write("x"); + writer.Write(1L); + writer.EndRow(); + }); + + List rows = Rows(styled); + Assert.Equal([RawCell.FromText("Name"), RawCell.FromText("Count")], rows[0]); + Assert.Equal(format == TabularFormat.Csv ? RawCell.FromText("1") : RawCell.FromNumber(1), rows[1][1]); + } + + [Fact] + public async Task TheXlsxHeaderCarriesItsStyle() + { + byte[] xlsx = await Write(TabularFormat.Xlsx, writer => writer.BeginSheet("data", [new("Name")], new SheetOptions { HeaderStyle = Header })); + + Assert.Contains("", Entry(xlsx, "xl/worksheets/sheet1.xml"), StringComparison.Ordinal); + Assert.Contains("", Entry(xlsx, "xl/styles.xml"), StringComparison.Ordinal); + } + + [Fact] + public async Task TheOdsHeaderCarriesItsStyle() + { + byte[] ods = await Write(TabularFormat.Ods, writer => writer.BeginSheet("data", [new("Name")], new SheetOptions { HeaderStyle = Header })); + + Assert.Contains("", Entry(ods, "content.xml"), StringComparison.Ordinal); + Assert.Contains("fo:background-color=\"#1F4E78\"", Entry(ods, "styles.xml"), StringComparison.Ordinal); + } + + [Theory] + [InlineData(-1, 0)] + [InlineData(0, -1)] + [InlineData(0, 4)] + [InlineData(1_048_576, 0)] + public async Task AFreezeOutsideTheSheetIsRefused(int rows, int columns) + { + await using TabularWriter writer = TabularWriter.Create(new WriteTarget(), TabularFormat.Xlsx); + + Assert.Throws(() => writer.BeginSheet("data", [new("a"), new("b"), new("c")], new SheetOptions { FreezeRows = rows, FreezeColumns = columns })); + } + + [Fact] + public async Task CsvIgnoresTheLayout() + { + byte[] plain = await Write(TabularFormat.Csv, writer => writer.BeginSheet("data", [new("a"), new("b")])); + byte[] laidOut = await Write(TabularFormat.Csv, writer => writer.BeginSheet("data", [new("a"), new("b")], new SheetOptions { HeaderStyle = Header, FreezeRows = 1, FreezeColumns = 1, AutoFilter = true })); + + Assert.Equal(plain, laidOut); + } + + private static void Filtered(TabularWriter writer) + { + writer.BeginSheet("Bob's data", [new("a"), new("b"), new("c")], new SheetOptions { AutoFilter = true }); + + for (int i = 0; i < 3; i++) + { + writer.BeginRow(); + writer.Write((long)i); + writer.EndRow(); + } + + writer.BeginSheet("plain", [new("a")]); + writer.BeginSheet("second", [new("a"), new("b")], new SheetOptions { AutoFilter = true }); + } + + [Fact] + public async Task XlsxFiltersTheHeaderThroughTheLastRow() + { + byte[] xlsx = await Write(TabularFormat.Xlsx, Filtered); + + Assert.Empty(OoxmlValidation.Errors(xlsx)); + Assert.Contains("", Entry(xlsx, "xl/worksheets/sheet1.xml"), StringComparison.Ordinal); + Assert.DoesNotContain("autoFilter", Entry(xlsx, "xl/worksheets/sheet2.xml"), StringComparison.Ordinal); + Assert.Contains("", Entry(xlsx, "xl/worksheets/sheet3.xml"), StringComparison.Ordinal); + + string workbook = Entry(xlsx, "xl/workbook.xml"); + Assert.Contains("", workbook, StringComparison.Ordinal); + } + + [Fact] + public async Task OdsFiltersTheHeaderThroughTheLastRow() + { + byte[] ods = await Write(TabularFormat.Ods, Filtered); + string content = Entry(ods, "content.xml"); + + Assert.Contains("", content, StringComparison.Ordinal); + Assert.Equal(4, Rows(ods).Count); + } +} diff --git a/tests/TriasDev.Tabular.Tests/Writing/StyledInteropTests.cs b/tests/TriasDev.Tabular.Tests/Writing/StyledInteropTests.cs index 3989edd..379c4ff 100644 --- a/tests/TriasDev.Tabular.Tests/Writing/StyledInteropTests.cs +++ b/tests/TriasDev.Tabular.Tests/Writing/StyledInteropTests.cs @@ -83,4 +83,195 @@ public async Task KeepsTheFillAndFontOfAnOds() Assert.Contains("rgb=\"FFF8696B\"", styles, StringComparison.OrdinalIgnoreCase); Assert.Matches("", styles); } + + [Fact] + public async Task LibreOfficeKeepsTheFilterOfAnOds() + { + byte[] ods = await SheetLayoutTests.Write(TabularFormat.Ods, writer => + { + writer.BeginSheet("Bob's data", [new("a"), new("b")], new SheetOptions { AutoFilter = true }); + writer.BeginRow(); + writer.Write(1L); + writer.EndRow(); + }); + + Assert.Contains(" + { + writer.BeginSheet("Bob's data", [new("a"), new("b")], new SheetOptions { AutoFilter = true }); + writer.BeginRow(); + writer.Write(1L); + writer.EndRow(); + }); + + Assert.Contains("table:display-filter-buttons=\"true\"", Entry(LibreOffice.Convert(xlsx, "xlsx", "ods", "ods"), "content.xml"), StringComparison.Ordinal); + } + + [Fact] + public async Task LibreOfficeKeepsTheMergesOfAnXlsx() + { + byte[] xlsx = await SheetLayoutTests.Write(TabularFormat.Xlsx, MergeTests.Legend); + string content = Entry(LibreOffice.Convert(xlsx, "xlsx", "ods", "ods"), "content.xml"); + + Assert.Contains("table:number-columns-spanned=\"2\"", content, StringComparison.Ordinal); + Assert.Contains("table:number-rows-spanned=\"2\"", content, StringComparison.Ordinal); + } + + [Fact] + public async Task LibreOfficeKeepsTheMergesOfAnOds() + { + byte[] ods = await SheetLayoutTests.Write(TabularFormat.Ods, MergeTests.Legend); + string sheet = Entry(LibreOffice.Convert(ods, "ods", "xlsx:Calc MS Excel 2007 XML", "xlsx"), "xl/worksheets/sheet1.xml"); + + Assert.Contains("", sheet, StringComparison.Ordinal); + Assert.Contains("", sheet, StringComparison.Ordinal); + } + + [Fact] + public async Task LibreOfficeKeepsTheMergeOfARowThatEndsEarly() + { + byte[] ods = await SheetLayoutTests.Write(TabularFormat.Ods, MergeTests.EndsEarly); + string sheet = Entry(LibreOffice.Convert(ods, "ods", "xlsx:Calc MS Excel 2007 XML", "xlsx"), "xl/worksheets/sheet1.xml"); + + Assert.Contains("", sheet, StringComparison.Ordinal); + } + + [Fact] + public async Task LibreOfficeKeepsTheMergeAndFillOfAnXlsx() + { + byte[] xlsx = await SheetLayoutTests.Write(TabularFormat.Xlsx, FilledMerge); + byte[] ods = LibreOffice.Convert(xlsx, "xlsx", "ods", "ods"); + string styles = Entry(ods, "content.xml") + Entry(ods, "styles.xml"); + + Assert.Contains("table:number-columns-spanned=\"3\"", styles, StringComparison.Ordinal); + Assert.Contains("fo:background-color=\"#f8696b\"", styles, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task LibreOfficeKeepsTheMergeAndFillOfAnOds() + { + byte[] ods = await SheetLayoutTests.Write(TabularFormat.Ods, FilledMerge); + byte[] xlsx = LibreOffice.Convert(ods, "ods", "xlsx:Calc MS Excel 2007 XML", "xlsx"); + + Assert.Contains("", Entry(xlsx, "xl/worksheets/sheet1.xml"), StringComparison.Ordinal); + Assert.Contains("rgb=\"FFF8696B\"", Entry(xlsx, "xl/styles.xml"), StringComparison.OrdinalIgnoreCase); + } + + private static void FilledMerge(TabularWriter writer) + { + StyleId fill = writer.Style(new CellStyle { Fill = CellColor.FromRgb(0xF8696B) }); + writer.BeginSheet("data", [new("a"), new("b"), new("c")]); + writer.BeginRow(); + writer.Merge(1, 3); + writer.Write("filled", fill); + writer.EndRow(); + } + + private static void ThreeFrozenSheets(TabularWriter writer) + { + writer.BeginSheet("rows", [new("a"), new("b")], new SheetOptions { FreezeRows = 1 }); + writer.BeginRow(); + writer.Write("x"); + writer.Write(1L); + writer.EndRow(); + writer.BeginSheet("columns", [new("a"), new("b"), new("c")], new SheetOptions { FreezeColumns = 2 }); + writer.BeginSheet("both", [new("a"), new("b"), new("c")], new SheetOptions { FreezeRows = 1, FreezeColumns = 2 }); + } + + [Theory] + [InlineData(TabularFormat.Ods, "ods")] + [InlineData(TabularFormat.Xlsx, "xlsx")] + public async Task LibreOfficeKeepsTheFrozenPanes(TabularFormat format, string extension) + { + byte[] file = await SheetLayoutTests.Write(format, ThreeFrozenSheets); + + string settings = Entry(LibreOffice.Resave(file, extension), "settings.xml"); + + // columns = horizontal split, rows = vertical split. + AssertPane(settings, "rows", horizontalMode: 0, verticalMode: 2, horizontal: 0, vertical: 1); + AssertPane(settings, "columns", horizontalMode: 2, verticalMode: 0, horizontal: 2, vertical: 0); + AssertPane(settings, "both", horizontalMode: 2, verticalMode: 2, horizontal: 2, vertical: 1); + } + + private static void AssertPane(string settings, string sheet, int horizontalMode, int verticalMode, int horizontal, int vertical) + { + int start = settings.IndexOf($"config:name=\"{sheet}\"", StringComparison.Ordinal); + Assert.True(start >= 0, $"The resaved settings have no entry for sheet '{sheet}': {settings}"); + string entry = settings[start..]; + + static string Value(string entry, string name) + { + int at = entry.IndexOf($"config:name=\"{name}\"", StringComparison.Ordinal); + Assert.True(at >= 0, $"The resaved entry has no {name}: {entry[..Math.Min(entry.Length, 400)]}"); + int from = entry.IndexOf('>', at) + 1; + return entry[from..entry.IndexOf('<', from)]; + } + + Assert.Equal(horizontalMode.ToString(System.Globalization.CultureInfo.InvariantCulture), Value(entry, "HorizontalSplitMode")); + Assert.Equal(verticalMode.ToString(System.Globalization.CultureInfo.InvariantCulture), Value(entry, "VerticalSplitMode")); + Assert.Equal(horizontal.ToString(System.Globalization.CultureInfo.InvariantCulture), Value(entry, "HorizontalSplitPosition")); + Assert.Equal(vertical.ToString(System.Globalization.CultureInfo.InvariantCulture), Value(entry, "VerticalSplitPosition")); + } + + [Theory] + [InlineData(TabularFormat.Xlsx, "xlsx")] + [InlineData(TabularFormat.Ods, "ods")] + public async Task ALaidOutWorkbookOpensAndReadsBack(TabularFormat format, string extension) + { + CellStyle header = new() { Fill = CellColor.FromRgb(0x1F4E78), Font = new CellFont { Color = CellColor.FromRgb(0xFFFFFF), Bold = true } }; + CellStyle[] legend = [new() { Fill = CellColor.FromRgb(0x63BE7B) }, new() { Fill = CellColor.FromRgb(0xFFEB84) }, new() { Fill = CellColor.FromRgb(0xF8696B) }]; + + byte[] file = await SheetLayoutTests.Write(format, writer => + { + StyleId[] colours = [.. legend.Select(writer.Style)]; + StyleId title = writer.Style(new CellStyle { Font = new CellFont { Bold = true }, Horizontal = HorizontalAlignment.Center }); + + writer.BeginSheet("Data", [new("Id"), new("Score"), new("Date", 12)], new SheetOptions { HeaderStyle = header, FreezeRows = 1, AutoFilter = true }); + + for (int i = 1; i <= 100; i++) + { + writer.BeginRow(); + writer.Write((long)i); + writer.Write(i / 10.0, colours[i % 3]); + writer.Write(new DateOnly(2026, 1, 1).AddDays(i)); + writer.EndRow(); + } + + writer.BeginSheet("Legend", [new("Range"), new("Colour"), new("Meaning")], new SheetOptions { HeaderStyle = header }); + writer.BeginRow(); + writer.Merge(1, 3); + writer.Write("Score legend", title); + writer.EndRow(); + + for (int i = 0; i < legend.Length; i++) + { + writer.BeginRow(); + writer.Write($"{i * 3}–{(i * 3) + 3}"); + writer.WriteEmpty(colours[i]); + writer.Write(i switch { 0 => "low", 1 => "medium", _ => "high" }); + writer.EndRow(); + } + }); + + if (format == TabularFormat.Xlsx) + { + Assert.Empty(OoxmlValidation.Errors(file)); + } + + string[] lines = LibreOffice.ConvertToCsv(file, extension); + Assert.Equal(101, lines.Length); + + List data = SheetLayoutTests.Rows(file, 0); + Assert.Equal(101, data.Count); + Assert.Equal(RawCell.FromNumber(5), data[50][1]); + + List legendRows = SheetLayoutTests.Rows(file, 1); + Assert.Equal(RawCell.FromText("Score legend"), legendRows[1][0]); + Assert.Equal(RawCell.FromText("high"), legendRows[4][2]); + } }