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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ All notable changes to SheetStream. Versions follow [Semantic Versioning](https:
- **Cached formula results**: `Worksheet.CacheFormulaResults()` opts a sheet into a small evaluator (`+ - * / %`, `SUM`, `AVERAGE`, `COUNT`, `COUNTA`, `MIN`, `MAX`, `ROUND`, `ABS` over cells already written) so viewers that do not calculate show totals instead of blanks. Anything it cannot prove is written without a value, as before.
- **Async reading** (.NET 8+): `ExcelReader.OpenAsync`, `ReadRowsAsync`, `ReadAsync<T>` and `ReadDataTableAsync`, with cancellation between rows; non-seekable uploads are buffered asynchronously and spill to a temp file above 64 MB.
- **From the first production migration (LogisticaHQ)**: `Worksheet.LoadFromArrays` replaces EPPlus' `Cells.LoadFromArrays` one for one; `ExcelPackage.CreateBytesAsync` lets a builder await a query per sheet; the migration guide gained "Things EPPlus did for you silently" (the code-page encoding provider that ExcelDataReader imports depend on, AutoFit timing, default date formats).
- **AutoFit** measures columns with a Calibri 11 glyph table calibrated against Excel's own AutoFit (bold, font size, formatted numbers and dates as displayed) and Excel's column-width formula, instead of counting characters; `Worksheet.AutoFitColumns(Func<string, bool, double> measure, ...)` and `PackageOptions.TextMeasurer` accept real font metrics in digit units.
- **Reader**: `ReadMergedRanges` and `ReadHyperlinks` (issue #6) read a sheet's merged cells and links, external URLs resolved from the sheet relationships, without parsing the rows.
- **Benchmarks**: 1,000,000-row and 1,000 × 10,000-column write scenarios, and read benchmarks against MiniExcel, ExcelDataReader, ClosedXML, EPPlus and NPOI.
- **Web sample** (`samples/SheetStream.Sample.Web`): a minimal API with a download button, a 50,000-row streamed export and a `Cells[...]` report with charts; double-click `run.cmd`. A [DotNetFiddle](https://dotnetfiddle.net/BR59ju) runs the quick start in the browser.
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,8 @@ File previews, Quick Look, some mobile viewers and `ExcelReader` show formula ce
### Layout, printing and protection

```csharp
sheet.AutoFitColumns(); // Widths sized to content at save time
sheet.AutoFitColumns(); // Widths measured like Excel's own AutoFit, at save time
sheet.AutoFitColumns((text, bold) => paint.MeasureText(text) / digitWidth); // Or plug in real font metrics
sheet.TabColor = "#C00000";
sheet.ShowGridLines = false;
sheet.Columns(5, 8).SetOutlineLevel(1); // Collapsible column group
Expand All @@ -188,6 +189,8 @@ workbook.Protect("password"); // Lock the sheet structur
sheet.Visibility = SheetVisibility.Hidden;
```

`AutoFitColumns()` sizes columns the way Excel's AutoFit does: each cell is measured as displayed (formatted numbers, dates, bold, font size) with a Calibri 11 glyph table calibrated against Excel, so "Description" comes out 11.2 wide and ten digits 11.1. For another default font or non-Latin text, pass a measurer (`sheet.AutoFitColumns((text, bold) => ...)` or `PackageOptions.TextMeasurer`) that returns widths in Excel's column unit: one digit of the default font, so `"0000000000"` must measure 10.

### Fill templates designed in Excel

```csharp
Expand Down
343 changes: 343 additions & 0 deletions src/SheetStream/Internal/NumberDisplayFormat.cs

Large diffs are not rendered by default.

162 changes: 162 additions & 0 deletions src/SheetStream/Internal/TextMeasure.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
using System;

namespace SheetStream.Internal
{
/// <summary>
/// Estimates how wide a text renders in Excel's default font (Calibri 11, the "Normal" style) without a
/// graphics library. Widths are returned in Excel's column unit: the width of one digit of the default
/// font, 7 px at 96 dpi, so <c>"0000000000"</c> measures exactly 10.
/// </summary>
/// <remarks>
/// <para>
/// The tables hold the hinted advance width of every glyph in whole pixels at 11 pt / 96 dpi, which is how
/// Excel itself lays cell text out (it rounds each glyph to the pixel grid; 'i' is 4 px, 'W' 13 px, digits
/// 7 px). The values were calibrated by auto-fitting every printable ASCII character, regular and bold, in
/// Excel 2016 and reading the resulting column widths back; the Latin-1 block reuses the width of the base
/// letter, which is what Excel shows for accented text (é renders as wide as e).
/// </para>
/// <para>
/// Calibri Bold at 11 pt is barely wider than regular on the pixel grid: only nine ASCII glyphs gain a
/// pixel (" &amp; ' G M U W Y `), so bold is handled as per-glyph overrides instead of a blanket factor.
/// Characters outside the tables are assumed to be one digit wide (two for CJK and other full-width
/// scripts); applications with real font metrics plug them in through
/// <see cref="PackageOptions.TextMeasurer"/> or <c>Worksheet.AutoFitColumns(measure)</c>.
/// </para>
/// </remarks>
internal static class TextMeasure
{
/// <summary>Width of a digit in Calibri 11 at 96 dpi, Excel's "maximum digit width" and column unit.</summary>
public const int DigitWidthPixels = 7;

/// <summary>Width of a glyph outside the tables: one digit.</summary>
private const int DefaultWidth = DigitWidthPixels;

/// <summary>Width of a full-width (CJK, Hangul) glyph: two digits.</summary>
private const int WideWidth = 2 * DigitWidthPixels;

// Hinted Calibri 11 advance widths in pixels for U+0020..U+007E, as measured in Excel.
private static readonly byte[] Ascii =
{
3, // ' '
5, // !
6, // "
7, // #
7, // $
11, // %
10, // &
3, // '
5, // (
5, // )
7, // *
7, // +
4, // ,
5, // -
4, // .
6, // /
7, 7, 7, 7, 7, 7, 7, 7, 7, 7, // 0-9
4, // :
4, // ;
7, // <
7, // =
7, // >
7, // ?
13, // @
9, 8, 8, 9, 7, 7, 9, 9, 4, 5, 8, 6, 12, // A-M
10, 10, 8, 10, 8, 7, 7, 9, 9, 13, 8, 7, 7, // N-Z
5, // [
6, // \
5, // ]
7, // ^
7, // _
4, // `
7, 8, 6, 8, 8, 5, 7, 8, 4, 4, 7, 4, 12, // a-m
8, 8, 8, 8, 5, 6, 5, 8, 7, 11, 7, 7, 6, // n-z
5, // {
7, // |
5, // }
7, // ~
};

// Latin-1 Supplement U+00A0..U+00FF. Accented letters take the width of their base letter; the symbols
// are the nearest ASCII equivalent (© and ® like &, superscripts like a narrow digit).
private static readonly byte[] Latin1 =
{
3, 5, 7, 7, 7, 7, 7, 7, 4, 10, 5, 7, 7, 5, 10, 7, // nbsp ¡ ¢ £ ¤ ¥ ¦ § ¨ © ª « ¬ shy ® ¯
5, 7, 5, 5, 4, 8, 8, 4, 4, 5, 5, 7, 10, 10, 10, 7, // ° ± ² ³ ´ µ ¶ · ¸ ¹ º » ¼ ½ ¾ ¿
9, 9, 9, 9, 9, 9, 13, 8, 7, 7, 7, 7, 4, 4, 4, 4, // À Á Â Ã Ä Å Æ Ç È É Ê Ë Ì Í Î Ï
9, 10, 10, 10, 10, 10, 10, 7, 10, 9, 9, 9, 9, 7, 8, 8, // Ð Ñ Ò Ó Ô Õ Ö × Ø Ù Ú Û Ü Ý Þ ß
7, 7, 7, 7, 7, 7, 12, 6, 8, 8, 8, 8, 4, 4, 4, 4, // à á â ã ä å æ ç è é ê ë ì í î ï
8, 8, 8, 8, 8, 8, 8, 7, 8, 8, 8, 8, 8, 7, 8, 7, // ð ñ ò ó ô õ ö ÷ ø ù ú û ü ý þ ÿ
};

/// <summary>
/// Width of <paramref name="text"/> in digit units. A multi-line cell (wrap text, Alt+Enter) is as wide
/// as its widest line. Allocation-free.
/// </summary>
public static double Measure(string text, bool bold)
{
var longest = 0;
var current = 0;
for (var i = 0; i < text.Length; i++)
{
var c = text[i];
if (c == '\n')
{
if (current > longest) longest = current;
current = 0;
}
else current += PixelWidth(c, bold);
}
return (current > longest ? current : longest) / (double)DigitWidthPixels;
}

/// <summary>Hinted advance width of one UTF-16 code unit in pixels at Calibri 11 / 96 dpi.</summary>
public static int PixelWidth(char c, bool bold)
{
if (c < 0x20) return 0; // Control characters (tab, CR) take no room in a cell.
if (c < 0x7F)
{
if (bold)
{
// The only ASCII glyphs Calibri Bold widens on the 11 pt pixel grid.
switch (c)
{
case '"': return 7;
case '&': return 11;
case '\'': return 4;
case 'G': return 10;
case 'M': return 13;
case 'U': return 10;
case 'W': return 14;
case 'Y': return 8;
case '`': return 5;
}
}
return Ascii[c - 0x20];
}
if (c < 0xA0) return 0; // C1 controls.
if (c <= 0xFF) return Latin1[c - 0xA0];
if (c >= 0x0300 && c <= 0x036F) return 0; // Combining diacritics sit on the previous glyph.
if (c >= 0x200B && c <= 0x200F) return 0; // Zero-width space, joiners, direction marks.
if (c == 0xFEFF) return 0;
if (char.IsLowSurrogate(c)) return 0; // Counted with the high surrogate.
if (char.IsHighSurrogate(c)) return WideWidth; // Supplementary planes: emoji and CJK extensions, all wide.
if (IsWide(c)) return bold ? WideWidth + 1 : WideWidth;
return bold ? DefaultWidth + 1 : DefaultWidth;
}

/// <summary>True for East Asian full-width ranges (Hangul, Kana, CJK ideographs, full-width forms).</summary>
private static bool IsWide(char c)
=> (c >= 0x1100 && c <= 0x115F)
|| (c >= 0x2E80 && c <= 0x303E)
|| (c >= 0x3041 && c <= 0x33FF)
|| (c >= 0x3400 && c <= 0x4DBF)
|| (c >= 0x4E00 && c <= 0x9FFF)
|| (c >= 0xA000 && c <= 0xA4CF)
|| (c >= 0xAC00 && c <= 0xD7A3)
|| (c >= 0xF900 && c <= 0xFAFF)
|| (c >= 0xFE30 && c <= 0xFE4F)
|| (c >= 0xFF00 && c <= 0xFF60)
|| (c >= 0xFFE0 && c <= 0xFFE6);
}
}
14 changes: 14 additions & 0 deletions src/SheetStream/PackageOptions.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System;
using System.IO.Compression;

namespace SheetStream
Expand Down Expand Up @@ -29,6 +30,19 @@ public sealed class PackageOptions
/// </summary>
public int MaxEditableCells { get; set; } = 200_000;

/// <summary>
/// Text measurer used by <see cref="Worksheet.AutoFitColumns(double, double)"/> on every sheet of the
/// package: <c>(text, bold) =&gt; width</c>, where the width is in Excel's column unit, the width of one
/// digit of the default font (Calibri 11: 7 px at 96 dpi), so <c>"0000000000"</c> measures 10.
/// SheetStream then adds Excel's cell padding and scales for font size. <c>null</c> (default) uses the
/// built-in Calibri 11 glyph table, which reproduces Excel's own AutoFit to a few tenths of a unit for
/// Latin text. Plug in real font metrics (SkiaSharp, System.Drawing, ...) when the workbook uses another
/// font or non-Latin scripts; the delegate runs once per observed cell, so keep it cheap.
/// A per-sheet measurer passed to <see cref="Worksheet.AutoFitColumns(Func{string, bool, double}, double, double)"/>
/// takes precedence.
/// </summary>
public Func<string, bool, double>? TextMeasurer { get; set; }

internal static PackageOptions Default => new PackageOptions();
}
}
19 changes: 7 additions & 12 deletions src/SheetStream/RowWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -316,8 +316,8 @@ public RowWriter WriteCell(DateTime value, CellStyle? style = null)
_w.WriteValue(value.ToOADate());
_w.WriteEndElement();
if (_cache) _sheet.RecordNumber(RowIndex, NextColumn, value.ToOADate());
// Display width: "10/13/2026" or "10/13/2026 15:30".
EndCell(_observe ? (value.TimeOfDay == TimeSpan.Zero ? "00/00/0000" : "00/00/0000 00:00") : null);
// Display width follows the style's date format: "10/13/2026", "2026-10-13", "13-Oct-26"...
EndCell(_observe ? NumberText(value.ToOADate()) : null);
return this;
}

