From 07ca2c2624c17dee3e0d7f3f836ea155f459e76a Mon Sep 17 00:00:00 2001 From: glenntosco Date: Wed, 14 Oct 2026 08:38:53 -0500 Subject: [PATCH 1/3] AutoFit: measure text with a Calibri 11 glyph table calibrated against Excel instead of counting characters AutoFitColumns sized columns by character count, so headers came out wider or narrower than the EPPlus/Excel widths the import templates were built around. Columns are now sized the way Excel's own AutoFit does: - TextMeasure: hinted Calibri 11 advance widths in pixels for ASCII and Latin-1 (accented letters as wide as their base letter), measured by auto-fitting every glyph in Excel 2016; digits are 7 px = one column unit, so "0000000000" is 10. Bold widens only the nine glyphs Calibri Bold actually widens on the pixel grid. Other scripts count one digit per character, two for CJK. - Column width = Truncate((textPixels + 5) / 7 * 256) / 256 (ECMA-376 formula), with Excel's 3.6% layout factor; results match Excel to a few tenths of a unit ("Description" bold 11.2 vs Excel 11.14, ten digits 11.07 vs 11.0). - Cells are measured as displayed: NumberDisplayFormat renders a number under its format ($1,234.50, 12.34%, 1.23457E+12, 2026-10-13, Tuesday, October 13, 2026, 36:00:00), with month/day names and elapsed digits taken from the value; General follows Excel's 11-digit rule. Dates and durations follow the cell's style. - Hooks for real font metrics: Worksheet.AutoFitColumns(Func) and PackageOptions.TextMeasurer, both returning widths in digit units. - StyleSheet.FontTraits replaces WidthFactor (bold and size separately). Co-Authored-By: Claude Fable 5.1 --- .../Internal/NumberDisplayFormat.cs | 315 ++++++++++++++++++ src/SheetStream/Internal/TextMeasure.cs | 162 +++++++++ src/SheetStream/PackageOptions.cs | 14 + src/SheetStream/RowWriter.cs | 19 +- src/SheetStream/Styles/StyleSheet.cs | 56 +++- src/SheetStream/Worksheet.Features.cs | 103 ++++-- src/SheetStream/Worksheet.cs | 2 +- tests/SheetStream.Tests/AutoFitTests.cs | 245 ++++++++++++++ tests/SheetStream.Tests/FeatureTests.cs | 4 +- 9 files changed, 880 insertions(+), 40 deletions(-) create mode 100644 src/SheetStream/Internal/NumberDisplayFormat.cs create mode 100644 src/SheetStream/Internal/TextMeasure.cs create mode 100644 tests/SheetStream.Tests/AutoFitTests.cs diff --git a/src/SheetStream/Internal/NumberDisplayFormat.cs b/src/SheetStream/Internal/NumberDisplayFormat.cs new file mode 100644 index 0000000..b73073c --- /dev/null +++ b/src/SheetStream/Internal/NumberDisplayFormat.cs @@ -0,0 +1,315 @@ +using System; +using System.Globalization; +using System.Text; + +namespace SheetStream.Internal +{ + /// + /// Approximates the text Excel displays for a number under a number format, so AutoFit can measure what the + /// user will see rather than the raw value. One instance is parsed per number format id and cached by the + /// ; rendering a value then costs one ToString, or nothing for date + /// and time formats, whose display width does not depend on the value. + /// + /// + /// Only the first section of the format code (positive numbers) is read. Literal text, escaped characters, + /// _x padding (Excel reserves the width of x), currency in [$€-407] blocks, percent + /// scaling, thousands separators, fixed decimals and scientific notation are honoured; colours, conditions + /// and * fill characters are ignored. Exact rendering would need Excel's formatter and locale. + /// + internal sealed class NumberDisplayFormat + { + /// Excel's General format: up to 11 characters, scientific beyond. + public static readonly NumberDisplayFormat General = new NumberDisplayFormat(null, true, "", "", 1); + + // Placeholders in a date template for the parts whose width depends on the value: month and day names + // (Excel shows them in English for the en-US locale assumed throughout) and elapsed hours/minutes/seconds, + // which grow with the value. Control characters never occur in a format code, so they cannot collide. + private const char MonthName = '\u0001'; + private const char MonthAbbreviation = '\u0002'; + private const char DayName = '\u0003'; + private const char DayAbbreviation = '\u0004'; + private const char Elapsed = '\u0005'; + + private readonly string? _dateTemplate; + private readonly bool _dateHasPlaceholders; + private readonly int _elapsedDigits; + private readonly double _elapsedPerDay; + private readonly string? _netFormat; + private readonly bool _general; + private readonly string _prefix; + private readonly string _suffix; + private readonly double _scale; + + private NumberDisplayFormat(string? netFormat, bool general, string prefix, string suffix, double scale) + { + _netFormat = netFormat; + _general = general; + _prefix = prefix; + _suffix = suffix; + _scale = scale; + } + + private NumberDisplayFormat(string dateTemplate, int elapsedDigits, double elapsedPerDay) + { + _dateTemplate = dateTemplate; + _dateHasPlaceholders = dateTemplate.IndexOfAny(new[] { MonthName, MonthAbbreviation, DayName, DayAbbreviation, Elapsed }) >= 0; + _elapsedDigits = elapsedDigits; + _elapsedPerDay = elapsedPerDay; + _prefix = _suffix = ""; + _scale = 1; + } + + /// The text a cell holding displays under this format, approximately. + public string Render(double value) + { + if (_dateTemplate != null) return _dateHasPlaceholders ? ExpandDate(value) : _dateTemplate; + var digits = _netFormat == null || _general + ? GeneralText(value * _scale) + : (value * _scale).ToString(_netFormat, CultureInfo.InvariantCulture); + if (_prefix.Length == 0 && _suffix.Length == 0) return digits; + return _prefix + digits + _suffix; + } + + /// Fills the month/day names and elapsed digits of a date template from the serial date value. + private string ExpandDate(double value) + { + // Out-of-range serials (Excel shows ####) fall back to the widest common names. + var inRange = value >= -657435 && value < 2958466; + var date = inRange ? DateTime.FromOADate(value) : new DateTime(2000, 9, 6); // A Wednesday in September. + var sb = new StringBuilder(_dateTemplate!.Length + 16); + foreach (var c in _dateTemplate) + { + switch (c) + { + case MonthName: sb.Append(CultureInfo.InvariantCulture.DateTimeFormat.GetMonthName(date.Month)); break; + case MonthAbbreviation: sb.Append(CultureInfo.InvariantCulture.DateTimeFormat.GetAbbreviatedMonthName(date.Month)); break; + case DayName: sb.Append(CultureInfo.InvariantCulture.DateTimeFormat.GetDayName(date.DayOfWeek)); break; + case DayAbbreviation: sb.Append(CultureInfo.InvariantCulture.DateTimeFormat.GetAbbreviatedDayName(date.DayOfWeek)); break; + case Elapsed: + { + var total = Math.Floor(Math.Abs(value) * _elapsedPerDay); + var digits = total < 1 ? 1 : (int)Math.Floor(Math.Log10(total)) + 1; + sb.Append('0', Math.Max(digits, _elapsedDigits)); + break; + } + default: sb.Append(c); break; + } + } + return sb.ToString(); + } + + /// + /// What Excel's General format shows in a column wide enough for the value: whole numbers in full up to + /// 11 digits, decimals to 10 significant digits, and six-significant-digit scientific notation beyond + /// (1.23457E+12), which is also how Excel abbreviates very small values. + /// + public static string GeneralText(double value) + { + var abs = Math.Abs(value); + if (abs == 0) return "0"; + if (abs >= 1e11 || abs < 1e-4) return value.ToString("0.#####E+00", CultureInfo.InvariantCulture); + if (value == Math.Floor(value)) return value.ToString("0", CultureInfo.InvariantCulture); + return value.ToString("G10", CultureInfo.InvariantCulture); + } + + /// Parses an Excel number format code. null, empty and General give . + public static NumberDisplayFormat Parse(string? code) + { + if (string.IsNullOrEmpty(code) || code == "General") return General; + var s = FirstSection(code!); + if (s.Length == 0) return General; + + // Literals are collected twice: in display order for date formats (whose text is value-independent) + // and split into prefix / suffix around the digits for numeric formats. + var dateText = new StringBuilder(); + var prefix = new StringBuilder(); + var suffix = new StringBuilder(); + var isDate = false; + var isText = false; + var general = false; + var hasPlaceholder = false; + var thousands = false; + var percent = false; + var scientific = false; + var inDecimals = false; + var decimals = 0; + var elapsedDigits = 0; + var elapsedPerDay = 0.0; + + void Literal(string text) + { + dateText.Append(text); + (hasPlaceholder ? suffix : prefix).Append(text); + } + + for (var i = 0; i < s.Length; i++) + { + var c = s[i]; + switch (c) + { + case '"': + { + var end = s.IndexOf('"', i + 1); + if (end < 0) end = s.Length; + Literal(s.Substring(i + 1, end - i - 1)); + i = end; + break; + } + case '\\': // Escaped literal character. + case '_': // Padding: Excel leaves the width of the next character blank. + if (i + 1 < s.Length) Literal(s[++i].ToString()); + break; + case '*': // Repeat-to-fill: no intrinsic width. + if (i + 1 < s.Length) i++; + break; + case '[': + { + var close = s.IndexOf(']', i); + if (close < 0) close = s.Length - 1; + var inner = s.Substring(i + 1, close - i - 1); + if (inner.Length > 0 && inner[0] == '$') + { + // [$€-407]: a currency symbol with a locale tag; [$-409] is a bare locale tag. + var dash = inner.IndexOf('-'); + Literal(dash < 0 ? inner.Substring(1) : inner.Substring(1, dash - 1)); + } + else if (inner.Length > 0 && IsElapsedToken(inner)) + { + // [h], [mm], [ss]: elapsed time that can exceed its unit; the digit count follows the value. + isDate = true; + elapsedDigits = inner.Length; + var unit = char.ToLowerInvariant(inner[0]); + elapsedPerDay = unit == 'h' ? 24 : unit == 'm' ? 1440 : 86400; + dateText.Append(Elapsed); + } + // Colours ([Red]) and conditions ([>=100]) do not display. + i = close; + break; + } + case '0': + case '#': + case '?': + if (isDate) dateText.Append('0'); // Fractional seconds: ss.00 + else + { + hasPlaceholder = true; + if (inDecimals) decimals++; + } + break; + case '.': + if (isDate) dateText.Append('.'); + else if (!inDecimals && !scientific) inDecimals = true; + break; + case ',': + if (isDate) dateText.Append(','); + else if (hasPlaceholder && !inDecimals) thousands = true; // Trailing commas (scaling) are rare; ignored. + break; + case '%': + percent = true; + Literal("%"); + break; + case 'E': + case 'e': + if (!isDate && i + 1 < s.Length && (s[i + 1] == '+' || s[i + 1] == '-')) + { + scientific = true; + inDecimals = false; + i++; + // The exponent digits are placeholders too; skip them so they do not count as decimals. + while (i + 1 < s.Length && s[i + 1] == '0') i++; + } + else Literal(c.ToString()); + break; + case '@': + isText = true; + break; + case 'y': case 'Y': + case 'm': case 'M': + case 'd': case 'D': + case 'h': case 'H': + case 's': case 'S': + { + isDate = true; + var run = 1; + while (i + run < s.Length && char.ToLowerInvariant(s[i + run]) == char.ToLowerInvariant(c)) run++; + dateText.Append(DateToken(char.ToLowerInvariant(c), run)); + i += run - 1; + break; + } + case 'A': + case 'a': + if (StartsWith(s, i, "AM/PM")) { isDate = true; dateText.Append("AM"); i += 4; } + else if (StartsWith(s, i, "A/P")) { isDate = true; dateText.Append('A'); i += 2; } + else Literal(c.ToString()); + break; + case 'G': + if (StartsWith(s, i, "General")) { hasPlaceholder = true; general = true; i += 6; } + else Literal(c.ToString()); + break; + default: + Literal(c.ToString()); + break; + } + } + + if (isDate) return new NumberDisplayFormat(dateText.ToString(), elapsedDigits, elapsedPerDay); + if (isText || !hasPlaceholder) return General; + + string netFormat; + if (scientific) netFormat = decimals > 0 ? "0." + new string('0', decimals) + "E+00" : "0E+00"; + else + { + netFormat = thousands ? "#,##0" : "0"; + if (decimals > 0) netFormat += "." + new string('0', decimals); + } + return new NumberDisplayFormat(netFormat, general, prefix.ToString(), suffix.ToString(), percent ? 100 : 1); + } + + /// The positive-number section: everything before the first ; outside quotes and brackets. + private static string FirstSection(string code) + { + var inQuote = false; + var depth = 0; + for (var i = 0; i < code.Length; i++) + { + var c = code[i]; + if (c == '"') inQuote = !inQuote; + else if (inQuote) continue; + else if (c == '\\' || c == '_' || c == '*') i++; + else if (c == '[') depth++; + else if (c == ']') depth--; + else if (c == ';' && depth <= 0) return code.Substring(0, i); + } + return code; + } + + /// + /// Stand-in text for a date/time token: zeros for numeric parts (every digit is the same width) and a + /// placeholder for month and day names, filled from the value when rendering. + /// + private static string DateToken(char token, int run) + { + switch (token) + { + case 'y': return run >= 3 ? "0000" : "00"; + case 'm': return run >= 5 ? "S" : run == 4 ? MonthName.ToString() : run == 3 ? MonthAbbreviation.ToString() : "00"; + case 'd': return run >= 4 ? DayName.ToString() : run == 3 ? DayAbbreviation.ToString() : "00"; + default: return "00"; // h, s (and minutes, which share 'm'). + } + } + + /// True for h, hh, mm, ss... as the whole bracket content. + private static bool IsElapsedToken(string inner) + { + var first = char.ToLowerInvariant(inner[0]); + if (first != 'h' && first != 'm' && first != 's') return false; + for (var i = 1; i < inner.Length; i++) + if (char.ToLowerInvariant(inner[i]) != first) return false; + return true; + } + + private static bool StartsWith(string s, int index, string token) + => string.Compare(s, index, token, 0, token.Length, StringComparison.OrdinalIgnoreCase) == 0 + && index + token.Length <= s.Length; + } +} diff --git a/src/SheetStream/Internal/TextMeasure.cs b/src/SheetStream/Internal/TextMeasure.cs new file mode 100644 index 0000000..ebdc825 --- /dev/null +++ b/src/SheetStream/Internal/TextMeasure.cs @@ -0,0 +1,162 @@ +using System; + +namespace SheetStream.Internal +{ + /// + /// 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 "0000000000" measures exactly 10. + /// + /// + /// + /// 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). + /// + /// + /// Calibri Bold at 11 pt is barely wider than regular on the pixel grid: only nine ASCII glyphs gain a + /// pixel (" & ' 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 + /// or Worksheet.AutoFitColumns(measure). + /// + /// + internal static class TextMeasure + { + /// Width of a digit in Calibri 11 at 96 dpi, Excel's "maximum digit width" and column unit. + public const int DigitWidthPixels = 7; + + /// Width of a glyph outside the tables: one digit. + private const int DefaultWidth = DigitWidthPixels; + + /// Width of a full-width (CJK, Hangul) glyph: two digits. + 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, // ð ñ ò ó ô õ ö ÷ ø ù ú û ü ý þ ÿ + }; + + /// + /// Width of in digit units. A multi-line cell (wrap text, Alt+Enter) is as wide + /// as its widest line. Allocation-free. + /// + 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; + } + + /// Hinted advance width of one UTF-16 code unit in pixels at Calibri 11 / 96 dpi. + 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; + } + + /// True for East Asian full-width ranges (Hangul, Kana, CJK ideographs, full-width forms). + 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); + } +} diff --git a/src/SheetStream/PackageOptions.cs b/src/SheetStream/PackageOptions.cs index ca283ac..7d0b98f 100644 --- a/src/SheetStream/PackageOptions.cs +++ b/src/SheetStream/PackageOptions.cs @@ -1,3 +1,4 @@ +using System; using System.IO.Compression; namespace SheetStream @@ -29,6 +30,19 @@ public sealed class PackageOptions /// public int MaxEditableCells { get; set; } = 200_000; + /// + /// Text measurer used by on every sheet of the + /// package: (text, bold) => width, 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 "0000000000" measures 10. + /// SheetStream then adds Excel's cell padding and scales for font size. null (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 + /// takes precedence. + /// + public Func? TextMeasurer { get; set; } + internal static PackageOptions Default => new PackageOptions(); } } diff --git a/src/SheetStream/RowWriter.cs b/src/SheetStream/RowWriter.cs index a65cdb2..8fdb6d6 100644 --- a/src/SheetStream/RowWriter.cs +++ b/src/SheetStream/RowWriter.cs @@ -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; } @@ -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; } @@ -507,16 +507,11 @@ private RowWriter WriteError(string errorCode, CellStyle? style) } /// - /// 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 ($1,234.50, 12.34%) otherwise, + /// and a value-independent stand-in for dates and times (00/00/0000). See . /// - 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); /// /// Opens a <c> element. The reference is written as two strings (cached column letters, diff --git a/src/SheetStream/Styles/StyleSheet.cs b/src/SheetStream/Styles/StyleSheet.cs index 5975b13..653ddc0 100644 --- a/src/SheetStream/Styles/StyleSheet.cs +++ b/src/SheetStream/Styles/StyleSheet.cs @@ -50,6 +50,9 @@ public sealed class StyleSheet private readonly List _dxfs = new List(); private readonly Dictionary _dxfIndex = new Dictionary(); + // AutoFit's display-text estimators, one per number format id (see DisplayFormat). + private readonly Dictionary _displayFormats = new Dictionary(); + private CellStyle? _dateStyle; private CellStyle? _dateTimeStyle; private CellStyle? _durationStyle; @@ -178,21 +181,62 @@ public CellStyle Hyperlink } /// - /// 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. /// - 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; } /// Number format id of a style, used by AutoFit to estimate the displayed width of numbers. internal int NumberFormatId(int styleId) => styleId > 0 && TryGetXf(styleId, out var xf) ? xf.NumFmtId : 0; + /// + /// 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. + /// + 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; + } + + /// Format code behind a number format id: built-in, registered here, or read from the template. + 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; + } + /// Looks up a cell format by its global index, whether it came from the template or was added here. private bool TryGetXf(int id, out XfSpec xf) { diff --git a/src/SheetStream/Worksheet.Features.cs b/src/SheetStream/Worksheet.Features.cs index 662d3a3..96c28ac 100644 --- a/src/SheetStream/Worksheet.Features.cs +++ b/src/SheetStream/Worksheet.Features.cs @@ -17,12 +17,24 @@ public sealed partial class Worksheet private readonly List _pivotTables = new List(); private SheetProtection? _protection; - // AutoFit state: widest content seen per column, in Calibri-11 character units. + // AutoFit state: widest content seen per column, in digit units (the width of one Calibri 11 digit, see + // TextMeasure), before Excel's padding is added. private bool _autoFit; private double _autoFitMin; private double _autoFitMax; + private Func? _autoFitMeasure; private double[] _widths = new double[0]; + /// + /// Excel lays cell text out about 3.6% wider than the hinted 96-dpi glyph advances add up to: ten digits + /// occupy 72.5 px in an auto-fitted column, not 70 (measured on Excel 2016 over every ASCII glyph). + /// Applied to the measured digit units so the widths written here match Excel's own AutoFit. + /// + private const double ExcelLayoutScale = 1.0357; + + /// Pixels Excel adds around the text of a column: a 2 px margin each side plus the grid line. + private const int CellPaddingPixels = 5; + // Widths of cells in the first rows, kept so that rows which later turn out to be table or filter // headers (LoadFromCollection adds its table after writing) can be padded for the drop-down button. private const int EarlyRowLimit = 20; @@ -47,16 +59,51 @@ public HyperlinkEntry(int row, int column, string? url, string? location, string } /// - /// Sizes columns to their content when the sheet is saved. Call before the first row. Widths are - /// estimated from character counts (no font metrics are available without a graphics library), scaled - /// for bold and larger fonts, and clamped to [, ]. - /// Columns given an explicit width keep it. + /// Sizes columns to their content when the sheet is saved, the way Excel's own AutoFit does. Call before + /// the first row. Each cell is measured as it will display (formatted numbers, dates, booleans, the + /// widest line of wrapped text) with a built-in Calibri 11 glyph table calibrated against Excel, bold + /// and larger fonts included; Excel's cell padding is added and the result is clamped to + /// [, ]. Columns given an explicit width keep it. /// /// + /// + /// The table covers ASCII and Latin-1; other scripts count one digit width per character (two for CJK). + /// For another default font or non-Latin text, supply real metrics with + /// or + /// . + /// + /// /// Column widths sit before the data in the file, so this mode writes the rows to a temporary file and /// copies them in at save time. Memory stays flat; disk use roughly equals the uncompressed sheet size. + /// /// - public Worksheet AutoFitColumns(double minWidth = 6, double maxWidth = 80) + public Worksheet AutoFitColumns(double minWidth = 6, double maxWidth = 80) => AutoFitCore(null, minWidth, maxWidth); + + /// + /// Sizes columns to their content using the application's own text measurer, for workbooks whose default + /// font is not Calibri 11 or whose text the built-in table cannot measure (Cyrillic, Arabic, Thai...). + /// + /// + /// (text, bold) => width in Excel's column unit: the width of one digit of the default font, + /// so "0000000000" must return 10. Measure at the default size; SheetStream scales the result for + /// larger fonts and adds Excel's cell padding. Called once per cell, so it should be cheap. + /// + /// Smallest width a measured column gets. + /// Largest width a measured column gets (Excel allows up to 255). + /// + /// + /// using var paint = new SKPaint { Typeface = SKTypeface.FromFamilyName("Calibri"), TextSize = 11 * 96f / 72 }; + /// var digit = paint.MeasureText("0"); + /// sheet.AutoFitColumns((text, bold) => paint.MeasureText(text) / digit); + /// + /// + public Worksheet AutoFitColumns(Func measure, double minWidth = 6, double maxWidth = 80) + { + if (measure == null) throw new ArgumentNullException(nameof(measure)); + return AutoFitCore(measure, minWidth, maxWidth); + } + + private Worksheet AutoFitCore(Func? measure, double minWidth, double maxWidth) { if (minWidth < 0 || maxWidth > 255 || minWidth > maxWidth) throw new ArgumentOutOfRangeException(nameof(maxWidth), "Widths must satisfy 0 <= minWidth <= maxWidth <= 255."); EnsureNotSealed(); @@ -65,6 +112,7 @@ public Worksheet AutoFitColumns(double minWidth = 6, double maxWidth = 80) _autoFit = true; _autoFitMin = minWidth; _autoFitMax = maxWidth; + _autoFitMeasure = measure; return this; } @@ -88,27 +136,45 @@ internal void Observe(int row, int column, string text, int styleId) Array.Copy(_widths, grown, _widths.Length); _widths = grown; } - var chars = LongestLine(text) * Workbook.Styles.WidthFactor(styleId); - if (chars > _widths[column]) _widths[column] = chars; - if (row <= EarlyRowLimit) _earlyWidths[(long)row << 16 | (uint)column] = chars; + Workbook.Styles.FontTraits(styleId, out var bold, out var sizeFactor); + var units = MeasureUnits(text, bold) * sizeFactor; + if (units > _widths[column]) _widths[column] = units; + if (row <= EarlyRowLimit) _earlyWidths[(long)row << 16 | (uint)column] = units; } foreach (var t in _tables) if (t.Range.FromRow == row) t.CaptureHeader(column, text); } - private static int LongestLine(string text) + /// + /// Width of a cell's text in digit units: the sheet's measurer, else the package's, else the built-in + /// Calibri 11 table. A multi-line cell is as wide as its widest line. + /// + private double MeasureUnits(string text, bool bold) { - var longest = 0; - var current = 0; - foreach (var c in text) + var measure = _autoFitMeasure ?? Workbook.Package.Options.TextMeasurer; + if (measure == null) return TextMeasure.Measure(text, bold); + if (text.IndexOf('\n') < 0) return measure(text, bold); + var widest = 0.0; + foreach (var line in text.Split('\n')) { - if (c == '\n') { if (current > longest) longest = current; current = 0; } - else if (c != '\r') current++; + var w = measure(line.TrimEnd('\r'), bold); + if (w > widest) widest = w; } - return current > longest ? current : longest; + return widest; + } + + /// + /// The width Excel stores for an auto-fitted column whose widest text measures : + /// Truncate((textPixels + 5) / 7 * 256) / 256, the column-width formula of ECMA-376 §18.3.1.13, with + /// the text laid out as Excel does (see ). + /// + internal static double ExcelColumnWidth(double digitUnits) + { + var textPixels = digitUnits * TextMeasure.DigitWidthPixels * ExcelLayoutScale; + return Math.Truncate((textPixels + CellPaddingPixels) / TextMeasure.DigitWidthPixels * 256) / 256; } - /// Turns measured character counts into column widths, leaving explicit widths alone. + /// Turns measured digit units into column widths, leaving explicit widths alone. private void ApplyAutoFitWidths() { // Header cells of tables and AutoFilters need room for the drop-down button as well as the text. @@ -132,8 +198,7 @@ private void ApplyAutoFitWidths() { if (_widths[c] <= 0) continue; if (_columns.TryGetValue(c, out var existing) && existing.HasExplicitWidth) continue; - // About one character of padding plus a little for the filter button area, as Excel's own AutoFit leaves. - var width = Math.Min(_autoFitMax, Math.Max(_autoFitMin, _widths[c] + 2)); + var width = Math.Min(_autoFitMax, Math.Max(_autoFitMin, ExcelColumnWidth(_widths[c]))); GetOrCreateColumn(c).SetAutoWidth(width); } } diff --git a/src/SheetStream/Worksheet.cs b/src/SheetStream/Worksheet.cs index 16b0a7f..5c566ea 100644 --- a/src/SheetStream/Worksheet.cs +++ b/src/SheetStream/Worksheet.cs @@ -32,7 +32,7 @@ public sealed class RowOptions /// A worksheet being streamed into the package. Rows are written forward-only through /// / ; layout settings that the schema places before the /// sheet data (column widths, frozen panes, tab colour) must be declared before the first row, unless - /// or random-access are used, which defer the header. + /// or random-access are used, which defer the header. /// /// /// The worksheet XML is produced in three phases: a header (sheetPr, sheetViews, diff --git a/tests/SheetStream.Tests/AutoFitTests.cs b/tests/SheetStream.Tests/AutoFitTests.cs new file mode 100644 index 0000000..bd801e2 --- /dev/null +++ b/tests/SheetStream.Tests/AutoFitTests.cs @@ -0,0 +1,245 @@ +// The Open XML SDK exposes nearly every member as nullable; in test assertions a null is a failure anyway. +#pragma warning disable CS8602, CS8603, CS8604 +using System.Linq; +using DocumentFormat.OpenXml.Packaging; +using SheetStream.Internal; +using X = DocumentFormat.OpenXml.Spreadsheet; + +namespace SheetStream.Tests; + +/// +/// AutoFit sizing checked against Excel's own AutoFit. The expected widths were obtained by auto-fitting the same +/// texts in Excel 2016 (Calibri 11, 96 dpi) and reading the stored col width back: the UI "column width" +/// plus 5/7 for the cell padding, e.g. ten digits give 10.29 in the UI and 11.0 in the file. +/// +public class AutoFitTests +{ + /// Writes one AutoFit sheet and returns its stored column widths by 1-based column index. + private static Dictionary Widths(Action fill, double minWidth = 1, double maxWidth = 255, + Func? measure = null, PackageOptions? options = null) + { + var bytes = ExcelPackage.CreateBytes(wb => + { + var sheet = wb.Worksheets.Add("Fit"); + if (measure != null) sheet.AutoFitColumns(measure, minWidth, maxWidth); + else sheet.AutoFitColumns(minWidth, maxWidth); + fill(wb, sheet); + }, options); + + using var doc = SpreadsheetDocument.Open(new MemoryStream(bytes), false); + var sheetId = doc.WorkbookPart.Workbook.Sheets.Elements().Single().Id; + var ws = ((WorksheetPart)doc.WorkbookPart.GetPartById(sheetId)).Worksheet; + // Adjacent columns with the same width are coalesced into one element; expand them. + var widths = new Dictionary(); + var cols = ws.GetFirstChild(); + if (cols != null) + foreach (var c in cols.Elements()) + for (var i = (int)c.Min.Value; i <= (int)c.Max.Value; i++) widths[i] = c.Width.Value; + return widths; + } + + [Fact] + public void TenDigits_MeasureTenUnits_AndGetExcelsWidth() + { + Assert.Equal(10.0, TextMeasure.Measure("0000000000", false)); + Assert.Equal(10.0, TextMeasure.Measure("0000000000", true)); + + var cols = Widths((wb, s) => s.Row().WriteCell("0000000000").WriteCell(1234567890)); + Assert.InRange(cols[1], 10.9, 11.2); // Excel: 11.0 (UI 10.29). + Assert.Equal(cols[1], cols[2]); // A number shows the same digits. + } + + [Fact] + public void NarrowGlyphs_GiveNarrowerColumnsThanWideOnes() + { + var cols = Widths((wb, s) => s.Row().WriteCell("iiiiiiiiii").WriteCell("WWWWWWWWWW").WriteCell("0000000000")); + Assert.InRange(cols[1], 6.3, 7.0); // Excel: 6.71. + Assert.InRange(cols[2], 19.5, 20.2); // Excel: 19.86. + Assert.True(cols[1] < cols[3] && cols[3] < cols[2]); + Assert.True(cols[2] > 2.5 * cols[1]); + } + + [Fact] + public void Headers_MatchExcelAutoFit() + { + var cols = Widths((wb, s) => + { + var bold = wb.Styles.Create(o => o.Font.Bold = true); + s.Row().WriteCell("Description", bold).WriteCell("Qty", bold).WriteCell("Customer Name", bold).WriteCell("Qty"); + }); + Assert.InRange(cols[1], 10.9, 11.5); // Excel: 11.14 for bold "Description" (regular measures the same). + Assert.InRange(cols[2], 3.7, 4.4); // Excel: 4.14. + Assert.InRange(cols[3], 15.0, 15.7); // Excel: 15.43. + Assert.Equal(cols[2], cols[4]); // No glyph of "Qty" widens in Calibri Bold. + } + + [Fact] + public void BoldText_IsWiderWhenItsGlyphsWiden() + { + // W, U, G, M, Y gain a pixel in Calibri Bold; most glyphs do not. + Assert.True(TextMeasure.Measure("WAREHOUSE", true) > TextMeasure.Measure("WAREHOUSE", false)); + Assert.Equal(TextMeasure.Measure("Description", true), TextMeasure.Measure("Description", false)); + + var cols = Widths((wb, s) => + { + var bold = wb.Styles.Create(o => o.Font.Bold = true); + s.Row().WriteCell("WAREHOUSE", bold).WriteCell("WAREHOUSE"); + }); + Assert.True(cols[1] > cols[2], $"bold {cols[1]} should exceed regular {cols[2]}"); + Assert.InRange(cols[1] - cols[2], 0.2, 0.5); + } + + [Fact] + public void LargerFont_ScalesWidth() + { + var cols = Widths((wb, s) => + { + var big = wb.Styles.Create(o => o.Font.Size = 22); + s.Row().WriteCell("Total", big).WriteCell("Total"); + }); + Assert.InRange(cols[1] / cols[2], 1.7, 2.0); // Glyphs double, the fixed padding does not. + } + + [Fact] + public void Dates_MeasureTheirDisplayedText() + { + var cols = Widths((wb, s) => + { + var iso = wb.Styles.Create(o => o.NumberFormat = "yyyy-mm-dd"); + var dmy = wb.Styles.Create(o => o.NumberFormat = "d-mmm-yy"); + var longDate = wb.Styles.Create(o => o.NumberFormat = "dddd, mmmm d, yyyy"); + s.Row().WriteCell(new DateTime(2026, 10, 13)) + .WriteCell(new DateTime(2026, 10, 13, 15, 30, 0)) + .WriteCell(new DateTime(2026, 10, 13), iso) + .WriteCell(new DateTime(2026, 10, 13), dmy) + .WriteCell(new DateTime(2026, 10, 13), longDate) + .WriteCell(TimeSpan.FromHours(36)); + }); + Assert.InRange(cols[1], 10.5, 11.0); // Built-in short date shows "10/13/2026" in en-US; Excel: 10.71. + Assert.InRange(cols[2], 15.7, 16.2); // "10/13/2026 15:30" (Excel's "10/13/26 15:30" measured 13.86; two more digits here). + Assert.InRange(cols[3], 10.2, 10.7); // "2026-10-13"; Excel: 10.43. + Assert.InRange(cols[4], 9.2, 9.7); // "13-Oct-26"; Excel: 9.43 (the month name comes from the value). + Assert.InRange(cols[5], 24.0, 25.0); // "Tuesday, October 13, 2026"; Excel: 24.29. + Assert.InRange(cols[6], 7.9, 8.4); // "36:00:00"; Excel: 8.14. + } + + [Fact] + public void FormattedNumbers_MeasureTheirDisplayedText() + { + var cols = Widths((wb, s) => + { + var money = wb.Styles.Create(o => o.NumberFormat = "#,##0.00"); + var percent = wb.Styles.Create(o => o.NumberFormat = "0%"); + var dollars = wb.Styles.Create(o => o.NumberFormat = "$#,##0.00"); + var euros = wb.Styles.Create(o => o.NumberFormat = "#,##0.00 \"EUR\""); + s.Row().WriteCell(1234567.89, money).WriteCell(0.5, percent).WriteCell(1234.5, dollars).WriteCell(1234.5, euros) + .WriteCell(123456789).WriteCell(1234567890123.0).WriteCell(true); + }); + Assert.InRange(cols[1], 11.5, 12.1); // "1,234,567.89"; Excel: 11.71. + Assert.InRange(cols[2], 4.2, 4.8); // "50%"; Excel: 4.57. + Assert.InRange(cols[3], 8.9, 9.4); // "$1,234.50"; Excel: 9.14. + Assert.InRange(cols[4], 11.8, 12.3); // "1,234.50 EUR"; Excel: 12.0. + Assert.InRange(cols[5], 9.8, 10.3); // General integer "123456789"; Excel: 10.0. + Assert.InRange(cols[6], 11.6, 12.3); // General beyond 11 digits: "1.23457E+12"; Excel: 12.0. + Assert.InRange(cols[7], 5.1, 5.6); // "TRUE"; Excel: 5.43. + } + + [Theory] + [InlineData("#,##0.00", 1234567.89, "1,234,567.89")] + [InlineData("0.00%", 0.1234, "12.34%")] + [InlineData("$#,##0.00", 1234.5, "$1,234.50")] + [InlineData("#,##0.00 \"EUR\"", 1234.5, "1,234.50 EUR")] + [InlineData("_(#,##0.00_);(#,##0.00)", 1234.5, "(1,234.50)")] + [InlineData("[$€-407] #,##0.00;[Red]-[$€-407] #,##0.00", 99.5, "€ 99.50")] + [InlineData("0.00E+00", 123456.0, "1.23E+05")] + [InlineData("yyyy-mm-dd", 0.0, "0000-00-00")] + [InlineData("m/d/yy h:mm", 0.0, "00/00/00 00:00")] + [InlineData("dddd, mmmm d, yyyy", 46308.0, "Tuesday, October 00, 0000")] // 2026-10-13 + [InlineData("d-mmm-yy", 46308.0, "00-Oct-00")] + [InlineData("[h]:mm:ss", 1.5, "00:00:00")] // 36 hours + [InlineData("[h]:mm:ss", 45.0, "0000:00:00")] // 1080 hours + [InlineData("[mm]:ss", 0.01, "00:00")] // 14 minutes, still two digits + [InlineData("h:mm AM/PM", 0.0, "00:00 AM")] + [InlineData("General", 1234.5678, "1234.5678")] + [InlineData("General", 1234567890123.0, "1.23457E+12")] + [InlineData("General", 0.000012345, "1.2345E-05")] + [InlineData("@", 42.0, "42")] + public void NumberDisplayFormat_RendersWhatExcelShows(string code, double value, string expected) + => Assert.Equal(expected, NumberDisplayFormat.Parse(code).Render(value)); + + [Fact] + public void AccentedLetters_MeasureLikeTheirBaseLetter() + { + Assert.Equal(TextMeasure.Measure("Descripción", false), TextMeasure.Measure("Descripcion", false)); + Assert.Equal(TextMeasure.Measure("Ñ", false), TextMeasure.Measure("N", false)); + Assert.Equal(2.0, TextMeasure.Measure("漢", false)); // CJK: two digits wide. + Assert.Equal(1.0, TextMeasure.Measure("Ж", false)); // Unknown script: one digit. + Assert.Equal(TextMeasure.Measure("e", false), TextMeasure.Measure("é", false)); // Combining accent adds nothing. + // Every code unit measures without throwing. + for (var c = 0; c <= 0xFFFF; c++) TextMeasure.PixelWidth((char)c, c % 2 == 0); + } + + [Fact] + public void MultiLineText_IsAsWideAsItsWidestLine() + { + var cols = Widths((wb, s) => s.Row().WriteCell("ab\ncdefgh\r\nx").WriteCell("cdefgh")); + Assert.Equal(cols[2], cols[1]); + } + + [Fact] + public void CustomMeasurer_IsHonouredAndToldAboutBold() + { + var calls = new List<(string Text, bool Bold)>(); + double Measure(string text, bool bold) { calls.Add((text, bold)); return text.Length * 3; } + + var cols = Widths((wb, s) => + { + var bold = wb.Styles.Create(o => o.Font.Bold = true); + s.Row().WriteCell("Head", bold).WriteCell("ab\ncd"); + s.Row().WriteCell("abcd").WriteCell(12); + }, measure: Measure); + + // 12 units of text + Excel's padding, through the same conversion as the built-in table (stored to 2 decimals). + Assert.Equal(Worksheet.ExcelColumnWidth(12), cols[1], 2); + Assert.InRange(cols[1], 13.0, 13.3); + Assert.Contains(("Head", true), calls); + Assert.Contains(("abcd", false), calls); + Assert.Contains(("12", false), calls); + Assert.Contains(("cd", false), calls); // Lines of a multi-line cell are measured one by one. + Assert.DoesNotContain(calls, c => c.Text.Contains('\n')); + } + + [Fact] + public void PackageOptions_TextMeasurer_AppliesToEverySheet() + { + var options = new PackageOptions { TextMeasurer = (text, bold) => 20 }; + var cols = Widths((wb, s) => s.Row().WriteCell("x"), options: options); + Assert.Equal(Worksheet.ExcelColumnWidth(20), cols[1], 2); + + // A per-sheet measurer wins over the package one. + cols = Widths((wb, s) => s.Row().WriteCell("x"), measure: (text, bold) => 5, options: options); + Assert.Equal(Worksheet.ExcelColumnWidth(5), cols[1], 2); + } + + [Fact] + public void Clamps_AndEmptyColumns_KeepTheirBehaviour() + { + var cols = Widths((wb, s) => + { + s.Row().WriteCell("a").Skip(1).WriteCell(new string('x', 400)); + s.Column(2).SetWidth(12.5); + }, minWidth: 6, maxWidth: 80); + Assert.Equal(6, cols[1]); // Below the minimum. + Assert.Equal(12.5, cols[2]); // Explicit width untouched by AutoFit. + Assert.Equal(80, cols[3]); // Clamped to the maximum. + Assert.False(cols.ContainsKey(4)); // Never written: no column record. + } + + [Fact] + public void ExcelColumnWidth_FollowsTheEcmaFormula() + { + // Truncate((pixels + 5) / 7 * 256) / 256 with ten digits laid out at 72.5 px. + Assert.Equal(Math.Truncate((72.5 + 5) / 7 * 256) / 256, Worksheet.ExcelColumnWidth(10), 3); + Assert.Equal(5.0 / 7, Worksheet.ExcelColumnWidth(0), 2); // Padding only. + } +} diff --git a/tests/SheetStream.Tests/FeatureTests.cs b/tests/SheetStream.Tests/FeatureTests.cs index 09ed732..a38ca34 100644 --- a/tests/SheetStream.Tests/FeatureTests.cs +++ b/tests/SheetStream.Tests/FeatureTests.cs @@ -356,8 +356,8 @@ public void AutoFit_SizesColumnsFromContentAndAllowsLateColumnSettings() using var doc = OpenValidated(bytes); var cols = SheetXml(doc, "Fit").GetFirstChild().Elements().ToDictionary(c => c.Min.Value, c => c.Width.Value); Assert.Equal(20, cols[1]); // Explicit width wins. - Assert.InRange(cols[2], 38, 45); // ~38 characters + padding. - Assert.InRange(cols[3], 6, 12); // "Big" at 16pt bold is scaled up. + Assert.InRange(cols[2], 33, 37); // 230 px of Calibri 11 text + padding, as Excel sizes it (~34.7). + Assert.InRange(cols[3], 4.5, 7); // "Big" at 16pt bold: 19 px scaled by 16/11, wider than the 11pt "1.5" below it. Assert.Equal(60, cols[4]); // Clamped to maxWidth. Assert.Equal(2, SheetXml(doc, "Fit").Descendants().Count()); } From 8562e8c3e3f3d0a82ccb98a910dca29d43cefdb5 Mon Sep 17 00:00:00 2001 From: glenntosco Date: Wed, 14 Oct 2026 08:44:42 -0500 Subject: [PATCH 2/3] NumberDisplayFormat: never throw on malformed format codes Format codes come from user styles and, in edit and template mode, from files SheetStream did not write. An unterminated '[' at the end of a code made the bracket Substring length negative; elapsed-time tokens could ask for a negative or absurd digit count on NaN or huge serials. Unterminated quotes and brackets now run to the end of the code, date-letter runs and elapsed digits are capped, and anything the parser still cannot handle falls back to General instead of failing the save over a width estimate. Tests feed the parser unterminated quotes and brackets, lone escapes at the end, only semicolons, nested brackets, 10,000-character runs and a 1 MB code, and render each with NaN, infinities and 1e300. Co-Authored-By: Claude Fable 5.1 --- .../Internal/NumberDisplayFormat.cs | 46 +++++++++++--- tests/SheetStream.Tests/AutoFitTests.cs | 61 +++++++++++++++++++ 2 files changed, 98 insertions(+), 9 deletions(-) diff --git a/src/SheetStream/Internal/NumberDisplayFormat.cs b/src/SheetStream/Internal/NumberDisplayFormat.cs index b73073c..cf95337 100644 --- a/src/SheetStream/Internal/NumberDisplayFormat.cs +++ b/src/SheetStream/Internal/NumberDisplayFormat.cs @@ -87,9 +87,12 @@ private string ExpandDate(double value) case DayAbbreviation: sb.Append(CultureInfo.InvariantCulture.DateTimeFormat.GetAbbreviatedDayName(date.DayOfWeek)); break; case Elapsed: { + // NaN, infinities and absurd serials would give a meaningless (or negative) digit count. var total = Math.Floor(Math.Abs(value) * _elapsedPerDay); - var digits = total < 1 ? 1 : (int)Math.Floor(Math.Log10(total)) + 1; - sb.Append('0', Math.Max(digits, _elapsedDigits)); + var digits = !(total >= 1) ? 1 + : total >= 1e19 ? MaxElapsedDigits + : (int)Math.Floor(Math.Log10(total)) + 1; + sb.Append('0', Math.Min(MaxElapsedDigits, Math.Max(digits, Math.Min(_elapsedDigits, MaxElapsedDigits)))); break; } default: sb.Append(c); break; @@ -112,11 +115,34 @@ public static string GeneralText(double value) return value.ToString("G10", CultureInfo.InvariantCulture); } - /// Parses an Excel number format code. null, empty and General give . + /// Longest run of one date letter that still changes the display (mmmm, dddd); longer runs are capped. + private const int MaxTokenRun = 8; + + /// Longest digit string an elapsed-time token is allowed to expand to. + private const int MaxElapsedDigits = 20; + + /// + /// Parses an Excel number format code. null, empty and General give . + /// Never throws: codes come from user styles and, in edit and template mode, from files SheetStream did + /// not write, so anything the parser cannot interpret falls back to General. + /// public static NumberDisplayFormat Parse(string? code) { if (string.IsNullOrEmpty(code) || code == "General") return General; - var s = FirstSection(code!); + try + { + return ParseCore(code!); + } + catch (Exception) + { + // A width estimate is never worth failing a save over. + return General; + } + } + + private static NumberDisplayFormat ParseCore(string code) + { + var s = FirstSection(code); if (s.Length == 0) return General; // Literals are collected twice: in display order for date formats (whose text is value-independent) @@ -149,7 +175,8 @@ void Literal(string text) { case '"': { - var end = s.IndexOf('"', i + 1); + // An unterminated quote runs to the end of the code. + var end = i + 1 < s.Length ? s.IndexOf('"', i + 1) : -1; if (end < 0) end = s.Length; Literal(s.Substring(i + 1, end - i - 1)); i = end; @@ -164,8 +191,9 @@ void Literal(string text) break; case '[': { + // An unterminated bracket runs to the end of the code. var close = s.IndexOf(']', i); - if (close < 0) close = s.Length - 1; + if (close < 0) close = s.Length; var inner = s.Substring(i + 1, close - i - 1); if (inner.Length > 0 && inner[0] == '$') { @@ -232,7 +260,7 @@ void Literal(string text) isDate = true; var run = 1; while (i + run < s.Length && char.ToLowerInvariant(s[i + run]) == char.ToLowerInvariant(c)) run++; - dateText.Append(DateToken(char.ToLowerInvariant(c), run)); + dateText.Append(DateToken(char.ToLowerInvariant(c), Math.Min(run, MaxTokenRun))); i += run - 1; break; } @@ -309,7 +337,7 @@ private static bool IsElapsedToken(string inner) } private static bool StartsWith(string s, int index, string token) - => string.Compare(s, index, token, 0, token.Length, StringComparison.OrdinalIgnoreCase) == 0 - && index + token.Length <= s.Length; + => index + token.Length <= s.Length + && string.Compare(s, index, token, 0, token.Length, StringComparison.OrdinalIgnoreCase) == 0; } } diff --git a/tests/SheetStream.Tests/AutoFitTests.cs b/tests/SheetStream.Tests/AutoFitTests.cs index bd801e2..d1cb441 100644 --- a/tests/SheetStream.Tests/AutoFitTests.cs +++ b/tests/SheetStream.Tests/AutoFitTests.cs @@ -167,6 +167,67 @@ public void FormattedNumbers_MeasureTheirDisplayedText() public void NumberDisplayFormat_RendersWhatExcelShows(string code, double value, string expected) => Assert.Equal(expected, NumberDisplayFormat.Parse(code).Render(value)); + [Theory] + [InlineData("\"unterminated")] + [InlineData("\"")] + [InlineData("#,##0\"")] + [InlineData("[")] + [InlineData("[Red")] + [InlineData("[$")] + [InlineData("[$€-")] + [InlineData("[h")] + [InlineData("[[h]]:mm")] + [InlineData("[[[]]]")] + [InlineData(";")] + [InlineData(";;;")] + [InlineData("\\")] + [InlineData("0\\")] + [InlineData("_")] + [InlineData("0_")] + [InlineData("*")] + [InlineData("0*")] + [InlineData("E+")] + [InlineData("0E")] + [InlineData("A")] + [InlineData("AM/P")] + [InlineData("G")] + [InlineData("Genera")] + [InlineData("\u0001\u0005")] // The parser's own placeholder characters as input. + [InlineData("]]]]")] + [InlineData("\"\"\"\"")] + public void NumberDisplayFormat_NeverThrowsOnHostileCodes(string code) + { + var format = NumberDisplayFormat.Parse(code); + foreach (var value in new[] { 0.0, 1.5, -1234567.891, 1e300, -1e300, double.NaN, double.PositiveInfinity, double.Epsilon, 2958466.0, 1e30 }) + Assert.NotNull(format.Render(value)); + } + + [Fact] + public void NumberDisplayFormat_SurvivesPathologicalLengths() + { + var codes = new[] + { + new string('d', 10_000), + new string('m', 10_000) + "/" + new string('y', 10_000), + new string('[', 10_000), + new string('"', 10_001), + new string('\\', 10_001), + new string('_', 10_001), + new string(';', 10_000), + new string('0', 100_000) + "." + new string('0', 100_000), + string.Concat(Enumerable.Repeat("[h]", 10_000)), + new string('#', 1_000_000), + }; + foreach (var code in codes) + { + var format = NumberDisplayFormat.Parse(code); + foreach (var value in new[] { 0.0, 123.456, 1e300, double.NaN }) + Assert.NotNull(format.Render(value)); + } + // Elapsed digits are capped even for absurd serial values. + Assert.Equal(20, NumberDisplayFormat.Parse("[h]").Render(1e300).Length); + } + [Fact] public void AccentedLetters_MeasureLikeTheirBaseLetter() { From 37385bda85338bd911ca683fe19ff7a954cc3163 Mon Sep 17 00:00:00 2001 From: glenntosco Date: Wed, 14 Oct 2026 08:45:25 -0500 Subject: [PATCH 3/3] README and changelog: AutoFit measured like Excel Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + README.md | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cf172e..5d2d1d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` 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 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. diff --git a/README.md b/README.md index c23b59d..9df3a48 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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