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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/KNOWN-ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1,353 changes: 1,353 additions & 0 deletions docs/superpowers/plans/2026-10-04-styles-part-2-sheet-layout.md

Large diffs are not rendered by default.

23 changes: 13 additions & 10 deletions docs/superpowers/specs/2026-10-04-styled-columnar-export-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<mergeCells>` 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`.
Expand All @@ -119,8 +121,9 @@ writer.EndRow();
- **Auto-filter** on the header row through the last row written: xlsx `<autoFilter>` 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,
Expand Down
12 changes: 10 additions & 2 deletions src/TriasDev.Tabular/Csv/CsvSheetWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ public void Dispose()
/// <summary>The row buffer's current size, for the test that a huge row does not keep it.</summary>
internal int RowBufferLength => _row.Capacity;

public void BeginSheet(string name, ReadOnlySpan<WriteColumn> columns)
public void BeginSheet(string name, ReadOnlySpan<WriteColumn> columns, SheetOptions options)
{
_columnCount = columns.Length;

Expand All @@ -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)
{
Expand Down Expand Up @@ -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()
Expand Down
80 changes: 78 additions & 2 deletions src/TriasDev.Tabular/Ods/OdsParts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,30 @@ internal static class OdsParts

public const string BooleanStyle = "ce3";

public const string ContentEnd = "</table:table></office:spreadsheet></office:body></office:document-content>";
public const string TableEnd = "</table:table>";

public const string SpreadsheetEnd = "</office:spreadsheet></office:body></office:document-content>";

/// <summary>The auto-filters, as LibreOffice's sheet-local anonymous database ranges, header through last row.</summary>
public static string DatabaseRanges(IReadOnlyList<(int Sheet, string Name, int Columns, long Rows)> filters)
{
if (filters.Count == 0)
{
return string.Empty;
}

StringBuilder xml = new("<table:database-ranges>");

foreach ((int sheet, string name, int columns, long rows) in filters)
{
string quoted = "'" + name.Replace("'", "''", StringComparison.Ordinal) + "'";
xml.Append(CultureInfo.InvariantCulture, $"<table:database-range table:name=\"__Anonymous_Sheet_DB__{sheet}\" table:target-range-address=\"");
AppendAttribute(xml, $"{quoted}.A1:{quoted}.{Xlsx.XlsxParts.ColumnName(columns - 1)}{rows}");
xml.Append("\" table:display-filter-buttons=\"true\"/>");
}

return xml.Append("</table:database-ranges>").ToString();
}

internal const string XmlDeclaration = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>";

Expand All @@ -39,14 +62,67 @@ internal static class OdsParts
/// </summary>
public static readonly string ContentStart = BuildContentStart();

public static readonly byte[] Manifest = Encoding.UTF8.GetBytes(
public static byte[] Manifest(bool settings) => Encoding.UTF8.GetBytes(
XmlDeclaration
+ "<manifest:manifest xmlns:manifest=\"urn:oasis:names:tc:opendocument:xmlns:manifest:1.0\" manifest:version=\"1.3\">"
+ "<manifest:file-entry manifest:full-path=\"/\" manifest:version=\"1.3\" manifest:media-type=\"" + Mimetype + "\"/>"
+ "<manifest:file-entry manifest:full-path=\"content.xml\" manifest:media-type=\"text/xml\"/>"
+ "<manifest:file-entry manifest:full-path=\"styles.xml\" manifest:media-type=\"text/xml\"/>"
+ (settings ? "<manifest:file-entry manifest:full-path=\"settings.xml\" manifest:media-type=\"text/xml\"/>" : string.Empty)
+ "</manifest:manifest>");

/// <summary>
/// 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
/// <c>ooo</c>: the view-settings set's name is a QName, and LibreOffice ignores the set without it.
/// </summary>
public static byte[] Settings(IReadOnlyList<(string Name, int Rows, int Columns)> frozen)
{
StringBuilder xml = new(
XmlDeclaration
+ "<office:document-settings xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\" xmlns:config=\"urn:oasis:names:tc:opendocument:xmlns:config:1.0\" xmlns:ooo=\"http://openoffice.org/2004/office\" office:version=\"1.3\">"
+ "<office:settings><config:config-item-set config:name=\"ooo:view-settings\"><config:config-item-map-indexed config:name=\"Views\"><config:config-item-map-entry>"
+ "<config:config-item config:name=\"ViewId\" config:type=\"string\">view1</config:config-item><config:config-item-map-named config:name=\"Tables\">");

foreach ((string name, int rows, int columns) in frozen)
{
xml.Append("<config:config-item-map-entry config:name=\"");
AppendAttribute(xml, name);
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("</config:config-item-map-entry>");
}

xml.Append("</config:config-item-map-named></config:config-item-map-entry></config:config-item-map-indexed></config:config-item-set></office:settings></office:document-settings>");
return Encoding.UTF8.GetBytes(xml.ToString());
}

private static void Item(StringBuilder xml, string name, string type, int value) =>
xml.Append(CultureInfo.InvariantCulture, $"<config:config-item config:name=\"{name}\" config:type=\"{type}\">{value}</config:config-item>");

private static void AppendAttribute(StringBuilder xml, string value)
{
foreach (char c in value)
{
_ = c switch
{
'&' => xml.Append("&amp;"),
'<' => xml.Append("&lt;"),
'>' => xml.Append("&gt;"),
'"' => xml.Append("&quot;"),
_ => xml.Append(c),
};
}
}

/// <summary>The column style for a width in characters, rounded to the nearest whole one.</summary>
public static string ColumnStyleFor(double width) =>
ColumnStyleNames[Math.Clamp((int)Math.Round(width, MidpointRounding.AwayFromZero), 1, WidestColumn)];
Expand Down
Loading
Loading