diff --git a/src/ExcelReader.Core/ExcelReader.Core.csproj b/src/ExcelReader.Core/ExcelReader.Core.csproj
index ba8e0a3c..3bb8342a 100644
--- a/src/ExcelReader.Core/ExcelReader.Core.csproj
+++ b/src/ExcelReader.Core/ExcelReader.Core.csproj
@@ -17,8 +17,8 @@
1.0.0
Gabriel Matte
ExcelReader
- High-performance, low-allocation XLSX reading, typed row parsing, and minimal workbook writing for .NET.
- excel;xlsx;reader;writer;parser;spreadsheet;streaming;performance;low-allocation
+ High-performance, low-allocation .NET library for reading and writing XLSX, XLSB, XLS, and CSV — streaming readers, typed row parsing, and workbook writers for all four formats.
+ excel;xlsx;xlsb;xls;csv;reader;writer;parser;spreadsheet;streaming;performance;low-allocation
MIT
README.md
https://github.com/GabrielMarquezMatte/ExcelReader
diff --git a/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs b/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs
index 269d7aab..890bcd54 100644
--- a/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs
+++ b/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs
@@ -73,6 +73,10 @@ internal static class ColumnParserFactory
{
return innerNullable is null ? BuildTextDateOnlyParser(prop) : BuildTextNullableDateOnlyParser(prop);
}
+ if (effective == typeof(TimeOnly))
+ {
+ return innerNullable is null ? BuildTextTimeOnlyParser(prop) : BuildTextNullableTimeOnlyParser(prop);
+ }
}
if (innerNullable is not null)
{
@@ -197,7 +201,11 @@ private static ColumnParser BuildBoolParser(PropertyInfo prop)
RefAction setter = CompileSetter(prop);
return (ref model, in cell, _, _) =>
{
- setter(ref model, IsTruthy(in cell));
+ if (!TryParseBool(in cell, out bool value))
+ {
+ return false;
+ }
+ setter(ref model, value);
return true;
};
}
@@ -330,6 +338,34 @@ private static ColumnParser BuildTextNullableDateOnlyParser(PropertyInfo p
};
}
+ private static ColumnParser BuildTextTimeOnlyParser(PropertyInfo prop)
+ {
+ RefAction setter = CompileSetter(prop);
+ return (ref model, in cell, _, provider) =>
+ {
+ if (!TryParseTimeOnlyText(in cell, provider, out TimeOnly t))
+ {
+ return false;
+ }
+ setter(ref model, t);
+ return true;
+ };
+ }
+
+ private static ColumnParser BuildTextNullableTimeOnlyParser(PropertyInfo prop)
+ {
+ RefAction setter = CompileSetter(prop);
+ return (ref model, in cell, _, provider) =>
+ {
+ if (!TryParseTimeOnlyText(in cell, provider, out TimeOnly t))
+ {
+ return false;
+ }
+ setter(ref model, t);
+ return true;
+ };
+ }
+
// DateTime/DateOnly implement ISpanParsable (char) and IUtf8SpanFormattable, but NOT
// IUtf8SpanParsable (no parse-from-UTF-8) on either net8 or net10. So decode the short date
// field to a stack char buffer — allocation-free — and parse culture-aware (honors Culture,
@@ -368,6 +404,18 @@ private static bool TryParseDateOnlyText(in Cell cell, IFormatProvider provider,
return DateOnly.TryParse(cell.GetString(), provider, DateTimeStyles.None, out value);
}
+ private static bool TryParseTimeOnlyText(in Cell cell, IFormatProvider provider, out TimeOnly value)
+ {
+ ReadOnlySpan utf8 = cell.Value;
+ if (utf8.Length <= MaxStackDateChars)
+ {
+ Span chars = stackalloc char[MaxStackDateChars];
+ int n = Encoding.UTF8.GetChars(utf8, chars);
+ return TimeOnly.TryParse(chars[..n], provider, DateTimeStyles.None, out value);
+ }
+ return TimeOnly.TryParse(cell.GetString(), provider, DateTimeStyles.None, out value);
+ }
+
private static ColumnParser BuildParsableCore(PropertyInfo prop)
where TProp : IUtf8SpanParsable
{
@@ -388,7 +436,11 @@ private static ColumnParser BuildNullableBoolParser(PropertyInfo prop)
RefAction setter = CompileSetter(prop);
return (ref model, in cell, _, _) =>
{
- setter(ref model, IsTruthy(in cell));
+ if (!TryParseBool(in cell, out bool value))
+ {
+ return false;
+ }
+ setter(ref model, value);
return true;
};
}
@@ -518,7 +570,7 @@ private static class EnumCache
#if !NET8_0
private static readonly FrozenDictionary _nameMap = BuildNameMap();
#endif
- private static readonly FrozenDictionary _valueMap = BuildValueMap();
+ private static readonly FrozenDictionary _valueMap = BuildValueMap();
#if NET8_0
private static readonly (string Name, TEnum Value)[] _sortedNames = BuildSortedNames();
@@ -529,8 +581,8 @@ private static (string Name, TEnum Value)[] BuildSortedNames()
{
string name = value.ToString();
list.Add((name, value));
- int intValue = Convert.ToInt32(value, CultureInfo.InvariantCulture);
- string intStr = intValue.ToString(CultureInfo.InvariantCulture);
+ long numericValue = Convert.ToInt64(value, CultureInfo.InvariantCulture);
+ string intStr = numericValue.ToString(CultureInfo.InvariantCulture);
if (!string.Equals(name, intStr, StringComparison.OrdinalIgnoreCase))
{
list.Add((intStr, value));
@@ -574,20 +626,20 @@ private static FrozenDictionary BuildNameMap()
foreach (TEnum value in Enum.GetValues())
{
string name = value.ToString();
- var intValue = Convert.ToInt32(value, CultureInfo.InvariantCulture);
+ long numericValue = Convert.ToInt64(value, CultureInfo.InvariantCulture);
map[name] = value;
- map[intValue.ToString(CultureInfo.InvariantCulture)] = value;
+ map[numericValue.ToString(CultureInfo.InvariantCulture)] = value;
}
return map.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
}
#endif
- private static FrozenDictionary BuildValueMap()
+ private static FrozenDictionary BuildValueMap()
{
- Dictionary map = [];
+ Dictionary map = [];
foreach (TEnum value in Enum.GetValues())
{
- int intValue = Convert.ToInt32(value, CultureInfo.InvariantCulture);
- map[intValue] = value;
+ long numericValue = Convert.ToInt64(value, CultureInfo.InvariantCulture);
+ map[numericValue] = value;
}
return map.ToFrozenDictionary();
}
@@ -595,7 +647,12 @@ public static bool TryParse(in Cell cell, out TEnum value)
{
if (cell.Type == CellType.Number && cell.TryGetDouble(out double d))
{
- return _valueMap.TryGetValue((int)d, out value);
+ if (d != Math.Truncate(d) || d < long.MinValue || d > long.MaxValue)
+ {
+ value = default;
+ return false;
+ }
+ return _valueMap.TryGetValue((long)d, out value);
}
#if NET8_0
ReadOnlySpan utf8 = cell.Value;
@@ -704,11 +761,21 @@ private static RefAction CompileSetter(PropertyInfo prop)
return (RefAction)lambda.Compile();
}
- private static bool IsTruthy(in Cell cell)
+ // Matches "1"/"0" and "true"/"false" case-insensitively (so .NET's own bool.ToString() form
+ // "True"/"False" round-trips) and reports failure for anything else, so a nullable bool?
+ // column with garbage text stays null instead of silently becoming false.
+ private static bool TryParseBool(in Cell cell, out bool value)
{
- return cell.Value.SequenceEqual("1"u8)
- || cell.Value.SequenceEqual("TRUE"u8)
- || cell.Value.SequenceEqual("true"u8);
+ ReadOnlySpan v = cell.Value;
+ if (v.Length == 1)
+ {
+ if (v[0] == (byte)'1') { value = true; return true; }
+ if (v[0] == (byte)'0') { value = false; return true; }
+ }
+ if (Ascii.EqualsIgnoreCase(v, "true"u8)) { value = true; return true; }
+ if (Ascii.EqualsIgnoreCase(v, "false"u8)) { value = false; return true; }
+ value = false;
+ return false;
}
}
}
diff --git a/src/ExcelReader.Core/Parser/Internal/CsvEnumerable.cs b/src/ExcelReader.Core/Parser/Internal/CsvEnumerable.cs
index fecdd061..5931b723 100644
--- a/src/ExcelReader.Core/Parser/Internal/CsvEnumerable.cs
+++ b/src/ExcelReader.Core/Parser/Internal/CsvEnumerable.cs
@@ -251,6 +251,12 @@ internal ProjectionStep Advance(CsvReader.Enumerator rows, ref T model)
{
return ProjectionStep.Stop;
}
+ // The raw CSV reader intentionally exposes a terminal blank line as one empty field.
+ // Typed projection treats it as absent so it cannot yield a phantom model or fail Required.
+ if (rows.FieldCount == 1 && rows.FieldAt(0).Type == CellType.Empty)
+ {
+ return ProjectionStep.Skip;
+ }
model = _typeInfo.CreateInstance();
ParseCurrentRow(rows, ref model);
return ProjectionStep.Yield;
diff --git a/src/ExcelReader.Core/Reader/Brt.cs b/src/ExcelReader.Core/Reader/Brt.cs
index 90625ef1..e8e22407 100644
--- a/src/ExcelReader.Core/Reader/Brt.cs
+++ b/src/ExcelReader.Core/Reader/Brt.cs
@@ -11,6 +11,11 @@ internal static class Brt
internal const int CellReal = 5;
internal const int CellSt = 6; // inline string
internal const int CellIsst = 7; // shared-string index
+ internal const int FmlaString = 8; // formula cell, cached string result
+ internal const int FmlaNum = 9; // formula cell, cached numeric result
+ internal const int FmlaBool = 10; // formula cell, cached bool result
+ internal const int FmlaError = 11; // formula cell, cached error result
+ internal const int CellRString = 62; // inline rich string
internal const int SSTItem = 19;
internal const int Fmt = 44;
internal const int Xf = 47;
diff --git a/src/ExcelReader.Core/Reader/XlsCompoundFile.cs b/src/ExcelReader.Core/Reader/XlsCompoundFile.cs
index 91bc7f8d..97e1ac2b 100644
--- a/src/ExcelReader.Core/Reader/XlsCompoundFile.cs
+++ b/src/ExcelReader.Core/Reader/XlsCompoundFile.cs
@@ -295,8 +295,13 @@ private static byte[] ReadChainBytes(Stream source, int sectorSize, ReadOnlySpan
byte[] sectorBuf = ArrayPool.Shared.Rent(sectorSize);
try
{
+ int sectorsRead = 0;
while (sector is >= 0 and not EndOfChain && (byteLimit < 0 || written < byteLimit))
{
+ if (sectorsRead++ >= fat.Length)
+ {
+ throw new InvalidDataException("OLE FAT chain contains a cycle.");
+ }
ReadAt(source, SectorOffset(sector, sectorSize), sectorBuf);
int take = byteLimit < 0 ? sectorSize : Math.Min(sectorSize, byteLimit - written);
ms.Write(sectorBuf, 0, take);
@@ -388,7 +393,7 @@ private static long SectorOffset(int sector, int sectorSize)
{
throw new InvalidDataException("Invalid OLE sector offset.");
}
- return HeaderSize + ((long)sector * sectorSize);
+ return ((long)sector + 1) * sectorSize;
}
private readonly record struct DirectoryEntry(string Name, byte ObjectType, int StartSector, long Size);
diff --git a/src/ExcelReader.Core/Reader/XlsReader.Enumerator.cs b/src/ExcelReader.Core/Reader/XlsReader.Enumerator.cs
index 5e142955..af1eed6e 100644
--- a/src/ExcelReader.Core/Reader/XlsReader.Enumerator.cs
+++ b/src/ExcelReader.Core/Reader/XlsReader.Enumerator.cs
@@ -46,51 +46,57 @@ public ValueTask MoveNextAsync()
private bool MoveNextCore()
{
- if (_ended)
+ // A row whose only records are BLANK/MULBLANK (styled empty cells — Excel writes these
+ // routinely) yields zero cells; that must not end enumeration, so keep advancing to the
+ // next row instead of returning false for anything short of true EOF.
+ while (!_ended)
{
- return false;
- }
-
- ResetRow();
- BiffCursor cursor = _cursor;
- while (true)
- {
- long recordStart = cursor.Position;
- if (!cursor.TryReadRecord(out int id, out ReadOnlySpan data))
+ ResetRow();
+ BiffCursor cursor = _cursor;
+ while (true)
{
- _ended = true;
- return FinishRow();
- }
+ long recordStart = cursor.Position;
+ if (!cursor.TryReadRecord(out int id, out ReadOnlySpan data))
+ {
+ _ended = true;
+ break;
+ }
- if (id == Rec.Bof)
- {
- if (data.Length < 4 || ReadU16(data, 0) != Biff8Version || ReadU16(data, 2) != SubstreamWorksheet)
+ if (id == Rec.Bof)
{
- throw new NotSupportedException("Only BIFF8 worksheet streams are supported.");
+ if (data.Length < 4 || ReadU16(data, 0) != Biff8Version || ReadU16(data, 2) != SubstreamWorksheet)
+ {
+ throw new NotSupportedException("Only BIFF8 worksheet streams are supported.");
+ }
+ continue;
}
- continue;
- }
- if (id == Rec.Eof)
- {
- _ended = true;
- return FinishRow();
- }
- if (!TryGetCellRow(id, data, out int row))
- {
- continue;
- }
- if (_row < 0)
- {
- _row = row;
+ if (id == Rec.Eof)
+ {
+ _ended = true;
+ break;
+ }
+ if (!TryGetCellRow(id, data, out int row))
+ {
+ continue;
+ }
+ if (_row < 0)
+ {
+ _row = row;
+ }
+ else if (row != _row)
+ {
+ cursor.Position = recordStart;
+ break;
+ }
+
+ ParseCellRecord(id, data);
}
- else if (row != _row)
+ if (FinishRow())
{
- cursor.Position = recordStart;
- return FinishRow();
+ return true;
}
-
- ParseCellRecord(id, data);
}
+ return false;
}
private bool FinishRow()
@@ -181,16 +187,22 @@ private void ParseLabel(ReadOnlySpan data)
int style = ReadU16(data, 4);
int chars = ReadU16(data, 6);
byte flags = data[8];
- const int start = 9;
-
int valueStart = _acc.ValueLength;
+ DecodeUnicodeString(data[9..], chars, flags);
+ _acc.Add(col, valueStart, _acc.ValueLength - valueStart, CellType.ExcelString, style, fromShared: false);
+ }
- int firstByteLen = data.Length - start;
+ // Decodes an XLUnicodeString body (chars, already-read flags byte) that may continue
+ // across one or more CONTINUE records — shared by LABEL and the FORMULA cached-string
+ // result, both of which use this exact shape. Appends decoded UTF-8 to the accumulator.
+ private void DecodeUnicodeString(ReadOnlySpan firstData, int chars, byte flags)
+ {
+ int firstByteLen = firstData.Length;
int firstChars = (flags & 1) == 0 ? firstByteLen : firstByteLen / 2;
firstChars = Math.Min(firstChars, chars);
int firstBytes = (flags & 1) == 0 ? firstChars : firstChars * 2;
Span dst = _acc.ReserveValueSpan(chars * 4);
- int written = DecodeStringToUtf8(data.Slice(start, firstBytes), firstChars, flags, dst);
+ int written = DecodeStringToUtf8(firstData[..firstBytes], firstChars, flags, dst);
_acc.Advance(written);
int charsDecoded = firstChars;
while (charsDecoded < chars && _cursor.PeekId() == Rec.Continue && _cursor.TryReadRecord(out _, out ReadOnlySpan cont) && cont.Length > 0)
@@ -205,7 +217,6 @@ private void ParseLabel(ReadOnlySpan data)
_acc.Advance(written);
charsDecoded += contChars;
}
- _acc.Add(col, valueStart, _acc.ValueLength - valueStart, CellType.ExcelString, style, fromShared: false);
}
private void ParseMulRk(ReadOnlySpan data)
@@ -265,6 +276,16 @@ private void ParseFormula(ReadOnlySpan data)
int len = _acc.AppendErrorText(result[2]);
_acc.Add(col, start, len, CellType.Error, style, fromShared: false);
break;
+ case 0:
+ // String result: the marker means "see the STRING record that follows".
+ if (_cursor.PeekId() == Rec.StringRec && _cursor.TryReadRecord(out _, out ReadOnlySpan str) && str.Length >= 3)
+ {
+ int cch = ReadU16(str, 0);
+ byte strFlags = str[2];
+ DecodeUnicodeString(str[3..], cch, strFlags);
+ _acc.Add(col, start, _acc.ValueLength - start, CellType.ExcelString, style, fromShared: false);
+ }
+ break;
default:
break;
}
diff --git a/src/ExcelReader.Core/Reader/XlsReader.cs b/src/ExcelReader.Core/Reader/XlsReader.cs
index 3778ad24..a97ce8b9 100644
--- a/src/ExcelReader.Core/Reader/XlsReader.cs
+++ b/src/ExcelReader.Core/Reader/XlsReader.cs
@@ -204,7 +204,10 @@ private static void ParseWorkbookGlobals(
private static bool TryParseBoundSheet(ReadOnlySpan data, out (string Name, int Offset) sheet)
{
sheet = default;
- if (data.Length < 8 || !TryDecodeBiffString(data, start: 8, charCount: data[6], flags: data[7], out string name))
+ // BoundSheet8 byte 5 is the sheet type (0 = worksheet). Charts, macro sheets and
+ // dialog sheets have a different substream layout and must not be enumerated as rows.
+ if (data.Length < 8 || data[5] != 0
+ || !TryDecodeBiffString(data, start: 8, charCount: data[6], flags: data[7], out string name))
{
return false;
}
@@ -395,6 +398,7 @@ private static class Rec
internal const int Formula = 0x0006;
internal const int Blank = 0x0201;
internal const int MulBlank = 0x00BE;
+ internal const int StringRec = 0x0207;
}
}
}
diff --git a/src/ExcelReader.Core/Reader/XlsbReader.Enumerator.cs b/src/ExcelReader.Core/Reader/XlsbReader.Enumerator.cs
index 64b0ddad..d85b5da7 100644
--- a/src/ExcelReader.Core/Reader/XlsbReader.Enumerator.cs
+++ b/src/ExcelReader.Core/Reader/XlsbReader.Enumerator.cs
@@ -309,6 +309,24 @@ private void ProcessCell(int id, ReadOnlySpan payload)
case Brt.CellError when payload.Length >= 9:
AppendError(col, style, payload[8]);
break;
+ // Formula cells: the cached result immediately follows the col/style header, in
+ // the same shape as the equivalent plain-cell record; the formula bytes that follow
+ // are outside the record framing we care about (TryReadRecord already bounds payload).
+ case Brt.FmlaNum when payload.Length >= 16:
+ AddDouble(col, style, Biff12.ReadF64(payload, 8));
+ break;
+ case Brt.FmlaString when Biff12.TryReadWideString(payload, 8, out ReadOnlySpan fmlaChars, out _):
+ AppendString(col, style, fmlaChars);
+ break;
+ case Brt.FmlaBool when payload.Length >= 9:
+ AppendBool(col, style, payload[8]);
+ break;
+ case Brt.FmlaError when payload.Length >= 9:
+ AppendError(col, style, payload[8]);
+ break;
+ case Brt.CellRString when payload.Length >= 9 && Biff12.TryReadWideString(payload, 9, out ReadOnlySpan richChars, out _):
+ AppendString(col, style, richChars);
+ break;
// CellBlank: no value to emit
}
}
diff --git a/src/ExcelReader.Core/Reader/XlsxNamespace.cs b/src/ExcelReader.Core/Reader/XlsxNamespace.cs
new file mode 100644
index 00000000..453f1a49
--- /dev/null
+++ b/src/ExcelReader.Core/Reader/XlsxNamespace.cs
@@ -0,0 +1,44 @@
+namespace ExcelReader.Core.Reader
+{
+ // Prefixed forms of every SpreadsheetML element token the worksheet scanner and inline-string
+ // decoder match against, built once when a worksheet's root element carries a namespace prefix
+ // (e.g. ). When the document is unprefixed — the default-namespace case Excel and
+ // almost every producer emit — no NsTokens is created and the scanner keeps its compile-time
+ // literal byte-match fast paths untouched. See XlsxReader.Enumerator's `_ns` field.
+ internal sealed class NsTokens
+ {
+ internal readonly byte[] RowOpen; // ""
+ internal readonly byte[] VClose; // ""
+ internal readonly byte[] CClose; // ""
+ internal readonly byte[] TOpen; // ""
+ internal readonly byte[] RPhOpen; // ""
+ // Bytes the top-level scan must buffer before ClassifyHead can match the longest head token
+ // (" prefix)
+ {
+ RowOpen = XlsxXml.Token("<"u8, prefix, "row"u8);
+ RowEnd = XlsxXml.Token(""u8, prefix, "row"u8);
+ SheetDataEnd = XlsxXml.Token(""u8, prefix, "sheetData"u8);
+ WorksheetEnd = XlsxXml.Token(""u8, prefix, "worksheet"u8);
+ CellOpen = XlsxXml.Token("<"u8, prefix, "c"u8);
+ VOpen = XlsxXml.Token("<"u8, prefix, "v>"u8);
+ VClose = XlsxXml.Token(""u8, prefix, "v>"u8);
+ CClose = XlsxXml.Token(""u8, prefix, "c>"u8);
+ TOpen = XlsxXml.Token("<"u8, prefix, "t"u8);
+ TClose = XlsxXml.Token(""u8, prefix, "t>"u8);
+ RPhOpen = XlsxXml.Token("<"u8, prefix, "rPh"u8);
+ RPhClose = XlsxXml.Token(""u8, prefix, "rPh>"u8);
+ HeadEnsure = WorksheetEnd.Length + 1;
+ }
+ }
+}
diff --git a/src/ExcelReader.Core/Reader/XlsxReader.Enumerator.cs b/src/ExcelReader.Core/Reader/XlsxReader.Enumerator.cs
index c0481677..f7225d8b 100644
--- a/src/ExcelReader.Core/Reader/XlsxReader.Enumerator.cs
+++ b/src/ExcelReader.Core/Reader/XlsxReader.Enumerator.cs
@@ -1,5 +1,6 @@
using System.Buffers.Text;
using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
using ExcelReader.Core.Enums;
using ExcelReader.Core.ValueObjects;
@@ -30,6 +31,16 @@ public sealed class Enumerator : IExcelRowEnumerator
private readonly CellAccumulator _acc; // per-row decoded values + cell descriptors
private int _nextCol;
+ // Non-null only when this sheet's elements carry a namespace prefix (e.g. ); holds the
+ // prefixed forms of every token the scanner matches. Detected once, lazily, on the first
+ // MoveNext(Async). Null for the default-namespace case, which keeps the literal fast paths.
+ private NsTokens? _ns;
+ private bool _nsChecked;
+
+ private ReadOnlySpan VOpen => _ns is null ? ""u8 : _ns.VOpen;
+ private ReadOnlySpan VClose => _ns is null ? ""u8 : _ns.VClose;
+ private ReadOnlySpan CClose => _ns is null ? ""u8 : _ns.CClose;
+
internal Enumerator(XlsxReader reader, Stream sheet, long entryLength = 0, CancellationToken ct = default)
{
_reader = reader;
@@ -52,6 +63,10 @@ internal Enumerator(XlsxReader reader, Stream sheet, long entryLength = 0, Cance
public bool MoveNext()
{
+ if (!_nsChecked)
+ {
+ DetectNamespace();
+ }
while (true)
{
// Fast path: in compact output _pos already sits on the next '<', so skip the scan.
@@ -61,7 +76,7 @@ public bool MoveNext()
return false;
}
_pos = lt;
- Ensure(12);
+ Ensure(_ns is null ? 12 : _ns.HeadEnsure);
switch (ClassifyHead())
{
case HeadKind.End:
@@ -94,6 +109,10 @@ public bool MoveNext()
Justification = "Every .Result access is guarded by IsCompletedSuccessfully immediately above it — never blocks.")]
public ValueTask MoveNextAsync()
{
+ if (!_nsChecked)
+ {
+ return DetectNamespaceThenMoveNextAsync();
+ }
while (true)
{
int lt;
@@ -116,7 +135,7 @@ public ValueTask MoveNextAsync()
}
_pos = lt;
- ValueTask ensureTask = EnsureAsync(12);
+ ValueTask ensureTask = EnsureAsync(_ns is null ? 12 : _ns.HeadEnsure);
if (!ensureTask.IsCompletedSuccessfully)
{
return AwaitThenRestartAsync(ensureTask);
@@ -188,10 +207,42 @@ private async ValueTask FinishRowAfterAsync(ValueTask pendingRowBuffe
return true;
}
+ // Detects the sheet's element-name prefix (e.g. "x:" in ) exactly once, from the
+ // root element at the very start of the stream. Prefixed worksheets are rare (Excel emits the
+ // default namespace), so this stays out of the per-row/per-cell hot path — it runs once, then
+ // _ns is null (fast literal matching) or holds the prefixed tokens for the whole enumeration.
+ private void DetectNamespace()
+ {
+ _nsChecked = true;
+ Ensure(256); // root element + its xmlns declarations sit at the head of the part
+ DetectNamespaceFromBuffer();
+ }
+
+ private async ValueTask DetectNamespaceThenMoveNextAsync()
+ {
+ _nsChecked = true;
+ await EnsureAsync(256).ConfigureAwait(false);
+ DetectNamespaceFromBuffer();
+ return await MoveNextAsync().ConfigureAwait(false);
+ }
+
+ private void DetectNamespaceFromBuffer()
+ {
+ ReadOnlySpan prefix = XlsxXml.DetectElementPrefix(_buf.AsSpan(_pos, _len - _pos));
+ if (!prefix.IsEmpty)
+ {
+ _ns = new NsTokens(prefix);
+ }
+ }
+
// Consumes the open tag and resets per-row state. Call only after ClassifyHead()==Row.
private bool BeginRow()
{
int gt = IndexOf((byte)'>'); // open tag already fully buffered by the Ensure(12) above
+ if (gt < 0)
+ {
+ return MissingRowOpenTag();
+ }
return BeginRowAt(gt);
}
@@ -277,13 +328,14 @@ private int ParseCellSpan(byte[] buf, int len, int p)
}
}
- int cEnd = IndexOfSeqBounded(buf, len, p, ""u8); // ensures whole cell contiguous
+ ReadOnlySpan cClose = CClose; // "", or "" for a prefixed sheet
+ int cEnd = IndexOfSeqBounded(buf, len, p, cClose); // ensures whole cell contiguous
if (cEnd < 0)
{
return len;
}
EmitCell(header.Kind, buf.AsSpan(p, cEnd - p), header.Col, header.Style);
- return cEnd + 4;
+ return cEnd + cClose.Length;
}
private enum HeadKind { End, Row, Skip }
@@ -298,6 +350,10 @@ private HeadKind ClassifyHead()
{
return HeadKind.Skip;
}
+ if (_ns is not null)
+ {
+ return ClassifyHeadPrefixed(_buf.AsSpan(_pos, avail));
+ }
switch (_buf[_pos + 1])
{
case (byte)'r':
@@ -315,12 +371,40 @@ private HeadKind ClassifyHead()
}
}
+ // Prefixed twin of ClassifyHead: matches " head)
+ {
+ if (StartsWithElement(head, _ns!.RowOpen))
+ {
+ return HeadKind.Row;
+ }
+ if (head.StartsWith(_ns.SheetDataEnd) || head.StartsWith(_ns.WorksheetEnd))
+ {
+ return HeadKind.End;
+ }
+ return HeadKind.Skip;
+ }
+
+ // token is a full element open like " span, ReadOnlySpan token)
+ {
+ return span.StartsWith(token) && (span.Length == token.Length || IsBoundary(span[token.Length]));
+ }
+
// "
Number; "s" shared; "inlineStr" inline; "b" bool; "e" error; "str" formula result.
+ // "" / "n" -> Number; "s" shared; "inlineStr" inline; "b" bool; "e" error; "str" formula
+ // result; "d" ISO-8601 date (ECMA-376 §18.18.11 ST_CellType, written by some non-Excel producers).
private static Kind ClassifyKind(ReadOnlySpan t)
{
return t.Length switch
@@ -462,6 +549,7 @@ private static Kind ClassifyKind(ReadOnlySpan t)
(byte)'s' => Kind.Shared,
(byte)'b' => Kind.Bool,
(byte)'e' => Kind.Error,
+ (byte)'d' => Kind.IsoDate,
_ => Kind.Number,
},
3 => Kind.Formula, // "str"
@@ -476,7 +564,7 @@ private void EmitCell(Kind kind, ReadOnlySpan inner, int col, int style)
// Shared strings: holds an index; point the cell at that slice of the shared buffer.
if (kind == Kind.Shared)
{
- var (start, len) = _reader.SharedAt(ParseInt(ElementText(inner, ""u8, ""u8)));
+ var (start, len) = _reader.SharedAt(ParseInt(ElementText(inner, VOpen, VClose)));
_acc.Add(col, start, len, CellType.ExcelString, style, fromShared: true);
return;
}
@@ -485,12 +573,15 @@ private void EmitCell(Kind kind, ReadOnlySpan inner, int col, int style)
{
int vStart = _acc.ValueLength;
Span dst = _acc.ReserveValueSpan(inner.Length);
- _acc.Advance(XlsxXml.WriteTextRuns(inner, dst));
+ int written = _ns is null
+ ? XlsxXml.WriteTextRuns(inner, dst)
+ : XlsxXml.WriteTextRuns(inner, dst, _ns.TOpen, _ns.TClose, _ns.RPhOpen, _ns.RPhClose);
+ _acc.Advance(written);
_acc.Add(col, vStart, _acc.ValueLength - vStart, CellType.ExcelString, style, fromShared: false);
return;
}
- EmitScalarValue(kind, ElementText(inner, ""u8, ""u8), col, style);
+ EmitScalarValue(kind, ElementText(inner, VOpen, VClose), col, style);
}
// Handles every Kind whose content is bare "..." with no other wrapper: Number,
@@ -501,6 +592,13 @@ private void EmitCell(Kind kind, ReadOnlySpan inner, int col, int style)
// direct '<' search) — everything after the value text is found is identical either way.
private void EmitScalarValue(Kind kind, ReadOnlySpan v, int col, int style)
{
+ // t="d": holds ISO-8601 date text, not a serial. Parse it and store a 1900-system
+ // serial so the cell behaves exactly like a style-based date cell (numeric, Type=Date).
+ if (kind == Kind.IsoDate)
+ {
+ EmitIsoDate(v, col, style);
+ return;
+ }
CellType cellType = kind switch
{
Kind.Bool => CellType.Boolean,
@@ -529,6 +627,45 @@ private void EmitScalarValue(Kind kind, ReadOnlySpan v, int col, int style
number: number, hasNumber: hasNumber);
}
+ // Stores a t="d" ISO-8601 cell as a numeric Excel date serial (identical shape to a
+ // style-based date cell), so TryGetDateTime works and GetString matches other date cells.
+ private void EmitIsoDate(ReadOnlySpan v, int col, int style)
+ {
+ if (TryParseIsoDate(v, out DateTime dt))
+ {
+ // ponytail: dt.ToOADate() is exact for dates >= 1900-03-01, which every real t="d"
+ // value is; pre-1900-03-01 would shift a day through the reader's 1900-leap fixup.
+ double serial = dt.ToOADate();
+ int start = _acc.ValueLength;
+ Span dst = _acc.ReserveValueSpan(32);
+ Utf8Formatter.TryFormat(serial, dst, out int written);
+ _acc.Advance(written);
+ _acc.Add(col, start, written, CellType.Date, style, fromShared: false, number: serial, hasNumber: true);
+ return;
+ }
+ // Unparseable ISO text: keep it verbatim as a string so nothing is silently dropped.
+ int s = _acc.ValueLength;
+ AppendRaw(v);
+ _acc.Add(col, s, _acc.ValueLength - s, CellType.ExcelString, style, fromShared: false);
+ }
+
+ private static bool TryParseIsoDate(ReadOnlySpan utf8, out DateTime value)
+ {
+ // ST_Xstring ISO dates are always ASCII; transcode to chars for DateTime.TryParse.
+ if (utf8.Length is 0 or > 40)
+ {
+ value = default;
+ return false;
+ }
+ Span chars = stackalloc char[40];
+ for (int i = 0; i < utf8.Length; i++)
+ {
+ chars[i] = (char)utf8[i];
+ }
+ return DateTime.TryParse(chars[..utf8.Length], CultureInfo.InvariantCulture,
+ DateTimeStyles.RoundtripKind | DateTimeStyles.AllowWhiteSpaces, out value);
+ }
+
private static ReadOnlySpan ElementText(ReadOnlySpan inner, ReadOnlySpan openTag, ReadOnlySpan closeTag)
{
int s = inner.IndexOf(openTag);
@@ -795,7 +932,7 @@ private int FindSeq(MarkupSeq seq, int start)
{
MarkupSeq.CommentEnd => _buf.AsSpan(start, _len - start).IndexOf("-->"u8),
MarkupSeq.CDataEnd => _buf.AsSpan(start, _len - start).IndexOf("]]>"u8),
- _ => _buf.AsSpan(start, _len - start).IndexOf(" _buf.AsSpan(start, _len - start).IndexOf(_ns is null ? " wbByt
}
Dictionary rels = XlsxXml.ParseRelationships(relsBytes);
var sheets = new List<(string, string)>();
- foreach (var tag in Tags(wbBytes, "/); match the prefixed name
+ // when present so a prefixed workbook part still yields its sheets instead of none.
+ ReadOnlySpan prefix = XlsxXml.DetectElementPrefix(wbBytes);
+ ReadOnlySpan sheetTag = " src)
{
return false;
}
- int pos = IdxOf(src, 0, " prefix = XlsxXml.DetectElementPrefix(src);
+ ReadOnlySpan workbookPrTag = " src)
// Decoded text is never longer than its XML, so src.Length bounds the flat buffer.
_sharedFlat = ArrayPool.Shared.Rent(Math.Max(1, src.Length));
+ // Match prefixed sst/si/t element names when the part uses a namespace prefix; built once,
+ // otherwise the compile-time literals with no allocation.
+ ReadOnlySpan prefix = XlsxXml.DetectElementPrefix(src);
+ ReadOnlySpan sstTag = " tOpen = ""u8);
+ tOpen = XlsxXml.Token("<"u8, prefix, "t"u8);
+ tClose = XlsxXml.Token(""u8, prefix, "t>"u8);
+ rPhOpen = XlsxXml.Token("<"u8, prefix, "rPh"u8);
+ rPhClose = XlsxXml.Token(""u8, prefix, "rPh>"u8);
+ }
+
// Pre-size offsets from ; exact counts avoid a final array copy.
int uniqueCount = 0;
- int sstPos = IdxOf(src, 0, "= 0)
{
int sstEnd = IdxOf(src, sstPos, (byte)'>');
@@ -125,7 +155,7 @@ private void ParseShared(ReadOnlySpan src)
int p = 0;
while (true)
{
- int si = IdxOf(src, p, " src)
}
if (src[open - 1] != '/') // not
{
- int end = IdxOf(src, open, ""u8);
+ int end = IdxOf(src, open, siClose);
if (end < 0)
{
break;
}
- flat += XlsxXml.WriteTextRuns(src.Slice(open + 1, end - open - 1), _sharedFlat.AsSpan(flat));
- p = end + 5;
+ flat += XlsxXml.WriteTextRuns(src.Slice(open + 1, end - open - 1), _sharedFlat.AsSpan(flat),
+ tOpen, tClose, rPhOpen, rPhClose);
+ p = end + siClose.Length;
}
else
{
diff --git a/src/ExcelReader.Core/Reader/XlsxReader.Styles.cs b/src/ExcelReader.Core/Reader/XlsxReader.Styles.cs
index 99c85624..a836e93e 100644
--- a/src/ExcelReader.Core/Reader/XlsxReader.Styles.cs
+++ b/src/ExcelReader.Core/Reader/XlsxReader.Styles.cs
@@ -13,9 +13,22 @@ private static bool[] ParseStyleDateFlags(ReadOnlySpan src)
return [];
}
+ // Match prefixed numFmt/cellXfs/xf element names when the styles part uses a namespace
+ // prefix; built once, otherwise the compile-time literals with no allocation.
+ ReadOnlySpan prefix = XlsxXml.DetectElementPrefix(src);
+ ReadOnlySpan numFmtTag = " cellXfsClose = ""u8, xfTag = ""u8);
+ xfTag = XlsxXml.Token("<"u8, prefix, "xf "u8);
+ }
+
// Custom formats: numFmtId -> isDate(formatCode). Builtin ids (<164) handled by IsBuiltinDate.
Dictionary custom = new(capacity: 16);
- foreach (var tag in Tags(src, "= 0)
@@ -25,19 +38,19 @@ private static bool[] ParseStyleDateFlags(ReadOnlySpan src)
}
// Only the entries inside are cell styles; is the master table.
- int region = IdxOf(src, 0, "');
- int end = IdxOf(src, open, ""u8);
+ int end = IdxOf(src, open, cellXfsClose);
if (open < 0 || end < 0)
{
return [];
}
List flags = new(capacity: 16);
- foreach (var xf in Tags(src.Slice(open + 1, end - open - 1), " body, Span dest, ReadO
}
// Scans every ... run inside `si`, entity-decodes each one, and writes the result
- // into `dest` starting at offset 0. Returns total bytes written.
+ // into `dest` starting at offset 0. Returns total bytes written. Text inside (phonetic
+ // guide runs, e.g. Japanese furigana) is skipped — it's a pronunciation hint, not cell content.
// `dest` must be at least `si.Length` bytes — decoded text is never longer than its source XML.
internal static int WriteTextRuns(ReadOnlySpan si, Span dest)
+ {
+ return WriteTextRuns(si, dest, ""u8, ""u8);
+ }
+
+ // Namespace-prefixed overload: the element tokens (e.g. "") are supplied by the
+ // caller, which built them once from the part's root-element prefix (see NsTokens / DetectElementPrefix).
+ internal static int WriteTextRuns(ReadOnlySpan si, Span dest,
+ ReadOnlySpan tOpen, ReadOnlySpan tClose, ReadOnlySpan rPhOpen, ReadOnlySpan rPhClose)
{
int totalWritten = 0;
ReadOnlySpan remaining = si;
Span destSlice = dest;
while (true)
{
- var tIndex = remaining.IndexOf("= 0 && rPhIndex < tIndex)
+ {
+ var rPhEnd = remaining.IndexOf(rPhClose);
+ if (rPhEnd < 0)
+ {
+ break;
+ }
+ remaining = remaining[(rPhEnd + rPhClose.Length)..]; // Skip past ""
+ continue;
+ }
+ remaining = remaining[(tIndex + tOpen.Length)..]; // Skip past "');
if (openIndex < 0)
{
@@ -174,7 +194,7 @@ internal static int WriteTextRuns(ReadOnlySpan si, Span dest)
continue;
}
remaining = remaining[(openIndex + 1)..]; // Skip past the opening tag
- var closeIndex = remaining.IndexOf(""u8);
+ var closeIndex = remaining.IndexOf(tClose);
if (closeIndex < 0)
{
break;
@@ -183,10 +203,60 @@ internal static int WriteTextRuns(ReadOnlySpan si, Span dest)
var written = Decode(innerText, destSlice);
totalWritten += written;
destSlice = destSlice[written..];
- remaining = remaining[(closeIndex + 4)..]; // Skip past the closing tag
+ remaining = remaining[(closeIndex + tClose.Length)..]; // Skip past the closing tag
}
return totalWritten;
}
+
+ // The element-name prefix on the document's root element (e.g. "x:" in ),
+ // including the trailing ':'. Empty span when elements are unprefixed — the default-namespace
+ // case Excel and most producers emit. Skips the XML declaration, comments, and DOCTYPE.
+ internal static ReadOnlySpan DetectElementPrefix(ReadOnlySpan src)
+ {
+ int i = 0;
+ while (true)
+ {
+ int lt = src[i..].IndexOf((byte)'<');
+ if (lt < 0)
+ {
+ return default;
+ }
+ i += lt + 1;
+ if (i >= src.Length)
+ {
+ return default;
+ }
+ // '?' = , '!' = / , '/' = stray close tag — none are the root.
+ if (src[i] is (byte)'?' or (byte)'!' or (byte)'/')
+ {
+ continue;
+ }
+ int nameStart = i;
+ for (int j = i; j < src.Length; j++)
+ {
+ byte b = src[j];
+ if (b is (byte)' ' or (byte)'\t' or (byte)'\r' or (byte)'\n' or (byte)'>' or (byte)'/')
+ {
+ return default; // root element name has no ':' — unprefixed
+ }
+ if (b == (byte)':')
+ {
+ return src.Slice(nameStart, j - nameStart + 1); // "x:" (colon included)
+ }
+ }
+ return default;
+ }
+ }
+
+ // Builds a literal element token "lead + prefix + rest", e.g. ("", "x:", "row") -> " lead, ReadOnlySpan prefix, ReadOnlySpan rest)
+ {
+ byte[] token = new byte[lead.Length + prefix.Length + rest.Length];
+ lead.CopyTo(token);
+ prefix.CopyTo(token.AsSpan(lead.Length));
+ rest.CopyTo(token.AsSpan(lead.Length + prefix.Length));
+ return token;
+ }
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static int HexVal(byte d)
{
diff --git a/src/ExcelReader.Core/ValueObjects/Cell.cs b/src/ExcelReader.Core/ValueObjects/Cell.cs
index 227b8afa..7cba0b18 100644
--- a/src/ExcelReader.Core/ValueObjects/Cell.cs
+++ b/src/ExcelReader.Core/ValueObjects/Cell.cs
@@ -71,7 +71,10 @@ public bool TryParse(IFormatProvider? provider, [MaybeNullWhen(false)] out T
{
// Text-backed double (e.g. CSV, or an XLSX cell FastDouble.TryParse declined to parse
// eagerly): try the same exact-representability fast parse before the general parser.
- if (typeof(T) == typeof(double) && FastDouble.TryParse(Value, out double fast))
+ // FastDouble always treats '.' as the decimal separator, so it's only valid when the
+ // caller's culture agrees — otherwise "1.234" under e.g. pt-BR (comma decimal) would
+ // silently parse as 1.234 instead of the correct 1234.
+ if (typeof(T) == typeof(double) && UsesDotDecimalSeparator(provider) && FastDouble.TryParse(Value, out double fast))
{
result = Unsafe.As(ref fast);
return true;
@@ -92,6 +95,12 @@ public bool TryParse(IFormatProvider? provider, [MaybeNullWhen(false)] out T
}
if (typeof(T) == typeof(decimal))
{
+ if (double.IsNaN(_number) || double.IsInfinity(_number)
+ || _number < (double)decimal.MinValue || _number > (double)decimal.MaxValue)
+ {
+ result = default;
+ return false;
+ }
decimal m = (decimal)_number;
result = Unsafe.As(ref m);
return true;
@@ -179,6 +188,14 @@ public bool TryParse(IFormatProvider? provider, [MaybeNullWhen(false)] out T
: T.TryParse(Value, provider, out result);
}
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static bool UsesDotDecimalSeparator(IFormatProvider? provider)
+ {
+ return provider is null
+ || ReferenceEquals(provider, CultureInfo.InvariantCulture)
+ || NumberFormatInfo.GetInstance(provider).NumberDecimalSeparator == ".";
+ }
+
// Interprets the cell's numeric value as an Excel serial date (1900 date system).
// Works on any cell whose value parses as a number — Type == Date signals the source style was a
// date/time format. For Mac-authored workbooks with XlsxReader.IsDate1904 == true, use the
@@ -198,7 +215,14 @@ public bool TryGetDateTime(bool isDate1904, out DateTime result)
result = default;
return false;
}
- double oadate = isDate1904 ? serial + 1462.0 : serial;
+ // Excel's 1900 calendar includes a fictitious 1900-02-29 at serial 60.
+ // Map it explicitly to the adjacent real day; serials 1–59 need the inverse writer shift.
+ double oadate = isDate1904 ? serial + 1462.0 : serial switch
+ {
+ < 60.0 => serial + 1.0,
+ 60.0 => 60.0,
+ _ => serial,
+ };
// FromOADate throws outside this range; guard first.
if (oadate is > -657435.0 and < 2958466.0)
{
diff --git a/src/ExcelReader.Core/Writer/Internal/BiffRecordWriter.cs b/src/ExcelReader.Core/Writer/Internal/BiffRecordWriter.cs
index ab2c1b05..a95a2440 100644
--- a/src/ExcelReader.Core/Writer/Internal/BiffRecordWriter.cs
+++ b/src/ExcelReader.Core/Writer/Internal/BiffRecordWriter.cs
@@ -197,14 +197,30 @@ internal static void WriteWindow2(BiffBuffer buffer)
internal static void WriteNumber(BiffBuffer buffer, int row, int col, int xf, double value)
{
+ // NaN/Infinity have no Xnum representation ([MS-XLS] 2.5.240 requires an IEEE 754 finite
+ // value) — writing them would produce a record a conformant reader must reject.
+ if (!double.IsFinite(value))
+ {
+ throw new ArgumentException($"Cannot write non-finite value '{value}' to a spreadsheet cell.", nameof(value));
+ }
int len = buffer.BeginRecord(BiffRecord.Number);
WriteCellHeader(buffer, row, col, xf);
buffer.WriteDouble(value);
buffer.EndRecord(len);
}
+ // Excel's universal per-cell text limit. Enforcing it here also keeps `cch` below (a u16) from
+ // ever truncating: 32,767 fits in 16 bits, so the overflow that used to corrupt the record
+ // stream for longer strings can no longer happen.
+ private const int MaxCellTextLength = 32_767;
+
internal static void WriteLabel(BiffBuffer buffer, int row, int col, int xf, ReadOnlySpan value)
{
+ if (value.Length > MaxCellTextLength)
+ {
+ throw new ArgumentException(
+ $"Cell text exceeds Excel's {MaxCellTextLength}-character limit ({value.Length} chars).", nameof(value));
+ }
bool compressed = BiffStringEncoder.CanCompress(value);
int charBytes = compressed ? value.Length : value.Length * 2;
if (9 + charBytes <= BiffRecord.MaxPayload)
diff --git a/src/ExcelReader.Core/Writer/Internal/CellFormatter.cs b/src/ExcelReader.Core/Writer/Internal/CellFormatter.cs
index 3003b8c1..4d34f9f2 100644
--- a/src/ExcelReader.Core/Writer/Internal/CellFormatter.cs
+++ b/src/ExcelReader.Core/Writer/Internal/CellFormatter.cs
@@ -1,12 +1,19 @@
using System.Buffers;
using System.Buffers.Text;
using System.Globalization;
+using System.Runtime.CompilerServices;
namespace ExcelReader.Core.Writer.Internal
{
internal static class CellFormatter
{
- private static readonly SearchValues specialChars = SearchValues.Create("&<>\"'");
+ // The 5 XML entity chars, '_' (to detect literal "_xHHHH_" escape sequences that must be
+ // themselves escaped), and every C0 control char that's illegal in XML 1.0 text content
+ // (0x00-0x08, 0x0B, 0x0C, 0x0E-0x1F — tab/LF/CR are legal and excluded).
+ private static readonly SearchValues specialChars = SearchValues.Create(
+ "&<>\"'_" +
+ "\u0000\u0001\u0002\u0003\u0004\u0005\u0006\u0007\u0008\u000B\u000C" +
+ "\u000E\u000F\u0010\u0011\u0012\u0013\u0014\u0015\u0016\u0017\u0018\u0019\u001A\u001B\u001C\u001D\u001E\u001F");
// Writes the cell reference (e.g. "B7") directly to the writer.
// Max XLSX cell is XFD1048576 -> 3 column letters + 7 row digits.
@@ -40,11 +47,15 @@ internal static void WriteString(BiffBuffer xml, string value, int columnIndex,
{
if (includeReference)
{
- WriteCellOpenWithRef(xml, columnIndex, rowNumber, " t=\"inlineStr\">"u8);
+ WriteCellOpenWithRef(xml, columnIndex, rowNumber, HasEdgeWhitespace(value)
+ ? " t=\"inlineStr\">"u8
+ : " t=\"inlineStr\">"u8);
}
else
{
- xml.Write(""u8);
+ xml.Write(HasEdgeWhitespace(value)
+ ? ""u8
+ : ""u8);
}
WriteEscaped(xml, value);
xml.Write(""u8);
@@ -88,7 +99,7 @@ internal static void WriteDateTime(BiffBuffer xml, DateTime value, int columnInd
{
xml.Write(""u8);
}
- WriteValue(xml, value.ToOADate(), sizeHint: 32);
+ WriteValue(xml, DateSerial.ForEpoch(value.ToOADate(), date1904: false), sizeHint: 32);
xml.Write(""u8);
}
@@ -107,6 +118,11 @@ internal static void WriteNumber(BiffBuffer xml, T value, int columnIndex, in
xml.Write(""u8);
}
+ private static bool HasEdgeWhitespace(string value)
+ {
+ return value.Length != 0 && (char.IsWhiteSpace(value[0]) || char.IsWhiteSpace(value[^1]));
+ }
+
internal static void WriteNumber(BiffBuffer xml, int value, int columnIndex, int rowNumber, bool includeReference)
{
if (includeReference)
@@ -191,6 +207,7 @@ private static void WriteValue(BiffBuffer xml, long value, int sizeHint)
private static void WriteValue(BiffBuffer xml, double value, int sizeHint)
{
+ ThrowIfNonFinite(value);
int size = sizeHint;
int written;
while (!Utf8Formatter.TryFormat(value, xml.GetSpan(size), out written))
@@ -206,6 +223,18 @@ private static void WriteValue(BiffBuffer xml, double value, int sizeHint)
private static void WriteValue(BiffBuffer xml, T value, int sizeHint)
where T : IUtf8SpanFormattable
{
+ if (typeof(T) == typeof(double))
+ {
+ ThrowIfNonFinite(Unsafe.As(ref value));
+ }
+ else if (typeof(T) == typeof(float))
+ {
+ float f = Unsafe.As(ref value);
+ if (!float.IsFinite(f))
+ {
+ throw new ArgumentException($"Cannot write non-finite value '{f}' to a spreadsheet cell.", nameof(value));
+ }
+ }
int size = sizeHint;
int written;
while (!value.TryFormat(xml.GetSpan(size), out written, default, CultureInfo.InvariantCulture))
@@ -215,6 +244,16 @@ private static void WriteValue(BiffBuffer xml, T value, int sizeHint)
xml.Advance(written);
}
+ // NaN/Infinity have no representation in the numeric element ([ISO/IEC 29500] ST_Xstring
+ // doesn't cover it either) — writing them as literal text produces a file Excel rejects on open.
+ private static void ThrowIfNonFinite(double value)
+ {
+ if (!double.IsFinite(value))
+ {
+ throw new ArgumentException($"Cannot write non-finite value '{value}' to a spreadsheet cell.", nameof(value));
+ }
+ }
+
internal static void WriteEscaped(BiffBuffer xml, ReadOnlySpan value)
{
int start = 0;
@@ -222,20 +261,54 @@ internal static void WriteEscaped(BiffBuffer xml, ReadOnlySpan value)
while (next >= 0)
{
int i = start + next;
- ReadOnlySpan escape = value[i] switch
+ char c = value[i];
+ if (TryGetEntity(c, out ReadOnlySpan entity))
{
- '&' => "&"u8,
- '<' => "<"u8,
- '>' => ">"u8,
- '"' => """u8,
- _ => "'"u8, // '\''
- };
- if (i > start)
+ if (i > start)
+ {
+ xml.WriteUtf8(value[start..i]);
+ }
+ xml.Write(entity);
+ start = i + 1;
+ }
+ else if (c == '_')
{
- xml.WriteUtf8(value[start..i]);
+ // A literal "_xHHHH_" in the source text must itself be escaped, or Excel reads it
+ // back as a ST_Xstring unicode escape instead of the literal characters the writer
+ // put there (the underscore's own escape is "_x005F_").
+ if (IsXHHHHUnderscorePattern(value, i))
+ {
+ if (i > start)
+ {
+ xml.WriteUtf8(value[start..i]);
+ }
+ xml.Write("_x005F_"u8);
+ start = i + 1;
+ }
+ else
+ {
+ // A plain '_' remains in the pending run, but the next scan must move past
+ // it; otherwise this loop would rediscover the same underscore forever.
+ int following = value[(i + 1)..].IndexOfAny(specialChars);
+ if (following < 0)
+ {
+ break;
+ }
+ next = following + (i + 1 - start);
+ continue;
+ }
+ }
+ else
+ {
+ // Illegal XML 1.0 control character: encode as ST_Xstring's "_xHHHH_" escape, which
+ // Excel writes and reads for exactly this case, instead of emitting invalid XML.
+ if (i > start)
+ {
+ xml.WriteUtf8(value[start..i]);
+ }
+ WriteHexEscape(xml, c);
+ start = i + 1;
}
- xml.Write(escape);
- start = i + 1;
next = value[start..].IndexOfAny(specialChars);
}
if (start < value.Length)
@@ -243,5 +316,52 @@ internal static void WriteEscaped(BiffBuffer xml, ReadOnlySpan value)
xml.WriteUtf8(value[start..]);
}
}
+
+ private static bool TryGetEntity(char c, out ReadOnlySpan entity)
+ {
+ switch (c)
+ {
+ case '&': entity = "&"u8; return true;
+ case '<': entity = "<"u8; return true;
+ case '>': entity = ">"u8; return true;
+ case '"': entity = """u8; return true;
+ case '\'': entity = "'"u8; return true;
+ default: entity = default; return false;
+ }
+ }
+
+ // True when value[i..] starts with the ECMA-376 ST_Xstring escape shape "_xHHHH_" (4 hex digits).
+ private static bool IsXHHHHUnderscorePattern(ReadOnlySpan value, int i)
+ {
+ if (i + 6 >= value.Length || (value[i + 1] != 'x' && value[i + 1] != 'X'))
+ {
+ return false;
+ }
+ for (int k = 0; k < 4; k++)
+ {
+ if (!Uri.IsHexDigit(value[i + 2 + k]))
+ {
+ return false;
+ }
+ }
+ return value[i + 6] == '_';
+ }
+
+ private static void WriteHexEscape(BiffBuffer xml, char c)
+ {
+ Span buf = stackalloc byte[7];
+ "_x0000_"u8.CopyTo(buf);
+ int code = c;
+ buf[2] = HexDigit((code >> 12) & 0xF);
+ buf[3] = HexDigit((code >> 8) & 0xF);
+ buf[4] = HexDigit((code >> 4) & 0xF);
+ buf[5] = HexDigit(code & 0xF);
+ xml.Write(buf);
+ }
+
+ private static byte HexDigit(int nibble)
+ {
+ return (byte)(nibble < 10 ? '0' + nibble : 'A' + (nibble - 10));
+ }
}
}
diff --git a/src/ExcelReader.Core/Writer/Internal/ColumnName.cs b/src/ExcelReader.Core/Writer/Internal/ColumnName.cs
index 21b6f923..95e11dd1 100644
--- a/src/ExcelReader.Core/Writer/Internal/ColumnName.cs
+++ b/src/ExcelReader.Core/Writer/Internal/ColumnName.cs
@@ -1,3 +1,5 @@
+using ExcelReader.Core.Reader;
+
namespace ExcelReader.Core.Writer.Internal
{
internal static class ColumnName
@@ -5,6 +7,10 @@ internal static class ColumnName
// Returns the number of bytes written. Max 3 bytes (Excel limit: 16384 columns = "XFD").
internal static int Write(Span destination, int columnIndex)
{
+ if ((uint)columnIndex >= 16_384)
+ {
+ throw new ExcelLimitExceededException("Columns", 16_384, columnIndex + 1L);
+ }
if (columnIndex < 26)
{
destination[0] = (byte)('A' + columnIndex);
diff --git a/src/ExcelReader.Core/Writer/Internal/DateSerial.cs b/src/ExcelReader.Core/Writer/Internal/DateSerial.cs
index 6f92fa54..7352d1ad 100644
--- a/src/ExcelReader.Core/Writer/Internal/DateSerial.cs
+++ b/src/ExcelReader.Core/Writer/Internal/DateSerial.cs
@@ -6,7 +6,21 @@ internal static class DateSerial
{
internal static double ForEpoch(double serial, bool date1904)
{
- return date1904 ? serial - 1462.0 : serial;
+ // OADate is one day ahead of Excel for Jan 1–Feb 28 1900 because Excel
+ // reserves serial 60 for its fictitious 1900-02-29.
+ if (!date1904 && serial < 61.0)
+ {
+ serial -= 1.0;
+ }
+ if (date1904)
+ {
+ serial -= 1462.0;
+ }
+ if (serial < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(serial), "Dates before the workbook epoch cannot be written to Excel.");
+ }
+ return serial;
}
}
}
diff --git a/src/ExcelReader.Core/Writer/RowWriter.cs b/src/ExcelReader.Core/Writer/RowWriter.cs
index d4793819..e32ff71e 100644
--- a/src/ExcelReader.Core/Writer/RowWriter.cs
+++ b/src/ExcelReader.Core/Writer/RowWriter.cs
@@ -1,5 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using ExcelReader.Core.Writer.Internal;
+using ExcelReader.Core.Reader;
namespace ExcelReader.Core.Writer
{
@@ -44,6 +45,14 @@ public void Write(string? value)
WriteEmptyCell();
return;
}
+ // Excel's universal per-cell text limit, enforced here so every writer rejects the same way
+ // instead of each format silently truncating or corrupting its own record encoding.
+ const int maxCellTextLength = 32_767;
+ if (value.Length > maxCellTextLength)
+ {
+ throw new ArgumentException(
+ $"Cell text exceeds Excel's {maxCellTextLength}-character limit ({value.Length} chars).", nameof(value));
+ }
if (_owner.UseSharedStrings)
{
int index = _owner.GetSharedStringIndex(value);
@@ -236,6 +245,10 @@ public void Skip(int count = 1)
ThrowIfDisposed();
if (count > 0)
{
+ if (_columnIndex > 16_384 - count)
+ {
+ throw new ExcelLimitExceededException("Columns", 16_384, (long)_columnIndex + count);
+ }
_useCellReferences = true;
}
_columnIndex += count;
@@ -247,6 +260,10 @@ public void Skip(int count = 1)
// still emits a real (self-closing) placeholder, so on its own it never needs one.
private bool ConsumeCellReference()
{
+ if ((uint)_columnIndex >= 16_384)
+ {
+ throw new ExcelLimitExceededException("Columns", 16_384, _columnIndex + 1L);
+ }
bool result = _useCellReferences;
_useCellReferences = false;
return result;
diff --git a/src/ExcelReader.Core/Writer/SheetWriter.cs b/src/ExcelReader.Core/Writer/SheetWriter.cs
index cf1e270c..7b49387c 100644
--- a/src/ExcelReader.Core/Writer/SheetWriter.cs
+++ b/src/ExcelReader.Core/Writer/SheetWriter.cs
@@ -1,5 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using System.IO.Compression;
+using ExcelReader.Core.Reader;
using ExcelReader.Core.Writer.Internal;
namespace ExcelReader.Core.Writer
@@ -153,6 +154,10 @@ private int BeginRow(CancellationToken ct)
throw new InvalidOperationException("The previous RowWriter must be disposed before starting a new row.");
}
ct.ThrowIfCancellationRequested();
+ if (_rowNumber >= 1_048_576)
+ {
+ throw new ExcelLimitExceededException("Rows", 1_048_576, _rowNumber + 1L);
+ }
_rowNumber++;
_rowActive = true;
// The `r` attribute on is optional per ECMA-376 (rows are positional); omitting it
diff --git a/src/ExcelReader.Core/Writer/WorkbookWriter.cs b/src/ExcelReader.Core/Writer/WorkbookWriter.cs
index ceddc89e..6932e2bb 100644
--- a/src/ExcelReader.Core/Writer/WorkbookWriter.cs
+++ b/src/ExcelReader.Core/Writer/WorkbookWriter.cs
@@ -104,6 +104,10 @@ public async ValueTask EndAsync(CancellationToken ct = default)
throw new InvalidOperationException("WorkbookWriter must be started before ending.");
}
ct.ThrowIfCancellationRequested();
+ if (_sheets.Count == 0)
+ {
+ throw new InvalidOperationException("An XLSX workbook must contain at least one sheet.");
+ }
_state = WriterState.Ended;
if (_activeSheet is not null)
{
@@ -138,7 +142,21 @@ public async ValueTask DisposeAsync()
_disposed = true;
if (_state == WriterState.Started)
{
- await EndAsync().ConfigureAwait(false);
+ // EndAsync deliberately rejects a zero-sheet workbook. Disposal still must release
+ // a partially configured writer (for example after an earlier validation failure).
+ if (_sheets.Count == 0)
+ {
+ _state = WriterState.Ended;
+#if NET10_0_OR_GREATER
+ await _zip.DisposeAsync().ConfigureAwait(false);
+#else
+ _zip.Dispose();
+#endif
+ }
+ else
+ {
+ await EndAsync().ConfigureAwait(false);
+ }
}
else if (_state == WriterState.Created)
{
diff --git a/src/ExcelReader.Core/Writer/XlsbSheetWriter.cs b/src/ExcelReader.Core/Writer/XlsbSheetWriter.cs
index 1b93d0e7..4c4ad181 100644
--- a/src/ExcelReader.Core/Writer/XlsbSheetWriter.cs
+++ b/src/ExcelReader.Core/Writer/XlsbSheetWriter.cs
@@ -230,6 +230,10 @@ private void BeginRow()
{
throw new InvalidOperationException("The previous XlsbRowWriter must be disposed before starting a new row.");
}
+ if (_rowNumber >= 1_048_576)
+ {
+ throw new ExcelLimitExceededException("Rows", 1_048_576, _rowNumber + 1L);
+ }
_rowNumber++;
WriteRowHeader(_rowNumber);
_rowActive = true;
@@ -244,7 +248,7 @@ private void WriteRowHeader(int rowNumber)
BinaryPrimitives.WriteUInt32LittleEndian(p.Slice(8, 4), 0);
BinaryPrimitives.WriteUInt32LittleEndian(p.Slice(12, 4), 1);
BinaryPrimitives.WriteUInt32LittleEndian(p.Slice(16, 4), 0);
- BinaryPrimitives.WriteUInt32LittleEndian(p.Slice(20, 4), 16384);
+ BinaryPrimitives.WriteUInt32LittleEndian(p.Slice(20, 4), 16383);
p[24] = 0;
MaybeFlush();
}
@@ -306,10 +310,19 @@ private void WriteCell(int columnIndex, XlsbCell cell)
internal void WriteStringCell(int columnIndex, string? value)
{
+ ValidateColumn(columnIndex);
if (value is null)
{
return;
}
+ // Excel's universal per-cell text limit, enforced here so every writer rejects the same way
+ // instead of each format silently truncating or corrupting its own record encoding.
+ const int maxCellTextLength = 32_767;
+ if (value.Length > maxCellTextLength)
+ {
+ throw new ArgumentException(
+ $"Cell text exceeds Excel's {maxCellTextLength}-character limit ({value.Length} chars).", nameof(value));
+ }
if (_owner.UseSharedStrings)
{
const int Length = CellHeaderLength + 4; // + shared-string index u32
@@ -330,6 +343,7 @@ internal void WriteStringCell(int columnIndex, string? value)
internal void WriteBoolCell(int columnIndex, bool value)
{
+ ValidateColumn(columnIndex);
const int Length = CellHeaderLength + 1; // + bool byte
Biff12RecordWriter.WriteFixedRecord(_records, Brt.CellBool, Length, out Span p);
Biff12RecordWriter.WriteCellHeader(p, columnIndex, 0);
@@ -344,11 +358,26 @@ internal void WriteDateSerialCell(int columnIndex, double serial)
internal void WriteDoubleCell(int columnIndex, double value, int style)
{
+ ValidateColumn(columnIndex);
+ // NaN/Infinity have no Xnum representation ([MS-XLSB] 2.5.166.6) — writing raw bit patterns
+ // for them would produce a record a conformant reader must reject.
+ if (!double.IsFinite(value))
+ {
+ throw new ArgumentException($"Cannot write non-finite value '{value}' to a spreadsheet cell.", nameof(value));
+ }
const int Length = CellHeaderLength + 8; // + double
Biff12RecordWriter.WriteFixedRecord(_records, Brt.CellReal, Length, out Span p);
Biff12RecordWriter.WriteCellHeader(p, columnIndex, style);
BinaryPrimitives.WriteDoubleLittleEndian(p.Slice(8, 8), value);
MaybeFlush();
}
+
+ private static void ValidateColumn(int columnIndex)
+ {
+ if ((uint)columnIndex >= 16_384)
+ {
+ throw new ExcelLimitExceededException("Columns", 16_384, columnIndex + 1L);
+ }
+ }
}
}
diff --git a/tests/ExcelReader.Tests/CellVariantTests.cs b/tests/ExcelReader.Tests/CellVariantTests.cs
index 516bc1c3..032dcf49 100644
--- a/tests/ExcelReader.Tests/CellVariantTests.cs
+++ b/tests/ExcelReader.Tests/CellVariantTests.cs
@@ -266,5 +266,36 @@ public async Task IsDate1904FalseForStandard1900Workbook()
await using var reader = Excel.From(ms);
Assert.False(reader.IsDate1904);
}
+
+ [Fact]
+ public void Excel1900SerialsOneToSixtyMapToExcelCalendar()
+ {
+ const string styles =
+ """""";
+ using var ms = WorkbookBuilder.Build(
+ """15960
""",
+ styles: styles);
+ using var reader = Excel.From(ms);
+ using var e = reader.GetEnumerator();
+
+ Assert.True(e.MoveNext());
+ Assert.True(e.Current[0].TryGetDateTime(out DateTime first));
+ Assert.True(e.Current[1].TryGetDateTime(out DateTime leapBoundary));
+ Assert.True(e.Current[2].TryGetDateTime(out DateTime phantomLeapDay));
+ Assert.Equal(new DateTime(1900, 1, 1), first);
+ Assert.Equal(new DateTime(1900, 2, 28), leapBoundary);
+ Assert.Equal(new DateTime(1900, 2, 28), phantomLeapDay);
+ }
+
+ [Fact]
+ public void BinaryNumberOutsideDecimalRangeDoesNotThrow()
+ {
+ using var ms = WorkbookBuilder.Build("""1E+30
""");
+ using var reader = Excel.From(ms);
+ using var e = reader.GetEnumerator();
+
+ Assert.True(e.MoveNext());
+ Assert.False(e.Current[0].TryParse(null, out decimal _));
+ }
}
}
diff --git a/tests/ExcelReader.Tests/CoverageEdgeTests.cs b/tests/ExcelReader.Tests/CoverageEdgeTests.cs
index 5b2b3ed4..72c4babc 100644
--- a/tests/ExcelReader.Tests/CoverageEdgeTests.cs
+++ b/tests/ExcelReader.Tests/CoverageEdgeTests.cs
@@ -231,6 +231,21 @@ public async Task AsyncReaderMissingRowOpenTagReturnsEmptyRow()
Assert.False(await rows.MoveNextAsync());
}
+ [Fact]
+ public void SyncReaderMissingRowOpenTagReturnsEmptyRow()
+ {
+ using MemoryStream ms = BuildRawWorkbook(
+ """""",
+ """""",
+ ("xl/worksheets/sheet1.xml", "(() => new ExcelParser().Parse(reader).ToList());
}
+ [Fact]
+ public void TerminalBlankLineDoesNotYieldPhantomModelOrRequiredFailure()
+ {
+ using var ms = Csv("Id,Note\n7,valid\n\n");
+ using var reader = Excel.FromCsv(ms);
+
+ RequiredRow row = Assert.Single(new ExcelParser().Parse(reader).ToList());
+
+ Assert.Equal(7, row.Id);
+ Assert.Equal("valid", row.Note);
+ }
+
[Fact]
public void PlainDateTimeAndDateOnlyColumnsParseTextNatively()
{
diff --git a/tests/ExcelReader.Tests/NamespacePrefixAndIsoDateTests.cs b/tests/ExcelReader.Tests/NamespacePrefixAndIsoDateTests.cs
new file mode 100644
index 00000000..6fa38c71
--- /dev/null
+++ b/tests/ExcelReader.Tests/NamespacePrefixAndIsoDateTests.cs
@@ -0,0 +1,191 @@
+using ExcelReader.Core.Enums;
+using ExcelReader.Core.Reader;
+
+namespace ExcelReader.Tests
+{
+ // Regression coverage for two SpreadsheetML dialects non-Excel producers emit:
+ // 3.4 — every element carries a namespace prefix (///...).
+ // 3.7 — ISO-8601 date cells typed t="d" (a bare serial is NOT what the holds).
+ public class NamespacePrefixAndIsoDateTests
+ {
+ // ---- 3.4: namespace-prefixed worksheets ----
+
+ [Fact]
+ public void PrefixedNumberAndInlineStringCellsAreRead()
+ {
+ using MemoryStream ms = WorkbookBuilder.BuildPrefixed("x",
+ """42hello""");
+ using XlsxReader reader = Excel.From(ms);
+ using XlsxReader.Enumerator e = reader.GetEnumerator();
+
+ Assert.True(e.MoveNext());
+ Assert.Equal(CellType.Number, e.Current[0].Type);
+ Assert.Equal("42", e.Current[0].GetString());
+ Assert.Equal(CellType.ExcelString, e.Current[1].Type);
+ Assert.Equal("hello", e.Current[1].GetString());
+ Assert.False(e.MoveNext());
+ }
+
+ [Fact]
+ public void PrefixedMultipleRowsAreAllEnumerated()
+ {
+ using MemoryStream ms = WorkbookBuilder.BuildPrefixed("x",
+ """123""");
+ using XlsxReader reader = Excel.From(ms);
+ using XlsxReader.Enumerator e = reader.GetEnumerator();
+
+ Assert.True(e.MoveNext());
+ Assert.Equal("1", e.Current[0].GetString());
+ Assert.True(e.MoveNext());
+ Assert.Equal("2", e.Current[0].GetString());
+ Assert.True(e.MoveNext());
+ Assert.Equal("3", e.Current[0].GetString());
+ Assert.False(e.MoveNext());
+ }
+
+ [Fact]
+ public void PrefixedSharedStringsAreResolved()
+ {
+ using MemoryStream ms = WorkbookBuilder.BuildPrefixed("x",
+ """01""",
+ sharedStrings: "alphabeta");
+ using XlsxReader reader = Excel.From(ms);
+ using XlsxReader.Enumerator e = reader.GetEnumerator();
+
+ Assert.True(e.MoveNext());
+ Assert.Equal("alpha", e.Current[0].GetString());
+ Assert.Equal("beta", e.Current[1].GetString());
+ }
+
+ [Fact]
+ public void PrefixedPhoneticRunsAreSkippedInSharedStrings()
+ {
+ // Exercises the prefixed skip in WriteTextRuns (item 1.1 under a namespace prefix).
+ using MemoryStream ms = WorkbookBuilder.BuildPrefixed("x",
+ """0""",
+ sharedStrings: "株式会社カブシキガイシャ");
+ using XlsxReader reader = Excel.From(ms);
+ using XlsxReader.Enumerator e = reader.GetEnumerator();
+
+ Assert.True(e.MoveNext());
+ Assert.Equal("株式会社", e.Current[0].GetString());
+ }
+
+ [Fact]
+ public void PrefixedCustomDateStyleIsClassifiedAsDate()
+ {
+ // Exercises the prefixed + / style parsing.
+ using MemoryStream ms = WorkbookBuilder.BuildPrefixed("x",
+ """45658""",
+ stylesInner: """""");
+ using XlsxReader reader = Excel.From(ms);
+ using XlsxReader.Enumerator e = reader.GetEnumerator();
+
+ Assert.True(e.MoveNext());
+ Assert.Equal(CellType.Date, e.Current[0].Type);
+ Assert.True(e.Current[0].TryGetDateTime(reader.IsDate1904, out DateTime dt));
+ Assert.Equal(new DateTime(2025, 1, 1), dt);
+ }
+
+ [Fact]
+ public async Task PrefixedWorksheetIsReadAsync()
+ {
+ await using MemoryStream ms = WorkbookBuilder.BuildPrefixed("x",
+ """70""",
+ sharedStrings: "async");
+ await using XlsxReader reader = await Excel.FromAsync(ms, ct: TestContext.Current.CancellationToken);
+ await using XlsxReader.Enumerator e = await reader.GetAsyncEnumeratorAsync(TestContext.Current.CancellationToken);
+
+ Assert.True(await e.MoveNextAsync());
+ Assert.Equal("7", e.Current[0].GetString());
+ Assert.Equal("async", e.Current[1].GetString());
+ Assert.False(await e.MoveNextAsync());
+ }
+
+ [Fact]
+ public void UnusualPrefixNameIsSupported()
+ {
+ // The prefix is detected from the root element, so it need not be the conventional "x".
+ using MemoryStream ms = WorkbookBuilder.BuildPrefixed("ss",
+ """99""");
+ using XlsxReader reader = Excel.From(ms);
+ using XlsxReader.Enumerator e = reader.GetEnumerator();
+
+ Assert.True(e.MoveNext());
+ Assert.Equal("99", e.Current[0].GetString());
+ }
+
+ // ---- 3.7: ISO-8601 date cells (t="d") ----
+
+ [Fact]
+ public void IsoDateTimeCellIsParsedAsDate()
+ {
+ using MemoryStream ms = WorkbookBuilder.Build(
+ """2026-01-02T13:45:00
""");
+ using XlsxReader reader = Excel.From(ms);
+ using XlsxReader.Enumerator e = reader.GetEnumerator();
+
+ Assert.True(e.MoveNext());
+ Assert.Equal(CellType.Date, e.Current[0].Type);
+ Assert.True(e.Current[0].TryGetDateTime(reader.IsDate1904, out DateTime dt));
+ Assert.Equal(new DateTime(2026, 1, 2, 13, 45, 0), dt);
+ }
+
+ [Fact]
+ public void IsoDateOnlyCellIsParsedAsDate()
+ {
+ using MemoryStream ms = WorkbookBuilder.Build(
+ """2026-01-02
""");
+ using XlsxReader reader = Excel.From(ms);
+ using XlsxReader.Enumerator e = reader.GetEnumerator();
+
+ Assert.True(e.MoveNext());
+ Assert.Equal(CellType.Date, e.Current[0].Type);
+ Assert.True(e.Current[0].TryGetDateTime(reader.IsDate1904, out DateTime dt));
+ Assert.Equal(new DateTime(2026, 1, 2), dt);
+ }
+
+ [Fact]
+ public void UnparseableIsoDateCellIsKeptAsString()
+ {
+ // Garbage in a t="d" cell must not crash the enumerator or silently vanish.
+ using MemoryStream ms = WorkbookBuilder.Build(
+ """not-a-date
""");
+ using XlsxReader reader = Excel.From(ms);
+ using XlsxReader.Enumerator e = reader.GetEnumerator();
+
+ Assert.True(e.MoveNext());
+ Assert.Equal(CellType.ExcelString, e.Current[0].Type);
+ Assert.Equal("not-a-date", e.Current[0].GetString());
+ }
+
+ [Fact]
+ public async Task IsoDateCellIsParsedAsDateAsync()
+ {
+ await using MemoryStream ms = WorkbookBuilder.Build(
+ """2026-01-02T00:00:00
""");
+ await using XlsxReader reader = await Excel.FromAsync(ms, ct: TestContext.Current.CancellationToken);
+ await using XlsxReader.Enumerator e = await reader.GetAsyncEnumeratorAsync(TestContext.Current.CancellationToken);
+
+ Assert.True(await e.MoveNextAsync());
+ Assert.Equal(CellType.Date, e.Current[0].Type);
+ Assert.True(e.Current[0].TryGetDateTime(reader.IsDate1904, out DateTime dt));
+ Assert.Equal(new DateTime(2026, 1, 2), dt);
+ }
+
+ [Fact]
+ public void IsoDateCellCombinedWithPrefix()
+ {
+ // Both dialects at once: a prefixed worksheet whose cell is also an ISO t="d" date.
+ using MemoryStream ms = WorkbookBuilder.BuildPrefixed("x",
+ """2026-03-04""");
+ using XlsxReader reader = Excel.From(ms);
+ using XlsxReader.Enumerator e = reader.GetEnumerator();
+
+ Assert.True(e.MoveNext());
+ Assert.Equal(CellType.Date, e.Current[0].Type);
+ Assert.True(e.Current[0].TryGetDateTime(reader.IsDate1904, out DateTime dt));
+ Assert.Equal(new DateTime(2026, 3, 4), dt);
+ }
+ }
+}
diff --git a/tests/ExcelReader.Tests/ParserFeatureTests.cs b/tests/ExcelReader.Tests/ParserFeatureTests.cs
index 093b8cf3..62e06aee 100644
--- a/tests/ExcelReader.Tests/ParserFeatureTests.cs
+++ b/tests/ExcelReader.Tests/ParserFeatureTests.cs
@@ -15,6 +15,16 @@ private enum Status
Closed = 2,
}
+ private enum LargeStatus : long
+ {
+ High = 3_000_000_000,
+ }
+
+ private sealed class LargeEnumRow
+ {
+ public LargeStatus? Status { get; set; }
+ }
+
private sealed class MoneyRow
{
public string? Name { get; set; }
@@ -153,5 +163,20 @@ public async Task InvalidGuidKeepsDefault()
Assert.Equal(Guid.Empty, row.Id);
Assert.Null(row.OptionalId);
}
+
+ [Fact]
+ public async Task LongBackedEnumParsesWithoutTruncatingFractionalNumbers()
+ {
+ await using var ms = await TypedWorkbook.BuildAsync(
+ ["Status"],
+ [3_000_000_000d],
+ [3_000_000_000.5d]);
+ await using var reader = await Excel.FromAsync(ms, ct: TestContext.Current.CancellationToken);
+
+ LargeEnumRow[] rows = new ExcelParser().Parse(reader).ToArray();
+
+ Assert.Equal(LargeStatus.High, rows[0].Status);
+ Assert.Null(rows[1].Status);
+ }
}
}
diff --git a/tests/ExcelReader.Tests/RealWorldInteropTests.cs b/tests/ExcelReader.Tests/RealWorldInteropTests.cs
index 5aa74f56..68fd6e04 100644
--- a/tests/ExcelReader.Tests/RealWorldInteropTests.cs
+++ b/tests/ExcelReader.Tests/RealWorldInteropTests.cs
@@ -92,6 +92,49 @@ public static IEnumerable
search.
string big = new('x', 100_000);
- await using var ms = await TypedWorkbook.BuildAsync(
- [10, new Gap(), 30],
- [big]);
+ // Deliberately raw: the writer now rejects values beyond Excel's 32,767-character
+ // cell limit, while this reader test needs an oversized XML value to cross its buffer.
+ await using var ms = WorkbookBuilder.Build(
+ $$"""1030
{{big}}
""");
await using var reader = await Excel.FromAsync(ms, ct: TestContext.Current.CancellationToken);
await using var enumerator = await reader.GetAsyncEnumeratorAsync(TestContext.Current.CancellationToken);
@@ -190,9 +191,12 @@ public async Task AsyncHandlesBufferGrowthAndDates()
// the cross-refill search on the async path; the date cell exercises style detection.
var ct = TestContext.Current.CancellationToken;
string big = new('x', 100_000);
- await using var ms = await TypedWorkbook.BuildAsync(
- [DateTime.FromOADate(45292)],
- [big]);
+ const string styles =
+ """""";
+ // Deliberately raw for the same reason as the synchronous buffer-growth test above.
+ await using var ms = WorkbookBuilder.Build(
+ $$"""45292
{{big}}
""",
+ styles: styles);
await using var reader = await Excel.FromAsync(ms, ct: ct);
await using var e = await reader.GetAsyncEnumeratorAsync(ct);
diff --git a/tests/ExcelReader.Tests/TestUtils.cs b/tests/ExcelReader.Tests/TestUtils.cs
index 10084dd7..8eabdb5a 100644
--- a/tests/ExcelReader.Tests/TestUtils.cs
+++ b/tests/ExcelReader.Tests/TestUtils.cs
@@ -117,6 +117,40 @@ internal static MemoryStream BuildMultiSheet(
return ms;
}
+ // Builds a workbook whose every SpreadsheetML element carries a namespace prefix (e.g. ),
+ // as some non-Excel producers emit. The caller supplies already-prefixed row/shared/style content;
+ // this prefixes the structural elements (workbook/sheets/sheet/worksheet/sheetData/sst). The .rels
+ // part keeps the OPC package-relationships namespace (never the spreadsheet prefix), matching reality.
+ internal static MemoryStream BuildPrefixed(
+ string prefix,
+ string sheetRows,
+ string? sharedStrings = null,
+ string? stylesInner = null)
+ {
+ string p = prefix + ":";
+ var ms = new MemoryStream();
+ using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
+ {
+ Write(zip, "xl/worksheets/sheet1.xml",
+ $"""<{p}worksheet xmlns:{prefix}="{Main}"><{p}sheetData>{sheetRows}{p}sheetData>{p}worksheet>""");
+ Write(zip, "xl/workbook.xml",
+ $"""<{p}workbook xmlns:{prefix}="{Main}" xmlns:r="{Rel}"><{p}sheets><{p}sheet name="S1" sheetId="1" r:id="rId1"/>{p}sheets>{p}workbook>""");
+ Write(zip, "xl/_rels/workbook.xml.rels",
+ $"""""");
+ if (sharedStrings is not null)
+ {
+ Write(zip, "xl/sharedStrings.xml", $"""<{p}sst xmlns:{prefix}="{Main}">{sharedStrings}{p}sst>""");
+ }
+ if (stylesInner is not null)
+ {
+ Write(zip, "xl/styles.xml",
+ $"""<{p}styleSheet xmlns:{prefix}="{Main}">{stylesInner}{p}styleSheet>""");
+ }
+ }
+ ms.Position = 0;
+ return ms;
+ }
+
private static void Write(ZipArchive zip, string name, string content)
{
using var s = zip.CreateEntry(name).Open();
diff --git a/tests/ExcelReader.Tests/WorkbookWriterTests.cs b/tests/ExcelReader.Tests/WorkbookWriterTests.cs
index d5fdcc5a..76525f8d 100644
--- a/tests/ExcelReader.Tests/WorkbookWriterTests.cs
+++ b/tests/ExcelReader.Tests/WorkbookWriterTests.cs
@@ -1,4 +1,6 @@
using System.Diagnostics.CodeAnalysis;
+using System.IO.Compression;
+using System.Text;
using ExcelReader.Core.Enums;
using ExcelReader.Core.Parser;
using ExcelReader.Core.Reader;
@@ -952,5 +954,104 @@ public async Task DisposeAsyncIsIdempotent()
Assert.Null(ex);
}
+
+ [Fact]
+ public async Task InlineStringWithEdgeWhitespaceUsesXmlSpacePreserve()
+ {
+ await using var ms = await WriteWorkbookAsync(async wb =>
+ {
+ SheetWriter sheet = wb.AddSheet("Sheet1");
+ await sheet.StartAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
+ await using RowWriter row = await sheet.StartRowAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
+ row.Write(" leading and trailing ");
+ await sheet.EndAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
+ }).ConfigureAwait(true);
+
+ using var zip = new ZipArchive(ms, ZipArchiveMode.Read, leaveOpen: true);
+ using var stream = zip.GetEntry("xl/worksheets/sheet1.xml")!.Open();
+ using var text = new StreamReader(stream, Encoding.UTF8);
+ string xml = await text.ReadToEndAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
+
+ Assert.Contains(" leading and trailing ", xml, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData(double.NaN)]
+ [InlineData(double.PositiveInfinity)]
+ public async Task WriterRejectsNonFiniteNumbers(double value)
+ {
+ await using var ms = new MemoryStream();
+ await using WorkbookWriter wb = await WorkbookWriter.CreateAsync(ms, leaveOpen: true, ct: TestContext.Current.CancellationToken).ConfigureAwait(true);
+ await wb.StartAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
+ SheetWriter sheet = wb.AddSheet("Sheet1");
+ await sheet.StartAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
+ await using RowWriter row = await sheet.StartRowAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
+
+ Assert.Throws(() => row.Write(value));
+ }
+
+ [Fact]
+ public async Task SheetWriterExtensionsWriteSynchronousAndAsyncRecords()
+ {
+ await using var ms = await WriteWorkbookAsync(async wb =>
+ {
+ SheetWriter sheet = wb.AddSheet("Numbers");
+ await sheet.StartAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
+
+ await sheet.WriteRecordsAsync([1, 2], static (row, value) => row.Write(value), TestContext.Current.CancellationToken);
+
+ ISheetWriter genericSheet = sheet;
+ await genericSheet.WriteRecordsAsync(
+ ToAsync([3, 4]),
+ static (row, value) => row.Write(value),
+ TestContext.Current.CancellationToken);
+
+ await sheet.EndAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
+ }).ConfigureAwait(true);
+
+ await using var reader = Excel.From(ms);
+ using XlsxReader.Enumerator e = reader.GetEnumerator();
+ for (int expected = 1; expected <= 4; expected++)
+ {
+ Assert.True(e.MoveNext());
+ Assert.True(e.Current[0].TryParse(null, out int actual));
+ Assert.Equal(expected, actual);
+ }
+ Assert.False(e.MoveNext());
+ }
+
+ [Fact]
+ public async Task WriterEscapesXmlControlsAndLiteralExcelEscapeSequences()
+ {
+ await using var ms = await WriteWorkbookAsync(async wb =>
+ {
+ SheetWriter sheet = wb.AddSheet("Sheet1");
+ await sheet.StartAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
+ await using RowWriter row = await sheet.StartRowAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
+ row.Write("a\u0001b _x0041_");
+ await sheet.EndAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
+ }).ConfigureAwait(true);
+
+ using var zip = new ZipArchive(ms, ZipArchiveMode.Read, leaveOpen: true);
+ using var stream = zip.GetEntry("xl/worksheets/sheet1.xml")!.Open();
+ using var text = new StreamReader(stream, Encoding.UTF8);
+ string xml = await text.ReadToEndAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
+
+ Assert.Contains("a_x0001_b _x005F_x0041_", xml, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task WriterRejectsColumnsBeyondXfd()
+ {
+ await using var ms = new MemoryStream();
+ await using WorkbookWriter wb = await WorkbookWriter.CreateAsync(ms, leaveOpen: true, ct: TestContext.Current.CancellationToken).ConfigureAwait(true);
+ await wb.StartAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
+ SheetWriter sheet = wb.AddSheet("Sheet1");
+ await sheet.StartAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
+ await using RowWriter row = await sheet.StartRowAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
+
+ row.Skip(16_384);
+ Assert.Throws(() => row.Write("beyond XFD"));
+ }
}
}