Expand All @@ -335,7 +335,7 @@ public RowWriter WriteCell(TimeSpan value, CellStyle? style = null)
_w.WriteValue(value.TotalDays);
_w.WriteEndElement();
if (_cache) _sheet.RecordNumber(RowIndex, NextColumn, value.TotalDays);
EndCell(_observe ? "000:00:00" : null);
EndCell(_observe ? NumberText(value.TotalDays) : null);
return this;
}

Expand Down Expand Up @@ -507,16 +507,11 @@ private RowWriter WriteError(string errorCode, CellStyle? style)
}

/// <summary>
/// Approximates how a number will display, for AutoFit: the shortest round-trip text, widened when the
/// style adds thousands separators or fixed decimals. Exact rendering would need Excel's formatter.
/// Approximates how a number will display under the cell's number format, for AutoFit: General text for
/// unformatted numbers, the formatted digits plus literals (<c>$1,234.50</c>, <c>12.34%</c>) otherwise,
/// and a value-independent stand-in for dates and times (<c>00/00/0000</c>). See <see cref="NumberDisplayFormat"/>.
/// </summary>
private string NumberText(double value)
{
var numFmt = _sheet.Workbook.Styles.NumberFormatId(_lastStyleId);
if (numFmt == 0) return value.ToString("G10", CultureInfo.InvariantCulture);
if ((numFmt >= 14 && numFmt <= 17) || numFmt == 22) return "00/00/0000 00:00".Substring(0, numFmt == 22 ? 16 : 10);
return value.ToString("#,##0.00", CultureInfo.InvariantCulture) + " ";
}
private string NumberText(double value) => _sheet.Workbook.Styles.DisplayFormat(_lastStyleId).Render(value);

