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); + VClose = XlsxXml.Token(""u8); + CClose = XlsxXml.Token(""u8); + TOpen = XlsxXml.Token("<"u8, prefix, "t"u8); + TClose = XlsxXml.Token(""u8); + RPhOpen = XlsxXml.Token("<"u8, prefix, "rPh"u8); + RPhClose = XlsxXml.Token(""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); + rPhOpen = XlsxXml.Token("<"u8, prefix, "rPh"u8); + rPhClose = XlsxXml.Token(""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. (" " 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 ProducerFixtures new ExpectedCell(0, 1, CellType.ExcelString, "rich text"), ]) ]; + + yield return + [ + new ProducerFixture( + "Google-Sheets-like ISO-8601 date cells (t=\"d\") alongside typed strings", + """ + + + 2024-01-01 + 2024-01-01T00:00:00 + 0 + + + """, + "label", + [ + new ExpectedCell(0, 0, CellType.Date, "45292"), + new ExpectedCell(0, 1, CellType.Date, "45292"), + new ExpectedCell(0, 2, CellType.ExcelString, "label"), + ]) + ]; + + yield return + [ + new ProducerFixture( + "Aspose/Java-like namespace-prefixed worksheet with an unprefixed workbook part", + """ + + + 3.14 + 0 + inline + + + """, + "shared", + [ + new ExpectedCell(0, 0, CellType.Number, "3.14"), + new ExpectedCell(0, 1, CellType.ExcelString, "shared"), + new ExpectedCell(0, 2, CellType.ExcelString, "inline"), + ]) + { Prefix = "x" } + ]; } } @@ -277,8 +320,14 @@ public async Task XlsWriterEmitsOleCompoundWorkbookStream() private static MemoryStream BuildProducerFixture(ProducerFixture fixture) { + // A fixture may prefix its worksheet/shared-strings elements (e.g. ) while the workbook + // part stays unprefixed — the mixed shape where a prefixed worksheet previously read as zero rows. + string pfx = fixture.Prefix is null ? "" : fixture.Prefix + ":"; + string wsNs = fixture.Prefix is null + ? $"""xmlns="{SpreadsheetNs}" """ + : $"""xmlns:{fixture.Prefix}="{SpreadsheetNs}" """; string worksheet = - $"""{fixture.WorksheetInnerXml}"""; + $"""<{pfx}worksheet {wsNs}xmlns:x14ac="http://schemas.microsoft.com/office/spreadsheetml/2009/9/ac">{fixture.WorksheetInnerXml}"""; var ms = new MemoryStream(); using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) { @@ -289,7 +338,7 @@ private static MemoryStream BuildProducerFixture(ProducerFixture fixture) $""""""); if (fixture.SharedStringsXml is not null) { - WriteText(zip, "xl/sharedStrings.xml", $"""{fixture.SharedStringsXml}"""); + WriteText(zip, "xl/sharedStrings.xml", $"""<{pfx}sst {wsNs.TrimEnd()}>{fixture.SharedStringsXml}"""); } if (fixture.StylesXml is not null) { @@ -426,6 +475,9 @@ public ProducerFixture( public string? StylesXml { get; init; } + // When set, the worksheet (and shared-strings) elements are namespace-prefixed, e.g. . + public string? Prefix { get; init; } + public override string ToString() { return Name; diff --git a/tests/ExcelReader.Tests/SampleTest.cs b/tests/ExcelReader.Tests/SampleTest.cs index 63181a60..7927d28e 100644 --- a/tests/ExcelReader.Tests/SampleTest.cs +++ b/tests/ExcelReader.Tests/SampleTest.cs @@ -43,9 +43,10 @@ public async Task HandlesSparseCellsAndBufferGrowth() // A row with a gap (no B), and an inline string far larger than the 64 KB scan buffer // to exercise compaction/grow and the cross-boundary 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}"""); + Write(zip, "xl/workbook.xml", + $"""<{p}workbook xmlns:{prefix}="{Main}" xmlns:r="{Rel}"><{p}sheets><{p}sheet name="S1" sheetId="1" r:id="rId1"/>"""); + Write(zip, "xl/_rels/workbook.xml.rels", + $""""""); + if (sharedStrings is not null) + { + Write(zip, "xl/sharedStrings.xml", $"""<{p}sst xmlns:{prefix}="{Main}">{sharedStrings}"""); + } + if (stylesInner is not null) + { + Write(zip, "xl/styles.xml", + $"""<{p}styleSheet xmlns:{prefix}="{Main}">{stylesInner}"""); + } + } + 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")); + } } }