diff --git a/docs/guide/reading.md b/docs/guide/reading.md
index 83ad3c87..142c5d18 100644
--- a/docs/guide/reading.md
+++ b/docs/guide/reading.md
@@ -107,7 +107,7 @@ using XlsxWorkbookWriter workbook = XlsxWorkbookWriter.Create(File.Create("repor
workbook.WriteRecordBatch(batch); // adds the sheet, writes the header + every row, ends the workbook
```
-Supports the same seven Arrow types `ToArrowRecordBatch` produces (string, int64, double, boolean, date32, time64, timestamp); any other type throws `NotSupportedException`. Pass `writeHeader: false` to skip the header row, or a `sheetName` (XLSX/XLSB/XLS only — CSV has no sheet name) to rename it from the `"Sheet1"` default.
+Supports string, large string, string view, every integer width (signed and unsigned), half float, float, double, decimal128, decimal256, boolean, date32, date64, time32, time64, timestamp and null columns; any other type (binary, nested, dictionary) throws `NotSupportedException`. Pass `writeHeader: false` to skip the header row, or a `sheetName` (XLSX/XLSB/XLS only — CSV has no sheet name) to rename it from the `"Sheet1"` default.
## Open by auto-detecting the format
diff --git a/src/ExcelReader.Arrow/ArrowWriteExtensions.cs b/src/ExcelReader.Arrow/ArrowWriteExtensions.cs
index b43db774..a1860064 100644
--- a/src/ExcelReader.Arrow/ArrowWriteExtensions.cs
+++ b/src/ExcelReader.Arrow/ArrowWriteExtensions.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using Apache.Arrow;
using Apache.Arrow.Types;
using ExcelReader.Core.Writer;
@@ -24,13 +25,14 @@ public static class ArrowWriteExtensions
///
/// A freshly created, not-yet-started workbook writer.
///
- /// The batch to write. Only the seven Arrow types
- /// produces (string, int64, double, boolean, date32, time64, timestamp) are supported.
+ /// The batch to write. Supported Arrow types: string, large string, string view, every integer
+ /// width (signed and unsigned), half float, float, double, decimal128, decimal256, boolean,
+ /// date32, date64, time32, time64, timestamp and null.
///
/// The sheet's name.
/// Whether to write a header row of column names first.
/// or is .
- /// A column's Arrow type is not one of the seven supported types.
+ /// A column's Arrow type is not one of the supported types.
public static void WriteRecordBatch(this XlsxWorkbookWriter workbook, RecordBatch batch, string sheetName = "Sheet1", bool writeHeader = true)
{
WriteRecordBatchCore(workbook, batch, sheetName, writeHeader);
@@ -38,7 +40,7 @@ public static void WriteRecordBatch(this XlsxWorkbookWriter workbook, RecordBatc
/// Same as , for an XLSB workbook.
/// or is .
- /// A column's Arrow type is not one of the seven supported types.
+ /// A column's Arrow type is not one of the supported types.
public static void WriteRecordBatch(this XlsbWorkbookWriter workbook, RecordBatch batch, string sheetName = "Sheet1", bool writeHeader = true)
{
WriteRecordBatchCore(workbook, batch, sheetName, writeHeader);
@@ -46,7 +48,7 @@ public static void WriteRecordBatch(this XlsbWorkbookWriter workbook, RecordBatc
/// Same as , for a legacy XLS workbook.
/// or is .
- /// A column's Arrow type is not one of the seven supported types.
+ /// A column's Arrow type is not one of the supported types.
public static void WriteRecordBatch(this XlsWorkbookWriter workbook, RecordBatch batch, string sheetName = "Sheet1", bool writeHeader = true)
{
WriteRecordBatchCore(workbook, batch, sheetName, writeHeader);
@@ -57,7 +59,7 @@ public static void WriteRecordBatch(this XlsWorkbookWriter workbook, RecordBatch
/// in a single call.
///
/// or is .
- /// A column's Arrow type is not one of the seven supported types.
+ /// A column's Arrow type is not one of the supported types.
public static void WriteRecordBatch(this CsvWorkbookWriter workbook, RecordBatch batch, bool writeHeader = true)
{
WriteRecordBatchCore(workbook, batch, sheetName: "Sheet1", writeHeader);
@@ -166,8 +168,10 @@ private static void ApplyTemporalStyles(IWorkbookWriter wo
switch (fields[i].DataType.TypeId)
{
case ArrowTypeId.Date32:
+ case ArrowTypeId.Date64:
sheet.SetColumnStyle(i, BuiltinDateStyleId);
break;
+ case ArrowTypeId.Time32:
case ArrowTypeId.Time64:
timeStyle = timeStyle < 0 ? workbook.AddStyle(new CellStyle { NumberFormat = "hh:mm:ss" }) : timeStyle;
sheet.SetColumnStyle(i, timeStyle);
@@ -187,13 +191,55 @@ private static void WriteCell(IRowWriter row, IArrowArray array, ArrowTypeId typ
switch (typeId)
{
case ArrowTypeId.String:
- var strings = (StringArray)array;
- if (strings.IsNull(index))
- {
- row.Write((string?)null);
- return;
- }
- row.WriteUtf8(strings.GetBytes(index));
+ WriteText(row, ((StringArray)array).GetBytes(index, out bool isNull), isNull);
+ return;
+ case ArrowTypeId.LargeString:
+ WriteText(row, ((LargeStringArray)array).GetBytes(index, out bool isLargeNull), isLargeNull);
+ return;
+ case ArrowTypeId.StringView:
+ WriteText(row, ((StringViewArray)array).GetBytes(index, out bool isViewNull), isViewNull);
+ return;
+ case ArrowTypeId.Null:
+ row.Write((string?)null);
+ return;
+ case ArrowTypeId.Int8:
+ row.Write(((Int8Array)array).GetValue(index));
+ return;
+ case ArrowTypeId.Int16:
+ row.Write(((Int16Array)array).GetValue(index));
+ return;
+ case ArrowTypeId.Int32:
+ row.Write(((Int32Array)array).GetValue(index));
+ return;
+ case ArrowTypeId.UInt8:
+ row.Write(((UInt8Array)array).GetValue(index));
+ return;
+ case ArrowTypeId.UInt16:
+ row.Write(((UInt16Array)array).GetValue(index));
+ return;
+ case ArrowTypeId.UInt32:
+ row.Write(((UInt32Array)array).GetValue(index));
+ return;
+ case ArrowTypeId.UInt64:
+ row.Write(((UInt64Array)array).GetValue(index));
+ return;
+ case ArrowTypeId.HalfFloat:
+ row.Write(ToShortestDouble(((HalfFloatArray)array).GetValue(index)));
+ return;
+ case ArrowTypeId.Float:
+ row.Write(ToShortestDouble(((FloatArray)array).GetValue(index)));
+ return;
+ case ArrowTypeId.Decimal128:
+ row.Write(((Decimal128Array)array).GetValue(index));
+ return;
+ case ArrowTypeId.Decimal256:
+ row.Write(((Decimal256Array)array).GetValue(index));
+ return;
+ case ArrowTypeId.Date64:
+ row.Write(((Date64Array)array).GetDateOnly(index));
+ return;
+ case ArrowTypeId.Time32:
+ row.Write(((Time32Array)array).GetTime(index));
return;
case ArrowTypeId.Int64:
row.Write(((Int64Array)array).GetValue(index));
@@ -217,5 +263,31 @@ private static void WriteCell(IRowWriter row, IArrowArray array, ArrowTypeId typ
throw new NotSupportedException($"Arrow type {typeId} is not supported for writing.");
}
}
+
+ ///
+ /// Widens through the value's shortest decimal text, so 0.1f lands as 0.1 in every
+ /// format instead of the binary writers storing 0.10000000149011612.
+ ///
+ private static double? ToShortestDouble(T? value)
+ where T : struct, IUtf8SpanFormattable
+ {
+ if (value is not T narrow)
+ {
+ return null;
+ }
+ Span text = stackalloc byte[32];
+ narrow.TryFormat(text, out int written, default, CultureInfo.InvariantCulture);
+ return double.Parse(text[..written], NumberStyles.Float, CultureInfo.InvariantCulture);
+ }
+
+ private static void WriteText(IRowWriter row, ReadOnlySpan utf8, bool isNull)
+ {
+ if (isNull)
+ {
+ row.Write((string?)null);
+ return;
+ }
+ row.WriteUtf8(utf8);
+ }
}
}
diff --git a/src/ExcelReader.Core/Reader/Cell.cs b/src/ExcelReader.Core/Reader/Cell.cs
index c12e2d14..27ec5356 100644
--- a/src/ExcelReader.Core/Reader/Cell.cs
+++ b/src/ExcelReader.Core/Reader/Cell.cs
@@ -354,8 +354,14 @@ public string GetString()
}
if (_sharedCache is not null && !Value.IsEmpty && (uint)_sharedIndex < (uint)_sharedCache.Length)
{
- ref string? cached = ref _sharedCache[_sharedIndex];
- return cached ??= Encoding.UTF8.GetString(Value);
+ ref string? slot = ref _sharedCache[_sharedIndex];
+ string? cached = Volatile.Read(ref slot);
+ if (cached is not null)
+ {
+ return cached;
+ }
+ string created = Encoding.UTF8.GetString(Value);
+ return Interlocked.CompareExchange(ref slot, created, null) ?? created;
}
if (_contentCache is not null && !Value.IsEmpty)
{
diff --git a/src/ExcelReader.Core/Reader/Csv/CsvReader.Enumerator.cs b/src/ExcelReader.Core/Reader/Csv/CsvReader.Enumerator.cs
index aaba9d23..4fbc2f71 100644
--- a/src/ExcelReader.Core/Reader/Csv/CsvReader.Enumerator.cs
+++ b/src/ExcelReader.Core/Reader/Csv/CsvReader.Enumerator.cs
@@ -74,7 +74,12 @@ private struct FieldState
}
internal Enumerator(Stream stream, CsvReaderOptions options, CancellationToken ct = default)
- : base(stream, options.MaxCellBytes, nameof(CsvReaderOptions.MaxCellBytes), 64 * 1024, ownsSource: false, ct)
+ : this(stream, options, ownsSource: false, lease: null, ct)
+ {
+ }
+
+ internal Enumerator(Stream stream, CsvReaderOptions options, bool ownsSource, ReaderLifetime? lease, CancellationToken ct)
+ : base(stream, options.MaxCellBytes, nameof(CsvReaderOptions.MaxCellBytes), 64 * 1024, ownsSource, ct)
{
_delimiter = options.Delimiter;
_quote = options.Quote;
@@ -82,9 +87,15 @@ internal Enumerator(Stream stream, CsvReaderOptions options, CancellationToken c
_contentCache = options.InternStrings ? new Utf8StringCache() : null;
_scanner = new CsvStructuralScanner(_delimiter, _quote);
_scannerSupported = CsvStructuralScanner.Supports(_delimiter, _quote);
+ _lease = lease;
}
internal Enumerator(ReadOnlyMemory content, CsvReaderOptions options, CancellationToken ct = default)
+ : this(content, options, lease: null, ct)
+ {
+ }
+
+ internal Enumerator(ReadOnlyMemory content, CsvReaderOptions options, ReaderLifetime? lease, CancellationToken ct)
: base(content, options.MaxCellBytes, nameof(CsvReaderOptions.MaxCellBytes), ct)
{
_delimiter = options.Delimiter;
@@ -93,6 +104,7 @@ internal Enumerator(ReadOnlyMemory content, CsvReaderOptions options, Canc
_contentCache = options.InternStrings ? new Utf8StringCache() : null;
_scanner = new CsvStructuralScanner(_delimiter, _quote);
_scannerSupported = CsvStructuralScanner.Supports(_delimiter, _quote);
+ _lease = lease;
}
///
diff --git a/src/ExcelReader.Core/Reader/Csv/CsvReader.cs b/src/ExcelReader.Core/Reader/Csv/CsvReader.cs
index 4caf7bf5..ae5277c4 100644
--- a/src/ExcelReader.Core/Reader/Csv/CsvReader.cs
+++ b/src/ExcelReader.Core/Reader/Csv/CsvReader.cs
@@ -1,5 +1,8 @@
+using System.Diagnostics.CodeAnalysis;
using System.Text;
using ExcelReader.Core.Parser.ParallelCsv;
+using ExcelReader.Core.Reader.Internal;
+using ExcelReader.Core.Reader.Sources;
using ExcelReader.Core.Reader.Xls;
using Microsoft.Win32.SafeHandles;
@@ -12,22 +15,37 @@ namespace ExcelReader.Core.Reader.Csv
/// Unlike the XLSX/XLSB/XLS readers, there are no styles or shared strings to resolve.
public sealed partial class CsvReader : IExcelRowReader, IExcelRowReader
{
+ [SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", Justification = "Disposed by ReleaseResources, which the lifetime runs after the last enumerator.")]
+ private readonly ByteSource? _source;
+ private readonly long _sourceStart;
+ [SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", Justification = "An alias of the stream the ByteSource owns; ReleaseResources disposes the source.")]
+ private readonly FileStream? _file;
+ [SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", Justification = "Disposed by ReleaseResources, which the lifetime runs after the last enumerator.")]
private readonly Stream? _stream;
private readonly bool _leaveOpen;
private readonly CsvReaderOptions _options;
private readonly ReadOnlyMemory _memory;
- private readonly long _startPosition = -1;
- private bool _enumeratedOnce;
+ private int _enumeratedOnce;
private SafeFileHandle? _chunkHandle;
+ internal ReaderLifetime Lifetime { get; }
+
internal CsvReader(Stream stream, bool leaveOpen, CsvReaderOptions? options = null)
{
ArgumentNullException.ThrowIfNull(stream);
_options = options ?? CsvReaderOptions.Default;
ValidateOptions(_options);
- if (_options.Encoding is not null && _options.Encoding.CodePage != Encoding.UTF8.CodePage)
+ Lifetime = new ReaderLifetime(ReleaseResources);
+ if (stream.CanSeek)
+ {
+ _sourceStart = stream.Position;
+ _file = stream as FileStream;
+ _source = ByteSource.FromStream(stream, leaveOpen);
+ _leaveOpen = true;
+ }
+ else if (NeedsTranscoding(_options.Encoding))
{
- _stream = Encoding.CreateTranscodingStream(stream, _options.Encoding, Encoding.UTF8, leaveOpen);
+ _stream = Encoding.CreateTranscodingStream(stream, _options.Encoding!, Encoding.UTF8, leaveOpen);
_leaveOpen = false;
}
else
@@ -35,17 +53,18 @@ internal CsvReader(Stream stream, bool leaveOpen, CsvReaderOptions? options = nu
_stream = stream;
_leaveOpen = leaveOpen;
}
- if (_stream.CanSeek)
- {
- _startPosition = _stream.Position;
- }
- _memory = default;
+ }
+
+ private static bool NeedsTranscoding(Encoding? encoding)
+ {
+ return encoding is not null && encoding.CodePage != Encoding.UTF8.CodePage;
}
internal CsvReader(ReadOnlyMemory data, CsvReaderOptions? options = null)
{
_options = options ?? CsvReaderOptions.Default;
ValidateOptions(_options);
+ Lifetime = new ReaderLifetime(ReleaseResources);
_stream = null;
_leaveOpen = true;
_memory = Transcode(data, _options.Encoding);
@@ -61,15 +80,15 @@ internal CsvReaderOptions Options
internal bool TryGetChunkSource(out CsvChunkSource source)
{
- if (_stream is null)
+ if (_source is null && _stream is null)
{
source = new CsvChunkSource(_memory);
return true;
}
- if (_stream is FileStream file && _startPosition >= 0)
+ if (_file is not null && !NeedsTranscoding(_options.Encoding))
{
- SafeFileHandle handle = ChunkHandle(file);
- source = new CsvChunkSource(handle, RandomAccess.GetLength(handle), _startPosition);
+ SafeFileHandle handle = ChunkHandle(_file);
+ source = new CsvChunkSource(handle, RandomAccess.GetLength(handle), _sourceStart);
return true;
}
source = default;
@@ -98,12 +117,12 @@ private SafeFileHandle ChunkHandle(FileStream file)
private static ReadOnlyMemory Transcode(ReadOnlyMemory data, Encoding? encoding)
{
- if (encoding is null || encoding.CodePage == Encoding.UTF8.CodePage)
+ if (!NeedsTranscoding(encoding))
{
return data;
}
using MemoryStream source = XlsCompoundFile.AsStream(data);
- using Stream transcoding = Encoding.CreateTranscodingStream(source, encoding, Encoding.UTF8, leaveOpen: true);
+ using Stream transcoding = Encoding.CreateTranscodingStream(source, encoding!, Encoding.UTF8, leaveOpen: true);
using MemoryStream target = new(data.Length);
transcoding.CopyTo(target);
return target.GetBuffer().AsMemory(0, (int)target.Length);
@@ -181,14 +200,11 @@ public void MoveToSheet(int index)
}
/// Gets an enumerator that reads records synchronously from the start of the source.
+ /// The reader was disposed.
+ /// The source is a non-seekable stream that was already enumerated.
public Enumerator GetEnumerator()
{
- ResetToStart();
- if (_stream is null)
- {
- return new Enumerator(_memory, _options);
- }
- return new Enumerator(_stream, _options);
+ return OpenSheet();
}
IExcelRowEnumerator IExcelRowReader.GetEnumerator()
@@ -198,15 +214,12 @@ IExcelRowEnumerator IExcelRowReader.GetEnumerator()
/// Gets an enumerator that reads records asynchronously from the start of the source.
/// A token observed by every MoveNextAsync call.
+ /// The reader was disposed.
+ /// The source is a non-seekable stream that was already enumerated.
public Enumerator GetAsyncEnumerator(CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
- ResetToStart();
- if (_stream is null)
- {
- return new Enumerator(_memory, _options, ct);
- }
- return new Enumerator(_stream, _options, ct);
+ return OpenSheet(ct);
}
IExcelRowEnumerator IExcelRowReader.GetAsyncEnumerator(CancellationToken ct)
@@ -214,44 +227,63 @@ IExcelRowEnumerator IExcelRowReader.GetAsyncEnumerator(Canc
return GetAsyncEnumerator(ct);
}
- private void ResetToStart()
+ internal Enumerator OpenSheet(CancellationToken ct = default)
{
- if (_stream is null)
- {
- return;
- }
- if (_startPosition >= 0)
+ Lifetime.Acquire(this);
+ try
{
- _stream.Position = _startPosition;
- return;
+ if (_source is not null)
+ {
+ return new Enumerator(OpenSourceStream(_source), _options, ownsSource: true, Lifetime, ct);
+ }
+ if (_stream is null)
+ {
+ return new Enumerator(_memory, _options, Lifetime, ct);
+ }
+ if (Interlocked.Exchange(ref _enumeratedOnce, 1) != 0)
+ {
+ throw new InvalidOperationException(
+ "This CsvReader is over a non-seekable stream and can only be enumerated once.");
+ }
+ return new Enumerator(_stream, _options, ownsSource: false, Lifetime, ct);
}
- if (_enumeratedOnce)
+ catch
{
- throw new InvalidOperationException(
- "This CsvReader is over a non-seekable stream and can only be enumerated once.");
+ Lifetime.Release();
+ throw;
}
- _enumeratedOnce = true;
+ }
+
+ private Stream OpenSourceStream(ByteSource source)
+ {
+ bool transcode = NeedsTranscoding(_options.Encoding);
+ ByteSourceStream raw = new(source, _sourceStart, source.Length - _sourceStart, buffered: transcode);
+ return transcode
+ ? Encoding.CreateTranscodingStream(raw, _options.Encoding!, Encoding.UTF8, leaveOpen: false)
+ : raw;
}
///
public void Dispose()
{
- _chunkHandle?.Dispose();
- if (!_leaveOpen && _stream is not null)
- {
- _stream.Dispose();
- }
+ Lifetime.Close();
}
///
public ValueTask DisposeAsync()
+ {
+ Lifetime.Close();
+ return ValueTask.CompletedTask;
+ }
+
+ private void ReleaseResources()
{
_chunkHandle?.Dispose();
- if (_leaveOpen || _stream is null)
+ _source?.Dispose();
+ if (!_leaveOpen)
{
- return ValueTask.CompletedTask;
+ _stream?.Dispose();
}
- return _stream.DisposeAsync();
}
}
}
diff --git a/src/ExcelReader.Core/Reader/Csv/CsvStructuralScanner.cs b/src/ExcelReader.Core/Reader/Csv/CsvStructuralScanner.cs
index 4fd09299..593a6af9 100644
--- a/src/ExcelReader.Core/Reader/Csv/CsvStructuralScanner.cs
+++ b/src/ExcelReader.Core/Reader/Csv/CsvStructuralScanner.cs
@@ -3,6 +3,7 @@
using System.Runtime.InteropServices;
using System.Runtime.Intrinsics;
using System.Runtime.Intrinsics.X86;
+using ArmAes = System.Runtime.Intrinsics.Arm.Aes;
namespace ExcelReader.Core.Reader.Csv
{
@@ -258,14 +259,22 @@ private void Classify(ulong quotes, ulong delims, ulong crs, ulong lfs, ulong va
_blocked = true;
}
- // ponytail: CLMUL on x86 only; ARM takes the shift ladder, add Aes.PolynomialMultiplyWideningLower if ARM throughput matters.
[MethodImpl(MethodImplOptions.AggressiveInlining)]
- private static ulong PrefixXor(ulong bits)
+ internal static ulong PrefixXor(ulong bits)
{
if (Pclmulqdq.IsSupported)
{
return Pclmulqdq.CarrylessMultiply(Vector128.CreateScalar(bits), Vector128.AllBitsSet, 0).ToScalar();
}
+ if (ArmAes.IsSupported)
+ {
+ return ArmAes.PolynomialMultiplyWideningLower(Vector64.CreateScalar(bits), Vector64.AllBitsSet).ToScalar();
+ }
+ return PrefixXorSoftware(bits);
+ }
+
+ internal static ulong PrefixXorSoftware(ulong bits)
+ {
bits ^= bits << 1;
bits ^= bits << 2;
bits ^= bits << 4;
diff --git a/src/ExcelReader.Core/Reader/Excel.cs b/src/ExcelReader.Core/Reader/Excel.cs
index fbbe7e4d..001b3f9b 100644
--- a/src/ExcelReader.Core/Reader/Excel.cs
+++ b/src/ExcelReader.Core/Reader/Excel.cs
@@ -1,11 +1,11 @@
using System.Buffers;
using System.Diagnostics.CodeAnalysis;
-using System.IO.Compression;
using System.Runtime.CompilerServices;
using ExcelReader.Core.Crypto;
using ExcelReader.Core.Parser;
using ExcelReader.Core.Reader.Csv;
using ExcelReader.Core.Reader.Schema;
+using ExcelReader.Core.Reader.Sources;
using ExcelReader.Core.Reader.Xls;
using ExcelReader.Core.Reader.Xlsb;
using ExcelReader.Core.Reader.Xlsx;
@@ -44,7 +44,7 @@ public static XlsxReader FromXlsx(Stream stream, bool leaveOpen = true, ExcelRea
///
/// Opens an XLSX workbook directly from an in-memory buffer. Reads the ZIP
- /// central directory and decompresses parts without a
+ /// central directory and decompresses parts without a ZipArchive
/// or intermediate — every part is fully materialized up front, so the returned
/// reader never suspends, even under await foreach.
///
@@ -109,7 +109,7 @@ public static XlsbReader FromXlsb(Stream stream, bool leaveOpen = true, ExcelRea
///
/// Opens an XLSB workbook directly from an in-memory buffer. Reads the ZIP
- /// central directory and decompresses parts without a
+ /// central directory and decompresses parts without a ZipArchive
/// or intermediate — every part is fully materialized up front, so the returned
/// reader never suspends, even under await foreach.
///
@@ -351,8 +351,8 @@ public static IExcelRowReader Open(Stream stream, bool leaveOpen = true, ExcelRe
///
/// Opens a workbook from an in-memory buffer, auto-detecting its format (XLSX/XLSB/XLS) from its
- /// signature. XLSX/XLSB route through instead of
- /// a /, so the returned reader never
+ /// signature. XLSX/XLSB route through instead of
+ /// a ZipArchive/, so the returned reader never
/// suspends, even under await foreach.
///
/// The whole workbook file's bytes. Must outlive the returned reader.
@@ -367,7 +367,7 @@ public static IExcelRowReader Open(ReadOnlyMemory data, ExcelReaderOptions
ReadOnlyMemory plain = EncryptedPackageOpener.DecryptToMemory(data, effective);
return OpenFromPlainMemory(plain, effective);
}
- ExcelFileFormat format = ClassifyMemory(data, effective, out ZipMemoryIndex? memZip);
+ ExcelFileFormat format = ClassifyMemory(data, effective, out ZipIndex? memZip);
if (format is ExcelFileFormat.Unknown)
{
memZip?.Dispose();
@@ -376,15 +376,15 @@ public static IExcelRowReader Open(ReadOnlyMemory data, ExcelReaderOptions
return format switch
{
ExcelFileFormat.Xls => new XlsReader(data, effective),
- ExcelFileFormat.Xlsb => XlsbReader.CreateFromMemory(memZip!, effective),
- ExcelFileFormat.Xlsx => XlsxReader.CreateFromMemory(memZip!, effective),
+ ExcelFileFormat.Xlsb => XlsbReader.CreateFromIndex(memZip!, effective),
+ ExcelFileFormat.Xlsx => XlsxReader.CreateFromIndex(memZip!, effective),
_ => throw new System.Diagnostics.UnreachableException(),
};
}
private static IExcelRowReader OpenFromPlainMemory(ReadOnlyMemory plain, ExcelReaderOptions options)
{
- ExcelFileFormat format = ClassifyMemory(plain, options, out ZipMemoryIndex? memZip);
+ ExcelFileFormat format = ClassifyMemory(plain, options, out ZipIndex? memZip);
if (format is not (ExcelFileFormat.Xlsb or ExcelFileFormat.Xlsx))
{
memZip?.Dispose();
@@ -392,13 +392,13 @@ private static IExcelRowReader OpenFromPlainMemory(ReadOnlyMemory plain, E
}
return format switch
{
- ExcelFileFormat.Xlsb => XlsbReader.CreateFromMemory(memZip!, options),
- ExcelFileFormat.Xlsx => XlsxReader.CreateFromMemory(memZip!, options),
+ ExcelFileFormat.Xlsb => XlsbReader.CreateFromIndex(memZip!, options),
+ ExcelFileFormat.Xlsx => XlsxReader.CreateFromIndex(memZip!, options),
_ => throw new System.Diagnostics.UnreachableException(),
};
}
- private static ExcelFileFormat ClassifyMemory(ReadOnlyMemory data, ExcelReaderOptions options, out ZipMemoryIndex? memZip)
+ private static ExcelFileFormat ClassifyMemory(ReadOnlyMemory data, ExcelReaderOptions options, out ZipIndex? memZip)
{
memZip = null;
ReadOnlySpan span = data.Span;
@@ -413,8 +413,8 @@ private static ExcelFileFormat ClassifyMemory(ReadOnlyMemory data, ExcelRe
? ExcelFileFormat.EncryptedOoxml
: ExcelFileFormat.Xls;
}
- memZip = ZipMemoryIndex.Create(data, options);
- return memZip.TryGetEntry("xl/workbook.bin"u8, out _) ? ExcelFileFormat.Xlsb : ExcelFileFormat.Xlsx;
+ memZip = ZipIndex.Create(data, options);
+ return ClassifyZip(memZip);
}
///
@@ -629,7 +629,7 @@ private static void RequireOpenableFormat(ExcelFileFormat format)
public static ExcelFileFormat DetectFileFormat(Stream stream)
{
ArgumentNullException.ThrowIfNull(stream);
- ExcelFileFormat format = DetectSeekable(stream, out ZipArchive? zip);
+ ExcelFileFormat format = DetectSeekable(stream, leaveOpen: true, ExcelReaderOptions.Default, out ZipIndex? zip);
zip?.Dispose();
return format;
}
@@ -640,7 +640,7 @@ public static ExcelFileFormat DetectFileFormat(Stream stream)
/// The detected , or if the signature matches no supported format.
public static ExcelFileFormat DetectFileFormat(ReadOnlyMemory data)
{
- var format = ClassifyMemory(data, ExcelReaderOptions.Default, out ZipMemoryIndex? memZip);
+ var format = ClassifyMemory(data, ExcelReaderOptions.Default, out ZipIndex? memZip);
memZip?.Dispose();
return format;
}
@@ -654,25 +654,22 @@ public static ExcelFileFormat DetectFileFormat(ReadOnlyMemory data)
public static async ValueTask DetectFileFormatAsync(Stream stream, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(stream);
- (ExcelFileFormat format, ZipArchive? zip) = await DetectSeekableAsync(stream, ct).ConfigureAwait(false);
- if (zip is not null)
- {
- await zip.DisposeAsync().ConfigureAwait(false);
- }
+ (ExcelFileFormat format, ZipIndex? zip) = await DetectSeekableAsync(stream, leaveOpen: true, ExcelReaderOptions.Default, ct).ConfigureAwait(false);
+ zip?.Dispose();
return format;
}
private static IExcelRowReader OpenSeekable(Stream stream, bool leaveOpen, ExcelReaderOptions? options)
{
+ ExcelReaderOptions effective = options ?? ExcelReaderOptions.Default;
ExcelFileFormat format;
- ZipArchive? zip = null;
+ ZipIndex? zip;
try
{
- format = DetectSeekable(stream, out zip);
+ format = DetectSeekable(stream, leaveOpen, effective, out zip);
}
catch
{
- zip?.Dispose();
DisposeOnFailure(stream, leaveOpen);
throw;
}
@@ -682,55 +679,37 @@ private static IExcelRowReader OpenSeekable(Stream stream, bool leaveOpen, Excel
}
if (format is ExcelFileFormat.EncryptedOoxml)
{
- ExcelReaderOptions effective = options ?? ExcelReaderOptions.Default;
Stream decrypted = EncryptedPackageOpener.Decrypt(stream, leaveOpen, effective);
return OpenDecryptedZip(decrypted, effective);
}
return format switch
{
ExcelFileFormat.Xls => new XlsReader(stream, leaveOpen, options),
- ExcelFileFormat.Xlsb => new XlsbReader(stream, leaveOpen, zip!, options),
- ExcelFileFormat.Xlsx => new XlsxReader(stream, leaveOpen, zip!, options),
+ ExcelFileFormat.Xlsb => XlsbReader.CreateFromIndex(zip!, effective),
+ ExcelFileFormat.Xlsx => XlsxReader.CreateFromIndex(zip!, effective),
_ => throw new System.Diagnostics.UnreachableException(),
};
}
private static IExcelRowReader OpenDecryptedZip(Stream decrypted, ExcelReaderOptions options)
{
- ZipArchive? zip = null;
- try
- {
- ExcelFileFormat zipFormat = ClassifyZipStream(decrypted, start: 0, out ZipArchive zipPeek);
- zip = zipPeek;
- return zipFormat switch
- {
- ExcelFileFormat.Xlsb => new XlsbReader(decrypted, leaveOpen: false, zip, options),
- ExcelFileFormat.Xlsx => new XlsxReader(decrypted, leaveOpen: false, zip, options),
- _ => throw new System.Diagnostics.UnreachableException(),
- };
- }
- catch
- {
- zip?.Dispose();
- decrypted.Dispose();
- throw;
- }
+ ZipIndex zip = ZipIndex.Create(ByteSource.FromStream(decrypted, leaveOpen: false), options);
+ return ClassifyZip(zip) is ExcelFileFormat.Xlsb
+ ? XlsbReader.CreateFromIndex(zip, options)
+ : XlsxReader.CreateFromIndex(zip, options);
}
private static async ValueTask OpenSeekableAsync(Stream stream, bool leaveOpen, ExcelReaderOptions? options, CancellationToken ct)
{
+ ExcelReaderOptions effective = options ?? ExcelReaderOptions.Default;
ExcelFileFormat format;
- ZipArchive? zip = null;
+ ZipIndex? zip;
try
{
- (format, zip) = await DetectSeekableAsync(stream, ct).ConfigureAwait(false);
+ (format, zip) = await DetectSeekableAsync(stream, leaveOpen, effective, ct).ConfigureAwait(false);
}
catch
{
- if (zip is not null)
- {
- await zip.DisposeAsync().ConfigureAwait(false);
- }
await DisposeOnFailureAsync(stream, leaveOpen).ConfigureAwait(false);
throw;
}
@@ -741,42 +720,25 @@ private static async ValueTask OpenSeekableAsync(Stream stream,
}
if (format is ExcelFileFormat.EncryptedOoxml)
{
- ExcelReaderOptions effective = options ?? ExcelReaderOptions.Default;
Stream decrypted = EncryptedPackageOpener.Decrypt(stream, leaveOpen, effective);
return await OpenDecryptedZipAsync(decrypted, effective, ct).ConfigureAwait(false);
}
return format switch
{
ExcelFileFormat.Xls => await XlsReader.CreateAsync(stream, leaveOpen, options, ct).ConfigureAwait(false),
- ExcelFileFormat.Xlsb => await XlsbReader.CreateFromOpenZipAsync(stream, leaveOpen, zip!, options, ct).ConfigureAwait(false),
- ExcelFileFormat.Xlsx => await XlsxReader.CreateFromOpenZipAsync(stream, leaveOpen, zip!, options, ct).ConfigureAwait(false),
+ ExcelFileFormat.Xlsb => await XlsbReader.CreateFromIndexAsync(zip!, effective, ct).ConfigureAwait(false),
+ ExcelFileFormat.Xlsx => await XlsxReader.CreateFromIndexAsync(zip!, effective, ct).ConfigureAwait(false),
_ => throw new System.Diagnostics.UnreachableException(),
};
}
private static async ValueTask OpenDecryptedZipAsync(Stream decrypted, ExcelReaderOptions options, CancellationToken ct)
{
- ZipArchive? zip = null;
- try
- {
- ExcelFileFormat zipFormat = ClassifyZipStream(decrypted, start: 0, out ZipArchive zipPeek);
- zip = zipPeek;
- return zipFormat switch
- {
- ExcelFileFormat.Xlsb => await XlsbReader.CreateFromOpenZipAsync(decrypted, leaveOpen: false, zip, options, ct).ConfigureAwait(false),
- ExcelFileFormat.Xlsx => await XlsxReader.CreateFromOpenZipAsync(decrypted, leaveOpen: false, zip, options, ct).ConfigureAwait(false),
- _ => throw new System.Diagnostics.UnreachableException(),
- };
- }
- catch
- {
- if (zip is not null)
- {
- await zip.DisposeAsync().ConfigureAwait(false);
- }
- await decrypted.DisposeAsync().ConfigureAwait(false);
- throw;
- }
+ ByteSource source = await ByteSource.FromStreamAsync(decrypted, leaveOpen: false, ct).ConfigureAwait(false);
+ ZipIndex zip = await ZipIndex.CreateAsync(source, options, ct).ConfigureAwait(false);
+ return ClassifyZip(zip) is ExcelFileFormat.Xlsb
+ ? await XlsbReader.CreateFromIndexAsync(zip, options, ct).ConfigureAwait(false)
+ : await XlsxReader.CreateFromIndexAsync(zip, options, ct).ConfigureAwait(false);
}
private static void DisposeOnFailure(Stream stream, bool leaveOpen)
@@ -816,17 +778,13 @@ private static bool TryClassifyHeader(ReadOnlySpan sig, out ExcelFileForma
return true;
}
- private static ExcelFileFormat ClassifyZipStream(Stream stream, long start, out ZipArchive zip)
+ private static ExcelFileFormat ClassifyZip(ZipIndex zip)
{
- var zipPeek = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true);
- zip = zipPeek;
- bool isXlsb = zipPeek.GetEntry("xl/workbook.bin") is not null;
- stream.Position = start;
- return isXlsb ? ExcelFileFormat.Xlsb : ExcelFileFormat.Xlsx;
+ return zip.TryGetEntry("xl/workbook.bin"u8, out _) ? ExcelFileFormat.Xlsb : ExcelFileFormat.Xlsx;
}
[SkipLocalsInit]
- private static ExcelFileFormat DetectSeekable(Stream stream, out ZipArchive? zip)
+ private static ExcelFileFormat DetectSeekable(Stream stream, bool leaveOpen, ExcelReaderOptions options, out ZipIndex? zip)
{
zip = null;
RequireSeekable(stream);
@@ -845,12 +803,22 @@ private static ExcelFileFormat DetectSeekable(Stream stream, out ZipArchive? zip
? ExcelFileFormat.EncryptedOoxml
: ExcelFileFormat.Xls;
}
- ExcelFileFormat zipFormat = ClassifyZipStream(stream, start, out ZipArchive zipPeek);
- zip = zipPeek;
- return zipFormat;
+ try
+ {
+ zip = ZipIndex.Create(ByteSource.FromStream(stream, leaveOpen), options);
+ }
+ finally
+ {
+ if (stream.CanSeek)
+ {
+ stream.Position = start;
+ }
+ }
+ return ClassifyZip(zip);
}
- private static async ValueTask<(ExcelFileFormat Format, ZipArchive? Zip)> DetectSeekableAsync(Stream stream, CancellationToken ct)
+ private static async ValueTask<(ExcelFileFormat Format, ZipIndex? Zip)> DetectSeekableAsync(
+ Stream stream, bool leaveOpen, ExcelReaderOptions options, CancellationToken ct)
{
RequireSeekable(stream);
long start = stream.Position;
@@ -879,8 +847,20 @@ private static ExcelFileFormat DetectSeekable(Stream stream, out ZipArchive? zip
: ExcelFileFormat.Xls;
return (cfbFormat, null);
}
- ExcelFileFormat zipFormat = ClassifyZipStream(stream, start, out ZipArchive zip);
- return (zipFormat, zip);
+ ByteSource source = await ByteSource.FromStreamAsync(stream, leaveOpen, ct).ConfigureAwait(false);
+ ZipIndex zip;
+ try
+ {
+ zip = await ZipIndex.CreateAsync(source, options, ct).ConfigureAwait(false);
+ }
+ finally
+ {
+ if (stream.CanSeek)
+ {
+ stream.Position = start;
+ }
+ }
+ return (ClassifyZip(zip), zip);
}
private static bool TryDecryptCfbStream(Stream stream, bool leaveOpen, ExcelReaderOptions? options, out Stream decrypted)
diff --git a/src/ExcelReader.Core/Reader/ExcelDataReader.cs b/src/ExcelReader.Core/Reader/ExcelDataReader.cs
index 7d965b00..7ec63e4c 100644
--- a/src/ExcelReader.Core/Reader/ExcelDataReader.cs
+++ b/src/ExcelReader.Core/Reader/ExcelDataReader.cs
@@ -58,21 +58,29 @@ public ExcelDataReader(IExcelRowReader reader, int headerRow = 1)
_sheetName = reader.SheetName;
_rows = reader.GetEnumerator();
_pendingConsumed = true;
- if (headerRow > 0 && SchemaInference.TrySkipToHeaderRow(_rows, headerRow, out _))
+ try
{
- _names = ReadHeaderNames(_rows.Current);
- }
- else if (headerRow > 0)
- {
- _names = [];
+ if (headerRow > 0 && SchemaInference.TrySkipToHeaderRow(_rows, headerRow, out _))
+ {
+ _names = ReadHeaderNames(_rows.Current);
+ }
+ else if (headerRow > 0)
+ {
+ _names = [];
+ }
+ else
+ {
+ _hasPendingRow = _rows.MoveNext();
+ _pendingConsumed = false;
+ _names = _hasPendingRow ? new string?[_rows.Current.ColumnCount] : [];
+ }
+ _ordinals = BuildOrdinals(_names);
}
- else
+ catch
{
- _hasPendingRow = _rows.MoveNext();
- _pendingConsumed = false;
- _names = _hasPendingRow ? new string?[_rows.Current.ColumnCount] : [];
+ _rows.Dispose();
+ throw;
}
- _ordinals = BuildOrdinals(_names);
}
private static string?[] ReadHeaderNames(Row header)
diff --git a/src/ExcelReader.Core/Reader/IExcelRowReader.cs b/src/ExcelReader.Core/Reader/IExcelRowReader.cs
index 71fd5e5f..4d2de72d 100644
--- a/src/ExcelReader.Core/Reader/IExcelRowReader.cs
+++ b/src/ExcelReader.Core/Reader/IExcelRowReader.cs
@@ -32,11 +32,11 @@ public interface IExcelRowReader
/// reader () and lets callers walk every sheet without
/// downcasting to the concrete XlsxReader/XlsbReader/XlsReader type.
///
- /// Thread safety: no implementation is thread-safe. A reader instance carries mutable enumeration
- /// state (current sheet, buffer positions, shared-string cache) with no synchronization; concurrent calls
- /// from multiple threads — including two enumerators obtained from the same reader used concurrently —
- /// produce undefined behavior. Use one reader instance per thread, or fully consume/dispose one enumerator
- /// before starting another.
+ /// Thread safety: enumerators obtained from one reader may be consumed on different threads,
+ /// each by one thread at a time, including two enumerators over the same sheet. The reader's own
+ /// members are not synchronized: select sheets and create enumerators from one thread at a time. A
+ /// CSV reader over a non-seekable stream serves a single enumerator. Disposing the reader closes it
+ /// to new enumerators; its file or stream is released when the last enumerator is disposed.
///
///
public interface IExcelRowReader : IExcelRowReader, IDisposable, IAsyncDisposable
diff --git a/src/ExcelReader.Core/Reader/Internal/BufferedStreamCursor.cs b/src/ExcelReader.Core/Reader/Internal/BufferedStreamCursor.cs
index f44d7531..b6c74825 100644
--- a/src/ExcelReader.Core/Reader/Internal/BufferedStreamCursor.cs
+++ b/src/ExcelReader.Core/Reader/Internal/BufferedStreamCursor.cs
@@ -140,6 +140,7 @@ internal void Return()
ArrayPool.Shared.Return(Buf);
}
Buf = [];
+ Eof = true;
}
}
}
diff --git a/src/ExcelReader.Core/Reader/Internal/OnceGate.cs b/src/ExcelReader.Core/Reader/Internal/OnceGate.cs
new file mode 100644
index 00000000..88a18938
--- /dev/null
+++ b/src/ExcelReader.Core/Reader/Internal/OnceGate.cs
@@ -0,0 +1,95 @@
+using System.Runtime.ExceptionServices;
+
+namespace ExcelReader.Core.Reader.Internal
+{
+ ///
+ /// Runs a load once for however many callers ask. A failure is kept and rethrown; a cancelled
+ /// load is undone so the next caller starts clean.
+ ///
+ internal sealed class OnceGate : IDisposable
+ {
+ private readonly SemaphoreSlim _gate = new(1, 1);
+ private volatile bool _done;
+ private ExceptionDispatchInfo? _failure;
+
+ internal bool IsDone => _done;
+
+ internal void Run(TState state, Action load, Action reset)
+ {
+ if (_done)
+ {
+ return;
+ }
+ _gate.Wait();
+ try
+ {
+ if (_done)
+ {
+ return;
+ }
+ _failure?.Throw();
+ try
+ {
+ load(state);
+ }
+ catch (OperationCanceledException)
+ {
+ reset(state);
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _failure = ExceptionDispatchInfo.Capture(ex);
+ throw;
+ }
+ _done = true;
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+
+ internal async ValueTask RunAsync(
+ TState state, Func load, Action reset, CancellationToken ct)
+ {
+ if (_done)
+ {
+ return;
+ }
+ await _gate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ if (_done)
+ {
+ return;
+ }
+ _failure?.Throw();
+ try
+ {
+ await load(state, ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ reset(state);
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _failure = ExceptionDispatchInfo.Capture(ex);
+ throw;
+ }
+ _done = true;
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+
+ public void Dispose()
+ {
+ _gate.Dispose();
+ }
+ }
+}
diff --git a/src/ExcelReader.Core/Reader/Internal/ReaderLifetime.cs b/src/ExcelReader.Core/Reader/Internal/ReaderLifetime.cs
new file mode 100644
index 00000000..4ea572dd
--- /dev/null
+++ b/src/ExcelReader.Core/Reader/Internal/ReaderLifetime.cs
@@ -0,0 +1,41 @@
+namespace ExcelReader.Core.Reader.Internal
+{
+ ///
+ /// Keeps a reader's resources alive while any enumerator still uses them: the reader holds one
+ /// reference, each enumerator another, and the release runs when the last one goes.
+ ///
+ internal sealed class ReaderLifetime(Action release)
+ {
+ private int _count = 1;
+ private int _closed;
+
+ internal void Acquire(object owner)
+ {
+ while (true)
+ {
+ int current = Volatile.Read(ref _count);
+ ObjectDisposedException.ThrowIf(current == 0 || Volatile.Read(ref _closed) != 0, owner);
+ if (Interlocked.CompareExchange(ref _count, current + 1, current) == current)
+ {
+ return;
+ }
+ }
+ }
+
+ internal void Release()
+ {
+ if (Interlocked.Decrement(ref _count) == 0)
+ {
+ release();
+ }
+ }
+
+ internal void Close()
+ {
+ if (Interlocked.Exchange(ref _closed, 1) == 0)
+ {
+ Release();
+ }
+ }
+ }
+}
diff --git a/src/ExcelReader.Core/Reader/Internal/WorkbookLookups.cs b/src/ExcelReader.Core/Reader/Internal/WorkbookLookups.cs
index e414e0c5..186910ae 100644
--- a/src/ExcelReader.Core/Reader/Internal/WorkbookLookups.cs
+++ b/src/ExcelReader.Core/Reader/Internal/WorkbookLookups.cs
@@ -1,4 +1,3 @@
-using System.IO.Compression;
using System.Runtime.CompilerServices;
using System.Text;
using ExcelReader.Core.Reader.Zip;
@@ -66,14 +65,8 @@ internal static (int Start, int Length, int ValidIndex) SharedAt(int[] sharedOff
return (sharedOffsets[index], sharedOffsets[index + 1] - sharedOffsets[index], index);
}
- internal static ZipArchiveEntry GetWorksheetEntry(ZipArchive zip, string path)
- {
- return zip.GetEntry(path)
- ?? throw new InvalidDataException($"Worksheet part not found: {path}");
- }
-
[SkipLocalsInit]
- internal static ZipEntryRef GetWorksheetEntry(ZipMemoryIndex memZip, string path)
+ internal static ZipEntryRef GetWorksheetEntry(ZipIndex memZip, string path)
{
Span stackBuffer = stackalloc byte[256];
ReadOnlySpan utf8Path = Encoding.UTF8.GetByteCount(path) <= stackBuffer.Length
@@ -84,21 +77,6 @@ internal static ZipEntryRef GetWorksheetEntry(ZipMemoryIndex memZip, string path
: throw new InvalidDataException($"Worksheet part not found: {path}");
}
- internal static LimitedReadStream OpenEntryStream(
- ZipArchiveEntry entry, DecompressedByteCounter counter, ExcelReaderOptions options,
- string entryLimitName = "", long entryLimit = 0)
- {
- return Wrap(entry.Open(), counter, options, entryLimitName, entryLimit, entry.Length);
- }
-
- internal static async ValueTask OpenEntryStreamAsync(
- ZipArchiveEntry entry, DecompressedByteCounter counter, ExcelReaderOptions options,
- CancellationToken ct, string entryLimitName = "", long entryLimit = 0)
- {
- Stream opened = await entry.OpenAsync(ct).ConfigureAwait(false);
- return Wrap(opened, counter, options, entryLimitName, entryLimit, entry.Length);
- }
-
private const long PrefetchMinUncompressedSize = 256 * 1024;
internal static LimitedReadStream Wrap(
diff --git a/src/ExcelReader.Core/Reader/PooledStreamRowEnumerator.cs b/src/ExcelReader.Core/Reader/PooledStreamRowEnumerator.cs
index 2cac3766..278e654e 100644
--- a/src/ExcelReader.Core/Reader/PooledStreamRowEnumerator.cs
+++ b/src/ExcelReader.Core/Reader/PooledStreamRowEnumerator.cs
@@ -14,6 +14,7 @@ public abstract class PooledStreamRowEnumerator
private protected readonly BufferedStreamCursor _io;
private protected readonly CellAccumulator _acc;
private readonly bool _ownsSource;
+ private protected ReaderLifetime? _lease;
private bool _deferred;
private protected byte[] _buf => _io.Buf;
private protected int _pos { get => _io.Pos; set => _io.Pos = value; }
@@ -78,7 +79,9 @@ public void Dispose()
_source?.Dispose();
}
_source = null;
+ _deferred = false;
ReturnBuffers();
+ Interlocked.Exchange(ref _lease, null)?.Release();
}
/// Asynchronous counterpart to .
@@ -89,7 +92,9 @@ public async ValueTask DisposeAsync()
await _source.DisposeAsync().ConfigureAwait(false);
}
_source = null;
+ _deferred = false;
ReturnBuffers();
+ Interlocked.Exchange(ref _lease, null)?.Release();
}
private protected void Fill()
diff --git a/src/ExcelReader.Core/Reader/Sources/ByteSource.cs b/src/ExcelReader.Core/Reader/Sources/ByteSource.cs
new file mode 100644
index 00000000..1801ead6
--- /dev/null
+++ b/src/ExcelReader.Core/Reader/Sources/ByteSource.cs
@@ -0,0 +1,137 @@
+using Microsoft.Win32.SafeHandles;
+
+namespace ExcelReader.Core.Reader.Sources
+{
+ ///
+ /// Random-access bytes of one workbook file. Every read names its own offset, so readers of the
+ /// same source share no cursor.
+ ///
+ internal abstract class ByteSource : IDisposable
+ {
+ internal abstract long Length { get; }
+
+ internal abstract int Read(long offset, Span destination);
+
+ internal abstract ValueTask ReadAsync(long offset, Memory destination, CancellationToken ct);
+
+ internal virtual bool TryGetMemory(out ReadOnlyMemory memory)
+ {
+ memory = default;
+ return false;
+ }
+
+ internal void ReadExactly(long offset, Span destination)
+ {
+ int filled = 0;
+ while (filled < destination.Length)
+ {
+ int read = Read(offset + filled, destination[filled..]);
+ if (read <= 0)
+ {
+ throw new EndOfStreamException();
+ }
+ filled += read;
+ }
+ }
+
+ internal async ValueTask ReadExactlyAsync(long offset, Memory destination, CancellationToken ct)
+ {
+ int filled = 0;
+ while (filled < destination.Length)
+ {
+ int read = await ReadAsync(offset + filled, destination[filled..], ct).ConfigureAwait(false);
+ if (read <= 0)
+ {
+ throw new EndOfStreamException();
+ }
+ filled += read;
+ }
+ }
+
+ public abstract void Dispose();
+
+ internal static ByteSource FromMemory(ReadOnlyMemory data)
+ {
+ return new MemoryByteSource(data, owner: null);
+ }
+
+ internal static ByteSource OpenFile(string path, bool asynchronous)
+ {
+ SafeFileHandle handle = File.OpenHandle(path, FileMode.Open, FileAccess.Read, FileShare.Read,
+ asynchronous ? FileOptions.Asynchronous : FileOptions.None);
+ try
+ {
+ return new FileByteSource(handle, RandomAccess.GetLength(handle), owner: handle);
+ }
+ catch
+ {
+ handle.Dispose();
+ throw;
+ }
+ }
+
+ internal static ByteSource FromStream(Stream stream, bool leaveOpen)
+ {
+ if (TryWrapSeekable(stream, leaveOpen, out ByteSource? source))
+ {
+ return source;
+ }
+ try
+ {
+ using MemoryStream copy = new();
+ stream.CopyTo(copy);
+ return new MemoryByteSource(copy.GetBuffer().AsMemory(0, (int)copy.Length), owner: null);
+ }
+ finally
+ {
+ if (!leaveOpen)
+ {
+ stream.Dispose();
+ }
+ }
+ }
+
+ internal static async ValueTask FromStreamAsync(Stream stream, bool leaveOpen, CancellationToken ct)
+ {
+ if (TryWrapSeekable(stream, leaveOpen, out ByteSource? source))
+ {
+ return source;
+ }
+ try
+ {
+ using MemoryStream copy = new();
+ await stream.CopyToAsync(copy, ct).ConfigureAwait(false);
+ return new MemoryByteSource(copy.GetBuffer().AsMemory(0, (int)copy.Length), owner: null);
+ }
+ finally
+ {
+ if (!leaveOpen)
+ {
+ await stream.DisposeAsync().ConfigureAwait(false);
+ }
+ }
+ }
+
+ private static bool TryWrapSeekable(Stream stream, bool leaveOpen, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out ByteSource? source)
+ {
+ IDisposable? owner = leaveOpen ? null : stream;
+ if (stream.GetType() == typeof(MemoryStream) && ((MemoryStream)stream).TryGetBuffer(out ArraySegment segment))
+ {
+ source = new MemoryByteSource(segment, owner);
+ return true;
+ }
+ if (stream.GetType() == typeof(FileStream) && stream.CanSeek)
+ {
+ source = new FileByteSource(((FileStream)stream).SafeFileHandle, stream.Length, owner);
+ return true;
+ }
+ if (stream.CanSeek)
+ {
+ source = new StreamByteSource(stream, leaveOpen);
+ return true;
+ }
+ source = null;
+ return false;
+ }
+ }
+}
diff --git a/src/ExcelReader.Core/Reader/Sources/ByteSourceStream.cs b/src/ExcelReader.Core/Reader/Sources/ByteSourceStream.cs
new file mode 100644
index 00000000..6ae56e11
--- /dev/null
+++ b/src/ExcelReader.Core/Reader/Sources/ByteSourceStream.cs
@@ -0,0 +1,155 @@
+using System.Buffers;
+
+namespace ExcelReader.Core.Reader.Sources
+{
+ /// Forward-only view of one byte range of a , with its own offset.
+ internal sealed class ByteSourceStream : Stream
+ {
+ private const int BufferSize = 64 * 1024;
+
+ private readonly ByteSource _source;
+ private readonly long _end;
+ private readonly bool _buffered;
+ private long _next;
+ private byte[]? _buffer;
+ private int _bufferPos;
+ private int _bufferLen;
+ private bool _disposed;
+
+ internal ByteSourceStream(ByteSource source, long offset, long length, bool buffered = true)
+ {
+ _source = source;
+ _buffered = buffered;
+ _next = offset;
+ _end = offset + length;
+ }
+
+ public override bool CanRead => true;
+
+ public override bool CanSeek => false;
+
+ public override bool CanWrite => false;
+
+ public override long Length => throw new NotSupportedException();
+
+ public override long Position
+ {
+ get => throw new NotSupportedException();
+ set => throw new NotSupportedException();
+ }
+
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ return Read(buffer.AsSpan(offset, count));
+ }
+
+ public override int Read(Span destination)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ if (destination.IsEmpty)
+ {
+ return 0;
+ }
+ if (_bufferPos == _bufferLen)
+ {
+ long remaining = _end - _next;
+ if (remaining <= 0)
+ {
+ return 0;
+ }
+ if (!_buffered || destination.Length >= BufferSize)
+ {
+ int direct = _source.Read(_next, destination[..(int)Math.Min(destination.Length, remaining)]);
+ _next += direct;
+ return direct;
+ }
+ _buffer ??= ArrayPool.Shared.Rent(BufferSize);
+ _bufferLen = _source.Read(_next, _buffer.AsSpan(0, (int)Math.Min(BufferSize, remaining)));
+ _bufferPos = 0;
+ _next += _bufferLen;
+ if (_bufferLen == 0)
+ {
+ return 0;
+ }
+ }
+ return TakeBuffered(destination);
+ }
+
+ public override async ValueTask ReadAsync(Memory destination, CancellationToken cancellationToken = default)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ if (destination.IsEmpty)
+ {
+ return 0;
+ }
+ if (_bufferPos == _bufferLen)
+ {
+ long remaining = _end - _next;
+ if (remaining <= 0)
+ {
+ return 0;
+ }
+ if (!_buffered || destination.Length >= BufferSize)
+ {
+ int direct = await _source.ReadAsync(
+ _next, destination[..(int)Math.Min(destination.Length, remaining)], cancellationToken).ConfigureAwait(false);
+ _next += direct;
+ return direct;
+ }
+ _buffer ??= ArrayPool.Shared.Rent(BufferSize);
+ _bufferLen = await _source.ReadAsync(
+ _next, _buffer.AsMemory(0, (int)Math.Min(BufferSize, remaining)), cancellationToken).ConfigureAwait(false);
+ _bufferPos = 0;
+ _next += _bufferLen;
+ if (_bufferLen == 0)
+ {
+ return 0;
+ }
+ }
+ return TakeBuffered(destination.Span);
+ }
+
+ public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
+ {
+ return ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();
+ }
+
+ private int TakeBuffered(Span destination)
+ {
+ int count = Math.Min(destination.Length, _bufferLen - _bufferPos);
+ _buffer.AsSpan(_bufferPos, count).CopyTo(destination);
+ _bufferPos += count;
+ return count;
+ }
+
+ public override void Flush()
+ {
+ }
+
+ public override long Seek(long offset, SeekOrigin origin)
+ {
+ throw new NotSupportedException();
+ }
+
+ public override void SetLength(long value)
+ {
+ throw new NotSupportedException();
+ }
+
+ public override void Write(byte[] buffer, int offset, int count)
+ {
+ throw new NotSupportedException();
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ _disposed = true;
+ byte[]? buffer = Interlocked.Exchange(ref _buffer, null);
+ if (buffer is not null)
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+ base.Dispose(disposing);
+ }
+ }
+}
diff --git a/src/ExcelReader.Core/Reader/Sources/FileByteSource.cs b/src/ExcelReader.Core/Reader/Sources/FileByteSource.cs
new file mode 100644
index 00000000..ebd9f584
--- /dev/null
+++ b/src/ExcelReader.Core/Reader/Sources/FileByteSource.cs
@@ -0,0 +1,36 @@
+using Microsoft.Win32.SafeHandles;
+
+namespace ExcelReader.Core.Reader.Sources
+{
+ internal sealed class FileByteSource : ByteSource
+ {
+ private readonly SafeFileHandle _handle;
+ private readonly IDisposable? _owner;
+
+ internal FileByteSource(SafeFileHandle handle, long length, IDisposable? owner)
+ {
+ _handle = handle;
+ Length = length;
+ _owner = owner;
+ }
+
+ internal override long Length { get; }
+
+ internal override int Read(long offset, Span destination)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(offset);
+ return offset >= Length ? 0 : RandomAccess.Read(_handle, destination, offset);
+ }
+
+ internal override ValueTask ReadAsync(long offset, Memory destination, CancellationToken ct)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(offset);
+ return offset >= Length ? new ValueTask(0) : RandomAccess.ReadAsync(_handle, destination, offset, ct);
+ }
+
+ public override void Dispose()
+ {
+ _owner?.Dispose();
+ }
+ }
+}
diff --git a/src/ExcelReader.Core/Reader/Sources/MemoryByteSource.cs b/src/ExcelReader.Core/Reader/Sources/MemoryByteSource.cs
new file mode 100644
index 00000000..68a66d85
--- /dev/null
+++ b/src/ExcelReader.Core/Reader/Sources/MemoryByteSource.cs
@@ -0,0 +1,45 @@
+namespace ExcelReader.Core.Reader.Sources
+{
+ internal sealed class MemoryByteSource : ByteSource
+ {
+ private readonly ReadOnlyMemory _memory;
+ private readonly IDisposable? _owner;
+
+ internal MemoryByteSource(ReadOnlyMemory memory, IDisposable? owner)
+ {
+ _memory = memory;
+ _owner = owner;
+ }
+
+ internal override long Length => _memory.Length;
+
+ internal override int Read(long offset, Span destination)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(offset);
+ if (offset >= _memory.Length)
+ {
+ return 0;
+ }
+ int count = (int)Math.Min(destination.Length, _memory.Length - offset);
+ _memory.Span.Slice((int)offset, count).CopyTo(destination);
+ return count;
+ }
+
+ internal override ValueTask ReadAsync(long offset, Memory destination, CancellationToken ct)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(offset);
+ return new ValueTask(Read(offset, destination.Span));
+ }
+
+ internal override bool TryGetMemory(out ReadOnlyMemory memory)
+ {
+ memory = _memory;
+ return true;
+ }
+
+ public override void Dispose()
+ {
+ _owner?.Dispose();
+ }
+ }
+}
diff --git a/src/ExcelReader.Core/Reader/Sources/StreamByteSource.cs b/src/ExcelReader.Core/Reader/Sources/StreamByteSource.cs
new file mode 100644
index 00000000..d8c6bc61
--- /dev/null
+++ b/src/ExcelReader.Core/Reader/Sources/StreamByteSource.cs
@@ -0,0 +1,69 @@
+namespace ExcelReader.Core.Reader.Sources
+{
+ ///
+ /// A seekable stream has one cursor, so each read takes the gate for its seek and read only;
+ /// decompression and parsing happen outside it.
+ ///
+ internal sealed class StreamByteSource : ByteSource
+ {
+ private readonly Stream _stream;
+ private readonly bool _leaveOpen;
+ private readonly SemaphoreSlim _gate = new(1, 1);
+
+ internal StreamByteSource(Stream stream, bool leaveOpen)
+ {
+ _stream = stream;
+ _leaveOpen = leaveOpen;
+ Length = stream.Length;
+ }
+
+ internal override long Length { get; }
+
+ internal override int Read(long offset, Span destination)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(offset);
+ if (offset >= Length)
+ {
+ return 0;
+ }
+ _gate.Wait();
+ try
+ {
+ _stream.Position = offset;
+ return _stream.Read(destination);
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+
+ internal override async ValueTask ReadAsync(long offset, Memory destination, CancellationToken ct)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(offset);
+ if (offset >= Length)
+ {
+ return 0;
+ }
+ await _gate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ _stream.Position = offset;
+ return await _stream.ReadAsync(destination, ct).ConfigureAwait(false);
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+
+ public override void Dispose()
+ {
+ if (!_leaveOpen)
+ {
+ _stream.Dispose();
+ }
+ _gate.Dispose();
+ }
+ }
+}
diff --git a/src/ExcelReader.Core/Reader/Xls/WorkbookStream.cs b/src/ExcelReader.Core/Reader/Xls/WorkbookStream.cs
index 42c430b5..78ed0d7a 100644
--- a/src/ExcelReader.Core/Reader/Xls/WorkbookStream.cs
+++ b/src/ExcelReader.Core/Reader/Xls/WorkbookStream.cs
@@ -1,6 +1,7 @@
using System.Buffers;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.InteropServices;
+using ExcelReader.Core.Reader.Sources;
namespace ExcelReader.Core.Reader.Xls
{
@@ -9,8 +10,7 @@ internal sealed class WorkbookStream : IDisposable
{
private const int HeaderSize = 512;
- private readonly Stream? _source;
- private readonly bool _ownsSource;
+ private readonly ByteSource? _source;
private readonly int[] _chain;
private readonly int _chainLength;
private bool _chainReturned;
@@ -29,10 +29,9 @@ internal enum SourceKind
internal SourceKind Kind { get; }
- private WorkbookStream(Stream? source, bool ownsSource, int[] chain, int chainLength, ReadOnlyMemory memory, int sectorSize, long length, SourceKind kind)
+ private WorkbookStream(ByteSource? source, int[] chain, int chainLength, ReadOnlyMemory memory, int sectorSize, long length, SourceKind kind)
{
_source = source;
- _ownsSource = ownsSource;
_chain = chain;
_chainLength = chainLength;
SectorSize = sectorSize;
@@ -54,19 +53,19 @@ private WorkbookStream(Stream? source, bool ownsSource, int[] chain, int chainLe
_fileLength = Buffer.Length;
}
- internal static WorkbookStream Streamed(Stream source, bool ownsSource, int[] chain, int chainLength, int sectorSize, long length)
+ internal static WorkbookStream Streamed(ByteSource source, int[] chain, int chainLength, int sectorSize, long length)
{
- return new WorkbookStream(source, ownsSource, chain, chainLength, default, sectorSize, length, SourceKind.Streamed);
+ return new WorkbookStream(source, chain, chainLength, default, sectorSize, length, SourceKind.Streamed);
}
internal static WorkbookStream InMemory(ReadOnlyMemory data)
{
- return new WorkbookStream(null, ownsSource: false, [], 0, data, sectorSize: 1, data.Length, SourceKind.Contiguous);
+ return new WorkbookStream(null, [], 0, data, sectorSize: 1, data.Length, SourceKind.Contiguous);
}
internal static WorkbookStream Chained(ReadOnlyMemory data, int[] chain, int chainLength, int sectorSize, long length)
{
- return new WorkbookStream(null, ownsSource: false, chain, chainLength, data, sectorSize, length, SourceKind.Chained);
+ return new WorkbookStream(null, chain, chainLength, data, sectorSize, length, SourceKind.Chained);
}
internal BiffCursor OpenCursor()
@@ -156,17 +155,13 @@ internal int LoadSectors(int chainIndex, Span dest)
count++;
}
long offset = HeaderSize + ((long)_chain[chainIndex] * SectorSize);
- _source!.Seek(offset, SeekOrigin.Begin);
- _source.ReadExactly(dest[..(count * SectorSize)]);
+ _source!.ReadExactly(offset, dest[..(count * SectorSize)]);
return count;
}
public void Dispose()
{
- if (_ownsSource)
- {
- _source?.Dispose();
- }
+ _source?.Dispose();
if (_chainLength > 0 && !_chainReturned)
{
_chainReturned = true;
diff --git a/src/ExcelReader.Core/Reader/Xls/XlsCompoundFile.cs b/src/ExcelReader.Core/Reader/Xls/XlsCompoundFile.cs
index 670170f1..c7771f65 100644
--- a/src/ExcelReader.Core/Reader/Xls/XlsCompoundFile.cs
+++ b/src/ExcelReader.Core/Reader/Xls/XlsCompoundFile.cs
@@ -2,6 +2,7 @@
using System.Runtime.InteropServices;
using ExcelReader.Core.Crypto;
using ExcelReader.Core.Reader.Internal;
+using ExcelReader.Core.Reader.Sources;
namespace ExcelReader.Core.Reader.Xls
{
@@ -138,7 +139,7 @@ private static WorkbookStream BuildWorkbook(Stream source, bool ownsSource, Exce
{
return WorkbookStream.Chained(memory, chain, chainCount, cfb.SectorSize, workbook.Size);
}
- return WorkbookStream.Streamed(source, ownsSource, chain, chainCount, cfb.SectorSize, workbook.Size);
+ return WorkbookStream.Streamed(ByteSource.FromStream(source, leaveOpen: !ownsSource), chain, chainCount, cfb.SectorSize, workbook.Size);
}
finally
{
diff --git a/src/ExcelReader.Core/Reader/Xls/XlsReader.Enumerator.cs b/src/ExcelReader.Core/Reader/Xls/XlsReader.Enumerator.cs
index deee8b98..2f8cabcd 100644
--- a/src/ExcelReader.Core/Reader/Xls/XlsReader.Enumerator.cs
+++ b/src/ExcelReader.Core/Reader/Xls/XlsReader.Enumerator.cs
@@ -17,8 +17,12 @@ public sealed class Enumerator : IExcelRowEnumerator
private readonly BiffCursor _cursor;
private readonly CellAccumulator _acc;
private readonly Utf8StringCache? _contentCache;
+ private readonly Queue _declaredRows = new();
private bool _ended;
+ private bool _emptyRow;
private int _row;
+ private int _rowFloor = -1;
+ private ReaderLifetime? _lease;
internal Enumerator(XlsReader reader, int sheetOffset, CancellationToken ct = default)
{
@@ -28,6 +32,7 @@ internal Enumerator(XlsReader reader, int sheetOffset, CancellationToken ct = de
_row = -1;
_acc = new CellAccumulator(reader._options.MaxCellBytes, nameof(ExcelReaderOptions.MaxCellBytes));
_contentCache = reader._options.InternStrings ? new Utf8StringCache() : null;
+ _lease = reader.Lifetime;
}
///
@@ -72,7 +77,8 @@ private bool MoveNextCore()
return true;
}
}
- return false;
+ ResetRow();
+ return _declaredRows.TryDequeue(out _);
}
private bool ReadRecord(BiffCursor cursor, long recordStart, int id, ReadOnlySpan data)
@@ -96,6 +102,9 @@ private bool ReadRecord(BiffCursor cursor, long recordStart, int id, ReadOnlySpa
}
switch (id)
{
+ case Rec.Row:
+ DeclareRow(ReadU16(data, 0));
+ return true;
case Rec.Label:
if (!AdvanceRow(cursor, recordStart, ReadU16(data, 0))) { return false; }
ParseLabel(data);
@@ -144,11 +153,32 @@ private bool ReadRecord(BiffCursor cursor, long recordStart, int id, ReadOnlySpa
}
}
+ /// Queues a ROW record's row so it is yielded, empty, if no cell record claims it.
+ private void DeclareRow(int row)
+ {
+ if (row > _rowFloor)
+ {
+ _rowFloor = row;
+ _declaredRows.Enqueue(row);
+ }
+ }
+
private bool AdvanceRow(BiffCursor cursor, long recordStart, int row)
{
if (_row < 0)
{
+ if (_declaredRows.TryPeek(out int declared) && declared <= row)
+ {
+ _declaredRows.Dequeue();
+ if (declared < row)
+ {
+ cursor.Position = recordStart;
+ _emptyRow = true;
+ return false;
+ }
+ }
_row = row;
+ _rowFloor = Math.Max(_rowFloor, row);
return true;
}
if (row != _row)
@@ -162,13 +192,14 @@ private bool AdvanceRow(BiffCursor cursor, long recordStart, int row)
private bool FinishRow()
{
_acc.SortByColumn();
- return _acc.Count > 0;
+ return _row >= 0 || _emptyRow;
}
private void ResetRow()
{
_acc.Reset();
_row = -1;
+ _emptyRow = false;
}
private void ParseLabel(ReadOnlySpan data)
@@ -303,6 +334,7 @@ private void ReturnBuffers()
{
_cursor.Dispose();
_acc.Return();
+ Interlocked.Exchange(ref _lease, null)?.Release();
}
}
}
diff --git a/src/ExcelReader.Core/Reader/Xls/XlsReader.cs b/src/ExcelReader.Core/Reader/Xls/XlsReader.cs
index 8ae7e95d..89ef3229 100644
--- a/src/ExcelReader.Core/Reader/Xls/XlsReader.cs
+++ b/src/ExcelReader.Core/Reader/Xls/XlsReader.cs
@@ -18,6 +18,8 @@ public sealed partial class XlsReader : IExcelRowReader, IExcelRowReader data, ExcelReaderOptions? options = null
private XlsReader(WorkbookStream workbook, ExcelReaderOptions? options = null)
{
+ Lifetime = new ReaderLifetime(ReleaseResources);
_workbook = workbook;
_options = options ?? ExcelReaderOptions.Default;
using (BiffCursor cursor = workbook.OpenCursor())
@@ -84,7 +87,13 @@ public ExcelSheetVisibility SheetVisibilityAt(int index)
internal ReadOnlySpan SharedSpan => _sharedFlat;
- internal string?[] SharedStringCache => _sharedStringCache ??= WorkbookLookups.CreateSharedStringCache(_sharedOffsets);
+ internal string?[] SharedStringCache => Volatile.Read(ref _sharedStringCache) ?? CreateSharedStringCache();
+
+ private string?[] CreateSharedStringCache()
+ {
+ string?[] created = WorkbookLookups.CreateSharedStringCache(_sharedOffsets);
+ return Interlocked.CompareExchange(ref _sharedStringCache, created, null) ?? created;
+ }
internal bool IsDateStyle(int style)
{
@@ -117,7 +126,16 @@ public void MoveToSheet(int index)
///
public Enumerator GetEnumerator()
{
- return new Enumerator(this, _sheets[_current].Offset);
+ Lifetime.Acquire(this);
+ try
+ {
+ return new Enumerator(this, _sheets[_current].Offset);
+ }
+ catch
+ {
+ Lifetime.Release();
+ throw;
+ }
}
IExcelRowEnumerator IExcelRowReader.GetEnumerator()
@@ -130,7 +148,16 @@ IExcelRowEnumerator IExcelRowReader.GetEnumerator()
public Enumerator GetAsyncEnumerator(CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
- return new Enumerator(this, _sheets[_current].Offset, ct);
+ Lifetime.Acquire(this);
+ try
+ {
+ return new Enumerator(this, _sheets[_current].Offset, ct);
+ }
+ catch
+ {
+ Lifetime.Release();
+ throw;
+ }
}
IExcelRowEnumerator IExcelRowReader.GetAsyncEnumerator(CancellationToken ct)
@@ -141,19 +168,24 @@ IExcelRowEnumerator IExcelRowReader.GetAsyncEnumerator(Canc
///
public void Dispose()
{
- _workbook.Dispose();
- _sharedFlat = [];
- _sharedOffsets = [0];
- _sharedStringCache = null;
+ Lifetime.Close();
}
///
public ValueTask DisposeAsync()
{
- Dispose();
+ Lifetime.Close();
return ValueTask.CompletedTask;
}
+ private void ReleaseResources()
+ {
+ _workbook.Dispose();
+ _sharedFlat = [];
+ _sharedOffsets = [0];
+ _sharedStringCache = null;
+ }
+
private static void ParseWorkbookGlobals(
BiffCursor cursor,
ExcelReaderOptions options,
@@ -428,6 +460,7 @@ private static class Rec
internal const int MulRk = 0x00BD;
internal const int BoolErr = 0x0205;
internal const int Formula = 0x0006;
+ internal const int Row = 0x0208;
internal const int Blank = 0x0201;
internal const int MulBlank = 0x00BE;
internal const int StringRec = 0x0207;
diff --git a/src/ExcelReader.Core/Reader/Xlsb/XlsbReader.Enumerator.cs b/src/ExcelReader.Core/Reader/Xlsb/XlsbReader.Enumerator.cs
index 614b2e52..957cb697 100644
--- a/src/ExcelReader.Core/Reader/Xlsb/XlsbReader.Enumerator.cs
+++ b/src/ExcelReader.Core/Reader/Xlsb/XlsbReader.Enumerator.cs
@@ -1,8 +1,8 @@
using System.Diagnostics.CodeAnalysis;
-using System.IO.Compression;
using System.Runtime.CompilerServices;
using System.Text;
using ExcelReader.Core.Reader.Internal;
+using ExcelReader.Core.Reader.Zip;
namespace ExcelReader.Core.Reader.Xlsb
{
@@ -24,7 +24,7 @@ public sealed class Enumerator : PooledStreamRowEnumerator, IExcelRowEnumerator
private readonly bool[] _styleIsDate;
private readonly int[] _sharedOffsets;
private readonly Utf8StringCache? _contentCache;
- private readonly ZipArchiveEntry? _entry;
+ private readonly ZipEntryRef _entry;
private bool _ended;
private bool _pendingRowHdr;
@@ -35,26 +35,28 @@ internal Enumerator(XlsbReader reader, Stream sheet, long entryLength = 0, Cance
_styleIsDate = reader._styleIsDate;
_sharedOffsets = reader._sharedOffsets;
_contentCache = reader._options.InternStrings ? new Utf8StringCache() : null;
+ _lease = reader.Lifetime;
}
- internal Enumerator(XlsbReader reader, ZipArchiveEntry entry, CancellationToken ct)
- : base(reader._options.MaxCellBytes, nameof(ExcelReaderOptions.MaxCellBytes), WorkbookLookups.InitialBufferCapacity(entry.Length), ct)
+ internal Enumerator(XlsbReader reader, ZipEntryRef entry, CancellationToken ct)
+ : base(reader._options.MaxCellBytes, nameof(ExcelReaderOptions.MaxCellBytes), WorkbookLookups.InitialBufferCapacity(entry.UncompressedSize), ct)
{
_reader = reader;
_styleIsDate = reader._styleIsDate;
_sharedOffsets = reader._sharedOffsets;
_contentCache = reader._options.InternStrings ? new Utf8StringCache() : null;
_entry = entry;
+ _lease = reader.Lifetime;
}
private protected override Stream OpenSource()
{
- return WorkbookLookups.OpenEntryStream(_entry!, _reader._decompressedBytes, _reader._options);
+ return _reader._zip!.OpenEntryStream(_entry, _reader._decompressedBytes, _reader._options);
}
private protected override async ValueTask OpenSourceAsync()
{
- return await WorkbookLookups.OpenEntryStreamAsync(_entry!, _reader._decompressedBytes, _reader._options, _ct).ConfigureAwait(false);
+ return await _reader._zip!.OpenEntryStreamAsync(_entry, _reader._decompressedBytes, _reader._options, _ct).ConfigureAwait(false);
}
///
@@ -76,36 +78,25 @@ public ValueTask MoveNextAsync()
{
return new ValueTask(false);
}
- while (true)
+ ResetRow();
+ if (!_pendingRowHdr)
{
- ResetRow();
- if (!_pendingRowHdr)
- {
- int seek = SeekRowHdrFromBuffer();
- if (seek == 0)
- {
- return new ValueTask(false);
- }
- if (seek == 2)
- {
- return MoveNextRowAsync(seekDone: false);
- }
- }
- _pendingRowHdr = false;
- int collect = CollectCellsFromBuffer();
- if (collect == 2)
- {
- return MoveNextRowAsync(seekDone: true);
- }
- if (_acc.Count > 0)
+ int seek = SeekRowHdrFromBuffer();
+ if (seek == 0)
{
- return new ValueTask(true);
+ return new ValueTask(false);
}
- if (!_pendingRowHdr)
+ if (seek == 2)
{
- return new ValueTask(false);
+ return MoveNextRowAsync(seekDone: false);
}
}
+ _pendingRowHdr = false;
+ if (CollectCellsFromBuffer() == 2)
+ {
+ return MoveNextRowAsync(seekDone: true);
+ }
+ return new ValueTask(true);
}
private async ValueTask MoveNextRowAsync(bool seekDone)
@@ -116,24 +107,7 @@ private async ValueTask MoveNextRowAsync(bool seekDone)
}
_pendingRowHdr = false;
await CollectCellsAsync().ConfigureAwait(false);
- while (true)
- {
- if (_acc.Count > 0)
- {
- return true;
- }
- if (!_pendingRowHdr)
- {
- return false;
- }
- ResetRow();
- if (!await SeekRowHdrAsync().ConfigureAwait(false))
- {
- return false;
- }
- _pendingRowHdr = false;
- await CollectCellsAsync().ConfigureAwait(false);
- }
+ return true;
}
private bool MoveNextCore()
@@ -142,24 +116,14 @@ private bool MoveNextCore()
{
return false;
}
- while (true)
+ ResetRow();
+ if (!_pendingRowHdr && !SkipToRowHdr())
{
- ResetRow();
- if (!_pendingRowHdr && !SkipToRowHdr())
- {
- return false;
- }
- _pendingRowHdr = false;
- CollectCells();
- if (_acc.Count > 0)
- {
- return true;
- }
- if (!_pendingRowHdr)
- {
- return false;
- }
+ return false;
}
+ _pendingRowHdr = false;
+ CollectCells();
+ return true;
}
private bool SkipToRowHdr()
diff --git a/src/ExcelReader.Core/Reader/Xlsb/XlsbReader.Memory.cs b/src/ExcelReader.Core/Reader/Xlsb/XlsbReader.Memory.cs
deleted file mode 100644
index a2b18a18..00000000
--- a/src/ExcelReader.Core/Reader/Xlsb/XlsbReader.Memory.cs
+++ /dev/null
@@ -1,55 +0,0 @@
-using ExcelReader.Core.Reader.Internal;
-using ExcelReader.Core.Reader.Zip;
-
-namespace ExcelReader.Core.Reader.Xlsb
-{
- public sealed partial class XlsbReader
- {
- internal static XlsbReader CreateFromMemory(ReadOnlyMemory data, ExcelReaderOptions? options = null)
- {
- ExcelReaderOptions effectiveOptions = options ?? ExcelReaderOptions.Default;
- return CreateFromMemory(ZipMemoryIndex.Create(data, effectiveOptions), effectiveOptions);
- }
-
- internal static XlsbReader CreateFromMemory(ZipMemoryIndex memZip, ExcelReaderOptions effectiveOptions)
- {
- return ZipReaderOpen.FromMemory(memZip, zip => BuildFromMemory(zip, effectiveOptions));
- }
-
- private static XlsbReader BuildFromMemory(ZipMemoryIndex memZip, ExcelReaderOptions effectiveOptions)
- {
- DecompressedByteCounter decompressedBytes = new(effectiveOptions.MaxTotalDecompressedBytes);
- using ZipPart wbPart = memZip.OpenPartOrDefault("xl/workbook.bin"u8, decompressedBytes);
- using ZipPart relsPart = memZip.OpenPartOrDefault("xl/_rels/workbook.bin.rels"u8, decompressedBytes);
- (string Name, string Path, ExcelSheetVisibility Visibility)[] sheets = XlsbWorkbook.ParseSheets(wbPart.Memory.Span, relsPart.Memory.Span);
- if (sheets.Length == 0)
- {
- throw new InvalidDataException("The workbook contains no sheets.");
- }
- using ZipPart stylesPart = memZip.OpenPartOrDefault("xl/styles.bin"u8, decompressedBytes);
- bool[] styleIsDate = XlsbStyles.ParseStyleDateFlags(stylesPart.Memory.Span);
- bool date1904 = XlsbWorkbook.ParseDate1904(wbPart.Memory.Span);
- (byte[] flat, int[] offsets) = LoadSharedStringsFromMemory(memZip, decompressedBytes, effectiveOptions);
- return new XlsbReader(memZip, sheets, styleIsDate, date1904, flat, offsets, effectiveOptions, decompressedBytes);
- }
-
- private static (byte[] Flat, int[] Offsets) LoadSharedStringsFromMemory(
- ZipMemoryIndex memZip, DecompressedByteCounter decompressedBytes, ExcelReaderOptions options)
- {
- if (!memZip.TryGetEntry("xl/sharedStrings.bin"u8, out ZipEntryRef entry))
- {
- return ([], [0]);
- }
- WorkbookLookups.ThrowIfSharedEntryTooLarge(entry.UncompressedSize, decompressedBytes, options);
- using ZipPart part = memZip.OpenPart(entry, decompressedBytes,
- nameof(ExcelReaderOptions.MaxSharedStringBytes), options.MaxSharedStringBytes);
- return XlsbSharedStrings.Parse(part.Memory.Span, options);
- }
-
- private Enumerator GetEnumeratorFromMemory()
- {
- ZipEntryRef entry = WorkbookLookups.GetWorksheetEntry(_memZip!, _sheets![_current].Path);
- return new Enumerator(this, _memZip!.OpenEntryStream(entry, _decompressedBytes, _options), entry.UncompressedSize);
- }
- }
-}
diff --git a/src/ExcelReader.Core/Reader/Xlsb/XlsbReader.cs b/src/ExcelReader.Core/Reader/Xlsb/XlsbReader.cs
index 0eb69b3c..e46b52d2 100644
--- a/src/ExcelReader.Core/Reader/Xlsb/XlsbReader.cs
+++ b/src/ExcelReader.Core/Reader/Xlsb/XlsbReader.cs
@@ -1,6 +1,6 @@
using System.Buffers;
-using System.IO.Compression;
using ExcelReader.Core.Reader.Internal;
+using ExcelReader.Core.Reader.Sources;
using ExcelReader.Core.Reader.Zip;
namespace ExcelReader.Core.Reader.Xlsb
@@ -21,16 +21,14 @@ public sealed partial class XlsbReader : IExcelRowReader, IExcelRowReader data, ExcelReaderOptions? options = null)
{
- _leaveOpen = true;
- _memZip = memZip;
- _options = options;
- _decompressedBytes = decompressedBytes;
- _sheets = sheets;
- _styleIsDate = styleIsDate;
- IsDate1904 = date1904;
- _sharedFlat = sharedFlat;
- _sharedOffsets = sharedOffsets;
- _pooledSharedFlat = false;
+ ExcelReaderOptions effectiveOptions = options ?? ExcelReaderOptions.Default;
+ return new XlsbReader(ZipIndex.Create(data, effectiveOptions), effectiveOptions);
}
- internal static ValueTask CreateAsync(Stream stream, bool leaveOpen, ExcelReaderOptions? options = null, CancellationToken ct = default)
+ internal static XlsbReader CreateFromIndex(ZipIndex zip, ExcelReaderOptions options)
{
- ExcelReaderOptions effectiveOptions = options ?? ExcelReaderOptions.Default;
- DecompressedByteCounter decompressedBytes = new(effectiveOptions.MaxTotalDecompressedBytes);
- return ZipReaderOpen.OpenAsync(stream, leaveOpen, effectiveOptions,
- zip => ParseAsync(stream, leaveOpen, zip, effectiveOptions, decompressedBytes, ct), ct);
+ return new XlsbReader(zip, options);
}
- internal static ValueTask CreateFromOpenZipAsync(
- Stream stream, bool leaveOpen, ZipArchive zip, ExcelReaderOptions? options, CancellationToken ct)
+ internal static async ValueTask CreateAsync(Stream stream, bool leaveOpen, ExcelReaderOptions? options = null, CancellationToken ct = default)
{
ExcelReaderOptions effectiveOptions = options ?? ExcelReaderOptions.Default;
- DecompressedByteCounter decompressedBytes = new(effectiveOptions.MaxTotalDecompressedBytes);
- return ZipReaderOpen.FromOpenZipAsync(stream, leaveOpen, zip, effectiveOptions,
- z => ParseAsync(stream, leaveOpen, z, effectiveOptions, decompressedBytes, ct));
+ ByteSource source = await ByteSource.FromStreamAsync(stream, leaveOpen, ct).ConfigureAwait(false);
+ ZipIndex zip = await ZipIndex.CreateAsync(source, effectiveOptions, ct).ConfigureAwait(false);
+ return await CreateFromIndexAsync(zip, effectiveOptions, ct).ConfigureAwait(false);
}
- private static async ValueTask ParseAsync(
- Stream stream, bool leaveOpen, ZipArchive zip, ExcelReaderOptions effectiveOptions,
- DecompressedByteCounter decompressedBytes, CancellationToken ct)
+ internal static async ValueTask CreateFromIndexAsync(ZipIndex zip, ExcelReaderOptions options, CancellationToken ct)
{
- using ZipPart wbPart = await ZipEntryBytes.ReadAsync(zip, "xl/workbook.bin", decompressedBytes, ct).ConfigureAwait(false);
- using ZipPart relsPart = await ZipEntryBytes.ReadAsync(zip, "xl/_rels/workbook.bin.rels", decompressedBytes, ct).ConfigureAwait(false);
- var sheets = XlsbWorkbook.ParseSheets(wbPart.Memory.Span, relsPart.Memory.Span);
- if (sheets.Length == 0)
+ try
{
- throw new InvalidDataException("The workbook contains no sheets.");
+ DecompressedByteCounter decompressedBytes = new(options.MaxTotalDecompressedBytes);
+ using ZipPart wbPart = await zip.OpenPartOrDefaultAsync("xl/workbook.bin"u8, decompressedBytes, ct).ConfigureAwait(false);
+ using ZipPart relsPart = await zip.OpenPartOrDefaultAsync("xl/_rels/workbook.bin.rels"u8, decompressedBytes, ct).ConfigureAwait(false);
+ (string Name, string Path, ExcelSheetVisibility Visibility)[] sheets = XlsbWorkbook.ParseSheets(wbPart.Memory.Span, relsPart.Memory.Span);
+ if (sheets.Length == 0)
+ {
+ throw new InvalidDataException("The workbook contains no sheets.");
+ }
+ using ZipPart stylesPart = await zip.OpenPartOrDefaultAsync("xl/styles.bin"u8, decompressedBytes, ct).ConfigureAwait(false);
+ bool[] styleIsDate = XlsbStyles.ParseStyleDateFlags(stylesPart.Memory.Span);
+ bool date1904 = XlsbWorkbook.ParseDate1904(wbPart.Memory.Span);
+ (byte[] flat, int[] offsets, bool pooled) = await LoadSharedStringsAsync(zip, decompressedBytes, options, ct).ConfigureAwait(false);
+ return new XlsbReader(zip, sheets, styleIsDate, date1904, flat, offsets, pooled, options, decompressedBytes);
+ }
+ catch
+ {
+ zip.Dispose();
+ throw;
}
- using ZipPart stylesPart = await ZipEntryBytes.ReadAsync(zip, "xl/styles.bin", decompressedBytes, ct).ConfigureAwait(false);
- var styleIsDate = XlsbStyles.ParseStyleDateFlags(stylesPart.Memory.Span);
- bool date1904 = XlsbWorkbook.ParseDate1904(wbPart.Memory.Span);
- var (flat, offsets) = await LoadSharedStringsAsync(zip, decompressedBytes, effectiveOptions, ct).ConfigureAwait(false);
- return new XlsbReader(stream, leaveOpen, zip, sheets, styleIsDate, date1904, flat, offsets, effectiveOptions, decompressedBytes);
}
- private (byte[] Flat, int[] Offsets) LoadSharedStrings(ZipArchive zip)
+ private static (byte[] Flat, int[] Offsets, bool Pooled) LoadSharedStrings(
+ ZipIndex zip, DecompressedByteCounter decompressedBytes, ExcelReaderOptions options)
{
- ZipArchiveEntry? entry = zip.GetEntry("xl/sharedStrings.bin");
- if (entry is null)
+ if (!zip.TryGetEntry("xl/sharedStrings.bin"u8, out ZipEntryRef entry))
{
- return ([], [0]);
+ return ([], [0], false);
}
- WorkbookLookups.ThrowIfSharedEntryTooLarge(entry.Length, _decompressedBytes, _options);
- using LimitedReadStream stream = WorkbookLookups.OpenEntryStream(entry, _decompressedBytes, _options,
- nameof(ExcelReaderOptions.MaxSharedStringBytes), _options.MaxSharedStringBytes);
- return XlsbSharedStrings.ParseStreaming(stream, entry.Length, _options);
+ WorkbookLookups.ThrowIfSharedEntryTooLarge(entry.UncompressedSize, decompressedBytes, options);
+ if (zip.HasMemory)
+ {
+ using ZipPart part = zip.OpenPart(entry, decompressedBytes,
+ nameof(ExcelReaderOptions.MaxSharedStringBytes), options.MaxSharedStringBytes);
+ (byte[] parsedFlat, int[] parsedOffsets) = XlsbSharedStrings.Parse(part.Memory.Span, options);
+ return (parsedFlat, parsedOffsets, false);
+ }
+ using LimitedReadStream stream = zip.OpenEntryStream(entry, decompressedBytes, options,
+ nameof(ExcelReaderOptions.MaxSharedStringBytes), options.MaxSharedStringBytes);
+ (byte[] flat, int[] offsets) = XlsbSharedStrings.ParseStreaming(stream, entry.UncompressedSize, options);
+ return (flat, offsets, flat.Length != 0);
}
- private static async ValueTask<(byte[] Flat, int[] Offsets)> LoadSharedStringsAsync(
- ZipArchive zip, DecompressedByteCounter decompressedBytes, ExcelReaderOptions options, CancellationToken ct)
+ private static async ValueTask<(byte[] Flat, int[] Offsets, bool Pooled)> LoadSharedStringsAsync(
+ ZipIndex zip, DecompressedByteCounter decompressedBytes, ExcelReaderOptions options, CancellationToken ct)
{
- ZipArchiveEntry? entry = zip.GetEntry("xl/sharedStrings.bin");
- if (entry is null)
+ if (zip.HasMemory || !zip.TryGetEntry("xl/sharedStrings.bin"u8, out ZipEntryRef entry))
{
- return ([], [0]);
+ return LoadSharedStrings(zip, decompressedBytes, options);
}
- WorkbookLookups.ThrowIfSharedEntryTooLarge(entry.Length, decompressedBytes, options);
- LimitedReadStream stream = await WorkbookLookups.OpenEntryStreamAsync(entry, decompressedBytes, options, ct,
+ WorkbookLookups.ThrowIfSharedEntryTooLarge(entry.UncompressedSize, decompressedBytes, options);
+ LimitedReadStream stream = await zip.OpenEntryStreamAsync(entry, decompressedBytes, options, ct,
nameof(ExcelReaderOptions.MaxSharedStringBytes), options.MaxSharedStringBytes).ConfigureAwait(false);
await using (stream.ConfigureAwait(false))
{
- return await XlsbSharedStrings.ParseStreamingAsync(stream, entry.Length, options, ct).ConfigureAwait(false);
+ (byte[] flat, int[] offsets) = await XlsbSharedStrings.ParseStreamingAsync(stream, entry.UncompressedSize, options, ct).ConfigureAwait(false);
+ return (flat, offsets, flat.Length != 0);
}
}
@@ -219,7 +210,13 @@ public void MoveToSheet(int index)
internal ReadOnlySpan SharedSpan => _sharedFlat;
- internal string?[] SharedStringCache => _sharedStringCache ??= WorkbookLookups.CreateSharedStringCache(_sharedOffsets);
+ internal string?[] SharedStringCache => Volatile.Read(ref _sharedStringCache) ?? CreateSharedStringCache();
+
+ private string?[] CreateSharedStringCache()
+ {
+ string?[] created = WorkbookLookups.CreateSharedStringCache(_sharedOffsets);
+ return Interlocked.CompareExchange(ref _sharedStringCache, created, null) ?? created;
+ }
internal bool IsDateStyle(int style)
{
@@ -229,12 +226,17 @@ internal bool IsDateStyle(int style)
///
public Enumerator GetEnumerator()
{
- if (_memZip is not null)
+ Lifetime.Acquire(this);
+ try
+ {
+ ZipEntryRef entry = WorkbookLookups.GetWorksheetEntry(_zip!, _sheets![_current].Path);
+ return new Enumerator(this, _zip!.OpenEntryStream(entry, _decompressedBytes, _options), entry.UncompressedSize);
+ }
+ catch
{
- return GetEnumeratorFromMemory();
+ Lifetime.Release();
+ throw;
}
- var entry = WorkbookLookups.GetWorksheetEntry(_zip!, _sheets![_current].Path);
- return new Enumerator(this, WorkbookLookups.OpenEntryStream(entry, _decompressedBytes, _options), entry.Length);
}
IExcelRowEnumerator IExcelRowReader.GetEnumerator()
@@ -245,11 +247,20 @@ IExcelRowEnumerator IExcelRowReader.GetEnumerator()
///
public Enumerator GetAsyncEnumerator(CancellationToken ct = default)
{
- if (_memZip is not null)
+ if (_zip!.HasMemory)
+ {
+ return GetEnumerator();
+ }
+ Lifetime.Acquire(this);
+ try
{
- return GetEnumeratorFromMemory();
+ return new Enumerator(this, WorkbookLookups.GetWorksheetEntry(_zip, _sheets![_current].Path), ct);
+ }
+ catch
+ {
+ Lifetime.Release();
+ throw;
}
- return new Enumerator(this, WorkbookLookups.GetWorksheetEntry(_zip!, _sheets![_current].Path), ct);
}
IExcelRowEnumerator IExcelRowReader.GetAsyncEnumerator(CancellationToken ct)
@@ -261,42 +272,23 @@ IExcelRowEnumerator IExcelRowReader.GetAsyncEnumerator(Canc
///
public void Dispose()
{
- if (Interlocked.Exchange(ref _disposed, 1) != 0)
- {
- return;
- }
- if (_pooledSharedFlat)
- {
- ArrayPool.Shared.Return(_sharedFlat);
- }
- _memZip?.Dispose();
- _zip?.Dispose();
- if (!_leaveOpen)
- {
- _stream?.Dispose();
- }
+ Lifetime.Close();
}
///
- public async ValueTask DisposeAsync()
+ public ValueTask DisposeAsync()
+ {
+ Lifetime.Close();
+ return ValueTask.CompletedTask;
+ }
+
+ private void ReleaseResources()
{
- if (Interlocked.Exchange(ref _disposed, 1) != 0)
- {
- return;
- }
if (_pooledSharedFlat)
{
ArrayPool.Shared.Return(_sharedFlat);
}
- _memZip?.Dispose();
- if (_zip is not null)
- {
- await _zip.DisposeAsync().ConfigureAwait(false);
- }
- if (!_leaveOpen && _stream is not null)
- {
- await _stream.DisposeAsync().ConfigureAwait(false);
- }
+ _zip?.Dispose();
}
}
diff --git a/src/ExcelReader.Core/Reader/Xlsx/XlsxReader.Enumerator.cs b/src/ExcelReader.Core/Reader/Xlsx/XlsxReader.Enumerator.cs
index 05a39cc5..9b54bf4d 100644
--- a/src/ExcelReader.Core/Reader/Xlsx/XlsxReader.Enumerator.cs
+++ b/src/ExcelReader.Core/Reader/Xlsx/XlsxReader.Enumerator.cs
@@ -1,9 +1,9 @@
using System.Buffers.Text;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
-using System.IO.Compression;
using System.Runtime.CompilerServices;
using ExcelReader.Core.Reader.Internal;
+using ExcelReader.Core.Reader.Zip;
using ExcelReader.Core.Writer.Internal;
namespace ExcelReader.Core.Reader.Xlsx
@@ -21,7 +21,7 @@ public sealed class Enumerator : PooledStreamRowEnumerator, IExcelRowEnumerator
private readonly bool[] _styleIsDate;
private int[] _sharedOffsets;
private readonly Utf8StringCache? _contentCache;
- private readonly ZipArchiveEntry? _entry;
+ private readonly ZipEntryRef _entry;
private int _nextCol;
private NsTokens? _ns;
@@ -38,30 +38,32 @@ internal Enumerator(XlsxReader reader, Stream sheet, long entryLength = 0, Cance
_styleIsDate = reader._styleIsDate;
_sharedOffsets = reader._sharedOffsets;
_contentCache = reader._options.InternStrings ? new Utf8StringCache() : null;
+ _lease = reader.Lifetime;
}
- internal Enumerator(XlsxReader reader, ZipArchiveEntry entry, CancellationToken ct)
- : base(reader._options.MaxCellBytes, nameof(ExcelReaderOptions.MaxCellBytes), WorkbookLookups.InitialBufferCapacity(entry.Length), ct)
+ internal Enumerator(XlsxReader reader, ZipEntryRef entry, CancellationToken ct)
+ : base(reader._options.MaxCellBytes, nameof(ExcelReaderOptions.MaxCellBytes), WorkbookLookups.InitialBufferCapacity(entry.UncompressedSize), ct)
{
_reader = reader;
_styleIsDate = reader._styleIsDate;
_sharedOffsets = reader._sharedOffsets;
_contentCache = reader._options.InternStrings ? new Utf8StringCache() : null;
_entry = entry;
+ _lease = reader.Lifetime;
}
private protected override Stream OpenSource()
{
_reader.EnsureSharedLoaded();
_sharedOffsets = _reader._sharedOffsets;
- return WorkbookLookups.OpenEntryStream(_entry!, _reader._decompressedBytes, _reader._options);
+ return _reader._zip.OpenEntryStream(_entry, _reader._decompressedBytes, _reader._options);
}
private protected override async ValueTask OpenSourceAsync()
{
await _reader.EnsureSharedLoadedAsync(_ct).ConfigureAwait(false);
_sharedOffsets = _reader._sharedOffsets;
- return await WorkbookLookups.OpenEntryStreamAsync(_entry!, _reader._decompressedBytes, _reader._options, _ct).ConfigureAwait(false);
+ return await _reader._zip.OpenEntryStreamAsync(_entry, _reader._decompressedBytes, _reader._options, _ct).ConfigureAwait(false);
}
///
diff --git a/src/ExcelReader.Core/Reader/Xlsx/XlsxReader.Loading.cs b/src/ExcelReader.Core/Reader/Xlsx/XlsxReader.Loading.cs
index 2d24b6a9..c7de2bb8 100644
--- a/src/ExcelReader.Core/Reader/Xlsx/XlsxReader.Loading.cs
+++ b/src/ExcelReader.Core/Reader/Xlsx/XlsxReader.Loading.cs
@@ -1,5 +1,4 @@
using System.Buffers;
-using System.IO.Compression;
using System.Text;
using ExcelReader.Core.Reader.Internal;
using ExcelReader.Core.Reader.Zip;
@@ -71,41 +70,56 @@ private static bool ParseDate1904(ReadOnlySpan src)
private void EnsureSharedLoaded()
{
- if (_sharedLoaded)
+ if (_sharedGate.IsDone)
{
return;
}
- _sharedLoaded = true;
- ZipArchiveEntry? entry = _zip!.GetEntry("xl/sharedStrings.xml");
- if (entry is null)
+ _sharedGate.Run(this, static reader => reader.LoadShared(), static reader => reader.ReturnSharedTable());
+ }
+
+ private ValueTask EnsureSharedLoadedAsync(CancellationToken ct)
+ {
+ if (_sharedGate.IsDone)
{
- return;
+ return ValueTask.CompletedTask;
}
- WorkbookLookups.ThrowIfSharedEntryTooLarge(entry.Length, _decompressedBytes, _options);
- using LimitedReadStream stream = WorkbookLookups.OpenEntryStream(entry, _decompressedBytes, _options,
- nameof(ExcelReaderOptions.MaxSharedStringBytes), _options.MaxSharedStringBytes);
- ParseSharedStreaming(stream, entry.Length);
+ return _sharedGate.RunAsync(
+ this, static (reader, token) => reader.LoadSharedAsync(token), static reader => reader.ReturnSharedTable(), ct);
}
- private async ValueTask EnsureSharedLoadedAsync(CancellationToken ct)
+ private void LoadShared()
{
- if (_sharedLoaded)
+ if (!_zip.TryGetEntry("xl/sharedStrings.xml"u8, out ZipEntryRef entry))
+ {
+ return;
+ }
+ WorkbookLookups.ThrowIfSharedEntryTooLarge(entry.UncompressedSize, _decompressedBytes, _options);
+ if (_zip.HasMemory)
{
+ using ZipPart part = _zip.OpenPart(entry, _decompressedBytes,
+ nameof(ExcelReaderOptions.MaxSharedStringBytes), _options.MaxSharedStringBytes);
+ ParseSharedFromMemory(part.Memory, entry.UncompressedSize);
return;
}
- _sharedLoaded = true;
- ZipArchiveEntry? entry = _zip!.GetEntry("xl/sharedStrings.xml");
- if (entry is null)
+ using LimitedReadStream stream = _zip.OpenEntryStream(entry, _decompressedBytes, _options,
+ nameof(ExcelReaderOptions.MaxSharedStringBytes), _options.MaxSharedStringBytes);
+ ParseSharedStreaming(stream, entry.UncompressedSize);
+ }
+
+ private async ValueTask LoadSharedAsync(CancellationToken ct)
+ {
+ if (_zip.HasMemory || !_zip.TryGetEntry("xl/sharedStrings.xml"u8, out ZipEntryRef entry))
{
+ LoadShared();
return;
}
- WorkbookLookups.ThrowIfSharedEntryTooLarge(entry.Length, _decompressedBytes, _options);
- LimitedReadStream stream = await WorkbookLookups.OpenEntryStreamAsync(
+ WorkbookLookups.ThrowIfSharedEntryTooLarge(entry.UncompressedSize, _decompressedBytes, _options);
+ LimitedReadStream stream = await _zip.OpenEntryStreamAsync(
entry, _decompressedBytes, _options, ct,
nameof(ExcelReaderOptions.MaxSharedStringBytes), _options.MaxSharedStringBytes).ConfigureAwait(false);
await using (stream.ConfigureAwait(false))
{
- await ParseSharedStreamingAsync(stream, entry.Length, ct).ConfigureAwait(false);
+ await ParseSharedStreamingAsync(stream, entry.UncompressedSize, ct).ConfigureAwait(false);
}
}
diff --git a/src/ExcelReader.Core/Reader/Xlsx/XlsxReader.Memory.cs b/src/ExcelReader.Core/Reader/Xlsx/XlsxReader.Memory.cs
index 87f1f50e..64672c4a 100644
--- a/src/ExcelReader.Core/Reader/Xlsx/XlsxReader.Memory.cs
+++ b/src/ExcelReader.Core/Reader/Xlsx/XlsxReader.Memory.cs
@@ -6,50 +6,6 @@ namespace ExcelReader.Core.Reader.Xlsx
{
public sealed partial class XlsxReader
{
- internal static XlsxReader CreateFromMemory(ReadOnlyMemory data, ExcelReaderOptions? options = null)
- {
- ExcelReaderOptions effectiveOptions = options ?? ExcelReaderOptions.Default;
- return CreateFromMemory(ZipMemoryIndex.Create(data, effectiveOptions), effectiveOptions);
- }
-
- internal static XlsxReader CreateFromMemory(ZipMemoryIndex memZip, ExcelReaderOptions effectiveOptions)
- {
- return ZipReaderOpen.FromMemory(memZip, zip => BuildFromMemory(zip, effectiveOptions));
- }
-
- private static XlsxReader BuildFromMemory(ZipMemoryIndex memZip, ExcelReaderOptions effectiveOptions)
- {
- DecompressedByteCounter decompressedBytes = new(effectiveOptions.MaxTotalDecompressedBytes);
- using ZipPart wbPart = memZip.OpenPartOrDefault("xl/workbook.xml"u8, decompressedBytes);
- using ZipPart relsPart = memZip.OpenPartOrDefault("xl/_rels/workbook.xml.rels"u8, decompressedBytes);
- (string Name, string Path, ExcelSheetVisibility Visibility)[] sheets = ParseSheets(wbPart.Memory.Span, relsPart.Memory.Span);
- if (sheets.Length == 0)
- {
- throw new InvalidDataException("The workbook contains no sheets.");
- }
- using ZipPart stylesPart = memZip.OpenPartOrDefault("xl/styles.xml"u8, decompressedBytes);
- bool[] styleIsDate = ParseStyleDateFlags(stylesPart.Memory.Span);
- bool date1904 = ParseDate1904(wbPart.Memory.Span);
- return new XlsxReader(memZip, sheets, styleIsDate, date1904, effectiveOptions, decompressedBytes);
- }
-
- private void EnsureSharedLoadedFromMemory()
- {
- if (_sharedLoaded)
- {
- return;
- }
- _sharedLoaded = true;
- if (!_memZip!.TryGetEntry("xl/sharedStrings.xml"u8, out ZipEntryRef entry))
- {
- return;
- }
- WorkbookLookups.ThrowIfSharedEntryTooLarge(entry.UncompressedSize, _decompressedBytes, _options);
- using ZipPart part = _memZip.OpenPart(entry, _decompressedBytes,
- nameof(ExcelReaderOptions.MaxSharedStringBytes), _options.MaxSharedStringBytes);
- ParseSharedFromMemory(part.Memory, entry.UncompressedSize);
- }
-
private void ParseSharedFromMemory(ReadOnlyMemory content, long entryLength)
{
LimitChecks.ThrowIfEntryLengthExceeds(entryLength, Array.MaxLength, "ArrayMaxLength");
@@ -59,11 +15,5 @@ private void ParseSharedFromMemory(ReadOnlyMemory content, long entryLengt
_sharedFlat = ArrayPool.Shared.Rent(Math.Max(1, partLength));
_sharedOffsets = ParseSharedBody(io, stream: null, partLength);
}
-
- private Enumerator GetEnumeratorFromMemory()
- {
- ZipEntryRef entry = WorkbookLookups.GetWorksheetEntry(_memZip!, _sheets[_current].Path);
- return new Enumerator(this, _memZip!.OpenEntryStream(entry, _decompressedBytes, _options), entry.UncompressedSize);
- }
}
}
diff --git a/src/ExcelReader.Core/Reader/Xlsx/XlsxReader.cs b/src/ExcelReader.Core/Reader/Xlsx/XlsxReader.cs
index 5c9c9792..65f65499 100644
--- a/src/ExcelReader.Core/Reader/Xlsx/XlsxReader.cs
+++ b/src/ExcelReader.Core/Reader/Xlsx/XlsxReader.cs
@@ -1,6 +1,7 @@
using System.Buffers;
-using System.IO.Compression;
+using System.Diagnostics.CodeAnalysis;
using ExcelReader.Core.Reader.Internal;
+using ExcelReader.Core.Reader.Sources;
using ExcelReader.Core.Reader.Zip;
namespace ExcelReader.Core.Reader.Xlsx
@@ -8,64 +9,57 @@ namespace ExcelReader.Core.Reader.Xlsx
/// Reads rows from an Office Open XML (.xlsx) workbook, streaming each sheet's cells without loading the whole file into memory.
public sealed partial class XlsxReader : IExcelRowReader, IExcelRowReader
{
- private readonly Stream? _stream;
- private readonly bool _leaveOpen;
- private readonly ZipArchive? _zip;
- private readonly ZipMemoryIndex? _memZip;
+ private readonly ZipIndex _zip;
private readonly ExcelReaderOptions _options;
private readonly DecompressedByteCounter _decompressedBytes;
private readonly (string Name, string Path, ExcelSheetVisibility Visibility)[] _sheets;
private readonly bool[] _styleIsDate;
private int _current;
+ [SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", Justification = "Disposed by ReleaseResources, which the lifetime runs after the last enumerator.")]
+ private readonly OnceGate _sharedGate = new();
private byte[] _sharedFlat = [];
private int[] _sharedOffsets = [0];
- private bool _sharedLoaded;
private string?[]? _sharedStringCache;
+ internal ReaderLifetime Lifetime { get; }
+
internal XlsxReader(Stream stream, bool leaveOpen, ExcelReaderOptions? options = null)
- : this(stream, leaveOpen, ZipReaderOpen.Open(stream, leaveOpen), options)
+ : this(ZipIndex.Create(ByteSource.FromStream(stream, leaveOpen), options ?? ExcelReaderOptions.Default), options ?? ExcelReaderOptions.Default)
{
}
- internal XlsxReader(Stream stream, bool leaveOpen, ZipArchive zip, ExcelReaderOptions? options = null)
+ private XlsxReader(ZipIndex zip, ExcelReaderOptions options)
{
- _stream = stream;
- _leaveOpen = leaveOpen;
- _options = options ?? ExcelReaderOptions.Default;
- _decompressedBytes = new DecompressedByteCounter(_options.MaxTotalDecompressedBytes);
+ Lifetime = new ReaderLifetime(ReleaseResources);
_zip = zip;
+ _options = options;
+ _decompressedBytes = new DecompressedByteCounter(options.MaxTotalDecompressedBytes);
try
{
- LimitChecks.ThrowIfTooManyEntries(_zip.Entries.Count, _options);
- using ZipPart wbPart = ZipEntryBytes.Read(_zip, "xl/workbook.xml", _decompressedBytes);
- using ZipPart relsPart = ZipEntryBytes.Read(_zip, "xl/_rels/workbook.xml.rels", _decompressedBytes);
+ using ZipPart wbPart = zip.OpenPartOrDefault("xl/workbook.xml"u8, _decompressedBytes);
+ using ZipPart relsPart = zip.OpenPartOrDefault("xl/_rels/workbook.xml.rels"u8, _decompressedBytes);
_sheets = ParseSheets(wbPart.Memory.Span, relsPart.Memory.Span);
if (_sheets.Length == 0)
{
throw new InvalidDataException("The workbook contains no sheets.");
}
- using ZipPart stylesPart = ZipEntryBytes.Read(_zip, "xl/styles.xml", _decompressedBytes);
+ using ZipPart stylesPart = zip.OpenPartOrDefault("xl/styles.xml"u8, _decompressedBytes);
_styleIsDate = ParseStyleDateFlags(stylesPart.Memory.Span);
IsDate1904 = ParseDate1904(wbPart.Memory.Span);
}
catch
{
- _zip.Dispose();
- if (!leaveOpen)
- {
- stream.Dispose();
- }
+ zip.Dispose();
throw;
}
}
- private XlsxReader(Stream stream, bool leaveOpen, ZipArchive zip,
+ private XlsxReader(ZipIndex zip,
(string Name, string Path, ExcelSheetVisibility Visibility)[] sheets, bool[] styleIsDate, bool date1904,
ExcelReaderOptions options, DecompressedByteCounter decompressedBytes)
{
- _stream = stream;
- _leaveOpen = leaveOpen;
+ Lifetime = new ReaderLifetime(ReleaseResources);
_zip = zip;
_options = options;
_decompressedBytes = decompressedBytes;
@@ -74,51 +68,47 @@ private XlsxReader(Stream stream, bool leaveOpen, ZipArchive zip,
IsDate1904 = date1904;
}
- private XlsxReader(ZipMemoryIndex memZip,
- (string Name, string Path, ExcelSheetVisibility Visibility)[] sheets, bool[] styleIsDate, bool date1904,
- ExcelReaderOptions options, DecompressedByteCounter decompressedBytes)
+ internal static XlsxReader CreateFromMemory(ReadOnlyMemory data, ExcelReaderOptions? options = null)
{
- _leaveOpen = true;
- _memZip = memZip;
- _options = options;
- _decompressedBytes = decompressedBytes;
- _sheets = sheets;
- _styleIsDate = styleIsDate;
- IsDate1904 = date1904;
+ ExcelReaderOptions effectiveOptions = options ?? ExcelReaderOptions.Default;
+ return new XlsxReader(ZipIndex.Create(data, effectiveOptions), effectiveOptions);
}
- internal static ValueTask CreateAsync(Stream stream, bool leaveOpen, ExcelReaderOptions? options = null, CancellationToken ct = default)
+ internal static XlsxReader CreateFromIndex(ZipIndex zip, ExcelReaderOptions options)
{
- ExcelReaderOptions effectiveOptions = options ?? ExcelReaderOptions.Default;
- DecompressedByteCounter decompressedBytes = new(effectiveOptions.MaxTotalDecompressedBytes);
- return ZipReaderOpen.OpenAsync(stream, leaveOpen, effectiveOptions,
- zip => ParseAsync(stream, leaveOpen, zip, effectiveOptions, decompressedBytes, ct), ct);
+ return new XlsxReader(zip, options);
}
- internal static ValueTask CreateFromOpenZipAsync(
- Stream stream, bool leaveOpen, ZipArchive zip, ExcelReaderOptions? options, CancellationToken ct)
+ internal static async ValueTask CreateAsync(Stream stream, bool leaveOpen, ExcelReaderOptions? options = null, CancellationToken ct = default)
{
ExcelReaderOptions effectiveOptions = options ?? ExcelReaderOptions.Default;
- DecompressedByteCounter decompressedBytes = new(effectiveOptions.MaxTotalDecompressedBytes);
- return ZipReaderOpen.FromOpenZipAsync(stream, leaveOpen, zip, effectiveOptions,
- z => ParseAsync(stream, leaveOpen, z, effectiveOptions, decompressedBytes, ct));
+ ByteSource source = await ByteSource.FromStreamAsync(stream, leaveOpen, ct).ConfigureAwait(false);
+ ZipIndex zip = await ZipIndex.CreateAsync(source, effectiveOptions, ct).ConfigureAwait(false);
+ return await CreateFromIndexAsync(zip, effectiveOptions, ct).ConfigureAwait(false);
}
- private static async ValueTask ParseAsync(
- Stream stream, bool leaveOpen, ZipArchive zip, ExcelReaderOptions effectiveOptions,
- DecompressedByteCounter decompressedBytes, CancellationToken ct)
+ internal static async ValueTask CreateFromIndexAsync(ZipIndex zip, ExcelReaderOptions options, CancellationToken ct)
{
- using ZipPart wbPart = await ZipEntryBytes.ReadAsync(zip, "xl/workbook.xml", decompressedBytes, ct).ConfigureAwait(false);
- using ZipPart relsPart = await ZipEntryBytes.ReadAsync(zip, "xl/_rels/workbook.xml.rels", decompressedBytes, ct).ConfigureAwait(false);
- var sheets = ParseSheets(wbPart.Memory.Span, relsPart.Memory.Span);
- if (sheets.Length == 0)
+ try
{
- throw new InvalidDataException("The workbook contains no sheets.");
+ DecompressedByteCounter decompressedBytes = new(options.MaxTotalDecompressedBytes);
+ using ZipPart wbPart = await zip.OpenPartOrDefaultAsync("xl/workbook.xml"u8, decompressedBytes, ct).ConfigureAwait(false);
+ using ZipPart relsPart = await zip.OpenPartOrDefaultAsync("xl/_rels/workbook.xml.rels"u8, decompressedBytes, ct).ConfigureAwait(false);
+ (string Name, string Path, ExcelSheetVisibility Visibility)[] sheets = ParseSheets(wbPart.Memory.Span, relsPart.Memory.Span);
+ if (sheets.Length == 0)
+ {
+ throw new InvalidDataException("The workbook contains no sheets.");
+ }
+ using ZipPart stylesPart = await zip.OpenPartOrDefaultAsync("xl/styles.xml"u8, decompressedBytes, ct).ConfigureAwait(false);
+ bool[] styleIsDate = ParseStyleDateFlags(stylesPart.Memory.Span);
+ bool date1904 = ParseDate1904(wbPart.Memory.Span);
+ return new XlsxReader(zip, sheets, styleIsDate, date1904, options, decompressedBytes);
+ }
+ catch
+ {
+ zip.Dispose();
+ throw;
}
- using ZipPart stylesPart = await ZipEntryBytes.ReadAsync(zip, "xl/styles.xml", decompressedBytes, ct).ConfigureAwait(false);
- var styleIsDate = ParseStyleDateFlags(stylesPart.Memory.Span);
- bool date1904 = ParseDate1904(wbPart.Memory.Span);
- return new XlsxReader(stream, leaveOpen, zip, sheets, styleIsDate, date1904, effectiveOptions, decompressedBytes);
}
///
@@ -168,14 +158,18 @@ public void MoveToSheet(int index)
///
public Enumerator GetEnumerator()
{
- if (_memZip is not null)
+ Lifetime.Acquire(this);
+ try
+ {
+ EnsureSharedLoaded();
+ ZipEntryRef entry = WorkbookLookups.GetWorksheetEntry(_zip, _sheets[_current].Path);
+ return new Enumerator(this, _zip.OpenEntryStream(entry, _decompressedBytes, _options), entry.UncompressedSize);
+ }
+ catch
{
- EnsureSharedLoadedFromMemory();
- return GetEnumeratorFromMemory();
+ Lifetime.Release();
+ throw;
}
- EnsureSharedLoaded();
- var entry = WorkbookLookups.GetWorksheetEntry(_zip!, _sheets[_current].Path);
- return new Enumerator(this, WorkbookLookups.OpenEntryStream(entry, _decompressedBytes, _options), entry.Length);
}
IExcelRowEnumerator IExcelRowReader.GetEnumerator()
@@ -186,12 +180,20 @@ IExcelRowEnumerator IExcelRowReader.GetEnumerator()
///
public Enumerator GetAsyncEnumerator(CancellationToken ct = default)
{
- if (_memZip is not null)
+ if (_zip.HasMemory)
{
- EnsureSharedLoadedFromMemory();
- return GetEnumeratorFromMemory();
+ return GetEnumerator();
+ }
+ Lifetime.Acquire(this);
+ try
+ {
+ return new Enumerator(this, WorkbookLookups.GetWorksheetEntry(_zip, _sheets[_current].Path), ct);
+ }
+ catch
+ {
+ Lifetime.Release();
+ throw;
}
- return new Enumerator(this, WorkbookLookups.GetWorksheetEntry(_zip!, _sheets[_current].Path), ct);
}
IExcelRowEnumerator IExcelRowReader.GetAsyncEnumerator(CancellationToken ct)
@@ -201,41 +203,42 @@ IExcelRowEnumerator IExcelRowReader.GetAsyncEnumerator(Canc
internal ReadOnlySpan SharedSpan => _sharedFlat;
- internal string?[] SharedStringCache => _sharedStringCache ??= WorkbookLookups.CreateSharedStringCache(_sharedOffsets);
+ internal string?[] SharedStringCache => Volatile.Read(ref _sharedStringCache) ?? CreateSharedStringCache();
+
+ private string?[] CreateSharedStringCache()
+ {
+ string?[] created = WorkbookLookups.CreateSharedStringCache(_sharedOffsets);
+ return Interlocked.CompareExchange(ref _sharedStringCache, created, null) ?? created;
+ }
///
public void Dispose()
{
- if (_sharedFlat.Length > 0)
- {
- ArrayPool.Shared.Return(_sharedFlat);
- _sharedFlat = [];
- }
- _memZip?.Dispose();
- _zip?.Dispose();
- if (!_leaveOpen)
- {
- _stream?.Dispose();
- }
+ Lifetime.Close();
}
///
- public async ValueTask DisposeAsync()
+ public ValueTask DisposeAsync()
+ {
+ Lifetime.Close();
+ return ValueTask.CompletedTask;
+ }
+
+ private void ReleaseResources()
+ {
+ ReturnSharedTable();
+ _sharedGate.Dispose();
+ _zip.Dispose();
+ }
+
+ private void ReturnSharedTable()
{
if (_sharedFlat.Length > 0)
{
ArrayPool.Shared.Return(_sharedFlat);
- _sharedFlat = [];
- }
- _memZip?.Dispose();
- if (_zip is not null)
- {
- await _zip.DisposeAsync().ConfigureAwait(false);
- }
- if (!_leaveOpen && _stream is not null)
- {
- await _stream.DisposeAsync().ConfigureAwait(false);
}
+ _sharedFlat = [];
+ _sharedOffsets = [0];
}
}
}
diff --git a/src/ExcelReader.Core/Reader/Zip/LimitedReadStream.cs b/src/ExcelReader.Core/Reader/Zip/LimitedReadStream.cs
index 8bfb9641..185a25ba 100644
--- a/src/ExcelReader.Core/Reader/Zip/LimitedReadStream.cs
+++ b/src/ExcelReader.Core/Reader/Zip/LimitedReadStream.cs
@@ -18,15 +18,14 @@ internal void Add(long bytes)
{
return;
}
- long total = checked(_total + bytes);
- if (total > _limit)
+ long total = Interlocked.Add(ref _total, bytes);
+ if (total > _limit || total < 0)
{
- throw new ExcelLimitExceededException(_limitName, _limit, total);
+ throw new ExcelLimitExceededException(_limitName, _limit, total < 0 ? long.MaxValue : total);
}
- _total = total;
}
- internal long Remaining => _limit <= 0 ? long.MaxValue : _limit - _total;
+ internal long Remaining => _limit <= 0 ? long.MaxValue : _limit - Interlocked.Read(ref _total);
}
internal sealed class LimitedReadStream : Stream
diff --git a/src/ExcelReader.Core/Reader/Zip/ZipEntryBytes.cs b/src/ExcelReader.Core/Reader/Zip/ZipEntryBytes.cs
deleted file mode 100644
index 270c9557..00000000
--- a/src/ExcelReader.Core/Reader/Zip/ZipEntryBytes.cs
+++ /dev/null
@@ -1,121 +0,0 @@
-using System.Buffers;
-using System.IO.Compression;
-using ExcelReader.Core.Reader.Internal;
-
-namespace ExcelReader.Core.Reader.Zip
-{
- internal static class ZipEntryBytes
- {
- private static void ThrowIfEntryLengthExceedsLimits(
- ZipArchiveEntry entry, DecompressedByteCounter counter, string entryLimitName, long entryLimit)
- {
- LimitChecks.ThrowIfEntryLengthExceeds(entry.Length, counter.Remaining,
- nameof(ExcelReaderOptions.MaxTotalDecompressedBytes));
- if (entryLimit > 0)
- {
- LimitChecks.ThrowIfEntryLengthExceeds(entry.Length, entryLimit, entryLimitName);
- }
- }
-
- internal static ZipPart Read(
- ZipArchive zip,
- string name,
- DecompressedByteCounter counter,
- string entryLimitName = "",
- long entryLimit = 0)
- {
- ZipArchiveEntry? entry = zip.GetEntry(name);
- return entry is null ? default : Read(entry, counter, entryLimitName, entryLimit);
- }
-
- internal static ZipPart Read(
- ZipArchiveEntry entry,
- DecompressedByteCounter counter,
- string entryLimitName = "",
- long entryLimit = 0)
- {
- ThrowIfEntryLengthExceedsLimits(entry, counter, entryLimitName, entryLimit);
- using var stream = new LimitedReadStream(entry.Open(), counter, entryLimitName, entryLimit);
- int length = checked((int)entry.Length);
- byte[] rented = ArrayPool.Shared.Rent(length);
- try
- {
- ReadExactlyChecked(stream, rented.AsSpan(0, length));
- }
- catch
- {
- ArrayPool.Shared.Return(rented);
- throw;
- }
- return new ZipPart(rented.AsMemory(0, length), rented);
- }
-
- private static void ReadExactlyChecked(Stream stream, Span destination)
- {
- try
- {
- stream.ReadExactly(destination);
- }
- catch (EndOfStreamException ex)
- {
- throw new InvalidDataException("The ZIP entry produced less data than its declared uncompressed size.", ex);
- }
- }
-
- internal static async ValueTask ReadAsync(
- ZipArchive zip,
- string name,
- DecompressedByteCounter counter,
- CancellationToken ct,
- string entryLimitName = "",
- long entryLimit = 0)
- {
- ZipArchiveEntry? entry = zip.GetEntry(name);
- if (entry is null)
- {
- return default;
- }
- return await ReadAsync(entry, counter, ct, entryLimitName, entryLimit).ConfigureAwait(false);
- }
-
- internal static async ValueTask ReadAsync(
- ZipArchiveEntry entry,
- DecompressedByteCounter counter,
- CancellationToken ct,
- string entryLimitName = "",
- long entryLimit = 0)
- {
- ThrowIfEntryLengthExceedsLimits(entry, counter, entryLimitName, entryLimit);
- Stream opened = await entry.OpenAsync(ct).ConfigureAwait(false);
- var stream = new LimitedReadStream(opened, counter, entryLimitName, entryLimit);
- await using (stream.ConfigureAwait(false))
- {
- int length = checked((int)entry.Length);
- byte[] rented = ArrayPool.Shared.Rent(length);
- try
- {
- await ReadExactlyCheckedAsync(stream, rented.AsMemory(0, length), ct).ConfigureAwait(false);
- }
- catch
- {
- ArrayPool.Shared.Return(rented);
- throw;
- }
- return new ZipPart(rented.AsMemory(0, length), rented);
- }
- }
-
- private static async ValueTask ReadExactlyCheckedAsync(Stream stream, Memory destination, CancellationToken ct)
- {
- try
- {
- await stream.ReadExactlyAsync(destination, ct).ConfigureAwait(false);
- }
- catch (EndOfStreamException ex)
- {
- throw new InvalidDataException("The ZIP entry produced less data than its declared uncompressed size.", ex);
- }
- }
-
- }
-}
diff --git a/src/ExcelReader.Core/Reader/Zip/ZipMemoryIndex.cs b/src/ExcelReader.Core/Reader/Zip/ZipIndex.cs
similarity index 50%
rename from src/ExcelReader.Core/Reader/Zip/ZipMemoryIndex.cs
rename to src/ExcelReader.Core/Reader/Zip/ZipIndex.cs
index 5ecb9d47..cb6b225d 100644
--- a/src/ExcelReader.Core/Reader/Zip/ZipMemoryIndex.cs
+++ b/src/ExcelReader.Core/Reader/Zip/ZipIndex.cs
@@ -3,6 +3,7 @@
using System.IO.Compression;
using System.Runtime.InteropServices;
using ExcelReader.Core.Reader.Internal;
+using ExcelReader.Core.Reader.Sources;
namespace ExcelReader.Core.Reader.Zip
{
@@ -40,7 +41,7 @@ public void Dispose()
}
}
- internal sealed class ZipMemoryIndex : IDisposable
+ internal sealed class ZipIndex : IDisposable
{
private const int EocdFixedSize = 22;
private const int Zip64LocatorSize = 20;
@@ -57,50 +58,195 @@ internal sealed class ZipMemoryIndex : IDisposable
private static ReadOnlySpan EocdSignatureBytes => [0x50, 0x4B, 0x05, 0x06];
+ private const int TailWindowSize = 65535 + EocdFixedSize + Zip64LocatorSize;
+
+ private readonly ByteSource _source;
private readonly ReadOnlyMemory _file;
+ private readonly ReadOnlyMemory _directory;
+ private readonly byte[]? _directoryRented;
private readonly ZipEntryRef[] _entries;
- private bool _disposed;
+ private int _disposed;
- private ZipMemoryIndex(ReadOnlyMemory file, ZipEntryRef[] entries, int count)
+ private ZipIndex(ByteSource source, ReadOnlyMemory directory, byte[]? directoryRented, ZipEntryRef[] entries, int count)
{
- _file = file;
+ _source = source;
+ HasMemory = source.TryGetMemory(out _file);
+ _directory = directory;
+ _directoryRented = directoryRented;
_entries = entries;
Count = count;
}
internal int Count { get; }
- internal static ZipMemoryIndex Create(ReadOnlyMemory file, ExcelReaderOptions options)
+ internal bool HasMemory { get; }
+
+ internal static ZipIndex Create(ReadOnlyMemory file, ExcelReaderOptions options)
{
- ReadOnlySpan span = file.Span;
- long eocdOffset = FindEocd(span);
- (long cdOffset, long cdSize, long declaredCount) = ReadEocdRecord(span, eocdOffset, out long zip64EocdOffset);
- if (zip64EocdOffset >= 0)
+ return Create(ByteSource.FromMemory(file), options);
+ }
+
+ internal static ZipIndex Create(ByteSource source, ExcelReaderOptions options)
+ {
+ byte[]? directoryRented = null;
+ try
{
- (cdOffset, cdSize, declaredCount) = ReadZip64Eocd(span, zip64EocdOffset);
+ long length = source.Length;
+ int tailSize = (int)Math.Min(length, TailWindowSize);
+ long cdOffset;
+ long cdSize;
+ long declaredCount;
+ long zip64EocdOffset;
+ if (source.TryGetMemory(out ReadOnlyMemory file))
+ {
+ (cdOffset, cdSize, declaredCount, zip64EocdOffset) = ParseTail(file.Span[(file.Length - tailSize)..]);
+ }
+ else
+ {
+ byte[] tail = ArrayPool.Shared.Rent(Math.Max(1, tailSize));
+ try
+ {
+ source.ReadExactly(length - tailSize, tail.AsSpan(0, tailSize));
+ (cdOffset, cdSize, declaredCount, zip64EocdOffset) = ParseTail(tail.AsSpan(0, tailSize));
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(tail);
+ }
+ }
+ if (zip64EocdOffset >= 0)
+ {
+ ThrowIfZip64RecordOutOfRange(zip64EocdOffset, length);
+ Span record = stackalloc byte[Zip64EocdFixedSize];
+ source.ReadExactly(zip64EocdOffset, record);
+ (cdOffset, cdSize, declaredCount) = ReadZip64Eocd(record, 0);
+ }
+ ThrowIfDirectoryOutOfRange(cdOffset, cdSize, length);
+
+ ReadOnlyMemory directory;
+ if (source.TryGetMemory(out file))
+ {
+ directory = file.Slice((int)cdOffset, (int)cdSize);
+ }
+ else
+ {
+ directoryRented = ArrayPool.Shared.Rent(Math.Max(1, (int)cdSize));
+ source.ReadExactly(cdOffset, directoryRented.AsSpan(0, (int)cdSize));
+ directory = directoryRented.AsMemory(0, (int)cdSize);
+ }
+ return Build(source, directory, directoryRented, declaredCount, options);
}
- if (cdOffset < 0 || cdSize < 0 || cdOffset > span.Length - cdSize)
+ catch
+ {
+ if (directoryRented is not null)
+ {
+ ArrayPool.Shared.Return(directoryRented);
+ }
+ source.Dispose();
+ throw;
+ }
+ }
+
+ internal static async ValueTask CreateAsync(ByteSource source, ExcelReaderOptions options, CancellationToken ct)
+ {
+ if (source.TryGetMemory(out _))
+ {
+ return Create(source, options);
+ }
+ byte[]? directoryRented = null;
+ try
+ {
+ long length = source.Length;
+ int tailSize = (int)Math.Min(length, TailWindowSize);
+ long cdOffset;
+ long cdSize;
+ long declaredCount;
+ long zip64EocdOffset;
+ byte[] tail = ArrayPool.Shared.Rent(Math.Max(1, tailSize));
+ try
+ {
+ await source.ReadExactlyAsync(length - tailSize, tail.AsMemory(0, tailSize), ct).ConfigureAwait(false);
+ (cdOffset, cdSize, declaredCount, zip64EocdOffset) = ParseTail(tail.AsSpan(0, tailSize));
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(tail);
+ }
+ if (zip64EocdOffset >= 0)
+ {
+ ThrowIfZip64RecordOutOfRange(zip64EocdOffset, length);
+ byte[] record = ArrayPool.Shared.Rent(Zip64EocdFixedSize);
+ try
+ {
+ await source.ReadExactlyAsync(zip64EocdOffset, record.AsMemory(0, Zip64EocdFixedSize), ct).ConfigureAwait(false);
+ (cdOffset, cdSize, declaredCount) = ReadZip64Eocd(record.AsSpan(0, Zip64EocdFixedSize), 0);
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(record);
+ }
+ }
+ ThrowIfDirectoryOutOfRange(cdOffset, cdSize, length);
+ directoryRented = ArrayPool.Shared.Rent(Math.Max(1, (int)cdSize));
+ await source.ReadExactlyAsync(cdOffset, directoryRented.AsMemory(0, (int)cdSize), ct).ConfigureAwait(false);
+ return Build(source, directoryRented.AsMemory(0, (int)cdSize), directoryRented, declaredCount, options);
+ }
+ catch
+ {
+ if (directoryRented is not null)
+ {
+ ArrayPool.Shared.Return(directoryRented);
+ }
+ source.Dispose();
+ throw;
+ }
+ }
+
+ private static (long CdOffset, long CdSize, long Count, long Zip64EocdOffset) ParseTail(ReadOnlySpan tail)
+ {
+ long eocdOffset = FindEocd(tail);
+ (long cdOffset, long cdSize, long declaredCount) = ReadEocdRecord(tail, eocdOffset, out long zip64EocdOffset);
+ return (cdOffset, cdSize, declaredCount, zip64EocdOffset);
+ }
+
+ private static void ThrowIfZip64RecordOutOfRange(long offset, long length)
+ {
+ if (offset < 0 || offset > length - Zip64EocdFixedSize)
+ {
+ throw new InvalidDataException("The ZIP64 end of central directory record is out of range.");
+ }
+ }
+
+ private static void ThrowIfDirectoryOutOfRange(long cdOffset, long cdSize, long length)
+ {
+ if (cdOffset < 0 || cdSize < 0 || cdOffset > length - cdSize)
{
throw new InvalidDataException("The ZIP central directory is out of range.");
}
+ if (cdSize > Array.MaxLength)
+ {
+ throw new ExcelLimitExceededException("ArrayMaxLength", Array.MaxLength, cdSize);
+ }
+ }
+ private static ZipIndex Build(ByteSource source, ReadOnlyMemory directory, byte[]? directoryRented, long declaredCount, ExcelReaderOptions options)
+ {
long maxHint = Math.Max(16, options.MaxZipEntries > 0 ? options.MaxZipEntries : 65_536);
ZipEntryRef[] entries = ArrayPool.Shared.Rent((int)Math.Clamp(declaredCount, 16, maxHint));
- int count;
try
{
- count = WalkCentralDirectory(span, cdOffset, cdSize, ref entries, options);
- ThrowIfDuplicateEntryNames(file, entries, count);
+ int count = WalkCentralDirectory(directory.Span, ref entries, options);
+ ThrowIfDuplicateEntryNames(directory, entries, count);
+ return new ZipIndex(source, directory, directoryRented, entries, count);
}
catch
{
ArrayPool.Shared.Return(entries);
throw;
}
- return new ZipMemoryIndex(file, entries, count);
}
- private static void ThrowIfDuplicateEntryNames(ReadOnlyMemory file, ZipEntryRef[] entries, int count)
+ private static void ThrowIfDuplicateEntryNames(ReadOnlyMemory directory, ZipEntryRef[] entries, int count)
{
if (count <= 1)
{
@@ -115,18 +261,18 @@ private static void ThrowIfDuplicateEntryNames(ReadOnlyMemory file, ZipEnt
}
Array.Sort(order, 0, count, Comparer.Create((a, b) =>
{
- ReadOnlySpan span = file.Span;
+ ReadOnlySpan span = directory.Span;
ref readonly ZipEntryRef ea = ref entries[a];
ref readonly ZipEntryRef eb = ref entries[b];
return span.Slice(ea.NameStart, ea.NameLength).SequenceCompareTo(span.Slice(eb.NameStart, eb.NameLength));
}));
- ReadOnlySpan fileSpan = file.Span;
+ ReadOnlySpan directorySpan = directory.Span;
for (int i = 1; i < count; i++)
{
ref readonly ZipEntryRef prev = ref entries[order[i - 1]];
ref readonly ZipEntryRef curr = ref entries[order[i]];
if (prev.NameLength == curr.NameLength &&
- fileSpan.Slice(prev.NameStart, prev.NameLength).SequenceEqual(fileSpan.Slice(curr.NameStart, curr.NameLength)))
+ directorySpan.Slice(prev.NameStart, prev.NameLength).SequenceEqual(directorySpan.Slice(curr.NameStart, curr.NameLength)))
{
throw new InvalidDataException("The ZIP central directory contains a duplicate entry name.");
}
@@ -138,12 +284,18 @@ private static void ThrowIfDuplicateEntryNames(ReadOnlyMemory file, ZipEnt
}
}
+ private void ThrowIfDisposed()
+ {
+ ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
+ }
+
internal bool TryGetEntry(ReadOnlySpan utf8Name, out ZipEntryRef entry)
{
- ReadOnlySpan fileSpan = _file.Span;
+ ThrowIfDisposed();
+ ReadOnlySpan directory = _directory.Span;
foreach (ref readonly ZipEntryRef candidate in _entries.AsSpan(0, Count))
{
- if (fileSpan.Slice(candidate.NameStart, candidate.NameLength).SequenceEqual(utf8Name))
+ if (directory.Slice(candidate.NameStart, candidate.NameLength).SequenceEqual(utf8Name))
{
entry = candidate;
return true;
@@ -154,6 +306,106 @@ internal bool TryGetEntry(ReadOnlySpan utf8Name, out ZipEntryRef entry)
}
internal ZipPart OpenPart(in ZipEntryRef entry, DecompressedByteCounter counter, string entryLimitName = "", long entryLimit = 0)
+ {
+ ThrowIfDisposed();
+ ThrowIfPartTooLarge(entry, counter, entryLimitName, entryLimit);
+ long dataOffset = ResolveDataOffset(entry);
+ ThrowIfEntryDataInvalid(dataOffset, entry);
+ ZipPart part;
+ if (HasMemory)
+ {
+ ReadOnlyMemory compressed = CompressedSlice(dataOffset, entry.CompressedSize);
+ part = entry.Method == 0 ? new ZipPart(compressed, rented: null) : InflateToPart(compressed, entry.UncompressedSize);
+ }
+ else
+ {
+ part = ReadPart(dataOffset, entry);
+ }
+ counter.Add(entry.UncompressedSize);
+ return part;
+ }
+
+ internal async ValueTask OpenPartAsync(
+ ZipEntryRef entry, DecompressedByteCounter counter, CancellationToken ct, string entryLimitName = "", long entryLimit = 0)
+ {
+ ThrowIfDisposed();
+ if (HasMemory)
+ {
+ return OpenPart(entry, counter, entryLimitName, entryLimit);
+ }
+ ThrowIfPartTooLarge(entry, counter, entryLimitName, entryLimit);
+ long dataOffset = await ResolveDataOffsetAsync(entry, ct).ConfigureAwait(false);
+ ThrowIfEntryDataInvalid(dataOffset, entry);
+ int size = (int)entry.UncompressedSize;
+ byte[] rented = ArrayPool.Shared.Rent(Math.Max(1, size));
+ try
+ {
+ ByteSourceStream raw = new(_source, dataOffset, entry.CompressedSize);
+ Stream content = entry.Method == 0 ? raw : new DeflateStream(raw, CompressionMode.Decompress);
+ await using (content.ConfigureAwait(false))
+ {
+ try
+ {
+ await content.ReadExactlyAsync(rented.AsMemory(0, size), ct).ConfigureAwait(false);
+ }
+ catch (EndOfStreamException ex)
+ {
+ throw new InvalidDataException("The ZIP entry produced less data than its declared uncompressed size.", ex);
+ }
+ if (await content.ReadAsync(new byte[1], ct).ConfigureAwait(false) != 0)
+ {
+ throw new InvalidDataException("The ZIP entry produced more data than its declared uncompressed size.");
+ }
+ }
+ }
+ catch
+ {
+ ArrayPool.Shared.Return(rented);
+ throw;
+ }
+ counter.Add(entry.UncompressedSize);
+ return new ZipPart(rented.AsMemory(0, size), rented);
+ }
+
+ internal ValueTask OpenPartOrDefaultAsync(ReadOnlySpan utf8Name, DecompressedByteCounter counter, CancellationToken ct)
+ {
+ return TryGetEntry(utf8Name, out ZipEntryRef entry) ? OpenPartAsync(entry, counter, ct) : new ValueTask(default(ZipPart));
+ }
+
+ internal ZipPart OpenPartOrDefault(ReadOnlySpan utf8Name, DecompressedByteCounter counter)
+ {
+ return TryGetEntry(utf8Name, out ZipEntryRef entry) ? OpenPart(entry, counter) : default;
+ }
+
+ internal LimitedReadStream OpenEntryStream(
+ in ZipEntryRef entry, DecompressedByteCounter counter, ExcelReaderOptions options, string entryLimitName = "", long entryLimit = 0)
+ {
+ ThrowIfDisposed();
+ long dataOffset = ResolveDataOffset(entry);
+ return WrapEntryStream(entry, dataOffset, counter, options, entryLimitName, entryLimit);
+ }
+
+ internal async ValueTask OpenEntryStreamAsync(
+ ZipEntryRef entry, DecompressedByteCounter counter, ExcelReaderOptions options, CancellationToken ct,
+ string entryLimitName = "", long entryLimit = 0)
+ {
+ ThrowIfDisposed();
+ long dataOffset = HasMemory ? ResolveDataOffset(entry) : await ResolveDataOffsetAsync(entry, ct).ConfigureAwait(false);
+ return WrapEntryStream(entry, dataOffset, counter, options, entryLimitName, entryLimit);
+ }
+
+ private LimitedReadStream WrapEntryStream(
+ in ZipEntryRef entry, long dataOffset, DecompressedByteCounter counter, ExcelReaderOptions options, string entryLimitName, long entryLimit)
+ {
+ ThrowIfEntryDataInvalid(dataOffset, entry);
+ Stream raw = HasMemory
+ ? ToReadableMemoryStream(CompressedSlice(dataOffset, entry.CompressedSize))
+ : new ByteSourceStream(_source, dataOffset, entry.CompressedSize);
+ Stream opened = entry.Method == 0 ? raw : new DeflateStream(raw, CompressionMode.Decompress);
+ return WorkbookLookups.Wrap(opened, counter, options, entryLimitName, entryLimit, entry.UncompressedSize);
+ }
+
+ private static void ThrowIfPartTooLarge(in ZipEntryRef entry, DecompressedByteCounter counter, string entryLimitName, long entryLimit)
{
LimitChecks.ThrowIfEntryLengthExceeds(entry.UncompressedSize, counter.Remaining, nameof(ExcelReaderOptions.MaxTotalDecompressedBytes));
if (entryLimit > 0)
@@ -164,61 +416,151 @@ internal ZipPart OpenPart(in ZipEntryRef entry, DecompressedByteCounter counter,
{
throw new ExcelLimitExceededException("ArrayMaxLength", Array.MaxLength, entry.UncompressedSize);
}
+ }
- ReadOnlyMemory compressed = ResolveCompressedSlice(entry);
- ZipPart part = entry.Method switch
+ private void ThrowIfEntryDataInvalid(long dataOffset, in ZipEntryRef entry)
+ {
+ long length = _source.Length;
+ if (dataOffset < 0 || dataOffset > length || entry.CompressedSize < 0 || entry.CompressedSize > length - dataOffset)
{
- 0 => new ZipPart(compressed, rented: null),
- 8 => InflateToPart(compressed, entry.UncompressedSize),
- _ => throw new NotSupportedException($"Unsupported ZIP compression method: {entry.Method}."),
- };
- counter.Add(entry.UncompressedSize);
- return part;
+ throw new InvalidDataException("The ZIP entry data runs past the end of the file.");
+ }
+ if (entry.Method is not (0 or 8))
+ {
+ throw new NotSupportedException($"Unsupported ZIP compression method: {entry.Method}.");
+ }
+ if (entry.Method == 0 && entry.CompressedSize != entry.UncompressedSize)
+ {
+ throw new InvalidDataException("The stored ZIP entry's compressed and uncompressed sizes differ.");
+ }
}
- internal ZipPart OpenPartOrDefault(ReadOnlySpan utf8Name, DecompressedByteCounter counter)
+ private ReadOnlyMemory CompressedSlice(long dataOffset, long compressedSize)
{
- return TryGetEntry(utf8Name, out ZipEntryRef entry) ? OpenPart(entry, counter) : default;
+ if (compressedSize > Array.MaxLength)
+ {
+ throw new ExcelLimitExceededException("ArrayMaxLength", Array.MaxLength, compressedSize);
+ }
+ return _file.Slice((int)dataOffset, (int)compressedSize);
}
- internal Stream OpenEntryStream(in ZipEntryRef entry, DecompressedByteCounter counter, ExcelReaderOptions options, string entryLimitName = "", long entryLimit = 0)
+ private ZipPart ReadPart(long dataOffset, in ZipEntryRef entry)
{
- ReadOnlyMemory compressed = ResolveCompressedSlice(entry);
- Stream opened = entry.Method switch
+ int size = (int)entry.UncompressedSize;
+ byte[] rented = ArrayPool.Shared.Rent(Math.Max(1, size));
+ try
{
- 0 => ToReadableMemoryStream(compressed),
- 8 => new DeflateStream(ToReadableMemoryStream(compressed), CompressionMode.Decompress),
- _ => throw new NotSupportedException($"Unsupported ZIP compression method: {entry.Method}."),
- };
- return WorkbookLookups.Wrap(opened, counter, options, entryLimitName, entryLimit, entry.UncompressedSize);
+ ByteSourceStream raw = new(_source, dataOffset, entry.CompressedSize);
+ using Stream content = entry.Method == 0 ? raw : new DeflateStream(raw, CompressionMode.Decompress);
+ try
+ {
+ content.ReadExactly(rented.AsSpan(0, size));
+ }
+ catch (EndOfStreamException ex)
+ {
+ throw new InvalidDataException("The ZIP entry produced less data than its declared uncompressed size.", ex);
+ }
+ if (content.ReadByte() != -1)
+ {
+ throw new InvalidDataException("The ZIP entry produced more data than its declared uncompressed size.");
+ }
+ return new ZipPart(rented.AsMemory(0, size), rented);
+ }
+ catch
+ {
+ ArrayPool.Shared.Return(rented);
+ throw;
+ }
}
- private ReadOnlyMemory ResolveCompressedSlice(in ZipEntryRef entry)
+ private long ResolveDataOffset(in ZipEntryRef entry)
{
- long dataOffset = ResolveDataOffset(entry);
- long fileLength = _file.Length;
- if (dataOffset < 0 || dataOffset > fileLength || entry.CompressedSize < 0 || entry.CompressedSize > fileLength - dataOffset)
+ ThrowIfLocalHeaderOutOfRange(entry.LocalHeaderOffset);
+ Span header = stackalloc byte[LocalHeaderFixedSize];
+ _source.ReadExactly(entry.LocalHeaderOffset, header);
+ long nameStart = ParseLocalHeader(header, entry, out int nameLength, out int extraLength);
+ byte[] name = ArrayPool.Shared.Rent(Math.Max(1, nameLength));
+ try
{
- throw new InvalidDataException("The ZIP entry data runs past the end of the file.");
+ _source.ReadExactly(nameStart, name.AsSpan(0, nameLength));
+ ThrowIfNameMismatch(name.AsSpan(0, nameLength), entry);
}
- if (entry.CompressedSize > Array.MaxLength)
+ finally
{
- throw new ExcelLimitExceededException("ArrayMaxLength", Array.MaxLength, entry.CompressedSize);
+ ArrayPool.Shared.Return(name);
+ }
+ return nameStart + nameLength + extraLength;
+ }
+
+ private async ValueTask ResolveDataOffsetAsync(ZipEntryRef entry, CancellationToken ct)
+ {
+ ThrowIfLocalHeaderOutOfRange(entry.LocalHeaderOffset);
+ byte[] buffer = ArrayPool.Shared.Rent(LocalHeaderFixedSize + ushort.MaxValue);
+ try
+ {
+ await _source.ReadExactlyAsync(entry.LocalHeaderOffset, buffer.AsMemory(0, LocalHeaderFixedSize), ct).ConfigureAwait(false);
+ long nameStart = ParseLocalHeader(buffer.AsSpan(0, LocalHeaderFixedSize), entry, out int nameLength, out int extraLength);
+ await _source.ReadExactlyAsync(nameStart, buffer.AsMemory(LocalHeaderFixedSize, nameLength), ct).ConfigureAwait(false);
+ ThrowIfNameMismatch(buffer.AsSpan(LocalHeaderFixedSize, nameLength), entry);
+ return nameStart + nameLength + extraLength;
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+ }
+
+ private void ThrowIfLocalHeaderOutOfRange(long headerOffset)
+ {
+ if (headerOffset < 0 || headerOffset > _source.Length - LocalHeaderFixedSize)
+ {
+ throw new InvalidDataException("The ZIP local file header is out of range.");
+ }
+ }
+
+ private long ParseLocalHeader(ReadOnlySpan header, in ZipEntryRef entry, out int nameLength, out int extraLength)
+ {
+ if (BinaryPrimitives.ReadInt32LittleEndian(header) != LocalFileHeaderSignature)
+ {
+ throw new InvalidDataException("Invalid ZIP local file header signature.");
+ }
+ nameLength = BinaryPrimitives.ReadUInt16LittleEndian(header[26..]);
+ extraLength = BinaryPrimitives.ReadUInt16LittleEndian(header[28..]);
+ long nameStart = entry.LocalHeaderOffset + LocalHeaderFixedSize;
+ if (nameLength > _source.Length - nameStart)
+ {
+ throw new InvalidDataException("The ZIP local file header name runs past the end of the file.");
+ }
+ if (nameLength != entry.NameLength)
+ {
+ throw new InvalidDataException("The ZIP local file header name does not match the central directory.");
+ }
+ return nameStart;
+ }
+
+ private void ThrowIfNameMismatch(ReadOnlySpan localName, in ZipEntryRef entry)
+ {
+ if (!localName.SequenceEqual(_directory.Span.Slice(entry.NameStart, entry.NameLength)))
+ {
+ throw new InvalidDataException("The ZIP local file header name does not match the central directory.");
}
- return _file.Slice((int)dataOffset, (int)entry.CompressedSize);
}
public void Dispose()
{
- if (_disposed)
+ if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
- _disposed = true;
if (_entries.Length > 0)
{
ArrayPool.Shared.Return(_entries);
}
+ if (_directoryRented is not null)
+ {
+ ArrayPool.Shared.Return(_directoryRented);
+ }
+ _source.Dispose();
}
private static long FindEocd(ReadOnlySpan span)
@@ -244,7 +586,7 @@ private static long FindEocd(ReadOnlySpan span)
continue;
}
int commentLength = BinaryPrimitives.ReadUInt16LittleEndian(window.Slice(index + 20, 2));
- if (index + EocdFixedSize + commentLength == window.Length)
+ if (index + EocdFixedSize + commentLength <= window.Length)
{
return windowStart + index;
}
@@ -298,11 +640,10 @@ private static (long CdOffset, long CdSize, long Count) ReadZip64Eocd(ReadOnlySp
return (cdOffset, cdSize, count);
}
- private static int WalkCentralDirectory(ReadOnlySpan span, long cdOffset, long cdSize, ref ZipEntryRef[] entries, ExcelReaderOptions options)
+ private static int WalkCentralDirectory(ReadOnlySpan directory, ref ZipEntryRef[] entries, ExcelReaderOptions options)
{
- // Create has checked cdOffset <= span.Length - cdSize, so end <= span.Length and cannot overflow.
- long end = cdOffset + cdSize;
- long pos = cdOffset;
+ int end = directory.Length;
+ int pos = 0;
int count = 0;
while (pos < end)
{
@@ -310,7 +651,7 @@ private static int WalkCentralDirectory(ReadOnlySpan span, long cdOffset,
{
throw new InvalidDataException("The ZIP central directory is truncated.");
}
- ReadOnlySpan record = span.Slice((int)pos, CentralDirectoryFixedSize);
+ ReadOnlySpan record = directory.Slice(pos, CentralDirectoryFixedSize);
if (BinaryPrimitives.ReadInt32LittleEndian(record) != CentralDirectorySignature)
{
throw new InvalidDataException("Invalid ZIP central directory record signature.");
@@ -321,21 +662,20 @@ private static int WalkCentralDirectory(ReadOnlySpan span, long cdOffset,
throw new NotSupportedException("Encrypted ZIP entries are not supported.");
}
- long nameStart = pos + CentralDirectoryFixedSize;
+ int nameStart = pos + CentralDirectoryFixedSize;
int variableLength = fields.NameLength + fields.ExtraLength + fields.CommentLength;
if (variableLength > end - nameStart)
{
throw new InvalidDataException("The ZIP central directory is truncated.");
}
- long recordEnd = nameStart + variableLength;
(long compressed, long uncompressed, long localOffset) = ResolveZip64Sizes(
- span, (int)(nameStart + fields.NameLength), fields.ExtraLength, fields);
+ directory, nameStart + fields.NameLength, fields.ExtraLength, fields);
EnsureCapacity(ref entries, count);
entries[count] = new ZipEntryRef
{
- NameStart = (int)nameStart,
+ NameStart = nameStart,
NameLength = fields.NameLength,
LocalHeaderOffset = localOffset,
CompressedSize = compressed,
@@ -345,7 +685,7 @@ private static int WalkCentralDirectory(ReadOnlySpan span, long cdOffset,
};
count++;
LimitChecks.ThrowIfTooManyEntries(count, options);
- pos = recordEnd;
+ pos = nameStart + variableLength;
}
return count;
}
@@ -448,34 +788,6 @@ private static void EnsureCapacity(ref ZipEntryRef[] entries, int count)
entries = bigger;
}
- private long ResolveDataOffset(in ZipEntryRef entry)
- {
- ReadOnlySpan fileSpan = _file.Span;
- long headerOffset = entry.LocalHeaderOffset;
- if (headerOffset < 0 || headerOffset > fileSpan.Length - LocalHeaderFixedSize)
- {
- throw new InvalidDataException("The ZIP local file header is out of range.");
- }
- ReadOnlySpan header = fileSpan.Slice((int)headerOffset, LocalHeaderFixedSize);
- if (BinaryPrimitives.ReadInt32LittleEndian(header) != LocalFileHeaderSignature)
- {
- throw new InvalidDataException("Invalid ZIP local file header signature.");
- }
- ushort nameLength = BinaryPrimitives.ReadUInt16LittleEndian(header[26..]);
- ushort extraLength = BinaryPrimitives.ReadUInt16LittleEndian(header[28..]);
- long nameStart = headerOffset + LocalHeaderFixedSize;
- if (nameLength > fileSpan.Length - nameStart)
- {
- throw new InvalidDataException("The ZIP local file header name runs past the end of the file.");
- }
- if (nameLength != entry.NameLength ||
- !fileSpan.Slice((int)nameStart, nameLength).SequenceEqual(fileSpan.Slice(entry.NameStart, entry.NameLength)))
- {
- throw new InvalidDataException("The ZIP local file header name does not match the central directory.");
- }
- return nameStart + nameLength + extraLength;
- }
-
private static ZipPart InflateToPart(ReadOnlyMemory compressed, long uncompressedSize)
{
int size = checked((int)uncompressedSize);
diff --git a/src/ExcelReader.Core/Reader/Zip/ZipReaderOpen.cs b/src/ExcelReader.Core/Reader/Zip/ZipReaderOpen.cs
deleted file mode 100644
index a9430d9e..00000000
--- a/src/ExcelReader.Core/Reader/Zip/ZipReaderOpen.cs
+++ /dev/null
@@ -1,81 +0,0 @@
-using System.IO.Compression;
-using ExcelReader.Core.Reader.Internal;
-
-namespace ExcelReader.Core.Reader.Zip
-{
- internal static class ZipReaderOpen
- {
- internal static ZipArchive Open(Stream stream, bool leaveOpen)
- {
- try
- {
- return new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true);
- }
- catch
- {
- if (!leaveOpen)
- {
- stream.Dispose();
- }
- throw;
- }
- }
-
- internal static async ValueTask OpenAsync(
- Stream stream, bool leaveOpen, ExcelReaderOptions options, Func> parseBody, CancellationToken ct = default)
- {
- ZipArchive? zip = null;
- try
- {
- zip = await ZipArchive.CreateAsync(stream, ZipArchiveMode.Read, leaveOpen: true, entryNameEncoding: null, ct).ConfigureAwait(false);
- LimitChecks.ThrowIfTooManyEntries(zip.Entries.Count, options);
- return await parseBody(zip).ConfigureAwait(false);
- }
- catch
- {
- if (zip is not null)
- {
- await zip.DisposeAsync().ConfigureAwait(false);
- }
- if (!leaveOpen)
- {
- await stream.DisposeAsync().ConfigureAwait(false);
- }
- throw;
- }
- }
-
- internal static async ValueTask FromOpenZipAsync(
- Stream stream, bool leaveOpen, ZipArchive zip, ExcelReaderOptions options,
- Func> parseBody)
- {
- try
- {
- LimitChecks.ThrowIfTooManyEntries(zip.Entries.Count, options);
- return await parseBody(zip).ConfigureAwait(false);
- }
- catch
- {
- await zip.DisposeAsync().ConfigureAwait(false);
- if (!leaveOpen)
- {
- await stream.DisposeAsync().ConfigureAwait(false);
- }
- throw;
- }
- }
-
- internal static TResult FromMemory(ZipMemoryIndex memZip, Func build)
- {
- try
- {
- return build(memZip);
- }
- catch
- {
- memZip.Dispose();
- throw;
- }
- }
- }
-}
diff --git a/src/ExcelReader.Core/Writer/Xls/BiffRecord.cs b/src/ExcelReader.Core/Writer/Xls/BiffRecord.cs
index 47e78a12..53f15481 100644
--- a/src/ExcelReader.Core/Writer/Xls/BiffRecord.cs
+++ b/src/ExcelReader.Core/Writer/Xls/BiffRecord.cs
@@ -27,6 +27,7 @@ internal static class BiffRecord
internal const int BookBool = 0x00DA;
internal const int Dimension = 0x0200;
internal const int Window2 = 0x023E;
+ internal const int Blank = 0x0201;
internal const int Number = 0x0203;
internal const int Label = 0x0204;
internal const int BoolErr = 0x0205;
diff --git a/src/ExcelReader.Core/Writer/Xls/BiffRecordWriter.cs b/src/ExcelReader.Core/Writer/Xls/BiffRecordWriter.cs
index 053c54c9..1b205a00 100644
--- a/src/ExcelReader.Core/Writer/Xls/BiffRecordWriter.cs
+++ b/src/ExcelReader.Core/Writer/Xls/BiffRecordWriter.cs
@@ -218,6 +218,13 @@ internal static void WriteWindow2(BiffBuffer buffer)
buffer.EndRecord(len);
}
+ internal static void WriteBlank(BiffBuffer buffer, int row, int col, int xf)
+ {
+ int len = buffer.BeginRecord(BiffRecord.Blank);
+ WriteCellHeader(buffer, row, col, xf);
+ buffer.EndRecord(len);
+ }
+
internal static void WriteNumber(BiffBuffer buffer, int row, int col, int xf, double value)
{
CellValueGuards.ThrowIfNonFinite(value, nameof(value));
diff --git a/src/ExcelReader.Core/Writer/Xls/XlsSheetWriter.cs b/src/ExcelReader.Core/Writer/Xls/XlsSheetWriter.cs
index 7bb20635..86517f61 100644
--- a/src/ExcelReader.Core/Writer/Xls/XlsSheetWriter.cs
+++ b/src/ExcelReader.Core/Writer/Xls/XlsSheetWriter.cs
@@ -31,6 +31,7 @@ public sealed class XlsSheetWriter : ISheetWriter
private int _rowNumber = -1;
private WriterState _state = WriterState.Created;
private bool _rowActive;
+ private bool _rowHasCells;
private Dictionary? _columnStyles;
private Dictionary? _columnWidths;
private int _activeRowStyle;
@@ -158,13 +159,20 @@ public XlsRowWriter StartRow(int styleId)
}
_activeRowStyle = styleId;
_rowActive = true;
+ _rowHasCells = false;
_rowWriter ??= new XlsRowWriter(this, _rowNumber);
_rowWriter.Reset(_rowNumber);
return _rowWriter;
}
+ /// A row left without cells gets one blank cell, since BIFF8 has no other trace of it for a reader to find.
internal void NotifyRowEnded()
{
+ if (_rowActive && !_rowHasCells)
+ {
+ BiffRecordWriter.WriteBlank(_cells, _rowNumber, 0, XlsGlobals.GeneralXf);
+ Track(_rowNumber, 0);
+ }
_rowActive = false;
}
@@ -209,6 +217,7 @@ internal void ReleaseBuffer()
private void Track(int row, int col)
{
+ _rowHasCells = true;
if (row > _maxRow) { _maxRow = row; }
if (col > _maxCol) { _maxCol = col; }
}
diff --git a/tests/ExcelReader.Tests/Arrow/ArrowWriteExtensionsTests.cs b/tests/ExcelReader.Tests/Arrow/ArrowWriteExtensionsTests.cs
index d52c5d4f..084d49f1 100644
--- a/tests/ExcelReader.Tests/Arrow/ArrowWriteExtensionsTests.cs
+++ b/tests/ExcelReader.Tests/Arrow/ArrowWriteExtensionsTests.cs
@@ -214,13 +214,147 @@ public void WriteRecordBatch_WithHeaderFalse_OmitsTheHeaderRow()
Assert.False(e.MoveNext());
}
+ private static RecordBatch BuildWideBatch()
+ {
+ var decimalType = new Decimal128Type(10, 2);
+ var secondTime = new Time32Type(TimeUnit.Second);
+
+ var i8 = new Int8Array.Builder();
+ i8.Append(-8);
+ i8.Append(7);
+ var i16 = new Int16Array.Builder();
+ i16.Append(-16);
+ i16.AppendNull();
+ var i32 = new Int32Array.Builder();
+ i32.Append(-32);
+ i32.AppendNull();
+ var u8 = new UInt8Array.Builder();
+ u8.Append(8);
+ u8.AppendNull();
+ var u16 = new UInt16Array.Builder();
+ u16.Append(16);
+ u16.AppendNull();
+ var u32 = new UInt32Array.Builder();
+ u32.Append(32);
+ u32.AppendNull();
+ var u64 = new UInt64Array.Builder();
+ u64.Append(64);
+ u64.AppendNull();
+ var half = new HalfFloatArray.Builder();
+ half.Append((Half)0.5f);
+ half.AppendNull();
+ var single = new FloatArray.Builder();
+ single.Append(0.1f);
+ single.AppendNull();
+ var dec = new Decimal128Array.Builder(decimalType);
+ dec.Append(12.34m);
+ dec.AppendNull();
+ var view = new StringViewArray.Builder();
+ view.Append("alice");
+ view.AppendNull();
+ var date = new Date64Array.Builder();
+ date.Append(new DateOnly(2024, 1, 15));
+ date.AppendNull();
+ var time = new Time32Array.Builder(secondTime);
+ time.Append(new TimeOnly(13, 30, 0));
+ time.AppendNull();
+
+ Schema schema = new Schema.Builder()
+ .Field(new Field("i8", Int8Type.Default, nullable: true))
+ .Field(new Field("i16", Int16Type.Default, nullable: true))
+ .Field(new Field("i32", Int32Type.Default, nullable: true))
+ .Field(new Field("u8", UInt8Type.Default, nullable: true))
+ .Field(new Field("u16", UInt16Type.Default, nullable: true))
+ .Field(new Field("u32", UInt32Type.Default, nullable: true))
+ .Field(new Field("u64", UInt64Type.Default, nullable: true))
+ .Field(new Field("half", HalfFloatType.Default, nullable: true))
+ .Field(new Field("single", FloatType.Default, nullable: true))
+ .Field(new Field("dec", decimalType, nullable: true))
+ .Field(new Field("view", StringViewType.Default, nullable: true))
+ .Field(new Field("date", Date64Type.Default, nullable: true))
+ .Field(new Field("time", secondTime, nullable: true))
+ .Field(new Field("nothing", NullType.Default, nullable: true))
+ .Build();
+
+ return new RecordBatch(
+ schema,
+ [
+ i8.Build(), i16.Build(), i32.Build(), u8.Build(), u16.Build(), u32.Build(), u64.Build(),
+ half.Build(), single.Build(), dec.Build(), view.Build(), date.Build(), time.Build(), new NullArray(2),
+ ],
+ 2);
+ }
+
+ private static void AssertWideRoundTrips(IExcelRowReader reader)
+ {
+ ExcelColumnType[] types =
+ [
+ ExcelColumnType.Int64Column, ExcelColumnType.Int64Column, ExcelColumnType.Int64Column,
+ ExcelColumnType.Int64Column, ExcelColumnType.Int64Column, ExcelColumnType.Int64Column,
+ ExcelColumnType.Int64Column, ExcelColumnType.Float64Column, ExcelColumnType.Float64Column,
+ ExcelColumnType.Float64Column, ExcelColumnType.StringColumn, ExcelColumnType.DateColumn,
+ ExcelColumnType.TimeColumn, ExcelColumnType.StringColumn,
+ ];
+ var schema = new ExcelColumnSchema[types.Length];
+ for (int i = 0; i < types.Length; i++)
+ {
+ schema[i] = new() { Index = i, Name = "c" + i, Type = types[i], IsNullable = true };
+ }
+ RecordBatch batch = reader.ToArrowRecordBatch(schema);
+
+ Assert.Equal(2, batch.Length);
+ long[] integers = [-8, -16, -32, 8, 16, 32, 64];
+ for (int i = 0; i < integers.Length; i++)
+ {
+ Assert.Equal(integers[i], Assert.IsType(batch.Column(i)).GetValue(0));
+ }
+ Assert.Equal(0.5, Assert.IsType(batch.Column(7)).GetValue(0));
+ Assert.Equal(0.1, Assert.IsType(batch.Column(8)).GetValue(0));
+ Assert.Equal(12.34, Assert.IsType(batch.Column(9)).GetValue(0));
+ Assert.Equal("alice", Assert.IsType(batch.Column(10)).GetString(0));
+ Assert.Equal(new DateOnly(2024, 1, 15), Assert.IsType(batch.Column(11)).GetDateOnly(0));
+ Assert.Equal(new TimeOnly(13, 30, 0), Assert.IsType(batch.Column(12)).GetTime(0));
+ Assert.Equal(7L, ((Int64Array)batch.Column(0)).GetValue(1));
+ for (int i = 1; i < types.Length; i++)
+ {
+ Assert.True(batch.Column(i).IsNull(1), "column " + i);
+ }
+ Assert.True(batch.Column(13).IsNull(0));
+ }
+
+ [Fact]
+ public void WriteRecordBatch_Xlsx_RoundTrips_Widened_Types()
+ {
+ using var ms = new MemoryStream();
+ using (XlsxWorkbookWriter workbook = XlsxWorkbookWriter.Create(ms, leaveOpen: true))
+ {
+ workbook.WriteRecordBatch(BuildWideBatch());
+ }
+
+ using XlsxReader reader = Excel.FromXlsx(ms.ToArray());
+ AssertWideRoundTrips(reader);
+ }
+
+ [Fact]
+ public void WriteRecordBatch_Xlsb_RoundTrips_Widened_Types()
+ {
+ using var ms = new MemoryStream();
+ using (XlsbWorkbookWriter workbook = XlsbWorkbookWriter.Create(ms, leaveOpen: true))
+ {
+ workbook.WriteRecordBatch(BuildWideBatch());
+ }
+
+ using var reader = Excel.FromXlsb(ms.ToArray());
+ AssertWideRoundTrips(reader);
+ }
+
[Fact]
public void WriteRecordBatch_UnsupportedArrowType_Throws()
{
- var col = new FloatArray.Builder();
- col.Append(1.5f);
+ var col = new BinaryArray.Builder();
+ col.Append(new byte[] { 1 }.AsSpan());
Schema schema = new Schema.Builder()
- .Field(new Field("f", FloatType.Default, nullable: false))
+ .Field(new Field("f", BinaryType.Default, nullable: false))
.Build();
var batch = new RecordBatch(schema, [col.Build()], 1);
diff --git a/tests/ExcelReader.Tests/Reader/ConcurrentSheetTests.cs b/tests/ExcelReader.Tests/Reader/ConcurrentSheetTests.cs
new file mode 100644
index 00000000..3682dafa
--- /dev/null
+++ b/tests/ExcelReader.Tests/Reader/ConcurrentSheetTests.cs
@@ -0,0 +1,268 @@
+using System.Globalization;
+using ExcelReader.Core.Reader;
+using ExcelReader.Core.Writer;
+using ExcelReader.Core.Writer.Xls;
+using ExcelReader.Core.Writer.Xlsb;
+using ExcelReader.Core.Writer.Xlsx;
+
+namespace ExcelReader.Tests.Reader
+{
+ public sealed class ConcurrentSheetTests : IDisposable
+ {
+ public enum Format
+ {
+ Xlsx,
+ Xlsb,
+ Xls,
+ }
+
+ public enum Source
+ {
+ Memory,
+ Stream,
+ Path,
+ }
+
+ private const int Sheets = 6;
+ private const int Rows = 3000;
+
+ private readonly List _paths = [];
+
+ public void Dispose()
+ {
+ foreach (string path in _paths)
+ {
+ File.Delete(path);
+ }
+ }
+
+ public static TheoryData Cases
+ {
+ get
+ {
+ TheoryData data = [];
+ foreach (Format format in Enum.GetValues())
+ {
+ foreach (Source source in Enum.GetValues())
+ {
+ data.Add(format, source);
+ }
+ }
+ return data;
+ }
+ }
+
+ private static void Fill(IWorkbookWriter workbook)
+ where TSheet : ISheetWriter
+ where TRow : IRowWriter
+ {
+ for (int s = 0; s < Sheets; s++)
+ {
+ TSheet sheet = workbook.AddSheet("sheet" + s.ToString(CultureInfo.InvariantCulture));
+ for (int r = 0; r < Rows; r++)
+ {
+ using TRow row = sheet.StartRow();
+ row.Write("s" + s.ToString(CultureInfo.InvariantCulture) + "-name-" + (r % 200).ToString(CultureInfo.InvariantCulture));
+ row.Write((s * 1_000_000) + r);
+ row.Write("shared-" + (r % 17).ToString(CultureInfo.InvariantCulture));
+ }
+ sheet.End();
+ sheet.Dispose();
+ }
+ workbook.End();
+ }
+
+ private static byte[] Build(Format format)
+ {
+ using MemoryStream buffer = new();
+ switch (format)
+ {
+ case Format.Xlsx:
+ using (XlsxWorkbookWriter xlsx = XlsxWorkbookWriter.Create(buffer, leaveOpen: true, new XlsxWriterOptions { UseSharedStrings = true }))
+ {
+ Fill(xlsx);
+ }
+ break;
+ case Format.Xlsb:
+ using (XlsbWorkbookWriter xlsb = XlsbWorkbookWriter.Create(buffer, leaveOpen: true, new XlsbWriterOptions { UseSharedStrings = true }))
+ {
+ Fill(xlsb);
+ }
+ break;
+ default:
+ using (XlsWorkbookWriter xls = XlsWorkbookWriter.Create(buffer, leaveOpen: true))
+ {
+ Fill