Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/ExcelReader.Core/ExcelReader.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
<Version>1.0.0</Version>
<Authors>Gabriel Matte</Authors>
<Title>ExcelReader</Title>
<Description>High-performance, low-allocation XLSX reading, typed row parsing, and minimal workbook writing for .NET.</Description>
<PackageTags>excel;xlsx;reader;writer;parser;spreadsheet;streaming;performance;low-allocation</PackageTags>
<Description>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.</Description>
<PackageTags>excel;xlsx;xlsb;xls;csv;reader;writer;parser;spreadsheet;streaming;performance;low-allocation</PackageTags>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageProjectUrl>https://github.com/GabrielMarquezMatte/ExcelReader</PackageProjectUrl>
Expand Down
99 changes: 83 additions & 16 deletions src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ internal static class ColumnParserFactory
{
return innerNullable is null ? BuildTextDateOnlyParser<T>(prop) : BuildTextNullableDateOnlyParser<T>(prop);
}
if (effective == typeof(TimeOnly))
{
return innerNullable is null ? BuildTextTimeOnlyParser<T>(prop) : BuildTextNullableTimeOnlyParser<T>(prop);
}
}
if (innerNullable is not null)
{
Expand Down Expand Up @@ -197,7 +201,11 @@ private static ColumnParser<T> BuildBoolParser<T>(PropertyInfo prop)
RefAction<T, bool> setter = CompileSetter<T, bool>(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;
};
}
Expand Down Expand Up @@ -330,6 +338,34 @@ private static ColumnParser<T> BuildTextNullableDateOnlyParser<T>(PropertyInfo p
};
}

private static ColumnParser<T> BuildTextTimeOnlyParser<T>(PropertyInfo prop)
{
RefAction<T, TimeOnly> setter = CompileSetter<T, TimeOnly>(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<T> BuildTextNullableTimeOnlyParser<T>(PropertyInfo prop)
{
RefAction<T, TimeOnly?> setter = CompileSetter<T, TimeOnly?>(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,
Expand Down Expand Up @@ -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<byte> utf8 = cell.Value;
if (utf8.Length <= MaxStackDateChars)
{
Span<char> 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<T> BuildParsableCore<T, TProp>(PropertyInfo prop)
where TProp : IUtf8SpanParsable<TProp>
{
Expand All @@ -388,7 +436,11 @@ private static ColumnParser<T> BuildNullableBoolParser<T>(PropertyInfo prop)
RefAction<T, bool?> setter = CompileSetter<T, bool?>(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;
};
}
Expand Down Expand Up @@ -518,7 +570,7 @@ private static class EnumCache<TEnum>
#if !NET8_0
private static readonly FrozenDictionary<string, TEnum> _nameMap = BuildNameMap();
#endif
private static readonly FrozenDictionary<int, TEnum> _valueMap = BuildValueMap();
private static readonly FrozenDictionary<long, TEnum> _valueMap = BuildValueMap();
#if NET8_0
private static readonly (string Name, TEnum Value)[] _sortedNames = BuildSortedNames();

Expand All @@ -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));
Expand Down Expand Up @@ -574,28 +626,33 @@ private static FrozenDictionary<string, TEnum> BuildNameMap()
foreach (TEnum value in Enum.GetValues<TEnum>())
{
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<int, TEnum> BuildValueMap()
private static FrozenDictionary<long, TEnum> BuildValueMap()
{
Dictionary<int, TEnum> map = [];
Dictionary<long, TEnum> map = [];
foreach (TEnum value in Enum.GetValues<TEnum>())
{
int intValue = Convert.ToInt32(value, CultureInfo.InvariantCulture);
map[intValue] = value;
long numericValue = Convert.ToInt64(value, CultureInfo.InvariantCulture);
map[numericValue] = value;
}
return map.ToFrozenDictionary();
}
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<byte> utf8 = cell.Value;
Expand Down Expand Up @@ -704,11 +761,21 @@ private static RefAction<T, TProp> CompileSetter<T, TProp>(PropertyInfo prop)
return (RefAction<T, TProp>)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<byte> 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;
}
}
}
6 changes: 6 additions & 0 deletions src/ExcelReader.Core/Parser/Internal/CsvEnumerable.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
return GetAsyncEnumerator(cancellationToken);
}

public AsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default)

Check warning on line 56 in src/ExcelReader.Core/Parser/Internal/CsvEnumerable.cs

View workflow job for this annotation

GitHub Actions / Run (write)

'GetAsyncEnumerator' returns a reference type. Consider returning a value type. (https://github.com/NetFabric/NetFabric.Hyperlinq.Analyzer/tree/master/docs/reference/HLQ006_GetEnumeratorReturnType.md)

Check warning on line 56 in src/ExcelReader.Core/Parser/Internal/CsvEnumerable.cs

View workflow job for this annotation

GitHub Actions / Run (read)

'GetAsyncEnumerator' returns a reference type. Consider returning a value type. (https://github.com/NetFabric/NetFabric.Hyperlinq.Analyzer/tree/master/docs/reference/HLQ006_GetEnumeratorReturnType.md)

Check warning on line 56 in src/ExcelReader.Core/Parser/Internal/CsvEnumerable.cs

View workflow job for this annotation

GitHub Actions / Build & Test (windows-latest)

'GetAsyncEnumerator' returns a reference type. Consider returning a value type. (https://github.com/NetFabric/NetFabric.Hyperlinq.Analyzer/tree/master/docs/reference/HLQ006_GetEnumeratorReturnType.md)
{
TypeMapInfo<T> info = TypeMapper<T>.GetCsvInfo();
CancellationToken effective = cancellationToken.CanBeCanceled ? cancellationToken : _ct;
Expand Down Expand Up @@ -251,6 +251,12 @@
{
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;
Expand Down
5 changes: 5 additions & 0 deletions src/ExcelReader.Core/Reader/Brt.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 6 additions & 1 deletion src/ExcelReader.Core/Reader/XlsCompoundFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -295,8 +295,13 @@ private static byte[] ReadChainBytes(Stream source, int sectorSize, ReadOnlySpan
byte[] sectorBuf = ArrayPool<byte>.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);
Expand Down Expand Up @@ -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);
Expand Down
103 changes: 62 additions & 41 deletions src/ExcelReader.Core/Reader/XlsReader.Enumerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,51 +46,57 @@ public ValueTask<bool> 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<byte> data))
ResetRow();
BiffCursor cursor = _cursor;
while (true)
{
_ended = true;
return FinishRow();
}
long recordStart = cursor.Position;
if (!cursor.TryReadRecord(out int id, out ReadOnlySpan<byte> 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()
Expand Down Expand Up @@ -181,16 +187,22 @@ private void ParseLabel(ReadOnlySpan<byte> 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<byte> 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<byte> 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<byte> cont) && cont.Length > 0)
Expand All @@ -205,7 +217,6 @@ private void ParseLabel(ReadOnlySpan<byte> data)
_acc.Advance(written);
charsDecoded += contChars;
}
_acc.Add(col, valueStart, _acc.ValueLength - valueStart, CellType.ExcelString, style, fromShared: false);
}

private void ParseMulRk(ReadOnlySpan<byte> data)
Expand Down Expand Up @@ -265,6 +276,16 @@ private void ParseFormula(ReadOnlySpan<byte> 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<byte> 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;
}
Expand Down
Loading
Loading