/// <summary>
/// Opens a <c>&lt;c&gt;</c> element. The reference is written as two strings (cached column letters,
Expand Down
56 changes: 50 additions & 6 deletions src/SheetStream/Styles/StyleSheet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ public sealed class StyleSheet
private readonly List<DxfSpec> _dxfs = new List<DxfSpec>();
private readonly Dictionary<DxfSpec, int> _dxfIndex = new Dictionary<DxfSpec, int>();

// AutoFit's display-text estimators, one per number format id (see DisplayFormat).
private readonly Dictionary<int, NumberDisplayFormat> _displayFormats = new Dictionary<int, NumberDisplayFormat>();

private CellStyle? _dateStyle;
private CellStyle? _dateTimeStyle;
private CellStyle? _durationStyle;
Expand Down Expand Up @@ -178,21 +181,62 @@ public CellStyle Hyperlink
}

/// <summary>
/// Approximate width multiplier of a style's font relative to Calibri 11 regular, used by AutoFit.
/// Bold text is about 10% wider; size scales linearly.
/// Font traits AutoFit needs for a style: whether it is bold, and its size relative to the 11 pt default
/// (glyph widths scale linearly with size). A style whose font came from a template reports regular 11 pt,
/// because the template's font records are not parsed.
/// </summary>
internal double WidthFactor(int styleId)
internal void FontTraits(int styleId, out bool bold, out double sizeFactor)
{
if (styleId <= 0 || !TryGetXf(styleId, out var xf)) return 1.0;
bold = false;
sizeFactor = 1.0;
if (styleId <= 0 || !TryGetXf(styleId, out var xf)) return;
var local = xf.FontId - _fontBase;
if (local < 0 || local >= _fonts.Count) return 1.0; // A template font: metrics unknown.
if (local < 0 || local >= _fonts.Count) return;
var font = _fonts[local];
return font.Size / 11.0 * (font.Bold ? 1.1 : 1.0);
bold = font.Bold;
sizeFactor = font.Size / 11.0;
}

