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 diff --git a/src/SheetStream/Internal/NumberDisplayFormat.cs b/src/SheetStream/Internal/NumberDisplayFormat.cs new file mode 100644 index 0000000..cf95337 --- /dev/null +++ b/src/SheetStream/Internal/NumberDisplayFormat.cs @@ -0,0 +1,343 @@ +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: + { + // 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 + : 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; + } + } + 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); + } + + /// 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; + 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) + // 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 '"': + { + // 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; + 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 '[': + { + // An unterminated bracket runs to the end of the code. + var close = s.IndexOf(']', i); + if (close < 0) close = s.Length; + 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), Math.Min(run, MaxTokenRun))); + 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) + => index + token.Length <= s.Length + && string.Compare(s, index, token, 0, token.Length, StringComparison.OrdinalIgnoreCase) == 0; + } +} 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..d1cb441 --- /dev/null +++ b/tests/SheetStream.Tests/AutoFitTests.cs @@ -0,0 +1,306 @@ +// 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)); + + [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() + { + 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()); }