/// <summary>Number format id of a style, used by AutoFit to estimate the displayed width of numbers.</summary>
internal int NumberFormatId(int styleId) => styleId > 0 && TryGetXf(styleId, out var xf) ? xf.NumFmtId : 0;

/// <summary>
/// The display-text estimator for a style's number format, used by AutoFit to measure what a numeric
/// cell shows. Parsed once per number format id and cached.
/// </summary>
internal NumberDisplayFormat DisplayFormat(int styleId)
{
var numFmtId = NumberFormatId(styleId);
if (numFmtId == 0) return NumberDisplayFormat.General;
if (!_displayFormats.TryGetValue(numFmtId, out var format))
{
format = NumberDisplayFormat.Parse(NumberFormatCode(numFmtId));
_displayFormats[numFmtId] = format;
}
return format;
}

/// <summary>Format code behind a number format id: built-in, registered here, or read from the template.</summary>
private string? NumberFormatCode(int numFmtId)
{
switch (numFmtId)
{
// The built-in short date and date-time render with the Windows locale, not their nominal code;
// en-US shows a four-digit year ("10/13/2026"), the widest common case.
case 14: return "m/d/yyyy";
case 22: return "m/d/yyyy h:mm";
// Legacy currency and accounting ids a template may carry; ECMA-376 leaves their codes implementation-defined.
case 5: case 7: return "$#,##0";
case 6: case 8: return "$#,##0.00";
case 41: case 43: return "_(* #,##0.00_)";
case 42: case 44: return "_($* #,##0.00_)";
}
foreach (var pair in BuiltInNumberFormats) if (pair.Value == numFmtId) return pair.Key;
foreach (var pair in _customNumberFormats) if (pair.Key == numFmtId) return pair.Value;
foreach (var pair in _numberFormatIndex) if (pair.Value == numFmtId) return pair.Key;
return null;
}

/// <summary>Looks up a cell format by its global index, whether it came from the template or was added here.</summary>
private bool TryGetXf(int id, out XfSpec xf)
{
Expand Down
Loading