From 6fcd15c929efdca82331afe1f450eba8cf015646 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte <61604367+GabrielMarquezMatte@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:41:16 +0000 Subject: [PATCH 001/115] chore: promote public API for v6.0.0 --- .../PublicAPI/PublicAPI.Shipped.txt | 2 +- .../PublicAPI/PublicAPI.Unshipped.txt | 2 - .../PublicAPI/PublicAPI.Shipped.txt | 290 ++++++++++-------- .../PublicAPI/PublicAPI.Unshipped.txt | 290 ------------------ 4 files changed, 158 insertions(+), 426 deletions(-) diff --git a/src/ExcelReader.Arrow/PublicAPI/PublicAPI.Shipped.txt b/src/ExcelReader.Arrow/PublicAPI/PublicAPI.Shipped.txt index 6a1803ef..297b0d9a 100644 --- a/src/ExcelReader.Arrow/PublicAPI/PublicAPI.Shipped.txt +++ b/src/ExcelReader.Arrow/PublicAPI/PublicAPI.Shipped.txt @@ -1,7 +1,7 @@ #nullable enable ExcelReader.Arrow.ArrowConversionExtensions ExcelReader.Arrow.ArrowWriteExtensions -static ExcelReader.Arrow.ArrowConversionExtensions.ToArrowRecordBatch(this ExcelReader.Core.Reader.IExcelRowReader! reader, ExcelReader.Core.Reader.Schema.ExcelColumnSchema[]? schema = null, int headerRow = 1) -> Apache.Arrow.RecordBatch! +static ExcelReader.Arrow.ArrowConversionExtensions.ToArrowRecordBatch(this ExcelReader.Core.Reader.IExcelSheet! sheet, ExcelReader.Core.Reader.Schema.ExcelColumnSchema[]? schema = null, int headerRow = 1) -> Apache.Arrow.RecordBatch! static ExcelReader.Arrow.ArrowWriteExtensions.WriteRecordBatch(this ExcelReader.Core.Writer.Csv.CsvWorkbookWriter! workbook, Apache.Arrow.RecordBatch! batch, bool writeHeader = true) -> void static ExcelReader.Arrow.ArrowWriteExtensions.WriteRecordBatch(this ExcelReader.Core.Writer.Xls.XlsWorkbookWriter! workbook, Apache.Arrow.RecordBatch! batch, string! sheetName = "Sheet1", bool writeHeader = true) -> void static ExcelReader.Arrow.ArrowWriteExtensions.WriteRecordBatch(this ExcelReader.Core.Writer.Xlsb.XlsbWorkbookWriter! workbook, Apache.Arrow.RecordBatch! batch, string! sheetName = "Sheet1", bool writeHeader = true) -> void diff --git a/src/ExcelReader.Arrow/PublicAPI/PublicAPI.Unshipped.txt b/src/ExcelReader.Arrow/PublicAPI/PublicAPI.Unshipped.txt index 38a178ff..7dc5c581 100644 --- a/src/ExcelReader.Arrow/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/ExcelReader.Arrow/PublicAPI/PublicAPI.Unshipped.txt @@ -1,3 +1 @@ #nullable enable -static ExcelReader.Arrow.ArrowConversionExtensions.ToArrowRecordBatch(this ExcelReader.Core.Reader.IExcelSheet! sheet, ExcelReader.Core.Reader.Schema.ExcelColumnSchema[]? schema = null, int headerRow = 1) -> Apache.Arrow.RecordBatch! -*REMOVED*static ExcelReader.Arrow.ArrowConversionExtensions.ToArrowRecordBatch(this ExcelReader.Core.Reader.IExcelRowReader! reader, ExcelReader.Core.Reader.Schema.ExcelColumnSchema[]? schema = null, int headerRow = 1) -> Apache.Arrow.RecordBatch! diff --git a/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt b/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt index dcc56664..514d6008 100644 --- a/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt +++ b/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt @@ -3,9 +3,9 @@ ExcelReader.Core.ExcelSheetVisibility ExcelReader.Core.ExcelSheetVisibility.Hidden = 1 -> ExcelReader.Core.ExcelSheetVisibility ExcelReader.Core.ExcelSheetVisibility.VeryHidden = 2 -> ExcelReader.Core.ExcelSheetVisibility ExcelReader.Core.ExcelSheetVisibility.Visible = 0 -> ExcelReader.Core.ExcelSheetVisibility -ExcelReader.Core.Parser.AsyncRowEnumerator -ExcelReader.Core.Parser.AsyncRowEnumerator.Current.get -> T -ExcelReader.Core.Parser.AsyncRowEnumerator.MoveNextAsync() -> System.Threading.Tasks.ValueTask +ExcelReader.Core.Parser.AsyncRowEnumerator +ExcelReader.Core.Parser.AsyncRowEnumerator.Current.get -> T +ExcelReader.Core.Parser.AsyncRowEnumerator.MoveNextAsync() -> System.Threading.Tasks.ValueTask ExcelReader.Core.Parser.CsvEnumerable ExcelReader.Core.Parser.CsvEnumerable.AsyncEnumerator ExcelReader.Core.Parser.CsvEnumerable.Enumerator @@ -19,9 +19,9 @@ ExcelReader.Core.Parser.ExcelColumnAttribute.Name.get -> string! ExcelReader.Core.Parser.ExcelConverterAttribute ExcelReader.Core.Parser.ExcelConverterAttribute.ConverterType.get -> System.Type! ExcelReader.Core.Parser.ExcelConverterAttribute.ExcelConverterAttribute(System.Type! converterType) -> void -ExcelReader.Core.Parser.ExcelEnumerable -ExcelReader.Core.Parser.ExcelEnumerable.AsyncEnumerator -ExcelReader.Core.Parser.ExcelEnumerable.Enumerator +ExcelReader.Core.Parser.ExcelEnumerable +ExcelReader.Core.Parser.ExcelEnumerable.AsyncEnumerator +ExcelReader.Core.Parser.ExcelEnumerable.Enumerator ExcelReader.Core.Parser.ExcelEnumerable ExcelReader.Core.Parser.ExcelIgnoreAttribute ExcelReader.Core.Parser.ExcelIgnoreAttribute.ExcelIgnoreAttribute() -> void @@ -37,11 +37,11 @@ ExcelReader.Core.Parser.ExcelParseException.RawValue.get -> string! ExcelReader.Core.Parser.ExcelParseException.Row.get -> int ExcelReader.Core.Parser.ExcelParser ExcelReader.Core.Parser.ExcelParser -ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Csv.CsvReader! reader) -> ExcelReader.Core.Parser.CsvEnumerable! -ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.IExcelRowReader! reader) -> ExcelReader.Core.Parser.ExcelEnumerable! -ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xls.XlsReader! reader) -> ExcelReader.Core.Parser.ExcelEnumerable! -ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xlsb.XlsbReader! reader) -> ExcelReader.Core.Parser.ExcelEnumerable! -ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xlsx.XlsxReader! reader) -> ExcelReader.Core.Parser.ExcelEnumerable! +ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Csv.CsvSheet sheet) -> ExcelReader.Core.Parser.CsvEnumerable! +ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.IExcelSheet! sheet) -> ExcelReader.Core.Parser.ExcelEnumerable! +ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xls.XlsSheet sheet) -> ExcelReader.Core.Parser.ExcelEnumerable! +ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xlsb.XlsbSheet sheet) -> ExcelReader.Core.Parser.ExcelEnumerable! +ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xlsx.XlsxSheet sheet) -> ExcelReader.Core.Parser.ExcelEnumerable! ExcelReader.Core.Parser.ExcelParserConfig ExcelReader.Core.Parser.ExcelParserConfig.ColumnNameComparer.get -> System.StringComparer! ExcelReader.Core.Parser.ExcelParserConfig.ColumnNameComparer.init -> void @@ -144,16 +144,11 @@ ExcelReader.Core.Reader.Csv.CsvReader.Enumerator ExcelReader.Core.Reader.Csv.CsvReader.Enumerator.Current.get -> ExcelReader.Core.Reader.Row ExcelReader.Core.Reader.Csv.CsvReader.Enumerator.MoveNext() -> bool ExcelReader.Core.Reader.Csv.CsvReader.Enumerator.MoveNextAsync() -> System.Threading.Tasks.ValueTask -ExcelReader.Core.Reader.Csv.CsvReader.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Csv.CsvReader.Enumerator! -ExcelReader.Core.Reader.Csv.CsvReader.GetEnumerator() -> ExcelReader.Core.Reader.Csv.CsvReader.Enumerator! +ExcelReader.Core.Reader.Csv.CsvReader.FirstSheet.get -> ExcelReader.Core.Reader.Csv.CsvSheet ExcelReader.Core.Reader.Csv.CsvReader.IsDate1904.get -> bool -ExcelReader.Core.Reader.Csv.CsvReader.MoveToSheet(int index) -> void ExcelReader.Core.Reader.Csv.CsvReader.SheetCount.get -> int -ExcelReader.Core.Reader.Csv.CsvReader.SheetName.get -> string! -ExcelReader.Core.Reader.Csv.CsvReader.SheetNameAt(int index) -> string! -ExcelReader.Core.Reader.Csv.CsvReader.SheetVisibility.get -> ExcelReader.Core.ExcelSheetVisibility -ExcelReader.Core.Reader.Csv.CsvReader.SheetVisibilityAt(int index) -> ExcelReader.Core.ExcelSheetVisibility -ExcelReader.Core.Reader.Csv.CsvReader.TryMoveToSheet(System.ReadOnlySpan name) -> bool +ExcelReader.Core.Reader.Csv.CsvReader.Sheets.get -> ExcelReader.Core.Reader.ExcelSheetList! +ExcelReader.Core.Reader.Csv.CsvReader.TryGetSheet(System.ReadOnlySpan name, out ExcelReader.Core.Reader.Csv.CsvSheet sheet) -> bool ExcelReader.Core.Reader.Csv.CsvReaderOptions ExcelReader.Core.Reader.Csv.CsvReaderOptions.$() -> ExcelReader.Core.Reader.Csv.CsvReaderOptions! ExcelReader.Core.Reader.Csv.CsvReaderOptions.CsvReaderOptions() -> void @@ -174,6 +169,15 @@ ExcelReader.Core.Reader.Csv.CsvReaderOptions.SniffDialect.get -> bool ExcelReader.Core.Reader.Csv.CsvReaderOptions.SniffDialect.init -> void ExcelReader.Core.Reader.Csv.CsvReaderOptions.WithDialect(ExcelReader.Core.Reader.Csv.CsvDialect dialect) -> ExcelReader.Core.Reader.Csv.CsvReaderOptions! ExcelReader.Core.Reader.Csv.CsvRowAction +ExcelReader.Core.Reader.Csv.CsvSheet +ExcelReader.Core.Reader.Csv.CsvSheet.CsvSheet() -> void +ExcelReader.Core.Reader.Csv.CsvSheet.Equals(ExcelReader.Core.Reader.Csv.CsvSheet other) -> bool +ExcelReader.Core.Reader.Csv.CsvSheet.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Csv.CsvReader.Enumerator! +ExcelReader.Core.Reader.Csv.CsvSheet.GetEnumerator() -> ExcelReader.Core.Reader.Csv.CsvReader.Enumerator! +ExcelReader.Core.Reader.Csv.CsvSheet.Index.get -> int +ExcelReader.Core.Reader.Csv.CsvSheet.IsDate1904.get -> bool +ExcelReader.Core.Reader.Csv.CsvSheet.Name.get -> string! +ExcelReader.Core.Reader.Csv.CsvSheet.Visibility.get -> ExcelReader.Core.ExcelSheetVisibility ExcelReader.Core.Reader.Csv.CsvSniffer ExcelReader.Core.Reader.Csv.CsvSnifferOptions ExcelReader.Core.Reader.Csv.CsvSnifferOptions.$() -> ExcelReader.Core.Reader.Csv.CsvSnifferOptions! @@ -195,7 +199,7 @@ ExcelReader.Core.Reader.ExcelDataReader ExcelReader.Core.Reader.ExcelDataReader.Close() -> void ExcelReader.Core.Reader.ExcelDataReader.Depth.get -> int ExcelReader.Core.Reader.ExcelDataReader.Dispose() -> void -ExcelReader.Core.Reader.ExcelDataReader.ExcelDataReader(ExcelReader.Core.Reader.IExcelRowReader! reader, int headerRow = 1) -> void +ExcelReader.Core.Reader.ExcelDataReader.ExcelDataReader(ExcelReader.Core.Reader.IExcelSheet! sheet, int headerRow = 1) -> void ExcelReader.Core.Reader.ExcelDataReader.FieldCount.get -> int ExcelReader.Core.Reader.ExcelDataReader.GetBoolean(int i) -> bool ExcelReader.Core.Reader.ExcelDataReader.GetByte(int i) -> byte @@ -281,34 +285,28 @@ ExcelReader.Core.Reader.ExcelReaderOptions.PrefetchDecompression.get -> bool ExcelReader.Core.Reader.ExcelReaderOptions.PrefetchDecompression.init -> void ExcelReader.Core.Reader.ExcelReaderOptions.VerifyEncryptedIntegrity.get -> bool ExcelReader.Core.Reader.ExcelReaderOptions.VerifyEncryptedIntegrity.init -> void -ExcelReader.Core.Reader.ExcelRowReaderExtensions -ExcelReader.Core.Reader.ExcelSheet -ExcelReader.Core.Reader.ExcelSheet.Deconstruct(out int Index, out string! Name) -> void -ExcelReader.Core.Reader.ExcelSheet.Equals(ExcelReader.Core.Reader.ExcelSheet other) -> bool -ExcelReader.Core.Reader.ExcelSheet.ExcelSheet() -> void -ExcelReader.Core.Reader.ExcelSheet.ExcelSheet(int Index, string! Name) -> void -ExcelReader.Core.Reader.ExcelSheet.Index.get -> int -ExcelReader.Core.Reader.ExcelSheet.Index.init -> void -ExcelReader.Core.Reader.ExcelSheet.Name.get -> string! -ExcelReader.Core.Reader.ExcelSheet.Name.init -> void -ExcelReader.Core.Reader.ExcelSheet.Visibility.get -> ExcelReader.Core.ExcelSheetVisibility -ExcelReader.Core.Reader.ExcelSheet.Visibility.init -> void +ExcelReader.Core.Reader.ExcelSheetList +ExcelReader.Core.Reader.ExcelSheetList.Count.get -> int +ExcelReader.Core.Reader.ExcelSheetList.GetEnumerator() -> System.ArraySegment.Enumerator +ExcelReader.Core.Reader.ExcelSheetList.this[int index].get -> TSheet ExcelReader.Core.Reader.IExcelRowEnumerator ExcelReader.Core.Reader.IExcelRowEnumerator.Current.get -> ExcelReader.Core.Reader.Row ExcelReader.Core.Reader.IExcelRowEnumerator.MoveNext() -> bool ExcelReader.Core.Reader.IExcelRowEnumerator.MoveNextAsync() -> System.Threading.Tasks.ValueTask -ExcelReader.Core.Reader.IExcelRowReader -ExcelReader.Core.Reader.IExcelRowReader.MoveToSheet(int index) -> void -ExcelReader.Core.Reader.IExcelRowReader.SheetCount.get -> int -ExcelReader.Core.Reader.IExcelRowReader.SheetName.get -> string! -ExcelReader.Core.Reader.IExcelRowReader.SheetNameAt(int index) -> string! -ExcelReader.Core.Reader.IExcelRowReader.SheetVisibility.get -> ExcelReader.Core.ExcelSheetVisibility -ExcelReader.Core.Reader.IExcelRowReader.SheetVisibilityAt(int index) -> ExcelReader.Core.ExcelSheetVisibility -ExcelReader.Core.Reader.IExcelRowReader.TryMoveToSheet(System.ReadOnlySpan name) -> bool -ExcelReader.Core.Reader.IExcelRowReader -ExcelReader.Core.Reader.IExcelRowReader.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> TEnumerator -ExcelReader.Core.Reader.IExcelRowReader.GetEnumerator() -> TEnumerator -ExcelReader.Core.Reader.IExcelRowReader.IsDate1904.get -> bool +ExcelReader.Core.Reader.IExcelSheet +ExcelReader.Core.Reader.IExcelSheet.Index.get -> int +ExcelReader.Core.Reader.IExcelSheet.Name.get -> string! +ExcelReader.Core.Reader.IExcelSheet.Visibility.get -> ExcelReader.Core.ExcelSheetVisibility +ExcelReader.Core.Reader.IExcelSheet +ExcelReader.Core.Reader.IExcelSheet.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> TEnumerator +ExcelReader.Core.Reader.IExcelSheet.GetEnumerator() -> TEnumerator +ExcelReader.Core.Reader.IExcelSheet.IsDate1904.get -> bool +ExcelReader.Core.Reader.IExcelWorkbook +ExcelReader.Core.Reader.IExcelWorkbook.FirstSheet.get -> ExcelReader.Core.Reader.IExcelSheet! +ExcelReader.Core.Reader.IExcelWorkbook.IsDate1904.get -> bool +ExcelReader.Core.Reader.IExcelWorkbook.SheetAt(int index) -> ExcelReader.Core.Reader.IExcelSheet! +ExcelReader.Core.Reader.IExcelWorkbook.SheetCount.get -> int +ExcelReader.Core.Reader.IExcelWorkbook.TryGetSheet(System.ReadOnlySpan name, out ExcelReader.Core.Reader.IExcelSheet! sheet) -> bool ExcelReader.Core.Reader.PooledStreamRowEnumerator ExcelReader.Core.Reader.PooledStreamRowEnumerator.Dispose() -> void ExcelReader.Core.Reader.PooledStreamRowEnumerator.DisposeAsync() -> System.Threading.Tasks.ValueTask @@ -346,59 +344,71 @@ ExcelReader.Core.Reader.Schema.ExcelColumnType.Int64Column = 1 -> ExcelReader.Co ExcelReader.Core.Reader.Schema.ExcelColumnType.StringColumn = 0 -> ExcelReader.Core.Reader.Schema.ExcelColumnType ExcelReader.Core.Reader.Schema.ExcelColumnType.TimeColumn = 5 -> ExcelReader.Core.Reader.Schema.ExcelColumnType ExcelReader.Core.Reader.Schema.ExcelColumnType.TimestampColumn = 6 -> ExcelReader.Core.Reader.Schema.ExcelColumnType -ExcelReader.Core.Reader.Xls.XlsReader -ExcelReader.Core.Reader.Xls.XlsReader.Dispose() -> void -ExcelReader.Core.Reader.Xls.XlsReader.DisposeAsync() -> System.Threading.Tasks.ValueTask -ExcelReader.Core.Reader.Xls.XlsReader.Enumerator -ExcelReader.Core.Reader.Xls.XlsReader.Enumerator.Current.get -> ExcelReader.Core.Reader.Row -ExcelReader.Core.Reader.Xls.XlsReader.Enumerator.Dispose() -> void -ExcelReader.Core.Reader.Xls.XlsReader.Enumerator.DisposeAsync() -> System.Threading.Tasks.ValueTask -ExcelReader.Core.Reader.Xls.XlsReader.Enumerator.MoveNext() -> bool -ExcelReader.Core.Reader.Xls.XlsReader.Enumerator.MoveNextAsync() -> System.Threading.Tasks.ValueTask -ExcelReader.Core.Reader.Xls.XlsReader.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Xls.XlsReader.Enumerator! -ExcelReader.Core.Reader.Xls.XlsReader.GetEnumerator() -> ExcelReader.Core.Reader.Xls.XlsReader.Enumerator! -ExcelReader.Core.Reader.Xls.XlsReader.IsDate1904.get -> bool -ExcelReader.Core.Reader.Xls.XlsReader.MoveToSheet(int index) -> void -ExcelReader.Core.Reader.Xls.XlsReader.SheetCount.get -> int -ExcelReader.Core.Reader.Xls.XlsReader.SheetName.get -> string! -ExcelReader.Core.Reader.Xls.XlsReader.SheetNameAt(int index) -> string! -ExcelReader.Core.Reader.Xls.XlsReader.SheetVisibility.get -> ExcelReader.Core.ExcelSheetVisibility -ExcelReader.Core.Reader.Xls.XlsReader.SheetVisibilityAt(int index) -> ExcelReader.Core.ExcelSheetVisibility -ExcelReader.Core.Reader.Xls.XlsReader.TryMoveToSheet(System.ReadOnlySpan name) -> bool -ExcelReader.Core.Reader.Xlsb.XlsbReader -ExcelReader.Core.Reader.Xlsb.XlsbReader.Dispose() -> void -ExcelReader.Core.Reader.Xlsb.XlsbReader.DisposeAsync() -> System.Threading.Tasks.ValueTask -ExcelReader.Core.Reader.Xlsb.XlsbReader.Enumerator -ExcelReader.Core.Reader.Xlsb.XlsbReader.Enumerator.Current.get -> ExcelReader.Core.Reader.Row -ExcelReader.Core.Reader.Xlsb.XlsbReader.Enumerator.MoveNext() -> bool -ExcelReader.Core.Reader.Xlsb.XlsbReader.Enumerator.MoveNextAsync() -> System.Threading.Tasks.ValueTask -ExcelReader.Core.Reader.Xlsb.XlsbReader.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Xlsb.XlsbReader.Enumerator! -ExcelReader.Core.Reader.Xlsb.XlsbReader.GetEnumerator() -> ExcelReader.Core.Reader.Xlsb.XlsbReader.Enumerator! -ExcelReader.Core.Reader.Xlsb.XlsbReader.IsDate1904.get -> bool -ExcelReader.Core.Reader.Xlsb.XlsbReader.MoveToSheet(int index) -> void -ExcelReader.Core.Reader.Xlsb.XlsbReader.SheetCount.get -> int -ExcelReader.Core.Reader.Xlsb.XlsbReader.SheetName.get -> string! -ExcelReader.Core.Reader.Xlsb.XlsbReader.SheetNameAt(int index) -> string! -ExcelReader.Core.Reader.Xlsb.XlsbReader.SheetVisibility.get -> ExcelReader.Core.ExcelSheetVisibility -ExcelReader.Core.Reader.Xlsb.XlsbReader.SheetVisibilityAt(int index) -> ExcelReader.Core.ExcelSheetVisibility -ExcelReader.Core.Reader.Xlsb.XlsbReader.TryMoveToSheet(System.ReadOnlySpan name) -> bool -ExcelReader.Core.Reader.Xlsx.XlsxReader -ExcelReader.Core.Reader.Xlsx.XlsxReader.Dispose() -> void -ExcelReader.Core.Reader.Xlsx.XlsxReader.DisposeAsync() -> System.Threading.Tasks.ValueTask -ExcelReader.Core.Reader.Xlsx.XlsxReader.Enumerator -ExcelReader.Core.Reader.Xlsx.XlsxReader.Enumerator.Current.get -> ExcelReader.Core.Reader.Row -ExcelReader.Core.Reader.Xlsx.XlsxReader.Enumerator.MoveNext() -> bool -ExcelReader.Core.Reader.Xlsx.XlsxReader.Enumerator.MoveNextAsync() -> System.Threading.Tasks.ValueTask -ExcelReader.Core.Reader.Xlsx.XlsxReader.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Xlsx.XlsxReader.Enumerator! -ExcelReader.Core.Reader.Xlsx.XlsxReader.GetEnumerator() -> ExcelReader.Core.Reader.Xlsx.XlsxReader.Enumerator! -ExcelReader.Core.Reader.Xlsx.XlsxReader.IsDate1904.get -> bool -ExcelReader.Core.Reader.Xlsx.XlsxReader.MoveToSheet(int index) -> void -ExcelReader.Core.Reader.Xlsx.XlsxReader.SheetCount.get -> int -ExcelReader.Core.Reader.Xlsx.XlsxReader.SheetName.get -> string! -ExcelReader.Core.Reader.Xlsx.XlsxReader.SheetNameAt(int index) -> string! -ExcelReader.Core.Reader.Xlsx.XlsxReader.SheetVisibility.get -> ExcelReader.Core.ExcelSheetVisibility -ExcelReader.Core.Reader.Xlsx.XlsxReader.SheetVisibilityAt(int index) -> ExcelReader.Core.ExcelSheetVisibility -ExcelReader.Core.Reader.Xlsx.XlsxReader.TryMoveToSheet(System.ReadOnlySpan name) -> bool +ExcelReader.Core.Reader.Xls.XlsSheet +ExcelReader.Core.Reader.Xls.XlsSheet.Equals(ExcelReader.Core.Reader.Xls.XlsSheet other) -> bool +ExcelReader.Core.Reader.Xls.XlsSheet.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator! +ExcelReader.Core.Reader.Xls.XlsSheet.GetEnumerator() -> ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator! +ExcelReader.Core.Reader.Xls.XlsSheet.Index.get -> int +ExcelReader.Core.Reader.Xls.XlsSheet.IsDate1904.get -> bool +ExcelReader.Core.Reader.Xls.XlsSheet.Name.get -> string! +ExcelReader.Core.Reader.Xls.XlsSheet.Visibility.get -> ExcelReader.Core.ExcelSheetVisibility +ExcelReader.Core.Reader.Xls.XlsSheet.XlsSheet() -> void +ExcelReader.Core.Reader.Xls.XlsWorkbook +ExcelReader.Core.Reader.Xls.XlsWorkbook.Dispose() -> void +ExcelReader.Core.Reader.Xls.XlsWorkbook.DisposeAsync() -> System.Threading.Tasks.ValueTask +ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator +ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator.Current.get -> ExcelReader.Core.Reader.Row +ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator.Dispose() -> void +ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator.DisposeAsync() -> System.Threading.Tasks.ValueTask +ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator.MoveNext() -> bool +ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator.MoveNextAsync() -> System.Threading.Tasks.ValueTask +ExcelReader.Core.Reader.Xls.XlsWorkbook.FirstSheet.get -> ExcelReader.Core.Reader.Xls.XlsSheet +ExcelReader.Core.Reader.Xls.XlsWorkbook.IsDate1904.get -> bool +ExcelReader.Core.Reader.Xls.XlsWorkbook.SheetCount.get -> int +ExcelReader.Core.Reader.Xls.XlsWorkbook.Sheets.get -> ExcelReader.Core.Reader.ExcelSheetList! +ExcelReader.Core.Reader.Xls.XlsWorkbook.TryGetSheet(System.ReadOnlySpan name, out ExcelReader.Core.Reader.Xls.XlsSheet sheet) -> bool +ExcelReader.Core.Reader.Xlsb.XlsbSheet +ExcelReader.Core.Reader.Xlsb.XlsbSheet.Equals(ExcelReader.Core.Reader.Xlsb.XlsbSheet other) -> bool +ExcelReader.Core.Reader.Xlsb.XlsbSheet.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Enumerator! +ExcelReader.Core.Reader.Xlsb.XlsbSheet.GetEnumerator() -> ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Enumerator! +ExcelReader.Core.Reader.Xlsb.XlsbSheet.Index.get -> int +ExcelReader.Core.Reader.Xlsb.XlsbSheet.IsDate1904.get -> bool +ExcelReader.Core.Reader.Xlsb.XlsbSheet.Name.get -> string! +ExcelReader.Core.Reader.Xlsb.XlsbSheet.Visibility.get -> ExcelReader.Core.ExcelSheetVisibility +ExcelReader.Core.Reader.Xlsb.XlsbSheet.XlsbSheet() -> void +ExcelReader.Core.Reader.Xlsb.XlsbWorkbook +ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Dispose() -> void +ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.DisposeAsync() -> System.Threading.Tasks.ValueTask +ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Enumerator +ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Enumerator.Current.get -> ExcelReader.Core.Reader.Row +ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Enumerator.MoveNext() -> bool +ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Enumerator.MoveNextAsync() -> System.Threading.Tasks.ValueTask +ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.FirstSheet.get -> ExcelReader.Core.Reader.Xlsb.XlsbSheet +ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.IsDate1904.get -> bool +ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.SheetCount.get -> int +ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Sheets.get -> ExcelReader.Core.Reader.ExcelSheetList! +ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.TryGetSheet(System.ReadOnlySpan name, out ExcelReader.Core.Reader.Xlsb.XlsbSheet sheet) -> bool +ExcelReader.Core.Reader.Xlsx.XlsxSheet +ExcelReader.Core.Reader.Xlsx.XlsxSheet.Equals(ExcelReader.Core.Reader.Xlsx.XlsxSheet other) -> bool +ExcelReader.Core.Reader.Xlsx.XlsxSheet.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Enumerator! +ExcelReader.Core.Reader.Xlsx.XlsxSheet.GetEnumerator() -> ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Enumerator! +ExcelReader.Core.Reader.Xlsx.XlsxSheet.Index.get -> int +ExcelReader.Core.Reader.Xlsx.XlsxSheet.IsDate1904.get -> bool +ExcelReader.Core.Reader.Xlsx.XlsxSheet.Name.get -> string! +ExcelReader.Core.Reader.Xlsx.XlsxSheet.Visibility.get -> ExcelReader.Core.ExcelSheetVisibility +ExcelReader.Core.Reader.Xlsx.XlsxSheet.XlsxSheet() -> void +ExcelReader.Core.Reader.Xlsx.XlsxWorkbook +ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Dispose() -> void +ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.DisposeAsync() -> System.Threading.Tasks.ValueTask +ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Enumerator +ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Enumerator.Current.get -> ExcelReader.Core.Reader.Row +ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Enumerator.MoveNext() -> bool +ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Enumerator.MoveNextAsync() -> System.Threading.Tasks.ValueTask +ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.FirstSheet.get -> ExcelReader.Core.Reader.Xlsx.XlsxSheet +ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.IsDate1904.get -> bool +ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.SheetCount.get -> int +ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Sheets.get -> ExcelReader.Core.Reader.ExcelSheetList! +ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.TryGetSheet(System.ReadOnlySpan name, out ExcelReader.Core.Reader.Xlsx.XlsxSheet sheet) -> bool ExcelReader.Core.Writer.CellStyle ExcelReader.Core.Writer.CellStyle.Bold.get -> bool ExcelReader.Core.Writer.CellStyle.Bold.init -> void @@ -647,6 +657,7 @@ override ExcelReader.Core.Reader.Csv.CsvDialect.GetHashCode() -> int override ExcelReader.Core.Reader.Csv.CsvReaderOptions.Equals(object? obj) -> bool override ExcelReader.Core.Reader.Csv.CsvReaderOptions.GetHashCode() -> int override ExcelReader.Core.Reader.Csv.CsvReaderOptions.ToString() -> string! +override ExcelReader.Core.Reader.Csv.CsvSheet.GetHashCode() -> int override ExcelReader.Core.Reader.Csv.CsvSnifferOptions.Equals(object? obj) -> bool override ExcelReader.Core.Reader.Csv.CsvSnifferOptions.GetHashCode() -> int override ExcelReader.Core.Reader.Csv.CsvSnifferOptions.ToString() -> string! @@ -654,8 +665,10 @@ override ExcelReader.Core.Reader.ExcelPassword.ToString() -> string! override ExcelReader.Core.Reader.ExcelReaderOptions.Equals(object? obj) -> bool override ExcelReader.Core.Reader.ExcelReaderOptions.GetHashCode() -> int override ExcelReader.Core.Reader.ExcelReaderOptions.ToString() -> string! -override ExcelReader.Core.Reader.ExcelSheet.GetHashCode() -> int override ExcelReader.Core.Reader.Schema.ExcelColumnSchema.GetHashCode() -> int +override ExcelReader.Core.Reader.Xls.XlsSheet.GetHashCode() -> int +override ExcelReader.Core.Reader.Xlsb.XlsbSheet.GetHashCode() -> int +override ExcelReader.Core.Reader.Xlsx.XlsxSheet.GetHashCode() -> int override ExcelReader.Core.Writer.CellStyle.GetHashCode() -> int override ExcelReader.Core.Writer.Csv.CsvWriterOptions.Equals(object? obj) -> bool override ExcelReader.Core.Writer.Csv.CsvWriterOptions.GetHashCode() -> int @@ -702,6 +715,8 @@ static ExcelReader.Core.Reader.Csv.CsvParallelOptions.Default.get -> ExcelReader static ExcelReader.Core.Reader.Csv.CsvReaderOptions.Default.get -> ExcelReader.Core.Reader.Csv.CsvReaderOptions! static ExcelReader.Core.Reader.Csv.CsvReaderOptions.operator !=(ExcelReader.Core.Reader.Csv.CsvReaderOptions? left, ExcelReader.Core.Reader.Csv.CsvReaderOptions? right) -> bool static ExcelReader.Core.Reader.Csv.CsvReaderOptions.operator ==(ExcelReader.Core.Reader.Csv.CsvReaderOptions? left, ExcelReader.Core.Reader.Csv.CsvReaderOptions? right) -> bool +static ExcelReader.Core.Reader.Csv.CsvSheet.operator !=(ExcelReader.Core.Reader.Csv.CsvSheet left, ExcelReader.Core.Reader.Csv.CsvSheet right) -> bool +static ExcelReader.Core.Reader.Csv.CsvSheet.operator ==(ExcelReader.Core.Reader.Csv.CsvSheet left, ExcelReader.Core.Reader.Csv.CsvSheet right) -> bool static ExcelReader.Core.Reader.Csv.CsvSniffer.Detect(System.IO.Stream! stream, ExcelReader.Core.Reader.Csv.CsvSnifferOptions? options = null) -> ExcelReader.Core.Reader.Csv.CsvDialect static ExcelReader.Core.Reader.Csv.CsvSniffer.Detect(System.ReadOnlySpan sample, ExcelReader.Core.Reader.Csv.CsvSnifferOptions? options = null) -> ExcelReader.Core.Reader.Csv.CsvDialect static ExcelReader.Core.Reader.Csv.CsvSniffer.DetectAsync(System.IO.Stream! stream, ExcelReader.Core.Reader.Csv.CsvSnifferOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask @@ -722,43 +737,46 @@ static ExcelReader.Core.Reader.Excel.FromCsv(System.ReadOnlyMemory data, E static ExcelReader.Core.Reader.Excel.FromCsvAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.Csv.CsvReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static ExcelReader.Core.Reader.Excel.FromCsvFile(string! path, ExcelReader.Core.Reader.Csv.CsvReaderOptions? options = null) -> ExcelReader.Core.Reader.Csv.CsvReader! static ExcelReader.Core.Reader.Excel.FromCsvFileAsync(string! path, ExcelReader.Core.Reader.Csv.CsvReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static ExcelReader.Core.Reader.Excel.FromXls(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xls.XlsReader! -static ExcelReader.Core.Reader.Excel.FromXls(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xls.XlsReader! -static ExcelReader.Core.Reader.Excel.FromXlsAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static ExcelReader.Core.Reader.Excel.FromXlsFile(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xls.XlsReader! -static ExcelReader.Core.Reader.Excel.FromXlsFileAsync(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static ExcelReader.Core.Reader.Excel.FromXlsb(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsb.XlsbReader! -static ExcelReader.Core.Reader.Excel.FromXlsb(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsb.XlsbReader! -static ExcelReader.Core.Reader.Excel.FromXlsbAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static ExcelReader.Core.Reader.Excel.FromXlsbFile(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsb.XlsbReader! -static ExcelReader.Core.Reader.Excel.FromXlsbFileAsync(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static ExcelReader.Core.Reader.Excel.FromXlsx(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsx.XlsxReader! -static ExcelReader.Core.Reader.Excel.FromXlsx(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsx.XlsxReader! -static ExcelReader.Core.Reader.Excel.FromXlsxAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static ExcelReader.Core.Reader.Excel.FromXlsxFile(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsx.XlsxReader! -static ExcelReader.Core.Reader.Excel.FromXlsxFileAsync(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static ExcelReader.Core.Reader.Excel.InferSchema(ExcelReader.Core.Reader.IExcelRowReader! reader, int headerRow = 1, int sampleSize = 100, bool parseText = false) -> ExcelReader.Core.Reader.Schema.ExcelColumnSchema[]! -static ExcelReader.Core.Reader.Excel.InferSchema(ExcelReader.Core.Reader.IExcelRowReader! reader, int headerRow, int sampleSize) -> ExcelReader.Core.Reader.Schema.ExcelColumnSchema[]! -static ExcelReader.Core.Reader.Excel.Open(System.IO.Stream! stream, ExcelReader.Core.Reader.ExcelFileFormat format, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelRowReader! -static ExcelReader.Core.Reader.Excel.Open(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelRowReader! -static ExcelReader.Core.Reader.Excel.Open(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelFileFormat format, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelRowReader! -static ExcelReader.Core.Reader.Excel.Open(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelRowReader! -static ExcelReader.Core.Reader.Excel.Open(string! path, ExcelReader.Core.Reader.ExcelFileFormat format, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelRowReader! -static ExcelReader.Core.Reader.Excel.Open(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelRowReader! -static ExcelReader.Core.Reader.Excel.OpenAsync(System.IO.Stream! stream, ExcelReader.Core.Reader.ExcelFileFormat format, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static ExcelReader.Core.Reader.Excel.OpenAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static ExcelReader.Core.Reader.Excel.OpenAsync(string! path, ExcelReader.Core.Reader.ExcelFileFormat format, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static ExcelReader.Core.Reader.Excel.OpenAsync(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Core.Reader.Excel.FromXls(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xls.XlsWorkbook! +static ExcelReader.Core.Reader.Excel.FromXls(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xls.XlsWorkbook! +static ExcelReader.Core.Reader.Excel.FromXlsAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Core.Reader.Excel.FromXlsFile(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xls.XlsWorkbook! +static ExcelReader.Core.Reader.Excel.FromXlsFileAsync(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Core.Reader.Excel.FromXlsb(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsb.XlsbWorkbook! +static ExcelReader.Core.Reader.Excel.FromXlsb(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsb.XlsbWorkbook! +static ExcelReader.Core.Reader.Excel.FromXlsbAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Core.Reader.Excel.FromXlsbFile(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsb.XlsbWorkbook! +static ExcelReader.Core.Reader.Excel.FromXlsbFileAsync(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Core.Reader.Excel.FromXlsx(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsx.XlsxWorkbook! +static ExcelReader.Core.Reader.Excel.FromXlsx(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsx.XlsxWorkbook! +static ExcelReader.Core.Reader.Excel.FromXlsxAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Core.Reader.Excel.FromXlsxFile(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsx.XlsxWorkbook! +static ExcelReader.Core.Reader.Excel.FromXlsxFileAsync(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Core.Reader.Excel.InferSchema(ExcelReader.Core.Reader.IExcelSheet! sheet, int headerRow = 1, int sampleSize = 100, bool parseText = false) -> ExcelReader.Core.Reader.Schema.ExcelColumnSchema[]! +static ExcelReader.Core.Reader.Excel.InferSchema(ExcelReader.Core.Reader.IExcelSheet! sheet, int headerRow, int sampleSize) -> ExcelReader.Core.Reader.Schema.ExcelColumnSchema[]! +static ExcelReader.Core.Reader.Excel.Open(System.IO.Stream! stream, ExcelReader.Core.Reader.ExcelFileFormat format, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelWorkbook! +static ExcelReader.Core.Reader.Excel.Open(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelWorkbook! +static ExcelReader.Core.Reader.Excel.Open(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelFileFormat format, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelWorkbook! +static ExcelReader.Core.Reader.Excel.Open(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelWorkbook! +static ExcelReader.Core.Reader.Excel.Open(string! path, ExcelReader.Core.Reader.ExcelFileFormat format, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelWorkbook! +static ExcelReader.Core.Reader.Excel.Open(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelWorkbook! +static ExcelReader.Core.Reader.Excel.OpenAsync(System.IO.Stream! stream, ExcelReader.Core.Reader.ExcelFileFormat format, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Core.Reader.Excel.OpenAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Core.Reader.Excel.OpenAsync(string! path, ExcelReader.Core.Reader.ExcelFileFormat format, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Core.Reader.Excel.OpenAsync(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static ExcelReader.Core.Reader.ExcelPassword.FromString(string! password) -> ExcelReader.Core.Reader.ExcelPassword! static ExcelReader.Core.Reader.ExcelPassword.implicit operator ExcelReader.Core.Reader.ExcelPassword!(string! password) -> ExcelReader.Core.Reader.ExcelPassword! static ExcelReader.Core.Reader.ExcelReaderOptions.Default.get -> ExcelReader.Core.Reader.ExcelReaderOptions! static ExcelReader.Core.Reader.ExcelReaderOptions.operator !=(ExcelReader.Core.Reader.ExcelReaderOptions? left, ExcelReader.Core.Reader.ExcelReaderOptions? right) -> bool static ExcelReader.Core.Reader.ExcelReaderOptions.operator ==(ExcelReader.Core.Reader.ExcelReaderOptions? left, ExcelReader.Core.Reader.ExcelReaderOptions? right) -> bool -static ExcelReader.Core.Reader.ExcelRowReaderExtensions.Sheets(this ExcelReader.Core.Reader.IExcelRowReader! reader) -> System.Collections.Generic.IEnumerable! -static ExcelReader.Core.Reader.ExcelSheet.operator !=(ExcelReader.Core.Reader.ExcelSheet left, ExcelReader.Core.Reader.ExcelSheet right) -> bool -static ExcelReader.Core.Reader.ExcelSheet.operator ==(ExcelReader.Core.Reader.ExcelSheet left, ExcelReader.Core.Reader.ExcelSheet right) -> bool static ExcelReader.Core.Reader.Schema.ExcelColumnSchema.operator !=(ExcelReader.Core.Reader.Schema.ExcelColumnSchema left, ExcelReader.Core.Reader.Schema.ExcelColumnSchema right) -> bool static ExcelReader.Core.Reader.Schema.ExcelColumnSchema.operator ==(ExcelReader.Core.Reader.Schema.ExcelColumnSchema left, ExcelReader.Core.Reader.Schema.ExcelColumnSchema right) -> bool +static ExcelReader.Core.Reader.Xls.XlsSheet.operator !=(ExcelReader.Core.Reader.Xls.XlsSheet left, ExcelReader.Core.Reader.Xls.XlsSheet right) -> bool +static ExcelReader.Core.Reader.Xls.XlsSheet.operator ==(ExcelReader.Core.Reader.Xls.XlsSheet left, ExcelReader.Core.Reader.Xls.XlsSheet right) -> bool +static ExcelReader.Core.Reader.Xlsb.XlsbSheet.operator !=(ExcelReader.Core.Reader.Xlsb.XlsbSheet left, ExcelReader.Core.Reader.Xlsb.XlsbSheet right) -> bool +static ExcelReader.Core.Reader.Xlsb.XlsbSheet.operator ==(ExcelReader.Core.Reader.Xlsb.XlsbSheet left, ExcelReader.Core.Reader.Xlsb.XlsbSheet right) -> bool +static ExcelReader.Core.Reader.Xlsx.XlsxSheet.operator !=(ExcelReader.Core.Reader.Xlsx.XlsxSheet left, ExcelReader.Core.Reader.Xlsx.XlsxSheet right) -> bool +static ExcelReader.Core.Reader.Xlsx.XlsxSheet.operator ==(ExcelReader.Core.Reader.Xlsx.XlsxSheet left, ExcelReader.Core.Reader.Xlsx.XlsxSheet right) -> bool static ExcelReader.Core.Writer.CellStyle.operator !=(ExcelReader.Core.Writer.CellStyle left, ExcelReader.Core.Writer.CellStyle right) -> bool static ExcelReader.Core.Writer.CellStyle.operator ==(ExcelReader.Core.Writer.CellStyle left, ExcelReader.Core.Writer.CellStyle right) -> bool static ExcelReader.Core.Writer.Csv.CsvWorkbookWriter.Create(System.IO.Stream! stream, bool leaveOpen = false, ExcelReader.Core.Writer.Csv.CsvWriterOptions? options = null) -> ExcelReader.Core.Writer.Csv.CsvWorkbookWriter! @@ -812,21 +830,27 @@ static readonly ExcelReader.Core.Parser.ExcelCellReaders.String -> ExcelReader.C static readonly ExcelReader.Core.Parser.ExcelCellReaders.TimeOnlyAuto -> ExcelReader.Core.Parser.ExcelCellReader! static readonly ExcelReader.Core.Parser.ExcelCellReaders.TimeOnlySerial -> ExcelReader.Core.Parser.ExcelCellReader! static readonly ExcelReader.Core.Parser.ExcelCellReaders.TimeOnlyText -> ExcelReader.Core.Parser.ExcelCellReader! -virtual ExcelReader.Core.Parser.AsyncRowEnumerator.DisposeAsync() -> System.Threading.Tasks.ValueTask +virtual ExcelReader.Core.Parser.AsyncRowEnumerator.DisposeAsync() -> System.Threading.Tasks.ValueTask virtual ExcelReader.Core.Parser.ExcelCellReader.Invoke(in ExcelReader.Core.Reader.Cell cell, bool isDate1904, System.IFormatProvider! provider, out TValue value) -> bool virtual ExcelReader.Core.Parser.ExcelPropertySetter.Invoke(ref TModel model, TValue value) -> void virtual ExcelReader.Core.Parser.ExcelRowParser.Invoke(ref TModel model, in ExcelReader.Core.Reader.Cell cell, bool isDate1904, System.IFormatProvider! provider) -> bool virtual ExcelReader.Core.Reader.Csv.CsvRowAction.Invoke(ref TState state, ExcelReader.Core.Reader.Row row) -> void -~ExcelReader.Core.Parser.ExcelEnumerable.GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Parser.ExcelEnumerable.AsyncEnumerator! -~ExcelReader.Core.Parser.ExcelEnumerable.GetEnumerator() -> ExcelReader.Core.Parser.ExcelEnumerable.Enumerator! +~ExcelReader.Core.Parser.ExcelEnumerable.GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Parser.ExcelEnumerable.AsyncEnumerator! +~ExcelReader.Core.Parser.ExcelEnumerable.GetEnumerator() -> ExcelReader.Core.Parser.ExcelEnumerable.Enumerator! ~override ExcelReader.Core.Parser.ExcelRowContext.Equals(object obj) -> bool ~override ExcelReader.Core.Parser.ExcelRowContext.ToString() -> string ~override ExcelReader.Core.Reader.Csv.CsvDialect.Equals(object obj) -> bool ~override ExcelReader.Core.Reader.Csv.CsvDialect.ToString() -> string -~override ExcelReader.Core.Reader.ExcelSheet.Equals(object obj) -> bool -~override ExcelReader.Core.Reader.ExcelSheet.ToString() -> string +~override ExcelReader.Core.Reader.Csv.CsvSheet.Equals(object obj) -> bool +~override ExcelReader.Core.Reader.Csv.CsvSheet.ToString() -> string ~override ExcelReader.Core.Reader.Schema.ExcelColumnSchema.Equals(object obj) -> bool ~override ExcelReader.Core.Reader.Schema.ExcelColumnSchema.ToString() -> string +~override ExcelReader.Core.Reader.Xls.XlsSheet.Equals(object obj) -> bool +~override ExcelReader.Core.Reader.Xls.XlsSheet.ToString() -> string +~override ExcelReader.Core.Reader.Xlsb.XlsbSheet.Equals(object obj) -> bool +~override ExcelReader.Core.Reader.Xlsb.XlsbSheet.ToString() -> string +~override ExcelReader.Core.Reader.Xlsx.XlsxSheet.Equals(object obj) -> bool +~override ExcelReader.Core.Reader.Xlsx.XlsxSheet.ToString() -> string ~override ExcelReader.Core.Writer.CellStyle.Equals(object obj) -> bool ~override ExcelReader.Core.Writer.CellStyle.ToString() -> string ~override ExcelReader.Core.Writer.Xlsb.XlsbCell.Equals(object obj) -> bool diff --git a/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt b/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt index 9f1f112d..7dc5c581 100644 --- a/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt @@ -1,291 +1 @@ #nullable enable -*REMOVED*ExcelReader.Core.Parser.AsyncRowEnumerator -*REMOVED*ExcelReader.Core.Parser.AsyncRowEnumerator.Current.get -> T -*REMOVED*ExcelReader.Core.Parser.AsyncRowEnumerator.MoveNextAsync() -> System.Threading.Tasks.ValueTask -*REMOVED*ExcelReader.Core.Parser.ExcelEnumerable -*REMOVED*ExcelReader.Core.Parser.ExcelEnumerable.AsyncEnumerator -*REMOVED*ExcelReader.Core.Parser.ExcelEnumerable.Enumerator -*REMOVED*ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Csv.CsvReader! reader) -> ExcelReader.Core.Parser.CsvEnumerable! -*REMOVED*ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.IExcelRowReader! reader) -> ExcelReader.Core.Parser.ExcelEnumerable! -*REMOVED*ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xls.XlsReader! reader) -> ExcelReader.Core.Parser.ExcelEnumerable! -*REMOVED*ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xlsb.XlsbReader! reader) -> ExcelReader.Core.Parser.ExcelEnumerable! -*REMOVED*ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xlsx.XlsxReader! reader) -> ExcelReader.Core.Parser.ExcelEnumerable! -*REMOVED*ExcelReader.Core.Reader.Csv.CsvReader.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Csv.CsvReader.Enumerator! -*REMOVED*ExcelReader.Core.Reader.Csv.CsvReader.GetEnumerator() -> ExcelReader.Core.Reader.Csv.CsvReader.Enumerator! -*REMOVED*ExcelReader.Core.Reader.Csv.CsvReader.MoveToSheet(int index) -> void -*REMOVED*ExcelReader.Core.Reader.Csv.CsvReader.SheetName.get -> string! -*REMOVED*ExcelReader.Core.Reader.Csv.CsvReader.SheetNameAt(int index) -> string! -*REMOVED*ExcelReader.Core.Reader.Csv.CsvReader.SheetVisibility.get -> ExcelReader.Core.ExcelSheetVisibility -*REMOVED*ExcelReader.Core.Reader.Csv.CsvReader.SheetVisibilityAt(int index) -> ExcelReader.Core.ExcelSheetVisibility -*REMOVED*ExcelReader.Core.Reader.Csv.CsvReader.TryMoveToSheet(System.ReadOnlySpan name) -> bool -*REMOVED*ExcelReader.Core.Reader.ExcelDataReader.ExcelDataReader(ExcelReader.Core.Reader.IExcelRowReader! reader, int headerRow = 1) -> void -*REMOVED*ExcelReader.Core.Reader.ExcelRowReaderExtensions -*REMOVED*ExcelReader.Core.Reader.ExcelSheet -*REMOVED*ExcelReader.Core.Reader.ExcelSheet.Deconstruct(out int Index, out string! Name) -> void -*REMOVED*ExcelReader.Core.Reader.ExcelSheet.Equals(ExcelReader.Core.Reader.ExcelSheet other) -> bool -*REMOVED*ExcelReader.Core.Reader.ExcelSheet.ExcelSheet() -> void -*REMOVED*ExcelReader.Core.Reader.ExcelSheet.ExcelSheet(int Index, string! Name) -> void -*REMOVED*ExcelReader.Core.Reader.ExcelSheet.Index.get -> int -*REMOVED*ExcelReader.Core.Reader.ExcelSheet.Index.init -> void -*REMOVED*ExcelReader.Core.Reader.ExcelSheet.Name.get -> string! -*REMOVED*ExcelReader.Core.Reader.ExcelSheet.Name.init -> void -*REMOVED*ExcelReader.Core.Reader.ExcelSheet.Visibility.get -> ExcelReader.Core.ExcelSheetVisibility -*REMOVED*ExcelReader.Core.Reader.ExcelSheet.Visibility.init -> void -*REMOVED*ExcelReader.Core.Reader.IExcelRowReader -*REMOVED*ExcelReader.Core.Reader.IExcelRowReader.MoveToSheet(int index) -> void -*REMOVED*ExcelReader.Core.Reader.IExcelRowReader.SheetCount.get -> int -*REMOVED*ExcelReader.Core.Reader.IExcelRowReader.SheetName.get -> string! -*REMOVED*ExcelReader.Core.Reader.IExcelRowReader.SheetNameAt(int index) -> string! -*REMOVED*ExcelReader.Core.Reader.IExcelRowReader.SheetVisibility.get -> ExcelReader.Core.ExcelSheetVisibility -*REMOVED*ExcelReader.Core.Reader.IExcelRowReader.SheetVisibilityAt(int index) -> ExcelReader.Core.ExcelSheetVisibility -*REMOVED*ExcelReader.Core.Reader.IExcelRowReader.TryMoveToSheet(System.ReadOnlySpan name) -> bool -*REMOVED*ExcelReader.Core.Reader.IExcelRowReader -*REMOVED*ExcelReader.Core.Reader.IExcelRowReader.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> TEnumerator -*REMOVED*ExcelReader.Core.Reader.IExcelRowReader.GetEnumerator() -> TEnumerator -*REMOVED*ExcelReader.Core.Reader.IExcelRowReader.IsDate1904.get -> bool -*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader -*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.Dispose() -> void -*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.DisposeAsync() -> System.Threading.Tasks.ValueTask -*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.Enumerator -*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.Enumerator.Current.get -> ExcelReader.Core.Reader.Row -*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.Enumerator.Dispose() -> void -*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.Enumerator.DisposeAsync() -> System.Threading.Tasks.ValueTask -*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.Enumerator.MoveNext() -> bool -*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.Enumerator.MoveNextAsync() -> System.Threading.Tasks.ValueTask -*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Xls.XlsReader.Enumerator! -*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.GetEnumerator() -> ExcelReader.Core.Reader.Xls.XlsReader.Enumerator! -*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.IsDate1904.get -> bool -*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.MoveToSheet(int index) -> void -*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.SheetCount.get -> int -*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.SheetName.get -> string! -*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.SheetNameAt(int index) -> string! -*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.SheetVisibility.get -> ExcelReader.Core.ExcelSheetVisibility -*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.SheetVisibilityAt(int index) -> ExcelReader.Core.ExcelSheetVisibility -*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.TryMoveToSheet(System.ReadOnlySpan name) -> bool -*REMOVED*ExcelReader.Core.Reader.Xlsb.XlsbReader -*REMOVED*ExcelReader.Core.Reader.Xlsb.XlsbReader.Dispose() -> void -*REMOVED*ExcelReader.Core.Reader.Xlsb.XlsbReader.DisposeAsync() -> System.Threading.Tasks.ValueTask -*REMOVED*ExcelReader.Core.Reader.Xlsb.XlsbReader.Enumerator -*REMOVED*ExcelReader.Core.Reader.Xlsb.XlsbReader.Enumerator.Current.get -> ExcelReader.Core.Reader.Row -*REMOVED*ExcelReader.Core.Reader.Xlsb.XlsbReader.Enumerator.MoveNext() -> bool -*REMOVED*ExcelReader.Core.Reader.Xlsb.XlsbReader.Enumerator.MoveNextAsync() -> System.Threading.Tasks.ValueTask -*REMOVED*ExcelReader.Core.Reader.Xlsb.XlsbReader.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Xlsb.XlsbReader.Enumerator! -*REMOVED*ExcelReader.Core.Reader.Xlsb.XlsbReader.GetEnumerator() -> ExcelReader.Core.Reader.Xlsb.XlsbReader.Enumerator! -*REMOVED*ExcelReader.Core.Reader.Xlsb.XlsbReader.IsDate1904.get -> bool -*REMOVED*ExcelReader.Core.Reader.Xlsb.XlsbReader.MoveToSheet(int index) -> void -*REMOVED*ExcelReader.Core.Reader.Xlsb.XlsbReader.SheetCount.get -> int -*REMOVED*ExcelReader.Core.Reader.Xlsb.XlsbReader.SheetName.get -> string! -*REMOVED*ExcelReader.Core.Reader.Xlsb.XlsbReader.SheetNameAt(int index) -> string! -*REMOVED*ExcelReader.Core.Reader.Xlsb.XlsbReader.SheetVisibility.get -> ExcelReader.Core.ExcelSheetVisibility -*REMOVED*ExcelReader.Core.Reader.Xlsb.XlsbReader.SheetVisibilityAt(int index) -> ExcelReader.Core.ExcelSheetVisibility -*REMOVED*ExcelReader.Core.Reader.Xlsb.XlsbReader.TryMoveToSheet(System.ReadOnlySpan name) -> bool -*REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader -*REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.Dispose() -> void -*REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.DisposeAsync() -> System.Threading.Tasks.ValueTask -*REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.Enumerator -*REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.Enumerator.Current.get -> ExcelReader.Core.Reader.Row -*REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.Enumerator.MoveNext() -> bool -*REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.Enumerator.MoveNextAsync() -> System.Threading.Tasks.ValueTask -*REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Xlsx.XlsxReader.Enumerator! -*REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.GetEnumerator() -> ExcelReader.Core.Reader.Xlsx.XlsxReader.Enumerator! -*REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.IsDate1904.get -> bool -*REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.MoveToSheet(int index) -> void -*REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.SheetCount.get -> int -*REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.SheetName.get -> string! -*REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.SheetNameAt(int index) -> string! -*REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.SheetVisibility.get -> ExcelReader.Core.ExcelSheetVisibility -*REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.SheetVisibilityAt(int index) -> ExcelReader.Core.ExcelSheetVisibility -*REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.TryMoveToSheet(System.ReadOnlySpan name) -> bool -*REMOVED*override ExcelReader.Core.Reader.ExcelSheet.GetHashCode() -> int -*REMOVED*static ExcelReader.Core.Reader.Excel.FromXls(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xls.XlsReader! -*REMOVED*static ExcelReader.Core.Reader.Excel.FromXls(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xls.XlsReader! -*REMOVED*static ExcelReader.Core.Reader.Excel.FromXlsAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -*REMOVED*static ExcelReader.Core.Reader.Excel.FromXlsFile(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xls.XlsReader! -*REMOVED*static ExcelReader.Core.Reader.Excel.FromXlsFileAsync(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -*REMOVED*static ExcelReader.Core.Reader.Excel.FromXlsb(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsb.XlsbReader! -*REMOVED*static ExcelReader.Core.Reader.Excel.FromXlsb(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsb.XlsbReader! -*REMOVED*static ExcelReader.Core.Reader.Excel.FromXlsbAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -*REMOVED*static ExcelReader.Core.Reader.Excel.FromXlsbFile(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsb.XlsbReader! -*REMOVED*static ExcelReader.Core.Reader.Excel.FromXlsbFileAsync(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -*REMOVED*static ExcelReader.Core.Reader.Excel.FromXlsx(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsx.XlsxReader! -*REMOVED*static ExcelReader.Core.Reader.Excel.FromXlsx(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsx.XlsxReader! -*REMOVED*static ExcelReader.Core.Reader.Excel.FromXlsxAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -*REMOVED*static ExcelReader.Core.Reader.Excel.FromXlsxFile(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsx.XlsxReader! -*REMOVED*static ExcelReader.Core.Reader.Excel.FromXlsxFileAsync(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -*REMOVED*static ExcelReader.Core.Reader.Excel.InferSchema(ExcelReader.Core.Reader.IExcelRowReader! reader, int headerRow = 1, int sampleSize = 100, bool parseText = false) -> ExcelReader.Core.Reader.Schema.ExcelColumnSchema[]! -*REMOVED*static ExcelReader.Core.Reader.Excel.InferSchema(ExcelReader.Core.Reader.IExcelRowReader! reader, int headerRow, int sampleSize) -> ExcelReader.Core.Reader.Schema.ExcelColumnSchema[]! -*REMOVED*static ExcelReader.Core.Reader.Excel.Open(System.IO.Stream! stream, ExcelReader.Core.Reader.ExcelFileFormat format, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelRowReader! -*REMOVED*static ExcelReader.Core.Reader.Excel.Open(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelRowReader! -*REMOVED*static ExcelReader.Core.Reader.Excel.Open(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelFileFormat format, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelRowReader! -*REMOVED*static ExcelReader.Core.Reader.Excel.Open(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelRowReader! -*REMOVED*static ExcelReader.Core.Reader.Excel.Open(string! path, ExcelReader.Core.Reader.ExcelFileFormat format, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelRowReader! -*REMOVED*static ExcelReader.Core.Reader.Excel.Open(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelRowReader! -*REMOVED*static ExcelReader.Core.Reader.Excel.OpenAsync(System.IO.Stream! stream, ExcelReader.Core.Reader.ExcelFileFormat format, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -*REMOVED*static ExcelReader.Core.Reader.Excel.OpenAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -*REMOVED*static ExcelReader.Core.Reader.Excel.OpenAsync(string! path, ExcelReader.Core.Reader.ExcelFileFormat format, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -*REMOVED*static ExcelReader.Core.Reader.Excel.OpenAsync(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -*REMOVED*static ExcelReader.Core.Reader.ExcelRowReaderExtensions.Sheets(this ExcelReader.Core.Reader.IExcelRowReader! reader) -> System.Collections.Generic.IEnumerable! -*REMOVED*static ExcelReader.Core.Reader.ExcelSheet.operator !=(ExcelReader.Core.Reader.ExcelSheet left, ExcelReader.Core.Reader.ExcelSheet right) -> bool -*REMOVED*static ExcelReader.Core.Reader.ExcelSheet.operator ==(ExcelReader.Core.Reader.ExcelSheet left, ExcelReader.Core.Reader.ExcelSheet right) -> bool -*REMOVED*virtual ExcelReader.Core.Parser.AsyncRowEnumerator.DisposeAsync() -> System.Threading.Tasks.ValueTask -*REMOVED*~ExcelReader.Core.Parser.ExcelEnumerable.GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Parser.ExcelEnumerable.AsyncEnumerator! -*REMOVED*~ExcelReader.Core.Parser.ExcelEnumerable.GetEnumerator() -> ExcelReader.Core.Parser.ExcelEnumerable.Enumerator! -*REMOVED*~override ExcelReader.Core.Reader.ExcelSheet.Equals(object obj) -> bool -*REMOVED*~override ExcelReader.Core.Reader.ExcelSheet.ToString() -> string -ExcelReader.Core.Parser.AsyncRowEnumerator -ExcelReader.Core.Parser.AsyncRowEnumerator.Current.get -> T -ExcelReader.Core.Parser.AsyncRowEnumerator.MoveNextAsync() -> System.Threading.Tasks.ValueTask -ExcelReader.Core.Parser.ExcelEnumerable -ExcelReader.Core.Parser.ExcelEnumerable.AsyncEnumerator -ExcelReader.Core.Parser.ExcelEnumerable.Enumerator -ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Csv.CsvSheet sheet) -> ExcelReader.Core.Parser.CsvEnumerable! -ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.IExcelSheet! sheet) -> ExcelReader.Core.Parser.ExcelEnumerable! -ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xls.XlsSheet sheet) -> ExcelReader.Core.Parser.ExcelEnumerable! -ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xlsb.XlsbSheet sheet) -> ExcelReader.Core.Parser.ExcelEnumerable! -ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xlsx.XlsxSheet sheet) -> ExcelReader.Core.Parser.ExcelEnumerable! -ExcelReader.Core.Reader.Csv.CsvReader.FirstSheet.get -> ExcelReader.Core.Reader.Csv.CsvSheet -ExcelReader.Core.Reader.Csv.CsvReader.Sheets.get -> ExcelReader.Core.Reader.ExcelSheetList! -ExcelReader.Core.Reader.Csv.CsvReader.TryGetSheet(System.ReadOnlySpan name, out ExcelReader.Core.Reader.Csv.CsvSheet sheet) -> bool -ExcelReader.Core.Reader.Csv.CsvSheet -ExcelReader.Core.Reader.Csv.CsvSheet.CsvSheet() -> void -ExcelReader.Core.Reader.Csv.CsvSheet.Equals(ExcelReader.Core.Reader.Csv.CsvSheet other) -> bool -ExcelReader.Core.Reader.Csv.CsvSheet.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Csv.CsvReader.Enumerator! -ExcelReader.Core.Reader.Csv.CsvSheet.GetEnumerator() -> ExcelReader.Core.Reader.Csv.CsvReader.Enumerator! -ExcelReader.Core.Reader.Csv.CsvSheet.Index.get -> int -ExcelReader.Core.Reader.Csv.CsvSheet.IsDate1904.get -> bool -ExcelReader.Core.Reader.Csv.CsvSheet.Name.get -> string! -ExcelReader.Core.Reader.Csv.CsvSheet.Visibility.get -> ExcelReader.Core.ExcelSheetVisibility -ExcelReader.Core.Reader.ExcelDataReader.ExcelDataReader(ExcelReader.Core.Reader.IExcelSheet! sheet, int headerRow = 1) -> void -ExcelReader.Core.Reader.ExcelSheetList -ExcelReader.Core.Reader.ExcelSheetList.Count.get -> int -ExcelReader.Core.Reader.ExcelSheetList.GetEnumerator() -> System.ArraySegment.Enumerator -ExcelReader.Core.Reader.ExcelSheetList.this[int index].get -> TSheet -ExcelReader.Core.Reader.IExcelSheet -ExcelReader.Core.Reader.IExcelSheet.Index.get -> int -ExcelReader.Core.Reader.IExcelSheet.Name.get -> string! -ExcelReader.Core.Reader.IExcelSheet.Visibility.get -> ExcelReader.Core.ExcelSheetVisibility -ExcelReader.Core.Reader.IExcelSheet -ExcelReader.Core.Reader.IExcelSheet.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> TEnumerator -ExcelReader.Core.Reader.IExcelSheet.GetEnumerator() -> TEnumerator -ExcelReader.Core.Reader.IExcelSheet.IsDate1904.get -> bool -ExcelReader.Core.Reader.IExcelWorkbook -ExcelReader.Core.Reader.IExcelWorkbook.FirstSheet.get -> ExcelReader.Core.Reader.IExcelSheet! -ExcelReader.Core.Reader.IExcelWorkbook.IsDate1904.get -> bool -ExcelReader.Core.Reader.IExcelWorkbook.SheetAt(int index) -> ExcelReader.Core.Reader.IExcelSheet! -ExcelReader.Core.Reader.IExcelWorkbook.SheetCount.get -> int -ExcelReader.Core.Reader.IExcelWorkbook.TryGetSheet(System.ReadOnlySpan name, out ExcelReader.Core.Reader.IExcelSheet! sheet) -> bool -ExcelReader.Core.Reader.Xls.XlsSheet -ExcelReader.Core.Reader.Xls.XlsSheet.Equals(ExcelReader.Core.Reader.Xls.XlsSheet other) -> bool -ExcelReader.Core.Reader.Xls.XlsSheet.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator! -ExcelReader.Core.Reader.Xls.XlsSheet.GetEnumerator() -> ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator! -ExcelReader.Core.Reader.Xls.XlsSheet.Index.get -> int -ExcelReader.Core.Reader.Xls.XlsSheet.IsDate1904.get -> bool -ExcelReader.Core.Reader.Xls.XlsSheet.Name.get -> string! -ExcelReader.Core.Reader.Xls.XlsSheet.Visibility.get -> ExcelReader.Core.ExcelSheetVisibility -ExcelReader.Core.Reader.Xls.XlsSheet.XlsSheet() -> void -ExcelReader.Core.Reader.Xls.XlsWorkbook -ExcelReader.Core.Reader.Xls.XlsWorkbook.Dispose() -> void -ExcelReader.Core.Reader.Xls.XlsWorkbook.DisposeAsync() -> System.Threading.Tasks.ValueTask -ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator -ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator.Current.get -> ExcelReader.Core.Reader.Row -ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator.Dispose() -> void -ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator.DisposeAsync() -> System.Threading.Tasks.ValueTask -ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator.MoveNext() -> bool -ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator.MoveNextAsync() -> System.Threading.Tasks.ValueTask -ExcelReader.Core.Reader.Xls.XlsWorkbook.FirstSheet.get -> ExcelReader.Core.Reader.Xls.XlsSheet -ExcelReader.Core.Reader.Xls.XlsWorkbook.IsDate1904.get -> bool -ExcelReader.Core.Reader.Xls.XlsWorkbook.SheetCount.get -> int -ExcelReader.Core.Reader.Xls.XlsWorkbook.Sheets.get -> ExcelReader.Core.Reader.ExcelSheetList! -ExcelReader.Core.Reader.Xls.XlsWorkbook.TryGetSheet(System.ReadOnlySpan name, out ExcelReader.Core.Reader.Xls.XlsSheet sheet) -> bool -ExcelReader.Core.Reader.Xlsb.XlsbSheet -ExcelReader.Core.Reader.Xlsb.XlsbSheet.Equals(ExcelReader.Core.Reader.Xlsb.XlsbSheet other) -> bool -ExcelReader.Core.Reader.Xlsb.XlsbSheet.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Enumerator! -ExcelReader.Core.Reader.Xlsb.XlsbSheet.GetEnumerator() -> ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Enumerator! -ExcelReader.Core.Reader.Xlsb.XlsbSheet.Index.get -> int -ExcelReader.Core.Reader.Xlsb.XlsbSheet.IsDate1904.get -> bool -ExcelReader.Core.Reader.Xlsb.XlsbSheet.Name.get -> string! -ExcelReader.Core.Reader.Xlsb.XlsbSheet.Visibility.get -> ExcelReader.Core.ExcelSheetVisibility -ExcelReader.Core.Reader.Xlsb.XlsbSheet.XlsbSheet() -> void -ExcelReader.Core.Reader.Xlsb.XlsbWorkbook -ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Dispose() -> void -ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.DisposeAsync() -> System.Threading.Tasks.ValueTask -ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Enumerator -ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Enumerator.Current.get -> ExcelReader.Core.Reader.Row -ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Enumerator.MoveNext() -> bool -ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Enumerator.MoveNextAsync() -> System.Threading.Tasks.ValueTask -ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.FirstSheet.get -> ExcelReader.Core.Reader.Xlsb.XlsbSheet -ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.IsDate1904.get -> bool -ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.SheetCount.get -> int -ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Sheets.get -> ExcelReader.Core.Reader.ExcelSheetList! -ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.TryGetSheet(System.ReadOnlySpan name, out ExcelReader.Core.Reader.Xlsb.XlsbSheet sheet) -> bool -ExcelReader.Core.Reader.Xlsx.XlsxSheet -ExcelReader.Core.Reader.Xlsx.XlsxSheet.Equals(ExcelReader.Core.Reader.Xlsx.XlsxSheet other) -> bool -ExcelReader.Core.Reader.Xlsx.XlsxSheet.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Enumerator! -ExcelReader.Core.Reader.Xlsx.XlsxSheet.GetEnumerator() -> ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Enumerator! -ExcelReader.Core.Reader.Xlsx.XlsxSheet.Index.get -> int -ExcelReader.Core.Reader.Xlsx.XlsxSheet.IsDate1904.get -> bool -ExcelReader.Core.Reader.Xlsx.XlsxSheet.Name.get -> string! -ExcelReader.Core.Reader.Xlsx.XlsxSheet.Visibility.get -> ExcelReader.Core.ExcelSheetVisibility -ExcelReader.Core.Reader.Xlsx.XlsxSheet.XlsxSheet() -> void -ExcelReader.Core.Reader.Xlsx.XlsxWorkbook -ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Dispose() -> void -ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.DisposeAsync() -> System.Threading.Tasks.ValueTask -ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Enumerator -ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Enumerator.Current.get -> ExcelReader.Core.Reader.Row -ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Enumerator.MoveNext() -> bool -ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Enumerator.MoveNextAsync() -> System.Threading.Tasks.ValueTask -ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.FirstSheet.get -> ExcelReader.Core.Reader.Xlsx.XlsxSheet -ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.IsDate1904.get -> bool -ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.SheetCount.get -> int -ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Sheets.get -> ExcelReader.Core.Reader.ExcelSheetList! -ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.TryGetSheet(System.ReadOnlySpan name, out ExcelReader.Core.Reader.Xlsx.XlsxSheet sheet) -> bool -override ExcelReader.Core.Reader.Csv.CsvSheet.GetHashCode() -> int -override ExcelReader.Core.Reader.Xls.XlsSheet.GetHashCode() -> int -override ExcelReader.Core.Reader.Xlsb.XlsbSheet.GetHashCode() -> int -override ExcelReader.Core.Reader.Xlsx.XlsxSheet.GetHashCode() -> int -static ExcelReader.Core.Reader.Csv.CsvSheet.operator !=(ExcelReader.Core.Reader.Csv.CsvSheet left, ExcelReader.Core.Reader.Csv.CsvSheet right) -> bool -static ExcelReader.Core.Reader.Csv.CsvSheet.operator ==(ExcelReader.Core.Reader.Csv.CsvSheet left, ExcelReader.Core.Reader.Csv.CsvSheet right) -> bool -static ExcelReader.Core.Reader.Excel.FromXls(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xls.XlsWorkbook! -static ExcelReader.Core.Reader.Excel.FromXls(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xls.XlsWorkbook! -static ExcelReader.Core.Reader.Excel.FromXlsAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static ExcelReader.Core.Reader.Excel.FromXlsFile(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xls.XlsWorkbook! -static ExcelReader.Core.Reader.Excel.FromXlsFileAsync(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static ExcelReader.Core.Reader.Excel.FromXlsb(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsb.XlsbWorkbook! -static ExcelReader.Core.Reader.Excel.FromXlsb(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsb.XlsbWorkbook! -static ExcelReader.Core.Reader.Excel.FromXlsbAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static ExcelReader.Core.Reader.Excel.FromXlsbFile(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsb.XlsbWorkbook! -static ExcelReader.Core.Reader.Excel.FromXlsbFileAsync(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static ExcelReader.Core.Reader.Excel.FromXlsx(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsx.XlsxWorkbook! -static ExcelReader.Core.Reader.Excel.FromXlsx(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsx.XlsxWorkbook! -static ExcelReader.Core.Reader.Excel.FromXlsxAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static ExcelReader.Core.Reader.Excel.FromXlsxFile(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsx.XlsxWorkbook! -static ExcelReader.Core.Reader.Excel.FromXlsxFileAsync(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static ExcelReader.Core.Reader.Excel.InferSchema(ExcelReader.Core.Reader.IExcelSheet! sheet, int headerRow = 1, int sampleSize = 100, bool parseText = false) -> ExcelReader.Core.Reader.Schema.ExcelColumnSchema[]! -static ExcelReader.Core.Reader.Excel.InferSchema(ExcelReader.Core.Reader.IExcelSheet! sheet, int headerRow, int sampleSize) -> ExcelReader.Core.Reader.Schema.ExcelColumnSchema[]! -static ExcelReader.Core.Reader.Excel.Open(System.IO.Stream! stream, ExcelReader.Core.Reader.ExcelFileFormat format, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelWorkbook! -static ExcelReader.Core.Reader.Excel.Open(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelWorkbook! -static ExcelReader.Core.Reader.Excel.Open(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelFileFormat format, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelWorkbook! -static ExcelReader.Core.Reader.Excel.Open(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelWorkbook! -static ExcelReader.Core.Reader.Excel.Open(string! path, ExcelReader.Core.Reader.ExcelFileFormat format, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelWorkbook! -static ExcelReader.Core.Reader.Excel.Open(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelWorkbook! -static ExcelReader.Core.Reader.Excel.OpenAsync(System.IO.Stream! stream, ExcelReader.Core.Reader.ExcelFileFormat format, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static ExcelReader.Core.Reader.Excel.OpenAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static ExcelReader.Core.Reader.Excel.OpenAsync(string! path, ExcelReader.Core.Reader.ExcelFileFormat format, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static ExcelReader.Core.Reader.Excel.OpenAsync(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static ExcelReader.Core.Reader.Xls.XlsSheet.operator !=(ExcelReader.Core.Reader.Xls.XlsSheet left, ExcelReader.Core.Reader.Xls.XlsSheet right) -> bool -static ExcelReader.Core.Reader.Xls.XlsSheet.operator ==(ExcelReader.Core.Reader.Xls.XlsSheet left, ExcelReader.Core.Reader.Xls.XlsSheet right) -> bool -static ExcelReader.Core.Reader.Xlsb.XlsbSheet.operator !=(ExcelReader.Core.Reader.Xlsb.XlsbSheet left, ExcelReader.Core.Reader.Xlsb.XlsbSheet right) -> bool -static ExcelReader.Core.Reader.Xlsb.XlsbSheet.operator ==(ExcelReader.Core.Reader.Xlsb.XlsbSheet left, ExcelReader.Core.Reader.Xlsb.XlsbSheet right) -> bool -static ExcelReader.Core.Reader.Xlsx.XlsxSheet.operator !=(ExcelReader.Core.Reader.Xlsx.XlsxSheet left, ExcelReader.Core.Reader.Xlsx.XlsxSheet right) -> bool -static ExcelReader.Core.Reader.Xlsx.XlsxSheet.operator ==(ExcelReader.Core.Reader.Xlsx.XlsxSheet left, ExcelReader.Core.Reader.Xlsx.XlsxSheet right) -> bool -virtual ExcelReader.Core.Parser.AsyncRowEnumerator.DisposeAsync() -> System.Threading.Tasks.ValueTask -~ExcelReader.Core.Parser.ExcelEnumerable.GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Parser.ExcelEnumerable.AsyncEnumerator! -~ExcelReader.Core.Parser.ExcelEnumerable.GetEnumerator() -> ExcelReader.Core.Parser.ExcelEnumerable.Enumerator! -~override ExcelReader.Core.Reader.Csv.CsvSheet.Equals(object obj) -> bool -~override ExcelReader.Core.Reader.Csv.CsvSheet.ToString() -> string -~override ExcelReader.Core.Reader.Xls.XlsSheet.Equals(object obj) -> bool -~override ExcelReader.Core.Reader.Xls.XlsSheet.ToString() -> string -~override ExcelReader.Core.Reader.Xlsb.XlsbSheet.Equals(object obj) -> bool -~override ExcelReader.Core.Reader.Xlsb.XlsbSheet.ToString() -> string -~override ExcelReader.Core.Reader.Xlsx.XlsxSheet.Equals(object obj) -> bool -~override ExcelReader.Core.Reader.Xlsx.XlsxSheet.ToString() -> string From d86e4afb91d9960a5399cd3e6647124e22ad0e2a Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Wed, 7 Oct 2026 11:27:56 -0300 Subject: [PATCH 002/115] feat(python): enhance Arrow stream handling with GIL management and threading support --- python/src/excelreader/arrow_stream.py | 80 ++++++++++++++++++++++++-- python/tests/test_reader.py | 46 +++++++++++++++ 2 files changed, 120 insertions(+), 6 deletions(-) diff --git a/python/src/excelreader/arrow_stream.py b/python/src/excelreader/arrow_stream.py index bd3c0399..71396b0b 100644 --- a/python/src/excelreader/arrow_stream.py +++ b/python/src/excelreader/arrow_stream.py @@ -17,7 +17,62 @@ _capsule_pointer.argtypes = [ctypes.c_void_p, ctypes.c_char_p] _capsule_pointer.restype = ctypes.c_void_p -_live: dict[int, _native.ArrowArrayStream] = {} +_ReadFn = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p) +_ErrorFn = ctypes.CFUNCTYPE(ctypes.c_void_p, ctypes.c_void_p) +_ReleaseFn = ctypes.CFUNCTYPE(None, ctypes.c_void_p) + + +class _Target: + """The native stream behind a proxy, with its callbacks re-typed so ctypes releases the GIL.""" + + def __init__(self, stream: _native.ArrowArrayStream) -> None: + self.stream = stream + self.address = ctypes.addressof(stream) + self.get_schema = _ReadFn(_address_of(stream.get_schema)) + self.get_next = _ReadFn(_address_of(stream.get_next)) + self.get_last_error = _ErrorFn(_address_of(stream.get_last_error)) + + +def _address_of(function: object) -> int: + return ctypes.cast(function, ctypes.c_void_p).value + + +# A consumer may move the proxy out of the capsule, so callbacks find the native stream through +# private_data, which travels with the move, never through the address they are called with. +_streams: dict[int, _Target] = {} +_proxies: dict[int, _native.ArrowArrayStream] = {} + + +def _target(stream: int) -> _Target: + return _streams[_native.ArrowArrayStream.from_address(stream).private_data] + + +@_ReadFn +def _get_schema(stream: int, out: int) -> int: + target = _target(stream) + return target.get_schema(target.address, out) + + +@_ReadFn +def _get_next(stream: int, out: int) -> int: + target = _target(stream) + return target.get_next(target.address, out) + + +@_ErrorFn +def _get_last_error(stream: int) -> int | None: + target = _target(stream) + return target.get_last_error(target.address) + + +def _close(proxy: _native.ArrowArrayStream) -> None: + _release(_streams.pop(proxy.private_data).stream) + proxy.release = _native._ArrowStreamRelease() + + +@_ReleaseFn +def _release_proxy(stream: int) -> None: + _close(_native.ArrowArrayStream.from_address(stream)) def _release(stream: _native.ArrowArrayStream) -> None: @@ -25,10 +80,22 @@ def _release(stream: _native.ArrowArrayStream) -> None: stream.release(ctypes.byref(stream)) +def _proxy(stream: _native.ArrowArrayStream) -> _native.ArrowArrayStream: + target = _Target(stream) + _streams[id(target)] = target + proxy = _native.ArrowArrayStream() + proxy.get_schema = ctypes.cast(_get_schema, _native._ArrowStreamGetSchema) + proxy.get_next = ctypes.cast(_get_next, _native._ArrowStreamGetNext) + proxy.get_last_error = ctypes.cast(_get_last_error, _native._ArrowStreamGetLastError) + proxy.release = ctypes.cast(_release_proxy, _native._ArrowStreamRelease) + proxy.private_data = id(target) + return proxy + + def _destroy_capsule(capsule: int) -> None: - address = _capsule_pointer(capsule, _CAPSULE_NAME) - stream = _live.pop(address) - _release(stream) + proxy = _proxies.pop(_capsule_pointer(capsule, _CAPSULE_NAME)) + if proxy.release: + _close(proxy) _destroy_capsule_callback = ctypes.CFUNCTYPE(None, ctypes.c_void_p)(_destroy_capsule) @@ -49,8 +116,9 @@ def __arrow_c_stream__(self, requested_schema: object = None) -> object: if self._stream is None: raise ExcelReaderError("this Arrow stream was already consumed") stream, self._stream = self._stream, None - address = ctypes.addressof(stream) - _live[address] = stream + proxy = _proxy(stream) + address = ctypes.addressof(proxy) + _proxies[address] = proxy return _capsule_new(address, _CAPSULE_NAME, _destroy_capsule_callback) def __del__(self) -> None: diff --git a/python/tests/test_reader.py b/python/tests/test_reader.py index ad3a6476..d08c554c 100644 --- a/python/tests/test_reader.py +++ b/python/tests/test_reader.py @@ -1,5 +1,8 @@ +import ctypes import gc import shutil +import threading +import time from pathlib import Path import pytest @@ -16,6 +19,7 @@ open_bytes, open_workbook, ) +from excelreader import _native from excelreader import arrow_stream as arrow_stream_module from excelreader import reader as reader_module @@ -649,6 +653,48 @@ def test_a_dropped_capsule_releases_the_read(batched_csv, monkeypatch): assert released == [True] +def test_a_consumer_holding_the_gil_does_not_block_other_threads_while_a_batch_parses(tmp_path): + path = tmp_path / "large.csv" + rows = 1_000_000 + path.write_text("name,qty\n" + "".join(f"row{i},{i}\n" for i in range(rows)), encoding="utf-8") + ticks = [] + stop = threading.Event() + + def tick(): + while not stop.is_set(): + ticks.append(time.perf_counter()) + + with open_workbook(path) as workbook: + capsule = workbook.sheets[0].to_arrow_stream(_BATCH_SCHEMA, batch_size=rows).__arrow_c_stream__() + capsule_pointer = ctypes.PYFUNCTYPE(ctypes.c_void_p, ctypes.py_object, ctypes.c_char_p)( + ("PyCapsule_GetPointer", ctypes.pythonapi) + ) + address = capsule_pointer(capsule, b"arrow_array_stream") + stream = _native.ArrowArrayStream.from_address(address) + holding_gil = ctypes.PYFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p) + get_next = holding_gil(ctypes.cast(stream.get_next, ctypes.c_void_p).value) + batch = _native.ArrowArray() + ticker = threading.Thread(target=tick) + ticker.start() + try: + start = time.perf_counter() + status = get_next(address, ctypes.addressof(batch)) + end = time.perf_counter() + finally: + stop.set() + ticker.join() + release_batch = ctypes.CFUNCTYPE(None, ctypes.c_void_p)(batch.release) + release_batch(ctypes.addressof(batch)) + del capsule + + assert status == 0 + assert batch.length == rows + inside = [start] + [t for t in ticks if start < t < end] + [end] + longest_gap = max(b - a for a, b in zip(inside, inside[1:])) + assert end - start > 0.05, "the batch must parse long enough for the gap to mean something" + assert longest_gap < (end - start) / 2 + + def test_arrow_stream_rejects_a_negative_batch_size(batched_csv): with open_workbook(batched_csv) as workbook: with pytest.raises(ExcelReaderError): From 57dc08c87595fe8566b3424899ac85ad7f0aebe7 Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Wed, 7 Oct 2026 14:15:51 -0300 Subject: [PATCH 003/115] feat(core): open a workbook from any byte source through a seekable view Co-Authored-By: Claude Opus 5.5 --- .../Reader/Sources/ByteSource.cs | 4 + .../Reader/Sources/SourceStream.cs | 99 +++++++++++++ .../Reader/Sources/CountingSource.cs | 45 ++++++ .../Reader/Sources/SourceStreamTests.cs | 136 ++++++++++++++++++ 4 files changed, 284 insertions(+) create mode 100644 src/ExcelReader.Core/Reader/Sources/SourceStream.cs create mode 100644 tests/ExcelReader.Tests/Reader/Sources/CountingSource.cs create mode 100644 tests/ExcelReader.Tests/Reader/Sources/SourceStreamTests.cs diff --git a/src/ExcelReader.Core/Reader/Sources/ByteSource.cs b/src/ExcelReader.Core/Reader/Sources/ByteSource.cs index 1801ead6..b92b1e3f 100644 --- a/src/ExcelReader.Core/Reader/Sources/ByteSource.cs +++ b/src/ExcelReader.Core/Reader/Sources/ByteSource.cs @@ -114,6 +114,10 @@ internal static async ValueTask FromStreamAsync(Stream stream, bool private static bool TryWrapSeekable(Stream stream, bool leaveOpen, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out ByteSource? source) { + if (!leaveOpen && stream is SourceStream view && view.TryTakeSource(out source)) + { + return true; + } IDisposable? owner = leaveOpen ? null : stream; if (stream.GetType() == typeof(MemoryStream) && ((MemoryStream)stream).TryGetBuffer(out ArraySegment segment)) { diff --git a/src/ExcelReader.Core/Reader/Sources/SourceStream.cs b/src/ExcelReader.Core/Reader/Sources/SourceStream.cs new file mode 100644 index 00000000..99dfb19f --- /dev/null +++ b/src/ExcelReader.Core/Reader/Sources/SourceStream.cs @@ -0,0 +1,99 @@ +using System.Diagnostics.CodeAnalysis; + +namespace ExcelReader.Core.Reader.Sources +{ + /// + /// A seekable stream over a whole . takes the + /// source back out, so the paths that open a workbook from a stream read the source positionally. + /// + [SuppressMessage("Usage", "CA2213", Justification = "The source is disposed through Interlocked.Exchange so a taken source is not.")] + internal sealed class SourceStream(ByteSource source) : Stream + { + private readonly ByteSource _source = source; + private ByteSource? _owned = source; + private long _position; + + public override bool CanRead => true; + + public override bool CanSeek => true; + + public override bool CanWrite => false; + + public override long Length => _source.Length; + + public override long Position + { + get => _position; + set + { + ArgumentOutOfRangeException.ThrowIfNegative(value); + _position = value; + } + } + + internal bool TryTakeSource([NotNullWhen(true)] out ByteSource? taken) + { + taken = Interlocked.Exchange(ref _owned, null); + return taken is not null; + } + + public override int Read(Span buffer) + { + int read = _source.Read(_position, buffer); + _position += read; + return read; + } + + public override int Read(byte[] buffer, int offset, int count) + { + return Read(buffer.AsSpan(offset, count)); + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + int read = await _source.ReadAsync(_position, buffer, cancellationToken).ConfigureAwait(false); + _position += read; + return read; + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + + public override long Seek(long offset, SeekOrigin origin) + { + Position = origin switch + { + SeekOrigin.Begin => offset, + SeekOrigin.Current => _position + offset, + SeekOrigin.End => _source.Length + offset, + _ => throw new ArgumentOutOfRangeException(nameof(origin)), + }; + return _position; + } + + public override void Flush() + { + } + + 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) + { + if (disposing) + { + Interlocked.Exchange(ref _owned, null)?.Dispose(); + } + base.Dispose(disposing); + } + } +} diff --git a/tests/ExcelReader.Tests/Reader/Sources/CountingSource.cs b/tests/ExcelReader.Tests/Reader/Sources/CountingSource.cs new file mode 100644 index 00000000..920e0dc5 --- /dev/null +++ b/tests/ExcelReader.Tests/Reader/Sources/CountingSource.cs @@ -0,0 +1,45 @@ +using ExcelReader.Core.Reader.Sources; + +namespace ExcelReader.Tests.Reader.Sources +{ + internal sealed class CountingSource(byte[] bytes) : ByteSource + { + internal int Reads; + internal int Disposals; + internal int FailReads; + internal TimeSpan Delay { get; init; } + + internal override long Length => bytes.Length; + + internal override int Read(long offset, Span destination) + { + ArgumentOutOfRangeException.ThrowIfNegative(offset); + Interlocked.Increment(ref Reads); + if (Delay > TimeSpan.Zero) + { + Thread.Sleep(Delay); + } + if (Interlocked.Decrement(ref FailReads) >= 0) + { + throw new IOException("transient"); + } + if (offset >= bytes.Length) + { + return 0; + } + int count = (int)Math.Min(destination.Length, bytes.Length - offset); + bytes.AsSpan((int)offset, count).CopyTo(destination); + return count; + } + + internal override ValueTask ReadAsync(long offset, Memory destination, CancellationToken ct) + { + return new ValueTask(Read(offset, destination.Span)); + } + + public override void Dispose() + { + Interlocked.Increment(ref Disposals); + } + } +} diff --git a/tests/ExcelReader.Tests/Reader/Sources/SourceStreamTests.cs b/tests/ExcelReader.Tests/Reader/Sources/SourceStreamTests.cs new file mode 100644 index 00000000..0c381951 --- /dev/null +++ b/tests/ExcelReader.Tests/Reader/Sources/SourceStreamTests.cs @@ -0,0 +1,136 @@ +using System.Text; +using ExcelReader.Core.Reader; +using ExcelReader.Core.Reader.Sources; +using ExcelReader.Tests.Crypto; + +namespace ExcelReader.Tests.Reader.Sources +{ + public sealed class SourceStreamTests + { + public static TheoryData Formats => new() + { + { ConcurrentSheetTests.Format.Xlsx, ExcelFileFormat.Xlsx }, + { ConcurrentSheetTests.Format.Xlsb, ExcelFileFormat.Xlsb }, + { ConcurrentSheetTests.Format.Xls, ExcelFileFormat.Xls }, + { ConcurrentSheetTests.Format.Xlsx, ExcelFileFormat.Unknown }, + { ConcurrentSheetTests.Format.Xls, ExcelFileFormat.Unknown }, + }; + + public static TheoryData EncryptedNames => new(EncryptedFixtures.All); + + private static List Drain(IExcelSheet sheet) + { + List lines = []; + using IExcelRowEnumerator e = sheet.GetEnumerator(); + while (e.MoveNext()) + { + StringBuilder line = new(); + foreach (RowCell cell in e.Current.Cells) + { + line.Append(cell.Value.GetString()).Append('|'); + } + lines.Add(line.ToString()); + } + return lines; + } + + [Fact] + public void FromStream_Unwraps_A_SourceStream_That_Owns_Its_Source() + { + CountingSource source = new(new byte[100]); + using ByteSource unwrapped = ByteSource.FromStream(new SourceStream(source), leaveOpen: false); + Assert.Same(source, unwrapped); + } + + [Fact] + public void FromStream_Does_Not_Unwrap_A_Stream_The_Caller_Keeps() + { + using SourceStream stream = new(new CountingSource(new byte[100])); + using ByteSource wrapped = ByteSource.FromStream(stream, leaveOpen: true); + Assert.IsType(wrapped); + } + + [Fact] + public void A_Taken_Source_Belongs_To_Its_New_Owner() + { + CountingSource source = new(new byte[100]); + SourceStream stream = new(source); + ByteSource unwrapped = ByteSource.FromStream(stream, leaveOpen: false); + stream.Dispose(); + Assert.Equal(0, source.Disposals); + unwrapped.Dispose(); + Assert.Equal(1, source.Disposals); + } + + [Fact] + public void Disposing_A_Stream_Whose_Source_Was_Not_Taken_Disposes_The_Source() + { + CountingSource source = new(new byte[100]); + new SourceStream(source).Dispose(); + Assert.Equal(1, source.Disposals); + } + + [Fact] + public void Seek_And_Read_Follow_The_Source() + { + byte[] bytes = [.. Enumerable.Range(0, 256).Select(i => (byte)i)]; + using SourceStream stream = new(new CountingSource(bytes)); + Assert.True(stream.CanSeek); + Assert.Equal(256, stream.Length); + stream.Seek(-6, SeekOrigin.End); + byte[] tail = new byte[10]; + Assert.Equal(6, stream.Read(tail)); + Assert.Equal(bytes[250..], tail[..6]); + Assert.Equal(256, stream.Position); + } + + [Theory] + [MemberData(nameof(Formats))] + public void Sheets_Opened_Over_A_SourceStream_Read_In_Parallel_What_Memory_Reads( + ConcurrentSheetTests.Format format, ExcelFileFormat requested) + { + byte[] bytes = ConcurrentSheetTests.Build(format); + List[] expected; + using (IExcelWorkbook reference = Excel.Open(bytes)) + { + expected = [.. Enumerable.Range(0, reference.SheetCount).Select(s => Drain(reference.SheetAt(s)))]; + } + + CountingSource source = new(bytes); + using (IExcelWorkbook workbook = Excel.Open(new SourceStream(source), requested, leaveOpen: false)) + { + List[] parallel = new List[expected.Length * 2]; + Parallel.For(0, parallel.Length, i => parallel[i] = Drain(workbook.SheetAt(i % expected.Length))); + for (int i = 0; i < parallel.Length; i++) + { + Assert.Equal(expected[i % expected.Length], parallel[i]); + } + } + Assert.Equal(1, source.Disposals); + } + + [Fact] + public void A_Csv_Over_A_SourceStream_Reads_More_Than_Once() + { + byte[] bytes = Encoding.UTF8.GetBytes("a,b,c\n1,2,3\n4,5,6\n"); + using IExcelWorkbook csv = Excel.Open(new SourceStream(new CountingSource(bytes)), ExcelFileFormat.Csv, leaveOpen: false); + Assert.Equal(["a|b|c|", "1|2|3|", "4|5|6|"], Drain(csv.FirstSheet)); + Assert.Equal(Drain(csv.FirstSheet), Drain(csv.FirstSheet)); + } + + [Theory] + [MemberData(nameof(EncryptedNames))] + public void An_Encrypted_Package_Over_A_SourceStream_Opens_With_Its_Password(string name) + { + ExcelReaderOptions options = new() { Password = EncryptedFixtures.PasswordFor(name) }; + List expected; + using (IExcelWorkbook plain = Excel.Open(EncryptedFixtures.PlainBytes(name))) + { + expected = Drain(plain.FirstSheet); + } + using IExcelWorkbook workbook = Excel.Open( + new SourceStream(new CountingSource(EncryptedFixtures.Bytes(name))), ExcelFileFormat.Unknown, leaveOpen: false, options); + Assert.Equal(expected, Drain(workbook.FirstSheet)); + } + } +} From fcb2605b045d7f0fffe6e1a7e18e71d636dfd219 Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Wed, 7 Oct 2026 14:21:32 -0300 Subject: [PATCH 004/115] feat(core): cache a byte source in blocks fetched once Co-Authored-By: Claude Opus 5.5 --- .../Reader/Sources/CachedByteSource.cs | 115 ++++++++++++++++++ .../Reader/Sources/CachedByteSourceTests.cs | 89 ++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 src/ExcelReader.Core/Reader/Sources/CachedByteSource.cs create mode 100644 tests/ExcelReader.Tests/Reader/Sources/CachedByteSourceTests.cs diff --git a/src/ExcelReader.Core/Reader/Sources/CachedByteSource.cs b/src/ExcelReader.Core/Reader/Sources/CachedByteSource.cs new file mode 100644 index 00000000..a9daa8f9 --- /dev/null +++ b/src/ExcelReader.Core/Reader/Sources/CachedByteSource.cs @@ -0,0 +1,115 @@ +namespace ExcelReader.Core.Reader.Sources +{ + /// + /// Serves reads from blocks of blockSize bytes fetched once from an inner source, keeping the + /// most recently used blocks up to capacityBytes. + /// + internal sealed class CachedByteSource(ByteSource inner, int blockSize, long capacityBytes) : ByteSource + { + private readonly int _maxBlocks = (int)Math.Clamp(capacityBytes / blockSize, 1, int.MaxValue); + private readonly Dictionary _blocks = []; + private readonly Lock _gate = new(); + private long _clock; + private int _disposed; + + internal override long Length => inner.Length; + + internal override int Read(long offset, Span destination) + { + ArgumentOutOfRangeException.ThrowIfNegative(offset); + if (offset >= Length || destination.IsEmpty) + { + return 0; + } + long index = offset / blockSize; + byte[] block = Block(index); + int start = (int)(offset - (index * blockSize)); + int count = Math.Min(destination.Length, block.Length - start); + block.AsSpan(start, count).CopyTo(destination); + return count; + } + + internal override ValueTask ReadAsync(long offset, Memory destination, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + return new ValueTask(Read(offset, destination.Span)); + } + + public override void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + lock (_gate) + { + _blocks.Clear(); + } + inner.Dispose(); + } + + private byte[] Block(long index) + { + Entry entry; + lock (_gate) + { + if (!_blocks.TryGetValue(index, out entry!)) + { + entry = new Entry(new Lazy(() => Fetch(index), LazyThreadSafetyMode.ExecutionAndPublication)); + _blocks[index] = entry; + EvictBeyondCapacity(index); + } + entry.LastUse = ++_clock; + } + try + { + return entry.Data.Value; + } + catch + { + lock (_gate) + { + if (_blocks.TryGetValue(index, out Entry? current) && ReferenceEquals(current, entry)) + { + _blocks.Remove(index); + } + } + throw; + } + } + + private byte[] Fetch(long index) + { + long start = index * blockSize; + byte[] data = new byte[(int)Math.Min(blockSize, Length - start)]; + inner.ReadExactly(start, data); + return data; + } + + // ponytail: linear scan for the oldest block; fine for the default 16 blocks, a linked list if capacities grow. + private void EvictBeyondCapacity(long keep) + { + while (_blocks.Count > _maxBlocks) + { + long oldest = -1; + long oldestUse = long.MaxValue; + foreach ((long key, Entry candidate) in _blocks) + { + if (key != keep && candidate.LastUse < oldestUse) + { + oldest = key; + oldestUse = candidate.LastUse; + } + } + _blocks.Remove(oldest); + } + } + + private sealed class Entry(Lazy data) + { + internal Lazy Data { get; } = data; + + internal long LastUse; + } + } +} diff --git a/tests/ExcelReader.Tests/Reader/Sources/CachedByteSourceTests.cs b/tests/ExcelReader.Tests/Reader/Sources/CachedByteSourceTests.cs new file mode 100644 index 00000000..e8d81494 --- /dev/null +++ b/tests/ExcelReader.Tests/Reader/Sources/CachedByteSourceTests.cs @@ -0,0 +1,89 @@ +using ExcelReader.Core.Reader.Sources; + +namespace ExcelReader.Tests.Reader.Sources +{ + public sealed class CachedByteSourceTests + { + private static readonly byte[] Payload = [.. Enumerable.Range(0, 10_000).Select(i => (byte)(i * 7))]; + + [Fact] + public void ReadExactly_Across_Blocks_Returns_The_Inner_Bytes() + { + using CachedByteSource cache = new(new CountingSource(Payload), blockSize: 1024, capacityBytes: 1 << 20); + byte[] read = new byte[3000]; + cache.ReadExactly(500, read); + Assert.Equal(Payload[500..3500], read); + } + + [Fact] + public void Each_Block_Is_Fetched_Once_While_It_Fits() + { + CountingSource inner = new(Payload); + using CachedByteSource cache = new(inner, blockSize: 1024, capacityBytes: 1 << 20); + byte[] chunk = new byte[100]; + for (int pass = 0; pass < 2; pass++) + { + for (int offset = 0; offset < Payload.Length; offset += chunk.Length) + { + cache.ReadExactly(offset, chunk); + } + } + Assert.Equal(10, inner.Reads); + } + + [Fact] + public void Concurrent_Readers_Of_One_Block_Fetch_It_Once() + { + CountingSource inner = new(Payload) { Delay = TimeSpan.FromMilliseconds(50) }; + using CachedByteSource cache = new(inner, blockSize: 4096, capacityBytes: 1 << 20); + Parallel.For(0, 16, _ => + { + byte[] read = new byte[64]; + cache.ReadExactly(0, read); + }); + Assert.Equal(1, inner.Reads); + } + + [Fact] + public void The_Least_Recently_Used_Block_Is_Evicted_Past_Capacity() + { + CountingSource inner = new(Payload); + using CachedByteSource cache = new(inner, blockSize: 1024, capacityBytes: 2048); + byte[] one = new byte[1]; + foreach (long offset in new long[] { 0, 1024, 2048, 1024, 0 }) + { + cache.ReadExactly(offset, one); + } + Assert.Equal(4, inner.Reads); + } + + [Fact] + public void A_Read_At_Or_Past_The_End_Returns_Zero() + { + using CachedByteSource cache = new(new CountingSource(Payload), blockSize: 1024, capacityBytes: 1 << 20); + Assert.Equal(0, cache.Read(Payload.Length, new byte[10])); + Assert.Equal(0, cache.Read(Payload.Length + 5, new byte[10])); + } + + [Fact] + public void A_Failed_Fetch_Is_Tried_Again() + { + CountingSource inner = new(Payload) { FailReads = 1 }; + using CachedByteSource cache = new(inner, blockSize: 1024, capacityBytes: 1 << 20); + byte[] read = new byte[10]; + Assert.Throws(() => cache.ReadExactly(0, read)); + cache.ReadExactly(0, read); + Assert.Equal(Payload[..10], read); + } + + [Fact] + public void Dispose_Disposes_The_Inner_Source_Once() + { + CountingSource inner = new(Payload); + CachedByteSource cache = new(inner, blockSize: 1024, capacityBytes: 1 << 20); + cache.Dispose(); + cache.Dispose(); + Assert.Equal(1, inner.Disposals); + } + } +} From 5574daa78bb9f26eba541fc2f099caa035166ad0 Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Wed, 7 Oct 2026 14:27:09 -0300 Subject: [PATCH 005/115] feat(native): add source and buffering options and keep accepting the 6.0 options layout Co-Authored-By: Claude Opus 5.5 --- src/ExcelReader.Native/Exports.Reading.cs | 10 +-- src/ExcelReader.Native/NativeOpenOptions.cs | 35 +++++++- src/ExcelReader.Native/include/excelreader.h | 12 ++- .../Native/OpenOptionsLayoutTests.cs | 90 +++++++++++++++++++ 4 files changed, 139 insertions(+), 8 deletions(-) create mode 100644 tests/ExcelReader.Tests/Native/OpenOptionsLayoutTests.cs diff --git a/src/ExcelReader.Native/Exports.Reading.cs b/src/ExcelReader.Native/Exports.Reading.cs index 4bb34f11..c8715c42 100644 --- a/src/ExcelReader.Native/Exports.Reading.cs +++ b/src/ExcelReader.Native/Exports.Reading.cs @@ -295,19 +295,17 @@ private static bool IsValidOpenRequest(byte* source, int sourceLength, nint* out private static bool TryReadOpenOptions(NativeOpenOptionsRaw* options, out NativeOpenOptionsRaw? rawOptions) { + rawOptions = null; if (options is null) { - rawOptions = null; return true; } - int expectedSize = sizeof(NativeOpenOptionsRaw); - if (options->StructSize != expectedSize) + if (!NativeOpenOptionsRaw.TryRead(options, out NativeOpenOptionsRaw raw, out string? error)) { - NativeApi.SetLastError($"xl_open_options.struct_size is {options->StructSize}, but this library expects {expectedSize}."); - rawOptions = null; + NativeApi.SetLastError(error); return false; } - rawOptions = *options; + rawOptions = raw; return true; } diff --git a/src/ExcelReader.Native/NativeOpenOptions.cs b/src/ExcelReader.Native/NativeOpenOptions.cs index d15582a8..3dd7b97e 100644 --- a/src/ExcelReader.Native/NativeOpenOptions.cs +++ b/src/ExcelReader.Native/NativeOpenOptions.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Numerics; using System.Runtime.InteropServices; using ExcelReader.Core.Reader; @@ -69,6 +70,29 @@ internal struct NativeOpenOptionsRaw public IntPtr Password; public int PasswordLen; + + public long SourceCacheBytes; + public long MaxBufferedBytes; + public int SourceBlockSize; + + internal static readonly int V6Size = (int)Marshal.OffsetOf(nameof(SourceCacheBytes)); + + internal static unsafe bool TryRead(NativeOpenOptionsRaw* options, out NativeOpenOptionsRaw raw, [NotNullWhen(false)] out string? error) + { + raw = default; + int size = options->StructSize; + if (size != sizeof(NativeOpenOptionsRaw) && size != V6Size) + { + error = $"xl_open_options.struct_size is {size}, but this library expects {sizeof(NativeOpenOptionsRaw)} (or {V6Size} for the 6.0 layout)."; + return false; + } + NativeOpenOptionsRaw copy = default; + Buffer.MemoryCopy(options, ©, sizeof(NativeOpenOptionsRaw), size); + copy.StructSize = sizeof(NativeOpenOptionsRaw); + raw = copy; + error = null; + return true; + } } internal readonly struct NativeOpenOptions @@ -93,6 +117,10 @@ internal readonly struct NativeOpenOptions internal string? Password { get; init; } + internal int? SourceBlockSize { get; init; } + internal long? SourceCacheBytes { get; init; } + internal long? MaxBufferedBytes { get; init; } + internal CsvReaderOptions ToCsvReaderOptions() { CsvReaderOptions options = CsvSniffDialect ? CsvReaderOptions.Default with { SniffDialect = true } : CsvReaderOptions.Default; @@ -170,7 +198,9 @@ internal static bool TryDecode(NativeOpenOptionsRaw raw, out NativeOpenOptions o || !TryDecodeNonNegative(raw.MaxCellBytes, "max_cell_bytes", out int? maxCellBytes, out error) || !TryDecodeNonNegative(raw.MaxZipEntries, "max_zip_entries", out int? maxZipEntries, out error) || !TryDecodeNonNegative(raw.MaxTotalDecompressedBytes, "max_total_decompressed_bytes", out long? maxTotal, out error) - || !TryDecodeNonNegative(raw.MaxSharedStringBytes, "max_shared_string_bytes", out long? maxSharedStrings, out error)) + || !TryDecodeNonNegative(raw.MaxSharedStringBytes, "max_shared_string_bytes", out long? maxSharedStrings, out error) + || !TryDecodeNonNegative(raw.SourceCacheBytes, "source_cache_bytes", out long? sourceCacheBytes, out error) + || !TryDecodeNonNegative(raw.MaxBufferedBytes, "max_buffered_bytes", out long? maxBufferedBytes, out error)) { return false; } @@ -218,6 +248,9 @@ internal static bool TryDecode(NativeOpenOptionsRaw raw, out NativeOpenOptions o PrefetchDecompression = prefetch, InternStrings = internStrings, Password = password, + SourceBlockSize = raw.SourceBlockSize == 0 ? null : raw.SourceBlockSize, + SourceCacheBytes = sourceCacheBytes, + MaxBufferedBytes = maxBufferedBytes, }; return true; } diff --git a/src/ExcelReader.Native/include/excelreader.h b/src/ExcelReader.Native/include/excelreader.h index 2c857eee..81b02098 100644 --- a/src/ExcelReader.Native/include/excelreader.h +++ b/src/ExcelReader.Native/include/excelreader.h @@ -79,10 +79,20 @@ typedef struct xl_open_options { const uint8_t* password; int32_t password_len; + + /* xl_open_source only: bytes of blocks kept in memory. 0 is the default, 64 MiB. */ + int64_t source_cache_bytes; + /* xl_open_stream with XLSX, XLSB or XLS only: the most bytes the stream may hold. 0 is the + default, the .NET array limit (about 2 GB). */ + int64_t max_buffered_bytes; + /* xl_open_source only: bytes fetched per read_at call. 0 is the default, 4 MiB; negative turns + the cache off, so every read the library makes reaches read_at. */ + int32_t source_block_size; } xl_open_options; /* options may be NULL for the defaults. When it is not NULL, set options->struct_size to - sizeof(xl_open_options) first. xl_open_memory copies data, so the buffer may be released as soon + sizeof(xl_open_options) first. The 6.0 layout, which ends at password_len, is still accepted. + xl_open_memory copies data, so the buffer may be released as soon as the call returns. */ int32_t xl_open_file(const uint8_t* path, int32_t path_len, int32_t format, const xl_open_options* options, xl_workbook** out_handle); diff --git a/tests/ExcelReader.Tests/Native/OpenOptionsLayoutTests.cs b/tests/ExcelReader.Tests/Native/OpenOptionsLayoutTests.cs new file mode 100644 index 00000000..bcd37ae9 --- /dev/null +++ b/tests/ExcelReader.Tests/Native/OpenOptionsLayoutTests.cs @@ -0,0 +1,90 @@ +using ExcelReader.Native; + +namespace ExcelReader.Tests.Native +{ + public sealed unsafe class OpenOptionsLayoutTests + { + [Fact] + public void The_6_0_Layout_Ends_Where_The_New_Fields_Begin() + { + Assert.Equal(88, NativeOpenOptionsRaw.V6Size); + Assert.Equal(112, sizeof(NativeOpenOptionsRaw)); + } + + [Fact] + public void A_6_0_Sized_Struct_Is_Read_As_A_Prefix() + { + NativeOpenOptionsRaw caller = new() + { + StructSize = NativeOpenOptionsRaw.V6Size, + MaxCellBytes = 1234, + SourceCacheBytes = -99, + MaxBufferedBytes = -99, + SourceBlockSize = -99, + }; + Assert.True(NativeOpenOptionsRaw.TryRead(&caller, out NativeOpenOptionsRaw raw, out string? error), error); + Assert.Equal(1234, raw.MaxCellBytes); + Assert.Equal(0, raw.SourceCacheBytes); + Assert.Equal(0, raw.MaxBufferedBytes); + Assert.Equal(0, raw.SourceBlockSize); + Assert.Equal(sizeof(NativeOpenOptionsRaw), raw.StructSize); + } + + [Fact] + public void A_Full_Sized_Struct_Is_Read_Whole() + { + NativeOpenOptionsRaw caller = new() + { + StructSize = sizeof(NativeOpenOptionsRaw), + SourceCacheBytes = 1 << 20, + MaxBufferedBytes = 4096, + SourceBlockSize = -1, + }; + Assert.True(NativeOpenOptionsRaw.TryRead(&caller, out NativeOpenOptionsRaw raw, out _)); + Assert.Equal(1 << 20, raw.SourceCacheBytes); + Assert.Equal(4096, raw.MaxBufferedBytes); + Assert.Equal(-1, raw.SourceBlockSize); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(87)] + [InlineData(113)] + public void Any_Other_Size_Is_Rejected(int size) + { + NativeOpenOptionsRaw caller = new() { StructSize = size }; + Assert.False(NativeOpenOptionsRaw.TryRead(&caller, out _, out string? error)); + Assert.Contains("struct_size", error, StringComparison.Ordinal); + } + + [Fact] + public void Decoding_Keeps_A_Negative_Block_Size_And_Rejects_Negative_Byte_Counts() + { + NativeOpenOptionsRaw raw = new() + { + StructSize = sizeof(NativeOpenOptionsRaw), + SourceBlockSize = -1, + SourceCacheBytes = 1 << 20, + MaxBufferedBytes = 10, + }; + Assert.True(NativeOpenOptions.TryDecode(raw, out NativeOpenOptions options, out string? error), error); + Assert.Equal(-1, options.SourceBlockSize); + Assert.Equal(1 << 20, options.SourceCacheBytes); + Assert.Equal(10, options.MaxBufferedBytes); + + Assert.False(NativeOpenOptions.TryDecode(raw with { SourceCacheBytes = -1 }, out _, out _)); + Assert.False(NativeOpenOptions.TryDecode(raw with { MaxBufferedBytes = -1 }, out _, out _)); + } + + [Fact] + public void Zero_Leaves_Every_New_Field_At_Its_Default() + { + NativeOpenOptionsRaw raw = new() { StructSize = sizeof(NativeOpenOptionsRaw) }; + Assert.True(NativeOpenOptions.TryDecode(raw, out NativeOpenOptions options, out _)); + Assert.Null(options.SourceBlockSize); + Assert.Null(options.SourceCacheBytes); + Assert.Null(options.MaxBufferedBytes); + } + } +} From d46ec618602003e8781d11b64862b4ece34865ba Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Wed, 7 Oct 2026 14:30:26 -0300 Subject: [PATCH 006/115] fix(native): make the 6.0 options size a constant and document it Co-Authored-By: Claude Opus 5.5 --- src/ExcelReader.Native/NativeOpenOptions.cs | 2 +- src/ExcelReader.Native/README.md | 5 +++-- tests/ExcelReader.Tests/Native/OpenOptionsLayoutTests.cs | 1 + 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/ExcelReader.Native/NativeOpenOptions.cs b/src/ExcelReader.Native/NativeOpenOptions.cs index 3dd7b97e..bf7e0d8c 100644 --- a/src/ExcelReader.Native/NativeOpenOptions.cs +++ b/src/ExcelReader.Native/NativeOpenOptions.cs @@ -75,7 +75,7 @@ internal struct NativeOpenOptionsRaw public long MaxBufferedBytes; public int SourceBlockSize; - internal static readonly int V6Size = (int)Marshal.OffsetOf(nameof(SourceCacheBytes)); + internal const int V6Size = 88; internal static unsafe bool TryRead(NativeOpenOptionsRaw* options, out NativeOpenOptionsRaw raw, [NotNullWhen(false)] out string? error) { diff --git a/src/ExcelReader.Native/README.md b/src/ExcelReader.Native/README.md index e9cfa05c..fb8a4b7d 100644 --- a/src/ExcelReader.Native/README.md +++ b/src/ExcelReader.Native/README.md @@ -133,8 +133,9 @@ if (status == XL_STATUS_PASSWORD_REQUIRED || status == XL_STATUS_PASSWORD_INCORR This is why `XL_ABI_VERSION` moved from 3 to 4: `password`/`password_len` are new fields at the end of `xl_open_options`, so a caller built against the old, smaller struct passes the old, smaller -`sizeof(xl_open_options)` as `struct_size` — the mismatch is rejected outright with -`XL_INVALID_ARGUMENT` instead of the library reading two garbage fields past the end of the caller's +`sizeof(xl_open_options)` as `struct_size` — any size other than the current one is rejected outright with +`XL_INVALID_ARGUMENT` (the 6.0 size, 88 bytes, is the one exception: it is also accepted, read as a prefix +with the newer fields defaulted) instead of the library reading two garbage fields past the end of the caller's allocation. Check `xl_abi_version()` against `XL_ABI_VERSION` (see above) and rebuild against the current header rather than only relying on the `struct_size` check to catch it. diff --git a/tests/ExcelReader.Tests/Native/OpenOptionsLayoutTests.cs b/tests/ExcelReader.Tests/Native/OpenOptionsLayoutTests.cs index bcd37ae9..e3e690fd 100644 --- a/tests/ExcelReader.Tests/Native/OpenOptionsLayoutTests.cs +++ b/tests/ExcelReader.Tests/Native/OpenOptionsLayoutTests.cs @@ -8,6 +8,7 @@ public sealed unsafe class OpenOptionsLayoutTests public void The_6_0_Layout_Ends_Where_The_New_Fields_Begin() { Assert.Equal(88, NativeOpenOptionsRaw.V6Size); + Assert.Equal(NativeOpenOptionsRaw.V6Size, (int)System.Runtime.InteropServices.Marshal.OffsetOf(nameof(NativeOpenOptionsRaw.SourceCacheBytes))); Assert.Equal(112, sizeof(NativeOpenOptionsRaw)); } From 6bc46a04703075dcd21c414f0333f600eb24b229 Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Wed, 7 Oct 2026 14:39:51 -0300 Subject: [PATCH 007/115] feat(native): open a workbook over a source the caller serves Co-Authored-By: Claude Opus 5.5 --- rust/excelreader/excelreader.def | 2 + src/ExcelReader.Native/Exports.Reading.cs | 29 ++ .../Reading/CallbackByteSource.cs | 77 +++++ .../Reading/NativeSourceRaw.cs | 23 ++ .../Reading/ReadApi.Sources.cs | 55 ++++ .../Reading/SourceErrors.cs | 28 ++ .../include/excelreader.def | 2 + src/ExcelReader.Native/include/excelreader.h | 25 ++ .../Native/CallbackSourceTests.cs | 301 ++++++++++++++++++ 9 files changed, 542 insertions(+) create mode 100644 src/ExcelReader.Native/Reading/CallbackByteSource.cs create mode 100644 src/ExcelReader.Native/Reading/NativeSourceRaw.cs create mode 100644 src/ExcelReader.Native/Reading/ReadApi.Sources.cs create mode 100644 src/ExcelReader.Native/Reading/SourceErrors.cs create mode 100644 tests/ExcelReader.Tests/Native/CallbackSourceTests.cs diff --git a/rust/excelreader/excelreader.def b/rust/excelreader/excelreader.def index 29729e2d..7b2dccdf 100644 --- a/rust/excelreader/excelreader.def +++ b/rust/excelreader/excelreader.def @@ -2,6 +2,8 @@ EXPORTS xl_abi_version xl_open_file xl_open_memory + xl_open_source + xl_set_source_error xl_close xl_sheet_count xl_sheet_name_at diff --git a/src/ExcelReader.Native/Exports.Reading.cs b/src/ExcelReader.Native/Exports.Reading.cs index c8715c42..09690c20 100644 --- a/src/ExcelReader.Native/Exports.Reading.cs +++ b/src/ExcelReader.Native/Exports.Reading.cs @@ -1,4 +1,5 @@ using System.Runtime.InteropServices; +using System.Text; using ExcelReader.Native.Reading; namespace ExcelReader.Native @@ -49,6 +50,34 @@ public static int OpenMemory(byte* data, int dataLength, int format, NativeOpenO return RegisterOpened(status, handle, outHandle); } + [UnmanagedCallersOnly(EntryPoint = "xl_open_source")] + public static int OpenSource(NativeSourceRaw* source, int format, NativeOpenOptionsRaw* options, nint* outHandle) + { + NativeApi.ClearLastError(); + if (outHandle is not null) + { + *outHandle = 0; + } + if (!CallbackByteSource.TryCreate(source, outHandle is not null, out CallbackByteSource? callback, out string? error)) + { + NativeApi.SetLastError(error); + return NativeStatus.InvalidArgument; + } + if (!TryReadOpenOptions(options, out NativeOpenOptionsRaw? rawOptions)) + { + callback.Dispose(); + return NativeStatus.InvalidArgument; + } + int status = ReadApi.OpenSource(callback, format, rawOptions, out NativeHandle? handle); + return RegisterOpened(status, handle, outHandle); + } + + [UnmanagedCallersOnly(EntryPoint = "xl_set_source_error")] + public static void SetSourceError(byte* message, int length) + { + SourceErrors.Set(message is null || length <= 0 ? null : Encoding.UTF8.GetString(message, length)); + } + [UnmanagedCallersOnly(EntryPoint = "xl_close")] public static int Close(nint handle) { diff --git a/src/ExcelReader.Native/Reading/CallbackByteSource.cs b/src/ExcelReader.Native/Reading/CallbackByteSource.cs new file mode 100644 index 00000000..2ddc3382 --- /dev/null +++ b/src/ExcelReader.Native/Reading/CallbackByteSource.cs @@ -0,0 +1,77 @@ +using System.Diagnostics.CodeAnalysis; +using ExcelReader.Core.Reader.Sources; + +namespace ExcelReader.Native.Reading +{ + internal sealed unsafe class CallbackByteSource : ByteSource + { + private readonly delegate* unmanaged _readAt; + private readonly delegate* unmanaged _release; + private readonly void* _userData; + private int _released; + + private CallbackByteSource(in NativeSourceRaw raw) + { + _readAt = raw.ReadAt; + _release = raw.Release; + _userData = raw.UserData; + Length = raw.Length; + } + + internal override long Length { get; } + + internal static bool TryCreate( + NativeSourceRaw* raw, bool hasOutHandle, [NotNullWhen(true)] out CallbackByteSource? source, [NotNullWhen(false)] out string? error) + { + source = null; + if (raw is null || !hasOutHandle || raw->StructSize != sizeof(NativeSourceRaw) || raw->ReadAt is null || raw->Length < 0) + { + error = "xl_open_source needs a non-NULL source whose struct_size is sizeof(xl_source), whose read_at is set and whose length is 0 or more, and a non-NULL out_handle."; + return false; + } + error = null; + source = new CallbackByteSource(*raw); + return true; + } + + internal override int Read(long offset, Span destination) + { + ArgumentOutOfRangeException.ThrowIfNegative(offset); + ObjectDisposedException.ThrowIf(Volatile.Read(ref _released) != 0, this); + if (offset >= Length || destination.IsEmpty) + { + return 0; + } + int requested = (int)Math.Min(destination.Length, Length - offset); + SourceErrors.Clear(); + long got; + fixed (byte* buffer = destination) + { + got = _readAt(_userData, offset, buffer, requested); + } + if (got < 0 || got > requested) + { + throw SourceErrors.Failed($"xl_source.read_at at offset {offset}", got, requested); + } + if (got == 0) + { + throw new IOException($"The source ended at offset {offset}, before its declared length of {Length} bytes."); + } + return (int)got; + } + + internal override ValueTask ReadAsync(long offset, Memory destination, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + return new ValueTask(Read(offset, destination.Span)); + } + + public override void Dispose() + { + if (Interlocked.Exchange(ref _released, 1) == 0 && _release is not null) + { + _release(_userData); + } + } + } +} diff --git a/src/ExcelReader.Native/Reading/NativeSourceRaw.cs b/src/ExcelReader.Native/Reading/NativeSourceRaw.cs new file mode 100644 index 00000000..42cd707e --- /dev/null +++ b/src/ExcelReader.Native/Reading/NativeSourceRaw.cs @@ -0,0 +1,23 @@ +using System.Runtime.InteropServices; + +namespace ExcelReader.Native.Reading +{ + [StructLayout(LayoutKind.Sequential)] + internal unsafe struct NativeSourceRaw + { + public int StructSize; + public void* UserData; + public long Length; + public delegate* unmanaged ReadAt; + public delegate* unmanaged Release; + } + + [StructLayout(LayoutKind.Sequential)] + internal unsafe struct NativeStreamRaw + { + public int StructSize; + public void* UserData; + public delegate* unmanaged Read; + public delegate* unmanaged Release; + } +} diff --git a/src/ExcelReader.Native/Reading/ReadApi.Sources.cs b/src/ExcelReader.Native/Reading/ReadApi.Sources.cs new file mode 100644 index 00000000..3b65e277 --- /dev/null +++ b/src/ExcelReader.Native/Reading/ReadApi.Sources.cs @@ -0,0 +1,55 @@ +using ExcelReader.Core.Reader; +using ExcelReader.Core.Reader.Sources; + +namespace ExcelReader.Native.Reading +{ + internal static partial class ReadApi + { + private const int DefaultSourceBlockSize = 4 * 1024 * 1024; + private const long DefaultSourceCacheBytes = 64L * 1024 * 1024; + + internal static int OpenSource(ByteSource callback, int format, NativeOpenOptionsRaw? rawOptions, out NativeHandle? handle) + { + handle = null; + bool opened = false; + try + { + if (!TryDecodeOpenOptions(rawOptions, out NativeOpenOptions? decoded, out string? error)) + { + NativeApi.SetLastError(error!); + return NativeStatus.InvalidArgument; + } + if (!IsKnownFormat(format)) + { + NativeApi.SetLastError($"format must be one of the XL_FORMAT_* values; got {format}."); + return NativeStatus.InvalidArgument; + } + NativeOpenOptions options = decoded ?? default; + int blockSize = options.SourceBlockSize ?? DefaultSourceBlockSize; + ByteSource source = blockSize < 0 + ? callback + : new CachedByteSource(callback, blockSize, options.SourceCacheBytes ?? DefaultSourceCacheBytes); + IExcelWorkbook workbook = Excel.Open(new SourceStream(source), MapFormat(format), leaveOpen: false, options.ToExcelReaderOptions()); + handle = new NativeHandle(workbook); + opened = true; + return NativeStatus.Ok; + } + catch (ExcelEncryptionException ex) + { + return MapEncryptionException(ex); + } + catch (Exception exception) + { + NativeApi.SetLastError(exception.Message); + return NativeStatus.Error; + } + finally + { + if (!opened) + { + callback.Dispose(); + } + } + } + } +} diff --git a/src/ExcelReader.Native/Reading/SourceErrors.cs b/src/ExcelReader.Native/Reading/SourceErrors.cs new file mode 100644 index 00000000..65d75ae7 --- /dev/null +++ b/src/ExcelReader.Native/Reading/SourceErrors.cs @@ -0,0 +1,28 @@ +namespace ExcelReader.Native.Reading +{ + /// The message a source callback leaves through xl_set_source_error before returning -1. + internal static class SourceErrors + { + [ThreadStatic] + private static string? _pending; + + internal static void Set(string? message) + { + _pending = string.IsNullOrEmpty(message) ? null : message; + } + + internal static void Clear() + { + _pending = null; + } + + internal static IOException Failed(string where, long got, long requested) + { + string? message = _pending; + _pending = null; + return got == -1 + ? new IOException(message ?? $"{where} failed.") + : new IOException($"{where} returned {got} for a request of {requested} bytes; expected 0 to {requested}, or -1 on failure."); + } + } +} diff --git a/src/ExcelReader.Native/include/excelreader.def b/src/ExcelReader.Native/include/excelreader.def index 29729e2d..7b2dccdf 100644 --- a/src/ExcelReader.Native/include/excelreader.def +++ b/src/ExcelReader.Native/include/excelreader.def @@ -2,6 +2,8 @@ EXPORTS xl_abi_version xl_open_file xl_open_memory + xl_open_source + xl_set_source_error xl_close xl_sheet_count xl_sheet_name_at diff --git a/src/ExcelReader.Native/include/excelreader.h b/src/ExcelReader.Native/include/excelreader.h index 81b02098..895fa8a2 100644 --- a/src/ExcelReader.Native/include/excelreader.h +++ b/src/ExcelReader.Native/include/excelreader.h @@ -100,6 +100,31 @@ int32_t xl_open_file(const uint8_t* path, int32_t path_len, int32_t format, int32_t xl_open_memory(const uint8_t* data, int32_t data_len, int32_t format, const xl_open_options* options, xl_workbook** out_handle); +/* Random-access bytes for xl_open_source. */ +typedef struct xl_source { + int32_t struct_size; /* sizeof(xl_source) */ + void* user_data; + int64_t length; /* total size in bytes */ + /* Copy up to len bytes starting at offset into buf. Return the bytes copied, or -1 on failure + after calling xl_set_source_error. Returning 0 before length is a failure ("the source ended + early"). Called from any thread, concurrently. */ + int64_t (*read_at)(void* user_data, int64_t offset, uint8_t* buf, int64_t len); + /* May be NULL. Called exactly once, when nothing opened from the workbook needs the source. */ + void (*release)(void* user_data); +} xl_source; + +/* Opens a workbook over a source the caller serves. Every sheet can be read in parallel. A valid + source is released exactly once, whether the open succeeds or fails; a NULL source, a wrong + struct_size, a NULL read_at, a negative length or a NULL out_handle returns XL_INVALID_ARGUMENT + and the source is not released. With the default options, reads reach read_at in 4 MiB blocks + kept in a 64 MiB cache. */ +int32_t xl_open_source(const xl_source* source, int32_t format, + const xl_open_options* options, xl_workbook** out_handle); + +/* Callable only from inside read_at or read: the message xl_last_error reports when the callback + then returns -1. Inside a callback, no other xl_ function may be called. */ +void xl_set_source_error(const uint8_t* message, int32_t len); + /* Cursors, typed readers and Arrow streams opened from this handle keep working after xl_close; the workbook's resources are released when the last of them is closed. Any later call that takes this handle returns XL_INVALID_HANDLE. */ diff --git a/tests/ExcelReader.Tests/Native/CallbackSourceTests.cs b/tests/ExcelReader.Tests/Native/CallbackSourceTests.cs new file mode 100644 index 00000000..01b84fa2 --- /dev/null +++ b/tests/ExcelReader.Tests/Native/CallbackSourceTests.cs @@ -0,0 +1,301 @@ +using System.Globalization; +using System.Runtime.InteropServices; +using System.Text; +using ExcelReader.Core.Writer.Xlsx; +using ExcelReader.Native; +using ExcelReader.Native.Reading; +using ExcelReader.Tests.Reader; + +namespace ExcelReader.Tests.Native +{ + public sealed unsafe class CallbackSourceTests + { + internal sealed class TestSource(byte[] bytes) + { + internal readonly byte[] Bytes = bytes; + internal int Reads; + internal int Releases; + internal string? Error; + internal long ShortAt = -1; + internal int FailOffThread; + } + + private sealed class Pinned(TestSource source) : IDisposable + { + private GCHandle _handle = GCHandle.Alloc(source); + + internal NativeSourceRaw Raw(long? length = null) => new() + { + StructSize = sizeof(NativeSourceRaw), + UserData = (void*)GCHandle.ToIntPtr(_handle), + Length = length ?? source.Bytes.Length, + ReadAt = &ReadAt, + Release = &Release, + }; + + public void Dispose() + { + _handle.Free(); + } + } + + [UnmanagedCallersOnly] + private static long ReadAt(void* userData, long offset, byte* buffer, long length) + { + TestSource source = (TestSource)GCHandle.FromIntPtr((nint)userData).Target!; + Interlocked.Increment(ref source.Reads); + if (source.Error is string message && Environment.CurrentManagedThreadId != source.FailOffThread) + { + SourceErrors.Set(message); + return -1; + } + if (source.ShortAt >= 0 && offset >= source.ShortAt) + { + return 0; + } + long count = Math.Min(length, source.Bytes.Length - offset); + source.Bytes.AsSpan((int)offset, (int)count).CopyTo(new Span(buffer, (int)count)); + return count; + } + + [UnmanagedCallersOnly] + private static void Release(void* userData) + { + TestSource source = (TestSource)GCHandle.FromIntPtr((nint)userData).Target!; + Interlocked.Increment(ref source.Releases); + } + + private static int Open(Pinned pinned, int format, out NativeHandle? handle, NativeOpenOptionsRaw? options = null, long? length = null) + { + NativeSourceRaw raw = pinned.Raw(length); + Assert.True(CallbackByteSource.TryCreate(&raw, hasOutHandle: true, out CallbackByteSource? source, out string? error), error); + return ReadApi.OpenSource(source!, format, options, out handle); + } + + private static NativeOpenOptionsRaw Options() => new() { StructSize = sizeof(NativeOpenOptionsRaw) }; + + // Prefetch only engages on sheets of 256 KiB or more uncompressed; the ConcurrentSheetTests sheets are smaller. + private static byte[] BuildPrefetchedXlsx() + { + using MemoryStream buffer = new(); + using (XlsxWorkbookWriter workbook = XlsxWorkbookWriter.Create(buffer, leaveOpen: true)) + { + XlsxSheetWriter sheet = workbook.AddSheet("big"); + for (int r = 0; r < 20_000; r++) + { + using XlsxRowWriter row = sheet.StartRow(); + row.Write("row-" + r.ToString(CultureInfo.InvariantCulture)); + row.Write(r); + } + sheet.End(); + sheet.Dispose(); + workbook.End(); + } + return buffer.ToArray(); + } + + private static List ReadSheet(NativeHandle handle, int sheet) + { + Assert.Equal(NativeStatus.Ok, ReadApi.OpenRows(handle, sheet, out NativeRowCursor? opened)); + using NativeRowCursor cursor = opened!; + List lines = []; + while (true) + { + int status = ReadApi.NextRowView(cursor, out NativeRow row); + if (status == NativeStatus.Eof) + { + return lines; + } + Assert.Equal(NativeStatus.Ok, status); + StringBuilder line = new(); + int cellSize = Marshal.SizeOf(); + for (int i = 0; i < row.CellCount; i++) + { + NativeRowCell cell = Marshal.PtrToStructure(IntPtr.Add(row.Cells, i * cellSize)); + line.Append(Marshal.PtrToStringUTF8(cell.Value, cell.ValueLength)).Append('|'); + } + lines.Add(line.ToString()); + } + } + + [Theory] + [InlineData(ConcurrentSheetTests.Format.Xlsx, NativeFormat.Xlsx)] + [InlineData(ConcurrentSheetTests.Format.Xlsb, NativeFormat.Xlsb)] + [InlineData(ConcurrentSheetTests.Format.Xls, NativeFormat.Xls)] + [InlineData(ConcurrentSheetTests.Format.Xlsx, NativeFormat.Auto)] + public void Every_Sheet_Read_In_Parallel_Matches_The_Memory_Read(ConcurrentSheetTests.Format format, int nativeFormat) + { + byte[] bytes = ConcurrentSheetTests.Build(format); + Assert.Equal(NativeStatus.Ok, ReadApi.OpenMemory(bytes, nativeFormat, out NativeHandle? memory)); + List[] expected; + using (memory) + { + expected = [.. Enumerable.Range(0, ConcurrentSheetTests.Sheets).Select(s => ReadSheet(memory!, s))]; + } + + TestSource test = new(bytes); + using Pinned pinned = new(test); + Assert.Equal(NativeStatus.Ok, Open(pinned, nativeFormat, out NativeHandle? handle)); + using (NativeHandle live = handle!) + { + List[] parallel = new List[expected.Length * 2]; + Parallel.For(0, parallel.Length, i => parallel[i] = ReadSheet(live, i % expected.Length)); + for (int i = 0; i < parallel.Length; i++) + { + Assert.Equal(expected[i % expected.Length], parallel[i]); + } + } + Assert.Equal(1, test.Releases); + } + + [Fact] + public void A_Csv_Is_Read_Through_The_Source() + { + TestSource test = new(Encoding.UTF8.GetBytes("a,b\n1,2\n")); + using Pinned pinned = new(test); + Assert.Equal(NativeStatus.Ok, Open(pinned, NativeFormat.Csv, out NativeHandle? handle)); + using (handle) + { + Assert.Equal(["a|b|", "1|2|"], ReadSheet(handle!, 0)); + } + Assert.Equal(1, test.Releases); + } + + [Fact] + public void The_Source_Is_Released_Only_When_The_Last_Cursor_Closes_After_The_Workbook() + { + TestSource test = new(ConcurrentSheetTests.Build(ConcurrentSheetTests.Format.Xlsx)); + using Pinned pinned = new(test); + Assert.Equal(NativeStatus.Ok, Open(pinned, NativeFormat.Xlsx, out NativeHandle? handle)); + Assert.Equal(NativeStatus.Ok, ReadApi.OpenRows(handle, 0, out NativeRowCursor? cursor)); + Assert.Equal(NativeStatus.Ok, ReadApi.Close(handle)); + Assert.Equal(0, test.Releases); + cursor!.Dispose(); + Assert.Equal(1, test.Releases); + } + + [Fact] + public void A_Failed_Open_Releases_The_Source_Once() + { + TestSource test = new(Encoding.UTF8.GetBytes("not a workbook at all")); + using Pinned pinned = new(test); + Assert.Equal(NativeStatus.Error, Open(pinned, NativeFormat.Auto, out NativeHandle? handle)); + Assert.Null(handle); + Assert.Equal(1, test.Releases); + } + + [Fact] + public void An_Empty_Source_Fails_With_The_Format_Message_And_Is_Released() + { + TestSource test = new([]); + using Pinned pinned = new(test); + Assert.Equal(NativeStatus.Error, Open(pinned, NativeFormat.Auto, out _)); + Assert.Contains("Unrecognized file format", NativeApi.LastErrorText(), StringComparison.Ordinal); + Assert.Equal(1, test.Releases); + } + + [Fact] + public void Invalid_Options_Release_The_Source_Once() + { + TestSource test = new(ConcurrentSheetTests.Build(ConcurrentSheetTests.Format.Xlsx)); + using Pinned pinned = new(test); + NativeOpenOptionsRaw options = Options() with { MaxBufferedBytes = -1 }; + Assert.Equal(NativeStatus.InvalidArgument, Open(pinned, NativeFormat.Xlsx, out _, options)); + Assert.Equal(1, test.Releases); + } + + [Fact] + public void The_Callback_Message_Reaches_The_Caller() + { + TestSource test = new(ConcurrentSheetTests.Build(ConcurrentSheetTests.Format.Xlsx)) { Error = "disk on fire" }; + using Pinned pinned = new(test); + Assert.Equal(NativeStatus.Error, Open(pinned, NativeFormat.Xlsx, out _)); + Assert.Contains("disk on fire", NativeApi.LastErrorText(), StringComparison.Ordinal); + Assert.Equal(1, test.Releases); + } + + [Fact] + public void The_Callback_Message_Reaches_The_Caller_When_Prefetch_Reads_On_Another_Thread() + { + byte[] bytes = BuildPrefetchedXlsx(); + TestSource test = new(bytes); + using Pinned pinned = new(test); + NativeOpenOptionsRaw options = Options() with { PrefetchDecompression = 2, SourceBlockSize = -1 }; + Assert.Equal(NativeStatus.Ok, Open(pinned, NativeFormat.Xlsx, out NativeHandle? handle, options)); + using (handle) + { + test.FailOffThread = Environment.CurrentManagedThreadId; + test.Error = "disk on fire"; + Assert.Equal(NativeStatus.Ok, ReadApi.OpenRows(handle, 0, out NativeRowCursor? opened)); + using NativeRowCursor cursor = opened!; + int status; + do + { + status = ReadApi.NextRowView(cursor, out _); + } + while (status == NativeStatus.Ok); + Assert.Equal(NativeStatus.Error, status); + Assert.Contains("disk on fire", NativeApi.LastErrorText(), StringComparison.Ordinal); + } + } + + [Fact] + public void A_Source_That_Ends_Early_Fails_Instead_Of_Looping() + { + byte[] bytes = ConcurrentSheetTests.Build(ConcurrentSheetTests.Format.Xlsx); + TestSource test = new(bytes) { ShortAt = bytes.Length / 2 }; + using Pinned pinned = new(test); + NativeOpenOptionsRaw options = Options() with { SourceBlockSize = -1 }; + Assert.Equal(NativeStatus.Error, Open(pinned, NativeFormat.Xlsx, out _, options)); + Assert.Contains("ended at offset", NativeApi.LastErrorText(), StringComparison.Ordinal); + } + + [Fact] + public void The_Default_Cache_Fetches_Each_Block_Once() + { + byte[] bytes = ConcurrentSheetTests.Build(ConcurrentSheetTests.Format.Xlsx); + long blocks = (bytes.Length + (4L * 1024 * 1024) - 1) / (4L * 1024 * 1024); + + TestSource cached = new(bytes); + using (Pinned pinned = new(cached)) + { + Assert.Equal(NativeStatus.Ok, Open(pinned, NativeFormat.Xlsx, out NativeHandle? handle)); + using (handle) + { + ReadSheet(handle!, 0); + } + } + Assert.True(cached.Reads <= blocks, $"{cached.Reads} reads for {blocks} blocks"); + + TestSource uncached = new(bytes); + using (Pinned pinned = new(uncached)) + { + Assert.Equal(NativeStatus.Ok, Open(pinned, NativeFormat.Xlsx, out NativeHandle? handle, Options() with { SourceBlockSize = -1 })); + using (handle) + { + ReadSheet(handle!, 0); + } + } + Assert.True(uncached.Reads > blocks, $"{uncached.Reads} reads without the cache"); + } + + [Fact] + public void Invalid_Source_Structs_Are_Rejected_Without_Taking_Ownership() + { + TestSource test = new([1, 2, 3]); + using Pinned pinned = new(test); + NativeSourceRaw good = pinned.Raw(); + NativeSourceRaw wrongSize = good with { StructSize = 8 }; + NativeSourceRaw noRead = good with { ReadAt = null }; + NativeSourceRaw negative = good with { Length = -1 }; + + Assert.False(CallbackByteSource.TryCreate(null, true, out _, out _)); + Assert.False(CallbackByteSource.TryCreate(&good, false, out _, out _)); + Assert.False(CallbackByteSource.TryCreate(&wrongSize, true, out _, out _)); + Assert.False(CallbackByteSource.TryCreate(&noRead, true, out _, out _)); + Assert.False(CallbackByteSource.TryCreate(&negative, true, out _, out string? error)); + Assert.Contains("xl_open_source", error, StringComparison.Ordinal); + Assert.Equal(0, test.Releases); + } + } +} From 0e20232cde1eeb9121cf35c8026c8aa79973f928 Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Wed, 7 Oct 2026 14:46:13 -0300 Subject: [PATCH 008/115] feat(native): open a workbook over a stream that cannot seek Co-Authored-By: Claude Opus 5.5 --- rust/excelreader/excelreader.def | 1 + src/ExcelReader.Native/Exports.Reading.cs | 22 +++ .../Reading/CallbackReadStream.cs | 100 +++++++++++ .../Reading/ReadApi.Sources.cs | 51 ++++++ .../Reading/StreamBuffer.cs | 32 ++++ .../include/excelreader.def | 1 + src/ExcelReader.Native/include/excelreader.h | 19 ++ .../Native/CallbackStreamTests.cs | 162 ++++++++++++++++++ 8 files changed, 388 insertions(+) create mode 100644 src/ExcelReader.Native/Reading/CallbackReadStream.cs create mode 100644 src/ExcelReader.Native/Reading/StreamBuffer.cs create mode 100644 tests/ExcelReader.Tests/Native/CallbackStreamTests.cs diff --git a/rust/excelreader/excelreader.def b/rust/excelreader/excelreader.def index 7b2dccdf..6bd570d3 100644 --- a/rust/excelreader/excelreader.def +++ b/rust/excelreader/excelreader.def @@ -3,6 +3,7 @@ EXPORTS xl_open_file xl_open_memory xl_open_source + xl_open_stream xl_set_source_error xl_close xl_sheet_count diff --git a/src/ExcelReader.Native/Exports.Reading.cs b/src/ExcelReader.Native/Exports.Reading.cs index 09690c20..0373f9c7 100644 --- a/src/ExcelReader.Native/Exports.Reading.cs +++ b/src/ExcelReader.Native/Exports.Reading.cs @@ -72,6 +72,28 @@ public static int OpenSource(NativeSourceRaw* source, int format, NativeOpenOpti return RegisterOpened(status, handle, outHandle); } + [UnmanagedCallersOnly(EntryPoint = "xl_open_stream")] + public static int OpenStream(NativeStreamRaw* stream, int format, NativeOpenOptionsRaw* options, nint* outHandle) + { + NativeApi.ClearLastError(); + if (outHandle is not null) + { + *outHandle = 0; + } + if (!CallbackReadStream.TryCreate(stream, outHandle is not null, out CallbackReadStream? callback, out string? error)) + { + NativeApi.SetLastError(error); + return NativeStatus.InvalidArgument; + } + if (!TryReadOpenOptions(options, out NativeOpenOptionsRaw? rawOptions)) + { + callback.Dispose(); + return NativeStatus.InvalidArgument; + } + int status = ReadApi.OpenStream(callback, format, rawOptions, out NativeHandle? handle); + return RegisterOpened(status, handle, outHandle); + } + [UnmanagedCallersOnly(EntryPoint = "xl_set_source_error")] public static void SetSourceError(byte* message, int length) { diff --git a/src/ExcelReader.Native/Reading/CallbackReadStream.cs b/src/ExcelReader.Native/Reading/CallbackReadStream.cs new file mode 100644 index 00000000..10f3d6d4 --- /dev/null +++ b/src/ExcelReader.Native/Reading/CallbackReadStream.cs @@ -0,0 +1,100 @@ +using System.Diagnostics.CodeAnalysis; + +namespace ExcelReader.Native.Reading +{ + internal sealed unsafe class CallbackReadStream : Stream + { + private readonly delegate* unmanaged _read; + private readonly delegate* unmanaged _release; + private readonly void* _userData; + private int _released; + + private CallbackReadStream(in NativeStreamRaw raw) + { + _read = raw.Read; + _release = raw.Release; + _userData = raw.UserData; + } + + internal static bool TryCreate( + NativeStreamRaw* raw, bool hasOutHandle, [NotNullWhen(true)] out CallbackReadStream? stream, [NotNullWhen(false)] out string? error) + { + stream = null; + if (raw is null || !hasOutHandle || raw->StructSize != sizeof(NativeStreamRaw) || raw->Read is null) + { + error = "xl_open_stream needs a non-NULL stream whose struct_size is sizeof(xl_stream) and whose read is set, and a non-NULL out_handle."; + return false; + } + error = null; + stream = new CallbackReadStream(*raw); + return true; + } + + 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(Span buffer) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _released) != 0, this); + if (buffer.IsEmpty) + { + return 0; + } + SourceErrors.Clear(); + long got; + fixed (byte* destination = buffer) + { + got = _read(_userData, destination, buffer.Length); + } + if (got < 0 || got > buffer.Length) + { + throw SourceErrors.Failed("xl_stream.read", got, buffer.Length); + } + return (int)got; + } + + public override int Read(byte[] buffer, int offset, int count) + { + return Read(buffer.AsSpan(offset, 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) + { + if (Interlocked.Exchange(ref _released, 1) == 0 && _release is not null) + { + _release(_userData); + } + base.Dispose(disposing); + } + } +} diff --git a/src/ExcelReader.Native/Reading/ReadApi.Sources.cs b/src/ExcelReader.Native/Reading/ReadApi.Sources.cs index 3b65e277..555ba5c2 100644 --- a/src/ExcelReader.Native/Reading/ReadApi.Sources.cs +++ b/src/ExcelReader.Native/Reading/ReadApi.Sources.cs @@ -51,5 +51,56 @@ internal static int OpenSource(ByteSource callback, int format, NativeOpenOption } } } + + internal static int OpenStream(CallbackReadStream stream, int format, NativeOpenOptionsRaw? rawOptions, out NativeHandle? handle) + { + handle = null; + bool opened = false; + try + { + if (!TryDecodeOpenOptions(rawOptions, out NativeOpenOptions? decoded, out string? error)) + { + NativeApi.SetLastError(error!); + return NativeStatus.InvalidArgument; + } + if (!IsKnownFormat(format)) + { + NativeApi.SetLastError($"format must be one of the XL_FORMAT_* values; got {format}."); + return NativeStatus.InvalidArgument; + } + NativeOpenOptions options = decoded ?? default; + ExcelReaderOptions readerOptions = options.ToExcelReaderOptions(); + IExcelWorkbook workbook; + if (format == NativeFormat.Csv) + { + workbook = Excel.FromCsv(stream, leaveOpen: false, readerOptions.Csv); + } + else + { + ReadOnlyMemory data = StreamBuffer.ReadAll(stream, options.MaxBufferedBytes ?? Array.MaxLength); + stream.Dispose(); + workbook = Excel.Open(data, MapFormat(format), readerOptions); + } + handle = new NativeHandle(workbook); + opened = true; + return NativeStatus.Ok; + } + catch (ExcelEncryptionException ex) + { + return MapEncryptionException(ex); + } + catch (Exception exception) + { + NativeApi.SetLastError(exception.Message); + return NativeStatus.Error; + } + finally + { + if (!opened) + { + stream.Dispose(); + } + } + } } } diff --git a/src/ExcelReader.Native/Reading/StreamBuffer.cs b/src/ExcelReader.Native/Reading/StreamBuffer.cs new file mode 100644 index 00000000..e2570f03 --- /dev/null +++ b/src/ExcelReader.Native/Reading/StreamBuffer.cs @@ -0,0 +1,32 @@ +using System.Buffers; +using ExcelReader.Core.Reader; + +namespace ExcelReader.Native.Reading +{ + internal static class StreamBuffer + { + internal static ReadOnlyMemory ReadAll(Stream stream, long limit) + { + MemoryStream buffer = new(); + byte[] chunk = ArrayPool.Shared.Rent(81_920); + try + { + int read; + while ((read = stream.Read(chunk)) > 0) + { + long total = buffer.Length + read; + if (total > limit) + { + throw new ExcelLimitExceededException("MaxBufferedBytes", limit, total); + } + buffer.Write(chunk, 0, read); + } + } + finally + { + ArrayPool.Shared.Return(chunk); + } + return buffer.GetBuffer().AsMemory(0, (int)buffer.Length); + } + } +} diff --git a/src/ExcelReader.Native/include/excelreader.def b/src/ExcelReader.Native/include/excelreader.def index 7b2dccdf..6bd570d3 100644 --- a/src/ExcelReader.Native/include/excelreader.def +++ b/src/ExcelReader.Native/include/excelreader.def @@ -3,6 +3,7 @@ EXPORTS xl_open_file xl_open_memory xl_open_source + xl_open_stream xl_set_source_error xl_close xl_sheet_count diff --git a/src/ExcelReader.Native/include/excelreader.h b/src/ExcelReader.Native/include/excelreader.h index 895fa8a2..5deb8192 100644 --- a/src/ExcelReader.Native/include/excelreader.h +++ b/src/ExcelReader.Native/include/excelreader.h @@ -121,6 +121,25 @@ typedef struct xl_source { int32_t xl_open_source(const xl_source* source, int32_t format, const xl_open_options* options, xl_workbook** out_handle); +/* Sequential bytes for xl_open_stream. */ +typedef struct xl_stream { + int32_t struct_size; /* sizeof(xl_stream) */ + void* user_data; + /* Copy up to len bytes into buf. Return the bytes copied (0 at the end), or -1 on failure after + calling xl_set_source_error. Never called concurrently. */ + int64_t (*read)(void* user_data, uint8_t* buf, int64_t len); + /* May be NULL. Called exactly once. */ + void (*release)(void* user_data); +} xl_stream; + +/* Opens a workbook over a stream that cannot seek. A CSV is read as it arrives, by one cursor: + a second cursor, typed reader or Arrow stream on it fails with XL_ERROR, csv_sniff_dialect is + refused, and the stream is released when the workbook and its cursor are closed. An XLSX, XLSB + or XLS needs random access, so the stream is read whole (up to max_buffered_bytes) and released + before this returns. Ownership rules are those of xl_open_source. */ +int32_t xl_open_stream(const xl_stream* stream, int32_t format, + const xl_open_options* options, xl_workbook** out_handle); + /* Callable only from inside read_at or read: the message xl_last_error reports when the callback then returns -1. Inside a callback, no other xl_ function may be called. */ void xl_set_source_error(const uint8_t* message, int32_t len); diff --git a/tests/ExcelReader.Tests/Native/CallbackStreamTests.cs b/tests/ExcelReader.Tests/Native/CallbackStreamTests.cs new file mode 100644 index 00000000..a2e2c168 --- /dev/null +++ b/tests/ExcelReader.Tests/Native/CallbackStreamTests.cs @@ -0,0 +1,162 @@ +using System.Runtime.InteropServices; +using System.Text; +using ExcelReader.Native; +using ExcelReader.Native.Reading; +using ExcelReader.Tests.Reader; + +namespace ExcelReader.Tests.Native +{ + public sealed unsafe class CallbackStreamTests + { + internal sealed class TestStream(byte[] bytes) + { + internal readonly byte[] Bytes = bytes; + internal int Position; + internal int Releases; + internal string? Error; + } + + private sealed class Pinned(TestStream stream) : IDisposable + { + private GCHandle _handle = GCHandle.Alloc(stream); + + internal NativeStreamRaw Raw() + { + return new() + { + StructSize = sizeof(NativeStreamRaw), + UserData = (void*)GCHandle.ToIntPtr(_handle), + Read = &Read, + Release = &Release, + }; + } + + public void Dispose() + { + _handle.Free(); + } + } + + [UnmanagedCallersOnly] + private static long Read(void* userData, byte* buffer, long length) + { + TestStream stream = (TestStream)GCHandle.FromIntPtr((nint)userData).Target!; + if (stream.Error is string message) + { + SourceErrors.Set(message); + return -1; + } + int count = (int)Math.Min(Math.Min(length, 7), stream.Bytes.Length - stream.Position); + stream.Bytes.AsSpan(stream.Position, count).CopyTo(new Span(buffer, count)); + stream.Position += count; + return count; + } + + [UnmanagedCallersOnly] + private static void Release(void* userData) + { + TestStream stream = (TestStream)GCHandle.FromIntPtr((nint)userData).Target!; + Interlocked.Increment(ref stream.Releases); + } + + private static int Open(Pinned pinned, int format, out NativeHandle? handle, NativeOpenOptionsRaw? options = null) + { + NativeStreamRaw raw = pinned.Raw(); + Assert.True(CallbackReadStream.TryCreate(&raw, hasOutHandle: true, out CallbackReadStream? stream, out string? error), error); + return ReadApi.OpenStream(stream!, format, options, out handle); + } + + private static NativeOpenOptionsRaw Options() => new() { StructSize = sizeof(NativeOpenOptionsRaw) }; + + private static int CountRows(NativeHandle handle, int sheet) + { + Assert.Equal(NativeStatus.Ok, ReadApi.OpenRows(handle, sheet, out NativeRowCursor? opened)); + using NativeRowCursor cursor = opened!; + int rows = 0; + while (ReadApi.NextRowView(cursor, out _) == NativeStatus.Ok) + { + rows++; + } + return rows; + } + + [Fact] + public void A_Csv_Streams_Once_And_Is_Released_When_Its_Cursor_Closes() + { + TestStream test = new(Encoding.UTF8.GetBytes("a,b\n1,2\n3,4\n")); + using Pinned pinned = new(test); + Assert.Equal(NativeStatus.Ok, Open(pinned, NativeFormat.Csv, out NativeHandle? handle)); + Assert.Equal(3, CountRows(handle!, 0)); + + Assert.Equal(NativeStatus.Error, ReadApi.OpenRows(handle, 0, out _)); + Assert.Contains("only be enumerated once", NativeApi.LastErrorText(), StringComparison.Ordinal); + + Assert.Equal(0, test.Releases); + Assert.Equal(NativeStatus.Ok, ReadApi.Close(handle)); + Assert.Equal(1, test.Releases); + } + + [Theory] + [InlineData(ConcurrentSheetTests.Format.Xlsx, NativeFormat.Xlsx)] + [InlineData(ConcurrentSheetTests.Format.Xls, NativeFormat.Auto)] + public void An_Excel_Workbook_Is_Buffered_And_The_Stream_Released_Before_The_Open_Returns( + ConcurrentSheetTests.Format format, int nativeFormat) + { + TestStream test = new(ConcurrentSheetTests.Build(format)); + using Pinned pinned = new(test); + Assert.Equal(NativeStatus.Ok, Open(pinned, nativeFormat, out NativeHandle? handle)); + Assert.Equal(1, test.Releases); + using (handle) + { + Assert.Equal(ConcurrentSheetTests.Rows, CountRows(handle!, 5)); + } + Assert.Equal(1, test.Releases); + } + + [Fact] + public void A_Stream_Past_The_Buffer_Limit_Fails_And_Is_Released_Once() + { + TestStream test = new(ConcurrentSheetTests.Build(ConcurrentSheetTests.Format.Xlsx)); + using Pinned pinned = new(test); + Assert.Equal(NativeStatus.Error, Open(pinned, NativeFormat.Xlsx, out _, Options() with { MaxBufferedBytes = 1000 })); + Assert.Contains("MaxBufferedBytes", NativeApi.LastErrorText(), StringComparison.Ordinal); + Assert.Equal(1, test.Releases); + } + + [Fact] + public void Sniffing_A_Csv_Stream_Fails_With_A_Message_And_Is_Released_Once() + { + TestStream test = new(Encoding.UTF8.GetBytes("a;b\n1;2\n")); + using Pinned pinned = new(test); + Assert.Equal(NativeStatus.Error, Open(pinned, NativeFormat.Csv, out _, Options() with { CsvSniffDialect = 2 })); + Assert.Contains("seekable", NativeApi.LastErrorText(), StringComparison.Ordinal); + Assert.Equal(1, test.Releases); + } + + [Fact] + public void The_Callback_Message_Reaches_The_Caller() + { + TestStream test = new([1, 2, 3]) { Error = "pipe broke" }; + using Pinned pinned = new(test); + Assert.Equal(NativeStatus.Error, Open(pinned, NativeFormat.Xlsx, out _)); + Assert.Contains("pipe broke", NativeApi.LastErrorText(), StringComparison.Ordinal); + Assert.Equal(1, test.Releases); + } + + [Fact] + public void Invalid_Stream_Structs_Are_Rejected_Without_Taking_Ownership() + { + TestStream test = new([]); + using Pinned pinned = new(test); + NativeStreamRaw good = pinned.Raw(); + NativeStreamRaw noRead = good with { Read = null }; + NativeStreamRaw wrongSize = good with { StructSize = 4 }; + Assert.False(CallbackReadStream.TryCreate(null, true, out _, out _)); + Assert.False(CallbackReadStream.TryCreate(&good, false, out _, out _)); + Assert.False(CallbackReadStream.TryCreate(&noRead, true, out _, out _)); + Assert.False(CallbackReadStream.TryCreate(&wrongSize, true, out _, out string? error)); + Assert.Contains("xl_open_stream", error, StringComparison.Ordinal); + Assert.Equal(0, test.Releases); + } + } +} From 4714b45d731396a9cfba13d9102cf32c72738f54 Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Wed, 7 Oct 2026 14:52:12 -0300 Subject: [PATCH 009/115] test(native): open a workbook through xl_open_source from C Co-Authored-By: Claude Opus 5.5 --- tests/ExcelReader.NativeSmoke/smoke.c | 75 +++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/tests/ExcelReader.NativeSmoke/smoke.c b/tests/ExcelReader.NativeSmoke/smoke.c index 0720f16d..7536fbdb 100644 --- a/tests/ExcelReader.NativeSmoke/smoke.c +++ b/tests/ExcelReader.NativeSmoke/smoke.c @@ -99,6 +99,22 @@ XL_STATIC_ASSERT(sizeof(xl_csv_aggregation) == 48, csv_agg_size); XL_STATIC_ASSERT(offsetof(xl_csv_parallel_options, max_cell_bytes) == 24, csv_opt_max_cell_bytes); XL_STATIC_ASSERT(sizeof(xl_csv_parallel_options) == 28, csv_opt_size); +XL_STATIC_ASSERT(offsetof(xl_open_options, source_cache_bytes) == 88, open_options_source_cache_bytes); +XL_STATIC_ASSERT(offsetof(xl_open_options, max_buffered_bytes) == 96, open_options_max_buffered_bytes); +XL_STATIC_ASSERT(offsetof(xl_open_options, source_block_size) == 104, open_options_source_block_size); +XL_STATIC_ASSERT(sizeof(xl_open_options) == 112, open_options_size); + +XL_STATIC_ASSERT(offsetof(xl_source, user_data) == 8, source_user_data); +XL_STATIC_ASSERT(offsetof(xl_source, length) == 16, source_length); +XL_STATIC_ASSERT(offsetof(xl_source, read_at) == 24, source_read_at); +XL_STATIC_ASSERT(offsetof(xl_source, release) == 32, source_release); +XL_STATIC_ASSERT(sizeof(xl_source) == 40, source_size); + +XL_STATIC_ASSERT(offsetof(xl_stream, user_data) == 8, stream_user_data); +XL_STATIC_ASSERT(offsetof(xl_stream, read) == 16, stream_read); +XL_STATIC_ASSERT(offsetof(xl_stream, release) == 24, stream_release); +XL_STATIC_ASSERT(sizeof(xl_stream) == 32, stream_size); + static xl_lib_handle load_library(const char* path) { @@ -1268,6 +1284,64 @@ static int test_csv_aggregate_quoted_records(xl_lib_handle lib) return status; } +typedef int32_t (*xl_open_source_fn)(const xl_source*, int32_t, const xl_open_options*, xl_workbook**); + +typedef struct smoke_buffer { + uint8_t* data; + int64_t length; + int releases; +} smoke_buffer; + +static int64_t smoke_read_at(void* user_data, int64_t offset, uint8_t* buf, int64_t len) +{ + smoke_buffer* source = (smoke_buffer*)user_data; + if (offset >= source->length) + { + return 0; + } + int64_t count = source->length - offset < len ? source->length - offset : len; + memcpy(buf, source->data + offset, (size_t)count); + return count; +} + +static void smoke_release(void* user_data) +{ + ((smoke_buffer*)user_data)->releases++; +} + +static int test_open_source(xl_lib_handle lib, const api_t* api, const char* fixture) +{ + xl_open_source_fn open_source = (xl_open_source_fn)load_symbol(lib, "xl_open_source"); + CHECK(open_source != NULL, "xl_open_source must be exported"); + + FILE* file = fopen(fixture, "rb"); + CHECK(file != NULL, "the fixture must open"); + fseek(file, 0, SEEK_END); + smoke_buffer buffer = { NULL, ftell(file), 0 }; + fseek(file, 0, SEEK_SET); + buffer.data = (uint8_t*)malloc((size_t)buffer.length); + CHECK(buffer.data != NULL && fread(buffer.data, 1, (size_t)buffer.length, file) == (size_t)buffer.length, "the fixture must read"); + fclose(file); + + xl_source source; + memset(&source, 0, sizeof(source)); + source.struct_size = (int32_t)sizeof(xl_source); + source.user_data = &buffer; + source.length = buffer.length; + source.read_at = smoke_read_at; + source.release = smoke_release; + + xl_workbook* handle = NULL; + CHECK(open_source(&source, XL_FORMAT_XLSB, NULL, &handle) == XL_OK && handle != NULL, "xl_open_source must open the fixture"); + int32_t count = 0; + CHECK(api->sheet_count(handle, &count) == XL_OK && count == 1, "the fixture has one sheet"); + CHECK(api->close_(handle) == XL_OK, "xl_close must succeed"); + CHECK(buffer.releases == 1, "release must run exactly once"); + + free(buffer.data); + return 0; +} + int main(int argc, char** argv) { const char* library_path = argc > 1 ? argv[1] : EXCELREADER_LIB_PATH_DEFAULT; @@ -1308,6 +1382,7 @@ int main(int argc, char** argv) failures += test_write_typed(&api); failures += test_csv_aggregate_file(lib); failures += test_csv_aggregate_quoted_records(lib); + failures += test_open_source(lib, &api, fixture_path); if (failures == 0) { From c933af83449572745cd560dba66b136d0cc1def2 Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Wed, 7 Oct 2026 15:00:09 -0300 Subject: [PATCH 010/115] feat(rust): open a workbook from a Source or any Read Co-Authored-By: Claude Opus 5.5 --- rust/excelreader/src/lib.rs | 38 +++++++ rust/excelreader/src/options.rs | 18 ++++ rust/excelreader/src/source.rs | 95 +++++++++++++++++ rust/excelreader/src/workbook.rs | 4 + rust/excelreader/tests/source.rs | 177 +++++++++++++++++++++++++++++++ 5 files changed, 332 insertions(+) create mode 100644 rust/excelreader/src/source.rs create mode 100644 rust/excelreader/tests/source.rs diff --git a/rust/excelreader/src/lib.rs b/rust/excelreader/src/lib.rs index 9a4970d0..53550150 100644 --- a/rust/excelreader/src/lib.rs +++ b/rust/excelreader/src/lib.rs @@ -10,6 +10,7 @@ pub mod workbook; pub mod writer; pub mod writer_handle; pub mod rows; +pub mod source; #[cfg(feature = "arrow")] pub mod arrow; @@ -17,6 +18,7 @@ pub mod arrow; pub use aggregate::{aggregate_csv_file, aggregate_csv_memory, CsvAccumulator, CsvParallelOptions}; pub use error::Error; pub use rows::{AllRows, CellIter, CellRef, CellType, DecodedRows, RowCursor, RowRef}; +pub use source::Source; pub use options::{OpenOptions, OpenOptionsRaw, WriteOptions}; pub use temporal::{Date, Time, Timestamp}; @@ -118,6 +120,26 @@ pub struct XlOpenOptions { /// remain valid for the duration of the call - see [`OpenOptionsRaw`](crate::options::OpenOptionsRaw). pub password: *const u8, pub password_len: i32, + pub source_cache_bytes: i64, + pub max_buffered_bytes: i64, + pub source_block_size: i32, +} + +#[repr(C)] +pub struct XlSource { + pub struct_size: i32, + pub user_data: *mut c_void, + pub length: i64, + pub read_at: Option i64>, + pub release: Option, +} + +#[repr(C)] +pub struct XlStream { + pub struct_size: i32, + pub user_data: *mut c_void, + pub read: Option i64>, + pub release: Option, } /// Mirrors `xl_write_options`. Field ORDER is the C struct's, not a tidied-up version of it: with @@ -257,6 +279,22 @@ extern "C" { out_handle: *mut *mut XlWorkbook, ) -> c_int; + pub fn xl_open_source( + source: *const XlSource, + format: i32, + options: *const XlOpenOptions, + out_handle: *mut *mut XlWorkbook, + ) -> c_int; + + pub fn xl_open_stream( + stream: *const XlStream, + format: i32, + options: *const XlOpenOptions, + out_handle: *mut *mut XlWorkbook, + ) -> c_int; + + pub fn xl_set_source_error(message: *const u8, len: i32); + pub fn xl_close(handle: *mut XlWorkbook) -> c_int; pub fn xl_sheet_count(handle: *mut XlWorkbook, out_count: *mut i32) -> c_int; diff --git a/rust/excelreader/src/options.rs b/rust/excelreader/src/options.rs index 4684b2d9..6d5f39c4 100644 --- a/rust/excelreader/src/options.rs +++ b/rust/excelreader/src/options.rs @@ -38,6 +38,14 @@ pub struct OpenOptions { pub prefetch_decompression: Option, pub intern_strings: Option, + /// `open_source` only: bytes fetched per `read_at` call. `None` is 4 MiB; a negative value turns + /// the cache off. + pub source_block_size: Option, + /// `open_source` only: bytes of blocks kept in memory. `None` is 64 MiB. + pub source_cache_bytes: Option, + /// `open_reader` with XLSX, XLSB or XLS only: the most bytes the reader may hold. + pub max_buffered_bytes: Option, + /// Password for an encrypted OOXML workbook. `None` means "not encrypted, or fail with /// `Error::PasswordRequired`". pub password: Option, @@ -90,6 +98,13 @@ impl OpenOptions { prefetch_decompression: bool, /// XLS/XLSX/XLSB only. Library default is `false`. intern_strings: bool, + /// `open_source` only: bytes fetched per `read_at` call. Library default is 4 MiB; a negative + /// value turns the cache off. + source_block_size: i32, + /// `open_source` only: bytes of blocks kept in memory. Library default is 64 MiB. + source_cache_bytes: i64, + /// `open_reader` with XLSX, XLSB or XLS only: the most bytes the reader may hold. + max_buffered_bytes: i64, } /// Password for an encrypted OOXML workbook. Stored owned, since the C ABI receives a pointer @@ -130,6 +145,9 @@ impl OpenOptions { intern_strings: opt_state(self.intern_strings), password: std::ptr::null(), password_len: 0, + source_cache_bytes: opt_number(self.source_cache_bytes), + max_buffered_bytes: opt_number(self.max_buffered_bytes), + source_block_size: opt_number(self.source_block_size), }; if let Some(bytes) = password { raw.password = bytes.as_ptr(); diff --git a/rust/excelreader/src/source.rs b/rust/excelreader/src/source.rs new file mode 100644 index 00000000..b35a4105 --- /dev/null +++ b/rust/excelreader/src/source.rs @@ -0,0 +1,95 @@ +//! Workbooks opened from bytes the caller serves: a positional [`Source`] or any [`Read`]. + +use crate::workbook::{check, check_abi_version, Workbook}; +use crate::{Error, OpenOptions, XlSource, XlStream, XlWorkbook, XL_INVALID_ARGUMENT}; +use std::any::Any; +use std::ffi::c_void; +use std::io::{self, Read}; +use std::panic::{catch_unwind, AssertUnwindSafe}; + +/// Random-access bytes for [`Workbook::open_source`]. The library calls `read_at` from several +/// threads at once, which is why the trait requires `Send + Sync`. +pub trait Source: Send + Sync + 'static { + /// Total size in bytes. Called once, while the workbook opens. + fn size(&self) -> io::Result; + + /// Fills `buf` with the bytes starting at `offset` and returns how many it wrote. Returning 0 + /// before the end is a failure. + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result; +} + +impl Workbook { + /// Opens a workbook over `source`, which the library owns from here on and drops once nothing + /// opened from the workbook needs it. Every sheet can be read in parallel. + pub fn open_source(source: S, format: i32, options: Option<&OpenOptions>) -> Result { + check_abi_version()?; + let size = source.size().map_err(|error| Error::from_status(XL_INVALID_ARGUMENT, error.to_string()))?; + let length = i64::try_from(size) + .map_err(|_| Error::from_status(XL_INVALID_ARGUMENT, "the source size does not fit in an i64".to_string()))?; + let raw = XlSource { + struct_size: std::mem::size_of::() as i32, + user_data: Box::into_raw(Box::new(source)).cast(), + length, + read_at: Some(read_at::), + release: Some(release::), + }; + let options = options.map(OpenOptions::to_raw); + let options_ptr = options + .as_ref() + .map_or(std::ptr::null(), crate::options::OpenOptionsRaw::as_ptr); + let mut handle: *mut XlWorkbook = std::ptr::null_mut(); + check(unsafe { crate::xl_open_source(&raw, format, options_ptr, &mut handle) })?; + Ok(Workbook::from_handle(handle)) + } + + /// Opens a workbook over `reader`, which cannot seek. A CSV is read as it arrives, once; an + /// XLSX, XLSB or XLS is read whole first. The library owns `reader` from here on. + pub fn open_reader(reader: R, format: i32, options: Option<&OpenOptions>) -> Result { + check_abi_version()?; + let raw = XlStream { + struct_size: std::mem::size_of::() as i32, + user_data: Box::into_raw(Box::new(reader)).cast(), + read: Some(read::), + release: Some(release::), + }; + let options = options.map(OpenOptions::to_raw); + let options_ptr = options + .as_ref() + .map_or(std::ptr::null(), crate::options::OpenOptionsRaw::as_ptr); + let mut handle: *mut XlWorkbook = std::ptr::null_mut(); + check(unsafe { crate::xl_open_stream(&raw, format, options_ptr, &mut handle) })?; + Ok(Workbook::from_handle(handle)) + } +} + +unsafe extern "C" fn read_at(user_data: *mut c_void, offset: i64, buf: *mut u8, len: i64) -> i64 { + let source = &*user_data.cast::(); + let buf = std::slice::from_raw_parts_mut(buf, len as usize); + report(catch_unwind(AssertUnwindSafe(|| source.read_at(offset as u64, buf)))) +} + +// The ABI never calls read concurrently, so the reader can be borrowed mutably. +unsafe extern "C" fn read(user_data: *mut c_void, buf: *mut u8, len: i64) -> i64 { + let reader = &mut *user_data.cast::(); + let buf = std::slice::from_raw_parts_mut(buf, len as usize); + report(catch_unwind(AssertUnwindSafe(|| loop { + match reader.read(buf) { + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + other => return other, + } + }))) +} + +unsafe extern "C" fn release(user_data: *mut c_void) { + let _ = catch_unwind(AssertUnwindSafe(|| drop(Box::from_raw(user_data.cast::())))); +} + +fn report(outcome: Result, Box>) -> i64 { + let message = match outcome { + Ok(Ok(count)) => return count as i64, + Ok(Err(error)) => error.to_string(), + Err(_) => "source panicked".to_string(), + }; + unsafe { crate::xl_set_source_error(message.as_ptr(), message.len() as i32) }; + -1 +} diff --git a/rust/excelreader/src/workbook.rs b/rust/excelreader/src/workbook.rs index 863fd498..dc75b791 100644 --- a/rust/excelreader/src/workbook.rs +++ b/rust/excelreader/src/workbook.rs @@ -140,6 +140,10 @@ unsafe impl Send for Workbook {} unsafe impl Sync for Workbook {} impl Workbook { + pub(crate) fn from_handle(handle: *mut XlWorkbook) -> Workbook { + Workbook { handle } + } + /// Opens `path`, sniffing the format and using every library default. /// /// Sniffing does NOT detect CSV - open one with [`open_with`](Self::open_with) and diff --git a/rust/excelreader/tests/source.rs b/rust/excelreader/tests/source.rs new file mode 100644 index 00000000..1c396145 --- /dev/null +++ b/rust/excelreader/tests/source.rs @@ -0,0 +1,177 @@ +use excelreader::workbook::Workbook; +use excelreader::writer_handle::WriterHandle; +use excelreader::{OpenOptions, RowCursor, Source, XL_FORMAT_AUTO, XL_FORMAT_CSV, XL_FORMAT_XLSX}; +use std::io::{self, Read}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +const SHEETS: usize = 4; +const ROWS: usize = 300; + +fn workbook_bytes() -> Vec { + let mut writer = WriterHandle::open_memory(XL_FORMAT_XLSX, None).expect("open writer"); + for sheet in 0..SHEETS { + writer.start_sheet(&format!("sheet{sheet}")).expect("start sheet"); + for row in 0..ROWS { + writer.start_row().expect("start row"); + writer.write_str(Some(&format!("s{sheet}-r{row}"))).expect("write"); + writer.end_row().expect("end row"); + } + writer.end_sheet().expect("end sheet"); + } + writer.bytes().expect("bytes").to_vec() +} + +fn first_column(cursor: &mut RowCursor<'_>) -> Vec { + let mut values = Vec::new(); + while let Some(row) = cursor.next_row() { + let row = row.expect("a row"); + values.push(row.get(0).expect("a cell").as_str().expect("utf-8").to_string()); + } + values +} + +#[derive(Default, Clone)] +struct Counters { + reads: Arc, + drops: Arc, +} + +struct MemorySource { + bytes: Vec, + counters: Counters, +} + +impl Source for MemorySource { + fn size(&self) -> io::Result { + Ok(self.bytes.len() as u64) + } + + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result { + self.counters.reads.fetch_add(1, Ordering::SeqCst); + let start = (offset as usize).min(self.bytes.len()); + let count = buf.len().min(self.bytes.len() - start); + buf[..count].copy_from_slice(&self.bytes[start..start + count]); + Ok(count) + } +} + +impl Drop for MemorySource { + fn drop(&mut self) { + self.counters.drops.fetch_add(1, Ordering::SeqCst); + } +} + +struct FailingSource; + +impl Source for FailingSource { + fn size(&self) -> io::Result { + Ok(1024) + } + + fn read_at(&self, _offset: u64, _buf: &mut [u8]) -> io::Result { + Err(io::Error::other("disk on fire")) + } +} + +struct PanickingSource; + +impl Source for PanickingSource { + fn size(&self) -> io::Result { + Ok(1024) + } + + fn read_at(&self, _offset: u64, _buf: &mut [u8]) -> io::Result { + panic!("boom"); + } +} + +struct OnlyRead(io::Cursor>); + +impl Read for OnlyRead { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + let limit = buf.len().min(7); + self.0.read(&mut buf[..limit]) + } +} + +#[test] +fn every_sheet_of_a_source_reads_in_parallel_like_memory() { + let bytes = workbook_bytes(); + let memory = Workbook::open_memory(&bytes, XL_FORMAT_AUTO, None).expect("open memory"); + let expected: Vec> = memory + .sheets() + .expect("sheets") + .iter() + .map(|sheet| first_column(&mut sheet.rows().expect("cursor"))) + .collect(); + + let counters = Counters::default(); + let workbook = Workbook::open_source(MemorySource { bytes, counters: counters.clone() }, XL_FORMAT_AUTO, None) + .expect("open source"); + let sheets = workbook.sheets().expect("sheets"); + let parallel: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = sheets + .iter() + .chain(sheets.iter()) + .map(|sheet| { + let sheet = *sheet; + scope.spawn(move || first_column(&mut sheet.rows().expect("cursor"))) + }) + .collect(); + handles.into_iter().map(|handle| handle.join().expect("no panic")).collect() + }); + for (index, rows) in parallel.iter().enumerate() { + assert_eq!(rows, &expected[index % SHEETS]); + } + + drop(workbook); + assert_eq!(counters.drops.load(Ordering::SeqCst), 1); +} + +#[test] +fn an_io_error_reaches_the_caller_with_its_message() { + let error = Workbook::open_source(FailingSource, XL_FORMAT_XLSX, None).expect_err("must fail"); + assert!(error.message().contains("disk on fire"), "{}", error.message()); +} + +#[test] +fn a_panicking_source_becomes_an_error() { + let error = Workbook::open_source(PanickingSource, XL_FORMAT_XLSX, None).expect_err("must fail"); + assert!(error.message().contains("source panicked"), "{}", error.message()); +} + +#[test] +fn the_default_cache_fetches_a_small_workbook_once() { + let bytes = workbook_bytes(); + let cached = Counters::default(); + { + let workbook = Workbook::open_source(MemorySource { bytes: bytes.clone(), counters: cached.clone() }, XL_FORMAT_XLSX, None) + .expect("open"); + first_column(&mut workbook.sheet(0).expect("sheet").rows().expect("cursor")); + } + assert_eq!(cached.reads.load(Ordering::SeqCst), 1); + + let uncached = Counters::default(); + let options = OpenOptions::new().source_block_size(-1); + { + let workbook = Workbook::open_source(MemorySource { bytes, counters: uncached.clone() }, XL_FORMAT_XLSX, Some(&options)) + .expect("open"); + first_column(&mut workbook.sheet(0).expect("sheet").rows().expect("cursor")); + } + assert!(uncached.reads.load(Ordering::SeqCst) > 1); +} + +#[test] +fn a_reader_serves_a_csv_and_an_xlsx() { + let csv = OnlyRead(io::Cursor::new(b"name,qty\nwidget,7\ngadget,9\n".to_vec())); + let workbook = Workbook::open_reader(csv, XL_FORMAT_CSV, None).expect("open csv"); + let names = first_column(&mut workbook.sheet(0).expect("sheet").rows().expect("cursor")); + assert_eq!(names, vec!["name", "widget", "gadget"]); + + let xlsx = OnlyRead(io::Cursor::new(workbook_bytes())); + let workbook = Workbook::open_reader(xlsx, XL_FORMAT_AUTO, None).expect("open xlsx"); + let rows = first_column(&mut workbook.sheet(3).expect("sheet").rows().expect("cursor")); + assert_eq!(rows.len(), ROWS); + assert_eq!(rows[0], "s3-r0"); +} From 962e8720006c98d15285b233bb53ad7df1731e69 Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Wed, 7 Oct 2026 15:02:29 -0300 Subject: [PATCH 011/115] test(rust): pin source release counts and the new ABI layouts Co-Authored-By: Claude Opus 5.5 --- rust/excelreader/src/options.rs | 11 +++++++ rust/excelreader/tests/source.rs | 51 +++++++++++++++++++++++++++----- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/rust/excelreader/src/options.rs b/rust/excelreader/src/options.rs index 6d5f39c4..a14c58ae 100644 --- a/rust/excelreader/src/options.rs +++ b/rust/excelreader/src/options.rs @@ -385,4 +385,15 @@ mod tests { fn write_options_match_the_c_struct_size() { assert_eq!(std::mem::size_of::(), 32); } + + #[test] + fn source_layouts_match_the_c_structs() { + use crate::{XlOpenOptions, XlSource, XlStream}; + assert_eq!(std::mem::size_of::(), 112); + assert_eq!(std::mem::offset_of!(XlOpenOptions, source_cache_bytes), 88); + assert_eq!(std::mem::offset_of!(XlOpenOptions, max_buffered_bytes), 96); + assert_eq!(std::mem::offset_of!(XlOpenOptions, source_block_size), 104); + assert_eq!(std::mem::size_of::(), 40); + assert_eq!(std::mem::size_of::(), 32); + } } diff --git a/rust/excelreader/tests/source.rs b/rust/excelreader/tests/source.rs index 1c396145..9167fb42 100644 --- a/rust/excelreader/tests/source.rs +++ b/rust/excelreader/tests/source.rs @@ -62,7 +62,13 @@ impl Drop for MemorySource { } } -struct FailingSource; +struct FailingSource(Counters); + +impl Drop for FailingSource { + fn drop(&mut self) { + self.0.drops.fetch_add(1, Ordering::SeqCst); + } +} impl Source for FailingSource { fn size(&self) -> io::Result { @@ -74,7 +80,13 @@ impl Source for FailingSource { } } -struct PanickingSource; +struct PanickingSource(Counters); + +impl Drop for PanickingSource { + fn drop(&mut self) { + self.0.drops.fetch_add(1, Ordering::SeqCst); + } +} impl Source for PanickingSource { fn size(&self) -> io::Result { @@ -86,7 +98,19 @@ impl Source for PanickingSource { } } -struct OnlyRead(io::Cursor>); +struct OnlyRead(io::Cursor>, Counters); + +impl OnlyRead { + fn new(bytes: Vec, counters: &Counters) -> Self { + OnlyRead(io::Cursor::new(bytes), counters.clone()) + } +} + +impl Drop for OnlyRead { + fn drop(&mut self) { + self.1.drops.fetch_add(1, Ordering::SeqCst); + } +} impl Read for OnlyRead { fn read(&mut self, buf: &mut [u8]) -> io::Result { @@ -131,14 +155,18 @@ fn every_sheet_of_a_source_reads_in_parallel_like_memory() { #[test] fn an_io_error_reaches_the_caller_with_its_message() { - let error = Workbook::open_source(FailingSource, XL_FORMAT_XLSX, None).expect_err("must fail"); + let counters = Counters::default(); + let error = Workbook::open_source(FailingSource(counters.clone()), XL_FORMAT_XLSX, None).expect_err("must fail"); assert!(error.message().contains("disk on fire"), "{}", error.message()); + assert_eq!(counters.drops.load(Ordering::SeqCst), 1); } #[test] fn a_panicking_source_becomes_an_error() { - let error = Workbook::open_source(PanickingSource, XL_FORMAT_XLSX, None).expect_err("must fail"); + let counters = Counters::default(); + let error = Workbook::open_source(PanickingSource(counters.clone()), XL_FORMAT_XLSX, None).expect_err("must fail"); assert!(error.message().contains("source panicked"), "{}", error.message()); + assert_eq!(counters.drops.load(Ordering::SeqCst), 1); } #[test] @@ -164,13 +192,22 @@ fn the_default_cache_fetches_a_small_workbook_once() { #[test] fn a_reader_serves_a_csv_and_an_xlsx() { - let csv = OnlyRead(io::Cursor::new(b"name,qty\nwidget,7\ngadget,9\n".to_vec())); + let csv_counters = Counters::default(); + let csv = OnlyRead::new(b"name,qty +widget,7 +gadget,9 +".to_vec(), &csv_counters); let workbook = Workbook::open_reader(csv, XL_FORMAT_CSV, None).expect("open csv"); let names = first_column(&mut workbook.sheet(0).expect("sheet").rows().expect("cursor")); assert_eq!(names, vec!["name", "widget", "gadget"]); + assert_eq!(csv_counters.drops.load(Ordering::SeqCst), 0, "the CSV is read as it arrives"); + drop(workbook); + assert_eq!(csv_counters.drops.load(Ordering::SeqCst), 1); - let xlsx = OnlyRead(io::Cursor::new(workbook_bytes())); + let xlsx_counters = Counters::default(); + let xlsx = OnlyRead::new(workbook_bytes(), &xlsx_counters); let workbook = Workbook::open_reader(xlsx, XL_FORMAT_AUTO, None).expect("open xlsx"); + assert_eq!(xlsx_counters.drops.load(Ordering::SeqCst), 1, "an XLSX is read whole before open returns"); let rows = first_column(&mut workbook.sheet(3).expect("sheet").rows().expect("cursor")); assert_eq!(rows.len(), ROWS); assert_eq!(rows[0], "s3-r0"); From 4db2192bdcecc612861c409ea25d679a1205eefb Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Wed, 7 Oct 2026 15:06:22 -0300 Subject: [PATCH 012/115] feat(cpp): open a workbook from an xl::Source or an xl::InputStream Co-Authored-By: Claude Opus 5.5 --- cpp/tests/CMakeLists.txt | 8 + cpp/tests/sources.cpp | 170 ++++++++++++++++++ .../include/excelreader.hpp | 130 ++++++++++++++ 3 files changed, 308 insertions(+) create mode 100644 cpp/tests/sources.cpp diff --git a/cpp/tests/CMakeLists.txt b/cpp/tests/CMakeLists.txt index 3e166f8a..67467e16 100644 --- a/cpp/tests/CMakeLists.txt +++ b/cpp/tests/CMakeLists.txt @@ -38,6 +38,14 @@ target_compile_definitions(excelreader_cpp_rows PRIVATE add_test(NAME excelreader_cpp_rows COMMAND excelreader_cpp_rows) excelreader_copy_native_library(excelreader_cpp_rows) +add_executable(excelreader_cpp_sources sources.cpp) +target_link_libraries(excelreader_cpp_sources PRIVATE xl::excelreader) +target_compile_definitions(excelreader_cpp_sources PRIVATE + EXCELREADER_XLSX_FIXTURE_PATH="${EXCELREADER_XLSX_FIXTURE_PATH}") + +add_test(NAME excelreader_cpp_sources COMMAND excelreader_cpp_sources) +excelreader_copy_native_library(excelreader_cpp_sources) + add_executable(excelreader_cpp_sheets sheets.cpp) find_package(Threads REQUIRED) target_link_libraries(excelreader_cpp_sheets PRIVATE xl::excelreader Threads::Threads) diff --git a/cpp/tests/sources.cpp b/cpp/tests/sources.cpp new file mode 100644 index 00000000..1bcc958f --- /dev/null +++ b/cpp/tests/sources.cpp @@ -0,0 +1,170 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + int failures = 0; + + void check(bool condition, const char *what) + { + if (!condition) + { + std::fprintf(stderr, "FAILED: %s\n", what); + ++failures; + } + } + + std::vector read_file(const char *path) + { + std::ifstream in(path, std::ios::binary); + std::vector chars((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + std::vector bytes(chars.size()); + std::memcpy(bytes.data(), chars.data(), chars.size()); + return bytes; + } + + struct Counters + { + std::atomic reads{0}; + std::atomic destroyed{0}; + }; + + class MemorySource final : public xl::Source + { + public: + MemorySource(std::vector bytes, Counters &counters) : bytes_(std::move(bytes)), counters_(counters) {} + ~MemorySource() override { counters_.destroyed.fetch_add(1); } + + std::uint64_t size() const override { return bytes_.size(); } + + std::expected read_at(std::uint64_t offset, std::span buffer) const override + { + counters_.reads.fetch_add(1); + if (offset >= bytes_.size()) + { + return std::size_t{0}; + } + const std::size_t count = std::min(buffer.size(), bytes_.size() - offset); + std::memcpy(buffer.data(), bytes_.data() + offset, count); + return count; + } + + private: + std::vector bytes_; + Counters &counters_; + }; + + class FailingSource final : public xl::Source + { + public: + std::uint64_t size() const override { return 1024; } + std::expected read_at(std::uint64_t, std::span) const override + { + return std::unexpected(std::string("disk on fire")); + } + }; + + class ThrowingSource final : public xl::Source + { + public: + std::uint64_t size() const override { return 1024; } + std::expected read_at(std::uint64_t, std::span) const override + { + throw std::runtime_error("boom"); + } + }; + + class ChunkStream final : public xl::InputStream + { + public: + explicit ChunkStream(std::vector bytes) : bytes_(std::move(bytes)) {} + + std::expected read(std::span buffer) override + { + const std::size_t count = std::min({buffer.size(), std::size_t{7}, bytes_.size() - position_}); + std::memcpy(buffer.data(), bytes_.data() + position_, count); + position_ += count; + return count; + } + + private: + std::vector bytes_; + std::size_t position_ = 0; + }; + + std::size_t row_count(const xl::Workbook &workbook) + { + auto decoded = workbook.sheet(0).read_all_decoded(); + return decoded.has_value() ? decoded->size() : 0; + } +} + +int main() +{ + const std::vector bytes = read_file(EXCELREADER_XLSX_FIXTURE_PATH); + auto reference = xl::Workbook::open(EXCELREADER_XLSX_FIXTURE_PATH); + check(reference.has_value(), "open the fixture by path"); + const std::size_t expected_rows = reference.has_value() ? row_count(*reference) : 0; + check(expected_rows > 1, "the fixture has rows"); + + Counters counters; + { + auto workbook = xl::Workbook::open_source(std::make_unique(bytes, counters)); + check(workbook.has_value(), "open_source opens the fixture"); + check(workbook.has_value() && row_count(*workbook) == expected_rows, "open_source reads every row"); + } + check(counters.destroyed.load() == 1, "the source is destroyed exactly once"); + + const int blocks = static_cast((bytes.size() + (4u << 20) - 1) / (4u << 20)); + check(counters.reads.load() <= blocks, "the default cache fetches each block once"); + + Counters uncached; + { + xl::OpenOptions options; + options.source_block_size = -1; + auto workbook = xl::Workbook::open_source(std::make_unique(bytes, uncached), XL_FORMAT_XLSX, &options); + check(workbook.has_value() && row_count(*workbook) == expected_rows, "open_source without the cache reads every row"); + } + check(uncached.reads.load() > blocks, "without the cache every read reaches read_at"); + + { + auto workbook = xl::Workbook::open_source(std::make_unique(), XL_FORMAT_XLSX); + check(!workbook.has_value() && workbook.error().message.find("disk on fire") != std::string::npos, + "the source's error message reaches the caller"); + } + { + auto workbook = xl::Workbook::open_source(std::make_unique(), XL_FORMAT_XLSX); + check(!workbook.has_value() && workbook.error().message.find("boom") != std::string::npos, + "an exception from the source becomes an error"); + } + { + const std::string csv = "name,qty\nwidget,7\n"; + std::vector csv_bytes(csv.size()); + std::memcpy(csv_bytes.data(), csv.data(), csv.size()); + auto workbook = xl::Workbook::open_stream(std::make_unique(csv_bytes), XL_FORMAT_CSV); + check(workbook.has_value() && row_count(*workbook) == 2, "open_stream reads a CSV"); + } + { + auto workbook = xl::Workbook::open_stream(std::make_unique(bytes)); + check(workbook.has_value() && row_count(*workbook) == expected_rows, "open_stream reads an XLSX"); + } + + if (failures != 0) + { + std::fprintf(stderr, "%d check(s) failed\n", failures); + return EXIT_FAILURE; + } + std::puts("sources: OK"); + return EXIT_SUCCESS; +} diff --git a/src/ExcelReader.Native/include/excelreader.hpp b/src/ExcelReader.Native/include/excelreader.hpp index f3e39eb9..45d59dec 100644 --- a/src/ExcelReader.Native/include/excelreader.hpp +++ b/src/ExcelReader.Native/include/excelreader.hpp @@ -144,6 +144,9 @@ namespace xl int32_t max_zip_entries = 0; int32_t prefetch_decompression = XL_OPT_DEFAULT; int32_t intern_strings = XL_OPT_DEFAULT; + int64_t source_cache_bytes = 0; + int64_t max_buffered_bytes = 0; + int32_t source_block_size = 0; std::string password_{}; @@ -169,6 +172,9 @@ namespace xl opts.max_zip_entries = max_zip_entries; opts.prefetch_decompression = prefetch_decompression; opts.intern_strings = intern_strings; + opts.source_cache_bytes = source_cache_bytes; + opts.max_buffered_bytes = max_buffered_bytes; + opts.source_block_size = source_block_size; opts.password = password_.empty() ? nullptr : reinterpret_cast(password_.data()); opts.password_len = static_cast(password_.size()); return opts; @@ -800,6 +806,75 @@ namespace xl int32_t index_ = 0; }; + /// Random-access bytes for Workbook::open_source. read_at is called from several threads at once. + class Source + { + public: + virtual ~Source() = default; + virtual std::uint64_t size() const = 0; + virtual std::expected read_at(std::uint64_t offset, std::span buffer) const = 0; + }; + + /// Sequential bytes for Workbook::open_stream. read is never called concurrently. + class InputStream + { + public: + virtual ~InputStream() = default; + virtual std::expected read(std::span buffer) = 0; + }; + + namespace detail + { + inline int64_t report_source_error(std::string_view message) noexcept + { + xl_set_source_error(reinterpret_cast(message.data()), static_cast(message.size())); + return -1; + } + + template + inline int64_t guarded_read(Call &&call) noexcept + { + try + { + auto result = call(); + return result.has_value() ? static_cast(*result) : report_source_error(result.error()); + } + catch (const std::exception &e) + { + return report_source_error(e.what()); + } + catch (...) + { + return report_source_error("the source threw an exception"); + } + } + + inline int64_t source_read_at(void *user_data, int64_t offset, uint8_t *buffer, int64_t length) noexcept + { + return guarded_read([&] + { return static_cast(user_data)->read_at( + static_cast(offset), + std::span(reinterpret_cast(buffer), static_cast(length))); }); + } + + inline int64_t stream_read(void *user_data, uint8_t *buffer, int64_t length) noexcept + { + return guarded_read([&] + { return static_cast(user_data)->read( + std::span(reinterpret_cast(buffer), static_cast(length))); }); + } + + inline void source_release(void *user_data) noexcept + { + delete static_cast(user_data); + } + + inline void stream_release(void *user_data) noexcept + { + delete static_cast(user_data); + } + } + /// An open workbook. Safe to use from several threads at once: every read goes through a Sheet and opens its own cursor. class Workbook { @@ -859,6 +934,61 @@ namespace xl return Workbook(handle); } + /// Opens a workbook over a source the library owns from here on; every sheet can be read in parallel. + static std::expected open_source(std::unique_ptr source, int32_t format = XL_FORMAT_AUTO, + const OpenOptions *options = nullptr) + { + if (const auto &abi = detail::check_abi_version(); !abi.has_value()) + { + return std::unexpected(abi.error()); + } + if (source == nullptr) + { + return std::unexpected(Error{XL_INVALID_ARGUMENT, "source is null"}); + } + xl_source raw{}; + raw.struct_size = sizeof(xl_source); + raw.length = static_cast(source->size()); + raw.read_at = &detail::source_read_at; + raw.release = &detail::source_release; + raw.user_data = source.release(); + xl_workbook *handle = nullptr; + xl_open_options c_options{}; + int32_t status = xl_open_source(&raw, format, detail::lower_options(options, c_options), &handle); + if (status != XL_OK) + { + return std::unexpected(detail::make_error(status)); + } + return Workbook(handle); + } + + /// Opens a workbook over a stream that cannot seek: a CSV is read as it arrives, once; an XLSX, XLSB or XLS is read whole first. + static std::expected open_stream(std::unique_ptr stream, int32_t format = XL_FORMAT_AUTO, + const OpenOptions *options = nullptr) + { + if (const auto &abi = detail::check_abi_version(); !abi.has_value()) + { + return std::unexpected(abi.error()); + } + if (stream == nullptr) + { + return std::unexpected(Error{XL_INVALID_ARGUMENT, "stream is null"}); + } + xl_stream raw{}; + raw.struct_size = sizeof(xl_stream); + raw.read = &detail::stream_read; + raw.release = &detail::stream_release; + raw.user_data = stream.release(); + xl_workbook *handle = nullptr; + xl_open_options c_options{}; + int32_t status = xl_open_stream(&raw, format, detail::lower_options(options, c_options), &handle); + if (status != XL_OK) + { + return std::unexpected(detail::make_error(status)); + } + return Workbook(handle); + } + std::expected sheet_count() const { From 2b1b3ad968919f9562c221332b5dd601e106564f Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Wed, 7 Oct 2026 15:10:04 -0300 Subject: [PATCH 013/115] feat(python): open a workbook from a Source or a readable stream Co-Authored-By: Claude Opus 5.5 --- python/src/excelreader/__init__.py | 4 + python/src/excelreader/_native.py | 40 +++++++++ python/src/excelreader/sources.py | 106 ++++++++++++++++++++++ python/src/excelreader/types.py | 7 ++ python/tests/test_native.py | 3 + python/tests/test_sources.py | 140 +++++++++++++++++++++++++++++ 6 files changed, 300 insertions(+) create mode 100644 python/src/excelreader/sources.py create mode 100644 python/tests/test_sources.py diff --git a/python/src/excelreader/__init__.py b/python/src/excelreader/__init__.py index b5cf6ba4..6b8457d1 100644 --- a/python/src/excelreader/__init__.py +++ b/python/src/excelreader/__init__.py @@ -2,6 +2,7 @@ from excelreader.arrow_stream import ArrowStream from excelreader.reader import Sheet, Sheets, Workbook, decode_cell, open_bytes, open_workbook +from excelreader.sources import Source, open_source, open_stream from excelreader.types import ( Cell, CellType, @@ -46,6 +47,7 @@ "SheetVisibility", "SheetWriter", "Sheets", + "Source", "StringColumn", "TypedTable", "Workbook", @@ -54,6 +56,8 @@ "encrypt_package", "encrypt_package_bytes", "open_bytes", + "open_source", + "open_stream", "open_workbook", "open_writer", "open_writer_to_memory", diff --git a/python/src/excelreader/_native.py b/python/src/excelreader/_native.py index a619aacb..d6b25317 100644 --- a/python/src/excelreader/_native.py +++ b/python/src/excelreader/_native.py @@ -263,6 +263,37 @@ class NativeOpenOptions(ctypes.Structure): ("intern_strings", ctypes.c_int32), ("password", ctypes.POINTER(ctypes.c_uint8)), ("password_len", ctypes.c_int32), + ("source_cache_bytes", ctypes.c_int64), + ("max_buffered_bytes", ctypes.c_int64), + ("source_block_size", ctypes.c_int32), + ] + + +SourceReadAt = ctypes.CFUNCTYPE(ctypes.c_int64, ctypes.c_void_p, ctypes.c_int64, ctypes.c_void_p, ctypes.c_int64) +StreamRead = ctypes.CFUNCTYPE(ctypes.c_int64, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_int64) +SourceRelease = ctypes.CFUNCTYPE(None, ctypes.c_void_p) + + +class NativeSource(ctypes.Structure): + """Mirrors xl_source.""" + + _fields_ = [ + ("struct_size", ctypes.c_int32), + ("user_data", ctypes.c_void_p), + ("length", ctypes.c_int64), + ("read_at", SourceReadAt), + ("release", SourceRelease), + ] + + +class NativeStream(ctypes.Structure): + """Mirrors xl_stream.""" + + _fields_ = [ + ("struct_size", ctypes.c_int32), + ("user_data", ctypes.c_void_p), + ("read", StreamRead), + ("release", SourceRelease), ] @@ -310,6 +341,9 @@ def to_native_open_options(options: OpenOptions) -> NativeOpenOptions: raw.max_cell_bytes = _opt_number(options.max_cell_bytes) raw.max_shared_string_bytes = _opt_number(options.max_shared_string_bytes) raw.max_zip_entries = _opt_number(options.max_zip_entries) + raw.source_cache_bytes = _opt_number(options.source_cache_bytes) + raw.max_buffered_bytes = _opt_number(options.max_buffered_bytes) + raw.source_block_size = _opt_number(options.source_block_size) return raw @@ -445,6 +479,12 @@ def _bind(lib: ctypes.CDLL) -> ctypes.CDLL: lib.xl_open_file.restype = c_int lib.xl_open_memory.argtypes = [p_bytes, c_int, c_int, p_open_options, pp_void] lib.xl_open_memory.restype = c_int + lib.xl_open_source.argtypes = [ctypes.POINTER(NativeSource), c_int, p_open_options, pp_void] + lib.xl_open_source.restype = c_int + lib.xl_open_stream.argtypes = [ctypes.POINTER(NativeStream), c_int, p_open_options, pp_void] + lib.xl_open_stream.restype = c_int + lib.xl_set_source_error.argtypes = [p_bytes, c_int] + lib.xl_set_source_error.restype = None lib.xl_sheet_visibility_at.argtypes = [p_void, c_int, p_int] lib.xl_sheet_visibility_at.restype = c_int lib.xl_sheet_index.argtypes = [p_void, p_bytes, c_int, p_int] diff --git a/python/src/excelreader/sources.py b/python/src/excelreader/sources.py new file mode 100644 index 00000000..f6a8931a --- /dev/null +++ b/python/src/excelreader/sources.py @@ -0,0 +1,106 @@ +"""Open a workbook from bytes the caller serves: a positional `Source`, or a readable stream.""" + +from __future__ import annotations + +import ctypes +import itertools +from typing import Protocol + +from excelreader import _native +from excelreader.reader import Workbook, _check, _raw_options, _resolve_format +from excelreader.types import OpenOptions + + +class Source(Protocol): + """Random-access bytes for `open_source()`. `read_at` is called from several threads at once.""" + + size: int + + def read_at(self, offset: int, buffer: memoryview) -> int: + """Fills `buffer` with the bytes starting at `offset` and returns how many it wrote.""" + ... + + +_keys = itertools.count(1) +_live: dict[int, object] = {} + + +def _fail(error: Exception) -> int: + message = (str(error) or type(error).__name__).encode("utf-8", errors="replace") + _native.load_library().xl_set_source_error(message, len(message)) + return -1 + + +def _view(address: int, length: int) -> memoryview: + return memoryview((ctypes.c_ubyte * length).from_address(address)).cast("B") + + +@_native.SourceReadAt +def _read_at(key: int, offset: int, buffer: int, length: int) -> int: + try: + return _live[key].read_at(offset, _view(buffer, length)) + except Exception as error: # noqa: BLE001 + return _fail(error) + + +@_native.StreamRead +def _read(key: int, buffer: int, length: int) -> int: + try: + stream = _live[key] + readinto = getattr(stream, "readinto", None) + if readinto is None: + data = stream.read(length) + ctypes.memmove(buffer, data, len(data)) + return len(data) + count = readinto(_view(buffer, length)) + if count is None: + raise OSError("the stream has no data ready; open_stream() needs a blocking stream") + return count + except Exception as error: # noqa: BLE001 + return _fail(error) + + +@_native.SourceRelease +def _release(key: int) -> None: + _live.pop(key, None) + + +def open_source( + source: Source, format: str | None = None, *, options: OpenOptions | None = None, password: str | None = None +) -> Workbook: + """Opens a workbook over bytes `source` serves; every sheet can be read on its own thread. + + The library keeps a reference to `source` until the workbook and everything read from it are + closed. `read_at` runs with the GIL held, so a local file is better opened with `open_workbook()`. + """ + size = source.size + lib = _native.load_library() + resolved = _resolve_format(format, None) + key = next(_keys) + _live[key] = source + raw = _native.NativeSource(ctypes.sizeof(_native.NativeSource), key, size, _read_at, _release) + raw_options, _raw, _password_buffer = _raw_options(options, password) + handle = ctypes.c_void_p() + _check(lib.xl_open_source(ctypes.byref(raw), resolved, raw_options, ctypes.byref(handle))) + return Workbook(handle) + + +def open_stream( + stream: object, format: str | None = None, *, options: OpenOptions | None = None, password: str | None = None +) -> Workbook: + """Opens a workbook over a stream that cannot seek, such as `sys.stdin.buffer` or a socket. + + A CSV (`format="csv"`) is read as it arrives and only once; an XLSX, XLSB or XLS is read whole + before the workbook opens. `stream` needs `readinto` or `read`; the library never closes it. + """ + if not (hasattr(stream, "readinto") or hasattr(stream, "read")): + raise TypeError("open_stream() needs an object with readinto() or read()") + lib = _native.load_library() + resolved = _resolve_format(format, None) + key = next(_keys) + _live[key] = stream + raw = _native.NativeStream(ctypes.sizeof(_native.NativeStream), key, _read, _release) + raw_options, _raw, _password_buffer = _raw_options(options, password) + handle = ctypes.c_void_p() + _check(lib.xl_open_stream(ctypes.byref(raw), resolved, raw_options, ctypes.byref(handle))) + return Workbook(handle) diff --git a/python/src/excelreader/types.py b/python/src/excelreader/types.py index 8693c8c5..c562a61a 100644 --- a/python/src/excelreader/types.py +++ b/python/src/excelreader/types.py @@ -117,6 +117,10 @@ class OpenOptions(NamedTuple): `csv_delimiter` and `csv_quote` are byte values, not strings: pass `ord(';')`, not `';'`. + `source_block_size` and `source_cache_bytes` tune the block cache of `open_source()` (4 MiB blocks + in 64 MiB by default; a negative block size turns the cache off). `max_buffered_bytes` caps how + much `open_stream()` reads before an XLSX, XLSB or XLS opens. + Validation happens on the native side, which owns the real limits; an out-of-range value raises `ExcelReaderError` with the reason. Fields mirror xl_open_options in src/ExcelReader.Native/include/excelreader.h. @@ -134,6 +138,9 @@ class OpenOptions(NamedTuple): max_zip_entries: int | None = None prefetch_decompression: bool | None = None intern_strings: bool | None = None + source_block_size: int | None = None + source_cache_bytes: int | None = None + max_buffered_bytes: int | None = None class WriteOptions(NamedTuple): diff --git a/python/tests/test_native.py b/python/tests/test_native.py index 735934f1..291186b6 100644 --- a/python/tests/test_native.py +++ b/python/tests/test_native.py @@ -22,6 +22,9 @@ def test_exported_functions_are_present(): for name in ( "xl_open_file", "xl_open_memory", + "xl_open_source", + "xl_open_stream", + "xl_set_source_error", "xl_close", "xl_sheet_count", "xl_sheet_name_at", diff --git a/python/tests/test_sources.py b/python/tests/test_sources.py new file mode 100644 index 00000000..eceae2a1 --- /dev/null +++ b/python/tests/test_sources.py @@ -0,0 +1,140 @@ +import gc +import io +import os +import threading +import weakref +from concurrent.futures import ThreadPoolExecutor + +import pytest +from excelreader import ExcelReaderError, OpenOptions, open_bytes, open_source, open_stream, open_writer_to_memory +from excelreader import sources as sources_module + +SHEETS = 4 +ROWS = 300 + + +def _workbook_bytes() -> bytes: + with open_writer_to_memory("xlsx") as writer: + for sheet in range(SHEETS): + writer.start_sheet(f"sheet{sheet}") + for row in range(ROWS): + writer.write_row([f"s{sheet}-r{row}"]) + writer.end_sheet() + return writer.bytes() + + +def _first_column(sheet) -> list[str]: + return [row[0].value for row in sheet.rows()] + + +class BytesSource: + def __init__(self, data: bytes) -> None: + self.data = data + self.size = len(data) + self.reads = 0 + self._lock = threading.Lock() + + def read_at(self, offset: int, buffer: memoryview) -> int: + with self._lock: + self.reads += 1 + chunk = self.data[offset : offset + len(buffer)] + buffer[: len(chunk)] = chunk + return len(chunk) + + +class FailingSource: + size = 1024 + + def read_at(self, offset: int, buffer: memoryview) -> int: + raise OSError("disk on fire") + + +class OnlyRead: + def __init__(self, data: bytes) -> None: + self._inner = io.BytesIO(data) + + def read(self, size: int) -> bytes: + return self._inner.read(min(size, 7)) + + +def test_every_sheet_of_a_source_reads_in_parallel_like_memory(): + data = _workbook_bytes() + with open_bytes(data) as reference: + expected = [_first_column(sheet) for sheet in reference.sheets] + + with open_source(BytesSource(data)) as workbook, ThreadPoolExecutor(max_workers=SHEETS * 2) as pool: + sheets = list(workbook.sheets) + parallel = list(pool.map(_first_column, sheets + sheets)) + + for position, rows in enumerate(parallel): + assert rows == expected[position % SHEETS] + + +def test_the_source_is_dropped_after_the_workbook_closes(): + source = BytesSource(_workbook_bytes()) + finalized = weakref.finalize(source, lambda: None) + workbook = open_source(source, "xlsx") + del source + _first_column(workbook.sheets[0]) + workbook.close() + gc.collect() + assert not finalized.alive + assert sources_module._live == {} + + +def test_a_source_error_reaches_the_caller_with_its_message(): + with pytest.raises(ExcelReaderError, match="disk on fire"): + open_source(FailingSource(), "xlsx") + + +def test_the_default_cache_fetches_a_small_workbook_once(): + data = _workbook_bytes() + cached = BytesSource(data) + with open_source(cached, "xlsx") as workbook: + _first_column(workbook.sheets[0]) + assert cached.reads == 1 + + uncached = BytesSource(data) + with open_source(uncached, "xlsx", options=OpenOptions(source_block_size=-1)) as workbook: + _first_column(workbook.sheets[0]) + assert uncached.reads > 1 + + +def test_one_source_object_opened_twice_at_once_serves_both(): + source = BytesSource(_workbook_bytes()) + first = open_source(source, "xlsx") + second = open_source(source, "xlsx") + first.close() + assert _first_column(second.sheets[2])[0] == "s2-r0" + second.close() + + +def test_open_stream_reads_a_csv_from_a_pipe(): + read_fd, write_fd = os.pipe() + + def produce() -> None: + with os.fdopen(write_fd, "wb") as out: + out.write(b"name,qty\n") + for i in range(1000): + out.write(f"row{i},{i}\n".encode()) + + producer = threading.Thread(target=produce) + producer.start() + with os.fdopen(read_fd, "rb") as pipe, open_stream(pipe, "csv") as workbook: + names = _first_column(workbook.sheets[0]) + producer.join() + assert names[0] == "name" + assert len(names) == 1001 + assert names[-1] == "row999" + + +def test_open_stream_reads_an_xlsx_from_an_object_with_only_read(): + with open_stream(OnlyRead(_workbook_bytes())) as workbook: + rows = _first_column(workbook.sheets[3]) + assert rows[0] == "s3-r0" + assert len(rows) == ROWS + + +def test_open_stream_rejects_an_object_that_cannot_be_read(): + with pytest.raises(TypeError): + open_stream(object()) From 995dec97b63fdc55936a16e93fc4bf825f78c40b Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Wed, 7 Oct 2026 15:14:05 -0300 Subject: [PATCH 014/115] fix(python): register a source only after every fallible step, and guard stream reads Co-Authored-By: Claude Opus 5.5 --- python/src/excelreader/sources.py | 22 +++++++++----- python/tests/test_sources.py | 49 ++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/python/src/excelreader/sources.py b/python/src/excelreader/sources.py index f6a8931a..5c9a75af 100644 --- a/python/src/excelreader/sources.py +++ b/python/src/excelreader/sources.py @@ -4,6 +4,7 @@ import ctypes import itertools +import operator from typing import Protocol from excelreader import _native @@ -38,7 +39,7 @@ def _view(address: int, length: int) -> memoryview: @_native.SourceReadAt def _read_at(key: int, offset: int, buffer: int, length: int) -> int: try: - return _live[key].read_at(offset, _view(buffer, length)) + return operator.index(_live[key].read_at(offset, _view(buffer, length))) except Exception as error: # noqa: BLE001 return _fail(error) @@ -49,12 +50,17 @@ def _read(key: int, buffer: int, length: int) -> int: stream = _live[key] readinto = getattr(stream, "readinto", None) if readinto is None: - data = stream.read(length) + data = bytes(stream.read(length)) + if len(data) > length: + raise OSError("the stream returned more bytes than requested") ctypes.memmove(buffer, data, len(data)) return len(data) count = readinto(_view(buffer, length)) if count is None: raise OSError("the stream has no data ready; open_stream() needs a blocking stream") + count = operator.index(count) + if count > length: + raise OSError("the stream returned more bytes than requested") return count except Exception as error: # noqa: BLE001 return _fail(error) @@ -73,14 +79,16 @@ def open_source( The library keeps a reference to `source` until the workbook and everything read from it are closed. `read_at` runs with the GIL held, so a local file is better opened with `open_workbook()`. """ - size = source.size + size = operator.index(source.size) + if size < 0: + raise ValueError("source.size must not be negative") lib = _native.load_library() resolved = _resolve_format(format, None) + raw_options, _raw, _password_buffer = _raw_options(options, password) key = next(_keys) - _live[key] = source raw = _native.NativeSource(ctypes.sizeof(_native.NativeSource), key, size, _read_at, _release) - raw_options, _raw, _password_buffer = _raw_options(options, password) handle = ctypes.c_void_p() + _live[key] = source _check(lib.xl_open_source(ctypes.byref(raw), resolved, raw_options, ctypes.byref(handle))) return Workbook(handle) @@ -97,10 +105,10 @@ def open_stream( raise TypeError("open_stream() needs an object with readinto() or read()") lib = _native.load_library() resolved = _resolve_format(format, None) + raw_options, _raw, _password_buffer = _raw_options(options, password) key = next(_keys) - _live[key] = stream raw = _native.NativeStream(ctypes.sizeof(_native.NativeStream), key, _read, _release) - raw_options, _raw, _password_buffer = _raw_options(options, password) handle = ctypes.c_void_p() + _live[key] = stream _check(lib.xl_open_stream(ctypes.byref(raw), resolved, raw_options, ctypes.byref(handle))) return Workbook(handle) diff --git a/python/tests/test_sources.py b/python/tests/test_sources.py index eceae2a1..97f4acf1 100644 --- a/python/tests/test_sources.py +++ b/python/tests/test_sources.py @@ -73,13 +73,15 @@ def test_every_sheet_of_a_source_reads_in_parallel_like_memory(): def test_the_source_is_dropped_after_the_workbook_closes(): source = BytesSource(_workbook_bytes()) finalized = weakref.finalize(source, lambda: None) + before = set(sources_module._live) workbook = open_source(source, "xlsx") + assert len(sources_module._live) == len(before) + 1 del source _first_column(workbook.sheets[0]) workbook.close() gc.collect() assert not finalized.alive - assert sources_module._live == {} + assert set(sources_module._live) == before def test_a_source_error_reaches_the_caller_with_its_message(): @@ -138,3 +140,48 @@ def test_open_stream_reads_an_xlsx_from_an_object_with_only_read(): def test_open_stream_rejects_an_object_that_cannot_be_read(): with pytest.raises(TypeError): open_stream(object()) + + +def test_a_failed_open_leaves_nothing_registered(): + before = set(sources_module._live) + source = BytesSource(_workbook_bytes()) + with pytest.raises(ValueError): + open_source(source, "nope") + with pytest.raises(ValueError): + open_source(source, "xlsx", password="\ud800") + negative = BytesSource(b"") + negative.size = -1 + with pytest.raises(ValueError): + open_source(negative, "xlsx") + with pytest.raises(ValueError): + open_stream(io.BytesIO(), "nope") + assert set(sources_module._live) == before + + +def test_open_stream_fails_on_a_text_stream_and_releases_it(): + before = set(sources_module._live) + with pytest.raises(ExcelReaderError), open_stream(io.StringIO("a,b\n1,2\n"), "csv") as workbook: + _first_column(workbook.sheets[0]) + assert set(sources_module._live) == before + + +def test_open_stream_fails_when_read_returns_more_than_asked(): + class Greedy: + def read(self, size: int) -> bytes: + return b"x" * (size + 1) + + before = set(sources_module._live) + with pytest.raises(ExcelReaderError, match="more bytes than requested"), open_stream(Greedy(), "csv") as workbook: + _first_column(workbook.sheets[0]) + assert set(sources_module._live) == before + + +def test_a_read_at_that_returns_none_reaches_the_caller_as_an_error(): + class Bad: + size = 1024 + + def read_at(self, offset: int, buffer: memoryview): + return None + + with pytest.raises(ExcelReaderError): + open_source(Bad(), "xlsx") From fe9a3b6611af526dafcb5cbdc0782d1680c370a7 Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Wed, 7 Oct 2026 15:17:46 -0300 Subject: [PATCH 015/115] docs: read workbooks from your own sources and streams Co-Authored-By: Claude Opus 5.5 --- cpp/README.md | 27 +++++++++++++++++++++++ python/README.md | 36 +++++++++++++++++++++++++++++++ rust/excelreader/README.md | 37 ++++++++++++++++++++++++++++++++ src/ExcelReader.Native/README.md | 35 +++++++++++++++++++++++++++++- 4 files changed, 134 insertions(+), 1 deletion(-) diff --git a/cpp/README.md b/cpp/README.md index d21e5162..e8c0d794 100644 --- a/cpp/README.md +++ b/cpp/README.md @@ -245,6 +245,33 @@ if (!encrypted) { /* encrypted.error().message */ } Encryption parameters are fixed at Excel's own defaults — there are no options — and only XLSX/XLSB packages can be encrypted, matching what `Workbook::open`/`open_memory` can decrypt. +### Reading from your own bytes + +Derive from `xl::Source` to read from anything you can read at an offset; `read_at` is called from +several threads, in cached 4 MiB blocks. Derive from `xl::InputStream` for a stream that cannot +seek: a CSV is read as it arrives, an XLSX, XLSB or XLS is read whole first. + +```cpp +class StdinStream final : public xl::InputStream +{ +public: + std::expected read(std::span buffer) override + { + std::cin.read(reinterpret_cast(buffer.data()), static_cast(buffer.size())); + if (std::cin.bad()) + { + return std::unexpected(std::string("reading stdin failed")); + } + return static_cast(std::cin.gcount()); + } +}; + +auto workbook = xl::Workbook::open_stream(std::make_unique(), XL_FORMAT_CSV); +``` + +The workbook owns the object you pass and deletes it once nothing needs it. A returned error +string or a thrown exception becomes the `xl::Error` of the call that needed the bytes. + ### Arrow export `` is a separate header — including `` never pulls the diff --git a/python/README.md b/python/README.md index 74160f1e..8322f20e 100644 --- a/python/README.md +++ b/python/README.md @@ -380,6 +380,42 @@ with open_bytes(payload) as workbook: ... ``` +### From your own bytes + +`open_source()` takes any object with a `size` and a `read_at(offset, buffer)` that fills `buffer` +and returns the count. The library asks for 4 MiB blocks and caches them, and every sheet can still +be read on its own thread. + +```python +import urllib.request + +class HttpSource: + def __init__(self, url): + self.url = url + with urllib.request.urlopen(urllib.request.Request(url, method="HEAD")) as response: + self.size = int(response.headers["Content-Length"]) + + def read_at(self, offset, buffer): + request = urllib.request.Request(self.url, headers={"Range": f"bytes={offset}-{offset + len(buffer) - 1}"}) + with urllib.request.urlopen(request) as response: + data = response.read() + buffer[: len(data)] = data + return len(data) + +with open_source(HttpSource("https://example.com/book.xlsx")) as workbook: + ... +``` + +`open_stream()` reads anything with `readinto()` or `read()`, such as `sys.stdin.buffer`. A CSV is +read as it arrives; an XLSX, XLSB or XLS is read whole first. + +```python +with open_stream(sys.stdin.buffer, "csv") as workbook: + ... +``` + +Both callbacks run with the GIL held. For a file on disk, `open_workbook()` is faster. + ### Reader options `open_workbook()`/`open_bytes()` take an optional `OpenOptions` for CSV dialect settings and reader diff --git a/rust/excelreader/README.md b/rust/excelreader/README.md index 7727bbef..80db3161 100644 --- a/rust/excelreader/README.md +++ b/rust/excelreader/README.md @@ -147,6 +147,43 @@ encrypt_package("plain.xlsx", "secret.xlsx", "hunter2")?; Encryption parameters are fixed at Excel's own defaults - there are no options - and only XLSX/XLSB packages can be encrypted, matching what `Workbook::open_with`/`open_memory` can decrypt. +### Reading from your own bytes + +Implement [`Source`] to read from anything you can read at an offset. The library calls `read_at` +from several threads, fetching 4 MiB blocks it caches; every sheet can still be read in parallel. + +```rust +struct HttpSource { client: reqwest::blocking::Client, url: String, size: u64 } + +impl excelreader::Source for HttpSource { + fn size(&self) -> std::io::Result { + Ok(self.size) + } + + fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result { + let range = format!("bytes={}-{}", offset, offset + buf.len() as u64 - 1); + let bytes = self.client.get(&self.url).header("Range", range).send() + .and_then(|r| r.error_for_status()) + .and_then(|r| r.bytes()) + .map_err(std::io::Error::other)?; + buf[..bytes.len()].copy_from_slice(&bytes); + Ok(bytes.len()) + } +} + +let workbook = Workbook::open_source(source, XL_FORMAT_AUTO, None)?; +``` + +Anything that implements `Read` works through `open_reader`. A CSV is read as it arrives; an XLSX, +XLSB or XLS is read whole first. + +```rust +let workbook = Workbook::open_reader(std::io::stdin(), XL_FORMAT_CSV, None)?; +``` + +An `io::Error` from your source, or a panic in it, comes back as the `Error` of the call that +needed the bytes. + ### Arrow export (`arrow` feature) ```toml diff --git a/src/ExcelReader.Native/README.md b/src/ExcelReader.Native/README.md index fb8a4b7d..302f6a94 100644 --- a/src/ExcelReader.Native/README.md +++ b/src/ExcelReader.Native/README.md @@ -52,7 +52,8 @@ may get worse, not only better. | `Reading/RowViewBuffer.cs` | Cursor-owned native row behind `xl_rows_next_view`. | | `include/excelreader.h` | Hand-written C header; keep in sync with `Exports*.cs`. | -Reading exports: `xl_open_file`, `xl_open_memory`, `xl_close`, `xl_sheet_count`, `xl_sheet_name_at`, +Reading exports: `xl_open_file`, `xl_open_memory`, `xl_open_source`, `xl_open_stream`, +`xl_set_source_error`, `xl_close`, `xl_sheet_count`, `xl_sheet_name_at`, `xl_sheet_visibility_at`, `xl_sheet_index`, `xl_is_date1904`, `xl_rows_open`, `xl_rows_next`, `xl_rows_next_view`, `xl_rows_read_all_blob`, `xl_rows_read_all_decoded`, `xl_rows_close`, `xl_free_rows`, `xl_parse_typed`, `xl_free_table`, `xl_typed_reader_open`, `xl_typed_reader_next`, @@ -143,3 +144,35 @@ To produce one, `xl_encrypt_package` wraps a finished XLSX/XLSB file in the encr `xl_encrypt_package_to_memory` does the same over bytes, so a package written with `xl_write_typed_to_memory` is encrypted without its plaintext ever reaching disk. Release the result with `xl_free_buffer`. + +## Opening from your own bytes + +`xl_open_source` reads through a callback that copies bytes at an offset, from any thread. Use it +for S3, HTTP range requests or any store you can read at an offset; every sheet can still be read +in parallel. By default the library asks for 4 MiB blocks and keeps 64 MiB of them +(`source_block_size`, `source_cache_bytes` in `xl_open_options`). + +`xl_open_stream` reads through a callback that copies the next bytes. A CSV is read as it arrives, +by one cursor; an XLSX, XLSB or XLS is read whole first (`max_buffered_bytes` caps it), because +those formats need random access. + +Both take ownership: `release` runs exactly once, when nothing needs the bytes any more. A failing +callback calls `xl_set_source_error` and returns -1; that message is what `xl_last_error` reports. +Inside a callback, no other `xl_` function may be called. + +```c +static int64_t read_at(void* user_data, int64_t offset, uint8_t* buf, int64_t len) +{ + my_blob* blob = user_data; + int64_t got = my_blob_read(blob, offset, buf, len); /* your store */ + if (got < 0) { + const char* message = my_blob_error(blob); + xl_set_source_error((const uint8_t*)message, (int32_t)strlen(message)); + } + return got; +} + +xl_source source = { sizeof(xl_source), blob, my_blob_size(blob), read_at, my_blob_free }; +xl_workbook* workbook = NULL; +int32_t status = xl_open_source(&source, XL_FORMAT_AUTO, NULL, &workbook); +``` From 79f6201322ae437a1e2ba657f21ed0c967d9fbe0 Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Wed, 7 Oct 2026 15:33:32 -0300 Subject: [PATCH 016/115] fix: final-review fixes for callback sources Python callbacks now catch BaseException so Ctrl-C fails the read instead of truncating a CSV stream. C++ open_source rejects a size above INT64_MAX before releasing the source, and the C++ tests cover destruction on failure, parallel reads and stream errors. The smoke test pins the 88-byte 6.0 options struct. Source error wording no longer claims 0 is valid, StreamBuffer clamps its limit to Array.MaxLength, and the header, docstring and READMEs are tightened. Co-Authored-By: Claude Opus 5.5 --- cpp/tests/CMakeLists.txt | 3 +- cpp/tests/sources.cpp | 111 +++++++++++++++++- python/README.md | 6 + python/src/excelreader/sources.py | 11 +- python/tests/test_sources.py | 11 ++ src/ExcelReader.Native/README.md | 8 +- .../Reading/SourceErrors.cs | 2 +- .../Reading/StreamBuffer.cs | 1 + src/ExcelReader.Native/include/excelreader.h | 6 +- .../include/excelreader.hpp | 8 +- tests/ExcelReader.NativeSmoke/smoke.c | 8 ++ 11 files changed, 160 insertions(+), 15 deletions(-) diff --git a/cpp/tests/CMakeLists.txt b/cpp/tests/CMakeLists.txt index 67467e16..a91732ae 100644 --- a/cpp/tests/CMakeLists.txt +++ b/cpp/tests/CMakeLists.txt @@ -39,7 +39,8 @@ add_test(NAME excelreader_cpp_rows COMMAND excelreader_cpp_rows) excelreader_copy_native_library(excelreader_cpp_rows) add_executable(excelreader_cpp_sources sources.cpp) -target_link_libraries(excelreader_cpp_sources PRIVATE xl::excelreader) +find_package(Threads REQUIRED) +target_link_libraries(excelreader_cpp_sources PRIVATE xl::excelreader Threads::Threads) target_compile_definitions(excelreader_cpp_sources PRIVATE EXCELREADER_XLSX_FIXTURE_PATH="${EXCELREADER_XLSX_FIXTURE_PATH}") diff --git a/cpp/tests/sources.cpp b/cpp/tests/sources.cpp index 1bcc958f..a84afe35 100644 --- a/cpp/tests/sources.cpp +++ b/cpp/tests/sources.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include namespace @@ -68,21 +69,42 @@ namespace class FailingSource final : public xl::Source { public: + explicit FailingSource(Counters &counters) : counters_(counters) {} + ~FailingSource() override { counters_.destroyed.fetch_add(1); } + std::uint64_t size() const override { return 1024; } std::expected read_at(std::uint64_t, std::span) const override { return std::unexpected(std::string("disk on fire")); } + + private: + Counters &counters_; }; class ThrowingSource final : public xl::Source { public: + explicit ThrowingSource(Counters &counters) : counters_(counters) {} + ~ThrowingSource() override { counters_.destroyed.fetch_add(1); } + std::uint64_t size() const override { return 1024; } std::expected read_at(std::uint64_t, std::span) const override { throw std::runtime_error("boom"); } + + private: + Counters &counters_; + }; + + class ErrorStream final : public xl::InputStream + { + public: + std::expected read(std::span) override + { + return std::unexpected(std::string("pipe broke")); + } }; class ChunkStream final : public xl::InputStream @@ -103,6 +125,50 @@ namespace std::size_t position_ = 0; }; + std::vector build_sheets(int sheet_count, int row_count) + { + auto writer = xl::WriterHandle::open_memory(XL_FORMAT_XLSX); + std::vector bytes; + if (!writer.has_value()) + { + return bytes; + } + for (int s = 0; s < sheet_count; ++s) + { + (void)writer->start_sheet("sheet" + std::to_string(s)); + for (int r = 0; r < row_count; ++r) + { + (void)writer->start_row(); + (void)writer->write("s" + std::to_string(s) + "-r" + std::to_string(r)); + (void)writer->end_row(); + } + (void)writer->end_sheet(); + } + auto written = writer->bytes(); + if (written.has_value()) + { + bytes.resize(written->size()); + std::memcpy(bytes.data(), written->data(), written->size()); + } + return bytes; + } + + std::vector first_column(const xl::Workbook &workbook, int32_t sheet) + { + std::vector values; + auto cursor = workbook.sheet(sheet).rows(); + while (cursor.has_value()) + { + auto row = cursor->next_row(); + if (!row.has_value()) + { + break; + } + values.emplace_back((*row)[0].value); + } + return values; + } + std::size_t row_count(const xl::Workbook &workbook) { auto decoded = workbook.sheet(0).read_all_decoded(); @@ -139,14 +205,55 @@ int main() check(uncached.reads.load() > blocks, "without the cache every read reaches read_at"); { - auto workbook = xl::Workbook::open_source(std::make_unique(), XL_FORMAT_XLSX); + Counters failing; + auto workbook = xl::Workbook::open_source(std::make_unique(failing), XL_FORMAT_XLSX); check(!workbook.has_value() && workbook.error().message.find("disk on fire") != std::string::npos, "the source's error message reaches the caller"); + check(failing.destroyed.load() == 1, "a source whose read failed is destroyed exactly once"); } { - auto workbook = xl::Workbook::open_source(std::make_unique(), XL_FORMAT_XLSX); + Counters throwing; + auto workbook = xl::Workbook::open_source(std::make_unique(throwing), XL_FORMAT_XLSX); check(!workbook.has_value() && workbook.error().message.find("boom") != std::string::npos, "an exception from the source becomes an error"); + check(throwing.destroyed.load() == 1, "a source that threw is destroyed exactly once"); + } + { + auto workbook = xl::Workbook::open_stream(std::make_unique(), XL_FORMAT_XLSX); + check(!workbook.has_value() && workbook.error().message.find("pipe broke") != std::string::npos, + "the stream's error message reaches the caller"); + } + { + constexpr int sheet_count = 4; + const std::vector many = build_sheets(sheet_count, 300); + check(!many.empty(), "build the multi-sheet workbook"); + Counters parallel_counters; + auto reference_many = xl::Workbook::open_memory(std::span(reinterpret_cast(many.data()), many.size())); + auto workbook = xl::Workbook::open_source(std::make_unique(many, parallel_counters), XL_FORMAT_XLSX); + check(reference_many.has_value() && workbook.has_value(), "open the multi-sheet workbook both ways"); + if (reference_many.has_value() && workbook.has_value()) + { + std::vector> parallel(sheet_count * 2); + { + std::vector threads; + for (std::size_t i = 0; i < parallel.size(); ++i) + { + threads.emplace_back([¶llel, &workbook, i] + { parallel[i] = first_column(*workbook, static_cast(i % sheet_count)); }); + } + for (std::thread &thread : threads) + { + thread.join(); + } + } + bool same = true; + for (std::size_t i = 0; i < parallel.size(); ++i) + { + const auto expected = first_column(*reference_many, static_cast(i % sheet_count)); + same = same && expected.size() == 300 && parallel[i] == expected; + } + check(same, "every sheet of a source read on its own thread matches the in-memory read"); + } } { const std::string csv = "name,qty\nwidget,7\n"; diff --git a/python/README.md b/python/README.md index 8322f20e..a7ffba44 100644 --- a/python/README.md +++ b/python/README.md @@ -389,6 +389,8 @@ be read on its own thread. ```python import urllib.request +from excelreader import open_source + class HttpSource: def __init__(self, url): self.url = url @@ -410,6 +412,10 @@ with open_source(HttpSource("https://example.com/book.xlsx")) as workbook: read as it arrives; an XLSX, XLSB or XLS is read whole first. ```python +import sys + +from excelreader import open_stream + with open_stream(sys.stdin.buffer, "csv") as workbook: ... ``` diff --git a/python/src/excelreader/sources.py b/python/src/excelreader/sources.py index 5c9a75af..3cf52826 100644 --- a/python/src/excelreader/sources.py +++ b/python/src/excelreader/sources.py @@ -18,7 +18,10 @@ class Source(Protocol): size: int def read_at(self, offset: int, buffer: memoryview) -> int: - """Fills `buffer` with the bytes starting at `offset` and returns how many it wrote.""" + """Fills `buffer` with the bytes starting at `offset` and returns how many it wrote. + + `buffer` is valid only during the call; do not retain it. + """ ... @@ -26,7 +29,7 @@ def read_at(self, offset: int, buffer: memoryview) -> int: _live: dict[int, object] = {} -def _fail(error: Exception) -> int: +def _fail(error: BaseException) -> int: message = (str(error) or type(error).__name__).encode("utf-8", errors="replace") _native.load_library().xl_set_source_error(message, len(message)) return -1 @@ -40,7 +43,7 @@ def _view(address: int, length: int) -> memoryview: def _read_at(key: int, offset: int, buffer: int, length: int) -> int: try: return operator.index(_live[key].read_at(offset, _view(buffer, length))) - except Exception as error: # noqa: BLE001 + except BaseException as error: # noqa: BLE001 return _fail(error) @@ -62,7 +65,7 @@ def _read(key: int, buffer: int, length: int) -> int: if count > length: raise OSError("the stream returned more bytes than requested") return count - except Exception as error: # noqa: BLE001 + except BaseException as error: # noqa: BLE001 return _fail(error) diff --git a/python/tests/test_sources.py b/python/tests/test_sources.py index 97f4acf1..ea5a8a88 100644 --- a/python/tests/test_sources.py +++ b/python/tests/test_sources.py @@ -176,6 +176,17 @@ def read(self, size: int) -> bytes: assert set(sources_module._live) == before +def test_open_stream_fails_when_read_raises_keyboard_interrupt_and_releases_it(): + class Interrupted: + def read(self, size: int) -> bytes: + raise KeyboardInterrupt + + before = set(sources_module._live) + with pytest.raises(ExcelReaderError), open_stream(Interrupted(), "csv") as workbook: + _first_column(workbook.sheets[0]) + assert set(sources_module._live) == before + + def test_a_read_at_that_returns_none_reaches_the_caller_as_an_error(): class Bad: size = 1024 diff --git a/src/ExcelReader.Native/README.md b/src/ExcelReader.Native/README.md index 302f6a94..0c779c3d 100644 --- a/src/ExcelReader.Native/README.md +++ b/src/ExcelReader.Native/README.md @@ -134,10 +134,10 @@ if (status == XL_STATUS_PASSWORD_REQUIRED || status == XL_STATUS_PASSWORD_INCORR This is why `XL_ABI_VERSION` moved from 3 to 4: `password`/`password_len` are new fields at the end of `xl_open_options`, so a caller built against the old, smaller struct passes the old, smaller -`sizeof(xl_open_options)` as `struct_size` — any size other than the current one is rejected outright with -`XL_INVALID_ARGUMENT` (the 6.0 size, 88 bytes, is the one exception: it is also accepted, read as a prefix -with the newer fields defaulted) instead of the library reading two garbage fields past the end of the caller's -allocation. Check `xl_abi_version()` against `XL_ABI_VERSION` (see above) and rebuild against the +`sizeof(xl_open_options)` as `struct_size`. Any size other than the current one is rejected outright with +`XL_INVALID_ARGUMENT` instead of the library reading two garbage fields past the end of the caller's +allocation, except the 6.0 size of 88 bytes, which is also accepted and read as a prefix with the newer +fields defaulted. Check `xl_abi_version()` against `XL_ABI_VERSION` (see above) and rebuild against the current header rather than only relying on the `struct_size` check to catch it. To produce one, `xl_encrypt_package` wraps a finished XLSX/XLSB file in the encrypted container, and diff --git a/src/ExcelReader.Native/Reading/SourceErrors.cs b/src/ExcelReader.Native/Reading/SourceErrors.cs index 65d75ae7..811e317f 100644 --- a/src/ExcelReader.Native/Reading/SourceErrors.cs +++ b/src/ExcelReader.Native/Reading/SourceErrors.cs @@ -22,7 +22,7 @@ internal static IOException Failed(string where, long got, long requested) _pending = null; return got == -1 ? new IOException(message ?? $"{where} failed.") - : new IOException($"{where} returned {got} for a request of {requested} bytes; expected 0 to {requested}, or -1 on failure."); + : new IOException($"{where} returned {got} for a request of {requested} bytes, which is not a valid result."); } } } diff --git a/src/ExcelReader.Native/Reading/StreamBuffer.cs b/src/ExcelReader.Native/Reading/StreamBuffer.cs index e2570f03..0cfd1391 100644 --- a/src/ExcelReader.Native/Reading/StreamBuffer.cs +++ b/src/ExcelReader.Native/Reading/StreamBuffer.cs @@ -7,6 +7,7 @@ internal static class StreamBuffer { internal static ReadOnlyMemory ReadAll(Stream stream, long limit) { + limit = Math.Min(limit, Array.MaxLength); MemoryStream buffer = new(); byte[] chunk = ArrayPool.Shared.Rent(81_920); try diff --git a/src/ExcelReader.Native/include/excelreader.h b/src/ExcelReader.Native/include/excelreader.h index 5deb8192..c0cbfda5 100644 --- a/src/ExcelReader.Native/include/excelreader.h +++ b/src/ExcelReader.Native/include/excelreader.h @@ -107,7 +107,8 @@ typedef struct xl_source { int64_t length; /* total size in bytes */ /* Copy up to len bytes starting at offset into buf. Return the bytes copied, or -1 on failure after calling xl_set_source_error. Returning 0 before length is a failure ("the source ended - early"). Called from any thread, concurrently. */ + early"). Returning more than len, or a negative other than -1, is a failure. Called from any + thread, concurrently. */ int64_t (*read_at)(void* user_data, int64_t offset, uint8_t* buf, int64_t len); /* May be NULL. Called exactly once, when nothing opened from the workbook needs the source. */ void (*release)(void* user_data); @@ -126,7 +127,8 @@ typedef struct xl_stream { int32_t struct_size; /* sizeof(xl_stream) */ void* user_data; /* Copy up to len bytes into buf. Return the bytes copied (0 at the end), or -1 on failure after - calling xl_set_source_error. Never called concurrently. */ + calling xl_set_source_error. Returning more than len, or a negative other than -1, is a failure. + Never called concurrently. */ int64_t (*read)(void* user_data, uint8_t* buf, int64_t len); /* May be NULL. Called exactly once. */ void (*release)(void* user_data); diff --git a/src/ExcelReader.Native/include/excelreader.hpp b/src/ExcelReader.Native/include/excelreader.hpp index 45d59dec..c17ae596 100644 --- a/src/ExcelReader.Native/include/excelreader.hpp +++ b/src/ExcelReader.Native/include/excelreader.hpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -946,9 +947,14 @@ namespace xl { return std::unexpected(Error{XL_INVALID_ARGUMENT, "source is null"}); } + const std::uint64_t size = source->size(); + if (size > static_cast(std::numeric_limits::max())) + { + return std::unexpected(Error{XL_INVALID_ARGUMENT, "source size exceeds INT64_MAX"}); + } xl_source raw{}; raw.struct_size = sizeof(xl_source); - raw.length = static_cast(source->size()); + raw.length = static_cast(size); raw.read_at = &detail::source_read_at; raw.release = &detail::source_release; raw.user_data = source.release(); diff --git a/tests/ExcelReader.NativeSmoke/smoke.c b/tests/ExcelReader.NativeSmoke/smoke.c index 7536fbdb..3e5a9639 100644 --- a/tests/ExcelReader.NativeSmoke/smoke.c +++ b/tests/ExcelReader.NativeSmoke/smoke.c @@ -522,6 +522,14 @@ static int test_open_file_options(const api_t* api, const char* fixture) int32_t status = api->open_file((const uint8_t*)fixture, (int32_t)path_len, XL_FORMAT_XLSB, &bad_options, &handle); CHECK(status == XL_INVALID_ARGUMENT, "a wrong xl_open_options.struct_size must be XL_INVALID_ARGUMENT"); CHECK(handle == NULL, "a rejected xl_open_file must not hand back a handle"); + + xl_open_options v6_options; + memset(&v6_options, 0, sizeof(v6_options)); + v6_options.struct_size = 88; + handle = NULL; + status = api->open_file((const uint8_t*)fixture, (int32_t)path_len, XL_FORMAT_XLSB, &v6_options, &handle); + CHECK(status == XL_OK, "the 88-byte 6.0 xl_open_options must still be accepted"); + CHECK(api->close_(handle) == XL_OK, "xl_close must succeed"); return 0; } From e269dc61cece60a4b15ac56d183d16d55a960347 Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Wed, 7 Oct 2026 16:38:14 -0300 Subject: [PATCH 017/115] feat: enhance CSV stream handling to support schema inference and replay functionality --- python/README.md | 3 +- python/scripts/build_native.py | 13 ++ python/src/excelreader/sources.py | 3 +- python/tests/test_sources.py | 11 ++ rust/excelreader/src/source.rs | 4 +- src/ExcelReader.Core/Reader/Csv/CsvReader.cs | 35 ++-- .../Reader/Csv/ReplayStream.cs | 137 +++++++++++++++ src/ExcelReader.Native/README.md | 3 +- src/ExcelReader.Native/include/excelreader.h | 8 +- .../include/excelreader.hpp | 3 +- .../Native/CallbackStreamTests.cs | 14 +- .../Reader/Csv/CsvReaderTests.cs | 4 +- .../Reader/Csv/CsvReplayTests.cs | 159 ++++++++++++++++++ 13 files changed, 367 insertions(+), 30 deletions(-) create mode 100644 src/ExcelReader.Core/Reader/Csv/ReplayStream.cs create mode 100644 tests/ExcelReader.Tests/Reader/Csv/CsvReplayTests.cs diff --git a/python/README.md b/python/README.md index a7ffba44..a2bf19ce 100644 --- a/python/README.md +++ b/python/README.md @@ -409,7 +409,8 @@ with open_source(HttpSource("https://example.com/book.xlsx")) as workbook: ``` `open_stream()` reads anything with `readinto()` or `read()`, such as `sys.stdin.buffer`. A CSV is -read as it arrives; an XLSX, XLSB or XLS is read whole first. +read as it arrives, and its first 16 MiB are kept so that `infer_schema()` followed by a read works; +an XLSX, XLSB or XLS is read whole first. ```python import sys diff --git a/python/scripts/build_native.py b/python/scripts/build_native.py index adaa3fe4..c85cf58d 100644 --- a/python/scripts/build_native.py +++ b/python/scripts/build_native.py @@ -4,6 +4,7 @@ """ import argparse +import os import platform import shutil import subprocess @@ -32,6 +33,17 @@ def default_rid() -> str: return f"linux-{arch}" +def publish_environment() -> dict[str, str]: + """The NativeAOT linker step finds MSVC through vswhere, which the installer keeps off PATH.""" + environment = dict(os.environ) + if platform.system() == "Windows" and shutil.which("vswhere") is None: + program_files = environment.get("ProgramFiles(x86)", r"C:\Program Files (x86)") + installer = Path(program_files) / "Microsoft Visual Studio" / "Installer" + if (installer / "vswhere.exe").exists(): + environment["PATH"] = environment.get("PATH", "") + os.pathsep + str(installer) + return environment + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--rid", default=default_rid(), help="dotnet runtime identifier") @@ -41,6 +53,7 @@ def main() -> int: subprocess.run( ["dotnet", "publish", str(CSPROJ), "-c", "Release", "-f", args.framework, "-r", args.rid], check=True, + env=publish_environment(), ) publish_dir = CSPROJ.parent / "bin" / "Release" / args.framework / args.rid / "publish" diff --git a/python/src/excelreader/sources.py b/python/src/excelreader/sources.py index 3cf52826..ba6aab91 100644 --- a/python/src/excelreader/sources.py +++ b/python/src/excelreader/sources.py @@ -101,7 +101,8 @@ def open_stream( ) -> Workbook: """Opens a workbook over a stream that cannot seek, such as `sys.stdin.buffer` or a socket. - A CSV (`format="csv"`) is read as it arrives and only once; an XLSX, XLSB or XLS is read whole + A CSV (`format="csv"`) is read as it arrives, and can be read again from the start + (`infer_schema()`, then a read) until 16 MiB of it were read; an XLSX, XLSB or XLS is read whole before the workbook opens. `stream` needs `readinto` or `read`; the library never closes it. """ if not (hasattr(stream, "readinto") or hasattr(stream, "read")): diff --git a/python/tests/test_sources.py b/python/tests/test_sources.py index ea5a8a88..a8353732 100644 --- a/python/tests/test_sources.py +++ b/python/tests/test_sources.py @@ -130,6 +130,17 @@ def produce() -> None: assert names[-1] == "row999" +def test_a_csv_stream_serves_a_schema_inference_and_then_every_row(): + data = b"name,qty\n" + b"".join(f"row{i},{i}\n".encode() for i in range(5000)) + with open_stream(OnlyRead(data), "csv") as workbook: + sheet = workbook.sheets[0] + schema = sheet.infer_schema(parse_text=True) + names = _first_column(sheet) + assert len(schema) == 2 + assert len(names) == 5001 + assert names[-1] == "row4999" + + def test_open_stream_reads_an_xlsx_from_an_object_with_only_read(): with open_stream(OnlyRead(_workbook_bytes())) as workbook: rows = _first_column(workbook.sheets[3]) diff --git a/rust/excelreader/src/source.rs b/rust/excelreader/src/source.rs index b35a4105..12ea90bf 100644 --- a/rust/excelreader/src/source.rs +++ b/rust/excelreader/src/source.rs @@ -42,8 +42,8 @@ impl Workbook { Ok(Workbook::from_handle(handle)) } - /// Opens a workbook over `reader`, which cannot seek. A CSV is read as it arrives, once; an - /// XLSX, XLSB or XLS is read whole first. The library owns `reader` from here on. + /// Opens a workbook over `reader`, which cannot seek. A CSV is read as it arrives, and + /// again from the start until 16 MiB of it were read; an XLSX, XLSB or XLS is read whole first. The library owns `reader` from here on. pub fn open_reader(reader: R, format: i32, options: Option<&OpenOptions>) -> Result { check_abi_version()?; let raw = XlStream { diff --git a/src/ExcelReader.Core/Reader/Csv/CsvReader.cs b/src/ExcelReader.Core/Reader/Csv/CsvReader.cs index 5ef80d2e..fde2ae72 100644 --- a/src/ExcelReader.Core/Reader/Csv/CsvReader.cs +++ b/src/ExcelReader.Core/Reader/Csv/CsvReader.cs @@ -18,14 +18,14 @@ public sealed partial class CsvReader : IExcelWorkbook [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 ReplayStream? _stream; private readonly CsvReaderOptions _options; private readonly ReadOnlyMemory _memory; private readonly ExcelSheetList _sheetList; - private int _enumeratedOnce; private SafeFileHandle? _chunkHandle; + internal const int ReplayLimit = 16 * 1024 * 1024; + internal ReaderLifetime Lifetime { get; } internal CsvReader(Stream stream, bool leaveOpen, CsvReaderOptions? options = null) @@ -39,17 +39,15 @@ internal CsvReader(Stream stream, bool leaveOpen, CsvReaderOptions? options = nu _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); - _leaveOpen = false; + Stream transcoding = Encoding.CreateTranscodingStream(stream, _options.Encoding!, Encoding.UTF8, leaveOpen); + _stream = new ReplayStream(transcoding, leaveOpen: false, ReplayLimit); } else { - _stream = stream; - _leaveOpen = leaveOpen; + _stream = new ReplayStream(stream, leaveOpen, ReplayLimit); } _sheetList = new ExcelSheetList([new CsvSheet(this)]); } @@ -65,7 +63,6 @@ internal CsvReader(ReadOnlyMemory data, CsvReaderOptions? options = null) ValidateOptions(_options); Lifetime = new ReaderLifetime(ReleaseResources); _stream = null; - _leaveOpen = true; _memory = Transcode(data, _options.Encoding); _sheetList = new ExcelSheetList([new CsvSheet(this)]); } @@ -223,12 +220,21 @@ internal Enumerator OpenSheet(CancellationToken ct = default) { return new Enumerator(_memory, _options, Lifetime, ct); } - if (Interlocked.Exchange(ref _enumeratedOnce, 1) != 0) + if (!_stream.TryBeginPass()) { throw new InvalidOperationException( - "This CsvReader is over a non-seekable stream and can only be enumerated once."); + "This CsvReader is over a non-seekable stream and cannot be enumerated again: " + + $"an enumeration is still open, or one has read past the first {ReplayLimit} bytes."); + } + try + { + return new Enumerator(_stream, _options, ownsSource: true, Lifetime, ct); + } + catch + { + _stream.Dispose(); + throw; } - return new Enumerator(_stream, _options, ownsSource: false, Lifetime, ct); } catch { @@ -263,10 +269,7 @@ private void ReleaseResources() { _chunkHandle?.Dispose(); _source?.Dispose(); - if (!_leaveOpen) - { - _stream?.Dispose(); - } + _stream?.Release(); } } } diff --git a/src/ExcelReader.Core/Reader/Csv/ReplayStream.cs b/src/ExcelReader.Core/Reader/Csv/ReplayStream.cs new file mode 100644 index 00000000..ffaee20e --- /dev/null +++ b/src/ExcelReader.Core/Reader/Csv/ReplayStream.cs @@ -0,0 +1,137 @@ +namespace ExcelReader.Core.Reader.Csv +{ + /// + /// Serves a forward-only stream to one pass at a time, and lets a later pass start over for as long + /// as everything read so far still fits in limit bytes. Disposing ends a pass; + /// closes the stream underneath. + /// + internal sealed class ReplayStream(Stream inner, bool leaveOpen, int limit) : Stream + { + private MemoryStream? _recorded = new(); + private int _position; + private int _busy; + + 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(); + } + + internal bool TryBeginPass() + { + if (Interlocked.CompareExchange(ref _busy, 1, 0) != 0) + { + return false; + } + if (_recorded is null) + { + Volatile.Write(ref _busy, 0); + return false; + } + _position = 0; + return true; + } + + internal void Release() + { + _recorded = null; + if (!leaveOpen) + { + inner.Dispose(); + } + } + + public override int Read(byte[] buffer, int offset, int count) + { + return Read(buffer.AsSpan(offset, count)); + } + + public override int Read(Span buffer) + { + if (TryReplay(buffer, out int replayed)) + { + return replayed; + } + int read = inner.Read(buffer); + Record(buffer[..read]); + return read; + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (TryReplay(buffer.Span, out int replayed)) + { + return replayed; + } + int read = await inner.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + Record(buffer.Span[..read]); + return read; + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + + private bool TryReplay(Span buffer, out int count) + { + count = 0; + if (_recorded is not { } recorded || _position >= recorded.Length || buffer.IsEmpty) + { + return false; + } + count = Math.Min(buffer.Length, (int)recorded.Length - _position); + recorded.GetBuffer().AsSpan(_position, count).CopyTo(buffer); + _position += count; + return true; + } + + private void Record(ReadOnlySpan data) + { + if (_recorded is not { } recorded) + { + return; + } + if (recorded.Length + data.Length > limit) + { + _recorded = null; + return; + } + recorded.Write(data); + _position += data.Length; + } + + 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) + { + Volatile.Write(ref _busy, 0); + base.Dispose(disposing); + } + } +} diff --git a/src/ExcelReader.Native/README.md b/src/ExcelReader.Native/README.md index 0c779c3d..27699d4c 100644 --- a/src/ExcelReader.Native/README.md +++ b/src/ExcelReader.Native/README.md @@ -153,7 +153,8 @@ in parallel. By default the library asks for 4 MiB blocks and keeps 64 MiB of th (`source_block_size`, `source_cache_bytes` in `xl_open_options`). `xl_open_stream` reads through a callback that copies the next bytes. A CSV is read as it arrives, -by one cursor; an XLSX, XLSB or XLS is read whole first (`max_buffered_bytes` caps it), because +by one cursor at a time, and its first 16 MiB are kept so that `xl_infer_schema` followed by a read +works; an XLSX, XLSB or XLS is read whole first (`max_buffered_bytes` caps it), because those formats need random access. Both take ownership: `release` runs exactly once, when nothing needs the bytes any more. A failing diff --git a/src/ExcelReader.Native/include/excelreader.h b/src/ExcelReader.Native/include/excelreader.h index c0cbfda5..7631490a 100644 --- a/src/ExcelReader.Native/include/excelreader.h +++ b/src/ExcelReader.Native/include/excelreader.h @@ -134,9 +134,11 @@ typedef struct xl_stream { void (*release)(void* user_data); } xl_stream; -/* Opens a workbook over a stream that cannot seek. A CSV is read as it arrives, by one cursor: - a second cursor, typed reader or Arrow stream on it fails with XL_ERROR, csv_sniff_dialect is - refused, and the stream is released when the workbook and its cursor are closed. An XLSX, XLSB +/* Opens a workbook over a stream that cannot seek. A CSV is read as it arrives, by one cursor + at a time. Its first 16 MiB are kept, so a later cursor, typed reader or Arrow stream starts over + (xl_infer_schema, then a read) until one has read past them; after that, or while one is still + open, another fails with XL_ERROR. csv_sniff_dialect is refused, and the stream is released when + the workbook and its cursors are closed. An XLSX, XLSB or XLS needs random access, so the stream is read whole (up to max_buffered_bytes) and released before this returns. Ownership rules are those of xl_open_source. */ int32_t xl_open_stream(const xl_stream* stream, int32_t format, diff --git a/src/ExcelReader.Native/include/excelreader.hpp b/src/ExcelReader.Native/include/excelreader.hpp index c17ae596..2717ab82 100644 --- a/src/ExcelReader.Native/include/excelreader.hpp +++ b/src/ExcelReader.Native/include/excelreader.hpp @@ -968,7 +968,8 @@ namespace xl return Workbook(handle); } - /// Opens a workbook over a stream that cannot seek: a CSV is read as it arrives, once; an XLSX, XLSB or XLS is read whole first. + /// Opens a workbook over a stream that cannot seek: a CSV is read as it arrives, and again from the start + /// until 16 MiB of it were read; an XLSX, XLSB or XLS is read whole first. static std::expected open_stream(std::unique_ptr stream, int32_t format = XL_FORMAT_AUTO, const OpenOptions *options = nullptr) { diff --git a/tests/ExcelReader.Tests/Native/CallbackStreamTests.cs b/tests/ExcelReader.Tests/Native/CallbackStreamTests.cs index a2e2c168..30a694a4 100644 --- a/tests/ExcelReader.Tests/Native/CallbackStreamTests.cs +++ b/tests/ExcelReader.Tests/Native/CallbackStreamTests.cs @@ -81,15 +81,23 @@ private static int CountRows(NativeHandle handle, int sheet) } [Fact] - public void A_Csv_Streams_Once_And_Is_Released_When_Its_Cursor_Closes() + public void A_Csv_Streams_Through_One_Cursor_At_A_Time_And_Is_Released_On_Close() { TestStream test = new(Encoding.UTF8.GetBytes("a,b\n1,2\n3,4\n")); using Pinned pinned = new(test); Assert.Equal(NativeStatus.Ok, Open(pinned, NativeFormat.Csv, out NativeHandle? handle)); + + Assert.Equal(NativeStatus.Ok, ReadApi.InferSchema(handle, 0, headerRow: 1, sampleSize: 100, flags: 0, out NativeInferredSchema schema)); + Assert.Equal(2, schema.ColumnCount); + ReadApi.FreeSchema(ref schema); Assert.Equal(3, CountRows(handle!, 0)); - Assert.Equal(NativeStatus.Error, ReadApi.OpenRows(handle, 0, out _)); - Assert.Contains("only be enumerated once", NativeApi.LastErrorText(), StringComparison.Ordinal); + Assert.Equal(NativeStatus.Ok, ReadApi.OpenRows(handle, 0, out NativeRowCursor? open)); + using (open) + { + Assert.Equal(NativeStatus.Error, ReadApi.OpenRows(handle, 0, out _)); + Assert.Contains("cannot be enumerated again", NativeApi.LastErrorText(), StringComparison.Ordinal); + } Assert.Equal(0, test.Releases); Assert.Equal(NativeStatus.Ok, ReadApi.Close(handle)); diff --git a/tests/ExcelReader.Tests/Reader/Csv/CsvReaderTests.cs b/tests/ExcelReader.Tests/Reader/Csv/CsvReaderTests.cs index a0aac49c..74739c0b 100644 --- a/tests/ExcelReader.Tests/Reader/Csv/CsvReaderTests.cs +++ b/tests/ExcelReader.Tests/Reader/Csv/CsvReaderTests.cs @@ -392,7 +392,7 @@ public void SeekableStreamCanBeEnumeratedMoreThanOnce() } [Fact] - public void NonSeekableStreamThrowsOnSecondEnumeration() + public void NonSeekableStreamIsReplayedOnSecondEnumeration() { byte[] bytes = Encoding.UTF8.GetBytes("a,b\n1,2\n"); using NonSeekableStream stream = new(bytes); @@ -401,7 +401,7 @@ public void NonSeekableStreamThrowsOnSecondEnumeration() var first = ReadAll(reader); Assert.Equal(2, first.Count); - Assert.Throws(reader.FirstSheet.GetEnumerator); + Assert.Equal(first, ReadAll(reader)); } diff --git a/tests/ExcelReader.Tests/Reader/Csv/CsvReplayTests.cs b/tests/ExcelReader.Tests/Reader/Csv/CsvReplayTests.cs new file mode 100644 index 00000000..2d8332f9 --- /dev/null +++ b/tests/ExcelReader.Tests/Reader/Csv/CsvReplayTests.cs @@ -0,0 +1,159 @@ +using System.Globalization; +using System.Text; +using ExcelReader.Core.Reader; +using ExcelReader.Core.Reader.Csv; +using ExcelReader.Core.Reader.Schema; + +namespace ExcelReader.Tests.Reader.Csv +{ + public sealed class CsvReplayTests + { + private static byte[] BuildCsv(int rows, Encoding? encoding = null) + { + StringBuilder text = new("name,value\n"); + for (int r = 0; r < rows; r++) + { + text.Append("name-").Append(r.ToString(CultureInfo.InvariantCulture)) + .Append(',').Append((r * 7).ToString(CultureInfo.InvariantCulture)).Append('\n'); + } + return (encoding ?? Encoding.UTF8).GetBytes(text.ToString()); + } + + private static List Drain(CsvReader reader) + { + List lines = []; + using CsvReader.Enumerator e = reader.FirstSheet.GetEnumerator(); + while (e.MoveNext()) + { + Row row = e.Current; + lines.Add(row[0].GetString() + "|" + row[1].GetString()); + } + return lines; + } + + private static List Expected(int rows) + { + using CsvReader reference = Excel.FromCsv(BuildCsv(rows)); + return Drain(reference); + } + + [Fact] + public void A_Schema_Inferred_From_A_NonSeekable_Stream_Leaves_Every_Row_To_Read() + { + const int rows = 20_000; + using NonSeekableStream stream = new(BuildCsv(rows)); + using CsvReader reader = Excel.FromCsv(stream); + + ExcelColumnSchema[] schema = Excel.InferSchema(reader.FirstSheet, headerRow: 1, sampleSize: 100, parseText: true); + + Assert.Equal(["name", "value"], schema.Select(c => c.Name), StringComparer.Ordinal); + Assert.Equal(ExcelColumnType.Int64Column, schema[1].Type); + Assert.Equal(Expected(rows), Drain(reader), StringComparer.Ordinal); + } + + [Fact] + public void A_Small_NonSeekable_Stream_Can_Be_Read_Any_Number_Of_Times() + { + const int rows = 500; + List expected = Expected(rows); + using NonSeekableStream stream = new(BuildCsv(rows)); + using CsvReader reader = Excel.FromCsv(stream); + + Assert.Equal(expected, Drain(reader), StringComparer.Ordinal); + Assert.Equal(expected, Drain(reader), StringComparer.Ordinal); + Assert.Equal(expected, Drain(reader), StringComparer.Ordinal); + } + + [Fact] + public void A_Transcoded_NonSeekable_Stream_Is_Replayed_Too() + { + const int rows = 20_000; + using NonSeekableStream stream = new(BuildCsv(rows, Encoding.Unicode)); + using CsvReader reader = Excel.FromCsv( + stream, leaveOpen: false, + new CsvReaderOptions { Encoding = Encoding.Unicode, DetectEncodingFromByteOrderMark = false }); + + Excel.InferSchema(reader.FirstSheet); + + Assert.Equal(Expected(rows), Drain(reader), StringComparer.Ordinal); + } + + [Fact] + public void A_Second_Enumeration_Is_Refused_While_The_First_Is_Open() + { + using NonSeekableStream stream = new(BuildCsv(10)); + using CsvReader reader = Excel.FromCsv(stream); + using CsvReader.Enumerator first = reader.FirstSheet.GetEnumerator(); + + Assert.Throws(reader.FirstSheet.GetEnumerator); + } + + [Fact] + public void A_Stream_Read_Past_The_Replay_Limit_Cannot_Be_Read_Again() + { + byte[] line = Encoding.UTF8.GetBytes(new string('x', 1021) + ",1\n"); + byte[] bytes = new byte[CsvReader.ReplayLimit + (line.Length * 4)]; + for (int offset = 0; offset < bytes.Length; offset += line.Length) + { + line.CopyTo(bytes, offset); + } + using NonSeekableStream stream = new(bytes); + using CsvReader reader = Excel.FromCsv(stream); + + Assert.Equal(bytes.Length / line.Length, Drain(reader).Count); + + InvalidOperationException error = Assert.Throws(reader.FirstSheet.GetEnumerator); + Assert.Contains("cannot be enumerated again", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void A_Pass_Replays_What_Was_Read_And_Then_Continues() + { + byte[] bytes = Enumerable.Range(0, 100).Select(i => (byte)i).ToArray(); + using NonSeekableStream inner = new(bytes); + using ReplayStream replay = new(inner, leaveOpen: true, limit: 64); + + Assert.True(replay.TryBeginPass()); + byte[] head = new byte[40]; + replay.ReadExactly(head); + replay.Dispose(); + + Assert.True(replay.TryBeginPass()); + byte[] all = new byte[100]; + replay.ReadExactly(all); + Assert.Equal(bytes, all); + Assert.Equal(0, replay.Read(new byte[1])); + replay.Dispose(); + + Assert.False(replay.TryBeginPass()); + } + + [Fact] + public void Releasing_Closes_The_Inner_Stream_Only_When_It_Is_Owned() + { + using TrackingStream owned = new(); + using TrackingStream borrowed = new(); + ReplayStream a = new(owned, leaveOpen: false, limit: 8); + ReplayStream b = new(borrowed, leaveOpen: true, limit: 8); + + a.Dispose(); + Assert.False(owned.Disposed); + a.Release(); + b.Release(); + + Assert.True(owned.Disposed); + Assert.False(borrowed.Disposed); + } + + private sealed class TrackingStream : MemoryStream + { + internal bool Disposed { get; private set; } + + protected override void Dispose(bool disposing) + { + Disposed = true; + base.Dispose(disposing); + } + } + } +} From d5da1e13bf73e4f448d826ff4027975889abf9d3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:07:24 +0000 Subject: [PATCH 018/115] Bump the analyzers group with 1 update Bumps Meziantou.Analyzer from 3.0.290 to 3.0.294 --- updated-dependencies: - dependency-name: Meziantou.Analyzer dependency-version: 3.0.294 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: analyzers ... Signed-off-by: dependabot[bot] --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index f9b76a42..8782a287 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -27,7 +27,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all From d185665193e02453022d494f09b86a3b5e155d72 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:08:47 +0000 Subject: [PATCH 019/115] Bump the test group with 2 updates Bumps Microsoft.Testing.Extensions.CodeCoverage from 18.11.2 to 18.12.0 Bumps Microsoft.Testing.Extensions.TrxReport from 2.4.1 to 2.5.1 --- updated-dependencies: - dependency-name: Microsoft.Testing.Extensions.CodeCoverage dependency-version: 18.12.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: test - dependency-name: Microsoft.Testing.Extensions.TrxReport dependency-version: 2.5.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: test ... Signed-off-by: dependabot[bot] --- tests/ExcelReader.Tests/ExcelReader.Tests.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/ExcelReader.Tests/ExcelReader.Tests.csproj b/tests/ExcelReader.Tests/ExcelReader.Tests.csproj index ab2b1d8a..478b1ae3 100644 --- a/tests/ExcelReader.Tests/ExcelReader.Tests.csproj +++ b/tests/ExcelReader.Tests/ExcelReader.Tests.csproj @@ -33,8 +33,8 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all From 77f97b68e913159c2e7340f823435387c1b65220 Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Wed, 7 Oct 2026 17:42:37 -0300 Subject: [PATCH 020/115] Removing unnecessary comments --- .github/workflows/release.yml | 2 - cpp/benchmarks/benchmark_csv_compare.cpp | 6 -- python/src/excelreader/_native.py | 2 - python/src/excelreader/arrow_stream.py | 2 - rust/excelreader/src/rows.rs | 2 - rust/excelreader/src/source.rs | 1 - rust/excelreader/src/workbook.rs | 3 - .../Parser/Internal/RowProjector.cs | 1 - src/ExcelReader.Core/Reader/Csv/CsvReader.cs | 2 - .../Reader/Internal/FastDouble.cs | 8 -- .../Reader/Schema/SchemaInference.cs | 4 - .../Reader/Xlsx/XlsxWorkbook.Enumerator.cs | 2 - src/ExcelReader.Core/Writer/Csv/CsvWriter.cs | 2 - .../Writer/Internal/BiffBuffer.cs | 2 - .../Writer/Xlsb/XlsbSheetWriter.cs | 3 - .../Writer/Xlsx/XlsxSheetWriter.cs | 2 - .../ExcelRowMapGenerator.cs | 1 - .../Reading/RowViewBuffer.cs | 2 - src/ExcelReader.Native/Typed/TypedApi.cs | 3 - src/ExcelReader.Native/include/excelreader.h | 80 +------------------ .../include/excelreader.hpp | 3 - .../include/excelreader_arrow.h | 2 - .../Read/XlsxSharedStringHotPathBenchmark.cs | 2 - .../Native/CallbackSourceTests.cs | 1 - .../Native/NativeApiTests.ParallelTyped.cs | 1 - .../Parser/HeaderCollisionTests.cs | 1 - .../ParallelCsv/CsvReaderChunkSourceTests.cs | 1 - tests/ExcelReader.Tests/TestUtils.cs | 1 - 28 files changed, 3 insertions(+), 139 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e7559e70..91fff678 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -312,8 +312,6 @@ jobs: grep -q 'already exists' <<<"$out" || exit 1 fi - # The workspace config points EXCELREADER_NATIVE_LIB_DIR at a local build that this job never makes; - # without it the package verification downloads the native library from the release, as users do. - name: Publish excelreader to crates.io working-directory: rust/excelreader env: diff --git a/cpp/benchmarks/benchmark_csv_compare.cpp b/cpp/benchmarks/benchmark_csv_compare.cpp index cb0eb531..211faf13 100644 --- a/cpp/benchmarks/benchmark_csv_compare.cpp +++ b/cpp/benchmarks/benchmark_csv_compare.cpp @@ -1,8 +1,6 @@ #include #include -// zsv's headers are plain C99: no extern "C" guard, and `restrict`, which GCC and Clang spell -// `__restrict` in C++. #define restrict __restrict extern "C" { @@ -135,8 +133,6 @@ namespace return {reinterpret_cast(cell.str), cell.len}; } - // The same conversions parse_sheet makes: text into owned std::string, ISO dates, integers - // and doubles, one FullRow per data row, header skipped. void zsv_typed_row(void *context) { auto *run = static_cast(context); @@ -146,8 +142,6 @@ namespace return; } const size_t count = zsv_cell_count(run->parser); - // zsv reports the empty line after the file's final CRLF as a one-cell row; ExcelReader skips - // blank lines. if (count == 1 && zsv_get_cell(run->parser, 0).len == 0) { return; diff --git a/python/src/excelreader/_native.py b/python/src/excelreader/_native.py index d6b25317..c67def55 100644 --- a/python/src/excelreader/_native.py +++ b/python/src/excelreader/_native.py @@ -437,8 +437,6 @@ def load_library() -> ctypes.CDLL: def _warm_up(lib: ctypes.CDLL) -> None: - # Threads whose first native call lands together can crash the NativeAOT runtime's start-up - # (see commit b5f8c5f); one small read here finishes it on a single thread. Failures are ignored. data = b"a,b\n1,2\n3,4\n" workbook = ctypes.c_void_p() if lib.xl_open_memory(data, len(data), XL_FORMAT_CSV, None, ctypes.byref(workbook)) != XL_OK: diff --git a/python/src/excelreader/arrow_stream.py b/python/src/excelreader/arrow_stream.py index 71396b0b..3292e25e 100644 --- a/python/src/excelreader/arrow_stream.py +++ b/python/src/excelreader/arrow_stream.py @@ -37,8 +37,6 @@ def _address_of(function: object) -> int: return ctypes.cast(function, ctypes.c_void_p).value -# A consumer may move the proxy out of the capsule, so callbacks find the native stream through -# private_data, which travels with the move, never through the address they are called with. _streams: dict[int, _Target] = {} _proxies: dict[int, _native.ArrowArrayStream] = {} diff --git a/rust/excelreader/src/rows.rs b/rust/excelreader/src/rows.rs index 58a50217..c516de9a 100644 --- a/rust/excelreader/src/rows.rs +++ b/rust/excelreader/src/rows.rs @@ -229,7 +229,6 @@ pub struct RowCursor<'w> { } // SAFETY: the cursor owns its native enumerator and ABI v6 lets one thread at a time use it; -// every method that touches it takes `&mut self`. unsafe impl Send for RowCursor<'_> {} impl<'w> RowCursor<'w> { @@ -337,7 +336,6 @@ impl Drop for DecodedRows { } // SAFETY: plain native allocations, read only through &self, released by xl_free_rows, which any -// thread may call. unsafe impl Send for DecodedRows {} unsafe impl Sync for DecodedRows {} diff --git a/rust/excelreader/src/source.rs b/rust/excelreader/src/source.rs index 12ea90bf..cee8f842 100644 --- a/rust/excelreader/src/source.rs +++ b/rust/excelreader/src/source.rs @@ -68,7 +68,6 @@ unsafe extern "C" fn read_at(user_data: *mut c_void, offset: i64, buf report(catch_unwind(AssertUnwindSafe(|| source.read_at(offset as u64, buf)))) } -// The ABI never calls read concurrently, so the reader can be borrowed mutably. unsafe extern "C" fn read(user_data: *mut c_void, buf: *mut u8, len: i64) -> i64 { let reader = &mut *user_data.cast::(); let buf = std::slice::from_raw_parts_mut(buf, len as usize); diff --git a/rust/excelreader/src/workbook.rs b/rust/excelreader/src/workbook.rs index dc75b791..14934735 100644 --- a/rust/excelreader/src/workbook.rs +++ b/rust/excelreader/src/workbook.rs @@ -135,7 +135,6 @@ pub struct Workbook { } // SAFETY: ABI v6 documents the workbook handle as usable from several threads at once. The only -// state behind it is immutable after open; each read owns its enumerator. unsafe impl Send for Workbook {} unsafe impl Sync for Workbook {} @@ -699,7 +698,6 @@ impl Drop for TableView { } // SAFETY: plain native allocations, read only through &self, released by xl_free_table, which any -// thread may call. T appears only as fn pointers, never as a stored value. unsafe impl Send for TableView {} unsafe impl Sync for TableView {} @@ -723,7 +721,6 @@ pub struct TypedChunks<'a, T: ExcelMapper> { } // SAFETY: a typed reader owns its native enumerator; ABI v6 lets it be used from any one thread -// at a time, which `&mut self` on `next` already guarantees. unsafe impl Send for TypedChunks<'_, T> {} impl Iterator for TypedChunks<'_, T> { diff --git a/src/ExcelReader.Core/Parser/Internal/RowProjector.cs b/src/ExcelReader.Core/Parser/Internal/RowProjector.cs index f38606ac..fc2a7380 100644 --- a/src/ExcelReader.Core/Parser/Internal/RowProjector.cs +++ b/src/ExcelReader.Core/Parser/Internal/RowProjector.cs @@ -51,7 +51,6 @@ internal ProjectionStep Classify(in Row row) return ProjectionStep.Skip; } - // Kept out of line so the caller's foreach tier-1 compile can't inline the whole projection and spill its hot loop. [MethodImpl(MethodImplOptions.NoInlining)] internal readonly T Project(Row row) { diff --git a/src/ExcelReader.Core/Reader/Csv/CsvReader.cs b/src/ExcelReader.Core/Reader/Csv/CsvReader.cs index fde2ae72..59899d3a 100644 --- a/src/ExcelReader.Core/Reader/Csv/CsvReader.cs +++ b/src/ExcelReader.Core/Reader/Csv/CsvReader.cs @@ -92,9 +92,7 @@ internal bool TryGetChunkSource(out CsvChunkSource source) return false; } - // A synchronous Windows handle serializes every read on its file object; parallel chunks need an overlapped one. // ponytail: reopens by path, safe because Excel's file opens deny write/delete sharing; switch to - // ReOpenFile if a caller-supplied FileStream that allows delete ever reaches this. private SafeFileHandle ChunkHandle(FileStream file) { if (!OperatingSystem.IsWindows()) diff --git a/src/ExcelReader.Core/Reader/Internal/FastDouble.cs b/src/ExcelReader.Core/Reader/Internal/FastDouble.cs index b73631e8..1051fb16 100644 --- a/src/ExcelReader.Core/Reader/Internal/FastDouble.cs +++ b/src/ExcelReader.Core/Reader/Internal/FastDouble.cs @@ -14,8 +14,6 @@ internal static class FastDouble [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool TryParse(ReadOnlySpan s, out double value) => TryParse(s, MaxMantissaDigits, out value); - // Rejects text with more significant digits than a double round-trips, so a caller that keeps - // the double in place of the text never loses digits a decimal or long parse of the text would see. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool TryParseLossless(ReadOnlySpan s, out double value) => TryParse(s, MaxLosslessDigits, out value); @@ -34,8 +32,6 @@ private static bool TryParse(ReadOnlySpan s, int maxDigits, out double val i = 1; } - // Integer digits, then fraction digits, each in its own tight loop: one loop over both paid a - // mispredicted branch at the dot and on every digit's dot/leading-zero checks. ulong mantissa = 0; int integerStart = i; for (; i < s.Length; i++) @@ -93,8 +89,6 @@ private static bool TryParse(ReadOnlySpan s, int maxDigits, out double val return true; } - // Eisel-Lemire as ported from fast_float's compute_float for binary64. Returns false for - // subnormals, overflow and powers outside the table so the caller's double.TryParse decides. [MethodImpl(MethodImplOptions.NoInlining)] private static bool EiselLemire(ulong w, int q, bool neg, out double value) { @@ -155,7 +149,6 @@ private static bool EiselLemire(ulong w, int q, bool neg, out double value) return true; } - // Leading zeros add nothing to the mantissa, so they neither overflow it nor count toward the limit. [MethodImpl(MethodImplOptions.NoInlining)] private static int SignificantDigits(ReadOnlySpan number) { @@ -214,7 +207,6 @@ private static double Pow10(int scale) ]; // ponytail: fast_float's 128-bit truncated 5^q table cut to q in [-64, 64]; widen to its full - // [-342, 308] if inputs outside that range ever show up often enough to matter. private static ReadOnlySpan PowersOfFive => [ 0xA87FEA27A539E9A5, 0x3F2398D747B36224, 0xD29FE4B18E88640E, 0x8EEC7F0D19A03AAD, diff --git a/src/ExcelReader.Core/Reader/Schema/SchemaInference.cs b/src/ExcelReader.Core/Reader/Schema/SchemaInference.cs index 9c9c607b..fd54b2d7 100644 --- a/src/ExcelReader.Core/Reader/Schema/SchemaInference.cs +++ b/src/ExcelReader.Core/Reader/Schema/SchemaInference.cs @@ -169,15 +169,12 @@ internal void Observe(in Cell cell, bool isDate1904, bool parseText) } } - // Stricter than the typed converters on purpose: their fallbacks (DateTime.TryParse, - // long.TryParse with whitespace) would type ID and ratio columns as dates or numbers. private bool ObserveText(in Cell cell, bool isDate1904) { ReadOnlySpan text = cell.Value; bool canonical = HasCanonicalIntegerPart(text, out bool allDigits); if (canonical && allDigits) { - // An integer past long stays text: as a double it would silently lose digits. if (!ExcelCellReaders.Parsable(in cell, isDate1904, CultureInfo.InvariantCulture, out _)) { return false; @@ -186,7 +183,6 @@ private bool ObserveText(in Cell cell, bool isDate1904) SawNonBinaryNumber |= !IsBinaryDigit(text); return true; } - // Scientific notation stays text: part codes like 12E4 would silently become 120000. if (canonical && !text.ContainsAny((byte)'e', (byte)'E') && FastDouble.TryParse(text, out _)) { SawNumber = true; diff --git a/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Enumerator.cs b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Enumerator.cs index 2c2ad493..000a579e 100644 --- a/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Enumerator.cs +++ b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Enumerator.cs @@ -562,8 +562,6 @@ private bool MissingRowOpenTag() return true; } - // Accepts only ` r="A1"`, ` s="12"`, ` t="s"` (any order, one space, double quotes); anything else goes generic. - // Inlined so col/style/kind/gt stay in registers instead of five out-params through the stack per cell. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool TryScanCanonicalCellTag(byte[] buf, int len, int i, out int gt, out int col, out int style, out Kind kind) { diff --git a/src/ExcelReader.Core/Writer/Csv/CsvWriter.cs b/src/ExcelReader.Core/Writer/Csv/CsvWriter.cs index b231193e..0bf1928a 100644 --- a/src/ExcelReader.Core/Writer/Csv/CsvWriter.cs +++ b/src/ExcelReader.Core/Writer/Csv/CsvWriter.cs @@ -212,8 +212,6 @@ private void FlushCore() } } - // A failed write leaves an unknown prefix of the buffer in the stream, so writing it again could - // silently corrupt the output: the writer becomes terminal and only Dispose remains meaningful. private void Fault() { if (_faulted) diff --git a/src/ExcelReader.Core/Writer/Internal/BiffBuffer.cs b/src/ExcelReader.Core/Writer/Internal/BiffBuffer.cs index 3f012f6e..346d0c79 100644 --- a/src/ExcelReader.Core/Writer/Internal/BiffBuffer.cs +++ b/src/ExcelReader.Core/Writer/Internal/BiffBuffer.cs @@ -15,7 +15,6 @@ internal sealed class BiffBuffer : IDisposable private static ArrayPool Pool => PoolOverride.Value ?? DefaultPool; - // Test seam: lets a test count every rent/return made on its own async flow. internal static void OverridePool(ArrayPool? pool) { PoolOverride.Value = pool; @@ -169,7 +168,6 @@ private void Ensure(int extra) } } - // Kept out of line so the inlined Ensure on every Write stays a single compare. [MethodImpl(MethodImplOptions.NoInlining)] private void Grow(int needed) { diff --git a/src/ExcelReader.Core/Writer/Xlsb/XlsbSheetWriter.cs b/src/ExcelReader.Core/Writer/Xlsb/XlsbSheetWriter.cs index fa9bdbf6..e4b6c591 100644 --- a/src/ExcelReader.Core/Writer/Xlsb/XlsbSheetWriter.cs +++ b/src/ExcelReader.Core/Writer/Xlsb/XlsbSheetWriter.cs @@ -265,8 +265,6 @@ public async ValueTask EndAsync(CancellationToken ct = default) Release(faulted: false); } - // Ended is only ever set by Release, after cleanup, so an already-ended sheet has nothing left - // to release: a fault raised inside a nested step (flush) is handled exactly once. private void Fault() { if (_state == WriterState.Ended) @@ -653,7 +651,6 @@ private static bool TryEncodeRk(double value, out uint rk) { return false; } - // Excel writes an integer as the float form whenever it is exact; calamine ignores a date style on the int form. ulong bits = BitConverter.DoubleToUInt64Bits(value); rk = (bits & 0x3_FFFF_FFFFUL) == 0 ? (uint)(bits >> 32) : ((uint)(int)value << 2) | 0x02; return true; diff --git a/src/ExcelReader.Core/Writer/Xlsx/XlsxSheetWriter.cs b/src/ExcelReader.Core/Writer/Xlsx/XlsxSheetWriter.cs index 5f4bc430..0f7ddd0b 100644 --- a/src/ExcelReader.Core/Writer/Xlsx/XlsxSheetWriter.cs +++ b/src/ExcelReader.Core/Writer/Xlsx/XlsxSheetWriter.cs @@ -265,8 +265,6 @@ public void End() Release(faulted: false); } - // Ended is only ever set by Release, after cleanup, so an already-ended sheet has nothing left - // to release: a fault raised inside a nested step (start, flush) is handled exactly once. private void Fault() { if (_state == WriterState.Ended) diff --git a/src/ExcelReader.Generator/ExcelRowMapGenerator.cs b/src/ExcelReader.Generator/ExcelRowMapGenerator.cs index 3cbbf77e..80e68cfe 100644 --- a/src/ExcelReader.Generator/ExcelRowMapGenerator.cs +++ b/src/ExcelReader.Generator/ExcelRowMapGenerator.cs @@ -366,7 +366,6 @@ private static (string Assign, string? InitAccessorDecl) PlanAssignment(IPropert { return ($"m.{member} = v", null); } - // Init accessors can't be called outside an object initializer; UnsafeAccessor binds the same setter reflection's GetSetMethod() does. string accessor = $"__ExcelInit_{property.Name}"; string byRef = init.ContainingType.IsValueType ? "ref " : ""; string target = init.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); diff --git a/src/ExcelReader.Native/Reading/RowViewBuffer.cs b/src/ExcelReader.Native/Reading/RowViewBuffer.cs index 8431a902..70cff1e8 100644 --- a/src/ExcelReader.Native/Reading/RowViewBuffer.cs +++ b/src/ExcelReader.Native/Reading/RowViewBuffer.cs @@ -65,7 +65,6 @@ private void Add(ref int count, int column, int type, int valueOffset, int lengt { GrowCells(); } - // Offsets until the row is complete: growing _values moves it, so pointers are fixed up in Finish. _cells[count++] = new NativeRowCell { Column = column, Type = type, ValueLength = length, Value = valueOffset }; } @@ -86,7 +85,6 @@ private void EnsureValues(int required) } } - // Out of line: without PGO the JIT/ILC inlines the Realloc P/Invoke and pays its frame setup on every row. [MethodImpl(MethodImplOptions.NoInlining)] private void GrowValues(int required) { diff --git a/src/ExcelReader.Native/Typed/TypedApi.cs b/src/ExcelReader.Native/Typed/TypedApi.cs index 928d40f9..e6eae494 100644 --- a/src/ExcelReader.Native/Typed/TypedApi.cs +++ b/src/ExcelReader.Native/Typed/TypedApi.cs @@ -98,7 +98,6 @@ private static void FreeColumn(in NativeColumn column) FreeBlock(column.Data); } - // AsyncLocal, not ThreadStatic: a test's hooks must follow a parallel build onto pool threads. private static readonly AsyncLocal?> AllocOverrideLocal = new(); private static readonly AsyncLocal?> FreeOverrideLocal = new(); @@ -419,8 +418,6 @@ private bool AppendString(in Cell cell) return true; } - // Each numeric type gets its own method so AppendFrom stays a small dispatcher: with the - // inlined double parser in it, every cell of every type paid its prologue. [MethodImpl(MethodImplOptions.NoInlining)] private bool AppendInt64(in Cell cell, bool isDate1904) { diff --git a/src/ExcelReader.Native/include/excelreader.h b/src/ExcelReader.Native/include/excelreader.h index 7631490a..4b53d87f 100644 --- a/src/ExcelReader.Native/include/excelreader.h +++ b/src/ExcelReader.Native/include/excelreader.h @@ -80,114 +80,60 @@ typedef struct xl_open_options { const uint8_t* password; int32_t password_len; - /* xl_open_source only: bytes of blocks kept in memory. 0 is the default, 64 MiB. */ int64_t source_cache_bytes; - /* xl_open_stream with XLSX, XLSB or XLS only: the most bytes the stream may hold. 0 is the - default, the .NET array limit (about 2 GB). */ int64_t max_buffered_bytes; - /* xl_open_source only: bytes fetched per read_at call. 0 is the default, 4 MiB; negative turns - the cache off, so every read the library makes reaches read_at. */ int32_t source_block_size; } xl_open_options; -/* options may be NULL for the defaults. When it is not NULL, set options->struct_size to - sizeof(xl_open_options) first. The 6.0 layout, which ends at password_len, is still accepted. - xl_open_memory copies data, so the buffer may be released as soon - as the call returns. */ int32_t xl_open_file(const uint8_t* path, int32_t path_len, int32_t format, const xl_open_options* options, xl_workbook** out_handle); int32_t xl_open_memory(const uint8_t* data, int32_t data_len, int32_t format, const xl_open_options* options, xl_workbook** out_handle); -/* Random-access bytes for xl_open_source. */ typedef struct xl_source { - int32_t struct_size; /* sizeof(xl_source) */ + int32_t struct_size; void* user_data; - int64_t length; /* total size in bytes */ - /* Copy up to len bytes starting at offset into buf. Return the bytes copied, or -1 on failure - after calling xl_set_source_error. Returning 0 before length is a failure ("the source ended - early"). Returning more than len, or a negative other than -1, is a failure. Called from any - thread, concurrently. */ + int64_t length; int64_t (*read_at)(void* user_data, int64_t offset, uint8_t* buf, int64_t len); - /* May be NULL. Called exactly once, when nothing opened from the workbook needs the source. */ void (*release)(void* user_data); } xl_source; -/* Opens a workbook over a source the caller serves. Every sheet can be read in parallel. A valid - source is released exactly once, whether the open succeeds or fails; a NULL source, a wrong - struct_size, a NULL read_at, a negative length or a NULL out_handle returns XL_INVALID_ARGUMENT - and the source is not released. With the default options, reads reach read_at in 4 MiB blocks - kept in a 64 MiB cache. */ int32_t xl_open_source(const xl_source* source, int32_t format, const xl_open_options* options, xl_workbook** out_handle); -/* Sequential bytes for xl_open_stream. */ typedef struct xl_stream { - int32_t struct_size; /* sizeof(xl_stream) */ + int32_t struct_size; void* user_data; - /* Copy up to len bytes into buf. Return the bytes copied (0 at the end), or -1 on failure after - calling xl_set_source_error. Returning more than len, or a negative other than -1, is a failure. - Never called concurrently. */ int64_t (*read)(void* user_data, uint8_t* buf, int64_t len); - /* May be NULL. Called exactly once. */ void (*release)(void* user_data); } xl_stream; -/* Opens a workbook over a stream that cannot seek. A CSV is read as it arrives, by one cursor - at a time. Its first 16 MiB are kept, so a later cursor, typed reader or Arrow stream starts over - (xl_infer_schema, then a read) until one has read past them; after that, or while one is still - open, another fails with XL_ERROR. csv_sniff_dialect is refused, and the stream is released when - the workbook and its cursors are closed. An XLSX, XLSB - or XLS needs random access, so the stream is read whole (up to max_buffered_bytes) and released - before this returns. Ownership rules are those of xl_open_source. */ int32_t xl_open_stream(const xl_stream* stream, int32_t format, const xl_open_options* options, xl_workbook** out_handle); -/* Callable only from inside read_at or read: the message xl_last_error reports when the callback - then returns -1. Inside a callback, no other xl_ function may be called. */ void xl_set_source_error(const uint8_t* message, int32_t len); -/* Cursors, typed readers and Arrow streams opened from this handle keep working after xl_close; the - workbook's resources are released when the last of them is closed. Any later call that takes this - handle returns XL_INVALID_HANDLE. */ int32_t xl_close(xl_workbook* handle); -/* A workbook handle may be used from several threads at once. index and sheet are zero-based: - negative is XL_INVALID_ARGUMENT, at or past xl_sheet_count is XL_ERROR. */ int32_t xl_sheet_count(xl_workbook* handle, int32_t* out_count); int32_t xl_sheet_name_at(xl_workbook* handle, int32_t index, uint8_t* buffer, int32_t capacity, int32_t* out_len); -/* Writes XL_SHEET_VISIBLE, XL_SHEET_HIDDEN or XL_SHEET_VERY_HIDDEN. */ int32_t xl_sheet_visibility_at(xl_workbook* handle, int32_t index, int32_t* out_visibility); -/* Finds a sheet by name, ignoring case. A name that matches no sheet is not an error: the function - returns XL_OK and writes -1. */ int32_t xl_sheet_index(xl_workbook* handle, const uint8_t* name, int32_t name_len, int32_t* out_index); int32_t xl_is_date1904(xl_workbook* handle, int32_t* out_flag); -/* Opens a cursor over one sheet. A cursor reads its sheet once, front to back; open another to read - it again. Any number of cursors may be open on one workbook, on the same sheet or on different - ones, and each may run on its own thread. Use one cursor from one thread at a time. A cursor keeps - working after xl_close on its workbook. Release it with xl_rows_close. */ int32_t xl_rows_open(xl_workbook* handle, int32_t sheet, xl_rows** out_rows); -/* A NULL or unknown cursor is XL_INVALID_HANDLE and nothing is released. */ int32_t xl_rows_close(xl_rows* rows); -/* XL_BUFFER_TOO_SMALL writes the needed size to out_written; call xl_rows_next again with a larger - buffer before any other read on that cursor, and the same row is returned. */ int32_t xl_rows_next(xl_rows* rows, uint8_t* buffer, int32_t capacity, int32_t* out_written); -/* out_row points into memory the cursor owns: overwritten by the next xl_rows_next_view on that - cursor, released by xl_rows_close. Values are NUL-terminated. Do not pass it to xl_free_rows. */ int32_t xl_rows_next_view(xl_rows* rows, xl_row* out_row); -/* Reads the rows the cursor has not yet returned. XL_BUFFER_TOO_SMALL writes the needed size to - out_written; call xl_rows_read_all_blob again with a larger buffer before any other read on that - cursor. */ int32_t xl_rows_read_all_blob(xl_rows* rows, uint8_t* buffer, int32_t capacity, int32_t* out_written); typedef struct xl_rows_decoded { @@ -195,7 +141,6 @@ typedef struct xl_rows_decoded { xl_row* rows; } xl_rows_decoded; -/* Reads the rows the cursor has not yet returned. Release out_rows with xl_free_rows. */ int32_t xl_rows_read_all_decoded(xl_rows* rows, xl_rows_decoded* out_rows); void xl_free_rows(xl_rows_decoded* rows); @@ -232,24 +177,16 @@ typedef struct xl_table { xl_column* columns; } xl_table; -/* sheet is a zero-based index: negative is XL_INVALID_ARGUMENT, at or past xl_sheet_count is XL_ERROR. - degree_of_parallelism: 0 = processor count, 1 = sequential, n = up to n threads; negative is - XL_INVALID_ARGUMENT. Only a CSV handle is read in parallel. Any other format, a CSV too small to - split, or a CSV opened with a non-UTF-8 encoding is read sequentially; the table is the same either - way. Calls on one handle may run on several threads at once. */ int32_t xl_parse_typed(xl_workbook* handle, int32_t sheet, const xl_column_spec* specs, int32_t spec_count, int32_t header_row, int32_t degree_of_parallelism, xl_table* out_table); void xl_free_table(xl_table* table); -/* The reader owns its position in the sheet. It keeps working after xl_close on its workbook, and any - number of readers may be open on one workbook. Use one reader from one thread at a time. */ int32_t xl_typed_reader_open(xl_workbook* handle, int32_t sheet, const xl_column_spec* specs, int32_t spec_count, int32_t header_row, int64_t max_rows, xl_typed_reader** out_reader); int32_t xl_typed_reader_next(xl_typed_reader* reader, xl_table* out_table); -/* A NULL or unknown reader is ignored. */ void xl_typed_reader_close(xl_typed_reader* reader); @@ -289,8 +226,6 @@ int32_t xl_encrypt_package(const uint8_t* package_path, int32_t package_path_len const uint8_t* destination_path, int32_t destination_path_len, const uint8_t* password, int32_t password_len); -/* xl_encrypt_package over bytes: the plaintext package never touches disk. Release out_buffer with - xl_free_buffer. */ int32_t xl_encrypt_package_to_memory(const uint8_t* package, int32_t package_len, const uint8_t* password, int32_t password_len, xl_buffer* out_buffer); @@ -302,10 +237,6 @@ typedef struct xl_inferred_schema { #define XL_INFER_PARSE_TEXT 1 -/* flags: 0, or XL_INFER_PARSE_TEXT to also type cells that hold text (a CSV's fields, numbers stored - as text): integers, decimals, true/false and ISO dates or date-times, when the text has exactly that - shape. Leading-zero codes such as 00123 and scientific notation such as 12E4 stay text. Any unknown - bit is XL_INVALID_ARGUMENT. Free the result with xl_free_schema. */ int32_t xl_infer_schema(xl_workbook* handle, int32_t sheet, int32_t header_row, int32_t sample_size, int32_t flags, xl_inferred_schema* out_schema); @@ -372,11 +303,6 @@ int32_t xl_csv_aggregate_memory(const uint8_t* data, int32_t data_len, const xl_csv_parallel_options* options, void** out_state); -/* The message for the calling thread's most recent failure. After any non-OK status from a - function that opens, reads or inspects a workbook (xl_open_*, xl_close, xl_sheet_*, xl_is_date1904, - xl_rows_*, xl_parse_*, xl_typed_reader_*, xl_infer_schema), it is that call's message, or empty - when the status alone says it all (a NULL out-pointer, an unknown handle): never a message left by - an earlier call. The writer, CSV-aggregation and encryption functions set it on XL_ERROR. */ int32_t xl_last_error(uint8_t* buffer, int32_t capacity, int32_t* out_len); const uint8_t* xl_last_error_ptr(int32_t* out_len); diff --git a/src/ExcelReader.Native/include/excelreader.hpp b/src/ExcelReader.Native/include/excelreader.hpp index 2717ab82..4094de50 100644 --- a/src/ExcelReader.Native/include/excelreader.hpp +++ b/src/ExcelReader.Native/include/excelreader.hpp @@ -76,8 +76,6 @@ namespace xl namespace detail { - // The first calls into the runtime from several threads at once can crash it; finish its - // start-up on this thread first (see commit b5f8c5f). Failures are ignored. inline void warm_up() noexcept { static constexpr char csv[] = "a,b\n1,2\n3,4\n"; @@ -528,7 +526,6 @@ namespace xl current_ = (*row_)[index_]; return; } - // Blob cell: int32 column, int32 type, int32 length, then the value bytes. const uint8_t *end = row_->payload_.data() + row_->payload_.size(); int32_t header[3]; if (static_cast(end - next_) < sizeof(header)) diff --git a/src/ExcelReader.Native/include/excelreader_arrow.h b/src/ExcelReader.Native/include/excelreader_arrow.h index 761c387f..a12cebee 100644 --- a/src/ExcelReader.Native/include/excelreader_arrow.h +++ b/src/ExcelReader.Native/include/excelreader_arrow.h @@ -62,12 +62,10 @@ struct ArrowArrayStream { #endif /* ARROW_C_STREAM_INTERFACE */ -/* Same sheet and degree_of_parallelism contract as xl_parse_typed. */ int32_t xl_parse_arrow(xl_workbook* handle, int32_t sheet, const xl_column_spec* specs, int32_t spec_count, int32_t header_row, int32_t degree_of_parallelism, struct ArrowArray* out_array, struct ArrowSchema* out_schema); -/* The stream owns its position in the sheet and keeps working after xl_close on its workbook. */ int32_t xl_parse_arrow_stream(xl_workbook* handle, int32_t sheet, const xl_column_spec* specs, int32_t spec_count, int32_t header_row, int64_t max_rows, struct ArrowArrayStream* out_stream); diff --git a/tests/ExcelReader.Benchmarks/Read/XlsxSharedStringHotPathBenchmark.cs b/tests/ExcelReader.Benchmarks/Read/XlsxSharedStringHotPathBenchmark.cs index 36782f49..e7354893 100644 --- a/tests/ExcelReader.Benchmarks/Read/XlsxSharedStringHotPathBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/Read/XlsxSharedStringHotPathBenchmark.cs @@ -8,8 +8,6 @@ namespace ExcelReader.Benchmarks { - // Sheet stage = full read minus the shared-string prologue; the Stored_* pair repeats both with - // the sheet re-zipped uncompressed, so their difference is sheet parse with no inflate floor. [MemoryDiagnoser] public partial class XlsxSharedStringHotPathBenchmark { diff --git a/tests/ExcelReader.Tests/Native/CallbackSourceTests.cs b/tests/ExcelReader.Tests/Native/CallbackSourceTests.cs index 01b84fa2..29cc16c6 100644 --- a/tests/ExcelReader.Tests/Native/CallbackSourceTests.cs +++ b/tests/ExcelReader.Tests/Native/CallbackSourceTests.cs @@ -74,7 +74,6 @@ private static int Open(Pinned pinned, int format, out NativeHandle? handle, Nat private static NativeOpenOptionsRaw Options() => new() { StructSize = sizeof(NativeOpenOptionsRaw) }; - // Prefetch only engages on sheets of 256 KiB or more uncompressed; the ConcurrentSheetTests sheets are smaller. private static byte[] BuildPrefetchedXlsx() { using MemoryStream buffer = new(); diff --git a/tests/ExcelReader.Tests/Native/NativeApiTests.ParallelTyped.cs b/tests/ExcelReader.Tests/Native/NativeApiTests.ParallelTyped.cs index e124be77..4cf1577f 100644 --- a/tests/ExcelReader.Tests/Native/NativeApiTests.ParallelTyped.cs +++ b/tests/ExcelReader.Tests/Native/NativeApiTests.ParallelTyped.cs @@ -182,7 +182,6 @@ public void ParseTypedTable_Should_Free_Every_Block_Exactly_Once_When_A_Parallel Assert.True(TypedApi.LastParseRanInParallel); Assert.Equal(NativeStatus.Error, status); Assert.Equal(IntPtr.Zero, table.Columns); - // Columns build concurrently, so another column can still allocate after the injected failure. Assert.True(tracker.Allocations >= failAt); Assert.Equal(tracker.Allocations - 1, tracker.Frees); Assert.Equal(0, tracker.Live); diff --git a/tests/ExcelReader.Tests/Parser/HeaderCollisionTests.cs b/tests/ExcelReader.Tests/Parser/HeaderCollisionTests.cs index 54b1c486..11ac642a 100644 --- a/tests/ExcelReader.Tests/Parser/HeaderCollisionTests.cs +++ b/tests/ExcelReader.Tests/Parser/HeaderCollisionTests.cs @@ -6,7 +6,6 @@ namespace ExcelReader.Tests.Parser { public partial class HeaderCollisionTests { - // EXR006 fires for these two, so their Generated path is covered by GeneratorTests' in-memory compilation instead. private sealed class CaseModel { [ExcelColumn("Name")] diff --git a/tests/ExcelReader.Tests/Parser/ParallelCsv/CsvReaderChunkSourceTests.cs b/tests/ExcelReader.Tests/Parser/ParallelCsv/CsvReaderChunkSourceTests.cs index fffe6e20..5e924f0c 100644 --- a/tests/ExcelReader.Tests/Parser/ParallelCsv/CsvReaderChunkSourceTests.cs +++ b/tests/ExcelReader.Tests/Parser/ParallelCsv/CsvReaderChunkSourceTests.cs @@ -60,7 +60,6 @@ public void TryGetChunkSource_Should_Read_A_File_Through_Its_Own_Overlapped_Hand Assert.True(reader.TryGetChunkSource(out source)); Assert.Equal(OperatingSystem.IsWindows(), source.Handle!.IsAsync); } - // Windows closes the reader's own overlapped handle; elsewhere it is the stream's, closed with the stream. Assert.True(source.Handle.IsClosed); } finally diff --git a/tests/ExcelReader.Tests/TestUtils.cs b/tests/ExcelReader.Tests/TestUtils.cs index 7e6653b8..a930bef7 100644 --- a/tests/ExcelReader.Tests/TestUtils.cs +++ b/tests/ExcelReader.Tests/TestUtils.cs @@ -257,7 +257,6 @@ internal static Action FailOnce(Exception failure) }; } - // Every span/async write on a derived MemoryStream funnels into this overload. public override void Write(byte[] buffer, int offset, int count) { OnWrite?.Invoke(); From 1514726f56a9c04948170c5051d9ceb9e2070816 Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Thu, 8 Oct 2026 13:43:05 -0300 Subject: [PATCH 021/115] feat: add Huffman decode-table builder for the managed inflate decoder Co-Authored-By: Claude Opus 5.5 --- THIRD-PARTY-NOTICES.md | 34 ++- src/ExcelReader.Core/ExcelReader.Core.csproj | 1 + .../Reader/Zip/Inflate/HuffmanTableBuilder.cs | 194 ++++++++++++++++++ .../Reader/Zip/Inflate/InflateFormat.cs | 71 +++++++ .../Reader/Zip/HuffmanTableBuilderTests.cs | 161 +++++++++++++++ 5 files changed, 460 insertions(+), 1 deletion(-) create mode 100644 src/ExcelReader.Core/Reader/Zip/Inflate/HuffmanTableBuilder.cs create mode 100644 src/ExcelReader.Core/Reader/Zip/Inflate/InflateFormat.cs create mode 100644 tests/ExcelReader.Tests/Reader/Zip/HuffmanTableBuilderTests.cs diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index e9fc20f2..c67d0e57 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -1,7 +1,7 @@ # Third-Party Notices This project (ExcelReader.NET) is licensed under the MIT License (see `LICENSE`). It vendors one -piece of third-party test data, noted below. +piece of third-party test data and ports one piece of third-party code, both noted below. ## tests/ExcelReader.Tests/data/encrypted/standard-aes128-sha1.xlsx @@ -14,3 +14,35 @@ It is used solely as a third-party oracle fixture to verify ECMA-376 standard-en against a file this codebase did not produce. It is test-project content only: it ships in the `ExcelReader.Tests` test project, never in the `ExcelReader.Core` NuGet package (`PackageLicenseExpression=MIT`), and is not distributed in any published package. + +## src/ExcelReader.Core/Reader/Zip/Inflate/ + +The inflate decoder in this folder is a C# port of the decompressor design in libdeflate: its +decode-table layout, table-building algorithm and fast-loop structure. + +- Project: libdeflate — https://github.com/ebiggers/libdeflate +- License: MIT + +This code ships in the `ExcelReader.NET` NuGet package, so the notice below ships with it. + + Copyright 2016 Eric Biggers + + Permission is hereby granted, free of charge, to any person + obtaining a copy of this software and associated documentation files + (the "Software"), to deal in the Software without restriction, + including without limitation the rights to use, copy, modify, merge, + publish, distribute, sublicense, and/or sell copies of the Software, + and to permit persons to whom the Software is furnished to do so, + subject to the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS + BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN + ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. diff --git a/src/ExcelReader.Core/ExcelReader.Core.csproj b/src/ExcelReader.Core/ExcelReader.Core.csproj index 216104fc..61872636 100644 --- a/src/ExcelReader.Core/ExcelReader.Core.csproj +++ b/src/ExcelReader.Core/ExcelReader.Core.csproj @@ -36,6 +36,7 @@ + diff --git a/src/ExcelReader.Core/Reader/Zip/Inflate/HuffmanTableBuilder.cs b/src/ExcelReader.Core/Reader/Zip/Inflate/HuffmanTableBuilder.cs new file mode 100644 index 00000000..e9fccbd3 --- /dev/null +++ b/src/ExcelReader.Core/Reader/Zip/Inflate/HuffmanTableBuilder.cs @@ -0,0 +1,194 @@ +// Ported from libdeflate's decompressor (MIT, Eric Biggers); the notice is in THIRD-PARTY-NOTICES.md. +using System.Numerics; + +namespace ExcelReader.Core.Reader.Zip.Inflate +{ + internal static class HuffmanTableBuilder + { + internal static bool TryBuild( + Span table, ReadOnlySpan lens, ReadOnlySpan results, int tableBits, int maxLen, + Span sorted, bool shrink, out int tableBitsUsed) + { + Span lenCounts = stackalloc int[InflateFormat.MaxCodewordLength + 2]; + lenCounts.Clear(); + foreach (byte len in lens) + { + lenCounts[len]++; + } + while (maxLen > 1 && lenCounts[maxLen] == 0) + { + maxLen--; + } + if (shrink) + { + tableBits = Math.Min(tableBits, maxLen); + } + tableBitsUsed = tableBits; + + uint codespaceUsed = SortSymbols(lens, lenCounts, maxLen, sorted); + ReadOnlySpan used = sorted[lenCounts[0]..lens.Length]; + uint codespace = 1U << maxLen; + if (codespaceUsed > codespace) + { + return false; + } + if (codespaceUsed < codespace) + { + return TryFillIncomplete(table[..(1 << tableBits)], results, used, codespaceUsed, codespace, lenCounts[1]); + } + FillComplete(table, results, used, lenCounts, tableBits); + return true; + } + + private static uint SortSymbols(ReadOnlySpan lens, ReadOnlySpan lenCounts, int maxLen, Span sorted) + { + Span offsets = stackalloc int[InflateFormat.MaxCodewordLength + 2]; + offsets[0] = 0; + offsets[1] = lenCounts[0]; + uint codespaceUsed = 0; + for (int len = 1; len < maxLen; len++) + { + offsets[len + 1] = offsets[len] + lenCounts[len]; + codespaceUsed = (codespaceUsed << 1) + (uint)lenCounts[len]; + } + codespaceUsed = (codespaceUsed << 1) + (uint)lenCounts[maxLen]; + for (int symbol = 0; symbol < lens.Length; symbol++) + { + sorted[offsets[lens[symbol]]++] = (ushort)symbol; + } + return codespaceUsed; + } + + // Deflate allows two incomplete codes: no codewords at all, and a single one-bit codeword. + private static bool TryFillIncomplete( + Span mainTable, ReadOnlySpan results, ReadOnlySpan used, uint codespaceUsed, uint codespace, int oneBitCodewords) + { + int symbol = 0; + if (codespaceUsed != 0) + { + if (codespaceUsed != codespace >> 1 || oneBitCodewords != 1) + { + return false; + } + symbol = used[0]; + } + mainTable.Fill(Entry(results[symbol], 1)); + return true; + } + + private static void FillComplete(Span table, ReadOnlySpan results, ReadOnlySpan used, ReadOnlySpan lenCounts, int tableBits) + { + int next = 0; + uint codeword = 0; + int len = 1; + while (lenCounts[len] == 0) + { + len++; + } + int count = lenCounts[len]; + int tableEnd = 1 << len; + while (len <= tableBits) + { + do + { + table[(int)codeword] = Entry(results[used[next++]], len); + if (codeword == (uint)tableEnd - 1) + { + Replicate(table, tableEnd, 1 << tableBits); + return; + } + codeword = NextCodeword(codeword, (uint)tableEnd - 1); + } + while (--count != 0); + + do + { + len++; + if (len <= tableBits) + { + Replicate(table, tableEnd, tableEnd << 1); + tableEnd <<= 1; + } + count = lenCounts[len]; + } + while (count == 0); + } + FillSubtables(table, results, used[next..], lenCounts, tableBits, codeword, len, count); + } + + private static void FillSubtables( + Span table, ReadOnlySpan results, ReadOnlySpan used, ReadOnlySpan lenCounts, + int tableBits, uint codeword, int len, int count) + { + uint tableEnd = 1U << tableBits; + uint mainMask = tableEnd - 1; + uint prefix = uint.MaxValue; + uint start = 0; + int next = 0; + while (true) + { + if ((codeword & mainMask) != prefix) + { + prefix = codeword & mainMask; + start = tableEnd; + int subtableBits = SubtableBits(lenCounts, tableBits, len, count); + tableEnd = start + (1U << subtableBits); + table[(int)prefix] = (start << 16) | InflateFormat.Exceptional | InflateFormat.SubtablePointer + | ((uint)subtableBits << 8) | (uint)tableBits; + } + + int subLen = len - tableBits; + uint entry = Entry(results[used[next++]], subLen); + for (uint i = start + (codeword >> tableBits); i < tableEnd; i += 1U << subLen) + { + table[(int)i] = entry; + } + + uint allOnes = (1U << len) - 1; + if (codeword == allOnes) + { + return; + } + codeword = NextCodeword(codeword, allOnes); + count--; + while (count == 0) + { + count = lenCounts[++len]; + } + } + } + + private static int SubtableBits(ReadOnlySpan lenCounts, int tableBits, int len, int count) + { + int bits = len - tableBits; + uint codespaceUsed = (uint)count; + while (codespaceUsed < 1U << bits) + { + bits++; + codespaceUsed = (codespaceUsed << 1) + (uint)lenCounts[tableBits + bits]; + } + return bits; + } + + private static void Replicate(Span table, int filled, int target) + { + while (filled < target) + { + table[..filled].CopyTo(table[filled..]); + filled <<= 1; + } + } + + // Codewords are stored bit-reversed, so the next one in canonical order is a reversed increment. + private static uint NextCodeword(uint codeword, uint allOnes) + { + uint bit = 1U << BitOperations.Log2(codeword ^ allOnes); + return (codeword & (bit - 1)) | bit; + } + + private static uint Entry(uint result, int len) + { + return result + ((uint)len << 8) + (uint)len; + } + } +} diff --git a/src/ExcelReader.Core/Reader/Zip/Inflate/InflateFormat.cs b/src/ExcelReader.Core/Reader/Zip/Inflate/InflateFormat.cs new file mode 100644 index 00000000..cb11b31e --- /dev/null +++ b/src/ExcelReader.Core/Reader/Zip/Inflate/InflateFormat.cs @@ -0,0 +1,71 @@ +namespace ExcelReader.Core.Reader.Zip.Inflate +{ + internal static class InflateFormat + { + internal const int LitlenTableBits = 11; + internal const int OffsetTableBits = 8; + internal const int PrecodeTableBits = 7; + + // Worst-case table sizes for these table widths, from zlib's `enough` tool as used by libdeflate. + internal const int LitlenTableSize = 2342; + internal const int OffsetTableSize = 402; + internal const int PrecodeTableSize = 128; + + internal const int MaxLitlenSymbols = 288; + internal const int MaxOffsetSymbols = 32; + internal const int PrecodeSymbols = 19; + internal const int MaxCodewordLength = 15; + internal const int MaxPrecodeLength = 7; + internal const int MaxRepeat = 138; + + internal const uint Literal = 0x8000_0000; + internal const uint Exceptional = 0x8000; + internal const uint SubtablePointer = 0x4000; + internal const uint EndOfBlock = 0x2000; + + internal static readonly uint[] LitlenResults = BuildLitlenResults(); + internal static readonly uint[] OffsetResults = BuildOffsetResults(); + internal static readonly uint[] PrecodeResults = BuildPrecodeResults(); + + internal static ReadOnlySpan PrecodeOrder => [16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]; + + private static uint[] BuildLitlenResults() + { + ReadOnlySpan bases = [3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258, 258, 258]; + ReadOnlySpan extraBits = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0, 0, 0]; + uint[] results = new uint[MaxLitlenSymbols]; + for (uint literal = 0; literal < 256; literal++) + { + results[literal] = Literal | (literal << 16); + } + results[256] = Exceptional | EndOfBlock; + for (int i = 0; i < bases.Length; i++) + { + results[257 + i] = ((uint)bases[i] << 16) | extraBits[i]; + } + return results; + } + + private static uint[] BuildOffsetResults() + { + ReadOnlySpan bases = [1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577, 24577, 24577]; + ReadOnlySpan extraBits = [0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13, 13, 13]; + uint[] results = new uint[MaxOffsetSymbols]; + for (int i = 0; i < results.Length; i++) + { + results[i] = ((uint)bases[i] << 16) | extraBits[i]; + } + return results; + } + + private static uint[] BuildPrecodeResults() + { + uint[] results = new uint[PrecodeSymbols]; + for (uint i = 0; i < results.Length; i++) + { + results[i] = i << 16; + } + return results; + } + } +} diff --git a/tests/ExcelReader.Tests/Reader/Zip/HuffmanTableBuilderTests.cs b/tests/ExcelReader.Tests/Reader/Zip/HuffmanTableBuilderTests.cs new file mode 100644 index 00000000..27365b70 --- /dev/null +++ b/tests/ExcelReader.Tests/Reader/Zip/HuffmanTableBuilderTests.cs @@ -0,0 +1,161 @@ +using ExcelReader.Core.Reader.Zip.Inflate; + +namespace ExcelReader.Tests.Reader.Zip +{ + public class HuffmanTableBuilderTests + { + private static readonly uint[] _symbolResults = [.. Enumerable.Range(0, InflateFormat.MaxLitlenSymbols).Select(static i => (uint)i << 16)]; + + public static TheoryData CompleteCodes() + { + byte[] fixedLitlen = new byte[288]; + fixedLitlen.AsSpan(0, 144).Fill(8); + fixedLitlen.AsSpan(144, 112).Fill(9); + fixedLitlen.AsSpan(256, 24).Fill(7); + fixedLitlen.AsSpan(280, 8).Fill(8); + return new TheoryData + { + { [3, 3, 3, 3, 3, 2, 4, 4], 11 }, + { [3, 3, 3, 3, 3, 2, 4, 4], 3 }, + { fixedLitlen, 11 }, + { fixedLitlen, 8 }, + { [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 15], 11 }, + { [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 15], 8 }, + { [0, 2, 0, 2, 2, 0, 0, 2], 8 }, + }; + } + + [Theory] + [MemberData(nameof(CompleteCodes))] + public void EveryCanonicalCodewordDecodesToItsSymbol(byte[] lens, int tableBits) + { + uint[] table = new uint[InflateFormat.LitlenTableSize]; + ushort[] sorted = new ushort[lens.Length]; + + Assert.True(HuffmanTableBuilder.TryBuild(table, lens, _symbolResults, tableBits, 15, sorted, shrink: false, out int used)); + Assert.Equal(tableBits, used); + + uint[] codewords = CanonicalCodewords(lens); + int checkedSymbols = 0; + for (int symbol = 0; symbol < lens.Length; symbol++) + { + if (lens[symbol] == 0) + { + continue; + } + uint reversed = Reverse(codewords[symbol], lens[symbol]); + foreach (uint padding in (uint[])[0, uint.MaxValue]) + { + uint bits = reversed | (padding << lens[symbol]); + (int decoded, int length) = Lookup(table, bits, tableBits); + Assert.Equal(symbol, decoded); + Assert.Equal(lens[symbol], length); + } + checkedSymbols++; + } + Assert.Equal(lens.Count(static len => len != 0), checkedSymbols); + } + + [Fact] + public void ShrinkNarrowsTheTableToTheLongestCodeword() + { + uint[] table = new uint[InflateFormat.LitlenTableSize]; + ushort[] sorted = new ushort[8]; + + Assert.True(HuffmanTableBuilder.TryBuild(table, [3, 3, 3, 3, 3, 2, 4, 4], _symbolResults, 11, 15, sorted, shrink: true, out int used)); + + Assert.Equal(4, used); + } + + [Theory] + [InlineData(new byte[] { 1, 1, 1 })] + [InlineData(new byte[] { 2, 2, 2, 2, 2 })] + [InlineData(new byte[] { 1, 2, 2, 15 })] + public void OverSubscribedCodeIsRejected(byte[] lens) + { + Assert.False(HuffmanTableBuilder.TryBuild( + new uint[InflateFormat.LitlenTableSize], lens, _symbolResults, 11, 15, new ushort[lens.Length], shrink: false, out _)); + } + + [Theory] + [InlineData(new byte[] { 2, 2, 2 })] + [InlineData(new byte[] { 2 })] + [InlineData(new byte[] { 1, 0, 2 })] + [InlineData(new byte[] { 1, 15 })] + public void IncompleteCodeIsRejected(byte[] lens) + { + Assert.False(HuffmanTableBuilder.TryBuild( + new uint[InflateFormat.LitlenTableSize], lens, _symbolResults, 11, 15, new ushort[lens.Length], shrink: false, out _)); + } + + [Fact] + public void SingleOneBitCodewordFillsTheTableWithItsSymbol() + { + uint[] table = new uint[256]; + + Assert.True(HuffmanTableBuilder.TryBuild(table, [0, 0, 1, 0], _symbolResults, 8, 15, new ushort[4], shrink: false, out _)); + + Assert.All(table, static entry => Assert.Equal((2U << 16) | (1U << 8) | 1U, entry)); + } + + [Fact] + public void EmptyCodeFillsTheTableWithSymbolZero() + { + uint[] table = new uint[256]; + Array.Fill(table, uint.MaxValue); + + Assert.True(HuffmanTableBuilder.TryBuild(table, [0, 0, 0, 0], _symbolResults, 8, 15, new ushort[4], shrink: false, out _)); + + Assert.All(table, static entry => Assert.Equal((1U << 8) | 1U, entry)); + } + + private static (int Symbol, int Length) Lookup(uint[] table, uint bits, int tableBits) + { + uint entry = table[bits & ((1U << tableBits) - 1)]; + if ((entry & InflateFormat.SubtablePointer) == 0) + { + return ((int)(entry >> 16), (int)(entry & 0xFF)); + } + int subtableBits = (int)((entry >> 8) & 0x3F); + uint subEntry = table[(entry >> 16) + ((bits >> tableBits) & ((1U << subtableBits) - 1))]; + return ((int)(subEntry >> 16), tableBits + (int)(subEntry & 0xFF)); + } + + // RFC 1951 section 3.2.2. + private static uint[] CanonicalCodewords(byte[] lens) + { + int[] counts = new int[16]; + foreach (byte len in lens) + { + counts[len]++; + } + counts[0] = 0; + uint[] next = new uint[16]; + uint code = 0; + for (int bits = 1; bits <= 15; bits++) + { + code = (code + (uint)counts[bits - 1]) << 1; + next[bits] = code; + } + uint[] codewords = new uint[lens.Length]; + for (int symbol = 0; symbol < lens.Length; symbol++) + { + if (lens[symbol] != 0) + { + codewords[symbol] = next[lens[symbol]]++; + } + } + return codewords; + } + + private static uint Reverse(uint codeword, int length) + { + uint reversed = 0; + for (int i = 0; i < length; i++) + { + reversed = (reversed << 1) | ((codeword >> i) & 1); + } + return reversed; + } + } +} From d7f155979d00e333d8fc33fd7c4fc5ff34c12ba4 Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Thu, 8 Oct 2026 13:45:37 -0300 Subject: [PATCH 022/115] feat: add a managed streaming inflate decoder Co-Authored-By: Claude Opus 5.5 --- .../Zip/Inflate/InflateDecoder.FastLoop.cs | 241 +++++++++++++ .../Reader/Zip/Inflate/InflateDecoder.cs | 336 ++++++++++++++++++ .../Reader/Zip/Inflate/InflateStream.cs | 211 +++++++++++ .../Reader/Zip/InflateDifferentialTests.cs | 87 +++++ .../Reader/Zip/InflateStreamTests.cs | 245 +++++++++++++ .../Reader/Zip/InflateTestSupport.cs | 202 +++++++++++ 6 files changed, 1322 insertions(+) create mode 100644 src/ExcelReader.Core/Reader/Zip/Inflate/InflateDecoder.FastLoop.cs create mode 100644 src/ExcelReader.Core/Reader/Zip/Inflate/InflateDecoder.cs create mode 100644 src/ExcelReader.Core/Reader/Zip/Inflate/InflateStream.cs create mode 100644 tests/ExcelReader.Tests/Reader/Zip/InflateDifferentialTests.cs create mode 100644 tests/ExcelReader.Tests/Reader/Zip/InflateStreamTests.cs create mode 100644 tests/ExcelReader.Tests/Reader/Zip/InflateTestSupport.cs diff --git a/src/ExcelReader.Core/Reader/Zip/Inflate/InflateDecoder.FastLoop.cs b/src/ExcelReader.Core/Reader/Zip/Inflate/InflateDecoder.FastLoop.cs new file mode 100644 index 00000000..fcf7f008 --- /dev/null +++ b/src/ExcelReader.Core/Reader/Zip/Inflate/InflateDecoder.FastLoop.cs @@ -0,0 +1,241 @@ +// Ported from libdeflate's decompressor (MIT, Eric Biggers); the notice is in THIRD-PARTY-NOTICES.md. +using System.Buffers.Binary; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Runtime.Intrinsics.X86; + +namespace ExcelReader.Core.Reader.Zip.Inflate +{ + internal sealed partial class InflateDecoder + { + private enum LoopStatus + { + BlockDone, + Limit, + BadOffset, + } + + private unsafe LoopStatus RunFastLoop(byte[] input, ref int inPos, int inLimit, byte[] window, ref int outPos, int outLimit) + { + fixed (byte* inBase = input) + fixed (byte* outBase = window) + fixed (uint* litlen = _litlen) + fixed (uint* offsets = _offset) + { + byte* inNext = inBase + inPos; + byte* outNext = outBase + outPos; + LoopStatus status = FastLoop(ref inNext, inBase + inLimit, outBase, ref outNext, outBase + outLimit, litlen, offsets); + inPos = (int)(inNext - inBase); + outPos = (int)(outNext - outBase); + return status; + } + } + + // Entry layout, shared with HuffmanTableBuilder: bits 0-7 hold the bits to consume (codeword + // plus extra bits), bits 8-11 the codeword length alone, bits 16 and up the literal, the + // base length or offset, or the subtable start. + // + // Bit budget: a refill leaves at least 56 bits. Three literals from the main table take at + // most 33; a length takes at most 20 and an offset at most 28, hence the refill between them. + [SuppressMessage("Design", "MA0051:Method is too long", + Justification = "The decode loop is deliberately one call-free method: every live value has to stay in a register across it, and a call inside the loop spills them.")] + private unsafe LoopStatus FastLoop( + ref byte* inPosition, byte* inLimit, byte* window, ref byte* outPosition, byte* outLimit, uint* litlen, uint* offsets) + { + ulong bitbuf = _bitbuf; + int bitsleft = _bitsleft; + byte* inNext = inPosition; + byte* outNext = outPosition; + ulong mask = _litlenMask; + LoopStatus status; + + bitbuf |= LoadWord(inNext) << bitsleft; + inNext += (63 - bitsleft) >> 3; + bitsleft |= 56; + uint entry = litlen[bitbuf & mask]; + + while (true) + { + if (inNext >= inLimit || outNext >= outLimit) + { + status = LoopStatus.Limit; + break; + } + + ulong saved = bitbuf; + bitbuf >>= (int)entry; + bitsleft -= (int)(entry & 0xFF); + + if ((int)entry < 0) + { + uint literal = entry >> 16; + entry = litlen[bitbuf & mask]; + saved = bitbuf; + bitbuf >>= (int)entry; + bitsleft -= (int)(entry & 0xFF); + *outNext++ = (byte)literal; + if ((int)entry < 0) + { + literal = entry >> 16; + entry = litlen[bitbuf & mask]; + saved = bitbuf; + bitbuf >>= (int)entry; + bitsleft -= (int)(entry & 0xFF); + *outNext++ = (byte)literal; + } + if ((int)entry < 0) + { + *outNext++ = (byte)(entry >> 16); + entry = litlen[bitbuf & mask]; + bitbuf |= LoadWord(inNext) << bitsleft; + inNext += (63 - bitsleft) >> 3; + bitsleft |= 56; + continue; + } + } + + if ((entry & InflateFormat.Exceptional) != 0) + { + if ((entry & InflateFormat.EndOfBlock) != 0) + { + status = LoopStatus.BlockDone; + break; + } + entry = litlen[(entry >> 16) + (uint)LowBits(bitbuf, (entry >> 8) & 0x3F)]; + saved = bitbuf; + bitbuf >>= (int)entry; + bitsleft -= (int)(entry & 0xFF); + if ((int)entry < 0) + { + *outNext++ = (byte)(entry >> 16); + entry = litlen[bitbuf & mask]; + bitbuf |= LoadWord(inNext) << bitsleft; + inNext += (63 - bitsleft) >> 3; + bitsleft |= 56; + continue; + } + if ((entry & InflateFormat.EndOfBlock) != 0) + { + status = LoopStatus.BlockDone; + break; + } + } + + uint length = (entry >> 16) + (uint)(LowBits(saved, entry & 0xFF) >> (int)((entry >> 8) & 0xF)); + + if (bitsleft < 30) + { + bitbuf |= LoadWord(inNext) << bitsleft; + inNext += (63 - bitsleft) >> 3; + bitsleft |= 56; + } + uint offsetEntry = offsets[bitbuf & ((1 << InflateFormat.OffsetTableBits) - 1)]; + if ((offsetEntry & InflateFormat.Exceptional) != 0) + { + bitbuf >>= InflateFormat.OffsetTableBits; + bitsleft -= InflateFormat.OffsetTableBits; + offsetEntry = offsets[(offsetEntry >> 16) + (uint)LowBits(bitbuf, (offsetEntry >> 8) & 0x3F)]; + } + saved = bitbuf; + bitbuf >>= (int)offsetEntry; + bitsleft -= (int)(offsetEntry & 0xFF); + uint offset = (offsetEntry >> 16) + (uint)(LowBits(saved, offsetEntry & 0xFF) >> (int)((offsetEntry >> 8) & 0xF)); + + if (offset > (nuint)(outNext - window)) + { + status = LoopStatus.BadOffset; + break; + } + byte* source = outNext - offset; + byte* destination = outNext; + outNext += length; + + bitbuf |= LoadWord(inNext) << bitsleft; + inNext += (63 - bitsleft) >> 3; + bitsleft |= 56; + entry = litlen[bitbuf & mask]; + + CopyMatch(source, destination, outNext, offset); + } + + _bitbuf = bitbuf; + _bitsleft = bitsleft; + inPosition = inNext; + outPosition = outNext; + return status; + } + + // Copies forward a word at a time, so it may write up to 39 bytes past `end`; the caller's + // output slack covers that. An offset below eight overlaps within one word and needs the + // narrower strides. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static unsafe void CopyMatch(byte* source, byte* destination, byte* end, uint offset) + { + if (offset >= sizeof(ulong)) + { + CopyWord(source, destination); + CopyWord(source + 8, destination + 8); + CopyWord(source + 16, destination + 16); + CopyWord(source + 24, destination + 24); + CopyWord(source + 32, destination + 32); + source += 40; + destination += 40; + while (destination < end) + { + CopyWord(source, destination); + CopyWord(source + 8, destination + 8); + source += 16; + destination += 16; + } + return; + } + if (offset == 1) + { + ulong repeated = 0x0101010101010101UL * *source; + Unsafe.WriteUnaligned(destination, repeated); + Unsafe.WriteUnaligned(destination + 8, repeated); + destination += 16; + while (destination < end) + { + Unsafe.WriteUnaligned(destination, repeated); + destination += 8; + } + return; + } + do + { + CopyWord(source, destination); + source += offset; + destination += offset; + } + while (destination < end); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static unsafe void CopyWord(byte* source, byte* destination) + { + Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned(source)); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static unsafe ulong LoadWord(byte* source) + { + ulong value = Unsafe.ReadUnaligned(source); + if (BitConverter.IsLittleEndian) + { + return value; + } + return BinaryPrimitives.ReverseEndianness(value); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static ulong LowBits(ulong value, uint count) + { + if (Bmi2.X64.IsSupported) + { + return Bmi2.X64.ZeroHighBits(value, count); + } + return value & ((1UL << (int)count) - 1); + } + } +} diff --git a/src/ExcelReader.Core/Reader/Zip/Inflate/InflateDecoder.cs b/src/ExcelReader.Core/Reader/Zip/Inflate/InflateDecoder.cs new file mode 100644 index 00000000..d421de69 --- /dev/null +++ b/src/ExcelReader.Core/Reader/Zip/Inflate/InflateDecoder.cs @@ -0,0 +1,336 @@ +using System.Buffers; +using System.Buffers.Binary; + +namespace ExcelReader.Core.Reader.Zip.Inflate +{ + internal enum InflateStatus + { + NeedInput, + OutputFull, + Finished, + } + + /// + /// Resumable DEFLATE decoder with no I/O of its own. The caller owns the input buffer and the + /// output window and feeds them through . + /// + internal sealed partial class InflateDecoder : IDisposable + { + // Bytes the caller must keep readable past the end of its input, zeroed once input has ended. + internal const int InputPadding = 2048; + + // Bytes the caller must keep writable past outLimit: one loop iteration writes at most + // three literals plus a 258-byte match rounded up by its word-at-a-time copy. + internal const int OutputSlack = 320; + + // A dynamic block header is at most 570 bytes; the decoder never starts one with less than this. + private const int HeaderMargin = 1024; + private const int LoopInputMargin = 64; + private const int LensSize = InflateFormat.MaxLitlenSymbols + InflateFormat.MaxOffsetSymbols + InflateFormat.MaxRepeat; + private const ulong PrecodeMask = (1UL << InflateFormat.PrecodeTableBits) - 1; + + private readonly uint[] _litlen = ArrayPool.Shared.Rent(InflateFormat.LitlenTableSize); + private readonly uint[] _offset = ArrayPool.Shared.Rent(InflateFormat.OffsetTableSize); + private ulong _litlenMask; + private bool _staticCodesLoaded; + private ulong _bitbuf; + private int _bitsleft; + private BlockState _state; + private bool _finalBlock; + private int _storedRemaining; + private bool _disposed; + + private enum BlockState + { + Header, + Coded, + Stored, + Finished, + } + + /// + /// Decodes until the window reaches , more input is required, or the + /// stream ends. On every unconsumed whole byte has been + /// handed back through , so the caller may compact and refill. + /// + internal InflateStatus Decode(byte[] input, ref int inPos, int inEnd, bool inputEnded, byte[] window, ref int outPos, int outLimit) + { + while (_state != BlockState.Finished) + { + if (outPos >= outLimit) + { + return InflateStatus.OutputFull; + } + bool advanced = _state switch + { + BlockState.Header => TryReadHeader(input, ref inPos, inEnd, inputEnded), + BlockState.Stored => TryCopyStored(input, ref inPos, inEnd, inputEnded, window, ref outPos, outLimit), + _ => TryDecodeCoded(input, ref inPos, inEnd, inputEnded, window, ref outPos, outLimit), + }; + if (!advanced) + { + return InflateStatus.NeedInput; + } + } + return InflateStatus.Finished; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + _disposed = true; + ArrayPool.Shared.Return(_litlen); + ArrayPool.Shared.Return(_offset); + } + + private bool TryReadHeader(byte[] input, ref int inPos, int inEnd, bool inputEnded) + { + if (!inputEnded && inEnd - inPos < HeaderMargin) + { + Unread(ref inPos); + return false; + } + ThrowIfOverrun(inPos, inEnd, inputEnded); + RefillBits(input, ref inPos); + _finalBlock = PopBits(1) != 0; + switch (PopBits(2)) + { + case 0: + BeginStored(input, ref inPos); + break; + case 1: + LoadStaticCodes(); + _state = BlockState.Coded; + break; + case 2: + ReadDynamicCodes(input, ref inPos); + _state = BlockState.Coded; + break; + default: + throw new InvalidDataException("The deflate stream uses a reserved block type."); + } + ThrowIfOverrun(inPos, inEnd, inputEnded); + return true; + } + + private void BeginStored(ReadOnlySpan input, ref int inPos) + { + inPos -= _bitsleft >> 3; + _bitbuf = 0; + _bitsleft = 0; + ushort length = BinaryPrimitives.ReadUInt16LittleEndian(input[inPos..]); + ushort complement = BinaryPrimitives.ReadUInt16LittleEndian(input[(inPos + 2)..]); + inPos += 4; + if (length != (ushort)~complement) + { + throw new InvalidDataException("A stored deflate block has mismatched length fields."); + } + _storedRemaining = length; + _state = BlockState.Stored; + } + + private bool TryCopyStored(byte[] input, ref int inPos, int inEnd, bool inputEnded, byte[] window, ref int outPos, int outLimit) + { + if (_storedRemaining == 0) + { + EndBlock(); + return true; + } + int available = inEnd - inPos; + if (available <= 0) + { + ThrowIfTruncated(inputEnded); + return false; + } + int count = Math.Min(Math.Min(_storedRemaining, available), outLimit - outPos); + input.AsSpan(inPos, count).CopyTo(window.AsSpan(outPos)); + inPos += count; + outPos += count; + _storedRemaining -= count; + return true; + } + + private bool TryDecodeCoded(byte[] input, ref int inPos, int inEnd, bool inputEnded, byte[] window, ref int outPos, int outLimit) + { + // Past the real end the padding is zeros, and a valid stream never needs more than the + // eight bytes a refill may have looked ahead. + int inLimit = inputEnded ? inEnd + sizeof(ulong) : inEnd - LoopInputMargin; + LoopStatus status = RunFastLoop(input, ref inPos, inLimit, window, ref outPos, outLimit); + if (status == LoopStatus.BadOffset) + { + throw new InvalidDataException("A deflate match reaches before the start of the output."); + } + if (status == LoopStatus.BlockDone) + { + ThrowIfOverrun(inPos, inEnd, inputEnded); + EndBlock(); + return true; + } + if (outPos >= outLimit) + { + return true; + } + ThrowIfTruncated(inputEnded); + Unread(ref inPos); + return false; + } + + private void EndBlock() + { + _state = _finalBlock ? BlockState.Finished : BlockState.Header; + } + + private void Unread(ref int inPos) + { + inPos -= _bitsleft >> 3; + _bitsleft &= 7; + _bitbuf &= (1UL << _bitsleft) - 1; + } + + private void ThrowIfOverrun(int inPos, int inEnd, bool inputEnded) + { + if (inputEnded && inPos - (_bitsleft >> 3) > inEnd) + { + throw new InvalidDataException("The deflate stream is truncated."); + } + } + + private static void ThrowIfTruncated(bool inputEnded) + { + if (inputEnded) + { + throw new InvalidDataException("The deflate stream is truncated."); + } + } + + private void RefillBits(ReadOnlySpan input, ref int inPos) + { + _bitbuf |= BinaryPrimitives.ReadUInt64LittleEndian(input[inPos..]) << _bitsleft; + inPos += (63 - _bitsleft) >> 3; + _bitsleft |= 56; + } + + private uint PopBits(int count) + { + uint value = (uint)(_bitbuf & ((1UL << count) - 1)); + _bitbuf >>= count; + _bitsleft -= count; + return value; + } + + private void LoadStaticCodes() + { + if (_staticCodesLoaded) + { + return; + } + Span lens = stackalloc byte[InflateFormat.MaxLitlenSymbols + InflateFormat.MaxOffsetSymbols]; + lens[..144].Fill(8); + lens[144..256].Fill(9); + lens[256..280].Fill(7); + lens[280..288].Fill(8); + lens[288..].Fill(5); + BuildCodeTables(lens, InflateFormat.MaxLitlenSymbols, InflateFormat.MaxOffsetSymbols); + _staticCodesLoaded = true; + } + + private void ReadDynamicCodes(ReadOnlySpan input, ref int inPos) + { + _staticCodesLoaded = false; + int litlenSymbols = (int)PopBits(5) + 257; + int offsetSymbols = (int)PopBits(5) + 1; + int precodeSymbols = (int)PopBits(4) + 4; + + Span precodeLens = stackalloc byte[InflateFormat.PrecodeSymbols]; + precodeLens.Clear(); + for (int i = 0; i < precodeSymbols; i++) + { + if (_bitsleft < 3) + { + RefillBits(input, ref inPos); + } + precodeLens[InflateFormat.PrecodeOrder[i]] = (byte)PopBits(3); + } + + Span precode = stackalloc uint[InflateFormat.PrecodeTableSize]; + Span sorted = stackalloc ushort[InflateFormat.PrecodeSymbols]; + if (!HuffmanTableBuilder.TryBuild( + precode, precodeLens, InflateFormat.PrecodeResults, InflateFormat.PrecodeTableBits, + InflateFormat.MaxPrecodeLength, sorted, shrink: false, out _)) + { + throw new InvalidDataException("A deflate block has an invalid code-length code."); + } + + Span lens = stackalloc byte[LensSize]; + ReadCodeLengths(input, ref inPos, precode, lens, litlenSymbols + offsetSymbols); + BuildCodeTables(lens[..(litlenSymbols + offsetSymbols)], litlenSymbols, offsetSymbols); + } + + private void ReadCodeLengths(ReadOnlySpan input, ref int inPos, ReadOnlySpan precode, Span lens, int total) + { + int count = 0; + while (count < total) + { + if (_bitsleft < InflateFormat.MaxPrecodeLength + 7) + { + RefillBits(input, ref inPos); + } + uint entry = precode[(int)(_bitbuf & PrecodeMask)]; + _ = PopBits((int)(entry & 0xFF)); + uint symbol = entry >> 16; + if (symbol < 16) + { + lens[count++] = (byte)symbol; + continue; + } + int repeat = ReadRepeat(symbol, count, lens, out byte value); + lens.Slice(count, repeat).Fill(value); + count += repeat; + } + if (count != total) + { + throw new InvalidDataException("A deflate block's code lengths overrun its header."); + } + } + + private int ReadRepeat(uint symbol, int count, ReadOnlySpan lens, out byte value) + { + value = 0; + if (symbol == 17) + { + return 3 + (int)PopBits(3); + } + if (symbol == 18) + { + return 11 + (int)PopBits(7); + } + if (count == 0) + { + throw new InvalidDataException("A deflate block repeats a code length before defining one."); + } + value = lens[count - 1]; + return 3 + (int)PopBits(2); + } + + private void BuildCodeTables(ReadOnlySpan lens, int litlenSymbols, int offsetSymbols) + { + Span sorted = stackalloc ushort[InflateFormat.MaxLitlenSymbols]; + if (!HuffmanTableBuilder.TryBuild( + _offset.AsSpan(0, InflateFormat.OffsetTableSize), lens.Slice(litlenSymbols, offsetSymbols), InflateFormat.OffsetResults, + InflateFormat.OffsetTableBits, InflateFormat.MaxCodewordLength, sorted, shrink: false, out _)) + { + throw new InvalidDataException("A deflate block has an invalid offset code."); + } + if (!HuffmanTableBuilder.TryBuild( + _litlen.AsSpan(0, InflateFormat.LitlenTableSize), lens[..litlenSymbols], InflateFormat.LitlenResults, + InflateFormat.LitlenTableBits, InflateFormat.MaxCodewordLength, sorted, shrink: true, out int litlenBits)) + { + throw new InvalidDataException("A deflate block has an invalid literal/length code."); + } + _litlenMask = (1UL << litlenBits) - 1; + } + } +} diff --git a/src/ExcelReader.Core/Reader/Zip/Inflate/InflateStream.cs b/src/ExcelReader.Core/Reader/Zip/Inflate/InflateStream.cs new file mode 100644 index 00000000..6d4ad8c0 --- /dev/null +++ b/src/ExcelReader.Core/Reader/Zip/Inflate/InflateStream.cs @@ -0,0 +1,211 @@ +using System.Buffers; + +namespace ExcelReader.Core.Reader.Zip.Inflate +{ + /// + /// Read-only stream that inflates a raw DEFLATE stream. Owns inner and disposes it. + /// + internal sealed class InflateStream : Stream + { + private const int InputSize = 64 * 1024; + private const int History = 32 * 1024; + private const int ChunkSize = 64 * 1024; + private const int OutLimit = History + ChunkSize; + private const int CanaryStart = OutLimit + InflateDecoder.OutputSlack; + private const int CanarySize = 16; + private const byte CanaryByte = 0xA5; + + private readonly Stream _inner; + private readonly InflateDecoder _decoder = new(); + private readonly byte[] _input = ArrayPool.Shared.Rent(InputSize + InflateDecoder.InputPadding); + private readonly byte[] _window = ArrayPool.Shared.Rent(CanaryStart + CanarySize); + private int _inPos; + private int _inEnd; + private bool _inputEnded; + private int _outPos; + private int _readPos; + private bool _finished; + private bool _disposed; + + internal InflateStream(Stream inner) + { + _inner = inner; + _window.AsSpan(CanaryStart, CanarySize).Fill(CanaryByte); + } + + public override bool CanRead => !_disposed; + 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 buffer) + { + ObjectDisposedException.ThrowIf(_disposed, this); + int total = 0; + while (total < buffer.Length) + { + int copied = CopyOut(buffer[total..]); + if (copied > 0) + { + total += copied; + continue; + } + if (_finished) + { + break; + } + if (!Advance()) + { + FillInput(); + } + } + return total; + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + int total = 0; + while (total < buffer.Length) + { + int copied = CopyOut(buffer.Span[total..]); + if (copied > 0) + { + total += copied; + continue; + } + if (_finished) + { + break; + } + if (!Advance()) + { + await FillInputAsync(cancellationToken).ConfigureAwait(false); + } + } + return total; + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + + 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) + { + if (!_disposed) + { + _disposed = true; + ArrayPool.Shared.Return(_input); + ArrayPool.Shared.Return(_window); + _decoder.Dispose(); + if (disposing) + { + _inner.Dispose(); + } + } + base.Dispose(disposing); + } + + private int CopyOut(Span destination) + { + int count = Math.Min(_outPos - _readPos, destination.Length); + _window.AsSpan(_readPos, count).CopyTo(destination); + _readPos += count; + return count; + } + + // Returns false when the decoder needs more input. Only called once everything decoded so far + // has been copied out, which is what makes sliding the window safe. + private bool Advance() + { + if (_outPos >= OutLimit) + { + _window.AsSpan(_outPos - History, History).CopyTo(_window); + _outPos = History; + _readPos = History; + } + InflateStatus status = _decoder.Decode(_input, ref _inPos, _inEnd, _inputEnded, _window, ref _outPos, OutLimit); + if (_window.AsSpan(CanaryStart, CanarySize).ContainsAnyExcept(CanaryByte)) + { + throw new InvalidOperationException("The inflate decoder wrote past its output window."); + } + _finished = status == InflateStatus.Finished; + return status != InflateStatus.NeedInput; + } + + private void FillInput() + { + Compact(); + while (_inEnd < InputSize) + { + int read = _inner.Read(_input, _inEnd, InputSize - _inEnd); + if (read == 0) + { + EndInput(); + return; + } + _inEnd += read; + } + } + + private async ValueTask FillInputAsync(CancellationToken cancellationToken) + { + Compact(); + while (_inEnd < InputSize) + { + int read = await _inner.ReadAsync(_input.AsMemory(_inEnd, InputSize - _inEnd), cancellationToken).ConfigureAwait(false); + if (read == 0) + { + EndInput(); + return; + } + _inEnd += read; + } + } + + private void Compact() + { + int remaining = _inEnd - _inPos; + _input.AsSpan(_inPos, remaining).CopyTo(_input); + _inPos = 0; + _inEnd = remaining; + } + + private void EndInput() + { + _inputEnded = true; + _input.AsSpan(_inEnd, InflateDecoder.InputPadding).Clear(); + } + } +} diff --git a/tests/ExcelReader.Tests/Reader/Zip/InflateDifferentialTests.cs b/tests/ExcelReader.Tests/Reader/Zip/InflateDifferentialTests.cs new file mode 100644 index 00000000..337464bf --- /dev/null +++ b/tests/ExcelReader.Tests/Reader/Zip/InflateDifferentialTests.cs @@ -0,0 +1,87 @@ +using System.Buffers.Binary; +using System.IO.Compression; +using static ExcelReader.Tests.Reader.Zip.InflateTestSupport; + +namespace ExcelReader.Tests.Reader.Zip +{ + public class InflateDifferentialTests + { + public static TheoryData ShapesAndLevels() + { + var data = new TheoryData(); + foreach (DataShape shape in Enum.GetValues()) + { + foreach (CompressionLevel level in Enum.GetValues()) + { + data.Add(shape, level); + } + } + return data; + } + + [Theory] + [MemberData(nameof(ShapesAndLevels))] + public void DeflateStreamOutputRoundTrips(DataShape shape, CompressionLevel level) + { + int[] sizes = [1, 2, 7, 100, 1000, 32768, 65536, 70_000, 300_000, 1_500_000]; + foreach (int size in sizes) + { + byte[] raw = Generate(shape, size, seed: size); + byte[] stream = Deflate(raw, level); + Assert.NotEmpty(stream); + + Assert.True(raw.AsSpan().SequenceEqual(Inflate(stream, readSize: 64 * 1024)), $"size {size}"); + } + } + + [Theory] + [InlineData("sample.xlsx")] + [InlineData("RealExcel.xlsb")] + public void EveryDeflateEntryOfAProducerWrittenWorkbookMatchesDeflateStream(string fileName) + { + byte[] zip = File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "data", fileName)); + int compared = 0; + + foreach (byte[] stream in DeflateEntries(zip)) + { + using var reference = new MemoryStream(); + using (var deflate = new DeflateStream(new MemoryStream(stream), CompressionMode.Decompress)) + { + deflate.CopyTo(reference); + } + + Assert.True(reference.ToArray().AsSpan().SequenceEqual(Inflate(stream)), $"entry {compared} of {fileName}"); + compared++; + } + + Assert.True(compared >= 5, $"{fileName} yielded only {compared} deflate entries"); + } + + private static List DeflateEntries(byte[] zip) + { + int end = zip.AsSpan().LastIndexOf("PK\x05\x06"u8); + Assert.True(end >= 0, "no end-of-central-directory record"); + int count = BinaryPrimitives.ReadUInt16LittleEndian(zip.AsSpan(end + 10)); + int position = (int)BinaryPrimitives.ReadUInt32LittleEndian(zip.AsSpan(end + 16)); + var entries = new List(); + for (int i = 0; i < count; i++) + { + ReadOnlySpan header = zip.AsSpan(position); + int method = BinaryPrimitives.ReadUInt16LittleEndian(header[10..]); + int compressedSize = (int)BinaryPrimitives.ReadUInt32LittleEndian(header[20..]); + int nameLength = BinaryPrimitives.ReadUInt16LittleEndian(header[28..]); + int extraLength = BinaryPrimitives.ReadUInt16LittleEndian(header[30..]); + int commentLength = BinaryPrimitives.ReadUInt16LittleEndian(header[32..]); + int local = (int)BinaryPrimitives.ReadUInt32LittleEndian(header[42..]); + if (method == 8) + { + ReadOnlySpan localHeader = zip.AsSpan(local); + int data = local + 30 + BinaryPrimitives.ReadUInt16LittleEndian(localHeader[26..]) + BinaryPrimitives.ReadUInt16LittleEndian(localHeader[28..]); + entries.Add(zip.AsSpan(data, compressedSize).ToArray()); + } + position += 46 + nameLength + extraLength + commentLength; + } + return entries; + } + } +} diff --git a/tests/ExcelReader.Tests/Reader/Zip/InflateStreamTests.cs b/tests/ExcelReader.Tests/Reader/Zip/InflateStreamTests.cs new file mode 100644 index 00000000..458a0b25 --- /dev/null +++ b/tests/ExcelReader.Tests/Reader/Zip/InflateStreamTests.cs @@ -0,0 +1,245 @@ +using System.Diagnostics.CodeAnalysis; +using System.IO.Compression; +using ExcelReader.Core.Reader.Zip.Inflate; +using static ExcelReader.Tests.Reader.Zip.InflateTestSupport; + +namespace ExcelReader.Tests.Reader.Zip +{ + public class InflateStreamTests + { + [Fact] + public void StoredBlockIsCopiedThrough() + { + byte[] stream = [0x01, 0x05, 0x00, 0xFA, 0xFF, (byte)'h', (byte)'e', (byte)'l', (byte)'l', (byte)'o']; + + Assert.Equal("hello"u8.ToArray(), Inflate(stream)); + } + + [Fact] + public void EmptyFixedBlockProducesNothing() + { + Assert.Empty(Inflate([0x03, 0x00])); + } + + [Fact] + public void ZeroLengthStoredBlockIsSkipped() + { + byte[] stream = [0x00, 0x00, 0x00, 0xFF, 0xFF, 0x01, 0x02, 0x00, 0xFD, 0xFF, (byte)'o', (byte)'k']; + + Assert.Equal("ok"u8.ToArray(), Inflate(stream)); + } + + [Fact] + public void BytesAfterTheFinalBlockAreIgnored() + { + byte[] raw = Generate(DataShape.SheetXml, 5000, seed: 1); + byte[] stream = [.. Deflate(raw, CompressionLevel.Optimal), 0xDE, 0xAD, 0xBE, 0xEF]; + + Assert.Equal(raw, Inflate(stream)); + } + + [Fact] + public void ReservedBlockTypeIsRejected() + { + InvalidDataException ex = Assert.Throws(() => Inflate([0x07, 0x00, 0x00, 0x00])); + + Assert.Contains("reserved block type", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void StoredBlockWithMismatchedLengthFieldsIsRejected() + { + InvalidDataException ex = Assert.Throws( + () => Inflate([0x01, 0x05, 0x00, 0x00, 0x00, 1, 2, 3, 4, 5])); + + Assert.Contains("mismatched length", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void MatchReachingBeforeTheOutputStartIsRejected() + { + // Fixed block whose first symbol is a length-3 match at offset 1, with nothing before it. + InvalidDataException ex = Assert.Throws(() => Inflate([0x03, 0x02, 0x00, 0x00])); + + Assert.Contains("before the start", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void DynamicHeaderWhoseCodeLengthsOverrunIsRejected() + { + // Code-length code: symbols 0 and 18, one bit each. Two runs of 138 zeros exceed the 258 lengths declared. + byte[] stream = DynamicHeader(precodeLens: [0, 0, 1, 1], codeLengthBits: [(1, 1), (127, 7), (1, 1), (127, 7)]); + + InvalidDataException ex = Assert.Throws(() => Inflate(stream)); + + Assert.Contains("overrun", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void DynamicHeaderThatRepeatsBeforeAnyLengthIsRejected() + { + // Code-length code: symbols 0 and 16, one bit each. The first symbol is 16, "repeat the previous length". + byte[] stream = DynamicHeader(precodeLens: [1, 0, 0, 1], codeLengthBits: [(1, 1), (0, 2)]); + + InvalidDataException ex = Assert.Throws(() => Inflate(stream)); + + Assert.Contains("before defining one", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void DynamicHeaderWithOverSubscribedCodeLengthCodeIsRejected() + { + byte[] stream = DynamicHeader(precodeLens: [1, 1, 1, 1], codeLengthBits: []); + + InvalidDataException ex = Assert.Throws(() => Inflate(stream)); + + Assert.Contains("code-length code", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void EmptyInputIsRejected() + { + Assert.Throws(() => Inflate([])); + } + + [Theory] + [InlineData(DataShape.SheetXml, CompressionLevel.Optimal)] + [InlineData(DataShape.SheetXml, CompressionLevel.Fastest)] + [InlineData(DataShape.Random, CompressionLevel.NoCompression)] + [InlineData(DataShape.Zeros, CompressionLevel.Optimal)] + [SuppressMessage("Security", "CA5394:Do not use insecure randomness", Justification = "Seeded cut positions, not security.")] + public void StreamCutAnywhereIsRejected(DataShape shape, CompressionLevel level) + { + byte[] stream = Deflate(Generate(shape, 200_000, seed: 7), level); + var random = new Random(11); + int[] cuts = [1, 2, stream.Length / 2, stream.Length - 2, stream.Length - 1, .. Enumerable.Range(0, 40).Select(_ => random.Next(1, stream.Length))]; + + foreach (int cut in cuts) + { + Assert.Throws(() => Inflate(stream[..cut])); + } + } + + [Theory] + [InlineData(1)] + [InlineData(7)] + [InlineData(4096)] + [InlineData(1 << 20)] + public void OutputIsTheSameForEveryReadSize(int readSize) + { + byte[] raw = Generate(DataShape.MixedRuns, 300_000, seed: 3); + + Assert.Equal(raw, Inflate(Deflate(raw, CompressionLevel.Optimal), readSize)); + } + + [Theory] + [InlineData(1)] + [InlineData(3)] + [InlineData(997)] + public void InputMayArriveInSmallPieces(int sourceStep) + { + byte[] raw = Generate(DataShape.SheetXml, 150_000, seed: 5); + + Assert.Equal(raw, Inflate(Deflate(raw, CompressionLevel.Optimal), readSize: 8192, sourceStep)); + } + + [Theory] + [InlineData(DataShape.SheetXml, 1)] + [InlineData(DataShape.RepeatedBlock, 997)] + [InlineData(DataShape.Random, int.MaxValue)] + public async Task ReadAsyncProducesTheSameBytesAsRead(DataShape shape, int sourceStep) + { + byte[] stream = Deflate(Generate(shape, 250_000, seed: 9), CompressionLevel.Optimal); + + byte[] sync = Inflate(stream, readSize: 5000, sourceStep); + byte[] async = await InflateAsync(stream, readSize: 5000, sourceStep, TestContext.Current.CancellationToken); + + Assert.Equal(sync, async); + } + + [Fact] + public void StreamsDecodingInParallelDoNotInterfere() + { + byte[] raw = Generate(DataShape.SheetXml, 400_000, seed: 13); + byte[] stream = Deflate(raw, CompressionLevel.Optimal); + + Parallel.For(0, 32, _ => Assert.True(raw.AsSpan().SequenceEqual(Inflate(stream, readSize: 3000)))); + } + + [Fact] + public void DisposeDisposesTheInnerStream() + { + var inner = new SteppedStream([0x03, 0x00], int.MaxValue); + + new InflateStream(inner).Dispose(); + + Assert.True(inner.Disposed); + } + + [Fact] + public void ReadAfterDisposeThrows() + { + var inflate = new InflateStream(new MemoryStream([0x03, 0x00])); + inflate.Dispose(); + + Assert.Throws(() => inflate.Read(new byte[1])); + } + + [Fact] + public void DisposeTwiceIsHarmless() + { + var inflate = new InflateStream(new MemoryStream([0x03, 0x00])); + + inflate.Dispose(); + inflate.Dispose(); + + Assert.False(inflate.CanRead); + } + + // A final dynamic block declaring 257 literal/length codes and 1 offset code, with the given + // lengths for the first four code-length-code symbols (16, 17, 18, 0) and raw bits after them. + private static byte[] DynamicHeader(int[] precodeLens, (int Value, int Count)[] codeLengthBits) + { + var bits = new BitWriter(); + bits.Write(1, 1); + bits.Write(2, 2); + bits.Write(0, 5); + bits.Write(0, 5); + bits.Write(precodeLens.Length - 4, 4); + foreach (int len in precodeLens) + { + bits.Write(len, 3); + } + foreach ((int value, int count) in codeLengthBits) + { + bits.Write(value, count); + } + return bits.ToArray(); + } + + private sealed class BitWriter + { + private readonly List _bytes = []; + private int _used = 8; + + internal void Write(int value, int count) + { + for (int i = 0; i < count; i++) + { + if (_used == 8) + { + _bytes.Add(0); + _used = 0; + } + _bytes[^1] |= (byte)(((value >> i) & 1) << _used); + _used++; + } + } + + internal byte[] ToArray() + { + return [.. _bytes]; + } + } + } +} diff --git a/tests/ExcelReader.Tests/Reader/Zip/InflateTestSupport.cs b/tests/ExcelReader.Tests/Reader/Zip/InflateTestSupport.cs new file mode 100644 index 00000000..fd12e36a --- /dev/null +++ b/tests/ExcelReader.Tests/Reader/Zip/InflateTestSupport.cs @@ -0,0 +1,202 @@ +using System.Diagnostics.CodeAnalysis; +using System.IO.Compression; +using System.Text; +using ExcelReader.Core.Reader.Zip.Inflate; + +namespace ExcelReader.Tests.Reader.Zip +{ + [SuppressMessage("Security", "CA5394:Do not use insecure randomness", Justification = "Seeded test data, not security.")] + internal static class InflateTestSupport + { + internal static byte[] Deflate(byte[] raw, CompressionLevel level) + { + using var compressed = new MemoryStream(); + using (var deflate = new DeflateStream(compressed, level, leaveOpen: true)) + { + deflate.Write(raw); + } + return compressed.ToArray(); + } + + internal static byte[] Inflate(byte[] compressed, int readSize = 4096, int sourceStep = int.MaxValue) + { + using var inflate = new InflateStream(new SteppedStream(compressed, sourceStep)); + using var output = new MemoryStream(); + byte[] buffer = new byte[readSize]; + int read; + while ((read = inflate.Read(buffer)) > 0) + { + output.Write(buffer, 0, read); + } + return output.ToArray(); + } + + internal static async Task InflateAsync(byte[] compressed, int readSize, int sourceStep, CancellationToken ct) + { + using var inflate = new InflateStream(new SteppedStream(compressed, sourceStep)); + using var output = new MemoryStream(); + byte[] buffer = new byte[readSize]; + int read; + while ((read = await inflate.ReadAsync(buffer, ct)) > 0) + { + output.Write(buffer, 0, read); + } + return output.ToArray(); + } + + internal static byte[] Generate(DataShape shape, int size, int seed) + { + var random = new Random(seed); + byte[] data = new byte[size]; + switch (shape) + { + case DataShape.Zeros: + break; + case DataShape.Random: + random.NextBytes(data); + break; + case DataShape.SheetXml: + FillSheetXml(data, random); + break; + case DataShape.ShortPeriods: + FillShortPeriods(data, random); + break; + case DataShape.RepeatedBlock: + FillRepeatedBlock(data, random); + break; + default: + FillMixedRuns(data, random); + break; + } + return data; + } + + private static void FillSheetXml(byte[] data, Random random) + { + var xml = new StringBuilder(); + for (int row = 1; xml.Length < data.Length; row++) + { + xml.Append(System.Globalization.CultureInfo.InvariantCulture, + $"{random.NextDouble() * 1000:F4}{random.Next(500)}"); + } + Encoding.ASCII.GetBytes(xml.ToString(0, data.Length), data); + } + + // Runs whose period is 1 to 9 bytes, so matches land on every short offset. + private static void FillShortPeriods(byte[] data, Random random) + { + int position = 0; + while (position < data.Length) + { + int period = random.Next(1, 10); + int run = Math.Min(data.Length - position, random.Next(period, 600)); + for (int i = 0; i < run; i++) + { + data[position + i] = i < period ? (byte)random.Next(256) : data[position + i - period]; + } + position += run; + } + } + + // One incompressible 32,768-byte block repeated, so matches sit at the far edge of the window. + private static void FillRepeatedBlock(byte[] data, Random random) + { + const int Block = 32768; + random.NextBytes(data.AsSpan(0, Math.Min(Block, data.Length))); + for (int i = Block; i < data.Length; i++) + { + data[i] = data[i - Block]; + } + } + + private static void FillMixedRuns(byte[] data, Random random) + { + int position = 0; + while (position < data.Length) + { + int run = Math.Min(data.Length - position, random.Next(1, 400)); + if (random.Next(3) == 0) + { + random.NextBytes(data.AsSpan(position, run)); + } + else + { + data.AsSpan(position, run).Fill((byte)random.Next(256)); + } + position += run; + } + } + } + + public enum DataShape + { + Zeros, + Random, + SheetXml, + ShortPeriods, + RepeatedBlock, + MixedRuns, + } + + internal sealed class SteppedStream(byte[] data, int step) : Stream + { + private int _position; + + internal bool Disposed { get; private set; } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => data.Length; + + public override long Position + { + get => _position; + 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 buffer) + { + int count = Math.Min(Math.Min(step, buffer.Length), data.Length - _position); + data.AsSpan(_position, count).CopyTo(buffer); + _position += count; + return count; + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + await Task.Yield(); + return Read(buffer.Span); + } + + 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; + base.Dispose(disposing); + } + } +} From 7f02562543f666d2f5900cf2d6337d91154b1aa6 Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Thu, 8 Oct 2026 13:49:08 -0300 Subject: [PATCH 023/115] perf: inflate ZIP entries with the managed decoder Replaces DeflateStream at all four sites in ZipIndex. A deflate entry that ends before its final block now throws InvalidDataException; DeflateStream returned the bytes decoded so far and reported end of stream. Co-Authored-By: Claude Opus 5.5 --- src/ExcelReader.Core/Reader/Zip/ZipIndex.cs | 10 +++--- .../Reader/Zip/ZipIndexTests.cs | 33 +++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/ExcelReader.Core/Reader/Zip/ZipIndex.cs b/src/ExcelReader.Core/Reader/Zip/ZipIndex.cs index cb6b225d..86332b39 100644 --- a/src/ExcelReader.Core/Reader/Zip/ZipIndex.cs +++ b/src/ExcelReader.Core/Reader/Zip/ZipIndex.cs @@ -1,9 +1,9 @@ using System.Buffers; using System.Buffers.Binary; -using System.IO.Compression; using System.Runtime.InteropServices; using ExcelReader.Core.Reader.Internal; using ExcelReader.Core.Reader.Sources; +using ExcelReader.Core.Reader.Zip.Inflate; namespace ExcelReader.Core.Reader.Zip { @@ -341,7 +341,7 @@ internal async ValueTask OpenPartAsync( try { ByteSourceStream raw = new(_source, dataOffset, entry.CompressedSize); - Stream content = entry.Method == 0 ? raw : new DeflateStream(raw, CompressionMode.Decompress); + Stream content = entry.Method == 0 ? raw : new InflateStream(raw); await using (content.ConfigureAwait(false)) { try @@ -401,7 +401,7 @@ private LimitedReadStream WrapEntryStream( 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); + Stream opened = entry.Method == 0 ? raw : new InflateStream(raw); return WorkbookLookups.Wrap(opened, counter, options, entryLimitName, entryLimit, entry.UncompressedSize); } @@ -451,7 +451,7 @@ private ZipPart ReadPart(long dataOffset, in ZipEntryRef entry) try { ByteSourceStream raw = new(_source, dataOffset, entry.CompressedSize); - using Stream content = entry.Method == 0 ? raw : new DeflateStream(raw, CompressionMode.Decompress); + using Stream content = entry.Method == 0 ? raw : new InflateStream(raw); try { content.ReadExactly(rented.AsSpan(0, size)); @@ -795,7 +795,7 @@ private static ZipPart InflateToPart(ReadOnlyMemory compressed, long uncom try { using MemoryStream source = ToReadableMemoryStream(compressed); - using var inflate = new DeflateStream(source, CompressionMode.Decompress); + using var inflate = new InflateStream(source); try { inflate.ReadExactly(rented.AsSpan(0, size)); diff --git a/tests/ExcelReader.Tests/Reader/Zip/ZipIndexTests.cs b/tests/ExcelReader.Tests/Reader/Zip/ZipIndexTests.cs index 8a765067..995258a2 100644 --- a/tests/ExcelReader.Tests/Reader/Zip/ZipIndexTests.cs +++ b/tests/ExcelReader.Tests/Reader/Zip/ZipIndexTests.cs @@ -859,6 +859,39 @@ public void ZipArchiveEntryOpenSilentlyTruncatesAtDeclaredLengthUnderOverDeliver Assert.Equal(-1, stream.ReadByte()); } + [Fact] + public void DeflatedEntryCutShortIsRejectedWhenOpenedAsAPart() + { + byte[] zipBytes = BuildZipWithCutDeflatedEntry("data.xml"); + + using ZipIndex index = ZipIndex.Create(zipBytes, ExcelReaderOptions.Default); + Assert.True(index.TryGetEntry("data.xml"u8, out ZipEntryRef entry)); + Assert.Equal((ushort)8, entry.Method); + Assert.Throws(() => index.OpenPart(entry, new DecompressedByteCounter(0))); + } + + [Fact] + public void DeflatedEntryCutShortIsRejectedWhenStreamedInsteadOfEndingEarly() + { + byte[] zipBytes = BuildZipWithCutDeflatedEntry("data.xml"); + + using ZipIndex index = ZipIndex.Create(zipBytes, ExcelReaderOptions.Default); + Assert.True(index.TryGetEntry("data.xml"u8, out ZipEntryRef entry)); + using LimitedReadStream stream = index.OpenEntryStream(entry, new DecompressedByteCounter(0), ExcelReaderOptions.Default); + Assert.Throws(() => stream.CopyTo(Stream.Null)); + } + + private static byte[] BuildZipWithCutDeflatedEntry(string entryName) + { + byte[] payload = Encoding.UTF8.GetBytes(string.Concat(Enumerable.Repeat("12345", 4000))); + byte[] zipBytes = BuildZipWithOneEntry(entryName, payload, CompressionLevel.Optimal); + int cdOffset = FindCentralDirectoryOffset(zipBytes, entryName); + uint compressedSize = BinaryPrimitives.ReadUInt32LittleEndian(zipBytes.AsSpan(cdOffset + 20, 4)); + Assert.True(compressedSize > 8); + PatchCentralDirectoryUInt32(zipBytes, entryName, fieldOffset: 20, compressedSize / 2); + return zipBytes; + } + private static uint ReadDeclaredUncompressedSize(byte[] zipBytes, string entryName) { int cdOffset = FindCentralDirectoryOffset(zipBytes, entryName); From 58bb3caaa65f6e8a50f246891bfc3f037371e8f2 Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Thu, 8 Oct 2026 13:52:57 -0300 Subject: [PATCH 024/115] test: fuzz the managed inflate decoder against DeflateStream Co-Authored-By: Claude Opus 5.5 --- .github/workflows/fuzz.yml | 3 +- tests/ExcelReader.Fuzz/Harnesses.cs | 55 +++++++++++++++++++ tests/ExcelReader.Fuzz/Program.cs | 1 + tests/ExcelReader.Fuzz/README.md | 7 ++- tests/ExcelReader.Fuzz/SeedCorpus.cs | 25 +++++++++ tests/ExcelReader.Fuzz/SmokeRunner.cs | 34 ++++++++++++ .../dictionaries/inflate.dict | 15 +++++ 7 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 tests/ExcelReader.Fuzz/dictionaries/inflate.dict diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index ab0a4f81..50a4998b 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -53,7 +53,7 @@ jobs: strategy: fail-fast: false matrix: - target: [xlsx, xlsx-memory, xlsx-differential, xlsb, xlsb-memory, xlsb-differential, xls, encrypted, csv, csv-sniff, csv-parallel] + target: [xlsx, xlsx-memory, xlsx-differential, xlsb, xlsb-memory, xlsb-differential, xls, encrypted, csv, csv-sniff, csv-parallel, inflate] steps: - name: Checkout uses: actions/checkout@v7 @@ -110,6 +110,7 @@ jobs: xls) dict=xls ;; encrypted) dict=encrypted ;; csv*) dict=csv ;; + inflate) dict=inflate ;; esac ./libfuzzer-dotnet \ --target_path=fuzz-out/ExcelReader.Fuzz \ diff --git a/tests/ExcelReader.Fuzz/Harnesses.cs b/tests/ExcelReader.Fuzz/Harnesses.cs index 9b98fa7a..d05ae096 100644 --- a/tests/ExcelReader.Fuzz/Harnesses.cs +++ b/tests/ExcelReader.Fuzz/Harnesses.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.IO.Compression; using System.Text; using ExcelReader.Core.Parser; using ExcelReader.Core.Parser.ParallelCsv; @@ -7,6 +8,7 @@ using ExcelReader.Core.Reader.Xls; using ExcelReader.Core.Reader.Xlsb; using ExcelReader.Core.Reader.Xlsx; +using ExcelReader.Core.Reader.Zip.Inflate; namespace ExcelReader.Fuzz { @@ -119,6 +121,59 @@ internal static void Encrypted(ReadOnlySpan data) }); } + private const int MaxInflateOutput = 4 * 1024 * 1024; + + internal static void Inflate(ReadOnlySpan data) + { + byte[] bytes = data.ToArray(); + FuzzOracle.Guard(() => + { + byte[] actual = InflateWith(static source => new InflateStream(source), bytes); + byte[] reference; + try + { + reference = InflateWith(static source => new DeflateStream(source, CompressionMode.Decompress), bytes); + } + catch (InvalidDataException) + { + return; + } + if (!actual.AsSpan().SequenceEqual(reference)) + { + throw new InvalidOperationException( + $"Oracle divergence: InflateStream produced {actual.Length} bytes that differ from DeflateStream's {reference.Length}."); + } + }); + } + + internal static int InflateSeedForSelfCheck(byte[] bytes) + { + byte[] actual = InflateWith(static source => new InflateStream(source), bytes); + byte[] reference = InflateWith(static source => new DeflateStream(source, CompressionMode.Decompress), bytes); + if (!actual.AsSpan().SequenceEqual(reference)) + { + throw new InvalidOperationException("InflateStream and DeflateStream disagree on an unmutated seed."); + } + return actual.Length; + } + + private static byte[] InflateWith(Func open, byte[] bytes) + { + using Stream stream = open(new MemoryStream(bytes, writable: false)); + using var output = new MemoryStream(); + byte[] buffer = new byte[64 * 1024]; + while (output.Length < MaxInflateOutput) + { + int read = stream.Read(buffer, 0, (int)Math.Min(buffer.Length, MaxInflateOutput - output.Length)); + if (read == 0) + { + break; + } + output.Write(buffer, 0, read); + } + return output.ToArray(); + } + internal static int OpenEncryptedSeedForSelfCheck(ReadOnlySpan data) { byte[] bytes = data.ToArray(); diff --git a/tests/ExcelReader.Fuzz/Program.cs b/tests/ExcelReader.Fuzz/Program.cs index c6eb632c..f4613d29 100644 --- a/tests/ExcelReader.Fuzz/Program.cs +++ b/tests/ExcelReader.Fuzz/Program.cs @@ -28,6 +28,7 @@ internal static class Program ["csv"] = Harnesses.Csv, ["csv-sniff"] = Harnesses.CsvSniff, ["csv-parallel"] = Harnesses.CsvParallel, + ["inflate"] = Harnesses.Inflate, }; private static async Task Main(string[] args) diff --git a/tests/ExcelReader.Fuzz/README.md b/tests/ExcelReader.Fuzz/README.md index c5946655..d3953d23 100644 --- a/tests/ExcelReader.Fuzz/README.md +++ b/tests/ExcelReader.Fuzz/README.md @@ -25,7 +25,7 @@ result cannot silently mean "the oracle accepts everything". ## Targets -`xlsx`, `xlsx-memory`, `xlsb`, `xlsb-memory`, `xls`, `encrypted`, `csv`, `csv-sniff`. +`xlsx`, `xlsx-memory`, `xlsb`, `xlsb-memory`, `xls`, `encrypted`, `csv`, `csv-sniff`, `inflate`. The `-memory` variants exist because the in-memory ZIP path (`ZipMemoryIndex`) is a different container parser from the `Stream`/`ZipArchive` one, and `csv-sniff` because dialect detection runs @@ -34,6 +34,11 @@ directory + `EncryptionInfo` descriptor) layered under the ZIP one, and runs bef check, so it gets its own target with a fixed, correct password (`hunter2`) so mutations explore the parsers instead of dead-ending on a verifier mismatch. +`inflate` feeds raw bytes straight to the managed DEFLATE decoder, without a ZIP around them, and +compares its output with `DeflateStream`'s whenever both accept the input. It exists because the +other targets only notice a decoder that crashes or hangs: one that decodes the wrong bytes +without crashing would pass every one of them. + ## Running locally No native tooling — runs anywhere, and is what gates pull requests: diff --git a/tests/ExcelReader.Fuzz/SeedCorpus.cs b/tests/ExcelReader.Fuzz/SeedCorpus.cs index 59a9af75..7b5b4b65 100644 --- a/tests/ExcelReader.Fuzz/SeedCorpus.cs +++ b/tests/ExcelReader.Fuzz/SeedCorpus.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.IO.Compression; using System.Text; using ExcelReader.Core.Reader; using ExcelReader.Core.Writer; @@ -46,6 +47,7 @@ private static readonly (string Target, string[] Seeds)[] _layout = "csv-invalid-utf8", "csv-utf16le", "csv-utf16be", "csv-unterminated-quote"]), ("csv-parallel", ["csv", "csv-ragged", "csv-parallel-0", "csv-parallel-1", "csv-parallel-2", "csv-parallel-3", "csv-parallel-4", "csv-parallel-5", "csv-parallel-6"]), + ("inflate", ["inflate-stored", "inflate-fixed", "inflate-dynamic"]), ]; internal static async Task GenerateAsync(string rootDirectory) @@ -93,9 +95,32 @@ private static async Task> BuildAllAsync() { seeds[name] = bytes; } + foreach ((string name, byte[] bytes) in InflateSeeds()) + { + seeds[name] = bytes; + } return seeds; } + private static IEnumerable<(string Name, byte[] Bytes)> InflateSeeds() + { + byte[] sheet = Utf8(string.Concat(Enumerable.Range(1, 40).Select(static row => string.Create( + CultureInfo.InvariantCulture, $"{row % 7}{row * 1.5}")))); + yield return ("inflate-stored", Deflate(sheet, CompressionLevel.NoCompression)); + yield return ("inflate-fixed", Deflate(Utf8("fixed block"), CompressionLevel.Optimal)); + yield return ("inflate-dynamic", Deflate(sheet, CompressionLevel.Optimal)); + } + + private static byte[] Deflate(byte[] raw, CompressionLevel level) + { + using var compressed = new MemoryStream(); + using (var deflate = new DeflateStream(compressed, level, leaveOpen: true)) + { + deflate.Write(raw); + } + return compressed.ToArray(); + } + private static byte[] Encrypt(byte[] package) { using var source = new MemoryStream(package, writable: false); diff --git a/tests/ExcelReader.Fuzz/SmokeRunner.cs b/tests/ExcelReader.Fuzz/SmokeRunner.cs index 975ce40e..12558c82 100644 --- a/tests/ExcelReader.Fuzz/SmokeRunner.cs +++ b/tests/ExcelReader.Fuzz/SmokeRunner.cs @@ -19,6 +19,7 @@ internal static int Run(string corpusDirectory, int mutationsPerInput, int seed) Harnesses.AssertSameRowsSelfCheck(); VerifyEncryptedSeedReachesRealCode(corpusDirectory); VerifyDifferentialSeedsReachBothReaders(corpusDirectory); + VerifyInflateSeedsCoverEveryBlockType(corpusDirectory); string[] files = Directory.Exists(corpusDirectory) ? [.. Directory.GetFiles(corpusDirectory, "*", SearchOption.AllDirectories).Order(StringComparer.Ordinal)] @@ -88,6 +89,39 @@ private static void VerifyDifferentialSeedsReachBothReaders(string corpusDirecto } } + private static void VerifyInflateSeedsCoverEveryBlockType(string corpusDirectory) + { + if (!Directory.Exists(corpusDirectory)) + { + return; + } + + (string Name, int BlockType)[] expected = [("seed-inflate-stored.bin", 0), ("seed-inflate-fixed.bin", 1), ("seed-inflate-dynamic.bin", 2)]; + foreach ((string name, int blockType) in expected) + { + foreach (string seedPath in Directory.GetFiles(corpusDirectory, name, SearchOption.AllDirectories)) + { + VerifyOneInflateSeed(seedPath, blockType); + } + } + } + + private static void VerifyOneInflateSeed(string seedPath, int blockType) + { + string name = Path.GetFileName(seedPath); + byte[] seed = File.ReadAllBytes(seedPath); + if (seed.Length == 0 || ((seed[0] >> 1) & 3) != blockType) + { + throw new InvalidOperationException( + $"{name} does not start with a type-{blockType} deflate block, so it no longer seeds that path."); + } + if (Harnesses.InflateSeedForSelfCheck(seed) == 0) + { + throw new InvalidOperationException( + $"the unmutated {name} decoded to nothing, so the inflate target compares nothing."); + } + } + private static void VerifyOneDifferentialSeed(string seedPath, bool xlsb) { string name = Path.GetFileName(seedPath); diff --git a/tests/ExcelReader.Fuzz/dictionaries/inflate.dict b/tests/ExcelReader.Fuzz/dictionaries/inflate.dict new file mode 100644 index 00000000..e9af807d --- /dev/null +++ b/tests/ExcelReader.Fuzz/dictionaries/inflate.dict @@ -0,0 +1,15 @@ +# libFuzzer token dictionary for the inflate target (a raw DEFLATE stream). +# +# DEFLATE is bit-packed, so most of its structure cannot be expressed as byte tokens. These are the +# byte-aligned pieces: stored-block length fields and the shortest complete blocks. + +# Empty stored block: not final, then final +"\x00\x00\x00\xff\xff" +"\x01\x00\x00\xff\xff" + +# Stored-block length fields for one byte and for the maximum +"\x01\x00\xfe\xff" +"\xff\xff\x00\x00" + +# Final fixed block holding only end-of-block +"\x03\x00" From 0929fc6b1856761cc9fc70d78795158b3206cc1d Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Thu, 8 Oct 2026 13:58:01 -0300 Subject: [PATCH 025/115] perf: benchmark the managed inflate decoder against DeflateStream Co-Authored-By: Claude Opus 5.5 --- src/ExcelReader.Core/ExcelReader.Core.csproj | 2 + .../Read/InflateBenchmark.cs | 137 ++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 tests/ExcelReader.Benchmarks/Read/InflateBenchmark.cs diff --git a/src/ExcelReader.Core/ExcelReader.Core.csproj b/src/ExcelReader.Core/ExcelReader.Core.csproj index 61872636..f8a3cc5b 100644 --- a/src/ExcelReader.Core/ExcelReader.Core.csproj +++ b/src/ExcelReader.Core/ExcelReader.Core.csproj @@ -48,6 +48,8 @@ + + From 30edcd0e0681bb4aeed145eb0b077a035e7d7590 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Sat, 10 Oct 2026 02:09:40 -0300 Subject: [PATCH 063/115] chore: show the project icon in the Python, Rust and C++ READMEs and on docs.rs --- cpp/README.md | 2 ++ python/README.md | 2 ++ rust/excelreader/README.md | 2 ++ rust/excelreader/src/lib.rs | 4 ++++ 4 files changed, 10 insertions(+) diff --git a/cpp/README.md b/cpp/README.md index 78a00821..d756c881 100644 --- a/cpp/README.md +++ b/cpp/README.md @@ -1,3 +1,5 @@ +![ExcelReader logo](https://raw.githubusercontent.com/GabrielMarquezMatte/ExcelReader/master/assets/icon.png) + # excelreader (C++) Header-only C++23 wrapper around ExcelReader's native C ABI: opening a workbook (from a path or diff --git a/python/README.md b/python/README.md index aff81b8f..d04edc65 100644 --- a/python/README.md +++ b/python/README.md @@ -1,3 +1,5 @@ +![ExcelReader logo](https://raw.githubusercontent.com/GabrielMarquezMatte/ExcelReader/master/assets/icon.png) + # excelreader (Python) Read and write XLSX, XLSB, XLS and CSV through ExcelReader's NativeAOT library. No .NET runtime diff --git a/rust/excelreader/README.md b/rust/excelreader/README.md index d00f2f6a..61c5412a 100644 --- a/rust/excelreader/README.md +++ b/rust/excelreader/README.md @@ -1,3 +1,5 @@ +![ExcelReader logo](https://raw.githubusercontent.com/GabrielMarquezMatte/ExcelReader/master/assets/icon.png) + # excelreader (Rust) Read and write Excel/CSV workbooks via ExcelReader's native ABI: opening a workbook (from a path or diff --git a/rust/excelreader/src/lib.rs b/rust/excelreader/src/lib.rs index 53550150..1f2b200a 100644 --- a/rust/excelreader/src/lib.rs +++ b/rust/excelreader/src/lib.rs @@ -1,6 +1,10 @@ //! Raw FFI bindings to ExcelReader's C ABI (`excelreader.h`). The `extern "C"` block below is //! declared directly in this crate root (not a submodule), so `crate::xl_open_file` etc. resolve //! from anywhere in the crate. See the `workbook` module for the safe wrapper built on top of this. +#![doc( + html_logo_url = "https://raw.githubusercontent.com/GabrielMarquezMatte/ExcelReader/master/assets/icon.png", + html_favicon_url = "https://raw.githubusercontent.com/GabrielMarquezMatte/ExcelReader/master/assets/icon.png" +)] pub mod aggregate; mod error; From 7e0a8dbf202f0e12a30086af3056dd23a82f213e Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Sat, 10 Oct 2026 21:38:52 -0300 Subject: [PATCH 064/115] refactor!: rename the parallel record, accumulator and aggregation contracts off their CSV names Co-Authored-By: Claude Sonnet 5.5 --- .../Parser/ParallelCsv/MappedAggregation.cs | 8 +- .../Parser/ParallelCsv/MappedCallback.cs | 6 +- .../Parser/ParallelCsv/MappedProjection.cs | 4 +- .../{CsvModelSink.cs => ModelSink.cs} | 2 +- .../ParallelCsv/ParallelCsvProcessor.cs | 38 ++++---- .../Parser/ParallelCsv/RecordAggregation.cs | 7 +- .../Parser/ParallelCsv/RecordCallback.cs | 6 +- ...cumulateFactory.cs => RowActionFactory.cs} | 2 +- .../PublicAPI/PublicAPI.Shipped.txt | 36 ++++---- .../Reader/Csv/CsvParallel.cs | 88 +++++++++---------- .../ICsvAccumulator.cs => IRowAccumulator.cs} | 10 ++- .../{Csv/ICsvRecord.cs => IRowRecord.cs} | 8 +- .../{Csv/CsvRowAction.cs => RowAction.cs} | 6 +- .../CsvAggregation.cs => RowAggregation.cs} | 12 +-- src/ExcelReader.Native/Csv/CsvAggregateApi.cs | 4 +- .../Typed/TypedApi.Parallel.cs | 2 +- .../Parse/CsvParallelParseBenchmark.cs | 5 +- .../ParallelCsv/AggregateCsvModelTests.cs | 4 +- .../ParallelCsv/AggregateCsvParallelTests.cs | 24 ++--- .../ParallelCsv/CsvReaderChunkSourceTests.cs | 2 +- .../ParallelCsv/ForEachCsvParallelTests.cs | 6 +- 21 files changed, 145 insertions(+), 135 deletions(-) rename src/ExcelReader.Core/Parser/ParallelCsv/{CsvModelSink.cs => ModelSink.cs} (51%) rename src/ExcelReader.Core/Parser/ParallelCsv/{CsvAccumulateFactory.cs => RowActionFactory.cs} (53%) rename src/ExcelReader.Core/Reader/{Csv/ICsvAccumulator.cs => IRowAccumulator.cs} (87%) rename src/ExcelReader.Core/Reader/{Csv/ICsvRecord.cs => IRowRecord.cs} (82%) rename src/ExcelReader.Core/Reader/{Csv/CsvRowAction.cs => RowAction.cs} (73%) rename src/ExcelReader.Core/Reader/{Csv/CsvAggregation.cs => RowAggregation.cs} (75%) diff --git a/src/ExcelReader.Core/Parser/ParallelCsv/MappedAggregation.cs b/src/ExcelReader.Core/Parser/ParallelCsv/MappedAggregation.cs index fa08f6c6..aef12a45 100644 --- a/src/ExcelReader.Core/Parser/ParallelCsv/MappedAggregation.cs +++ b/src/ExcelReader.Core/Parser/ParallelCsv/MappedAggregation.cs @@ -4,13 +4,13 @@ namespace ExcelReader.Core.Parser.ParallelCsv { internal static class MappedAggregation - where TAccumulator : ICsvAccumulator, new() + where TAccumulator : IRowAccumulator, new() where TModel : allows ref struct { - private static readonly CsvModelSink Sink = + private static readonly ModelSink Sink = static (ref TAccumulator accumulator, TModel model) => accumulator.Add(model); - internal static readonly CsvAggregation Unbound = new() + internal static readonly RowAggregation Unbound = new() { Seed = static () => new TAccumulator(), Accumulate = static (ref _, _) => throw new InvalidOperationException("The model map was not bound before the first record."), @@ -21,7 +21,7 @@ internal static class MappedAggregation }, }; - internal static CsvAccumulateFactory Binder(ExcelParser parser, int headerRow) + internal static RowActionFactory Binder(ExcelParser parser, int headerRow) { return (Row header, bool sequential) => MappedProjection.Bind(parser, header, headerRow, sequential, Sink); diff --git a/src/ExcelReader.Core/Parser/ParallelCsv/MappedCallback.cs b/src/ExcelReader.Core/Parser/ParallelCsv/MappedCallback.cs index 0722a6fc..53640f08 100644 --- a/src/ExcelReader.Core/Parser/ParallelCsv/MappedCallback.cs +++ b/src/ExcelReader.Core/Parser/ParallelCsv/MappedCallback.cs @@ -6,16 +6,16 @@ namespace ExcelReader.Core.Parser.ParallelCsv internal static class MappedCallback where TModel : allows ref struct { - internal static readonly CsvAggregation Unbound = new() + internal static readonly RowAggregation Unbound = new() { Seed = static () => 0, Accumulate = static (ref _, _) => throw new InvalidOperationException("The model map was not bound before the first record."), Combine = static (left, _) => left, }; - internal static CsvAccumulateFactory Binder(ExcelParser parser, int headerRow, Action body) + internal static RowActionFactory Binder(ExcelParser parser, int headerRow, Action body) { - CsvModelSink sink = (ref byte _, TModel model) => body(model); + ModelSink sink = (ref byte _, TModel model) => body(model); return (Row header, bool sequential) => MappedProjection.Bind(parser, header, headerRow, sequential, sink); } diff --git a/src/ExcelReader.Core/Parser/ParallelCsv/MappedProjection.cs b/src/ExcelReader.Core/Parser/ParallelCsv/MappedProjection.cs index 58249b82..32144cb2 100644 --- a/src/ExcelReader.Core/Parser/ParallelCsv/MappedProjection.cs +++ b/src/ExcelReader.Core/Parser/ParallelCsv/MappedProjection.cs @@ -9,8 +9,8 @@ internal static class MappedProjection { private const int StackallocLimit = 256; - internal static CsvRowAction Bind( - ExcelParser parser, Row header, int headerRow, bool sequential, CsvModelSink sink) + internal static RowAction Bind( + ExcelParser parser, Row header, int headerRow, bool sequential, ModelSink sink) { TypeMapInfo info = parser.CsvInfo; ExcelParserConfig config = parser.Config; diff --git a/src/ExcelReader.Core/Parser/ParallelCsv/CsvModelSink.cs b/src/ExcelReader.Core/Parser/ParallelCsv/ModelSink.cs similarity index 51% rename from src/ExcelReader.Core/Parser/ParallelCsv/CsvModelSink.cs rename to src/ExcelReader.Core/Parser/ParallelCsv/ModelSink.cs index daab7434..22bbc4dd 100644 --- a/src/ExcelReader.Core/Parser/ParallelCsv/CsvModelSink.cs +++ b/src/ExcelReader.Core/Parser/ParallelCsv/ModelSink.cs @@ -1,5 +1,5 @@ namespace ExcelReader.Core.Parser.ParallelCsv { - internal delegate void CsvModelSink(ref TState state, TModel model) + internal delegate void ModelSink(ref TState state, TModel model) where TModel : allows ref struct; } diff --git a/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvProcessor.cs b/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvProcessor.cs index b1822f87..f6f64e92 100644 --- a/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvProcessor.cs +++ b/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvProcessor.cs @@ -22,7 +22,7 @@ private sealed class ChunkOutcome(long actualStart, TState state) } internal static async Task RunAsync( - string path, CsvAggregation aggregation, CsvAccumulateFactory? factory, CsvParallelOptions options, CancellationToken ct) + string path, RowAggregation aggregation, RowActionFactory? factory, CsvParallelOptions options, CancellationToken ct) { options = WithResolvedDialect(options, CsvDialectResolver.Resolve(path, options.Reader)); int dop = ParallelCsvFactory.Normalize(options.DegreeOfParallelism); @@ -37,7 +37,7 @@ internal static async Task RunAsync( } internal static Task RunAsync( - ReadOnlyMemory data, CsvAggregation aggregation, CsvAccumulateFactory? factory, CsvParallelOptions options, CancellationToken ct) + ReadOnlyMemory data, RowAggregation aggregation, RowActionFactory? factory, CsvParallelOptions options, CancellationToken ct) { options = WithResolvedDialect(options, CsvDialectResolver.Resolve(data, options.Reader)); int dop = ParallelCsvFactory.Normalize(options.DegreeOfParallelism); @@ -49,7 +49,7 @@ internal static Task RunAsync( } internal static async Task RunAsync( - Stream stream, CsvAggregation aggregation, CsvAccumulateFactory? factory, CsvParallelOptions options, CancellationToken ct) + Stream stream, RowAggregation aggregation, RowActionFactory? factory, CsvParallelOptions options, CancellationToken ct) { options = WithResolvedDialect(options, await CsvDialectResolver.ResolveAsync(stream, options.Reader, ct).ConfigureAwait(false)); int dop = ParallelCsvFactory.Normalize(options.DegreeOfParallelism); @@ -63,7 +63,7 @@ internal static async Task RunAsync( } internal static Task RunWithChunkSizeAsync( - ReadOnlyMemory data, CsvAggregation aggregation, CsvAccumulateFactory? factory, CsvParallelOptions options, int chunkSize, CancellationToken ct) + ReadOnlyMemory data, RowAggregation aggregation, RowActionFactory? factory, CsvParallelOptions options, int chunkSize, CancellationToken ct) { options = WithResolvedDialect(options, CsvDialectResolver.Resolve(data, options.Reader)); int dop = ParallelCsvFactory.Normalize(options.DegreeOfParallelism); @@ -71,7 +71,7 @@ internal static Task RunWithChunkSizeAsync( } internal static Task RunPartitionedAsync( - CsvChunkSource source, CsvAggregation aggregation, CsvParallelOptions options, int chunkSizeOverride, CancellationToken ct) + CsvChunkSource source, RowAggregation aggregation, CsvParallelOptions options, int chunkSizeOverride, CancellationToken ct) { int dop = ParallelCsvFactory.Normalize(options.DegreeOfParallelism); return PartitionedAsync(source, aggregation, null, options, dop, chunkSizeOverride, ct); @@ -91,13 +91,13 @@ private static CsvParallelOptions WithResolvedDialect(CsvParallelOptions options }; } - private static CsvAggregation WithAccumulate(CsvAggregation aggregation, CsvRowAction accumulate) + private static RowAggregation WithAccumulate(RowAggregation aggregation, RowAction accumulate) { - return new CsvAggregation { Seed = aggregation.Seed, Accumulate = accumulate, Combine = aggregation.Combine }; + return new RowAggregation { Seed = aggregation.Seed, Accumulate = accumulate, Combine = aggregation.Combine }; } private static async Task SequentialAsync( - CsvReader reader, CsvAggregation aggregation, CsvAccumulateFactory? factory, int headerRow, CancellationToken ct) + CsvReader reader, RowAggregation aggregation, RowActionFactory? factory, int headerRow, CancellationToken ct) { await using (reader.ConfigureAwait(false)) { @@ -135,14 +135,14 @@ private static async Task SequentialAsync( } private static async Task PartitionedAsync( - CsvChunkSource source, CsvAggregation aggregation, CsvAccumulateFactory? factory, CsvParallelOptions options, int dop, int chunkSizeOverride, + CsvChunkSource source, RowAggregation aggregation, RowActionFactory? factory, CsvParallelOptions options, int dop, int chunkSizeOverride, CancellationToken ct) { CsvReaderOptions reader = options.Reader; long dataStart = 0; if (options.HeaderRow > 0) { - (dataStart, CsvRowAction? bound) = await RecordStartAfterAsync(source, reader, options.HeaderRow, factory, ct).ConfigureAwait(false); + (dataStart, RowAction? bound) = await RecordStartAfterAsync(source, reader, options.HeaderRow, factory, ct).ConfigureAwait(false); if (bound is not null) { aggregation = WithAccumulate(aggregation, bound); @@ -189,7 +189,7 @@ private static async Task PartitionedAsync( private static async Task DrainPlanAsync( CsvChunkSource source, CsvChunkPlan plan, long dataStart, CsvReaderOptions reader, - CsvAggregation aggregation, ChunkOutcome[] outcomes, CancellationToken ct) + RowAggregation aggregation, ChunkOutcome[] outcomes, CancellationToken ct) { while (plan.TryTakeNext(out CsvChunk chunk)) { @@ -201,7 +201,7 @@ private static async Task DrainPlanAsync( private static ValueTask> ParseChunkAsync( CsvChunkSource source, CsvChunk chunk, long start, CsvReaderOptions reader, - CsvAggregation aggregation, CancellationToken ct) + RowAggregation aggregation, CancellationToken ct) { var outcome = new ChunkOutcome(start, aggregation.Seed()); if (start >= source.Length) @@ -219,7 +219,7 @@ private static ValueTask> ParseChunkAsync( private static void ParseMemoryChunk( CsvChunkSource source, CsvChunk chunk, ChunkOutcome outcome, CsvReaderOptions chunkOptions, - CsvRowAction accumulate, CancellationToken ct) + RowAction accumulate, CancellationToken ct) { var rows = new CsvReader.Enumerator(source.SliceAt(outcome.ActualStart), chunkOptions, ct); try @@ -244,7 +244,7 @@ private static void ParseMemoryChunk( private static async ValueTask> ParseFileChunkAsync( CsvChunkSource source, CsvChunk chunk, ChunkOutcome outcome, CsvReaderOptions chunkOptions, - CsvRowAction accumulate, CancellationToken ct) + RowAction accumulate, CancellationToken ct) { Stream partition = source.OpenAt(outcome.ActualStart); await using (partition.ConfigureAwait(false)) @@ -272,7 +272,7 @@ private static async ValueTask> ParseFileChunkAsync } private static bool DeliverWithinChunk( - CsvReader.Enumerator rows, CsvChunk chunk, ChunkOutcome outcome, CsvRowAction accumulate, CancellationToken ct) + CsvReader.Enumerator rows, CsvChunk chunk, ChunkOutcome outcome, RowAction accumulate, CancellationToken ct) { long absolute = outcome.ActualStart + rows.CurrentRecordStart; if (absolute >= chunk.End) @@ -288,15 +288,15 @@ private static bool DeliverWithinChunk( return true; } - private static void Deliver(CsvRowAction accumulate, ref TState state, CsvReader.Enumerator rows) + private static void Deliver(RowAction accumulate, ref TState state, CsvReader.Enumerator rows) { accumulate(ref state, rows.Current); } - private static async Task<(long Start, CsvRowAction? Accumulate)> RecordStartAfterAsync( - CsvChunkSource source, CsvReaderOptions reader, int records, CsvAccumulateFactory? factory, CancellationToken ct) + private static async Task<(long Start, RowAction? Accumulate)> RecordStartAfterAsync( + CsvChunkSource source, CsvReaderOptions reader, int records, RowActionFactory? factory, CancellationToken ct) { - CsvRowAction? bound = null; + RowAction? bound = null; CsvReader csv = source.OpenReader(reader); await using (csv.ConfigureAwait(false)) { diff --git a/src/ExcelReader.Core/Parser/ParallelCsv/RecordAggregation.cs b/src/ExcelReader.Core/Parser/ParallelCsv/RecordAggregation.cs index 3704d893..99457ac8 100644 --- a/src/ExcelReader.Core/Parser/ParallelCsv/RecordAggregation.cs +++ b/src/ExcelReader.Core/Parser/ParallelCsv/RecordAggregation.cs @@ -1,12 +1,13 @@ +using ExcelReader.Core.Reader; using ExcelReader.Core.Reader.Csv; namespace ExcelReader.Core.Parser.ParallelCsv { internal static class RecordAggregation - where TAccumulator : ICsvAccumulator, new() - where TRecord : ICsvRecord, allows ref struct + where TAccumulator : IRowAccumulator, new() + where TRecord : IRowRecord, allows ref struct { - internal static readonly CsvAggregation Instance = new() + internal static readonly RowAggregation Instance = new() { Seed = static () => new TAccumulator(), Accumulate = static (ref accumulator, row) => diff --git a/src/ExcelReader.Core/Parser/ParallelCsv/RecordCallback.cs b/src/ExcelReader.Core/Parser/ParallelCsv/RecordCallback.cs index 050c5c13..c71f1177 100644 --- a/src/ExcelReader.Core/Parser/ParallelCsv/RecordCallback.cs +++ b/src/ExcelReader.Core/Parser/ParallelCsv/RecordCallback.cs @@ -4,11 +4,11 @@ namespace ExcelReader.Core.Parser.ParallelCsv { internal static class RecordCallback - where TRecord : ICsvRecord, allows ref struct + where TRecord : IRowRecord, allows ref struct { - internal static CsvAggregation For(Action body) + internal static RowAggregation For(Action body) { - return new CsvAggregation + return new RowAggregation { Seed = static () => 0, Accumulate = (ref byte _, Row row) => diff --git a/src/ExcelReader.Core/Parser/ParallelCsv/CsvAccumulateFactory.cs b/src/ExcelReader.Core/Parser/ParallelCsv/RowActionFactory.cs similarity index 53% rename from src/ExcelReader.Core/Parser/ParallelCsv/CsvAccumulateFactory.cs rename to src/ExcelReader.Core/Parser/ParallelCsv/RowActionFactory.cs index ad7b54c3..a8a11da6 100644 --- a/src/ExcelReader.Core/Parser/ParallelCsv/CsvAccumulateFactory.cs +++ b/src/ExcelReader.Core/Parser/ParallelCsv/RowActionFactory.cs @@ -3,5 +3,5 @@ namespace ExcelReader.Core.Parser.ParallelCsv { - internal delegate CsvRowAction CsvAccumulateFactory(Row header, bool sequential); + internal delegate RowAction RowActionFactory(Row header, bool sequential); } diff --git a/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt b/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt index 514d6008..848ebba8 100644 --- a/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt +++ b/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt @@ -109,14 +109,14 @@ ExcelReader.Core.Reader.CellType.Error = 6 -> ExcelReader.Core.Reader.CellType ExcelReader.Core.Reader.CellType.ExcelString = 1 -> ExcelReader.Core.Reader.CellType ExcelReader.Core.Reader.CellType.Formula = 5 -> ExcelReader.Core.Reader.CellType ExcelReader.Core.Reader.CellType.Number = 2 -> ExcelReader.Core.Reader.CellType -ExcelReader.Core.Reader.Csv.CsvAggregation -ExcelReader.Core.Reader.Csv.CsvAggregation.Accumulate.get -> ExcelReader.Core.Reader.Csv.CsvRowAction! -ExcelReader.Core.Reader.Csv.CsvAggregation.Accumulate.init -> void -ExcelReader.Core.Reader.Csv.CsvAggregation.Combine.get -> System.Func! -ExcelReader.Core.Reader.Csv.CsvAggregation.Combine.init -> void -ExcelReader.Core.Reader.Csv.CsvAggregation.CsvAggregation() -> void -ExcelReader.Core.Reader.Csv.CsvAggregation.Seed.get -> System.Func! -ExcelReader.Core.Reader.Csv.CsvAggregation.Seed.init -> void +ExcelReader.Core.Reader.RowAggregation +ExcelReader.Core.Reader.RowAggregation.Accumulate.get -> ExcelReader.Core.Reader.RowAction! +ExcelReader.Core.Reader.RowAggregation.Accumulate.init -> void +ExcelReader.Core.Reader.RowAggregation.Combine.get -> System.Func! +ExcelReader.Core.Reader.RowAggregation.Combine.init -> void +ExcelReader.Core.Reader.RowAggregation.RowAggregation() -> void +ExcelReader.Core.Reader.RowAggregation.Seed.get -> System.Func! +ExcelReader.Core.Reader.RowAggregation.Seed.init -> void ExcelReader.Core.Reader.Csv.CsvDialect ExcelReader.Core.Reader.Csv.CsvDialect.CsvDialect() -> void ExcelReader.Core.Reader.Csv.CsvDialect.Delimiter.get -> byte @@ -168,7 +168,7 @@ ExcelReader.Core.Reader.Csv.CsvReaderOptions.Quote.init -> void ExcelReader.Core.Reader.Csv.CsvReaderOptions.SniffDialect.get -> bool ExcelReader.Core.Reader.Csv.CsvReaderOptions.SniffDialect.init -> void ExcelReader.Core.Reader.Csv.CsvReaderOptions.WithDialect(ExcelReader.Core.Reader.Csv.CsvDialect dialect) -> ExcelReader.Core.Reader.Csv.CsvReaderOptions! -ExcelReader.Core.Reader.Csv.CsvRowAction +ExcelReader.Core.Reader.RowAction ExcelReader.Core.Reader.Csv.CsvSheet ExcelReader.Core.Reader.Csv.CsvSheet.CsvSheet() -> void ExcelReader.Core.Reader.Csv.CsvSheet.Equals(ExcelReader.Core.Reader.Csv.CsvSheet other) -> bool @@ -189,11 +189,11 @@ ExcelReader.Core.Reader.Csv.CsvSnifferOptions.CsvSnifferOptions() -> void ExcelReader.Core.Reader.Csv.CsvSnifferOptions.Equals(ExcelReader.Core.Reader.Csv.CsvSnifferOptions? other) -> bool ExcelReader.Core.Reader.Csv.CsvSnifferOptions.MaxSampleLines.get -> int ExcelReader.Core.Reader.Csv.CsvSnifferOptions.MaxSampleLines.init -> void -ExcelReader.Core.Reader.Csv.ICsvAccumulator -ExcelReader.Core.Reader.Csv.ICsvAccumulator.Add(TModel model) -> void -ExcelReader.Core.Reader.Csv.ICsvAccumulator.Merge(TSelf following) -> void -ExcelReader.Core.Reader.Csv.ICsvRecord -ExcelReader.Core.Reader.Csv.ICsvRecord.TryParse(ExcelReader.Core.Reader.Row row, out TSelf record) -> bool +ExcelReader.Core.Reader.IRowAccumulator +ExcelReader.Core.Reader.IRowAccumulator.Add(TModel model) -> void +ExcelReader.Core.Reader.IRowAccumulator.Merge(TSelf following) -> void +ExcelReader.Core.Reader.IRowRecord +ExcelReader.Core.Reader.IRowRecord.TryParse(ExcelReader.Core.Reader.Row row, out TSelf record) -> bool ExcelReader.Core.Reader.Excel ExcelReader.Core.Reader.ExcelDataReader ExcelReader.Core.Reader.ExcelDataReader.Close() -> void @@ -699,9 +699,9 @@ static ExcelReader.Core.Reader.Csv.CsvParallel.AggregateAsync(System.IO.Stream! stream, ExcelReader.Core.Reader.Csv.CsvParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! static ExcelReader.Core.Reader.Csv.CsvParallel.AggregateAsync(System.ReadOnlyMemory data, ExcelReader.Core.Reader.Csv.CsvParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! static ExcelReader.Core.Reader.Csv.CsvParallel.AggregateAsync(string! path, ExcelReader.Core.Reader.Csv.CsvParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -static ExcelReader.Core.Reader.Csv.CsvParallel.AggregateAsync(System.IO.Stream! stream, ExcelReader.Core.Reader.Csv.CsvAggregation! aggregation, ExcelReader.Core.Reader.Csv.CsvParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -static ExcelReader.Core.Reader.Csv.CsvParallel.AggregateAsync(System.ReadOnlyMemory data, ExcelReader.Core.Reader.Csv.CsvAggregation! aggregation, ExcelReader.Core.Reader.Csv.CsvParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -static ExcelReader.Core.Reader.Csv.CsvParallel.AggregateAsync(string! path, ExcelReader.Core.Reader.Csv.CsvAggregation! aggregation, ExcelReader.Core.Reader.Csv.CsvParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +static ExcelReader.Core.Reader.Csv.CsvParallel.AggregateAsync(System.IO.Stream! stream, ExcelReader.Core.Reader.RowAggregation! aggregation, ExcelReader.Core.Reader.Csv.CsvParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +static ExcelReader.Core.Reader.Csv.CsvParallel.AggregateAsync(System.ReadOnlyMemory data, ExcelReader.Core.Reader.RowAggregation! aggregation, ExcelReader.Core.Reader.Csv.CsvParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +static ExcelReader.Core.Reader.Csv.CsvParallel.AggregateAsync(string! path, ExcelReader.Core.Reader.RowAggregation! aggregation, ExcelReader.Core.Reader.Csv.CsvParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! static ExcelReader.Core.Reader.Csv.CsvParallel.ForEachAsync(System.IO.Stream! stream, ExcelReader.Core.Parser.ExcelParser! parser, System.Action! body, ExcelReader.Core.Reader.Csv.CsvParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! static ExcelReader.Core.Reader.Csv.CsvParallel.ForEachAsync(System.ReadOnlyMemory data, ExcelReader.Core.Parser.ExcelParser! parser, System.Action! body, ExcelReader.Core.Reader.Csv.CsvParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! static ExcelReader.Core.Reader.Csv.CsvParallel.ForEachAsync(string! path, ExcelReader.Core.Parser.ExcelParser! parser, System.Action! body, ExcelReader.Core.Reader.Csv.CsvParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! @@ -834,7 +834,7 @@ virtual ExcelReader.Core.Parser.AsyncRowEnumerator.DisposeAsyn virtual ExcelReader.Core.Parser.ExcelCellReader.Invoke(in ExcelReader.Core.Reader.Cell cell, bool isDate1904, System.IFormatProvider! provider, out TValue value) -> bool virtual ExcelReader.Core.Parser.ExcelPropertySetter.Invoke(ref TModel model, TValue value) -> void virtual ExcelReader.Core.Parser.ExcelRowParser.Invoke(ref TModel model, in ExcelReader.Core.Reader.Cell cell, bool isDate1904, System.IFormatProvider! provider) -> bool -virtual ExcelReader.Core.Reader.Csv.CsvRowAction.Invoke(ref TState state, ExcelReader.Core.Reader.Row row) -> void +virtual ExcelReader.Core.Reader.RowAction.Invoke(ref TState state, ExcelReader.Core.Reader.Row row) -> void ~ExcelReader.Core.Parser.ExcelEnumerable.GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Parser.ExcelEnumerable.AsyncEnumerator! ~ExcelReader.Core.Parser.ExcelEnumerable.GetEnumerator() -> ExcelReader.Core.Parser.ExcelEnumerable.Enumerator! ~override ExcelReader.Core.Parser.ExcelRowContext.Equals(object obj) -> bool diff --git a/src/ExcelReader.Core/Reader/Csv/CsvParallel.cs b/src/ExcelReader.Core/Reader/Csv/CsvParallel.cs index 07b90323..52633387 100644 --- a/src/ExcelReader.Core/Reader/Csv/CsvParallel.cs +++ b/src/ExcelReader.Core/Reader/Csv/CsvParallel.cs @@ -115,8 +115,8 @@ public static IAsyncEnumerable ParseAsync( /// and folds it into a , one /// instance per partition, merged in buffer order. /// - /// The accumulator type. See for the contract it must honor. - /// The record type. A record whose returns is skipped. + /// The accumulator type. See for the contract it must honor. + /// The record type. A record whose returns is skipped. /// The CSV bytes. The caller keeps ownership; the buffer must not be mutated during processing. /// Parallelism, dialect and header options. Defaults to . /// A token to cancel processing. @@ -125,12 +125,12 @@ public static IAsyncEnumerable ParseAsync( /// /// Partitions start at guessed record boundaries; one whose guess landed inside a quoted field /// spanning lines is read again into a new accumulator. The result holds every record exactly once, - /// but and - /// may already have run for the discarded pass's records, as describes. + /// but and + /// may already have run for the discarded pass's records, as describes. /// /// /// Falls back to one sequential pass, with a single accumulator and no call to - /// , when the source is too small to partition usefully + /// , when the source is too small to partition usefully /// or when is set to a non-UTF-8 encoding. /// /// @@ -138,8 +138,8 @@ public static Task AggregateAsync( ReadOnlyMemory data, CsvParallelOptions? options = null, CancellationToken ct = default) - where TAccumulator : ICsvAccumulator, new() - where TRecord : ICsvRecord, allows ref struct + where TAccumulator : IRowAccumulator, new() + where TRecord : IRowRecord, allows ref struct { return ParallelCsvProcessor.RunAsync(data, RecordAggregation.Instance, null, Validated(options), ct); } @@ -148,8 +148,8 @@ public static Task AggregateAsync( /// Reads a CSV file across several threads, parses every record into a /// and folds it into a , one instance per partition, merged in file order. /// - /// The accumulator type. See for the contract it must honor. - /// The record type. A record whose returns is skipped. + /// The accumulator type. See for the contract it must honor. + /// The record type. A record whose returns is skipped. /// The path of the CSV file to read. /// Parallelism, dialect and header options. Defaults to . /// A token to cancel processing. @@ -159,8 +159,8 @@ public static Task AggregateAsync( string path, CsvParallelOptions? options = null, CancellationToken ct = default) - where TAccumulator : ICsvAccumulator, new() - where TRecord : ICsvRecord, allows ref struct + where TAccumulator : IRowAccumulator, new() + where TRecord : IRowRecord, allows ref struct { ArgumentException.ThrowIfNullOrEmpty(path); return ParallelCsvProcessor.RunAsync(path, RecordAggregation.Instance, null, Validated(options), ct); @@ -171,8 +171,8 @@ public static Task AggregateAsync( /// and folds it into a , one instance /// per partition, merged in stream order. /// - /// The accumulator type. See for the contract it must honor. - /// The record type. A record whose returns is skipped. + /// The accumulator type. See for the contract it must honor. + /// The record type. A record whose returns is skipped. /// The CSV stream, read from its current position. The caller keeps ownership and must not read from it concurrently. /// Parallelism, dialect and header options. Defaults to . /// A token to cancel processing. @@ -186,8 +186,8 @@ public static Task AggregateAsync( Stream stream, CsvParallelOptions? options = null, CancellationToken ct = default) - where TAccumulator : ICsvAccumulator, new() - where TRecord : ICsvRecord, allows ref struct + where TAccumulator : IRowAccumulator, new() + where TRecord : IRowRecord, allows ref struct { ArgumentNullException.ThrowIfNull(stream); return ParallelCsvProcessor.RunAsync(stream, RecordAggregation.Instance, null, Validated(options), ct); @@ -197,7 +197,7 @@ public static Task AggregateAsync( /// Reads a CSV file across several threads, parses every record into a /// and hands it to . /// - /// The record type. A record whose returns is skipped. + /// The record type. A record whose returns is skipped. /// The path of the CSV file to read. /// Receives each record. See the remarks for the contract it must honor. /// Parallelism, dialect and header options. Defaults to . @@ -241,7 +241,7 @@ public static Task ForEachAsync( Action body, CsvParallelOptions? options = null, CancellationToken ct = default) - where TRecord : ICsvRecord, allows ref struct + where TRecord : IRowRecord, allows ref struct { ArgumentException.ThrowIfNullOrEmpty(path); ArgumentNullException.ThrowIfNull(body); @@ -252,7 +252,7 @@ public static Task ForEachAsync( /// Reads an in-memory CSV buffer across several threads, parses every record into a /// and hands it to . /// - /// The record type. A record whose returns is skipped. + /// The record type. A record whose returns is skipped. /// The CSV bytes. The caller keeps ownership; the buffer must not be mutated during processing. /// Receives each record. See the remarks for the contract it must honor. /// Parallelism, dialect and header options. Defaults to . @@ -265,7 +265,7 @@ public static Task ForEachAsync( Action body, CsvParallelOptions? options = null, CancellationToken ct = default) - where TRecord : ICsvRecord, allows ref struct + where TRecord : IRowRecord, allows ref struct { ArgumentNullException.ThrowIfNull(body); return ParallelCsvProcessor.RunAsync(data, RecordCallback.For(body), null, Validated(options), ct); @@ -275,7 +275,7 @@ public static Task ForEachAsync( /// Reads a CSV stream, in parallel where the stream can be partitioned, parses every record into a /// and hands it to . /// - /// The record type. A record whose returns is skipped. + /// The record type. A record whose returns is skipped. /// The CSV stream, read from its current position. The caller keeps ownership and must not read from it concurrently. /// Receives each record. See the remarks for the contract it must honor. /// Parallelism, dialect and header options. Defaults to . @@ -292,7 +292,7 @@ public static Task ForEachAsync( Action body, CsvParallelOptions? options = null, CancellationToken ct = default) - where TRecord : ICsvRecord, allows ref struct + where TRecord : IRowRecord, allows ref struct { ArgumentNullException.ThrowIfNull(stream); ArgumentNullException.ThrowIfNull(body); @@ -315,7 +315,7 @@ public static Task ForEachAsync( /// /// /// Carries the same concurrency, repeat-invocation and span-lifetime contract as the - /// overloads. A property that fails to parse keeps its default + /// overloads. A property that fails to parse keeps its default /// unless is set; a missing /// [ExcelRequired] column or value throws ; empty /// records are skipped. @@ -379,7 +379,7 @@ public static Task ForEachAsync( /// binds columns by header name and is 0. /// /// Carries the same contract and fallbacks as the path-based overload, and the stream restrictions - /// of the stream overload. + /// of the stream overload. /// public static Task ForEachAsync( Stream stream, @@ -399,7 +399,7 @@ public static Task ForEachAsync( /// through and folds it into a /// , one instance per partition, merged in buffer order. /// - /// The accumulator type. See for the contract it must honor. + /// The accumulator type. See for the contract it must honor. /// The model type. /// The CSV bytes. The caller keeps ownership; the buffer must not be mutated during processing. /// How columns bind to ; created with . Its is ignored in favour of . @@ -417,14 +417,14 @@ public static Task ForEachAsync( /// is exact when the source is read sequentially and 0 when it is /// partitioned, since a partition does not know how many records precede it. /// - /// Otherwise carries the same contract and fallbacks as the overloads. + /// Otherwise carries the same contract and fallbacks as the overloads. /// public static Task AggregateAsync( ReadOnlyMemory data, ExcelParser parser, CsvParallelOptions? options = null, CancellationToken ct = default) - where TAccumulator : ICsvAccumulator, new() + where TAccumulator : IRowAccumulator, new() where TModel : allows ref struct { CsvParallelOptions validated = Validated(options); @@ -436,7 +436,7 @@ public static Task AggregateAsync( /// through and folds it into a , one instance /// per partition, merged in file order. /// - /// The accumulator type. See for the contract it must honor. + /// The accumulator type. See for the contract it must honor. /// The model type. /// The path of the CSV file to read. /// How columns bind to ; created with . Its is ignored in favour of . @@ -450,7 +450,7 @@ public static Task AggregateAsync( ExcelParser parser, CsvParallelOptions? options = null, CancellationToken ct = default) - where TAccumulator : ICsvAccumulator, new() + where TAccumulator : IRowAccumulator, new() where TModel : allows ref struct { ArgumentException.ThrowIfNullOrEmpty(path); @@ -463,7 +463,7 @@ public static Task AggregateAsync( /// through and folds it into a /// , one instance per partition, merged in stream order. /// - /// The accumulator type. See for the contract it must honor. + /// The accumulator type. See for the contract it must honor. /// The model type. /// The CSV stream, read from its current position. The caller keeps ownership and must not read from it concurrently. /// How columns bind to ; created with . Its is ignored in favour of . @@ -471,13 +471,13 @@ public static Task AggregateAsync( /// A token to cancel processing. /// The accumulator holding every record. /// binds columns by header name and is 0. - /// Carries the same contract and fallbacks as the in-memory overload, and the stream restrictions of the stream overload. + /// Carries the same contract and fallbacks as the in-memory overload, and the stream restrictions of the stream overload. public static Task AggregateAsync( Stream stream, ExcelParser parser, CsvParallelOptions? options = null, CancellationToken ct = default) - where TAccumulator : ICsvAccumulator, new() + where TAccumulator : IRowAccumulator, new() where TModel : allows ref struct { ArgumentNullException.ThrowIfNull(stream); @@ -485,8 +485,8 @@ public static Task AggregateAsync( return ParallelCsvProcessor.RunAsync(stream, MappedAggregation.Unbound, Bound(parser, validated), validated, ct); } - private static CsvAccumulateFactory Bound(ExcelParser parser, CsvParallelOptions options) - where TAccumulator : ICsvAccumulator, new() + private static RowActionFactory Bound(ExcelParser parser, CsvParallelOptions options) + where TAccumulator : IRowAccumulator, new() where TModel : allows ref struct { ArgumentNullException.ThrowIfNull(parser); @@ -497,7 +497,7 @@ private static CsvAccumulateFactory Bound(Ex return MappedAggregation.Binder(parser, options.HeaderRow); } - private static CsvAccumulateFactory BoundCallback( + private static RowActionFactory BoundCallback( ExcelParser parser, Action body, CsvParallelOptions options) where TModel : allows ref struct { @@ -516,14 +516,14 @@ private static CsvAccumulateFactory BoundCallback( /// /// The accumulator type. /// The CSV bytes. The caller keeps ownership; the buffer must not be mutated during processing. - /// The seed, accumulate and combine functions. They carry the same contract as . + /// The seed, accumulate and combine functions. They carry the same contract as . /// Parallelism, dialect and header options. Defaults to . /// A token to cancel processing. /// The combined accumulator. - /// Carries the same fallbacks as the overloads. + /// Carries the same fallbacks as the overloads. public static Task AggregateAsync( ReadOnlyMemory data, - CsvAggregation aggregation, + RowAggregation aggregation, CsvParallelOptions? options = null, CancellationToken ct = default) { @@ -536,14 +536,14 @@ public static Task AggregateAsync( /// /// The accumulator type. /// The path of the CSV file to read. - /// The seed, accumulate and combine functions. They carry the same contract as . + /// The seed, accumulate and combine functions. They carry the same contract as . /// Parallelism, dialect and header options. Defaults to . /// A token to cancel processing. /// The combined accumulator. - /// Carries the same fallbacks as the overloads. + /// Carries the same fallbacks as the overloads. public static Task AggregateAsync( string path, - CsvAggregation aggregation, + RowAggregation aggregation, CsvParallelOptions? options = null, CancellationToken ct = default) { @@ -557,14 +557,14 @@ public static Task AggregateAsync( /// /// The accumulator type. /// The CSV stream, read from its current position. The caller keeps ownership and must not read from it concurrently. - /// The seed, accumulate and combine functions. They carry the same contract as . + /// The seed, accumulate and combine functions. They carry the same contract as . /// Parallelism, dialect and header options. Defaults to . /// A token to cancel processing. /// The combined accumulator. - /// Carries the same fallbacks and stream restrictions as the stream overload. + /// Carries the same fallbacks and stream restrictions as the stream overload. public static Task AggregateAsync( Stream stream, - CsvAggregation aggregation, + RowAggregation aggregation, CsvParallelOptions? options = null, CancellationToken ct = default) { @@ -572,7 +572,7 @@ public static Task AggregateAsync( return ParallelCsvProcessor.RunAsync(stream, Validated(aggregation), null, Validated(options), ct); } - private static CsvAggregation Validated(CsvAggregation aggregation) + private static RowAggregation Validated(RowAggregation aggregation) { ArgumentNullException.ThrowIfNull(aggregation); ArgumentNullException.ThrowIfNull(aggregation.Seed, nameof(aggregation)); diff --git a/src/ExcelReader.Core/Reader/Csv/ICsvAccumulator.cs b/src/ExcelReader.Core/Reader/IRowAccumulator.cs similarity index 87% rename from src/ExcelReader.Core/Reader/Csv/ICsvAccumulator.cs rename to src/ExcelReader.Core/Reader/IRowAccumulator.cs index 9effefdb..ea0bcfdf 100644 --- a/src/ExcelReader.Core/Reader/Csv/ICsvAccumulator.cs +++ b/src/ExcelReader.Core/Reader/IRowAccumulator.cs @@ -1,11 +1,13 @@ -namespace ExcelReader.Core.Reader.Csv +using ExcelReader.Core.Reader.Csv; + +namespace ExcelReader.Core.Reader { /// /// Folds typed CSV records read by CsvParallel.AggregateAsync into a result. Each partition /// of the source gets its own instance. /// /// The implementing type. - /// The record type, produced by or by an . + /// The record type, produced by or by an . /// /// /// runs concurrently across instances on worker threads, and must mutate only the @@ -19,8 +21,8 @@ namespace ExcelReader.Core.Reader.Csv /// partition into the accumulator of everything before it. It may receive an instance that saw no records. /// /// - public interface ICsvAccumulator - where TSelf : ICsvAccumulator + public interface IRowAccumulator + where TSelf : IRowAccumulator where TModel : allows ref struct { /// Folds one record into this instance. diff --git a/src/ExcelReader.Core/Reader/Csv/ICsvRecord.cs b/src/ExcelReader.Core/Reader/IRowRecord.cs similarity index 82% rename from src/ExcelReader.Core/Reader/Csv/ICsvRecord.cs rename to src/ExcelReader.Core/Reader/IRowRecord.cs index aae666c0..0706fdb3 100644 --- a/src/ExcelReader.Core/Reader/Csv/ICsvRecord.cs +++ b/src/ExcelReader.Core/Reader/IRowRecord.cs @@ -1,12 +1,14 @@ -namespace ExcelReader.Core.Reader.Csv +using ExcelReader.Core.Reader.Csv; + +namespace ExcelReader.Core.Reader { /// /// A typed CSV record that parses itself from a , for /// . /// /// The implementing type. May be a holding spans taken from the row. - public interface ICsvRecord - where TSelf : ICsvRecord, allows ref struct + public interface IRowRecord + where TSelf : IRowRecord, allows ref struct { /// Parses one record. /// The record. Spans taken from it stay valid only until the accumulator's Add returns. diff --git a/src/ExcelReader.Core/Reader/Csv/CsvRowAction.cs b/src/ExcelReader.Core/Reader/RowAction.cs similarity index 73% rename from src/ExcelReader.Core/Reader/Csv/CsvRowAction.cs rename to src/ExcelReader.Core/Reader/RowAction.cs index be7ad414..4557fa70 100644 --- a/src/ExcelReader.Core/Reader/Csv/CsvRowAction.cs +++ b/src/ExcelReader.Core/Reader/RowAction.cs @@ -1,8 +1,10 @@ -namespace ExcelReader.Core.Reader.Csv +using ExcelReader.Core.Reader.Csv; + +namespace ExcelReader.Core.Reader { /// Processes one CSV record into the state owned by the partition that read it. /// The per-partition accumulator type. /// The accumulator of the partition the record belongs to. Mutate only this. /// The record. Valid only for the duration of the call. - public delegate void CsvRowAction(ref TState state, Row row); + public delegate void RowAction(ref TState state, Row row); } diff --git a/src/ExcelReader.Core/Reader/Csv/CsvAggregation.cs b/src/ExcelReader.Core/Reader/RowAggregation.cs similarity index 75% rename from src/ExcelReader.Core/Reader/Csv/CsvAggregation.cs rename to src/ExcelReader.Core/Reader/RowAggregation.cs index 87bc7f30..b23e0e18 100644 --- a/src/ExcelReader.Core/Reader/Csv/CsvAggregation.cs +++ b/src/ExcelReader.Core/Reader/RowAggregation.cs @@ -1,18 +1,20 @@ -namespace ExcelReader.Core.Reader.Csv +using ExcelReader.Core.Reader.Csv; + +namespace ExcelReader.Core.Reader { /// /// A fold over CSV records expressed as three functions, for - /// . - /// Carries the same contract as , without requiring a type. + /// . + /// Carries the same contract as , without requiring a type. /// /// The accumulator type. Each partition of the source owns one instance. - public sealed class CsvAggregation + public sealed class RowAggregation { /// Gets the function that creates an empty accumulator. Called once per partition, possibly more. public required Func Seed { get; init; } /// Gets the function that folds one record into its partition's accumulator. Runs concurrently on worker threads and must mutate only the accumulator it is given. - public required CsvRowAction Accumulate { get; init; } + public required RowAction Accumulate { get; init; } /// Gets the function that folds the following partition's accumulator into the preceding one. Called on one thread, left to right in source order. public required Func Combine { get; init; } diff --git a/src/ExcelReader.Native/Csv/CsvAggregateApi.cs b/src/ExcelReader.Native/Csv/CsvAggregateApi.cs index 51a77b4d..c3fc08f3 100644 --- a/src/ExcelReader.Native/Csv/CsvAggregateApi.cs +++ b/src/ExcelReader.Native/Csv/CsvAggregateApi.cs @@ -99,9 +99,9 @@ internal static int AggregateCsvMemory( out result); } - private static CsvAggregation Build(CsvAggregateContext context) + private static RowAggregation Build(CsvAggregateContext context) { - return new CsvAggregation + return new RowAggregation { Seed = () => { diff --git a/src/ExcelReader.Native/Typed/TypedApi.Parallel.cs b/src/ExcelReader.Native/Typed/TypedApi.Parallel.cs index f7c3b530..cf84a0ca 100644 --- a/src/ExcelReader.Native/Typed/TypedApi.Parallel.cs +++ b/src/ExcelReader.Native/Typed/TypedApi.Parallel.cs @@ -57,7 +57,7 @@ private static int ParseCsvInParallel(NativeHandle handle, CsvChunkSource source } bool isDate1904 = handle.Workbook.IsDate1904; - CsvAggregation aggregation = new() + RowAggregation aggregation = new() { Seed = () => new PartitionTable(specs), Accumulate = (ref PartitionTable state, Row row) => state.Append(row, columnIndices, isDate1904), diff --git a/tests/ExcelReader.Benchmarks/Parse/CsvParallelParseBenchmark.cs b/tests/ExcelReader.Benchmarks/Parse/CsvParallelParseBenchmark.cs index 6a34ead1..e0c20d3d 100644 --- a/tests/ExcelReader.Benchmarks/Parse/CsvParallelParseBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/Parse/CsvParallelParseBenchmark.cs @@ -1,6 +1,7 @@ using System.Runtime.InteropServices; using BenchmarkDotNet.Attributes; using ExcelReader.Core.Parser; +using ExcelReader.Core.Reader; using ExcelReader.Core.Reader.Csv; namespace ExcelReader.Benchmarks @@ -83,7 +84,7 @@ public async Task NarrowInt() return n; } - private sealed class Aggregation : ICsvAccumulator + private sealed class Aggregation : IRowAccumulator { public long Units { get; private set; } public void Add(WideRowRef model) @@ -97,7 +98,7 @@ public void Merge(Aggregation following) } } - private sealed class AggregationNarrow : ICsvAccumulator + private sealed class AggregationNarrow : IRowAccumulator { public long Units { get; private set; } public void Add(NarrowRowStruct model) diff --git a/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvModelTests.cs b/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvModelTests.cs index b8f33ed3..6afffef7 100644 --- a/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvModelTests.cs +++ b/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvModelTests.cs @@ -31,7 +31,7 @@ public record struct IdOnly public int Id { get; set; } } - private sealed class PeopleLog : ICsvAccumulator, ICsvAccumulator + private sealed class PeopleLog : IRowAccumulator, IRowAccumulator { public List Rows { get; } = []; @@ -345,7 +345,7 @@ public struct WideRow : IEquatable public static bool operator !=(WideRow left, WideRow right) => !left.Equals(right); } - private sealed class WideLog : ICsvAccumulator + private sealed class WideLog : IRowAccumulator { public List Values { get; } = []; diff --git a/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvParallelTests.cs b/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvParallelTests.cs index 262e54d6..34c8c029 100644 --- a/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvParallelTests.cs +++ b/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvParallelTests.cs @@ -9,7 +9,7 @@ namespace ExcelReader.Tests.Parser.ParallelCsv { public class AggregateCsvParallelTests { - private readonly struct RowText : ICsvRecord + private readonly struct RowText : IRowRecord { public string Text { get; init; } @@ -20,7 +20,7 @@ public static bool TryParse(Row row, out RowText record) } } - private ref struct NumberedRow : ICsvRecord + private ref struct NumberedRow : IRowRecord { public ReadOnlySpan Name; public int Id; @@ -37,7 +37,7 @@ record = default; } } - private sealed class RowLog : ICsvAccumulator, ICsvAccumulator + private sealed class RowLog : IRowAccumulator, IRowAccumulator { public List Rows { get; } = []; @@ -57,7 +57,7 @@ public void Merge(RowLog following) } } - private static readonly CsvAggregation> Collecting = new() + private static readonly RowAggregation> Collecting = new() { Seed = static () => [], Accumulate = static (ref List rows, Row row) => rows.Add(Render(row)), @@ -89,7 +89,7 @@ private static List Sequential(byte[] csv, int headerRow = 0) return rows.Count > headerRow ? rows[headerRow..] : []; } - private static Task> WithChunkSize(byte[] csv, int dop, int chunkSize, int headerRow = 0, CsvAggregation>? aggregation = null) + private static Task> WithChunkSize(byte[] csv, int dop, int chunkSize, int headerRow = 0, RowAggregation>? aggregation = null) { return ParallelCsvProcessor.RunWithChunkSizeAsync( csv.AsMemory(), @@ -153,7 +153,7 @@ public async Task DiscardsExceptionsThrownWhileReadingAMisguessedPartition() byte[] csv = Encoding.UTF8.GetBytes("ok,\"one\nBOOM,x\"\nok,two\nok,\"three\nBOOM,y\"\nok,four\n"); List expected = Sequential(csv); int misguessedDeliveries = 0; - var throwOnBoom = new CsvAggregation> + var throwOnBoom = new RowAggregation> { Seed = Collecting.Seed, Accumulate = (ref List rows, Row row) => @@ -179,7 +179,7 @@ public async Task DiscardsExceptionsThrownWhileReadingAMisguessedPartition() public async Task SurfacesAnExceptionThrownForARealRecord() { byte[] csv = Encoding.UTF8.GetBytes(string.Concat(Enumerable.Range(0, 200).Select(i => $"r{i},v\n"))); - var failAt150 = new CsvAggregation + var failAt150 = new RowAggregation { Seed = static () => 0, Accumulate = static (ref int count, Row row) => @@ -259,8 +259,8 @@ public async Task RejectsInvalidOptionsAndAggregations() byte[] csv = "a\n"u8.ToArray(); await Assert.ThrowsAsync(() => CsvParallel.AggregateAsync(csv, new CsvParallelOptions { DegreeOfParallelism = -1 }, TestContext.Current.CancellationToken)); await Assert.ThrowsAsync(() => CsvParallel.AggregateAsync(csv, new CsvParallelOptions { HeaderRow = -1 }, TestContext.Current.CancellationToken)); - await Assert.ThrowsAsync(() => CsvParallel.AggregateAsync(csv, (CsvAggregation)null!, null, TestContext.Current.CancellationToken)); - await Assert.ThrowsAsync(() => CsvParallel.AggregateAsync(csv, new CsvAggregation { Seed = null!, Accumulate = static (ref int s, Row r) => s++, Combine = static (a, b) => a + b }, null, TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(() => CsvParallel.AggregateAsync(csv, (RowAggregation)null!, null, TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(() => CsvParallel.AggregateAsync(csv, new RowAggregation { Seed = null!, Accumulate = static (ref int s, Row r) => s++, Combine = static (a, b) => a + b }, null, TestContext.Current.CancellationToken)); } [Fact] @@ -284,7 +284,7 @@ public async Task CallsTheFactoryOnceWithTheHeaderRecord() foreach (bool partitioned in new[] { true, false }) { var calls = new ConcurrentBag<(string Header, bool Sequential)>(); - CsvAccumulateFactory> factory = (Row header, bool sequential) => + RowActionFactory> factory = (Row header, bool sequential) => { calls.Add((Render(header), sequential)); return Collecting.Accumulate; @@ -306,7 +306,7 @@ public async Task CallsTheFactoryWithAnEmptyHeaderWhenThereIsNoHeaderRow() byte[] csv = LargeCsv(rows: 500); CancellationToken ct = TestContext.Current.CancellationToken; var columnCounts = new ConcurrentBag(); - CsvAccumulateFactory> factory = (Row header, bool sequential) => + RowActionFactory> factory = (Row header, bool sequential) => { columnCounts.Add(header.ColumnCount); return Collecting.Accumulate; @@ -322,7 +322,7 @@ public async Task CallsTheFactoryWithAnEmptyHeaderWhenThereIsNoHeaderRow() public async Task DoesNotCallTheFactoryWhenTheInputEndsBeforeTheHeader() { int calls = 0; - CsvAccumulateFactory> factory = (Row header, bool sequential) => + RowActionFactory> factory = (Row header, bool sequential) => { Interlocked.Increment(ref calls); return Collecting.Accumulate; diff --git a/tests/ExcelReader.Tests/Parser/ParallelCsv/CsvReaderChunkSourceTests.cs b/tests/ExcelReader.Tests/Parser/ParallelCsv/CsvReaderChunkSourceTests.cs index 5e924f0c..ee7d91b6 100644 --- a/tests/ExcelReader.Tests/Parser/ParallelCsv/CsvReaderChunkSourceTests.cs +++ b/tests/ExcelReader.Tests/Parser/ParallelCsv/CsvReaderChunkSourceTests.cs @@ -82,7 +82,7 @@ public async Task RunPartitionedAsync_Should_Deliver_Every_Record_After_The_Head byte[] csv = Numbered(50_000); using CsvReader reader = Excel.FromCsv(csv.AsMemory()); Assert.True(reader.TryGetChunkSource(out CsvChunkSource source)); - CsvAggregation count = new() + RowAggregation count = new() { Seed = () => 0L, Accumulate = (ref long seen, Row _) => seen++, diff --git a/tests/ExcelReader.Tests/Parser/ParallelCsv/ForEachCsvParallelTests.cs b/tests/ExcelReader.Tests/Parser/ParallelCsv/ForEachCsvParallelTests.cs index 1571980b..1c679698 100644 --- a/tests/ExcelReader.Tests/Parser/ParallelCsv/ForEachCsvParallelTests.cs +++ b/tests/ExcelReader.Tests/Parser/ParallelCsv/ForEachCsvParallelTests.cs @@ -10,7 +10,7 @@ namespace ExcelReader.Tests.Parser.ParallelCsv { public class ForEachCsvParallelTests { - public readonly ref struct Sale : ICsvRecord + public readonly ref struct Sale : IRowRecord { private Sale(int units) { @@ -275,10 +275,10 @@ private sealed class ChunkProbe internal int Chunks => Volatile.Read(ref _chunks); - internal CsvAggregation Wrap(CsvAggregation inner) + internal RowAggregation Wrap(RowAggregation inner) { Func seed = inner.Seed; - return new CsvAggregation + return new RowAggregation { Seed = () => { From 97d797c627228d1c79326be11b135a4b9be06951 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Sat, 10 Oct 2026 21:54:58 -0300 Subject: [PATCH 065/115] feat: add SheetParallel, a parallel reading API that takes a sheet of any format Co-Authored-By: Claude Sonnet 5.5 --- src/ExcelReader.Core/Parser/ExcelParser.cs | 2 + .../Parser/ParallelCsv/MappedAggregation.cs | 5 +- .../Parser/ParallelCsv/MappedCallback.cs | 5 +- .../Parser/ParallelCsv/MappedProjection.cs | 15 +- .../ParallelCsv/ParallelCsvEnumerable.cs | 11 +- .../Parser/ParallelCsv/ParallelCsvFactory.cs | 17 +- .../ParallelCsv/ParallelCsvProcessor.cs | 55 +++- .../Parser/SheetPipeline/SheetRunner.cs | 90 ++++++ .../PublicAPI/PublicAPI.Unshipped.txt | 14 + .../Reader/Csv/CsvParallel.cs | 4 +- src/ExcelReader.Core/Reader/Csv/CsvSheet.cs | 2 +- src/ExcelReader.Core/Reader/SheetParallel.cs | 278 ++++++++++++++++++ .../Reader/SheetParallelOptions.cs | 18 ++ .../ParallelCsv/AggregateCsvModelTests.cs | 4 +- .../ParallelCsv/ForEachCsvParallelTests.cs | 3 +- .../Parser/SheetParallelTests.cs | 195 ++++++++++++ 16 files changed, 682 insertions(+), 36 deletions(-) create mode 100644 src/ExcelReader.Core/Parser/SheetPipeline/SheetRunner.cs create mode 100644 src/ExcelReader.Core/Reader/SheetParallel.cs create mode 100644 src/ExcelReader.Core/Reader/SheetParallelOptions.cs create mode 100644 tests/ExcelReader.Tests/Parser/SheetParallelTests.cs diff --git a/src/ExcelReader.Core/Parser/ExcelParser.cs b/src/ExcelReader.Core/Parser/ExcelParser.cs index 99212d62..8bf07360 100644 --- a/src/ExcelReader.Core/Parser/ExcelParser.cs +++ b/src/ExcelReader.Core/Parser/ExcelParser.cs @@ -39,6 +39,8 @@ internal ExcelParser(ExcelParserConfig config, Func> info, Func CsvInfo => _csvInfo(); + internal TypeMapInfo Info => _info(); + /// Parses the rows of an XLSX sheet into instances, lazily as the result is enumerated. /// The sheet to pull rows from. Each enumeration of the result starts a new read of it. /// An enumerable that yields one per data row. diff --git a/src/ExcelReader.Core/Parser/ParallelCsv/MappedAggregation.cs b/src/ExcelReader.Core/Parser/ParallelCsv/MappedAggregation.cs index aef12a45..cf5af1f9 100644 --- a/src/ExcelReader.Core/Parser/ParallelCsv/MappedAggregation.cs +++ b/src/ExcelReader.Core/Parser/ParallelCsv/MappedAggregation.cs @@ -1,3 +1,4 @@ +using ExcelReader.Core.Parser.Internal; using ExcelReader.Core.Reader; using ExcelReader.Core.Reader.Csv; @@ -21,10 +22,10 @@ internal static class MappedAggregation }, }; - internal static RowActionFactory Binder(ExcelParser parser, int headerRow) + internal static RowActionFactory Binder(ExcelParser parser, TypeMapInfo info, bool isDate1904, int headerRow) { return (Row header, bool sequential) => - MappedProjection.Bind(parser, header, headerRow, sequential, Sink); + MappedProjection.Bind(parser, info, isDate1904, header, headerRow, sequential, Sink); } } } diff --git a/src/ExcelReader.Core/Parser/ParallelCsv/MappedCallback.cs b/src/ExcelReader.Core/Parser/ParallelCsv/MappedCallback.cs index 53640f08..686ccbb4 100644 --- a/src/ExcelReader.Core/Parser/ParallelCsv/MappedCallback.cs +++ b/src/ExcelReader.Core/Parser/ParallelCsv/MappedCallback.cs @@ -1,3 +1,4 @@ +using ExcelReader.Core.Parser.Internal; using ExcelReader.Core.Reader; using ExcelReader.Core.Reader.Csv; @@ -13,11 +14,11 @@ internal static class MappedCallback Combine = static (left, _) => left, }; - internal static RowActionFactory Binder(ExcelParser parser, int headerRow, Action body) + internal static RowActionFactory Binder(ExcelParser parser, TypeMapInfo info, bool isDate1904, int headerRow, Action body) { ModelSink sink = (ref byte _, TModel model) => body(model); return (Row header, bool sequential) => - MappedProjection.Bind(parser, header, headerRow, sequential, sink); + MappedProjection.Bind(parser, info, isDate1904, header, headerRow, sequential, sink); } } } diff --git a/src/ExcelReader.Core/Parser/ParallelCsv/MappedProjection.cs b/src/ExcelReader.Core/Parser/ParallelCsv/MappedProjection.cs index 32144cb2..93b5022f 100644 --- a/src/ExcelReader.Core/Parser/ParallelCsv/MappedProjection.cs +++ b/src/ExcelReader.Core/Parser/ParallelCsv/MappedProjection.cs @@ -10,9 +10,8 @@ internal static class MappedProjection private const int StackallocLimit = 256; internal static RowAction Bind( - ExcelParser parser, Row header, int headerRow, bool sequential, ModelSink sink) + ExcelParser parser, TypeMapInfo info, bool isDate1904, Row header, int headerRow, bool sequential, ModelSink sink) { - TypeMapInfo info = parser.CsvInfo; ExcelParserConfig config = parser.Config; ColumnBinding[] bindings; int requireValueCount; @@ -37,7 +36,7 @@ void Plain(ref TState state, Row row) return; } TModel model = info.CreateInstance(); - SparseRowProjection.ParseRow(in row, bindings, default, false, false, provider, throwOnParseFailure, 0, ref model); + SparseRowProjection.ParseRow(in row, bindings, default, false, isDate1904, provider, throwOnParseFailure, 0, ref model); sink(ref state, model); } @@ -49,7 +48,7 @@ void PlainNumbered(ref TState state, Row row) return; } TModel model = info.CreateInstance(); - SparseRowProjection.ParseRow(in row, bindings, default, false, false, provider, throwOnParseFailure, current, ref model); + SparseRowProjection.ParseRow(in row, bindings, default, false, isDate1904, provider, throwOnParseFailure, current, ref model); sink(ref state, model); } @@ -63,11 +62,11 @@ void Tracked(ref TState state, Row row) if (bindings.Length <= StackallocLimit) { Span seen = stackalloc bool[bindings.Length]; - SparseRowProjection.ParseRow(in row, bindings, seen, true, false, provider, throwOnParseFailure, 0, ref model); + SparseRowProjection.ParseRow(in row, bindings, seen, true, isDate1904, provider, throwOnParseFailure, 0, ref model); } else { - SparseRowProjection.ParseRow(in row, bindings, new bool[bindings.Length], true, false, provider, throwOnParseFailure, 0, ref model); + SparseRowProjection.ParseRow(in row, bindings, new bool[bindings.Length], true, isDate1904, provider, throwOnParseFailure, 0, ref model); } sink(ref state, model); } @@ -83,11 +82,11 @@ void TrackedNumbered(ref TState state, Row row) if (bindings.Length <= StackallocLimit) { Span seen = stackalloc bool[bindings.Length]; - SparseRowProjection.ParseRow(in row, bindings, seen, true, false, provider, throwOnParseFailure, current, ref model); + SparseRowProjection.ParseRow(in row, bindings, seen, true, isDate1904, provider, throwOnParseFailure, current, ref model); } else { - SparseRowProjection.ParseRow(in row, bindings, new bool[bindings.Length], true, false, provider, throwOnParseFailure, current, ref model); + SparseRowProjection.ParseRow(in row, bindings, new bool[bindings.Length], true, isDate1904, provider, throwOnParseFailure, current, ref model); } sink(ref state, model); } diff --git a/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvEnumerable.cs b/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvEnumerable.cs index 6fd452a3..edb39806 100644 --- a/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvEnumerable.cs +++ b/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvEnumerable.cs @@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis; using ExcelReader.Core.Parser.Internal; using ExcelReader.Core.Reader.Csv; +using ExcelReader.Core.Reader.Internal; using Microsoft.Win32.SafeHandles; namespace ExcelReader.Core.Parser.ParallelCsv @@ -18,6 +19,8 @@ internal sealed class ParallelCsvEnumerable : IAsyncEnumerable private readonly int _dop; private readonly SafeFileHandle? _ownedHandle; + private readonly ReaderLifetime? _lease; + private readonly object? _owner; internal ParallelCsvEnumerable( CsvChunkSource source, @@ -28,7 +31,9 @@ internal ParallelCsvEnumerable( CsvReaderOptions readerOptions, ExcelParserConfig config, int degreeOfParallelism, - SafeFileHandle? ownedHandle) + SafeFileHandle? ownedHandle, + ReaderLifetime? lease = null, + object? owner = null) { _source = source; _plan = plan; @@ -39,12 +44,15 @@ internal ParallelCsvEnumerable( _config = config; _dop = degreeOfParallelism; _ownedHandle = ownedHandle; + _lease = lease; + _owner = owner; } [SuppressMessage("Reliability", "CA2025:Do not pass 'IDisposable' instances into unawaited tasks", Justification = "The CTS and semaphore handed to the workers outlive them by construction: the finally block cancels and then awaits every worker task to completion, and only the enclosing `using` declarations' finally — which runs after it — disposes them.")] public async IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) { + _lease?.Acquire(_owner!); using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); CancellationToken ct = cts.Token; @@ -90,6 +98,7 @@ public async IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellati await cts.CancelAsync().ConfigureAwait(false); await allWorkers.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); _ownedHandle?.Dispose(); + _lease?.Release(); } } diff --git a/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvFactory.cs b/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvFactory.cs index 3ce1d617..098331a3 100644 --- a/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvFactory.cs +++ b/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvFactory.cs @@ -3,6 +3,7 @@ using ExcelReader.Core.Parser.Internal; using ExcelReader.Core.Reader; using ExcelReader.Core.Reader.Csv; +using ExcelReader.Core.Reader.Internal; using Microsoft.Win32.SafeHandles; namespace ExcelReader.Core.Parser.ParallelCsv @@ -65,6 +66,16 @@ internal static IAsyncEnumerable CreateWithChunkSize( return Build(new CsvChunkSource(data), options, parser, Normalize(degreeOfParallelism), chunkSize, ownedHandle: null, ct); } + internal static IAsyncEnumerable Create(CsvReader reader, int degreeOfParallelism, ExcelParser parser, CancellationToken ct) + { + int dop = Normalize(degreeOfParallelism); + if (!reader.TryGetChunkSource(out CsvChunkSource source) || !CanPartition(dop, source.Length, reader.Options)) + { + return new CsvEnumerable(reader.FirstSheet, parser.Config, parser.CsvInfo, ownedReader: null, ct); + } + return Build(source, reader.Options, parser, dop, chunkSizeOverride: 0, ownedHandle: null, ct, reader.Lifetime, reader); + } + internal static int Normalize(int degreeOfParallelism) { return degreeOfParallelism == 0 ? Environment.ProcessorCount : degreeOfParallelism; @@ -86,7 +97,9 @@ private static IAsyncEnumerable Build( int dop, int chunkSizeOverride, SafeFileHandle? ownedHandle, - CancellationToken ct) + CancellationToken ct, + ReaderLifetime? lease = null, + object? owner = null) { ExcelParserConfig config = parser.Config; TypeMapInfo info = parser.CsvInfo; @@ -110,7 +123,7 @@ private static IAsyncEnumerable Build( return Sequential(source.OpenReader(options), parser, ct); } - return new ParallelCsvEnumerable(source, plan, dataStart, map, info, options, config, dop, ownedHandle); + return new ParallelCsvEnumerable(source, plan, dataStart, map, info, options, config, dop, ownedHandle, lease, owner); } private static CsvEnumerable Sequential( diff --git a/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvProcessor.cs b/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvProcessor.cs index f6f64e92..c0101002 100644 --- a/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvProcessor.cs +++ b/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvProcessor.cs @@ -30,10 +30,10 @@ internal static async Task RunAsync( if (!ParallelCsvFactory.CanPartition(dop, length, options.Reader)) { CsvReader reader = await Excel.FromCsvFileAsync(path, options.Reader, ct).ConfigureAwait(false); - return await SequentialAsync(reader, aggregation, factory, options.HeaderRow, ct).ConfigureAwait(false); + return await SequentialAsync(reader, ownsReader: true, aggregation, factory, options.HeaderRow, ct).ConfigureAwait(false); } using SafeFileHandle handle = File.OpenHandle(path, FileMode.Open, FileAccess.Read, FileShare.Read, FileOptions.Asynchronous | FileOptions.RandomAccess); - return await PartitionedAsync(new CsvChunkSource(handle, length), aggregation, factory, options, dop, chunkSizeOverride: 0, ct).ConfigureAwait(false); + return await PartitionedAsync(new CsvChunkSource(handle, length), aggregation, factory, options.Reader, options.HeaderRow, dop, chunkSizeOverride: 0, ct).ConfigureAwait(false); } internal static Task RunAsync( @@ -43,9 +43,9 @@ internal static Task RunAsync( int dop = ParallelCsvFactory.Normalize(options.DegreeOfParallelism); if (!ParallelCsvFactory.CanPartition(dop, data.Length, options.Reader)) { - return SequentialAsync(Excel.FromCsv(data, options.Reader), aggregation, factory, options.HeaderRow, ct); + return SequentialAsync(Excel.FromCsv(data, options.Reader), ownsReader: true, aggregation, factory, options.HeaderRow, ct); } - return PartitionedAsync(new CsvChunkSource(data), aggregation, factory, options, dop, chunkSizeOverride: 0, ct); + return PartitionedAsync(new CsvChunkSource(data), aggregation, factory, options.Reader, options.HeaderRow, dop, chunkSizeOverride: 0, ct); } internal static async Task RunAsync( @@ -57,9 +57,9 @@ internal static async Task RunAsync( || !ParallelCsvFactory.CanPartition(dop, source.Length, options.Reader)) { CsvReader reader = await Excel.FromCsvAsync(stream, leaveOpen: true, options.Reader, ct).ConfigureAwait(false); - return await SequentialAsync(reader, aggregation, factory, options.HeaderRow, ct).ConfigureAwait(false); + return await SequentialAsync(reader, ownsReader: true, aggregation, factory, options.HeaderRow, ct).ConfigureAwait(false); } - return await PartitionedAsync(source, aggregation, factory, options, dop, chunkSizeOverride: 0, ct).ConfigureAwait(false); + return await PartitionedAsync(source, aggregation, factory, options.Reader, options.HeaderRow, dop, chunkSizeOverride: 0, ct).ConfigureAwait(false); } internal static Task RunWithChunkSizeAsync( @@ -67,14 +67,33 @@ internal static Task RunWithChunkSizeAsync( { options = WithResolvedDialect(options, CsvDialectResolver.Resolve(data, options.Reader)); int dop = ParallelCsvFactory.Normalize(options.DegreeOfParallelism); - return PartitionedAsync(new CsvChunkSource(data), aggregation, factory, options, dop, chunkSize, ct); + return PartitionedAsync(new CsvChunkSource(data), aggregation, factory, options.Reader, options.HeaderRow, dop, chunkSize, ct); } internal static Task RunPartitionedAsync( CsvChunkSource source, RowAggregation aggregation, CsvParallelOptions options, int chunkSizeOverride, CancellationToken ct) { int dop = ParallelCsvFactory.Normalize(options.DegreeOfParallelism); - return PartitionedAsync(source, aggregation, null, options, dop, chunkSizeOverride, ct); + return PartitionedAsync(source, aggregation, null, options.Reader, options.HeaderRow, dop, chunkSizeOverride, ct); + } + + internal static async Task RunAsync( + CsvReader reader, RowAggregation aggregation, RowActionFactory? factory, SheetParallelOptions options, CancellationToken ct) + { + reader.Lifetime.Acquire(reader); + try + { + int dop = ParallelCsvFactory.Normalize(options.DegreeOfParallelism); + if (!reader.TryGetChunkSource(out CsvChunkSource source) || !ParallelCsvFactory.CanPartition(dop, source.Length, reader.Options)) + { + return await SequentialAsync(reader, ownsReader: false, aggregation, factory, options.HeaderRow, ct).ConfigureAwait(false); + } + return await PartitionedAsync(source, aggregation, factory, reader.Options, options.HeaderRow, dop, chunkSizeOverride: 0, ct).ConfigureAwait(false); + } + finally + { + reader.Lifetime.Release(); + } } private static CsvParallelOptions WithResolvedDialect(CsvParallelOptions options, CsvReaderOptions reader) @@ -97,9 +116,9 @@ private static RowAggregation WithAccumulate(RowAggregation SequentialAsync( - CsvReader reader, RowAggregation aggregation, RowActionFactory? factory, int headerRow, CancellationToken ct) + CsvReader reader, bool ownsReader, RowAggregation aggregation, RowActionFactory? factory, int headerRow, CancellationToken ct) { - await using (reader.ConfigureAwait(false)) + try { CsvReader.Enumerator rows = reader.OpenSheet(ct); await using (rows.ConfigureAwait(false)) @@ -132,17 +151,23 @@ private static async Task SequentialAsync( return state; } } + finally + { + if (ownsReader) + { + await reader.DisposeAsync().ConfigureAwait(false); + } + } } private static async Task PartitionedAsync( - CsvChunkSource source, RowAggregation aggregation, RowActionFactory? factory, CsvParallelOptions options, int dop, int chunkSizeOverride, - CancellationToken ct) + CsvChunkSource source, RowAggregation aggregation, RowActionFactory? factory, CsvReaderOptions reader, int headerRow, int dop, + int chunkSizeOverride, CancellationToken ct) { - CsvReaderOptions reader = options.Reader; long dataStart = 0; - if (options.HeaderRow > 0) + if (headerRow > 0) { - (dataStart, RowAction? bound) = await RecordStartAfterAsync(source, reader, options.HeaderRow, factory, ct).ConfigureAwait(false); + (dataStart, RowAction? bound) = await RecordStartAfterAsync(source, reader, headerRow, factory, ct).ConfigureAwait(false); if (bound is not null) { aggregation = WithAccumulate(aggregation, bound); diff --git a/src/ExcelReader.Core/Parser/SheetPipeline/SheetRunner.cs b/src/ExcelReader.Core/Parser/SheetPipeline/SheetRunner.cs new file mode 100644 index 00000000..2e555b1c --- /dev/null +++ b/src/ExcelReader.Core/Parser/SheetPipeline/SheetRunner.cs @@ -0,0 +1,90 @@ +using System.Runtime.CompilerServices; +using ExcelReader.Core.Parser.ParallelCsv; +using ExcelReader.Core.Reader; +using ExcelReader.Core.Reader.Csv; +using ExcelReader.Core.Reader.Xls; +using ExcelReader.Core.Reader.Xlsb; +using ExcelReader.Core.Reader.Xlsx; + +namespace ExcelReader.Core.Parser.SheetPipeline +{ + internal static class SheetRunner + { + private const int RowsPerCancellationCheck = 4096; + + internal static Task RunAsync( + IExcelSheet sheet, RowAggregation aggregation, RowActionFactory? factory, SheetParallelOptions options, CancellationToken ct) + { + if (sheet is CsvSheet csv) + { + return ParallelCsvProcessor.RunAsync(csv.Reader, aggregation, factory, options, ct); + } + return SequentialAsync(sheet, aggregation, factory, options.HeaderRow, ct); + } + + internal static IAsyncEnumerable Parse(IExcelSheet sheet, ExcelParser parser, int degreeOfParallelism, CancellationToken ct) + { + if (sheet is CsvSheet csv) + { + return ParallelCsvFactory.Create(csv.Reader, degreeOfParallelism, parser, ct); + } + return Sequential(sheet, parser, ct); + } + + private static async IAsyncEnumerable Sequential(IExcelSheet sheet, ExcelParser parser, [EnumeratorCancellation] CancellationToken ct) + { + IAsyncEnumerable rows = sheet switch + { + XlsxSheet xlsx => parser.Parse(xlsx), + XlsbSheet xlsb => parser.Parse(xlsb), + XlsSheet xls => parser.Parse(xls), + _ => parser.Parse(sheet), + }; + await foreach (T row in rows.WithCancellation(ct).ConfigureAwait(false)) + { + yield return row; + } + } + + internal static async Task SequentialAsync( + IExcelSheet sheet, RowAggregation aggregation, RowActionFactory? factory, int headerRow, CancellationToken ct) + { + IExcelRowEnumerator rows = sheet.GetAsyncEnumerator(ct); + await using (rows.ConfigureAwait(false)) + { + TState state = aggregation.Seed(); + RowAction accumulate = aggregation.Accumulate; + if (factory is not null && headerRow == 0) + { + accumulate = factory(default, sequential: true); + } + for (int skipped = 0; skipped < headerRow; skipped++) + { + if (!await rows.MoveNextAsync().ConfigureAwait(false)) + { + return state; + } + if (factory is not null && skipped == headerRow - 1) + { + accumulate = factory(rows.Current, sequential: true); + } + } + long delivered = 0; + while (await rows.MoveNextAsync().ConfigureAwait(false)) + { + if (++delivered % RowsPerCancellationCheck == 0) + { + ct.ThrowIfCancellationRequested(); + } + Deliver(accumulate, ref state, rows); + } + return state; + } + } + + private static void Deliver(RowAction accumulate, ref TState state, IExcelRowEnumerator rows) + { + accumulate(ref state, rows.Current); + } + } +} diff --git a/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt b/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt index 1742ce90..6b7a4d18 100644 --- a/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt @@ -56,3 +56,17 @@ ExcelReader.Core.Writer.Xlsb.XlsbSheetWriter.BeginTableAsync(string! name, Syste ExcelReader.Core.Writer.Xlsb.XlsbSheetWriter.EndTable() -> void static ExcelReader.Core.Writer.SheetWriterExtensions.WriteTableAsync(this ExcelReader.Core.Writer.ITableSheetWriter! sheet, System.Collections.Generic.IAsyncEnumerable! records, string! name, ExcelReader.Core.Writer.ExcelRecordLayout! layout, ExcelReader.Core.Writer.ExcelTableOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static ExcelReader.Core.Writer.SheetWriterExtensions.WriteTableAsync(this ExcelReader.Core.Writer.ITableSheetWriter! sheet, System.Collections.Generic.IEnumerable! records, string! name, ExcelReader.Core.Writer.ExcelRecordLayout! layout, ExcelReader.Core.Writer.ExcelTableOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +ExcelReader.Core.Reader.SheetParallel +ExcelReader.Core.Reader.SheetParallelOptions +ExcelReader.Core.Reader.SheetParallelOptions.DegreeOfParallelism.get -> int +ExcelReader.Core.Reader.SheetParallelOptions.DegreeOfParallelism.init -> void +ExcelReader.Core.Reader.SheetParallelOptions.HeaderRow.get -> int +ExcelReader.Core.Reader.SheetParallelOptions.HeaderRow.init -> void +ExcelReader.Core.Reader.SheetParallelOptions.SheetParallelOptions() -> void +static ExcelReader.Core.Reader.SheetParallel.AggregateAsync(ExcelReader.Core.Reader.IExcelSheet! sheet, ExcelReader.Core.Parser.ExcelParser! parser, ExcelReader.Core.Reader.SheetParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +static ExcelReader.Core.Reader.SheetParallel.AggregateAsync(ExcelReader.Core.Reader.IExcelSheet! sheet, ExcelReader.Core.Reader.SheetParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +static ExcelReader.Core.Reader.SheetParallel.AggregateAsync(ExcelReader.Core.Reader.IExcelSheet! sheet, ExcelReader.Core.Reader.RowAggregation! aggregation, ExcelReader.Core.Reader.SheetParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +static ExcelReader.Core.Reader.SheetParallel.ForEachAsync(ExcelReader.Core.Reader.IExcelSheet! sheet, ExcelReader.Core.Parser.ExcelParser! parser, System.Action! body, ExcelReader.Core.Reader.SheetParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +static ExcelReader.Core.Reader.SheetParallel.ForEachAsync(ExcelReader.Core.Reader.IExcelSheet! sheet, System.Action! body, ExcelReader.Core.Reader.SheetParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +static ExcelReader.Core.Reader.SheetParallel.ParseAsync(ExcelReader.Core.Reader.IExcelSheet! sheet, ExcelReader.Core.Parser.ExcelParser! parser, ExcelReader.Core.Reader.SheetParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable! +static ExcelReader.Core.Reader.SheetParallelOptions.Default.get -> ExcelReader.Core.Reader.SheetParallelOptions! diff --git a/src/ExcelReader.Core/Reader/Csv/CsvParallel.cs b/src/ExcelReader.Core/Reader/Csv/CsvParallel.cs index 52633387..a19b0445 100644 --- a/src/ExcelReader.Core/Reader/Csv/CsvParallel.cs +++ b/src/ExcelReader.Core/Reader/Csv/CsvParallel.cs @@ -494,7 +494,7 @@ private static RowActionFactory Bound(ExcelP { throw new ArgumentException("A parser that binds columns by header name needs CsvParallelOptions.HeaderRow of at least 1.", nameof(parser)); } - return MappedAggregation.Binder(parser, options.HeaderRow); + return MappedAggregation.Binder(parser, parser.CsvInfo, false, options.HeaderRow); } private static RowActionFactory BoundCallback( @@ -507,7 +507,7 @@ private static RowActionFactory BoundCallback( { throw new ArgumentException("A parser that binds columns by header name needs CsvParallelOptions.HeaderRow of at least 1.", nameof(parser)); } - return MappedCallback.Binder(parser, options.HeaderRow, body); + return MappedCallback.Binder(parser, parser.CsvInfo, false, options.HeaderRow, body); } /// diff --git a/src/ExcelReader.Core/Reader/Csv/CsvSheet.cs b/src/ExcelReader.Core/Reader/Csv/CsvSheet.cs index 39015bc7..e6effc5e 100644 --- a/src/ExcelReader.Core/Reader/Csv/CsvSheet.cs +++ b/src/ExcelReader.Core/Reader/Csv/CsvSheet.cs @@ -46,7 +46,7 @@ public bool IsDate1904 } } - private CsvReader Reader + internal CsvReader Reader { get { diff --git a/src/ExcelReader.Core/Reader/SheetParallel.cs b/src/ExcelReader.Core/Reader/SheetParallel.cs new file mode 100644 index 00000000..5d643ef8 --- /dev/null +++ b/src/ExcelReader.Core/Reader/SheetParallel.cs @@ -0,0 +1,278 @@ +using ExcelReader.Core.Parser; +using ExcelReader.Core.Parser.Internal; +using ExcelReader.Core.Parser.ParallelCsv; +using ExcelReader.Core.Parser.SheetPipeline; +using ExcelReader.Core.Reader.Csv; + +namespace ExcelReader.Core.Reader +{ + /// + /// Reads a sheet of any format across several threads: ParseAsync yields typed rows in source + /// order, ForEachAsync hands each row to a callback on the worker threads, and + /// AggregateAsync folds rows into one accumulator per partition. + /// + /// + /// + /// The caller owns the workbook and keeps it open until the returned task completes or the + /// enumeration ends. The CSV dialect is the the workbook was opened with. + /// + /// + /// A read falls back to one sequential pass, with the same results and no error, when + /// is 1, the sheet is too small to partition + /// usefully, the sheet is an XLS sheet or a table view, or a CSV cannot be partitioned (a non-seekable + /// stream or a non-UTF-8 encoding). + /// + /// + /// Delivery guarantees. The general contract is the weaker CSV one: ForEachAsync is at-least-once, + /// and a partition whose start was guessed wrongly is discarded and read again. XLSB delivers every row + /// exactly once, in ForEachAsync too. XLSX delivers exactly once for a well-formed sheet; a + /// malformed one can make ForEachAsync deliver a row twice. ParseAsync yields the same items in + /// the same order as the sequential on every format, and + /// AggregateAsync and ForEachAsync equal their own single-thread run. + /// + /// + /// : ParseAsync renumbers it from the count emitted at the + /// merge; ForEachAsync and AggregateAsync report the exact row when sequential and 0 when partitioned. + /// + /// + public static class SheetParallel + { + /// + /// Parses a sheet into instances across several threads, yielding the + /// same sequence, in the same order, that the sequential produces. + /// + /// The row model type to bind each row to. + /// The sheet to read. The caller keeps ownership and keeps its workbook open until the enumeration ends. + /// How columns bind to ; created with . Its locates the header. + /// Parallelism options. Defaults to . does not apply here and must be left at 0. + /// A token to cancel enumeration. + /// An enumerable that yields one per data row. + /// or is . + /// is not 0. + /// A numeric option is negative. + /// + /// Parsing and type conversion run in parallel; whatever the caller does per row does not. A caller + /// whose per-row work dominates will see little gain from raising . + /// For CSV, yields substantially less here than sequentially, + /// since each partition keeps its own dedup cache. A partition read from a wrongly guessed start is + /// discarded before any of its rows are yielded, but its cells have already passed through the parser's + /// converters and property setters, which must therefore be free of side effects. + /// + public static IAsyncEnumerable ParseAsync( + IExcelSheet sheet, ExcelParser parser, SheetParallelOptions? options = null, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(sheet); + ArgumentNullException.ThrowIfNull(parser); + SheetParallelOptions validated = Validated(options); + if (validated.HeaderRow != 0) + { + throw new ArgumentException( + $"{nameof(SheetParallelOptions)}.{nameof(SheetParallelOptions.HeaderRow)} does not apply to typed parsing; set {nameof(ExcelParserConfig)}.{nameof(ExcelParserConfig.HeaderRow)} instead.", + nameof(options)); + } + return SheetRunner.Parse(sheet, parser, validated.DegreeOfParallelism, ct); + } + + /// + /// Reads a sheet across several threads, parses every row into a + /// and hands it to . + /// + /// The record type. A record whose returns is skipped. + /// The sheet to read. The caller keeps ownership and keeps its workbook open until the task completes. + /// Receives each record. See the remarks for the contract it must honor. + /// Parallelism and header options. Defaults to . + /// A token to cancel processing. + /// A task that completes when every record has been delivered. + /// or is . + /// A numeric option is negative. + /// + /// + /// runs concurrently on worker threads and must be safe to call from + /// several at once; the caller owns any synchronization. Records arrive in no particular order. + /// + /// + /// Delivery is at least once in general: may be invoked more than once for the + /// same record. On CSV, a partition whose start was guessed wrongly is read again from the confirmed + /// offset, and the discarded speculative pass may already have delivered a whole partition's worth of + /// records, which may also be misparsed: a wrong start shifts every field boundary. An exception + /// throws during a discarded pass is discarded with it. A start is only guessed + /// wrongly inside a quoted field, so a CSV in which the quote character never appears delivers every + /// record exactly once. XLSB delivers every row exactly once. XLSX does too for a well-formed sheet; on + /// a malformed one, workers may already have called for rows that are then + /// dropped, so a row can be delivered twice or be one the sequential reader would not produce. + /// + /// + /// A with side effects must therefore be idempotent and tolerate records that + /// do not exist. For exactly-once on any source, use + /// , where the + /// discarded partition's accumulator is thrown away, and apply the side effects once it returns. + /// + /// + /// A column points into a pooled buffer the worker reuses once + /// returns. Copy anything that must outlive the call. + /// + /// + public static Task ForEachAsync( + IExcelSheet sheet, Action body, SheetParallelOptions? options = null, CancellationToken ct = default) + where TRecord : IRowRecord, allows ref struct + { + ArgumentNullException.ThrowIfNull(sheet); + ArgumentNullException.ThrowIfNull(body); + return SheetRunner.RunAsync(sheet, RecordCallback.For(body), null, Validated(options), ct); + } + + /// + /// Reads a sheet across several threads, binds every row to a + /// through and hands it to . + /// + /// The model type. May be a with of properties. + /// The sheet to read. The caller keeps ownership and keeps its workbook open until the task completes. + /// How columns bind to ; created with . Its is ignored in favour of . + /// Receives each record. See the remarks for the contract it must honor. + /// Parallelism and header options. Defaults to . + /// A token to cancel processing. + /// A task that completes when every record has been delivered. + /// , or is . + /// binds columns by header name and is 0. + /// A numeric option is negative. + /// + /// + /// Carries the same concurrency, delivery and span-lifetime contract as the + /// overload: runs concurrently, delivery is + /// at least once in general (exactly once on XLSB, and on a well-formed XLSX sheet), and records arrive + /// in no particular order. A property that fails to parse keeps its default unless + /// is set; a missing [ExcelRequired] column or + /// value throws ; empty rows are skipped. + /// + /// + /// is exact when the sheet is read sequentially and + /// 0 when it is partitioned, since a partition does not know how many rows precede it. + /// + /// + public static Task ForEachAsync( + IExcelSheet sheet, ExcelParser parser, Action body, SheetParallelOptions? options = null, CancellationToken ct = default) + where TModel : allows ref struct + { + ArgumentNullException.ThrowIfNull(sheet); + ArgumentNullException.ThrowIfNull(parser); + ArgumentNullException.ThrowIfNull(body); + SheetParallelOptions validated = Validated(options); + TypeMapInfo info = InfoFor(sheet, parser, validated); + return SheetRunner.RunAsync( + sheet, MappedCallback.Unbound, MappedCallback.Binder(parser, info, sheet.IsDate1904, validated.HeaderRow, body), validated, ct); + } + + /// + /// Reads a sheet across several threads, parses every row into a + /// and folds it into a , one instance per partition, merged in sheet order. + /// + /// The accumulator type. See for the contract it must honor. + /// The record type. A record whose returns is skipped. + /// The sheet to read. The caller keeps ownership and keeps its workbook open until the task completes. + /// Parallelism and header options. Defaults to . + /// A token to cancel processing. + /// The accumulator holding every record. + /// is . + /// A numeric option is negative. + /// + /// A partition read from a wrongly guessed start is read again into a new accumulator. The result + /// holds every record exactly once, but and + /// may already have run for the discarded pass's records, as + /// describes. A sequential fallback uses a single accumulator and never calls + /// . + /// + public static Task AggregateAsync( + IExcelSheet sheet, SheetParallelOptions? options = null, CancellationToken ct = default) + where TAccumulator : IRowAccumulator, new() + where TRecord : IRowRecord, allows ref struct + { + ArgumentNullException.ThrowIfNull(sheet); + return SheetRunner.RunAsync(sheet, RecordAggregation.Instance, null, Validated(options), ct); + } + + /// + /// Reads a sheet across several threads, binds every row to a + /// through and folds it into a , one + /// instance per partition, merged in sheet order. + /// + /// The accumulator type. See for the contract it must honor. + /// The model type. + /// The sheet to read. The caller keeps ownership and keeps its workbook open until the task completes. + /// How columns bind to ; created with . Its is ignored in favour of . + /// Parallelism and header options. Defaults to . + /// A token to cancel processing. + /// The accumulator holding every record. + /// or is . + /// binds columns by header name and is 0. + /// A numeric option is negative. + /// + /// + /// The header is bound once, before any row is folded. A property that fails to parse keeps its + /// default unless is set; a missing + /// [ExcelRequired] column or value throws ; empty rows are skipped. + /// + /// + /// is exact when the sheet is read sequentially and 0 when it is + /// partitioned, since a partition does not know how many rows precede it. + /// + /// Otherwise carries the same contract and fallbacks as the overload. + /// + public static Task AggregateAsync( + IExcelSheet sheet, ExcelParser parser, SheetParallelOptions? options = null, CancellationToken ct = default) + where TAccumulator : IRowAccumulator, new() + where TModel : allows ref struct + { + ArgumentNullException.ThrowIfNull(sheet); + ArgumentNullException.ThrowIfNull(parser); + SheetParallelOptions validated = Validated(options); + TypeMapInfo info = InfoFor(sheet, parser, validated); + return SheetRunner.RunAsync( + sheet, MappedAggregation.Unbound, + MappedAggregation.Binder(parser, info, sheet.IsDate1904, validated.HeaderRow), validated, ct); + } + + /// + /// Reads a sheet across several threads and folds every row with the functions of + /// , one accumulator per partition, combined in sheet order. + /// + /// The accumulator type. + /// The sheet to read. The caller keeps ownership and keeps its workbook open until the task completes. + /// The seed, accumulate and combine functions. They carry the same contract as . + /// Parallelism and header options. Defaults to . + /// A token to cancel processing. + /// The combined accumulator. + /// or , or one of its functions, is . + /// A numeric option is negative. + /// Carries the same fallbacks as the overloads. + public static Task AggregateAsync( + IExcelSheet sheet, RowAggregation aggregation, SheetParallelOptions? options = null, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(sheet); + ArgumentNullException.ThrowIfNull(aggregation); + ArgumentNullException.ThrowIfNull(aggregation.Seed, nameof(aggregation)); + ArgumentNullException.ThrowIfNull(aggregation.Accumulate, nameof(aggregation)); + ArgumentNullException.ThrowIfNull(aggregation.Combine, nameof(aggregation)); + return SheetRunner.RunAsync(sheet, aggregation, null, Validated(options), ct); + } + + private static TypeMapInfo InfoFor(IExcelSheet sheet, ExcelParser parser, SheetParallelOptions options) + where TModel : allows ref struct + { + TypeMapInfo info = sheet is CsvSheet ? parser.CsvInfo : parser.Info; + if (options.HeaderRow == 0 && !info.IsIndexBased) + { + throw new ArgumentException( + $"A parser that binds columns by header name needs {nameof(SheetParallelOptions)}.{nameof(SheetParallelOptions.HeaderRow)} of at least 1.", nameof(parser)); + } + return info; + } + + private static SheetParallelOptions Validated(SheetParallelOptions? options) + { + options ??= SheetParallelOptions.Default; + ArgumentOutOfRangeException.ThrowIfNegative(options.DegreeOfParallelism, nameof(options)); + ArgumentOutOfRangeException.ThrowIfNegative(options.HeaderRow, nameof(options)); + return options; + } + } +} diff --git a/src/ExcelReader.Core/Reader/SheetParallelOptions.cs b/src/ExcelReader.Core/Reader/SheetParallelOptions.cs new file mode 100644 index 00000000..be31ae13 --- /dev/null +++ b/src/ExcelReader.Core/Reader/SheetParallelOptions.cs @@ -0,0 +1,18 @@ +namespace ExcelReader.Core.Reader +{ + /// Options for reading a sheet across several threads with . + public sealed class SheetParallelOptions + { + /// Gets the default options: one thread per processor, no header. + public static SheetParallelOptions Default { get; } = new(); + + /// Gets the maximum number of reading threads. 0, the default, means ; 1 reads sequentially. + public int DegreeOfParallelism { get; init; } + + /// + /// Gets the 1-based number of the header row; it and every row before it are skipped. + /// 0, the default, means the sheet has no header. + /// + public int HeaderRow { get; init; } + } +} diff --git a/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvModelTests.cs b/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvModelTests.cs index 6afffef7..bc24b5ea 100644 --- a/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvModelTests.cs +++ b/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvModelTests.cs @@ -107,7 +107,7 @@ private static Task Chunked(byte[] csv, ExcelParser map, int return ParallelCsvProcessor.RunWithChunkSizeAsync( csv.AsMemory(), MappedAggregation.Unbound, - MappedAggregation.Binder(map, headerRow), + MappedAggregation.Binder(map, map.CsvInfo, false, headerRow), new CsvParallelOptions { DegreeOfParallelism = dop, HeaderRow = headerRow }, chunkSize, TestContext.Current.CancellationToken); @@ -118,7 +118,7 @@ private static Task Chunked(byte[] csv, ExcelParser map, int return ParallelCsvProcessor.RunWithChunkSizeAsync( csv.AsMemory(), MappedAggregation.Unbound, - MappedAggregation.Binder(map, headerRow), + MappedAggregation.Binder(map, map.CsvInfo, false, headerRow), new CsvParallelOptions { DegreeOfParallelism = dop, HeaderRow = headerRow }, chunkSize, TestContext.Current.CancellationToken); diff --git a/tests/ExcelReader.Tests/Parser/ParallelCsv/ForEachCsvParallelTests.cs b/tests/ExcelReader.Tests/Parser/ParallelCsv/ForEachCsvParallelTests.cs index 1c679698..d4931d0d 100644 --- a/tests/ExcelReader.Tests/Parser/ParallelCsv/ForEachCsvParallelTests.cs +++ b/tests/ExcelReader.Tests/Parser/ParallelCsv/ForEachCsvParallelTests.cs @@ -304,10 +304,11 @@ private static Task ChunkedRecord(byte[] csv, Action body, ChunkProb private static Task ChunkedMapped(byte[] csv, Action body, ChunkProbe probe, int dop, int chunkSize) { + ExcelParser order = ExcelParser.FromAttributes(); return ParallelCsvProcessor.RunWithChunkSizeAsync( csv.AsMemory(), probe.Wrap(MappedCallback.Unbound), - MappedCallback.Binder(ExcelParser.FromAttributes(), 1, body), + MappedCallback.Binder(order, order.CsvInfo, false, 1, body), new CsvParallelOptions { DegreeOfParallelism = dop, HeaderRow = 1 }, chunkSize, TestContext.Current.CancellationToken); diff --git a/tests/ExcelReader.Tests/Parser/SheetParallelTests.cs b/tests/ExcelReader.Tests/Parser/SheetParallelTests.cs new file mode 100644 index 00000000..de8c6aad --- /dev/null +++ b/tests/ExcelReader.Tests/Parser/SheetParallelTests.cs @@ -0,0 +1,195 @@ +using System.Globalization; +using System.Text; +using ExcelReader.Core.Parser; +using ExcelReader.Core.Reader; +using ExcelReader.Core.Reader.Csv; +using ExcelReader.Core.Reader.Xlsx; +using ExcelReader.Core.Writer.Xls; +using ExcelReader.Tests.Reader.Tables; + +namespace ExcelReader.Tests.Parser +{ + public class SheetParallelTests + { + private sealed class Person + { + public string? Name { get; set; } + + public int Age { get; set; } + } + + private static readonly RowAggregation> Collecting = new() + { + Seed = static () => [], + Accumulate = static (ref List rows, Row row) => rows.Add(Render(row)), + Combine = static (left, right) => + { + left.AddRange(right); + return left; + }, + }; + + private static string Render(Row row) + { + var sb = new StringBuilder(); + foreach (RowCell cell in row.Cells) + { + sb.Append(cell.ColumnIndex).Append(':').Append(cell.Value.GetString()).Append('|'); + } + return sb.ToString(); + } + + private static List Sequential(IExcelSheet sheet, int headerRow) + { + var rows = new List(); + using IExcelRowEnumerator e = sheet.GetEnumerator(); + while (e.MoveNext()) + { + rows.Add(Render(e.Current)); + } + return rows.Count > headerRow ? rows[headerRow..] : []; + } + + private static byte[] Csv(int rows) + { + var sb = new StringBuilder("Name,Age\n"); + for (int i = 0; i < rows; i++) + { + sb.Append(CultureInfo.InvariantCulture, $"name{i:D5},{i}\n"); + } + return Encoding.UTF8.GetBytes(sb.ToString()); + } + + private static async Task XlsxAsync(int rows) + { + object?[][] data = new object?[rows + 1][]; + data[0] = ["Name", "Age"]; + for (int i = 0; i < rows; i++) + { + data[i + 1] = [string.Create(CultureInfo.InvariantCulture, $"name{i:D5}"), i]; + } + return await TypedWorkbook.BuildAsync(data); + } + + [Fact] + public async Task CsvAggregateMatchesSequential() + { + CancellationToken ct = TestContext.Current.CancellationToken; + using CsvReader reader = Excel.FromCsv(Csv(40_000)); + var options = new SheetParallelOptions { DegreeOfParallelism = 4, HeaderRow = 1 }; + + List actual = await SheetParallel.AggregateAsync(reader.FirstSheet, Collecting, options, ct); + + Assert.Equal(Sequential(reader.FirstSheet, 1), actual); + } + + [Fact] + public async Task CsvParseMatchesTheSequentialParser() + { + CancellationToken ct = TestContext.Current.CancellationToken; + using CsvReader reader = Excel.FromCsv(Csv(40_000)); + ExcelParser parser = ExcelParser.FromAttributes(); + List expected = [.. parser.Parse(reader.FirstSheet)]; + + var actual = new List(); + await foreach (Person person in SheetParallel.ParseAsync(reader.FirstSheet, parser, new SheetParallelOptions { DegreeOfParallelism = 4 }, ct)) + { + actual.Add(person); + } + + Assert.Equal(expected.Select(p => (p.Name, p.Age)), actual.Select(p => (p.Name, p.Age))); + } + + [Fact] + public async Task ACsvOverANonSeekableStreamFallsBackToSequential() + { + CancellationToken ct = TestContext.Current.CancellationToken; + byte[] csv = Csv(5_000); + await using var pipe = new NonSeekableStream(csv); + using CsvReader reader = Excel.FromCsv(pipe); + using CsvReader reference = Excel.FromCsv(csv); + + List actual = await SheetParallel.AggregateAsync(reader.FirstSheet, Collecting, new SheetParallelOptions { DegreeOfParallelism = 4, HeaderRow = 1 }, ct); + + Assert.Equal(Sequential(reference.FirstSheet, 1), actual); + } + + [Fact] + public async Task AnXlsSheetReadsSequentially() + { + CancellationToken ct = TestContext.Current.CancellationToken; + var ms = new MemoryStream(); + await using (XlsWorkbookWriter wb = XlsWorkbookWriter.Create(ms, leaveOpen: true)) + { + XlsSheetWriter sheet = wb.AddSheet("S1"); + for (int r = 0; r < 200; r++) + { + using XlsRowWriter row = sheet.StartRow(); + row.Write("name-" + r.ToString(CultureInfo.InvariantCulture)); + row.Write(r); + } + sheet.End(); + await wb.EndAsync(ct); + } + ms.Position = 0; + using IExcelWorkbook workbook = Excel.Open(ms); + + List actual = await SheetParallel.AggregateAsync(workbook.FirstSheet, Collecting, new SheetParallelOptions { DegreeOfParallelism = 4 }, ct); + + Assert.Equal(Sequential(workbook.FirstSheet, 0), actual); + } + + [Theory] + [MemberData(nameof(TableMetadataTests.Fixtures), MemberType = typeof(TableMetadataTests))] + public async Task ATableViewReadsSequentially(string file) + { + CancellationToken ct = TestContext.Current.CancellationToken; + using IExcelWorkbook workbook = Excel.Open(TableMetadataTests.FixturePath(file)); + Assert.True(workbook.TryGetTable("Sales", out ExcelTable? table)); + IExcelSheet view = table.AsSheet(); + + List actual = await SheetParallel.AggregateAsync(view, Collecting, new SheetParallelOptions { DegreeOfParallelism = 4 }, ct); + + Assert.Equal(Sequential(view, 0), actual); + } + + [Fact] + public async Task XlsxFamiliesMatchSequential() + { + CancellationToken ct = TestContext.Current.CancellationToken; + using MemoryStream ms = await XlsxAsync(20_000); + using XlsxWorkbook workbook = Excel.FromXlsx(ms); + ExcelParser parser = ExcelParser.FromAttributes(); + List expected = [.. parser.Parse(workbook.FirstSheet)]; + var options = new SheetParallelOptions { DegreeOfParallelism = 4, HeaderRow = 1 }; + + Assert.Equal(Sequential(workbook.FirstSheet, 1), await SheetParallel.AggregateAsync(workbook.FirstSheet, Collecting, options, ct)); + + var parsed = new List(); + await foreach (Person person in SheetParallel.ParseAsync(workbook.FirstSheet, parser, new SheetParallelOptions { DegreeOfParallelism = 4 }, ct)) + { + parsed.Add(person); + } + Assert.Equal(expected.Select(p => (p.Name, p.Age)), parsed.Select(p => (p.Name, p.Age))); + + var seen = new System.Collections.Concurrent.ConcurrentBag<(string?, int)>(); + await SheetParallel.ForEachAsync(workbook.FirstSheet, parser, (Person p) => seen.Add((p.Name, p.Age)), options, ct); + Assert.Equal(expected.Select(p => (p.Name, p.Age)).Order(), seen.Order()); + } + + [Fact] + public async Task ArgumentsAreValidated() + { + CancellationToken ct = TestContext.Current.CancellationToken; + using CsvReader reader = Excel.FromCsv(Csv(10)); + ExcelParser parser = ExcelParser.FromAttributes(); + + await Assert.ThrowsAsync(() => SheetParallel.AggregateAsync(null!, Collecting, null, ct)); + await Assert.ThrowsAsync(() => SheetParallel.AggregateAsync(reader.FirstSheet, (RowAggregation)null!, null, ct)); + await Assert.ThrowsAsync(() => SheetParallel.AggregateAsync(reader.FirstSheet, Collecting, new SheetParallelOptions { DegreeOfParallelism = -1 }, ct)); + await Assert.ThrowsAsync(() => SheetParallel.AggregateAsync(reader.FirstSheet, Collecting, new SheetParallelOptions { HeaderRow = -1 }, ct)); + Assert.Throws(() => SheetParallel.ParseAsync(reader.FirstSheet, parser, new SheetParallelOptions { HeaderRow = 2 }, ct)); + await Assert.ThrowsAsync(() => SheetParallel.ForEachAsync(reader.FirstSheet, parser, (Person _) => { }, new SheetParallelOptions { HeaderRow = 0 }, ct)); + } + } +} From cd6550e6d8277aca9d54ad8271598a48bce9e236 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Sat, 10 Oct 2026 22:07:19 -0300 Subject: [PATCH 066/115] feat!: remove CsvParallel in favour of SheetParallel Co-Authored-By: Claude Sonnet 5.5 --- .../Parser/ParallelCsv/CsvSourceResolver.cs | 39 -- .../Parser/ParallelCsv/ParallelCsvFactory.cs | 58 +- .../ParallelCsv/ParallelCsvProcessor.cs | 124 +--- .../PublicAPI/PublicAPI.Shipped.txt | 28 - .../Reader/Csv/CsvParallel.cs | 605 ------------------ .../Reader/Csv/CsvParallelOptions.cs | 21 - .../Reader/IRowAccumulator.cs | 25 +- src/ExcelReader.Core/Reader/IRowRecord.cs | 14 +- src/ExcelReader.Core/Reader/RowAction.cs | 8 +- src/ExcelReader.Core/Reader/RowAggregation.cs | 12 +- src/ExcelReader.Native/Csv/CsvAggregateApi.cs | 40 +- .../Csv/NativeCsvAggregateOptions.cs | 10 +- .../Typed/TypedApi.Parallel.cs | 3 +- .../Parse/CsvParallelParseBenchmark.cs | 16 +- .../Parse/CsvParallelVsSepBenchmark.cs | 5 +- .../Native/CsvAggregateNativeTests.cs | 28 +- .../ParallelCsv/AggregateCsvModelTests.cs | 61 +- .../ParallelCsv/AggregateCsvParallelTests.cs | 72 ++- .../ParallelCsv/CsvReaderChunkSourceTests.cs | 4 +- .../ParallelCsv/ForEachCsvParallelTests.cs | 88 ++- .../ParallelCsv/ParseCsvParallelTests.cs | 39 +- .../Reader/Csv/CsvDialectAndFormatTests.cs | 9 +- 22 files changed, 292 insertions(+), 1017 deletions(-) delete mode 100644 src/ExcelReader.Core/Parser/ParallelCsv/CsvSourceResolver.cs delete mode 100644 src/ExcelReader.Core/Reader/Csv/CsvParallel.cs delete mode 100644 src/ExcelReader.Core/Reader/Csv/CsvParallelOptions.cs diff --git a/src/ExcelReader.Core/Parser/ParallelCsv/CsvSourceResolver.cs b/src/ExcelReader.Core/Parser/ParallelCsv/CsvSourceResolver.cs deleted file mode 100644 index 2f50ec78..00000000 --- a/src/ExcelReader.Core/Parser/ParallelCsv/CsvSourceResolver.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Microsoft.Win32.SafeHandles; - -namespace ExcelReader.Core.Parser.ParallelCsv -{ - internal static class CsvSourceResolver - { - internal static bool TryResolve(Stream stream, out CsvChunkSource source) - { - if (stream is MemoryStream memory && memory.TryGetBuffer(out ArraySegment segment)) - { - int offset = segment.Offset + (int)memory.Position; - int count = segment.Count - (int)memory.Position; - if (count <= 0) - { - source = default; - return false; - } - source = new CsvChunkSource(segment.Array!.AsMemory(offset, count)); - return true; - } - - if (stream is FileStream file) - { - SafeFileHandle handle = file.SafeFileHandle; - long length = RandomAccess.GetLength(handle); - if (file.Position >= length) - { - source = default; - return false; - } - source = new CsvChunkSource(handle, length, startOffset: file.Position); - return true; - } - - source = default; - return false; - } - } -} diff --git a/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvFactory.cs b/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvFactory.cs index 098331a3..34babc94 100644 --- a/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvFactory.cs +++ b/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvFactory.cs @@ -1,10 +1,8 @@ -using System.Diagnostics.CodeAnalysis; using System.Text; using ExcelReader.Core.Parser.Internal; using ExcelReader.Core.Reader; using ExcelReader.Core.Reader.Csv; using ExcelReader.Core.Reader.Internal; -using Microsoft.Win32.SafeHandles; namespace ExcelReader.Core.Parser.ParallelCsv { @@ -12,58 +10,11 @@ internal static class ParallelCsvFactory { private const long MinParallelBytes = 4 * 64 * 1024; - [SuppressMessage("Performance", "CA1849:Call async methods when in an async method", - Justification = "This factory is synchronous by design (see the VSTHRD200 suppression above); opening the file here, once, before any enumeration starts, is deliberate rather than a blocking call inside an async method.")] - internal static IAsyncEnumerable Create( - string path, int degreeOfParallelism, CsvReaderOptions? readerOptions, ExcelParser parser, CancellationToken ct) - { - CsvReaderOptions options = CsvDialectResolver.Resolve(path, readerOptions ?? CsvReaderOptions.Default); - int dop = Normalize(degreeOfParallelism); - - var info = new FileInfo(path); - if (!CanPartition(dop, info.Length, options)) - { - return Sequential(Excel.FromCsvFile(path, options), parser, ct); - } - - SafeFileHandle handle = File.OpenHandle(path, FileMode.Open, FileAccess.Read, FileShare.Read, FileOptions.Asynchronous | FileOptions.RandomAccess); - return Build(new CsvChunkSource(handle, info.Length), options, parser, dop, chunkSizeOverride: 0, ownedHandle: handle, ct); - } - - internal static IAsyncEnumerable Create( - ReadOnlyMemory data, int degreeOfParallelism, CsvReaderOptions? readerOptions, ExcelParser parser, CancellationToken ct) - { - CsvReaderOptions options = CsvDialectResolver.Resolve(data, readerOptions ?? CsvReaderOptions.Default); - int dop = Normalize(degreeOfParallelism); - - if (!CanPartition(dop, data.Length, options)) - { - return Sequential(Excel.FromCsv(data, options), parser, ct); - } - return Build(new CsvChunkSource(data), options, parser, dop, chunkSizeOverride: 0, ownedHandle: null, ct); - } - - [SuppressMessage("Performance", "CA1849:Call async methods when in an async method", - Justification = "This factory is synchronous by design (see the VSTHRD200 suppression above); wrapping the caller-owned stream here, once, before any enumeration starts, is deliberate rather than a blocking call inside an async method.")] - internal static IAsyncEnumerable Create( - Stream stream, int degreeOfParallelism, CsvReaderOptions? readerOptions, ExcelParser parser, CancellationToken ct) - { - CsvReaderOptions options = CsvDialectResolver.Resolve(stream, readerOptions ?? CsvReaderOptions.Default); - int dop = Normalize(degreeOfParallelism); - - if (!CsvSourceResolver.TryResolve(stream, out CsvChunkSource source) - || !CanPartition(dop, source.Length, options)) - { - return Sequential(Excel.FromCsv(stream, leaveOpen: true, options), parser, ct); - } - return Build(source, options, parser, dop, chunkSizeOverride: 0, ownedHandle: null, ct); - } - internal static IAsyncEnumerable CreateWithChunkSize( ReadOnlyMemory data, int degreeOfParallelism, int chunkSize, CsvReaderOptions? readerOptions, ExcelParser parser, CancellationToken ct) { CsvReaderOptions options = CsvDialectResolver.Resolve(data, readerOptions ?? CsvReaderOptions.Default); - return Build(new CsvChunkSource(data), options, parser, Normalize(degreeOfParallelism), chunkSize, ownedHandle: null, ct); + return Build(new CsvChunkSource(data), options, parser, Normalize(degreeOfParallelism), chunkSize, ct); } internal static IAsyncEnumerable Create(CsvReader reader, int degreeOfParallelism, ExcelParser parser, CancellationToken ct) @@ -73,7 +24,7 @@ internal static IAsyncEnumerable Create(CsvReader reader, int degreeOfPara { return new CsvEnumerable(reader.FirstSheet, parser.Config, parser.CsvInfo, ownedReader: null, ct); } - return Build(source, reader.Options, parser, dop, chunkSizeOverride: 0, ownedHandle: null, ct, reader.Lifetime, reader); + return Build(source, reader.Options, parser, dop, chunkSizeOverride: 0, ct, reader.Lifetime, reader); } internal static int Normalize(int degreeOfParallelism) @@ -96,7 +47,6 @@ private static IAsyncEnumerable Build( ExcelParser parser, int dop, int chunkSizeOverride, - SafeFileHandle? ownedHandle, CancellationToken ct, ReaderLifetime? lease = null, object? owner = null) @@ -112,18 +62,16 @@ private static IAsyncEnumerable Build( if (dataStart >= source.Length) { - ownedHandle?.Dispose(); return Empty(); } CsvChunkPlan plan = CsvChunkPlan.Create(dataStart, source.Length - dataStart, dop, chunkSizeOverride); if (plan.Count <= 1) { - ownedHandle?.Dispose(); return Sequential(source.OpenReader(options), parser, ct); } - return new ParallelCsvEnumerable(source, plan, dataStart, map, info, options, config, dop, ownedHandle, lease, owner); + return new ParallelCsvEnumerable(source, plan, dataStart, map, info, options, config, dop, null, lease, owner); } private static CsvEnumerable Sequential( diff --git a/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvProcessor.cs b/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvProcessor.cs index c0101002..31e1e16b 100644 --- a/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvProcessor.cs +++ b/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvProcessor.cs @@ -1,7 +1,6 @@ using System.Runtime.ExceptionServices; using ExcelReader.Core.Reader; using ExcelReader.Core.Reader.Csv; -using Microsoft.Win32.SafeHandles; namespace ExcelReader.Core.Parser.ParallelCsv { @@ -21,60 +20,19 @@ private sealed class ChunkOutcome(long actualStart, TState state) public ExceptionDispatchInfo? Failure; } - internal static async Task RunAsync( - string path, RowAggregation aggregation, RowActionFactory? factory, CsvParallelOptions options, CancellationToken ct) - { - options = WithResolvedDialect(options, CsvDialectResolver.Resolve(path, options.Reader)); - int dop = ParallelCsvFactory.Normalize(options.DegreeOfParallelism); - long length = new FileInfo(path).Length; - if (!ParallelCsvFactory.CanPartition(dop, length, options.Reader)) - { - CsvReader reader = await Excel.FromCsvFileAsync(path, options.Reader, ct).ConfigureAwait(false); - return await SequentialAsync(reader, ownsReader: true, aggregation, factory, options.HeaderRow, ct).ConfigureAwait(false); - } - using SafeFileHandle handle = File.OpenHandle(path, FileMode.Open, FileAccess.Read, FileShare.Read, FileOptions.Asynchronous | FileOptions.RandomAccess); - return await PartitionedAsync(new CsvChunkSource(handle, length), aggregation, factory, options.Reader, options.HeaderRow, dop, chunkSizeOverride: 0, ct).ConfigureAwait(false); - } - - internal static Task RunAsync( - ReadOnlyMemory data, RowAggregation aggregation, RowActionFactory? factory, CsvParallelOptions options, CancellationToken ct) - { - options = WithResolvedDialect(options, CsvDialectResolver.Resolve(data, options.Reader)); - int dop = ParallelCsvFactory.Normalize(options.DegreeOfParallelism); - if (!ParallelCsvFactory.CanPartition(dop, data.Length, options.Reader)) - { - return SequentialAsync(Excel.FromCsv(data, options.Reader), ownsReader: true, aggregation, factory, options.HeaderRow, ct); - } - return PartitionedAsync(new CsvChunkSource(data), aggregation, factory, options.Reader, options.HeaderRow, dop, chunkSizeOverride: 0, ct); - } - - internal static async Task RunAsync( - Stream stream, RowAggregation aggregation, RowActionFactory? factory, CsvParallelOptions options, CancellationToken ct) - { - options = WithResolvedDialect(options, await CsvDialectResolver.ResolveAsync(stream, options.Reader, ct).ConfigureAwait(false)); - int dop = ParallelCsvFactory.Normalize(options.DegreeOfParallelism); - if (!CsvSourceResolver.TryResolve(stream, out CsvChunkSource source) - || !ParallelCsvFactory.CanPartition(dop, source.Length, options.Reader)) - { - CsvReader reader = await Excel.FromCsvAsync(stream, leaveOpen: true, options.Reader, ct).ConfigureAwait(false); - return await SequentialAsync(reader, ownsReader: true, aggregation, factory, options.HeaderRow, ct).ConfigureAwait(false); - } - return await PartitionedAsync(source, aggregation, factory, options.Reader, options.HeaderRow, dop, chunkSizeOverride: 0, ct).ConfigureAwait(false); - } - internal static Task RunWithChunkSizeAsync( - ReadOnlyMemory data, RowAggregation aggregation, RowActionFactory? factory, CsvParallelOptions options, int chunkSize, CancellationToken ct) + ReadOnlyMemory data, RowAggregation aggregation, RowActionFactory? factory, + CsvReaderOptions reader, int headerRow, int degreeOfParallelism, int chunkSize, CancellationToken ct) { - options = WithResolvedDialect(options, CsvDialectResolver.Resolve(data, options.Reader)); - int dop = ParallelCsvFactory.Normalize(options.DegreeOfParallelism); - return PartitionedAsync(new CsvChunkSource(data), aggregation, factory, options.Reader, options.HeaderRow, dop, chunkSize, ct); + CsvReaderOptions resolved = CsvDialectResolver.Resolve(data, reader); + return PartitionedAsync(new CsvChunkSource(data), aggregation, factory, resolved, headerRow, ParallelCsvFactory.Normalize(degreeOfParallelism), chunkSize, ct); } internal static Task RunPartitionedAsync( - CsvChunkSource source, RowAggregation aggregation, CsvParallelOptions options, int chunkSizeOverride, CancellationToken ct) + CsvChunkSource source, RowAggregation aggregation, CsvReaderOptions reader, int headerRow, int degreeOfParallelism, + int chunkSizeOverride, CancellationToken ct) { - int dop = ParallelCsvFactory.Normalize(options.DegreeOfParallelism); - return PartitionedAsync(source, aggregation, null, options.Reader, options.HeaderRow, dop, chunkSizeOverride, ct); + return PartitionedAsync(source, aggregation, null, reader, headerRow, ParallelCsvFactory.Normalize(degreeOfParallelism), chunkSizeOverride, ct); } internal static async Task RunAsync( @@ -86,7 +44,7 @@ internal static async Task RunAsync( int dop = ParallelCsvFactory.Normalize(options.DegreeOfParallelism); if (!reader.TryGetChunkSource(out CsvChunkSource source) || !ParallelCsvFactory.CanPartition(dop, source.Length, reader.Options)) { - return await SequentialAsync(reader, ownsReader: false, aggregation, factory, options.HeaderRow, ct).ConfigureAwait(false); + return await SequentialAsync(reader, aggregation, factory, options.HeaderRow, ct).ConfigureAwait(false); } return await PartitionedAsync(source, aggregation, factory, reader.Options, options.HeaderRow, dop, chunkSizeOverride: 0, ct).ConfigureAwait(false); } @@ -96,67 +54,43 @@ internal static async Task RunAsync( } } - private static CsvParallelOptions WithResolvedDialect(CsvParallelOptions options, CsvReaderOptions reader) - { - if (ReferenceEquals(reader, options.Reader)) - { - return options; - } - return new CsvParallelOptions - { - DegreeOfParallelism = options.DegreeOfParallelism, - Reader = reader, - HeaderRow = options.HeaderRow, - }; - } - private static RowAggregation WithAccumulate(RowAggregation aggregation, RowAction accumulate) { return new RowAggregation { Seed = aggregation.Seed, Accumulate = accumulate, Combine = aggregation.Combine }; } private static async Task SequentialAsync( - CsvReader reader, bool ownsReader, RowAggregation aggregation, RowActionFactory? factory, int headerRow, CancellationToken ct) + CsvReader reader, RowAggregation aggregation, RowActionFactory? factory, int headerRow, CancellationToken ct) { - try + CsvReader.Enumerator rows = reader.OpenSheet(ct); + await using (rows.ConfigureAwait(false)) { - CsvReader.Enumerator rows = reader.OpenSheet(ct); - await using (rows.ConfigureAwait(false)) + TState state = aggregation.Seed(); + if (factory is not null && headerRow == 0) { - TState state = aggregation.Seed(); - if (factory is not null && headerRow == 0) + aggregation = WithAccumulate(aggregation, factory(default, sequential: true)); + } + for (int skipped = 0; skipped < headerRow; skipped++) + { + if (!await rows.MoveNextAsync().ConfigureAwait(false)) { - aggregation = WithAccumulate(aggregation, factory(default, sequential: true)); + return state; } - for (int skipped = 0; skipped < headerRow; skipped++) + if (factory is not null && skipped == headerRow - 1) { - if (!await rows.MoveNextAsync().ConfigureAwait(false)) - { - return state; - } - if (factory is not null && skipped == headerRow - 1) - { - aggregation = WithAccumulate(aggregation, factory(rows.Current, sequential: true)); - } - } - long delivered = 0; - while (await rows.MoveNextAsync().ConfigureAwait(false)) - { - if (++delivered % RowsPerCancellationCheck == 0) - { - ct.ThrowIfCancellationRequested(); - } - Deliver(aggregation.Accumulate, ref state, rows); + aggregation = WithAccumulate(aggregation, factory(rows.Current, sequential: true)); } - return state; } - } - finally - { - if (ownsReader) + long delivered = 0; + while (await rows.MoveNextAsync().ConfigureAwait(false)) { - await reader.DisposeAsync().ConfigureAwait(false); + if (++delivered % RowsPerCancellationCheck == 0) + { + ct.ThrowIfCancellationRequested(); + } + Deliver(aggregation.Accumulate, ref state, rows); } + return state; } } diff --git a/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt b/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt index 848ebba8..a175e6ab 100644 --- a/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt +++ b/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt @@ -128,15 +128,6 @@ ExcelReader.Core.Reader.Csv.CsvDialect.HasByteOrderMark.get -> bool ExcelReader.Core.Reader.Csv.CsvDialect.HasByteOrderMark.init -> void ExcelReader.Core.Reader.Csv.CsvDialect.Quote.get -> byte ExcelReader.Core.Reader.Csv.CsvDialect.Quote.init -> void -ExcelReader.Core.Reader.Csv.CsvParallel -ExcelReader.Core.Reader.Csv.CsvParallelOptions -ExcelReader.Core.Reader.Csv.CsvParallelOptions.CsvParallelOptions() -> void -ExcelReader.Core.Reader.Csv.CsvParallelOptions.DegreeOfParallelism.get -> int -ExcelReader.Core.Reader.Csv.CsvParallelOptions.DegreeOfParallelism.init -> void -ExcelReader.Core.Reader.Csv.CsvParallelOptions.HeaderRow.get -> int -ExcelReader.Core.Reader.Csv.CsvParallelOptions.HeaderRow.init -> void -ExcelReader.Core.Reader.Csv.CsvParallelOptions.Reader.get -> ExcelReader.Core.Reader.Csv.CsvReaderOptions! -ExcelReader.Core.Reader.Csv.CsvParallelOptions.Reader.init -> void ExcelReader.Core.Reader.Csv.CsvReader ExcelReader.Core.Reader.Csv.CsvReader.Dispose() -> void ExcelReader.Core.Reader.Csv.CsvReader.DisposeAsync() -> System.Threading.Tasks.ValueTask @@ -693,25 +684,6 @@ static ExcelReader.Core.Parser.ExcelRowContext.operator ==(ExcelReader.Core.Pars static ExcelReader.Core.Reader.Csv.CsvDialect.Default.get -> ExcelReader.Core.Reader.Csv.CsvDialect static ExcelReader.Core.Reader.Csv.CsvDialect.operator !=(ExcelReader.Core.Reader.Csv.CsvDialect left, ExcelReader.Core.Reader.Csv.CsvDialect right) -> bool static ExcelReader.Core.Reader.Csv.CsvDialect.operator ==(ExcelReader.Core.Reader.Csv.CsvDialect left, ExcelReader.Core.Reader.Csv.CsvDialect right) -> bool -static ExcelReader.Core.Reader.Csv.CsvParallel.AggregateAsync(System.IO.Stream! stream, ExcelReader.Core.Parser.ExcelParser! parser, ExcelReader.Core.Reader.Csv.CsvParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -static ExcelReader.Core.Reader.Csv.CsvParallel.AggregateAsync(System.ReadOnlyMemory data, ExcelReader.Core.Parser.ExcelParser! parser, ExcelReader.Core.Reader.Csv.CsvParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -static ExcelReader.Core.Reader.Csv.CsvParallel.AggregateAsync(string! path, ExcelReader.Core.Parser.ExcelParser! parser, ExcelReader.Core.Reader.Csv.CsvParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -static ExcelReader.Core.Reader.Csv.CsvParallel.AggregateAsync(System.IO.Stream! stream, ExcelReader.Core.Reader.Csv.CsvParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -static ExcelReader.Core.Reader.Csv.CsvParallel.AggregateAsync(System.ReadOnlyMemory data, ExcelReader.Core.Reader.Csv.CsvParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -static ExcelReader.Core.Reader.Csv.CsvParallel.AggregateAsync(string! path, ExcelReader.Core.Reader.Csv.CsvParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -static ExcelReader.Core.Reader.Csv.CsvParallel.AggregateAsync(System.IO.Stream! stream, ExcelReader.Core.Reader.RowAggregation! aggregation, ExcelReader.Core.Reader.Csv.CsvParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -static ExcelReader.Core.Reader.Csv.CsvParallel.AggregateAsync(System.ReadOnlyMemory data, ExcelReader.Core.Reader.RowAggregation! aggregation, ExcelReader.Core.Reader.Csv.CsvParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -static ExcelReader.Core.Reader.Csv.CsvParallel.AggregateAsync(string! path, ExcelReader.Core.Reader.RowAggregation! aggregation, ExcelReader.Core.Reader.Csv.CsvParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -static ExcelReader.Core.Reader.Csv.CsvParallel.ForEachAsync(System.IO.Stream! stream, ExcelReader.Core.Parser.ExcelParser! parser, System.Action! body, ExcelReader.Core.Reader.Csv.CsvParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -static ExcelReader.Core.Reader.Csv.CsvParallel.ForEachAsync(System.ReadOnlyMemory data, ExcelReader.Core.Parser.ExcelParser! parser, System.Action! body, ExcelReader.Core.Reader.Csv.CsvParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -static ExcelReader.Core.Reader.Csv.CsvParallel.ForEachAsync(string! path, ExcelReader.Core.Parser.ExcelParser! parser, System.Action! body, ExcelReader.Core.Reader.Csv.CsvParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -static ExcelReader.Core.Reader.Csv.CsvParallel.ForEachAsync(System.IO.Stream! stream, System.Action! body, ExcelReader.Core.Reader.Csv.CsvParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -static ExcelReader.Core.Reader.Csv.CsvParallel.ForEachAsync(System.ReadOnlyMemory data, System.Action! body, ExcelReader.Core.Reader.Csv.CsvParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -static ExcelReader.Core.Reader.Csv.CsvParallel.ForEachAsync(string! path, System.Action! body, ExcelReader.Core.Reader.Csv.CsvParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -static ExcelReader.Core.Reader.Csv.CsvParallel.ParseAsync(System.IO.Stream! stream, ExcelReader.Core.Parser.ExcelParser! parser, ExcelReader.Core.Reader.Csv.CsvParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable! -static ExcelReader.Core.Reader.Csv.CsvParallel.ParseAsync(System.ReadOnlyMemory data, ExcelReader.Core.Parser.ExcelParser! parser, ExcelReader.Core.Reader.Csv.CsvParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable! -static ExcelReader.Core.Reader.Csv.CsvParallel.ParseAsync(string! path, ExcelReader.Core.Parser.ExcelParser! parser, ExcelReader.Core.Reader.Csv.CsvParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable! -static ExcelReader.Core.Reader.Csv.CsvParallelOptions.Default.get -> ExcelReader.Core.Reader.Csv.CsvParallelOptions! static ExcelReader.Core.Reader.Csv.CsvReaderOptions.Default.get -> ExcelReader.Core.Reader.Csv.CsvReaderOptions! static ExcelReader.Core.Reader.Csv.CsvReaderOptions.operator !=(ExcelReader.Core.Reader.Csv.CsvReaderOptions? left, ExcelReader.Core.Reader.Csv.CsvReaderOptions? right) -> bool static ExcelReader.Core.Reader.Csv.CsvReaderOptions.operator ==(ExcelReader.Core.Reader.Csv.CsvReaderOptions? left, ExcelReader.Core.Reader.Csv.CsvReaderOptions? right) -> bool diff --git a/src/ExcelReader.Core/Reader/Csv/CsvParallel.cs b/src/ExcelReader.Core/Reader/Csv/CsvParallel.cs deleted file mode 100644 index a19b0445..00000000 --- a/src/ExcelReader.Core/Reader/Csv/CsvParallel.cs +++ /dev/null @@ -1,605 +0,0 @@ -using ExcelReader.Core.Parser; -using ExcelReader.Core.Parser.ParallelCsv; - -namespace ExcelReader.Core.Reader.Csv -{ - /// - /// Reads CSV across several threads: - /// yields typed rows in source order, ForEachAsync hands each record to a callback on the worker - /// threads, and AggregateAsync folds records into one accumulator per partition. - /// - public static class CsvParallel - { - /// - /// Parses a CSV file into instances across several threads, yielding the - /// same sequence, in the same order, that the sequential produces. - /// - /// The row model type to bind each CSV record to. - /// The path of the CSV file to read. - /// How columns bind to ; created with . Its locates the header. - /// Parallelism and dialect options. Defaults to . does not apply here and must be left at 0. - /// A token to cancel enumeration. - /// - /// - /// Parsing and type conversion run in parallel; whatever the caller does per row does not. A caller - /// whose per-row work dominates will see little gain from raising . - /// - /// - /// Falls back to sequential parsing — same results, one thread — when the source is too small to - /// partition usefully, or when is set to a non-UTF-8 encoding, - /// which must be transcoded sequentially. - /// - /// - /// yields substantially less here than on the sequential - /// path: each partition keeps its own dedup cache, so hit rates fall and memory multiplies. Results - /// are unaffected. - /// - /// - /// Each row is yielded exactly once. A partition read from a wrongly guessed start is discarded before - /// any of its rows are yielded, but its cells have already passed through the parser's converters and - /// property setters, which must therefore be free of side effects. - /// - /// - public static IAsyncEnumerable ParseAsync( - string path, - ExcelParser parser, - CsvParallelOptions? options = null, - CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(parser); - ArgumentException.ThrowIfNullOrEmpty(path); - CsvParallelOptions parallel = ValidatedForTypedParse(options); - return ParallelCsvFactory.Create(path, parallel.DegreeOfParallelism, parallel.Reader, parser, ct); - } - - /// - /// Parses an in-memory CSV buffer into instances across several threads, - /// yielding the same sequence, in the same order, that the sequential produces. - /// - /// The row model type to bind each CSV record to. - /// The CSV bytes. The caller keeps ownership; the buffer must not be mutated during enumeration. - /// How columns bind to ; created with . Its locates the header. - /// Parallelism and dialect options. Defaults to . does not apply here and must be left at 0. - /// A token to cancel enumeration. - /// Carries the same fallback, and exactly-once caveats as the path-based overload. - public static IAsyncEnumerable ParseAsync( - ReadOnlyMemory data, - ExcelParser parser, - CsvParallelOptions? options = null, - CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(parser); - CsvParallelOptions parallel = ValidatedForTypedParse(options); - return ParallelCsvFactory.Create(data, parallel.DegreeOfParallelism, parallel.Reader, parser, ct); - } - - /// - /// Parses a CSV stream into instances, in parallel where the stream can be - /// partitioned, yielding the same sequence, in the same order, that the sequential - /// produces. - /// - /// The row model type to bind each CSV record to. - /// The CSV stream, read from its current position. The caller keeps ownership and must not read from it concurrently. - /// How columns bind to ; created with . Its locates the header. - /// Parallelism and dialect options. Defaults to . does not apply here and must be left at 0. - /// A token to cancel enumeration. - /// - /// - /// Only a , or a whose buffer is publicly - /// visible, can be partitioned; every other stream — including seekable ones — parses sequentially, - /// with identical results. A stream exposes a single mutable position, and a seek can be - /// arbitrarily expensive, so this overload partitions only what it can read positionally and - /// cheaply rather than promising parallelism it cannot deliver. - /// - /// - /// Passing a reads its , which - /// flushes that stream's internal buffer and disables its subsequent buffering optimizations. The - /// stream's position is not moved. Prefer the path-based overload where a path is available. - /// - /// Carries the same and exactly-once caveats as the other overloads. - /// - public static IAsyncEnumerable ParseAsync( - Stream stream, - ExcelParser parser, - CsvParallelOptions? options = null, - CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(parser); - ArgumentNullException.ThrowIfNull(stream); - CsvParallelOptions parallel = ValidatedForTypedParse(options); - return ParallelCsvFactory.Create(stream, parallel.DegreeOfParallelism, parallel.Reader, parser, ct); - } - - /// - /// Reads an in-memory CSV buffer across several threads, parses every record into a - /// and folds it into a , one - /// instance per partition, merged in buffer order. - /// - /// The accumulator type. See for the contract it must honor. - /// The record type. A record whose returns is skipped. - /// The CSV bytes. The caller keeps ownership; the buffer must not be mutated during processing. - /// Parallelism, dialect and header options. Defaults to . - /// A token to cancel processing. - /// The accumulator holding every record. - /// - /// - /// Partitions start at guessed record boundaries; one whose guess landed inside a quoted field - /// spanning lines is read again into a new accumulator. The result holds every record exactly once, - /// but and - /// may already have run for the discarded pass's records, as describes. - /// - /// - /// Falls back to one sequential pass, with a single accumulator and no call to - /// , when the source is too small to partition usefully - /// or when is set to a non-UTF-8 encoding. - /// - /// - public static Task AggregateAsync( - ReadOnlyMemory data, - CsvParallelOptions? options = null, - CancellationToken ct = default) - where TAccumulator : IRowAccumulator, new() - where TRecord : IRowRecord, allows ref struct - { - return ParallelCsvProcessor.RunAsync(data, RecordAggregation.Instance, null, Validated(options), ct); - } - - /// - /// Reads a CSV file across several threads, parses every record into a - /// and folds it into a , one instance per partition, merged in file order. - /// - /// The accumulator type. See for the contract it must honor. - /// The record type. A record whose returns is skipped. - /// The path of the CSV file to read. - /// Parallelism, dialect and header options. Defaults to . - /// A token to cancel processing. - /// The accumulator holding every record. - /// Carries the same contract and fallbacks as the in-memory overload. - public static Task AggregateAsync( - string path, - CsvParallelOptions? options = null, - CancellationToken ct = default) - where TAccumulator : IRowAccumulator, new() - where TRecord : IRowRecord, allows ref struct - { - ArgumentException.ThrowIfNullOrEmpty(path); - return ParallelCsvProcessor.RunAsync(path, RecordAggregation.Instance, null, Validated(options), ct); - } - - /// - /// Reads a CSV stream, in parallel where the stream can be partitioned, parses every record into a - /// and folds it into a , one instance - /// per partition, merged in stream order. - /// - /// The accumulator type. See for the contract it must honor. - /// The record type. A record whose returns is skipped. - /// The CSV stream, read from its current position. The caller keeps ownership and must not read from it concurrently. - /// Parallelism, dialect and header options. Defaults to . - /// A token to cancel processing. - /// The accumulator holding every record. - /// - /// Carries the same contract and fallbacks as the in-memory overload, and partitions only a - /// or a whose buffer is publicly visible; - /// every other stream is read sequentially. - /// - public static Task AggregateAsync( - Stream stream, - CsvParallelOptions? options = null, - CancellationToken ct = default) - where TAccumulator : IRowAccumulator, new() - where TRecord : IRowRecord, allows ref struct - { - ArgumentNullException.ThrowIfNull(stream); - return ParallelCsvProcessor.RunAsync(stream, RecordAggregation.Instance, null, Validated(options), ct); - } - - /// - /// Reads a CSV file across several threads, parses every record into a - /// and hands it to . - /// - /// The record type. A record whose returns is skipped. - /// The path of the CSV file to read. - /// Receives each record. See the remarks for the contract it must honor. - /// Parallelism, dialect and header options. Defaults to . - /// A token to cancel processing. - /// A task that completes when every record has been delivered. - /// is . - /// - /// - /// runs concurrently on worker threads and must be safe to call from - /// several at once; the caller owns any synchronization. Records arrive in no particular order. - /// - /// - /// Delivery is at least once, not exactly once: may be invoked more than once - /// for the same record. A partition whose start was guessed wrongly is read again from the confirmed - /// offset, and the discarded speculative pass may already have delivered a whole partition's worth of - /// records — 1 MiB to 64 MiB of them, not just a few near the seam. Those records may also be misparsed: - /// a wrong start shifts every field boundary, so a quoted field holding a delimiter can split into values - /// that appear nowhere in the source. An exception throws during a discarded - /// pass is discarded with it. - /// - /// - /// A with side effects must therefore be idempotent, and must also tolerate - /// records that do not exist; deduplicating on a key does not filter those out. A start is only guessed - /// wrongly when it falls inside a quoted field, so a source in which the quote character never appears - /// delivers every record exactly once. For that guarantee on any source, use - /// , - /// where the discarded partition's accumulator is thrown away, and apply the side effects once it returns. - /// - /// - /// A column points into a pooled buffer the worker reuses once - /// returns. Copy anything that must outlive the call. - /// - /// - /// Falls back to one sequential pass, delivering records once each in source order on a single - /// thread, when the source is too small to partition usefully or when - /// is set to a non-UTF-8 encoding. - /// - /// - public static Task ForEachAsync( - string path, - Action body, - CsvParallelOptions? options = null, - CancellationToken ct = default) - where TRecord : IRowRecord, allows ref struct - { - ArgumentException.ThrowIfNullOrEmpty(path); - ArgumentNullException.ThrowIfNull(body); - return ParallelCsvProcessor.RunAsync(path, RecordCallback.For(body), null, Validated(options), ct); - } - - /// - /// Reads an in-memory CSV buffer across several threads, parses every record into a - /// and hands it to . - /// - /// The record type. A record whose returns is skipped. - /// The CSV bytes. The caller keeps ownership; the buffer must not be mutated during processing. - /// Receives each record. See the remarks for the contract it must honor. - /// Parallelism, dialect and header options. Defaults to . - /// A token to cancel processing. - /// A task that completes when every record has been delivered. - /// is . - /// Carries the same contract and fallbacks as the path-based overload. - public static Task ForEachAsync( - ReadOnlyMemory data, - Action body, - CsvParallelOptions? options = null, - CancellationToken ct = default) - where TRecord : IRowRecord, allows ref struct - { - ArgumentNullException.ThrowIfNull(body); - return ParallelCsvProcessor.RunAsync(data, RecordCallback.For(body), null, Validated(options), ct); - } - - /// - /// Reads a CSV stream, in parallel where the stream can be partitioned, parses every record into a - /// and hands it to . - /// - /// The record type. A record whose returns is skipped. - /// The CSV stream, read from its current position. The caller keeps ownership and must not read from it concurrently. - /// Receives each record. See the remarks for the contract it must honor. - /// Parallelism, dialect and header options. Defaults to . - /// A token to cancel processing. - /// A task that completes when every record has been delivered. - /// or is . - /// - /// Carries the same contract and fallbacks as the path-based overload, and partitions only a - /// or a whose buffer is publicly visible; - /// every other stream is read sequentially. - /// - public static Task ForEachAsync( - Stream stream, - Action body, - CsvParallelOptions? options = null, - CancellationToken ct = default) - where TRecord : IRowRecord, allows ref struct - { - ArgumentNullException.ThrowIfNull(stream); - ArgumentNullException.ThrowIfNull(body); - return ParallelCsvProcessor.RunAsync(stream, RecordCallback.For(body), null, Validated(options), ct); - } - - /// - /// Reads a CSV file across several threads, binds every record to a - /// through and hands it to . - /// - /// The model type. May be a with of properties. - /// The path of the CSV file to read. - /// How columns bind to ; created with . Its is ignored in favour of . - /// Receives each record. See the remarks for the contract it must honor. - /// Parallelism, dialect and header options. Defaults to . - /// A token to cancel processing. - /// A task that completes when every record has been delivered. - /// or is . - /// binds columns by header name and is 0. - /// - /// - /// Carries the same concurrency, repeat-invocation and span-lifetime contract as the - /// overloads. A property that fails to parse keeps its default - /// unless is set; a missing - /// [ExcelRequired] column or value throws ; empty - /// records are skipped. - /// - /// - /// is exact when the source is read sequentially and - /// 0 when it is partitioned, since a partition does not know how many records precede it. - /// - /// - public static Task ForEachAsync( - string path, - ExcelParser parser, - Action body, - CsvParallelOptions? options = null, - CancellationToken ct = default) - where TModel : allows ref struct - { - ArgumentException.ThrowIfNullOrEmpty(path); - CsvParallelOptions validated = Validated(options); - return ParallelCsvProcessor.RunAsync(path, MappedCallback.Unbound, BoundCallback(parser, body, validated), validated, ct); - } - - /// - /// Reads an in-memory CSV buffer across several threads, binds every record to a - /// through and hands it to . - /// - /// The model type. May be a with of properties. - /// The CSV bytes. The caller keeps ownership; the buffer must not be mutated during processing. - /// How columns bind to ; created with . Its is ignored in favour of . - /// Receives each record. See the remarks for the contract it must honor. - /// Parallelism, dialect and header options. Defaults to . - /// A token to cancel processing. - /// A task that completes when every record has been delivered. - /// or is . - /// binds columns by header name and is 0. - /// Carries the same contract and fallbacks as the path-based overload. - public static Task ForEachAsync( - ReadOnlyMemory data, - ExcelParser parser, - Action body, - CsvParallelOptions? options = null, - CancellationToken ct = default) - where TModel : allows ref struct - { - CsvParallelOptions validated = Validated(options); - return ParallelCsvProcessor.RunAsync(data, MappedCallback.Unbound, BoundCallback(parser, body, validated), validated, ct); - } - - /// - /// Reads a CSV stream, in parallel where the stream can be partitioned, binds every record to a - /// through and hands it to . - /// - /// The model type. May be a with of properties. - /// The CSV stream, read from its current position. The caller keeps ownership and must not read from it concurrently. - /// How columns bind to ; created with . Its is ignored in favour of . - /// Receives each record. See the remarks for the contract it must honor. - /// Parallelism, dialect and header options. Defaults to . - /// A token to cancel processing. - /// A task that completes when every record has been delivered. - /// , or is . - /// binds columns by header name and is 0. - /// - /// Carries the same contract and fallbacks as the path-based overload, and the stream restrictions - /// of the stream overload. - /// - public static Task ForEachAsync( - Stream stream, - ExcelParser parser, - Action body, - CsvParallelOptions? options = null, - CancellationToken ct = default) - where TModel : allows ref struct - { - ArgumentNullException.ThrowIfNull(stream); - CsvParallelOptions validated = Validated(options); - return ParallelCsvProcessor.RunAsync(stream, MappedCallback.Unbound, BoundCallback(parser, body, validated), validated, ct); - } - - /// - /// Reads an in-memory CSV buffer across several threads, binds every record to a - /// through and folds it into a - /// , one instance per partition, merged in buffer order. - /// - /// The accumulator type. See for the contract it must honor. - /// The model type. - /// The CSV bytes. The caller keeps ownership; the buffer must not be mutated during processing. - /// How columns bind to ; created with . Its is ignored in favour of . - /// Parallelism, dialect and header options. Defaults to . - /// A token to cancel processing. - /// The accumulator holding every record. - /// binds columns by header name and is 0. - /// - /// - /// The header is bound once, before any record is folded. A property that fails to parse keeps its - /// default unless is set; a missing - /// [ExcelRequired] column or value throws ; empty records are skipped. - /// - /// - /// is exact when the source is read sequentially and 0 when it is - /// partitioned, since a partition does not know how many records precede it. - /// - /// Otherwise carries the same contract and fallbacks as the overloads. - /// - public static Task AggregateAsync( - ReadOnlyMemory data, - ExcelParser parser, - CsvParallelOptions? options = null, - CancellationToken ct = default) - where TAccumulator : IRowAccumulator, new() - where TModel : allows ref struct - { - CsvParallelOptions validated = Validated(options); - return ParallelCsvProcessor.RunAsync(data, MappedAggregation.Unbound, Bound(parser, validated), validated, ct); - } - - /// - /// Reads a CSV file across several threads, binds every record to a - /// through and folds it into a , one instance - /// per partition, merged in file order. - /// - /// The accumulator type. See for the contract it must honor. - /// The model type. - /// The path of the CSV file to read. - /// How columns bind to ; created with . Its is ignored in favour of . - /// Parallelism, dialect and header options. Defaults to . - /// A token to cancel processing. - /// The accumulator holding every record. - /// binds columns by header name and is 0. - /// Carries the same contract and fallbacks as the in-memory overload. - public static Task AggregateAsync( - string path, - ExcelParser parser, - CsvParallelOptions? options = null, - CancellationToken ct = default) - where TAccumulator : IRowAccumulator, new() - where TModel : allows ref struct - { - ArgumentException.ThrowIfNullOrEmpty(path); - CsvParallelOptions validated = Validated(options); - return ParallelCsvProcessor.RunAsync(path, MappedAggregation.Unbound, Bound(parser, validated), validated, ct); - } - - /// - /// Reads a CSV stream, in parallel where the stream can be partitioned, binds every record to a - /// through and folds it into a - /// , one instance per partition, merged in stream order. - /// - /// The accumulator type. See for the contract it must honor. - /// The model type. - /// The CSV stream, read from its current position. The caller keeps ownership and must not read from it concurrently. - /// How columns bind to ; created with . Its is ignored in favour of . - /// Parallelism, dialect and header options. Defaults to . - /// A token to cancel processing. - /// The accumulator holding every record. - /// binds columns by header name and is 0. - /// Carries the same contract and fallbacks as the in-memory overload, and the stream restrictions of the stream overload. - public static Task AggregateAsync( - Stream stream, - ExcelParser parser, - CsvParallelOptions? options = null, - CancellationToken ct = default) - where TAccumulator : IRowAccumulator, new() - where TModel : allows ref struct - { - ArgumentNullException.ThrowIfNull(stream); - CsvParallelOptions validated = Validated(options); - return ParallelCsvProcessor.RunAsync(stream, MappedAggregation.Unbound, Bound(parser, validated), validated, ct); - } - - private static RowActionFactory Bound(ExcelParser parser, CsvParallelOptions options) - where TAccumulator : IRowAccumulator, new() - where TModel : allows ref struct - { - ArgumentNullException.ThrowIfNull(parser); - if (options.HeaderRow == 0 && !parser.CsvInfo.IsIndexBased) - { - throw new ArgumentException("A parser that binds columns by header name needs CsvParallelOptions.HeaderRow of at least 1.", nameof(parser)); - } - return MappedAggregation.Binder(parser, parser.CsvInfo, false, options.HeaderRow); - } - - private static RowActionFactory BoundCallback( - ExcelParser parser, Action body, CsvParallelOptions options) - where TModel : allows ref struct - { - ArgumentNullException.ThrowIfNull(parser); - ArgumentNullException.ThrowIfNull(body); - if (options.HeaderRow == 0 && !parser.CsvInfo.IsIndexBased) - { - throw new ArgumentException("A parser that binds columns by header name needs CsvParallelOptions.HeaderRow of at least 1.", nameof(parser)); - } - return MappedCallback.Binder(parser, parser.CsvInfo, false, options.HeaderRow, body); - } - - /// - /// Reads an in-memory CSV buffer across several threads and folds every record with the functions of - /// , one accumulator per partition, combined in buffer order. - /// - /// The accumulator type. - /// The CSV bytes. The caller keeps ownership; the buffer must not be mutated during processing. - /// The seed, accumulate and combine functions. They carry the same contract as . - /// Parallelism, dialect and header options. Defaults to . - /// A token to cancel processing. - /// The combined accumulator. - /// Carries the same fallbacks as the overloads. - public static Task AggregateAsync( - ReadOnlyMemory data, - RowAggregation aggregation, - CsvParallelOptions? options = null, - CancellationToken ct = default) - { - return ParallelCsvProcessor.RunAsync(data, Validated(aggregation), null, Validated(options), ct); - } - - /// - /// Reads a CSV file across several threads and folds every record with the functions of - /// , one accumulator per partition, combined in file order. - /// - /// The accumulator type. - /// The path of the CSV file to read. - /// The seed, accumulate and combine functions. They carry the same contract as . - /// Parallelism, dialect and header options. Defaults to . - /// A token to cancel processing. - /// The combined accumulator. - /// Carries the same fallbacks as the overloads. - public static Task AggregateAsync( - string path, - RowAggregation aggregation, - CsvParallelOptions? options = null, - CancellationToken ct = default) - { - ArgumentException.ThrowIfNullOrEmpty(path); - return ParallelCsvProcessor.RunAsync(path, Validated(aggregation), null, Validated(options), ct); - } - - /// - /// Reads a CSV stream, in parallel where the stream can be partitioned, and folds every record with the - /// functions of , one accumulator per partition, combined in stream order. - /// - /// The accumulator type. - /// The CSV stream, read from its current position. The caller keeps ownership and must not read from it concurrently. - /// The seed, accumulate and combine functions. They carry the same contract as . - /// Parallelism, dialect and header options. Defaults to . - /// A token to cancel processing. - /// The combined accumulator. - /// Carries the same fallbacks and stream restrictions as the stream overload. - public static Task AggregateAsync( - Stream stream, - RowAggregation aggregation, - CsvParallelOptions? options = null, - CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(stream); - return ParallelCsvProcessor.RunAsync(stream, Validated(aggregation), null, Validated(options), ct); - } - - private static RowAggregation Validated(RowAggregation aggregation) - { - ArgumentNullException.ThrowIfNull(aggregation); - ArgumentNullException.ThrowIfNull(aggregation.Seed, nameof(aggregation)); - ArgumentNullException.ThrowIfNull(aggregation.Accumulate, nameof(aggregation)); - ArgumentNullException.ThrowIfNull(aggregation.Combine, nameof(aggregation)); - return aggregation; - } - - private static CsvParallelOptions Validated(CsvParallelOptions? options) - { - options ??= CsvParallelOptions.Default; - ArgumentOutOfRangeException.ThrowIfNegative(options.DegreeOfParallelism, nameof(options)); - ArgumentOutOfRangeException.ThrowIfNegative(options.HeaderRow, nameof(options)); - ArgumentNullException.ThrowIfNull(options.Reader, nameof(options)); - return options; - } - - private static CsvParallelOptions ValidatedForTypedParse(CsvParallelOptions? options) - { - CsvParallelOptions validated = Validated(options); - if (validated.HeaderRow != 0) - { - throw new ArgumentException( - $"{nameof(CsvParallelOptions)}.{nameof(CsvParallelOptions.HeaderRow)} does not apply to typed parsing; set {nameof(ExcelParserConfig)}.{nameof(ExcelParserConfig.HeaderRow)} instead.", - nameof(options)); - } - return validated; - } - } -} diff --git a/src/ExcelReader.Core/Reader/Csv/CsvParallelOptions.cs b/src/ExcelReader.Core/Reader/Csv/CsvParallelOptions.cs deleted file mode 100644 index 62a43107..00000000 --- a/src/ExcelReader.Core/Reader/Csv/CsvParallelOptions.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace ExcelReader.Core.Reader.Csv -{ - /// Options for reading a CSV source across several threads. - public sealed class CsvParallelOptions - { - /// Gets the default options: one thread per processor, the default dialect, no header. - public static CsvParallelOptions Default { get; } = new(); - - /// Gets the maximum number of reading threads. 0, the default, means ; 1 reads sequentially. - public int DegreeOfParallelism { get; init; } - - /// Gets the CSV dialect options. Defaults to . - public CsvReaderOptions Reader { get; init; } = CsvReaderOptions.Default; - - /// - /// Gets the 1-based number of the header record; it and every record before it are skipped. - /// 0, the default, means the source has no header. - /// - public int HeaderRow { get; init; } - } -} diff --git a/src/ExcelReader.Core/Reader/IRowAccumulator.cs b/src/ExcelReader.Core/Reader/IRowAccumulator.cs index ea0bcfdf..ec30d5b2 100644 --- a/src/ExcelReader.Core/Reader/IRowAccumulator.cs +++ b/src/ExcelReader.Core/Reader/IRowAccumulator.cs @@ -1,35 +1,34 @@ -using ExcelReader.Core.Reader.Csv; - namespace ExcelReader.Core.Reader { /// - /// Folds typed CSV records read by CsvParallel.AggregateAsync into a result. Each partition - /// of the source gets its own instance. + /// Folds typed rows read by SheetParallel.AggregateAsync into a result. Each partition + /// of the sheet gets its own instance. /// /// The implementing type. /// The record type, produced by or by an . /// /// /// runs concurrently across instances on worker threads, and must mutate only the - /// instance it is called on. A partition that started at a misguessed record boundary is read again - /// into a new instance and the first one is discarded, so an instance may see records that are later - /// thrown away — including records whose parse or threw, and records misparsed from - /// the wrong start that appear nowhere in the source. A side effect outside the instance is not undone. + /// instance it is called on. For CSV, a partition that started at a misguessed row boundary is read again + /// into a new instance and the first one is discarded, so an instance may see rows that are later + /// thrown away — including rows whose parse or threw, and rows misparsed from + /// the wrong start that appear nowhere in the sheet. A side effect outside the instance is not undone. + /// XLSX and XLSB never re-read a partition. /// /// - /// runs on a single thread, left to right in source order, folding each later - /// partition into the accumulator of everything before it. It may receive an instance that saw no records. + /// runs on a single thread, left to right in sheet order, folding each later + /// partition into the accumulator of everything before it. It may receive an instance that saw no rows. /// /// public interface IRowAccumulator where TSelf : IRowAccumulator where TModel : allows ref struct { - /// Folds one record into this instance. - /// The record. Spans it holds are valid only for the duration of the call. + /// Folds one row into this instance. + /// The row. Spans it holds are valid only for the duration of the call. void Add(TModel model); - /// Folds the records of the partition that immediately follows this one into this instance. + /// Folds the rows of the partition that immediately follows this one into this instance. /// The accumulator of the following partition. It is not used again afterwards. void Merge(TSelf following); } diff --git a/src/ExcelReader.Core/Reader/IRowRecord.cs b/src/ExcelReader.Core/Reader/IRowRecord.cs index 0706fdb3..9f637ba9 100644 --- a/src/ExcelReader.Core/Reader/IRowRecord.cs +++ b/src/ExcelReader.Core/Reader/IRowRecord.cs @@ -1,19 +1,17 @@ -using ExcelReader.Core.Reader.Csv; - namespace ExcelReader.Core.Reader { /// - /// A typed CSV record that parses itself from a , for - /// . + /// A typed row that parses itself from a , for + /// . /// /// The implementing type. May be a holding spans taken from the row. public interface IRowRecord where TSelf : IRowRecord, allows ref struct { - /// Parses one record. - /// The record. Spans taken from it stay valid only until the accumulator's Add returns. - /// The parsed record. - /// to skip the record. + /// Parses one row. + /// The row. Spans taken from it stay valid only until the accumulator's Add returns. + /// The parsed row. + /// to skip the row. static abstract bool TryParse(Row row, out TSelf record); } } diff --git a/src/ExcelReader.Core/Reader/RowAction.cs b/src/ExcelReader.Core/Reader/RowAction.cs index 4557fa70..ae8a6698 100644 --- a/src/ExcelReader.Core/Reader/RowAction.cs +++ b/src/ExcelReader.Core/Reader/RowAction.cs @@ -1,10 +1,8 @@ -using ExcelReader.Core.Reader.Csv; - namespace ExcelReader.Core.Reader { - /// Processes one CSV record into the state owned by the partition that read it. + /// Processes one row into the state owned by the partition that read it. /// The per-partition accumulator type. - /// The accumulator of the partition the record belongs to. Mutate only this. - /// The record. Valid only for the duration of the call. + /// The accumulator of the partition the row belongs to. Mutate only this. + /// The row. Valid only for the duration of the call. public delegate void RowAction(ref TState state, Row row); } diff --git a/src/ExcelReader.Core/Reader/RowAggregation.cs b/src/ExcelReader.Core/Reader/RowAggregation.cs index b23e0e18..c09269db 100644 --- a/src/ExcelReader.Core/Reader/RowAggregation.cs +++ b/src/ExcelReader.Core/Reader/RowAggregation.cs @@ -1,22 +1,20 @@ -using ExcelReader.Core.Reader.Csv; - namespace ExcelReader.Core.Reader { /// - /// A fold over CSV records expressed as three functions, for - /// . + /// A fold over rows expressed as three functions, for + /// . /// Carries the same contract as , without requiring a type. /// - /// The accumulator type. Each partition of the source owns one instance. + /// The accumulator type. Each partition of the sheet owns one instance. public sealed class RowAggregation { /// Gets the function that creates an empty accumulator. Called once per partition, possibly more. public required Func Seed { get; init; } - /// Gets the function that folds one record into its partition's accumulator. Runs concurrently on worker threads and must mutate only the accumulator it is given. + /// Gets the function that folds one row into its partition's accumulator. Runs concurrently on worker threads and must mutate only the accumulator it is given. public required RowAction Accumulate { get; init; } - /// Gets the function that folds the following partition's accumulator into the preceding one. Called on one thread, left to right in source order. + /// Gets the function that folds the following partition's accumulator into the preceding one. Called on one thread, left to right in sheet order. public required Func Combine { get; init; } } } diff --git a/src/ExcelReader.Native/Csv/CsvAggregateApi.cs b/src/ExcelReader.Native/Csv/CsvAggregateApi.cs index c3fc08f3..213e9a13 100644 --- a/src/ExcelReader.Native/Csv/CsvAggregateApi.cs +++ b/src/ExcelReader.Native/Csv/CsvAggregateApi.cs @@ -65,7 +65,7 @@ internal static int AggregateCsvFile( { NativeApi.ClearLastError(); result = 0; - int status = NativeCsvAggregateOptions.Translate(rawOptions, out CsvParallelOptions options); + int status = NativeCsvAggregateOptions.Translate(rawOptions, out SheetParallelOptions options, out CsvReaderOptions reader); if (status != NativeStatus.Ok) { return status; @@ -73,10 +73,7 @@ internal static int AggregateCsvFile( string path = Encoding.UTF8.GetString(utf8Path); CsvAggregateContext context = new(aggregation); - return Run( - context, - Task.Run(() => CsvParallel.AggregateAsync(path, Build(context), options, CancellationToken.None)), - out result); + return Run(context, Task.Run(() => CsvAggregateFile.AggregateAsync(path, reader, Build(context), options)), out result); } internal static int AggregateCsvMemory( @@ -85,18 +82,24 @@ internal static int AggregateCsvMemory( { NativeApi.ClearLastError(); result = 0; - int status = NativeCsvAggregateOptions.Translate(rawOptions, out CsvParallelOptions options); + int status = NativeCsvAggregateOptions.Translate(rawOptions, out SheetParallelOptions options, out CsvReaderOptions reader); if (status != NativeStatus.Ok) { return status; } - using PointerMemoryManager manager = new(data, dataLength); - CsvAggregateContext context = new(aggregation); - return Run( - context, - Task.Run(() => CsvParallel.AggregateAsync(manager.Memory, Build(context), options, CancellationToken.None)), - out result); + try + { + using PointerMemoryManager manager = new(data, dataLength); + CsvAggregateContext context = new(aggregation); + using CsvReader csv = Excel.FromCsv(manager.Memory, reader); + return Run(context, Task.Run(() => SheetParallel.AggregateAsync(csv.FirstSheet, Build(context), options, CancellationToken.None)), out result); + } + catch (Exception exception) + { + NativeApi.SetLastError(exception.Message); + return NativeStatus.Error; + } } private static RowAggregation Build(CsvAggregateContext context) @@ -175,4 +178,17 @@ private static void Sweep(CsvAggregateContext context, CsvAggregateState? winner } } } + + internal static class CsvAggregateFile + { + internal static async Task AggregateAsync( + string path, CsvReaderOptions reader, RowAggregation aggregation, SheetParallelOptions options) + { + CsvReader csv = await Excel.FromCsvFileAsync(path, reader, CancellationToken.None).ConfigureAwait(false); + await using (csv.ConfigureAwait(false)) + { + return await SheetParallel.AggregateAsync(csv.FirstSheet, aggregation, options, CancellationToken.None).ConfigureAwait(false); + } + } + } } diff --git a/src/ExcelReader.Native/Csv/NativeCsvAggregateOptions.cs b/src/ExcelReader.Native/Csv/NativeCsvAggregateOptions.cs index 1d6a2667..8773fe62 100644 --- a/src/ExcelReader.Native/Csv/NativeCsvAggregateOptions.cs +++ b/src/ExcelReader.Native/Csv/NativeCsvAggregateOptions.cs @@ -1,4 +1,5 @@ using System.Runtime.InteropServices; +using ExcelReader.Core.Reader; using ExcelReader.Core.Reader.Csv; namespace ExcelReader.Native.Csv @@ -30,9 +31,10 @@ internal static unsafe class NativeCsvAggregateOptions { private const string OptionsName = "csv_parallel_options"; - internal static int Translate(NativeCsvParallelOptionsRaw? raw, out CsvParallelOptions options) + internal static int Translate(NativeCsvParallelOptionsRaw? raw, out SheetParallelOptions options, out CsvReaderOptions reader) { - options = CsvParallelOptions.Default; + options = SheetParallelOptions.Default; + reader = CsvReaderOptions.Default; if (raw is null) { return NativeStatus.Ok; @@ -69,7 +71,6 @@ internal static int Translate(NativeCsvParallelOptionsRaw? raw, out CsvParallelO return NativeStatus.InvalidArgument; } - CsvReaderOptions reader = CsvReaderOptions.Default; if (delimiter is byte d) { reader = reader with { Delimiter = d }; @@ -87,11 +88,10 @@ internal static int Translate(NativeCsvParallelOptionsRaw? raw, out CsvParallelO reader = reader with { MaxCellBytes = value.MaxCellBytes }; } - options = new CsvParallelOptions + options = new SheetParallelOptions { DegreeOfParallelism = value.DegreeOfParallelism, HeaderRow = value.HeaderRow, - Reader = reader, }; return NativeStatus.Ok; } diff --git a/src/ExcelReader.Native/Typed/TypedApi.Parallel.cs b/src/ExcelReader.Native/Typed/TypedApi.Parallel.cs index cf84a0ca..64ac899b 100644 --- a/src/ExcelReader.Native/Typed/TypedApi.Parallel.cs +++ b/src/ExcelReader.Native/Typed/TypedApi.Parallel.cs @@ -63,9 +63,8 @@ private static int ParseCsvInParallel(NativeHandle handle, CsvChunkSource source Accumulate = (ref PartitionTable state, Row row) => state.Append(row, columnIndices, isDate1904), Combine = PartitionTable.Combine, }; - CsvParallelOptions options = new() { DegreeOfParallelism = degreeOfParallelism, HeaderRow = headerRow, Reader = reader }; PartitionTable merged = Task.Run(() => ParallelCsvProcessor.RunPartitionedAsync( - source, aggregation, options, chunkSizeOverride, CancellationToken.None)).GetAwaiter().GetResult(); + source, aggregation, reader, headerRow, degreeOfParallelism, chunkSizeOverride, CancellationToken.None)).GetAwaiter().GetResult(); try { if (merged.FailedColumn >= 0) diff --git a/tests/ExcelReader.Benchmarks/Parse/CsvParallelParseBenchmark.cs b/tests/ExcelReader.Benchmarks/Parse/CsvParallelParseBenchmark.cs index e0c20d3d..e2430e70 100644 --- a/tests/ExcelReader.Benchmarks/Parse/CsvParallelParseBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/Parse/CsvParallelParseBenchmark.cs @@ -66,7 +66,8 @@ public void Cleanup() public async Task ConversionHeavy() { long n = 0; - await foreach (WideRow row in CsvParallel.ParseAsync(_wide, ExcelParser.FromAttributes(), new CsvParallelOptions { DegreeOfParallelism = Dop })) + using CsvReader csv = Excel.FromCsvFile(_wide); + await foreach (WideRow row in SheetParallel.ParseAsync(csv.FirstSheet, ExcelParser.FromAttributes(), new SheetParallelOptions { DegreeOfParallelism = Dop })) { n += row.Units; } @@ -77,7 +78,8 @@ public async Task ConversionHeavy() public async Task NarrowInt() { long n = 0; - await foreach (NarrowRow row in CsvParallel.ParseAsync(_narrow, ExcelParser.FromAttributes(), new CsvParallelOptions { DegreeOfParallelism = Dop })) + using CsvReader csv = Excel.FromCsvFile(_narrow); + await foreach (NarrowRow row in SheetParallel.ParseAsync(csv.FirstSheet, ExcelParser.FromAttributes(), new SheetParallelOptions { DegreeOfParallelism = Dop })) { n += row.A; } @@ -115,9 +117,10 @@ public void Merge(AggregationNarrow following) [Benchmark] public async Task ConversionHeavyAggregate() { - CsvParallelOptions options = new() { DegreeOfParallelism = Dop, HeaderRow = 1 }; + SheetParallelOptions options = new() { DegreeOfParallelism = Dop, HeaderRow = 1 }; ExcelParser map = ExcelParser.FromAttributes(); - var aggregation = await CsvParallel.AggregateAsync(_wide, map, options); + using CsvReader csv = Excel.FromCsvFile(_wide); + var aggregation = await SheetParallel.AggregateAsync(csv.FirstSheet, map, options); if (aggregation.Units == 0) { throw new InvalidOperationException("WideRowRef bound no columns; the aggregate benchmark is measuring nothing."); @@ -128,9 +131,10 @@ public async Task ConversionHeavyAggregate() [Benchmark] public async Task NarrowIntAggregate() { - CsvParallelOptions options = new() { DegreeOfParallelism = Dop, HeaderRow = 1 }; + SheetParallelOptions options = new() { DegreeOfParallelism = Dop, HeaderRow = 1 }; ExcelParser map = ExcelParser.FromAttributes(); - var aggregation = await CsvParallel.AggregateAsync(_narrow, map, options); + using CsvReader csv = Excel.FromCsvFile(_narrow); + var aggregation = await SheetParallel.AggregateAsync(csv.FirstSheet, map, options); if (aggregation.Units == 0) { throw new InvalidOperationException("NarrowRowStruct bound no columns; the aggregate benchmark is measuring nothing."); diff --git a/tests/ExcelReader.Benchmarks/Parse/CsvParallelVsSepBenchmark.cs b/tests/ExcelReader.Benchmarks/Parse/CsvParallelVsSepBenchmark.cs index 78201f19..2174dce9 100644 --- a/tests/ExcelReader.Benchmarks/Parse/CsvParallelVsSepBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/Parse/CsvParallelVsSepBenchmark.cs @@ -142,15 +142,16 @@ private async Task ReadExcelReaderAsync(int degreeOfParallelism) return seqAcc; } long acc = 0; + using CsvReader csv = Excel.FromCsvFile(Path); if (Corpus == ParallelCorpus.NarrowInt) { - await foreach (NarrowRow row in CsvParallel.ParseAsync(Path, ExcelParser.FromAttributes(), new CsvParallelOptions { DegreeOfParallelism = degreeOfParallelism })) + await foreach (NarrowRow row in SheetParallel.ParseAsync(csv.FirstSheet, ExcelParser.FromAttributes(), new SheetParallelOptions { DegreeOfParallelism = degreeOfParallelism })) { acc += row.A; } return acc; } - await foreach (WideRow row in CsvParallel.ParseAsync(Path, ExcelParser.FromAttributes(), new CsvParallelOptions { DegreeOfParallelism = degreeOfParallelism })) + await foreach (WideRow row in SheetParallel.ParseAsync(csv.FirstSheet, ExcelParser.FromAttributes(), new SheetParallelOptions { DegreeOfParallelism = degreeOfParallelism })) { acc += row.Units; } diff --git a/tests/ExcelReader.Tests/Native/CsvAggregateNativeTests.cs b/tests/ExcelReader.Tests/Native/CsvAggregateNativeTests.cs index 1602d4a0..eb3f5c86 100644 --- a/tests/ExcelReader.Tests/Native/CsvAggregateNativeTests.cs +++ b/tests/ExcelReader.Tests/Native/CsvAggregateNativeTests.cs @@ -14,15 +14,15 @@ public sealed unsafe class CsvAggregateNativeTests [Fact] public void Translate_Should_Return_Defaults_When_Options_Are_Absent() { - int status = NativeCsvAggregateOptions.Translate(null, out CsvParallelOptions options); + int status = NativeCsvAggregateOptions.Translate(null, out SheetParallelOptions options, out CsvReaderOptions reader); Assert.Equal(NativeStatus.Ok, status); Assert.Equal(0, options.DegreeOfParallelism); Assert.Equal(0, options.HeaderRow); - Assert.Equal((byte)',', options.Reader.Delimiter); - Assert.Equal((byte)'"', options.Reader.Quote); - Assert.True(options.Reader.DetectEncodingFromByteOrderMark); - Assert.Equal(32 * 1024 * 1024, options.Reader.MaxCellBytes); + Assert.Equal((byte)',', reader.Delimiter); + Assert.Equal((byte)'"', reader.Quote); + Assert.True(reader.DetectEncodingFromByteOrderMark); + Assert.Equal(32 * 1024 * 1024, reader.MaxCellBytes); } [Fact] @@ -39,15 +39,15 @@ public void Translate_Should_Apply_Dialect_When_Fields_Are_Set() MaxCellBytes = 4096, }; - int status = NativeCsvAggregateOptions.Translate(raw, out CsvParallelOptions options); + int status = NativeCsvAggregateOptions.Translate(raw, out SheetParallelOptions options, out CsvReaderOptions reader); Assert.Equal(NativeStatus.Ok, status); Assert.Equal(4, options.DegreeOfParallelism); Assert.Equal(1, options.HeaderRow); - Assert.Equal((byte)';', options.Reader.Delimiter); - Assert.Equal((byte)'\'', options.Reader.Quote); - Assert.False(options.Reader.DetectEncodingFromByteOrderMark); - Assert.Equal(4096, options.Reader.MaxCellBytes); + Assert.Equal((byte)';', reader.Delimiter); + Assert.Equal((byte)'\'', reader.Quote); + Assert.False(reader.DetectEncodingFromByteOrderMark); + Assert.Equal(4096, reader.MaxCellBytes); } [Theory] @@ -71,7 +71,7 @@ public void Translate_Should_Reject_When_A_Field_Is_Out_Of_Range( MaxCellBytes = maxCellBytes, }; - int status = NativeCsvAggregateOptions.Translate(raw, out _); + int status = NativeCsvAggregateOptions.Translate(raw, out _, out _); Assert.Equal(NativeStatus.InvalidArgument, status); } @@ -83,7 +83,7 @@ public void Translate_Should_Reject_When_StructSize_Is_Invalid(int structSize) { NativeCsvParallelOptionsRaw raw = new() { StructSize = structSize }; - int status = NativeCsvAggregateOptions.Translate(raw, out _); + int status = NativeCsvAggregateOptions.Translate(raw, out _, out _); Assert.Equal(NativeStatus.InvalidArgument, status); } @@ -94,7 +94,7 @@ public void Translate_Should_Explain_Why_A_Zeroed_Options_Struct_Is_Rejected() NativeApi.ClearLastError(); NativeCsvParallelOptionsRaw raw = default; - int status = NativeCsvAggregateOptions.Translate(raw, out _); + int status = NativeCsvAggregateOptions.Translate(raw, out _, out _); Assert.Equal(NativeStatus.InvalidArgument, status); string message = NativeApi.LastErrorText(); @@ -112,7 +112,7 @@ public void Translate_Should_Explain_Which_Dialect_Field_Was_Rejected() Delimiter = 9000, }; - int status = NativeCsvAggregateOptions.Translate(raw, out _); + int status = NativeCsvAggregateOptions.Translate(raw, out _, out _); Assert.Equal(NativeStatus.InvalidArgument, status); Assert.Contains("csv_parallel_options.delimiter", NativeApi.LastErrorText(), StringComparison.Ordinal); diff --git a/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvModelTests.cs b/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvModelTests.cs index bc24b5ea..65508c2a 100644 --- a/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvModelTests.cs +++ b/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvModelTests.cs @@ -108,7 +108,9 @@ private static Task Chunked(byte[] csv, ExcelParser map, int csv.AsMemory(), MappedAggregation.Unbound, MappedAggregation.Binder(map, map.CsvInfo, false, headerRow), - new CsvParallelOptions { DegreeOfParallelism = dop, HeaderRow = headerRow }, + CsvReaderOptions.Default, + headerRow, + dop, chunkSize, TestContext.Current.CancellationToken); } @@ -119,7 +121,9 @@ private static Task Chunked(byte[] csv, ExcelParser map, int csv.AsMemory(), MappedAggregation.Unbound, MappedAggregation.Binder(map, map.CsvInfo, false, headerRow), - new CsvParallelOptions { DegreeOfParallelism = dop, HeaderRow = headerRow }, + CsvReaderOptions.Default, + headerRow, + dop, chunkSize, TestContext.Current.CancellationToken); } @@ -165,21 +169,29 @@ public async Task EveryPublicOverloadMatchesSequential() { byte[] csv = PeopleCsv(rows: 60_000); List expected = Sequential(csv, headerRow: 1); - var options = new CsvParallelOptions { DegreeOfParallelism = 4, HeaderRow = 1 }; + var options = new SheetParallelOptions { DegreeOfParallelism = 4, HeaderRow = 1 }; CancellationToken ct = TestContext.Current.CancellationToken; ExcelParser map = ExcelParser.Generated(); string path = Path.Combine(Path.GetTempPath(), $"exr-model-{Guid.NewGuid():N}.csv"); await File.WriteAllBytesAsync(path, csv, ct); try { - Assert.Equal(expected, (await CsvParallel.AggregateAsync(csv.AsMemory(), map, options, ct)).Rows); - Assert.Equal(expected, (await CsvParallel.AggregateAsync(path, map, options, ct)).Rows); + using (CsvReader memory = Excel.FromCsv(csv)) + { + Assert.Equal(expected, (await SheetParallel.AggregateAsync(memory.FirstSheet, map, options, ct)).Rows); + } + using (CsvReader fromPath = Excel.FromCsvFile(path)) + { + Assert.Equal(expected, (await SheetParallel.AggregateAsync(fromPath.FirstSheet, map, options, ct)).Rows); + } await using (var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true)) { - Assert.Equal(expected, (await CsvParallel.AggregateAsync(file, map, options, ct)).Rows); + using CsvReader fromFile = Excel.FromCsv(file, leaveOpen: true); + Assert.Equal(expected, (await SheetParallel.AggregateAsync(fromFile.FirstSheet, map, options, ct)).Rows); } using var unseekable = new BufferedStream(new MemoryStream(csv, writable: false)); - Assert.Equal(expected, (await CsvParallel.AggregateAsync(unseekable, map, options, ct)).Rows); + using CsvReader fromBuffered = Excel.FromCsv(unseekable, leaveOpen: true); + Assert.Equal(expected, (await SheetParallel.AggregateAsync(fromBuffered.FirstSheet, map, options, ct)).Rows); } finally { @@ -200,7 +212,9 @@ public async Task BindsReorderedAndAliasedColumns() .Property(["Identifier", "Id"], ExcelCellReaders.Parsable, static (ref Person m, int v) => m.Id = v) .Property(["Note"], ExcelCellReaders.String, static (ref Person m, string v) => m.Note = v)); - PeopleLog log = await CsvParallel.AggregateAsync(csv, map, new CsvParallelOptions { HeaderRow = 1 }, TestContext.Current.CancellationToken); + using CsvReader reader = Excel.FromCsv(csv); + + PeopleLog log = await SheetParallel.AggregateAsync(reader.FirstSheet, map, new SheetParallelOptions { HeaderRow = 1 }, TestContext.Current.CancellationToken); Assert.Equal(["ada|1|n1", "bob|2|n2"], log.Rows); } @@ -209,8 +223,9 @@ public async Task BindsReorderedAndAliasedColumns() public async Task AMissingRequiredColumnThrows() { byte[] csv = "Name,Note\nada,n1\n"u8.ToArray(); - await Assert.ThrowsAsync(() => CsvParallel.AggregateAsync( - csv, ExcelParser.Generated(), new CsvParallelOptions { HeaderRow = 1 }, TestContext.Current.CancellationToken)); + using CsvReader reader = Excel.FromCsv(csv); + await Assert.ThrowsAsync(() => SheetParallel.AggregateAsync( + reader.FirstSheet, ExcelParser.Generated(), new SheetParallelOptions { HeaderRow = 1 }, TestContext.Current.CancellationToken)); } [Fact] @@ -218,14 +233,15 @@ public async Task ANameMapWithoutAHeaderRowIsRejectedAndAnIndexMapIsNot() { byte[] csv = "ada,1\nbob,2\n"u8.ToArray(); CancellationToken ct = TestContext.Current.CancellationToken; - await Assert.ThrowsAsync(() => CsvParallel.AggregateAsync( - csv, ExcelParser.Generated(), new CsvParallelOptions { HeaderRow = 0 }, ct)); + using CsvReader reader = Excel.FromCsv(csv); + await Assert.ThrowsAsync(() => SheetParallel.AggregateAsync( + reader.FirstSheet, ExcelParser.Generated(), new SheetParallelOptions { HeaderRow = 0 }, ct)); - await Assert.ThrowsAsync(() => CsvParallel.AggregateAsync( - csv, (ExcelParser)null!, new CsvParallelOptions { HeaderRow = 1 }, ct)); + await Assert.ThrowsAsync(() => SheetParallel.AggregateAsync( + reader.FirstSheet, (ExcelParser)null!, new SheetParallelOptions { HeaderRow = 1 }, ct)); ExcelParser byIndex = ExcelParser.Build(static b => b.PropertyAt(1, ExcelCellReaders.Parsable, static (ref IdOnly m, int v) => m.Id = v)); - PeopleLog log = await CsvParallel.AggregateAsync(csv, byIndex, new CsvParallelOptions { HeaderRow = 0 }, ct); + PeopleLog log = await SheetParallel.AggregateAsync(reader.FirstSheet, byIndex, new SheetParallelOptions { HeaderRow = 0 }, ct); Assert.Equal(["1", "2"], log.Rows); } @@ -248,8 +264,9 @@ public async Task AnIndexMapRunsThroughThePartitionedPathForBothHeaderRowSetting public async Task SkipsEmptyRecords() { byte[] csv = "Name,Id,Note\nada,1,n\n\nbob,2,m\n"u8.ToArray(); - PeopleLog log = await CsvParallel.AggregateAsync( - csv, ExcelParser.Generated(), new CsvParallelOptions { HeaderRow = 1 }, TestContext.Current.CancellationToken); + using CsvReader reader = Excel.FromCsv(csv); + PeopleLog log = await SheetParallel.AggregateAsync( + reader.FirstSheet, ExcelParser.Generated(), new SheetParallelOptions { HeaderRow = 1 }, TestContext.Current.CancellationToken); Assert.Equal(["ada|1|n", "bob|2|m"], log.Rows); } @@ -259,8 +276,9 @@ public async Task ReportsTheExactRowSequentiallyAndZeroWhenPartitioned() byte[] csv = "Name,Id,Note\nada,1,n\nbob,2,n\ncid,oops,n\ndan,4,n\n"u8.ToArray(); ExcelParser map = ExcelParser.Generated(new ExcelParserConfig { ThrowOnParseFailure = true }); - ExcelParseException sequential = await Assert.ThrowsAsync(() => CsvParallel.AggregateAsync( - csv, map, new CsvParallelOptions { DegreeOfParallelism = 1, HeaderRow = 1 }, TestContext.Current.CancellationToken)); + using CsvReader reader = Excel.FromCsv(csv); + ExcelParseException sequential = await Assert.ThrowsAsync(() => SheetParallel.AggregateAsync( + reader.FirstSheet, map, new SheetParallelOptions { DegreeOfParallelism = 1, HeaderRow = 1 }, TestContext.Current.CancellationToken)); Assert.Equal(4, sequential.Row); ExcelParseException partitioned = await Assert.ThrowsAsync(() => Chunked(csv, map, dop: 4, chunkSize: 8, headerRow: 1)); @@ -387,8 +405,9 @@ public async Task ATrackedMapWiderThanTheStackallocLimitStillBinds() b.Property([$"c{columns - 1}"], ExcelCellReaders.Parsable, static (ref WideRow m, int v) => m.Last = v, isRequired: true, requireValue: true); }); - WideLog log = await CsvParallel.AggregateAsync( - csv, map, new CsvParallelOptions { HeaderRow = 1 }, TestContext.Current.CancellationToken); + using CsvReader reader = Excel.FromCsv(csv); + WideLog log = await SheetParallel.AggregateAsync( + reader.FirstSheet, map, new SheetParallelOptions { HeaderRow = 1 }, TestContext.Current.CancellationToken); Assert.Equal([7, 7], log.Values); } diff --git a/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvParallelTests.cs b/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvParallelTests.cs index 34c8c029..144f8c3c 100644 --- a/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvParallelTests.cs +++ b/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvParallelTests.cs @@ -89,13 +89,21 @@ private static List Sequential(byte[] csv, int headerRow = 0) return rows.Count > headerRow ? rows[headerRow..] : []; } + private static async Task> RunSequentialAsync(byte[] csv, RowActionFactory> factory, CancellationToken ct) + { + using CsvReader reader = Excel.FromCsv(csv); + return await ParallelCsvProcessor.RunAsync(reader, Collecting, factory, new SheetParallelOptions { DegreeOfParallelism = 1, HeaderRow = 1 }, ct); + } + private static Task> WithChunkSize(byte[] csv, int dop, int chunkSize, int headerRow = 0, RowAggregation>? aggregation = null) { return ParallelCsvProcessor.RunWithChunkSizeAsync( csv.AsMemory(), aggregation ?? Collecting, null, - new CsvParallelOptions { DegreeOfParallelism = dop, HeaderRow = headerRow }, + CsvReaderOptions.Default, + headerRow, + dop, chunkSize, TestContext.Current.CancellationToken); } @@ -194,7 +202,7 @@ public async Task SurfacesAnExceptionThrownForARealRecord() }; var ex = await Assert.ThrowsAsync(() => ParallelCsvProcessor.RunWithChunkSizeAsync( - csv.AsMemory(), failAt150, null, new CsvParallelOptions { DegreeOfParallelism = 4 }, 64, TestContext.Current.CancellationToken)); + csv.AsMemory(), failAt150, null, CsvReaderOptions.Default, 0, 4, 64, TestContext.Current.CancellationToken)); Assert.Equal("r150", ex.Message); } @@ -213,24 +221,38 @@ public async Task EveryPublicOverloadMatchesSequentialOnAPartitionableSource() { byte[] csv = LargeCsv(rows: 150_000); List expected = Sequential(csv, headerRow: 1); - var options = new CsvParallelOptions { DegreeOfParallelism = 4, HeaderRow = 1 }; + var options = new SheetParallelOptions { DegreeOfParallelism = 4, HeaderRow = 1 }; CancellationToken ct = TestContext.Current.CancellationToken; string path = Path.Combine(Path.GetTempPath(), $"exr-aggregate-{Guid.NewGuid():N}.csv"); await File.WriteAllBytesAsync(path, csv, ct); try { - Assert.Equal(expected, await CsvParallel.AggregateAsync(csv.AsMemory(), Collecting, options, ct)); - Assert.Equal(expected, (await CsvParallel.AggregateAsync(csv.AsMemory(), options, ct)).Rows); - Assert.Equal(expected, await CsvParallel.AggregateAsync(path, Collecting, options, ct)); - Assert.Equal(expected, (await CsvParallel.AggregateAsync(path, options, ct)).Rows); + using (CsvReader memory = Excel.FromCsv(csv)) + { + Assert.Equal(expected, await SheetParallel.AggregateAsync(memory.FirstSheet, Collecting, options, ct)); + } + using (CsvReader memory = Excel.FromCsv(csv)) + { + Assert.Equal(expected, (await SheetParallel.AggregateAsync(memory.FirstSheet, options, ct)).Rows); + } + using (CsvReader fromPath = Excel.FromCsvFile(path)) + { + Assert.Equal(expected, await SheetParallel.AggregateAsync(fromPath.FirstSheet, Collecting, options, ct)); + } + using (CsvReader fromPath = Excel.FromCsvFile(path)) + { + Assert.Equal(expected, (await SheetParallel.AggregateAsync(fromPath.FirstSheet, options, ct)).Rows); + } await using (var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true)) { - Assert.Equal(expected, await CsvParallel.AggregateAsync(file, Collecting, options, ct)); + using CsvReader fromFile = Excel.FromCsv(file, leaveOpen: true); + Assert.Equal(expected, await SheetParallel.AggregateAsync(fromFile.FirstSheet, Collecting, options, ct)); } await using (var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true)) { - Assert.Equal(expected, (await CsvParallel.AggregateAsync(file, options, ct)).Rows); + using CsvReader fromFile = Excel.FromCsv(file, leaveOpen: true); + Assert.Equal(expected, (await SheetParallel.AggregateAsync(fromFile.FirstSheet, options, ct)).Rows); } } finally @@ -246,21 +268,24 @@ public async Task FallsBackToOneSequentialPassWithTheSameResult() List expected = Sequential(csv, headerRow: 1); CancellationToken ct = TestContext.Current.CancellationToken; - var sequential = new CsvParallelOptions { DegreeOfParallelism = 1, HeaderRow = 1 }; - Assert.Equal(expected, await CsvParallel.AggregateAsync(csv.AsMemory(), Collecting, sequential, ct)); + var sequential = new SheetParallelOptions { DegreeOfParallelism = 1, HeaderRow = 1 }; + using CsvReader memory = Excel.FromCsv(csv); + Assert.Equal(expected, await SheetParallel.AggregateAsync(memory.FirstSheet, Collecting, sequential, ct)); using var unpartitionable = new BufferedStream(new MemoryStream(csv, writable: false)); - Assert.Equal(expected, (await CsvParallel.AggregateAsync(unpartitionable, new CsvParallelOptions { HeaderRow = 1 }, ct)).Rows); + using CsvReader buffered = Excel.FromCsv(unpartitionable, leaveOpen: true); + Assert.Equal(expected, (await SheetParallel.AggregateAsync(buffered.FirstSheet, new SheetParallelOptions { HeaderRow = 1 }, ct)).Rows); } [Fact] public async Task RejectsInvalidOptionsAndAggregations() { byte[] csv = "a\n"u8.ToArray(); - await Assert.ThrowsAsync(() => CsvParallel.AggregateAsync(csv, new CsvParallelOptions { DegreeOfParallelism = -1 }, TestContext.Current.CancellationToken)); - await Assert.ThrowsAsync(() => CsvParallel.AggregateAsync(csv, new CsvParallelOptions { HeaderRow = -1 }, TestContext.Current.CancellationToken)); - await Assert.ThrowsAsync(() => CsvParallel.AggregateAsync(csv, (RowAggregation)null!, null, TestContext.Current.CancellationToken)); - await Assert.ThrowsAsync(() => CsvParallel.AggregateAsync(csv, new RowAggregation { Seed = null!, Accumulate = static (ref int s, Row r) => s++, Combine = static (a, b) => a + b }, null, TestContext.Current.CancellationToken)); + using CsvReader reader = Excel.FromCsv(csv); + await Assert.ThrowsAsync(() => SheetParallel.AggregateAsync(reader.FirstSheet, new SheetParallelOptions { DegreeOfParallelism = -1 }, TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(() => SheetParallel.AggregateAsync(reader.FirstSheet, new SheetParallelOptions { HeaderRow = -1 }, TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(() => SheetParallel.AggregateAsync(reader.FirstSheet, (RowAggregation)null!, null, TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(() => SheetParallel.AggregateAsync(reader.FirstSheet, new RowAggregation { Seed = null!, Accumulate = static (ref int s, Row r) => s++, Combine = static (a, b) => a + b }, null, TestContext.Current.CancellationToken)); } [Fact] @@ -269,9 +294,10 @@ public async Task ACanceledTokenStopsProcessing() byte[] csv = LargeCsv(rows: 150_000); using var cts = new CancellationTokenSource(); await cts.CancelAsync(); + using CsvReader reader = Excel.FromCsv(csv); await Assert.ThrowsAnyAsync( - () => CsvParallel.AggregateAsync(csv, new CsvParallelOptions { DegreeOfParallelism = 4 }, cts.Token)); + () => SheetParallel.AggregateAsync(reader.FirstSheet, new SheetParallelOptions { DegreeOfParallelism = 4 }, cts.Token)); } [Fact] @@ -290,8 +316,8 @@ public async Task CallsTheFactoryOnceWithTheHeaderRecord() return Collecting.Accumulate; }; List rows = partitioned - ? await ParallelCsvProcessor.RunWithChunkSizeAsync(csv.AsMemory(), Collecting, factory, new CsvParallelOptions { DegreeOfParallelism = 4, HeaderRow = 1 }, 4096, ct) - : await ParallelCsvProcessor.RunAsync(csv.AsMemory(), Collecting, factory, new CsvParallelOptions { DegreeOfParallelism = 1, HeaderRow = 1 }, ct); + ? await ParallelCsvProcessor.RunWithChunkSizeAsync(csv.AsMemory(), Collecting, factory, CsvReaderOptions.Default, 1, 4, 4096, ct) + : await RunSequentialAsync(csv, factory, ct); Assert.Equal(expected, rows); (string header, bool sequentialCall) = Assert.Single(calls); @@ -312,7 +338,7 @@ public async Task CallsTheFactoryWithAnEmptyHeaderWhenThereIsNoHeaderRow() return Collecting.Accumulate; }; - List rows = await ParallelCsvProcessor.RunWithChunkSizeAsync(csv.AsMemory(), Collecting, factory, new CsvParallelOptions { DegreeOfParallelism = 4 }, 1024, ct); + List rows = await ParallelCsvProcessor.RunWithChunkSizeAsync(csv.AsMemory(), Collecting, factory, CsvReaderOptions.Default, 0, 4, 1024, ct); Assert.Equal(Sequential(csv), rows); Assert.Equal(0, Assert.Single(columnCounts)); @@ -328,7 +354,7 @@ public async Task DoesNotCallTheFactoryWhenTheInputEndsBeforeTheHeader() return Collecting.Accumulate; }; - Assert.Empty(await ParallelCsvProcessor.RunWithChunkSizeAsync(ReadOnlyMemory.Empty, Collecting, factory, new CsvParallelOptions { DegreeOfParallelism = 4, HeaderRow = 1 }, 4, TestContext.Current.CancellationToken)); + Assert.Empty(await ParallelCsvProcessor.RunWithChunkSizeAsync(ReadOnlyMemory.Empty, Collecting, factory, CsvReaderOptions.Default, 1, 4, 4, TestContext.Current.CancellationToken)); Assert.Equal(0, calls); } @@ -344,7 +370,7 @@ public async Task RecordAccumulatorMatchesSequentialAcrossChunkSizes() { RowLog log = await ParallelCsvProcessor.RunWithChunkSizeAsync( csv.AsMemory(), RecordAggregation.Instance, null, - new CsvParallelOptions { DegreeOfParallelism = dop, HeaderRow = 1 }, chunkSize, ct); + CsvReaderOptions.Default, 1, dop, chunkSize, ct); Assert.Equal(expected, log.Rows); } } @@ -359,7 +385,7 @@ public async Task SkipsRecordsWhoseTryParseReturnsFalseAndKeepsSpansValidDuringA { RowLog log = await ParallelCsvProcessor.RunWithChunkSizeAsync( csv.AsMemory(), RecordAggregation.Instance, null, - new CsvParallelOptions { DegreeOfParallelism = 4, HeaderRow = 1 }, chunkSize, ct); + CsvReaderOptions.Default, 1, 4, chunkSize, ct); Assert.Equal(["ada#1", "cid\nmulti#3"], log.Rows); } } diff --git a/tests/ExcelReader.Tests/Parser/ParallelCsv/CsvReaderChunkSourceTests.cs b/tests/ExcelReader.Tests/Parser/ParallelCsv/CsvReaderChunkSourceTests.cs index ee7d91b6..af77f2b0 100644 --- a/tests/ExcelReader.Tests/Parser/ParallelCsv/CsvReaderChunkSourceTests.cs +++ b/tests/ExcelReader.Tests/Parser/ParallelCsv/CsvReaderChunkSourceTests.cs @@ -88,9 +88,7 @@ public async Task RunPartitionedAsync_Should_Deliver_Every_Record_After_The_Head Accumulate = (ref long seen, Row _) => seen++, Combine = (left, right) => left + right, }; - CsvParallelOptions options = new() { DegreeOfParallelism = 4, HeaderRow = 1, Reader = reader.Options }; - - long rows = await ParallelCsvProcessor.RunPartitionedAsync(source, count, options, chunkSizeOverride: 4096, CancellationToken.None); + long rows = await ParallelCsvProcessor.RunPartitionedAsync(source, count, reader.Options, headerRow: 1, degreeOfParallelism: 4, chunkSizeOverride: 4096, CancellationToken.None); Assert.Equal(50_000, rows); } diff --git a/tests/ExcelReader.Tests/Parser/ParallelCsv/ForEachCsvParallelTests.cs b/tests/ExcelReader.Tests/Parser/ParallelCsv/ForEachCsvParallelTests.cs index d4931d0d..75358416 100644 --- a/tests/ExcelReader.Tests/Parser/ParallelCsv/ForEachCsvParallelTests.cs +++ b/tests/ExcelReader.Tests/Parser/ParallelCsv/ForEachCsvParallelTests.cs @@ -61,10 +61,11 @@ public async Task ForEachRecord_Memory_SeesEveryRecordExactlyOnce(int dop) byte[] csv = SalesCsv(rows); long total = 0; long count = 0; - var options = new CsvParallelOptions { DegreeOfParallelism = dop, HeaderRow = 1 }; + var options = new SheetParallelOptions { DegreeOfParallelism = dop, HeaderRow = 1 }; + using CsvReader reader = Excel.FromCsv(csv); - await CsvParallel.ForEachAsync( - csv.AsMemory(), + await SheetParallel.ForEachAsync( + reader.FirstSheet, sale => { Interlocked.Add(ref total, sale.Units); @@ -86,10 +87,11 @@ public async Task ForEachRecord_Path_MatchesMemory() long total = 0; try { - await CsvParallel.ForEachAsync( - path, + using CsvReader reader = Excel.FromCsvFile(path); + await SheetParallel.ForEachAsync( + reader.FirstSheet, sale => Interlocked.Add(ref total, sale.Units), - new CsvParallelOptions { DegreeOfParallelism = 4, HeaderRow = 1 }, + new SheetParallelOptions { DegreeOfParallelism = 4, HeaderRow = 1 }, TestContext.Current.CancellationToken); } finally @@ -107,10 +109,12 @@ public async Task ForEachRecord_Stream_MatchesMemory() using var stream = new MemoryStream(SalesCsv(rows), writable: false); long total = 0; - await CsvParallel.ForEachAsync( - stream, + using CsvReader reader = Excel.FromCsv(stream, leaveOpen: true); + + await SheetParallel.ForEachAsync( + reader.FirstSheet, sale => Interlocked.Add(ref total, sale.Units), - new CsvParallelOptions { DegreeOfParallelism = 4, HeaderRow = 1 }, + new SheetParallelOptions { DegreeOfParallelism = 4, HeaderRow = 1 }, TestContext.Current.CancellationToken); Assert.Equal(ExpectedUnits(rows), total); @@ -122,10 +126,12 @@ public async Task ForEachRecord_TinySource_FallsBackToSequentialInOrder() byte[] csv = SalesCsv(5); var seen = new List(); - await CsvParallel.ForEachAsync( - csv.AsMemory(), + using CsvReader reader = Excel.FromCsv(csv); + + await SheetParallel.ForEachAsync( + reader.FirstSheet, sale => seen.Add(sale.Units), - new CsvParallelOptions { DegreeOfParallelism = 8, HeaderRow = 1 }, + new SheetParallelOptions { DegreeOfParallelism = 8, HeaderRow = 1 }, TestContext.Current.CancellationToken); Assert.Equal([1, 2, 3, 4, 5], seen); @@ -135,20 +141,22 @@ await CsvParallel.ForEachAsync( public async Task ForEachRecord_NullBody_Throws() { byte[] csv = SalesCsv(4); + using CsvReader reader = Excel.FromCsv(csv); await Assert.ThrowsAsync( - () => CsvParallel.ForEachAsync(csv.AsMemory(), null!, null, TestContext.Current.CancellationToken)); + () => SheetParallel.ForEachAsync(reader.FirstSheet, null!, null, TestContext.Current.CancellationToken)); } [Fact] public async Task ForEachRecord_BodyThrows_SurfacesFromTask() { byte[] csv = SalesCsv(20_000); + using CsvReader reader = Excel.FromCsv(csv); await Assert.ThrowsAsync( - () => CsvParallel.ForEachAsync( - csv.AsMemory(), + () => SheetParallel.ForEachAsync( + reader.FirstSheet, static _ => throw new InvalidOperationException("boom"), - new CsvParallelOptions { DegreeOfParallelism = 4, HeaderRow = 1 }, + new SheetParallelOptions { DegreeOfParallelism = 4, HeaderRow = 1 }, TestContext.Current.CancellationToken)); } @@ -157,12 +165,13 @@ public async Task ForEachRecord_Cancelled_Throws() { byte[] csv = SalesCsv(200_000); using var cts = new CancellationTokenSource(); + using CsvReader reader = Excel.FromCsv(csv); await Assert.ThrowsAnyAsync( - () => CsvParallel.ForEachAsync( - csv.AsMemory(), + () => SheetParallel.ForEachAsync( + reader.FirstSheet, _ => cts.Cancel(), - new CsvParallelOptions { DegreeOfParallelism = 4, HeaderRow = 1 }, + new SheetParallelOptions { DegreeOfParallelism = 4, HeaderRow = 1 }, cts.Token)); } @@ -197,9 +206,10 @@ public async Task ForEachMapped_BindsEveryColumn(int dop) long units = 0; long regionBytes = 0; long count = 0; + using CsvReader reader = Excel.FromCsv(csv); - await CsvParallel.ForEachAsync( - csv.AsMemory(), + await SheetParallel.ForEachAsync( + reader.FirstSheet, ExcelParser.FromAttributes(), order => { @@ -207,7 +217,7 @@ await CsvParallel.ForEachAsync( Interlocked.Add(ref regionBytes, order.Region.Length); Interlocked.Increment(ref count); }, - new CsvParallelOptions { DegreeOfParallelism = dop, HeaderRow = 1 }, + new SheetParallelOptions { DegreeOfParallelism = dop, HeaderRow = 1 }, TestContext.Current.CancellationToken); Assert.Equal(rows, count); @@ -221,20 +231,22 @@ public async Task ForEachMapped_MatchesSequentialParser() const int rows = 20_000; byte[] csv = OrdersCsv(rows); long parallel = 0; + using CsvReader reader = Excel.FromCsv(csv); - await CsvParallel.ForEachAsync( - csv.AsMemory(), + await SheetParallel.ForEachAsync( + reader.FirstSheet, ExcelParser.FromAttributes(), order => Interlocked.Add(ref parallel, order.Units), - new CsvParallelOptions { DegreeOfParallelism = 8, HeaderRow = 1 }, + new SheetParallelOptions { DegreeOfParallelism = 8, HeaderRow = 1 }, TestContext.Current.CancellationToken); long sequential = 0; - await CsvParallel.ForEachAsync( - csv.AsMemory(), + using CsvReader sequentialReader = Excel.FromCsv(csv); + await SheetParallel.ForEachAsync( + sequentialReader.FirstSheet, ExcelParser.FromAttributes(), order => sequential += order.Units, - new CsvParallelOptions { DegreeOfParallelism = 1, HeaderRow = 1 }, + new SheetParallelOptions { DegreeOfParallelism = 1, HeaderRow = 1 }, TestContext.Current.CancellationToken); Assert.Equal(sequential, parallel); @@ -245,13 +257,14 @@ await CsvParallel.ForEachAsync( public async Task ForEachMapped_HeaderRowZeroWithNamedMap_Throws() { byte[] csv = OrdersCsv(8); + using CsvReader reader = Excel.FromCsv(csv); await Assert.ThrowsAsync( - () => CsvParallel.ForEachAsync( - csv.AsMemory(), + () => SheetParallel.ForEachAsync( + reader.FirstSheet, ExcelParser.FromAttributes(), static _ => { }, - new CsvParallelOptions { DegreeOfParallelism = 4, HeaderRow = 0 }, + new SheetParallelOptions { DegreeOfParallelism = 4, HeaderRow = 0 }, TestContext.Current.CancellationToken)); } @@ -259,10 +272,11 @@ await Assert.ThrowsAsync( public async Task ForEachMapped_NullMap_Throws() { byte[] csv = OrdersCsv(8); + using CsvReader reader = Excel.FromCsv(csv); await Assert.ThrowsAsync( - () => CsvParallel.ForEachAsync( - csv.AsMemory(), + () => SheetParallel.ForEachAsync( + reader.FirstSheet, null!, static _ => { }, null, @@ -297,7 +311,9 @@ private static Task ChunkedRecord(byte[] csv, Action body, ChunkProb csv.AsMemory(), probe.Wrap(RecordCallback.For(body)), null, - new CsvParallelOptions { DegreeOfParallelism = dop, HeaderRow = 1 }, + CsvReaderOptions.Default, + 1, + dop, chunkSize, TestContext.Current.CancellationToken); } @@ -309,7 +325,9 @@ private static Task ChunkedMapped(byte[] csv, Action body, ChunkPro csv.AsMemory(), probe.Wrap(MappedCallback.Unbound), MappedCallback.Binder(order, order.CsvInfo, false, 1, body), - new CsvParallelOptions { DegreeOfParallelism = dop, HeaderRow = 1 }, + CsvReaderOptions.Default, + 1, + dop, chunkSize, TestContext.Current.CancellationToken); } diff --git a/tests/ExcelReader.Tests/Parser/ParallelCsv/ParseCsvParallelTests.cs b/tests/ExcelReader.Tests/Parser/ParallelCsv/ParseCsvParallelTests.cs index 7cda722e..8ebd9fdb 100644 --- a/tests/ExcelReader.Tests/Parser/ParallelCsv/ParseCsvParallelTests.cs +++ b/tests/ExcelReader.Tests/Parser/ParallelCsv/ParseCsvParallelTests.cs @@ -41,7 +41,8 @@ public async Task MemoryOverloadMatchesTheSequentialParser() using var reader = Excel.FromCsv(csv); List expected = [.. ExcelParser.FromAttributes().Parse(reader.FirstSheet)]; - List actual = await DrainAsync(CsvParallel.ParseAsync(csv.AsMemory(), ExcelParser.FromAttributes(), options: new CsvParallelOptions { DegreeOfParallelism = 4 }, ct: TestContext.Current.CancellationToken)); + using CsvReader source = Excel.FromCsv(csv); + List actual = await DrainAsync(SheetParallel.ParseAsync(source.FirstSheet, ExcelParser.FromAttributes(), options: new SheetParallelOptions { DegreeOfParallelism = 4 }, ct: TestContext.Current.CancellationToken)); Assert.Equal(expected.Count, actual.Count); for (int i = 0; i < expected.Count; i++) @@ -62,7 +63,8 @@ public async Task FileOverloadMatchesTheSequentialParser() using var reader = Excel.FromCsvFile(path); List expected = [.. ExcelParser.FromAttributes().Parse(reader.FirstSheet)]; - List actual = await DrainAsync(CsvParallel.ParseAsync(path, ExcelParser.FromAttributes(), options: new CsvParallelOptions { DegreeOfParallelism = 8 }, ct: TestContext.Current.CancellationToken)); + using CsvReader source = Excel.FromCsvFile(path); + List actual = await DrainAsync(SheetParallel.ParseAsync(source.FirstSheet, ExcelParser.FromAttributes(), options: new SheetParallelOptions { DegreeOfParallelism = 8 }, ct: TestContext.Current.CancellationToken)); Assert.Equal(expected.Count, actual.Count); Assert.Equal(expected[^1].Name, actual[^1].Name); @@ -79,7 +81,8 @@ public async Task ATinySourceStillProducesTheRightRowsThroughTheSequentialFallba { byte[] csv = BuildCsv(3); - List actual = await DrainAsync(CsvParallel.ParseAsync(csv.AsMemory(), ExcelParser.FromAttributes(), options: new CsvParallelOptions { DegreeOfParallelism = 8 }, ct: TestContext.Current.CancellationToken)); + using CsvReader source = Excel.FromCsv(csv); + List actual = await DrainAsync(SheetParallel.ParseAsync(source.FirstSheet, ExcelParser.FromAttributes(), options: new SheetParallelOptions { DegreeOfParallelism = 8 }, ct: TestContext.Current.CancellationToken)); Assert.Equal(3, actual.Count); Assert.Equal("name00000", actual[0].Name); @@ -93,8 +96,10 @@ public async Task ANonUtf8EncodingFallsBackAndStillDecodesCorrectly() byte[] csv = latin1.GetBytes("Name,Age\nJosé,30\nRenée,41\n"); var options = new CsvReaderOptions { Encoding = latin1 }; + using CsvReader source = Excel.FromCsv(csv, options); + List actual = await DrainAsync( - CsvParallel.ParseAsync(csv.AsMemory(), ExcelParser.FromAttributes(), options: new CsvParallelOptions { DegreeOfParallelism = 8, Reader = options }, ct: TestContext.Current.CancellationToken)); + SheetParallel.ParseAsync(source.FirstSheet, ExcelParser.FromAttributes(), options: new SheetParallelOptions { DegreeOfParallelism = 8 }, ct: TestContext.Current.CancellationToken)); Assert.Equal(2, actual.Count); Assert.Equal("José", actual[0].Name); @@ -106,7 +111,8 @@ public async Task ADegreeOfOneIsTheSequentialPath() { byte[] csv = BuildCsv(5_000); - List actual = await DrainAsync(CsvParallel.ParseAsync(csv.AsMemory(), ExcelParser.FromAttributes(), options: new CsvParallelOptions { DegreeOfParallelism = 1 }, ct: TestContext.Current.CancellationToken)); + using CsvReader source = Excel.FromCsv(csv); + List actual = await DrainAsync(SheetParallel.ParseAsync(source.FirstSheet, ExcelParser.FromAttributes(), options: new SheetParallelOptions { DegreeOfParallelism = 1 }, ct: TestContext.Current.CancellationToken)); Assert.Equal(5_000, actual.Count); Assert.Equal(4_999, actual[^1].Age); @@ -117,8 +123,10 @@ public void ANegativeDegreeOfParallelismThrows() { byte[] csv = BuildCsv(10); + using CsvReader source = Excel.FromCsv(csv); + Assert.Throws( - () => CsvParallel.ParseAsync(csv.AsMemory(), ExcelParser.FromAttributes(), options: new CsvParallelOptions { DegreeOfParallelism = -1 }, ct: TestContext.Current.CancellationToken)); + () => SheetParallel.ParseAsync(source.FirstSheet, ExcelParser.FromAttributes(), options: new SheetParallelOptions { DegreeOfParallelism = -1 }, ct: TestContext.Current.CancellationToken)); } [Fact] @@ -126,8 +134,10 @@ public void AHeaderRowOnTheParallelOptionsThrowsBecauseTypedParsingReadsItFromTh { byte[] csv = BuildCsv(10); + using CsvReader source = Excel.FromCsv(csv); + ArgumentException exception = Assert.Throws( - () => CsvParallel.ParseAsync(csv.AsMemory(), ExcelParser.FromAttributes(), options: new CsvParallelOptions { HeaderRow = 2 }, ct: TestContext.Current.CancellationToken)); + () => SheetParallel.ParseAsync(source.FirstSheet, ExcelParser.FromAttributes(), options: new SheetParallelOptions { HeaderRow = 2 }, ct: TestContext.Current.CancellationToken)); Assert.Contains(nameof(ExcelParserConfig.HeaderRow), exception.Message, StringComparison.Ordinal); } @@ -137,7 +147,8 @@ public async Task AMemoryStreamWithAnExposedBufferIsUnwrappedAndParallelized() byte[] csv = BuildCsv(20_000); using var ms = new MemoryStream(csv, 0, csv.Length, writable: false, publiclyVisible: true); - List actual = await DrainAsync(CsvParallel.ParseAsync(ms, ExcelParser.FromAttributes(), options: new CsvParallelOptions { DegreeOfParallelism = 4 }, ct: TestContext.Current.CancellationToken)); + using CsvReader source = Excel.FromCsv(ms, leaveOpen: true); + List actual = await DrainAsync(SheetParallel.ParseAsync(source.FirstSheet, ExcelParser.FromAttributes(), options: new SheetParallelOptions { DegreeOfParallelism = 4 }, ct: TestContext.Current.CancellationToken)); Assert.Equal(20_000, actual.Count); Assert.Equal("name00000", actual[0].Name); @@ -150,7 +161,8 @@ public async Task AMemoryStreamWithoutAnExposedBufferStillProducesTheRightRows() byte[] csv = BuildCsv(20_000); using var ms = new MemoryStream(csv, 0, csv.Length, writable: false, publiclyVisible: false); - List actual = await DrainAsync(CsvParallel.ParseAsync(ms, ExcelParser.FromAttributes(), options: new CsvParallelOptions { DegreeOfParallelism = 4 }, ct: TestContext.Current.CancellationToken)); + using CsvReader source = Excel.FromCsv(ms, leaveOpen: true); + List actual = await DrainAsync(SheetParallel.ParseAsync(source.FirstSheet, ExcelParser.FromAttributes(), options: new SheetParallelOptions { DegreeOfParallelism = 4 }, ct: TestContext.Current.CancellationToken)); Assert.Equal(20_000, actual.Count); Assert.Equal(19_999, actual[^1].Age); @@ -166,7 +178,8 @@ public async Task AFileStreamIsUnwrappedToItsHandleAndParallelized() { await using var fs = new FileStream(path, FileMode.Open, FileAccess.Read); - List actual = await DrainAsync(CsvParallel.ParseAsync(fs, ExcelParser.FromAttributes(), options: new CsvParallelOptions { DegreeOfParallelism = 8 }, ct: TestContext.Current.CancellationToken)); + using CsvReader source = Excel.FromCsv(fs, leaveOpen: true); + List actual = await DrainAsync(SheetParallel.ParseAsync(source.FirstSheet, ExcelParser.FromAttributes(), options: new SheetParallelOptions { DegreeOfParallelism = 8 }, ct: TestContext.Current.CancellationToken)); Assert.Equal(20_000, actual.Count); Assert.Equal(19_999, actual[^1].Age); @@ -189,7 +202,8 @@ public async Task AnUnwrappableStreamFallsBackWithoutThrowing() await using var fs = new FileStream(path, FileMode.Open, FileAccess.Read); await using var buffered = new BufferedStream(fs); - List actual = await DrainAsync(CsvParallel.ParseAsync(buffered, ExcelParser.FromAttributes(), options: new CsvParallelOptions { DegreeOfParallelism = 8 }, ct: TestContext.Current.CancellationToken)); + using CsvReader source = Excel.FromCsv(buffered, leaveOpen: true); + List actual = await DrainAsync(SheetParallel.ParseAsync(source.FirstSheet, ExcelParser.FromAttributes(), options: new SheetParallelOptions { DegreeOfParallelism = 8 }, ct: TestContext.Current.CancellationToken)); Assert.Equal(20_000, actual.Count); Assert.Equal(19_999, actual[^1].Age); @@ -206,7 +220,8 @@ public async Task ANonSeekableStreamFallsBackWithoutThrowing() byte[] csv = BuildCsv(20_000); await using var pipe = new NonSeekableStream(csv); - List actual = await DrainAsync(CsvParallel.ParseAsync(pipe, ExcelParser.FromAttributes(), options: new CsvParallelOptions { DegreeOfParallelism = 8 }, ct: TestContext.Current.CancellationToken)); + using CsvReader source = Excel.FromCsv(pipe, leaveOpen: true); + List actual = await DrainAsync(SheetParallel.ParseAsync(source.FirstSheet, ExcelParser.FromAttributes(), options: new SheetParallelOptions { DegreeOfParallelism = 8 }, ct: TestContext.Current.CancellationToken)); Assert.Equal(20_000, actual.Count); Assert.Equal(19_999, actual[^1].Age); diff --git a/tests/ExcelReader.Tests/Reader/Csv/CsvDialectAndFormatTests.cs b/tests/ExcelReader.Tests/Reader/Csv/CsvDialectAndFormatTests.cs index 8f38b216..c8f8f657 100644 --- a/tests/ExcelReader.Tests/Reader/Csv/CsvDialectAndFormatTests.cs +++ b/tests/ExcelReader.Tests/Reader/Csv/CsvDialectAndFormatTests.cs @@ -173,14 +173,11 @@ public async Task Should_InferDelimiter_When_SniffingOnTheParallelPath() string path = WriteTemp(sb.ToString()); try { - var options = new CsvParallelOptions - { - DegreeOfParallelism = 4, - Reader = CsvReaderOptions.Default with { SniffDialect = true }, - }; + var options = new SheetParallelOptions { DegreeOfParallelism = 4 }; + using CsvReader reader = Excel.FromCsvFile(path, CsvReaderOptions.Default with { SniffDialect = true }); var rows = new List(); - await foreach (Person person in CsvParallel.ParseAsync(path, ExcelParser.FromAttributes(), options, ct: TestContext.Current.CancellationToken)) + await foreach (Person person in SheetParallel.ParseAsync(reader.FirstSheet, ExcelParser.FromAttributes(), options, ct: TestContext.Current.CancellationToken)) { rows.Add(person); } From 6b4905672976694bb8db3c475477359ad908db0e Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Sat, 10 Oct 2026 22:11:55 -0300 Subject: [PATCH 067/115] feat(reader): let XLSX and XLSB enumerators parse a slice of inflated sheet bytes in place Co-Authored-By: Claude Sonnet 5.5 --- src/ExcelReader.Core/Reader/Xlsb/XlsbSheet.cs | 6 +- .../Reader/Xlsb/XlsbWorkbook.Enumerator.cs | 17 ++ .../Reader/Xlsb/XlsbWorkbook.cs | 31 +++ src/ExcelReader.Core/Reader/Xlsx/XlsxSheet.cs | 6 +- .../Reader/Xlsx/XlsxWorkbook.Enumerator.cs | 38 +++- .../Reader/Xlsx/XlsxWorkbook.cs | 32 +++ .../Reader/ChunkEnumeratorTests.cs | 182 ++++++++++++++++++ 7 files changed, 305 insertions(+), 7 deletions(-) create mode 100644 tests/ExcelReader.Tests/Reader/ChunkEnumeratorTests.cs diff --git a/src/ExcelReader.Core/Reader/Xlsb/XlsbSheet.cs b/src/ExcelReader.Core/Reader/Xlsb/XlsbSheet.cs index 69a57008..ee0f9ec4 100644 --- a/src/ExcelReader.Core/Reader/Xlsb/XlsbSheet.cs +++ b/src/ExcelReader.Core/Reader/Xlsb/XlsbSheet.cs @@ -26,7 +26,7 @@ internal XlsbSheet(XlsbWorkbook workbook, int index, string name, ExcelSheetVisi /// public bool IsDate1904 { get; } - private XlsbWorkbook Workbook + internal XlsbWorkbook Owner { get { @@ -37,13 +37,13 @@ private XlsbWorkbook Workbook /// public XlsbWorkbook.Enumerator GetEnumerator() { - return Workbook.OpenSheet(Index); + return Owner.OpenSheet(Index); } /// public XlsbWorkbook.Enumerator GetAsyncEnumerator(CancellationToken ct = default) { - return Workbook.OpenSheetAsync(Index, ct); + return Owner.OpenSheetAsync(Index, ct); } IExcelRowEnumerator IExcelSheet.GetEnumerator() diff --git a/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.Enumerator.cs b/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.Enumerator.cs index f3bbf1e0..cd1fb5fc 100644 --- a/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.Enumerator.cs +++ b/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.Enumerator.cs @@ -52,6 +52,16 @@ internal Enumerator(XlsbWorkbook reader, ZipEntryRef entry, CancellationToken ct _lease = reader.Lifetime; } + internal Enumerator(XlsbWorkbook reader, ReadOnlyMemory chunk, bool rowOpen, CancellationToken ct) + : base(chunk, reader._options.MaxCellBytes, nameof(ExcelReaderOptions.MaxCellBytes), ct) + { + _reader = reader; + _styleIsDate = reader._styleIsDate; + _sharedOffsets = reader._sharedOffsets; + _contentCache = reader._options.InternStrings ? new Utf8StringCache() : null; + _pendingRowHdr = rowOpen; + } + private protected override Stream OpenSource() { return _reader._zip!.OpenEntryStream(_entry, _reader._decompressedBytes, _reader._options); @@ -66,6 +76,13 @@ private protected override async ValueTask OpenSourceAsync() public Row Current => new(_acc.CellSpan, _acc.ValueSpan, _reader.SharedSpan, rowBuffer: default, _reader.SharedStringCache, _contentCache); + internal long Offset => _io.BaseOffset + _pos; + + // The last MoveNext stopped just past the next row's header record, so that row is already open. + internal bool RowOpen => _pendingRowHdr; + + internal bool Ended => _ended; + int IRowIndexedEnumerator.RowIndex { get diff --git a/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.cs b/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.cs index 089307a1..a51ef242 100644 --- a/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.cs +++ b/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.cs @@ -287,6 +287,37 @@ internal Enumerator OpenSheet(int index) } } + internal int MaxCellBytes => _options.MaxCellBytes; + + private ExcelReaderOptions WithoutPrefetch => _options.PrefetchDecompression ? _options with { PrefetchDecompression = false } : _options; + + internal long SheetUncompressedSize(int index) + { + Lifetime.ThrowIfClosed(this); + return WorkbookLookups.GetWorksheetEntry(_zip!, _sheets![index].Path).UncompressedSize; + } + + internal Enumerator OpenSheetWithoutPrefetch(int index) + { + Lifetime.Acquire(this); + try + { + ZipEntryRef entry = WorkbookLookups.GetWorksheetEntry(_zip!, _sheets![index].Path); + return new Enumerator(this, _zip!.OpenEntryStream(entry, _decompressedBytes, WithoutPrefetch), entry.UncompressedSize); + } + catch + { + Lifetime.Release(); + throw; + } + } + + internal Stream OpenSheetStream(int index) + { + ZipEntryRef entry = WorkbookLookups.GetWorksheetEntry(_zip!, _sheets![index].Path); + return _zip!.OpenEntryStream(entry, _decompressedBytes, WithoutPrefetch); + } + internal Enumerator OpenSheetAsync(int index, CancellationToken ct) { if (_zip!.HasMemory) diff --git a/src/ExcelReader.Core/Reader/Xlsx/XlsxSheet.cs b/src/ExcelReader.Core/Reader/Xlsx/XlsxSheet.cs index 866237f9..d9aafa65 100644 --- a/src/ExcelReader.Core/Reader/Xlsx/XlsxSheet.cs +++ b/src/ExcelReader.Core/Reader/Xlsx/XlsxSheet.cs @@ -26,7 +26,7 @@ internal XlsxSheet(XlsxWorkbook workbook, int index, string name, ExcelSheetVisi /// public bool IsDate1904 { get; } - private XlsxWorkbook Workbook + internal XlsxWorkbook Owner { get { @@ -37,13 +37,13 @@ private XlsxWorkbook Workbook /// public XlsxWorkbook.Enumerator GetEnumerator() { - return Workbook.OpenSheet(Index); + return Owner.OpenSheet(Index); } /// public XlsxWorkbook.Enumerator GetAsyncEnumerator(CancellationToken ct = default) { - return Workbook.OpenSheetAsync(Index, ct); + return Owner.OpenSheetAsync(Index, ct); } IExcelRowEnumerator IExcelSheet.GetEnumerator() diff --git a/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Enumerator.cs b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Enumerator.cs index eef45f84..1f7c928d 100644 --- a/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Enumerator.cs +++ b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Enumerator.cs @@ -25,6 +25,8 @@ public sealed class Enumerator : PooledStreamRowEnumerator, IExcelRowEnumerator, private int _nextCol; private bool _trackRowIndex; private int _rowIndex = -1; + private bool _reachedEnd; + private bool _endedMidMarkup; private NsTokens? _ns; private bool _nsChecked; @@ -54,6 +56,17 @@ internal Enumerator(XlsxWorkbook reader, ZipEntryRef entry, CancellationToken ct _lease = reader.Lifetime; } + internal Enumerator(XlsxWorkbook reader, ReadOnlyMemory chunk, NsTokens? ns, CancellationToken ct) + : base(chunk, reader._options.MaxCellBytes, nameof(ExcelReaderOptions.MaxCellBytes), ct) + { + _reader = reader; + _styleIsDate = reader._styleIsDate; + _sharedOffsets = reader._sharedOffsets; + _contentCache = reader._options.InternStrings ? new Utf8StringCache() : null; + _ns = ns; + _nsChecked = true; + } + private protected override Stream OpenSource() { _reader.EnsureSharedLoaded(); @@ -72,6 +85,25 @@ private protected override async ValueTask OpenSourceAsync() public Row Current => new(_acc.CellSpan, _acc.ValueSpan, _reader.SharedSpan, _buf.AsSpan(0, _len), _reader.SharedStringCache, _contentCache); + internal NsTokens? Namespace + { + get + { + if (!_nsChecked) + { + DetectNamespace(); + } + return _ns; + } + } + + internal long Offset => _io.BaseOffset + _pos; + + internal bool ReachedEnd => _reachedEnd; + + // True once the end of the input cut a row or a tag short: what was read last is not what a longer input would give. + internal bool EndedMidMarkup => _endedMidMarkup; + int IRowIndexedEnumerator.RowIndex { get @@ -104,6 +136,7 @@ public bool MoveNext() switch (ClassifyHead()) { case HeadKind.End: + _reachedEnd = true; return false; case HeadKind.Row: if (!BeginRow()) @@ -114,6 +147,7 @@ public bool MoveNext() default: if (!SkipMarkup()) { + _endedMidMarkup = true; return false; } break; @@ -283,6 +317,7 @@ private void RestartRowAt(int rowStart) private void ParseTruncatedRow(int rowStart) { + _endedMidMarkup = true; RestartRowAt(rowStart); ParseRow(_len); } @@ -587,6 +622,7 @@ private bool BeginRowAt(int gt) private bool MissingRowOpenTag() { + _endedMidMarkup = true; if (_trackRowIndex) { _rowIndex++; @@ -1016,7 +1052,7 @@ private static bool SkipMarkupSpan(byte[] buf, int len, ref int p) return true; } - private static bool IsBoundary(byte b) + internal static bool IsBoundary(byte b) { return b is (byte)' ' or (byte)'>' or (byte)'/' or (byte)'\t' or (byte)'\r' or (byte)'\n'; } diff --git a/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.cs b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.cs index 89ff1512..c684f478 100644 --- a/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.cs +++ b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.cs @@ -218,6 +218,38 @@ internal Enumerator OpenSheet(int index) } } + internal int MaxCellBytes => _options.MaxCellBytes; + + private ExcelReaderOptions WithoutPrefetch => _options.PrefetchDecompression ? _options with { PrefetchDecompression = false } : _options; + + internal long SheetUncompressedSize(int index) + { + Lifetime.ThrowIfClosed(this); + return WorkbookLookups.GetWorksheetEntry(_zip, _sheets[index].Path).UncompressedSize; + } + + internal Enumerator OpenSheetWithoutPrefetch(int index) + { + Lifetime.Acquire(this); + try + { + EnsureSharedLoaded(); + ZipEntryRef entry = WorkbookLookups.GetWorksheetEntry(_zip, _sheets[index].Path); + return new Enumerator(this, _zip.OpenEntryStream(entry, _decompressedBytes, WithoutPrefetch), entry.UncompressedSize); + } + catch + { + Lifetime.Release(); + throw; + } + } + + internal Stream OpenSheetStream(int index) + { + ZipEntryRef entry = WorkbookLookups.GetWorksheetEntry(_zip, _sheets[index].Path); + return _zip.OpenEntryStream(entry, _decompressedBytes, WithoutPrefetch); + } + internal Enumerator OpenSheetAsync(int index, CancellationToken ct) { if (_zip.HasMemory) diff --git a/tests/ExcelReader.Tests/Reader/ChunkEnumeratorTests.cs b/tests/ExcelReader.Tests/Reader/ChunkEnumeratorTests.cs new file mode 100644 index 00000000..57b4f603 --- /dev/null +++ b/tests/ExcelReader.Tests/Reader/ChunkEnumeratorTests.cs @@ -0,0 +1,182 @@ +using System.Globalization; +using System.Text; +using ExcelReader.Core.Reader; +using ExcelReader.Core.Reader.Xlsb; +using ExcelReader.Core.Reader.Xlsx; +using ExcelReader.Core.Writer.Xlsb; + +namespace ExcelReader.Tests.Reader +{ + public class ChunkEnumeratorTests + { + private static string Render(Row row) + { + var sb = new StringBuilder(); + foreach (RowCell cell in row.Cells) + { + sb.Append(cell.ColumnIndex).Append(':').Append(cell.Value.GetString()).Append('|'); + } + return sb.ToString(); + } + + private static string Rows(int count) + { + var sb = new StringBuilder(); + for (int r = 1; r <= count; r++) + { + sb.Append(CultureInfo.InvariantCulture, $"n{r}{r}"); + } + return sb.ToString(); + } + + private static byte[] Inflated(XlsxWorkbook workbook) + { + using Stream stream = workbook.OpenSheetStream(0); + using var copy = new MemoryStream(); + stream.CopyTo(copy); + return copy.ToArray(); + } + + [Fact] + public void AnXlsxChunkParsesLikeTheSameRowsReadSequentially() + { + using MemoryStream ms = WorkbookBuilder.Build(Rows(50)); + using XlsxWorkbook workbook = Excel.FromXlsx(ms); + var expected = new List(); + long dataStart; + NsTokens? ns; + using (XlsxWorkbook.Enumerator rows = workbook.OpenSheetWithoutPrefetch(0)) + { + ns = rows.Namespace; + Assert.True(rows.MoveNext()); + dataStart = rows.Offset; + while (rows.MoveNext()) + { + expected.Add(Render(rows.Current)); + } + } + byte[] inflated = Inflated(workbook); + + var actual = new List(); + using var chunk = new XlsxWorkbook.Enumerator(workbook, inflated.AsMemory((int)dataStart), ns, CancellationToken.None); + while (chunk.MoveNext()) + { + actual.Add(Render(chunk.Current)); + } + + Assert.Equal(expected, actual); + Assert.True(chunk.ReachedEnd); + Assert.False(chunk.EndedMidMarkup); + } + + [Fact] + public void AnXlsxChunkCutBetweenRowsEndsCleanly() + { + using MemoryStream ms = WorkbookBuilder.Build(Rows(10)); + using XlsxWorkbook workbook = Excel.FromXlsx(ms); + using (workbook.OpenSheetWithoutPrefetch(0)) + { + } + byte[] inflated = Inflated(workbook); + int first = inflated.AsSpan().IndexOf("1"); + using XlsxWorkbook workbook = Excel.FromXlsx(ms); + using XlsxWorkbook.Enumerator rows = workbook.OpenSheetWithoutPrefetch(0); + + Assert.NotNull(rows.Namespace); + Assert.Equal("(); + long dataStart; + bool rowOpen; + using (XlsbWorkbook.Enumerator rows = workbook.OpenSheetWithoutPrefetch(0)) + { + Assert.True(rows.MoveNext()); + dataStart = rows.Offset; + rowOpen = rows.RowOpen; + while (rows.MoveNext()) + { + expected.Add(Render(rows.Current)); + } + } + byte[] inflated; + using (Stream stream = workbook.OpenSheetStream(0)) + using (var copy = new MemoryStream()) + { + await stream.CopyToAsync(copy, ct); + inflated = copy.ToArray(); + } + + var actual = new List(); + using var chunk = new XlsbWorkbook.Enumerator(workbook, inflated.AsMemory((int)dataStart), rowOpen, CancellationToken.None); + while (chunk.MoveNext()) + { + actual.Add(Render(chunk.Current)); + } + + Assert.True(rowOpen); + Assert.Equal(29, expected.Count); + Assert.Equal(expected, actual); + } + } +} From ddac3c831821eba2df2350cd2de2204cee512437 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Sat, 10 Oct 2026 22:16:11 -0300 Subject: [PATCH 068/115] feat: cut an inflated sheet stream into row-aligned chunks Co-Authored-By: Claude Sonnet 5.5 --- .../Parser/SheetPipeline/ChunkProducer.cs | 78 ++++++++++++ .../Parser/SheetPipeline/RowCutter.cs | 73 +++++++++++ .../SheetPipeline/ChunkProducerTests.cs | 119 ++++++++++++++++++ 3 files changed, 270 insertions(+) create mode 100644 src/ExcelReader.Core/Parser/SheetPipeline/ChunkProducer.cs create mode 100644 src/ExcelReader.Core/Parser/SheetPipeline/RowCutter.cs create mode 100644 tests/ExcelReader.Tests/Parser/SheetPipeline/ChunkProducerTests.cs diff --git a/src/ExcelReader.Core/Parser/SheetPipeline/ChunkProducer.cs b/src/ExcelReader.Core/Parser/SheetPipeline/ChunkProducer.cs new file mode 100644 index 00000000..807342ad --- /dev/null +++ b/src/ExcelReader.Core/Parser/SheetPipeline/ChunkProducer.cs @@ -0,0 +1,78 @@ +using System.Buffers; +using ExcelReader.Core.Reader; +using ExcelReader.Core.Reader.Internal; + +namespace ExcelReader.Core.Parser.SheetPipeline +{ + internal readonly record struct SheetChunk(byte[] Buffer, int Length); + + internal static class ChunkProducer + { + internal static async Task RunAsync( + Stream source, RowCutter cutter, int chunkBytes, int maxCellBytes, Func emit, CancellationToken ct) + { + byte[]? buffer = ArrayPool.Shared.Rent(chunkBytes); + int target = chunkBytes; + int filled = 0; + try + { + while (true) + { + bool eof = false; + while (filled < target) + { + int read = await source.ReadAsync(buffer.AsMemory(filled, target - filled), ct).ConfigureAwait(false); + if (read == 0) + { + eof = true; + break; + } + filled += read; + } + + int cut = cutter.Cut(buffer.AsSpan(0, filled), out bool ended); + if (ended || eof) + { + int length = ended ? cut : filled; + if (length > 0) + { + byte[] last = buffer; + buffer = null; + await emit(new SheetChunk(last, length)).ConfigureAwait(false); + } + return; + } + if (cut < 0) + { + target = LimitChecks.NextBufferSize(maxCellBytes, nameof(ExcelReaderOptions.MaxCellBytes), target, target + 1); + if (buffer.Length < target) + { + byte[] bigger = ArrayPool.Shared.Rent(target); + buffer.AsSpan(0, filled).CopyTo(bigger); + ArrayPool.Shared.Return(buffer); + buffer = bigger; + } + continue; + } + + int tail = filled - cut; + byte[] next = ArrayPool.Shared.Rent(Math.Max(chunkBytes, tail)); + buffer.AsSpan(cut, tail).CopyTo(next); + byte[] full = buffer; + buffer = next; + filled = tail; + target = Math.Max(chunkBytes, tail); + cutter.Rebase(cut); + await emit(new SheetChunk(full, cut)).ConfigureAwait(false); + } + } + finally + { + if (buffer is not null) + { + ArrayPool.Shared.Return(buffer); + } + } + } + } +} diff --git a/src/ExcelReader.Core/Parser/SheetPipeline/RowCutter.cs b/src/ExcelReader.Core/Parser/SheetPipeline/RowCutter.cs new file mode 100644 index 00000000..0ac1e0d2 --- /dev/null +++ b/src/ExcelReader.Core/Parser/SheetPipeline/RowCutter.cs @@ -0,0 +1,73 @@ +using ExcelReader.Core.Reader.Xlsb; +using ExcelReader.Core.Reader.Xlsx; + +namespace ExcelReader.Core.Parser.SheetPipeline +{ + internal abstract class RowCutter + { + // The offset of the last row start past offset 0, or -1 when there is none yet. When `ended` is + // set, the sheet's rows end at the returned offset and nothing after it is to be read. + internal abstract int Cut(ReadOnlySpan data, out bool ended); + + internal virtual void Rebase(int cut) + { + } + } + + internal sealed class XlsxRowCutter(byte[] rowOpen) : RowCutter + { + internal override int Cut(ReadOnlySpan data, out bool ended) + { + ended = false; + ReadOnlySpan search = data; + while (true) + { + int at = search.LastIndexOf(rowOpen); + if (at <= 0) + { + return -1; + } + int after = at + rowOpen.Length; + if (after < data.Length && XlsxWorkbook.Enumerator.IsBoundary(data[after])) + { + return at; + } + search = search[..at]; + } + } + } + + internal sealed class XlsbRowCutter : RowCutter + { + private int _walked; + private int _lastRow = -1; + + internal override int Cut(ReadOnlySpan data, out bool ended) + { + var reader = new Biff12RecordReader(data[_walked..]); + int before = 0; + while (reader.TryReadRecord(out int id, out _)) + { + if (id == Brt.RowHdr) + { + _lastRow = _walked + before; + } + else if (id == Brt.EndSheetData) + { + ended = true; + return _walked + reader.Position; + } + before = reader.Position; + } + _walked += reader.Position; + ended = false; + return _lastRow > 0 ? _lastRow : -1; + } + + internal override void Rebase(int cut) + { + _walked -= cut; + _lastRow = -1; + } + } +} diff --git a/tests/ExcelReader.Tests/Parser/SheetPipeline/ChunkProducerTests.cs b/tests/ExcelReader.Tests/Parser/SheetPipeline/ChunkProducerTests.cs new file mode 100644 index 00000000..dec1b32e --- /dev/null +++ b/tests/ExcelReader.Tests/Parser/SheetPipeline/ChunkProducerTests.cs @@ -0,0 +1,119 @@ +using System.Buffers; +using System.Globalization; +using System.Text; +using ExcelReader.Core.Parser.SheetPipeline; +using ExcelReader.Core.Reader; + +namespace ExcelReader.Tests.Parser.SheetPipeline +{ + public class ChunkProducerTests + { + private static byte[] Sheet(int rows, int padding = 0) + { + var sb = new StringBuilder(""); + for (int r = 1; r <= rows; r++) + { + sb.Append(CultureInfo.InvariantCulture, $"{r}{new string(' ', padding)}"); + } + return Encoding.UTF8.GetBytes(sb.Append("").ToString()); + } + + private static async Task> ProduceAsync(byte[] input, int chunkBytes, int maxCellBytes = 0, RowCutter? cutter = null) + { + var chunks = new List(); + await ChunkProducer.RunAsync( + new MemoryStream(input), cutter ?? new XlsxRowCutter(" + { + chunks.Add(chunk.Buffer.AsSpan(0, chunk.Length).ToArray()); + ArrayPool.Shared.Return(chunk.Buffer); + return ValueTask.CompletedTask; + }, + TestContext.Current.CancellationToken); + return chunks; + } + + [Theory] + [InlineData(64)] + [InlineData(100)] + [InlineData(4096)] + public async Task ChunksConcatenateToTheInputAndStartOnRows(int chunkBytes) + { + byte[] input = Sheet(200); + + List chunks = await ProduceAsync(input, chunkBytes); + + Assert.Equal(input, chunks.SelectMany(c => c).ToArray()); + Assert.True(chunks.Count > 1); + Assert.All(chunks.Skip(1), chunk => Assert.StartsWith(" chunks = await ProduceAsync(input, 64); + + Assert.Equal(input, chunks.SelectMany(c => c).ToArray()); + Assert.All(chunks.Skip(1), chunk => Assert.StartsWith("(() => ProduceAsync(input, 64, maxCellBytes: 1024)); + } + + [Fact] + public async Task ARowLikeNameIsNotACut() + { + byte[] input = Encoding.UTF8.GetBytes("1"); + + List chunks = await ProduceAsync(input, 48); + + Assert.Equal(input, chunks.SelectMany(c => c).ToArray()); + Assert.All(chunks.Skip(1), chunk => Assert.StartsWith("(); + for (int r = 0; r < 40; r++) + { + input.AddRange(Record(0, 8)); + input.AddRange(Record(Cell, 12)); + input.AddRange(Record(Cell, 12)); + } + byte[] end = [(byte)((EndSheetData & 0x7F) | 0x80), (byte)(EndSheetData >> 7), 0]; + input.AddRange(end); + int dataLength = input.Count; + input.AddRange(Record(0, 8)); + + List chunks = await ProduceAsync([.. input], 96, cutter: new XlsbRowCutter()); + + Assert.Equal(input.Take(dataLength), chunks.SelectMany(c => c)); + Assert.True(chunks.Count > 1); + Assert.All(chunks, chunk => Assert.Equal(0, chunk[0])); + } + } +} From c2ec1c12dd4dc85ebcb97518c0ef5d2de34cfb1a Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Sat, 10 Oct 2026 22:22:08 -0300 Subject: [PATCH 069/115] fix: stop the XLSX chunk producer at the end of sheet data Co-Authored-By: Claude Sonnet 5.5 --- .../Parser/SheetPipeline/RowCutter.cs | 14 ++++- .../SheetPipeline/ChunkProducerTests.cs | 51 +++++++++++++++++-- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/ExcelReader.Core/Parser/SheetPipeline/RowCutter.cs b/src/ExcelReader.Core/Parser/SheetPipeline/RowCutter.cs index 0ac1e0d2..e3b24170 100644 --- a/src/ExcelReader.Core/Parser/SheetPipeline/RowCutter.cs +++ b/src/ExcelReader.Core/Parser/SheetPipeline/RowCutter.cs @@ -14,11 +14,23 @@ internal virtual void Rebase(int cut) } } - internal sealed class XlsxRowCutter(byte[] rowOpen) : RowCutter + internal sealed class XlsxRowCutter(byte[] rowOpen, byte[] sheetDataEnd) : RowCutter { internal override int Cut(ReadOnlySpan data, out bool ended) { + int at = LastRowStart(data); + int end = data[Math.Max(at, 0)..].IndexOf(sheetDataEnd); + if (end >= 0) + { + ended = true; + return Math.Max(at, 0) + end + sheetDataEnd.Length; + } ended = false; + return at; + } + + private int LastRowStart(ReadOnlySpan data) + { ReadOnlySpan search = data; while (true) { diff --git a/tests/ExcelReader.Tests/Parser/SheetPipeline/ChunkProducerTests.cs b/tests/ExcelReader.Tests/Parser/SheetPipeline/ChunkProducerTests.cs index dec1b32e..53681daf 100644 --- a/tests/ExcelReader.Tests/Parser/SheetPipeline/ChunkProducerTests.cs +++ b/tests/ExcelReader.Tests/Parser/SheetPipeline/ChunkProducerTests.cs @@ -18,11 +18,16 @@ private static byte[] Sheet(int rows, int padding = 0) return Encoding.UTF8.GetBytes(sb.Append("").ToString()); } + private static byte[] UpToSheetDataEnd(byte[] input, string token = "> ProduceAsync(byte[] input, int chunkBytes, int maxCellBytes = 0, RowCutter? cutter = null) { var chunks = new List(); await ChunkProducer.RunAsync( - new MemoryStream(input), cutter ?? new XlsxRowCutter(" { chunks.Add(chunk.Buffer.AsSpan(0, chunk.Length).ToArray()); @@ -43,7 +48,7 @@ public async Task ChunksConcatenateToTheInputAndStartOnRows(int chunkBytes) List chunks = await ProduceAsync(input, chunkBytes); - Assert.Equal(input, chunks.SelectMany(c => c).ToArray()); + Assert.Equal(UpToSheetDataEnd(input), chunks.SelectMany(c => c).ToArray()); Assert.True(chunks.Count > 1); Assert.All(chunks.Skip(1), chunk => Assert.StartsWith(" chunks = await ProduceAsync(input, 64); - Assert.Equal(input, chunks.SelectMany(c => c).ToArray()); + Assert.Equal(UpToSheetDataEnd(input), chunks.SelectMany(c => c).ToArray()); Assert.All(chunks.Skip(1), chunk => Assert.StartsWith(" chunks = await ProduceAsync(input, 48); - Assert.Equal(input, chunks.SelectMany(c => c).ToArray()); + Assert.Equal(UpToSheetDataEnd(input), chunks.SelectMany(c => c).ToArray()); Assert.All(chunks.Skip(1), chunk => Assert.StartsWith("".Length]; + byte[] input = Encoding.UTF8.GetBytes(rows + string.Concat(Enumerable.Repeat("", 3000)) + ""); + Assert.True(input.Length > 50_000); + + List chunks = await ProduceAsync(input, 64, maxCellBytes: 1024); + + Assert.Equal(UpToSheetDataEnd(input), chunks.SelectMany(c => c).ToArray()); + } + + [Fact] + public async Task APrefixedSheetEndsAtItsPrefixedTag() + { + var sb = new StringBuilder(""); + for (int r = 1; r <= 20; r++) + { + sb.Append(CultureInfo.InvariantCulture, $"{r}"); + } + sb.Append("").Append(string.Concat(Enumerable.Repeat("", 3000))).Append(""); + byte[] input = Encoding.UTF8.GetBytes(sb.ToString()); + var chunks = new List(); + + await ChunkProducer.RunAsync( + new MemoryStream(input), new XlsxRowCutter(" + { + chunks.Add(chunk.Buffer.AsSpan(0, chunk.Length).ToArray()); + ArrayPool.Shared.Return(chunk.Buffer); + return ValueTask.CompletedTask; + }, + TestContext.Current.CancellationToken); + + Assert.Equal(UpToSheetDataEnd(input, " c).ToArray()); + Assert.All(chunks.Skip(1), chunk => Assert.StartsWith(" Date: Sat, 10 Oct 2026 22:49:46 -0300 Subject: [PATCH 070/115] feat: add the sheet pipeline, reading XLSX rows on several threads --- .../SheetPipeline/SheetPipelineRunner.cs | 173 ++++++++++++++ .../SheetPipeline/SheetPipelineSource.cs | 87 +++++++ .../SheetPipeline/XlsxPipelineSource.cs | 107 +++++++++ .../Parser/SheetPipeline/XlsxPipelineTests.cs | 213 ++++++++++++++++++ 4 files changed, 580 insertions(+) create mode 100644 src/ExcelReader.Core/Parser/SheetPipeline/SheetPipelineRunner.cs create mode 100644 src/ExcelReader.Core/Parser/SheetPipeline/SheetPipelineSource.cs create mode 100644 src/ExcelReader.Core/Parser/SheetPipeline/XlsxPipelineSource.cs create mode 100644 tests/ExcelReader.Tests/Parser/SheetPipeline/XlsxPipelineTests.cs diff --git a/src/ExcelReader.Core/Parser/SheetPipeline/SheetPipelineRunner.cs b/src/ExcelReader.Core/Parser/SheetPipeline/SheetPipelineRunner.cs new file mode 100644 index 00000000..1af580fa --- /dev/null +++ b/src/ExcelReader.Core/Parser/SheetPipeline/SheetPipelineRunner.cs @@ -0,0 +1,173 @@ +using System.Buffers; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Runtime.ExceptionServices; +using System.Threading.Channels; +using ExcelReader.Core.Parser.Internal; +using ExcelReader.Core.Parser.ParallelCsv; +using ExcelReader.Core.Reader; + +namespace ExcelReader.Core.Parser.SheetPipeline +{ + internal static class SheetPipelineRunner + { + private readonly record struct Work(SheetChunk Chunk, bool First, TaskCompletionSource> Done); + + internal static async Task AggregateAsync( + SheetPipelineSource source, RowAggregation aggregation, RowActionFactory? factory, + int headerRow, int dop, int chunkBytes, CancellationToken ct) + { + TState merged = default!; + bool any = false; + await foreach (TState state in RunAsync(source, aggregation, factory, headerRow, dop, chunkBytes, ct).ConfigureAwait(false)) + { + merged = any ? aggregation.Combine(merged, state) : state; + any = true; + } + return any ? merged : aggregation.Seed(); + } + + internal static async IAsyncEnumerable RunAsync( + SheetPipelineSource source, RowAggregation aggregation, RowActionFactory? factory, + int headerRow, int dop, int chunkBytes, [EnumeratorCancellation] CancellationToken cancellationToken) + { + source.Lifetime.Acquire(source.Owner); + try + { + RowAction? bound = factory is not null && headerRow == 0 ? factory(default, sequential: false) : null; + PipelineStart? start = source.Begin(headerRow, factory is null || headerRow == 0 ? null : header => bound = factory(header, sequential: false)); + if (start is null) + { + yield break; + } + if (bound is not null) + { + aggregation = new RowAggregation { Seed = aggregation.Seed, Accumulate = bound, Combine = aggregation.Combine }; + } + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + CancellationToken ct = cts.Token; + int workers = Math.Max(1, dop - 1); + var work = Channel.CreateBounded>(workers * 2); + var ordered = Channel.CreateBounded>>( + new BoundedChannelOptions(workers * 2) { SingleReader = true, SingleWriter = true }); + + var tasks = new Task[workers + 1]; + tasks[0] = Task.Run(() => ProduceAsync(start, chunkBytes, source.MaxCellBytes, work.Writer, ordered.Writer, ct), CancellationToken.None); + for (int w = 1; w <= workers; w++) + { + tasks[w] = Task.Run(() => WorkAsync(source, aggregation, work.Reader, ct), CancellationToken.None); + } + + long rows = 0; + bool recover = false; + try + { + await foreach (Task> pending in ordered.Reader.ReadAllAsync(ct).ConfigureAwait(false)) + { + ChunkOutcome outcome = await pending.WaitAsync(ct).ConfigureAwait(false); + if (outcome.Failure is not null) + { + ThrowRenumbered(outcome.Failure, headerRow + rows); + } + rows += outcome.Rows; + yield return outcome.State; + if (!outcome.Clean) + { + recover = true; + break; + } + if (outcome.ReachedEnd) + { + break; + } + } + cancellationToken.ThrowIfCancellationRequested(); + } + finally + { + await cts.CancelAsync().ConfigureAwait(false); + await Task.WhenAll(tasks).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + } + + if (recover) + { + yield return source.ParseTail(headerRow, rows, aggregation, cancellationToken); + } + } + finally + { + source.Lifetime.Release(); + } + } + + [SuppressMessage("Design", "CA1031:Do not catch general exception types", + Justification = "The exception is recorded and delivered to the merge through the ordered channel's completion.")] + private static async Task ProduceAsync( + PipelineStart start, int chunkBytes, int maxCellBytes, ChannelWriter> work, + ChannelWriter>> ordered, CancellationToken ct) + { + Exception? failure = null; + try + { + bool first = true; + await ChunkProducer.RunAsync( + start.Stream, start.Cutter, chunkBytes, maxCellBytes, + async chunk => + { + var done = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + await ordered.WriteAsync(done.Task, ct).ConfigureAwait(false); + await work.WriteAsync(new Work(chunk, first, done), ct).ConfigureAwait(false); + first = false; + }, + ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + failure = ex; + } + finally + { + await start.Stream.DisposeAsync().ConfigureAwait(false); + work.Complete(); + ordered.Complete(failure); + } + } + + private static async Task WorkAsync( + SheetPipelineSource source, RowAggregation aggregation, ChannelReader> work, CancellationToken ct) + { + await foreach (Work item in work.ReadAllAsync(ct).ConfigureAwait(false)) + { + try + { + item.Done.SetResult(source.ParseChunk(item.Chunk.Buffer.AsMemory(0, item.Chunk.Length), item.First, aggregation, ct)); + } + catch (Exception ex) + { + item.Done.TrySetException(ex); + throw; + } + finally + { + ArrayPool.Shared.Return(item.Chunk.Buffer); + } + } + } + + private static void ThrowRenumbered(ExceptionDispatchInfo failure, long rowsBefore) + { + if (failure.SourceException is ExcelParseException { Row: > 0 } parse) + { + int row = (int)(rowsBefore + parse.Row); + throw parse.RawValue.Length == 0 + ? ProjectionRules.MissingRequiredValue(parse.ColumnName, row) + : new ExcelParseException(row, parse.ColumnName, parse.RawValue); + } + failure.Throw(); + } + } +} diff --git a/src/ExcelReader.Core/Parser/SheetPipeline/SheetPipelineSource.cs b/src/ExcelReader.Core/Parser/SheetPipeline/SheetPipelineSource.cs new file mode 100644 index 00000000..f1f1731d --- /dev/null +++ b/src/ExcelReader.Core/Parser/SheetPipeline/SheetPipelineSource.cs @@ -0,0 +1,87 @@ +using System.Buffers; +using System.Runtime.ExceptionServices; +using ExcelReader.Core.Reader; +using ExcelReader.Core.Reader.Internal; +using ExcelReader.Core.Reader.Xlsx; + +namespace ExcelReader.Core.Parser.SheetPipeline +{ + internal delegate void HeaderVisitor(Row header); + + internal sealed record PipelineStart(Stream Stream, RowCutter Cutter); + + internal sealed class ChunkOutcome(TState state) + { + public TState State = state; + public long Rows; + + // False when the chunk did not end between rows: its rows are valid, but what follows must be read sequentially. + public bool Clean = true; + public bool ReachedEnd; + public ExceptionDispatchInfo? Failure; + } + + internal abstract class SheetPipelineSource + { + internal const long MinBytes = 256 * 1024; + internal const int DefaultChunkBytes = 1024 * 1024; + private protected const int RowsPerCancellationCheck = 4096; + + internal abstract object Owner { get; } + + internal abstract ReaderLifetime Lifetime { get; } + + internal abstract int MaxCellBytes { get; } + + internal abstract long UncompressedSize { get; } + + // Null when the sheet ends at or before headerRow; otherwise the stream is positioned at the first data row. + internal abstract PipelineStart? Begin(int headerRow, HeaderVisitor? onHeader); + + internal abstract ChunkOutcome ParseChunk( + ReadOnlyMemory chunk, bool first, RowAggregation aggregation, CancellationToken ct); + + // Reads the sheet again from its start: skipRows counts data rows, after the header. + internal abstract TState ParseTail(int headerRow, long skipRows, RowAggregation aggregation, CancellationToken ct); + + internal static SheetPipelineSource? Create(IExcelSheet sheet) + { + return sheet switch + { + XlsxSheet xlsx => new XlsxPipelineSource(xlsx.Owner, xlsx.Index), + _ => null, + }; + } + + internal static SheetPipelineSource? TryCreate(IExcelSheet sheet, int dop) + { + if (dop <= 1) + { + return null; + } + SheetPipelineSource? source = Create(sheet); + return source is not null && source.UncompressedSize >= MinBytes ? source : null; + } + + private protected static void Skip(Stream stream, long count) + { + byte[] scratch = ArrayPool.Shared.Rent(64 * 1024); + try + { + while (count > 0) + { + int read = stream.Read(scratch, 0, (int)Math.Min(count, scratch.Length)); + if (read == 0) + { + return; + } + count -= read; + } + } + finally + { + ArrayPool.Shared.Return(scratch); + } + } + } +} diff --git a/src/ExcelReader.Core/Parser/SheetPipeline/XlsxPipelineSource.cs b/src/ExcelReader.Core/Parser/SheetPipeline/XlsxPipelineSource.cs new file mode 100644 index 00000000..ef852c05 --- /dev/null +++ b/src/ExcelReader.Core/Parser/SheetPipeline/XlsxPipelineSource.cs @@ -0,0 +1,107 @@ +using System.Runtime.ExceptionServices; +using ExcelReader.Core.Reader; +using ExcelReader.Core.Reader.Internal; +using ExcelReader.Core.Reader.Xlsx; + +namespace ExcelReader.Core.Parser.SheetPipeline +{ + internal sealed class XlsxPipelineSource(XlsxWorkbook workbook, int index) : SheetPipelineSource + { + private NsTokens? _ns; + + internal override object Owner => workbook; + + internal override ReaderLifetime Lifetime => workbook.Lifetime; + + internal override int MaxCellBytes => workbook.MaxCellBytes; + + internal override long UncompressedSize => workbook.SheetUncompressedSize(index); + + internal override PipelineStart? Begin(int headerRow, HeaderVisitor? onHeader) + { + long offset; + using (XlsxWorkbook.Enumerator rows = workbook.OpenSheetWithoutPrefetch(index)) + { + _ns = rows.Namespace; + for (int row = 1; row <= headerRow; row++) + { + if (!rows.MoveNext()) + { + return null; + } + if (row == headerRow) + { + onHeader?.Invoke(rows.Current); + } + } + offset = rows.Offset; + } + + Stream stream = workbook.OpenSheetStream(index); + try + { + Skip(stream, offset); + } + catch + { + stream.Dispose(); + throw; + } + return new PipelineStart(stream, new XlsxRowCutter(_ns?.RowOpen ?? " ParseChunk( + ReadOnlyMemory chunk, bool first, RowAggregation aggregation, CancellationToken ct) + { + var outcome = new ChunkOutcome(aggregation.Seed()); + var rows = new XlsxWorkbook.Enumerator(workbook, chunk, _ns, ct); + try + { + RowAction accumulate = aggregation.Accumulate; + while (rows.MoveNext() && !rows.EndedMidMarkup) + { + if (++outcome.Rows % RowsPerCancellationCheck == 0) + { + ct.ThrowIfCancellationRequested(); + } + accumulate(ref outcome.State, rows.Current); + } + outcome.Clean = !rows.EndedMidMarkup; + outcome.ReachedEnd = rows.ReachedEnd; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + outcome.Failure = ExceptionDispatchInfo.Capture(ex); + } + finally + { + rows.Dispose(); + } + return outcome; + } + + internal override TState ParseTail(int headerRow, long skipRows, RowAggregation aggregation, CancellationToken ct) + { + TState state = aggregation.Seed(); + using XlsxWorkbook.Enumerator rows = workbook.OpenSheetWithoutPrefetch(index); + for (long skipped = 0; skipped < headerRow + skipRows; skipped++) + { + if (!rows.MoveNext()) + { + return state; + } + } + RowAction accumulate = aggregation.Accumulate; + long delivered = 0; + while (rows.MoveNext()) + { + if (++delivered % RowsPerCancellationCheck == 0) + { + ct.ThrowIfCancellationRequested(); + } + accumulate(ref state, rows.Current); + } + return state; + } + } +} diff --git a/tests/ExcelReader.Tests/Parser/SheetPipeline/XlsxPipelineTests.cs b/tests/ExcelReader.Tests/Parser/SheetPipeline/XlsxPipelineTests.cs new file mode 100644 index 00000000..45e949b6 --- /dev/null +++ b/tests/ExcelReader.Tests/Parser/SheetPipeline/XlsxPipelineTests.cs @@ -0,0 +1,213 @@ +using System.Globalization; +using System.Text; +using ExcelReader.Core.Parser.ParallelCsv; +using ExcelReader.Core.Parser.SheetPipeline; +using ExcelReader.Core.Reader; +using ExcelReader.Core.Reader.Xlsx; + +namespace ExcelReader.Tests.Parser.SheetPipeline +{ + public class XlsxPipelineTests + { + private static readonly RowAggregation> Collecting = new() + { + Seed = static () => [], + Accumulate = static (ref List rows, Row row) => rows.Add(Render(row)), + Combine = static (left, right) => + { + left.AddRange(right); + return left; + }, + }; + + private static string Render(Row row) + { + var sb = new StringBuilder(); + foreach (RowCell cell in row.Cells) + { + sb.Append(cell.ColumnIndex).Append(':').Append(cell.Value.GetString()).Append('|'); + } + return sb.ToString(); + } + + private static string Rows(int count, Func? text = null) + { + var sb = new StringBuilder(); + for (int r = 1; r <= count; r++) + { + string value = text?.Invoke(r) ?? string.Create(CultureInfo.InvariantCulture, $"n{r}"); + sb.Append(CultureInfo.InvariantCulture, $"{value}{r}"); + } + return sb.ToString(); + } + + private static List Sequential(XlsxWorkbook workbook, int headerRow) + { + var rows = new List(); + foreach (Row row in workbook.FirstSheet) + { + rows.Add(Render(row)); + } + return rows.Count > headerRow ? rows[headerRow..] : []; + } + + private static Task> PipelineAsync(XlsxWorkbook workbook, int headerRow, int dop, int chunkBytes, RowActionFactory>? factory = null) + { + SheetPipelineSource source = SheetPipelineSource.Create(workbook.FirstSheet)!; + return SheetPipelineRunner.AggregateAsync(source, Collecting, factory, headerRow, dop, chunkBytes, TestContext.Current.CancellationToken); + } + + public static TheoryData Shapes() + { + var data = new TheoryData(); + foreach (int dop in new[] { 2, 4, 8 }) + { + foreach (int chunk in new[] { 64, 200, 1000, 1 << 20 }) + { + data.Add(dop, chunk); + } + } + return data; + } + + [Theory] + [MemberData(nameof(Shapes))] + public async Task MatchesSequential(int dop, int chunkBytes) + { + using MemoryStream ms = WorkbookBuilder.Build(Rows(500)); + using XlsxWorkbook workbook = Excel.FromXlsx(ms); + + Assert.Equal(Sequential(workbook, 0), await PipelineAsync(workbook, 0, dop, chunkBytes)); + Assert.Equal(Sequential(workbook, 1), await PipelineAsync(workbook, 1, dop, chunkBytes)); + Assert.Equal(Sequential(workbook, 3), await PipelineAsync(workbook, 3, dop, chunkBytes)); + } + + [Theory] + [InlineData("")] + [InlineData("1")] + public async Task ShortSheetsYieldNothing(string rows) + { + using MemoryStream ms = WorkbookBuilder.Build(rows); + using XlsxWorkbook workbook = Excel.FromXlsx(ms); + + Assert.Empty(await PipelineAsync(workbook, 1, 4, 64)); + Assert.Empty(await PipelineAsync(workbook, 5, 4, 64)); + } + + [Fact] + public async Task TheHeaderRowReachesTheFactoryOnce() + { + using MemoryStream ms = WorkbookBuilder.Build(Rows(300)); + using XlsxWorkbook workbook = Excel.FromXlsx(ms); + int calls = 0; + string? header = null; + RowActionFactory> factory = (Row row, bool sequential) => + { + Interlocked.Increment(ref calls); + header = Render(row); + Assert.False(sequential); + return Collecting.Accumulate; + }; + + List rows = await PipelineAsync(workbook, 2, 4, 128, factory); + + Assert.Equal(1, calls); + Assert.Equal(Sequential(workbook, 0)[1], header); + Assert.Equal(Sequential(workbook, 2), rows); + } + + [Fact] + public async Task APrefixedSheetMatchesSequential() + { + var sb = new StringBuilder(); + for (int r = 1; r <= 200; r++) + { + sb.Append(CultureInfo.InvariantCulture, $"{r}"); + } + using MemoryStream ms = WorkbookBuilder.BuildPrefixed("x", sb.ToString()); + using XlsxWorkbook workbook = Excel.FromXlsx(ms); + + Assert.Equal(Sequential(workbook, 0), await PipelineAsync(workbook, 0, 4, 96)); + } + + [Theory] + [InlineData(" ]]>")] + [InlineData("z")] + public async Task ARowLikeTagInsideARowMatchesSequential(string poison) + { + using MemoryStream ms = WorkbookBuilder.Build(Rows(120, r => r % 10 == 0 ? poison : "n")); + using XlsxWorkbook workbook = Excel.FromXlsx(ms); + List expected = Sequential(workbook, 0); + + foreach (int chunkBytes in new[] { 64, 90, 150, 400 }) + { + Assert.Equal(expected, await PipelineAsync(workbook, 0, 4, chunkBytes)); + Assert.Equal(expected[1..], await PipelineAsync(workbook, 1, 4, chunkBytes)); + } + } + + [Fact] + public async Task RowsAfterTheEndOfSheetDataAreIgnored() + { + string rows = Rows(60) + "" + Rows(60) + ""; + using MemoryStream ms = WorkbookBuilder.Build(rows); + using XlsxWorkbook workbook = Excel.FromXlsx(ms); + List expected = Sequential(workbook, 0); + + Assert.Equal(60, expected.Count); + Assert.Equal(expected, await PipelineAsync(workbook, 0, 4, 80)); + } + + [Fact] + public async Task AnAccumulateExceptionSurfaces() + { + using MemoryStream ms = WorkbookBuilder.Build(Rows(300)); + using XlsxWorkbook workbook = Excel.FromXlsx(ms); + var failing = new RowAggregation + { + Seed = static () => 0, + Accumulate = static (ref int count, Row row) => + { + if (row[1].TryGetDouble(out double value) && value == 150) + { + throw new InvalidOperationException("boom"); + } + count++; + }, + Combine = static (left, right) => left + right, + }; + SheetPipelineSource source = SheetPipelineSource.Create(workbook.FirstSheet)!; + + InvalidOperationException ex = await Assert.ThrowsAsync( + () => SheetPipelineRunner.AggregateAsync(source, failing, null, 0, 4, 128, TestContext.Current.CancellationToken)); + + Assert.Equal("boom", ex.Message); + } + + [Fact] + public async Task CancellationStopsTheRead() + { + using MemoryStream ms = WorkbookBuilder.Build(Rows(2000)); + using XlsxWorkbook workbook = Excel.FromXlsx(ms); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + int seen = 0; + var cancelling = new RowAggregation + { + Seed = static () => 0, + Accumulate = (ref int count, Row _) => + { + count++; + if (Interlocked.Increment(ref seen) == 5) + { + cts.Cancel(); + } + }, + Combine = static (left, right) => left + right, + }; + SheetPipelineSource source = SheetPipelineSource.Create(workbook.FirstSheet)!; + + await Assert.ThrowsAnyAsync( + () => SheetPipelineRunner.AggregateAsync(source, cancelling, null, 0, 4, 64, cts.Token)); + } + } +} From 5b3d75506a8caed786e93125fbececa136fe3cda Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Sat, 10 Oct 2026 23:03:01 -0300 Subject: [PATCH 071/115] feat: read XLSX sheets in parallel through SheetParallel Co-Authored-By: Claude Opus 5.5 (1M context) --- .../SheetPipeline/SheetRowProjection.cs | 60 ++++ .../Parser/SheetPipeline/SheetRunner.cs | 12 + .../Parser/SheetParallelXlsxTests.cs | 298 ++++++++++++++++++ 3 files changed, 370 insertions(+) create mode 100644 src/ExcelReader.Core/Parser/SheetPipeline/SheetRowProjection.cs create mode 100644 tests/ExcelReader.Tests/Parser/SheetParallelXlsxTests.cs diff --git a/src/ExcelReader.Core/Parser/SheetPipeline/SheetRowProjection.cs b/src/ExcelReader.Core/Parser/SheetPipeline/SheetRowProjection.cs new file mode 100644 index 00000000..0bab5264 --- /dev/null +++ b/src/ExcelReader.Core/Parser/SheetPipeline/SheetRowProjection.cs @@ -0,0 +1,60 @@ +using System.Runtime.CompilerServices; +using ExcelReader.Core.Parser.Internal; +using ExcelReader.Core.Parser.ParallelCsv; +using ExcelReader.Core.Reader; + +namespace ExcelReader.Core.Parser.SheetPipeline +{ + internal static class SheetRowProjection + { + internal sealed class Chunk + { + public readonly List Items = []; + public int Rows; + public bool[]? Seen; + } + + private static readonly RowAggregation Unbound = new() + { + Seed = static () => new Chunk(), + Accumulate = static (ref _, _) => throw new InvalidOperationException("The model map was not bound before the first row."), + Combine = static (left, _) => left, + }; + + internal static async IAsyncEnumerable ParseAsync( + SheetPipelineSource source, ExcelParser parser, bool isDate1904, int dop, int chunkBytes, [EnumeratorCancellation] CancellationToken ct) + { + TypeMapInfo info = parser.Info; + int headerRow = info.IsIndexBased ? 0 : parser.Config.HeaderRow; + await foreach (Chunk chunk in SheetPipelineRunner.RunAsync(source, Unbound, Binder(parser, info, isDate1904), headerRow, dop, chunkBytes, ct).ConfigureAwait(false)) + { + foreach (T item in chunk.Items) + { + yield return item; + } + } + } + + private static RowActionFactory Binder(ExcelParser parser, TypeMapInfo info, bool isDate1904) + { + return (Row header, bool _) => + { + ExcelParserConfig config = parser.Config; + ColumnBinding[] bindings = info.IsIndexBased + ? info.IndexBindings + : SparseRowProjection.BuildColumnMap(in header, info, config.ColumnNameComparer, config.HeaderNormalization, out int _); + bool track = bindings.Any(static binding => binding.RequireValue); + IFormatProvider provider = config.Culture; + bool throwOnParseFailure = config.ThrowOnParseFailure; + return (ref Chunk chunk, Row row) => + { + int rowInChunk = ++chunk.Rows; + T model = info.CreateInstance(); + Span seen = track ? (chunk.Seen ??= new bool[bindings.Length]) : default; + SparseRowProjection.ParseRow(in row, bindings, seen, track, isDate1904, provider, throwOnParseFailure, rowInChunk, ref model); + chunk.Items.Add(model); + }; + }; + } + } +} diff --git a/src/ExcelReader.Core/Parser/SheetPipeline/SheetRunner.cs b/src/ExcelReader.Core/Parser/SheetPipeline/SheetRunner.cs index 2e555b1c..13a8a390 100644 --- a/src/ExcelReader.Core/Parser/SheetPipeline/SheetRunner.cs +++ b/src/ExcelReader.Core/Parser/SheetPipeline/SheetRunner.cs @@ -19,6 +19,12 @@ internal static Task RunAsync( { return ParallelCsvProcessor.RunAsync(csv.Reader, aggregation, factory, options, ct); } + int dop = ParallelCsvFactory.Normalize(options.DegreeOfParallelism); + SheetPipelineSource? pipeline = SheetPipelineSource.TryCreate(sheet, dop); + if (pipeline is not null) + { + return SheetPipelineRunner.AggregateAsync(pipeline, aggregation, factory, options.HeaderRow, dop, SheetPipelineSource.DefaultChunkBytes, ct); + } return SequentialAsync(sheet, aggregation, factory, options.HeaderRow, ct); } @@ -28,6 +34,12 @@ internal static IAsyncEnumerable Parse(IExcelSheet sheet, ExcelParser p { return ParallelCsvFactory.Create(csv.Reader, degreeOfParallelism, parser, ct); } + int dop = ParallelCsvFactory.Normalize(degreeOfParallelism); + SheetPipelineSource? pipeline = SheetPipelineSource.TryCreate(sheet, dop); + if (pipeline is not null) + { + return SheetRowProjection.ParseAsync(pipeline, parser, sheet.IsDate1904, dop, SheetPipelineSource.DefaultChunkBytes, ct); + } return Sequential(sheet, parser, ct); } diff --git a/tests/ExcelReader.Tests/Parser/SheetParallelXlsxTests.cs b/tests/ExcelReader.Tests/Parser/SheetParallelXlsxTests.cs new file mode 100644 index 00000000..6f1f7fc7 --- /dev/null +++ b/tests/ExcelReader.Tests/Parser/SheetParallelXlsxTests.cs @@ -0,0 +1,298 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Text; +using ExcelReader.Core.Parser; +using ExcelReader.Core.Parser.SheetPipeline; +using ExcelReader.Core.Reader; +using ExcelReader.Core.Reader.Xlsx; + +namespace ExcelReader.Tests.Parser +{ + public class SheetParallelXlsxTests + { + private sealed class Sale + { + [ExcelColumn("Name")] + public string? Name { get; set; } + + [ExcelColumn("Qty")] + public int Qty { get; set; } + + [ExcelColumn("Day")] + public DateTime Day { get; set; } + } + + private readonly struct NameOnly : IRowRecord + { + public string Name { get; init; } + + public static bool TryParse(Row row, out NameOnly record) + { + record = new NameOnly { Name = row[0].GetString() }; + return true; + } + } + + private sealed class Names : IRowAccumulator, IRowAccumulator + { + public List Items { get; } = []; + + public void Add(NameOnly model) + { + Items.Add(model.Name); + } + + public void Add(Sale model) + { + Items.Add(model.Name); + } + + public void Merge(Names following) + { + Items.AddRange(following.Items); + } + } + + private static async Task BuildAsync(int rows) + { + object?[][] data = new object?[rows + 1][]; + data[0] = ["Name", "Qty", "Day"]; + for (int i = 0; i < rows; i++) + { + data[i + 1] = [string.Create(CultureInfo.InvariantCulture, $"item{i:D6}"), i, new DateTime(2024, 1, 1).AddDays(i % 400)]; + } + return await TypedWorkbook.BuildAsync(data); + } + + private static IEnumerable<(string?, int, DateTime)> Keys(IEnumerable sales) + { + return sales.Select(s => (s.Name, s.Qty, s.Day)); + } + + [Theory] + [InlineData(2)] + [InlineData(4)] + [InlineData(8)] + [InlineData(0)] + public async Task EveryFamilyMatchesSequential(int dop) + { + CancellationToken ct = TestContext.Current.CancellationToken; + using MemoryStream ms = await BuildAsync(30_000); + using XlsxWorkbook workbook = Excel.FromXlsx(ms); + Assert.NotNull(SheetPipelineSource.TryCreate(workbook.FirstSheet, 2)); + ExcelParser parser = ExcelParser.FromAttributes(); + List expected = [.. parser.Parse(workbook.FirstSheet)]; + var withHeader = new SheetParallelOptions { DegreeOfParallelism = dop, HeaderRow = 1 }; + + var parsed = new List(); + await foreach (Sale sale in SheetParallel.ParseAsync(workbook.FirstSheet, parser, new SheetParallelOptions { DegreeOfParallelism = dop }, ct)) + { + parsed.Add(sale); + } + Assert.Equal(Keys(expected), Keys(parsed)); + + Names byRecord = await SheetParallel.AggregateAsync(workbook.FirstSheet, withHeader, ct); + Assert.Equal(expected.Select(s => s.Name), byRecord.Items, StringComparer.Ordinal); + + Names byModel = await SheetParallel.AggregateAsync(workbook.FirstSheet, parser, withHeader, ct); + Assert.Equal(expected.Select(s => s.Name), byModel.Items, StringComparer.Ordinal); + + var each = new ConcurrentBag<(string?, int, DateTime)>(); + await SheetParallel.ForEachAsync(workbook.FirstSheet, parser, (Sale s) => each.Add((s.Name, s.Qty, s.Day)), withHeader, ct); + Assert.Equal(Keys(expected).Order(), each.Order()); + + var eachRecord = new ConcurrentBag(); + await SheetParallel.ForEachAsync(workbook.FirstSheet, r => eachRecord.Add(r.Name), withHeader, ct); + Assert.Equal(expected.Select(s => s.Name).Order(StringComparer.Ordinal), eachRecord.Order(StringComparer.Ordinal)); + } + + [Fact] + public async Task ParseAtSmallChunksMatchesSequentialIncludingEmptyRows() + { + var sb = new StringBuilder("NameQty"); + for (int r = 2; r <= 400; r++) + { + sb.Append(r % 7 == 0 + ? string.Create(CultureInfo.InvariantCulture, $"") + : string.Create(CultureInfo.InvariantCulture, $"n{r}{r}")); + } + using MemoryStream ms = WorkbookBuilder.Build(sb.ToString()); + using XlsxWorkbook workbook = Excel.FromXlsx(ms); + ExcelParser parser = ExcelParser.FromAttributes(); + List expected = [.. parser.Parse(workbook.FirstSheet)]; + SheetPipelineSource source = SheetPipelineSource.Create(workbook.FirstSheet)!; + + var actual = new List(); + await foreach (Sale sale in SheetRowProjection.ParseAsync(source, parser, isDate1904: false, 4, 96, TestContext.Current.CancellationToken)) + { + actual.Add(sale); + } + + Assert.Equal(399, expected.Count); + Assert.Equal(Keys(expected), Keys(actual)); + } + + [Fact] + public async Task AParseFailureReportsTheSheetRow() + { + var sb = new StringBuilder("NameQty"); + for (int r = 2; r <= 300; r++) + { + string qty = r == 211 ? "oops" : string.Create(CultureInfo.InvariantCulture, $"{r}"); + sb.Append(CultureInfo.InvariantCulture, $"n{r}{qty}"); + } + using MemoryStream ms = WorkbookBuilder.Build(sb.ToString()); + using XlsxWorkbook workbook = Excel.FromXlsx(ms); + ExcelParser parser = ExcelParser.FromAttributes(new ExcelParserConfig { ThrowOnParseFailure = true }); + ExcelParseException sequential = Assert.Throws(() => parser.Parse(workbook.FirstSheet).ToList()); + SheetPipelineSource source = SheetPipelineSource.Create(workbook.FirstSheet)!; + + ExcelParseException parallel = await Assert.ThrowsAsync(async () => + { + await foreach (Sale _ in SheetRowProjection.ParseAsync(source, parser, isDate1904: false, 4, 128, TestContext.Current.CancellationToken)) + { + } + }); + + Assert.Equal(211, sequential.Row); + Assert.Equal(sequential.Row, parallel.Row); + Assert.Equal(sequential.ColumnName, parallel.ColumnName); + } + + [Fact] + public async Task ACallbackExceptionReachesTheCaller() + { + CancellationToken ct = TestContext.Current.CancellationToken; + using MemoryStream ms = await BuildAsync(30_000); + using XlsxWorkbook workbook = Excel.FromXlsx(ms); + ExcelParser parser = ExcelParser.FromAttributes(); + + InvalidOperationException ex = await Assert.ThrowsAsync(() => SheetParallel.ForEachAsync( + workbook.FirstSheet, parser, + (Sale s) => + { + if (s.Qty == 12_345) + { + throw new InvalidOperationException("boom"); + } + }, + new SheetParallelOptions { DegreeOfParallelism = 4, HeaderRow = 1 }, ct)); + + Assert.Equal("boom", ex.Message); + } + + [Fact] + public async Task LeavingTheEnumerationEarlyStopsThePipeline() + { + CancellationToken ct = TestContext.Current.CancellationToken; + var tracked = new TrackingStream(); + using (MemoryStream built = await BuildAsync(30_000)) + { + await built.CopyToAsync(tracked, ct); + } + tracked.Position = 0; + XlsxWorkbook workbook = Excel.FromXlsx(tracked, leaveOpen: false); + ExcelParser parser = ExcelParser.FromAttributes(); + + await foreach (Sale _ in SheetParallel.ParseAsync(workbook.FirstSheet, parser, new SheetParallelOptions { DegreeOfParallelism = 4 }, ct)) + { + break; + } + workbook.Dispose(); + + Assert.True(tracked.Disposed); + } + + [Fact] + public async Task DisposingTheWorkbookMidReadIsSafe() + { + CancellationToken ct = TestContext.Current.CancellationToken; + using MemoryStream ms = await BuildAsync(30_000); + XlsxWorkbook workbook = Excel.FromXlsx(ms); + ExcelParser parser = ExcelParser.FromAttributes(); + IExcelSheet sheet = workbook.FirstSheet; + List expected = [.. parser.Parse(workbook.FirstSheet)]; + + var actual = new List(); + await foreach (Sale sale in SheetParallel.ParseAsync(sheet, parser, new SheetParallelOptions { DegreeOfParallelism = 4 }, ct)) + { + actual.Add(sale); + if (actual.Count == 1) + { + workbook.Dispose(); + } + } + Assert.Equal(Keys(expected), Keys(actual)); + + await Assert.ThrowsAsync(async () => + { + await foreach (Sale _ in SheetParallel.ParseAsync(sheet, parser, new SheetParallelOptions { DegreeOfParallelism = 4 }, ct)) + { + } + }); + } + + private static readonly RowAggregation Counting = new() + { + Seed = static () => 0, + Accumulate = static (ref int count, Row _) => count++, + Combine = static (left, right) => left + right, + }; + + private static string Outcome(Func read) + { + try + { + return string.Create(CultureInfo.InvariantCulture, $"rows={read()}"); + } + catch (Exception ex) when (ex is InvalidDataException or ExcelLimitExceededException) + { + return ex.GetType().Name; + } + } + + private static int CountSequentially(XlsxWorkbook workbook) + { + int count = 0; + foreach (Row _ in workbook.FirstSheet) + { + count++; + } + return count; + } + + [Fact] + public async Task ACorruptSheetEntryEndsLikeTheSequentialRead() + { + CancellationToken ct = TestContext.Current.CancellationToken; + using MemoryStream ms = await BuildAsync(30_000); + byte[] bytes = ms.ToArray(); + int entry = bytes.AsSpan().IndexOf("xl/worksheets/sheet1.xml"u8); + Array.Fill(bytes, (byte)0xFF, entry + 300_000, 512); + using XlsxWorkbook sequentialWorkbook = Excel.FromXlsx(bytes); + using XlsxWorkbook parallelWorkbook = Excel.FromXlsx(bytes); + var options = new SheetParallelOptions { DegreeOfParallelism = 4 }; + + string sequential = Outcome(() => CountSequentially(sequentialWorkbook)); + string parallel = Outcome(() => SheetParallel.AggregateAsync(parallelWorkbook.FirstSheet, Counting, options, ct).GetAwaiter().GetResult()); + + Assert.Equal(sequential, parallel); + Assert.Equal(nameof(InvalidDataException), sequential); + } + + [Fact] + public async Task ExceedingTheDecompressedLimitMidSheetThrows() + { + CancellationToken ct = TestContext.Current.CancellationToken; + using MemoryStream ms = await BuildAsync(30_000); + byte[] bytes = ms.ToArray(); + var limits = new ExcelReaderOptions { MaxTotalDecompressedBytes = 600 * 1024 }; + using XlsxWorkbook sequentialWorkbook = Excel.FromXlsx(bytes, limits); + using XlsxWorkbook parallelWorkbook = Excel.FromXlsx(bytes, limits); + + Assert.Throws(() => CountSequentially(sequentialWorkbook)); + await Assert.ThrowsAsync( + () => SheetParallel.AggregateAsync(parallelWorkbook.FirstSheet, Counting, new SheetParallelOptions { DegreeOfParallelism = 4 }, ct)); + } + } +} From 6749b9eb3027adbf8d5c64c7bcd11d352431cba2 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Sat, 10 Oct 2026 23:08:30 -0300 Subject: [PATCH 072/115] fix: report the sheet row for a parse failure after pipeline recovery Co-Authored-By: Claude Opus 5.5 (1M context) --- .../SheetPipeline/SheetPipelineRunner.cs | 11 ++++++- .../Parser/SheetParallelXlsxTests.cs | 30 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/ExcelReader.Core/Parser/SheetPipeline/SheetPipelineRunner.cs b/src/ExcelReader.Core/Parser/SheetPipeline/SheetPipelineRunner.cs index 1af580fa..88413e03 100644 --- a/src/ExcelReader.Core/Parser/SheetPipeline/SheetPipelineRunner.cs +++ b/src/ExcelReader.Core/Parser/SheetPipeline/SheetPipelineRunner.cs @@ -92,7 +92,16 @@ internal static async IAsyncEnumerable RunAsync( if (recover) { - yield return source.ParseTail(headerRow, rows, aggregation, cancellationToken); + TState tail = default!; + try + { + tail = source.ParseTail(headerRow, rows, aggregation, cancellationToken); + } + catch (ExcelParseException ex) + { + ThrowRenumbered(ExceptionDispatchInfo.Capture(ex), headerRow + rows); + } + yield return tail; } } finally diff --git a/tests/ExcelReader.Tests/Parser/SheetParallelXlsxTests.cs b/tests/ExcelReader.Tests/Parser/SheetParallelXlsxTests.cs index 6f1f7fc7..e3f8a859 100644 --- a/tests/ExcelReader.Tests/Parser/SheetParallelXlsxTests.cs +++ b/tests/ExcelReader.Tests/Parser/SheetParallelXlsxTests.cs @@ -159,6 +159,36 @@ public async Task AParseFailureReportsTheSheetRow() Assert.Equal(sequential.ColumnName, parallel.ColumnName); } + [Fact] + public async Task AParseFailureAfterPipelineRecoveryReportsTheSheetRow() + { + var sb = new StringBuilder("NameQty"); + for (int r = 2; r <= 300; r++) + { + string name = r == 20 ? " ]]>" : string.Create(CultureInfo.InvariantCulture, $"n{r}"); + string qty = r == 211 ? "oops" : string.Create(CultureInfo.InvariantCulture, $"{r}"); + sb.Append(CultureInfo.InvariantCulture, $"{name}{qty}"); + } + using MemoryStream ms = WorkbookBuilder.Build(sb.ToString()); + using XlsxWorkbook workbook = Excel.FromXlsx(ms); + ExcelParser parser = ExcelParser.FromAttributes(new ExcelParserConfig { ThrowOnParseFailure = true }); + ExcelParseException sequential = Assert.Throws(() => parser.Parse(workbook.FirstSheet).ToList()); + SheetPipelineSource source = SheetPipelineSource.Create(workbook.FirstSheet)!; + Assert.Equal(211, sequential.Row); + + foreach (int chunkBytes in new[] { 64, 90, 96, 150, 400 }) + { + ExcelParseException parallel = await Assert.ThrowsAsync(async () => + { + await foreach (Sale _ in SheetRowProjection.ParseAsync(source, parser, isDate1904: false, 4, chunkBytes, TestContext.Current.CancellationToken)) + { + } + }); + Assert.Equal(sequential.Row, parallel.Row); + Assert.Equal(sequential.ColumnName, parallel.ColumnName); + } + } + [Fact] public async Task ACallbackExceptionReachesTheCaller() { From 04e084c81356138bb9a3f764211e4173bd867c85 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Sat, 10 Oct 2026 23:16:24 -0300 Subject: [PATCH 073/115] feat: read XLSB sheets in parallel through SheetParallel Co-Authored-By: Claude Sonnet 5.5 --- .../SheetPipeline/SheetPipelineSource.cs | 2 + .../SheetPipeline/XlsbPipelineSource.cs | 90 +++++++++++ .../Parser/SheetPipeline/XlsbPipelineTests.cs | 140 ++++++++++++++++++ 3 files changed, 232 insertions(+) create mode 100644 src/ExcelReader.Core/Parser/SheetPipeline/XlsbPipelineSource.cs create mode 100644 tests/ExcelReader.Tests/Parser/SheetPipeline/XlsbPipelineTests.cs diff --git a/src/ExcelReader.Core/Parser/SheetPipeline/SheetPipelineSource.cs b/src/ExcelReader.Core/Parser/SheetPipeline/SheetPipelineSource.cs index f1f1731d..74f0b081 100644 --- a/src/ExcelReader.Core/Parser/SheetPipeline/SheetPipelineSource.cs +++ b/src/ExcelReader.Core/Parser/SheetPipeline/SheetPipelineSource.cs @@ -2,6 +2,7 @@ using System.Runtime.ExceptionServices; using ExcelReader.Core.Reader; using ExcelReader.Core.Reader.Internal; +using ExcelReader.Core.Reader.Xlsb; using ExcelReader.Core.Reader.Xlsx; namespace ExcelReader.Core.Parser.SheetPipeline @@ -49,6 +50,7 @@ internal abstract ChunkOutcome ParseChunk( return sheet switch { XlsxSheet xlsx => new XlsxPipelineSource(xlsx.Owner, xlsx.Index), + XlsbSheet xlsb => new XlsbPipelineSource(xlsb.Owner, xlsb.Index), _ => null, }; } diff --git a/src/ExcelReader.Core/Parser/SheetPipeline/XlsbPipelineSource.cs b/src/ExcelReader.Core/Parser/SheetPipeline/XlsbPipelineSource.cs new file mode 100644 index 00000000..217efc3b --- /dev/null +++ b/src/ExcelReader.Core/Parser/SheetPipeline/XlsbPipelineSource.cs @@ -0,0 +1,90 @@ +using System.Runtime.ExceptionServices; +using ExcelReader.Core.Reader; +using ExcelReader.Core.Reader.Internal; +using ExcelReader.Core.Reader.Xlsb; + +namespace ExcelReader.Core.Parser.SheetPipeline +{ + internal sealed class XlsbPipelineSource(XlsbWorkbook workbook, int index) : SheetPipelineSource + { + private bool _rowOpen; + + internal override object Owner => workbook; + + internal override ReaderLifetime Lifetime => workbook.Lifetime; + + internal override int MaxCellBytes => workbook.MaxCellBytes; + + internal override long UncompressedSize => workbook.SheetUncompressedSize(index); + + internal override PipelineStart? Begin(int headerRow, HeaderVisitor? onHeader) + { + long offset; + using (XlsbWorkbook.Enumerator rows = workbook.OpenSheetWithoutPrefetch(index)) + { + for (int row = 1; row <= headerRow; row++) + { + if (!rows.MoveNext()) + { + return null; + } + if (row == headerRow) + { + onHeader?.Invoke(rows.Current); + } + } + if (rows.Ended) + { + return null; + } + _rowOpen = rows.RowOpen; + offset = rows.Offset; + } + + Stream stream = workbook.OpenSheetStream(index); + try + { + Skip(stream, offset); + } + catch + { + stream.Dispose(); + throw; + } + return new PipelineStart(stream, new XlsbRowCutter()); + } + + internal override ChunkOutcome ParseChunk( + ReadOnlyMemory chunk, bool first, RowAggregation aggregation, CancellationToken ct) + { + var outcome = new ChunkOutcome(aggregation.Seed()); + var rows = new XlsbWorkbook.Enumerator(workbook, chunk, first && _rowOpen, ct); + try + { + RowAction accumulate = aggregation.Accumulate; + while (rows.MoveNext()) + { + if (++outcome.Rows % RowsPerCancellationCheck == 0) + { + ct.ThrowIfCancellationRequested(); + } + accumulate(ref outcome.State, rows.Current); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + outcome.Failure = ExceptionDispatchInfo.Capture(ex); + } + finally + { + rows.Dispose(); + } + return outcome; + } + + internal override TState ParseTail(int headerRow, long skipRows, RowAggregation aggregation, CancellationToken ct) + { + throw new System.Diagnostics.UnreachableException("An XLSB chunk always ends on a record boundary."); + } + } +} diff --git a/tests/ExcelReader.Tests/Parser/SheetPipeline/XlsbPipelineTests.cs b/tests/ExcelReader.Tests/Parser/SheetPipeline/XlsbPipelineTests.cs new file mode 100644 index 00000000..69385e61 --- /dev/null +++ b/tests/ExcelReader.Tests/Parser/SheetPipeline/XlsbPipelineTests.cs @@ -0,0 +1,140 @@ +using System.Globalization; +using System.Text; +using ExcelReader.Core.Parser; +using ExcelReader.Core.Parser.SheetPipeline; +using ExcelReader.Core.Reader; +using ExcelReader.Core.Reader.Xlsb; +using ExcelReader.Core.Writer.Xlsb; + +namespace ExcelReader.Tests.Parser.SheetPipeline +{ + public class XlsbPipelineTests + { + private sealed class Sale + { + [ExcelColumn("Name")] + public string? Name { get; set; } + + [ExcelColumn("Qty")] + public int Qty { get; set; } + + [ExcelColumn("Day")] + public DateTime Day { get; set; } + } + + private static readonly RowAggregation> Collecting = new() + { + Seed = static () => [], + Accumulate = static (ref List rows, Row row) => rows.Add(Render(row)), + Combine = static (left, right) => + { + left.AddRange(right); + return left; + }, + }; + + private static string Render(Row row) + { + var sb = new StringBuilder(); + foreach (RowCell cell in row.Cells) + { + sb.Append(cell.ColumnIndex).Append(':').Append(cell.Value.GetString()).Append('|'); + } + return sb.ToString(); + } + + private static async Task BuildAsync(int rows) + { + CancellationToken ct = TestContext.Current.CancellationToken; + var ms = new MemoryStream(); + await using (XlsbWorkbookWriter wb = XlsbWorkbookWriter.Create(ms, leaveOpen: true)) + { + XlsbSheetWriter sheet = wb.AddSheet("S1"); + await using (XlsbRowWriter header = await sheet.StartRowAsync(ct)) + { + header.Write("Name"); + header.Write("Qty"); + header.Write("Day"); + } + for (int i = 0; i < rows; i++) + { + await using XlsbRowWriter row = await sheet.StartRowAsync(ct); + row.Write(i % 3 == 0 ? new string('x', 1 + (i % 40)) : string.Create(CultureInfo.InvariantCulture, $"item{i:D6}")); + row.Write(i); + row.Write(new DateTime(2024, 1, 1).AddDays(i % 400)); + } + await sheet.EndAsync(ct); + await wb.EndAsync(ct); + } + ms.Position = 0; + return ms; + } + + private static List Sequential(XlsbWorkbook workbook, int headerRow) + { + var rows = new List(); + foreach (Row row in workbook.FirstSheet) + { + rows.Add(Render(row)); + } + return rows.Count > headerRow ? rows[headerRow..] : []; + } + + [Theory] + [InlineData(2, 96)] + [InlineData(4, 96)] + [InlineData(4, 700)] + [InlineData(8, 4096)] + [InlineData(4, 1 << 20)] + public async Task MatchesSequential(int dop, int chunkBytes) + { + CancellationToken ct = TestContext.Current.CancellationToken; + using MemoryStream ms = await BuildAsync(2_000); + using XlsbWorkbook workbook = Excel.FromXlsb(ms); + SheetPipelineSource source = SheetPipelineSource.Create(workbook.FirstSheet)!; + + foreach (int headerRow in new[] { 0, 1, 4 }) + { + Assert.Equal(Sequential(workbook, headerRow), await SheetPipelineRunner.AggregateAsync(source, Collecting, null, headerRow, dop, chunkBytes, ct)); + } + } + + [Fact] + public async Task ShortSheetsYieldNothing() + { + CancellationToken ct = TestContext.Current.CancellationToken; + using MemoryStream ms = await BuildAsync(0); + using XlsbWorkbook workbook = Excel.FromXlsb(ms); + SheetPipelineSource source = SheetPipelineSource.Create(workbook.FirstSheet)!; + + Assert.Empty(await SheetPipelineRunner.AggregateAsync(source, Collecting, null, 1, 4, 96, ct)); + Assert.Empty(await SheetPipelineRunner.AggregateAsync(source, Collecting, null, 3, 4, 96, ct)); + } + + [Theory] + [InlineData(2)] + [InlineData(8)] + public async Task ParseAndForEachMatchSequentialThroughThePublicApi(int dop) + { + CancellationToken ct = TestContext.Current.CancellationToken; + using MemoryStream ms = await BuildAsync(40_000); + using XlsbWorkbook workbook = Excel.FromXlsb(ms); + Assert.NotNull(SheetPipelineSource.TryCreate(workbook.FirstSheet, 2)); + ExcelParser parser = ExcelParser.FromAttributes(); + IEnumerable sequential = parser.Parse(workbook.FirstSheet); + List<(string?, int, DateTime)> expected = [.. sequential.Select(s => (s.Name, s.Qty, s.Day))]; + + var parsed = new List<(string?, int, DateTime)>(); + await foreach (Sale sale in SheetParallel.ParseAsync(workbook.FirstSheet, parser, new SheetParallelOptions { DegreeOfParallelism = dop }, ct)) + { + parsed.Add((sale.Name, sale.Qty, sale.Day)); + } + Assert.Equal(expected, parsed); + + var each = new System.Collections.Concurrent.ConcurrentBag<(string?, int, DateTime)>(); + await SheetParallel.ForEachAsync(workbook.FirstSheet, parser, (Sale s) => each.Add((s.Name, s.Qty, s.Day)), new SheetParallelOptions { DegreeOfParallelism = dop, HeaderRow = 1 }, ct); + Assert.Equal(expected.Count, each.Count); + Assert.Equal(expected.Order(), each.Order()); + } + } +} From 01369bda2a807f0cb86773fb886e226022fd6218 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Sat, 10 Oct 2026 23:23:50 -0300 Subject: [PATCH 074/115] feat(native): parse XLSX and XLSB typed tables in parallel Co-Authored-By: Claude Sonnet 5.5 --- .../Typed/TypedApi.Parallel.cs | 71 +++++++++ tests/ExcelReader.NativePgoTrainer/Program.cs | 5 + .../NativeApiTests.ParallelTypedSheets.cs | 140 ++++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 tests/ExcelReader.Tests/Native/NativeApiTests.ParallelTypedSheets.cs diff --git a/src/ExcelReader.Native/Typed/TypedApi.Parallel.cs b/src/ExcelReader.Native/Typed/TypedApi.Parallel.cs index 64ac899b..6bc96fce 100644 --- a/src/ExcelReader.Native/Typed/TypedApi.Parallel.cs +++ b/src/ExcelReader.Native/Typed/TypedApi.Parallel.cs @@ -1,4 +1,6 @@ +using System.Collections.Concurrent; using ExcelReader.Core.Parser.ParallelCsv; +using ExcelReader.Core.Parser.SheetPipeline; using ExcelReader.Core.Reader; using ExcelReader.Core.Reader.Csv; @@ -30,9 +32,78 @@ internal static int ParseTypedTable(NativeHandle? handle, int sheet, NativeColum LastParseRanInParallel = true; return ParseCsvInParallel(handle, source, csv.Options, specs, headerRow, degreeOfParallelism, chunkSizeOverride, out table); } + if (dop > 1 && sheet >= 0 && sheet < handle.Workbook.SheetCount + && SheetPipelineSource.TryCreate(handle.Workbook.SheetAt(sheet), dop) is { } pipeline) + { + LastParseRanInParallel = true; + return ParseSheetInParallel(handle, sheet, pipeline, specs, headerRow, dop, chunkSizeOverride, out table); + } return ParseSequential(handle, sheet, specs, headerRow, out table); } + private static int ParseSheetInParallel(NativeHandle handle, int sheet, SheetPipelineSource pipeline, NativeColumnSpec[] specs, + int headerRow, int dop, int chunkSizeOverride, out NativeTable table) + { + table = default; + if (!TryValidateArguments(specs, headerRow, out string? argumentError)) + { + NativeApi.SetLastError(argumentError); + return NativeStatus.InvalidArgument; + } + NativeApi.ClearLastError(); + var seeded = new ConcurrentBag(); + try + { + int[] columnIndices = new int[specs.Length]; + using (IExcelRowEnumerator rows = handle.Workbook.SheetAt(sheet).GetEnumerator()) + { + if (!TryResolveColumns(rows, specs, headerRow, columnIndices, out string? resolveError)) + { + NativeApi.SetLastError(resolveError); + return NativeStatus.InvalidArgument; + } + } + + bool isDate1904 = handle.Workbook.IsDate1904; + RowAggregation aggregation = new() + { + Seed = () => + { + var partition = new PartitionTable(specs); + seeded.Add(partition); + return partition; + }, + Accumulate = (ref PartitionTable state, Row row) => state.Append(row, columnIndices, isDate1904), + Combine = PartitionTable.Combine, + }; + int chunkBytes = chunkSizeOverride > 0 ? chunkSizeOverride : SheetPipelineSource.DefaultChunkBytes; + PartitionTable merged = Task.Run(() => SheetPipelineRunner.AggregateAsync( + pipeline, aggregation, null, headerRow, dop, chunkBytes, CancellationToken.None)).GetAwaiter().GetResult(); + if (merged.FailedColumn >= 0) + { + NativeApi.SetLastError(DescribeFailedColumn(specs, merged.FailedColumn)); + return NativeStatus.Error; + } + table = BuildTable([.. merged.Parts], dop); + return NativeStatus.Ok; + } + catch (Exception exception) + { + NativeApi.SetLastError(exception.Message); + table = default; + return NativeStatus.Error; + } + finally + { + // The runner drops states on failure and recovery, and Combine folds builders into another state: + // every seeded state is disposed, and disposing a builder twice is a no-op. + foreach (PartitionTable partition in seeded) + { + partition.Dispose(); + } + } + } + private static int ParseCsvInParallel(NativeHandle handle, CsvChunkSource source, CsvReaderOptions reader, NativeColumnSpec[] specs, int headerRow, int degreeOfParallelism, int chunkSizeOverride, out NativeTable table) { diff --git a/tests/ExcelReader.NativePgoTrainer/Program.cs b/tests/ExcelReader.NativePgoTrainer/Program.cs index 3a24806c..5f7a48f1 100644 --- a/tests/ExcelReader.NativePgoTrainer/Program.cs +++ b/tests/ExcelReader.NativePgoTrainer/Program.cs @@ -51,6 +51,11 @@ private static int Main() { status = TypedApi.ParseTyped(handle, 0, Specs, headerRow: 1, out NativeTable table); TypedApi.FreeTable(ref table); + if (status == NativeStatus.Ok && format != NativeFormat.Csv) + { + status = TypedApi.ParseTypedTable(handle, 0, Specs, headerRow: 1, degreeOfParallelism: 0, out table); + TypedApi.FreeTable(ref table); + } ReadApi.Close(handle); } if (status == NativeStatus.Ok && (status = ReadApi.OpenMemory(bytes, format, out handle)) == NativeStatus.Ok) diff --git a/tests/ExcelReader.Tests/Native/NativeApiTests.ParallelTypedSheets.cs b/tests/ExcelReader.Tests/Native/NativeApiTests.ParallelTypedSheets.cs new file mode 100644 index 00000000..cc04326a --- /dev/null +++ b/tests/ExcelReader.Tests/Native/NativeApiTests.ParallelTypedSheets.cs @@ -0,0 +1,140 @@ +using System.Globalization; +using ExcelReader.Core.Writer.Xlsb; +using ExcelReader.Native; +using ExcelReader.Native.Reading; +using ExcelReader.Native.Typed; + +namespace ExcelReader.Tests.Native +{ + public sealed partial class NativeApiTests + { + private static object?[][] MixedSheetRows(int rows) + { + object?[][] data = new object?[rows + 1][]; + data[0] = ["name", "qty", "price", "flag", "day", "note"]; + for (int i = 0; i < rows; i++) + { + data[i + 1] = + [ + string.Create(CultureInfo.InvariantCulture, $"item{i}"), + i % 7 == 0 ? null : i * 3, + i * 0.25, + i % 2 == 0, + new DateTime(2024, 1, 1).AddDays(i % 28), + string.Create(CultureInfo.InvariantCulture, $"n{i}"), + ]; + } + return data; + } + + private static async Task MixedXlsbAsync(int rows) + { + CancellationToken ct = TestContext.Current.CancellationToken; + var ms = new MemoryStream(); + await using (XlsbWorkbookWriter wb = XlsbWorkbookWriter.Create(ms, leaveOpen: true)) + { + XlsbSheetWriter sheet = wb.AddSheet("S1"); + foreach (object?[] cells in MixedSheetRows(rows)) + { + await using XlsbRowWriter row = await sheet.StartRowAsync(ct); + foreach (object? cell in cells) + { + switch (cell) + { + case null: row.Write((string?)null); break; + case string s: row.Write(s); break; + case bool b: row.Write(b); break; + case int i: row.Write(i); break; + case double d: row.Write(d); break; + case DateTime dt: row.Write(dt); break; + } + } + } + await sheet.EndAsync(ct); + await wb.EndAsync(ct); + } + return ms.ToArray(); + } + + private static (int Status, NativeTable Table, bool Parallel) ParseSheetWith(byte[] workbook, int format, int dop, int chunkBytes) + { + Assert.Equal(NativeStatus.Ok, ReadApi.OpenMemory(workbook, format, out NativeHandle? handle)); + try + { + int status = TypedApi.ParseTypedTable(handle, 0, MixedSpecs, 1, dop, out NativeTable table, chunkBytes); + return (status, table, TypedApi.LastParseRanInParallel); + } + finally + { + ReadApi.Close(handle); + } + } + + [Theory] + [InlineData(2, 0)] + [InlineData(8, 0)] + [InlineData(4, 4096)] + public async Task ParallelXlsxTypedParseMatchesSequential(int dop, int chunkBytes) + { + using MemoryStream built = await TypedWorkbook.BuildAsync(MixedSheetRows(20_000)); + byte[] workbook = built.ToArray(); + (int expectedStatus, NativeTable expected, bool expectedParallel) = ParseSheetWith(workbook, NativeFormat.Xlsx, 1, 0); + (int status, NativeTable actual, bool parallel) = ParseSheetWith(workbook, NativeFormat.Xlsx, dop, chunkBytes); + try + { + Assert.Equal(NativeStatus.Ok, expectedStatus); + Assert.Equal(NativeStatus.Ok, status); + Assert.False(expectedParallel); + Assert.True(parallel); + AssertSameTable(expected, actual); + } + finally + { + TypedApi.FreeTable(ref expected); + TypedApi.FreeTable(ref actual); + } + } + + [Theory] + [InlineData(2, 0)] + [InlineData(8, 0)] + [InlineData(4, 4096)] + public async Task ParallelXlsbTypedParseMatchesSequential(int dop, int chunkBytes) + { + byte[] workbook = await MixedXlsbAsync(40_000); + (int expectedStatus, NativeTable expected, _) = ParseSheetWith(workbook, NativeFormat.Xlsb, 1, 0); + (int status, NativeTable actual, bool parallel) = ParseSheetWith(workbook, NativeFormat.Xlsb, dop, chunkBytes); + try + { + Assert.Equal(NativeStatus.Ok, expectedStatus); + Assert.Equal(NativeStatus.Ok, status); + Assert.True(parallel); + AssertSameTable(expected, actual); + } + finally + { + TypedApi.FreeTable(ref expected); + TypedApi.FreeTable(ref actual); + } + } + + [Fact] + public async Task AnInvalidSheetIndexStillFailsTheSameWay() + { + using MemoryStream built = await TypedWorkbook.BuildAsync(MixedSheetRows(20_000)); + Assert.Equal(NativeStatus.Ok, ReadApi.OpenMemory(built.ToArray(), NativeFormat.Xlsx, out NativeHandle? handle)); + try + { + int sequential = TypedApi.ParseTypedTable(handle, 9, MixedSpecs, 1, 1, out _); + int parallel = TypedApi.ParseTypedTable(handle, 9, MixedSpecs, 1, 8, out _); + + Assert.NotEqual(NativeStatus.Ok, sequential); + Assert.Equal(sequential, parallel); + } + finally + { + ReadApi.Close(handle); + } + } + } +} From 06963375524687c23cce35cf8ceccc2f7ff2cdb1 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Sat, 10 Oct 2026 23:28:24 -0300 Subject: [PATCH 075/115] test(fuzz): compare the sheet pipeline with the sequential read Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/fuzz.yml | 2 +- tests/ExcelReader.Fuzz/Harnesses.cs | 84 ++++++++++++++++++++++++++++ tests/ExcelReader.Fuzz/Program.cs | 2 + tests/ExcelReader.Fuzz/SeedCorpus.cs | 2 + 4 files changed, 89 insertions(+), 1 deletion(-) diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 50a4998b..d633c942 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -53,7 +53,7 @@ jobs: strategy: fail-fast: false matrix: - target: [xlsx, xlsx-memory, xlsx-differential, xlsb, xlsb-memory, xlsb-differential, xls, encrypted, csv, csv-sniff, csv-parallel, inflate] + target: [xlsx, xlsx-memory, xlsx-differential, xlsx-pipeline, xlsb, xlsb-memory, xlsb-differential, xlsb-pipeline, xls, encrypted, csv, csv-sniff, csv-parallel, inflate] steps: - name: Checkout uses: actions/checkout@v7 diff --git a/tests/ExcelReader.Fuzz/Harnesses.cs b/tests/ExcelReader.Fuzz/Harnesses.cs index 4e73c04e..81f1fdb8 100644 --- a/tests/ExcelReader.Fuzz/Harnesses.cs +++ b/tests/ExcelReader.Fuzz/Harnesses.cs @@ -3,6 +3,7 @@ using System.Text; using ExcelReader.Core.Parser; using ExcelReader.Core.Parser.ParallelCsv; +using ExcelReader.Core.Parser.SheetPipeline; using ExcelReader.Core.Reader; using ExcelReader.Core.Reader.Csv; using ExcelReader.Core.Reader.Xls; @@ -100,6 +101,89 @@ internal static void XlsbDifferential(ReadOnlySpan data) "memory")); } + internal static void XlsxPipeline(ReadOnlySpan data) + { + byte[] bytes = data.ToArray(); + FuzzOracle.Guard(() => ComparePipeline(bytes, static b => Excel.FromXlsx(new ReadOnlyMemory(b), Limits))); + } + + internal static void XlsbPipeline(ReadOnlySpan data) + { + byte[] bytes = data.ToArray(); + FuzzOracle.Guard(() => ComparePipeline(bytes, static b => Excel.FromXlsb(new ReadOnlyMemory(b), Limits))); + } + + private static void ComparePipeline(byte[] bytes, Func open) + { + int chunkBytes = 48 + ((bytes.Length > 0 ? bytes[^1] : 0) * 8); + List? sequential = null; + Exception? sequentialFailure = null; + try + { + sequential = RenderAllSheets(open(bytes)); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + sequentialFailure = ex; + } + + List? pipeline = null; + Exception? pipelineFailure = null; + try + { + pipeline = RenderAllSheetsThroughPipeline(open(bytes), chunkBytes); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + pipelineFailure = ex; + } + + // The two paths count limits at the same seam but not at the same byte, so a limit hit on either proves nothing. + if (sequentialFailure is ExcelLimitExceededException || pipelineFailure is ExcelLimitExceededException) + { + return; + } + if (sequentialFailure is not null || pipelineFailure is not null) + { + if (sequentialFailure?.GetType() != pipelineFailure?.GetType()) + { + throw new InvalidOperationException( + $"Oracle divergence: sequential threw {sequentialFailure?.GetType().Name ?? "nothing"}, " + + $"pipeline threw {pipelineFailure?.GetType().Name ?? "nothing"}."); + } + return; + } + AssertSameRows(sequential!, pipeline!, "sequential", "pipeline"); + } + + private static List RenderAllSheetsThroughPipeline(IExcelWorkbook reader, int chunkBytes) + { + using (reader) + { + bool isDate1904 = reader.IsDate1904; + RowAggregation> collect = new() + { + Seed = static () => [], + Accumulate = (ref List list, Row row) => list.Add(RenderRow(row, isDate1904)), + Combine = static (left, right) => + { + left.AddRange(right); + return left; + }, + }; + var rows = new List(); + int sheets = reader.SheetCount; + for (int i = 0; i < sheets; i++) + { + rows.Add(string.Create(CultureInfo.InvariantCulture, $"#sheet{i}")); + SheetPipelineSource source = SheetPipelineSource.Create(reader.SheetAt(i)) + ?? throw new InvalidOperationException("The fuzzed workbook's sheet has no pipeline source."); + rows.AddRange(SheetPipelineRunner.AggregateAsync(source, collect, null, 0, 4, chunkBytes, CancellationToken.None).GetAwaiter().GetResult()); + } + return rows; + } + } + internal static void Xls(ReadOnlySpan data) { byte[] bytes = data.ToArray(); diff --git a/tests/ExcelReader.Fuzz/Program.cs b/tests/ExcelReader.Fuzz/Program.cs index f4613d29..f60d1b89 100644 --- a/tests/ExcelReader.Fuzz/Program.cs +++ b/tests/ExcelReader.Fuzz/Program.cs @@ -20,9 +20,11 @@ internal static class Program ["xlsx"] = Harnesses.Xlsx, ["xlsx-memory"] = Harnesses.XlsxMemory, ["xlsx-differential"] = Harnesses.XlsxDifferential, + ["xlsx-pipeline"] = Harnesses.XlsxPipeline, ["xlsb"] = Harnesses.Xlsb, ["xlsb-memory"] = Harnesses.XlsbMemory, ["xlsb-differential"] = Harnesses.XlsbDifferential, + ["xlsb-pipeline"] = Harnesses.XlsbPipeline, ["xls"] = Harnesses.Xls, ["encrypted"] = Harnesses.Encrypted, ["csv"] = Harnesses.Csv, diff --git a/tests/ExcelReader.Fuzz/SeedCorpus.cs b/tests/ExcelReader.Fuzz/SeedCorpus.cs index 7b5b4b65..a63119a4 100644 --- a/tests/ExcelReader.Fuzz/SeedCorpus.cs +++ b/tests/ExcelReader.Fuzz/SeedCorpus.cs @@ -34,9 +34,11 @@ private static readonly (string Target, string[] Seeds)[] _layout = ("xlsx", ["xlsx", "xlsx-multisheet", "xlsx-sharedstrings", "xlsx-blanks", "xlsx-empty-sheet"]), ("xlsx-memory", ["xlsx", "xlsx-multisheet", "xlsx-sharedstrings", "xlsx-blanks", "xlsx-empty-sheet"]), ("xlsx-differential", ["xlsx", "xlsx-multisheet", "xlsx-sharedstrings", "xlsx-blanks", "xlsx-empty-sheet"]), + ("xlsx-pipeline", ["xlsx", "xlsx-multisheet", "xlsx-sharedstrings", "xlsx-blanks", "xlsx-empty-sheet"]), ("xlsb", ["xlsb", "xlsb-multisheet"]), ("xlsb-memory", ["xlsb", "xlsb-multisheet"]), ("xlsb-differential", ["xlsb", "xlsb-multisheet"]), + ("xlsb-pipeline", ["xlsb", "xlsb-multisheet"]), ("xls", ["xls"]), ("encrypted", ["encrypted", "encrypted-multisheet", "encrypted-sharedstrings"]), ("csv", ["csv", "csv-semicolon", "csv-tab", "csv-bom-lf", "csv-pipe", "csv-ragged", From d6b1186042df924badd7faa39fb047d44f87fed3 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Sat, 10 Oct 2026 23:51:04 -0300 Subject: [PATCH 076/115] perf: cut sheet pipeline chunks at 256 KB instead of 1 MB The last chunk is parsed after inflate finishes, so its size is serial tail time. Ryzen 7 5700X, 65K real-data corpus, light consumer, min/median of 30 interleaved rounds after 4 warm-up calls, sheet stage only, dop 4: 1 MB 256 KB XLSX 18.12 / 19.62 17.56 / 18.49 ms XLSB 13.99 / 14.69 13.32 / 13.88 ms Typed ParseAsync (FullRow), dop 4: XLSX 20.01 -> 18.85 ms, XLSB 16.21 -> 14.44 ms (min). Published AOT DLL, xl_parse_typed degree 0, median: XLSX 23.2 -> 22.0 ms, XLSB 18.0 -> 16.5 ms. 2 MB and 4 MB chunks are slower (XLSX 19.66 / 21.86 ms); 128 KB matches 256 KB. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Parser/SheetPipeline/SheetPipelineSource.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ExcelReader.Core/Parser/SheetPipeline/SheetPipelineSource.cs b/src/ExcelReader.Core/Parser/SheetPipeline/SheetPipelineSource.cs index 74f0b081..bf096983 100644 --- a/src/ExcelReader.Core/Parser/SheetPipeline/SheetPipelineSource.cs +++ b/src/ExcelReader.Core/Parser/SheetPipeline/SheetPipelineSource.cs @@ -25,7 +25,7 @@ internal sealed class ChunkOutcome(TState state) internal abstract class SheetPipelineSource { internal const long MinBytes = 256 * 1024; - internal const int DefaultChunkBytes = 1024 * 1024; + internal const int DefaultChunkBytes = 256 * 1024; private protected const int RowsPerCancellationCheck = 4096; internal abstract object Owner { get; } From fa57c54d0fc0e7ae5ea3d860b65683effd0cb596 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Sun, 11 Oct 2026 00:25:11 -0300 Subject: [PATCH 077/115] perf: benchmark parallel sheet reading and regenerate the static PGO profile Adds SheetParallelBenchmark (realdata group) and a "Parallel sheet reading" table. The regenerated profile keeps the Xlsb|Biff12 exclusion: through the published DLL it is neutral against the old one on every format, and including XLSB methods still makes sequential XLSB ~22% slower (28 -> 34 ms). Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/performance/benchmarks.md | 15 ++++- src/ExcelReader.Native/pgo/excelreader.mibc | Bin 65999 -> 101715 bytes .../RealData/SheetParallelBenchmark.cs | 58 ++++++++++++++++++ .../benchmark-groups.json | 2 +- 4 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 tests/ExcelReader.Benchmarks/RealData/SheetParallelBenchmark.cs diff --git a/docs/performance/benchmarks.md b/docs/performance/benchmarks.md index 7d3c6f24..591fd725 100644 --- a/docs/performance/benchmarks.md +++ b/docs/performance/benchmarks.md @@ -66,7 +66,7 @@ For raw CSV reads, all three libraries read text through spans. ExcelReader is ~ ### Parallel CSV -`CsvParallel.ParseAsync` and `CsvParallel.AggregateAsync` on generated files (8,000,000 rows of three ints for the narrow corpus, 4,300,000 rows of two strings, a date, two decimals and an int for the conversion-heavy one), by `degreeOfParallelism`. The machine has 8 physical / 16 logical cores. +`SheetParallel.ParseAsync` and `SheetParallel.AggregateAsync` over a `CsvSheet`, on generated files (8,000,000 rows of three ints for the narrow corpus, 4,300,000 rows of two strings, a date, two decimals and an int for the conversion-heavy one), by `degreeOfParallelism`. The machine has 8 physical / 16 logical cores. | Dop | Conversion-heavy, typed | Narrow ints, typed | Conversion-heavy, `ref struct` aggregate | Narrow ints, `ref struct` aggregate | |---:|---:|---:|---:|---:| @@ -91,6 +91,19 @@ Against [Sep](https://github.com/nietras/Sep)'s own `ParallelEnumerate` (all cor Sequentially, ExcelReader's typed parser is ~1.1x faster than Sep on narrow ints (490.6 ms vs. 521.7 ms), and ~1.3x faster on the conversion-heavy corpus. In parallel ExcelReader is ~2.3x (narrow) and ~3.3x (conversion-heavy) faster than Sep's, while yielding rows in file order. +### Parallel sheet reading + +`SheetParallel.AggregateAsync` over the first sheet of the real-data workbook (65,535 rows, 14 columns; XLSX 6.7 MB compressed, 34.4 MB sheet XML; XLSB 3.7 MB, 15.9 MB sheet records), folding every row of a typed model into a per-partition sum (`SheetParallelBenchmark`). One thread inflates the sheet and cuts it into row-aligned chunks; `Dop - 1` workers parse them. + +| Dop | XLSX | XLSB | +|---:|---:|---:| +| 1 | 54.38 ms, 8.02 MB | 36.55 ms, 8.02 MB | +| 2 | 37.84 ms, 8.13 MB | 25.60 ms, 8.07 MB | +| 4 | 20.07 ms, 8.11 MB | 14.94 ms, 8.07 MB | +| 8 | 19.94 ms, 8.16 MB | 15.26 ms, 8.09 MB | + +At dop 4 XLSX reads ~2.7x faster than sequentially and XLSB ~2.4x. Dop 2 is a single parse worker running alongside the inflating thread. Past dop 4 nothing improves: the one inflating thread is the floor (~15 ms for XLSX, ~10 ms for XLSB, plus the XLSB record walk that finds row boundaries). Allocation barely moves with dop (8.02 to 8.16 MB): it is the typed model's strings. + ### Real data reads This benchmark reads a real workbook exported in multiple formats. diff --git a/src/ExcelReader.Native/pgo/excelreader.mibc b/src/ExcelReader.Native/pgo/excelreader.mibc index e08a44d4dfa74b918b8adf2966b2cca9a517806c..1a2ce5d016b86cbffd586493f5b857a0dd5e7ccf 100644 GIT binary patch literal 101715 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 literal 65999 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 @@ -86,10 +86,10 @@ private static bool ReadTimeOnlyAuto(in Cell cell, bool isDate1904, IFormatProvi /// . /// /// The value type to parse. - public static bool Parsable(in Cell cell, bool isDate1904, IFormatProvider provider, [MaybeNullWhen(false)] out TValue value) + public static bool Parsable(in Cell cell, in ExcelRowContext context, [MaybeNullWhen(false)] out TValue value) where TValue : IUtf8SpanParsable { - return cell.TryParse(provider, out value); + return cell.TryParse(context.FormatProvider, out value); } /// @@ -97,14 +97,14 @@ public static bool Parsable(in Cell cell, bool isDate1904, IFormatProvid /// for types such as and that have no UTF-8 parser. /// /// The value type to parse. - public static bool SpanParsable(in Cell cell, bool isDate1904, IFormatProvider provider, [MaybeNullWhen(false)] out TValue value) + public static bool SpanParsable(in Cell cell, in ExcelRowContext context, [MaybeNullWhen(false)] out TValue value) where TValue : ISpanParsable { - return ColumnParserFactory.TryParseSpanParsable(in cell, provider, out value); + return ColumnParserFactory.TryParseSpanParsable(in cell, context.FormatProvider, out value); } /// Reads a cell's raw UTF-8 bytes without allocating. The span aliases the reader's buffer and is valid only until the reader advances. - public static bool Utf8(scoped in Cell cell, bool isDate1904, IFormatProvider provider, out ReadOnlySpan value) + public static bool Utf8(scoped in Cell cell, scoped in ExcelRowContext context, out ReadOnlySpan value) { value = cell.Value; return true; @@ -112,7 +112,7 @@ public static bool Utf8(scoped in Cell cell, bool isDate1904, IFormatProvider pr /// Reads a cell as an enum, by its declared value name (case-insensitive) or its underlying numeric value. /// The enum type to parse. - public static bool Enum(in Cell cell, bool isDate1904, IFormatProvider provider, out TEnum value) + public static bool Enum(in Cell cell, in ExcelRowContext context, out TEnum value) where TEnum : struct, Enum { return ColumnParserFactory.TryParseEnum(in cell, out value); diff --git a/src/ExcelReader.Core/Parser/ExcelEnumerable.cs b/src/ExcelReader.Core/Parser/ExcelEnumerable.cs index 540fa59c..6937b258 100644 --- a/src/ExcelReader.Core/Parser/ExcelEnumerable.cs +++ b/src/ExcelReader.Core/Parser/ExcelEnumerable.cs @@ -12,7 +12,7 @@ namespace ExcelReader.Core.Parser public sealed class ExcelEnumerable : ExcelEnumerable where T : allows ref struct { - internal ExcelEnumerable(XlsxSheet sheet, ExcelParserConfig config, TypeMapInfo explicitInfo) + internal ExcelEnumerable(XlsxSheet sheet, ExcelParserOptions config, TypeMapInfo explicitInfo) : base(sheet, config, explicitInfo) { } @@ -31,10 +31,10 @@ public class ExcelEnumerable : IEnumerable, IAsyncEnu where TEnumerator : class, IExcelRowEnumerator { private readonly TSheet _sheet; - private readonly ExcelParserConfig _config; + private readonly ExcelParserOptions _config; private readonly TypeMapInfo _info; - internal ExcelEnumerable(TSheet sheet, ExcelParserConfig config, TypeMapInfo explicitInfo) + internal ExcelEnumerable(TSheet sheet, ExcelParserOptions config, TypeMapInfo explicitInfo) { _sheet = sheet; _config = config; diff --git a/src/ExcelReader.Core/Parser/ExcelMapDelegates.cs b/src/ExcelReader.Core/Parser/ExcelMapDelegates.cs index 73bf5769..ffde3277 100644 --- a/src/ExcelReader.Core/Parser/ExcelMapDelegates.cs +++ b/src/ExcelReader.Core/Parser/ExcelMapDelegates.cs @@ -11,11 +11,10 @@ namespace ExcelReader.Core.Parser ///

Wuh zS{7f8n|!1fckY*Z{dYxMFibeBZ$vXq;x`}8^{Xc_yCL8dmLpWR`xBppHn*l5g0)~3 z#RCZ=_^E;&aYKd`F&i)gdkhN*QPAMXZQ|~~fVzOWfWCkQgrcPxTsqaV%SWuPEKjEc zPiC|RQzS`BEv~T~%}sDj)J-@{+)V%`@+LK9Hj(FcFx7?1bIt{K%%M#C6?w6`0RYoM z2G~c9%gFmFl|Dq$lm_R=bx45QwW~`z{aV-(37-8%V~6eZeYs5Ryd8FWUNxFt>Ch(B z7R(m(7Ht=+UNKf*A9eStuoZ2Y7kY$%kx%)Cy&TOK7+%Kob>^uI$%~LM4>&IvF9a`W zFL*ClFT_WgI1NUQ7?LR4+?KohD?E{7o1>xMF2ZLd_3iCl6%Y0qgOosjs76}IR})qf zXA^Q0V-pdRU@#Q;EBG@QTxNsLClqP9S=orx?krKB^;6dV8eQVGkaGFc(|loS)OY^q z|JiTjGZ1lp=`_8k*f-k14j5F(+|*)Ph^e_oKgsE-UQ1l^B`f8OEX$EOzENmMe7)^^ zGZ~hr;Q3C&E)skA&o1SnSiNkOjj9Tupt-RU3L2NAjW-GeIqqu!7R6I{FW zR2pd$B0;DcNDReJ)3BlGy0!B^ZC`} zus>?h3)2+2#?jVy6>lgNHz{H5aJ&Xf)9!0&`j}*2UX#iqd%3GhWfg%{ku(pPWqGrw zuF;oiQD-4<>FLKdi~O>B&bv5A3~9ra2_ZuBR_fa$N=sz~)Mp76Cs;@_H)Np(l~n!u zQUS^S&=UAn1MkJ3EpB4hIw@b25zjEh&)(Q)xjnS}o9ESSYh(+k&jb`1xM*RTDArw}=xpHC1s>@dCgERmmNTm?k5t)%1@e>ET3u4A(C-*raq8>n8X(r2LI9Zdg3$F4JT*Y zMdP$(HfCU38jj$H{BghvMjU4s#>b&|xq){J4MtY{n1rGph3o~_nNF5RhXUX1EGm7Z zUI@3Hah`<%fz2Y<;+kP@t7ckzF8yr?hTS67;u@nZ`URlOr?#Ph|G?W~M5GJr16Sl= z`BsVP^Jsi|HL?84bJJla24LeyFP#OI;`wLUg6&rRQRNyLX+cG)x5WBi6lwQKG_zU2 zMQrlP%+u0eL`;DX?i?GeK{SXLG5%9A(iz@f7@tSCgqTpNqTHLj%(A3{GAeudct6d- z&%w?il1U`REthoqwBTHKu7r}D55+f43acs$NF#60rba}HreUV2ucEfd{^a9a< z11WE>HT~G!y1|pbbf1i^03%n)$ve&kAdL zCXW4U2|P+MVvbk)vZwN?k@dzt<$23>YT0G(h-Uq9-uy*}JocBh=#iS7v>FjtJy>CV z`^~JliX-QM)vuY5b>I_)zi?OF3AddNxjp0|mhX94^T1(BtVrzgdFCXETI-TL+E0Yc z2L>VmmIbBD&WUr(;-q+Tbm)F@m$zJ5*{bd4c9-q^B}qbrL3RAN`6?kykW{b5U}#=} z-QOmWnCr?MOSr~xAr&qtsa*yzSVTKk5Y5)zu86XQ=R|ECM%7@?{nb_=$UERl^_M27 z%;J;o=9%VAFECmHZU;A(E)P7XK>8#0enBF=6HRzqrc>2koLV@Ojn7YMkd6<}hX~>U zCIoRr+Q3%~w@k{Q{NuXV6t$E$2rJ*Dz8JAyN*bp%C~bVoel*q-$H}u!RE>I#!aje%)D3f#6))G$b6mN6cQBK7p^OwSA}~i5cWvpD1KjpZLe0lF`01$;3UAJV`|X z0PXFnK4RBD&sa~ZSQy{1o?NM}a74_=@(zwCV-R!vXDI6ZO70r>1;{%IeV`#?U`bGX zsD4Nm6e~5cMD^e1$hJ+!9#zt=(4wZ1UziNd0=s?}?m1Mq6r;nlD-}{Ro+Sl%{f0A; zom$9_Srqx_X~6`6DA{qOsf37CF$O*6?VGgJTDkVkwcGw@Q{lg+P>KVLvpL`7Zkn(s z@ViLrg5V)?U`=-TOH$<~U3e@C}=Gf(Sijc$qH%A@Ev+O6uj-`OjKDzM9KbrW{u z2~2`efch5Q?#VwY{M2F|-TCOsJnKCSLNQY)!r3gS1tuL+VC9}^9GRQrkB)7f-8k~; zz2g?!_>o^I1o)zMSM1`;Oy10|XP%&oP#!2}xLvqjLZA4n>E%wglb>OtO1uYl-Q9T5 z|A_^JD?LE;g0oM{Md;dS~h8TeZL4QKGLIpxFpvd8b;Q~HA%iNE~cXOv`PfN** zPj2pVJ-ml(aV;Mqva77+)}N2%*XQc(=Z&GV8gEUns0UwPTMl6|zNKXE`>O-KrPof- zj3zkzGeO)NB7AYLqA8PJXd;6ZMh}2_214k;OCalzd6**DD>z|{fWeHUy7RpDkdCUV z3-r6}3fucW#ln-LvFM%UL`*?0p7wuDcs=Y1p$8CxVBhecNg@7V3=nzf|70N$Z!kIN z9|?&Se(rk0{pent5rQQ{$n6Q4`r-}!$DUh~@akxft4au8+}bseG2``%x+(+Jvp2yC zvNL``b6((rHH7E=uhb@E-H+g6<9D?Cvh;s zpU9mj>)XuIzfBL+G_-oCE$9~HVFGW0&`8;c-N@dE!UkQ7@*kQ5UHji+8<$dDG8MeIygHP{qB=09%Vb zEP&#gu_Mlm56@PwzFSmC!{o1p_ut5jir2+par%-OnoTbLg&zz2g4g8)KU()2O2jg} zLcI>3hc`vPqCNRFvsX{OGDrk+{ZB!T%5yVLnDAPRRE}Jqyqcy-nP$ZR}+d_FFE6KWVv8QGX)T*JaB37C*(w8^^#wC~_ z_TD)(+vf^a%PqUUGf6{!a6;rJ^^==HLM*X!`K`h@9nsCWGI4Dl{Y-W9S0%Y|y3)Im z+X=5T$0&LJWx3Y$s^4k$trkLj;}T1quOR{Ai6N(7c(RRD^jU0sB-I$szH|!2<9)ht zC)mM+UyWg=qoU&GY}fhvq|-cNsJiN8@kTF>rBbHo#LBKtde*L$RDT8bwk0tsm8Y!G z`Nj~M^aX5ZP`~Jk29l-fxBa zZ4b4ckFUs>VN49PP6$ze#ZAqkcE3(FY}&xLpAq@fkHcSPRyrkj%LSZCRvX+F*V z*~E)FHzlf%tJi~I>MFJBOMt;T*pdfP*>3Sy+KB&mAXf_TR_6{~mMG5(f2yfJf3#W5A`yUcFUY_)M=D6_g_(9yPrOqfgll+=5*rP;YG6DX1Z=fR@O zCgMwZ@3y7c4PrMQf2YwboOd;VEvw*4{}LpAIMU^cD*iW&oC#MFs7Lh57f*?c^IXx8 zfIH6!W{8?~yq2VwLVK;yg3X{cLRcy5dtoq~>yhS8u;7V$-pZx@(H9=0z|_HR z!%qkKWV3STldOuc1G1Lj>cO8mj_Jce)H<~ozJj(skfxTV(jfrsVK!-(aqKk*0XY4cYt{ke*O zkmk1f?QnYheEQ#?kz~4wl0b=&NcXVOoo zgLO9xGGi2pH&5b3+K*={p!5fR)U?B=ja*;PBSg8KT!+X z`h?=Oy=C!_JU7KUG2#3gXWvP*^n253h4WrdWnC&GO>5OncgiT!S2WJ%@J>N)&dvW! zYigH|nh%qYk}pLw4SN|M2Rpsa*otfsrBxwJ(-P#jhA%icoi6GjIx{j4n3(62C}Q^x zuem7v^$%oDyv6`%wKr_+5Rfpy42DWsWr#po`HHCi3AG*dhtTdT#?Zn{JRzsxiQSoy zM+|2x3ygxKcV1Xt99{q~F)!^#MwFO%Qk%f#dgn_65$(s5M8M@nOVp`S4X!WwInF|n zxN8R8{ILUHo=o=5TT&u9eHFF;JLUjrnLnTbxMN?PE-_)Ps0sSsCtH7|kvAi`Gyh*y z5zYY00LcK(0M(#GpT4)Ny8OYtQE#d>z^!#ZMa)jFHkO~p6~2NU%nocJ>4fRT_F;oy zf{Q_#Wy5rr%z=QIuA@P=m0BlnX-ljn;C{OsSBai@iQ0jt=53W`sDhazbC&SKgr;2O z8fAqiXgK6TwiZo>_Sn~7uQKe~6e4r=YVktN*AUN7Q_sU52gmmhh{;uDktF%F|JD+Li_(H&t0|9a5sh< zdBhAJiW5;CdZ}c^6ZgLzf~n1M5k;o0wIKA_Myspegpdb~((S#zEc@9gH{(WZ)ieI= zsfROy?W~X|v<_3GeHw8{+?_i(#Wnp90FL^rndR)E%ifr6 z04~-_bfO;Y>+3*mL5`#2zitd`*6ehB2x0d#4v_*)bw7jxO|A1@hn}f*CeHvpjvcQ< zohISbxK9Z{A#;S+89{{Sig!u2cL~CX#)!ws+^T857WEL^26eb&;tfu1?N49WX9=rm z%<)2>0~<6a4GkAPb6XWrrf&wSS7mIsQhF?u()vy{?@QJ~8esRJ`jGtRwt#xVb7?2i; z{*2SsFncTft+z=)H06IW_Eu4CHR$^`?(Xic#T{DQ-MzSbai_)IU4uKNI3c(eEm|bF zyZgtyGr#%I;dhXgtgNi89PI3U=YFo|O6o76r7g;6f>8J5-^Z`N>wXu)UcGrl)oJ9P zah`sENIDdr@F24kRQZ&*@;Q+JvCRGFp}(ZiGE_Rk*_^;`nWwITrMt|NZRG$k3YGH~ z;YU}M>CaCz&GWb$!V!m&z|V6$pXUO8oY#kJB%Y~;botJO`;KZBsOP6}wBdR40YgS}Xviq=C?Z=njz%Z;|H(!D7%skZ(wvSZcET*#lBq?+kgOz^@1s8SZ#azfVVbh<}Vi?m=A#KZ2kr zpy5C(AVE+E$QLAS(Nd3bs~-@lkLS^CX<)QIy?lKh13V{eCNK@?FxyRGFGl?l+Oj+PWK!uBu@2c?GK&T54_|hiq{!=`Uq?kwGjsOBT{>eE7Bah zRVR2=kVLUu*`%m4JjTG}?M|xf^pO}+?u0HVJ`O2ItD+1hi}thPgo|1F-1Q^AR{-%Z z$x{>i#AojD)5mdmm)MNmbx%-u)C< zw?j_DU^76j(~x4n9#f?#Y?mCGAW60J4KzP%4WIF6Ly^~81z`pII)VAYx~>f(?@0PH z;FU7rB@yhKBoma8f^gxeINJMIrs9&ulI&1mmARhNiA)vLSPi_MZsDmYnxoUKH082; zDysePg8(FZuu#SZn7^2>akq%y+4rVbjA>hxvNUVUGs<8(q)S}k=?IYOT~D&@GaRT@ z70C`yrUDS_Di6un4q5-zhsVkp9%VUOqjmLizL@e! z*8%m31L5v`3Z8C>yUDyXLbt6SvkZ+U4J#+lWwt^EDrQfKcHMY+SElMX1$hP6tt-RX z-`O_-(cPSI^$N4?Y=;aP&11B+9&+0nxh)R{DOZ-jm1E1`jJTAeG_IU|KEC3xF@gE% zLE1~JrmC+$=~&(U8zEPJle6oMt{sPu$$pdFRnH67=>WF00hJZMH33&?f4j;_7^(+m zON>nezKv1U5no_u+&EQXyF?Mc)^L}9o?FF{>4c2v1PZnTi8=w|d=%~hwOpq-W(b{t z5+kg4dnHiq=%4CIj`4M3DM$4^Kn2ukhGRgP-jQVkW+KS)h(A3<9m^@7KL1)O9w9I9w+GYRc zPZ-}Tc1ac*9_&9gFrWH0wo%tXwSA8k8jKr@dK8pH>L0*4a;8#1Co~b4%T?^5-XXBY>xZ#UZ%OLef`FZSWIb{~+fZ{;-c*Cs` z>W$?;b5KlWJ^VIliX}mV`}!KTkloQS)8Ta;7Z4spA)qgyUJ&IL4Zgjq zR4gM&@@R0VA76;mON<$~qH z>4N0K?Tv z%2T2pDNs`7Xa{Ul<)p4|aqi&w0oU4lC-oc5sr?I?&o7t5@lQtty2 zYzJTwJ89)(q!CgM@xN)7#!E{!!A23NlRPwMP59D0v=Y3P7tVNFN1oC@kDKld9tr%ze1OQw2>s?lzc0xf-kM7Szi`K2JO z_#-Cbq!K02H4HlDn$J%#&DOKHzX?niK^v?$;)OccGIXDSGRGk%x$@cE7pp#{5_Mgc zH}av#nJy`6@=x-pgGkT(Zdqwf-3Fw<8Nvxn7m+|GoOdl)71NfzQXu^HcX>kf^=);9 z>gDAvf*^_RTkC?Llgv zO@zVuh+R#TCjw*Cl~&4X9|5=s8I)SNm$UVUHA-Va0aMcfc%UO zjZ&L=AJ3%hWtQKGM@XTg>MJ!PT2tvQxGH}S;R4ec@YbznO|&IZW}XvA|LL|pe{Rt` zpn;($x3JOpf8jewY;bzgi)((B5{=~khcx*PFp07wO4Q&Ka^b@@Pd!!}xFxu7#VXM! zJ_M*&G%0z;Ee(2>qQAYo1Enhj{ye3vsDrIE@9(AM(5fYpjK67x+U8M3S{ELYoWzGq z+}MDfncM2Oxerm~pxjr5jYDzHz1!y|elwR{^DM&6(K|I245-{0aFIGbBWgg>(;g#V zdHC3yFfYpy0lekz|6rmgVY)*KBzMrw+p=rhXlUW4to2f+(e@J9naFpumHj63%lVM&u&v24r5DPz6`?u^z^9X@{P`Z(DU z?i;g~61H}(xO`C`icieA4}KkRXY&t2ctLpJxWERNgQkLCV**5*ZKkFo@J1B<;}75{ z9@#HmX)l<;i(ADIJ!nvY@MHHN^5FlNf?yx)qpPi&uz7^}6Kl!|;DC=bdCV6f3=woj zdje2_xe55IjA^cRoa-ijKPWaN^^?Txx2`0tI9WEjg7+~WX*QsfLTj3g7}lK`{a%rL z#n|%R5tshzOqT))hL~+y01wvFs#(rG-vfsO5Lvy+ET1TEgw7;Byi@fN(>>-aFOVU>3s1J&`mDlV;ZTSr+3H1NaUTLCc#eC_+$siW{DvT-ajn51cRQj+@^~EOULxY*N zYN_uN){A;{h)8KNy4)a|RrqA7ATK60f5V=ucj^V%=%f{pgG8FIV{C^U6PwiSdJ zMD|MELna*^mjY4jGRSKWR_&1Vod9a?L;ic&+tO-llSjcprAz(m6Sr#n9joK~A^5G$ zP^!C6Tdra|boZ$_Q}{-+5l(*oXK177Yr*xMkUf8BMzAKi2U_}}Rguch-9VbAL=D29 zjL_%b8N(QJ#!N%mxmJy)o3kd_Wc5y%w}p!Ie&*&)cj-O%U{?iiCPLOA)WFmz!pWlA zJt#Mpgvr&a)e5L-Uls~Wl&QjA(*7H{4dDsq3HcQ)`bnc4ox1#vmz=wUfpob1eHPi@ zH-=#-!cv7Cq+`Y5hEtiTj>827oL);gUY_8%b+j5^Be)z?I@9&^c%aSrwmb+8hx^^Z zr-i4u4u1!3L7b-35wGJb@27v$Pbr&f3UXhrj+6A|k(x|Z-kU>rpd^QbAsaje7TM%l zSQ`_q6u@_CSH*Fbda$X`S$6)@VNRxGp zBsSr?J3d|Fdi3=7Y8YN}{$>teX<-MaZe~Vs3g|3)tKH zOupiYs{EF-`k3VF=iX(4O`J}G(hOB9Q+Ec?)JF&M&xi6&+#31Z%$+se8ok{9^^QYg zK7zlMKI<)v2XU^ls=gQnQgw3g;0(%SPJGE5qQ zKZ>9~u$r`%>||kuq74{J5K9IokQQ){g2Xv=pNf{NWEa@1~6-J|s}1Bw558yIc-S^1{>c`I)9$MuT_e4*@nm8VlF^hOCpkvl<8h7GZuMU?oqFdd`zC5uu?B}Sk*Euyo_KN zjfo6P6L3E~5oVQ@89jn-QWM{P!mD>h66Y*pohn+3%?Ye3xp}|P>}UDy?w%R@_>7(& zBz*hn@9@HM{7f=ayC%ywlyKwAmwvoG?3O`1Zggo*X}8;U#;^Re!osEIIY+C{$>nzoEho%Nr&Yn@a_~o1*P~fxU0YanQfqub*&yg(5joy8nl(vvxkN{c z?&90Gcl;lye$c%iF6UnKmtluDd{4(=kf&0O> zOu$S+O~Oq={)e>rfAuMpA(A1SA)4W)+k#phl$p6=w?BAgc^>^JOS_`Xk4g=ZlKe5# zZ-ObL<*sSd6Vu@ANnIS!71T#Nyt z^iK3&c0+o@T*!jeKqVo=5DBnk@WN!?p-xk(kFvC8nGNN>_plrENekr*fIo9tJYBqX zv#7m;c`S|P&m_!dzHGvTQMpjU|D{MmtRbi1@}Y(C+c4V@y~!^8!Fy{)2)C_t^KY~s z<;4>fZ2L)*%~)BF3Ati-BG>jc-ixUi`vnSM{l7Aa@>b8@t;9mIwEYw7XG0&SgFhcD z;0*8fXTsO|FUv<#eChbx!&mVJ4_@+h7p>VprZkuuCSZb?+FNL5$zC{VxA5pc#E0c9 zeCah0jW1r#mVzCPVVay?jv8xAUzoBeMP+MSmREq4Bj3)`B^FMl#O@eMZdhf#Vak$Twn zOlP||5sJgHT=p1X8I1=@t@!lJJVAAC8|-UN>+_b`D%oc#faD}2@7VxdpEN%$@AQ;W zRgqwajcM7}sCez2i^1E$nGC$g%X5}H)ne6ydMiN{kbvMgE#5*?t60O#Vjj{b*~!j-daN$ns?(Rd}B(4J@=Ssd85(5uh9| z(7ij;NhCd@7pVAr{z%UD)^M3{_2KP`&B9Z>lqKZ;9QbWQ&5(e~3UN6tyrg`sp7U)p z!BuHl7Xatfu)sMM)~8r70?~ED(H%@FdlTli*Dy*W4VNFh57Gno1tIt+XdmVoLDXyO*{4??YFEm?2gY9y|2xysflbeQ5G>FH z7AQ@w-Y#5Pq#4v}w=mfN$Ku=bXQuZ`FM#mfv6<#PpyFP9Gf&w;IG1f2#Sr)68@BfBu83Ft;iDQ01y6tb-!=Pf2Tki z92#T^4sXFXK`?JHFUxHv6lGMEyrV1zIW#&$$;b>&8pCd*{b*z=YytC_)MQmDHMC#Z z@0+&He|{@Mq$gvVy-p9{*+IKWThS^C)_-B>fs|yRI#Fc&-Biu-8z}o z<(a4CputbeurI&$n)G1Flq?S`zTECN19~hhZFyWAel+c|I4>?0MHf!sSXI(5$a;nX zVWSoC>>B*-{Oj1(kSmY7d)wkpWI94=rXk%a$Ul{$`upnOImObM$vMpGXR4x!RA-R=YTt&~$~rlf@S=2;kg{=9u)R;(dz(=GTNGP! zlx(5l9(I+FTV=j(4ZKwBXxM{ngP%XIz&>T(>jt-9JaV;!~u+ju&~`I zg%Q*g7pdv=@GE`8fsOvE)jz2ua5~3^oil_5rb+ol%54TmomOBio#UKc1nMd-{kT;Y zW%Lxo#0|VtEf-_v*$QiVyHm(Aa4Zf|A#)%G@bjeIFL9gtmd^3n? zAe&y$za%Iz7-fU7%ZDCfxk4yg@^A7(-j1Ul@2A(jnHq-2M)=+*@OZScM{BJYu5Ln;a1EQWW5weAxT0f14hqKL(}z#b_5SgwLOb;LgB9rAR(r#h61$@>nNq^QfbflBgcTUg)Nn7Z9c@;1JM%Fa3b@KH@m2swYiC%+UM)2>~>m zv~Hu1cSX^{#@#@xjV<*#zG{&eLv)uWK?yVor2Y`o)-45=>%X_NyUF4o!P+MuI(xb_EHyCxn1v2a7=1(|4cRYr z*|%t9$MO5p@xj|P#s2<@+H@d9G^mkC2cz5ro)JfjZE6gV1ltc+EBYglfwSh2Wel!;h;2Ds4@Gw*Y zG}(Wrz?j5H#yZP4mYs7aNtG;TKY3&=jVTmDye-LbH)t zD0RP!ggzT4WjgF)$?26@%yK15I#e*nbVx#1$a8EaC$9yI`Jo*mXGoU$;x2O0F^)IL zZslYj7L`P~Lw`u2Cw2K@S-qJqxWR`YuHgR&u;9JfT&P{JFS@ptT6^2|v+f@;V~4V9 zrn@qBnd9}^85Y@2!-#G8Sb^WQq-$2l>E!$e)#o@f`gS~+sj!fa4Zdyb8Gh3BC9ac$ zS5P-d`R=|!E3`+^#BF_0g4I6hi-*nhcrg&Lh)|T zY~lE26mrfSq61kEXAIqi=S_Oy3q}PIhww?Mj2?-HIs0-(F~+vbQ|=D3ZNxLsey2#e zk^4jFuQ(z^tqBZ~Ue=Ux*>gICO-EQ?EcGi$J(Ky+GW+)_yAALEwh8^mA+7N3ROB}O zXeqNcA|#9CUcG)Vfu|LDx+@m9kl+VuX%7T6^`8&I_?37zVdBDaMXAgFH2;6p~o9w}RL8Xai#cBf zXZ+VUMu;NW!xRT-A`CYbr8Bt^KKm;UH(bi2;IH_K>B&n7etD7?Ep+8H3Z$#oH@;1} z(jB@fXh{IA?C&aso4!h-@*nOj+aR!_*`nbGwZI6i1DNr?Vq!z1*JmYcGTTg_5oJ*( z(FKvs%T*AMWcTiV$b+`zVFPXuJs`zJq&cO3HN{3Z4xBIgeRyADX;=4BcvYUBRRqO zlRm&*D1i;OBqJQyAb|>;#h$mU?UBwS`VkVZf3jsIzvu0U3_N|LCRb(2Rr07gD&W6R ze=%HZX@7DS#kAn7e_Ab&bMOXK4|E{=Wadox_KA*o?jL=x0MYL*s|!G0x#X5oED~l} zhjxzwmbEL~0GBe4eO`A3E_cl>ht5j@( z&Zt~GJ*tPl9>5CdRefa#Q!);@)kR(`phXZnvH-anGUZ&(Hp-uB0 zN3WJvx$L4X7GHL_a)sQ~XlRR^r{Hw`0e5F`4`dKMsjL#^+JQL~?hU&4`#%kvAj7Yy z@+FF`x?I^$EoJyl^7zBvOc^b`ze0SYXl&D6`PY1Hh4JM;7N+vqY>HZT4C!|66_P=! zD~g~!@VrR zEKn8E(1(8ey^t3}I#$B3>1e&n^Kw}R`$<_)B868Ti+|U*4_Qw0$pTl5_%PPsFZ zM3i|xkj1oxr63{sKcaBcIs+8~{b>3Wr9T*E(zZ^&5qu)|R2@pSPp#|$@QSK>p3rX@ zbx(B(LPoPbXmW>QNNXu9UOLQ3eP3!1tMJ!oofm0SZzz{!(~fMFWj2*RnbI&>#h z^*EZsCetRgMv1Q$vHFAqLp<=U4(rZh%`#uQ2&N>&JlH&B zI|Ms8CHLyoYO(<3T;t9#sOWt8LIl4J!W-ejg*0DuYCD+;XphK2Gx!&9jO=?dRBI&A zO3|-PSZ3khBERPc?dAjH!rJbGaw@7)ojY*ck;0kYOBj=v+8A3CdQx)|TEl_{tEacD z2MenQy9~Sh!X-q^uIiiUNY8UBJg85-P~=zeJvLW;wT~n>5zq5)zieH#dQq{IHj=e8 zpC#=T>xOc}-j==rj-LNG*142(dO}IStmSg=pUePwlX0cTg`++mVl%e|8X$AO+5`{c zLa54S$Jg%$d)(!jK`fYmW|%+wkyQ{YfFF0OsWtOn|+85(6Qx{{@>I@4WOp;$eKZB2n>2XgNhU+;se7~YNeZaVQ za(5j+r%M*`tF;me#4Hls2Pf1F6J40r5#Dox_o4Y@YTSPL*It@T8GJarOF`qahGkMl z2Nn)ahZbE_zc-__xmiZ(ZS+@@Y*B}*6Sw9;_Q8u#_0Y!XU1}3bg9~9y<8v49C>MHQ zcTgU{Fo0p|SHUl*+n9DIrQ*4`WP?2MM}P(kb^npz6^hro81-A1%J-pzED1y+4EI@6 zsRM@#dgM)kg^3~N%b!U_^kva*HadR*RsSNU^T4tbk5eT{#t=XRf5ZpQ3tVtD0FldL zZ6SP<&Ijf?OOO$`cV{NEAeP#E(gH81aQ%Q|^>o|JfcW!Qr62XzJL&8Uwdmf&Puu$5 zsEAy02(%L&qewnsXB87$_ynavDdifzU-0%BT+b8nSZVSl3d~xHk)QOBXD-#ws2`Kn zvI7f<#{(|S+n473o0lmYF##~%V~<_!^(Z_r*>se~UexqRNqvaVBC)3*%gBjeOVcsy z;N2Jr^W=Y@Go9&L+8PSZlU9*a?Ii^N?~iYA2I!wHZGeKtGjuu_hg-G;Fm&gxOSCYE$qwGEPKtPeFDihMd?p8I8YTE?K7`yMZir?)D$ z2Nu58-__t)o)i{uB2g|2)$IisKcK*f0@9)*Yavj(iHGMJ0v!_ zsRJ{aF?SpJkk;Xs$i1~2{eQ1#YlxNkyR!3?>7a-tNWE-Fi`0>ngvBNZ20By{_`L8F z_Xnys3Up}qkY>e_DA9iTW0mo!pBGNf{bz1KK-MxFEm`J%Y-bSb7c5ST(2f?a7n8R- zJNiD-Da^=J?3Eu7?57KFpx9(QV@$)8-Y6By^!JuDo}bA^PwtN&!M>a3y*z38%_5ePHWO&TdYb!U{lPmTqma`4EfohkA>+9uh#6Vd>)zm(kMJMvJOKQ?-oLBX(;R zbC9V*O|`lW2NQ;`Dp=dKGE4z?kRg8I_C0rwwRA#g`#ta8XD=Y=X7kadhkifb@4WqQ zd*{qX`6uGnXCaN99@LrBlv=BeDsC4(r$`kiOCs8Wu$x!Q3r}*w*F8w-n58pe;*V-FW#wqKE9R4eR&=Z z3Ueksh~4WAbOrln0#Q%97yDjn7nM&pqT}!3Kzj#8i$j^K-8BAJzzl3Oh>+rdmcs1( z(;NwJ*F=xz1Q@x)gg*@4idHTRuZ$!4d=@!8vx=uh1U7@P26PvGpNW0DMQ?g$f7tF# zYv7cTO;8rhsR(L>qR63!1 zb-^T?#Sv+MREf^SEh6X$df@LO556*wh{33C7Y;-@Y_NTCK}oeN8Pen(>2C_qXrF_# z<@ogJn7QLDCT_Ai=o@ziEy&9!mn5m`$@W`8^#m7qh2ol|f)A})fww>R)0l#5^b z^+JMwvmbpE#>54I{-J=R50N+!VOW>@1}EP2ZQ7i_S0Z5(R6fO#oA`c35dZangQS02 z&G%wjO%SgFIsEWb)g@_3&In9A3TgTA`#aeXNAC|)iQyw}mEb8;5&s8Mk(~mxg=f_6 z=SHP&Y=tQ!e0#kdKk}xEw!*HpIMK7q%NggRZuqQ?WV4%ajED$*XXHnW@dlS zkgPNF_f1qxQI+Y{wc%a&PBiYO&rg3TM%RXUycy$1mlidN;N^m>B8+$;%HhI+1tILX zCZEQiw8`a&Sh}-;3xoU1CdfE8TqN2AfI$iOPlFX4tRw)|qw6CL{HJfOixh`vqHG1r zi}z2m9lcFB!v4gZaEQ*qEdP3qxdD%?KC;8K7k>Z|SETLhv@OHp^b~P;;5zrs^^}Cz znuORn*^|%4^i+CpXn@GZBFDjFZ|I%R`3t85_vIyz8+eXe=io06f35`~O*fhp0nzFC z1ErQ@_9scF!cUS2d&kaU>SW;O=-EPcb8!=%3)3|}luOrv9*Ei zi=mGG!AX)dv*h^x`SM~@*qCA8T@}B8__)B!<={h*LMZB-fT|Zf`{;m1?3U*DzSp)H zqfV!@(NLPO==}(yD_`IEnj)A8?pji1e+UZd!$Y^UJu%l4%;mQIIHooMfK4*!h?LAt znu4FDuK>P?lzbB9r(>5{)PA|@h!iVy`Zz-6Xj0`Xa^UmR%%gv(k*OmK1gMnR&g@mxp2%Cz$vPKc@T% zWK7zmd3BQ%7gW7h3*X(jRKCT z3J$Ah#m?oL~*Ns0Y6@zrHWG%(HoA*OT) zIlcmw#rW=VW}FfQ+N{FAji=(e-5qk{{lox~uj~m2#k_WMpX$0gAgKK*6d4T!12G%5 zNkcx)U!}4k(of!qKr)HTBb&WsBF-v0bK}S^bI(y&l3;aMt^_PpReY4mph6*2k{SFl z{yvHp`9=~=FvNfJYcWuiI+}-4`e{c#(YfW$(Ug#x#n#7{W0I6_2!=fp+IbOl*pV|1e4;HJ0$RxF&nPqS?kgK3@FG>uC>uF8>ncV1fsF7ZrtbwgioP!?EqQGr(KKIN99YFc;!}d!pEq zh^!4mEuETwrei(L34)RdGMtOJy7BtngO+UC7dU@PizCkL41A}WjX=q`_;vE8Jjm@jy$?^&iH<2I zej@%taEY=-A6Z}wpfi(MddK`qCCtKs;-ULcJ+|KuewKJ2l=7kL`#G>LzR$T4uu>N1 zRwq_hfI3uYl04?+ZME`1tpXP`m(4r2OVQR-%cpeTKX>uUw~+;$nGReoFemE7tV9@z ze4oGlCCJ_KEd29;5{0a(}=&6R|>p~reD2aqK z0u8bi5q zpDony1^y4u^&W%?c86lwTv$DHC_a3iOV`y(TNfpZcox4<{$lR@HQRKi1P;f;5f<$Y zki7p)*m+wJ8*7D%<8rz1dBb3R`6?H1Oypgl=PPhKMj|9pcT{~zHB2)TEk%@F7lugtVzHI z-3`M&8@{33BcgTdOw&BENuz4PCckAe`Ik{YXFN$nps)5vMBu&+_eT?n8eeeq{0wl` zxb~4Hy5}^)AUMH6dPRrRweFGSEHlcC%>!?W;hO?YKZQ*Oy9b<~wdCO^em%s!YeS)x z@C0(_e)lRxtEK(;xW9IOZH6YGB+O<9Bl_I-r$jNSy5-W6n?PgqiQ8Wrz6@bTljn=s#4;A_q-J`6{9hv_Byj1CMWjk zMKyX7hYyx32+aMpAR%IAm^yOG4AIwV+UtF2%mZ6sPsZPAh_Bm4pF;Gf7TVmw@mKfL z&u5v&y7V{9i(!4_68>ai-Xv?!828UMs;AdBrtj69-S7raXIFDscbjq7ub9_-jx{7B z1N5rvDgC=eRPbzZiqYg3m!S#8PvqyM5sP&wlii1kX|4fNK`8Tv&xQLK8`s$Z%z&X= zx<}=krHmhfbMfme2c2%C{C$9IL5#Hgubl*!OpSr9LpKe0BKPI14opYgyMT8lW{U&t zJ8Zix>j4vtsE>yKkEj$MyetBuY)T4GFU))VM%+2VYS>a}8}j9wLjB2$^}0|4rn?8% zmP%jGw*17K=aXP+JGn}`NXP@+)@6_jY2m3jB<}NfvsyshgYgoWT5HwnHE$D)D;7^>(>4L>+wFlt9aayDNUV%V)?7(QtNN7u_ zEu`wLu7vQRT*J7Y|E`!TKhgpxKEbtEQ>WkFjK{A`LQNA#IdGCG*S3goN&P*Oo*!u# zOB~9Cnr2uz(ns+&4f%O%0z;hMMUrg{ky301rI?yPaLMgLEHMp99L%WxaZ#h3Cc!id z6N*HjgpwoFNh+r>s)iKlo3n#;bq^-h`Ycz|RV)7Gt_kd^b(UX3QNLNQbnr%p;u4GG zatQa0)_>=UG)dFw=_9FDQ=lUNX*{U}1@ zeUbwC6QeaAW@5+$U&~kXgm?BC{ zrN95L(5{eFVrEemH4Ll%yspsIpJbnUHN|wkxP>|8Ra2~G-koYqHX^ChLiL0SS+6O@ z=t$^xlH&TnTCl0eLJANgrTXaVw`8?J!6toW>{x5|Q-*9GMih4M4=$&H3p6Up`?{u7 zA`MAPa(&|u^ZkJ#eUr7lFsn}>*u5Mo|BKO@p8%LTLuZGvlJuA3z>Dt@Cx}^1A&bGW zSG*7=KB8d)8zNx}IyW9?8>+4_*uO9I`#m%0^)m{k$}A!aSd9yOX!h`pDt%&S%ZCZ} z>tdMDSa_*sC>B9zvRM2+(ivT+Ca7l_Xc-^A%89!ARZf?05qgU9&*;WXo)Khb_~oMj zCM^Y$E?t5ts*)zuASMcb8Qg^h514pH789{OL!c2&{L!0K)$9rqdPpKt#f+#mJ=kyF zQa6Jmzn(T!dA7-GDr;!R%=YDjUeA=9{R0NV`z$Cy3Ud)o$E9*Wfw>mes&N6M9e%e{ z69vs6@>JAR_9h4eB+Ar+=~uR``deH$*yUg+Dd@HyBc$6#6a{C5^rKMDM1SO?qK`k{ z5*GVdu#0bJA=yb7nC{#PJ_Bi{vO|pJ5cJvgA|E_HuSd2vk1!YU%=T8e~~3*(eSdJ!^;F=c=PUXOV)iKcCcptKMQ;lsk& ziEynT9FX}m>0IJB50xm4{CD#^bFdo~{wj9E8=lT*dF!#>H395Eo{HYrH53=-M#I`T z7?xAV7$#76F_z1y|MU_%$;aZ;AB}fLFx!CV74PPs;)3O{`R}>r!}WAK)^|^mk=B(Y z2OBgHmPqiawcGoZf!2Okpi?7s9*+>0$?TEa1)!_Olpfd9YGo!hc4O+K$rP^5$Ox1; zolEy=+D{k{Hy2Sbs&3C&B(&{gJh?W*NP$N%Jsq^uXT$iL3fo|I+~5g4*=1)45^K1w zUpSwz7^SWu>+{d648{UEh{XZhAy;;YrZ6p=K{=34wsdVh!zPQ!`!xyogJJ~NTgog7 z8|G&(sMos}{9qLFAK8l^1+(x#Px6m0<4^%736c2Z}=xO#%9b{SfiY@ZTqI@m5R`-{E+%5gjdnRgd%)!fv?(- zsWKUXqE=+22`|Sa7_GU8!9pDQWRia!Vt*34=y2ZIi%YFL09)ViR1klPD#vSKU-3WA ze*fjCE|o7Sy~CPQMWG|}b(wh&>FBXU0kLvF=PJZ{f3BdSwH!KVy;->nxwj@HY-R8tJz-n<;DaBc2$NQa3=dIR=uBLD)+SQ+k=zhk; z9qjd?COx^rOlmTdETm~|d4})_?HK2rg)CVpK;WJz%QDla@lP3QV%E~3JNV2L;WIx~ zSbZhi>LCIHcs#=A6~(Z6U$v#r97rF#j0vl$H+DjY&d~1-$|+_w86p4#4#6v9!#c+! zHBuWZ-)P#9rboiU`Pl9a_En^-C=*&NlrHY+TP{*Yk=(e1`m3myCsS9>zss3Z7a3xkHo_1%#%cr$9pd2LIDEm*)v&FCD67jqK>;ud6MKea!HWDQ6}pkdSQ+%jG!x0KTe-&2Whks56R%~7}g+5q`rkO4kocsc`B2t zLrjr|@MoR)0iv%$lp$Z3op=DHlW4Rr&o3#Xd?OU4Zx;KC45u5_5Uq<)fqJQqT8XdU z4E6?)5{osz8j#2?@j{9}VxBGurRkElm(L5jy`%iKL~{F!GExaWUP$B4^`*5|15(Qh z)bicg)x!)Q^6L#J=^m3Q!`B}ieS5%qFT#f=p8LSO{Y~bbAM5qXkicZHk$f?A>j>B$ zSJ*_-0|4Ix46$yHjCV-s*D(w@Uo<%aD=3I&GhpIg@)u*6%L!ld&qhU)D2-BSUn&1V zFr(e-nxQFJZSkgROlH+6Oa82`DUyMc((xb#2xZA_2*pX+YbH`xR9|GF;<4z>7Q~Xx z_fS=r8k%ei=O2aKz6B(Cll+yXCZy*{k-qx6R0{}g);Xn7zEV291w?t!Hz(5VxTcuT zkyF+JY%c@1C{2ElQaBP+e;&|wD+my?rDrbhkzL_PEg9JMPHU$2Of0!e0bn2IjciB> z$9u`acL>RP_sPx1^>|Vb#sKx@q}BpwB?flMlgY$W*9IiSH8&E;+-x2<EIJryZh) zhR`XZ%ba?uw)__z+&|miIV+XJmyBDB&2WdFPcNYbm3d@Jo0&-Z2J9TTH|a|lWl73t zji2HeiZBHf4pFOM=wiJ|1Qf9Y`h#$`%dqJZ%vVRudr2Bi&uEH>KjA6RwWTqhHvjM? z*;N#}<*&1dS5Gh}oeN2J9N2D)hg=<7lJ)A-E{iFX|NZ{~u0T=0Ob^{$bvxswtE*ZV zFWp_$!nD)vGd`t95p&U3E@>=zm^n&2vf7vqdNk`5ri0!cewsN-*NyobbCj;H_>O6! zN3ZH6q!ZVCV(`MQ;BjaQBEbwDI;sw1AdvbpDiMvtgNR;>2c-L zYz4h3{V?J&R!e6q-(;(a$MFGM!|WdYDZ2*U74)9i?-Avklle9zn5&>Y@@TG!IV~^X zT9_sFMO+J0S6j=qFkfWUat#>U&BWt)jB8>1^AB*_kkZ05#C^_bse9SaoR;3ZlHtKE z@~zDDcn#mmfF3-Ia77UB>2rUp1b1&uk4p>Xoy;vEX1E7}Qd@D0EatiNZK3-FdIGfLB zK3-GKcQQvps`zf^ru5akm+9o4yqCG9WE1aY&KCkgh+Za|fcxp}O?(gIT(X6y>~q3R zyvRN$Y~i)^NJum9Whxil&U=~3OYY;n%$T%?c`u`!zmIn^;qwphpvhNwDE}X@R{Ey) z9bU_VWIelOeHS0edK{ng@hr4gR&?80&_h0ZKIKhXO3X8xiIx4%_%*L*J&x~qJ$u#q zpAe~_Wu&#Dumek1DO(<56k5mwmS*B2Wg`JIjnP6WQu5hXlHa5*GD(pjxX9zq6d|8{ zt(!=8k~0apf{S=U3j`PWHMBr*lJbZ`!Ab7Mw#)0clhm*t$8@2K{#rF#sA79tvvyON z;3T&)WrCB9wCp4=Qzw}<7wCT*ze8wcSBE~1 z_=4bO-RfrIX2(^%EV$VTC9eyh$#k-dT;q2gV`YC%eNSj(V^h0?U1Yc4cxq)oGLEM^ zh}-XL-p>4L{7l$Ew)@@4?jVmEz!#vEos|GMHUaViY8l|WB%VzPY9_5@eBFN1K_6D0 z6I$r&K|c$2>PHl^c6zV2nRFnw(>1w%vJS*{x-(cO>p-;Ar-H&{Ue@Etk@a9az3iT# z*)lK7E?pq&Vt>l6l0i!tMJlm&+o=-E@Ui*{d>A?op}dM0g@sjzJn zIcN_>4q7%XcWTlNGLh@Zx=SW<(2nW3ak7VH9rQWnlQKI~Q+HTqXSVviA%h;_U0Ede zdGbfHZsy12v$8$dJ_G(J+e4loN97&#y*ZDKp3x8s+Ht>n3>PO+8Doe1chxSt##`_^469>V$v7)y?FbE=!fSIL~} z{o*0CuezBaLp?o&{kxr}Pdg%dxT{SkMHlJCM$>zulRdD$OLVdytp7~x;#76bq=)-4 z@hh>j4@1j(L>HNyDkxlJHq<`yO|(kULbjS8Pk7l9h;B8_Iy-u+{miIxv7OH^oiv?6%Z@?6CUOf_IE;#7r;IL&huE;6|Qd;z-1 z@)6|+Oly*LRlo>Hq zna^j~>S;bdEia1{^E*f9DxJ)@(KD21vCX&h;-Y0rJMR^1l@lT(SJWx1 zc+j)RPYb?A36y)4cD^n@i#S(nnM;=hRScr*>Ei@if zx>zVPF1EY)b>(?%E1*8^BqHBYT#H(LPwC_{W1u9svOiYZ`KRK(QC9KkY0bpW?@H}a zR`ApEh|0w$&EQp4d>4lG1mda&z}Hs--jdo(x;fSep%)qzsz!dK$xqe9&k50~TCfhf z$bp&RDi^5@gAxMh<{@7?IY5zrPhnQI@E%8&O61!WxvCblh}_)rhH0u_(Cxtz=s{m? z=x#%I3%RCYfeLUr;s%wAyfzZhIqy%ZDmH8Ny(*4Q%X>=I&3_|*UgbrkryJf_kLA^C5p^hl3Hx|i=+xsH1I@5Y`|dHKyX@2ld4 z%))M!Rk*S6dzDqtmH>X9`HRZS=gg;SFMo2ZpW4eWuL)J#1+5sXwhC8d+0|BoW{pu> zh3AVVsP~bmxlEepUL5|p>u2w@Uw~c7&J^9zE_4N8Az(ew_ zY8Tg%(WGwV_LeuP_4KI`x2g5?*XkC;-D*9J%zRi~D70zzshc<$BXsf}$3b;Frrm?N zVCM@Ky{@iep@dWj+vk0t1{5V1yX~?-$wiLF$4OO!Jvu|G61p@aq(=S`V>78@KhPCQ z`^aOuS<*gohj}NlavsM*sSsthAvW?by4gq0)r=Agx!U@*l9jt>-ZheydouT zwn|k3@L0Kyq-Lp+_c*@ht=t3Uw@EEr=-Onlm3)(Wr_|2Br`s)c^6%;PN)^JMnfs(R z4qCQC?pXa=sY(c1bwJ|ioV-Ppqu*yl5f#OeEd6N}P`;k}n$*cdE8WTeTKSgL$v-^$ zUCGLAK|EFYmSp7~j5{M)xzY5D)F^lyUr3F@nCkB&J9n;C`twhMQKxv7<#j(%gKHwjg2slAz4Ik2Xz++Dh6QYCC3vqNL$x}rA;R?bE5 z&{(-g3R*Oc0$>~Wo4T2_3b!oZt#J`u^leg$0PzW<&{A0tA$8AbT7(HpU(&P+*IoWM zO{egcI=^EShq#+ z3j0^4lVZLy?XX`l|IX<5{hZ88qrdb6Eq%bYkiF&}KNs0+{@u?-a--y0Ev-w{YW4Ig ziw-ebtEb^=z55!I^mesMS4rZq-dB|u}4GyeusIJ;39`+ zcWPZ^#=O62tJs4z-)Z-e^|OD|?jw)SmizA`*Uy&w+xeZdp)ZSEWbiMQc^qc{LYc>r z;h!&qyl|1mV$j3vIivgwWl*Q=G)OnWzfcCYN~sLWd8zCf^CrR0Z%*w|HVV&}cM?0j zE@Os&3#qN2;qM~qsKaa(Ylu3`x`-iSi%`YJM9uYgl9;GP{xCXQ?(ZUC>sGP0sKacj z>`%!FLaD4Gxz@i%R*_uiZ|65AZ}NA_pgnhDtXhd;-JkqRW#FrkgHdfae<0oM?_?ed zd4<=~hpd0~_wq4U?(z5X!Z^TNY99Bu3#m!Z`&)(ONw4@@g|8ieZ(zH9{h~Mhz5JQ6 zr~SSB`kK#xQ~sU5RTwSj1FS+-krdz+nu3D@im-eF#*!i|OGQ|gim)uXWME?yVTrN| z&p8qT$}q>vs6$f}U=8v4Bz%WC8o<=XK4*&7HM98&FF87B>^CaAje8Ks6nIWtp~` z5=~b?HQihOLqMV6pU(uYp)ekApr>L-1r`boaZ>^tkh6geZ|bbP=f6ULnc8AutK!3AiT!^6@swD(ooQ80ZzY z8a4!4(WbR>P(rL6Sf^GFEK;znZU_WxvpLYpfyLz_U#;5}Sj7UiaT9WziJiVNwKdSn z{ifX?DDuB)4+LuY-?WDVMg9}*8;G5OF7{-^Cx~YQw^10mILTvkNl=w=YNj@*MdooB zf~th<@lio7GU#hW{>qTJAV7PNi$ZbJuNO40m@yI-mzXt7~#mV5t znF=hc9$9tdvq2rQ$C3{Rb;!c~-U#ZD)s25Us6)2fb}py`IXh+D2|oonnc^C`ZYQ=X zoibghS+|QmoE#|ri>^@U zPIwsc1zinOQrD^5Lrdzu)Olqkb>|_hSXHoBc4%T!uqgj+&ctA^Y-8;F;CQ)L;|xAP zvvg%z(8l)Q4%zVqw+DB~njH5BcgPmcc`Vo?TNm|g@EQX36y{D}3iinC(XR)$$vln^ zf?H*5%HMU?vZ&lV?$bHZeCRwuR^MX-%aO3Ud0+mWq!+{Y_F;g*K6q` znX!79c}vyTFi(xJ>pkSP@JV_q+h~}kw{qty-;(Tn`l1rOo%1*<^e(hT?eu!7R&VFD zF`ERh5HqJiUxd9|5%zAgD2#MYV2wV3HQI*xexi4#6o0j24DIx}m0R_8nv(IL-pb8g z@6lViW0&nDRl?l$PwJiWP3sQo58*8FA)F;XL=u-B(VwOs$NT!zly`LN&!GDZg;~cl z6lNU{5wN{&*lu()d&_^P$gyS#m6zuk+T|X{L_@oL zOm&IjsC+{JCEeVy`YR0GTy6aoh7S4truBw)`BKM5#On?1a{$pE;= z&?WacUNv;d$5ela_=TZM-lm8mU2-zbYXJP!&?P@Vg$?bJpEWB&yX0eIw4q({=%pc{ zUGghe0?(aGp|4z80KM?if}Ny_T}*b8F1gk6tHDLIOJhTwWUV?jv`hX$Tykia{GBE5 zl1N%zmk|o{oTEeAAT6O@xyLahv{MfG)q~h2UodTMD8z74D8y}1C>48W%VB2Nf-}Pw zoEf&@%rF&gGp`60MI6P89!G7cSFWqB5B18UH$)MyT&k`Q)rwyxT^p(u4Jq|hD?X;G zr(St&%JrdIu`;Kb06)0*mfsxel^>Bs5h$g%gu=Yg2W+I+F=|(+lX*1pki0vf9E>&EYeo26XumaweYbpuYB#ggZeJ{ymbfl zF7_r}TNvQWVQ1-)kPpMEHTw=a>I{T}zDgj1ME}}?mCi!Bj)Dz|;sS*tb ztO&~&W5&zE0RzLE=muSM_)$T3c~W@1cxzHtcnjGalpF3Mn}Z6%!DgEh-Xy??$tnJB zstk9D9>*2oO*o#)mmRBb2+x<*);ENMHm(mZlz~lHDC^h!pTb?@O%d*Jr|5Cq9S&N$ zKfFowI35Wv6z7e9D!fqKx$wpCcDcK}nG}kz#2wTZif3295?(0U^4|=1iV%uRd^qX- za4P%C@RI5%*<1d%aIbK>pgSB|*K^^}x^fZFx&}mOg>x&8sl3AW$>sNiWovWhMZl`WkFsvYQdJq@Wlr&R5niUbJ zh#r~Ve^-Q8z!EO=2JMT`$^yn@M`&d*qp}9)EwwUjP87*!N3VMZLq8NzO%ASj1tpIX zVYdG;>t${&c_%_EL$?qf`F@03bVh#?;Ue>*!EzQxH50d36Kzqru{Np55zSc)>wJV; zzSdwc93o)<0PYEBA>ZhGB3vYABsEsCFY2kWg}kqq8LQaidW}(qoPc`*&iAg<(f87$ zjauqRk2W6V0@4zVt>Qy#YL%@RmRG1#<{E!NPOtEI%|X3aIGlc%Yg8oZwb zGB8h-L;(Yt=f_=Xs9q10N`MWU*XIH?|qb3!} zpD&n_80ZCT4DNBn7~@ARdkChWvM2wI9FyffqR6>%TzI=OW~x4^IPkXwCM>R%S%JhA$>t;=hY5CQq;J ziEQP*iYHOUOf}B)SEH@i%8ZXPM8S&QODe#~C=U~+jE#a47^`n%jCHUk2XDU)E<>z|f*!3t3iubAw1Q>&F_ni=C}Tl> zY%^(NR+qbJ8|FwG^V4c@$0T1L)yDjuv@OcRq{TK94|8+mjwr40?h5dm7dYg=QlUE;pEMV?ezYd@6C4lagagoq%j#k zo1NI+!fuC`R7Vva2enktRY7u#msyt=Y^h;3V&G1-(s_TMjWW zV+O0enU)G#lvHGCVji8ikN1jSrO&o_#oy8wSdJ>L3W_v42liAfwK#EKgq@xVa8oyZ zbkRDCTk&A}^%l3{v&iihJzZpp*21`ZhXv-A@3SPK{hWk*MUrr(qk=+xPr|jN4yJp- zBbE*<1D%RbCq8ZIRNxG-VtV;@OPAuw{4#B~VygNDi&wER;bn_g0b@h2;;MwVEMCP8 z`R`f03K-}1C_Ii&EPCarG2dB4<+1voEuylv{%4DpOma@)Bb6qz$VVz4xD2o?fRBz> zM$I~=idW7kJf@0NMvsu9VXa?^t|sF`!lGYgVEzFpY0*|?XIVkCRe91_7+tE&Tsl9x zROuJAD!Nqpy>5MUq%ziK}R8^lu#qu*@yFgigqfy6Lc~8 z%mhPNOgp>8WRA&amYA|);+4b?;u93|AkAl*60>63aaGra>kH0433GB_?fzZ=X41}n zWUi+X(mD}P^hR_0OG&7{PtFspV2hT&Rd zK09mi{+L$o4XG`rmFtq)Vv5P^q;E)}_)AJUIl)|4eIwhZgqX(@ptzMgC%qig$~`Tm zlhe$>74OBIhCLOcTdA)4Jf>BdweVa_r?RN}`?)Dg3#Dj<>uhSY^RbEkm+$G#sZ6`RNBK@&O{}QuPH2qPsy?W@ zEmp7cICjNqRoDxwx)VIHdR2z4+hez$`Hq~uxCcadK_7CqH0{|m^e4r zl3Aub&5R2vj02rJ;zZT$L1l5B%5O^RX&Af0e^Xo-?i&fjl4e0nVm*$QxG;3La6eCY zI4+RQykdWx3tKZ6wl*$oXG&GKt{p*2RmYc)Aa>lVAgaoo2je2eXGXmhC#v=ao{ps;FKq*DskO{bO1<+re|3h4bs?i2mh`22Chd5ESd;ZUH z6)JPevakwOA!5;rWnrQ!dV^oQU3F-cA>OXK%?7w|MO3_9m6ZaxASEikLRG2zA^n|yPukE-lGuP$aO)b{fXkx)0kbHhM6YVUbblgMmn?la(X0B*A9xy8 z15avUI*Fv$k5GzQ^{R1CBx=<$OP)=P6h9yJuuQAAE_pdot6p3BY+}3`(h{$JY{_eh z@#>cl?^^O&qE-Eupf?k(>Tk=viB|Q!i9(W9{fpC-WL2*Zj!CkrUr9(x+Q&iQ#Q%CTZ1)6PuE>>XeBsNm{i$cz=>sZC~4d6dlIkhw`ToQj=6v0Wq!Sp*3HIQMFOxb|9>*_9R1N(pRWDBfUBQm5 z2KJ!}d2%V{Zv|?jLLGQznYKbbI{YS02g8oE@%hZ<;hN+QCIjiW7L;kLkiS%IumvZ# za+Okgd>7fu=_ZwF^Oi8;wt zh@Gkj>++I&l%Fr2kQ|093r*_3FT6Y%cEe6pxYZuV70GUO&&>K{x0-isOm?fm!UszR zdYp#TccRX)U@_|H18e`1>{j2fvNhSQu3wqJG^vl2JeJ&~z9Icka+7+l;k9I#=ZCS< z-z_j!`nzRvl%9TNnH=R6J2sq7203d30dGkJX&)z_#Z;Wd6g8>8T`Q#YD38n!NDJLyb6(`aF;N$2Pv)UYVmA}M}2O^Sy`LFrd%H8(f4Q$5dYi0@Qy3Llr+r7riMlG>$yENnq) z7nV<3uYS}gQbv$2b(?)kDwQ72ccyl!)0b{d?NlF~duwW^dWN~4x|Oq*-;vs)+@7;L zwMRK)+1}J1<@<}(sXfZBsC}vJs&@a^g(MEQOzrBApaV8Rp+pJ*tOe`81DeRRT~d0|8xu&BUVuzr|AH40T$uw0(vl ztyBUn^(fc*r;|4It)Ve#oyr%=%FR8>gLT%l9%cK4dRifQ9Jy%~(!*)*k_zca+V|!P z>GZ-VQX%~`8gS%_r3pRCob?maDx_=UW~Eg~-;M@fCl&%epLS4RC3zgJ$vw*8jIy*I zruM=*QfO;A6hn>?NLUo*p$|zyw(4vv>xTNwRflG zaxenwQPw!0NNZ9bUiea4g>-)!q-cIZJq64nZR&5FpQnkc(7bQcL{(xQXKhoToDgj7 zRF;itCdF!xBi?G4JdR9jr}ERXdfKM8Z78tTNFK*TE2Oc+>XbZ=`PL#1>Rl1mk+E0? ziV(+gP~$q4n-?^bE_HN8z11!KwRWr3ENTK^H*$4} z6;e=9Iw@8|`&_KntcoIbDYppt-$*;CuaV9ytfw{7U-ALJS_r>BAl+iMOD89S9h@eEnA;X?9zwG)3xdjt6f?#`u;?_ z^iJBnR=X6h+i$f?g==51wyAB)j#+16eRoR7G+kDw1eSnHdMn=$?~?Yc0?e}k&NF{& zbx9{p;EvJ%Vs%MJ3x#x-ls9Qys!Ou3HN@McX?{|=U8-LhobHqsj1EsP0Q!+^uuhI^kq^4qoucG zPvES$7NLFjs+@t9BCO;TAeW}qf32k(S(dn^}6t8B5FL2Jpm0^ z#x^5vL)_83-%lf7vNB^EwtL$+$fIo>w8=a1*PWax^_q-r9FzgOR8q4!qfPyx>CYLF znkN?Do)M|JD&fA2NR5JcG9ooh{u3Ep>T7GC%V?LT<-L*7E;XcP5m9qZ-l+^xvo!s~ zj0R2}`dLN;hSGqc?7~oXVJHn4im0jD@GcQG_avOkh}ZnS{>O|i>Def`txK91p|#~} zni7I-cFpw(;WoP_uiRp@Ydns4n^p5@l+|X{?2fw3X4lxt%gt5|ocOS6iYC=lQ3G`i zDEY__IJSJE&92cGo|V}(pUS4$>>8=?CSKHdgJ#=A%~aW`jAG59@Flh`>8gZvwk~P+ z2$yX$YH2gZe>28^55|8F#(#5P{Ig}JGIk*64&>Z{ocoY-A9C(M&PdJDC2z%bNs3u_ z*g%t@&mUs`YKzo7o&1okg?oSG<2E~!XP-#&nPBM|TaENYS~Jw>+;mbQrK~77Yt_1d z7j1TF+cL`yI{fQvVWm zJhNLpa@jkXUFt9LKg{e>=jVKu*`;pLpUt#0Q|zB*+L_PtZjZ@l9x{BN=~gaJ_$9Li z=a+hVXPH!B7569)hC#0i6wd&@OGI=Bq6HO*7Ssuh&k2m19)EQ!qn4?%TpEufB+I2a zlJULSt=wO0%yMZKS(}MVqh59_)unkR%wbs?!YH2i?QN*QL zZGD%xG{4%wuBfgZm*vvTt(~6b#yoOq7S)zyxixR(&d+jdO6Nxrx2AS}6lu|nl%BD< zHODkdv)r1`%hQQl^GileR+|#%qf~&0xVy-0(l!>5Lw6avPoVoWx+~e(fM!z3!Yp?e z{Uq?S%(2{tyk^qD2v?SAJD7E{nB-DbW8BSI`E28a9f-GQ9mTk{X*`a5v)VKzW##5J z&6cuqvxP03^ar`2XPw8M{VZ3%@}sQt9Gtp3 z%Yi=g*$-0Qq;?i!-oQc}8rb<#J&ng*=%wlnd1te#5YOYUHpD>0oru3+9)Y!)PAq+H zT=B>HZsBmnp97n=N)=koXTvV#FLfLb@lo;6Ge(NK(NWo*nw*Hl>`u)_ZBBLz_g=~P z>@JO^YI1hBrnS16c$r-#^RnSgQ=Q2rdrnxM?Pb<3x-z?kE6CfB9f;DrO!R!1$G#)) z=Im~b$MNUvZq1Lm?(A;O-U$W+NVpSy?alTo=cKk}cPb-l(__8L8MOzqy&5*9BfCra zPE|9Blst})vLmIygg8nkLzDBBd*qUzXD(c^m~fU1%eLbySkm zrFCt!`i*tH3?=3x@+=P8`la%S7PMW0cQ=>?eR8j=)6~Tb!BKY;P;1xMN zny;6y&2dVvmjlo4*;nN_rT5CO%jwZf&H$4*a zJ(|FIfN!p};a+;*n)R%Iar0*xbn$sl32fvlmqq!sN-JB-rK>kNL zPRV7R%{EB~{U9b_nKVhym4~NCN-#zc{jN&*B1iPwvE+vw(Qob&&TiMdn3*ivHB03C zc)P|ahaTrG**?BUQ(5k3@6jyNLRz4`vf-%Nf_vj5u{Dd7phdD_`GnTnZ0}O<&rP3(N^D z3+xHp7Zk3u>8{jm)@=#CHbkwzPXCtvd;QyCVa9RBBI5$%65}n#oko9Cm}!UU4%05v zL#D?~Pn+6JFPq*lb(*?N-KL*Sib!Xq((G?evcyI|A9Esxiw%sm#7>Ac$0fzp#BGSX zDefa*Po@hzDIWZ?`QPSF^hNLq|3CVv- zKA&t(Nk|!=vLvN8Wlzd$DVEfl)Q8g+S*xw@SYy-E(tFY)GiGFzW|U_v&8W-pW;EJ% z*pf0mnNMf3S#?>PvYyF0mDQ7_&JNDLKHHEJhUdVC{@K(QQuN~h6+RT6yaJb9_1$~b z|F>-FaN5b_;WT9|pq2c8?Y(VmB-fcPcB-1yUENJ~R~1c*6SK#ys}=4}b~RTs`mhB{ zLV7qHsaL}xhvbYbFN9G|cac+fnpHhj)x)9T#T_=;!bZzg`Iy(NC3UDf_ZyQ+PlUDbX?|KHQT z)ZP*W{Vmbb-x5F5?`d!ApO${8-P3l(J?-!Gx5P*KTS645BivQd6Zt-uXN5#`$`)`ZCnYd-#)1D~Je*LM^ zRjpmRs=ZLUsx6kTYD=Y0OD~qLYS&8lG`Dn5>y)l)8zu38Upn!?&60Q^EX5D}o6>{! z8>L6>zbrjy|GUydvp*}X&HhSzOZ;`|p7tx_x3hn1eC@rtT-6?{T-BbcT-AP4ZhiBITI-vhhB9x7-&C$@e_#2{#Q$0O?mzp>(r+fT>e}pM z6Zf<~uO2+~=hYuS^yk%2OMhOyr~MDrtJ-_jtJ?ouy{i39^}-)`+J!&(W%a7|LG`Nk ztLjzllj@`PcP1aSpP78n{?o~;+N+cIw9?c)?LU_8X%9`^(@stOX5!S;!9#DB|LPA< zO?_H=v;1l4y{UWJtCN5AhdJL9EVLpHS(3|CZ+P7-=wC~jJX>XPf9{N`8;Gt`^ zds^XlZ_%n!A} znLoAv!`YYZ|8TZr|7PMt`_s~|?H@n%clM7T`pEwALm%2de(2ZsUp)SI_Fp{yq5T(+ zx1D=h+xfKAc7FU&+xd&fADbAQd2Hg#C!Z*ZZ=IU@i^uG@t5c+dWUSPr+&JSv`U2Y%waJ^%aOv)8X(JM-wH*B%v5Uug%) zV`taG^Im5)i7tfE3qiOQtZur!-lf6jbuW5$6R{?tACGbst{|19}amWp0)uU(8yw){D;#Iyb!}ZUx=7$lu)bgAHop85CRy z;z8uC-f$zYdogsoUKe>!P~N$L-y`s`vj8u;ancUD-kqiOt8Q=Lp-E`Ss=wK7Z}xjX zc<-=05%{6mRu=0o(J3a&-l7s6=MO|Y1rd>lm&p;mk0mMr|7 zmn??eK~D`m1F9!n-;F5TYOkLk^m{(qybvVO_9b`Ii!s%YidGzZo7a237hhYr)A4#M zp4;`J(`eCmuALE2Kf8VHT07|Zfk$0^?3FX(s@F-v=*h>^2;E0GSt{7VU*4!I_tSn=LT*6_?0sPJA2hjFs9TgZ)4L70Mg1?z2SKY#t<(iQ5bBT z3-2Ivv!86!3%R*j^fto?h_>n|76dI$AmbNr_=&gLcRSuiH}M0YWTMr*?e_i0&Wabj zDDZlZofYjM@uI-(oqo{|9y=>mx8uayJl(#Mtk2(Yqo*Z|^s}eu-8fkbnNW*#V=*{J z0ngrVXYhLX&b70=$j_!TMJxKOm=A+4(Pp=YHM~aa`K*}tA`D+5K_;n#J^}d!n_E?9 zd6>sAFL>MZ>ay!ofw<%Q=-JpzofYl#KCOB;+J5D%xEOAn743LA+&a|B$G*Cg%NyZZ zD=j5>LxULUiyzHi&nh z_Y&9d#m|ZhZk#NKz20;4*p;-pXj7i2rd*tFpBL@UfM~u4?zI*YPL*A_<93qsYB5~` zE%Q6^X-V79xv@7+2HNo@_fop{mc;VP()_~e>e9-!OIH@pEvyJ)o9Enad(++UFrIx9 zHNxom6|r!2;nLc*3+;<*3oF;oFRae5w3pYGR>bPU{FRmV+Dq43Yild*b63_DR%O}t zr3*{f<}bEZR|U&F?I2FvpyMsAugT4$7$Go_4FB*m6j>5hdCEH8U1>fs&&U0RJ96B9sf{2RcDw-ekAhD?m^5DuK zNc>H2eh??&W-Cb||N0=|EUqwHBu7>ahTU_!R4%Rih8qMPgIE118MwU_FAjSHT0c~1 zp1BF+3PRA5xZw8^FCth{&>Uth+QtyBcpHA4MBBNX=e*=-KVS5d8|~n>8~JX)+002H zbFH{rgrP|QZ|?QH4$-l-UQ7g>+u$WP!Y++tYjDT!`EJA+auKe0>-1h2oL=-2x65q_ zz>~ccfp08F;cdU`MMp|4-1dTGtmwjRw>O}Dwwkz|o9De=_Ps+iL%bOI_)r|X8y?S^ zSP8f2YU}&0zVG(p>uS2Wdzb;p)R2Nj;BB4W^sjdWlc4#qH`okT`(B6Vrxi;=woN#> z3%oe-y32#>J->sg;C+(#)Cq~^k~v^L38OJYJH8Yq5?0$@PD>cXrzOLN2pG(Jz22I; zp-_)Auix^0=qhp8eys zXHP@-{5@5A=IpogmC|6Ut$HaW+yy`AE`*&fwtcJH6-TvTaYz{ytZSqozp-#9@q(_` zU5>nU|4y3Oju+iK=e>S%13QIK&v^;v8EUY4NwniE$r`}9aF7RAy;};i7HyyRz+O>9 zS6qm~%|$nGH@vQx5Bu9|p@NWeCD@eET9~EfI!zMDrCo8}>-o3A3KIe^xlt5uktnhj zk_2!~jqJM59grhV{kY+EZsOZa6eYA4ZA+;Qtx(v1Fb~dNUklaCX!N;WNNL(cGM!55 zP}{t&fNZi5bi%G5Y^-{OYR3x!sH^Jv`R%~ngaU;@-0b_v0i{^#dx0EA`cf(uiFSOL zX~E7y87jr3Z0WWa^`Nw&8Zt}Jp5$rF@_5ml3pc^w7;{80JB{J(GDYMhSxRVW3am|; z#L6Y9dAaBMfkeu6?6Y#}b;UVv!w*)IP`-gaDu~Sh^Sv;Z;~*W2y5^*LM6H*+dV`e$QD@DIHvNDKwPOf6D`7})NCI{xxHTXIHz_xn ztAl<&jIysCo7Ik6{eEwoO+|;_V7lRdpE={9%tbHpqFhBOT|AN{xx%qU$4f1{Sj2Ld zAI?-vFP$RDg}oj%ZpFJb;7mD9$L2{bi}pvN7Sp^JB$3-Y??=?z?F;^$Y&5;N6Z!ol zjN+`(T|t|0%;KfmM0z=-6;Rfm-xaOMQ^{3W{9q4h#R|oZO$($Jlka&^xD+k=!604= zy!Kdms2Czv2_@Gv5YmGzW?ru=*1S6^6>XDB$P6vq+;ju3M5R}~psUj7LzP+z=EER~ zAQD6wj<~D>c}&G~^Dp>8SAd;T0xIsBzv&Us>UNbd2J;Ew)cPfWMZ)9)h&pLFi=iZI z{m4tOB5sE_y=yExQE8csvn1_bxyB_5BI+1|$6LxU64&Z>Ws1L&y*ux+rjcP_IZT1o z6j=2lS_ib;o*TFkQ=}Keh{-1Lgep07S98^>F2-VMgJ z!c_Sf8=o{67BD=Y^O97*NUf~EUJ3)y*kwN;jyRTg6geMmwzk~J8#C6D0FVM?O_yob zIopAPiC=|-sN>PHl}Xmf&TS{2L_pdlTa1KT-7al72{Jd7B1b+WHSFe*_$F}~aNEf5 zGUh_$c_{*f?Mm!LV>PfkJA&SC?P%P#tsR^X*^p(IT99q28@2K|5WUJeiDH;)(PVF(QhF~bf zYA5V_Ym@>JxfpJF(Ul-}*Qx0XY=)5tCO)M7N$SJt=e&d}M8_4b)$Ojh!3I9M4T9kZ z8$C~Am|xHBi14q=);W-KiHtv$b6nNc}uH;SSe)DTL^CZQ5azDU4@n=zjz#)_@}gs z(W*>=ahsW7$zQCw8;~(GKN9TVkX0#-5n*I}&QG@d*n=a44DyoQlYq)%6xWwuUvds% zJB1C?9mKHlh;|H99mxF(*gPbCTDoKb%Ji1=;UF6a2|yx{NibpQ>>_$|NX@tqMyuOF zM;4&zhWsO8xnKb2MZIl5*x;HEe@Vi;G_uTG+n|8R3}}Kfc^)(-3I{RK zbnbqd!v0s~Jgk#+p$pavb4#B_O_>#Q14T}-w1;jN11rAUi?LR)ZV2Z6MMRkAI26sOpC!)eIcOgymqCC*Dg^svfPV>53^B%bpm7gA7W zre{Y&>_z9jb$8Iq=b+AGGw9=x{4iZ;=H@f))vgX(p41EQQ*Rzz8iFxBc#drj|6#H z{B;kDhZd+8rQo;)PUI!|r9sjOspp&5{Xi|-^Px;$LgGr`#T~cr38}P@SdvnYR1_Oy zCJ<=}x#-?hQ7;6KsX8e}8qd8hVKDHd8K?O~8VO?NMWrOYRj**%QCe*~l`#(SE+VKE zd8=?mrLD-QOlAS@cJt_QS_BYwF-l(wgE2cn8gwI$i^LhzWe#IAIIR>4uj>c7tW4x$ z_tr5lWks^g904EGveM6=_c}cntWx#x2x1}V^ul-yG1$x~``Gx6(R`jkSnSouvOHd- zszyBFjKDrf3$KMYy?|Gh^pa#4UPTa*Qjn!I0*SyYT|X_b>_+Y;Y(jD{lsIxBWK1j0 zH-|JHmbn#rK8*4h)cL#{A1ZVRgq`dVYS|@&J$DRk22D|!>=OjQN;lcXvMxx^pDMue zsuI4%f**Umi{7RmK~TLl!1J&-h}dJ!0hr?2oxx%B@p7KZVvUQ_%nvA1i0e};@nRU> z9P|lEaV#+q>&43zZAZO1~3yfUa=GH<9vWP;-b_~TX@!=bn zItCI;LC1Rzi4{*q0;UaOv6BWblIbkmP70;xy&i+`g~;UMpqKa^d?3qQ9Yj&c>C|=W z-PeQsJ->gt9jwEPDMxh~gVpN{2(=vcIiZ3-;itl+nXkB$c8UFNGX7M0qj;e1_nhS#F8mzD9gHjcds(h9IS!|;l`Me?>A zNSxwjX`Sc&+mg${@)cu5Z+qee&+E6~3q8ggr!;nDD&adBN_+WivpRtMKRaFZ;p zi~0CAP~AI7yTO&%83Mzp_2k>Io z>v@~7s%K;xqP({*u+Q*RD|W?OUy86Lfm}`S-b(Jk~F*z?>#A|xp}@4Fj87=vM+hAQU= z8^`&(0U=8nyou2QA=mS=7Z4bagYe7wNh|J1I3EDO{Z@dqyArJe!%Q;}tr06bIkj%n zCudkI=(6ZfYc$umoN(qvVSm+&Zu=cCKCL2hQS^5ly^xBt3votAp#YeASQRteEub12G=x zk;?#wSoLlpR9^Ma-d2F;H z*B^Nj5p_=PJ&`h6Wf+op_#!!_$hW)cE&A~$;fO$YB1*@(^lB3JxA-mQZp3xOm7wo- zZa(jC+z>0?4L81lPWrK|2h{hRcf-Byhl6N&5Yx-_Ty&Egd3zEM2A%Gp&&nh%)jLV# z=CblIlPH?O43ixcNv&_W*K?r!z8at0J0LkZkSJHKjy%ktz-G-Mx)&%+d<2sUO{<9;FNdeW#-N=C>&&kk`K3ze4k zTn1Ke`0L4vzi}hE7;Zou5$)`#m&k?iN(L;W3=|{-Hz))LF~z>yV`suDLZzUJ^7rJ5 zQC2PmjxDGAs;r7>A1y$i`lAZ zbi~^Lf@i)%p5ETk(A4Zm-BO=Vzs1Th$LDkY_*h8Fzt5fy``4Z$>JhEoaD%0YKO?1( zV{)WFC6u<(+ZLcTg2c(!1QIdUhuHTaZUJ=CED~Zo9wt77w?MFzi zg^=tK(N9mE{})gT&jU znWY4;EV1fsy8RnrryAV?HPNY$Sq`G9Wz{f!+>;+$bWq75-715Y7AJW77Ezc+rbv zsu;7w-2S4UBt35-==u_U6ic-D_`)ok9g9BmgGT^L%~a)ZG@OF4 zFA0#*yAoQx>G#2X2YmwB+rwU`xD^vFXE%@-Wkbx0=Dweg#vXrnEJvy@N_QEvqpf)X zpJ>F!^pY)4=8w76QcocPc|I>~E}X$jj??+@X2xXREdp6nqu7A7)@wbqGKuim>cn_p zONvE55R0xKNd4_R4zf!90tb<#VnIoYgpz3pCo-8ow?t%;BibZ__asyck_90U@w9po zrQ=+B)k{>8BzP&6kQ62TaNfE{Mo(o0L=F>44sN;>CjL6?_M9QDT4Ov^nNY@&t5^)! zz6>52V;QU>3-kUOi(Hfk=#L&$LtT8H`a&4x(6q2KX$@zVbI=hFes-Hhj`<(TSoS+N zm;Kve(h4wmNm_i-y+f-vUiP9agT-nx(2(IGAnYQ%U`NqJ-$3W-R1KL%j?0yYD~~C{ zij6uIkscNmN`T4Y9FOK<@gK!zm_wE*jxfQI?C!!HuR~`bvy(5-ljX3aELh^yav@0} zD@R&z29hQ_o=f2+<0bN_!>0*hNEEoO1W(eSRNR~*@&X4J{K$)s$$$|KvagawD0p`e zj4{2nFqP`00KXPu2yPAh+is6Y1sSZ=+EfQ&)e$@Tj*@l_Bwn>g){0YDZM52doCJcp1htc{`6`Ct_*6*4+I)F8dC z+zlpPbmT)7%OCemzpCUp)z0O}>-gx~QUt9*-V$=5=O!>#CO78YK1pg~b<6F4m46Z^ zIyU%Pixxb{x#;=(txT!L zQxejSOwPx*Wr_*Ma^ywQv*&eFTYyrYvP%INl=J?47~V{AZ4RqRVRf8hObg4*42RIk z);Qw0RwY|$_Va`F^-VV*LzBFSC$)r-)~JxmH;Uz^a4b%bp;%H=kmB45R)kc+px(4B zNa>SyPqFAl8+87kWg=Js+W{HH#B$_;{qX6_)b_xEkz`rpj6(;4ayfJ^^jx@^Ly=g0 zAsj@h*FTmwhO~k%DJ^TEEXro$wJpvj)-BD&_bG~Me|OoHaO;QO=apbc~*VyaA> zWsdY&@IegtVb$pX7T6=45IOTCqq5t3a_2^J$T8cp_^La2grtWw$O!$CBr(T~g|h8PC=DRXau0nY%Sc1gGL#Gri85rX-$#e( z1R(s(5)F%^Odd4|rx|&}u0*o*B?0CX3drJtDAZq8!l#19Z|_oA)hEhM2bFdL2WDTv zm0seAZ$e$UrE-8<^@7-EO;cv39@`_u6|8&~v<_43*fBM(;qYBw<8L0xy^m3wmaa@H zVN`;7aa5v|8GxyDL?Tvd4$TKKfeyJHAji)?UI?RB5GFUgNQ&JlAsE*lGqgHQI9?EU z9>As-Wo`|HAb+t(;UBy(!~%9RxCnNX-?Uk@GIaJbuplVElX+J1#}Fg4kaR>0qG}dD5#!h(5r;a3RFkV_fbP22x_?J@3W|>yLCC z5<=)<6bX_*GN>*(VAJaSAd16C9UTp~B-{=D>K? zPk%+R{0(qVuf5=H|M42Z&pvxvhSJMV-PePa-PZT?SC?4{x3c;USAR?q0S|zho$(yG zy4n}zZAD=!+2v6X?sH%&Rw{~;WhrS3wm|G6#-@iU#c9lhINhPg;oH})v3_w>Uy%5( zh{60FML^Ng+7Z@)(9VwUOB9SxE8coe_Z5p_;L8eCY?`{9A*E_L6kUzCoX`M_D<}%{ zfl{1lMsuO)kH*?9ssW!#8nCT6au**x0oa z?5yXTb7C>yrHd-a@Dis1z=uHT?vq%oGMOYJ@1erdiW74n4W&3$KFrC zZ_9fIVp8o=fK9?FNYQ7QzZA0;j>CK=maiFzDHsnL0j(&kB#I+*eb_7MZh`oB1g0Ie z7Sg>pQJPQW6k>8X$WHgi_rG3_WTIFgCR3%ecsC_OWYSaC0I4;^>NIDD;21GFQ7*4B z$>%)p#`$;MZtixMnD4`(RmCtLx{nnzmp5>wawGe(vsSg2POrky?QQrTjlDV#DtnS$ ztf<0?WWXgb>aLp*7T|4#g}>ELkWx!Ya#*0SFms^vGIaS!bgaaACCn&~YE0hQ)xmW- zgB%~a-Sl*JKsOePekTg!a6LID4<^qeM{>Diq>84YV~oq4icI@@%$>o~wgyWW?;<+c z$6d|N+{Mx^$Z0h)oKMjiUbY+O-JnYnS8n%>qZN$lgK#ZG0o_iY2x_?7R!$Gq#P0PNIZ*)6;p3WF#Vq2fAVnzzRNbb z8?lN;+F%p~2GADZR9=f=47=!X`dI}*^P8{|A##ckDrjsBDeVcZNW#kMkh`s1{Epkq zQ{kJX`FuSf20Vf3^o%2O^cYe;;gnXBBH*{(@B;*vEv;wqHhJX$s`MTD#$6U8*ou72ueWWl|k3@db7<$B@W0Ej=G8juD^(*db%2 zy*OuaW$uP}9_Weu_LbPX)Vn`>{%%h#3SS=SsF6k+} z={Ne3GxAcG@Db@Wmvh7=EV)z9!(l>zV>SoX=Y0`i>R`4(~Juk-|g{Ae>Ye)UaL2T&t?%)i%WaS}KK|Cp# zqAwAYkoIg27Km^7{o}YD_*%x!&wJ}BLY=0`5RwHlLdB)BH!9BiTZpa5An6=Jd8kFE z)i6xMjCIT7iv5+k7{)vhvA6`&HW~cW)x%bWSo)t+h=nIM?#1#VY*0^ykwzh9#8zln zPSclgqMEc}h2i5(AX*BeY;Ou4x34LzQoj@|l`92ic1x^>I8AfB%b1**1qF!<2swe# zkUE~r7z;BxCY)%z-g#-xqLP;eo2eNO3xeb9*N-U-N2xLvscow^K`Y4V!SR?Elmy7J zSYH}uHung&e@Z|Z2`+|l+yjE{lOr7*9d32Ikga5xmggmk>vMWO#4#5pbxZ5(F>sf> zt#O2s)6e+{7SOui>rrw!?9=mNxJ6G2_r8D>Z$c#|Q8;)CKx(5XANWQHLl(h&`g!k8 z_NGkHf`-RC>_XET^c3!*pY-4y%%Qm93bjfTMpPq}7-jIKq(CYNm#9Jp9TTPtkvRj_ zqHRSWGbC}w3{(Yp%Vqf7tvabwQBITBTjzywU0pea#ovjj$*qX*XNt#fF9)et8sdB z5|)FZpqPB$5?XZoG9@pYVIaMOfk|2?QSqWUUj~r3_}B%b96F^gXeR+pqL#xL7i_^b zAu)9Nos;vCkCW%Ulztq23Bes8fij2BT~UA*(Tp5=HSpKhrDa=*)ML2ZT_n&L~SuiBo6^B@mCzDOPBr4KGQz56id`DIMu;iYMLnArb5W;nyi6x|uleZ0u@g3nEGGCLdUN=&OOuPM$IhG$iR@i>?& zZ}Nh`tk6*`U*I8u%;riU4J=NKfQp^-lT8;Smm3e4M2xFUfx&ekBJ5v7IGo?%^uisP z9`lkcMMP1_n2+btyZI4hpDqXja-nK@;g~c5dFMCEfJmgSN>O6$)(#;Z zKQw$9b`e(%QT(Y`@q$D;tkNY(j9uQLCM2hU8;l9~@BKy=J__M)(3yqb`^_xlwJ#AL z83aCT+>7{2WGW|jZX&fKLdx@Qdl7BoL##BJauja%shUhykuDevY<58d zL29on*1Q%H^gX{%PZ^UK@#Jwz8GAE)1`&;hP|9FCrXvTG#*q`1+UC{@>4666i^8}M zYIxg&jUaRFN+^j!N)gpqy@6!@Y(RSxyMzc09l+-hJ)&!f>`5(#3Lph9pF?Ax&&?3H z?OSOOhZKrbK}D!dW@%PXX1gZLF+pG1A8C*wvsp#z%B}RuSNV6)@+vRE7u+5~KmvT> z$Z?sa0qdQf?VX8yJZ+cwD+l za?v(u^`B7u0YR9?c4u&3d38R%t%N2f8}aS?9*u+o+3R^dl%YQvA>T#38K0Lx0zHvI zX!11v#c(V80|+bOR_5|gk0GHfx)_-zk_O<)?ZfpeRHL8kN;-O`S_eiuSEPF zB>dcIkaO}%(uF(YcS$gOd~EB&9kJqVfEvBvZ7hE(KH{?A;|Q5}})I`sC1h!1FN zD|-qWdM@m4kDqHMOm-m~Ahi(*keW2>LxbxoiP=M1aap9*jk`!o|BG@G@GMBRU-pK{ zeTvL>dy1lKbyJ8u(J=J}vWiS2NKwm?*X1Zm=}V0%i~c?W{yJT6`*1LSPH@OY^0TQ3 zoB&=LgkHGO_F-zy9i0dZu3rB~R1AXi$H%4rpH=%0IokVARBb!(RK>J% zullin9dR7nxj$r^LsB$1M|u$OZI!<2MgBV7MX-SjL)gd!%9%e)zZ5Y_ovmg_s$eo_ zVSSw?>fUxfTM3VOK~f0ujSC_ak=PWjNW@j)iAco47oiA*D4h|f#iK$POClDc{-W?j zAi5$HTY$?g5s8ktAw=npcv3trM14gJL?9C3i%sDPQJ<%}JuxqOA`;?ETLdB&iHO92 zN}w#h%ilXsX;-Y$ZzK{S9{Gp8u|Vw-4=sp0q9Z)f6Dz_KuIN&KP7BqJ?+EeF?^ouM ztoJsT`_4b4+_KmZp;!}Zs;(z(O|NX}w{2xDC`q6)9X@B3m@rfu1p_xDUUq^rYKmNPz|FR+q zTD@MV*9)dO<4oy};TRK5O*1P^T{CAKTbmYzrqCvunrTi5%`{6w%-9XhG=x4oJ3Aq? zYPmS$Jghl}DFhPJq9ioSsN0ViO<~zj6aiVYMWJbR`w@l=LugKG)@W*4T`M$&ZZt*R zeq^F4oR(>6n%%ZeiiwKmpx~TyxhV9xx;9r7sAA3(8d_GHGn*QxCWLT&v|BHWf_->iV>> zrnOR2v!=CaF(EW-M4F#%?Wzici(B$OrhC#-?8t$Q*IUr+o+X= zuG@Ftw(q`O6*|V`ZTxQt;oN<{)TEg<4EilLb*-+MMpM`3W=)|vcR#T2zFnGe?tZ8_ zMj4C8zWcUg)S9|B>W*WjEc}^DLE}{QPS%6-M3AnQmNZJQ+3U1mQZQkP^elPir#E>6a9}lH(#5?*DOs(=>_eisGk&tidHI$LakP-6-7a-nb=gd zS`ABm=L-G4R1yWPF*iF`=CNp;6h%?cYc(B|0Oj?XjsbRdUcwgDYqf$9Tu<2)6K1Up zGRg@JQ}E55TUMb~Y-*UMvY4ouCQX>oYGn{p{FKY3rl5iqOVjJr*fI!%R<55EM7?xL zbB&s%(X`T}o)nX6&HyOtgZ1g`yf%vkIjdvE&eG=F`BAZn3@jTw^eJJO&d!f&rfE4l zKf#7Qhod!f)2bH<|pOEb>S+nQq(g*GjmmeCZ>&O61XHj7=`)HKU}26Sq+ zgipWcwD4>>JMWc{=j^;!YzoWS`2}VF(rJOrUp9oMQ&slP2PZ{Qb6VCngf>^#th$Dj zHb=jfVd;~WRv-a!JdmS6!=Xbuo@H1$Ggp zzFE+Tmht{K#e_E7Fif*zX-J(ECOEP-TPtDdoD{V}MFYduO{NTn*(_)cPzGuuP}8C) zbhBZYV9n0m*Lj)zJxyNSeq>5aGz{l5x@r$+FhIix%T2A}3?H_Kr@r1S&e+38G-vp5 z&9sN7?BSV)b6K}VQL~4qteS4&UwimOrKvZZ;R$>AL~Z6#ZK82fSToM>Dea^H${s%B zJc=PBjLIp|(=oV3?@TrejcHY!fto#h!X7?T#>SwoIL!&5xv!iF@qnVcyH_Y2+2OC#I}s4W|9D8f*x3= zSrrqFIcMiR({x%7h@!?#Cydk58K?-oVGn--91Vb6`0tru;g&so1M_VU-=y@UC~C8& zX_y!4J`4_|k7 z-m{1AI4xZ^(sEjsGkn*=@SFDVooch-Ts9kORPEuLM5TUSZ5Hfj%!d6;!+wUg?#_F} z5J2a3XZYTfDCk+OW>d3=Z`(uAYkT;%InmS%bD}Bgn$gtkXH1AM!}qZjXj$lUy6%V) zDz#>3t(v8ySr}c-sv*PPdEc6ywTB;Ay4|+hMN0#t#DqW&M4N{n*Qm|)=0uuq*uzg| znnl3a9mCI3tkay3%Zsu=M3`iJjtyU)v4>xfR#I$gSoz@8!!JShNj@%sLl+8#VpBKk z1+&_mz{A-!EG&h3pZDIi6j4md=p_lRRL`{K_#cfggty8TY%OP{Zs@9nA5U{?-NI`hwo2;P`;0n zt%lR8G}8i8m~~EqpwmdaPc5`}pR$Li=9pYQ>WKfY)hbIKy|#GtTZav?wr+&?7{{*=>=u-O!v}5>R#*u%Dgb zyG{#AP+c?a-L^e^zYa3E+d{_hC1-cxYr+_VEoZl-vj8)Ew@4Kl5F+e`HVwLMG$GD| zo7OQ4wWguX@sgf{Ac>AG()TZO_fC|l880`SmMtc-RI@NA zi`v6e#il+tSD&qm303MANzcE>fC&I=Fik9qMdz|PDJGC; znRT;n*1+_4-leo|x2eOse`XJ7O2p%skJ~$+(PMW65xVB=?vz1uc3-u3J}WmTkb*dl z@q3MaQLbk1j%vjjXZJ_YJcYe`4@}&-T&q~RZZQ?ER`eNr_a_?G!xRzdhNRC^M3^BQ z12Vv%YGzY+E`!j39oMXyRfB2{*}GtpglQHkTCrm33M^Oj;*7ofI*@!Kk$&?!Ow+RVJ)Os6B8^Nv0PLJk!yj*nV=ypdkC@F9xga7-tJ0s0hJ(naKbW9 zR3TSfwuh%`daWi(kU>~>sp&+DcHgjvr|QiD)0^El&{L+TyKfRbr9yAo!Uz;5}e{S0>S@D%u=v-1wxc&a3{hI83z!Eog4ZsQZQJ1;wz&8A+nYWD8i zP+>tsvD-WYco|hh(b%MZk#A{cD-N{EvFRIa!RJOoT0j5)nTeRQ8(&^6GX-fWhhIv zDlOM4_8JykwQA`T8cJCwPSmi-QkAsC^KDF$KDwYyi~6)ETAHS3^KDd`Bo-jIW|1no zsGrmz;FT>MB6|I#R+iP4WR0aMq35b3qk>LDJzR8#i)NuIPLzeL*Pur2oSmbLp!usPVwhVMf@hZrV`5F3dN zKoT-xf3kPphtR`t8&ZfdDGaAoFF3Laf`npI9cz)VrUU4+w)0^dj4>XpdB2Um~17 z1yy+W3;De|tx1qdCXo{ry-*}MvS?ZKZAmz-lL8y!OBgt5ULl0(dZADSiIc_$EPnH( zkW|kyDN(Rait3EBr|I^dVegg6T#Ij`zAvE1$%FX8#HMdFf4F}Q!&gmXU5(;p%uSj z6$-f>##=ycGcsyrn@|3##u@ovqoUb+<(k}=5?SO=RGKt|5Glw4E9WYg#uUnQq14oL z%h_v~f@x);PRokLP1D(HJkTs@HD|Bk?0$shMpEeRN2W>InX~&5B(=-*YR%dG2rGTB zLALQi!8D2K6^oLizOEJRy$8p;DMz5LW<~aesm$wIak4p~s@HY4{tR!`g`wGd4>Q`d z+vbeD_lWKovlh)XI?qbH2B(8m;ZrJC8n+%w>s}qUcRBGepqr2-TF&qmiyEI7g;s<7 zCbXJ8d>@AI;R1xe9N$Jq)(A~e(8*X&)gl0o`-r_AJ{P&2J;xyv16X@UeYd-t_e z+uM7}hV^-O0ldz%_nvZwFPTEqEeM&A8lBx&t2H8&Cd^(IsSCB#7%+U;+1+t=w?Xts z+J(gI?6n|{?>$4}EU2SVg-v^Jf$RZgO5yb?spD)VKnZ)VElVJU5+KUv3z(1i84f^YcixjmioHz;_IsP47|z};XE;#r$^Yd4-xTFE+dL*8ng(+B25BxPLZVpy z9+31RbIFBsnPo_56S}?g0k4=3U>wX}@)ishWpT&DWuz zQ_2~B#zre>=nphY%E~u}FE+70Pv8MA3m2XJ2qaYvi9%D9>63e}(m0xIt+Ah}iV6BK z6cg~Xy*Hf8b2MZ-?>T!|pmRKRdoLS8uT(0e-tGOY29@JoXXm|I!*KR~%1X!kwT5AW zC6{LGy?1oOFa_yHF)l5OjC2D6Y6uco$S4LEiliv4fW98dDq^1 zmzU1NwAx8I!ocsofq~zBg9e^O>#xJTHY7ejo0p`S-t4w($lqM8XUVnsQBjnX$HT!Cf?eawWO%L(ims{r?(X~Lu7?7mvyhiN{l;R$}rguF1_ zoZ#hLkj^G&=gb3^R@BWDOg&FE2pi=ToDf=}To)6jRm8vCApTGdLIt)55nX5ZRTjTt z{l$C>t%j{w1NDXh^RjbUN}}*yYXxXmdmpfVz4w8H$joT(eE=3c{E10cEPMD9jGbv( ztYl`ESy0|;p`ulbR1gpFT{imf{TfzKCdD{KT5o zNlTkx7gv?S2@3eBlrhbxcz7110(SR94f+`18cXgN?Y&R<@i88Fr^>t!O-AWYG=6?8 zpTC8Pnnv@tJUJscm5ZV}|qs0e!gpb_tvxJ{U4?Sk7nx48P`Fc18bhD_-r2O5zhkR$` zl8+K{Ps5W@hE1osNzxTSY&tKuBpfn6V65O7x+eMR%)`4k8;y6YbJb?uoGY@)F~=Qs~2Dx ztrzNA1q=AkXibn-WRL!=+|+feX4#`17EVW_nmu}z!?vm)1q)j4ezOApiljNAHxyMA<5ot-`Df!YS0130gLAGShZX3QMrQ&dsxT z-=VMVzB48C9R3@kbfm#-SQxzvFVN^cY`?Nqc7$=b;55H$+M_q?_UPyK=yekjU8b}1 zlL{Q5J0F{MtqiR!C&rpnqF_2ZA4^9ImhI@LP_ah8aCSbf)F}YrCuGd4+oNB=NHjYm zCa7D6X*r`0Wb-~O2vH{4_lh)yj6Q^DH~JXT-{`~Qj6M29TE0e~usi^N+vqcL@{c~J z9s4=SC%YeO6{vk5W4|#>HrtOiQ^92L#RFo-=yR##G^RRrog9_6O?&C!>dGy1}& z_HaY&(Pz#GIw+b@6S_0{EoZ1kU?PFvH&T36XwH6Fc7)hIW$fEkD(CE%ozds8;(@t* zq8YSj_Gcz4l}d#NOlX|2a)uBY8UnSsp&1oRlSsn>E#7Gr)Y}!SVhZSqs7d9N(=Wj1 z#4$M2aJgxO(&cDFIxJ{&ruj8ta8tR74Woh%&p7)hG<&~+#Dho>N8W!B`+UaPe^`nh z&i*M%O_DUV|1fdQqCh(;#U=?+pgQ{vOE5<=tcJbcz!ymNK_`S}@1LP$i6^EcpXEYR zl)fe=#`hY@l*V*MpCcAbb_m6a)E|00$RgWsH50SJLWX3-$bLqN zh6*#%P`hrD4Goes>5h$Qm=Q$7-fwXx+^xDcH-|df8tg)n!RT|!u2vf%m1*ZCK5cz5f~%G*hSFa&v+c#Ct7sGGn(V zMY*CIGxq+Eh>YMto0+Ws@=s-(!WDZ?7dUvCRA zqrkYg>xb$g(U=BE-o|)UY#LW#sjUJitiAufbUko&b?l{IW-#vp zSn2m4B*%nQ=A}oABFAWK3Pfd&EEeE9L^d2*2Hw0 z3ds_{$0ZEV=QY!0&d>m{LqfM8!i+vJ8}|O^re*Jc>4;K>APi!a_WxdU3|kbltcv9v zXcbY=`OO+E3$-f5rO^kb;N=(j<|e@n$1 z=GPvjA~Vjx!`Xwo@>>Xc2d7{dsn`dnCY!o_@CcQdo2xb_?1M*~y*K7`@;O$VeV=3w zwP`9arE=1&Rhxx|=^UJK_IKCI2f zA8Qp`0OjoOC}Vx8Rw1V$zy~wdFe{j>QWG%}`(IXIaz7X$2QR!z#OmNx=oWZT zEw5@*a0nAjh}Sf*?b%t>spiu($?DP2nLrV>nu70JYC%&ILYq>jZl++V#&<3Iz-^ei z1xqSoXnB@CFdClWk(k5c<>-c14 z3gcd}56GB(@DuysbvpQP@H&ekW>FNX5Ma4sWj;=TuAUV7Nl~}7+~-b;%A=Ytlch&B z4cafat5T?FQ=FWlFsPj~rkK#HrfyBix~nBos8*|1wIp=JNFxAG(#sf#HN1VwUZ$qnC72ZRebWchn|$7ex==<)V`qk9Sd(b1>rb zDJn^G<{XUXEUlo>R3klUY%H_dbLmPhJ%tYvt7U2e>+}IU7;Gy82`wQ= z-rh$XzJ2hn1P|WD7|qg&tb>O&^amqH8vE!2S>t{q$8dg5{NVrxUrguV=TfSr@Byqf z3m!)q{3_AT!Ot!G;1|Sye#vkDvW!Oj0xv+c#_=kMuE3cU(&?b&%vENbgAX}L?C96@ z`w=a)kL&|*Jq@nuYL(&m(vdSdOHlkSPtT{e8gDi4;?qnufP{Lmq zDp2Y%eELL1H^_(t)>f8Q9>=Jp0*h`6ZHk2$QP8JUmmyhHl(bJ~>k771RblLdFUgfr z&0D;y)$CA5wJKR#wOXw*U#nIpgl1I1H41RO7HHd4Gov|49sVOAJ#TS`3#DPuO@}O-W zoN}5I*(aQR*FHD}N0*We?1NM2k%5Cd_P^G^$4YRnLY-;izyYY1XxIlWnCUFTs>**w zp*j{e0ZLP?R*RTyvsx7s*aVo*mk|$yg9}t0lq2ZU_-M5X{)m4pb26o;_QC6uN-#uD zj=UncOnqOvbs&I(FgOPz>?StWISFa9Z-{{PK??iR$P5aPc-`=7cY|NlSYKfyo$H&9Cf0u%rg z000080HTOUU6t^>U{wJD003wM01N;C0000000000000000001PWp^%ZX<}n8WNd6u YO9ci1000010096x0001g0RaF20D6_dtpET3 diff --git a/tests/ExcelReader.Benchmarks/RealData/SheetParallelBenchmark.cs b/tests/ExcelReader.Benchmarks/RealData/SheetParallelBenchmark.cs new file mode 100644 index 00000000..3f47f560 --- /dev/null +++ b/tests/ExcelReader.Benchmarks/RealData/SheetParallelBenchmark.cs @@ -0,0 +1,58 @@ +using BenchmarkDotNet.Attributes; +using ExcelReader.Core.Parser; +using ExcelReader.Core.Reader; +using ExcelReader.Core.Reader.Xlsb; +using ExcelReader.Core.Reader.Xlsx; + +namespace ExcelReader.Benchmarks +{ + [MemoryDiagnoser] + public class SheetParallelBenchmark + { + private sealed class Sum : IRowAccumulator + { + public long Total; + + public void Add(FullRow model) + { + Total += model.OrderId + model.UnitsSold + (long)model.TotalRevenue + model.Region.Length; + } + + public void Merge(Sum following) + { + Total += following.Total; + } + } + + private readonly ExcelParser _parser = ExcelParser.FromAttributes(); + private byte[] _xlsx = []; + private byte[] _xlsb = []; + + [Params(1, 2, 4, 8)] + public int Dop { get; set; } + + [GlobalSetup] + public void Setup() + { + string dir = Path.Combine(AppContext.BaseDirectory, "Data"); + _xlsx = File.ReadAllBytes(Path.Combine(dir, "65K_Records_Data.xlsx")); + _xlsb = File.ReadAllBytes(Path.Combine(dir, "65K_Records_Data.xlsb")); + } + + [Benchmark] + public async Task Xlsx_Aggregate() + { + using XlsxWorkbook workbook = Excel.FromXlsx(_xlsx); + Sum sum = await SheetParallel.AggregateAsync(workbook.FirstSheet, _parser, new SheetParallelOptions { DegreeOfParallelism = Dop, HeaderRow = 1 }); + return sum.Total; + } + + [Benchmark] + public async Task Xlsb_Aggregate() + { + using XlsbWorkbook workbook = Excel.FromXlsb(_xlsb); + Sum sum = await SheetParallel.AggregateAsync(workbook.FirstSheet, _parser, new SheetParallelOptions { DegreeOfParallelism = Dop, HeaderRow = 1 }); + return sum.Total; + } + } +} diff --git a/tests/ExcelReader.Benchmarks/benchmark-groups.json b/tests/ExcelReader.Benchmarks/benchmark-groups.json index 3bce06d0..be6bdae8 100644 --- a/tests/ExcelReader.Benchmarks/benchmark-groups.json +++ b/tests/ExcelReader.Benchmarks/benchmark-groups.json @@ -25,7 +25,7 @@ }, { "name": "realdata", - "filter": "ExcelReader.Benchmarks.RealDataReadBenchmark.* ExcelReader.Benchmarks.RealDataTypedParseBenchmark.*", + "filter": "ExcelReader.Benchmarks.RealDataReadBenchmark.* ExcelReader.Benchmarks.RealDataTypedParseBenchmark.* ExcelReader.Benchmarks.SheetParallelBenchmark.*", "pagesArgs": "--warmupCount 3 --iterationCount 10", "prArgs": "--warmupCount 1 --iterationCount 5" }, From 580fba697da70bab1bb5b5cf6f4ca0b752e164f2 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Sun, 11 Oct 2026 00:28:41 -0300 Subject: [PATCH 078/115] docs: mark the parallel benchmark tables as pending an idle rerun Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/performance/benchmarks.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/performance/benchmarks.md b/docs/performance/benchmarks.md index 591fd725..58598563 100644 --- a/docs/performance/benchmarks.md +++ b/docs/performance/benchmarks.md @@ -76,6 +76,8 @@ For raw CSV reads, all three libraries read text through spans. ExcelReader is ~ | 8 | 285.2 ms, 680.15 MB | 204.7 ms, 254.63 MB | 158.3 ms, 1.54 MB | 94.6 ms, 1.62 MB | | 16 | 324.9 ms, 680.32 MB | 189.0 ms, 255.15 MB | 114.4 ms, 1.66 MB | 73.0 ms, 1.69 MB | +These numbers were measured before the rename to `SheetParallel`; the rename did not touch the CSV engine, and a scratch harness comparing the two builds on a 156 MB CSV showed no regression. + The typed path scales ~2.8x on the conversion-heavy corpus (bimodal at dop 16: 86.7 ms standard deviation, median 275.8 ms) and ~2.7x on narrow ints, where it flattens out after dop 4 (217.2 ms vs. 189.0 ms at dop 16). Its ~680 MB is one materialized row object per record, and at dop 16 that costs 42,000 Gen0 plus 41,000 Gen1 collections. The aggregate columns fold each record into a per-partition accumulator through a `ref struct` model, so text columns stay as `ReadOnlySpan` and no row object is ever materialized. They scale better than the typed path — ~6.9x (conversion-heavy) and ~6.5x (narrow) at dop 16 — because they are not fighting the allocator: at dop 16 the conversion-heavy aggregate is ~2.8x faster than typed while allocating ~410x less (1.66 MB against 680.32 MB), with **zero** garbage collections at every degree. The remaining allocation is per-partition bookkeeping, not per-row. @@ -102,6 +104,8 @@ Sequentially, ExcelReader's typed parser is ~1.1x faster than Sep on narrow ints | 4 | 20.07 ms, 8.11 MB | 14.94 ms, 8.07 MB | | 8 | 19.94 ms, 8.16 MB | 15.26 ms, 8.09 MB | +This run had background load and may read a few percent high: on an idle machine a scratch harness's minimums at dop 4 were 18.85 ms for XLSX (against 20.07 ms here) and 14.44 ms for XLSB (against 14.94 ms); an idle rerun is pending. + At dop 4 XLSX reads ~2.7x faster than sequentially and XLSB ~2.4x. Dop 2 is a single parse worker running alongside the inflating thread. Past dop 4 nothing improves: the one inflating thread is the floor (~15 ms for XLSX, ~10 ms for XLSB, plus the XLSB record walk that finds row boundaries). Allocation barely moves with dop (8.02 to 8.16 MB): it is the typed model's strings. ### Real data reads From c9c3dc63d96763647d1a4db72883cb136fa2a779 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Sun, 11 Oct 2026 00:35:35 -0300 Subject: [PATCH 079/115] docs: document SheetParallel and the v7 migration Co-Authored-By: Claude Sonnet 5.5 --- ARCHITECTURE.md | 78 +++++++++++++++--- README.md | 3 +- docs/guide/csv.md | 4 +- docs/guide/migrating-to-v7.md | 99 +++++++++++++++++++++++ docs/guide/parsing.md | 2 +- docs/guide/reading.md | 107 ++++++++++++++++++------- docs/performance/reader-performance.md | 47 ++++++++++- python/README.md | 15 ++-- rust/excelreader/README.md | 5 +- src/ExcelReader.Native/README.md | 2 +- 10 files changed, 308 insertions(+), 54 deletions(-) create mode 100644 docs/guide/migrating-to-v7.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2d7c67de..67d45d6e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -47,13 +47,73 @@ source-generated map) or `ExcelParser.Build` (a runtime-configured map). Its mod a `ref struct` model binds directly to `Cell.Value` spans — zero allocation for the container and, for span-typed columns, for the values too. It consumes `Row`/`Cell` from any sheet uniformly. -## Parallel CSV parsing - -`CsvParallel.ParseAsync` (`src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsv*.cs`) is an opt-in -alternate path over the same typed-parsing machinery, for sources large enough that partitioning -pays for itself. `ParallelCsvFactory` decides eligibility — a seekable, UTF-8, large-enough source — -and falls back to the ordinary sequential `ExcelParser` otherwise, so callers get identical output -either way. +## Parallel sheet reading + +`SheetParallel` (`src/ExcelReader.Core/Reader/SheetParallel.cs`) reads one sheet on several threads. It is +opt-in, takes any `IExcelSheet`, validates its arguments and dispatches on the sheet's concrete type: + +| sheet | engine | +|---|---| +| `CsvSheet` | the CSV engine (`ParallelCsvProcessor`, `ParallelCsvEnumerable`), described below. Its source comes from `CsvReader.TryGetChunkSource`, which the native layer also uses. | +| `XlsxSheet`, `XlsbSheet` | the pipeline engine (`src/ExcelReader.Core/Parser/SheetPipeline`) | +| anything else, and every fallback case | one sequential runner over `IExcelRowEnumerator` | + +The fallback cases give the same results with no error: a degree of parallelism of 1, a sheet under 256 KB +uncompressed, XLS, a table view (`ExcelTable.AsSheet`), and a CSV that cannot be partitioned (a non-seekable +stream or a non-UTF-8 encoding). The caller owns the workbook; the engine holds a `ReaderLifetime` lease +until the task completes or the enumeration ends. + +The CSV engine is not folded into a shared partition abstraction. It is tuned, and its model, a static +byte-range plan with speculative starts and re-reads, differs from the pipeline's dynamic, exact chunks. What +the two share is the contract types (`IRowRecord`, `IRowAccumulator`, `RowAggregation`, `RowAction`) and the +binders in `Parser/ParallelCsv` (`MappedProjection`, `MappedAggregation`, `MappedCallback`, `RecordAggregation`, +`RecordCallback`), which pick the parser's CSV type map for a `CsvSheet` and the general one otherwise. + +### The pipeline engine + +``` +zip entry -> producer (1 thread) -> bounded queue -> N workers -> ordered merge + inflate, cut at the row-aligned parse + by chunk index + last row boundary chunks, indexed row action +``` + +Inflate is sequential, so the engine splits the work around it instead of through it. One producer thread +owns the entry stream, opened through `ZipIndex.OpenEntryStream` so the decompressed-byte limits count where +they do on the sequential path. It inflates into pooled buffers and cuts each at the last row boundary, +copying the partial tail into the next buffer; chunks default to 256 KB (`SheetPipelineSource.DefaultChunkBytes`). +In XLSX the boundary is the last ``, +call `Seed()`, then run the row action for each row. Shared strings are loaded before the pipeline starts, and a +prologue reads up to `HeaderRow` with an ordinary enumerator, so header rows never reach a chunk. The engine's +one primitive is an async stream of `(index, state, failure)` in chunk order, reordered through a ring of slots as +`ParallelCsvEnumerable` does; `AggregateAsync` folds the states with `Combine`, `ForEachAsync` ignores them, and +`ParseAsync` uses a `List` per chunk and yields its items. + +The XLSX row boundary is a candidate, not a proof: the same bytes can sit in CDATA, a comment or malformed +markup. So every chunk enumerator reports whether it ended cleanly, on a row boundary with no row or tag cut +short. A run of clean chunks equals the sequential read by induction. A chunk that does not end cleanly stops +before delivering its cut-short row; the merge keeps that chunk's rows, cancels the pipeline and reads the rest +with one sequential enumerator, skipping the rows already delivered, and later chunks' results are dropped. +This recovery re-inflates the sheet and only happens on such sheets. It leaves `ParseAsync` and `AggregateAsync` +exact, but `ForEachAsync` may already have called back for dropped chunks, so it is exactly once only for a +well-formed XLSX sheet. XLSB frames records from the first byte as the sequential reader does, so it has no +such case. + +The measured gain is bounded by inflate, which stays on one thread: see "Parallel intra-sheet parsing" in +[reader-performance.md](docs/performance/reader-performance.md). Parallel inflate would replace the producer +only; the queue, workers and merge stay. + +### The CSV engine + +`CsvSheet` takes the older path (`src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsv*.cs`), over the same +typed-parsing machinery, for sources large enough that partitioning pays for itself. `ParallelCsvFactory` +decides eligibility, a seekable, UTF-8, large-enough source, and falls back to the ordinary sequential +`ExcelParser` otherwise, so callers get identical output either way. When it partitions, `CsvChunkPlan` slices the data range into four chunks per worker (not one — see the file's comments on why one-per-worker would buffer up to the whole output) and hands them out @@ -96,8 +156,8 @@ Still unresolved, and untested by any of the above: whether true unordered emiss differently. The ordered merge is the safer default and the only mode either public overload can reach, so it stays absent a specific reason to revisit. -`CsvParallel.ForEachAsync` exposes the same partitioning to a per-row callback rather than an -accumulator type. It accepts a `ref struct` row, which the enumerating `CsvParallel.ParseAsync` cannot — +`SheetParallel.ForEachAsync` exposes the same partitioning to a per-row callback rather than an +accumulator type. It accepts a `ref struct` row, which the enumerating `SheetParallel.ParseAsync` cannot — not because of the interface (`IAsyncEnumerable`'s `T` is declared `allows ref struct` on net9.0+), but because a row holding spans into a worker's buffer cannot cross to the consumer thread at all. diff --git a/README.md b/README.md index 643a01b6..026534bc 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ foreach (Row row in workbook.FirstSheet) - [Writing workbooks](docs/guide/writing.md) — XLSX, XLSB (BIFF12) and XLS (BIFF8) writers, cell styles, typed records, and prefetch compression. - [CSV](docs/guide/csv.md) — reading, dialect sniffing, parallel parsing, and writing delimited text. - [Encrypted workbooks](docs/guide/encryption.md) — opening password-protected packages and encrypting written ones. +- [Migrating to v7](docs/guide/migrating-to-v7.md) — porting `CsvParallel` code to `SheetParallel`, which now reads XLSX and XLSB on several threads too. - [Migrating to v6](docs/guide/migrating-to-v6.md) — porting v5 reader code to workbooks and sheets, and reading sheets in parallel. - [Benchmarks](docs/performance/benchmarks.md) — throughput and allocation against Sep, Sylvan, OfficeIMO and SpreadCheetah. [Live results](https://gabrielmarquezmatte.github.io/ExcelReader/dev/bench/). @@ -77,7 +78,7 @@ errors to stderr, so `convert` is safe to pipe. - Reads `.xlsx`, `.xlsb` (BIFF12), `.xls` (BIFF8), and `.csv`; writes `.xlsx`, `.xlsb`, `.xls`, and `.csv`. - `Excel.Open`/`OpenAsync` auto-detect XLSX/XLSB/XLS from the file signature. CSV has none, so pass `ExcelFileFormat.Csv` to open delimited text through the same entry point; `ExcelReaderOptions.Csv` carries the dialect, and `CsvReaderOptions.SniffDialect` infers it from the source. - A workbook hands out its sheets: `FirstSheet`, `Sheets[index]` or `SheetAt(index)`, and `TryGetSheet(name)`. CSV is a workbook with one unnamed sheet. Coming from v5? See [Migrating to v6](docs/guide/migrating-to-v6.md). -- Sheets of one workbook can be read in parallel, one enumerator per thread. +- Sheets of one workbook can be read in parallel, one enumerator per thread. One sheet of any format (XLSX, XLSB or CSV) can also be read on several threads with `SheetParallel`. - `sheet.Visibility` reports whether a sheet is hidden (`Visible`, `Hidden`, `VeryHidden`). Hidden sheets still enumerate their rows — filter on the value if you want them skipped. The writers take the same value via `AddSheet(name, visibility)`, and reject a workbook whose sheets would all be hidden. - Missing cells in sparse rows are exposed as empty cells. - String conversion allocates only when you call `GetString()`. diff --git a/docs/guide/csv.md b/docs/guide/csv.md index 074acf38..dc9a0fc2 100644 --- a/docs/guide/csv.md +++ b/docs/guide/csv.md @@ -1,7 +1,7 @@ # CSV Reading, dialect sniffing and writing delimited text. -Parallel CSV parsing is covered in [parsing.md](parsing.md). +Parallel CSV parsing is covered in [reading.md](reading.md#parallel-reading-opt-in). ## Read CSV @@ -64,7 +64,7 @@ using var csv = Excel.FromCsvFile("export.csv", CsvReaderOptions.Default.WithDia `CsvSniffer.Detect` takes a span of bytes or a seekable `Stream`, `DetectFile` takes a path, and `DetectAsync`/`DetectFileAsync` are the async siblings; only the first 64 KiB are examined. The `Stream` overloads require a seekable source — they read a bounded sample and restore the stream's position — so a non-seekable stream throws `ArgumentException`; buffer it first, or pass the bytes as a span instead. Pass `CsvSnifferOptions` to change the candidate delimiters/quotes (and their priority order) or the number of sample lines considered. -`CsvReaderOptions.SniffDialect` does the sample-then-apply above for you, at the entry point that owns the source — the `Excel.FromCsv*` factories, `Excel.Open` with `ExcelFileFormat.Csv`, and the parallel CSV entry points: +`CsvReaderOptions.SniffDialect` does the sample-then-apply above for you, at the entry point that owns the source — the `Excel.FromCsv*` factories, and `Excel.Open` with `ExcelFileFormat.Csv`. The dialect of a parallel read is the one the CSV workbook was opened with: ```csharp using var csv = Excel.FromCsvFile("export.csv", CsvReaderOptions.Default with { SniffDialect = true }); diff --git a/docs/guide/migrating-to-v7.md b/docs/guide/migrating-to-v7.md new file mode 100644 index 00000000..1bd4f4bd --- /dev/null +++ b/docs/guide/migrating-to-v7.md @@ -0,0 +1,99 @@ +# Migrating from v6 to v7 + +What changed in parallel reading and how to port v6 code. +See also [reading.md](reading.md#parallel-reading-opt-in) for the full guide. + +## What changed + +Parallel reading now takes a sheet, and it works on XLSX and XLSB as well as CSV. `CsvParallel` is gone: its eighteen path, memory and stream methods became six `SheetParallel` methods that take any `IExcelSheet`. You open the workbook yourself, pass its sheet, and the library parallelises as far as the format allows. The contract types lost their `Csv` prefix because they now serve every format. + +## Types + +| v6 | v7 | +|---|---| +| `CsvParallel` | `SheetParallel` | +| `CsvParallelOptions` | `SheetParallelOptions` (no `Reader` property) | +| `ICsvRecord` | `IRowRecord` | +| `ICsvAccumulator` | `IRowAccumulator` | +| `CsvAggregation` | `RowAggregation` | +| `CsvRowAction` | `RowAction` | + +The old names are removed, with no `[Obsolete]` forwarders. Members, contracts and results are unchanged. + +## Before and after + +A CSV file: + +```csharp +// v6 +await foreach (Sale s in CsvParallel.ParseAsync(path, parser, new CsvParallelOptions { DegreeOfParallelism = 8, Reader = dialect })) +{ + Total += s.Revenue; +} + +// v7 +using CsvReader csv = Excel.FromCsvFile(path, dialect); +await foreach (Sale s in SheetParallel.ParseAsync(csv.FirstSheet, parser, new SheetParallelOptions { DegreeOfParallelism = 8 })) +{ + Total += s.Revenue; +} +``` + +CSV bytes in memory: + +```csharp +// v6 +await foreach (Sale s in CsvParallel.ParseAsync(data, parser, new CsvParallelOptions { DegreeOfParallelism = 8, Reader = dialect })) +{ + Total += s.Revenue; +} + +// v7 +using CsvReader csv = Excel.FromCsv(data, dialect); +await foreach (Sale s in SheetParallel.ParseAsync(csv.FirstSheet, parser, new SheetParallelOptions { DegreeOfParallelism = 8 })) +{ + Total += s.Revenue; +} +``` + +A stream: + +```csharp +// v6 +await foreach (Sale s in CsvParallel.ParseAsync(stream, parser, new CsvParallelOptions { DegreeOfParallelism = 8, Reader = dialect })) +{ + Total += s.Revenue; +} + +// v7 +using CsvReader csv = Excel.FromCsv(stream, leaveOpen: true, dialect); +await foreach (Sale s in SheetParallel.ParseAsync(csv.FirstSheet, parser, new SheetParallelOptions { DegreeOfParallelism = 8 })) +{ + Total += s.Revenue; +} +``` + +The other five methods port the same way: open a workbook, pass `csv.FirstSheet` (or any sheet) in place of the path, bytes or stream, and drop `Reader` from the options. XLSX and XLSB need no other change: + +```csharp +using XlsxWorkbook workbook = Excel.FromXlsxFile("big.xlsx"); +await foreach (Sale s in SheetParallel.ParseAsync(workbook.FirstSheet, parser, new SheetParallelOptions { DegreeOfParallelism = 8 })) +{ + Total += s.Revenue; +} +``` + +## Behavior changes + +- The caller owns the workbook and keeps it open until the task completes or the enumeration ends. v6 opened and closed its own source. +- The CSV dialect is given when the workbook is opened (`CsvReaderOptions`), not in the parallel options. +- A CSV in a `MemoryStream` is no longer partitioned: the stream overload of `Excel.FromCsv` reads it sequentially. Pass the bytes as `ReadOnlyMemory` to keep partitioning. +- `HeaderRow` rules are the same as v6. `ParseAsync` takes the header from `ExcelParserConfig.HeaderRow` and rejects a non-zero `SheetParallelOptions.HeaderRow`; the parser-based `ForEachAsync` and `AggregateAsync` use `SheetParallelOptions.HeaderRow`. +- XLSX and XLSB sheets are now read on several threads when `DegreeOfParallelism` allows it. XLS, a table view (`ExcelTable.AsSheet()`), a sheet under 256 KB uncompressed and a CSV that cannot be partitioned still read sequentially with the same results. +- `PrefetchDecompression` does not apply to a parallel XLSX or XLSB read; the pipeline already overlaps inflate and parse. + +## Native bindings + +The C ABI is unchanged: no signature moved, and `XL_ABI_VERSION` stays 6. Behavior changed. `degree_of_parallelism` of `0` on `xl_parse_typed` and the Arrow exports now parallelises XLSX and XLSB as well as CSV, using every processor. Pass `1` for the old sequential behavior. The Rust, Python and C++ bindings take the same value (`degree_of_parallelism`, `parallelism`) and need no code change; a `parallelism=0` call on an XLSX or XLSB sheet is now faster, and holds every partition's columns until they merge, so its peak memory is higher. + +`xl_csv_aggregate` keeps its signature. Inside, it opens a `CsvReader` with the dialect from the native struct and calls `SheetParallel`. diff --git a/docs/guide/parsing.md b/docs/guide/parsing.md index fec416d3..c53c3d54 100644 --- a/docs/guide/parsing.md +++ b/docs/guide/parsing.md @@ -76,7 +76,7 @@ await using (var sheet = workbook.AddSheet("Changes")) Notes: - `[ExcelSerializable]` requires the model — and every type it's nested inside, if any — to be `partial`; the generator emits into an additional part of the same declaration. A compile error (`EXR001`/`EXR002`) names exactly what to fix. -- Supported property types match `ExcelParser.FromAttributes`'s: `string`, `bool`, `DateTime`, `DateOnly`, `TimeOnly`, `TimeSpan`, `DateTimeOffset`, `Guid`, `char`, every integral and floating type plus `decimal`, `enum`s, and `Nullable` of each — plus `ReadOnlySpan`, which binds zero-copy through `ExcelCellReaders.Utf8`. The model may be a `ref struct`: mark it `ref partial struct` and the generator emits `IExcelRowMap` on it like any other shape, so `ExcelParser.Generated` and `ExcelParser.Build` read it AOT-clean, as do the map-driven `CsvParallel.ForEachAsync`/`CsvParallel.AggregateAsync` overloads. +- Supported property types match `ExcelParser.FromAttributes`'s: `string`, `bool`, `DateTime`, `DateOnly`, `TimeOnly`, `TimeSpan`, `DateTimeOffset`, `Guid`, `char`, every integral and floating type plus `decimal`, `enum`s, and `Nullable` of each — plus `ReadOnlySpan`, which binds zero-copy through `ExcelCellReaders.Utf8`. The model may be a `ref struct`: mark it `ref partial struct` and the generator emits `IExcelRowMap` on it like any other shape, so `ExcelParser.Generated` and `ExcelParser.Build` read it AOT-clean, as do the map-driven `SheetParallel.ForEachAsync`/`SheetParallel.AggregateAsync` overloads. - The generator requires a build via `dotnet build`/the .NET SDK. Visual Studio's or `MSBuild.exe`'s .NET Framework host can't load it, so a project built only through those tools won't see generated code — build via the SDK, or fall back to `ExcelParser.FromAttributes`/`ExcelRecordLayout.FromAttributes` for that build path. - `ExcelParser.Generated` builds one map per model and reuses it for every reader, including CSV — unlike `ExcelParser.FromAttributes`, which swaps in a text-based date reader specifically for CSV. A `[ExcelSerializable]` model reads `DateTime`/`DateOnly`/`TimeOnly` via `ExcelCellReaders.DateTimeAuto`/`DateOnlyAuto`/`TimeOnlyAuto`: an Excel serial number first, falling back to date/time text when the cell isn't numeric — so a CSV column round-trips through the library's own writer either way. The one edge case this can't distinguish: a CSV cell that's only digits (e.g. an Excel serial number typed as plain text) is always read as a serial number, never as date text. - The attribute-based reflection path keeps working unchanged; `[ExcelSerializable]` is an additive, opt-in alternative for the same model shape, not a replacement. diff --git a/docs/guide/reading.md b/docs/guide/reading.md index eec0e114..53ae2455 100644 --- a/docs/guide/reading.md +++ b/docs/guide/reading.md @@ -28,23 +28,34 @@ foreach (var row in workbook.FirstSheet) An opened workbook (`XlsxWorkbook`, `XlsbWorkbook`, `XlsWorkbook`, `CsvReader`) hands out its sheets: `FirstSheet` is the first one, `Sheets[i]` any of them. A sheet holds no resources of its own; iterating it starts a new, independent read. Dispose the workbook, not the sheets. -### Parallel CSV parsing (opt-in) +### Parallel reading (opt-in) + +`SheetParallel` reads one sheet on several threads. It takes any sheet, so it works the same on XLSX, XLSB and CSV: ```csharp -await foreach (var row in CsvParallel.ParseAsync("big.csv", ExcelParser.FromAttributes(), new CsvParallelOptions { DegreeOfParallelism = 8 })) +using XlsxWorkbook workbook = Excel.FromXlsxFile("big.xlsx"); + +await foreach (var row in SheetParallel.ParseAsync(workbook.FirstSheet, ExcelParser.FromAttributes(), new SheetParallelOptions { DegreeOfParallelism = 8 })) { Total += row.Revenue; } ``` -Rows arrive in file order, identical to the sequential parser. Parsing and type conversion run across -threads; whatever your loop body does per row does not — if that dominates, raising the degree will -not help. Sources that cannot be partitioned (non-seekable streams, non-UTF-8 encodings, small files) -fall back to sequential parsing with the same results. +You own the workbook: keep it open until the task completes or the enumeration ends. For CSV, give the dialect when you open it (`Excel.FromCsvFile(path, dialect)`), not in the options. `DegreeOfParallelism` of `0` (the default) uses every processor; `1` reads sequentially. + +There are three families, each over a sheet: + +| Family | Result | Order | +|---|---|---| +| `ParseAsync` | an `IAsyncEnumerable` | the sheet's own order, identical to `parser.Parse(sheet)` | +| `ForEachAsync` | calls your callback on the worker threads | none | +| `AggregateAsync` | folds one accumulator per partition into a result | the same result as a single-threaded run | -For a fold or a per-row side effect, `CsvParallel.ForEachAsync` accepts a `ref struct` row type, so text -columns stay as `ReadOnlySpan` and the per-row model allocation disappears entirely — a class -model still costs one instance per row: +Parsing and type conversion run across threads; whatever your loop body does per row does not, so if that dominates, raising the degree will not help. + +Both of the next two examples read a `ref struct` model, so text columns stay as `ReadOnlySpan` and the per-row model allocation disappears; a class model still costs one instance per row. + +A fold over an XLSX sheet, with one accumulator per partition merged in sheet order: ```csharp public ref struct SaleRow @@ -53,32 +64,66 @@ public ref struct SaleRow [ExcelColumn("Units")] public int Units { get; set; } } +public sealed class UnitTotal : IRowAccumulator +{ + public long Units; + + public void Add(SaleRow model) => Units += model.Units; + + public void Merge(UnitTotal following) => Units += following.Units; +} + +using XlsxWorkbook workbook = Excel.FromXlsxFile("big.xlsx"); + +UnitTotal total = await SheetParallel.AggregateAsync( + workbook.FirstSheet, + ExcelParser.FromAttributes(), + new SheetParallelOptions { HeaderRow = 1 }); +``` + +The same call takes a `RowAggregation` when you would rather pass delegates than write a type, and an `IRowRecord` type in place of the parser. + +A per-row callback: + +```csharp +using XlsxWorkbook workbook = Excel.FromXlsxFile("big.xlsx"); + long total = 0; -await CsvParallel.ForEachAsync( - "big.csv", +await SheetParallel.ForEachAsync( + workbook.FirstSheet, ExcelParser.FromAttributes(), row => Interlocked.Add(ref total, row.Units), - new CsvParallelOptions { DegreeOfParallelism = 8, HeaderRow = 1 }); + new SheetParallelOptions { DegreeOfParallelism = 8, HeaderRow = 1 }); ``` -Unlike `CsvParallel.ParseAsync`, the callback runs on the worker threads, so records arrive in no -particular order and the callback must be safe to call from several threads at once. Spans are valid -only for the duration of the call. - -Delivery is at least once, not exactly once: the callback may be invoked more than once for the same -record. A partition whose start offset was guessed wrongly is read again from the confirmed offset, and -the discarded pass may already have delivered a whole partition's worth of records — 1 MiB to 64 MiB of them, not just a few near the seam. -On a source with quoted fields those records may also be misparsed, carrying values that appear nowhere -in the file, and an exception the callback threw during a discarded pass is discarded with it. A start -is only guessed wrongly inside a quoted field, so a source in which the quote character never appears -delivers every record exactly once; for that guarantee on any source, use `CsvParallel.AggregateAsync`, -where the discarded partition's accumulator is thrown away, and apply side effects once it returns. -A callback that writes to a database or sends messages must be idempotent and must also tolerate -records that do not exist — deduplicating on a key does not filter those out. - -See the [parallel CSV benchmarks](../performance/benchmarks.md#parallel-csv) for what this buys. Those figures are -`CsvParallel.AggregateAsync`'s, measured over the same projection path this shares: at dop 16 it is -~2.8x faster than the typed path while allocating ~410x less, with zero garbage collections. +Unlike `ParseAsync`, the callback runs on the worker threads, so records arrive in no particular order and the callback must be safe to call from several threads at once. Spans are valid only for the duration of the call. + +`HeaderRow` follows the same rule as before: `ParseAsync` takes the header from the parser's `ExcelParserConfig.HeaderRow` and rejects a non-zero `SheetParallelOptions.HeaderRow`, while the parser-based `ForEachAsync` and `AggregateAsync` use `SheetParallelOptions.HeaderRow`. + +#### When it reads sequentially + +These cases read on one thread with the same results and no error: + +- `DegreeOfParallelism` of 1. +- A sheet under 256 KB uncompressed. +- XLS. +- A table view (`ExcelTable.AsSheet()`). +- A CSV that cannot be partitioned: a non-seekable stream or a non-UTF-8 encoding. A CSV over a `MemoryStream` is one of them; pass the bytes as `ReadOnlyMemory` to partition it. + +`PrefetchDecompression` does not apply on this path: XLSX and XLSB go through a pipeline that already overlaps inflate and parse. + +#### Delivery guarantees + +- **`ParseAsync` and `AggregateAsync`** are exact on every format. `ParseAsync` yields the same items in the same order as the sequential parse; `AggregateAsync` equals its own single-threaded run. +- **XLSB** delivers every row exactly once, in `ForEachAsync` too. +- **XLSX** delivers exactly once for a well-formed sheet. The pipeline cuts the inflated sheet at what looks like a row start; on a sheet where those bytes sit inside cell content (a CDATA run, a comment) or the file is truncated, a worker detects it, and the read recovers by reading the rest sequentially. `ParseAsync` and `AggregateAsync` stay exact. In `ForEachAsync`, workers may already have called the callback for chunks that are then dropped, so on such a sheet rows can be delivered twice, or be rows the sequential reader would not produce. +- **CSV** is at least once in `ForEachAsync`, unchanged from v6: the callback may be invoked more than once for the same record. A partition whose start offset was guessed wrongly is read again from the confirmed offset, and the discarded pass may already have delivered a whole partition's worth of records, 1 MiB to 64 MiB of them, not just a few near the seam. On a source with quoted fields those records may also be misparsed, carrying values that appear nowhere in the file, and an exception the callback threw during a discarded pass is discarded with it. A start is only guessed wrongly inside a quoted field, so a source in which the quote character never appears delivers every record exactly once. For that guarantee on any source, use `AggregateAsync`, where the discarded partition's accumulator is thrown away, and apply side effects once it returns. + +A callback that writes to a database or sends messages must be idempotent on CSV, and on XLSX if the sheet may be malformed, and must also tolerate records that do not exist: deduplicating on a key does not filter those out. + +`ExcelParseException.Row` on `ParseAsync` counts the rows emitted so far. On `ForEachAsync` and `AggregateAsync` it is the exact row when the read is sequential and `0` when it was partitioned. + +See the [parallel sheet benchmarks](../performance/benchmarks.md#parallel-sheet-reading) and the [parallel CSV benchmarks](../performance/benchmarks.md#parallel-csv) for what this buys. On a real-data XLSX the sheet stage drops from 29 ms (prefetched, sequential) to about 18 ms, and on XLSB from 16 ms to about 13 ms; the floor is inflate, which runs on one thread. CSV has no such floor: its `AggregateAsync` at dop 16 is ~2.8x faster than the typed path while allocating ~410x less, with zero garbage collections. Properties need setters. A get-only property is skipped during binding and silently receives nothing. @@ -216,7 +261,7 @@ if (workbook.TryGetTable("Sales", out ExcelTable? sales)) ## Reading sheets in parallel -Each sheet of a workbook can be read on its own thread: +This is one thread per sheet. To read a single sheet on several threads, see [Parallel reading](#parallel-reading-opt-in). Each sheet of a workbook can be read on its own thread: ```csharp using XlsxWorkbook workbook = Excel.FromXlsxFile(path); diff --git a/docs/performance/reader-performance.md b/docs/performance/reader-performance.md index 02b94976..46031f41 100644 --- a/docs/performance/reader-performance.md +++ b/docs/performance/reader-performance.md @@ -488,9 +488,12 @@ starts. Typed models, allocating, own interleave: | dop 6 | 5.068 ms (2.80x) | | dop 12 | 4.107 ms (3.46x) | -This is the one place in the library where parallelism converts, and it converts because CSV has no -inflate floor. It is only available on the typed path: `Row` and `Cell` are `ref struct`s and cannot -cross a thread boundary, so raw row enumeration hits the same wall as XLSX. +This was the one place in the library where parallelism converted, and it converted because CSV has no +inflate floor. Since v7 `SheetParallel` also reads XLSX and XLSB on several threads (see "Parallel intra-sheet +parsing" below), with a smaller gain because inflate is still sequential. Parallel reading is only available on +the typed path: `Row` and `Cell` are `ref struct`s and cannot cross a thread boundary, so raw row enumeration +hits the same wall. `CsvParallel` is named `SheetParallel` in v7; the entries in this file keep the names they +were measured under. ## Approaches measured and rejected @@ -524,6 +527,37 @@ because inflate is then all that is left. It would also only ever serve the typed-parsing layer: `Row`/`Cell` are `ref struct` and cannot cross a thread boundary. +**Update 2026-10-10: built.** `InflateStream` moved the inflate floor, so the prefetched XLSX read became +parse-bound and the reasoning above no longer holds. A spike (Ryzen 7 5700X, real-data 65K corpus, min of 30 +interleaved rounds, sheet stage only, outputs identical to sequential) measured: + +| | XLSX (6.7 → 34.4 MB) | XLSB (3.7 → 15.9 MB) | +|---|---|---| +| read today, no prefetch / prefetch | 44.6 / 29.2 ms | 26.5 / 15.8 ms | +| inflate only, whole buffer | 14.9 ms | 9.5 ms | +| parse only, dop 1 / 2 / 4 / 8 | 29.0 / 14.8 / 7.8 / 4.2 ms | 15.4 / 7.8 / 4.0 / 2.3 ms | +| A: 1 inflate thread + N parse workers (N ≥ 2) | 16.0 ms (1.8x) | 12.5 ms (1.25x) | +| B oracle: parallel inflate + parse, 8 threads | 7.7 ms | 3.6 ms | + +A is what shipped: `SheetParallel` over XLSX and XLSB, one producer that inflates and cuts row-aligned chunks, +`dop - 1` parse workers, an ordered merge (see ARCHITECTURE.md). The spike's consumer was light and its buffers +were pre-touched, so those numbers are the shape of the gain, not a promise. Measured on the built pipeline +(JIT harness, same corpus and method, light consumer, 256 KB chunks): + +| | XLSX | XLSB | +|---|---|---| +| prefetched sequential read | 28.35 ms | 15.65 ms | +| pipeline, dop 3 to 16 | 17.6 ms (1.61x) | 13.3 ms (1.18x) | +| typed `ParseAsync`, sequential / dop 4 | 55.4 / 18.9 ms | 37.6 / 14.4 ms | +| `xl_parse_typed` over the published NativeAOT DLL, dop 1 / dop 0 | 54.1 / 20.3 ms (2.6x) | 28.1 / 15.2 ms (1.8x) | + +It saturates at dop 3, one producer plus two workers: dop 2 is a plain overlap of inflate and parse and gains +nothing. The chunk size defaults to 256 KB, down from 1 MB: it removes ~0.5 ms of serial tail, and 4 MB adds +~3.7 ms. XLSB stops at ~13 ms against a 9.6 ms inflate floor because the producer walks every BIFF12 record +(~2.2 ms) and `InflateStream.Read` adds a copy (~0.7 ms). Only parallel inflate moves that. The +BenchmarkDotNet tables for this are in `benchmarks.md`; they were taken on a loaded machine and are pending an +idle rerun. + ### Faster inflate — measured separately, no gain libdeflate was measured against `System.IO.Compression` on this workload before this investigation: @@ -1115,6 +1149,13 @@ used to yield a silent prefix of its rows. `InflateStream` throws `InvalidDataEx ## Known open items +- **Parallel inflate is the open lead (spec B, not built).** It replaces the pipeline's producer only; the + queue, workers and merge stay. The oracle ceilings at 8 threads, parallel inflate plus parse, are 7.7 ms for + XLSX and 3.6 ms for XLSB, against 17.6 and 13.3 ms for the shipped pipeline. A deflate block finder costs + 0.4–1 ms per split point, with no false positives in 14 hits. The marker finding: XLSX back-references stop + depending on the unknown window after about 1 MB, but XLSB ones never do, so about 65–70% of XLSB bytes need + a second resolution pass. The cost of the 16-bit marker decode loop is unmeasured, and it decides whether + XLSB gains much. - `InflateStream` reaches about three quarters of libdeflate's inflate speed on XLSX. The Tier1 disassembly shows no spills and 88% of the time in `FastLoop` itself (see "The decode loop's Tier1 disassembly"); the remainder is JIT codegen. A `uint bitsleft` is worth ~1.5% if anyone wants it. diff --git a/python/README.md b/python/README.md index d04edc65..bde9d4d3 100644 --- a/python/README.md +++ b/python/README.md @@ -177,11 +177,11 @@ once, typed or Arrow (see [Arrow](#arrow) below), and none disturbs another. Being a generator, `iter_parse_typed()` opens nothing until the first iteration, so a bad `batch_size` is only raised there, not at the call. -#### Reading a large CSV on several threads +#### Reading a large sheet on several threads `parse_typed()`, `to_arrow()`, `to_record_batch()` and `to_polars()` take `parallelism`: `1` (the -default) reads on one thread, `0` uses every core, `n` up to n threads. Only CSV is split. Any other -format, or a CSV under 256 KiB, is read on one thread with the same result. The table is identical +default) reads on one thread, `0` uses every core, `n` up to n threads. XLSX, XLSB and CSV are split. XLS, or a +sheet under 256 KiB, is read on one thread with the same result. The table is identical either way. Every partition's columns are held until they are merged, so the peak memory is higher than a sequential read of the same file. `to_polars()` with `parallelism` other than 1 parses the whole file instead of streaming it in batches. @@ -193,6 +193,11 @@ file instead of streaming it in batches. | `65K_Records_Data.csv` (8 MB, 65,535 rows) | 21.4 ms | 5.8 ms | 3.7x | | the same rows ×20 (160 MB, 1.3M rows) | 425.9 ms | 117.5 ms | 3.6x | +The same call on the real-data workbooks (XLSX 6.7 MB compressed, XLSB 3.7 MB; measured with the +published NativeAOT library): XLSX 54.1 ms at +`parallelism=1` against 20.3 ms at `0` (2.6x), XLSB 28.1 ms against 15.2 ms (1.8x). Inflate runs on +one thread, so these formats stop scaling at about three threads. + Peak working set on the 160 MB file, above the interpreter's own, went from 470 MiB to 503 MiB (both include the file's bytes and the handle's copy of them). @@ -553,8 +558,8 @@ CSV, from `65K_Records_Data.csv` (8.2 MB), same 14 typed columns: polars' CSV reader is ahead (multithreaded, and it infers types from the text without our schema step); ExcelReader is ~10x faster than pandas on either engine. These include opening the file, so -they are above the in-memory `xl_parse_typed` figures in [Reading a large CSV on several -threads](#reading-a-large-csv-on-several-threads). +they are above the in-memory `xl_parse_typed` figures in [Reading a large sheet on several +threads](#reading-a-large-sheet-on-several-threads). ### Writing diff --git a/rust/excelreader/README.md b/rust/excelreader/README.md index 61c5412a..ea970955 100644 --- a/rust/excelreader/README.md +++ b/rust/excelreader/README.md @@ -71,7 +71,7 @@ for column in workbook.sheet(1)?.infer_schema(1, 100)? { `infer_schema_parse_text(header_row, sample_size)` also types text cells (every CSV field, or numbers stored as text): integers, decimals, `true`/`false` and ISO dates. `parse_sheet_parallel` and `arrow::parse_arrow_parallel` take a `degree_of_parallelism` (`0` = every processor, `1` = -sequential); only CSV is split, and the result equals `parse_sheet`'s. +sequential); XLSX, XLSB and CSV are split, and the result equals `parse_sheet`'s. A workbook hands out sheets. A `Sheet` is the workbook and an index: it holds no native resource, it is `Copy`, and every read on it opens its own cursor. @@ -369,6 +369,9 @@ in this crate: a .NET thread resumes at address 0 after a signal the runtime sen process with no message. It happens with any `degree_of_parallelism` above 1, and the same runtime built into a plain C host does not show it, so a Rust host appears to make it far more likely. +`parse_sheet_parallel` and `arrow::parse_arrow_parallel` now fan out on XLSX and XLSB too; this has not been tested +on macOS, so treat them the same way. + Until it is fixed upstream, pass `degree_of_parallelism: 1` on macOS for a sequential run, or keep sources under the 256 KB partitioning floor, where the library never fans out. Linux and Windows are unaffected. The crate's own partitioned aggregation tests are `#[ignore]`d on macOS for this reason. diff --git a/src/ExcelReader.Native/README.md b/src/ExcelReader.Native/README.md index 546e1b6f..dd0154f1 100644 --- a/src/ExcelReader.Native/README.md +++ b/src/ExcelReader.Native/README.md @@ -37,7 +37,7 @@ trace (needs `dotnet-trace` and `dotnet-pgo`): The XLSB methods are excluded on purpose: with their profile the XLSB parse got 3% slower, and without it 1.12x faster. Re-measured with the regenerated profile: including them makes XLSB typed -parsing ~16% slower. Measure every format before and after replacing the profile. Some formats +parsing ~22% slower. Measure every format before and after replacing the profile. Some formats may get worse, not only better. ## Layout From 0ffacdef2d96b4dbf936ecd55ee557a1419a2648 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Sun, 11 Oct 2026 00:49:08 -0300 Subject: [PATCH 080/115] fix: surface a source stream's own cancellation from the sheet pipeline Co-Authored-By: Claude Sonnet 5.5 --- .../ParallelCsv/ParallelCsvEnumerable.cs | 5 -- .../Parser/ParallelCsv/ParallelCsvFactory.cs | 2 +- .../SheetPipeline/SheetPipelineRunner.cs | 3 +- .../ParallelCsv/ParallelCsvEnumerableTests.cs | 2 +- .../Parser/SheetPipeline/XlsxPipelineTests.cs | 68 +++++++++++++++++++ 5 files changed, 72 insertions(+), 8 deletions(-) diff --git a/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvEnumerable.cs b/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvEnumerable.cs index edb39806..82f862d2 100644 --- a/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvEnumerable.cs +++ b/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvEnumerable.cs @@ -3,7 +3,6 @@ using ExcelReader.Core.Parser.Internal; using ExcelReader.Core.Reader.Csv; using ExcelReader.Core.Reader.Internal; -using Microsoft.Win32.SafeHandles; namespace ExcelReader.Core.Parser.ParallelCsv { @@ -18,7 +17,6 @@ internal sealed class ParallelCsvEnumerable : IAsyncEnumerable private readonly ExcelParserConfig _config; private readonly int _dop; - private readonly SafeFileHandle? _ownedHandle; private readonly ReaderLifetime? _lease; private readonly object? _owner; @@ -31,7 +29,6 @@ internal ParallelCsvEnumerable( CsvReaderOptions readerOptions, ExcelParserConfig config, int degreeOfParallelism, - SafeFileHandle? ownedHandle, ReaderLifetime? lease = null, object? owner = null) { @@ -43,7 +40,6 @@ internal ParallelCsvEnumerable( _readerOptions = readerOptions; _config = config; _dop = degreeOfParallelism; - _ownedHandle = ownedHandle; _lease = lease; _owner = owner; } @@ -97,7 +93,6 @@ public async IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellati { await cts.CancelAsync().ConfigureAwait(false); await allWorkers.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); - _ownedHandle?.Dispose(); _lease?.Release(); } } diff --git a/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvFactory.cs b/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvFactory.cs index 34babc94..9a34134c 100644 --- a/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvFactory.cs +++ b/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvFactory.cs @@ -71,7 +71,7 @@ private static IAsyncEnumerable Build( return Sequential(source.OpenReader(options), parser, ct); } - return new ParallelCsvEnumerable(source, plan, dataStart, map, info, options, config, dop, null, lease, owner); + return new ParallelCsvEnumerable(source, plan, dataStart, map, info, options, config, dop, lease, owner); } private static CsvEnumerable Sequential( diff --git a/src/ExcelReader.Core/Parser/SheetPipeline/SheetPipelineRunner.cs b/src/ExcelReader.Core/Parser/SheetPipeline/SheetPipelineRunner.cs index 88413e03..a2262549 100644 --- a/src/ExcelReader.Core/Parser/SheetPipeline/SheetPipelineRunner.cs +++ b/src/ExcelReader.Core/Parser/SheetPipeline/SheetPipelineRunner.cs @@ -131,7 +131,7 @@ await ChunkProducer.RunAsync( }, ct).ConfigureAwait(false); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (ct.IsCancellationRequested) { } catch (Exception ex) @@ -158,6 +158,7 @@ private static async Task WorkAsync( catch (Exception ex) { item.Done.TrySetException(ex); + _ = item.Done.Task.Exception; throw; } finally diff --git a/tests/ExcelReader.Tests/Parser/ParallelCsv/ParallelCsvEnumerableTests.cs b/tests/ExcelReader.Tests/Parser/ParallelCsv/ParallelCsvEnumerableTests.cs index cc18ecb0..0d99f848 100644 --- a/tests/ExcelReader.Tests/Parser/ParallelCsv/ParallelCsvEnumerableTests.cs +++ b/tests/ExcelReader.Tests/Parser/ParallelCsv/ParallelCsvEnumerableTests.cs @@ -39,7 +39,7 @@ private static ParallelCsvEnumerable Build(byte[] csv, int dop, int CsvChunkPlan plan = CsvChunkPlan.Create(dataStart, csv.Length - dataStart, dop, chunkSizeOverride); return new ParallelCsvEnumerable( source, plan, dataStart, map, TypeMapper.GetCsvInfo(), - CsvReaderOptions.Default, config, dop, null); + CsvReaderOptions.Default, config, dop); } private static async Task AssertSameFailureAsSequentialAsync(byte[] csv, ExcelParserConfig config) diff --git a/tests/ExcelReader.Tests/Parser/SheetPipeline/XlsxPipelineTests.cs b/tests/ExcelReader.Tests/Parser/SheetPipeline/XlsxPipelineTests.cs index 45e949b6..f58ccccd 100644 --- a/tests/ExcelReader.Tests/Parser/SheetPipeline/XlsxPipelineTests.cs +++ b/tests/ExcelReader.Tests/Parser/SheetPipeline/XlsxPipelineTests.cs @@ -82,6 +82,74 @@ public async Task MatchesSequential(int dop, int chunkBytes) Assert.Equal(Sequential(workbook, 3), await PipelineAsync(workbook, 3, dop, chunkBytes)); } + [Fact] + public async Task SourceStreamCancellationIsNotSwallowed() + { + using var ms = WorkbookBuilder.Build(Rows(20000)); + using var stream = new CancelAfterArmedStream(ms); + using XlsxWorkbook workbook = Excel.FromXlsx(stream); + SheetPipelineSource source = SheetPipelineSource.Create(workbook.FirstSheet)!; + stream.Arm(100 * 1024); + + await Assert.ThrowsAnyAsync(() => + SheetPipelineRunner.AggregateAsync(source, Collecting, null, 0, 2, 64, CancellationToken.None)); + } + + private sealed class CancelAfterArmedStream(Stream inner) : Stream + { + private long _remaining = long.MaxValue; + + public void Arm(long budget) => _remaining = budget; + + public override bool CanRead => inner.CanRead; + public override bool CanSeek => inner.CanSeek; + public override bool CanWrite => false; + public override long Length => inner.Length; + public override long Position + { + get => inner.Position; + set => inner.Position = value; + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + int n = inner.Read(buffer, offset, count); + Spend(n); + return n; + } + + public override int Read(Span buffer) + { + int n = inner.Read(buffer); + Spend(n); + return n; + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + int n = await inner.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + Spend(n); + return n; + } + + public override long Seek(long offset, SeekOrigin origin) => inner.Seek(offset, origin); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + private void Spend(int n) + { + _remaining -= n; + if (_remaining < 0) + { + throw new OperationCanceledException("source timeout"); + } + } + } + [Theory] [InlineData("")] [InlineData("1")] From 876d9ac88818639d0bc1f13b8f5ee2e000787aa5 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Sun, 11 Oct 2026 00:50:41 -0300 Subject: [PATCH 081/115] docs: correct the CSV fallback rule and sample variables Co-Authored-By: Claude Sonnet 5.5 --- ARCHITECTURE.md | 4 ++-- docs/guide/migrating-to-v7.md | 12 ++++++------ docs/guide/reading.md | 4 ++-- docs/performance/reader-performance.md | 7 +++---- python/README.md | 4 ++-- src/ExcelReader.Core/Reader/SheetParallel.cs | 4 ++-- 6 files changed, 17 insertions(+), 18 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 67d45d6e..84bc9600 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -59,8 +59,8 @@ opt-in, takes any `IExcelSheet`, validates its arguments and dispatches on the s | anything else, and every fallback case | one sequential runner over `IExcelRowEnumerator` | The fallback cases give the same results with no error: a degree of parallelism of 1, a sheet under 256 KB -uncompressed, XLS, a table view (`ExcelTable.AsSheet`), and a CSV that cannot be partitioned (a non-seekable -stream or a non-UTF-8 encoding). The caller owns the workbook; the engine holds a `ReaderLifetime` lease +uncompressed, XLS, a table view (`ExcelTable.AsSheet`), and a CSV that cannot be partitioned (a stream that is not a file +or a non-UTF-8 encoding). The caller owns the workbook; the engine holds a `ReaderLifetime` lease until the task completes or the enumeration ends. The CSV engine is not folded into a shared partition abstraction. It is tuned, and its model, a static diff --git a/docs/guide/migrating-to-v7.md b/docs/guide/migrating-to-v7.md index 1bd4f4bd..ca2660f7 100644 --- a/docs/guide/migrating-to-v7.md +++ b/docs/guide/migrating-to-v7.md @@ -26,13 +26,13 @@ A CSV file: ```csharp // v6 -await foreach (Sale s in CsvParallel.ParseAsync(path, parser, new CsvParallelOptions { DegreeOfParallelism = 8, Reader = dialect })) +await foreach (Sale s in CsvParallel.ParseAsync(path, parser, new CsvParallelOptions { DegreeOfParallelism = 8, Reader = options })) { Total += s.Revenue; } // v7 -using CsvReader csv = Excel.FromCsvFile(path, dialect); +using CsvReader csv = Excel.FromCsvFile(path, options); await foreach (Sale s in SheetParallel.ParseAsync(csv.FirstSheet, parser, new SheetParallelOptions { DegreeOfParallelism = 8 })) { Total += s.Revenue; @@ -43,13 +43,13 @@ CSV bytes in memory: ```csharp // v6 -await foreach (Sale s in CsvParallel.ParseAsync(data, parser, new CsvParallelOptions { DegreeOfParallelism = 8, Reader = dialect })) +await foreach (Sale s in CsvParallel.ParseAsync(data, parser, new CsvParallelOptions { DegreeOfParallelism = 8, Reader = options })) { Total += s.Revenue; } // v7 -using CsvReader csv = Excel.FromCsv(data, dialect); +using CsvReader csv = Excel.FromCsv(data, options); await foreach (Sale s in SheetParallel.ParseAsync(csv.FirstSheet, parser, new SheetParallelOptions { DegreeOfParallelism = 8 })) { Total += s.Revenue; @@ -60,13 +60,13 @@ A stream: ```csharp // v6 -await foreach (Sale s in CsvParallel.ParseAsync(stream, parser, new CsvParallelOptions { DegreeOfParallelism = 8, Reader = dialect })) +await foreach (Sale s in CsvParallel.ParseAsync(stream, parser, new CsvParallelOptions { DegreeOfParallelism = 8, Reader = options })) { Total += s.Revenue; } // v7 -using CsvReader csv = Excel.FromCsv(stream, leaveOpen: true, dialect); +using CsvReader csv = Excel.FromCsv(stream, leaveOpen: true, options); await foreach (Sale s in SheetParallel.ParseAsync(csv.FirstSheet, parser, new SheetParallelOptions { DegreeOfParallelism = 8 })) { Total += s.Revenue; diff --git a/docs/guide/reading.md b/docs/guide/reading.md index 53ae2455..2454130d 100644 --- a/docs/guide/reading.md +++ b/docs/guide/reading.md @@ -41,7 +41,7 @@ await foreach (var row in SheetParallel.ParseAsync(workbook.FirstSheet, ExcelPar } ``` -You own the workbook: keep it open until the task completes or the enumeration ends. For CSV, give the dialect when you open it (`Excel.FromCsvFile(path, dialect)`), not in the options. `DegreeOfParallelism` of `0` (the default) uses every processor; `1` reads sequentially. +You own the workbook: keep it open until the task completes or the enumeration ends. For CSV, give the dialect when you open it (`Excel.FromCsvFile(path, options)`), not in the options. `DegreeOfParallelism` of `0` (the default) uses every processor; `1` reads sequentially. There are three families, each over a sheet: @@ -108,7 +108,7 @@ These cases read on one thread with the same results and no error: - A sheet under 256 KB uncompressed. - XLS. - A table view (`ExcelTable.AsSheet()`). -- A CSV that cannot be partitioned: a non-seekable stream or a non-UTF-8 encoding. A CSV over a `MemoryStream` is one of them; pass the bytes as `ReadOnlyMemory` to partition it. +- A CSV that cannot be partitioned: a stream that is not a file, or a non-UTF-8 encoding. Only in-memory data (`ReadOnlyMemory`, `byte[]`) and a `FileStream`-backed source partition; a CSV over a `MemoryStream` does not, so pass its bytes as `ReadOnlyMemory`. `PrefetchDecompression` does not apply on this path: XLSX and XLSB go through a pipeline that already overlaps inflate and parse. diff --git a/docs/performance/reader-performance.md b/docs/performance/reader-performance.md index 46031f41..6a77667c 100644 --- a/docs/performance/reader-performance.md +++ b/docs/performance/reader-performance.md @@ -549,9 +549,9 @@ were pre-touched, so those numbers are the shape of the gain, not a promise. Mea | prefetched sequential read | 28.35 ms | 15.65 ms | | pipeline, dop 3 to 16 | 17.6 ms (1.61x) | 13.3 ms (1.18x) | | typed `ParseAsync`, sequential / dop 4 | 55.4 / 18.9 ms | 37.6 / 14.4 ms | -| `xl_parse_typed` over the published NativeAOT DLL, dop 1 / dop 0 | 54.1 / 20.3 ms (2.6x) | 28.1 / 15.2 ms (1.8x) | +| `xl_parse_typed` over the published NativeAOT DLL, dop 1 / dop 0 | 54.1 / 20.3 ms (2.6x) | 28.2 / 15.2 ms (1.85x) | -It saturates at dop 3, one producer plus two workers: dop 2 is a plain overlap of inflate and parse and gains +Under the JIT the sheet stage stops scaling at dop 3, one producer plus two workers: dop 2 is a plain overlap of inflate and parse and gains nothing. The chunk size defaults to 256 KB, down from 1 MB: it removes ~0.5 ms of serial tail, and 4 MB adds ~3.7 ms. XLSB stops at ~13 ms against a 9.6 ms inflate floor because the producer walks every BIFF12 record (~2.2 ms) and `InflateStream.Read` adds a copy (~0.7 ms). Only parallel inflate moves that. The @@ -1153,8 +1153,7 @@ used to yield a silent prefix of its rows. `InflateStream` throws `InvalidDataEx queue, workers and merge stay. The oracle ceilings at 8 threads, parallel inflate plus parse, are 7.7 ms for XLSX and 3.6 ms for XLSB, against 17.6 and 13.3 ms for the shipped pipeline. A deflate block finder costs 0.4–1 ms per split point, with no false positives in 14 hits. The marker finding: XLSX back-references stop - depending on the unknown window after about 1 MB, but XLSB ones never do, so about 65–70% of XLSB bytes need - a second resolution pass. The cost of the 16-bit marker decode loop is unmeasured, and it decides whether + depending on the unknown window after about 1 MB, but XLSB ones never do, so about 65–70% of XLSB output bytes depend on the unknown window. The cost of the 16-bit marker decode loop is unmeasured, and it decides whether XLSB gains much. - `InflateStream` reaches about three quarters of libdeflate's inflate speed on XLSX. The Tier1 disassembly shows no spills and 88% of the time in `FastLoop` itself (see "The decode loop's Tier1 diff --git a/python/README.md b/python/README.md index bde9d4d3..16bf8f9f 100644 --- a/python/README.md +++ b/python/README.md @@ -195,8 +195,8 @@ file instead of streaming it in batches. The same call on the real-data workbooks (XLSX 6.7 MB compressed, XLSB 3.7 MB; measured with the published NativeAOT library): XLSX 54.1 ms at -`parallelism=1` against 20.3 ms at `0` (2.6x), XLSB 28.1 ms against 15.2 ms (1.8x). Inflate runs on -one thread, so these formats stop scaling at about three threads. +`parallelism=1` against 20.3 ms at `0` (2.6x), XLSB 28.2 ms against 15.2 ms (1.85x). Inflate runs on +one thread, so under the JIT the sheet stage stops scaling at about three threads. Peak working set on the 160 MB file, above the interpreter's own, went from 470 MiB to 503 MiB (both include the file's bytes and the handle's copy of them). diff --git a/src/ExcelReader.Core/Reader/SheetParallel.cs b/src/ExcelReader.Core/Reader/SheetParallel.cs index 5d643ef8..8406aa60 100644 --- a/src/ExcelReader.Core/Reader/SheetParallel.cs +++ b/src/ExcelReader.Core/Reader/SheetParallel.cs @@ -19,8 +19,8 @@ namespace ExcelReader.Core.Reader /// /// A read falls back to one sequential pass, with the same results and no error, when /// is 1, the sheet is too small to partition - /// usefully, the sheet is an XLS sheet or a table view, or a CSV cannot be partitioned (a non-seekable - /// stream or a non-UTF-8 encoding). + /// usefully, the sheet is an XLS sheet or a table view, or a CSV cannot be partitioned (a stream that is not a file + /// or a non-UTF-8 encoding). /// /// /// Delivery guarantees. The general contract is the weaker CSV one: ForEachAsync is at-least-once, From e56971d8128f7d62380c6007c77910fe352e08ff Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Sun, 11 Oct 2026 01:06:10 -0300 Subject: [PATCH 082/115] fix(reader): decode OOXML _xHHHH_ escapes in XLSX text Excel writes characters XML cannot hold, such as CR, as _xHHHH_ (UTF-16 code unit HHHH), and a literal "_x0041_" as "_x005F_x0041_". The library's own writer emits the same escapes, but the reader returned them as literal text, so a round trip through the library corrupted strings. Shared strings, inline strings, formula string results and table column names now decode them, including surrogate pairs; a lone surrogate or a malformed escape stays literal. HexVal also accepted bytes below '0' and '@'..'`' as hex digits because an int <= uint comparison widens to long; it now rejects them. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Reader/Xlsx/XlsxTables.cs | 2 +- .../Reader/Xlsx/XlsxWorkbook.Enumerator.cs | 6 +- .../Reader/Xlsx/XlsxWorkbook.Loading.cs | 2 +- src/ExcelReader.Core/Reader/Xlsx/XlsxXml.cs | Bin 11871 -> 14872 bytes .../Reader/Xlsx/XStringEscapeTests.cs | 123 ++++++++++++++++++ 5 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 tests/ExcelReader.Tests/Reader/Xlsx/XStringEscapeTests.cs diff --git a/src/ExcelReader.Core/Reader/Xlsx/XlsxTables.cs b/src/ExcelReader.Core/Reader/Xlsx/XlsxTables.cs index 2899e7b6..0e906ad5 100644 --- a/src/ExcelReader.Core/Reader/Xlsx/XlsxTables.cs +++ b/src/ExcelReader.Core/Reader/Xlsx/XlsxTables.cs @@ -44,7 +44,7 @@ private static string[] ColumnNames(ReadOnlySpan xml, ReadOnlySpan p List names = []; foreach (ReadOnlySpan column in new TagSpanEnumerable(xml, XlsxXml.Token("<"u8, prefix, "tableColumn"u8))) { - names.Add(XlsxXml.DecodeToString(XlsxXml.Attr(column, " name="u8))); + names.Add(XlsxXml.DecodeXStringToString(XlsxXml.Attr(column, " name="u8))); } return [.. names]; } diff --git a/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Enumerator.cs b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Enumerator.cs index 1f7c928d..12741b73 100644 --- a/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Enumerator.cs +++ b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Enumerator.cs @@ -516,7 +516,7 @@ private int TryEmitPlainInline(byte[] buf, int len, int p, int col, int style) return -1; } int lt = textStart + rel; - if (!buf.AsSpan(lt, Math.Min(13, len - lt)).StartsWith(""u8)) + if (!buf.AsSpan(lt, Math.Min(13, len - lt)).StartsWith(""u8) || buf.AsSpan(textStart, rel).IndexOf("_x"u8) >= 0) { return -1; } @@ -878,7 +878,7 @@ private void EmitScalarValue(Kind kind, ReadOnlySpan v, int col, int style private void EmitScalarValueFast(Kind kind, ReadOnlySpan v, int valueStart, int col, int style) { - if (kind == Kind.IsoDate || (kind == Kind.Formula && v.IndexOf((byte)'&') >= 0)) + if (kind == Kind.IsoDate || (kind == Kind.Formula && (v.IndexOf((byte)'&') >= 0 || v.IndexOf("_x"u8) >= 0))) { EmitScalarValue(kind, v, col, style); return; @@ -964,7 +964,7 @@ private void AppendDecoded(ReadOnlySpan src) return; } Span dst = _acc.ReserveValueSpan(src.Length); - _acc.Advance(XlsxXml.Decode(src, dst)); + _acc.Advance(XlsxXml.UnescapeXString(dst[..XlsxXml.Decode(src, dst)])); } private void AppendRaw(ReadOnlySpan src) diff --git a/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Loading.cs b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Loading.cs index 80df6e9f..4e06ef2c 100644 --- a/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Loading.cs +++ b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Loading.cs @@ -278,7 +278,7 @@ private int TryAppendPlainShared(BufferedStreamCursor io, SharedStringTokens tok } EnsureSharedFlat(flat + rel, flat); text[..rel].CopyTo(_sharedFlat.AsSpan(flat)); - flat += rel; + flat += XlsxXml.UnescapeXString(_sharedFlat.AsSpan(flat, rel)); return io.Pos + 7 + rel + 9; } diff --git a/src/ExcelReader.Core/Reader/Xlsx/XlsxXml.cs b/src/ExcelReader.Core/Reader/Xlsx/XlsxXml.cs index 8cc6c831a52440b848723176371330db5c649f03..783da178639a4fb111ae3e0dec011c5c4a114dc0 100644 GIT binary patch delta 1871 zcmaJ?&rcgy5Uwp8+9C>}wgatA#suTt+AMYT|Do`*Ll4Dtm{cNwaee3SN zjg1s3>VH7>F1_@BNX4B~&s8ryxA`-A>&)8)>?G8eWp`%2`R1D+{QdkF@%ke1YGVTq z>#bu?xEAk$U?TzEfZ4VOCLGmw)=On*I~@i-w*;6Tv_`x5Yk_9+4!YQuV6JJyN^4Xs zZk1Xq+>7RCNEl;}@t)|*pKhh+xPYOHwIh=9 z`Ej}SZQJ3V{{xm$gU5_S3u!!2oHT-Hkh5Vl3o3s@L8w^GP_^0xp<*Au# z%$hWp;&Oj6nNmP1G@oyVr6^#0m2YNOWp6$|ugc8Vuz3L~Y0e~LDo9q+X?d1eSfhNL z8Xl-|$?Yvwh)R!KU7w?;3aCO6z78Y|G=X&`13XmrNgYW`CEuZKwV9H%Kw!G@jWal{ zJAp6(wcqBG`XzI8c^62z0oH|9dGJwkiulOZQj(OB2ZZjl4)cV?O<_5_K!Y~XgM;P8$WS)#EUwciWa{qeGwfc7d4&6*d5-V4SrFOo zo37dtX)a{sAUYi>;-q94em0(ZyH>A%=)11dGX>LVnRHlNgBvj$+gD?ufNf`}0_X+d z_MG808rmplnY6*#QB4}so&|{txG_WBc*1EU>&}trKG4SMR-=Y=qopn48xIm(qZ8M$ zk}|Q><_(E%&Y@T2?PNT%E%yrvY|Px96UVXNQ~YI%W~mj|f?^|Hsryrd+y}YIp`g|T zZ3x#Bs;~_&UqbM)QEWn{1{xg!J=e;OSxV!U(YU2NZfWIoJPWcOy%+16w#R7ww{RU^ zn6^*rU!3IAbzIPBT`aVHS;0pY%J|6V*(84cxq~2F7);VnIc&vtcQ%BuWGMW2*>*kmfzuRT5@yMggp%n`l O7euSz^M7;QTmJ!TFk8U@ delta 80 zcmV-W0I&a;bl+UCz7>-oFc7oKA2SD&87CZ*k`xlNNhgp8liw~>lQ}O`lesV%le!-e mlQ1w1lkPAdlWHg#votXx0ReZDfHL9&cPW#BG99zBGiC<5 RoundTripText() + { + return new() + { + "a\rb", + "line1\r\nline2", + "a\u0001b _x0041_", + "_x000D_", + "x_y_z", + "trailing_", + "_x0041", + "\u001F\u0000", + }; + } + + private static List ReadFirstColumn(Stream workbook) + { + using XlsxWorkbook reader = Excel.FromXlsx(workbook); + var values = new List(); + foreach (Row row in reader.FirstSheet) + { + values.Add(row[0].GetString()); + } + return values; + } + + [Theory] + [MemberData(nameof(RoundTripText))] + public async Task TextWrittenByTheLibraryReadsBackUnchanged(string text) + { + foreach (bool shared in new[] { false, true }) + { + var ms = new MemoryStream(); + await using (XlsxWorkbookWriter wb = XlsxWorkbookWriter.Create(ms, leaveOpen: true, new XlsxWriterOptions { UseSharedStrings = shared })) + { + XlsxSheetWriter sheet = wb.AddSheet("S1"); + await using (XlsxRowWriter row = await sheet.StartRowAsync(TestContext.Current.CancellationToken)) + { + row.Write(text); + } + await sheet.EndAsync(TestContext.Current.CancellationToken); + await wb.EndAsync(TestContext.Current.CancellationToken); + } + ms.Position = 0; + + Assert.Equal([text], ReadFirstColumn(ms)); + } + } + + public static TheoryData ExcelEscapes() + { + return new() + { + { "a_x000D_b", "a\rb" }, + { "a_x000d_b", "a\rb" }, + { "_x000D__x000A_", "\r\n" }, + { "_x005F_x0041_", "_x0041_" }, + { "_x0041_", "A" }, + { "_xD83D__xDE00_", "\U0001F600" }, + { "_xD800_", "_xD800_" }, + { "_xDE00_x", "_xDE00_x" }, + { "_X000D_", "_X000D_" }, + { "_x00D_", "_x00D_" }, + { "_x000G_", "_x000G_" }, + { "_x00!0_", "_x00!0_" }, + { "_x00@0_", "_x00@0_" }, + { "a_x000D", "a_x000D" }, + { "&_x0026_", "&&" }, + }; + } + + [Theory] + [MemberData(nameof(ExcelEscapes))] + public void EscapesInSharedInlineAndFormulaTextAreDecoded(string xml, string expected) + { + string rows = + $"""0{xml}A1{xml}1"""; + string sharedStrings = $"""{xml}[{xml}]"""; + using MemoryStream ms = WorkbookBuilder.Build(rows, sharedStrings); + using XlsxWorkbook reader = Excel.FromXlsx(ms); + + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); + Assert.True(e.MoveNext()); + Row row = e.Current; + Assert.Equal(expected, row[0].GetString()); + Assert.Equal(expected, row[1].GetString()); + Assert.Equal(expected, row[2].GetString()); + Assert.Equal("[" + expected + "]", row[3].GetString()); + } + + [Fact] + public void APrefixedSheetDecodesEscapesInInlineText() + { + using MemoryStream ms = WorkbookBuilder.BuildPrefixed( + "x", """a_x000D_b"""); + + Assert.Equal(["a\rb"], ReadFirstColumn(ms)); + } + + [Fact] + public void ATableColumnNameWrittenByTheLibraryReadsBackUnchanged() + { + ITableBook book = TableBooks.Create("xlsx"); + book.AddSheet("S"); + book.BeginTable("Codes", ["Code_x0041_", "Line_x000D_"]); + book.Row("a", "b"); + byte[] package = book.Finish(); + + using XlsxWorkbook reader = Excel.FromXlsx(package); + Assert.True(reader.TryGetTable("Codes", out ExcelTable? table)); + Assert.Equal(["Code_x0041_", "Line_x000D_"], table.ColumnNames); + } + } +} From 60686eb02928dc679af4ec8913c27b1df2dcfcbd Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Sun, 11 Oct 2026 01:06:10 -0300 Subject: [PATCH 083/115] fix(reader): reject a streamed ZIP entry that ends short of its declared size Sheets are read through a stream that never compared the bytes it produced with the entry's declared uncompressed size; only whole-part reads did. A damaged deflate stream that decoded as an early final block therefore ended a sheet silently, mid-way (one test workbook stopped at 298 of 30,000 rows, sequential and parallel alike). The entry stream now throws InvalidDataException when it reaches its end short of the declared size, or produces more than it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Reader/Internal/WorkbookLookups.cs | 4 +- .../Reader/Zip/LimitedReadStream.cs | 32 ++++++++---- .../Reader/ReaderLimitTests.cs | 3 +- .../Reader/Xlsx/CorruptSheetEntryTests.cs | 46 +++++++++++++++++ .../Reader/Zip/ZipIndexTests.cs | 50 +++++++++++++++++++ 5 files changed, 121 insertions(+), 14 deletions(-) create mode 100644 tests/ExcelReader.Tests/Reader/Xlsx/CorruptSheetEntryTests.cs diff --git a/src/ExcelReader.Core/Reader/Internal/WorkbookLookups.cs b/src/ExcelReader.Core/Reader/Internal/WorkbookLookups.cs index 186910ae..8d13eb95 100644 --- a/src/ExcelReader.Core/Reader/Internal/WorkbookLookups.cs +++ b/src/ExcelReader.Core/Reader/Internal/WorkbookLookups.cs @@ -85,9 +85,9 @@ internal static LimitedReadStream Wrap( { if (!options.PrefetchDecompression || uncompressedSize < PrefetchMinUncompressedSize) { - return new LimitedReadStream(opened, counter, entryLimitName, entryLimit); + return new LimitedReadStream(opened, uncompressedSize, counter, entryLimitName, entryLimit); } - return new LimitedReadStream(new PrefetchStream(opened), counter, entryLimitName, entryLimit); + return new LimitedReadStream(new PrefetchStream(opened), uncompressedSize, counter, entryLimitName, entryLimit); } internal static int InitialBufferCapacity(long entryLength) diff --git a/src/ExcelReader.Core/Reader/Zip/LimitedReadStream.cs b/src/ExcelReader.Core/Reader/Zip/LimitedReadStream.cs index 185a25ba..a1863f9a 100644 --- a/src/ExcelReader.Core/Reader/Zip/LimitedReadStream.cs +++ b/src/ExcelReader.Core/Reader/Zip/LimitedReadStream.cs @@ -33,14 +33,20 @@ internal sealed class LimitedReadStream : Stream private readonly Stream _inner; private readonly DecompressedByteCounter? _totalCounter; private readonly DecompressedByteCounter? _entryCounter; + private readonly long _declaredLength; + private long _produced; + // declaredLength is the entry's size from the central directory. Neither raw deflate nor a stored + // entry can tell a cut-short stream from a complete one, so the length is the only end-of-data check. internal LimitedReadStream( Stream inner, + long declaredLength, DecompressedByteCounter? totalCounter, string entryLimitName = "", long entryLimit = 0) { _inner = inner; + _declaredLength = declaredLength; _totalCounter = totalCounter; _entryCounter = entryLimit > 0 ? new DecompressedByteCounter(entryLimit, entryLimitName) : null; } @@ -63,23 +69,17 @@ public override void Flush() public override int Read(byte[] buffer, int offset, int count) { - int read = _inner.Read(buffer, offset, count); - Count(read); - return read; + return Count(_inner.Read(buffer, offset, count), count); } public override int Read(Span buffer) { - int read = _inner.Read(buffer); - Count(read); - return read; + return Count(_inner.Read(buffer), buffer.Length); } public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) { - int read = await _inner.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); - Count(read); - return read; + return Count(await _inner.ReadAsync(buffer, cancellationToken).ConfigureAwait(false), buffer.Length); } public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) @@ -117,14 +117,24 @@ public override async ValueTask DisposeAsync() await base.DisposeAsync().ConfigureAwait(false); } - private void Count(int read) + private int Count(int read, int requested) { if (read <= 0) { - return; + if (requested > 0 && _produced != _declaredLength) + { + throw new InvalidDataException("The ZIP entry produced less data than its declared uncompressed size."); + } + return read; + } + _produced += read; + if (_produced > _declaredLength) + { + throw new InvalidDataException("The ZIP entry produced more data than its declared uncompressed size."); } _totalCounter?.Add(read); _entryCounter?.Add(read); + return read; } } } diff --git a/tests/ExcelReader.Tests/Reader/ReaderLimitTests.cs b/tests/ExcelReader.Tests/Reader/ReaderLimitTests.cs index 0153e247..4469dbaa 100644 --- a/tests/ExcelReader.Tests/Reader/ReaderLimitTests.cs +++ b/tests/ExcelReader.Tests/Reader/ReaderLimitTests.cs @@ -395,7 +395,7 @@ public void ZeroTotalDecompressedLimitRestoresUnlimitedTotalRead() public async Task LimitedReadStreamCoversUnsupportedOperationsAndByteArrayAsyncRead() { await using MemoryStream inner = new([1, 2, 3, 4, 5]); - await using LimitedReadStream limited = new(inner, new DecompressedByteCounter(limit: 10)); + await using LimitedReadStream limited = new(inner, declaredLength: 5, new DecompressedByteCounter(limit: 10)); Assert.True(limited.CanRead); Assert.False(limited.CanSeek); @@ -421,6 +421,7 @@ public void LimitedReadStreamCountsSpanReadsAndEntryLimit() using MemoryStream inner = new([1, 2, 3, 4]); using LimitedReadStream limited = new( inner, + declaredLength: 4, totalCounter: null, entryLimitName: nameof(ExcelReaderOptions.MaxCellBytes), entryLimit: 2); diff --git a/tests/ExcelReader.Tests/Reader/Xlsx/CorruptSheetEntryTests.cs b/tests/ExcelReader.Tests/Reader/Xlsx/CorruptSheetEntryTests.cs new file mode 100644 index 00000000..89f8c156 --- /dev/null +++ b/tests/ExcelReader.Tests/Reader/Xlsx/CorruptSheetEntryTests.cs @@ -0,0 +1,46 @@ +using System.Globalization; +using ExcelReader.Core.Reader; +using ExcelReader.Core.Reader.Xlsx; + +namespace ExcelReader.Tests.Reader.Xlsx +{ + public class CorruptSheetEntryTests + { + private static async Task BuildAsync(int rows) + { + object?[][] data = new object?[rows + 1][]; + data[0] = ["Name", "Qty", "Day"]; + for (int i = 0; i < rows; i++) + { + data[i + 1] = [string.Create(CultureInfo.InvariantCulture, $"item{i:D6}"), i, new DateTime(2024, 1, 1).AddDays(i % 400)]; + } + using MemoryStream ms = await TypedWorkbook.BuildAsync(data); + return ms.ToArray(); + } + + private static int CountRows(XlsxWorkbook workbook) + { + int count = 0; + foreach (Row _ in workbook.FirstSheet) + { + count++; + } + return count; + } + + // Damage at +4096 decodes as a stream that ends early: before the fix every reader stopped at 298 rows without an error. + [Theory] + [InlineData(4096, false)] + [InlineData(4096, true)] + [InlineData(300_000, false)] + public async Task ADamagedSheetEntryThrowsInsteadOfEndingEarly(int offset, bool prefetch) + { + byte[] bytes = await BuildAsync(30_000); + int entry = bytes.AsSpan().IndexOf("xl/worksheets/sheet1.xml"u8); + Array.Fill(bytes, (byte)0xFF, entry + offset, 512); + using XlsxWorkbook workbook = Excel.FromXlsx(bytes, new ExcelReaderOptions { PrefetchDecompression = prefetch }); + + Assert.Throws(() => CountRows(workbook)); + } + } +} diff --git a/tests/ExcelReader.Tests/Reader/Zip/ZipIndexTests.cs b/tests/ExcelReader.Tests/Reader/Zip/ZipIndexTests.cs index 995258a2..4328dfc1 100644 --- a/tests/ExcelReader.Tests/Reader/Zip/ZipIndexTests.cs +++ b/tests/ExcelReader.Tests/Reader/Zip/ZipIndexTests.cs @@ -881,6 +881,56 @@ public void DeflatedEntryCutShortIsRejectedWhenStreamedInsteadOfEndingEarly() Assert.Throws(() => stream.CopyTo(Stream.Null)); } + [Theory] + [InlineData(1)] + [InlineData(-1)] + public void StreamedEntryWhoseLengthDiffersFromItsDeclaredSizeIsRejected(int declaredDelta) + { + byte[] payload = Encoding.UTF8.GetBytes(string.Concat(Enumerable.Repeat("12345", 4000))); + byte[] zipBytes = BuildZipWithOneEntry("data.xml", payload, CompressionLevel.Optimal); + PatchCentralDirectoryUInt32(zipBytes, "data.xml", fieldOffset: 24, (uint)(payload.Length + declaredDelta)); + + using ZipIndex index = ZipIndex.Create(zipBytes, ExcelReaderOptions.Default); + Assert.True(index.TryGetEntry("data.xml"u8, out ZipEntryRef entry)); + using LimitedReadStream stream = index.OpenEntryStream(entry, new DecompressedByteCounter(0), ExcelReaderOptions.Default); + + InvalidDataException ex = Assert.Throws(() => stream.CopyTo(Stream.Null)); + Assert.Contains(declaredDelta > 0 ? "less data" : "more data", ex.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData(1)] + [InlineData(-1)] + public async Task StreamedEntryWhoseLengthDiffersFromItsDeclaredSizeIsRejectedAsync(int declaredDelta) + { + byte[] payload = Encoding.UTF8.GetBytes(string.Concat(Enumerable.Repeat("12345", 4000))); + byte[] zipBytes = BuildZipWithOneEntry("data.xml", payload, CompressionLevel.Optimal); + PatchCentralDirectoryUInt32(zipBytes, "data.xml", fieldOffset: 24, (uint)(payload.Length + declaredDelta)); + CancellationToken ct = TestContext.Current.CancellationToken; + + using ZipIndex index = ZipIndex.Create(zipBytes, ExcelReaderOptions.Default); + Assert.True(index.TryGetEntry("data.xml"u8, out ZipEntryRef entry)); + await using LimitedReadStream stream = await index.OpenEntryStreamAsync(entry, new DecompressedByteCounter(0), ExcelReaderOptions.Default, ct); + + await Assert.ThrowsAsync(() => stream.CopyToAsync(Stream.Null, ct)); + } + + [Fact] + public void StreamedEntryOfExactlyItsDeclaredSizeReadsToTheEnd() + { + byte[] payload = Encoding.UTF8.GetBytes(string.Concat(Enumerable.Repeat("12345", 4000))); + byte[] zipBytes = BuildZipWithOneEntry("data.xml", payload, CompressionLevel.Optimal); + + using ZipIndex index = ZipIndex.Create(zipBytes, ExcelReaderOptions.Default); + Assert.True(index.TryGetEntry("data.xml"u8, out ZipEntryRef entry)); + using LimitedReadStream stream = index.OpenEntryStream(entry, new DecompressedByteCounter(0), ExcelReaderOptions.Default); + using var copy = new MemoryStream(); + stream.CopyTo(copy); + + Assert.Equal(payload, copy.ToArray()); + Assert.Equal(0, stream.Read(new byte[16])); + } + private static byte[] BuildZipWithCutDeflatedEntry(string entryName) { byte[] payload = Encoding.UTF8.GetBytes(string.Concat(Enumerable.Repeat("12345", 4000))); From 11eb8c828b6349133b0e7c81701e90aa2a5486bf Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Sun, 11 Oct 2026 01:16:58 -0300 Subject: [PATCH 084/115] refactor!: rename ExcelParserConfig to ExcelParserOptions Every other settings type is named *Options; the parser factories' parameter is now `options`. --- .../Parser/{ExcelParserConfig.cs => ExcelParserOptions.cs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/ExcelReader.Core/Parser/{ExcelParserConfig.cs => ExcelParserOptions.cs} (100%) diff --git a/src/ExcelReader.Core/Parser/ExcelParserConfig.cs b/src/ExcelReader.Core/Parser/ExcelParserOptions.cs similarity index 100% rename from src/ExcelReader.Core/Parser/ExcelParserConfig.cs rename to src/ExcelReader.Core/Parser/ExcelParserOptions.cs From 0ece1fe5dc1b0031e9e5fff4e6580cd799790bbb Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Sun, 11 Oct 2026 01:23:32 -0300 Subject: [PATCH 085/115] refactor!: hand converters an ExcelRowContext instead of loose date-system and culture arguments IExcelCellConverter.TryConvert, ExcelCellReader, ExcelRowParser and the ExcelCellReaders methods now take `in ExcelRowContext`, so a new conversion setting is a new property rather than a signature change for every converter. ExcelRowContext gains init accessors so converters can be unit-tested. --- docs/guide/migrating-to-v7.md | 2 +- docs/guide/parsing.md | 10 +-- docs/guide/reading.md | 2 +- src/ExcelReader.Arrow/ColumnAppender.cs | 8 +-- src/ExcelReader.Core/Parser/CsvEnumerable.cs | 14 ++--- .../Parser/ExcelCellReaders.cs | 32 +++++----- .../Parser/ExcelEnumerable.cs | 6 +- .../Parser/ExcelMapDelegates.cs | 10 ++- .../Parser/ExcelParseException.cs | 4 +- src/ExcelReader.Core/Parser/ExcelParser.cs | 48 +++++++------- .../Parser/ExcelParserOptions.cs | 2 +- .../Parser/ExcelRequiredAttribute.cs | 2 +- .../Parser/ExcelRowContext.cs | 28 +++++---- .../Parser/ExcelRowMapBuilder.cs | 16 ++--- .../Parser/IExcelCellConverter.cs | 5 +- .../Parser/Internal/ColumnParserFactory.cs | 62 +++++++++---------- .../Parser/Internal/Delegates.cs | 3 +- .../Parser/Internal/RowProjector.cs | 8 +-- .../Parser/Internal/SparseRowProjection.cs | 5 +- .../Parser/ParallelCsv/CsvChunkWorker.cs | 4 +- .../Parser/ParallelCsv/CsvHeaderBinder.cs | 2 +- .../Parser/ParallelCsv/MappedProjection.cs | 16 ++--- .../ParallelCsv/ParallelCsvEnumerable.cs | 4 +- .../Parser/ParallelCsv/ParallelCsvFactory.cs | 2 +- .../SheetPipeline/SheetRowProjection.cs | 6 +- .../PublicAPI/PublicAPI.Shipped.txt | 48 +++++++------- .../Reader/ExcelDataReader.cs | 4 +- .../Reader/Schema/SchemaInference.cs | 4 +- src/ExcelReader.Core/Reader/SheetParallel.cs | 12 ++-- .../Writer/Internal/RecordColumns.cs | 2 +- .../ExcelRowMapGenerator.cs | 8 +-- src/ExcelReader.Native/Typed/TypedApi.cs | 18 +++--- .../Generator/GeneratorTests.cs | 10 +-- .../Parser/ConverterTests.cs | 16 ++--- .../Parser/CsvParserTests.cs | 14 ++--- .../Parser/ExcelCellReadersUtf8Tests.cs | 2 +- .../Parser/ExcelParserTests.cs | 12 ++-- .../Parser/ExcelRowMapBuilderTests.cs | 2 +- .../Parser/GeneratedMapParityTests.cs | 2 +- .../Parser/HeaderCollisionTests.cs | 10 +-- .../ParallelCsv/AggregateCsvModelTests.cs | 10 +-- .../Parser/ParallelCsv/CsvChunkWorkerTests.cs | 8 +-- .../ParallelCsv/CsvHeaderBinderTests.cs | 6 +- .../ParallelCsv/ParallelCsvEnumerableTests.cs | 12 ++-- .../ParallelCsv/ParseCsvParallelTests.cs | 2 +- .../Parser/ParserFeatureTests.cs | 4 +- .../Parser/RefStructModelTests.cs | 4 +- .../ExcelReader.Tests/Parser/RequiredTests.cs | 2 +- .../Parser/SheetParallelXlsxTests.cs | 4 +- .../Parser/UnifiedParserTests.cs | 6 +- .../Reader/Xls/XlsWorkbookTests.cs | 2 +- .../Writer/WorkbookWriterTests.cs | 6 +- 52 files changed, 264 insertions(+), 267 deletions(-) diff --git a/docs/guide/migrating-to-v7.md b/docs/guide/migrating-to-v7.md index ca2660f7..32716cfe 100644 --- a/docs/guide/migrating-to-v7.md +++ b/docs/guide/migrating-to-v7.md @@ -88,7 +88,7 @@ await foreach (Sale s in SheetParallel.ParseAsync(workbook.FirstSheet, parser, n - The caller owns the workbook and keeps it open until the task completes or the enumeration ends. v6 opened and closed its own source. - The CSV dialect is given when the workbook is opened (`CsvReaderOptions`), not in the parallel options. - A CSV in a `MemoryStream` is no longer partitioned: the stream overload of `Excel.FromCsv` reads it sequentially. Pass the bytes as `ReadOnlyMemory` to keep partitioning. -- `HeaderRow` rules are the same as v6. `ParseAsync` takes the header from `ExcelParserConfig.HeaderRow` and rejects a non-zero `SheetParallelOptions.HeaderRow`; the parser-based `ForEachAsync` and `AggregateAsync` use `SheetParallelOptions.HeaderRow`. +- `HeaderRow` rules are the same as v6. `ParseAsync` takes the header from `ExcelParserOptions.HeaderRow` and rejects a non-zero `SheetParallelOptions.HeaderRow`; the parser-based `ForEachAsync` and `AggregateAsync` use `SheetParallelOptions.HeaderRow`. - XLSX and XLSB sheets are now read on several threads when `DegreeOfParallelism` allows it. XLS, a table view (`ExcelTable.AsSheet()`), a sheet under 256 KB uncompressed and a CSV that cannot be partitioned still read sequentially with the same results. - `PrefetchDecompression` does not apply to a parallel XLSX or XLSB read; the pipeline already overlaps inflate and parse. diff --git a/docs/guide/parsing.md b/docs/guide/parsing.md index c53c3d54..9c43a860 100644 --- a/docs/guide/parsing.md +++ b/docs/guide/parsing.md @@ -133,13 +133,13 @@ The match is by header name, not by property identity: configuring a *different* ## Parser configuration -Pass an `ExcelParserConfig` to control header handling and culture: +Pass an `ExcelParserOptions` to control header handling and culture: ```csharp using System.Globalization; using ExcelReader.Core.Parser; -var config = new ExcelParserConfig +var options = new ExcelParserOptions { HeaderRow = 1, // 1-based row holding the headers ColumnNameComparer = StringComparer.OrdinalIgnoreCase, @@ -147,7 +147,7 @@ var config = new ExcelParserConfig Culture = CultureInfo.GetCultureInfo("pt-BR"), // parse "1.234,56" as 1234.56m }; -var parser = ExcelParser.FromAttributes(config); +var parser = ExcelParser.FromAttributes(options); ``` `Culture` applies when parsing text-backed numeric/`Guid` cells (XLSX inline and shared strings); binary numeric cells (XLS/XLSB) carry a raw value and ignore it. `HeaderNormalization` flags (`Trim`, `CollapseSpaces`, `RemoveDiacritics`) are applied to both the sheet headers and the property names before matching. @@ -182,7 +182,7 @@ using ExcelReader.Core.Reader; public sealed class BrlMoneyConverter : IExcelCellConverter { - public bool TryConvert(in Cell cell, bool isDate1904, IFormatProvider provider, out decimal value) + public bool TryConvert(in Cell cell, in ExcelRowContext context, out decimal value) { string text = cell.GetString().Replace("R$", "", StringComparison.Ordinal).Trim(); return decimal.TryParse(text, NumberStyles.Currency, CultureInfo.GetCultureInfo("pt-BR"), out value); @@ -196,7 +196,7 @@ public sealed class Invoice } ``` -Return `false` to signal a parse failure (the property keeps its default). Empty cells are skipped before the converter runs. +Return `false` to signal a parse failure (the property keeps its default). Empty cells are skipped before the converter runs. `context` carries the workbook's date system (`IsDate1904`) and the parser culture (`FormatProvider`); to unit-test a converter, pass `new ExcelRowContext { IsDate1904 = false, FormatProvider = CultureInfo.InvariantCulture }`. ## Parse into a ref struct (zero-copy) diff --git a/docs/guide/reading.md b/docs/guide/reading.md index 2454130d..ba6e00b0 100644 --- a/docs/guide/reading.md +++ b/docs/guide/reading.md @@ -98,7 +98,7 @@ await SheetParallel.ForEachAsync( Unlike `ParseAsync`, the callback runs on the worker threads, so records arrive in no particular order and the callback must be safe to call from several threads at once. Spans are valid only for the duration of the call. -`HeaderRow` follows the same rule as before: `ParseAsync` takes the header from the parser's `ExcelParserConfig.HeaderRow` and rejects a non-zero `SheetParallelOptions.HeaderRow`, while the parser-based `ForEachAsync` and `AggregateAsync` use `SheetParallelOptions.HeaderRow`. +`HeaderRow` follows the same rule as before: `ParseAsync` takes the header from the parser's `ExcelParserOptions.HeaderRow` and rejects a non-zero `SheetParallelOptions.HeaderRow`, while the parser-based `ForEachAsync` and `AggregateAsync` use `SheetParallelOptions.HeaderRow`. #### When it reads sequentially diff --git a/src/ExcelReader.Arrow/ColumnAppender.cs b/src/ExcelReader.Arrow/ColumnAppender.cs index 4ab6ae78..7b5e9376 100644 --- a/src/ExcelReader.Arrow/ColumnAppender.cs +++ b/src/ExcelReader.Arrow/ColumnAppender.cs @@ -175,7 +175,7 @@ internal override Field Field internal override void Append(in Cell cell, bool isDate1904) { - if (ExcelCellReaders.Bool(in cell, isDate1904, CultureInfo.InvariantCulture, out bool value)) + if (ExcelCellReaders.Bool(in cell, new ExcelRowContext { IsDate1904 = isDate1904 }, out bool value)) { _builder.Append(value); } @@ -210,7 +210,7 @@ internal override Field Field internal override void Append(in Cell cell, bool isDate1904) { - if (ExcelCellReaders.DateOnlyAuto(in cell, isDate1904, CultureInfo.InvariantCulture, out DateOnly value)) + if (ExcelCellReaders.DateOnlyAuto(in cell, new ExcelRowContext { IsDate1904 = isDate1904 }, out DateOnly value)) { _builder.Append(value); } @@ -247,7 +247,7 @@ internal override Field Field internal override void Append(in Cell cell, bool isDate1904) { - if (ExcelCellReaders.TimeOnlyAuto(in cell, isDate1904, CultureInfo.InvariantCulture, out TimeOnly value)) + if (ExcelCellReaders.TimeOnlyAuto(in cell, new ExcelRowContext { IsDate1904 = isDate1904 }, out TimeOnly value)) { _builder.Append(value.ToTimeSpan().Ticks / 10); } @@ -284,7 +284,7 @@ internal override Field Field internal override void Append(in Cell cell, bool isDate1904) { - if (ExcelCellReaders.DateTimeAuto(in cell, isDate1904, CultureInfo.InvariantCulture, out DateTime value)) + if (ExcelCellReaders.DateTimeAuto(in cell, new ExcelRowContext { IsDate1904 = isDate1904 }, out DateTime value)) { DateTime normalized = value.Kind == DateTimeKind.Unspecified ? value diff --git a/src/ExcelReader.Core/Parser/CsvEnumerable.cs b/src/ExcelReader.Core/Parser/CsvEnumerable.cs index d935aad4..bb4d5902 100644 --- a/src/ExcelReader.Core/Parser/CsvEnumerable.cs +++ b/src/ExcelReader.Core/Parser/CsvEnumerable.cs @@ -22,12 +22,12 @@ public sealed class CsvEnumerable : IEnumerable, IAsyncEnumerable where T : allows ref struct { private readonly CsvSheet _sheet; - private readonly ExcelParserConfig _config; + private readonly ExcelParserOptions _config; private readonly CancellationToken _ct; private readonly TypeMapInfo _info; private readonly CsvReader? _ownedReader; - internal CsvEnumerable(CsvSheet sheet, ExcelParserConfig config, TypeMapInfo info, CsvReader? ownedReader, CancellationToken ct) + internal CsvEnumerable(CsvSheet sheet, ExcelParserOptions config, TypeMapInfo info, CsvReader? ownedReader, CancellationToken ct) { _sheet = sheet; _config = config; @@ -36,7 +36,7 @@ internal CsvEnumerable(CsvSheet sheet, ExcelParserConfig config, TypeMapInfo _ownedReader = ownedReader; } - internal CsvEnumerable(CsvSheet sheet, ExcelParserConfig config, TypeMapInfo explicitInfo) + internal CsvEnumerable(CsvSheet sheet, ExcelParserOptions config, TypeMapInfo explicitInfo) { _sheet = sheet; _config = config; @@ -178,7 +178,7 @@ internal struct CsvRowProjector private readonly StringComparer _comparer; private readonly HeaderNormalization _normalization; private readonly int _headerRow; - private readonly IFormatProvider _provider; + private readonly ExcelRowContext _context; private readonly bool _throwOnParseFailure; private ColumnParser[]? _fieldParsers; private string?[] _fieldNames; @@ -192,7 +192,7 @@ internal CsvRowProjector(TypeMapInfo typeInfo, StringComparer comparer, Heade _comparer = comparer; _normalization = normalization; _headerRow = headerRow; - _provider = provider; + _context = new ExcelRowContext { FormatProvider = provider }; _throwOnParseFailure = throwOnParseFailure; _fieldNames = []; _fieldRequired = []; @@ -205,7 +205,7 @@ internal CsvRowProjector(TypeMapInfo typeInfo, CsvBoundColumnMap map, IFor _comparer = StringComparer.Ordinal; _normalization = HeaderNormalization.None; _headerRow = -1; - _provider = provider; + _context = new ExcelRowContext { FormatProvider = provider }; _throwOnParseFailure = throwOnParseFailure; _fieldParsers = map.FieldParsers; _fieldNames = map.FieldNames; @@ -362,7 +362,7 @@ private readonly void ParseCurrentRow(CsvReader.Enumerator rows, ref T model) continue; } Cell cell = rows.FieldAt(i); - if (cell.Type == CellType.Empty || parser(ref model, in cell, false, _provider)) + if (cell.Type == CellType.Empty || parser(ref model, in cell, in _context)) { continue; } diff --git a/src/ExcelReader.Core/Parser/ExcelCellReaders.cs b/src/ExcelReader.Core/Parser/ExcelCellReaders.cs index 4978046b..b186ce01 100644 --- a/src/ExcelReader.Core/Parser/ExcelCellReaders.cs +++ b/src/ExcelReader.Core/Parser/ExcelCellReaders.cs @@ -56,28 +56,28 @@ public static class ExcelCellReaders public static readonly ExcelCellReader TimeOnlyAuto = ReadTimeOnlyAuto; - private static bool ReadString(in Cell cell, bool isDate1904, IFormatProvider provider, out string value) + private static bool ReadString(in Cell cell, in ExcelRowContext context, out string value) { value = cell.GetString(); return true; } - private static bool ReadDateTimeAuto(in Cell cell, bool isDate1904, IFormatProvider provider, out DateTime value) + private static bool ReadDateTimeAuto(in Cell cell, in ExcelRowContext context, out DateTime value) { - return ColumnParserFactory.ReadDateTime(in cell, isDate1904, provider, out value) - || ColumnParserFactory.ReadTextDateTime(in cell, isDate1904, provider, out value); + return ColumnParserFactory.ReadDateTime(in cell, in context, out value) + || ColumnParserFactory.ReadTextDateTime(in cell, in context, out value); } - private static bool ReadDateOnlyAuto(in Cell cell, bool isDate1904, IFormatProvider provider, out DateOnly value) + private static bool ReadDateOnlyAuto(in Cell cell, in ExcelRowContext context, out DateOnly value) { - return ColumnParserFactory.ReadDateOnly(in cell, isDate1904, provider, out value) - || ColumnParserFactory.ReadTextDateOnly(in cell, isDate1904, provider, out value); + return ColumnParserFactory.ReadDateOnly(in cell, in context, out value) + || ColumnParserFactory.ReadTextDateOnly(in cell, in context, out value); } - private static bool ReadTimeOnlyAuto(in Cell cell, bool isDate1904, IFormatProvider provider, out TimeOnly value) + private static bool ReadTimeOnlyAuto(in Cell cell, in ExcelRowContext context, out TimeOnly value) { - return ColumnParserFactory.ReadTimeOnly(in cell, isDate1904, provider, out value) - || ColumnParserFactory.ReadTextTimeOnly(in cell, isDate1904, provider, out value); + return ColumnParserFactory.ReadTimeOnly(in cell, in context, out value) + || ColumnParserFactory.ReadTextTimeOnly(in cell, in context, out value); } ///

/// The value type the cell is read into. /// The cell to read; callers only invoke this for a non-empty cell. - /// True when the source workbook uses the 1904 date system. - /// The format provider configured for parsing. + /// The date system and format provider to convert with. /// The read value, when this method returns true. /// if the cell was read successfully. - public delegate bool ExcelCellReader(in Cell cell, bool isDate1904, IFormatProvider provider, out TValue value); + public delegate bool ExcelCellReader(in Cell cell, in ExcelRowContext context, out TValue value); /// /// Assigns to a property of . The source generator @@ -41,9 +40,8 @@ public delegate void ExcelPropertySetter(ref TModel model, TV /// The row model type being read into. /// The model instance to mutate. /// The cell to read; callers only invoke this for a non-empty cell. - /// True when the source workbook uses the 1904 date system. - /// The format provider configured for parsing. + /// The date system and format provider to convert with. /// if the cell was read and assigned successfully. - public delegate bool ExcelRowParser(ref TModel model, in Cell cell, bool isDate1904, IFormatProvider provider) + public delegate bool ExcelRowParser(ref TModel model, in Cell cell, in ExcelRowContext context) where TModel : allows ref struct; } diff --git a/src/ExcelReader.Core/Parser/ExcelParseException.cs b/src/ExcelReader.Core/Parser/ExcelParseException.cs index 413d7074..921a1347 100644 --- a/src/ExcelReader.Core/Parser/ExcelParseException.cs +++ b/src/ExcelReader.Core/Parser/ExcelParseException.cs @@ -6,11 +6,11 @@ namespace ExcelReader.Core.Parser /// /// Thrown when a bound column's cell is non-empty but fails to parse into the target property type /// and the parser is configured to throw on parse failures (rather than silently keep the property - /// at its default). See . + /// at its default). See . /// /// /// A parse failure on an [ExcelRequired] column always surfaces as a missing-required-value - /// error instead, regardless of , since the column + /// error instead, regardless of , since the column /// has no usable value either way. /// public sealed class ExcelParseException : Exception diff --git a/src/ExcelReader.Core/Parser/ExcelParser.cs b/src/ExcelReader.Core/Parser/ExcelParser.cs index 8bf07360..2e9029ee 100644 --- a/src/ExcelReader.Core/Parser/ExcelParser.cs +++ b/src/ExcelReader.Core/Parser/ExcelParser.cs @@ -24,18 +24,18 @@ namespace ExcelReader.Core.Parser public sealed class ExcelParser where T : allows ref struct { - private readonly ExcelParserConfig _config; + private readonly ExcelParserOptions _config; private readonly Func> _info; private readonly Func> _csvInfo; - internal ExcelParser(ExcelParserConfig config, Func> info, Func> csvInfo) + internal ExcelParser(ExcelParserOptions config, Func> info, Func> csvInfo) { _config = config; _info = info; _csvInfo = csvInfo; } - internal ExcelParserConfig Config => _config; + internal ExcelParserOptions Config => _config; internal TypeMapInfo CsvInfo => _csvInfo(); @@ -104,15 +104,15 @@ public static class ExcelParser /// a separate map with text-based date parsing. /// /// The model type. - /// Header matching, culture and parse-failure options. Defaults to a new . + /// Header matching, culture and parse-failure options. Defaults to a new . /// The parser. - /// has less than 1. + /// has less than 1. [RequiresUnreferencedCode("Typed parsing reflects over T's public properties, which trimming may remove.")] [RequiresDynamicCode("Typed parsing binds property setters at runtime (MethodInfo.CreateDelegate / MakeGenericMethod).")] - public static ExcelParser FromAttributes(ExcelParserConfig? config = null) + public static ExcelParser FromAttributes(ExcelParserOptions? options = null) where T : allows ref struct { - return new ExcelParser(ValidateConfig(config), TypeMapper.GetInfo, TypeMapper.GetCsvInfo); + return new ExcelParser(ValidateOptions(options), TypeMapper.GetInfo, TypeMapper.GetCsvInfo); } /// Creates a parser from the map the [ExcelSerializable] source generator emitted for . Trimming and AOT safe. @@ -122,13 +122,13 @@ public static ExcelParser FromAttributes(ExcelParserConfig? config = null) /// *Auto readers in — serial number first, text as a fallback. /// /// The model type; must implement . - /// Header matching, culture and parse-failure options. Defaults to a new . + /// Header matching, culture and parse-failure options. Defaults to a new . /// The parser. - /// has less than 1. - public static ExcelParser Generated(ExcelParserConfig? config = null) + /// has less than 1. + public static ExcelParser Generated(ExcelParserOptions? options = null) where T : IExcelRowMap, allows ref struct { - ExcelParserConfig effective = ValidateConfig(config); + ExcelParserOptions effective = ValidateOptions(options); var builder = new ExcelRowMapBuilder(); T.ConfigureExcelRowMap(builder); return Fixed(effective, builder.Build()); @@ -142,15 +142,15 @@ public static ExcelParser Generated(ExcelParserConfig? config = null) /// /// The model type. /// Configures the row map by calling and its siblings. - /// Header matching, culture and parse-failure options. Defaults to a new . + /// Header matching, culture and parse-failure options. Defaults to a new . /// The parser. /// is . - /// has less than 1. - public static ExcelParser Build(Action> configure, ExcelParserConfig? config = null) + /// has less than 1. + public static ExcelParser Build(Action> configure, ExcelParserOptions? options = null) where T : allows ref struct { ArgumentNullException.ThrowIfNull(configure); - ExcelParserConfig effective = ValidateConfig(config); + ExcelParserOptions effective = ValidateOptions(options); var builder = new ExcelRowMapBuilder(); configure(builder); return Fixed(effective, builder.Build()); @@ -171,18 +171,18 @@ public static ExcelParser Build(Action> configure, E /// /// The model type. /// Configures the properties that should override their attribute-driven binding. - /// Header matching, culture and parse-failure options. Defaults to a new . + /// Header matching, culture and parse-failure options. Defaults to a new . /// The parser. /// is . - /// has less than 1. + /// has less than 1. /// calls : an index-based map has no header row to match attribute-driven properties against. [RequiresUnreferencedCode("BuildWithAttributeFallback reflects over T's public properties for the attribute-driven fallback, which trimming may remove.")] [RequiresDynamicCode("BuildWithAttributeFallback binds attribute-driven property setters at runtime (MethodInfo.CreateDelegate / MakeGenericMethod).")] - public static ExcelParser BuildWithAttributeFallback(Action> configure, ExcelParserConfig? config = null) + public static ExcelParser BuildWithAttributeFallback(Action> configure, ExcelParserOptions? options = null) where T : allows ref struct { ArgumentNullException.ThrowIfNull(configure); - ExcelParserConfig effective = ValidateConfig(config); + ExcelParserOptions effective = ValidateOptions(options); var builder = new ExcelRowMapBuilder(); configure(builder); TypeMapInfo fluent = builder.Build(requireFactory: false); @@ -191,19 +191,19 @@ public static ExcelParser BuildWithAttributeFallback(Action Fixed(ExcelParserConfig config, TypeMapInfo info) + private static ExcelParser Fixed(ExcelParserOptions config, TypeMapInfo info) where T : allows ref struct { return new ExcelParser(config, () => info, () => info); } - private static ExcelParserConfig ValidateConfig(ExcelParserConfig? config) + private static ExcelParserOptions ValidateOptions(ExcelParserOptions? options) { - if (config is not null && config.HeaderRow < 1) + if (options is not null && options.HeaderRow < 1) { - throw new ArgumentOutOfRangeException(nameof(config), config.HeaderRow, "HeaderRow must be at least 1."); + throw new ArgumentOutOfRangeException(nameof(options), options.HeaderRow, "HeaderRow must be at least 1."); } - return config ?? new ExcelParserConfig(); + return options ?? new ExcelParserOptions(); } } } diff --git a/src/ExcelReader.Core/Parser/ExcelParserOptions.cs b/src/ExcelReader.Core/Parser/ExcelParserOptions.cs index 26b575b0..6b96934e 100644 --- a/src/ExcelReader.Core/Parser/ExcelParserOptions.cs +++ b/src/ExcelReader.Core/Parser/ExcelParserOptions.cs @@ -6,7 +6,7 @@ namespace ExcelReader.Core.Parser /// Options controlling how locates the header row, matches header /// text to bound properties, and handles cells that fail to parse. /// - public sealed class ExcelParserConfig + public sealed class ExcelParserOptions { /// Gets the 1-based row number that contains column headers. Defaults to 1. public int HeaderRow { get; init; } = 1; diff --git a/src/ExcelReader.Core/Parser/ExcelRequiredAttribute.cs b/src/ExcelReader.Core/Parser/ExcelRequiredAttribute.cs index d5607bd6..e9fbe37c 100644 --- a/src/ExcelReader.Core/Parser/ExcelRequiredAttribute.cs +++ b/src/ExcelReader.Core/Parser/ExcelRequiredAttribute.cs @@ -5,7 +5,7 @@ namespace ExcelReader.Core.Parser /// is read if no matching name is found. By default every data row's cell must also be non-empty /// (and must parse successfully — a non-empty but unparsable cell fails the same way as a blank one), /// throwing on the first row that fails either check. Set to true to require - /// only the column's presence. Contrast with , + /// only the column's presence. Contrast with , /// which governs parse-failure behavior only for non-required columns. /// [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] diff --git a/src/ExcelReader.Core/Parser/ExcelRowContext.cs b/src/ExcelReader.Core/Parser/ExcelRowContext.cs index 524cb955..592db68a 100644 --- a/src/ExcelReader.Core/Parser/ExcelRowContext.cs +++ b/src/ExcelReader.Core/Parser/ExcelRowContext.cs @@ -1,27 +1,29 @@ +using System.Globalization; + namespace ExcelReader.Core.Parser { /// - /// Immutable per-enumeration context handed to every generated IExcelRowModel<TSelf>.FromRow - /// call, carrying settings a row model needs to convert its own cells. + /// Settings a cell reader, converter or row parser needs to convert a cell, handed to every + /// , and + /// call. /// /// - /// A normal struct (not a ref struct), so it can be passed in - /// freely, unlike Row itself. Declared as a record struct purely for its - /// compiler-synthesized equality members (satisfying CA1815 without hand-written boilerplate; no - /// code in this library actually compares two contexts), and kept non-positional (no primary - /// constructor) so construction stays internal-only. + /// Passed by reference. New settings arrive as new properties, so a converter + /// written against this type keeps compiling. Construct one with an object initializer to unit-test a + /// converter; means the 1900 date system and the invariant culture. /// public readonly record struct ExcelRowContext { + private readonly IFormatProvider? _formatProvider; + /// True when the source workbook uses the 1904 date system. - public bool IsDate1904 { get; } - /// The format provider to use when parsing cell values. - public IFormatProvider FormatProvider { get; } + public bool IsDate1904 { get; init; } - internal ExcelRowContext(bool isDate1904, IFormatProvider formatProvider) + /// The format provider to use when parsing cell text. Defaults to . + public IFormatProvider FormatProvider { - IsDate1904 = isDate1904; - FormatProvider = formatProvider; + get => _formatProvider ?? CultureInfo.InvariantCulture; + init => _formatProvider = value; } } } diff --git a/src/ExcelReader.Core/Parser/ExcelRowMapBuilder.cs b/src/ExcelReader.Core/Parser/ExcelRowMapBuilder.cs index 97c35e5a..7a31abc4 100644 --- a/src/ExcelReader.Core/Parser/ExcelRowMapBuilder.cs +++ b/src/ExcelReader.Core/Parser/ExcelRowMapBuilder.cs @@ -37,9 +37,9 @@ public ExcelRowMapBuilder Property( ArgumentNullException.ThrowIfNull(names); ArgumentNullException.ThrowIfNull(read); ArgumentNullException.ThrowIfNull(set); - bool parser(ref T model, in Cell cell, bool isDate1904, IFormatProvider provider) + bool parser(ref T model, in Cell cell, in ExcelRowContext context) { - if (!read(in cell, isDate1904, provider, out TValue value)) + if (!read(in cell, in context, out TValue value)) { return false; } @@ -77,9 +77,9 @@ public ExcelRowMapBuilder PropertyNullable( ArgumentNullException.ThrowIfNull(names); ArgumentNullException.ThrowIfNull(read); ArgumentNullException.ThrowIfNull(set); - bool parser(ref T model, in Cell cell, bool isDate1904, IFormatProvider provider) + bool parser(ref T model, in Cell cell, in ExcelRowContext context) { - if (!read(in cell, isDate1904, provider, out TValue value)) + if (!read(in cell, in context, out TValue value)) { return false; } @@ -138,9 +138,9 @@ public ExcelRowMapBuilder Converted( ArgumentNullException.ThrowIfNull(names); ArgumentNullException.ThrowIfNull(converter); ArgumentNullException.ThrowIfNull(set); - bool parser(ref T model, in Cell cell, bool isDate1904, IFormatProvider provider) + bool parser(ref T model, in Cell cell, in ExcelRowContext context) { - if (!converter.TryConvert(in cell, isDate1904, provider, out TValue value)) + if (!converter.TryConvert(in cell, in context, out TValue value)) { return false; } @@ -176,9 +176,9 @@ public ExcelRowMapBuilder PropertyAt( ArgumentOutOfRangeException.ThrowIfNegative(columnIndex); ArgumentNullException.ThrowIfNull(read); ArgumentNullException.ThrowIfNull(set); - bool parser(ref T model, in Cell cell, bool isDate1904, IFormatProvider provider) + bool parser(ref T model, in Cell cell, in ExcelRowContext context) { - if (!read(in cell, isDate1904, provider, out TValue value)) + if (!read(in cell, in context, out TValue value)) { return false; } diff --git a/src/ExcelReader.Core/Parser/IExcelCellConverter.cs b/src/ExcelReader.Core/Parser/IExcelCellConverter.cs index 25787ae1..0986a054 100644 --- a/src/ExcelReader.Core/Parser/IExcelCellConverter.cs +++ b/src/ExcelReader.Core/Parser/IExcelCellConverter.cs @@ -21,9 +21,8 @@ public interface IExcelCellConverter /// signal a parse failure, which leaves the target property at its default. /// /// The non-empty cell to convert. - /// True when the source workbook uses the 1904 date system. - /// The format provider configured for parsing. + /// The date system and format provider to convert with. /// The converted value, when this method returns true. - bool TryConvert(in Cell cell, bool isDate1904, IFormatProvider provider, out T value); + bool TryConvert(in Cell cell, in ExcelRowContext context, out T value); } } diff --git a/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs b/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs index d08c0ec1..0af63c74 100644 --- a/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs +++ b/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs @@ -188,7 +188,7 @@ private static ColumnParser BuildStringParser(PropertyInfo prop) where T : allows ref struct { RefAction setter = CompileSetter(prop); - return (ref model, in cell, _, _) => + return (ref model, in cell, in _) => { setter(ref model, cell.GetString()); return true; @@ -199,22 +199,22 @@ private static ColumnParser BuildSpanParser(PropertyInfo prop) where T : allows ref struct { RefAction> setter = CompileRefStructSetter>(prop); - return (ref model, in cell, _, _) => + return (ref model, in cell, in _) => { setter(ref model, cell.Value); return true; }; } - private delegate bool CellReader(in Cell cell, bool isDate1904, IFormatProvider provider, out V value); + private delegate bool CellReader(in Cell cell, in ExcelRowContext context, out V value); private static ColumnParser BuildValue(PropertyInfo prop, CellReader read) where T : allows ref struct { RefAction setter = CompileSetter(prop); - return (ref model, in cell, isDate1904, provider) => + return (ref model, in cell, in context) => { - if (!read(in cell, isDate1904, provider, out V value)) + if (!read(in cell, in context, out V value)) { return false; } @@ -228,9 +228,9 @@ private static ColumnParser BuildNullableValue(PropertyInfo prop, CellR where T : allows ref struct { RefAction setter = CompileSetter(prop); - return (ref model, in cell, isDate1904, provider) => + return (ref model, in cell, in context) => { - if (!read(in cell, isDate1904, provider, out V value)) + if (!read(in cell, in context, out V value)) { return false; } @@ -240,19 +240,19 @@ private static ColumnParser BuildNullableValue(PropertyInfo prop, CellR } #pragma warning disable S1172 - internal static bool ReadBool(in Cell cell, bool isDate1904, IFormatProvider provider, out bool value) + internal static bool ReadBool(in Cell cell, in ExcelRowContext context, out bool value) { return TryParseBool(in cell, out value); } - internal static bool ReadDateTime(in Cell cell, bool isDate1904, IFormatProvider _, out DateTime value) + internal static bool ReadDateTime(in Cell cell, in ExcelRowContext context, out DateTime value) { - return cell.TryGetDateTime(isDate1904, out value); + return cell.TryGetDateTime(context.IsDate1904, out value); } - internal static bool ReadDateOnly(in Cell cell, bool isDate1904, IFormatProvider _, out DateOnly value) + internal static bool ReadDateOnly(in Cell cell, in ExcelRowContext context, out DateOnly value) { - if (!cell.TryGetDateTime(isDate1904, out DateTime dt)) + if (!cell.TryGetDateTime(context.IsDate1904, out DateTime dt)) { value = default; return false; @@ -261,7 +261,7 @@ internal static bool ReadDateOnly(in Cell cell, bool isDate1904, IFormatProvider return true; } - internal static bool ReadTimeOnly(in Cell cell, bool isDate1904, IFormatProvider provider, out TimeOnly value) + internal static bool ReadTimeOnly(in Cell cell, in ExcelRowContext context, out TimeOnly value) { if (!cell.TryGetDouble(out double serial) || !double.IsFinite(serial)) { @@ -272,19 +272,19 @@ internal static bool ReadTimeOnly(in Cell cell, bool isDate1904, IFormatProvider return true; } - internal static bool ReadTextDateTime(in Cell cell, bool _, IFormatProvider provider, out DateTime value) + internal static bool ReadTextDateTime(in Cell cell, in ExcelRowContext context, out DateTime value) { - return TryParseDateTimeText(in cell, provider, out value); + return TryParseDateTimeText(in cell, context.FormatProvider, out value); } - internal static bool ReadTextDateOnly(in Cell cell, bool _, IFormatProvider provider, out DateOnly value) + internal static bool ReadTextDateOnly(in Cell cell, in ExcelRowContext context, out DateOnly value) { - return TryParseDateOnlyText(in cell, provider, out value); + return TryParseDateOnlyText(in cell, context.FormatProvider, out value); } - internal static bool ReadTextTimeOnly(in Cell cell, bool _, IFormatProvider provider, out TimeOnly value) + internal static bool ReadTextTimeOnly(in Cell cell, in ExcelRowContext context, out TimeOnly value) { - return TryParseTimeOnlyText(in cell, provider, out value); + return TryParseTimeOnlyText(in cell, context.FormatProvider, out value); } #pragma warning restore S1172 @@ -387,9 +387,9 @@ private static ColumnParser BuildParsableCore(PropertyInfo prop) where T : allows ref struct { RefAction setter = CompileSetter(prop); - return (ref model, in cell, _, provider) => + return (ref model, in cell, in context) => { - if (!cell.TryParse(provider, out var value)) + if (!cell.TryParse(context.FormatProvider, out var value)) { return false; } @@ -403,9 +403,9 @@ private static ColumnParser BuildNullableParsableCore(PropertyInfo where T : allows ref struct { RefAction setter = CompileSetter(prop); - return (ref model, in cell, _, provider) => + return (ref model, in cell, in context) => { - if (!cell.TryParse(provider, out TProp parsed)) + if (!cell.TryParse(context.FormatProvider, out TProp parsed)) { return false; } @@ -420,9 +420,9 @@ private static ColumnParser BuildSpanParsableCore(PropertyInfo prop where T : allows ref struct { RefAction setter = CompileSetter(prop); - return (ref model, in cell, _, provider) => + return (ref model, in cell, in context) => { - if (!TryParseSpanParsable(in cell, provider, out var value)) + if (!TryParseSpanParsable(in cell, context.FormatProvider, out var value)) { return false; } @@ -436,9 +436,9 @@ private static ColumnParser BuildNullableSpanParsableCore(PropertyI where T : allows ref struct { RefAction setter = CompileSetter(prop); - return (ref model, in cell, _, provider) => + return (ref model, in cell, in context) => { - if (!TryParseSpanParsable(in cell, provider, out TProp parsed)) + if (!TryParseSpanParsable(in cell, context.FormatProvider, out TProp parsed)) { return false; } @@ -512,7 +512,7 @@ private static ColumnParser BuildEnumCore(PropertyInfo prop) where T : allows ref struct { RefAction setter = CompileSetter(prop); - return (ref model, in cell, _, _) => + return (ref model, in cell, in _) => { if (!EnumCache.TryParse(in cell, out TEnum value)) { @@ -528,7 +528,7 @@ private static ColumnParser BuildNullableEnumCore(PropertyInfo prop where T : allows ref struct { RefAction setter = CompileSetter(prop); - return (ref model, in cell, _, _) => + return (ref model, in cell, in _) => { if (!EnumCache.TryParse(in cell, out TEnum parsed)) { @@ -545,9 +545,9 @@ private static ColumnParser BuildConverterCore(PropertyInfo { var typed = (TConv)converter; RefAction setter = CompileSetter(prop); - return (ref model, in cell, isDate1904, provider) => + return (ref model, in cell, in context) => { - if (!typed.TryConvert(in cell, isDate1904, provider, out TProp value)) + if (!typed.TryConvert(in cell, in context, out TProp value)) { return false; } diff --git a/src/ExcelReader.Core/Parser/Internal/Delegates.cs b/src/ExcelReader.Core/Parser/Internal/Delegates.cs index 82c54573..1d6b2136 100644 --- a/src/ExcelReader.Core/Parser/Internal/Delegates.cs +++ b/src/ExcelReader.Core/Parser/Internal/Delegates.cs @@ -10,7 +10,6 @@ internal delegate void RefAction(ref TModel model, TProper internal delegate bool ColumnParser( ref TModel model, in Cell cell, - bool isDate1904, - IFormatProvider provider) + in ExcelRowContext context) where TModel : allows ref struct; } diff --git a/src/ExcelReader.Core/Parser/Internal/RowProjector.cs b/src/ExcelReader.Core/Parser/Internal/RowProjector.cs index fc2a7380..19ab1294 100644 --- a/src/ExcelReader.Core/Parser/Internal/RowProjector.cs +++ b/src/ExcelReader.Core/Parser/Internal/RowProjector.cs @@ -11,8 +11,7 @@ internal struct RowProjector private readonly StringComparer _comparer; private readonly HeaderNormalization _normalization; private readonly int _headerRow; - private readonly bool _isDate1904; - private readonly IFormatProvider _provider; + private readonly ExcelRowContext _context; private readonly bool _throwOnParseFailure; private ColumnBinding[]? _bindings; private bool[] _seen; @@ -25,8 +24,7 @@ internal RowProjector(TypeMapInfo typeInfo, StringComparer comparer, HeaderNo _comparer = comparer; _normalization = normalization; _headerRow = headerRow; - _isDate1904 = isDate1904; - _provider = provider; + _context = new ExcelRowContext { IsDate1904 = isDate1904, FormatProvider = provider }; _throwOnParseFailure = throwOnParseFailure; _seen = []; } @@ -84,7 +82,7 @@ private void BuildIndexColumnMap() private readonly void ParseCurrentRow(in Row row, ref T model) { bool track = _requireValueCount > 0; - SparseRowProjection.ParseRow(in row, _bindings!, _seen, track, _isDate1904, _provider, _throwOnParseFailure, _rowNumber, ref model); + SparseRowProjection.ParseRow(in row, _bindings!, _seen, track, in _context, _throwOnParseFailure, _rowNumber, ref model); } } } diff --git a/src/ExcelReader.Core/Parser/Internal/SparseRowProjection.cs b/src/ExcelReader.Core/Parser/Internal/SparseRowProjection.cs index 6dae79fa..71be8232 100644 --- a/src/ExcelReader.Core/Parser/Internal/SparseRowProjection.cs +++ b/src/ExcelReader.Core/Parser/Internal/SparseRowProjection.cs @@ -73,8 +73,7 @@ internal static void ParseRow( ColumnBinding[] bindings, scoped Span seen, bool track, - bool isDate1904, - IFormatProvider provider, + in ExcelRowContext context, bool throwOnParseFailure, int rowNumber, ref TModel model) @@ -107,7 +106,7 @@ internal static void ParseRow( bindingIndex++; continue; } - if (binding.Parser(ref model, in cell, isDate1904, provider)) + if (binding.Parser(ref model, in cell, in context)) { if (track && binding.RequireValue) { diff --git a/src/ExcelReader.Core/Parser/ParallelCsv/CsvChunkWorker.cs b/src/ExcelReader.Core/Parser/ParallelCsv/CsvChunkWorker.cs index 36ad5263..df4614f8 100644 --- a/src/ExcelReader.Core/Parser/ParallelCsv/CsvChunkWorker.cs +++ b/src/ExcelReader.Core/Parser/ParallelCsv/CsvChunkWorker.cs @@ -112,7 +112,7 @@ internal static ValueTask> ParseAsync( CsvBoundColumnMap map, TypeMapInfo info, CsvReaderOptions readerOptions, - ExcelParserConfig config, + ExcelParserOptions config, List models, CancellationToken ct) { @@ -134,7 +134,7 @@ private static async ValueTask> ParseFromAsync( CsvBoundColumnMap map, TypeMapInfo info, CsvReaderOptions options, - ExcelParserConfig config, + ExcelParserOptions config, CancellationToken ct) { var projector = new CsvRowProjector(info, map, config.Culture, config.ThrowOnParseFailure); diff --git a/src/ExcelReader.Core/Parser/ParallelCsv/CsvHeaderBinder.cs b/src/ExcelReader.Core/Parser/ParallelCsv/CsvHeaderBinder.cs index faf29623..b9287001 100644 --- a/src/ExcelReader.Core/Parser/ParallelCsv/CsvHeaderBinder.cs +++ b/src/ExcelReader.Core/Parser/ParallelCsv/CsvHeaderBinder.cs @@ -7,7 +7,7 @@ internal static class CsvHeaderBinder { internal static CsvBoundColumnMap Bind( CsvReader reader, - ExcelParserConfig config, + ExcelParserOptions config, TypeMapInfo info, out long firstDataRecordOffset) { diff --git a/src/ExcelReader.Core/Parser/ParallelCsv/MappedProjection.cs b/src/ExcelReader.Core/Parser/ParallelCsv/MappedProjection.cs index 93b5022f..f40dc39c 100644 --- a/src/ExcelReader.Core/Parser/ParallelCsv/MappedProjection.cs +++ b/src/ExcelReader.Core/Parser/ParallelCsv/MappedProjection.cs @@ -12,7 +12,7 @@ internal static class MappedProjection internal static RowAction Bind( ExcelParser parser, TypeMapInfo info, bool isDate1904, Row header, int headerRow, bool sequential, ModelSink sink) { - ExcelParserConfig config = parser.Config; + ExcelParserOptions config = parser.Config; ColumnBinding[] bindings; int requireValueCount; if (info.IsIndexBased) @@ -25,7 +25,7 @@ internal static RowAction Bind( bindings = SparseRowProjection.BuildColumnMap(in header, info, config.ColumnNameComparer, config.HeaderNormalization, out requireValueCount); } bool track = requireValueCount > 0; - IFormatProvider provider = config.Culture; + ExcelRowContext context = new() { IsDate1904 = isDate1904, FormatProvider = config.Culture }; bool throwOnParseFailure = config.ThrowOnParseFailure; int rowNumber = headerRow; @@ -36,7 +36,7 @@ void Plain(ref TState state, Row row) return; } TModel model = info.CreateInstance(); - SparseRowProjection.ParseRow(in row, bindings, default, false, isDate1904, provider, throwOnParseFailure, 0, ref model); + SparseRowProjection.ParseRow(in row, bindings, default, false, in context, throwOnParseFailure, 0, ref model); sink(ref state, model); } @@ -48,7 +48,7 @@ void PlainNumbered(ref TState state, Row row) return; } TModel model = info.CreateInstance(); - SparseRowProjection.ParseRow(in row, bindings, default, false, isDate1904, provider, throwOnParseFailure, current, ref model); + SparseRowProjection.ParseRow(in row, bindings, default, false, in context, throwOnParseFailure, current, ref model); sink(ref state, model); } @@ -62,11 +62,11 @@ void Tracked(ref TState state, Row row) if (bindings.Length <= StackallocLimit) { Span seen = stackalloc bool[bindings.Length]; - SparseRowProjection.ParseRow(in row, bindings, seen, true, isDate1904, provider, throwOnParseFailure, 0, ref model); + SparseRowProjection.ParseRow(in row, bindings, seen, true, in context, throwOnParseFailure, 0, ref model); } else { - SparseRowProjection.ParseRow(in row, bindings, new bool[bindings.Length], true, isDate1904, provider, throwOnParseFailure, 0, ref model); + SparseRowProjection.ParseRow(in row, bindings, new bool[bindings.Length], true, in context, throwOnParseFailure, 0, ref model); } sink(ref state, model); } @@ -82,11 +82,11 @@ void TrackedNumbered(ref TState state, Row row) if (bindings.Length <= StackallocLimit) { Span seen = stackalloc bool[bindings.Length]; - SparseRowProjection.ParseRow(in row, bindings, seen, true, isDate1904, provider, throwOnParseFailure, current, ref model); + SparseRowProjection.ParseRow(in row, bindings, seen, true, in context, throwOnParseFailure, current, ref model); } else { - SparseRowProjection.ParseRow(in row, bindings, new bool[bindings.Length], true, isDate1904, provider, throwOnParseFailure, current, ref model); + SparseRowProjection.ParseRow(in row, bindings, new bool[bindings.Length], true, in context, throwOnParseFailure, current, ref model); } sink(ref state, model); } diff --git a/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvEnumerable.cs b/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvEnumerable.cs index 82f862d2..964692ab 100644 --- a/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvEnumerable.cs +++ b/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvEnumerable.cs @@ -14,7 +14,7 @@ internal sealed class ParallelCsvEnumerable : IAsyncEnumerable private readonly CsvBoundColumnMap _map; private readonly TypeMapInfo _info; private readonly CsvReaderOptions _readerOptions; - private readonly ExcelParserConfig _config; + private readonly ExcelParserOptions _config; private readonly int _dop; private readonly ReaderLifetime? _lease; @@ -27,7 +27,7 @@ internal ParallelCsvEnumerable( CsvBoundColumnMap map, TypeMapInfo info, CsvReaderOptions readerOptions, - ExcelParserConfig config, + ExcelParserOptions config, int degreeOfParallelism, ReaderLifetime? lease = null, object? owner = null) diff --git a/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvFactory.cs b/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvFactory.cs index 9a34134c..80b1e0cb 100644 --- a/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvFactory.cs +++ b/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvFactory.cs @@ -51,7 +51,7 @@ private static IAsyncEnumerable Build( ReaderLifetime? lease = null, object? owner = null) { - ExcelParserConfig config = parser.Config; + ExcelParserOptions config = parser.Config; TypeMapInfo info = parser.CsvInfo; CsvBoundColumnMap map; long dataStart; diff --git a/src/ExcelReader.Core/Parser/SheetPipeline/SheetRowProjection.cs b/src/ExcelReader.Core/Parser/SheetPipeline/SheetRowProjection.cs index 0bab5264..4c08148e 100644 --- a/src/ExcelReader.Core/Parser/SheetPipeline/SheetRowProjection.cs +++ b/src/ExcelReader.Core/Parser/SheetPipeline/SheetRowProjection.cs @@ -39,19 +39,19 @@ private static RowActionFactory Binder(ExcelParser parser, TypeMapInfo { return (Row header, bool _) => { - ExcelParserConfig config = parser.Config; + ExcelParserOptions config = parser.Config; ColumnBinding[] bindings = info.IsIndexBased ? info.IndexBindings : SparseRowProjection.BuildColumnMap(in header, info, config.ColumnNameComparer, config.HeaderNormalization, out int _); bool track = bindings.Any(static binding => binding.RequireValue); - IFormatProvider provider = config.Culture; + ExcelRowContext context = new() { IsDate1904 = isDate1904, FormatProvider = config.Culture }; bool throwOnParseFailure = config.ThrowOnParseFailure; return (ref Chunk chunk, Row row) => { int rowInChunk = ++chunk.Rows; T model = info.CreateInstance(); Span seen = track ? (chunk.Seen ??= new bool[bindings.Length]) : default; - SparseRowProjection.ParseRow(in row, bindings, seen, track, isDate1904, provider, throwOnParseFailure, rowInChunk, ref model); + SparseRowProjection.ParseRow(in row, bindings, seen, track, in context, throwOnParseFailure, rowInChunk, ref model); chunk.Items.Add(model); }; }; diff --git a/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt b/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt index a175e6ab..5240f03a 100644 --- a/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt +++ b/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt @@ -42,18 +42,18 @@ ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.IExcelSheet ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xls.XlsSheet sheet) -> ExcelReader.Core.Parser.ExcelEnumerable! ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xlsb.XlsbSheet sheet) -> ExcelReader.Core.Parser.ExcelEnumerable! ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xlsx.XlsxSheet sheet) -> ExcelReader.Core.Parser.ExcelEnumerable! -ExcelReader.Core.Parser.ExcelParserConfig -ExcelReader.Core.Parser.ExcelParserConfig.ColumnNameComparer.get -> System.StringComparer! -ExcelReader.Core.Parser.ExcelParserConfig.ColumnNameComparer.init -> void -ExcelReader.Core.Parser.ExcelParserConfig.Culture.get -> System.Globalization.CultureInfo! -ExcelReader.Core.Parser.ExcelParserConfig.Culture.init -> void -ExcelReader.Core.Parser.ExcelParserConfig.ExcelParserConfig() -> void -ExcelReader.Core.Parser.ExcelParserConfig.HeaderNormalization.get -> ExcelReader.Core.Parser.HeaderNormalization -ExcelReader.Core.Parser.ExcelParserConfig.HeaderNormalization.init -> void -ExcelReader.Core.Parser.ExcelParserConfig.HeaderRow.get -> int -ExcelReader.Core.Parser.ExcelParserConfig.HeaderRow.init -> void -ExcelReader.Core.Parser.ExcelParserConfig.ThrowOnParseFailure.get -> bool -ExcelReader.Core.Parser.ExcelParserConfig.ThrowOnParseFailure.init -> void +ExcelReader.Core.Parser.ExcelParserOptions +ExcelReader.Core.Parser.ExcelParserOptions.ColumnNameComparer.get -> System.StringComparer! +ExcelReader.Core.Parser.ExcelParserOptions.ColumnNameComparer.init -> void +ExcelReader.Core.Parser.ExcelParserOptions.Culture.get -> System.Globalization.CultureInfo! +ExcelReader.Core.Parser.ExcelParserOptions.Culture.init -> void +ExcelReader.Core.Parser.ExcelParserOptions.ExcelParserOptions() -> void +ExcelReader.Core.Parser.ExcelParserOptions.HeaderNormalization.get -> ExcelReader.Core.Parser.HeaderNormalization +ExcelReader.Core.Parser.ExcelParserOptions.HeaderNormalization.init -> void +ExcelReader.Core.Parser.ExcelParserOptions.HeaderRow.get -> int +ExcelReader.Core.Parser.ExcelParserOptions.HeaderRow.init -> void +ExcelReader.Core.Parser.ExcelParserOptions.ThrowOnParseFailure.get -> bool +ExcelReader.Core.Parser.ExcelParserOptions.ThrowOnParseFailure.init -> void ExcelReader.Core.Parser.ExcelPropertySetter ExcelReader.Core.Parser.ExcelRequiredAttribute ExcelReader.Core.Parser.ExcelRequiredAttribute.AllowEmpty.get -> bool @@ -63,7 +63,9 @@ ExcelReader.Core.Parser.ExcelRowContext ExcelReader.Core.Parser.ExcelRowContext.Equals(ExcelReader.Core.Parser.ExcelRowContext other) -> bool ExcelReader.Core.Parser.ExcelRowContext.ExcelRowContext() -> void ExcelReader.Core.Parser.ExcelRowContext.FormatProvider.get -> System.IFormatProvider! +ExcelReader.Core.Parser.ExcelRowContext.FormatProvider.init -> void ExcelReader.Core.Parser.ExcelRowContext.IsDate1904.get -> bool +ExcelReader.Core.Parser.ExcelRowContext.IsDate1904.init -> void ExcelReader.Core.Parser.ExcelRowMapBuilder ExcelReader.Core.Parser.ExcelRowMapBuilder.Converted(string![]! names, ExcelReader.Core.Parser.IExcelCellConverter! converter, ExcelReader.Core.Parser.ExcelPropertySetter! set, bool isRequired = false, bool requireValue = false) -> ExcelReader.Core.Parser.ExcelRowMapBuilder! ExcelReader.Core.Parser.ExcelRowMapBuilder.ExcelRowMapBuilder() -> void @@ -81,7 +83,7 @@ ExcelReader.Core.Parser.HeaderNormalization.None = 0 -> ExcelReader.Core.Parser. ExcelReader.Core.Parser.HeaderNormalization.RemoveDiacritics = 4 -> ExcelReader.Core.Parser.HeaderNormalization ExcelReader.Core.Parser.HeaderNormalization.Trim = 1 -> ExcelReader.Core.Parser.HeaderNormalization ExcelReader.Core.Parser.IExcelCellConverter -ExcelReader.Core.Parser.IExcelCellConverter.TryConvert(in ExcelReader.Core.Reader.Cell cell, bool isDate1904, System.IFormatProvider! provider, out T value) -> bool +ExcelReader.Core.Parser.IExcelCellConverter.TryConvert(in ExcelReader.Core.Reader.Cell cell, in ExcelReader.Core.Parser.ExcelRowContext context, out T value) -> bool ExcelReader.Core.Parser.IExcelRowMap ExcelReader.Core.Parser.IExcelRowMap.ConfigureExcelRowMap(ExcelReader.Core.Parser.ExcelRowMapBuilder! builder) -> void ExcelReader.Core.Parser.SyncRowEnumerator @@ -671,14 +673,14 @@ override ExcelReader.Core.Writer.Xlsb.XlsbWriterOptions.ToString() -> string! override ExcelReader.Core.Writer.Xlsx.XlsxWriterOptions.Equals(object? obj) -> bool override ExcelReader.Core.Writer.Xlsx.XlsxWriterOptions.GetHashCode() -> int override ExcelReader.Core.Writer.Xlsx.XlsxWriterOptions.ToString() -> string! -static ExcelReader.Core.Parser.ExcelCellReaders.Enum(in ExcelReader.Core.Reader.Cell cell, bool isDate1904, System.IFormatProvider! provider, out TEnum value) -> bool -static ExcelReader.Core.Parser.ExcelCellReaders.Parsable(in ExcelReader.Core.Reader.Cell cell, bool isDate1904, System.IFormatProvider! provider, out TValue value) -> bool -static ExcelReader.Core.Parser.ExcelCellReaders.SpanParsable(in ExcelReader.Core.Reader.Cell cell, bool isDate1904, System.IFormatProvider! provider, out TValue value) -> bool -static ExcelReader.Core.Parser.ExcelCellReaders.Utf8(scoped in ExcelReader.Core.Reader.Cell cell, bool isDate1904, System.IFormatProvider! provider, out System.ReadOnlySpan value) -> bool -static ExcelReader.Core.Parser.ExcelParser.Build(System.Action!>! configure, ExcelReader.Core.Parser.ExcelParserConfig? config = null) -> ExcelReader.Core.Parser.ExcelParser! -static ExcelReader.Core.Parser.ExcelParser.BuildWithAttributeFallback(System.Action!>! configure, ExcelReader.Core.Parser.ExcelParserConfig? config = null) -> ExcelReader.Core.Parser.ExcelParser! -static ExcelReader.Core.Parser.ExcelParser.FromAttributes(ExcelReader.Core.Parser.ExcelParserConfig? config = null) -> ExcelReader.Core.Parser.ExcelParser! -static ExcelReader.Core.Parser.ExcelParser.Generated(ExcelReader.Core.Parser.ExcelParserConfig? config = null) -> ExcelReader.Core.Parser.ExcelParser! +static ExcelReader.Core.Parser.ExcelCellReaders.Enum(in ExcelReader.Core.Reader.Cell cell, in ExcelReader.Core.Parser.ExcelRowContext context, out TEnum value) -> bool +static ExcelReader.Core.Parser.ExcelCellReaders.Parsable(in ExcelReader.Core.Reader.Cell cell, in ExcelReader.Core.Parser.ExcelRowContext context, out TValue value) -> bool +static ExcelReader.Core.Parser.ExcelCellReaders.SpanParsable(in ExcelReader.Core.Reader.Cell cell, in ExcelReader.Core.Parser.ExcelRowContext context, out TValue value) -> bool +static ExcelReader.Core.Parser.ExcelCellReaders.Utf8(scoped in ExcelReader.Core.Reader.Cell cell, scoped in ExcelReader.Core.Parser.ExcelRowContext context, out System.ReadOnlySpan value) -> bool +static ExcelReader.Core.Parser.ExcelParser.Build(System.Action!>! configure, ExcelReader.Core.Parser.ExcelParserOptions? options = null) -> ExcelReader.Core.Parser.ExcelParser! +static ExcelReader.Core.Parser.ExcelParser.BuildWithAttributeFallback(System.Action!>! configure, ExcelReader.Core.Parser.ExcelParserOptions? options = null) -> ExcelReader.Core.Parser.ExcelParser! +static ExcelReader.Core.Parser.ExcelParser.FromAttributes(ExcelReader.Core.Parser.ExcelParserOptions? options = null) -> ExcelReader.Core.Parser.ExcelParser! +static ExcelReader.Core.Parser.ExcelParser.Generated(ExcelReader.Core.Parser.ExcelParserOptions? options = null) -> ExcelReader.Core.Parser.ExcelParser! static ExcelReader.Core.Parser.ExcelRowContext.operator !=(ExcelReader.Core.Parser.ExcelRowContext left, ExcelReader.Core.Parser.ExcelRowContext right) -> bool static ExcelReader.Core.Parser.ExcelRowContext.operator ==(ExcelReader.Core.Parser.ExcelRowContext left, ExcelReader.Core.Parser.ExcelRowContext right) -> bool static ExcelReader.Core.Reader.Csv.CsvDialect.Default.get -> ExcelReader.Core.Reader.Csv.CsvDialect @@ -803,9 +805,9 @@ static readonly ExcelReader.Core.Parser.ExcelCellReaders.TimeOnlyAuto -> ExcelRe static readonly ExcelReader.Core.Parser.ExcelCellReaders.TimeOnlySerial -> ExcelReader.Core.Parser.ExcelCellReader! static readonly ExcelReader.Core.Parser.ExcelCellReaders.TimeOnlyText -> ExcelReader.Core.Parser.ExcelCellReader! virtual ExcelReader.Core.Parser.AsyncRowEnumerator.DisposeAsync() -> System.Threading.Tasks.ValueTask -virtual ExcelReader.Core.Parser.ExcelCellReader.Invoke(in ExcelReader.Core.Reader.Cell cell, bool isDate1904, System.IFormatProvider! provider, out TValue value) -> bool +virtual ExcelReader.Core.Parser.ExcelCellReader.Invoke(in ExcelReader.Core.Reader.Cell cell, in ExcelReader.Core.Parser.ExcelRowContext context, out TValue value) -> bool virtual ExcelReader.Core.Parser.ExcelPropertySetter.Invoke(ref TModel model, TValue value) -> void -virtual ExcelReader.Core.Parser.ExcelRowParser.Invoke(ref TModel model, in ExcelReader.Core.Reader.Cell cell, bool isDate1904, System.IFormatProvider! provider) -> bool +virtual ExcelReader.Core.Parser.ExcelRowParser.Invoke(ref TModel model, in ExcelReader.Core.Reader.Cell cell, in ExcelReader.Core.Parser.ExcelRowContext context) -> bool virtual ExcelReader.Core.Reader.RowAction.Invoke(ref TState state, ExcelReader.Core.Reader.Row row) -> void ~ExcelReader.Core.Parser.ExcelEnumerable.GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Parser.ExcelEnumerable.AsyncEnumerator! ~ExcelReader.Core.Parser.ExcelEnumerable.GetEnumerator() -> ExcelReader.Core.Parser.ExcelEnumerable.Enumerator! diff --git a/src/ExcelReader.Core/Reader/ExcelDataReader.cs b/src/ExcelReader.Core/Reader/ExcelDataReader.cs index ca877c92..b83d21b4 100644 --- a/src/ExcelReader.Core/Reader/ExcelDataReader.cs +++ b/src/ExcelReader.Core/Reader/ExcelDataReader.cs @@ -208,7 +208,7 @@ public object GetValue(int i) CellType.Empty => DBNull.Value, CellType.Number => cell.TryGetDouble(out double d) ? d : cell.GetString(), CellType.Date => cell.TryGetDateTime(_isDate1904, out DateTime dt) ? dt : cell.GetString(), - CellType.Boolean => ExcelCellReaders.Bool(in cell, _isDate1904, CultureInfo.InvariantCulture, out bool b) ? b : cell.GetString(), + CellType.Boolean => ExcelCellReaders.Bool(in cell, default, out bool b) ? b : cell.GetString(), _ => cell.GetString(), }; } @@ -241,7 +241,7 @@ public string GetString(int i) public bool GetBoolean(int i) { Cell cell = CurrentCell(i); - return ExcelCellReaders.Bool(in cell, _isDate1904, CultureInfo.InvariantCulture, out bool value) + return ExcelCellReaders.Bool(in cell, default, out bool value) ? value : throw new FormatException($"Column {i} is not a valid boolean."); } diff --git a/src/ExcelReader.Core/Reader/Schema/SchemaInference.cs b/src/ExcelReader.Core/Reader/Schema/SchemaInference.cs index 1d31b075..ce927267 100644 --- a/src/ExcelReader.Core/Reader/Schema/SchemaInference.cs +++ b/src/ExcelReader.Core/Reader/Schema/SchemaInference.cs @@ -152,7 +152,7 @@ internal void Observe(in Cell cell, bool isDate1904, bool parseText) break; case CellType.Number: SawNumber = true; - if (!ExcelCellReaders.Parsable(in cell, isDate1904, CultureInfo.InvariantCulture, out _)) + if (!cell.TryParse(CultureInfo.InvariantCulture, out _)) { SawNonIntegralNumber = true; } @@ -176,7 +176,7 @@ private bool ObserveText(in Cell cell, bool isDate1904) bool canonical = HasCanonicalIntegerPart(text, out bool allDigits); if (canonical && allDigits) { - if (!ExcelCellReaders.Parsable(in cell, isDate1904, CultureInfo.InvariantCulture, out _)) + if (!cell.TryParse(CultureInfo.InvariantCulture, out _)) { return false; } diff --git a/src/ExcelReader.Core/Reader/SheetParallel.cs b/src/ExcelReader.Core/Reader/SheetParallel.cs index 8406aa60..0dfd53b2 100644 --- a/src/ExcelReader.Core/Reader/SheetParallel.cs +++ b/src/ExcelReader.Core/Reader/SheetParallel.cs @@ -43,7 +43,7 @@ public static class SheetParallel /// /// The row model type to bind each row to. /// The sheet to read. The caller keeps ownership and keeps its workbook open until the enumeration ends. - /// How columns bind to ; created with . Its locates the header. + /// How columns bind to ; created with . Its locates the header. /// Parallelism options. Defaults to . does not apply here and must be left at 0. /// A token to cancel enumeration. /// An enumerable that yields one per data row. @@ -67,7 +67,7 @@ public static IAsyncEnumerable ParseAsync( if (validated.HeaderRow != 0) { throw new ArgumentException( - $"{nameof(SheetParallelOptions)}.{nameof(SheetParallelOptions.HeaderRow)} does not apply to typed parsing; set {nameof(ExcelParserConfig)}.{nameof(ExcelParserConfig.HeaderRow)} instead.", + $"{nameof(SheetParallelOptions)}.{nameof(SheetParallelOptions.HeaderRow)} does not apply to typed parsing; set {nameof(ExcelParserOptions)}.{nameof(ExcelParserOptions.HeaderRow)} instead.", nameof(options)); } return SheetRunner.Parse(sheet, parser, validated.DegreeOfParallelism, ct); @@ -127,7 +127,7 @@ public static Task ForEachAsync( /// /// The model type. May be a with of properties. /// The sheet to read. The caller keeps ownership and keeps its workbook open until the task completes. - /// How columns bind to ; created with . Its is ignored in favour of . + /// How columns bind to ; created with . Its is ignored in favour of . /// Receives each record. See the remarks for the contract it must honor. /// Parallelism and header options. Defaults to . /// A token to cancel processing. @@ -141,7 +141,7 @@ public static Task ForEachAsync( /// overload: runs concurrently, delivery is /// at least once in general (exactly once on XLSB, and on a well-formed XLSX sheet), and records arrive /// in no particular order. A property that fails to parse keeps its default unless - /// is set; a missing [ExcelRequired] column or + /// is set; a missing [ExcelRequired] column or /// value throws ; empty rows are skipped. /// /// @@ -198,7 +198,7 @@ public static Task AggregateAsync( /// The accumulator type. See for the contract it must honor. /// The model type. /// The sheet to read. The caller keeps ownership and keeps its workbook open until the task completes. - /// How columns bind to ; created with . Its is ignored in favour of . + /// How columns bind to ; created with . Its is ignored in favour of . /// Parallelism and header options. Defaults to . /// A token to cancel processing. /// The accumulator holding every record. @@ -208,7 +208,7 @@ public static Task AggregateAsync( /// /// /// The header is bound once, before any row is folded. A property that fails to parse keeps its - /// default unless is set; a missing + /// default unless is set; a missing /// [ExcelRequired] column or value throws ; empty rows are skipped. /// /// diff --git a/src/ExcelReader.Core/Writer/Internal/RecordColumns.cs b/src/ExcelReader.Core/Writer/Internal/RecordColumns.cs index 2bb7d8a4..330217eb 100644 --- a/src/ExcelReader.Core/Writer/Internal/RecordColumns.cs +++ b/src/ExcelReader.Core/Writer/Internal/RecordColumns.cs @@ -180,7 +180,7 @@ internal static MethodInfo Select(Type pt, out bool asString) } /// - /// Formats text-fallback columns with the invariant culture, which is what + /// Formats text-fallback columns with the invariant culture, which is what /// defaults to, so a column written on any machine parses back. /// internal static class InvariantText diff --git a/src/ExcelReader.Generator/ExcelRowMapGenerator.cs b/src/ExcelReader.Generator/ExcelRowMapGenerator.cs index f6ac8f16..6869f785 100644 --- a/src/ExcelReader.Generator/ExcelRowMapGenerator.cs +++ b/src/ExcelReader.Generator/ExcelRowMapGenerator.cs @@ -650,16 +650,16 @@ private static void EmitReadFragment(StringBuilder sb, string qualifiedType, Pro case ReadKind.Value: case ReadKind.Nullable: EmitPropertyRaw(sb, qualifiedType, namesLiteral, p.Assign, req, - $"{p.Read.Reader}(in c, d, pr, out {p.Read.ValueType} v)"); + $"{p.Read.Reader}(in c, in x, out {p.Read.ValueType} v)"); return; case ReadKind.Converted: EmitPropertyRaw(sb, qualifiedType, namesLiteral, p.Assign, req, - $"{p.Read.Reader}.TryConvert(in c, d, pr, out {p.Read.ValueType} v)"); + $"{p.Read.Reader}.TryConvert(in c, in x, out {p.Read.ValueType} v)"); return; case ReadKind.GuidValue: case ReadKind.GuidNullable: EmitPropertyRaw(sb, qualifiedType, namesLiteral, p.Assign, req, - "global::ExcelReader.Core.Parser.ExcelCellReaders.Parsable(in c, d, pr, out global::System.Guid v)"); + "global::ExcelReader.Core.Parser.ExcelCellReaders.Parsable(in c, in x, out global::System.Guid v)"); return; default: return; @@ -668,7 +668,7 @@ private static void EmitReadFragment(StringBuilder sb, string qualifiedType, Pro private static void EmitPropertyRaw(StringBuilder sb, string qualifiedType, string namesLiteral, string assign, string req, string tryReadExpr) { - sb.AppendLine($" .PropertyRaw([{namesLiteral}], static (ref {qualifiedType} m, in global::ExcelReader.Core.Reader.Cell c, bool d, global::System.IFormatProvider pr) =>"); + sb.AppendLine($" .PropertyRaw([{namesLiteral}], static (ref {qualifiedType} m, in global::ExcelReader.Core.Reader.Cell c, in global::ExcelReader.Core.Parser.ExcelRowContext x) =>"); sb.AppendLine(" {"); sb.AppendLine($" if (!{tryReadExpr}) {{ return false; }}"); sb.AppendLine($" {assign};"); diff --git a/src/ExcelReader.Native/Typed/TypedApi.cs b/src/ExcelReader.Native/Typed/TypedApi.cs index a5d4baaf..a7766d07 100644 --- a/src/ExcelReader.Native/Typed/TypedApi.cs +++ b/src/ExcelReader.Native/Typed/TypedApi.cs @@ -432,41 +432,41 @@ private bool AppendString(in Cell cell) [MethodImpl(MethodImplOptions.NoInlining)] private bool AppendInt64(in Cell cell, bool isDate1904) { - return Append(_longs, ExcelCellReaders.Parsable(in cell, isDate1904, CultureInfo.InvariantCulture, out long value), value); + return Append(_longs, ExcelCellReaders.Parsable(in cell, new ExcelRowContext { IsDate1904 = isDate1904 }, out long value), value); } [MethodImpl(MethodImplOptions.NoInlining)] private bool AppendFloat64(in Cell cell, bool isDate1904) { - return Append(_doubles, ExcelCellReaders.Parsable(in cell, isDate1904, CultureInfo.InvariantCulture, out double value), value); + return Append(_doubles, ExcelCellReaders.Parsable(in cell, new ExcelRowContext { IsDate1904 = isDate1904 }, out double value), value); } [MethodImpl(MethodImplOptions.NoInlining)] private bool AppendBool(in Cell cell, bool isDate1904) { - return Append(_bools, ColumnParserFactory.ReadBool(in cell, isDate1904, CultureInfo.InvariantCulture, out bool flag), (byte)(flag ? 1 : 0)); + return Append(_bools, ColumnParserFactory.ReadBool(in cell, new ExcelRowContext { IsDate1904 = isDate1904 }, out bool flag), (byte)(flag ? 1 : 0)); } private static readonly int UnixEpochDayNumber = new DateOnly(1970, 1, 1).DayNumber; private bool AppendDate(in Cell cell, bool isDate1904) { - bool ok = ColumnParserFactory.ReadDateOnly(in cell, isDate1904, CultureInfo.InvariantCulture, out DateOnly value) - || ColumnParserFactory.ReadTextDateOnly(in cell, isDate1904, CultureInfo.InvariantCulture, out value); + bool ok = ColumnParserFactory.ReadDateOnly(in cell, new ExcelRowContext { IsDate1904 = isDate1904 }, out DateOnly value) + || ColumnParserFactory.ReadTextDateOnly(in cell, new ExcelRowContext { IsDate1904 = isDate1904 }, out value); return Append(_ints, ok, value.DayNumber - UnixEpochDayNumber); } private bool AppendTime(in Cell cell, bool isDate1904) { - bool ok = ColumnParserFactory.ReadTimeOnly(in cell, isDate1904, CultureInfo.InvariantCulture, out TimeOnly value) - || ColumnParserFactory.ReadTextTimeOnly(in cell, isDate1904, CultureInfo.InvariantCulture, out value); + bool ok = ColumnParserFactory.ReadTimeOnly(in cell, new ExcelRowContext { IsDate1904 = isDate1904 }, out TimeOnly value) + || ColumnParserFactory.ReadTextTimeOnly(in cell, new ExcelRowContext { IsDate1904 = isDate1904 }, out value); return Append(_longs, ok, value.ToTimeSpan().Ticks / 10); } private bool AppendTimestamp(in Cell cell, bool isDate1904) { - bool ok = ColumnParserFactory.ReadDateTime(in cell, isDate1904, CultureInfo.InvariantCulture, out DateTime value) - || ColumnParserFactory.ReadTextDateTime(in cell, isDate1904, CultureInfo.InvariantCulture, out value); + bool ok = ColumnParserFactory.ReadDateTime(in cell, new ExcelRowContext { IsDate1904 = isDate1904 }, out DateTime value) + || ColumnParserFactory.ReadTextDateTime(in cell, new ExcelRowContext { IsDate1904 = isDate1904 }, out value); return Append(_longs, ok, (value - DateTime.UnixEpoch).Ticks / 10); } diff --git a/tests/ExcelReader.Tests/Generator/GeneratorTests.cs b/tests/ExcelReader.Tests/Generator/GeneratorTests.cs index 4285abbd..73a07cff 100644 --- a/tests/ExcelReader.Tests/Generator/GeneratorTests.cs +++ b/tests/ExcelReader.Tests/Generator/GeneratorTests.cs @@ -224,7 +224,7 @@ namespace GeneratorTests.BadConverter { public sealed class WrongConverter : IExcelCellConverter { - public bool TryConvert(in Cell cell, bool isDate1904, System.IFormatProvider provider, out int value) + public bool TryConvert(in Cell cell, in ExcelRowContext context, out int value) { value = 0; return true; @@ -258,7 +258,7 @@ namespace GeneratorTests.GoodConverter { public sealed class UpperCaseConverter : IExcelCellConverter, IExcelCellWriter { - public bool TryConvert(in Cell cell, bool isDate1904, System.IFormatProvider provider, out string value) + public bool TryConvert(in Cell cell, in ExcelRowContext context, out string value) { value = cell.GetString().ToUpperInvariant(); return true; @@ -789,7 +789,7 @@ private static async Task BuildAsync(string[] header, string[] row return ms; } - private static async Task ParseAsync(ExcelParserConfig? config, string[] header, string[] row, Func describe) + private static async Task ParseAsync(ExcelParserOptions? config, string[] header, string[] row, Func describe) where T : IExcelRowMap { using MemoryStream ms = await BuildAsync(header, row); @@ -811,7 +811,7 @@ private static async Task ParseAsync(ExcelParserConfig? config, strin public static async Task RunAsync() { - var ordinal = new ExcelParserConfig { ColumnNameComparer = StringComparer.Ordinal }; + var ordinal = new ExcelParserOptions { ColumnNameComparer = StringComparer.Ordinal }; return new[] { await ParseAsync(null, new[] { "Name" }, new[] { "A" }, static m => m.Upper + "," + m.Lower), @@ -940,7 +940,7 @@ public ref partial struct Reading Assert.True(emit.Success, string.Join(Environment.NewLine, emit.Diagnostics.Select(static d => d.ToString()))); Assert.Contains("ref partial struct Reading : global::ExcelReader.Core.Parser.IExcelRowMap<", generated, StringComparison.Ordinal); Assert.Contains("global::ExcelReader.Core.Writer.IExcelRecordMap<", generated, StringComparison.Ordinal); - Assert.Contains("global::ExcelReader.Core.Parser.ExcelCellReaders.Utf8(in c, d, pr, out global::System.ReadOnlySpan v)", generated, StringComparison.Ordinal); + Assert.Contains("global::ExcelReader.Core.Parser.ExcelCellReaders.Utf8(in c, in x, out global::System.ReadOnlySpan v)", generated, StringComparison.Ordinal); Assert.Contains("row.Write(global::System.Text.Encoding.UTF8.GetString(m.Station))", generated, StringComparison.Ordinal); } } diff --git a/tests/ExcelReader.Tests/Parser/ConverterTests.cs b/tests/ExcelReader.Tests/Parser/ConverterTests.cs index efa44624..cf67d9f1 100644 --- a/tests/ExcelReader.Tests/Parser/ConverterTests.cs +++ b/tests/ExcelReader.Tests/Parser/ConverterTests.cs @@ -10,7 +10,7 @@ public class ConverterTests private sealed class BrlMoneyConverter : IExcelCellConverter { - public bool TryConvert(in Cell cell, bool isDate1904, IFormatProvider provider, out decimal value) + public bool TryConvert(in Cell cell, in ExcelRowContext context, out decimal value) { string text = cell.GetString().Replace("R$", string.Empty, StringComparison.Ordinal).Trim(); return decimal.TryParse(text, NumberStyles.Currency, CultureInfo.GetCultureInfo("pt-BR"), out value); @@ -19,10 +19,10 @@ public bool TryConvert(in Cell cell, bool isDate1904, IFormatProvider provider, private sealed class PercentConverter : IExcelCellConverter { - public bool TryConvert(in Cell cell, bool isDate1904, IFormatProvider provider, out Percent value) + public bool TryConvert(in Cell cell, in ExcelRowContext context, out Percent value) { string text = cell.GetString().TrimEnd('%'); - if (double.TryParse(text, NumberStyles.Any, provider, out double pct)) + if (double.TryParse(text, NumberStyles.Any, context.FormatProvider, out double pct)) { value = new Percent(pct / 100.0); return true; @@ -34,10 +34,10 @@ public bool TryConvert(in Cell cell, bool isDate1904, IFormatProvider provider, private sealed class NullablePercentConverter : IExcelCellConverter { - public bool TryConvert(in Cell cell, bool isDate1904, IFormatProvider provider, out Percent? value) + public bool TryConvert(in Cell cell, in ExcelRowContext context, out Percent? value) { string text = cell.GetString().TrimEnd('%'); - if (double.TryParse(text, NumberStyles.Any, provider, out double pct)) + if (double.TryParse(text, NumberStyles.Any, context.FormatProvider, out double pct)) { value = new Percent(pct / 100.0); return true; @@ -49,9 +49,9 @@ public bool TryConvert(in Cell cell, bool isDate1904, IFormatProvider provider, private sealed class YearConverter : IExcelCellConverter { - public bool TryConvert(in Cell cell, bool isDate1904, IFormatProvider provider, out int value) + public bool TryConvert(in Cell cell, in ExcelRowContext context, out int value) { - if (cell.TryGetDateTime(isDate1904, out DateTime dt)) + if (cell.TryGetDateTime(context.IsDate1904, out DateTime dt)) { value = dt.Year; return true; @@ -86,7 +86,7 @@ private sealed class DatedRow private sealed class MismatchedConverter : IExcelCellConverter { - public bool TryConvert(in Cell cell, bool isDate1904, IFormatProvider provider, out int value) + public bool TryConvert(in Cell cell, in ExcelRowContext context, out int value) { value = 0; return false; diff --git a/tests/ExcelReader.Tests/Parser/CsvParserTests.cs b/tests/ExcelReader.Tests/Parser/CsvParserTests.cs index 61f8a99b..d00f8c38 100644 --- a/tests/ExcelReader.Tests/Parser/CsvParserTests.cs +++ b/tests/ExcelReader.Tests/Parser/CsvParserTests.cs @@ -66,9 +66,9 @@ private sealed class DateRow private sealed class IsoDateConverter : IExcelCellConverter { - public bool TryConvert(in Cell cell, bool isDate1904, IFormatProvider provider, out DateTime value) + public bool TryConvert(in Cell cell, in ExcelRowContext context, out DateTime value) { - return DateTime.TryParse(cell.GetString(), provider, DateTimeStyles.None, out value); + return DateTime.TryParse(cell.GetString(), context.FormatProvider, DateTimeStyles.None, out value); } } @@ -125,7 +125,7 @@ public void PtBrCultureParsesCommaDecimal() { using var ms = Csv("Name,Amount\nConta,\"1.234,56\"\n"); using var reader = Excel.FromCsv(ms); - var config = new ExcelParserConfig { Culture = CultureInfo.GetCultureInfo("pt-BR") }; + var config = new ExcelParserOptions { Culture = CultureInfo.GetCultureInfo("pt-BR") }; MoneyRow row = ExcelParser.FromAttributes(config).Parse(reader.FirstSheet).Single(); @@ -174,7 +174,7 @@ public void EmptyCellLeavesNullableColumnNull() public void HeaderRowGreaterThanOneSkipsPrecedingRows() { using var ms = Csv("ignore me\nName,Age,Score,Active,Balance\nAlice,42,95.5,true,12345.67\n"); - var config = new ExcelParserConfig { HeaderRow = 2 }; + var config = new ExcelParserOptions { HeaderRow = 2 }; PersonRow row = ExcelParser.FromAttributes(config).Parse(Excel.FromCsv(ms).FirstSheet).Single(); @@ -215,7 +215,7 @@ public void ThrowOnParseFailureThrowsExcelParseExceptionForUnparseableColumn() { using var ms = Csv("Name,Amount\nConta,not-a-number\n"); using var reader = Excel.FromCsv(ms); - var config = new ExcelParserConfig { ThrowOnParseFailure = true }; + var config = new ExcelParserOptions { ThrowOnParseFailure = true }; ExcelParseException ex = Assert.Throws( () => ExcelParser.FromAttributes(config).Parse(reader.FirstSheet).ToList()); @@ -263,7 +263,7 @@ public void ANonFiniteTimeOnlyCellFailsToParseInsteadOfBecomingMidnight(string t { using var ms = Csv($"Start\n{text}\n"); using var reader = Excel.FromCsv(ms); - var config = new ExcelParserConfig { ThrowOnParseFailure = true }; + var config = new ExcelParserOptions { ThrowOnParseFailure = true }; ExcelParseException ex = Assert.Throws( () => ExcelParser.FromAttributes(config).Parse(reader.FirstSheet).ToList()); @@ -311,7 +311,7 @@ public void DateColumnRespectsCulture() { using var ms = Csv("Created,Day\n02/07/2026,02/07/2026\n"); using var reader = Excel.FromCsv(ms); - var config = new ExcelParserConfig { Culture = CultureInfo.GetCultureInfo("pt-BR") }; + var config = new ExcelParserOptions { Culture = CultureInfo.GetCultureInfo("pt-BR") }; DateRow row = ExcelParser.FromAttributes(config).Parse(reader.FirstSheet).Single(); diff --git a/tests/ExcelReader.Tests/Parser/ExcelCellReadersUtf8Tests.cs b/tests/ExcelReader.Tests/Parser/ExcelCellReadersUtf8Tests.cs index 6680c1b7..2573924e 100644 --- a/tests/ExcelReader.Tests/Parser/ExcelCellReadersUtf8Tests.cs +++ b/tests/ExcelReader.Tests/Parser/ExcelCellReadersUtf8Tests.cs @@ -17,7 +17,7 @@ public void ReadsTheRawBytesOfEveryField() { for (int i = 0; i < 3; i++) { - Assert.True(ExcelCellReaders.Utf8(row[i], false, System.Globalization.CultureInfo.InvariantCulture, out ReadOnlySpan value)); + Assert.True(ExcelCellReaders.Utf8(row[i], default, out ReadOnlySpan value)); seen.Add(Encoding.UTF8.GetString(value)); } } diff --git a/tests/ExcelReader.Tests/Parser/ExcelParserTests.cs b/tests/ExcelReader.Tests/Parser/ExcelParserTests.cs index 6ad2e6b5..afb3f5d4 100644 --- a/tests/ExcelReader.Tests/Parser/ExcelParserTests.cs +++ b/tests/ExcelReader.Tests/Parser/ExcelParserTests.cs @@ -223,7 +223,7 @@ public async Task OrdinalComparerRejectsCaseMismatch() { await using var ms = await TypedWorkbook.BuildAsync(["name"], ["Alice"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var config = new ExcelParserConfig { ColumnNameComparer = StringComparer.Ordinal }; + var config = new ExcelParserOptions { ColumnNameComparer = StringComparer.Ordinal }; var result = ExcelParser.FromAttributes(config).Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Null(result[0].Name); @@ -234,7 +234,7 @@ public async Task OrdinalComparerAcceptsExactCaseMatch() { await using var ms = await TypedWorkbook.BuildAsync(["Name"], ["Alice"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var config = new ExcelParserConfig { ColumnNameComparer = StringComparer.Ordinal }; + var config = new ExcelParserOptions { ColumnNameComparer = StringComparer.Ordinal }; var result = ExcelParser.FromAttributes(config).Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Equal("Alice", result[0].Name); @@ -249,7 +249,7 @@ public async Task HeaderRowTwoSkipsFirstRow() ["Name"], ["Alice"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var config = new ExcelParserConfig { HeaderRow = 2 }; + var config = new ExcelParserOptions { HeaderRow = 2 }; var result = ExcelParser.FromAttributes(config).Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Equal("Alice", result[0].Name); @@ -264,7 +264,7 @@ public async Task HeaderRowThreeSkipsTwoRows() ["Name"], ["Bob"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var config = new ExcelParserConfig { HeaderRow = 3 }; + var config = new ExcelParserOptions { HeaderRow = 3 }; var result = ExcelParser.FromAttributes(config).Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Equal("Bob", result[0].Name); @@ -275,14 +275,14 @@ public async Task HeaderRowThreeSkipsTwoRows() public void ZeroHeaderRowThrows() { Assert.Throws(() => - ExcelParser.FromAttributes(new ExcelParserConfig { HeaderRow = 0 })); + ExcelParser.FromAttributes(new ExcelParserOptions { HeaderRow = 0 })); } [Fact] public void NegativeHeaderRowThrows() { Assert.Throws(() => - ExcelParser.FromAttributes(new ExcelParserConfig { HeaderRow = -1 })); + ExcelParser.FromAttributes(new ExcelParserOptions { HeaderRow = -1 })); } diff --git a/tests/ExcelReader.Tests/Parser/ExcelRowMapBuilderTests.cs b/tests/ExcelReader.Tests/Parser/ExcelRowMapBuilderTests.cs index 80f4606f..a94fb2eb 100644 --- a/tests/ExcelReader.Tests/Parser/ExcelRowMapBuilderTests.cs +++ b/tests/ExcelReader.Tests/Parser/ExcelRowMapBuilderTests.cs @@ -97,7 +97,7 @@ public async Task RequiredColumnMissingThrowsLikeReflection() TypeMapInfo info = builder.Build(); await using XlsxWorkbook mappedReader = await Excel.FromXlsxAsync(mappedStream, ct: TestContext.Current.CancellationToken); - var mappedEnumerable = new ExcelEnumerable(mappedReader.FirstSheet, new ExcelParserConfig(), info); + var mappedEnumerable = new ExcelEnumerable(mappedReader.FirstSheet, new ExcelParserOptions(), info); ExcelParseException mappedEx = Assert.Throws(() => mappedEnumerable.ToList()); await using MemoryStream reflectedStream = await TypedWorkbook.BuildAsync(["Name"], ["Alice"]); diff --git a/tests/ExcelReader.Tests/Parser/GeneratedMapParityTests.cs b/tests/ExcelReader.Tests/Parser/GeneratedMapParityTests.cs index 3e2a8975..7158a982 100644 --- a/tests/ExcelReader.Tests/Parser/GeneratedMapParityTests.cs +++ b/tests/ExcelReader.Tests/Parser/GeneratedMapParityTests.cs @@ -47,7 +47,7 @@ public partial class EveryTypeModel public sealed class UpperCaseConverter : IExcelCellConverter, IExcelCellWriter { - public bool TryConvert(in Cell cell, bool isDate1904, IFormatProvider provider, out string value) + public bool TryConvert(in Cell cell, in ExcelRowContext context, out string value) { value = cell.GetString().ToUpperInvariant(); return true; diff --git a/tests/ExcelReader.Tests/Parser/HeaderCollisionTests.cs b/tests/ExcelReader.Tests/Parser/HeaderCollisionTests.cs index 11ac642a..36e7624f 100644 --- a/tests/ExcelReader.Tests/Parser/HeaderCollisionTests.cs +++ b/tests/ExcelReader.Tests/Parser/HeaderCollisionTests.cs @@ -41,9 +41,9 @@ private sealed partial class SamePropertyAliasModel public string Name { get; set; } = ""; } - private static readonly ExcelParserConfig Ordinal = new() { ColumnNameComparer = StringComparer.Ordinal }; + private static readonly ExcelParserOptions Ordinal = new() { ColumnNameComparer = StringComparer.Ordinal }; - private static ExcelParser[] CaseParsers(ExcelParserConfig? config) + private static ExcelParser[] CaseParsers(ExcelParserOptions? config) { return [ @@ -55,7 +55,7 @@ private static ExcelParser[] CaseParsers(ExcelParserConfig? config) ]; } - private static ExcelParser[] AliasParsers(ExcelParserConfig? config) + private static ExcelParser[] AliasParsers(ExcelParserOptions? config) { return [ @@ -67,7 +67,7 @@ private static ExcelParser[] AliasParsers(ExcelParserConfig? config) ]; } - private static ExcelParser[] DiacriticParsers(ExcelParserConfig? config) + private static ExcelParser[] DiacriticParsers(ExcelParserOptions? config) { return [ @@ -130,7 +130,7 @@ public async Task AliasCollidingWithAnotherPropertyThrows() [Fact] public async Task NormalizationEquivalentHeadersThrowOnlyWhenNormalizationMakesThemEqual() { - foreach (ExcelParser parser in DiacriticParsers(new ExcelParserConfig { HeaderNormalization = HeaderNormalization.RemoveDiacritics })) + foreach (ExcelParser parser in DiacriticParsers(new ExcelParserOptions { HeaderNormalization = HeaderNormalization.RemoveDiacritics })) { await Assert.ThrowsAsync(() => ParseAsync(parser, ["Cafe"], ["x"])); } diff --git a/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvModelTests.cs b/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvModelTests.cs index 65508c2a..4c108968 100644 --- a/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvModelTests.cs +++ b/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvModelTests.cs @@ -76,7 +76,7 @@ private static List Sequential(byte[] csv, int headerRow) { using CsvReader reader = Excel.FromCsv(csv); var rows = new List(); - foreach (Person person in ExcelParser.FromAttributes(new ExcelParserConfig { HeaderRow = headerRow }).Parse(reader.FirstSheet)) + foreach (Person person in ExcelParser.FromAttributes(new ExcelParserOptions { HeaderRow = headerRow }).Parse(reader.FirstSheet)) { rows.Add(Render(person)); } @@ -92,7 +92,7 @@ private static ExcelParser[] EveryMap() ExcelParser.Generated(), ExcelParser.FromAttributes(), ExcelParser.Build(static b => b - .PropertyRaw(["Name"], static (ref Person m, in Cell c, bool d, IFormatProvider pr) => + .PropertyRaw(["Name"], static (ref Person m, in Cell c, in ExcelRowContext x) => { m.Name = c.Value; return true; @@ -204,7 +204,7 @@ public async Task BindsReorderedAndAliasedColumns() { byte[] csv = "Note,Identifier,Name\nn1,1,ada\nn2,2,bob\n"u8.ToArray(); ExcelParser map = ExcelParser.Build(static b => b - .PropertyRaw(["Name"], static (ref Person m, in Cell c, bool d, IFormatProvider pr) => + .PropertyRaw(["Name"], static (ref Person m, in Cell c, in ExcelRowContext x) => { m.Name = c.Value; return true; @@ -274,7 +274,7 @@ public async Task SkipsEmptyRecords() public async Task ReportsTheExactRowSequentiallyAndZeroWhenPartitioned() { byte[] csv = "Name,Id,Note\nada,1,n\nbob,2,n\ncid,oops,n\ndan,4,n\n"u8.ToArray(); - ExcelParser map = ExcelParser.Generated(new ExcelParserConfig { ThrowOnParseFailure = true }); + ExcelParser map = ExcelParser.Generated(new ExcelParserOptions { ThrowOnParseFailure = true }); using CsvReader reader = Excel.FromCsv(csv); ExcelParseException sequential = await Assert.ThrowsAsync(() => SheetParallel.AggregateAsync( @@ -289,7 +289,7 @@ public async Task ReportsTheExactRowSequentiallyAndZeroWhenPartitioned() public async Task DiscardsParseFailuresFromAMisguessedPartition() { byte[] csv = "Name,Id,Note\nok,1,\"one\nBOOM,notint,x\"\nok,2,two\nok,3,\"three\nBOOM,bad,y\"\nok,4,four\n"u8.ToArray(); - ExcelParser map = ExcelParser.Generated(new ExcelParserConfig { ThrowOnParseFailure = true }); + ExcelParser map = ExcelParser.Generated(new ExcelParserOptions { ThrowOnParseFailure = true }); List expected = Sequential(csv, headerRow: 1); for (int chunkSize = 1; chunkSize <= csv.Length; chunkSize++) { diff --git a/tests/ExcelReader.Tests/Parser/ParallelCsv/CsvChunkWorkerTests.cs b/tests/ExcelReader.Tests/Parser/ParallelCsv/CsvChunkWorkerTests.cs index 2be4f5fb..99c9bde8 100644 --- a/tests/ExcelReader.Tests/Parser/ParallelCsv/CsvChunkWorkerTests.cs +++ b/tests/ExcelReader.Tests/Parser/ParallelCsv/CsvChunkWorkerTests.cs @@ -19,7 +19,7 @@ private static async Task> ParseAsync(byte[] csv, long start { using var headerReader = Excel.FromCsv(csv); CsvBoundColumnMap map = CsvHeaderBinder.Bind( - headerReader, new ExcelParserConfig(), TypeMapper.GetCsvInfo(), out _); + headerReader, new ExcelParserOptions(), TypeMapper.GetCsvInfo(), out _); var source = new CsvChunkSource(csv.AsMemory()); return await CsvChunkWorker.ParseAsync( @@ -29,7 +29,7 @@ private static async Task> ParseAsync(byte[] csv, long start map, TypeMapper.GetCsvInfo(), CsvReaderOptions.Default, - new ExcelParserConfig(), + new ExcelParserOptions(), [], CancellationToken.None); } @@ -120,7 +120,7 @@ public async Task ParsesTheSameRecordFromAFileBackedSourceAsFromMemory() { using var headerReader = Excel.FromCsv(csv); CsvBoundColumnMap map = CsvHeaderBinder.Bind( - headerReader, new ExcelParserConfig(), TypeMapper.GetCsvInfo(), out _); + headerReader, new ExcelParserOptions(), TypeMapper.GetCsvInfo(), out _); using SafeFileHandle handle = File.OpenHandle(path, FileMode.Open, FileAccess.Read, FileShare.Read); var source = new CsvChunkSource(handle, csv.Length); @@ -132,7 +132,7 @@ public async Task ParsesTheSameRecordFromAFileBackedSourceAsFromMemory() map, TypeMapper.GetCsvInfo(), CsvReaderOptions.Default, - new ExcelParserConfig(), + new ExcelParserOptions(), [], TestContext.Current.CancellationToken); diff --git a/tests/ExcelReader.Tests/Parser/ParallelCsv/CsvHeaderBinderTests.cs b/tests/ExcelReader.Tests/Parser/ParallelCsv/CsvHeaderBinderTests.cs index cd1495ff..20a43952 100644 --- a/tests/ExcelReader.Tests/Parser/ParallelCsv/CsvHeaderBinderTests.cs +++ b/tests/ExcelReader.Tests/Parser/ParallelCsv/CsvHeaderBinderTests.cs @@ -23,7 +23,7 @@ public void BindsTheHeaderAndReportsWhereDataStarts() CsvBoundColumnMap map = CsvHeaderBinder.Bind( reader, - new ExcelParserConfig(), + new ExcelParserOptions(), TypeMapper.GetCsvInfo(), out long firstDataRecordOffset); @@ -42,7 +42,7 @@ public void AProjectorBuiltFromABoundMapParsesTheVeryFirstRowAsData() using var headerReader = Excel.FromCsv(all); CsvBoundColumnMap map = CsvHeaderBinder.Bind( headerReader, - new ExcelParserConfig(), + new ExcelParserOptions(), TypeMapper.GetCsvInfo(), out long dataStart); @@ -77,7 +77,7 @@ public void TheSameBoundMapIsSafeToShareAcrossConcurrentProjectors() using var headerReader = Excel.FromCsv(all); CsvBoundColumnMap map = CsvHeaderBinder.Bind( headerReader, - new ExcelParserConfig(), + new ExcelParserOptions(), TypeMapper.GetCsvInfo(), out long dataStart); diff --git a/tests/ExcelReader.Tests/Parser/ParallelCsv/ParallelCsvEnumerableTests.cs b/tests/ExcelReader.Tests/Parser/ParallelCsv/ParallelCsvEnumerableTests.cs index 0d99f848..4050c2d6 100644 --- a/tests/ExcelReader.Tests/Parser/ParallelCsv/ParallelCsvEnumerableTests.cs +++ b/tests/ExcelReader.Tests/Parser/ParallelCsv/ParallelCsvEnumerableTests.cs @@ -26,10 +26,10 @@ private sealed class RequiredRow private static ParallelCsvEnumerable Build(byte[] csv, int dop, int chunkSizeOverride) { - return Build(csv, dop, chunkSizeOverride, new ExcelParserConfig()); + return Build(csv, dop, chunkSizeOverride, new ExcelParserOptions()); } - private static ParallelCsvEnumerable Build(byte[] csv, int dop, int chunkSizeOverride, ExcelParserConfig config) + private static ParallelCsvEnumerable Build(byte[] csv, int dop, int chunkSizeOverride, ExcelParserOptions config) { using var headerReader = Excel.FromCsv(csv); CsvBoundColumnMap map = CsvHeaderBinder.Bind( @@ -42,7 +42,7 @@ private static ParallelCsvEnumerable Build(byte[] csv, int dop, int CsvReaderOptions.Default, config, dop); } - private static async Task AssertSameFailureAsSequentialAsync(byte[] csv, ExcelParserConfig config) + private static async Task AssertSameFailureAsSequentialAsync(byte[] csv, ExcelParserOptions config) { using CsvReader sequentialReader = Excel.FromCsv(csv); ExcelParseException expected = Assert.Throws( @@ -169,7 +169,7 @@ public Task ReportsAParseFailureAtTheRowTheSequentialPathWouldReport() return AssertSameFailureAsSequentialAsync( Encoding.UTF8.GetBytes(sb.ToString()), - new ExcelParserConfig { ThrowOnParseFailure = true }); + new ExcelParserOptions { ThrowOnParseFailure = true }); } [Fact] @@ -183,7 +183,7 @@ public Task ReportsABlankRequiredCellTheWayTheSequentialPathDoes() } return AssertSameFailureAsSequentialAsync( - Encoding.UTF8.GetBytes(sb.ToString()), new ExcelParserConfig()); + Encoding.UTF8.GetBytes(sb.ToString()), new ExcelParserOptions()); } [Fact] @@ -198,7 +198,7 @@ public Task ReportsAParseFailureRowRelativeToANonDefaultHeaderRow() return AssertSameFailureAsSequentialAsync( Encoding.UTF8.GetBytes(sb.ToString()), - new ExcelParserConfig { HeaderRow = 3, ThrowOnParseFailure = true }); + new ExcelParserOptions { HeaderRow = 3, ThrowOnParseFailure = true }); } [Fact] diff --git a/tests/ExcelReader.Tests/Parser/ParallelCsv/ParseCsvParallelTests.cs b/tests/ExcelReader.Tests/Parser/ParallelCsv/ParseCsvParallelTests.cs index 8ebd9fdb..960740c3 100644 --- a/tests/ExcelReader.Tests/Parser/ParallelCsv/ParseCsvParallelTests.cs +++ b/tests/ExcelReader.Tests/Parser/ParallelCsv/ParseCsvParallelTests.cs @@ -138,7 +138,7 @@ public void AHeaderRowOnTheParallelOptionsThrowsBecauseTypedParsingReadsItFromTh ArgumentException exception = Assert.Throws( () => SheetParallel.ParseAsync(source.FirstSheet, ExcelParser.FromAttributes(), options: new SheetParallelOptions { HeaderRow = 2 }, ct: TestContext.Current.CancellationToken)); - Assert.Contains(nameof(ExcelParserConfig.HeaderRow), exception.Message, StringComparison.Ordinal); + Assert.Contains(nameof(ExcelParserOptions.HeaderRow), exception.Message, StringComparison.Ordinal); } [Fact] diff --git a/tests/ExcelReader.Tests/Parser/ParserFeatureTests.cs b/tests/ExcelReader.Tests/Parser/ParserFeatureTests.cs index a29a87dc..00d4e276 100644 --- a/tests/ExcelReader.Tests/Parser/ParserFeatureTests.cs +++ b/tests/ExcelReader.Tests/Parser/ParserFeatureTests.cs @@ -73,7 +73,7 @@ public async Task PtBrCultureParsesCommaDecimalAndThousandsSeparator() { await using var ms = await TypedWorkbook.BuildAsync(["Name", "Amount"], ["Conta", "1.234,56"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var config = new ExcelParserConfig { Culture = CultureInfo.GetCultureInfo("pt-BR") }; + var config = new ExcelParserOptions { Culture = CultureInfo.GetCultureInfo("pt-BR") }; MoneyRow row = ExcelParser.FromAttributes(config).Parse(reader.FirstSheet).Single(); @@ -97,7 +97,7 @@ public async Task ThrowOnParseFailureThrowsForUnparseableNonRequiredColumn() { await using var ms = await TypedWorkbook.BuildAsync(["Name", "Amount"], ["Conta", "not-a-number"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var config = new ExcelParserConfig { ThrowOnParseFailure = true }; + var config = new ExcelParserOptions { ThrowOnParseFailure = true }; ExcelParseException ex = Assert.Throws( () => ExcelParser.FromAttributes(config).Parse(reader.FirstSheet).ToList()); diff --git a/tests/ExcelReader.Tests/Parser/RefStructModelTests.cs b/tests/ExcelReader.Tests/Parser/RefStructModelTests.cs index 120d4447..87e6e858 100644 --- a/tests/ExcelReader.Tests/Parser/RefStructModelTests.cs +++ b/tests/ExcelReader.Tests/Parser/RefStructModelTests.cs @@ -32,7 +32,7 @@ private readonly ref struct SaleSpanRef private sealed class UpperCaseConverter : IExcelCellConverter { - public bool TryConvert(in Cell cell, bool isDate1904, IFormatProvider provider, out string value) + public bool TryConvert(in Cell cell, in ExcelRowContext context, out string value) { value = cell.GetString().ToUpperInvariant(); return true; @@ -179,7 +179,7 @@ public async Task ParseNamed_ThrowOnParseFailureThrowsExcelParseException() await using var ms = await TypedWorkbook.BuildAsync( ["Name", "Id", "Value", "Date"], ["Alice", "oops", 10.5, SampleDate]); - var config = new ExcelParserConfig { ThrowOnParseFailure = true }; + var config = new ExcelParserOptions { ThrowOnParseFailure = true }; using var reader = Excel.FromXlsx(ms, leaveOpen: true); var enumerator = ExcelParser.FromAttributes(config).Parse(reader.FirstSheet).GetEnumerator(); diff --git a/tests/ExcelReader.Tests/Parser/RequiredTests.cs b/tests/ExcelReader.Tests/Parser/RequiredTests.cs index fa28db6b..0ddf20e2 100644 --- a/tests/ExcelReader.Tests/Parser/RequiredTests.cs +++ b/tests/ExcelReader.Tests/Parser/RequiredTests.cs @@ -133,7 +133,7 @@ public async Task ThrowOnParseFailureReportsColumnAndRawValueEvenForRequiredColu { await using var ms = await TypedWorkbook.BuildAsync(["Id", "FullName"], ["oops", "Alice"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var config = new ExcelParserConfig { ThrowOnParseFailure = true }; + var config = new ExcelParserOptions { ThrowOnParseFailure = true }; ExcelParseException ex = Assert.Throws( () => ExcelParser.FromAttributes(config).Parse(reader.FirstSheet).ToList()); diff --git a/tests/ExcelReader.Tests/Parser/SheetParallelXlsxTests.cs b/tests/ExcelReader.Tests/Parser/SheetParallelXlsxTests.cs index e3f8a859..b4696b3b 100644 --- a/tests/ExcelReader.Tests/Parser/SheetParallelXlsxTests.cs +++ b/tests/ExcelReader.Tests/Parser/SheetParallelXlsxTests.cs @@ -143,7 +143,7 @@ public async Task AParseFailureReportsTheSheetRow() } using MemoryStream ms = WorkbookBuilder.Build(sb.ToString()); using XlsxWorkbook workbook = Excel.FromXlsx(ms); - ExcelParser parser = ExcelParser.FromAttributes(new ExcelParserConfig { ThrowOnParseFailure = true }); + ExcelParser parser = ExcelParser.FromAttributes(new ExcelParserOptions { ThrowOnParseFailure = true }); ExcelParseException sequential = Assert.Throws(() => parser.Parse(workbook.FirstSheet).ToList()); SheetPipelineSource source = SheetPipelineSource.Create(workbook.FirstSheet)!; @@ -171,7 +171,7 @@ public async Task AParseFailureAfterPipelineRecoveryReportsTheSheetRow() } using MemoryStream ms = WorkbookBuilder.Build(sb.ToString()); using XlsxWorkbook workbook = Excel.FromXlsx(ms); - ExcelParser parser = ExcelParser.FromAttributes(new ExcelParserConfig { ThrowOnParseFailure = true }); + ExcelParser parser = ExcelParser.FromAttributes(new ExcelParserOptions { ThrowOnParseFailure = true }); ExcelParseException sequential = Assert.Throws(() => parser.Parse(workbook.FirstSheet).ToList()); SheetPipelineSource source = SheetPipelineSource.Create(workbook.FirstSheet)!; Assert.Equal(211, sequential.Row); diff --git a/tests/ExcelReader.Tests/Parser/UnifiedParserTests.cs b/tests/ExcelReader.Tests/Parser/UnifiedParserTests.cs index 40265324..ba7f6f38 100644 --- a/tests/ExcelReader.Tests/Parser/UnifiedParserTests.cs +++ b/tests/ExcelReader.Tests/Parser/UnifiedParserTests.cs @@ -154,14 +154,14 @@ public void RefStructModelParsesThroughExcelFluentParser() { using CsvReader reader = Excel.FromCsv(Csv); var parser = ExcelParser.Build(static builder => builder - .PropertyRaw(["Name"], static (ref FluentSale m, in Cell c, bool _, IFormatProvider _) => + .PropertyRaw(["Name"], static (ref FluentSale m, in Cell c, in ExcelRowContext _) => { m.Name = c.Value; return true; }) - .PropertyRaw(["Id"], static (ref FluentSale m, in Cell c, bool _, IFormatProvider p) => + .PropertyRaw(["Id"], static (ref FluentSale m, in Cell c, in ExcelRowContext x) => { - bool ok = c.TryParse(p, out int id); + bool ok = c.TryParse(x.FormatProvider, out int id); m.Id = id; return ok; })); diff --git a/tests/ExcelReader.Tests/Reader/Xls/XlsWorkbookTests.cs b/tests/ExcelReader.Tests/Reader/Xls/XlsWorkbookTests.cs index aff305a6..5b5c4d54 100644 --- a/tests/ExcelReader.Tests/Reader/Xls/XlsWorkbookTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xls/XlsWorkbookTests.cs @@ -305,7 +305,7 @@ public void XlsParserHeaderRowSkipsLeadingRows() { using var ms = XlsWorkbookBuilder.Build(sheets: [("S1", [["skip"], ["Name"], ["Sol"]])]); using var reader = Excel.FromXls(ms); - var parser = ExcelParser.FromAttributes(new ExcelParserConfig { HeaderRow = 2 }); + var parser = ExcelParser.FromAttributes(new ExcelParserOptions { HeaderRow = 2 }); var result = parser.Parse(reader.FirstSheet).ToList(); Assert.Single(result); diff --git a/tests/ExcelReader.Tests/Writer/WorkbookWriterTests.cs b/tests/ExcelReader.Tests/Writer/WorkbookWriterTests.cs index af1a9491..aed46749 100644 --- a/tests/ExcelReader.Tests/Writer/WorkbookWriterTests.cs +++ b/tests/ExcelReader.Tests/Writer/WorkbookWriterTests.cs @@ -114,9 +114,9 @@ private sealed class IgnoreRow private sealed class MoneyConverter : IExcelCellConverter, IExcelCellWriter { - public bool TryConvert(in Cell cell, bool isDate1904, IFormatProvider provider, out Money value) + public bool TryConvert(in Cell cell, in ExcelRowContext context, out Money value) { - if (cell.TryParse(provider, out decimal amount)) + if (cell.TryParse(context.FormatProvider, out decimal amount)) { value = new Money(amount); return true; @@ -833,7 +833,7 @@ public async Task HeaderRowTwoConfig() }).ConfigureAwait(true); await using var reader = Excel.FromXlsx(ms); - var config = new ExcelParserConfig { HeaderRow = 2 }; + var config = new ExcelParserOptions { HeaderRow = 2 }; var rows = ExcelParser.FromAttributes(config).Parse(reader.FirstSheet).ToList(); Assert.Single(rows); Assert.Equal("HeaderRow2", rows[0].Name); From 042c8a38e0b39b7ca146da12195754344f05b7d3 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Sun, 11 Oct 2026 01:30:12 -0300 Subject: [PATCH 086/115] refactor!: take options objects instead of positional optional parameters Excel.InferSchema now takes SchemaInferenceOptions (replacing both overloads), the ExcelDataReader constructor takes ExcelDataReaderOptions, ToArrowRecordBatch takes ArrowReadOptions and WriteRecordBatch/WriteRecordBatchAsync take ArrowWriteOptions. A new knob becomes a new init property instead of another overload. --- docs/guide/reading.md | 12 ++-- .../ArrowConversionExtensions.cs | 20 +++--- src/ExcelReader.Arrow/ArrowReadOptions.cs | 24 +++++++ src/ExcelReader.Arrow/ArrowWriteExtensions.cs | 67 ++++++++++--------- src/ExcelReader.Arrow/ArrowWriteOptions.cs | 15 +++++ .../PublicAPI/PublicAPI.Shipped.txt | 9 --- .../PublicAPI/PublicAPI.Unshipped.txt | 23 +++++++ src/ExcelReader.Cli/CliCommands.cs | 2 +- .../PublicAPI/PublicAPI.Shipped.txt | 3 - .../PublicAPI/PublicAPI.Unshipped.txt | 16 +++++ src/ExcelReader.Core/Reader/Excel.cs | 47 +++---------- .../Reader/ExcelDataReader.cs | 16 ++--- .../Reader/ExcelDataReaderOptions.cs | 16 +++++ .../Reader/Schema/ExcelColumnSchema.cs | 2 +- .../Reader/Schema/ExcelColumnType.cs | 2 +- .../Reader/Schema/SchemaInference.cs | 2 +- .../Reader/Schema/SchemaInferenceOptions.cs | 27 ++++++++ .../Interop/ArrowConversionBenchmark.cs | 6 +- .../Parse/ParseBenchmark.cs | 4 +- .../Read/ReadBenchmark.cs | 2 +- .../Read/StringHeavyReadBenchmark.cs | 4 +- .../Read/XlsReadBenchmark.cs | 2 +- .../RealData/RealDataReadBenchmark.cs | 8 +-- .../Arrow/ArrowConversionTests.cs | 26 +++---- .../Arrow/ArrowWriteExtensionsTests.cs | 10 +-- .../Reader/Csv/CsvReplayTests.cs | 2 +- .../Reader/ExcelDataReaderTests.cs | 4 +- .../Reader/SchemaInferenceTests.cs | 2 +- .../Reader/SheetConsumerTests.cs | 2 +- .../Reader/Tables/TableBindingTests.cs | 8 +-- 30 files changed, 231 insertions(+), 152 deletions(-) create mode 100644 src/ExcelReader.Arrow/ArrowReadOptions.cs create mode 100644 src/ExcelReader.Arrow/ArrowWriteOptions.cs create mode 100644 src/ExcelReader.Core/Reader/ExcelDataReaderOptions.cs create mode 100644 src/ExcelReader.Core/Reader/Schema/SchemaInferenceOptions.cs diff --git a/docs/guide/reading.md b/docs/guide/reading.md index ba6e00b0..8852fb1a 100644 --- a/docs/guide/reading.md +++ b/docs/guide/reading.md @@ -141,7 +141,7 @@ using var workbook = Excel.FromXlsxFile("report.xlsx"); Apache.Arrow.RecordBatch batch = workbook.FirstSheet.ToArrowRecordBatch(); ``` -`schema` defaults to `Excel.InferSchema`'s guess; pass an explicit `ExcelColumnSchema[]` to skip inference. The whole sheet is materialized into one `RecordBatch` — there is no chunked/streaming variant yet. Inference costs about the same time and ~1.8x the allocation of an explicit schema on the same file. See [Arrow conversion](../performance/benchmarks.md#arrow-conversion) in the benchmarks. +The schema defaults to `Excel.InferSchema`'s guess; pass one in `new ArrowReadOptions { Schema = ... }` to skip inference, and set `HeaderRow` there (default 1, 0 for none). The whole sheet is materialized into one `RecordBatch` — there is no chunked/streaming variant yet. Inference costs about the same time and ~1.8x the allocation of an explicit schema on the same file. See [Arrow conversion](../performance/benchmarks.md#arrow-conversion) in the benchmarks. `WriteRecordBatch`/`WriteRecordBatchAsync` are the write-side mirror — one call from a `RecordBatch` to a sheet, for XLSX, XLSB, XLS, and CSV: @@ -154,7 +154,7 @@ using XlsxWorkbookWriter workbook = XlsxWorkbookWriter.Create(File.Create("repor workbook.WriteRecordBatch(batch); // adds the sheet, writes the header + every row, ends the workbook ``` -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. +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 `new ArrowWriteOptions { WriteHeader = false }` to skip the header row, or set `SheetName` (XLSX/XLSB/XLS only — CSV has no sheet name) to rename it from the `"Sheet1"` default. ## Open by auto-detecting the format @@ -255,8 +255,8 @@ if (workbook.TryGetTable("Sales", out ExcelTable? sales)) its first column. - A table whose header row is turned off (`HeaderRowCount == 0`) still enumerates, but `Parse` throws `InvalidOperationException` for every map, and `ExcelDataReader` and `InferSchema` throw it when - `headerRow` is greater than 0. Read it with `new ExcelDataReader(table.AsSheet(), headerRow: 0)`, - `Excel.InferSchema(table.AsSheet(), headerRow: 0)`, or by column index on the rows. + `HeaderRow` is greater than 0. Read it with `new ExcelDataReader(table.AsSheet(), new ExcelDataReaderOptions { HeaderRow = 0 })`, + `Excel.InferSchema(table.AsSheet(), new SchemaInferenceOptions { HeaderRow = 0 })`, or by column index on the rows. - `StyleName` is the table style's name, such as `TableStyleMedium2`, or `null` when the table has none. ## Reading sheets in parallel @@ -366,13 +366,13 @@ using System.Data; using ExcelReader.Core.Reader; using IExcelWorkbook workbook = Excel.Open("report.xlsx"); -using IDataReader data = new ExcelDataReader(workbook.FirstSheet); // headerRow: 1 by default +using IDataReader data = new ExcelDataReader(workbook.FirstSheet); // header on row 1 by default var table = new DataTable(); table.Load(data); ``` -The header row (1-based, default 1) fixes the column shape; pass `headerRow: 0` for a header-less sheet, whose columns come back named `Column0`, `Column1`, ... sized from the first data row. There is no schema-inference pass — `GetFieldType`/`GetValue`/the typed getters read the *current* row's own cell type, so a consumer building its schema from the first row (like `DataTable.Load`) locks in that row's types for the whole load. `NextResult()` always returns `false`; build one `ExcelDataReader` per sheet instead of chaining result sets. +The header row (1-based, default 1) fixes the column shape; pass `new ExcelDataReaderOptions { HeaderRow = 0 }` for a header-less sheet, whose columns come back named `Column0`, `Column1`, ... sized from the first data row. There is no schema-inference pass — `GetFieldType`/`GetValue`/the typed getters read the *current* row's own cell type, so a consumer building its schema from the first row (like `DataTable.Load`) locks in that row's types for the whole load. `NextResult()` always returns `false`; build one `ExcelDataReader` per sheet instead of chaining result sets. The bridge costs ~1.3–1.4x over iterating rows directly, and `GetBytes` stays within ~10% of the direct read. See [ADO.NET bridge](../performance/benchmarks.md#adonet-bridge-idatareader) in the benchmarks. diff --git a/src/ExcelReader.Arrow/ArrowConversionExtensions.cs b/src/ExcelReader.Arrow/ArrowConversionExtensions.cs index a06e89be..25f7cdd3 100644 --- a/src/ExcelReader.Arrow/ArrowConversionExtensions.cs +++ b/src/ExcelReader.Arrow/ArrowConversionExtensions.cs @@ -9,26 +9,24 @@ public static class ArrowConversionExtensions { /// Converts one sheet into an Arrow . /// The sheet to convert. - /// The column schema to use; inferred from the sheet with when . - /// - /// 1-based header row, reused both for schema inference (when is - /// ) and to skip the header line during the data pass. 0 means no header row. - /// + /// The schema and header row; when . /// A record batch with one column per schema entry and one row per data row. /// is . /// /// A cell failed to convert to its column's declared type on a non-nullable column. /// - /// The sheet has fewer rows than . - /// names an unrecognized . - public static RecordBatch ToArrowRecordBatch(this IExcelSheet sheet, ExcelColumnSchema[]? schema = null, int headerRow = 1) + /// The sheet has fewer rows than . + /// names an unrecognized . + public static RecordBatch ToArrowRecordBatch(this IExcelSheet sheet, ArrowReadOptions? options = null) { ArgumentNullException.ThrowIfNull(sheet); - schema ??= Excel.InferSchema(sheet, headerRow); + options ??= ArrowReadOptions.Default; + int headerRow = options.HeaderRow; + IReadOnlyList schema = options.Schema ?? Excel.InferSchema(sheet, new SchemaInferenceOptions { HeaderRow = headerRow }); bool isDate1904 = sheet.IsDate1904; using IExcelRowEnumerator rows = sheet.GetEnumerator(); - ColumnAppender[] appenders = new ColumnAppender[schema.Length]; - for (int i = 0; i < schema.Length; i++) + ColumnAppender[] appenders = new ColumnAppender[schema.Count]; + for (int i = 0; i < schema.Count; i++) { appenders[i] = ColumnAppender.Create(schema[i]); } diff --git a/src/ExcelReader.Arrow/ArrowReadOptions.cs b/src/ExcelReader.Arrow/ArrowReadOptions.cs new file mode 100644 index 00000000..60606bc4 --- /dev/null +++ b/src/ExcelReader.Arrow/ArrowReadOptions.cs @@ -0,0 +1,24 @@ +using ExcelReader.Core.Reader.Schema; + +namespace ExcelReader.Arrow +{ + /// Options for converting a sheet with . + public sealed class ArrowReadOptions + { + /// Gets the default options: header on row 1, schema inferred from the sheet. + public static ArrowReadOptions Default { get; } = new(); + + /// + /// Gets the column schema to use. , the default, infers it from the sheet with + /// using . + /// + public IReadOnlyList? Schema { get; init; } + + /// + /// Gets the 1-based header row, used both for schema inference (when is + /// ) and to skip the header line during the data pass. 0 means no header + /// row. Defaults to 1. + /// + public int HeaderRow { get; init; } = 1; + } +} diff --git a/src/ExcelReader.Arrow/ArrowWriteExtensions.cs b/src/ExcelReader.Arrow/ArrowWriteExtensions.cs index 0b74138b..06266f89 100644 --- a/src/ExcelReader.Arrow/ArrowWriteExtensions.cs +++ b/src/ExcelReader.Arrow/ArrowWriteExtensions.cs @@ -11,7 +11,7 @@ namespace ExcelReader.Arrow { /// /// Writes an Apache Arrow to a workbook as one sheet — the write-side - /// mirror of . + /// mirror of . /// public static class ArrowWriteExtensions { @@ -20,7 +20,7 @@ public static class ArrowWriteExtensions /// /// Writes to as one XLSX sheet, in a single - /// call: adds the sheet, writes the header row (unless is false) + /// call: adds the sheet, writes the header row (unless is false) /// and every data row, then ends the sheet and the workbook. /// /// A freshly created, not-yet-started workbook writer. @@ -29,78 +29,78 @@ public static class ArrowWriteExtensions /// 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. + /// The sheet name and whether to write a header row; when . /// or is . /// 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) + public static void WriteRecordBatch(this XlsxWorkbookWriter workbook, RecordBatch batch, ArrowWriteOptions? options = null) { - WriteRecordBatchCore(workbook, batch, sheetName, writeHeader); + WriteRecordBatchCore(workbook, batch, options); } - /// Same as , for an XLSB workbook. + /// Same as , for an XLSB workbook. /// or is . /// 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) + public static void WriteRecordBatch(this XlsbWorkbookWriter workbook, RecordBatch batch, ArrowWriteOptions? options = null) { - WriteRecordBatchCore(workbook, batch, sheetName, writeHeader); + WriteRecordBatchCore(workbook, batch, options); } - /// Same as , for a legacy XLS workbook. + /// Same as , for a legacy XLS workbook. /// or is . /// 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) + public static void WriteRecordBatch(this XlsWorkbookWriter workbook, RecordBatch batch, ArrowWriteOptions? options = null) { - WriteRecordBatchCore(workbook, batch, sheetName, writeHeader); + WriteRecordBatchCore(workbook, batch, options); } /// /// Writes to as CSV's single unnamed sheet, - /// in a single call. + /// in a single call. is ignored. /// /// or is . /// A column's Arrow type is not one of the supported types. - public static void WriteRecordBatch(this CsvWorkbookWriter workbook, RecordBatch batch, bool writeHeader = true) + public static void WriteRecordBatch(this CsvWorkbookWriter workbook, RecordBatch batch, ArrowWriteOptions? options = null) { - WriteRecordBatchCore(workbook, batch, sheetName: "Sheet1", writeHeader); + WriteRecordBatchCore(workbook, batch, options); } - /// Asynchronous counterpart to . - public static ValueTask WriteRecordBatchAsync(this XlsxWorkbookWriter workbook, RecordBatch batch, string sheetName = "Sheet1", bool writeHeader = true, CancellationToken ct = default) + /// Asynchronous counterpart to . + public static ValueTask WriteRecordBatchAsync(this XlsxWorkbookWriter workbook, RecordBatch batch, ArrowWriteOptions? options = null, CancellationToken ct = default) { - return WriteRecordBatchCoreAsync(workbook, batch, sheetName, writeHeader, ct); + return WriteRecordBatchCoreAsync(workbook, batch, options, ct); } - /// Asynchronous counterpart to . - public static ValueTask WriteRecordBatchAsync(this XlsbWorkbookWriter workbook, RecordBatch batch, string sheetName = "Sheet1", bool writeHeader = true, CancellationToken ct = default) + /// Asynchronous counterpart to . + public static ValueTask WriteRecordBatchAsync(this XlsbWorkbookWriter workbook, RecordBatch batch, ArrowWriteOptions? options = null, CancellationToken ct = default) { - return WriteRecordBatchCoreAsync(workbook, batch, sheetName, writeHeader, ct); + return WriteRecordBatchCoreAsync(workbook, batch, options, ct); } - /// Asynchronous counterpart to . - public static ValueTask WriteRecordBatchAsync(this XlsWorkbookWriter workbook, RecordBatch batch, string sheetName = "Sheet1", bool writeHeader = true, CancellationToken ct = default) + /// Asynchronous counterpart to . + public static ValueTask WriteRecordBatchAsync(this XlsWorkbookWriter workbook, RecordBatch batch, ArrowWriteOptions? options = null, CancellationToken ct = default) { - return WriteRecordBatchCoreAsync(workbook, batch, sheetName, writeHeader, ct); + return WriteRecordBatchCoreAsync(workbook, batch, options, ct); } - /// Asynchronous counterpart to . - public static ValueTask WriteRecordBatchAsync(this CsvWorkbookWriter workbook, RecordBatch batch, bool writeHeader = true, CancellationToken ct = default) + /// Asynchronous counterpart to . + public static ValueTask WriteRecordBatchAsync(this CsvWorkbookWriter workbook, RecordBatch batch, ArrowWriteOptions? options = null, CancellationToken ct = default) { - return WriteRecordBatchCoreAsync(workbook, batch, sheetName: "Sheet1", writeHeader, ct); + return WriteRecordBatchCoreAsync(workbook, batch, options, ct); } - private static void WriteRecordBatchCore(IWorkbookWriter workbook, RecordBatch batch, string sheetName, bool writeHeader) + private static void WriteRecordBatchCore(IWorkbookWriter workbook, RecordBatch batch, ArrowWriteOptions? options) where TSheet : ISheetWriter where TRow : IRowWriter { ArgumentNullException.ThrowIfNull(workbook); ArgumentNullException.ThrowIfNull(batch); + options ??= ArrowWriteOptions.Default; IReadOnlyList fields = batch.Schema.FieldsList; - TSheet sheet = workbook.AddSheet(sheetName); + TSheet sheet = workbook.AddSheet(options.SheetName); ApplyTemporalStyles(workbook, sheet, fields); - if (writeHeader) + if (options.WriteHeader) { using TRow header = sheet.StartRow(); foreach (Field field in fields) @@ -122,18 +122,19 @@ private static void WriteRecordBatchCore(IWorkbookWriter w workbook.End(); } - private static async ValueTask WriteRecordBatchCoreAsync(IWorkbookWriter workbook, RecordBatch batch, string sheetName, bool writeHeader, CancellationToken ct) + private static async ValueTask WriteRecordBatchCoreAsync(IWorkbookWriter workbook, RecordBatch batch, ArrowWriteOptions? options, CancellationToken ct) where TSheet : ISheetWriter where TRow : IRowWriter { ArgumentNullException.ThrowIfNull(workbook); ArgumentNullException.ThrowIfNull(batch); + options ??= ArrowWriteOptions.Default; IReadOnlyList fields = batch.Schema.FieldsList; - TSheet sheet = workbook.AddSheet(sheetName); + TSheet sheet = workbook.AddSheet(options.SheetName); ApplyTemporalStyles(workbook, sheet, fields); - if (writeHeader) + if (options.WriteHeader) { TRow header = await sheet.StartRowAsync(ct).ConfigureAwait(false); foreach (Field field in fields) diff --git a/src/ExcelReader.Arrow/ArrowWriteOptions.cs b/src/ExcelReader.Arrow/ArrowWriteOptions.cs new file mode 100644 index 00000000..e0d97653 --- /dev/null +++ b/src/ExcelReader.Arrow/ArrowWriteOptions.cs @@ -0,0 +1,15 @@ +namespace ExcelReader.Arrow +{ + /// Options for writing a record batch with . + public sealed class ArrowWriteOptions + { + /// Gets the default options: a sheet named Sheet1 with a header row. + public static ArrowWriteOptions Default { get; } = new(); + + /// Gets the name of the sheet the batch is written to. Defaults to Sheet1. + public string SheetName { get; init; } = "Sheet1"; + + /// Gets whether the first row holds the batch's field names. Defaults to . + public bool WriteHeader { get; init; } = true; + } +} diff --git a/src/ExcelReader.Arrow/PublicAPI/PublicAPI.Shipped.txt b/src/ExcelReader.Arrow/PublicAPI/PublicAPI.Shipped.txt index 297b0d9a..a10d55c4 100644 --- a/src/ExcelReader.Arrow/PublicAPI/PublicAPI.Shipped.txt +++ b/src/ExcelReader.Arrow/PublicAPI/PublicAPI.Shipped.txt @@ -1,12 +1,3 @@ #nullable enable ExcelReader.Arrow.ArrowConversionExtensions ExcelReader.Arrow.ArrowWriteExtensions -static ExcelReader.Arrow.ArrowConversionExtensions.ToArrowRecordBatch(this ExcelReader.Core.Reader.IExcelSheet! sheet, ExcelReader.Core.Reader.Schema.ExcelColumnSchema[]? schema = null, int headerRow = 1) -> Apache.Arrow.RecordBatch! -static ExcelReader.Arrow.ArrowWriteExtensions.WriteRecordBatch(this ExcelReader.Core.Writer.Csv.CsvWorkbookWriter! workbook, Apache.Arrow.RecordBatch! batch, bool writeHeader = true) -> void -static ExcelReader.Arrow.ArrowWriteExtensions.WriteRecordBatch(this ExcelReader.Core.Writer.Xls.XlsWorkbookWriter! workbook, Apache.Arrow.RecordBatch! batch, string! sheetName = "Sheet1", bool writeHeader = true) -> void -static ExcelReader.Arrow.ArrowWriteExtensions.WriteRecordBatch(this ExcelReader.Core.Writer.Xlsb.XlsbWorkbookWriter! workbook, Apache.Arrow.RecordBatch! batch, string! sheetName = "Sheet1", bool writeHeader = true) -> void -static ExcelReader.Arrow.ArrowWriteExtensions.WriteRecordBatch(this ExcelReader.Core.Writer.Xlsx.XlsxWorkbookWriter! workbook, Apache.Arrow.RecordBatch! batch, string! sheetName = "Sheet1", bool writeHeader = true) -> void -static ExcelReader.Arrow.ArrowWriteExtensions.WriteRecordBatchAsync(this ExcelReader.Core.Writer.Csv.CsvWorkbookWriter! workbook, Apache.Arrow.RecordBatch! batch, bool writeHeader = true, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static ExcelReader.Arrow.ArrowWriteExtensions.WriteRecordBatchAsync(this ExcelReader.Core.Writer.Xls.XlsWorkbookWriter! workbook, Apache.Arrow.RecordBatch! batch, string! sheetName = "Sheet1", bool writeHeader = true, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static ExcelReader.Arrow.ArrowWriteExtensions.WriteRecordBatchAsync(this ExcelReader.Core.Writer.Xlsb.XlsbWorkbookWriter! workbook, Apache.Arrow.RecordBatch! batch, string! sheetName = "Sheet1", bool writeHeader = true, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static ExcelReader.Arrow.ArrowWriteExtensions.WriteRecordBatchAsync(this ExcelReader.Core.Writer.Xlsx.XlsxWorkbookWriter! workbook, Apache.Arrow.RecordBatch! batch, string! sheetName = "Sheet1", bool writeHeader = true, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask diff --git a/src/ExcelReader.Arrow/PublicAPI/PublicAPI.Unshipped.txt b/src/ExcelReader.Arrow/PublicAPI/PublicAPI.Unshipped.txt index 7dc5c581..854fae10 100644 --- a/src/ExcelReader.Arrow/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/ExcelReader.Arrow/PublicAPI/PublicAPI.Unshipped.txt @@ -1 +1,24 @@ #nullable enable +ExcelReader.Arrow.ArrowReadOptions +ExcelReader.Arrow.ArrowReadOptions.ArrowReadOptions() -> void +ExcelReader.Arrow.ArrowReadOptions.HeaderRow.get -> int +ExcelReader.Arrow.ArrowReadOptions.HeaderRow.init -> void +ExcelReader.Arrow.ArrowReadOptions.Schema.get -> System.Collections.Generic.IReadOnlyList? +ExcelReader.Arrow.ArrowReadOptions.Schema.init -> void +ExcelReader.Arrow.ArrowWriteOptions +ExcelReader.Arrow.ArrowWriteOptions.ArrowWriteOptions() -> void +ExcelReader.Arrow.ArrowWriteOptions.SheetName.get -> string! +ExcelReader.Arrow.ArrowWriteOptions.SheetName.init -> void +ExcelReader.Arrow.ArrowWriteOptions.WriteHeader.get -> bool +ExcelReader.Arrow.ArrowWriteOptions.WriteHeader.init -> void +static ExcelReader.Arrow.ArrowConversionExtensions.ToArrowRecordBatch(this ExcelReader.Core.Reader.IExcelSheet! sheet, ExcelReader.Arrow.ArrowReadOptions? options = null) -> Apache.Arrow.RecordBatch! +static ExcelReader.Arrow.ArrowReadOptions.Default.get -> ExcelReader.Arrow.ArrowReadOptions! +static ExcelReader.Arrow.ArrowWriteExtensions.WriteRecordBatch(this ExcelReader.Core.Writer.Csv.CsvWorkbookWriter! workbook, Apache.Arrow.RecordBatch! batch, ExcelReader.Arrow.ArrowWriteOptions? options = null) -> void +static ExcelReader.Arrow.ArrowWriteExtensions.WriteRecordBatch(this ExcelReader.Core.Writer.Xls.XlsWorkbookWriter! workbook, Apache.Arrow.RecordBatch! batch, ExcelReader.Arrow.ArrowWriteOptions? options = null) -> void +static ExcelReader.Arrow.ArrowWriteExtensions.WriteRecordBatch(this ExcelReader.Core.Writer.Xlsb.XlsbWorkbookWriter! workbook, Apache.Arrow.RecordBatch! batch, ExcelReader.Arrow.ArrowWriteOptions? options = null) -> void +static ExcelReader.Arrow.ArrowWriteExtensions.WriteRecordBatch(this ExcelReader.Core.Writer.Xlsx.XlsxWorkbookWriter! workbook, Apache.Arrow.RecordBatch! batch, ExcelReader.Arrow.ArrowWriteOptions? options = null) -> void +static ExcelReader.Arrow.ArrowWriteExtensions.WriteRecordBatchAsync(this ExcelReader.Core.Writer.Csv.CsvWorkbookWriter! workbook, Apache.Arrow.RecordBatch! batch, ExcelReader.Arrow.ArrowWriteOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Arrow.ArrowWriteExtensions.WriteRecordBatchAsync(this ExcelReader.Core.Writer.Xls.XlsWorkbookWriter! workbook, Apache.Arrow.RecordBatch! batch, ExcelReader.Arrow.ArrowWriteOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Arrow.ArrowWriteExtensions.WriteRecordBatchAsync(this ExcelReader.Core.Writer.Xlsb.XlsbWorkbookWriter! workbook, Apache.Arrow.RecordBatch! batch, ExcelReader.Arrow.ArrowWriteOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Arrow.ArrowWriteExtensions.WriteRecordBatchAsync(this ExcelReader.Core.Writer.Xlsx.XlsxWorkbookWriter! workbook, Apache.Arrow.RecordBatch! batch, ExcelReader.Arrow.ArrowWriteOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Arrow.ArrowWriteOptions.Default.get -> ExcelReader.Arrow.ArrowWriteOptions! diff --git a/src/ExcelReader.Cli/CliCommands.cs b/src/ExcelReader.Cli/CliCommands.cs index 0105637e..85c2c391 100644 --- a/src/ExcelReader.Cli/CliCommands.cs +++ b/src/ExcelReader.Cli/CliCommands.cs @@ -195,7 +195,7 @@ internal static int Schema(string path, string? sheet, int headerRow, int sample { using IExcelWorkbook workbook = Open(path, sheet, out IExcelSheet selected, password, inputDelimiter); - foreach (ExcelColumnSchema column in Excel.InferSchema(selected, headerRow, sampleSize)) + foreach (ExcelColumnSchema column in Excel.InferSchema(selected, new SchemaInferenceOptions { HeaderRow = headerRow, SampleSize = sampleSize })) { stdout.Write(column.Index.ToString(CultureInfo.InvariantCulture)); stdout.Write('\t'); diff --git a/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt b/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt index 5240f03a..649515e1 100644 --- a/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt +++ b/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt @@ -192,7 +192,6 @@ ExcelReader.Core.Reader.ExcelDataReader ExcelReader.Core.Reader.ExcelDataReader.Close() -> void ExcelReader.Core.Reader.ExcelDataReader.Depth.get -> int ExcelReader.Core.Reader.ExcelDataReader.Dispose() -> void -ExcelReader.Core.Reader.ExcelDataReader.ExcelDataReader(ExcelReader.Core.Reader.IExcelSheet! sheet, int headerRow = 1) -> void ExcelReader.Core.Reader.ExcelDataReader.FieldCount.get -> int ExcelReader.Core.Reader.ExcelDataReader.GetBoolean(int i) -> bool ExcelReader.Core.Reader.ExcelDataReader.GetByte(int i) -> byte @@ -726,8 +725,6 @@ static ExcelReader.Core.Reader.Excel.FromXlsx(System.ReadOnlyMemory data, static ExcelReader.Core.Reader.Excel.FromXlsxAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static ExcelReader.Core.Reader.Excel.FromXlsxFile(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsx.XlsxWorkbook! static ExcelReader.Core.Reader.Excel.FromXlsxFileAsync(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static ExcelReader.Core.Reader.Excel.InferSchema(ExcelReader.Core.Reader.IExcelSheet! sheet, int headerRow = 1, int sampleSize = 100, bool parseText = false) -> ExcelReader.Core.Reader.Schema.ExcelColumnSchema[]! -static ExcelReader.Core.Reader.Excel.InferSchema(ExcelReader.Core.Reader.IExcelSheet! sheet, int headerRow, int sampleSize) -> ExcelReader.Core.Reader.Schema.ExcelColumnSchema[]! static ExcelReader.Core.Reader.Excel.Open(System.IO.Stream! stream, ExcelReader.Core.Reader.ExcelFileFormat format, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelWorkbook! static ExcelReader.Core.Reader.Excel.Open(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelWorkbook! static ExcelReader.Core.Reader.Excel.Open(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelFileFormat format, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelWorkbook! diff --git a/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt b/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt index 6b7a4d18..f262ae01 100644 --- a/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt @@ -70,3 +70,19 @@ static ExcelReader.Core.Reader.SheetParallel.ForEachAsync(ExcelReader.Co static ExcelReader.Core.Reader.SheetParallel.ForEachAsync(ExcelReader.Core.Reader.IExcelSheet! sheet, System.Action! body, ExcelReader.Core.Reader.SheetParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! static ExcelReader.Core.Reader.SheetParallel.ParseAsync(ExcelReader.Core.Reader.IExcelSheet! sheet, ExcelReader.Core.Parser.ExcelParser! parser, ExcelReader.Core.Reader.SheetParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable! static ExcelReader.Core.Reader.SheetParallelOptions.Default.get -> ExcelReader.Core.Reader.SheetParallelOptions! +ExcelReader.Core.Reader.ExcelDataReader.ExcelDataReader(ExcelReader.Core.Reader.IExcelSheet! sheet, ExcelReader.Core.Reader.ExcelDataReaderOptions? options = null) -> void +ExcelReader.Core.Reader.ExcelDataReaderOptions +ExcelReader.Core.Reader.ExcelDataReaderOptions.ExcelDataReaderOptions() -> void +ExcelReader.Core.Reader.ExcelDataReaderOptions.HeaderRow.get -> int +ExcelReader.Core.Reader.ExcelDataReaderOptions.HeaderRow.init -> void +ExcelReader.Core.Reader.Schema.SchemaInferenceOptions +ExcelReader.Core.Reader.Schema.SchemaInferenceOptions.HeaderRow.get -> int +ExcelReader.Core.Reader.Schema.SchemaInferenceOptions.HeaderRow.init -> void +ExcelReader.Core.Reader.Schema.SchemaInferenceOptions.ParseText.get -> bool +ExcelReader.Core.Reader.Schema.SchemaInferenceOptions.ParseText.init -> void +ExcelReader.Core.Reader.Schema.SchemaInferenceOptions.SampleSize.get -> int +ExcelReader.Core.Reader.Schema.SchemaInferenceOptions.SampleSize.init -> void +ExcelReader.Core.Reader.Schema.SchemaInferenceOptions.SchemaInferenceOptions() -> void +static ExcelReader.Core.Reader.Excel.InferSchema(ExcelReader.Core.Reader.IExcelSheet! sheet, ExcelReader.Core.Reader.Schema.SchemaInferenceOptions? options = null) -> ExcelReader.Core.Reader.Schema.ExcelColumnSchema[]! +static ExcelReader.Core.Reader.ExcelDataReaderOptions.Default.get -> ExcelReader.Core.Reader.ExcelDataReaderOptions! +static ExcelReader.Core.Reader.Schema.SchemaInferenceOptions.Default.get -> ExcelReader.Core.Reader.Schema.SchemaInferenceOptions! diff --git a/src/ExcelReader.Core/Reader/Excel.cs b/src/ExcelReader.Core/Reader/Excel.cs index a81620e1..64d843c8 100644 --- a/src/ExcelReader.Core/Reader/Excel.cs +++ b/src/ExcelReader.Core/Reader/Excel.cs @@ -267,58 +267,31 @@ public static async ValueTask FromCsvAsync(Stream stream, bool leaveO /// Guesses a column schema for by sampling it from the first row. /// The sheet to sample. A new enumerator is opened and disposed; no other read of the sheet is disturbed. - /// 1-based row number to take column names from; 0 means "no header", - /// so every returned schema is addressable only by . - /// How many rows after the header to inspect. + /// Header row, sample size and text parsing; when . /// One per column, in column order. /// /// This is a guess over a bounded sample, not a guarantee about the whole sheet — a column - /// whose first rows are all integers is reported as + /// whose first rows are all integers is reported as /// even if row 10,000 holds text. Verify it fits /// before trusting it, and feed the result into to /// build a real map. /// /// is . - /// is negative, or - /// is not positive. - /// The sheet has fewer rows than . - /// is positive and is a table view () of a table without a header row. - public static ExcelColumnSchema[] InferSchema(IExcelSheet sheet, int headerRow, int sampleSize) - { - return InferSchema(sheet, headerRow, sampleSize, parseText: false); - } - - /// - /// Guesses a column schema by sampling the sheet's rows, as - /// does, and can also type cells that hold text. - /// - /// The sheet to sample. A new enumerator is opened and disposed; no other read of the sheet is disturbed. - /// 1-based row number to take column names from; 0 means "no header", - /// so every returned schema is addressable only by . - /// How many rows after the header to inspect. - /// When , a text cell (every CSV field, or a number - /// stored as text) counts as an integer, a decimal, true/false, or an ISO-8601 date or - /// date-time when its text has exactly that shape. Numbers with a leading zero (00123), scientific - /// notation (12E4), padded or culture-formatted numbers, and non-ISO dates stay text. A column only gets a non-text type when - /// every sampled value converts to it. - /// One per column, in column order. - /// Still a guess over a bounded sample: a value past the sample can still fail to convert. - /// See . - /// is . - /// is negative, or - /// is not positive. - /// The sheet has fewer rows than . - /// is positive and is a table view () of a table without a header row. - public static ExcelColumnSchema[] InferSchema(IExcelSheet sheet, int headerRow = 1, int sampleSize = 100, bool parseText = false) + /// is negative, or + /// is not positive. + /// The sheet has fewer rows than . + /// is positive and is a table view () of a table without a header row. + public static ExcelColumnSchema[] InferSchema(IExcelSheet sheet, SchemaInferenceOptions? options = null) { ArgumentNullException.ThrowIfNull(sheet); - if (headerRow > 0) + options ??= SchemaInferenceOptions.Default; + if (options.HeaderRow > 0) { ExcelTableSheet.ThrowIfHeaderless(sheet); } using IExcelRowEnumerator rows = sheet.GetEnumerator(); - return SchemaInference.Infer(rows, sheet.IsDate1904, headerRow, sampleSize, parseText, ExcelTableSheet.FirstColumnOf(sheet)); + return SchemaInference.Infer(rows, sheet.IsDate1904, options.HeaderRow, options.SampleSize, options.ParseText, ExcelTableSheet.FirstColumnOf(sheet)); } private static ReadOnlySpan ZipSignature => [0x50, 0x4B, 0x03, 0x04]; diff --git a/src/ExcelReader.Core/Reader/ExcelDataReader.cs b/src/ExcelReader.Core/Reader/ExcelDataReader.cs index b83d21b4..62b286f1 100644 --- a/src/ExcelReader.Core/Reader/ExcelDataReader.cs +++ b/src/ExcelReader.Core/Reader/ExcelDataReader.cs @@ -15,7 +15,7 @@ namespace ExcelReader.Core.Reader /// /// /// The column shape is fixed once, at construction time, from the header row (or from the first data - /// row when headerRow is 0) — it never grows as later, wider rows come in. There is no schema + /// row when is 0) — it never grows as later, wider rows come in. There is no schema /// pass, so , , and the typed getters all read the /// current row's own cell type; a consumer that builds a schema from the first /// (e.g. DataTable.Load) locks in that row's types for the whole load. @@ -45,17 +45,15 @@ public sealed class ExcelDataReader : IDataReader /// Wraps . /// /// The sheet to read. Its workbook stays owned by the caller. - /// - /// The 1-based row holding column names. Pass 0 for a header-less sheet, whose columns come back - /// named "Column0", "Column1", ... and whose count is taken from the first data row. - /// + /// The header row; when . /// is null. - /// is negative. - /// is positive and is a table view () of a table without a header row. - public ExcelDataReader(IExcelSheet sheet, int headerRow = 1) + /// is negative. + /// is positive and is a table view () of a table without a header row. + public ExcelDataReader(IExcelSheet sheet, ExcelDataReaderOptions? options = null) { ArgumentNullException.ThrowIfNull(sheet); - ArgumentOutOfRangeException.ThrowIfNegative(headerRow); + int headerRow = (options ?? ExcelDataReaderOptions.Default).HeaderRow; + ArgumentOutOfRangeException.ThrowIfNegative(headerRow, nameof(options)); if (headerRow > 0) { ExcelTableSheet.ThrowIfHeaderless(sheet); diff --git a/src/ExcelReader.Core/Reader/ExcelDataReaderOptions.cs b/src/ExcelReader.Core/Reader/ExcelDataReaderOptions.cs new file mode 100644 index 00000000..95f87a1b --- /dev/null +++ b/src/ExcelReader.Core/Reader/ExcelDataReaderOptions.cs @@ -0,0 +1,16 @@ +namespace ExcelReader.Core.Reader +{ + /// Options for adapting a sheet to with . + public sealed class ExcelDataReaderOptions + { + /// Gets the default options: column names on row 1. + public static ExcelDataReaderOptions Default { get; } = new(); + + /// + /// Gets the 1-based row holding column names. 0 means a header-less sheet, whose columns come + /// back named "Column0", "Column1", ... and whose count is taken from the first data row. + /// Defaults to 1. + /// + public int HeaderRow { get; init; } = 1; + } +} diff --git a/src/ExcelReader.Core/Reader/Schema/ExcelColumnSchema.cs b/src/ExcelReader.Core/Reader/Schema/ExcelColumnSchema.cs index 34694243..35e934cd 100644 --- a/src/ExcelReader.Core/Reader/Schema/ExcelColumnSchema.cs +++ b/src/ExcelReader.Core/Reader/Schema/ExcelColumnSchema.cs @@ -2,7 +2,7 @@ namespace ExcelReader.Core.Reader.Schema { /// /// One column's guessed shape, as returned by - /// . + /// . /// /// /// This is a guess over a bounded sample, not a guarantee about the whole sheet — verify it fits diff --git a/src/ExcelReader.Core/Reader/Schema/ExcelColumnType.cs b/src/ExcelReader.Core/Reader/Schema/ExcelColumnType.cs index e1af2304..d22685a9 100644 --- a/src/ExcelReader.Core/Reader/Schema/ExcelColumnType.cs +++ b/src/ExcelReader.Core/Reader/Schema/ExcelColumnType.cs @@ -2,7 +2,7 @@ namespace ExcelReader.Core.Reader.Schema { /// /// A column's inferred or declared value type, as produced by - /// . + /// . /// /// /// The underlying values are fixed: they are the XL_T_* constants of the native C ABI diff --git a/src/ExcelReader.Core/Reader/Schema/SchemaInference.cs b/src/ExcelReader.Core/Reader/Schema/SchemaInference.cs index ce927267..b5978362 100644 --- a/src/ExcelReader.Core/Reader/Schema/SchemaInference.cs +++ b/src/ExcelReader.Core/Reader/Schema/SchemaInference.cs @@ -9,7 +9,7 @@ namespace ExcelReader.Core.Reader.Schema { /// /// Guesses a column schema by sampling a sheet's rows. Backs - /// and the native xl_infer_schema + /// and the native xl_infer_schema /// export, so both answer identically by construction. /// internal static class SchemaInference diff --git a/src/ExcelReader.Core/Reader/Schema/SchemaInferenceOptions.cs b/src/ExcelReader.Core/Reader/Schema/SchemaInferenceOptions.cs new file mode 100644 index 00000000..4da1a698 --- /dev/null +++ b/src/ExcelReader.Core/Reader/Schema/SchemaInferenceOptions.cs @@ -0,0 +1,27 @@ +namespace ExcelReader.Core.Reader.Schema +{ + /// Options for guessing a column schema with . + public sealed class SchemaInferenceOptions + { + /// Gets the default options: header on row 1, 100 sampled rows, text left as text. + public static SchemaInferenceOptions Default { get; } = new(); + + /// + /// Gets the 1-based row number to take column names from. 0 means "no header", so every + /// returned schema is addressable only by . Defaults to 1. + /// + public int HeaderRow { get; init; } = 1; + + /// Gets how many rows after the header to inspect. Defaults to 100. + public int SampleSize { get; init; } = 100; + + /// + /// Gets whether a text cell (every CSV field, or a number stored as text) counts as an integer, a + /// decimal, true/false, or an ISO-8601 date or date-time when its text has exactly that + /// shape. Numbers with a leading zero (00123), scientific notation (12E4), padded or + /// culture-formatted numbers, and non-ISO dates stay text. A column only gets a non-text type when + /// every sampled value converts to it. Defaults to . + /// + public bool ParseText { get; init; } + } +} diff --git a/tests/ExcelReader.Benchmarks/Interop/ArrowConversionBenchmark.cs b/tests/ExcelReader.Benchmarks/Interop/ArrowConversionBenchmark.cs index 7edec50b..f1815589 100644 --- a/tests/ExcelReader.Benchmarks/Interop/ArrowConversionBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/Interop/ArrowConversionBenchmark.cs @@ -48,7 +48,7 @@ public async Task SetupAsync() public long CsvAllString() { using var reader = Excel.FromCsv(_csvAllString); - using RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(AllStringSchema, headerRow: 0); + using RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(new ArrowReadOptions { Schema = AllStringSchema, HeaderRow = 0 }); return batch.Length; } @@ -56,7 +56,7 @@ public long CsvAllString() public long CsvTyped() { using var reader = Excel.FromCsv(_csvTyped); - using RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(TypedSchema); + using RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(new ArrowReadOptions { Schema = TypedSchema }); return batch.Length; } @@ -65,7 +65,7 @@ public long XlsbTyped() { using var ms = new MemoryStream(_xlsbTyped, writable: false); using var reader = Excel.FromXlsb(ms); - using RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(TypedSchema); + using RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(new ArrowReadOptions { Schema = TypedSchema }); return batch.Length; } diff --git a/tests/ExcelReader.Benchmarks/Parse/ParseBenchmark.cs b/tests/ExcelReader.Benchmarks/Parse/ParseBenchmark.cs index c7d1b1c2..cac206ca 100644 --- a/tests/ExcelReader.Benchmarks/Parse/ParseBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/Parse/ParseBenchmark.cs @@ -155,7 +155,7 @@ public long OfficeIMO() public long Sylvan() { using var ms = new MemoryStream(_workbook, writable: false); - using var reader = global::Sylvan.Data.Excel.ExcelDataReader.Create(ms, ExcelWorkbookType.ExcelXml, new ExcelDataReaderOptions()); + using var reader = global::Sylvan.Data.Excel.ExcelDataReader.Create(ms, ExcelWorkbookType.ExcelXml, new global::Sylvan.Data.Excel.ExcelDataReaderOptions()); long acc = 0; foreach (Record rec in reader.GetRecords()) { @@ -168,7 +168,7 @@ public long Sylvan() public async Task SylvanAsync() { await using var ms = new MemoryStream(_workbook, writable: false); - await using var reader = await global::Sylvan.Data.Excel.ExcelDataReader.CreateAsync(ms, ExcelWorkbookType.ExcelXml, new ExcelDataReaderOptions()).ConfigureAwait(false); + await using var reader = await global::Sylvan.Data.Excel.ExcelDataReader.CreateAsync(ms, ExcelWorkbookType.ExcelXml, new global::Sylvan.Data.Excel.ExcelDataReaderOptions()).ConfigureAwait(false); long acc = 0; await foreach (Record rec in reader.GetRecordsAsync()) { diff --git a/tests/ExcelReader.Benchmarks/Read/ReadBenchmark.cs b/tests/ExcelReader.Benchmarks/Read/ReadBenchmark.cs index 82aff6fa..f17e01f1 100644 --- a/tests/ExcelReader.Benchmarks/Read/ReadBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/Read/ReadBenchmark.cs @@ -78,7 +78,7 @@ public long ExcelReaderMaterialized() public long Sylvan() { using var ms = new MemoryStream(_workbook, writable: false); - using var reader = global::Sylvan.Data.Excel.ExcelDataReader.Create(ms, ExcelWorkbookType.ExcelXml, new ExcelDataReaderOptions()); + using var reader = global::Sylvan.Data.Excel.ExcelDataReader.Create(ms, ExcelWorkbookType.ExcelXml, new global::Sylvan.Data.Excel.ExcelDataReaderOptions()); return AccumulateSylvanExcel(reader); } diff --git a/tests/ExcelReader.Benchmarks/Read/StringHeavyReadBenchmark.cs b/tests/ExcelReader.Benchmarks/Read/StringHeavyReadBenchmark.cs index 56a402ce..2cfb67e1 100644 --- a/tests/ExcelReader.Benchmarks/Read/StringHeavyReadBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/Read/StringHeavyReadBenchmark.cs @@ -66,7 +66,7 @@ public long Xlsx_ExcelReader_Prefetch() public long Xlsx_Sylvan() { using MemoryStream ms = Open(_xlsx, nameof(Xlsx_Sylvan)); - using Sylvan.Data.Excel.ExcelDataReader reader = Sylvan.Data.Excel.ExcelDataReader.Create(ms, ExcelWorkbookType.ExcelXml, new ExcelDataReaderOptions()); + using Sylvan.Data.Excel.ExcelDataReader reader = Sylvan.Data.Excel.ExcelDataReader.Create(ms, ExcelWorkbookType.ExcelXml, new global::Sylvan.Data.Excel.ExcelDataReaderOptions()); return AccumulateSylvanExcel(reader); } @@ -115,7 +115,7 @@ public long Xlsb_ExcelReader_Prefetch() public long Xlsb_Sylvan() { using MemoryStream ms = Open(_xlsb, nameof(Xlsb_Sylvan)); - using Sylvan.Data.Excel.ExcelDataReader reader = Sylvan.Data.Excel.ExcelDataReader.Create(ms, ExcelWorkbookType.ExcelBinary, new ExcelDataReaderOptions()); + using Sylvan.Data.Excel.ExcelDataReader reader = Sylvan.Data.Excel.ExcelDataReader.Create(ms, ExcelWorkbookType.ExcelBinary, new global::Sylvan.Data.Excel.ExcelDataReaderOptions()); return AccumulateSylvanExcel(reader); } diff --git a/tests/ExcelReader.Benchmarks/Read/XlsReadBenchmark.cs b/tests/ExcelReader.Benchmarks/Read/XlsReadBenchmark.cs index 0dd985a3..f45f7902 100644 --- a/tests/ExcelReader.Benchmarks/Read/XlsReadBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/Read/XlsReadBenchmark.cs @@ -47,7 +47,7 @@ public async Task ExcelReaderAsync() public long Sylvan() { using var ms = new MemoryStream(_workbook, writable: false); - using var reader = global::Sylvan.Data.Excel.ExcelDataReader.Create(ms, ExcelWorkbookType.Excel, new ExcelDataReaderOptions()); + using var reader = global::Sylvan.Data.Excel.ExcelDataReader.Create(ms, ExcelWorkbookType.Excel, new global::Sylvan.Data.Excel.ExcelDataReaderOptions()); return AccumulateSylvanExcel(reader); } diff --git a/tests/ExcelReader.Benchmarks/RealData/RealDataReadBenchmark.cs b/tests/ExcelReader.Benchmarks/RealData/RealDataReadBenchmark.cs index d8a2e82d..2dfedb26 100644 --- a/tests/ExcelReader.Benchmarks/RealData/RealDataReadBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/RealData/RealDataReadBenchmark.cs @@ -50,7 +50,7 @@ public long Xlsx_ExcelReader() public long Xlsx_Sylvan() { using MemoryStream ms = new(_xlsx, writable: false); - using Sylvan.Data.Excel.ExcelDataReader reader = Sylvan.Data.Excel.ExcelDataReader.Create(ms, ExcelWorkbookType.ExcelXml, new ExcelDataReaderOptions()); + using Sylvan.Data.Excel.ExcelDataReader reader = Sylvan.Data.Excel.ExcelDataReader.Create(ms, ExcelWorkbookType.ExcelXml, new global::Sylvan.Data.Excel.ExcelDataReaderOptions()); return AccumulateSylvanExcel(reader); } @@ -107,7 +107,7 @@ public long Xlsm_ExcelReader() public long Xlsm_Sylvan() { using MemoryStream ms = new(_xlsm, writable: false); - using Sylvan.Data.Excel.ExcelDataReader reader = Sylvan.Data.Excel.ExcelDataReader.Create(ms, ExcelWorkbookType.ExcelXml, new ExcelDataReaderOptions()); + using Sylvan.Data.Excel.ExcelDataReader reader = Sylvan.Data.Excel.ExcelDataReader.Create(ms, ExcelWorkbookType.ExcelXml, new global::Sylvan.Data.Excel.ExcelDataReaderOptions()); return AccumulateSylvanExcel(reader); } @@ -164,7 +164,7 @@ public long Xlsb_ExcelReader() public long Xlsb_Sylvan() { using MemoryStream ms = new(_xlsb, writable: false); - using Sylvan.Data.Excel.ExcelDataReader reader = Sylvan.Data.Excel.ExcelDataReader.Create(ms, ExcelWorkbookType.ExcelBinary, new ExcelDataReaderOptions()); + using Sylvan.Data.Excel.ExcelDataReader reader = Sylvan.Data.Excel.ExcelDataReader.Create(ms, ExcelWorkbookType.ExcelBinary, new global::Sylvan.Data.Excel.ExcelDataReaderOptions()); return AccumulateSylvanExcel(reader); } @@ -221,7 +221,7 @@ public long Xls_ExcelReader() public long Xls_Sylvan() { using MemoryStream ms = new(_xls, writable: false); - using Sylvan.Data.Excel.ExcelDataReader reader = Sylvan.Data.Excel.ExcelDataReader.Create(ms, ExcelWorkbookType.Excel, new ExcelDataReaderOptions()); + using Sylvan.Data.Excel.ExcelDataReader reader = Sylvan.Data.Excel.ExcelDataReader.Create(ms, ExcelWorkbookType.Excel, new global::Sylvan.Data.Excel.ExcelDataReaderOptions()); return AccumulateSylvanExcel(reader); } diff --git a/tests/ExcelReader.Tests/Arrow/ArrowConversionTests.cs b/tests/ExcelReader.Tests/Arrow/ArrowConversionTests.cs index 73737873..07ef7aa8 100644 --- a/tests/ExcelReader.Tests/Arrow/ArrowConversionTests.cs +++ b/tests/ExcelReader.Tests/Arrow/ArrowConversionTests.cs @@ -39,7 +39,7 @@ public void ToArrowRecordBatch_Should_Convert_String_And_Int64_Columns() new() { Index = 1, Name = "qty", Type = ExcelColumnType.Int64Column }, ]; - RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(schema); + RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(new ArrowReadOptions { Schema = schema }); Assert.Equal(2, batch.Length); Assert.Equal(2, batch.ColumnCount); @@ -58,7 +58,7 @@ public void ToArrowRecordBatch_Should_Append_Null_For_Blank_Cells_On_Nullable_Co using CsvReader reader = Excel.FromCsv(Encoding.UTF8.GetBytes("qty\n5\n\n")); ExcelColumnSchema[] schema = [new() { Index = 0, Name = "qty", Type = ExcelColumnType.Int64Column, IsNullable = true }]; - RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(schema); + RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(new ArrowReadOptions { Schema = schema }); var qty = Assert.IsType(batch.Column(0)); Assert.False(qty.IsNull(0)); @@ -76,7 +76,7 @@ public void ToArrowRecordBatch_Should_Append_Null_For_Blank_Cells_On_Nullable_St new() { Index = 1, Name = "qty", Type = ExcelColumnType.Int64Column }, ]; - RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(schema); + RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(new ArrowReadOptions { Schema = schema }); var name = Assert.IsType(batch.Column(0)); Assert.Equal("widget", name.GetString(0)); @@ -94,7 +94,7 @@ public void ToArrowRecordBatch_Should_Keep_Blank_Cells_As_Empty_Strings_On_NonNu new() { Index = 1, Name = "qty", Type = ExcelColumnType.Int64Column }, ]; - RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(schema); + RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(new ArrowReadOptions { Schema = schema }); var name = Assert.IsType(batch.Column(0)); Assert.False(name.IsNull(0)); @@ -107,7 +107,7 @@ public void ToArrowRecordBatch_Should_Throw_When_A_NonNullable_Value_Fails_To_Co using CsvReader reader = Excel.FromCsv(Encoding.UTF8.GetBytes("qty\nnotanumber\n")); ExcelColumnSchema[] schema = [new() { Index = 0, Name = "qty", Type = ExcelColumnType.Int64Column }]; - InvalidOperationException exception = Assert.Throws(() => reader.FirstSheet.ToArrowRecordBatch(schema)); + InvalidOperationException exception = Assert.Throws(() => reader.FirstSheet.ToArrowRecordBatch(new ArrowReadOptions { Schema = schema })); Assert.Contains("qty", exception.Message, StringComparison.Ordinal); } @@ -117,7 +117,7 @@ public void ToArrowRecordBatch_Should_Convert_Float64_Column() using CsvReader reader = Excel.FromCsv(Encoding.UTF8.GetBytes("price\n1.5\n2.25\n")); ExcelColumnSchema[] schema = [new() { Index = 0, Name = "price", Type = ExcelColumnType.Float64Column }]; - RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(schema); + RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(new ArrowReadOptions { Schema = schema }); var price = Assert.IsType(batch.Column(0)); Assert.Equal(1.5, price.GetValue(0)); @@ -130,7 +130,7 @@ public void ToArrowRecordBatch_Should_Convert_Bool_Column() using CsvReader reader = Excel.FromCsv(Encoding.UTF8.GetBytes("flag\ntrue\nfalse\n")); ExcelColumnSchema[] schema = [new() { Index = 0, Name = "flag", Type = ExcelColumnType.BoolColumn }]; - RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(schema); + RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(new ArrowReadOptions { Schema = schema }); var flag = Assert.IsType(batch.Column(0)); Assert.True(flag.GetValue(0)); @@ -143,7 +143,7 @@ public void ToArrowRecordBatch_Should_Convert_Date_Column() using var reader = Excel.FromCsv(Encoding.UTF8.GetBytes("day\n2024-01-15\n2024-03-01\n")); ExcelColumnSchema[] schema = [new() { Index = 0, Name = "day", Type = ExcelColumnType.DateColumn }]; - RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(schema); + RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(new ArrowReadOptions { Schema = schema }); var day = Assert.IsType(batch.Column(0)); int expectedFirst = (new DateTime(2024, 1, 15) - DateTime.UnixEpoch).Days; @@ -158,7 +158,7 @@ public void ToArrowRecordBatch_Should_Convert_Time_Column() using var reader = Excel.FromCsv(Encoding.UTF8.GetBytes("clock\n13:30:00\n\n")); ExcelColumnSchema[] schema = [new() { Index = 0, Name = "clock", Type = ExcelColumnType.TimeColumn, IsNullable = true }]; - RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(schema); + RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(new ArrowReadOptions { Schema = schema }); var clock = Assert.IsType(batch.Column(0)); Assert.Equal(new TimeSpan(13, 30, 0).Ticks / 10, clock.GetValue(0)); @@ -171,7 +171,7 @@ public void ToArrowRecordBatch_Should_Convert_Timestamp_Column() using var reader = Excel.FromCsv(Encoding.UTF8.GetBytes("stamp\n2024-01-15 13:30:00\n")); ExcelColumnSchema[] schema = [new() { Index = 0, Name = "stamp", Type = ExcelColumnType.TimestampColumn }]; - RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(schema); + RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(new ArrowReadOptions { Schema = schema }); var stamp = Assert.IsType(batch.Column(0)); long expected = (new DateTime(2024, 1, 15, 13, 30, 0) - DateTime.UnixEpoch).Ticks / 10; @@ -184,7 +184,7 @@ public void ToArrowRecordBatch_Should_Convert_Timestamp_Column_With_Explicit_Utc using var reader = Excel.FromCsv(Encoding.UTF8.GetBytes("stamp\n2024-01-15T13:30:00Z\n")); ExcelColumnSchema[] schema = [new() { Index = 0, Name = "stamp", Type = ExcelColumnType.TimestampColumn }]; - RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(schema); + RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(new ArrowReadOptions { Schema = schema }); var stamp = Assert.IsType(batch.Column(0)); long expected = (new DateTime(2024, 1, 15, 13, 30, 0, DateTimeKind.Utc) - DateTime.UnixEpoch).Ticks / 10; @@ -214,7 +214,7 @@ public void ToArrowRecordBatch_Should_Treat_HeaderRow_Zero_As_No_Header() new() { Index = 1, Type = ExcelColumnType.Int64Column }, ]; - RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(schema, headerRow: 0); + RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(new ArrowReadOptions { Schema = schema, HeaderRow = 0 }); Assert.Equal(2, batch.Length); var name = Assert.IsType(batch.Column(0)); @@ -228,7 +228,7 @@ public void ToArrowRecordBatch_Should_Match_The_Native_Arrow_Path_On_RealExcel() using IExcelWorkbook managedReader = Excel.Open(path); ExcelColumnSchema[] schema = Excel.InferSchema(managedReader.FirstSheet); - RecordBatch managedBatch = managedReader.FirstSheet.ToArrowRecordBatch(schema, headerRow: 1); + RecordBatch managedBatch = managedReader.FirstSheet.ToArrowRecordBatch(new ArrowReadOptions { Schema = schema, HeaderRow = 1 }); Assert.Equal(NativeStatus.Ok, NativeApiTests.OpenPath(path, NativeFormat.Xlsb, out NativeHandle? handle)); try diff --git a/tests/ExcelReader.Tests/Arrow/ArrowWriteExtensionsTests.cs b/tests/ExcelReader.Tests/Arrow/ArrowWriteExtensionsTests.cs index db2fbae7..e9540354 100644 --- a/tests/ExcelReader.Tests/Arrow/ArrowWriteExtensionsTests.cs +++ b/tests/ExcelReader.Tests/Arrow/ArrowWriteExtensionsTests.cs @@ -117,7 +117,7 @@ public void WriteRecordBatch_Xlsx_RoundTrips_Every_Supported_Type() ms.Position = 0; using XlsxWorkbook reader = Excel.FromXlsx(ms.ToArray()); - RecordBatch roundTripped = reader.FirstSheet.ToArrowRecordBatch(ReadBackSchema()); + RecordBatch roundTripped = reader.FirstSheet.ToArrowRecordBatch(new ArrowReadOptions { Schema = ReadBackSchema() }); AssertRoundTrips(roundTripped); } @@ -133,7 +133,7 @@ public void WriteRecordBatch_Xlsb_RoundTrips_Every_Supported_Type() ms.Position = 0; using var reader = Excel.FromXlsb(ms.ToArray()); - RecordBatch roundTripped = reader.FirstSheet.ToArrowRecordBatch(ReadBackSchema()); + RecordBatch roundTripped = reader.FirstSheet.ToArrowRecordBatch(new ArrowReadOptions { Schema = ReadBackSchema() }); AssertRoundTrips(roundTripped); } @@ -202,7 +202,7 @@ public void WriteRecordBatch_WithHeaderFalse_OmitsTheHeaderRow() using var ms = new MemoryStream(); using (XlsxWorkbookWriter workbook = XlsxWorkbookWriter.Create(ms, leaveOpen: true)) { - workbook.WriteRecordBatch(batch, writeHeader: false); + workbook.WriteRecordBatch(batch, new ArrowWriteOptions { WriteHeader = false }); } ms.Position = 0; @@ -300,7 +300,7 @@ private static void AssertWideRoundTrips(IExcelSheet sheet) { schema[i] = new() { Index = i, Name = "c" + i, Type = types[i], IsNullable = true }; } - RecordBatch batch = sheet.ToArrowRecordBatch(schema); + RecordBatch batch = sheet.ToArrowRecordBatch(new ArrowReadOptions { Schema = schema }); Assert.Equal(2, batch.Length); long[] integers = [-8, -16, -32, 8, 16, 32, 64]; @@ -375,7 +375,7 @@ public async Task WriteRecordBatchAsync_Xlsx_RoundTrips() ms.Position = 0; using XlsxWorkbook reader = Excel.FromXlsx(ms.ToArray()); - RecordBatch roundTripped = reader.FirstSheet.ToArrowRecordBatch(ReadBackSchema()); + RecordBatch roundTripped = reader.FirstSheet.ToArrowRecordBatch(new ArrowReadOptions { Schema = ReadBackSchema() }); AssertRoundTrips(roundTripped); } diff --git a/tests/ExcelReader.Tests/Reader/Csv/CsvReplayTests.cs b/tests/ExcelReader.Tests/Reader/Csv/CsvReplayTests.cs index 2d8332f9..4d7e5367 100644 --- a/tests/ExcelReader.Tests/Reader/Csv/CsvReplayTests.cs +++ b/tests/ExcelReader.Tests/Reader/Csv/CsvReplayTests.cs @@ -44,7 +44,7 @@ public void A_Schema_Inferred_From_A_NonSeekable_Stream_Leaves_Every_Row_To_Read using NonSeekableStream stream = new(BuildCsv(rows)); using CsvReader reader = Excel.FromCsv(stream); - ExcelColumnSchema[] schema = Excel.InferSchema(reader.FirstSheet, headerRow: 1, sampleSize: 100, parseText: true); + ExcelColumnSchema[] schema = Excel.InferSchema(reader.FirstSheet, new SchemaInferenceOptions { ParseText = true }); Assert.Equal(["name", "value"], schema.Select(c => c.Name), StringComparer.Ordinal); Assert.Equal(ExcelColumnType.Int64Column, schema[1].Type); diff --git a/tests/ExcelReader.Tests/Reader/ExcelDataReaderTests.cs b/tests/ExcelReader.Tests/Reader/ExcelDataReaderTests.cs index bc4c7f62..94d059ad 100644 --- a/tests/ExcelReader.Tests/Reader/ExcelDataReaderTests.cs +++ b/tests/ExcelReader.Tests/Reader/ExcelDataReaderTests.cs @@ -69,7 +69,7 @@ public async Task NoHeaderSynthesizesColumnNamesFromFirstRowWidth() { await using var ms = await TypedWorkbook.BuildAsync([1, 2, 3]); await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader.FirstSheet, headerRow: 0); + using var data = new ExcelDataReader(reader.FirstSheet, new ExcelDataReaderOptions { HeaderRow = 0 }); Assert.Equal(3, data.FieldCount); Assert.Equal("Column0", data.GetName(0)); @@ -83,7 +83,7 @@ public async Task HeaderRowBeyondSheetSizeIsAnEmptyResultSet() { await using var ms = await TypedWorkbook.BuildAsync(["Id"]); await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader.FirstSheet, headerRow: 5); + using var data = new ExcelDataReader(reader.FirstSheet, new ExcelDataReaderOptions { HeaderRow = 5 }); Assert.Equal(0, data.FieldCount); Assert.False(data.Read()); diff --git a/tests/ExcelReader.Tests/Reader/SchemaInferenceTests.cs b/tests/ExcelReader.Tests/Reader/SchemaInferenceTests.cs index 984a013c..3ba9cda7 100644 --- a/tests/ExcelReader.Tests/Reader/SchemaInferenceTests.cs +++ b/tests/ExcelReader.Tests/Reader/SchemaInferenceTests.cs @@ -67,7 +67,7 @@ public void InferSchema_Should_Type_The_Fixture_Csv_When_Parsing_Text() { using CsvReader reader = Excel.FromCsvFile(FixtureCsv()); - ExcelColumnSchema[] schema = Excel.InferSchema(reader.FirstSheet, parseText: true); + ExcelColumnSchema[] schema = Excel.InferSchema(reader.FirstSheet, new SchemaInferenceOptions { ParseText = true }); ExcelColumnType s = ExcelColumnType.StringColumn, d = ExcelColumnType.DateColumn, i = ExcelColumnType.Int64Column, f = ExcelColumnType.Float64Column; diff --git a/tests/ExcelReader.Tests/Reader/SheetConsumerTests.cs b/tests/ExcelReader.Tests/Reader/SheetConsumerTests.cs index 88b396ec..caee015e 100644 --- a/tests/ExcelReader.Tests/Reader/SheetConsumerTests.cs +++ b/tests/ExcelReader.Tests/Reader/SheetConsumerTests.cs @@ -162,7 +162,7 @@ public void InferSchema_Samples_The_Sheet_It_Is_Given() Assert.Equal(2, schema.Length); Assert.Equal("Name", schema[0].Name); Assert.Equal(ExcelColumnType.Int64Column, schema[1].Type); - Assert.Equal(schema.Length, Excel.InferSchema(workbook.Sheets[1], 1, 5).Length); + Assert.Equal(schema.Length, Excel.InferSchema(workbook.Sheets[1], new SchemaInferenceOptions { SampleSize = 5 }).Length); Assert.Throws(() => Excel.InferSchema((IExcelSheet)null!)); } diff --git a/tests/ExcelReader.Tests/Reader/Tables/TableBindingTests.cs b/tests/ExcelReader.Tests/Reader/Tables/TableBindingTests.cs index 725d0d49..633b4ba8 100644 --- a/tests/ExcelReader.Tests/Reader/Tables/TableBindingTests.cs +++ b/tests/ExcelReader.Tests/Reader/Tables/TableBindingTests.cs @@ -82,7 +82,7 @@ public void HeaderlessTableReadsWithoutAHeader(string file) using IExcelWorkbook workbook = Excel.Open(TableMetadataTests.FixturePath(file)); IExcelSheet view = Table(workbook, "NoHeader").AsSheet(); - using ExcelDataReader reader = new(view, headerRow: 0); + using ExcelDataReader reader = new(view, new ExcelDataReaderOptions { HeaderRow = 0 }); List letters = []; while (reader.Read()) { @@ -90,7 +90,7 @@ public void HeaderlessTableReadsWithoutAHeader(string file) } Assert.Equal(["a", "b", "c"], letters); - Assert.Equal(2, Excel.InferSchema(view, headerRow: 0).Length); + Assert.Equal(2, Excel.InferSchema(view, new SchemaInferenceOptions { HeaderRow = 0 }).Length); } [Theory] @@ -115,9 +115,9 @@ public void DataReaderOrdinalsLineUpWithOrWithoutAHeaderRow(string file) using IExcelWorkbook workbook = Excel.Open(TableMetadataTests.FixturePath(file)); IExcelSheet view = Table(workbook, "NoStyle").AsSheet(); - using ExcelDataReader named = new(view, headerRow: 1); + using ExcelDataReader named = new(view, new ExcelDataReaderOptions { HeaderRow = 1 }); Assert.True(named.Read()); - using ExcelDataReader unnamed = new(view, headerRow: 0); + using ExcelDataReader unnamed = new(view, new ExcelDataReaderOptions { HeaderRow = 0 }); Assert.True(unnamed.Read()); Assert.Equal((2, "Name", "Value", "x"), (named.FieldCount, named.GetName(0), named.GetName(1), named.GetString(0))); From 30af28e733a3b5507979d46f6cb81b1949b7837d Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Sun, 11 Oct 2026 01:33:06 -0300 Subject: [PATCH 087/115] refactor!: give the XLS writer an options record and the XLSX writer a 1904 date system XlsWorkbookWriter.Create takes XlsWriterOptions instead of a bare date1904 flag, matching the XLSX and XLSB writers. XlsxWriterOptions gains Date1904, which writes workbookPr date1904 and serializes dates against the 1904 epoch; the CLI and the native writer now pass it through for XLSX as they already did for XLSB and XLS. --- src/ExcelReader.Cli/CliCommands.cs | 4 +-- .../PublicAPI/PublicAPI.Shipped.txt | 1 - .../PublicAPI/PublicAPI.Unshipped.txt | 15 ++++++++++ .../Writer/Internal/CellFormatter.cs | 4 +-- .../Writer/Xls/XlsWorkbookWriter.cs | 6 ++-- .../Writer/Xls/XlsWriterOptions.cs | 12 ++++++++ .../Writer/Xlsx/XlsxRowWriter.cs | 4 +-- .../Writer/Xlsx/XlsxSheetWriter.cs | 2 ++ .../Writer/Xlsx/XlsxWorkbookWriter.cs | 7 +++++ .../Writer/Xlsx/XlsxWriterOptions.cs | 5 +++- .../Writer/NativeWriterHandle.cs | 4 +-- src/ExcelReader.Native/Writer/WriteApi.cs | 4 +-- .../Writer/WorkbookWriterTests.cs | 30 +++++++++++++++++++ .../Writer/Xls/XlsWriterTests.cs | 2 +- 14 files changed, 84 insertions(+), 16 deletions(-) create mode 100644 src/ExcelReader.Core/Writer/Xls/XlsWriterOptions.cs diff --git a/src/ExcelReader.Cli/CliCommands.cs b/src/ExcelReader.Cli/CliCommands.cs index 85c2c391..ca914cfa 100644 --- a/src/ExcelReader.Cli/CliCommands.cs +++ b/src/ExcelReader.Cli/CliCommands.cs @@ -120,7 +120,7 @@ private static void WriteCsv(IExcelSheet sheet, Stream target, bool leaveOpen, c private static void WriteXlsx(IExcelSheet sheet, Stream target, bool leaveOpen, Action? onProgress) { - using XlsxWorkbookWriter workbook = XlsxWorkbookWriter.Create(target, leaveOpen); + using XlsxWorkbookWriter workbook = XlsxWorkbookWriter.Create(target, leaveOpen, new XlsxWriterOptions { Date1904 = sheet.IsDate1904 }); WriteRows(workbook, sheet, onProgress); } @@ -132,7 +132,7 @@ private static void WriteXlsb(IExcelSheet sheet, Stream target, bool leaveOpen, private static void WriteXls(IExcelSheet sheet, Stream target, bool leaveOpen, Action? onProgress) { - using XlsWorkbookWriter workbook = XlsWorkbookWriter.Create(target, leaveOpen, date1904: sheet.IsDate1904); + using XlsWorkbookWriter workbook = XlsWorkbookWriter.Create(target, leaveOpen, new XlsWriterOptions { Date1904 = sheet.IsDate1904 }); WriteRows(workbook, sheet, onProgress); } diff --git a/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt b/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt index 649515e1..c68e72c8 100644 --- a/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt +++ b/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt @@ -761,7 +761,6 @@ static ExcelReader.Core.Writer.SheetWriterExtensions.WriteRecordsAsync( static ExcelReader.Core.Writer.SheetWriterExtensions.WriteRecordsAsync(this ExcelReader.Core.Writer.ISheetWriter! sheet, System.Collections.Generic.IEnumerable! records, ExcelReader.Core.Writer.ExcelRecordLayout! layout, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static ExcelReader.Core.Writer.SheetWriterExtensions.WriteRecordsAsync(this ExcelReader.Core.Writer.ISheetWriter! sheet, System.Collections.Generic.IEnumerable! records, System.Action! writeRow, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static ExcelReader.Core.Writer.SheetWriterExtensions.WriteRecordsAsync(this ExcelReader.Core.Writer.Xlsx.XlsxSheetWriter! sheet, System.Collections.Generic.IEnumerable! records, System.Action! writeRow, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static ExcelReader.Core.Writer.Xls.XlsWorkbookWriter.Create(System.IO.Stream! stream, bool leaveOpen = false, bool date1904 = false) -> ExcelReader.Core.Writer.Xls.XlsWorkbookWriter! static ExcelReader.Core.Writer.Xlsb.XlsbCell.Create(System.DateTime value) -> ExcelReader.Core.Writer.Xlsb.XlsbCell static ExcelReader.Core.Writer.Xlsb.XlsbCell.Create(System.DateTime? value) -> ExcelReader.Core.Writer.Xlsb.XlsbCell static ExcelReader.Core.Writer.Xlsb.XlsbCell.Create(bool value) -> ExcelReader.Core.Writer.Xlsb.XlsbCell diff --git a/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt b/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt index f262ae01..7e565c10 100644 --- a/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt @@ -86,3 +86,18 @@ ExcelReader.Core.Reader.Schema.SchemaInferenceOptions.SchemaInferenceOptions() - static ExcelReader.Core.Reader.Excel.InferSchema(ExcelReader.Core.Reader.IExcelSheet! sheet, ExcelReader.Core.Reader.Schema.SchemaInferenceOptions? options = null) -> ExcelReader.Core.Reader.Schema.ExcelColumnSchema[]! static ExcelReader.Core.Reader.ExcelDataReaderOptions.Default.get -> ExcelReader.Core.Reader.ExcelDataReaderOptions! static ExcelReader.Core.Reader.Schema.SchemaInferenceOptions.Default.get -> ExcelReader.Core.Reader.Schema.SchemaInferenceOptions! +ExcelReader.Core.Writer.Xls.XlsWriterOptions +ExcelReader.Core.Writer.Xls.XlsWriterOptions.$() -> ExcelReader.Core.Writer.Xls.XlsWriterOptions! +ExcelReader.Core.Writer.Xls.XlsWriterOptions.Date1904.get -> bool +ExcelReader.Core.Writer.Xls.XlsWriterOptions.Date1904.init -> void +ExcelReader.Core.Writer.Xls.XlsWriterOptions.Equals(ExcelReader.Core.Writer.Xls.XlsWriterOptions? other) -> bool +ExcelReader.Core.Writer.Xls.XlsWriterOptions.XlsWriterOptions() -> void +ExcelReader.Core.Writer.Xlsx.XlsxWriterOptions.Date1904.get -> bool +ExcelReader.Core.Writer.Xlsx.XlsxWriterOptions.Date1904.init -> void +override ExcelReader.Core.Writer.Xls.XlsWriterOptions.Equals(object? obj) -> bool +override ExcelReader.Core.Writer.Xls.XlsWriterOptions.GetHashCode() -> int +override ExcelReader.Core.Writer.Xls.XlsWriterOptions.ToString() -> string! +static ExcelReader.Core.Writer.Xls.XlsWorkbookWriter.Create(System.IO.Stream! stream, bool leaveOpen = false, ExcelReader.Core.Writer.Xls.XlsWriterOptions? options = null) -> ExcelReader.Core.Writer.Xls.XlsWorkbookWriter! +static ExcelReader.Core.Writer.Xls.XlsWriterOptions.Default.get -> ExcelReader.Core.Writer.Xls.XlsWriterOptions! +static ExcelReader.Core.Writer.Xls.XlsWriterOptions.operator !=(ExcelReader.Core.Writer.Xls.XlsWriterOptions? left, ExcelReader.Core.Writer.Xls.XlsWriterOptions? right) -> bool +static ExcelReader.Core.Writer.Xls.XlsWriterOptions.operator ==(ExcelReader.Core.Writer.Xls.XlsWriterOptions? left, ExcelReader.Core.Writer.Xls.XlsWriterOptions? right) -> bool diff --git a/src/ExcelReader.Core/Writer/Internal/CellFormatter.cs b/src/ExcelReader.Core/Writer/Internal/CellFormatter.cs index 9c84874d..d3616f74 100644 --- a/src/ExcelReader.Core/Writer/Internal/CellFormatter.cs +++ b/src/ExcelReader.Core/Writer/Internal/CellFormatter.cs @@ -111,11 +111,11 @@ internal static void WriteBool(BiffBuffer xml, bool value, int columnIndex, int xml.Write(""u8); } - internal static void WriteDateTime(BiffBuffer xml, DateTime value, int columnIndex, int rowNumber, bool includeReference, int styleId) + internal static void WriteDateTime(BiffBuffer xml, DateTime value, bool date1904, int columnIndex, int rowNumber, bool includeReference, int styleId) { WriteCellOpen(xml, columnIndex, rowNumber, includeReference, styleId, default, selfClose: false); xml.Write(""u8); - WriteValue(xml, ExcelEpoch.OADateToSerial(value.ToOADate(), date1904: false), sizeHint: 32); + WriteValue(xml, ExcelEpoch.OADateToSerial(value.ToOADate(), date1904), sizeHint: 32); xml.Write(""u8); } diff --git a/src/ExcelReader.Core/Writer/Xls/XlsWorkbookWriter.cs b/src/ExcelReader.Core/Writer/Xls/XlsWorkbookWriter.cs index a2a64eb1..2e88bd17 100644 --- a/src/ExcelReader.Core/Writer/Xls/XlsWorkbookWriter.cs +++ b/src/ExcelReader.Core/Writer/Xls/XlsWorkbookWriter.cs @@ -39,11 +39,11 @@ private XlsWorkbookWriter(Stream stream, bool leaveOpen, bool date1904) /// /// The destination stream. /// If , the stream is left open when the writer is disposed. - /// If , dates are serialized using the 1904 epoch instead of the default 1900 epoch. - public static XlsWorkbookWriter Create(Stream stream, bool leaveOpen = false, bool date1904 = false) + /// The date system; when . + public static XlsWorkbookWriter Create(Stream stream, bool leaveOpen = false, XlsWriterOptions? options = null) { ArgumentNullException.ThrowIfNull(stream); - return new XlsWorkbookWriter(stream, leaveOpen, date1904); + return new XlsWorkbookWriter(stream, leaveOpen, (options ?? XlsWriterOptions.Default).Date1904); } /// diff --git a/src/ExcelReader.Core/Writer/Xls/XlsWriterOptions.cs b/src/ExcelReader.Core/Writer/Xls/XlsWriterOptions.cs new file mode 100644 index 00000000..ea7c1885 --- /dev/null +++ b/src/ExcelReader.Core/Writer/Xls/XlsWriterOptions.cs @@ -0,0 +1,12 @@ +namespace ExcelReader.Core.Writer.Xls +{ + /// Configures how a BIFF8 (.xls) workbook is written. + public sealed record XlsWriterOptions + { + /// Whether the workbook uses the 1904 date system instead of the default 1900 system. Defaults to . + public bool Date1904 { get; init; } + + /// The default options: 1900 date system. + public static XlsWriterOptions Default { get; } = new(); + } +} diff --git a/src/ExcelReader.Core/Writer/Xlsx/XlsxRowWriter.cs b/src/ExcelReader.Core/Writer/Xlsx/XlsxRowWriter.cs index 72c5d3cc..bf1ebb5a 100644 --- a/src/ExcelReader.Core/Writer/Xlsx/XlsxRowWriter.cs +++ b/src/ExcelReader.Core/Writer/Xlsx/XlsxRowWriter.cs @@ -112,7 +112,7 @@ public void Write(DateTime value) { ThrowIfDisposed(); int styleId = EffectiveStyle(); - CellFormatter.WriteDateTime(_row, value, _columnIndex, _rowNumber, ConsumeCellReference(), styleId == 0 ? 1 : styleId); + CellFormatter.WriteDateTime(_row, value, _owner.Date1904, _columnIndex, _rowNumber, ConsumeCellReference(), styleId == 0 ? 1 : styleId); _columnIndex++; } @@ -138,7 +138,7 @@ public void Write(DateOnly value) { ThrowIfDisposed(); int styleId = EffectiveStyle(); - CellFormatter.WriteDateTime(_row, value.ToDateTime(TimeOnly.MinValue), _columnIndex, _rowNumber, ConsumeCellReference(), styleId == 0 ? 1 : styleId); + CellFormatter.WriteDateTime(_row, value.ToDateTime(TimeOnly.MinValue), _owner.Date1904, _columnIndex, _rowNumber, ConsumeCellReference(), styleId == 0 ? 1 : styleId); _columnIndex++; } diff --git a/src/ExcelReader.Core/Writer/Xlsx/XlsxSheetWriter.cs b/src/ExcelReader.Core/Writer/Xlsx/XlsxSheetWriter.cs index d7639d0d..9bb2c5f1 100644 --- a/src/ExcelReader.Core/Writer/Xlsx/XlsxSheetWriter.cs +++ b/src/ExcelReader.Core/Writer/Xlsx/XlsxSheetWriter.cs @@ -45,6 +45,8 @@ internal XlsxSheetWriter(XlsxWorkbookWriter owner, ZipArchive zip, string name, internal int SheetId { get; } internal ExcelSheetVisibility Visibility { get; } internal bool UseSharedStrings => _owner.UseSharedStrings; + + internal bool Date1904 => _owner.Date1904; internal bool ResourcesReleased => _stream is null && _rowBuffer.IsReleased; internal int GetSharedStringIndex(string value) diff --git a/src/ExcelReader.Core/Writer/Xlsx/XlsxWorkbookWriter.cs b/src/ExcelReader.Core/Writer/Xlsx/XlsxWorkbookWriter.cs index d06da584..34b40541 100644 --- a/src/ExcelReader.Core/Writer/Xlsx/XlsxWorkbookWriter.cs +++ b/src/ExcelReader.Core/Writer/Xlsx/XlsxWorkbookWriter.cs @@ -34,6 +34,7 @@ private XlsxWorkbookWriter(ZipArchive zip, Stream stream, bool leaveOpen, XlsxWr _stream = stream; _leaveOpen = leaveOpen; UseSharedStrings = options.UseSharedStrings; + Date1904 = options.Date1904; _compression = options.Compression; _prefetchWrite = options.PrefetchWrite; _sharedStrings = options.UseSharedStrings ? new SharedStringTable() : null; @@ -132,6 +133,8 @@ internal void NotifySheetEnded(bool faulted) internal bool UseSharedStrings { get; } + internal bool Date1904 { get; } + internal int GetSharedStringIndex(string value) { return _sharedStrings!.GetOrAdd(value); @@ -488,6 +491,10 @@ private string BuildWorkbookXml() StringBuilder sb = new(); sb.Append(""); sb.Append($""); + if (Date1904) + { + sb.Append(""); + } sb.Append(""); bool anyVisible = false; foreach ((string name, int sheetId, ExcelSheetVisibility visibility) in _sheets) diff --git a/src/ExcelReader.Core/Writer/Xlsx/XlsxWriterOptions.cs b/src/ExcelReader.Core/Writer/Xlsx/XlsxWriterOptions.cs index 5689d024..02dccd4d 100644 --- a/src/ExcelReader.Core/Writer/Xlsx/XlsxWriterOptions.cs +++ b/src/ExcelReader.Core/Writer/Xlsx/XlsxWriterOptions.cs @@ -5,6 +5,9 @@ namespace ExcelReader.Core.Writer.Xlsx /// Configures how an XLSX package is written. public sealed record XlsxWriterOptions { + /// Whether the workbook uses the 1904 date system instead of the default 1900 system. Defaults to . + public bool Date1904 { get; init; } + /// The compression level applied to each ZIP entry. Defaults to . public CompressionLevel Compression { get; init; } = CompressionLevel.Fastest; @@ -23,7 +26,7 @@ public sealed record XlsxWriterOptions /// public bool PrefetchWrite { get; init; } - /// The default options: fastest compression, inline strings, no background deflate. + /// The default options: 1900 date system, fastest compression, inline strings, no background deflate. public static XlsxWriterOptions Default { get; } = new(); } } diff --git a/src/ExcelReader.Native/Writer/NativeWriterHandle.cs b/src/ExcelReader.Native/Writer/NativeWriterHandle.cs index 29f90a04..f16c627a 100644 --- a/src/ExcelReader.Native/Writer/NativeWriterHandle.cs +++ b/src/ExcelReader.Native/Writer/NativeWriterHandle.cs @@ -52,13 +52,13 @@ internal static NativeWriterHandle Create(Stream stream, int format, NativeWrite { case NativeFormat.Xlsx: return new NativeWriterHandle( - XlsxWorkbookWriter.Create(stream, options: new XlsxWriterOptions { UseSharedStrings = sharedStrings })); + XlsxWorkbookWriter.Create(stream, options: new XlsxWriterOptions { Date1904 = date1904, UseSharedStrings = sharedStrings })); case NativeFormat.Xlsb: return new NativeWriterHandle( XlsbWorkbookWriter.Create(stream, options: new XlsbWriterOptions { Date1904 = date1904, UseSharedStrings = sharedStrings })); case NativeFormat.Xls: return new NativeWriterHandle( - XlsWorkbookWriter.Create(stream, date1904: date1904)); + XlsWorkbookWriter.Create(stream, options: new XlsWriterOptions { Date1904 = date1904 })); case NativeFormat.Csv: return new NativeWriterHandle( CsvWorkbookWriter.Create(stream, options: options.ToCsvWriterOptions())); diff --git a/src/ExcelReader.Native/Writer/WriteApi.cs b/src/ExcelReader.Native/Writer/WriteApi.cs index 2d4f8290..1fa5a81a 100644 --- a/src/ExcelReader.Native/Writer/WriteApi.cs +++ b/src/ExcelReader.Native/Writer/WriteApi.cs @@ -194,7 +194,7 @@ private static void WriteToStream(Stream stream, int format, NativeColumnSpec[] { case NativeFormat.Xlsx: WriteWorkbook( - XlsxWorkbookWriter.Create(stream, options: new XlsxWriterOptions { UseSharedStrings = sharedStrings }), specs, table, sheetName, hasHeader); + XlsxWorkbookWriter.Create(stream, options: new XlsxWriterOptions { Date1904 = date1904, UseSharedStrings = sharedStrings }), specs, table, sheetName, hasHeader); return; case NativeFormat.Xlsb: WriteWorkbook( @@ -202,7 +202,7 @@ private static void WriteToStream(Stream stream, int format, NativeColumnSpec[] return; case NativeFormat.Xls: WriteWorkbook( - XlsWorkbookWriter.Create(stream, date1904: date1904), specs, table, sheetName, hasHeader); + XlsWorkbookWriter.Create(stream, options: new XlsWriterOptions { Date1904 = date1904 }), specs, table, sheetName, hasHeader); return; default: WriteWorkbook( diff --git a/tests/ExcelReader.Tests/Writer/WorkbookWriterTests.cs b/tests/ExcelReader.Tests/Writer/WorkbookWriterTests.cs index aed46749..1c575393 100644 --- a/tests/ExcelReader.Tests/Writer/WorkbookWriterTests.cs +++ b/tests/ExcelReader.Tests/Writer/WorkbookWriterTests.cs @@ -12,6 +12,36 @@ namespace ExcelReader.Tests.Writer { public class WorkbookWriterTests { + [Fact] + public async Task XlsxDate1904RoundTrips() + { + CancellationToken ct = TestContext.Current.CancellationToken; + var date = new DateTime(1904, 1, 2, 6, 0, 0, DateTimeKind.Unspecified); + var day = new DateOnly(2024, 5, 6); + var ms = new MemoryStream(); + await using (XlsxWorkbookWriter wb = XlsxWorkbookWriter.Create(ms, leaveOpen: true, new XlsxWriterOptions { Date1904 = true })) + { + XlsxSheetWriter sheet = wb.AddSheet("S"); + await using (XlsxRowWriter row = await sheet.StartRowAsync(ct)) + { + row.Write(date); + row.Write(day); + } + await sheet.EndAsync(ct); + await wb.EndAsync(ct); + } + ms.Position = 0; + + using XlsxWorkbook reader = Excel.FromXlsx(ms); + Assert.True(reader.IsDate1904); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); + Assert.True(e.MoveNext()); + Assert.True(e.Current[0].TryGetDateTime(isDate1904: true, out DateTime parsed)); + Assert.Equal(date, parsed); + Assert.True(e.Current[1].TryGetDateTime(isDate1904: true, out DateTime parsedDay)); + Assert.Equal(day.ToDateTime(TimeOnly.MinValue), parsedDay); + } + private sealed class StringRow { diff --git a/tests/ExcelReader.Tests/Writer/Xls/XlsWriterTests.cs b/tests/ExcelReader.Tests/Writer/Xls/XlsWriterTests.cs index acdd2aab..b9d80a7c 100644 --- a/tests/ExcelReader.Tests/Writer/Xls/XlsWriterTests.cs +++ b/tests/ExcelReader.Tests/Writer/Xls/XlsWriterTests.cs @@ -13,7 +13,7 @@ private static async Task WriteAsync( Action build, bool date1904 = false, CancellationToken ct = default) { var ms = new MemoryStream(); - await using (var wb = XlsWorkbookWriter.Create(ms, leaveOpen: true, date1904: date1904)) + await using (var wb = XlsWorkbookWriter.Create(ms, leaveOpen: true, new XlsWriterOptions { Date1904 = date1904 })) { build(wb); await wb.EndAsync(ct); From fda69b853de750b70af78bab086331a896e86a7b Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Sun, 11 Oct 2026 01:41:19 -0300 Subject: [PATCH 088/115] refactor!: open CSV with ExcelReaderOptions like every other format The Excel.FromCsv* factories take ExcelReaderOptions instead of CsvReaderOptions; the delimiter, quote and encoding stay in ExcelReaderOptions.Csv. MaxCellBytes and InternStrings leave CsvReaderOptions and come from ExcelReaderOptions, which also fixes Excel.Open with ExcelFileFormat.Csv silently ignoring those two reader limits. The native ABI keeps its separate csv_max_cell_bytes and csv_intern_strings fields. --- docs/guide/csv.md | 10 +-- .../Parser/ParallelCsv/CsvChunkWorker.cs | 4 +- .../PublicAPI/PublicAPI.Shipped.txt | 9 --- .../PublicAPI/PublicAPI.Unshipped.txt | 5 ++ .../Reader/Csv/CsvReaderOptions.cs | 26 +++----- src/ExcelReader.Core/Reader/Excel.cs | 65 ++++++++++++------- .../Reader/ExcelReaderOptions.cs | 26 ++++---- src/ExcelReader.Native/Csv/CsvAggregateApi.cs | 4 +- src/ExcelReader.Native/NativeOpenOptions.cs | 2 +- .../Reading/ReadApi.Sources.cs | 3 +- tests/ExcelReader.Fuzz/Harnesses.cs | 6 +- .../Native/CsvAggregateNativeTests.cs | 2 +- .../ParallelCsv/ParseCsvParallelTests.cs | 2 +- .../Reader/Csv/CsvBlockBoundaryTests.cs | 4 +- .../Reader/Csv/CsvDialectAndFormatTests.cs | 6 +- .../Reader/Csv/CsvLifetimeTests.cs | 4 +- .../Reader/Csv/CsvReaderTests.cs | 36 ++++++---- .../Reader/Csv/CsvReplayTests.cs | 2 +- .../Reader/Csv/CsvSnifferTests.cs | 2 +- .../Reader/FileOwnershipTests.cs | 4 +- .../Reader/Utf8StringCacheTests.cs | 3 +- .../Writer/Csv/CsvWriterOptionsTests.cs | 2 +- .../Writer/Csv/CsvWriterTests.cs | 2 +- 23 files changed, 121 insertions(+), 108 deletions(-) diff --git a/docs/guide/csv.md b/docs/guide/csv.md index dc9a0fc2..3cea059e 100644 --- a/docs/guide/csv.md +++ b/docs/guide/csv.md @@ -25,10 +25,10 @@ foreach (var item in ExcelParser.FromAttributes().Parse(csv.FirstShee } ``` -`Excel.FromCsv`/`FromCsvFile`/`FromCsvAsync`/`FromCsvFileAsync` mirror the other formats' factory shape. Pass `CsvReaderOptions` to change the delimiter/quote character, supply a non-UTF-8 `Encoding` (transcoded to UTF-8 internally), or turn off BOM detection: +`Excel.FromCsv`/`FromCsvFile`/`FromCsvAsync`/`FromCsvFileAsync` mirror the other formats' factory shape and take the same `ExcelReaderOptions`. Its `Csv` property, a `CsvReaderOptions`, changes the delimiter/quote character, supplies a non-UTF-8 `Encoding` (transcoded to UTF-8 internally), or turns off BOM detection; `MaxCellBytes` and `InternStrings` stay on `ExcelReaderOptions`, as for every format: ```csharp -var options = new CsvReaderOptions { Delimiter = (byte)';' }; +var options = new ExcelReaderOptions { Csv = new CsvReaderOptions { Delimiter = (byte)';' } }; using var csv = Excel.FromCsvFile("relatorio.csv", options); ``` @@ -59,7 +59,7 @@ using ExcelReader.Core.Reader; using ExcelReader.Core.Reader.Csv; CsvDialect dialect = CsvSniffer.DetectFile("export.csv"); -using var csv = Excel.FromCsvFile("export.csv", CsvReaderOptions.Default.WithDialect(dialect)); +using var csv = Excel.FromCsvFile("export.csv", new ExcelReaderOptions { Csv = CsvReaderOptions.Default.WithDialect(dialect) }); ``` `CsvSniffer.Detect` takes a span of bytes or a seekable `Stream`, `DetectFile` takes a path, and `DetectAsync`/`DetectFileAsync` are the async siblings; only the first 64 KiB are examined. The `Stream` overloads require a seekable source — they read a bounded sample and restore the stream's position — so a non-seekable stream throws `ArgumentException`; buffer it first, or pass the bytes as a span instead. Pass `CsvSnifferOptions` to change the candidate delimiters/quotes (and their priority order) or the number of sample lines considered. @@ -67,7 +67,7 @@ using var csv = Excel.FromCsvFile("export.csv", CsvReaderOptions.Default.WithDia `CsvReaderOptions.SniffDialect` does the sample-then-apply above for you, at the entry point that owns the source — the `Excel.FromCsv*` factories, and `Excel.Open` with `ExcelFileFormat.Csv`. The dialect of a parallel read is the one the CSV workbook was opened with: ```csharp -using var csv = Excel.FromCsvFile("export.csv", CsvReaderOptions.Default with { SniffDialect = true }); +using var csv = Excel.FromCsvFile("export.csv", new ExcelReaderOptions { Csv = CsvReaderOptions.Default with { SniffDialect = true } }); ``` An explicitly set `Encoding` survives sniffing unless the source carries a byte-order mark naming a different one — a sniffed encoding only ever comes from a BOM, so it never silently overrides a caller who knows their file is Windows-1252. @@ -101,7 +101,7 @@ using (CsvRowWriter row = writer.StartRow()) Fields are quoted only when they contain the delimiter, quote character, `\r`, or `\n`; embedded quotes are doubled. `bool` writes as lowercase `true`/`false` and `DateTime`/`DateOnly` as round-trip ISO 8601 (`"O"`); `TimeOnly` as a time-of-day fraction — all matching what `ExcelParser.Parse(CsvSheet)` expects, so a file written by `CsvWorkbookWriter` parses back without configuration. `Skip(count)` writes empty fields to keep column positions aligned (CSV has no sparse-cell concept). -`CsvWriterOptions` mirrors `CsvReaderOptions` property for property, so a file written with one set of settings reads back with the matching set: +`CsvWriterOptions` mirrors `CsvReaderOptions`'s delimiter, quote and encoding, so a file written with one set of settings reads back with the matching set: ```csharp using var workbook = CsvWorkbookWriter.Create(stream, leaveOpen: false, new CsvWriterOptions diff --git a/src/ExcelReader.Core/Parser/ParallelCsv/CsvChunkWorker.cs b/src/ExcelReader.Core/Parser/ParallelCsv/CsvChunkWorker.cs index df4614f8..a61b39ab 100644 --- a/src/ExcelReader.Core/Parser/ParallelCsv/CsvChunkWorker.cs +++ b/src/ExcelReader.Core/Parser/ParallelCsv/CsvChunkWorker.cs @@ -48,9 +48,9 @@ internal CsvReader OpenReader(CsvReaderOptions options) { if (IsMemory) { - return Excel.FromCsv(SliceAt(0), options); + return Excel.OpenCsv(SliceAt(0), options); } - return Excel.FromCsv(OpenAt(0), leaveOpen: false, options); + return Excel.OpenCsv(OpenAt(0), leaveOpen: false, options); } internal int ReadWindow(long offset, Span buffer) diff --git a/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt b/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt index c68e72c8..f30f97e8 100644 --- a/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt +++ b/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt @@ -152,10 +152,6 @@ ExcelReader.Core.Reader.Csv.CsvReaderOptions.DetectEncodingFromByteOrderMark.ini ExcelReader.Core.Reader.Csv.CsvReaderOptions.Encoding.get -> System.Text.Encoding? ExcelReader.Core.Reader.Csv.CsvReaderOptions.Encoding.init -> void ExcelReader.Core.Reader.Csv.CsvReaderOptions.Equals(ExcelReader.Core.Reader.Csv.CsvReaderOptions? other) -> bool -ExcelReader.Core.Reader.Csv.CsvReaderOptions.InternStrings.get -> bool -ExcelReader.Core.Reader.Csv.CsvReaderOptions.InternStrings.init -> void -ExcelReader.Core.Reader.Csv.CsvReaderOptions.MaxCellBytes.get -> int -ExcelReader.Core.Reader.Csv.CsvReaderOptions.MaxCellBytes.init -> void ExcelReader.Core.Reader.Csv.CsvReaderOptions.Quote.get -> byte ExcelReader.Core.Reader.Csv.CsvReaderOptions.Quote.init -> void ExcelReader.Core.Reader.Csv.CsvReaderOptions.SniffDialect.get -> bool @@ -705,11 +701,6 @@ static ExcelReader.Core.Reader.Excel.EncryptPackage(System.IO.Stream! package, S static ExcelReader.Core.Reader.Excel.EncryptPackage(string! packagePath, string! destinationPath, ExcelReader.Core.Reader.ExcelPassword! password) -> void static ExcelReader.Core.Reader.Excel.EncryptPackageAsync(System.IO.Stream! package, System.IO.Stream! destination, ExcelReader.Core.Reader.ExcelPassword! password, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static ExcelReader.Core.Reader.Excel.EncryptPackageAsync(string! packagePath, string! destinationPath, ExcelReader.Core.Reader.ExcelPassword! password, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static ExcelReader.Core.Reader.Excel.FromCsv(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.Csv.CsvReaderOptions? options = null) -> ExcelReader.Core.Reader.Csv.CsvReader! -static ExcelReader.Core.Reader.Excel.FromCsv(System.ReadOnlyMemory data, ExcelReader.Core.Reader.Csv.CsvReaderOptions? options = null) -> ExcelReader.Core.Reader.Csv.CsvReader! -static ExcelReader.Core.Reader.Excel.FromCsvAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.Csv.CsvReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static ExcelReader.Core.Reader.Excel.FromCsvFile(string! path, ExcelReader.Core.Reader.Csv.CsvReaderOptions? options = null) -> ExcelReader.Core.Reader.Csv.CsvReader! -static ExcelReader.Core.Reader.Excel.FromCsvFileAsync(string! path, ExcelReader.Core.Reader.Csv.CsvReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static ExcelReader.Core.Reader.Excel.FromXls(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xls.XlsWorkbook! static ExcelReader.Core.Reader.Excel.FromXls(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xls.XlsWorkbook! static ExcelReader.Core.Reader.Excel.FromXlsAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask diff --git a/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt b/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt index 7e565c10..71ce2edc 100644 --- a/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt @@ -101,3 +101,8 @@ static ExcelReader.Core.Writer.Xls.XlsWorkbookWriter.Create(System.IO.Stream! st static ExcelReader.Core.Writer.Xls.XlsWriterOptions.Default.get -> ExcelReader.Core.Writer.Xls.XlsWriterOptions! static ExcelReader.Core.Writer.Xls.XlsWriterOptions.operator !=(ExcelReader.Core.Writer.Xls.XlsWriterOptions? left, ExcelReader.Core.Writer.Xls.XlsWriterOptions? right) -> bool static ExcelReader.Core.Writer.Xls.XlsWriterOptions.operator ==(ExcelReader.Core.Writer.Xls.XlsWriterOptions? left, ExcelReader.Core.Writer.Xls.XlsWriterOptions? right) -> bool +static ExcelReader.Core.Reader.Excel.FromCsv(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Csv.CsvReader! +static ExcelReader.Core.Reader.Excel.FromCsv(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Csv.CsvReader! +static ExcelReader.Core.Reader.Excel.FromCsvAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Core.Reader.Excel.FromCsvFile(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Csv.CsvReader! +static ExcelReader.Core.Reader.Excel.FromCsvFileAsync(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask diff --git a/src/ExcelReader.Core/Reader/Csv/CsvReaderOptions.cs b/src/ExcelReader.Core/Reader/Csv/CsvReaderOptions.cs index 6c05d781..86a2bf5f 100644 --- a/src/ExcelReader.Core/Reader/Csv/CsvReaderOptions.cs +++ b/src/ExcelReader.Core/Reader/Csv/CsvReaderOptions.cs @@ -2,7 +2,8 @@ namespace ExcelReader.Core.Reader.Csv { - /// Options controlling how parses a CSV source. + /// The delimited-text settings for a CSV source, carried by . + /// Size limits and string interning come from , as for every other format. public sealed record CsvReaderOptions { /// Gets the byte used to separate fields within a record. Defaults to ,. @@ -21,23 +22,14 @@ public sealed record CsvReaderOptions /// Gets a value indicating whether a leading UTF-8 byte-order mark should be detected and skipped. Defaults to . public bool DetectEncodingFromByteOrderMark { get; init; } = true; - /// Gets the maximum byte length allowed for a single buffered record/field. Defaults to 32 MiB. - /// Mirrors for the Excel readers. - public int MaxCellBytes { get; init; } = 32 * 1024 * 1024; + internal int MaxCellBytes { get; init; } = 32 * 1024 * 1024; - /// - /// Gets a value indicating whether repeated field text is deduplicated through a small - /// content-keyed cache when materialized via . - /// Defaults to . Has no effect on the zero-copy - /// span path — only GetString() consults the - /// cache. Worth enabling for genuinely categorical columns (low-cardinality repeated text); - /// for high-cardinality data (unique IDs, or numeric/date columns read back as text, which CSV - /// has no other representation for) every lookup is a cache miss, so the per-call hashing cost - /// is pure overhead with no dedup benefit — measured roughly doubling wall-clock time on a - /// real-world, mixed-cardinality CSV corpus. Off by default so this never regresses a caller - /// who hasn't measured their own data. - /// - public bool InternStrings { get; init; } + internal bool InternStrings { get; init; } + + // ponytail: the native ABI keeps separate csv_max_cell_bytes/csv_intern_strings fields; these let it win over ExcelReaderOptions. + internal int? MaxCellBytesOverride { get; init; } + + internal bool? InternStringsOverride { get; init; } /// /// Gets a value indicating whether the source's dialect is inferred from a sample of its leading diff --git a/src/ExcelReader.Core/Reader/Excel.cs b/src/ExcelReader.Core/Reader/Excel.cs index 64d843c8..ccfca244 100644 --- a/src/ExcelReader.Core/Reader/Excel.cs +++ b/src/ExcelReader.Core/Reader/Excel.cs @@ -199,13 +199,13 @@ public static ValueTask FromXlsbAsync(Stream stream, bool leaveOpe /// Opens a CSV (or other delimited-text) source from a file path, taking ownership of the file stream. /// The path to the CSV file. - /// Delimiter, quote, encoding, and size-limit settings; when . - public static CsvReader FromCsvFile(string path, CsvReaderOptions? options = null) + /// Size limits and interning, plus the delimiter, quote and encoding in ; when . + public static CsvReader FromCsvFile(string path, ExcelReaderOptions? options = null) { FileStream stream = File.OpenRead(path); try { - return new CsvReader(stream, leaveOpen: false, CsvDialectResolver.Resolve(stream, options ?? CsvReaderOptions.Default)); + return new CsvReader(stream, leaveOpen: false, CsvDialectResolver.Resolve(stream, CsvOf(options))); } catch { @@ -217,33 +217,46 @@ public static CsvReader FromCsvFile(string path, CsvReaderOptions? options = nul /// Opens a CSV (or other delimited-text) source from an existing stream. /// The stream containing the CSV data. /// When (the default), is not disposed when the workbook is disposed, but the caller must not use, reposition or dispose it until the workbook and every enumerator obtained from it are disposed. - /// Delimiter, quote, encoding, and size-limit settings; when . - public static CsvReader FromCsv(Stream stream, bool leaveOpen = true, CsvReaderOptions? options = null) + /// Size limits and interning, plus the delimiter, quote and encoding in ; when . + public static CsvReader FromCsv(Stream stream, bool leaveOpen = true, ExcelReaderOptions? options = null) { ArgumentNullException.ThrowIfNull(stream); - CsvReaderOptions effective = CsvDialectResolver.Resolve(stream, options ?? CsvReaderOptions.Default); - return new CsvReader(stream, leaveOpen, effective); + return OpenCsv(stream, leaveOpen, CsvOf(options)); + } + + internal static CsvReader OpenCsv(Stream stream, bool leaveOpen, CsvReaderOptions options) + { + return new CsvReader(stream, leaveOpen, CsvDialectResolver.Resolve(stream, options)); } /// Opens a CSV (or other delimited-text) source directly from an in-memory buffer. /// The whole CSV source's bytes. Must stay valid and unmodified until the workbook and every enumerator obtained from it are disposed. - /// Delimiter, quote, encoding, and size-limit settings; when . - public static CsvReader FromCsv(ReadOnlyMemory data, CsvReaderOptions? options = null) + /// Size limits and interning, plus the delimiter, quote and encoding in ; when . + public static CsvReader FromCsv(ReadOnlyMemory data, ExcelReaderOptions? options = null) + { + return OpenCsv(data, CsvOf(options)); + } + + internal static CsvReader OpenCsv(ReadOnlyMemory data, CsvReaderOptions options) { - CsvReaderOptions effective = CsvDialectResolver.Resolve(data, options ?? CsvReaderOptions.Default); - return new CsvReader(data, effective); + return new CsvReader(data, CsvDialectResolver.Resolve(data, options)); } /// Asynchronously opens a CSV (or other delimited-text) source from a file path, taking ownership of the file stream. /// The path to the CSV file. - /// Delimiter, quote, encoding, and size-limit settings; when . + /// Size limits and interning, plus the delimiter, quote and encoding in ; when . /// A token to cancel the open operation. - public static async ValueTask FromCsvFileAsync(string path, CsvReaderOptions? options = null, CancellationToken ct = default) + public static ValueTask FromCsvFileAsync(string path, ExcelReaderOptions? options = null, CancellationToken ct = default) + { + return OpenCsvFileAsync(path, CsvOf(options), ct); + } + + internal static async ValueTask OpenCsvFileAsync(string path, CsvReaderOptions options, CancellationToken ct) { FileStream stream = OpenAsyncFile(path); try { - CsvReaderOptions effective = await CsvDialectResolver.ResolveAsync(stream, options ?? CsvReaderOptions.Default, ct).ConfigureAwait(false); + CsvReaderOptions effective = await CsvDialectResolver.ResolveAsync(stream, options, ct).ConfigureAwait(false); return await CsvReader.CreateAsync(stream, leaveOpen: false, effective, ct).ConfigureAwait(false); } catch @@ -256,12 +269,12 @@ public static async ValueTask FromCsvFileAsync(string path, CsvReader /// Asynchronously opens a CSV (or other delimited-text) source from an existing stream. /// The stream containing the CSV data. /// When (the default), is not disposed when the workbook is disposed, but the caller must not use, reposition or dispose it until the workbook and every enumerator obtained from it are disposed. - /// Delimiter, quote, encoding, and size-limit settings; when . + /// Size limits and interning, plus the delimiter, quote and encoding in ; when . /// A token to cancel the open operation. - public static async ValueTask FromCsvAsync(Stream stream, bool leaveOpen = true, CsvReaderOptions? options = null, CancellationToken ct = default) + public static async ValueTask FromCsvAsync(Stream stream, bool leaveOpen = true, ExcelReaderOptions? options = null, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(stream); - CsvReaderOptions effective = await CsvDialectResolver.ResolveAsync(stream, options ?? CsvReaderOptions.Default, ct).ConfigureAwait(false); + CsvReaderOptions effective = await CsvDialectResolver.ResolveAsync(stream, CsvOf(options), ct).ConfigureAwait(false); return await CsvReader.CreateAsync(stream, leaveOpen, effective, ct).ConfigureAwait(false); } @@ -453,7 +466,7 @@ public static IExcelWorkbook Open(string path, ExcelFileFormat format, ExcelRead RequireOpenableFormat(format); return format switch { - ExcelFileFormat.Csv => FromCsvFile(path, CsvOf(options)), + ExcelFileFormat.Csv => FromCsvFile(path, options), ExcelFileFormat.Xlsx => FromXlsxFile(path, options), ExcelFileFormat.Xlsb => FromXlsbFile(path, options), ExcelFileFormat.Xls => FromXlsFile(path, options), @@ -483,7 +496,7 @@ public static IExcelWorkbook Open(Stream stream, ExcelFileFormat format, bool le RequireOpenableFormat(format); return format switch { - ExcelFileFormat.Csv => FromCsv(stream, leaveOpen, CsvOf(options)), + ExcelFileFormat.Csv => FromCsv(stream, leaveOpen, options), ExcelFileFormat.Xlsx => FromXlsx(stream, leaveOpen, options), ExcelFileFormat.Xlsb => FromXlsb(stream, leaveOpen, options), ExcelFileFormat.Xls => FromXls(stream, leaveOpen, options), @@ -508,7 +521,7 @@ public static IExcelWorkbook Open(ReadOnlyMemory data, ExcelFileFormat for RequireOpenableFormat(format); return format switch { - ExcelFileFormat.Csv => FromCsv(data, CsvOf(options)), + ExcelFileFormat.Csv => FromCsv(data, options), ExcelFileFormat.Xlsx => FromXlsx(data, options), ExcelFileFormat.Xlsb => FromXlsb(data, options), ExcelFileFormat.Xls => FromXls(data, options), @@ -536,7 +549,7 @@ public static ValueTask OpenAsync(string path, ExcelFileFormat f RequireOpenableFormat(format); return format switch { - ExcelFileFormat.Csv => AsWorkbookAsync(FromCsvFileAsync(path, CsvOf(options), ct)), + ExcelFileFormat.Csv => AsWorkbookAsync(FromCsvFileAsync(path, options, ct)), ExcelFileFormat.Xlsx => AsWorkbookAsync(FromXlsxFileAsync(path, options, ct)), ExcelFileFormat.Xlsb => AsWorkbookAsync(FromXlsbFileAsync(path, options, ct)), ExcelFileFormat.Xls => AsWorkbookAsync(FromXlsFileAsync(path, options, ct)), @@ -567,7 +580,7 @@ public static ValueTask OpenAsync(Stream stream, ExcelFileFormat RequireOpenableFormat(format); return format switch { - ExcelFileFormat.Csv => AsWorkbookAsync(FromCsvAsync(stream, leaveOpen, CsvOf(options), ct)), + ExcelFileFormat.Csv => AsWorkbookAsync(FromCsvAsync(stream, leaveOpen, options, ct)), ExcelFileFormat.Xlsx => AsWorkbookAsync(FromXlsxAsync(stream, leaveOpen, options, ct)), ExcelFileFormat.Xlsb => AsWorkbookAsync(FromXlsbAsync(stream, leaveOpen, options, ct)), ExcelFileFormat.Xls => AsWorkbookAsync(FromXlsAsync(stream, leaveOpen, options, ct)), @@ -577,7 +590,13 @@ public static ValueTask OpenAsync(Stream stream, ExcelFileFormat private static CsvReaderOptions CsvOf(ExcelReaderOptions? options) { - return options?.Csv ?? CsvReaderOptions.Default; + options ??= ExcelReaderOptions.Default; + CsvReaderOptions csv = options.Csv ?? CsvReaderOptions.Default; + return csv with + { + MaxCellBytes = csv.MaxCellBytesOverride ?? options.MaxCellBytes, + InternStrings = csv.InternStringsOverride ?? options.InternStrings, + }; } private static async ValueTask AsWorkbookAsync(ValueTask pending) diff --git a/src/ExcelReader.Core/Reader/ExcelReaderOptions.cs b/src/ExcelReader.Core/Reader/ExcelReaderOptions.cs index 574b6e44..4f2d0285 100644 --- a/src/ExcelReader.Core/Reader/ExcelReaderOptions.cs +++ b/src/ExcelReader.Core/Reader/ExcelReaderOptions.cs @@ -3,7 +3,7 @@ namespace ExcelReader.Core.Reader { /// - /// Resource limits and behavior toggles applied when opening and reading an Excel workbook (XLSX/XLSB/XLS). + /// Resource limits and behavior toggles applied when opening and reading a workbook or a CSV source. /// Exceeding a byte or entry-count limit throws . /// public sealed record ExcelReaderOptions @@ -15,7 +15,7 @@ public sealed record ExcelReaderOptions /// materialize. public long MaxTotalDecompressedBytes { get; init; } = 512L * 1024 * 1024; - /// Gets the maximum byte length allowed for a single cell's value. Defaults to 32 MiB. + /// Gets the maximum byte length allowed for a single cell's value, or a single buffered CSV record. Defaults to 32 MiB. public int MaxCellBytes { get; init; } = 32 * 1024 * 1024; /// Gets the maximum total byte size allowed for the shared string table. Defaults to 128 MiB. @@ -34,15 +34,15 @@ public sealed record ExcelReaderOptions public bool PrefetchDecompression { get; init; } /// - /// Gets a value indicating whether inline/formula-string text cells (cells not served by the - /// workbook's shared-string table) are deduplicated through a small content-keyed cache when - /// materialized via . Defaults to . + /// Gets a value indicating whether text cells not served by a shared-string table (inline and + /// formula-string cells, and every CSV field) are deduplicated through a small content-keyed cache + /// when materialized via . Defaults to . /// Has no effect on shared-string cells, which already dedup through the shared-string table's /// own index-keyed cache, nor on the zero-copy span path — - /// only GetString() consults this cache. Worth enabling only when inline/formula-string - /// cells are known to repeat; for mostly-unique text the per-call hashing cost has no dedup - /// benefit to offset it (see for measurements - /// against a real-world corpus — the risk is the same shape here). + /// only GetString() consults this cache. Worth enabling only for genuinely categorical + /// (low-cardinality, repeated) text; for high-cardinality data every lookup is a cache miss, so the + /// per-call hashing cost is pure overhead — measured roughly doubling wall-clock time on a + /// real-world, mixed-cardinality CSV corpus. /// public bool InternStrings { get; init; } @@ -77,11 +77,9 @@ public sealed record ExcelReaderOptions /// Gets the delimited-text settings used when a source is opened as /// . Defaults to , meaning /// . - /// Lets one options object carry both format families, so a caller that does not know - /// which one it will get — , - /// or the xl_open_options struct the native ABI passes through — configures both in one - /// place. The other properties on this record cover the Excel formats only and are ignored on the - /// CSV path, as this one is on every other path. + /// Ignored for the Excel formats. On the CSV path, and + /// still apply; the ZIP, shared-string, encryption and prefetch + /// settings have nothing to act on. public CsvReaderOptions? Csv { get; init; } /// Gets the default options instance, used whenever a reader is opened without explicit options. diff --git a/src/ExcelReader.Native/Csv/CsvAggregateApi.cs b/src/ExcelReader.Native/Csv/CsvAggregateApi.cs index 213e9a13..6e56156b 100644 --- a/src/ExcelReader.Native/Csv/CsvAggregateApi.cs +++ b/src/ExcelReader.Native/Csv/CsvAggregateApi.cs @@ -92,7 +92,7 @@ internal static int AggregateCsvMemory( { using PointerMemoryManager manager = new(data, dataLength); CsvAggregateContext context = new(aggregation); - using CsvReader csv = Excel.FromCsv(manager.Memory, reader); + using CsvReader csv = Excel.OpenCsv(manager.Memory, reader); return Run(context, Task.Run(() => SheetParallel.AggregateAsync(csv.FirstSheet, Build(context), options, CancellationToken.None)), out result); } catch (Exception exception) @@ -184,7 +184,7 @@ internal static class CsvAggregateFile internal static async Task AggregateAsync( string path, CsvReaderOptions reader, RowAggregation aggregation, SheetParallelOptions options) { - CsvReader csv = await Excel.FromCsvFileAsync(path, reader, CancellationToken.None).ConfigureAwait(false); + CsvReader csv = await Excel.OpenCsvFileAsync(path, reader, CancellationToken.None).ConfigureAwait(false); await using (csv.ConfigureAwait(false)) { return await SheetParallel.AggregateAsync(csv.FirstSheet, aggregation, options, CancellationToken.None).ConfigureAwait(false); diff --git a/src/ExcelReader.Native/NativeOpenOptions.cs b/src/ExcelReader.Native/NativeOpenOptions.cs index bf7e0d8c..5728fe5a 100644 --- a/src/ExcelReader.Native/NativeOpenOptions.cs +++ b/src/ExcelReader.Native/NativeOpenOptions.cs @@ -144,7 +144,7 @@ internal CsvReaderOptions ToCsvReaderOptions() { options = options with { InternStrings = internStrings }; } - return options; + return options with { MaxCellBytesOverride = options.MaxCellBytes, InternStringsOverride = options.InternStrings }; } internal ExcelReaderOptions ToExcelReaderOptions() diff --git a/src/ExcelReader.Native/Reading/ReadApi.Sources.cs b/src/ExcelReader.Native/Reading/ReadApi.Sources.cs index a7d51e28..c08e7a60 100644 --- a/src/ExcelReader.Native/Reading/ReadApi.Sources.cs +++ b/src/ExcelReader.Native/Reading/ReadApi.Sources.cs @@ -1,4 +1,5 @@ using ExcelReader.Core.Reader; +using ExcelReader.Core.Reader.Csv; using ExcelReader.Core.Reader.Sources; using ExcelReader.Core.Reader.Xls; @@ -74,7 +75,7 @@ internal static int OpenStream(CallbackReadStream stream, int format, NativeOpen IExcelWorkbook workbook; if (format == NativeFormat.Csv || (format == NativeFormat.Auto && !IsWorkbookSignature(stream.Peek(8)))) { - workbook = Excel.FromCsv(stream, leaveOpen: false, readerOptions.Csv); + workbook = Excel.OpenCsv(stream, leaveOpen: false, readerOptions.Csv ?? CsvReaderOptions.Default); } else { diff --git a/tests/ExcelReader.Fuzz/Harnesses.cs b/tests/ExcelReader.Fuzz/Harnesses.cs index 81f1fdb8..5f97c322 100644 --- a/tests/ExcelReader.Fuzz/Harnesses.cs +++ b/tests/ExcelReader.Fuzz/Harnesses.cs @@ -315,7 +315,7 @@ internal static void Csv(ReadOnlySpan data) FuzzOracle.Guard(() => { using var ms = new MemoryStream(bytes, writable: false); - using CsvReader reader = Excel.FromCsv(ms, leaveOpen: true, CsvLimits); + using CsvReader reader = Excel.FromCsv(ms, leaveOpen: true, Limits); DrainRows(reader.FirstSheet); }); } @@ -327,7 +327,7 @@ internal static void CsvSniff(ReadOnlySpan data) { CsvDialect dialect = CsvSniffer.Detect(bytes); using var ms = new MemoryStream(bytes, writable: false); - using CsvReader reader = Excel.FromCsv(ms, leaveOpen: true, CsvLimits.WithDialect(dialect)); + using CsvReader reader = Excel.FromCsv(ms, leaveOpen: true, Limits with { Csv = CsvReaderOptions.Default.WithDialect(dialect) }); DrainRows(reader.FirstSheet); }); } @@ -396,7 +396,7 @@ internal static void CsvParallel(ReadOnlySpan data) private static List ParseSequential(byte[] bytes) { - using CsvReader reader = Excel.FromCsv(bytes, CsvLimits); + using CsvReader reader = Excel.FromCsv(bytes, Limits); var rows = new List(); foreach (FuzzRow row in ExcelParser.FromAttributes().Parse(reader.FirstSheet)) { diff --git a/tests/ExcelReader.Tests/Native/CsvAggregateNativeTests.cs b/tests/ExcelReader.Tests/Native/CsvAggregateNativeTests.cs index eb3f5c86..2d55a3a5 100644 --- a/tests/ExcelReader.Tests/Native/CsvAggregateNativeTests.cs +++ b/tests/ExcelReader.Tests/Native/CsvAggregateNativeTests.cs @@ -136,7 +136,7 @@ public void AggregateCsvMemory_Should_Clear_A_Previous_Error_On_Success() private static Row FirstRowOf(string csv) { - CsvReader reader = Excel.FromCsv(Encoding.UTF8.GetBytes(csv), CsvReaderOptions.Default); + CsvReader reader = Excel.FromCsv(Encoding.UTF8.GetBytes(csv), new ExcelReaderOptions { Csv = CsvReaderOptions.Default }); CsvReader.Enumerator rows = reader.FirstSheet.GetEnumerator(); Assert.True(rows.MoveNext()); return rows.Current; diff --git a/tests/ExcelReader.Tests/Parser/ParallelCsv/ParseCsvParallelTests.cs b/tests/ExcelReader.Tests/Parser/ParallelCsv/ParseCsvParallelTests.cs index 960740c3..47f82a13 100644 --- a/tests/ExcelReader.Tests/Parser/ParallelCsv/ParseCsvParallelTests.cs +++ b/tests/ExcelReader.Tests/Parser/ParallelCsv/ParseCsvParallelTests.cs @@ -96,7 +96,7 @@ public async Task ANonUtf8EncodingFallsBackAndStillDecodesCorrectly() byte[] csv = latin1.GetBytes("Name,Age\nJosé,30\nRenée,41\n"); var options = new CsvReaderOptions { Encoding = latin1 }; - using CsvReader source = Excel.FromCsv(csv, options); + using CsvReader source = Excel.FromCsv(csv, new ExcelReaderOptions { Csv = options }); List actual = await DrainAsync( SheetParallel.ParseAsync(source.FirstSheet, ExcelParser.FromAttributes(), options: new SheetParallelOptions { DegreeOfParallelism = 8 }, ct: TestContext.Current.CancellationToken)); diff --git a/tests/ExcelReader.Tests/Reader/Csv/CsvBlockBoundaryTests.cs b/tests/ExcelReader.Tests/Reader/Csv/CsvBlockBoundaryTests.cs index 28d22f04..f09a02ec 100644 --- a/tests/ExcelReader.Tests/Reader/Csv/CsvBlockBoundaryTests.cs +++ b/tests/ExcelReader.Tests/Reader/Csv/CsvBlockBoundaryTests.cs @@ -167,10 +167,10 @@ public void QuoteEqualToDelimiterIsRejectedByBothSourcesAlike() var options = CsvReaderOptions.Default with { Delimiter = (byte)',', Quote = (byte)',' }; byte[] bytes = Encoding.UTF8.GetBytes("a,,b,c\n,d,\ne"); - Assert.Throws(() => Excel.FromCsv(bytes, options)); + Assert.Throws(() => Excel.FromCsv(bytes, new ExcelReaderOptions { Csv = options })); using var ms = new MemoryStream(bytes); - Assert.Throws(() => Excel.FromCsv(ms, options: options)); + Assert.Throws(() => Excel.FromCsv(ms, options: new ExcelReaderOptions { Csv = options })); } } } diff --git a/tests/ExcelReader.Tests/Reader/Csv/CsvDialectAndFormatTests.cs b/tests/ExcelReader.Tests/Reader/Csv/CsvDialectAndFormatTests.cs index c8f8f657..39e20d54 100644 --- a/tests/ExcelReader.Tests/Reader/Csv/CsvDialectAndFormatTests.cs +++ b/tests/ExcelReader.Tests/Reader/Csv/CsvDialectAndFormatTests.cs @@ -135,7 +135,7 @@ public void Should_InferDelimiter_When_SniffDialectIsSet() string path = WriteTemp("Name;City;Age\nAda;London;36\n"); try { - using CsvReader reader = Excel.FromCsvFile(path, CsvReaderOptions.Default with { SniffDialect = true }); + using CsvReader reader = Excel.FromCsvFile(path, new ExcelReaderOptions { Csv = CsvReaderOptions.Default with { SniffDialect = true } }); Assert.Equal(["Ada", "London", "36"], ReadAll(reader.FirstSheet)[1]); } @@ -152,7 +152,7 @@ public void Should_KeepExplicitEncoding_When_SniffingFindsNoByteOrderMark() try { var options = CsvReaderOptions.Default with { SniffDialect = true, Encoding = Encoding.Latin1 }; - using CsvReader reader = Excel.FromCsvFile(path, options); + using CsvReader reader = Excel.FromCsvFile(path, new ExcelReaderOptions { Csv = options }); Assert.Equal(["José", "São Paulo"], ReadAll(reader.FirstSheet)[1]); } @@ -174,7 +174,7 @@ public async Task Should_InferDelimiter_When_SniffingOnTheParallelPath() try { var options = new SheetParallelOptions { DegreeOfParallelism = 4 }; - using CsvReader reader = Excel.FromCsvFile(path, CsvReaderOptions.Default with { SniffDialect = true }); + using CsvReader reader = Excel.FromCsvFile(path, new ExcelReaderOptions { Csv = CsvReaderOptions.Default with { SniffDialect = true } }); var rows = new List(); await foreach (Person person in SheetParallel.ParseAsync(reader.FirstSheet, ExcelParser.FromAttributes(), options, ct: TestContext.Current.CancellationToken)) diff --git a/tests/ExcelReader.Tests/Reader/Csv/CsvLifetimeTests.cs b/tests/ExcelReader.Tests/Reader/Csv/CsvLifetimeTests.cs index 551122f0..42a986c9 100644 --- a/tests/ExcelReader.Tests/Reader/Csv/CsvLifetimeTests.cs +++ b/tests/ExcelReader.Tests/Reader/Csv/CsvLifetimeTests.cs @@ -127,7 +127,7 @@ public void A_Transcoded_Stream_Serves_Two_Enumerators() List expected = Expected(); using CsvReader reader = Excel.FromCsv( new TrickleStream(BuildCsv(Encoding.Unicode)), leaveOpen: false, - new CsvReaderOptions { Encoding = Encoding.Unicode, DetectEncodingFromByteOrderMark = false }); + new ExcelReaderOptions { Csv = new CsvReaderOptions { Encoding = Encoding.Unicode, DetectEncodingFromByteOrderMark = false } }); using CsvReader.Enumerator first = reader.FirstSheet.GetEnumerator(); using CsvReader.Enumerator second = reader.FirstSheet.GetEnumerator(); Assert.Equal(expected, Drain(first), StringComparer.Ordinal); @@ -213,7 +213,7 @@ public void A_Transcoded_File_Offers_No_Raw_Chunk_Source() File.WriteAllBytes(path, BuildCsv(Encoding.Unicode)); _paths.Add(path); - using CsvReader utf16 = Excel.FromCsvFile(path, new CsvReaderOptions { Encoding = Encoding.Unicode, DetectEncodingFromByteOrderMark = false }); + using CsvReader utf16 = Excel.FromCsvFile(path, new ExcelReaderOptions { Csv = new CsvReaderOptions { Encoding = Encoding.Unicode, DetectEncodingFromByteOrderMark = false } }); Assert.False(utf16.TryGetChunkSource(out _)); using CsvReader utf8 = Excel.FromCsvFile(path); diff --git a/tests/ExcelReader.Tests/Reader/Csv/CsvReaderTests.cs b/tests/ExcelReader.Tests/Reader/Csv/CsvReaderTests.cs index 74739c0b..771f6e33 100644 --- a/tests/ExcelReader.Tests/Reader/Csv/CsvReaderTests.cs +++ b/tests/ExcelReader.Tests/Reader/Csv/CsvReaderTests.cs @@ -292,24 +292,32 @@ public async Task QuotedFieldSplitAcrossBufferBoundaryIsHandledAsync() public void FieldExceedingMaxCellBytesThrows() { using var ms = Csv("a," + new string('x', 128 * 1024) + ",c\n"); - var options = new CsvReaderOptions { MaxCellBytes = 1024 }; - using var reader = Excel.FromCsv(ms, options: options); + using var reader = Excel.FromCsv(ms, options: new ExcelReaderOptions { MaxCellBytes = 1024 }); using CsvReader.Enumerator e = reader.FirstSheet.GetEnumerator(); ExcelLimitExceededException ex = Assert.Throws(() => e.MoveNext()); - Assert.Equal(nameof(CsvReaderOptions.MaxCellBytes), ex.LimitName); + Assert.Equal(nameof(ExcelReaderOptions.MaxCellBytes), ex.LimitName); + } + + [Fact] + public void OpenAppliesTheReaderLimitsToCsv() + { + using var ms = Csv("a," + new string('x', 128 * 1024) + ",c\n"); + using IExcelWorkbook reader = Excel.Open(ms, ExcelFileFormat.Csv, options: new ExcelReaderOptions { MaxCellBytes = 1024 }); + using IExcelRowEnumerator e = reader.FirstSheet.GetEnumerator(); + + Assert.Throws(() => e.MoveNext()); } [Fact] public void EmptyFieldsExceedingMaxCellBytesThrows() { using var ms = Csv(new string(',', 32)); - var options = new CsvReaderOptions { MaxCellBytes = 1024 }; - using var reader = Excel.FromCsv(ms, options: options); + using var reader = Excel.FromCsv(ms, options: new ExcelReaderOptions { MaxCellBytes = 1024 }); using CsvReader.Enumerator e = reader.FirstSheet.GetEnumerator(); ExcelLimitExceededException ex = Assert.Throws(() => e.MoveNext()); - Assert.Equal(nameof(CsvReaderOptions.MaxCellBytes), ex.LimitName); + Assert.Equal(nameof(ExcelReaderOptions.MaxCellBytes), ex.LimitName); } [Fact] @@ -317,7 +325,7 @@ public void SemicolonDelimiterIsRespected() { using var ms = Csv("a;b;c\n"); var options = new CsvReaderOptions { Delimiter = (byte)';' }; - using var reader = Excel.FromCsv(ms, options: options); + using var reader = Excel.FromCsv(ms, options: new ExcelReaderOptions { Csv = options }); var rows = ReadAll(reader); @@ -329,7 +337,7 @@ public void TabDelimiterIsRespected() { using var ms = Csv("a\tb\tc\n"); var options = new CsvReaderOptions { Delimiter = (byte)'\t' }; - using var reader = Excel.FromCsv(ms, options: options); + using var reader = Excel.FromCsv(ms, options: new ExcelReaderOptions { Csv = options }); var rows = ReadAll(reader); @@ -375,7 +383,7 @@ public void DelimiterEqualToQuoteThrows() using var ms = Csv("a,b\n"); var options = new CsvReaderOptions { Delimiter = (byte)'"' }; - Assert.Throws(() => Excel.FromCsv(ms, options: options)); + Assert.Throws(() => Excel.FromCsv(ms, options: new ExcelReaderOptions { Csv = options })); } [Fact] @@ -487,7 +495,7 @@ public void BomDetectionCanBeDisabled() byte[] body = Encoding.UTF8.GetBytes("a,b\n"); using var ms = new MemoryStream([.. bom, .. body]); var options = new CsvReaderOptions { DetectEncodingFromByteOrderMark = false }; - using var reader = Excel.FromCsv(ms, options: options); + using var reader = Excel.FromCsv(ms, options: new ExcelReaderOptions { Csv = options }); var rows = ReadAll(reader); @@ -501,7 +509,7 @@ public async Task BomDetectionCanBeDisabledAsync() byte[] body = Encoding.UTF8.GetBytes("a,b\n"); using var ms = new MemoryStream([.. bom, .. body]); var options = new CsvReaderOptions { DetectEncodingFromByteOrderMark = false }; - using var reader = Excel.FromCsv(ms, options: options); + using var reader = Excel.FromCsv(ms, options: new ExcelReaderOptions { Csv = options }); var rows = await ReadAllAsync(reader); @@ -514,7 +522,7 @@ public void NonUtf8EncodingIsTranscoded() byte[] latin1Bytes = Encoding.Latin1.GetBytes("nome,cidade\nGabriel,São Paulo\n"); using var ms = new MemoryStream(latin1Bytes); var options = new CsvReaderOptions { Encoding = Encoding.Latin1 }; - using var reader = Excel.FromCsv(ms, options: options); + using var reader = Excel.FromCsv(ms, options: new ExcelReaderOptions { Csv = options }); var rows = ReadAll(reader); @@ -529,7 +537,7 @@ public void DelimiterAsLineTerminatorThrows(byte delimiter) using var ms = Csv("a,b\n"); var options = new CsvReaderOptions { Delimiter = delimiter }; - Assert.Throws(() => Excel.FromCsv(ms, options: options)); + Assert.Throws(() => Excel.FromCsv(ms, options: new ExcelReaderOptions { Csv = options })); } [Theory] @@ -540,7 +548,7 @@ public void QuoteAsLineTerminatorThrows(byte quote) using var ms = Csv("a,b\n"); var options = new CsvReaderOptions { Quote = quote }; - Assert.Throws(() => Excel.FromCsv(ms, options: options)); + Assert.Throws(() => Excel.FromCsv(ms, options: new ExcelReaderOptions { Csv = options })); } diff --git a/tests/ExcelReader.Tests/Reader/Csv/CsvReplayTests.cs b/tests/ExcelReader.Tests/Reader/Csv/CsvReplayTests.cs index 4d7e5367..b67e0ffc 100644 --- a/tests/ExcelReader.Tests/Reader/Csv/CsvReplayTests.cs +++ b/tests/ExcelReader.Tests/Reader/Csv/CsvReplayTests.cs @@ -71,7 +71,7 @@ public void A_Transcoded_NonSeekable_Stream_Is_Replayed_Too() using NonSeekableStream stream = new(BuildCsv(rows, Encoding.Unicode)); using CsvReader reader = Excel.FromCsv( stream, leaveOpen: false, - new CsvReaderOptions { Encoding = Encoding.Unicode, DetectEncodingFromByteOrderMark = false }); + new ExcelReaderOptions { Csv = new CsvReaderOptions { Encoding = Encoding.Unicode, DetectEncodingFromByteOrderMark = false } }); Excel.InferSchema(reader.FirstSheet); diff --git a/tests/ExcelReader.Tests/Reader/Csv/CsvSnifferTests.cs b/tests/ExcelReader.Tests/Reader/Csv/CsvSnifferTests.cs index b4620e2a..6820038a 100644 --- a/tests/ExcelReader.Tests/Reader/Csv/CsvSnifferTests.cs +++ b/tests/ExcelReader.Tests/Reader/Csv/CsvSnifferTests.cs @@ -177,7 +177,7 @@ public void SniffedDialectRoundTripsThroughReader() byte[] data = Bytes("nome;idade\nAna;30\nBia;25\n"); CsvDialect dialect = CsvSniffer.Detect(data); CsvReaderOptions options = CsvReaderOptions.Default.WithDialect(dialect); - using var reader = Excel.FromCsv(data, options); + using var reader = Excel.FromCsv(data, new ExcelReaderOptions { Csv = options }); using CsvReader.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); diff --git a/tests/ExcelReader.Tests/Reader/FileOwnershipTests.cs b/tests/ExcelReader.Tests/Reader/FileOwnershipTests.cs index e0db4145..f680d5d7 100644 --- a/tests/ExcelReader.Tests/Reader/FileOwnershipTests.cs +++ b/tests/ExcelReader.Tests/Reader/FileOwnershipTests.cs @@ -79,10 +79,10 @@ private async Task Open(string entryPoint) Excel.FromXlsbFile(_path).Dispose(); break; case "FromCsvFile": - Excel.FromCsvFile(_path, DelimiterEqualsQuote).Dispose(); + Excel.FromCsvFile(_path, new ExcelReaderOptions { Csv = DelimiterEqualsQuote }).Dispose(); break; case "FromCsvFileAsync": - (await Excel.FromCsvFileAsync(_path, DelimiterEqualsQuote)).Dispose(); + (await Excel.FromCsvFileAsync(_path, new ExcelReaderOptions { Csv = DelimiterEqualsQuote })).Dispose(); break; default: (await Excel.FromCsvFileAsync(_path, ct: new CancellationToken(canceled: true))).Dispose(); diff --git a/tests/ExcelReader.Tests/Reader/Utf8StringCacheTests.cs b/tests/ExcelReader.Tests/Reader/Utf8StringCacheTests.cs index cf354554..6ed9cf32 100644 --- a/tests/ExcelReader.Tests/Reader/Utf8StringCacheTests.cs +++ b/tests/ExcelReader.Tests/Reader/Utf8StringCacheTests.cs @@ -95,9 +95,8 @@ public void CsvInternStringsDefaultsToFalse() [Fact] public void CsvInternStringsTrueEnablesDedupAcrossRows() { - var options = new CsvReaderOptions { InternStrings = true }; using MemoryStream ms = new(Encoding.UTF8.GetBytes("alpha,1\nalpha,2\nbeta,3\n")); - using CsvReader reader = Excel.FromCsv(ms, options: options); + using CsvReader reader = Excel.FromCsv(ms, options: new ExcelReaderOptions { InternStrings = true }); using CsvReader.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); diff --git a/tests/ExcelReader.Tests/Writer/Csv/CsvWriterOptionsTests.cs b/tests/ExcelReader.Tests/Writer/Csv/CsvWriterOptionsTests.cs index e5c17422..091eb792 100644 --- a/tests/ExcelReader.Tests/Writer/Csv/CsvWriterOptionsTests.cs +++ b/tests/ExcelReader.Tests/Writer/Csv/CsvWriterOptionsTests.cs @@ -80,7 +80,7 @@ public void Should_RoundTripThroughTheReader_When_BothAreGivenTheSameDialect() }); var options = CsvReaderOptions.Default with { Delimiter = (byte)';', Encoding = Encoding.Latin1 }; - using CsvReader reader = Excel.FromCsv(written.AsMemory(), options); + using CsvReader reader = Excel.FromCsv(written.AsMemory(), new ExcelReaderOptions { Csv = options }); using CsvReader.Enumerator rows = reader.FirstSheet.GetEnumerator(); Assert.True(rows.MoveNext()); diff --git a/tests/ExcelReader.Tests/Writer/Csv/CsvWriterTests.cs b/tests/ExcelReader.Tests/Writer/Csv/CsvWriterTests.cs index 63e027f1..dc608a88 100644 --- a/tests/ExcelReader.Tests/Writer/Csv/CsvWriterTests.cs +++ b/tests/ExcelReader.Tests/Writer/Csv/CsvWriterTests.cs @@ -246,7 +246,7 @@ public void NumericFieldContainingQuoteByteIsEscaped() } ms.Position = 0; - using IExcelWorkbook reader = Excel.FromCsv(ms, options: new CsvReaderOptions { Quote = (byte)'5' }); + using IExcelWorkbook reader = Excel.FromCsv(ms, options: new ExcelReaderOptions { Csv = new CsvReaderOptions { Quote = (byte)'5' } }); using IExcelRowEnumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("155", e.Current[0].GetString()); From 3f9fff875d7c3a7a320c5a4667db736a7f2a92ac Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Sun, 11 Oct 2026 01:42:05 -0300 Subject: [PATCH 089/115] docs: list the v7 options and converter-context changes in the migration guide --- docs/guide/migrating-to-v7.md | 48 +++++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/docs/guide/migrating-to-v7.md b/docs/guide/migrating-to-v7.md index 32716cfe..7e542feb 100644 --- a/docs/guide/migrating-to-v7.md +++ b/docs/guide/migrating-to-v7.md @@ -1,6 +1,6 @@ # Migrating from v6 to v7 -What changed in parallel reading and how to port v6 code. +What changed in parallel reading and in the rest of the public API, and how to port v6 code. See also [reading.md](reading.md#parallel-reading-opt-in) for the full guide. ## What changed @@ -32,7 +32,7 @@ await foreach (Sale s in CsvParallel.ParseAsync(path, parser, new CsvParallelOpt } // v7 -using CsvReader csv = Excel.FromCsvFile(path, options); +using CsvReader csv = Excel.FromCsvFile(path, new ExcelReaderOptions { Csv = options }); await foreach (Sale s in SheetParallel.ParseAsync(csv.FirstSheet, parser, new SheetParallelOptions { DegreeOfParallelism = 8 })) { Total += s.Revenue; @@ -49,7 +49,7 @@ await foreach (Sale s in CsvParallel.ParseAsync(data, parser, new CsvParallelOpt } // v7 -using CsvReader csv = Excel.FromCsv(data, options); +using CsvReader csv = Excel.FromCsv(data, new ExcelReaderOptions { Csv = options }); await foreach (Sale s in SheetParallel.ParseAsync(csv.FirstSheet, parser, new SheetParallelOptions { DegreeOfParallelism = 8 })) { Total += s.Revenue; @@ -66,7 +66,7 @@ await foreach (Sale s in CsvParallel.ParseAsync(stream, parser, new CsvParallelO } // v7 -using CsvReader csv = Excel.FromCsv(stream, leaveOpen: true, options); +using CsvReader csv = Excel.FromCsv(stream, leaveOpen: true, new ExcelReaderOptions { Csv = options }); await foreach (Sale s in SheetParallel.ParseAsync(csv.FirstSheet, parser, new SheetParallelOptions { DegreeOfParallelism = 8 })) { Total += s.Revenue; @@ -86,14 +86,52 @@ await foreach (Sale s in SheetParallel.ParseAsync(workbook.FirstSheet, parser, n ## Behavior changes - The caller owns the workbook and keeps it open until the task completes or the enumeration ends. v6 opened and closed its own source. -- The CSV dialect is given when the workbook is opened (`CsvReaderOptions`), not in the parallel options. +- The CSV dialect is given when the workbook is opened (`ExcelReaderOptions.Csv`), not in the parallel options. - A CSV in a `MemoryStream` is no longer partitioned: the stream overload of `Excel.FromCsv` reads it sequentially. Pass the bytes as `ReadOnlyMemory` to keep partitioning. - `HeaderRow` rules are the same as v6. `ParseAsync` takes the header from `ExcelParserOptions.HeaderRow` and rejects a non-zero `SheetParallelOptions.HeaderRow`; the parser-based `ForEachAsync` and `AggregateAsync` use `SheetParallelOptions.HeaderRow`. - XLSX and XLSB sheets are now read on several threads when `DegreeOfParallelism` allows it. XLS, a table view (`ExcelTable.AsSheet()`), a sheet under 256 KB uncompressed and a CSV that cannot be partitioned still read sequentially with the same results. - `PrefetchDecompression` does not apply to a parallel XLSX or XLSB read; the pipeline already overlaps inflate and parse. +## Other breaking changes + +These changes replace positional parameters and loose arguments with types that can grow without another break. + +| v6 | v7 | +|---|---| +| `ExcelParserConfig` | `ExcelParserOptions`; the `ExcelParser` factories' parameter is `options` | +| `IExcelCellConverter.TryConvert(in Cell, bool isDate1904, IFormatProvider provider, out T)` | `TryConvert(in Cell, in ExcelRowContext context, out T)` | +| `ExcelCellReader`, `ExcelRowParser`, `ExcelCellReaders.*`: `(…, bool isDate1904, IFormatProvider provider, …)` | `(…, in ExcelRowContext context, …)` | +| `Excel.InferSchema(sheet, headerRow, sampleSize, parseText)` | `Excel.InferSchema(sheet, new SchemaInferenceOptions { HeaderRow = …, SampleSize = …, ParseText = … })` | +| `new ExcelDataReader(sheet, headerRow)` | `new ExcelDataReader(sheet, new ExcelDataReaderOptions { HeaderRow = … })` | +| `sheet.ToArrowRecordBatch(schema, headerRow)` | `sheet.ToArrowRecordBatch(new ArrowReadOptions { Schema = schema, HeaderRow = … })` | +| `WriteRecordBatch(batch, sheetName, writeHeader)` and the async form | `WriteRecordBatch(batch, new ArrowWriteOptions { SheetName = …, WriteHeader = … })` | +| `XlsWorkbookWriter.Create(stream, leaveOpen, date1904)` | `XlsWorkbookWriter.Create(stream, leaveOpen, new XlsWriterOptions { Date1904 = … })` | +| `Excel.FromCsv*(…, CsvReaderOptions options)` | `Excel.FromCsv*(…, new ExcelReaderOptions { Csv = options })` | +| `CsvReaderOptions.MaxCellBytes`, `CsvReaderOptions.InternStrings` | `ExcelReaderOptions.MaxCellBytes`, `ExcelReaderOptions.InternStrings` | + +A converter reads the date system and culture from the context: + +```csharp +// v6 +public bool TryConvert(in Cell cell, bool isDate1904, IFormatProvider provider, out decimal value) + => decimal.TryParse(cell.GetString(), NumberStyles.Currency, provider, out value); + +// v7 +public bool TryConvert(in Cell cell, in ExcelRowContext context, out decimal value) + => decimal.TryParse(cell.GetString(), NumberStyles.Currency, context.FormatProvider, out value); +``` + +To unit-test a converter, build the context with an initializer: `new ExcelRowContext { IsDate1904 = false, FormatProvider = CultureInfo.InvariantCulture }`. + +Two behavior changes come with them: + +- `Excel.Open` with `ExcelFileFormat.Csv` now applies `ExcelReaderOptions.MaxCellBytes` and `InternStrings`. v6 ignored both and used the CSV defaults. +- `XlsxWriterOptions` gains `Date1904`, which v6 lacked. It defaults to `false`, so existing output is unchanged. + ## Native bindings The C ABI is unchanged: no signature moved, and `XL_ABI_VERSION` stays 6. Behavior changed. `degree_of_parallelism` of `0` on `xl_parse_typed` and the Arrow exports now parallelises XLSX and XLSB as well as CSV, using every processor. Pass `1` for the old sequential behavior. The Rust, Python and C++ bindings take the same value (`degree_of_parallelism`, `parallelism`) and need no code change; a `parallelism=0` call on an XLSX or XLSB sheet is now faster, and holds every partition's columns until they merge, so its peak memory is higher. +`xl_write_options.date1904` now applies to XLSX as well as XLSB and XLS; v6 ignored it for XLSX. + `xl_csv_aggregate` keeps its signature. Inside, it opens a `CsvReader` with the dialect from the native struct and calls `SheetParallel`. From 1cc50aeed12f44c8476578feab9792a150cf67cb Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Sun, 11 Oct 2026 01:48:29 -0300 Subject: [PATCH 090/115] refactor!: return the inferred schema as IReadOnlyList Matches ArrowReadOptions.Schema and stops handing callers a mutable array. --- docs/guide/migrating-to-v7.md | 2 +- src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt | 2 +- src/ExcelReader.Core/Reader/Excel.cs | 2 +- tests/ExcelReader.Tests/Arrow/ArrowConversionTests.cs | 8 ++++---- tests/ExcelReader.Tests/Reader/Csv/CsvReplayTests.cs | 2 +- tests/ExcelReader.Tests/Reader/SchemaInferenceTests.cs | 10 +++++----- tests/ExcelReader.Tests/Reader/SheetConsumerTests.cs | 6 +++--- .../Reader/Tables/TableBindingTests.cs | 4 ++-- 8 files changed, 18 insertions(+), 18 deletions(-) diff --git a/docs/guide/migrating-to-v7.md b/docs/guide/migrating-to-v7.md index 7e542feb..1149878b 100644 --- a/docs/guide/migrating-to-v7.md +++ b/docs/guide/migrating-to-v7.md @@ -101,7 +101,7 @@ These changes replace positional parameters and loose arguments with types that | `ExcelParserConfig` | `ExcelParserOptions`; the `ExcelParser` factories' parameter is `options` | | `IExcelCellConverter.TryConvert(in Cell, bool isDate1904, IFormatProvider provider, out T)` | `TryConvert(in Cell, in ExcelRowContext context, out T)` | | `ExcelCellReader`, `ExcelRowParser`, `ExcelCellReaders.*`: `(…, bool isDate1904, IFormatProvider provider, …)` | `(…, in ExcelRowContext context, …)` | -| `Excel.InferSchema(sheet, headerRow, sampleSize, parseText)` | `Excel.InferSchema(sheet, new SchemaInferenceOptions { HeaderRow = …, SampleSize = …, ParseText = … })` | +| `Excel.InferSchema(sheet, headerRow, sampleSize, parseText)` returning `ExcelColumnSchema[]` | `Excel.InferSchema(sheet, new SchemaInferenceOptions { HeaderRow = …, SampleSize = …, ParseText = … })` returning `IReadOnlyList` | | `new ExcelDataReader(sheet, headerRow)` | `new ExcelDataReader(sheet, new ExcelDataReaderOptions { HeaderRow = … })` | | `sheet.ToArrowRecordBatch(schema, headerRow)` | `sheet.ToArrowRecordBatch(new ArrowReadOptions { Schema = schema, HeaderRow = … })` | | `WriteRecordBatch(batch, sheetName, writeHeader)` and the async form | `WriteRecordBatch(batch, new ArrowWriteOptions { SheetName = …, WriteHeader = … })` | diff --git a/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt b/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt index 71ce2edc..c0fabc56 100644 --- a/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt @@ -83,7 +83,6 @@ ExcelReader.Core.Reader.Schema.SchemaInferenceOptions.ParseText.init -> void ExcelReader.Core.Reader.Schema.SchemaInferenceOptions.SampleSize.get -> int ExcelReader.Core.Reader.Schema.SchemaInferenceOptions.SampleSize.init -> void ExcelReader.Core.Reader.Schema.SchemaInferenceOptions.SchemaInferenceOptions() -> void -static ExcelReader.Core.Reader.Excel.InferSchema(ExcelReader.Core.Reader.IExcelSheet! sheet, ExcelReader.Core.Reader.Schema.SchemaInferenceOptions? options = null) -> ExcelReader.Core.Reader.Schema.ExcelColumnSchema[]! static ExcelReader.Core.Reader.ExcelDataReaderOptions.Default.get -> ExcelReader.Core.Reader.ExcelDataReaderOptions! static ExcelReader.Core.Reader.Schema.SchemaInferenceOptions.Default.get -> ExcelReader.Core.Reader.Schema.SchemaInferenceOptions! ExcelReader.Core.Writer.Xls.XlsWriterOptions @@ -106,3 +105,4 @@ static ExcelReader.Core.Reader.Excel.FromCsv(System.ReadOnlyMemory data, E static ExcelReader.Core.Reader.Excel.FromCsvAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static ExcelReader.Core.Reader.Excel.FromCsvFile(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Csv.CsvReader! static ExcelReader.Core.Reader.Excel.FromCsvFileAsync(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Core.Reader.Excel.InferSchema(ExcelReader.Core.Reader.IExcelSheet! sheet, ExcelReader.Core.Reader.Schema.SchemaInferenceOptions? options = null) -> System.Collections.Generic.IReadOnlyList! diff --git a/src/ExcelReader.Core/Reader/Excel.cs b/src/ExcelReader.Core/Reader/Excel.cs index ccfca244..76ebff13 100644 --- a/src/ExcelReader.Core/Reader/Excel.cs +++ b/src/ExcelReader.Core/Reader/Excel.cs @@ -294,7 +294,7 @@ public static async ValueTask FromCsvAsync(Stream stream, bool leaveO /// is not positive. /// The sheet has fewer rows than . /// is positive and is a table view () of a table without a header row. - public static ExcelColumnSchema[] InferSchema(IExcelSheet sheet, SchemaInferenceOptions? options = null) + public static IReadOnlyList InferSchema(IExcelSheet sheet, SchemaInferenceOptions? options = null) { ArgumentNullException.ThrowIfNull(sheet); options ??= SchemaInferenceOptions.Default; diff --git a/tests/ExcelReader.Tests/Arrow/ArrowConversionTests.cs b/tests/ExcelReader.Tests/Arrow/ArrowConversionTests.cs index 07ef7aa8..068b20ba 100644 --- a/tests/ExcelReader.Tests/Arrow/ArrowConversionTests.cs +++ b/tests/ExcelReader.Tests/Arrow/ArrowConversionTests.cs @@ -227,14 +227,14 @@ public void ToArrowRecordBatch_Should_Match_The_Native_Arrow_Path_On_RealExcel() string path = Path.Combine(AppContext.BaseDirectory, "data", "RealExcel.xlsb"); using IExcelWorkbook managedReader = Excel.Open(path); - ExcelColumnSchema[] schema = Excel.InferSchema(managedReader.FirstSheet); + IReadOnlyList schema = Excel.InferSchema(managedReader.FirstSheet); RecordBatch managedBatch = managedReader.FirstSheet.ToArrowRecordBatch(new ArrowReadOptions { Schema = schema, HeaderRow = 1 }); Assert.Equal(NativeStatus.Ok, NativeApiTests.OpenPath(path, NativeFormat.Xlsb, out NativeHandle? handle)); try { - NativeColumnSpec[] specs = new NativeColumnSpec[schema.Length]; - for (int i = 0; i < schema.Length; i++) + NativeColumnSpec[] specs = new NativeColumnSpec[schema.Count]; + for (int i = 0; i < schema.Count; i++) { specs[i] = new NativeColumnSpec { @@ -249,7 +249,7 @@ public void ToArrowRecordBatch_Should_Match_The_Native_Arrow_Path_On_RealExcel() try { Assert.Equal(managedBatch.Length, nativeArray.Length); - for (int i = 0; i < schema.Length; i++) + for (int i = 0; i < schema.Count; i++) { ArrowArray nativeColumn = NativeApiTests.ArrowChildArray(nativeArray, i); Assert.Equal(nativeColumn.Length, managedBatch.Column(i).Length); diff --git a/tests/ExcelReader.Tests/Reader/Csv/CsvReplayTests.cs b/tests/ExcelReader.Tests/Reader/Csv/CsvReplayTests.cs index b67e0ffc..e68f5592 100644 --- a/tests/ExcelReader.Tests/Reader/Csv/CsvReplayTests.cs +++ b/tests/ExcelReader.Tests/Reader/Csv/CsvReplayTests.cs @@ -44,7 +44,7 @@ public void A_Schema_Inferred_From_A_NonSeekable_Stream_Leaves_Every_Row_To_Read using NonSeekableStream stream = new(BuildCsv(rows)); using CsvReader reader = Excel.FromCsv(stream); - ExcelColumnSchema[] schema = Excel.InferSchema(reader.FirstSheet, new SchemaInferenceOptions { ParseText = true }); + IReadOnlyList schema = Excel.InferSchema(reader.FirstSheet, new SchemaInferenceOptions { ParseText = true }); Assert.Equal(["name", "value"], schema.Select(c => c.Name), StringComparer.Ordinal); Assert.Equal(ExcelColumnType.Int64Column, schema[1].Type); diff --git a/tests/ExcelReader.Tests/Reader/SchemaInferenceTests.cs b/tests/ExcelReader.Tests/Reader/SchemaInferenceTests.cs index 3ba9cda7..d9ca2f9e 100644 --- a/tests/ExcelReader.Tests/Reader/SchemaInferenceTests.cs +++ b/tests/ExcelReader.Tests/Reader/SchemaInferenceTests.cs @@ -45,9 +45,9 @@ private static ExcelColumnSchema[] InferFromXlsx(string sheetRows, int headerRow return SchemaInference.Infer(rows, isDate1904: false, headerRow, sampleSize, parseText); } - private static ExcelColumnType[] TypesOf(ExcelColumnSchema[] schema) + private static ExcelColumnType[] TypesOf(IReadOnlyList schema) { - return Array.ConvertAll(schema, column => column.Type); + return [.. schema.Select(column => column.Type)]; } private static string FixtureCsv() @@ -67,7 +67,7 @@ public void InferSchema_Should_Type_The_Fixture_Csv_When_Parsing_Text() { using CsvReader reader = Excel.FromCsvFile(FixtureCsv()); - ExcelColumnSchema[] schema = Excel.InferSchema(reader.FirstSheet, new SchemaInferenceOptions { ParseText = true }); + IReadOnlyList schema = Excel.InferSchema(reader.FirstSheet, new SchemaInferenceOptions { ParseText = true }); ExcelColumnType s = ExcelColumnType.StringColumn, d = ExcelColumnType.DateColumn, i = ExcelColumnType.Int64Column, f = ExcelColumnType.Float64Column; @@ -292,11 +292,11 @@ public void Should_SampleAtMostSampleSizeRows_When_TheSheetIsLonger() public void Should_InferAcrossEveryFormat_When_CalledThroughTheExcelFacade() { using IExcelWorkbook reader = Excel.Open(Path.Combine("data", "RealExcel.xlsb")); - ExcelColumnSchema[] schema = Excel.InferSchema(reader.FirstSheet); + IReadOnlyList schema = Excel.InferSchema(reader.FirstSheet); Assert.NotEmpty(schema); Assert.All(schema, column => Assert.True(column.Index >= 0)); - for (int i = 0; i < schema.Length; i++) + for (int i = 0; i < schema.Count; i++) { Assert.Equal(i, schema[i].Index); } diff --git a/tests/ExcelReader.Tests/Reader/SheetConsumerTests.cs b/tests/ExcelReader.Tests/Reader/SheetConsumerTests.cs index caee015e..28a9c550 100644 --- a/tests/ExcelReader.Tests/Reader/SheetConsumerTests.cs +++ b/tests/ExcelReader.Tests/Reader/SheetConsumerTests.cs @@ -158,11 +158,11 @@ public void One_Parse_Result_Can_Be_Enumerated_Twice_And_Sheets_Stay_Independent public void InferSchema_Samples_The_Sheet_It_Is_Given() { using XlsxWorkbook workbook = Excel.FromXlsx(BuildXlsx()); - ExcelColumnSchema[] schema = Excel.InferSchema(workbook.Sheets[1]); - Assert.Equal(2, schema.Length); + IReadOnlyList schema = Excel.InferSchema(workbook.Sheets[1]); + Assert.Equal(2, schema.Count); Assert.Equal("Name", schema[0].Name); Assert.Equal(ExcelColumnType.Int64Column, schema[1].Type); - Assert.Equal(schema.Length, Excel.InferSchema(workbook.Sheets[1], new SchemaInferenceOptions { SampleSize = 5 }).Length); + Assert.Equal(schema.Count, Excel.InferSchema(workbook.Sheets[1], new SchemaInferenceOptions { SampleSize = 5 }).Count); Assert.Throws(() => Excel.InferSchema((IExcelSheet)null!)); } diff --git a/tests/ExcelReader.Tests/Reader/Tables/TableBindingTests.cs b/tests/ExcelReader.Tests/Reader/Tables/TableBindingTests.cs index 633b4ba8..e4fa5500 100644 --- a/tests/ExcelReader.Tests/Reader/Tables/TableBindingTests.cs +++ b/tests/ExcelReader.Tests/Reader/Tables/TableBindingTests.cs @@ -90,7 +90,7 @@ public void HeaderlessTableReadsWithoutAHeader(string file) } Assert.Equal(["a", "b", "c"], letters); - Assert.Equal(2, Excel.InferSchema(view, new SchemaInferenceOptions { HeaderRow = 0 }).Length); + Assert.Equal(2, Excel.InferSchema(view, new SchemaInferenceOptions { HeaderRow = 0 }).Count); } [Theory] @@ -130,7 +130,7 @@ public void InferSchemaDropsColumnsLeftOfTheTableAndKeepsAbsoluteIndexes(string { using IExcelWorkbook workbook = Excel.Open(TableMetadataTests.FixturePath(file)); - ExcelColumnSchema[] schema = Excel.InferSchema(Table(workbook, "Sales").AsSheet()); + IReadOnlyList schema = Excel.InferSchema(Table(workbook, "Sales").AsSheet()); Assert.Equal([("Product", 2), ("Qty", 3), ("Price", 4)], schema.Select(static c => (c.Name, c.Index))); } From b2891ff190ace99e15a3f8f04bb50980d398ae06 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Sun, 11 Oct 2026 01:50:30 -0300 Subject: [PATCH 091/115] refactor!: remove Cell.TryGetDateTime(out DateTime), which assumed the 1900 date system A cell does not know its workbook's date system, so the overload silently returned dates four years early for a 1904 workbook. Callers now pass IsDate1904. The CLI convert command had exactly that bug and now passes the sheet's date system. --- docs/guide/migrating-to-v7.md | 2 + src/ExcelReader.Cli/CliCommands.cs | 2 +- .../PublicAPI/PublicAPI.Shipped.txt | 1 - src/ExcelReader.Core/Reader/Cell.cs | 12 ------ .../Read/CsvReadBenchmark.cs | 2 +- .../Shared/BenchmarkAccumulators.cs | 4 +- tests/ExcelReader.Tests/Cli/CliTests.cs | 39 +++++++++++++++++++ .../Reader/CellVariantTests.cs | 10 ++--- .../ExcelReader.Tests/Reader/FastDateTests.cs | 4 +- tests/ExcelReader.Tests/Reader/SampleTest.cs | 6 +-- .../Reader/Xls/XlsWorkbookTests.cs | 2 +- .../Reader/Xlsx/RealWorldXlsxCorpusTests.cs | 6 +-- .../Writer/Csv/CsvWriterTests.cs | 2 +- .../Writer/Xls/XlsWriterTests.cs | 4 +- .../Writer/Xlsb/XlsbWriterTests.cs | 2 +- 15 files changed, 63 insertions(+), 35 deletions(-) diff --git a/docs/guide/migrating-to-v7.md b/docs/guide/migrating-to-v7.md index 1149878b..bf55f019 100644 --- a/docs/guide/migrating-to-v7.md +++ b/docs/guide/migrating-to-v7.md @@ -108,6 +108,7 @@ These changes replace positional parameters and loose arguments with types that | `XlsWorkbookWriter.Create(stream, leaveOpen, date1904)` | `XlsWorkbookWriter.Create(stream, leaveOpen, new XlsWriterOptions { Date1904 = … })` | | `Excel.FromCsv*(…, CsvReaderOptions options)` | `Excel.FromCsv*(…, new ExcelReaderOptions { Csv = options })` | | `CsvReaderOptions.MaxCellBytes`, `CsvReaderOptions.InternStrings` | `ExcelReaderOptions.MaxCellBytes`, `ExcelReaderOptions.InternStrings` | +| `Cell.TryGetDateTime(out DateTime)`, which silently assumed the 1900 date system | `Cell.TryGetDateTime(workbook.IsDate1904, out DateTime)` | A converter reads the date system and culture from the context: @@ -126,6 +127,7 @@ To unit-test a converter, build the context with an initializer: `new ExcelRowCo Two behavior changes come with them: - `Excel.Open` with `ExcelFileFormat.Csv` now applies `ExcelReaderOptions.MaxCellBytes` and `InternStrings`. v6 ignored both and used the CSV defaults. +- The CLI `convert` command no longer shifts dates by four years when the source workbook uses the 1904 date system. - `XlsxWriterOptions` gains `Date1904`, which v6 lacked. It defaults to `false`, so existing output is unchanged. ## Native bindings diff --git a/src/ExcelReader.Cli/CliCommands.cs b/src/ExcelReader.Cli/CliCommands.cs index ca914cfa..8f1833d8 100644 --- a/src/ExcelReader.Cli/CliCommands.cs +++ b/src/ExcelReader.Cli/CliCommands.cs @@ -170,7 +170,7 @@ private static void WriteRows(TWorkbook workbook, IExce } break; case CellType.Date: - var dateValue = cellValue.TryGetDateTime(out var date) ? date : throw new InvalidOperationException($"cell {cell.ColumnIndex} is a date but TryGetDateTime failed to parse it."); + var dateValue = cellValue.TryGetDateTime(sheet.IsDate1904, out var date) ? date : throw new InvalidOperationException($"cell {cell.ColumnIndex} is a date but TryGetDateTime failed to parse it."); row.Write(dateValue); break; default: diff --git a/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt b/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt index f30f97e8..3a15a7d9 100644 --- a/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt +++ b/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt @@ -98,7 +98,6 @@ ExcelReader.Core.Reader.Cell.GetString() -> string! ExcelReader.Core.Reader.Cell.StyleIndex.get -> int ExcelReader.Core.Reader.Cell.TryFormat(System.Span destination, out int bytesWritten) -> bool ExcelReader.Core.Reader.Cell.TryGetDateTime(bool isDate1904, out System.DateTime result) -> bool -ExcelReader.Core.Reader.Cell.TryGetDateTime(out System.DateTime result) -> bool ExcelReader.Core.Reader.Cell.TryGetDouble(out double value) -> bool ExcelReader.Core.Reader.Cell.TryParse(System.IFormatProvider? provider, out T result) -> bool ExcelReader.Core.Reader.Cell.Type.get -> ExcelReader.Core.Reader.CellType diff --git a/src/ExcelReader.Core/Reader/Cell.cs b/src/ExcelReader.Core/Reader/Cell.cs index 27ec5356..92e6d66e 100644 --- a/src/ExcelReader.Core/Reader/Cell.cs +++ b/src/ExcelReader.Core/Reader/Cell.cs @@ -271,18 +271,6 @@ private static bool UsesDotDecimalSeparator(IFormatProvider provider) || string.Equals(NumberFormatInfo.GetInstance(provider).NumberDecimalSeparator, ".", StringComparison.Ordinal); } - /// - /// Interprets the cell's numeric value as an Excel serial date under the 1900 date system, or, - /// when the value is not numeric, parses it as an ISO-8601 date or date-time. Works on any cell, - /// not only cells whose is — text formats such as - /// CSV carry dates as text rather than serials. Use the - /// overload for workbooks using the 1904 date system. - /// - public bool TryGetDateTime(out DateTime result) - { - return TryGetDateTime(isDate1904: false, out result); - } - /// /// Interprets the cell's numeric value as an Excel serial date, falling back to an ISO-8601 /// text date (yyyy-MM-dd, optionally followed by T or a space, a time, and up to diff --git a/tests/ExcelReader.Benchmarks/Read/CsvReadBenchmark.cs b/tests/ExcelReader.Benchmarks/Read/CsvReadBenchmark.cs index 12a7a87c..eb4f6067 100644 --- a/tests/ExcelReader.Benchmarks/Read/CsvReadBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/Read/CsvReadBenchmark.cs @@ -76,7 +76,7 @@ private static long AccumulateRow(Row row) { acc += id; } - if (row[2].TryGetDateTime(out DateTime date)) + if (row[2].TryGetDateTime(isDate1904: false, out DateTime date)) { acc += date.Ticks; } diff --git a/tests/ExcelReader.Benchmarks/Shared/BenchmarkAccumulators.cs b/tests/ExcelReader.Benchmarks/Shared/BenchmarkAccumulators.cs index 884df90b..5a2f8bc3 100644 --- a/tests/ExcelReader.Benchmarks/Shared/BenchmarkAccumulators.cs +++ b/tests/ExcelReader.Benchmarks/Shared/BenchmarkAccumulators.cs @@ -21,7 +21,7 @@ internal static long AccumulateRow(Row row) if (cell.TryParse(null, out double n)) { acc += (long)n; } break; case CellType.Date: - if (cell.TryGetDateTime(out DateTime d)) { acc += d.Ticks; } + if (cell.TryGetDateTime(isDate1904: false, out DateTime d)) { acc += d.Ticks; } break; } } @@ -43,7 +43,7 @@ internal static long AccumulateRowMaterialized(Row row) if (cell.TryParse(null, out double n)) { acc += (long)n; } break; case CellType.Date: - if (cell.TryGetDateTime(out DateTime d)) { acc += d.Ticks; } + if (cell.TryGetDateTime(isDate1904: false, out DateTime d)) { acc += d.Ticks; } break; } } diff --git a/tests/ExcelReader.Tests/Cli/CliTests.cs b/tests/ExcelReader.Tests/Cli/CliTests.cs index 75c774a6..0643d294 100644 --- a/tests/ExcelReader.Tests/Cli/CliTests.cs +++ b/tests/ExcelReader.Tests/Cli/CliTests.cs @@ -284,6 +284,45 @@ public void Should_ConvertToAnotherSpreadsheetFormat_When_OutputExtensionNamesOn } } + [Theory] + [InlineData("xlsx")] + [InlineData("xlsb")] + [InlineData("xls")] + public void Should_KeepDates_When_ConvertingA1904Workbook(string extension) + { + var date = new DateTime(2024, 5, 6, 12, 30, 0, DateTimeKind.Unspecified); + string source = Path.Combine(Path.GetTempPath(), $"excelreader-cli-{Guid.NewGuid():N}.xlsx"); + string target = Path.ChangeExtension(source, "out." + extension); + try + { + using (XlsxWorkbookWriter wb = XlsxWorkbookWriter.Create(File.Create(source), options: new XlsxWriterOptions { Date1904 = true })) + { + XlsxSheetWriter sheet = wb.AddSheet("S"); + using (XlsxRowWriter row = sheet.StartRow()) + { + row.Write(date); + } + sheet.End(); + wb.End(); + } + + (int code, _, string error) = Convert(source, output: target); + + Assert.Equal(0, code); + Assert.Empty(error); + using IExcelWorkbook written = Excel.Open(target); + using IExcelRowEnumerator rows = written.FirstSheet.GetEnumerator(); + Assert.True(rows.MoveNext()); + Assert.True(rows.Current[0].TryGetDateTime(written.IsDate1904, out DateTime readBack)); + Assert.Equal(date, readBack); + } + finally + { + File.Delete(source); + File.Delete(target); + } + } + [Fact] public void Should_UseTheExplicitFormat_When_FormatOverridesTheOutputExtension() { diff --git a/tests/ExcelReader.Tests/Reader/CellVariantTests.cs b/tests/ExcelReader.Tests/Reader/CellVariantTests.cs index 08ee5612..44f1aac1 100644 --- a/tests/ExcelReader.Tests/Reader/CellVariantTests.cs +++ b/tests/ExcelReader.Tests/Reader/CellVariantTests.cs @@ -167,7 +167,7 @@ public async Task TryGetDateTimeOnInlineStringReturnsFalse() await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); await using var e = reader.FirstSheet.GetEnumerator(); Assert.True(await e.MoveNextAsync()); - Assert.False(e.Current[0].TryGetDateTime(out _)); + Assert.False(e.Current[0].TryGetDateTime(isDate1904: false, out _)); } [Fact] @@ -177,7 +177,7 @@ public async Task TryGetDateTimeOutOfRangeSerialReturnsFalse() await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); await using var e = reader.FirstSheet.GetEnumerator(); Assert.True(await e.MoveNextAsync()); - Assert.False(e.Current[0].TryGetDateTime(out _)); + Assert.False(e.Current[0].TryGetDateTime(isDate1904: false, out _)); } [Fact] @@ -278,9 +278,9 @@ public void Excel1900SerialsOneToSixtyMapToExcelCalendar() using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); - Assert.True(e.Current[0].TryGetDateTime(out DateTime first)); - Assert.True(e.Current[1].TryGetDateTime(out DateTime leapBoundary)); - Assert.True(e.Current[2].TryGetDateTime(out DateTime phantomLeapDay)); + Assert.True(e.Current[0].TryGetDateTime(isDate1904: false, out DateTime first)); + Assert.True(e.Current[1].TryGetDateTime(isDate1904: false, out DateTime leapBoundary)); + Assert.True(e.Current[2].TryGetDateTime(isDate1904: false, out DateTime phantomLeapDay)); Assert.Equal(new DateTime(1900, 1, 1), first); Assert.Equal(new DateTime(1900, 2, 28), leapBoundary); Assert.Equal(new DateTime(1900, 2, 28), phantomLeapDay); diff --git a/tests/ExcelReader.Tests/Reader/FastDateTests.cs b/tests/ExcelReader.Tests/Reader/FastDateTests.cs index b4833bd3..b8edd98e 100644 --- a/tests/ExcelReader.Tests/Reader/FastDateTests.cs +++ b/tests/ExcelReader.Tests/Reader/FastDateTests.cs @@ -146,7 +146,7 @@ public void CsvTextDatesAreReadableThroughTryGetDateTime() { if (col == 1) { - Assert.True(rowCell.Value.TryGetDateTime(out DateTime d)); + Assert.True(rowCell.Value.TryGetDateTime(isDate1904: false, out DateTime d)); dates.Add(d); } col++; @@ -165,7 +165,7 @@ public void NumericCellsStillReadAsExcelSerials() foreach (Row row in reader.FirstSheet) { - Assert.True(row[1].TryGetDateTime(out DateTime d)); + Assert.True(row[1].TryGetDateTime(isDate1904: false, out DateTime d)); Assert.Equal(new DateTime(2024, 3, 15), d); } } diff --git a/tests/ExcelReader.Tests/Reader/SampleTest.cs b/tests/ExcelReader.Tests/Reader/SampleTest.cs index 86e0bddc..590755d9 100644 --- a/tests/ExcelReader.Tests/Reader/SampleTest.cs +++ b/tests/ExcelReader.Tests/Reader/SampleTest.cs @@ -135,11 +135,11 @@ public void DetectsDateStylesAndConvertsSerial() var row = e.Current; Assert.Equal(CellType.Date, row[0].Type); - Assert.True(row[0].TryGetDateTime(out var d0)); + Assert.True(row[0].TryGetDateTime(isDate1904: false, out var d0)); Assert.Equal(new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Unspecified), d0); Assert.Equal(CellType.Date, row[1].Type); - Assert.True(row[1].TryGetDateTime(out var d1)); + Assert.True(row[1].TryGetDateTime(isDate1904: false, out var d1)); Assert.Equal(new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Unspecified), d1); Assert.Equal(CellType.Number, row[2].Type); @@ -188,7 +188,7 @@ public async Task AsyncHandlesBufferGrowthAndDates() Assert.True(await e.MoveNextAsync()); Assert.Equal(CellType.Date, e.Current[0].Type); - Assert.True(e.Current[0].TryGetDateTime(out var d)); + Assert.True(e.Current[0].TryGetDateTime(isDate1904: false, out var d)); Assert.Equal(new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Unspecified), d); Assert.True(await e.MoveNextAsync()); diff --git a/tests/ExcelReader.Tests/Reader/Xls/XlsWorkbookTests.cs b/tests/ExcelReader.Tests/Reader/Xls/XlsWorkbookTests.cs index 5b5c4d54..79fe49be 100644 --- a/tests/ExcelReader.Tests/Reader/Xls/XlsWorkbookTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xls/XlsWorkbookTests.cs @@ -211,7 +211,7 @@ public void CustomDateFormatMarksDateCells() Assert.True(e.MoveNext()); Assert.True(e.MoveNext()); Assert.Equal(CellType.Date, e.Current[0].Type); - Assert.True(e.Current[0].TryGetDateTime(out DateTime parsed)); + Assert.True(e.Current[0].TryGetDateTime(isDate1904: false, out DateTime parsed)); Assert.Equal(date, parsed); } diff --git a/tests/ExcelReader.Tests/Reader/Xlsx/RealWorldXlsxCorpusTests.cs b/tests/ExcelReader.Tests/Reader/Xlsx/RealWorldXlsxCorpusTests.cs index 0516f401..a35682eb 100644 --- a/tests/ExcelReader.Tests/Reader/Xlsx/RealWorldXlsxCorpusTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xlsx/RealWorldXlsxCorpusTests.cs @@ -30,19 +30,19 @@ public void ReadsSheetJsGeneratedWorkbook() Assert.Equal(CellType.Boolean, e.Current[3].Type); Assert.Equal("1", e.Current[3].GetString()); Assert.Equal(CellType.Date, e.Current[4].Type); - Assert.True(e.Current[4].TryGetDateTime(out DateTime restock)); + Assert.True(e.Current[4].TryGetDateTime(isDate1904: false, out DateTime restock)); Assert.Equal(new DateTime(2024, 1, 14, 21, 0, 0, DateTimeKind.Unspecified), restock); Assert.True(e.MoveNext()); Assert.Equal("Gadget", e.Current[0].GetString()); Assert.True(e.Current[1].TryParse(null, out int gadgetQty)); Assert.Equal(0, gadgetQty); - Assert.True(e.Current[4].TryGetDateTime(out DateTime gadgetDate)); + Assert.True(e.Current[4].TryGetDateTime(isDate1904: false, out DateTime gadgetDate)); Assert.Equal(new DateTime(2024, 2, 29, 21, 0, 0, DateTimeKind.Unspecified), gadgetDate); Assert.True(e.MoveNext()); Assert.Equal("Gizmo", e.Current[0].GetString()); - Assert.True(e.Current[4].TryGetDateTime(out DateTime gizmoDate)); + Assert.True(e.Current[4].TryGetDateTime(isDate1904: false, out DateTime gizmoDate)); Assert.Equal(new DateTime(2024, 6, 29, 21, 0, 0, DateTimeKind.Unspecified), gizmoDate); Assert.False(e.MoveNext()); diff --git a/tests/ExcelReader.Tests/Writer/Csv/CsvWriterTests.cs b/tests/ExcelReader.Tests/Writer/Csv/CsvWriterTests.cs index dc608a88..7973709c 100644 --- a/tests/ExcelReader.Tests/Writer/Csv/CsvWriterTests.cs +++ b/tests/ExcelReader.Tests/Writer/Csv/CsvWriterTests.cs @@ -340,7 +340,7 @@ public void WrittenCsvRoundTripsThroughCsvReader() Assert.Equal("has, comma", row2[0].GetString()); Assert.Equal(CellType.ExcelString, row2[1].Type); Assert.Equal("true", row2[1].GetString()); - Assert.True(row2[2].TryGetDateTime(out DateTime readBack)); + Assert.True(row2[2].TryGetDateTime(isDate1904: false, out DateTime readBack)); Assert.Equal(date, readBack); Assert.Equal(date.ToString("O"), row2[2].GetString()); } diff --git a/tests/ExcelReader.Tests/Writer/Xls/XlsWriterTests.cs b/tests/ExcelReader.Tests/Writer/Xls/XlsWriterTests.cs index b9d80a7c..7fa3c62d 100644 --- a/tests/ExcelReader.Tests/Writer/Xls/XlsWriterTests.cs +++ b/tests/ExcelReader.Tests/Writer/Xls/XlsWriterTests.cs @@ -51,7 +51,7 @@ public async Task RoundTripsAllCellTypes() Assert.Equal(CellType.Boolean, e.Current[3].Type); Assert.Equal("1", e.Current[3].GetString()); Assert.Equal(CellType.Date, e.Current[4].Type); - Assert.True(e.Current[4].TryGetDateTime(out DateTime parsed)); + Assert.True(e.Current[4].TryGetDateTime(isDate1904: false, out DateTime parsed)); Assert.Equal(date, parsed); Assert.False(e.MoveNext()); } @@ -130,7 +130,7 @@ public async Task NullableAndGenericOverloadsRoundTrip() Assert.True(e.MoveNext()); Assert.Equal(CellType.Boolean, e.Current[0].Type); Assert.Equal(CellType.Empty, e.Current[1].Type); - Assert.True(e.Current[2].TryGetDateTime(out DateTime parsed)); + Assert.True(e.Current[2].TryGetDateTime(isDate1904: false, out DateTime parsed)); Assert.Equal(date, parsed); Assert.Equal(CellType.Empty, e.Current[3].Type); Assert.True(e.Current[4].TryParse(Inv, out double nullableFloat)); diff --git a/tests/ExcelReader.Tests/Writer/Xlsb/XlsbWriterTests.cs b/tests/ExcelReader.Tests/Writer/Xlsb/XlsbWriterTests.cs index dc4fd2c4..1e21e32c 100644 --- a/tests/ExcelReader.Tests/Writer/Xlsb/XlsbWriterTests.cs +++ b/tests/ExcelReader.Tests/Writer/Xlsb/XlsbWriterTests.cs @@ -205,7 +205,7 @@ public async Task NullableAndGenericOverloadsRoundTrip() Assert.True(e.MoveNext()); Assert.Equal(CellType.Boolean, e.Current[0].Type); Assert.Equal(CellType.Empty, e.Current[1].Type); - Assert.True(e.Current[2].TryGetDateTime(out DateTime parsed)); + Assert.True(e.Current[2].TryGetDateTime(isDate1904: false, out DateTime parsed)); Assert.Equal(date, parsed); Assert.Equal(CellType.Empty, e.Current[3].Type); Assert.True(e.Current[4].TryParse(null, out double nullableFloat)); From b1f098031fe61c508f0b9abc394c0d70b7cbb649 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Sun, 11 Oct 2026 01:51:54 -0300 Subject: [PATCH 092/115] refactor!: rename ExcelDataReader to SheetDataReader ExcelDataReader and ExcelDataReaderOptions collided with Sylvan.Data.Excel's types and with the ExcelDataReader package's namespace, so code importing either needed global:: qualification. SheetDataReaderOptions follows the same rename. --- docs/guide/migrating-to-v7.md | 3 +- docs/guide/reading.md | 12 ++--- docs/performance/benchmarks.md | 2 +- .../PublicAPI/PublicAPI.Shipped.txt | 34 -------------- .../PublicAPI/PublicAPI.Unshipped.txt | 46 ++++++++++++++++--- ...{ExcelDataReader.cs => SheetDataReader.cs} | 18 ++++---- ...erOptions.cs => SheetDataReaderOptions.cs} | 6 +-- .../Interop/DataReaderBenchmark.cs | 8 ++-- .../Reader/SheetConsumerTests.cs | 6 +-- ...ReaderTests.cs => SheetDataReaderTests.cs} | 44 +++++++++--------- .../Reader/Tables/TableBindingTests.cs | 10 ++-- 11 files changed, 95 insertions(+), 94 deletions(-) rename src/ExcelReader.Core/Reader/{ExcelDataReader.cs => SheetDataReader.cs} (96%) rename src/ExcelReader.Core/Reader/{ExcelDataReaderOptions.cs => SheetDataReaderOptions.cs} (76%) rename tests/ExcelReader.Tests/Reader/{ExcelDataReaderTests.cs => SheetDataReaderTests.cs} (90%) diff --git a/docs/guide/migrating-to-v7.md b/docs/guide/migrating-to-v7.md index bf55f019..235c0476 100644 --- a/docs/guide/migrating-to-v7.md +++ b/docs/guide/migrating-to-v7.md @@ -102,12 +102,13 @@ These changes replace positional parameters and loose arguments with types that | `IExcelCellConverter.TryConvert(in Cell, bool isDate1904, IFormatProvider provider, out T)` | `TryConvert(in Cell, in ExcelRowContext context, out T)` | | `ExcelCellReader`, `ExcelRowParser`, `ExcelCellReaders.*`: `(…, bool isDate1904, IFormatProvider provider, …)` | `(…, in ExcelRowContext context, …)` | | `Excel.InferSchema(sheet, headerRow, sampleSize, parseText)` returning `ExcelColumnSchema[]` | `Excel.InferSchema(sheet, new SchemaInferenceOptions { HeaderRow = …, SampleSize = …, ParseText = … })` returning `IReadOnlyList` | -| `new ExcelDataReader(sheet, headerRow)` | `new ExcelDataReader(sheet, new ExcelDataReaderOptions { HeaderRow = … })` | +| `new ExcelDataReader(sheet, headerRow)` | `new SheetDataReader(sheet, new SheetDataReaderOptions { HeaderRow = … })` | | `sheet.ToArrowRecordBatch(schema, headerRow)` | `sheet.ToArrowRecordBatch(new ArrowReadOptions { Schema = schema, HeaderRow = … })` | | `WriteRecordBatch(batch, sheetName, writeHeader)` and the async form | `WriteRecordBatch(batch, new ArrowWriteOptions { SheetName = …, WriteHeader = … })` | | `XlsWorkbookWriter.Create(stream, leaveOpen, date1904)` | `XlsWorkbookWriter.Create(stream, leaveOpen, new XlsWriterOptions { Date1904 = … })` | | `Excel.FromCsv*(…, CsvReaderOptions options)` | `Excel.FromCsv*(…, new ExcelReaderOptions { Csv = options })` | | `CsvReaderOptions.MaxCellBytes`, `CsvReaderOptions.InternStrings` | `ExcelReaderOptions.MaxCellBytes`, `ExcelReaderOptions.InternStrings` | +| `ExcelDataReader` (its name collided with Sylvan.Data.Excel and the ExcelDataReader package) | `SheetDataReader` | | `Cell.TryGetDateTime(out DateTime)`, which silently assumed the 1900 date system | `Cell.TryGetDateTime(workbook.IsDate1904, out DateTime)` | A converter reads the date system and culture from the context: diff --git a/docs/guide/reading.md b/docs/guide/reading.md index 8852fb1a..fcd04718 100644 --- a/docs/guide/reading.md +++ b/docs/guide/reading.md @@ -251,11 +251,11 @@ if (workbook.TryGetTable("Sales", out ExcelTable? sales)) rows; `Ref` is the same range in A1 notation. - Column indexes inside `AsSheet()` rows stay those of the sheet: a table starting at column C reports its first cell at index 2. `Excel.InferSchema` keeps those indexes in `ExcelColumnSchema.Index` but - lists only the table's columns. `ExcelDataReader` ordinals are relative to the table: ordinal 0 is + lists only the table's columns. `SheetDataReader` ordinals are relative to the table: ordinal 0 is its first column. - A table whose header row is turned off (`HeaderRowCount == 0`) still enumerates, but `Parse` throws - `InvalidOperationException` for every map, and `ExcelDataReader` and `InferSchema` throw it when - `HeaderRow` is greater than 0. Read it with `new ExcelDataReader(table.AsSheet(), new ExcelDataReaderOptions { HeaderRow = 0 })`, + `InvalidOperationException` for every map, and `SheetDataReader` and `InferSchema` throw it when + `HeaderRow` is greater than 0. Read it with `new SheetDataReader(table.AsSheet(), new SheetDataReaderOptions { HeaderRow = 0 })`, `Excel.InferSchema(table.AsSheet(), new SchemaInferenceOptions { HeaderRow = 0 })`, or by column index on the rows. - `StyleName` is the table style's name, such as `TableStyleMedium2`, or `null` when the table has none. @@ -359,20 +359,20 @@ Writing has the same option, under a different name: see ## Bridge to ADO.NET (`IDataReader`) -`ExcelDataReader` adapts one `IExcelSheet` to `System.Data.IDataReader`, so it drops straight into `SqlBulkCopy`, `DataTable.Load`, Dapper, or any other ADO.NET consumer: +`SheetDataReader` adapts one `IExcelSheet` to `System.Data.IDataReader`, so it drops straight into `SqlBulkCopy`, `DataTable.Load`, Dapper, or any other ADO.NET consumer: ```csharp using System.Data; using ExcelReader.Core.Reader; using IExcelWorkbook workbook = Excel.Open("report.xlsx"); -using IDataReader data = new ExcelDataReader(workbook.FirstSheet); // header on row 1 by default +using IDataReader data = new SheetDataReader(workbook.FirstSheet); // header on row 1 by default var table = new DataTable(); table.Load(data); ``` -The header row (1-based, default 1) fixes the column shape; pass `new ExcelDataReaderOptions { HeaderRow = 0 }` for a header-less sheet, whose columns come back named `Column0`, `Column1`, ... sized from the first data row. There is no schema-inference pass — `GetFieldType`/`GetValue`/the typed getters read the *current* row's own cell type, so a consumer building its schema from the first row (like `DataTable.Load`) locks in that row's types for the whole load. `NextResult()` always returns `false`; build one `ExcelDataReader` per sheet instead of chaining result sets. +The header row (1-based, default 1) fixes the column shape; pass `new SheetDataReaderOptions { HeaderRow = 0 }` for a header-less sheet, whose columns come back named `Column0`, `Column1`, ... sized from the first data row. There is no schema-inference pass — `GetFieldType`/`GetValue`/the typed getters read the *current* row's own cell type, so a consumer building its schema from the first row (like `DataTable.Load`) locks in that row's types for the whole load. `NextResult()` always returns `false`; build one `SheetDataReader` per sheet instead of chaining result sets. The bridge costs ~1.3–1.4x over iterating rows directly, and `GetBytes` stays within ~10% of the direct read. See [ADO.NET bridge](../performance/benchmarks.md#adonet-bridge-idatareader) in the benchmarks. diff --git a/docs/performance/benchmarks.md b/docs/performance/benchmarks.md index 58598563..2960ca10 100644 --- a/docs/performance/benchmarks.md +++ b/docs/performance/benchmarks.md @@ -253,7 +253,7 @@ Parsing into a `ref struct` with a `ReadOnlySpan` text column removes esse ### ADO.NET bridge (`IDataReader`) -`DataReaderBenchmark`: the generated 50,000-row XLSX workbook (header plus four columns) read through [`ExcelDataReader`](../guide/reading.md#bridge-to-adonet-idatareader), against iterating the same rows directly. The reader is held as `IDataReader`, the way `SqlBulkCopy`, `DataTable.Load` and Dapper hold it. +`DataReaderBenchmark`: the generated 50,000-row XLSX workbook (header plus four columns) read through [`SheetDataReader`](../guide/reading.md#bridge-to-adonet-idatareader), against iterating the same rows directly. The reader is held as `IDataReader`, the way `SqlBulkCopy`, `DataTable.Load` and Dapper hold it. | Access | Mean | Ratio | Allocated | |---|---:|---:|---:| diff --git a/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt b/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt index 3a15a7d9..9a57304e 100644 --- a/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt +++ b/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt @@ -183,40 +183,6 @@ ExcelReader.Core.Reader.IRowAccumulator.Merge(TSelf following) -> ExcelReader.Core.Reader.IRowRecord ExcelReader.Core.Reader.IRowRecord.TryParse(ExcelReader.Core.Reader.Row row, out TSelf record) -> bool ExcelReader.Core.Reader.Excel -ExcelReader.Core.Reader.ExcelDataReader -ExcelReader.Core.Reader.ExcelDataReader.Close() -> void -ExcelReader.Core.Reader.ExcelDataReader.Depth.get -> int -ExcelReader.Core.Reader.ExcelDataReader.Dispose() -> void -ExcelReader.Core.Reader.ExcelDataReader.FieldCount.get -> int -ExcelReader.Core.Reader.ExcelDataReader.GetBoolean(int i) -> bool -ExcelReader.Core.Reader.ExcelDataReader.GetByte(int i) -> byte -ExcelReader.Core.Reader.ExcelDataReader.GetBytes(int i, long fieldOffset, byte[]? buffer, int bufferoffset, int length) -> long -ExcelReader.Core.Reader.ExcelDataReader.GetChar(int i) -> char -ExcelReader.Core.Reader.ExcelDataReader.GetChars(int i, long fieldoffset, char[]? buffer, int bufferoffset, int length) -> long -ExcelReader.Core.Reader.ExcelDataReader.GetData(int i) -> System.Data.IDataReader! -ExcelReader.Core.Reader.ExcelDataReader.GetDataTypeName(int i) -> string! -ExcelReader.Core.Reader.ExcelDataReader.GetDateTime(int i) -> System.DateTime -ExcelReader.Core.Reader.ExcelDataReader.GetDecimal(int i) -> decimal -ExcelReader.Core.Reader.ExcelDataReader.GetDouble(int i) -> double -ExcelReader.Core.Reader.ExcelDataReader.GetFieldType(int i) -> System.Type! -ExcelReader.Core.Reader.ExcelDataReader.GetFloat(int i) -> float -ExcelReader.Core.Reader.ExcelDataReader.GetGuid(int i) -> System.Guid -ExcelReader.Core.Reader.ExcelDataReader.GetInt16(int i) -> short -ExcelReader.Core.Reader.ExcelDataReader.GetInt32(int i) -> int -ExcelReader.Core.Reader.ExcelDataReader.GetInt64(int i) -> long -ExcelReader.Core.Reader.ExcelDataReader.GetName(int i) -> string! -ExcelReader.Core.Reader.ExcelDataReader.GetOrdinal(string! name) -> int -ExcelReader.Core.Reader.ExcelDataReader.GetSchemaTable() -> System.Data.DataTable! -ExcelReader.Core.Reader.ExcelDataReader.GetString(int i) -> string! -ExcelReader.Core.Reader.ExcelDataReader.GetValue(int i) -> object! -ExcelReader.Core.Reader.ExcelDataReader.GetValues(object![]! values) -> int -ExcelReader.Core.Reader.ExcelDataReader.IsClosed.get -> bool -ExcelReader.Core.Reader.ExcelDataReader.IsDBNull(int i) -> bool -ExcelReader.Core.Reader.ExcelDataReader.NextResult() -> bool -ExcelReader.Core.Reader.ExcelDataReader.Read() -> bool -ExcelReader.Core.Reader.ExcelDataReader.RecordsAffected.get -> int -ExcelReader.Core.Reader.ExcelDataReader.this[int i].get -> object! -ExcelReader.Core.Reader.ExcelDataReader.this[string! name].get -> object! ExcelReader.Core.Reader.ExcelEncryptionException ExcelReader.Core.Reader.ExcelEncryptionException.ExcelEncryptionException() -> void ExcelReader.Core.Reader.ExcelEncryptionException.ExcelEncryptionException(ExcelReader.Core.Reader.ExcelEncryptionReason reason, string! message) -> void diff --git a/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt b/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt index c0fabc56..772cc5ed 100644 --- a/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt @@ -70,11 +70,6 @@ static ExcelReader.Core.Reader.SheetParallel.ForEachAsync(ExcelReader.Co static ExcelReader.Core.Reader.SheetParallel.ForEachAsync(ExcelReader.Core.Reader.IExcelSheet! sheet, System.Action! body, ExcelReader.Core.Reader.SheetParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! static ExcelReader.Core.Reader.SheetParallel.ParseAsync(ExcelReader.Core.Reader.IExcelSheet! sheet, ExcelReader.Core.Parser.ExcelParser! parser, ExcelReader.Core.Reader.SheetParallelOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable! static ExcelReader.Core.Reader.SheetParallelOptions.Default.get -> ExcelReader.Core.Reader.SheetParallelOptions! -ExcelReader.Core.Reader.ExcelDataReader.ExcelDataReader(ExcelReader.Core.Reader.IExcelSheet! sheet, ExcelReader.Core.Reader.ExcelDataReaderOptions? options = null) -> void -ExcelReader.Core.Reader.ExcelDataReaderOptions -ExcelReader.Core.Reader.ExcelDataReaderOptions.ExcelDataReaderOptions() -> void -ExcelReader.Core.Reader.ExcelDataReaderOptions.HeaderRow.get -> int -ExcelReader.Core.Reader.ExcelDataReaderOptions.HeaderRow.init -> void ExcelReader.Core.Reader.Schema.SchemaInferenceOptions ExcelReader.Core.Reader.Schema.SchemaInferenceOptions.HeaderRow.get -> int ExcelReader.Core.Reader.Schema.SchemaInferenceOptions.HeaderRow.init -> void @@ -83,7 +78,6 @@ ExcelReader.Core.Reader.Schema.SchemaInferenceOptions.ParseText.init -> void ExcelReader.Core.Reader.Schema.SchemaInferenceOptions.SampleSize.get -> int ExcelReader.Core.Reader.Schema.SchemaInferenceOptions.SampleSize.init -> void ExcelReader.Core.Reader.Schema.SchemaInferenceOptions.SchemaInferenceOptions() -> void -static ExcelReader.Core.Reader.ExcelDataReaderOptions.Default.get -> ExcelReader.Core.Reader.ExcelDataReaderOptions! static ExcelReader.Core.Reader.Schema.SchemaInferenceOptions.Default.get -> ExcelReader.Core.Reader.Schema.SchemaInferenceOptions! ExcelReader.Core.Writer.Xls.XlsWriterOptions ExcelReader.Core.Writer.Xls.XlsWriterOptions.$() -> ExcelReader.Core.Writer.Xls.XlsWriterOptions! @@ -106,3 +100,43 @@ static ExcelReader.Core.Reader.Excel.FromCsvAsync(System.IO.Stream! stream, bool static ExcelReader.Core.Reader.Excel.FromCsvFile(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Csv.CsvReader! static ExcelReader.Core.Reader.Excel.FromCsvFileAsync(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static ExcelReader.Core.Reader.Excel.InferSchema(ExcelReader.Core.Reader.IExcelSheet! sheet, ExcelReader.Core.Reader.Schema.SchemaInferenceOptions? options = null) -> System.Collections.Generic.IReadOnlyList! +ExcelReader.Core.Reader.SheetDataReader +ExcelReader.Core.Reader.SheetDataReader.Close() -> void +ExcelReader.Core.Reader.SheetDataReader.Depth.get -> int +ExcelReader.Core.Reader.SheetDataReader.Dispose() -> void +ExcelReader.Core.Reader.SheetDataReader.FieldCount.get -> int +ExcelReader.Core.Reader.SheetDataReader.GetBoolean(int i) -> bool +ExcelReader.Core.Reader.SheetDataReader.GetByte(int i) -> byte +ExcelReader.Core.Reader.SheetDataReader.GetBytes(int i, long fieldOffset, byte[]? buffer, int bufferoffset, int length) -> long +ExcelReader.Core.Reader.SheetDataReader.GetChar(int i) -> char +ExcelReader.Core.Reader.SheetDataReader.GetChars(int i, long fieldoffset, char[]? buffer, int bufferoffset, int length) -> long +ExcelReader.Core.Reader.SheetDataReader.GetData(int i) -> System.Data.IDataReader! +ExcelReader.Core.Reader.SheetDataReader.GetDataTypeName(int i) -> string! +ExcelReader.Core.Reader.SheetDataReader.GetDateTime(int i) -> System.DateTime +ExcelReader.Core.Reader.SheetDataReader.GetDecimal(int i) -> decimal +ExcelReader.Core.Reader.SheetDataReader.GetDouble(int i) -> double +ExcelReader.Core.Reader.SheetDataReader.GetFieldType(int i) -> System.Type! +ExcelReader.Core.Reader.SheetDataReader.GetFloat(int i) -> float +ExcelReader.Core.Reader.SheetDataReader.GetGuid(int i) -> System.Guid +ExcelReader.Core.Reader.SheetDataReader.GetInt16(int i) -> short +ExcelReader.Core.Reader.SheetDataReader.GetInt32(int i) -> int +ExcelReader.Core.Reader.SheetDataReader.GetInt64(int i) -> long +ExcelReader.Core.Reader.SheetDataReader.GetName(int i) -> string! +ExcelReader.Core.Reader.SheetDataReader.GetOrdinal(string! name) -> int +ExcelReader.Core.Reader.SheetDataReader.GetSchemaTable() -> System.Data.DataTable! +ExcelReader.Core.Reader.SheetDataReader.GetString(int i) -> string! +ExcelReader.Core.Reader.SheetDataReader.GetValue(int i) -> object! +ExcelReader.Core.Reader.SheetDataReader.GetValues(object![]! values) -> int +ExcelReader.Core.Reader.SheetDataReader.IsClosed.get -> bool +ExcelReader.Core.Reader.SheetDataReader.IsDBNull(int i) -> bool +ExcelReader.Core.Reader.SheetDataReader.NextResult() -> bool +ExcelReader.Core.Reader.SheetDataReader.Read() -> bool +ExcelReader.Core.Reader.SheetDataReader.RecordsAffected.get -> int +ExcelReader.Core.Reader.SheetDataReader.SheetDataReader(ExcelReader.Core.Reader.IExcelSheet! sheet, ExcelReader.Core.Reader.SheetDataReaderOptions? options = null) -> void +ExcelReader.Core.Reader.SheetDataReader.this[int i].get -> object! +ExcelReader.Core.Reader.SheetDataReader.this[string! name].get -> object! +ExcelReader.Core.Reader.SheetDataReaderOptions +ExcelReader.Core.Reader.SheetDataReaderOptions.HeaderRow.get -> int +ExcelReader.Core.Reader.SheetDataReaderOptions.HeaderRow.init -> void +ExcelReader.Core.Reader.SheetDataReaderOptions.SheetDataReaderOptions() -> void +static ExcelReader.Core.Reader.SheetDataReaderOptions.Default.get -> ExcelReader.Core.Reader.SheetDataReaderOptions! diff --git a/src/ExcelReader.Core/Reader/ExcelDataReader.cs b/src/ExcelReader.Core/Reader/SheetDataReader.cs similarity index 96% rename from src/ExcelReader.Core/Reader/ExcelDataReader.cs rename to src/ExcelReader.Core/Reader/SheetDataReader.cs index 62b286f1..95cfee9c 100644 --- a/src/ExcelReader.Core/Reader/ExcelDataReader.cs +++ b/src/ExcelReader.Core/Reader/SheetDataReader.cs @@ -15,12 +15,12 @@ namespace ExcelReader.Core.Reader /// /// /// The column shape is fixed once, at construction time, from the header row (or from the first data - /// row when is 0) — it never grows as later, wider rows come in. There is no schema + /// row when is 0) — it never grows as later, wider rows come in. There is no schema /// pass, so , , and the typed getters all read the /// current row's own cell type; a consumer that builds a schema from the first /// (e.g. DataTable.Load) locks in that row's types for the whole load. /// - /// One reads one sheet: always returns + /// One reads one sheet: always returns /// . To read every sheet, construct one per . /// /// @@ -28,7 +28,7 @@ namespace ExcelReader.Core.Reader /// from — the caller still owns that. /// /// - public sealed class ExcelDataReader : IDataReader + public sealed class SheetDataReader : IDataReader { private readonly IExcelRowEnumerator _rows; private readonly bool _isDate1904; @@ -45,14 +45,14 @@ public sealed class ExcelDataReader : IDataReader /// Wraps . /// /// The sheet to read. Its workbook stays owned by the caller. - /// The header row; when . + /// The header row; when . /// is null. - /// is negative. - /// is positive and is a table view () of a table without a header row. - public ExcelDataReader(IExcelSheet sheet, ExcelDataReaderOptions? options = null) + /// is negative. + /// is positive and is a table view () of a table without a header row. + public SheetDataReader(IExcelSheet sheet, SheetDataReaderOptions? options = null) { ArgumentNullException.ThrowIfNull(sheet); - int headerRow = (options ?? ExcelDataReaderOptions.Default).HeaderRow; + int headerRow = (options ?? SheetDataReaderOptions.Default).HeaderRow; ArgumentOutOfRangeException.ThrowIfNegative(headerRow, nameof(options)); if (headerRow > 0) { @@ -364,7 +364,7 @@ public long GetChars(int i, long fieldoffset, char[]? buffer, int bufferoffset, /// public IDataReader GetData(int i) { - throw new NotSupportedException("ExcelDataReader has no nested result sets."); + throw new NotSupportedException("SheetDataReader has no nested result sets."); } /// diff --git a/src/ExcelReader.Core/Reader/ExcelDataReaderOptions.cs b/src/ExcelReader.Core/Reader/SheetDataReaderOptions.cs similarity index 76% rename from src/ExcelReader.Core/Reader/ExcelDataReaderOptions.cs rename to src/ExcelReader.Core/Reader/SheetDataReaderOptions.cs index 95f87a1b..4c14d7c8 100644 --- a/src/ExcelReader.Core/Reader/ExcelDataReaderOptions.cs +++ b/src/ExcelReader.Core/Reader/SheetDataReaderOptions.cs @@ -1,10 +1,10 @@ namespace ExcelReader.Core.Reader { - /// Options for adapting a sheet to with . - public sealed class ExcelDataReaderOptions + /// Options for adapting a sheet to with . + public sealed class SheetDataReaderOptions { /// Gets the default options: column names on row 1. - public static ExcelDataReaderOptions Default { get; } = new(); + public static SheetDataReaderOptions Default { get; } = new(); /// public sealed record ExcelReaderOptions { - /// Gets the maximum total decompressed bytes allowed across the whole workbook. Defaults to 512 MiB. - /// Applies to ZIP-backed formats (XLSX/XLSB) as their decompressed byte budget, and to - /// the legacy CFB container (.xls) as the cap on its declared Workbook stream size — the CFB path - /// has nothing to decompress, but this is still the caller's budget for what that phase may - /// materialize. + /// Gets the maximum total decompressed bytes allowed for one read of a sheet. Defaults to 512 MiB. + /// Applies to ZIP-backed formats (XLSX/XLSB) as their decompressed byte budget: what the + /// workbook inflated when it opened and loaded its shared strings, plus the sheet being read. Each + /// read of a sheet starts from that one-time total again, so reading a sheet twice does not count + /// it twice. For the legacy CFB container (.xls) it caps the declared Workbook stream size — the + /// CFB path has nothing to decompress, but this is still the caller's budget for what that phase + /// may materialize. public long MaxTotalDecompressedBytes { get; init; } = 512L * 1024 * 1024; /// Gets the maximum byte length allowed for a single cell's value, or a single buffered CSV record. Defaults to 32 MiB. diff --git a/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.Enumerator.cs b/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.Enumerator.cs index cd1fb5fc..4884a29b 100644 --- a/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.Enumerator.cs +++ b/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.Enumerator.cs @@ -64,12 +64,12 @@ internal Enumerator(XlsbWorkbook reader, ReadOnlyMemory chunk, bool rowOpe private protected override Stream OpenSource() { - return _reader._zip!.OpenEntryStream(_entry, _reader._decompressedBytes, _reader._options); + return _reader._zip!.OpenEntryStream(_entry, _reader._decompressedBytes.ForRead(), _reader._options); } private protected override async ValueTask OpenSourceAsync() { - return await _reader._zip!.OpenEntryStreamAsync(_entry, _reader._decompressedBytes, _reader._options, _ct).ConfigureAwait(false); + return await _reader._zip!.OpenEntryStreamAsync(_entry, _reader._decompressedBytes.ForRead(), _reader._options, _ct).ConfigureAwait(false); } /// diff --git a/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.cs b/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.cs index a51ef242..f2ce0b53 100644 --- a/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.cs +++ b/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.cs @@ -278,7 +278,7 @@ internal Enumerator OpenSheet(int index) try { ZipEntryRef entry = WorkbookLookups.GetWorksheetEntry(_zip!, _sheets![index].Path); - return new Enumerator(this, _zip!.OpenEntryStream(entry, _decompressedBytes, _options), entry.UncompressedSize); + return new Enumerator(this, _zip!.OpenEntryStream(entry, _decompressedBytes.ForRead(), _options), entry.UncompressedSize); } catch { @@ -303,7 +303,7 @@ internal Enumerator OpenSheetWithoutPrefetch(int index) try { ZipEntryRef entry = WorkbookLookups.GetWorksheetEntry(_zip!, _sheets![index].Path); - return new Enumerator(this, _zip!.OpenEntryStream(entry, _decompressedBytes, WithoutPrefetch), entry.UncompressedSize); + return new Enumerator(this, _zip!.OpenEntryStream(entry, _decompressedBytes.ForRead(), WithoutPrefetch), entry.UncompressedSize); } catch { @@ -315,7 +315,7 @@ internal Enumerator OpenSheetWithoutPrefetch(int index) internal Stream OpenSheetStream(int index) { ZipEntryRef entry = WorkbookLookups.GetWorksheetEntry(_zip!, _sheets![index].Path); - return _zip!.OpenEntryStream(entry, _decompressedBytes, WithoutPrefetch); + return _zip!.OpenEntryStream(entry, _decompressedBytes.ForRead(), WithoutPrefetch); } internal Enumerator OpenSheetAsync(int index, CancellationToken ct) diff --git a/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Enumerator.cs b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Enumerator.cs index 61db4709..23b97aea 100644 --- a/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Enumerator.cs +++ b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Enumerator.cs @@ -71,14 +71,14 @@ private protected override Stream OpenSource() { _reader.EnsureSharedLoaded(); _sharedOffsets = _reader._sharedOffsets; - return _reader._zip.OpenEntryStream(_entry, _reader._decompressedBytes, _reader._options); + return _reader._zip.OpenEntryStream(_entry, _reader._decompressedBytes.ForRead(), _reader._options); } private protected override async ValueTask OpenSourceAsync() { await _reader.EnsureSharedLoadedAsync(_ct).ConfigureAwait(false); _sharedOffsets = _reader._sharedOffsets; - return await _reader._zip.OpenEntryStreamAsync(_entry, _reader._decompressedBytes, _reader._options, _ct).ConfigureAwait(false); + return await _reader._zip.OpenEntryStreamAsync(_entry, _reader._decompressedBytes.ForRead(), _reader._options, _ct).ConfigureAwait(false); } /// diff --git a/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.cs b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.cs index c684f478..2151f557 100644 --- a/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.cs +++ b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.cs @@ -209,7 +209,7 @@ internal Enumerator OpenSheet(int index) { EnsureSharedLoaded(); ZipEntryRef entry = WorkbookLookups.GetWorksheetEntry(_zip, _sheets[index].Path); - return new Enumerator(this, _zip.OpenEntryStream(entry, _decompressedBytes, _options), entry.UncompressedSize); + return new Enumerator(this, _zip.OpenEntryStream(entry, _decompressedBytes.ForRead(), _options), entry.UncompressedSize); } catch { @@ -235,7 +235,7 @@ internal Enumerator OpenSheetWithoutPrefetch(int index) { EnsureSharedLoaded(); ZipEntryRef entry = WorkbookLookups.GetWorksheetEntry(_zip, _sheets[index].Path); - return new Enumerator(this, _zip.OpenEntryStream(entry, _decompressedBytes, WithoutPrefetch), entry.UncompressedSize); + return new Enumerator(this, _zip.OpenEntryStream(entry, _decompressedBytes.ForRead(), WithoutPrefetch), entry.UncompressedSize); } catch { @@ -247,7 +247,7 @@ internal Enumerator OpenSheetWithoutPrefetch(int index) internal Stream OpenSheetStream(int index) { ZipEntryRef entry = WorkbookLookups.GetWorksheetEntry(_zip, _sheets[index].Path); - return _zip.OpenEntryStream(entry, _decompressedBytes, WithoutPrefetch); + return _zip.OpenEntryStream(entry, _decompressedBytes.ForRead(), WithoutPrefetch); } internal Enumerator OpenSheetAsync(int index, CancellationToken ct) diff --git a/src/ExcelReader.Core/Reader/Zip/LimitedReadStream.cs b/src/ExcelReader.Core/Reader/Zip/LimitedReadStream.cs index a1863f9a..b805477c 100644 --- a/src/ExcelReader.Core/Reader/Zip/LimitedReadStream.cs +++ b/src/ExcelReader.Core/Reader/Zip/LimitedReadStream.cs @@ -12,6 +12,13 @@ internal DecompressedByteCounter(long limit, string limitName = nameof(ExcelRead _limitName = limitName; } + // A sheet read counts from what the workbook's one-time loads (metadata, styles, shared strings) + // inflated, so a single read stays within the limit while a re-read does not pay for earlier ones. + internal DecompressedByteCounter ForRead() + { + return new DecompressedByteCounter(_limit, _limitName) { _total = Interlocked.Read(ref _total) }; + } + internal void Add(long bytes) { if (bytes <= 0 || _limit <= 0) diff --git a/tests/ExcelReader.Tests/Reader/ReaderLimitTests.cs b/tests/ExcelReader.Tests/Reader/ReaderLimitTests.cs index 4469dbaa..babf22dd 100644 --- a/tests/ExcelReader.Tests/Reader/ReaderLimitTests.cs +++ b/tests/ExcelReader.Tests/Reader/ReaderLimitTests.cs @@ -9,6 +9,8 @@ using ExcelReader.Core.Reader.Xlsb; using ExcelReader.Core.Reader.Xlsx; using ExcelReader.Core.Reader.Zip; +using ExcelReader.Core.Writer.Xlsb; +using ExcelReader.Core.Writer.Xlsx; using ExcelReader.Tests.Crypto; using ExcelReader.Tests.Reader.Tables; using ExcelReader.Tests.Reader.Xls; @@ -391,6 +393,94 @@ public void ZeroTotalDecompressedLimitRestoresUnlimitedTotalRead() Assert.Equal(value.Length, e.Current[0].GetString().Length); } + private const int BudgetRows = 5_000; + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void RereadingASheetStartsAFreshTotalDecompressedBudget(bool xlsb, bool fromStream) + { + byte[] package = WriteTextRows(xlsb, sharedStrings: false); + (long total, long sheet) = EntrySizes(package, xlsb); + var options = new ExcelReaderOptions { MaxTotalDecompressedBytes = total + (sheet / 2) }; + + using MemoryStream stream = new(package); + using IExcelWorkbook workbook = fromStream + ? Excel.Open(stream, options: options) + : Excel.Open(package, options); + for (int pass = 0; pass < 3; pass++) + { + Assert.Equal(BudgetRows, CountRows(workbook.FirstSheet)); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void OneSheetReadStillCountsTheSharedStringsTowardTheTotalBudget(bool xlsb) + { + byte[] package = WriteTextRows(xlsb, sharedStrings: true); + (long total, long sheet) = EntrySizes(package, xlsb); + var options = new ExcelReaderOptions { MaxTotalDecompressedBytes = total - (sheet / 2) }; + + using IExcelWorkbook workbook = Excel.Open(package, options); + ExcelLimitExceededException ex = Assert.Throws(() => CountRows(workbook.FirstSheet)); + Assert.Equal(nameof(ExcelReaderOptions.MaxTotalDecompressedBytes), ex.LimitName); + } + + private static byte[] WriteTextRows(bool xlsb, bool sharedStrings) + { + using MemoryStream ms = new(); + if (xlsb) + { + using XlsbWorkbookWriter workbook = XlsbWorkbookWriter.Create(ms, leaveOpen: true, new XlsbWriterOptions { UseSharedStrings = sharedStrings }); + using (XlsbSheetWriter sheet = workbook.AddSheet("S")) + { + for (int i = 0; i < BudgetRows; i++) + { + using XlsbRowWriter row = sheet.StartRow(); + row.Write(string.Create(CultureInfo.InvariantCulture, $"value {i:D6} padding padding padding")); + } + } + workbook.End(); + } + else + { + using XlsxWorkbookWriter workbook = XlsxWorkbookWriter.Create(ms, leaveOpen: true, new XlsxWriterOptions { UseSharedStrings = sharedStrings }); + using (XlsxSheetWriter sheet = workbook.AddSheet("S")) + { + for (int i = 0; i < BudgetRows; i++) + { + using XlsxRowWriter row = sheet.StartRow(); + row.Write(string.Create(CultureInfo.InvariantCulture, $"value {i:D6} padding padding padding")); + } + } + workbook.End(); + } + return ms.ToArray(); + } + + private static (long Total, long Sheet) EntrySizes(byte[] package, bool xlsb) + { + using MemoryStream ms = new(package); + using ZipArchive zip = new(ms, ZipArchiveMode.Read); + long total = zip.Entries.Sum(entry => entry.Length); + long sheet = zip.GetEntry(xlsb ? "xl/worksheets/sheet1.bin" : "xl/worksheets/sheet1.xml")!.Length; + return (total, sheet); + } + + private static int CountRows(IExcelSheet sheet) + { + int rows = 0; + foreach (Row row in sheet) + { + rows++; + } + return rows; + } + [Fact] public async Task LimitedReadStreamCoversUnsupportedOperationsAndByteArrayAsyncRead() { From 518c2d78c9e753519e0df99f9b6f0e82a0d5c2f5 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Mon, 12 Oct 2026 20:31:49 -0300 Subject: [PATCH 114/115] ci: optimize workflow triggers by limiting branches for benchmarks and native bindings --- .github/workflows/benchmark-pr.yml | 3 ++- .github/workflows/ci.yml | 7 +++++-- .github/workflows/codeql.yml | 10 ++++++++-- .github/workflows/native-bindings.yml | 7 ++++++- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/.github/workflows/benchmark-pr.yml b/.github/workflows/benchmark-pr.yml index 059d97da..ab3cd3f5 100644 --- a/.github/workflows/benchmark-pr.yml +++ b/.github/workflows/benchmark-pr.yml @@ -22,7 +22,8 @@ jobs: runs-on: ubuntu-latest outputs: benchmarks: ${{ steps.filter.outputs.benchmarks }} - run: ${{ steps.filter.outputs.benchmarks == 'true' && (github.base_ref == 'master' || contains(github.event.pull_request.labels.*.name, 'run-benchmarks')) }} + # Opt-in on every base branch: the suite is ~40 runner-minutes, too much to repeat on each push. + run: ${{ steps.filter.outputs.benchmarks == 'true' && contains(github.event.pull_request.labels.*.name, 'run-benchmarks') }} steps: - name: Checkout uses: actions/checkout@v7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7953c3bf..3a5f5eba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,9 @@ jobs: os: [ubuntu-latest, windows-latest, macos-latest] env: RUN: ${{ needs.changes.outputs.dotnet }} + # The whole solution compiles the same on every OS, so Linux builds all of it and the others + # build only what the tests need. + BUILD_TARGET: ${{ matrix.os == 'ubuntu-latest' && 'ExcelReader.slnx' || 'tests/ExcelReader.Tests/ExcelReader.Tests.csproj' }} steps: - name: Nothing to build @@ -85,11 +88,11 @@ jobs: - name: Restore if: env.RUN == 'true' - run: dotnet restore ExcelReader.slnx + run: dotnet restore ${{ env.BUILD_TARGET }} - name: Build (Release) if: env.RUN == 'true' - run: dotnet build ExcelReader.slnx --configuration Release --no-restore -p:DeterministicSourcePaths=false + run: dotnet build ${{ env.BUILD_TARGET }} --configuration Release --no-restore -p:DeterministicSourcePaths=false - name: Verify formatting (whitespace) if: env.RUN == 'true' && matrix.os == 'ubuntu-latest' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b93ef95d..2012de50 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,8 +1,9 @@ name: CodeQL on: + # develop is covered by its pull request into master; a push trigger there would run everything twice. push: - branches: [ master, develop ] + branches: [ master ] pull_request: branches: [ master, develop ] types: [ opened, synchronize, reopened, ready_for_review ] @@ -89,9 +90,14 @@ jobs: languages: ${{ matrix.language }} queries: security-extended + # Only the shipped projects: tests, benchmarks and the fuzz harness double the build for code + # no one runs against untrusted input. - name: Build (Release) if: matrix.language == 'csharp' - run: dotnet build ExcelReader.slnx --configuration Release + run: | + for project in src/*/*.csproj; do + dotnet build "$project" --configuration Release + done - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/native-bindings.yml b/.github/workflows/native-bindings.yml index 754d53c1..7d4afc11 100644 --- a/.github/workflows/native-bindings.yml +++ b/.github/workflows/native-bindings.yml @@ -1,8 +1,9 @@ name: Native bindings on: + # develop is covered by its pull request into master; a push trigger there would run everything twice. push: - branches: [master, develop] + branches: [master] pull_request: branches: [master, develop] types: [opened, synchronize, reopened, ready_for_review] @@ -145,13 +146,17 @@ jobs: EXCELREADER_NATIVE_LIB_DIR: ${{ github.workspace }}/${{ steps.lib.outputs.dir }} run: cargo test --workspace --all-features --verbose + # The next two compile the crate again (default features, then the bench profile); that + # catches nothing platform-specific the test build above missed, so one OS is enough. - name: cargo build (default features) + if: runner.os == 'Linux' working-directory: rust env: EXCELREADER_NATIVE_LIB_DIR: ${{ github.workspace }}/${{ steps.lib.outputs.dir }} run: cargo build --workspace --verbose - name: cargo bench (smoke) + if: runner.os == 'Linux' working-directory: rust env: EXCELREADER_NATIVE_LIB_DIR: ${{ github.workspace }}/${{ steps.lib.outputs.dir }} From b796cfcbfb21959bb9149faa0cdf732840e700c9 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Mon, 12 Oct 2026 20:42:42 -0300 Subject: [PATCH 115/115] ci: disable analyzers during builds to improve performance across workflows --- .github/workflows/benchmark-pr.yml | 4 ++-- .github/workflows/ci.yml | 9 ++++++--- .github/workflows/codeql.yml | 2 +- .github/workflows/docs.yml | 2 +- .github/workflows/fuzz.yml | 4 ++-- .github/workflows/native-bindings.yml | 2 ++ 6 files changed, 14 insertions(+), 9 deletions(-) diff --git a/.github/workflows/benchmark-pr.yml b/.github/workflows/benchmark-pr.yml index ab3cd3f5..ecbbcad9 100644 --- a/.github/workflows/benchmark-pr.yml +++ b/.github/workflows/benchmark-pr.yml @@ -67,7 +67,7 @@ jobs: cache-dependency-path: '**/*.csproj' - name: Build (Release) - run: dotnet build tests/ExcelReader.Benchmarks/ExcelReader.Benchmarks.csproj --configuration Release + run: dotnet build tests/ExcelReader.Benchmarks/ExcelReader.Benchmarks.csproj --configuration Release -p:RunAnalyzers=false - name: Check group coverage run: bash tests/ExcelReader.Benchmarks/check-benchmark-groups.sh @@ -95,7 +95,7 @@ jobs: run: dotnet restore tests/ExcelReader.Benchmarks/ExcelReader.Benchmarks.csproj - name: Build (Release) - run: dotnet build tests/ExcelReader.Benchmarks/ExcelReader.Benchmarks.csproj --configuration Release --no-restore + run: dotnet build tests/ExcelReader.Benchmarks/ExcelReader.Benchmarks.csproj --configuration Release --no-restore -p:RunAnalyzers=false - name: Run benchmarks run: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a5f5eba..23f980d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,9 @@ jobs: # The whole solution compiles the same on every OS, so Linux builds all of it and the others # build only what the tests need. BUILD_TARGET: ${{ matrix.os == 'ubuntu-latest' && 'ExcelReader.slnx' || 'tests/ExcelReader.Tests/ExcelReader.Tests.csproj' }} + # Analyzers are about two thirds of the compile and report the same on every OS, so only the + # Linux build runs them; every other build in CI skips them. Source generators still run. + BUILD_ARGS: ${{ matrix.os != 'ubuntu-latest' && '-p:RunAnalyzers=false' || '' }} steps: - name: Nothing to build @@ -92,7 +95,7 @@ jobs: - name: Build (Release) if: env.RUN == 'true' - run: dotnet build ${{ env.BUILD_TARGET }} --configuration Release --no-restore -p:DeterministicSourcePaths=false + run: dotnet build ${{ env.BUILD_TARGET }} --configuration Release --no-restore -p:DeterministicSourcePaths=false ${{ env.BUILD_ARGS }} - name: Verify formatting (whitespace) if: env.RUN == 'true' && matrix.os == 'ubuntu-latest' @@ -159,7 +162,7 @@ jobs: cache-dependency-path: '**/*.csproj' - name: Build (Release) - run: dotnet build tests/ExcelReader.Tests/ExcelReader.Tests.csproj --configuration Release + run: dotnet build tests/ExcelReader.Tests/ExcelReader.Tests.csproj --configuration Release -p:RunAnalyzers=false - name: Test (Release) run: dotnet test --project tests/ExcelReader.Tests/ExcelReader.Tests.csproj --configuration Release --no-build @@ -192,7 +195,7 @@ jobs: - name: Pack and verify the generator ships inside the nupkg shell: pwsh run: | - dotnet pack src/ExcelReader.Core/ExcelReader.Core.csproj -c Release -o ./artifacts + dotnet pack src/ExcelReader.Core/ExcelReader.Core.csproj -c Release -o ./artifacts -p:RunAnalyzers=false $pkg = Get-ChildItem ./artifacts/*.nupkg | Select-Object -First 1 Add-Type -AssemblyName System.IO.Compression.FileSystem $zip = [System.IO.Compression.ZipFile]::OpenRead($pkg.FullName) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2012de50..a0b08d88 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -96,7 +96,7 @@ jobs: if: matrix.language == 'csharp' run: | for project in src/*/*.csproj; do - dotnet build "$project" --configuration Release + dotnet build "$project" --configuration Release -p:RunAnalyzers=false done - name: Perform CodeQL Analysis diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 8aeea115..14b6e992 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -58,7 +58,7 @@ jobs: # FailedToResolveAnalyzer. - name: Build site run: | - dotnet build src/ExcelReader.Generator/ExcelReader.Generator.csproj --configuration Release + dotnet build src/ExcelReader.Generator/ExcelReader.Generator.csproj --configuration Release -p:RunAnalyzers=false dotnet tool install --global docfx --version 2.81.0 docfx docfx.json --warningsAsErrors diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index d633c942..d0dd6254 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -76,7 +76,7 @@ jobs: - name: Publish harness run: | - dotnet publish tests/ExcelReader.Fuzz/ExcelReader.Fuzz.csproj -c Release -o fuzz-out + dotnet publish tests/ExcelReader.Fuzz/ExcelReader.Fuzz.csproj -c Release -o fuzz-out -p:RunAnalyzers=false chmod +x fuzz-out/ExcelReader.Fuzz - name: Generate seed corpus @@ -159,7 +159,7 @@ jobs: - name: Build if: env.RUN == 'true' - run: dotnet build tests/ExcelReader.Fuzz/ExcelReader.Fuzz.csproj -c Release + run: dotnet build tests/ExcelReader.Fuzz/ExcelReader.Fuzz.csproj -c Release -p:RunAnalyzers=false - name: Generate corpus if: env.RUN == 'true' diff --git a/.github/workflows/native-bindings.yml b/.github/workflows/native-bindings.yml index 7d4afc11..06d6862d 100644 --- a/.github/workflows/native-bindings.yml +++ b/.github/workflows/native-bindings.yml @@ -77,6 +77,8 @@ jobs: - name: Build the native library shell: bash + env: + RunAnalyzers: 'false' run: python python/scripts/build_native.py - name: Upload native library /// Gets the 1-based row holding column names. 0 means a header-less sheet, whose columns come diff --git a/tests/ExcelReader.Benchmarks/Interop/DataReaderBenchmark.cs b/tests/ExcelReader.Benchmarks/Interop/DataReaderBenchmark.cs index dd62d122..81710035 100644 --- a/tests/ExcelReader.Benchmarks/Interop/DataReaderBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/Interop/DataReaderBenchmark.cs @@ -48,7 +48,7 @@ public long Baseline_RawRows() public long DataReader_GetValue() { using IExcelWorkbook reader = OpenReader(); - using IDataReader data = new ExcelDataReader(reader.FirstSheet); + using IDataReader data = new SheetDataReader(reader.FirstSheet); long acc = 0; while (data.Read()) { @@ -64,7 +64,7 @@ public long DataReader_GetValue() public long DataReader_TypedGetters() { using IExcelWorkbook reader = OpenReader(); - using IDataReader data = new ExcelDataReader(reader.FirstSheet); + using IDataReader data = new SheetDataReader(reader.FirstSheet); long acc = 0; while (data.Read()) { @@ -80,7 +80,7 @@ public long DataReader_TypedGetters() public long DataReader_GetBytes() { using IExcelWorkbook reader = OpenReader(); - using IDataReader data = new ExcelDataReader(reader.FirstSheet); + using IDataReader data = new SheetDataReader(reader.FirstSheet); long acc = 0; while (data.Read()) { @@ -93,7 +93,7 @@ public long DataReader_GetBytes() public int DataTable_Load() { using IExcelWorkbook reader = OpenReader(); - using IDataReader data = new ExcelDataReader(reader.FirstSheet); + using IDataReader data = new SheetDataReader(reader.FirstSheet); var table = new DataTable(); table.Load(data); return table.Rows.Count; diff --git a/tests/ExcelReader.Tests/Reader/SheetConsumerTests.cs b/tests/ExcelReader.Tests/Reader/SheetConsumerTests.cs index 28a9c550..3c100dff 100644 --- a/tests/ExcelReader.Tests/Reader/SheetConsumerTests.cs +++ b/tests/ExcelReader.Tests/Reader/SheetConsumerTests.cs @@ -167,16 +167,16 @@ public void InferSchema_Samples_The_Sheet_It_Is_Given() } [Fact] - public void ExcelDataReader_Reads_The_Sheet_It_Is_Given_And_Names_Its_Table_After_It() + public void SheetDataReader_Reads_The_Sheet_It_Is_Given_And_Names_Its_Table_After_It() { using XlsxWorkbook workbook = Excel.FromXlsx(BuildXlsx()); - using ExcelDataReader data = new(workbook.Sheets[1]); + using SheetDataReader data = new(workbook.Sheets[1]); Assert.Equal(2, data.FieldCount); Assert.True(data.Read()); Assert.Equal("s1-0", data.GetString(0)); using DataTable schema = data.GetSchemaTable()!; Assert.Equal("S1", schema.Rows[0][SchemaTableColumn.BaseTableName]); - Assert.Throws(() => new ExcelDataReader((IExcelSheet)null!)); + Assert.Throws(() => new SheetDataReader((IExcelSheet)null!)); } [Fact] diff --git a/tests/ExcelReader.Tests/Reader/ExcelDataReaderTests.cs b/tests/ExcelReader.Tests/Reader/SheetDataReaderTests.cs similarity index 90% rename from tests/ExcelReader.Tests/Reader/ExcelDataReaderTests.cs rename to tests/ExcelReader.Tests/Reader/SheetDataReaderTests.cs index 94d059ad..e725b8f9 100644 --- a/tests/ExcelReader.Tests/Reader/ExcelDataReaderTests.cs +++ b/tests/ExcelReader.Tests/Reader/SheetDataReaderTests.cs @@ -9,7 +9,7 @@ namespace ExcelReader.Tests.Reader { - public class ExcelDataReaderTests + public class SheetDataReaderTests { [Fact] public async Task HeaderRowFixesNamesAndFieldCount() @@ -18,7 +18,7 @@ public async Task HeaderRowFixesNamesAndFieldCount() ["Id", "Name"], [1, "alice"]); await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader.FirstSheet); + using var data = new SheetDataReader(reader.FirstSheet); Assert.Equal(2, data.FieldCount); Assert.Equal("Id", data.GetName(0)); @@ -36,7 +36,7 @@ public async Task ReadWalksEveryDataRowAfterTheHeader() [2], [3]); await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader.FirstSheet); + using var data = new SheetDataReader(reader.FirstSheet); var seen = new List(); while (data.Read()) @@ -53,7 +53,7 @@ public async Task GetValueMapsEachCellTypeToItsClrType() ["Text", "Number", "Flag", "When", "Blank"], ["hi", 42, true, new DateTime(2024, 1, 1), null]); await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader.FirstSheet); + using var data = new SheetDataReader(reader.FirstSheet); Assert.True(data.Read()); Assert.Equal("hi", data.GetValue(0)); @@ -69,7 +69,7 @@ public async Task NoHeaderSynthesizesColumnNamesFromFirstRowWidth() { await using var ms = await TypedWorkbook.BuildAsync([1, 2, 3]); await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader.FirstSheet, new ExcelDataReaderOptions { HeaderRow = 0 }); + using var data = new SheetDataReader(reader.FirstSheet, new SheetDataReaderOptions { HeaderRow = 0 }); Assert.Equal(3, data.FieldCount); Assert.Equal("Column0", data.GetName(0)); @@ -83,7 +83,7 @@ public async Task HeaderRowBeyondSheetSizeIsAnEmptyResultSet() { await using var ms = await TypedWorkbook.BuildAsync(["Id"]); await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader.FirstSheet, new ExcelDataReaderOptions { HeaderRow = 5 }); + using var data = new SheetDataReader(reader.FirstSheet, new SheetDataReaderOptions { HeaderRow = 5 }); Assert.Equal(0, data.FieldCount); Assert.False(data.Read()); @@ -94,7 +94,7 @@ public async Task GetOrdinalForUnknownNameThrows() { await using var ms = await TypedWorkbook.BuildAsync(["Id"], [1]); await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader.FirstSheet); + using var data = new SheetDataReader(reader.FirstSheet); Assert.Throws(() => data.GetOrdinal("DoesNotExist")); } @@ -104,7 +104,7 @@ public async Task GetValueBeforeReadThrows() { await using var ms = await TypedWorkbook.BuildAsync(["Id"], [1]); await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader.FirstSheet); + using var data = new SheetDataReader(reader.FirstSheet); Assert.Throws(() => data.GetValue(0)); } @@ -116,7 +116,7 @@ public async Task NextResultAlwaysReturnsFalse() ("A", [[1]]), ("B", [[2]])); await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader.FirstSheet); + using var data = new SheetDataReader(reader.FirstSheet); Assert.False(data.NextResult()); } @@ -126,7 +126,7 @@ public async Task GetBytesReturnsTheCellsUtf8BytesWindowedByOffsetAndLength() { await using var ms = await TypedWorkbook.BuildAsync(["Text"], ["héllo"]); await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader.FirstSheet); + using var data = new SheetDataReader(reader.FirstSheet); Assert.True(data.Read()); byte[] expected = Encoding.UTF8.GetBytes("héllo"); @@ -148,7 +148,7 @@ public async Task GetBytesFormatsANumericCellInsteadOfReturningNothing() { await using var ms = await TypedWorkbook.BuildAsync(["Number"], [42]); await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader.FirstSheet); + using var data = new SheetDataReader(reader.FirstSheet); Assert.True(data.Read()); byte[] buffer = new byte[8]; @@ -161,7 +161,7 @@ public async Task GetBytesOnADbNullCellReturnsZero() { await using var ms = await TypedWorkbook.BuildAsync(["Blank"], [null]); await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader.FirstSheet); + using var data = new SheetDataReader(reader.FirstSheet); Assert.True(data.Read()); Assert.Equal(0, data.GetBytes(0, 0, null, 0, 0)); @@ -174,7 +174,7 @@ public async Task GetCharsReturnsTheCellsTextWindowedByOffsetAndLength() { await using var ms = await TypedWorkbook.BuildAsync(["Text"], ["hello world"]); await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader.FirstSheet); + using var data = new SheetDataReader(reader.FirstSheet); Assert.True(data.Read()); Assert.Equal(11, data.GetChars(0, 0, null, 0, 0)); @@ -206,7 +206,7 @@ public async Task GetBytesFormatsAnXlsbNumericCellFromItsBinaryValue() ms.Position = 0; using XlsbWorkbook reader = Excel.FromXlsb(ms); - using var data = new ExcelDataReader(reader.FirstSheet); + using var data = new SheetDataReader(reader.FirstSheet); Assert.True(data.Read()); byte[] buffer = new byte[8]; @@ -219,7 +219,7 @@ public async Task TypedGettersParseANumericCellIntoEveryIntegralAndFloatingType( { await using var ms = await TypedWorkbook.BuildAsync(["Number"], [42]); await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader.FirstSheet); + using var data = new SheetDataReader(reader.FirstSheet); Assert.True(data.Read()); Assert.Equal((byte)42, data.GetByte(0)); @@ -238,7 +238,7 @@ public async Task GetBooleanAndGetDateTimeReadTheirOwnCellTypesAndRejectText() ["Flag", "When", "Text"], [true, new DateTime(2024, 3, 7), "nope"]); await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader.FirstSheet); + using var data = new SheetDataReader(reader.FirstSheet); Assert.True(data.Read()); Assert.True(data.GetBoolean(0)); @@ -255,7 +255,7 @@ public async Task GetGuidParsesATextCellAndRejectsOneThatIsNotAGuid() ["Id", "Text"], [guid.ToString(), "nope"]); await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader.FirstSheet); + using var data = new SheetDataReader(reader.FirstSheet); Assert.True(data.Read()); Assert.Equal(guid, data.GetGuid(0)); @@ -269,7 +269,7 @@ public async Task GetCharTakesTheFirstCharacterAndRejectsAnEmptyCell() ["Text", "Blank"], ["hello", null]); await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader.FirstSheet); + using var data = new SheetDataReader(reader.FirstSheet); Assert.True(data.Read()); Assert.Equal('h', data.GetChar(0)); @@ -283,7 +283,7 @@ public async Task GetFieldTypeFollowsTheCurrentRowsOwnCellTypes() ["Text", "Number", "Flag", "When", "Blank"], ["hi", 42, true, new DateTime(2024, 1, 1), null]); await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader.FirstSheet); + using var data = new SheetDataReader(reader.FirstSheet); Assert.True(data.Read()); Assert.Equal(typeof(string), data.GetFieldType(0)); @@ -299,7 +299,7 @@ public async Task IndexersAndTheFixedAdoMembersReportTheDocumentedValues() { await using var ms = await TypedWorkbook.BuildAsync(["Id", "Name"], [1, "alice"]); await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader.FirstSheet); + using var data = new SheetDataReader(reader.FirstSheet); Assert.True(data.Read()); Assert.Equal(1.0, data[0]); @@ -317,7 +317,7 @@ public async Task DataTableLoadPopulatesFromTheSheet() [1, "alice"], [2, "bob"]); await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader.FirstSheet); + using var data = new SheetDataReader(reader.FirstSheet); var table = new DataTable(); table.Load(data); @@ -353,7 +353,7 @@ public void AFailedConstructorDoesNotKeepTheReaderAlive() XlsxWorkbook reader = Excel.FromXlsx(stream, leaveOpen: false, new ExcelReaderOptions { MaxCellBytes = 64 }); Assert.Throws(() => { - using ExcelDataReader data = new(reader.FirstSheet); + using SheetDataReader data = new(reader.FirstSheet); }); reader.Dispose(); Assert.False(stream.CanRead); diff --git a/tests/ExcelReader.Tests/Reader/Tables/TableBindingTests.cs b/tests/ExcelReader.Tests/Reader/Tables/TableBindingTests.cs index e4fa5500..ff90b8c3 100644 --- a/tests/ExcelReader.Tests/Reader/Tables/TableBindingTests.cs +++ b/tests/ExcelReader.Tests/Reader/Tables/TableBindingTests.cs @@ -71,7 +71,7 @@ public void HeaderlessTableRejectsBindingByName(string file) IExcelSheet view = Table(workbook, "NoHeader").AsSheet(); Assert.Throws(() => ExcelParser.FromAttributes().Parse(view)); - Assert.Throws(() => new ExcelDataReader(view)); + Assert.Throws(() => new SheetDataReader(view)); Assert.Throws(() => Excel.InferSchema(view)); } @@ -82,7 +82,7 @@ public void HeaderlessTableReadsWithoutAHeader(string file) using IExcelWorkbook workbook = Excel.Open(TableMetadataTests.FixturePath(file)); IExcelSheet view = Table(workbook, "NoHeader").AsSheet(); - using ExcelDataReader reader = new(view, new ExcelDataReaderOptions { HeaderRow = 0 }); + using SheetDataReader reader = new(view, new SheetDataReaderOptions { HeaderRow = 0 }); List letters = []; while (reader.Read()) { @@ -99,7 +99,7 @@ public void DataReaderOrdinalsStartAtTheTableFirstColumn(string file) { using IExcelWorkbook workbook = Excel.Open(TableMetadataTests.FixturePath(file)); - using ExcelDataReader reader = new(Table(workbook, "Sales").AsSheet()); + using SheetDataReader reader = new(Table(workbook, "Sales").AsSheet()); Assert.True(reader.Read()); Assert.Equal(3, reader.FieldCount); @@ -115,9 +115,9 @@ public void DataReaderOrdinalsLineUpWithOrWithoutAHeaderRow(string file) using IExcelWorkbook workbook = Excel.Open(TableMetadataTests.FixturePath(file)); IExcelSheet view = Table(workbook, "NoStyle").AsSheet(); - using ExcelDataReader named = new(view, new ExcelDataReaderOptions { HeaderRow = 1 }); + using SheetDataReader named = new(view, new SheetDataReaderOptions { HeaderRow = 1 }); Assert.True(named.Read()); - using ExcelDataReader unnamed = new(view, new ExcelDataReaderOptions { HeaderRow = 0 }); + using SheetDataReader unnamed = new(view, new SheetDataReaderOptions { HeaderRow = 0 }); Assert.True(unnamed.Read()); Assert.Equal((2, "Name", "Value", "x"), (named.FieldCount, named.GetName(0), named.GetName(1), named.GetString(0))); From 584acd44c9b997a9d54e5831b6598fcb54225eee Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Sun, 11 Oct 2026 01:55:00 -0300 Subject: [PATCH 093/115] refactor!: type formula cells by their cached result and remove CellType.Formula CellType.Formula meant a different thing per format: XLSX used it for a text result, XLS for a numeric result (dropping Date for a date-styled formula), and XLSB never used it. Every format now reports the result's own type. CellType.Error keeps its value 6, and the native XL_CELL_FORMULA constant stays defined but is no longer produced. --- docs/guide/migrating-to-v7.md | 4 ++++ .../PublicAPI/PublicAPI.Shipped.txt | 1 - src/ExcelReader.Core/Reader/CellType.cs | 10 ++++++---- .../Reader/Xls/XlsWorkbook.Enumerator.cs | 6 +++--- .../Reader/Xlsx/XlsxWorkbook.Enumerator.cs | 4 ++-- src/ExcelReader.Native/include/excelreader.h | 2 +- .../ExcelReader.Tests/Reader/CellVariantTests.cs | 4 ++-- .../Reader/Xls/XlsWorkbookBuilder.cs | 4 ++++ .../Reader/Xls/XlsWorkbookTests.cs | 16 +++++++++++++++- .../Reader/Xlsx/RealWorldXlsxCorpusTests.cs | 2 +- .../Reader/Xlsx/XlsxDialectShapeTests.cs | 2 +- .../Reader/Xlsx/XlsxProducerDialectShapeTests.cs | 2 +- 12 files changed, 40 insertions(+), 17 deletions(-) diff --git a/docs/guide/migrating-to-v7.md b/docs/guide/migrating-to-v7.md index 235c0476..1f4e5c09 100644 --- a/docs/guide/migrating-to-v7.md +++ b/docs/guide/migrating-to-v7.md @@ -108,6 +108,7 @@ These changes replace positional parameters and loose arguments with types that | `XlsWorkbookWriter.Create(stream, leaveOpen, date1904)` | `XlsWorkbookWriter.Create(stream, leaveOpen, new XlsWriterOptions { Date1904 = … })` | | `Excel.FromCsv*(…, CsvReaderOptions options)` | `Excel.FromCsv*(…, new ExcelReaderOptions { Csv = options })` | | `CsvReaderOptions.MaxCellBytes`, `CsvReaderOptions.InternStrings` | `ExcelReaderOptions.MaxCellBytes`, `ExcelReaderOptions.InternStrings` | +| `CellType.Formula` | Removed. A formula cell reports its cached result's type: `Number`, `Date`, `ExcelString`, `Boolean` or `Error` | | `ExcelDataReader` (its name collided with Sylvan.Data.Excel and the ExcelDataReader package) | `SheetDataReader` | | `Cell.TryGetDateTime(out DateTime)`, which silently assumed the 1900 date system | `Cell.TryGetDateTime(workbook.IsDate1904, out DateTime)` | @@ -128,6 +129,7 @@ To unit-test a converter, build the context with an initializer: `new ExcelRowCo Two behavior changes come with them: - `Excel.Open` with `ExcelFileFormat.Csv` now applies `ExcelReaderOptions.MaxCellBytes` and `InternStrings`. v6 ignored both and used the CSV defaults. +- Formula cells are typed by their cached result in every format. v6 reported an XLSX text result and an XLS numeric result as `Formula`, so an XLS formula with a date style lost its `Date` type, and XLSB never used `Formula`. - The CLI `convert` command no longer shifts dates by four years when the source workbook uses the 1904 date system. - `XlsxWriterOptions` gains `Date1904`, which v6 lacked. It defaults to `false`, so existing output is unchanged. @@ -135,6 +137,8 @@ Two behavior changes come with them: The C ABI is unchanged: no signature moved, and `XL_ABI_VERSION` stays 6. Behavior changed. `degree_of_parallelism` of `0` on `xl_parse_typed` and the Arrow exports now parallelises XLSX and XLSB as well as CSV, using every processor. Pass `1` for the old sequential behavior. The Rust, Python and C++ bindings take the same value (`degree_of_parallelism`, `parallelism`) and need no code change; a `parallelism=0` call on an XLSX or XLSB sheet is now faster, and holds every partition's columns until they merge, so its peak memory is higher. +`XL_CELL_FORMULA` (5) is no longer produced; formula cells come back with their result's cell type. The constant stays defined in the header and in the bindings. + `xl_write_options.date1904` now applies to XLSX as well as XLSB and XLS; v6 ignored it for XLSX. `xl_csv_aggregate` keeps its signature. Inside, it opens a `CsvReader` with the dialect from the native struct and calls `SheetParallel`. diff --git a/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt b/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt index 9a57304e..64ceb60b 100644 --- a/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt +++ b/src/ExcelReader.Core/PublicAPI/PublicAPI.Shipped.txt @@ -108,7 +108,6 @@ ExcelReader.Core.Reader.CellType.Date = 3 -> ExcelReader.Core.Reader.CellType ExcelReader.Core.Reader.CellType.Empty = 0 -> ExcelReader.Core.Reader.CellType ExcelReader.Core.Reader.CellType.Error = 6 -> ExcelReader.Core.Reader.CellType ExcelReader.Core.Reader.CellType.ExcelString = 1 -> ExcelReader.Core.Reader.CellType -ExcelReader.Core.Reader.CellType.Formula = 5 -> ExcelReader.Core.Reader.CellType ExcelReader.Core.Reader.CellType.Number = 2 -> ExcelReader.Core.Reader.CellType ExcelReader.Core.Reader.RowAggregation ExcelReader.Core.Reader.RowAggregation.Accumulate.get -> ExcelReader.Core.Reader.RowAction! diff --git a/src/ExcelReader.Core/Reader/CellType.cs b/src/ExcelReader.Core/Reader/CellType.cs index c40c279a..d770a84d 100644 --- a/src/ExcelReader.Core/Reader/CellType.cs +++ b/src/ExcelReader.Core/Reader/CellType.cs @@ -1,13 +1,17 @@ +using System.Diagnostics.CodeAnalysis; + namespace ExcelReader.Core.Reader { /// /// Identifies the kind of value a holds. /// + /// A formula cell reports the type of its cached result, the same in every format. + [SuppressMessage("Design", "CA1027:Mark enums with FlagsAttribute", Justification = "Not flags: the values match the native XL_CELL_* codes, and 5 belonged to the removed Formula.")] public enum CellType { /// The cell has no value. Empty, - /// The cell holds text (a shared string or inline string). + /// The cell holds text (a shared string, an inline string, or a formula's text result). ExcelString, /// The cell holds a numeric value. Number, @@ -15,9 +19,7 @@ public enum CellType Date, /// The cell holds a boolean value. Boolean, - /// The cell holds a formula's cached result. - Formula, /// The cell holds an Excel error value (e.g. #DIV/0!). - Error, + Error = 6, } } diff --git a/src/ExcelReader.Core/Reader/Xls/XlsWorkbook.Enumerator.cs b/src/ExcelReader.Core/Reader/Xls/XlsWorkbook.Enumerator.cs index 82e65d56..84beb389 100644 --- a/src/ExcelReader.Core/Reader/Xls/XlsWorkbook.Enumerator.cs +++ b/src/ExcelReader.Core/Reader/Xls/XlsWorkbook.Enumerator.cs @@ -308,12 +308,12 @@ private void ParseFormula(ReadOnlySpan data) } return; } - AddDouble(col, style, BinaryPrimitives.ReadDoubleLittleEndian(result), CellType.Formula); + AddDouble(col, style, BinaryPrimitives.ReadDoubleLittleEndian(result)); } - private void AddDouble(int col, int style, double value, CellType? forced = null) + private void AddDouble(int col, int style, double value) { - CellType type = forced ?? (_reader.IsDateStyle(style) ? CellType.Date : CellType.Number); + CellType type = _reader.IsDateStyle(style) ? CellType.Date : CellType.Number; _acc.Add(col, _acc.ValueLength, 0, type, style, CellValueSource.RowValues, number: value, hasNumber: true); } diff --git a/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Enumerator.cs b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Enumerator.cs index 12741b73..a9110bb9 100644 --- a/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Enumerator.cs +++ b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Enumerator.cs @@ -859,7 +859,7 @@ private void EmitScalarValue(Kind kind, ReadOnlySpan v, int col, int style { Kind.Bool => CellType.Boolean, Kind.Error => CellType.Error, - Kind.Formula => CellType.Formula, + Kind.Formula => CellType.ExcelString, _ => WorkbookLookups.IsDateStyle(_styleIsDate, style) ? CellType.Date : CellType.Number, }; int vStart = _acc.ValueLength; @@ -887,7 +887,7 @@ private void EmitScalarValueFast(Kind kind, ReadOnlySpan v, int valueStart { Kind.Bool => CellType.Boolean, Kind.Error => CellType.Error, - Kind.Formula => CellType.Formula, + Kind.Formula => CellType.ExcelString, _ => WorkbookLookups.IsDateStyle(_styleIsDate, style) ? CellType.Date : CellType.Number, }; double number = 0; diff --git a/src/ExcelReader.Native/include/excelreader.h b/src/ExcelReader.Native/include/excelreader.h index 4b53d87f..12cf422c 100644 --- a/src/ExcelReader.Native/include/excelreader.h +++ b/src/ExcelReader.Native/include/excelreader.h @@ -29,7 +29,7 @@ extern "C" { #define XL_CELL_NUMBER 2 #define XL_CELL_DATE 3 #define XL_CELL_BOOL 4 -#define XL_CELL_FORMULA 5 +#define XL_CELL_FORMULA 5 /* no longer produced: a formula cell reports its cached result type */ #define XL_CELL_ERROR 6 #define XL_MAX_COLUMN_SPECS 16384 diff --git a/tests/ExcelReader.Tests/Reader/CellVariantTests.cs b/tests/ExcelReader.Tests/Reader/CellVariantTests.cs index 44f1aac1..2bc0f781 100644 --- a/tests/ExcelReader.Tests/Reader/CellVariantTests.cs +++ b/tests/ExcelReader.Tests/Reader/CellVariantTests.cs @@ -31,14 +31,14 @@ public void ErrorCellHasErrorType() } [Fact] - public void FormulaCellHasFormulaType() + public void AFormulaTextResultIsAString() { using var ms = WorkbookBuilder.Build( """Hello"""); using var reader = Excel.FromXlsx(ms); using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); - Assert.Equal(CellType.Formula, e.Current[0].Type); + Assert.Equal(CellType.ExcelString, e.Current[0].Type); Assert.Equal("Hello", e.Current[0].GetString()); } diff --git a/tests/ExcelReader.Tests/Reader/Xls/XlsWorkbookBuilder.cs b/tests/ExcelReader.Tests/Reader/Xls/XlsWorkbookBuilder.cs index 5230335f..04dd0027 100644 --- a/tests/ExcelReader.Tests/Reader/Xls/XlsWorkbookBuilder.cs +++ b/tests/ExcelReader.Tests/Reader/Xls/XlsWorkbookBuilder.cs @@ -12,6 +12,7 @@ internal sealed record XlsDate(DateTime Value); internal sealed record XlsError(byte Code); internal sealed record XlsFormula(double Value); internal sealed record XlsFormulaBool(bool Value); + internal sealed record XlsFormulaDate(DateTime Value); internal sealed record XlsFormulaError(byte Code); internal sealed record XlsRkInt(int Value); internal sealed record XlsRkRaw(uint Value); @@ -319,6 +320,9 @@ private static void WriteCell( byte[] data = [.. U16(row), .. U16(col), .. U16(0), .. Double(f.Value), .. new byte[8]]; WriteRecord(sheet, 0x0006, data); break; + case XlsFormulaDate f: + WriteRecord(sheet, 0x0006, [.. U16(row), .. U16(col), .. U16(1), .. Double(f.Value.ToOADate()), .. new byte[8]]); + break; case XlsFormulaBool f: WriteRecord(sheet, 0x0006, [.. U16(row), .. U16(col), .. U16(0), .. FormulaSpecial(1, (byte)(f.Value ? 1 : 0)), .. new byte[8]]); break; diff --git a/tests/ExcelReader.Tests/Reader/Xls/XlsWorkbookTests.cs b/tests/ExcelReader.Tests/Reader/Xls/XlsWorkbookTests.cs index 79fe49be..514e1d66 100644 --- a/tests/ExcelReader.Tests/Reader/Xls/XlsWorkbookTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xls/XlsWorkbookTests.cs @@ -38,11 +38,25 @@ public void ReadsBasicBiff8Cells() Assert.Equal("1", row[2].GetString()); Assert.Equal(CellType.Error, row[3].Type); Assert.Equal("#DIV/0!", row[3].GetString()); - Assert.Equal(CellType.Formula, row[4].Type); + Assert.Equal(CellType.Number, row[4].Type); Assert.Equal("12.5", row[4].GetString()); Assert.False(e.MoveNext()); } + [Fact] + public void AFormulaWithADateStyleReadsAsADate() + { + var date = new DateTime(2024, 5, 6, 0, 0, 0, DateTimeKind.Unspecified); + using var ms = XlsWorkbookBuilder.Build(sheets: [("S1", [[new XlsFormulaDate(date)]])]); + using var reader = Excel.FromXls(ms); + + using var e = reader.FirstSheet.GetEnumerator(); + Assert.True(e.MoveNext()); + Assert.Equal(CellType.Date, e.Current[0].Type); + Assert.True(e.Current[0].TryGetDateTime(isDate1904: false, out DateTime parsed)); + Assert.Equal(date, parsed); + } + [Fact] public void ReadsAdditionalBiff8CellEncodings() { diff --git a/tests/ExcelReader.Tests/Reader/Xlsx/RealWorldXlsxCorpusTests.cs b/tests/ExcelReader.Tests/Reader/Xlsx/RealWorldXlsxCorpusTests.cs index a35682eb..ccb3324f 100644 --- a/tests/ExcelReader.Tests/Reader/Xlsx/RealWorldXlsxCorpusTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xlsx/RealWorldXlsxCorpusTests.cs @@ -19,7 +19,7 @@ public void ReadsSheetJsGeneratedWorkbook() Assert.Equal("price", e.Current[2].GetString()); Assert.Equal("in_stock", e.Current[3].GetString()); Assert.Equal("restock_date", e.Current[4].GetString()); - Assert.Equal(CellType.Formula, e.Current[0].Type); + Assert.Equal(CellType.ExcelString, e.Current[0].Type); Assert.True(e.MoveNext()); Assert.Equal("Widget", e.Current[0].GetString()); diff --git a/tests/ExcelReader.Tests/Reader/Xlsx/XlsxDialectShapeTests.cs b/tests/ExcelReader.Tests/Reader/Xlsx/XlsxDialectShapeTests.cs index 25ca20e9..abe8e018 100644 --- a/tests/ExcelReader.Tests/Reader/Xlsx/XlsxDialectShapeTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xlsx/XlsxDialectShapeTests.cs @@ -73,7 +73,7 @@ private static IEnumerable LibraryAndCloudFixtures() """, null, [ - new ExpectedCell(0, 0, "ab", CellType.Formula), + new ExpectedCell(0, 0, "ab", CellType.ExcelString), new ExpectedCell(0, 1, "#DIV/0!", CellType.Error), new ExpectedCell(0, 2, "0", CellType.Boolean), ]) diff --git a/tests/ExcelReader.Tests/Reader/Xlsx/XlsxProducerDialectShapeTests.cs b/tests/ExcelReader.Tests/Reader/Xlsx/XlsxProducerDialectShapeTests.cs index 566dfe0b..6aee085d 100644 --- a/tests/ExcelReader.Tests/Reader/Xlsx/XlsxProducerDialectShapeTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xlsx/XlsxProducerDialectShapeTests.cs @@ -72,7 +72,7 @@ private static IEnumerable DotNetAndJsProducerFixtures() [ new ExpectedCell(0, 0, CellType.Date, "45292"), new ExpectedCell(0, 1, CellType.Empty, ""), - new ExpectedCell(0, 2, CellType.Formula, "AB"), + new ExpectedCell(0, 2, CellType.ExcelString, "AB"), new ExpectedCell(0, 3, CellType.Empty, ""), new ExpectedCell(0, 4, CellType.Error, "#N/A"), ]) From 4569c30845d637c2316e9b66ec1a4a212a955452 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Sun, 11 Oct 2026 02:23:20 -0300 Subject: [PATCH 094/115] test(python): tie the GIL test's duration floor to the switch interval The batch parse got fast enough (about 40 ms for a million CSV rows on CI) to fall under the fixed 50 ms floor, which only exists so a gap is long compared with a thread switch. The test now drops the switch interval to 1 ms for the parse and requires 20 ms, still twenty switch intervals. --- python/tests/test_reader.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/python/tests/test_reader.py b/python/tests/test_reader.py index d08c554c..1f9888ba 100644 --- a/python/tests/test_reader.py +++ b/python/tests/test_reader.py @@ -1,6 +1,7 @@ import ctypes import gc import shutil +import sys import threading import time from pathlib import Path @@ -674,6 +675,8 @@ def tick(): holding_gil = ctypes.PYFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p) get_next = holding_gil(ctypes.cast(stream.get_next, ctypes.c_void_p).value) batch = _native.ArrowArray() + switch_interval = sys.getswitchinterval() + sys.setswitchinterval(0.001) ticker = threading.Thread(target=tick) ticker.start() try: @@ -683,6 +686,7 @@ def tick(): finally: stop.set() ticker.join() + sys.setswitchinterval(switch_interval) release_batch = ctypes.CFUNCTYPE(None, ctypes.c_void_p)(batch.release) release_batch(ctypes.addressof(batch)) del capsule @@ -691,7 +695,7 @@ def tick(): assert batch.length == rows inside = [start] + [t for t in ticks if start < t < end] + [end] longest_gap = max(b - a for a, b in zip(inside, inside[1:])) - assert end - start > 0.05, "the batch must parse long enough for the gap to mean something" + assert end - start > 0.02, "the batch must parse for many switch intervals for the gap to mean something" assert longest_gap < (end - start) / 2 From fff70b360ebd403441c4ff167f0fbf7b9a12dee4 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Sun, 11 Oct 2026 15:32:22 -0300 Subject: [PATCH 095/115] perf: load the next litlen entry before the refill that follows a match Loads the offset entry before deciding whether to refill, with libdeflate's thresholds (31 bits, 38 through a subtable), so at least 11 bits remain after the offset. The next litlen entry is then loaded from those bits before the refill instead of waiting on it. Inflate only, Ryzen 7 5700X, 65K-row sheets: XLSX 15.0 -> 12.7 ms, XLSB 9.5 -> 8.6 ms under the JIT; 14.9 -> 13.5 and 9.5 -> 8.9 ms under NativeAOT. Native libdeflate on the same streams is 13.5 and 8.8 ms. --- .../Zip/Inflate/InflateDecoder.FastLoop.cs | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/ExcelReader.Core/Reader/Zip/Inflate/InflateDecoder.FastLoop.cs b/src/ExcelReader.Core/Reader/Zip/Inflate/InflateDecoder.FastLoop.cs index e8c7db58..ee9aa369 100644 --- a/src/ExcelReader.Core/Reader/Zip/Inflate/InflateDecoder.FastLoop.cs +++ b/src/ExcelReader.Core/Reader/Zip/Inflate/InflateDecoder.FastLoop.cs @@ -8,6 +8,9 @@ namespace ExcelReader.Core.Reader.Zip.Inflate { internal sealed partial class InflateDecoder { + private const int OffsetRefill = 21 + InflateFormat.LitlenTableBits - 1; + private const int SubtableOffsetRefill = 28 + InflateFormat.LitlenTableBits - 1; + private enum LoopStatus { BlockDone, @@ -35,8 +38,10 @@ private unsafe LoopStatus RunFastLoop(byte[] input, ref int inPos, int inLimit, // base length or offset, or the subtable start. Bits 12-15 are clear on length and offset // entries, so `entry >> 8` needs no mask before it shifts out the extra bits. // - // Bit budget: a refill leaves at least 56 bits. Three literals from the main table take at - // most 33; a length takes at most 20 and an offset at most 28, hence the refill between them. + // Bit budget: a refill leaves at least 56 bits, and the buffer holds one valid bit beyond + // `bitsleft`. Two literals and a length take at most 42. The refill before an offset (21 + // bits, 28 through a subtable) keeps 11 bits behind it, so the next litlen entry is loaded + // before the refill that follows the match rather than after it. // // Only the low byte of `bitsleft` is meaningful: it subtracts whole entries, as libdeflate does. [SuppressMessage("Design", "MA0051:Method is too long", @@ -125,19 +130,25 @@ private unsafe LoopStatus FastLoop( uint length = (entry >> 16) + (uint)(LowBits(saved, entry & 0xFF) >> (int)(entry >> 8)); - if ((byte)bitsleft < 30) - { - bitbuf |= LoadWord(inNext) << (int)bitsleft; - inNext += 7 - ((bitsleft >> 3) & 7); - bitsleft |= 56; - } uint offsetEntry = tables[InflateFormat.LitlenTableSize + (bitbuf & ((1 << InflateFormat.OffsetTableBits) - 1))]; if ((offsetEntry & InflateFormat.Exceptional) != 0) { + if ((byte)bitsleft < SubtableOffsetRefill) + { + bitbuf |= LoadWord(inNext) << (int)bitsleft; + inNext += 7 - ((bitsleft >> 3) & 7); + bitsleft |= 56; + } bitbuf >>= InflateFormat.OffsetTableBits; bitsleft -= InflateFormat.OffsetTableBits; offsetEntry = tables[InflateFormat.LitlenTableSize + (offsetEntry >> 16) + (uint)LowBits(bitbuf, (offsetEntry >> 8) & 0x3F)]; } + else if ((byte)bitsleft < OffsetRefill) + { + bitbuf |= LoadWord(inNext) << (int)bitsleft; + inNext += 7 - ((bitsleft >> 3) & 7); + bitsleft |= 56; + } saved = bitbuf; bitbuf >>= (int)offsetEntry; bitsleft -= offsetEntry; @@ -152,10 +163,10 @@ private unsafe LoopStatus FastLoop( byte* destination = outNext; outNext += length; + entry = tables[bitbuf & mask]; bitbuf |= LoadWord(inNext) << (int)bitsleft; inNext += 7 - ((bitsleft >> 3) & 7); bitsleft |= 56; - entry = tables[bitbuf & mask]; CopyMatch(source, destination, outNext, offset); } From caa40386837465b79f40e9d0cae516c0283455ce Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Sun, 11 Oct 2026 15:36:30 -0300 Subject: [PATCH 096/115] perf(native): regenerate the static PGO profile after the inflate fast-loop change The fast-loop change and the v7 renames left ILC reporting "PGO data available, but IL did not match" for FastLoop. Recorded with NativePgoTrainer as the Native README describes, XLSB methods still excluded. AOT xl_parse_typed, 65K fixtures, min of three alternating runs: XLSX dop 0 19.1 -> 17.8 ms, dop 1 52.2 -> 51.0 ms; XLSB dop 0 14.8 -> 14.3 ms, dop 1 27.8 -> 27.1 ms; 160 MB CSV dop 1 396 -> 385 ms. CSV, XLS and the parallel 160 MB CSV are flat. --- src/ExcelReader.Native/pgo/excelreader.mibc | Bin 101715 -> 102103 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/src/ExcelReader.Native/pgo/excelreader.mibc b/src/ExcelReader.Native/pgo/excelreader.mibc index 1a2ce5d016b86cbffd586493f5b857a0dd5e7ccf..4e912f649529bb940073f8762654b0daa65ee4f9 100644 GIT binary patch literal 102103 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̰WU}%mD^D$_X3hYd94O{#Edr_ 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 G0000*xVoeO literal 101715 zcmV)xK$E{vO9KQH000080C>JiT?JPQm)DE|002G(01W^D0A+Y%Y%XnSVq-33Y;5d( z1(Y5|*7iG=OeXS1dn7Yc6(vgCl@Q{tKoa8a262MBJHg#uf(Lg9t_$q4%d+?`vcMw$ zxldPh-Ky$HC$sF#f4;zZru)9-Rb6uH%J73vPi3W2sq9oL_51Is)Yeq^uWxGC|2C&m zsT$(`8mYUgyx3rC#;_L~jGH%qrChdR>D(357t5K`mn>PjO3s)qSFBzl=P!}{M~;4oIbjWn`u@GP9@%`1esNC97t%O{Kc$q*9q7?Jo!Bq*5}KN=?fN zOGnR{VY%sF6nV3Wla=awOe$3q|C_(jAN)K0z?{^`omFyLPO4fem0ERRPO3PS;GayT za@lLHOr-{~_kCB*Ub8BdN`2Z@)r-}YB{6N^6|)yDotZ)zPzhjTOYPym zC#YRhVc9ZMIjP3aW~H{Dnvu$I!hJU}2mQUPe^0H*NTrsoC`qN3HEp`02|c$ApL@{r z0pas-dY*#k7O7O7wz;YN3&Z%^r&mhlHQhb~I1{)8xD>boxDvPuxE{EnQYvr7;#6vx zNQ;K@h>TP!UyKe!S~ONK2hmFh{US<5TJ+*S4dgEeg)f09U2!QDa{ES>e5nfAkU}<9 zFU^qRer~)?CSH*WnMcv~Q2_&%UMG6#Ws(17b(#dm!AA{h>0*?b84?MvnP$+Lt zrBeAc<~CEQ3+3afR7yw?wjZC7$|!e$b}DkpEs82g!56ZEhwrT1!7l!Zv{(^#vV1C) zN(CYTULq|oGh+o&R3@h*;d5BR2dMdK7@r}h>Mw#EqUR6dH zuq@OpC~5{GU7tOR&TboA%wpA1A&XG3cJA!b&Klx#QmOjo4lp~+c4n8^31^o@ZW~>K zF1*>alswDyb$1$AGcr>t{n_Xtp(5Xk$hQG+2i^g^6L=T!Zs0w@dx2Yl_W|z*J^*}> zbyr>0T@B>>R6o<}l4{XN6-50gFSiHK?WP8Iv(fqxa(Ni|2ymOpu|y|HLtUiRGL1H? zGQQpUZj{~0oJ_&pZ_%!et;IphPtEi@Hs_Le0K{-rFS(OG^$q(yU8`*wODv+@11n(kgUq&MiXNf`2dL&iX(nebzK?WHCO zX%(tf`nlML-7O}i>)3H}DH_bu={;P=2ESbJ7wpISXPfq%1r!}T|Ne)tVfQp0+xJRO5o z%y*W|ACcEj!2hs>hN*~SDa#2u6?&Pf^Oq`#UsyD~(l#q)g^5D`ifn%KSLr~s7im%A zlr2>`3aHbqEe46SsI6lSQXw-;NHN%*iUwO_ZzT=y@(Us@HmJI85ItzS7&DWAR-q~- zPh>Mm57ld#m1#I+vA(P3_Fc8|KW`W?!!$oIEC*BHY*y1+ZcS@dteT1js;W(7HJaVh zqK%$<=v~#g6030$o5PBA*eA@^B9Da~?zYbG3f4M@I#XFq4E1`Zip5S1iz`?Kh3ftm zY)J}VYVnso>SceL%OZZ-U|L5{H`}J`)dq|Hx*tt|mdghg01JUdY`9c$hf9@;H9Lx4 zZUuT-6}W;LzWjnniv_A@m!b5GxwTCZOm((Zfa)m5hnS1fuTpR8@V45m+-j?GS5h+N z7erdDQ`K3Hy#%-_Hg5@}gd8gNlv}zLXjzde zpn9Yk>yfH%g{oQ=I*g*0Ur-h5z$&EJLZXknDH}VNZiWz^ng;9b$w%RPyC^`vED$ue zV6dp7GU$j!sT0elx(YK!94gY_FzHlJHk+Q#)+(lce=pon>CNOw`CNvT>qya8q{SHh zNzD*xF;a6)C0z%Kw3woPEm9eEVHu^>Z~5lps6ZYQR&}9TlD8<9?>hD_R*EzpbU`VO zO1~@W-;3qa)EzfXt#MQS=SjVx6;%w^SoGIpWVCLCD@`NxMkDkG4m3?ssN&WSOs@uV zf3r;j&Rtbeu9D_jq9fg%gn9Uv-YyS>hCL8V!{t=R|W@D^} z%Z?Lk93}uIt{3O z^)T(ndRB}RX)#{E)KV$U@);_)F;)|TD3I5LgIo7!qkxUPcKX+zdN8p&Au(3}s+uLh zyUKVrE4&`J6UBIu#@D1qz{+IuEw1LC7y7Gw~)tKqYKL*t^Z zNXyj`O^lFhauI!-6Rx*N%X0f3bD6!lP)XiZLGrSP(M-U~s8LED-%w_=O0+$ouqhtS@BG4V_KiVn2$g z#=Z#ARs~$T`v<%K`B7o#penQyO?D`&R(-cx^()dIQKUsHw*svySOqj+VuejvwpMxTOSKZjP|oITd4%e4f8Lq}l2aaM7lgoVhcI-P;H9UuKTmf{}kw z%=#CwZTBRa!Y(%R(%ebO^N>1&^qpXrhE45_Cg_4Gs0 zU#FOdW_s;#Z%jJ`&gfMqDlrk=!nQ&a>DS5XWvc#F-9r|rclLqHOH{X>zf%$&+~k04 zXX_q?JlQ7cY_2rqYghs|uq~JhdB5GC&)Nb7I^w(jpxKI#?|#-~`K+1r=D!Ip*1ND@ zdb>BnFR~=+uz<~(Se5yfRiwB5I~#GZTwEEYJl{C=l9lUWXbX~=)sm;ch7|Dwd1MRzJ+^wyhA2Xr=Lt1xl9 z(xR_By1&@L5YU+o<5JrJmwgavc_~}^=!|!V{~3PC(pRk`24L?n%OiLzpnp6p})1R zU`@m6Reh`yj*7U8Q3G6+O0{%PcfHKSWnTu)56osRD#73BzU3Z6Fw}PEG z3f)~~PA*lrIQ1*{FM2MmDuzye?)uqfAbn!c6ANb0I{93j#|I?Y|5#j4KF z<9gfLF<_h?NC9caj;O6Kc#wYyuoQTW83xgn51$M=gaWu`#1rpQbml{TyUb1Hl~16; z_jNwkz6n29ElRZnHJ`5DrsHjiZeXDrm@;j;y(Q}^%{>mx{!y$41K8~4<%{Y&f69$+jB2k`h@mu)?7p+j!R&LPNsOi2eLMQ&gQ=Co}&*1atYs2fI}U|q*VpKUv|Q+r?sU`JplU}u(++AoI*^)~!#5O7qm7Y6V{|72*l z(PQ_H*tFHju*r9XdpSDkD{?QjbLwwG4p$`ltE=2w-$t>5)>I>-AYlA(k;kXZM*5No zwd@A$4(!1ailhRCwcC!`u4k@)nw7)C>oR>NDduv`Dqb%ZPjL+bd3<;g*weXY>y1+N z0q*DbtLP(&IY*(`>`=t2z=#@PplBH)IV&sL_V&y5w{LQDSe9xmF298i-;)WW4{_oT z*#lwp{d2vwyl%kQG$^+U+n?1dx)ZrI{05rEoflQS16VwLU&pyo^n5tm_R+3OH~s*X z#z2z>pI&RY;Rdn1dW1g~v;C(0yV}ud?ZT1J_K7kqX8kMAA+oC9NIO2rDvAmc2Dd}e zU*1rvkb^PwhOwf$s(r%N?Rz|x$|!e$4ruL+!PW`Ku;wq0L9GY5%`m8fG{Y+Q+%p_q zJR;Y>-R+_(W~A&QQHCh?NdK#>9q`#R?ua=GS&n8|>bOlASJ-4ShJ{ooaxs~4aZJdu zNO_$9396xLmt#CaPhhDxbi1`-`QIi))tP8*-FK=%Q~nP)?c563%5tYr(DDnaN2Xwa zP37N+E$vro3H!ZHSADwyAMR$jG6rYo*7uncR6LRHx5=%^X& z1JbOhee;=6gj~}B1<{Pmef6g|Gp4fEs;?)gStg*q!>J+;GINt?5EyCrKpdnc9!xZT zE4~Y%sN1OL$}c#ZEX{is3$#oF>kd6?nsbJ8A^QO=A-$g#b1fMk^!tPUrOboLFJ->+ zQvoUYes*r||7U^;^Kw0Ab3SA67NDWn;MVV2uU0qAeAY1SIXl<8kXV4Pb|G*Pa4~R6 z%rTDZi8#E?E7TsC{>F!(6Ro@--+uV4YH(@?=j4ir@0f^mTTG<~Zr$H}Fv%<3*m z%np^UO}yt#%5)(TSQjG*@wxr_W&E~&8Oto1+q#C6tUgT!%-D!Nk1o$mbX6Vf@TOY! z9Lkk~MKdND7RWD#MkQSl!&tOVeTR04!mjiu;Z_0Xf}Cx_uEIcB4LlTh7|W@ae_0}0 zi=NDQio9)el-3&m)F`^k+%3F0#ejebtBsYZN>x&ZeIJMcOzWYC+&necxdjX?;kH12 zb0J2a;mOft$jo+#9(rJ{jp0+UxsrGeITrIl9vQxCmbx4@9PgW0Q8xPncpb~90sH!s6sN1<_uv5)R!r);;>YR~E6^OK~YFI=C^f+xlM8>m9uWd*& zhB}ZVg#JugL@Mz~BTXdAjjr^Zdaq~o*7KgI$?qArEy(5?Z2`NBw#-rlOe~as;9{me>UW7N+9i4z!Dy%h>2q^?9rvBMrifpRZHsAh)TnjUZ4 z0zuAph76g`&1Znjdr+k1L?*kS3Ui1FL6w_kgg@)bZ5LG1U;PHw@A@MjE$c4Ax6++4 zZK6RBV^IV7%TWHJ1s0~t$qSLlc&;aXP@K}V9Je5vg*KY<=si&&w>8e@`_;y>@0ZjH z(KOY8-Ry`S@}p1jtFF$RQX>`}(DS_Lllm${=ex<9buGTMd}d`-#LYX{CF_^QAzy~w z7D z1fB&vJJ%QPuE(?&19?k+ROEAFcCw|ohA4H}_vf;pd2t*Si?6L?orf}A2;2g^2zYU> zFZCfC_eFj6K|)@3OqMR_C5Uw?@G@(Is@I(ZFhWK}1HSLjOiO>^qrsUvvCEOn6~HTj zR{^gMFZY1g0@(OwAAh3d3OG+PoaQAnGX=RknQ_q2DlM>~inv zgVliVcrOe|Eaca!znG?o9tiAKs!**2rH0f%k$M;si6B9W&5(L4Qa^i4Oi8zN0M zq^Ve)ex%Px&NSqu^jM5fHGEc;S`k+2d=v6&mF8JCN#1q{8tP_t-KIj`#?tG1XY|2t zM|Zyhd~FBY#z=hf$k8=)yw@!bs`@>3dr6ATjhNfDk{LZ8sD7A1FNg4#Ij$5_%q^95 z2FNw%g^zM$B?C+Otze`}U{>Hj9zVp|u3I)5Bhe^-sgt)2M!ZNMf(y~9Xn%=k-b5|= zX_$m?qTP~8wQRcmRwDzUPa=>Un7ac>B%`Ym$QycKsGMz&#_Rk&>}@6!zB!bC2@^09 zKC&b$96{&#q%(XH{dKas1GTzy2enc>#X!DV6>Xx$xenwzg9CR~f3rCb&Sagklc z7aoxEqV>Gnvr;#=2TLRbqERx=-aXdB%!G75cs^ zSMl$xv47BewGw$zxK8WC6c={0_#w{qJIRp}>0U#6P>~)rq$d>V2}62Dk&HYBl77a7 zc!^d>ItO7tZ-TTiQ%rmB;DjACkq)yX2|rTclWO^-YCh>iLpsZw4FmZ_pQ!b7(fZYZ z)vN4+YkQcAR0F}&z?#5X@%|BR| zGs6R8Za9$N*qZ0`+bs5cm@PHR4sS9Pcl^ zVw5OhHiSn7hVv1K5|zdT#GVK|5O@&qVQh4-%JV z{Pw@3$}!j{hWk+Duy$`7vLqFk72yi{OUgQWYw|v3y(Qi~tsi4DtOTGQ=5PIQ!m%%D zK}vSjd#6eGA(imQR>CqOB@)_}pE1(`8KF&pZ~c0qfz+q8&WJWXieYt49++foaB0|X^5io4kz`n*u@f3Vqe&n9SVX0KBcI3( zx3)^@Q$Cgo%{$s(!RCm>XTo1-EscSlxBXT><*j9t^51qYQ<_HU$p;pRO*^`0oeXJ|B7qxaHvvd{-vskaW}U*G#hX4ns{H9MYZc1>KBM*dUS>#o z&yYT&m2oIZS{Pc@tT0%+h{GUPDbnyPuYMr$`rS$kRt)UCelMELhE++W@b!wc{N9jj zXB%?c?1(%fJFNN$L+VQjoE<7Ro@dAta+K{tLmH$=Pa4t-^mtkr`QO=Ap{Tld+mVSR zaw1YsMH-Uh{SJ%WL4>F5*|`HEaWwYv`vxTGNivN(%%g!BR4Gf z_lA^Gr2klj+!m3z=-|}MR7M=^yx^3B%IqJM;@ROc{iR(73RyMR`m`~m45_0c(W&0~ z^g!DCsimXItbVR3=?R9sMU|BPI%7DmN<=DFB>JlsDJmsCXSHUXmL08GcVd~n3wSs1 z9=3{UBVGsKRHo&_4=4-0E^P&2Fp`e$e(`oNmmv)adm=>ohXN|4#hNDI=+DHfh~Hpe z--VhAr4~CH+)IaL`K}Je^xpCQH=nU^FFJ5*UW}$!}+BzO#Se~Vrx+Jh6uraVjRZo~fbi_K9?+*6-iq-F8qg#QE6>Ceu zjhj5JBpBESOg2<8(HT-co%DOsAv|SYZ(b5wo+T+__k#wFx69GA5|&>OY4|EqwHvQ% z4DW;vqZ%~MzHeDIl~;~|x;(@0azpyFnp6I4NT2EkXl0DE4x}($GQCw4q-3k;B0rTy z$y50*!!FzTcB<$s&??jfBfp5L251%QbNW5g|8zjBSf5VAEbG%D8yj+fq0Q7ZdeDPW z&Il*Q^(NfO>a#n}X4^)jWF!A%lkie1zyOj5u`|Qhdx~9ni1iv_wa7?A7p&R)vo*Vs zyT~=FNUC}wEe7bj7bS+P1nD3p<4E@TGGc)|&D`h=K;sN!jiaQ%L$iD@)j(G>$uuRc zWm#U8JwtowN<0O3ffzP)7o@KVR5`F0W>IN3Cekv;mj|Q2y2KZlP1#~rR&A)Tkm z(XQ5!s~Yc{QtQX5ZEd7Vu^&^ID-d~FCk<`18c5$w)9r@>H?!1xxaYlDZTpGYK#NH_>MQ#WY5rXNw7rnJp51faOP)QNpjs-g9D|XyvGvDh%5A?w>+2AwcH$7LdWDow z`2}OHOstF?yxIE$kdo~Wn1~{cyg;A&?1V2%p71Tc_T_Cp$@ozS9~EoFXcUFZingkV z@!AL2x0;z3wD#5bfVt&>9HEw(LzI5N{ecsJD}XD3hXKz7p2ZR`b=k)@&+UJ{g>fL@ zi!u~R>3xQ3p-m9yHI1%*=PF#Jyh@}m{7_7ZIAT_wg9Z~dxJ4`-B*vJ*qcte_V=TB< zh^dRaySlC{C&{lh-TrtDU+6J(BEJh?$p7punxefQr7``VnL8gu4?2ZMp|G8JsIEX%r7yR)Z){5(QRkclvTAt!| zX;)Nb^hS-?U3_G3Oy-7qUs`8N+aZd?+EqGlVX*F#_LXy+fHBDR{h70+RvEq_6mb~>EXohkuZMbfFacUw7=ys$!r8iX8%S6%nkkYP@&sS zd2+&(r*o~s*?d6HaZ;X>Fy)0@sdbSvlt!fEltPQ_HZMxp=j1@8uBouOC@A9M|(aJ%|FQc$Qs zZyu}3M7C0M6`r+pQXEi+x1?w-H{VW1zC!;zCY*Dv4C5l(UW08Vb_UhfCH`zkjbR#T zS^t!+^@6;QZih`3o>luU3w>WsFn58i<>ph6k1<&xTFcaRee`Z?KDus!{~_QE&8_-; z+92AZE%bs5v^{ssq}y#SRSEu&!m^Wvv%v9p)T()u^dgjJF>ncRDR3EZIdBE*p2(!l zp#dx?Ev@A11g2a&|`99^{Ce%|i z0R3eJATL%p&#>>kRC} z*)ZVitNyl{$<&)L8WdTlOm)bP%!skb(RZ8I!}=(6M#o^(9m`_qgX2IPk}Th;A)(yw z&eV6|6x{ZiT7C;Y&^==uk1jePhR;ze#e-(CZH?&Ic|4E&?tFF016v0MN{7&%MrSe%4aw7Mpf@&P{?g8@An$K2;?8>y~smO0gPv z7;p`6EpQ$1XZS4p75E$QcVG$zxiWy6z${=T%Pj}rG&A-{U;D)#oP861KFVCJn1<8b zB(8`Q61XDLi^TOUmk_XB`b6$I%N%Vho3`bQ)|%{jJbPZoo>#lgvf65vm#7rw30~F! z?K$70*OufSy{<7I1z8O;9>wgI32D4>wo_Tk6TG4!IX(*P)Mpm-aMy}Vb94s&5{ z7OZM$vqOB-i=s3z7V`KVC!RYx4o<9Fhi&EGEE6Bn+Bz0&Z<{JA248p*RAY}xf85E) z%<{&)jLiHSMB zD2vaL#m&s^F=IXGRiHW>wW36}?SkYYYr|`_1!$&uJ6esqC|h&Wqofm z-@)(5J5^Cb}2_SGU|Y;+9>+aRj=P~I!}5AQAk83GJr=ZzRXbzrG!R|1*ezy^eSvLaC!I8( zbhMCBTST~L_a~ZmRF3ise4>%No@mN{ZFVuyl=}+oV4~Svb9W}1a$h@dq6zD@dlOCh zuN|Lgq?$44r>5K61Ml7ad7}d*TYe$oyixvZvx|A7+*e?)=8bY+yS%75+R<_-F( z>GqDmPH`KQfbCE8LAf)6Z{7WArVEv#{6fNWQ2DRTE~c4sUxB?k2bKHU?bSJ`{MRn- z97I1g-QE@0ZTHVX-4UTjyz8whE~}=e?_o{x26aGtrV531xUtl2+drhsw(Rd+R$79)Nn5YUJkqhcpdPz7&ZGM z*%hwFB7yZL${C5)!3^-Oa=G24pn12oWwTWXujQ1Hpmu73eDACgBfBa`<=H9|$KMb| zyDK(AL6#li;FeK@yJI6Pt6@jTT@^*RCpN-^mF)1KtmO0QeyAA>hNn zM@$BVF?vOC_sMM)gzw3OQX0ELDcgAW*kU`y6+vo8RpUi(l%cx()>pdo64x5swtX9= zS)L#gC*%UCy~{#{tG7F^zl8DkGH`o*OIm}2wgmR*DF4pklo=6d#dFE*gJr{N_=MZK zHEdhKYN%y*p!XkEiW}!q6%R@FW#mQ4FC)2e0m@5_q*pM8UIo4ee4S038< zOnnB63QFQvtZ^Luz|8pOM<|SJ&hE#k#9C|tS-k^%7x-R{c&AEw4QftC>K3?NzMz72 zxe_0NupCurHG7wRAAtkxBVz=!K&wzyW%e#2GgIgLBl-NjF*6m&ZTCidtM@UC{sjC0 z_#qoo1DV6l&=)Lg_|#3asNwHt32$yyIr66+V~D6`A(!<@O>3e9{~0-d?3-K>r4%Jn zCQYr2qWl$6{$`?RUEv-w^~_BA&?a*Q1}&#AH+PJRcoOLkk?v0GOr^W?drp*Gc%llc zOR{PCmW-PGry?=^I6Ri_g9)jNArDuV>ZGXWke>DB>zD)EVJg(X3{S=zq+~MQ!%V^? zC;>ecL)xrJr~1MGV|)^GhdwaiQnHWX8y{w=XxSC1YQ~QuX zeOrJ1MfDw=YLi5BTil*heQi(PaEeSZUooV&Dfv1gEo&J64|PeFsf9@6LD4elku0u~ zk}I>K&y2A4VwEk5Sr{A1!)@(jF+s(=p3Oqq+DI9B)wYyxV$1TB4I>h|N4qf@)let$pq;q3RvBd@qa0u*mfb#j0Foan=3%HcrgM=N z!`Wcb9=b--%SiPymW_odyUNHY7gz+Z*gEBNu1e}Nxm3~GGhd1QtqB)(8i%HYZ^a&G7}Q_qKTt#YEt^m32a`g3McA%6g(mPpIv4Q+;s z`6uRls4DqOH5T6?fl2!-V;6q3vI~EzAzi0PEYxo>+>d!xUSQILT?k|N9yakPwo@Xi zBa5-vywaMw(*Bhi!0GjFXy5L@9>7d?4Yvy?bv09|jB*Tm3xI0%2KE8=O~td2tGj!E zEKFYgSa7Yog+tKF(D<%75C!tOp%{W{nh1Z|5^`Bp>he*w2Qm`XfxKxRQhJ?{3jXP; zE<%G{q-7Px6=*dZq&!B-2t#!@`>BQW_;e`edJPMi#@8*+HO73p$-_bov!y0B9^1DB zxrrgaN{G{t4KRf}yo!F-0ltv>xcq`Py29#E zSJVFBYB(nzS##a70HaHpg^SI!$*ZUScT|mlVrbe5He#f_YFhAQc1=B4ZYh1Nc=C3SKeg0=+h2-*{La26PMI@Xrd zu;r!=>3z2Q0wR%9W;^$gR3-)B%1<&orb9REaJ%q-zw(?C<0jMItw?Y6ul;yGIxfvw?GfbAj`K z^MMQ4U@3|-GAKWN%Ghx9z9h2p5b14=)AksV@?zi;;8Nf+;Bw#!;7XRe{(3r7?p+eG6m9`^*ihfo5*_ zqz`-}Y80x#zN#Ya(0tp#4n5{uilb4zjz?FW06YKrvXm~o&h`)coy(% z;5oo^f#(6w2VMZY(BJG9I7T6hxZxSiq>Y@0BbAGQ7XvQ=UJASncscM2;FZ9ufL8;r z0bUEd4tPEA2H=grn}9b1Zvoy4%)tqu60kDxPSEcH-VMA5crS1(@IK)Ezz2Z2i2D$p z9|k@Wv$^K1NyZrq__c<`z2Zrw-bHO6*ES!xaB z%OS^5U0nsTUAP<4CysVvur|Vk$~}Wzo&`P!d>;4$@I~NDz?XsBfv*5x1-|ATR07eK z$+s1`a)goR9Imu4TB%~ajzr%8z6pE__;&n=bbUd*gTU_s-vho6{1fm4;D^AEfPV)5 z1^6*A&#>xBxN`{I!iC1DuXW83h_0#(RnfAa#9W!A17-wBUUqb}(*GSe-NQ2X+!Dh>2TZ|6rbMU?v`LJFD%G z43Ta3;kT$_w9E|&`kHCA>=zOixDBvi2Mu6q;xvk>oe2`xT5)Zezw3r_GV&TgNwx*D z_CEXqE*f|3&Dbb#>RMn`wZJYx{;tcn!_ko+({rX@>+R^((45RE0j)KL0pAhWi4A*w zE88Ss?y>~~mszl@>k_}u%7{6zduMoa%@>)l{$q~y`V=<;O*;}e3OE`#1~?Wt4mdvCzhHTt z817=&Epm-mPiqHj9xTEioUtor(@h(qaWTmRF6OH)bN^V(N5Kd#U8DH8f&8XchEA|9 zN%&$g+LPs2KGSV+x;!5dfkUTDq!sKHIlx&8b6JF6xUElPOkP?nmrE{)=X9 ztf$U`w~4T`$PbFcY`R&oFK@Ota>cgfuVw>IMcYsFXIy29)vojO_-Tc2OiN+}oDM8x z{#2J1*e)<&yXh5=sZWnI*L-70WMwMfzEU86Hi6BU9j!>cZ4%|W#E_P$Q0oopctxVW znx&!`uXFr}o&6jyF{H~>#I1((lp?)kNN*@oiMe?s4=T~cXGm(PNNo)107bgZP972> zK53MRNRLFCV@MkmX_I~XTBu1L7e0brO>&3$nVe=wuPM?QR=zSKC9t+e*UTp2`&7aY z_}9rGCF8a}>zDX)Kd%@3P%ruQdMSCm-Y^OOM ziTQ38t7)`06|%KSZZntvv;Cb#t$gmMnYqWnHnlnH9SYdo6Pem&_e2I)_X})ZHR*dq zc8cvm08+9I=z5lFKFf8o<74Y8-;f|FML3q(-%+H$8q#Nq^qC?3s7QnSTQZQ64dea& zRQ6Av3fnW$R3F&h(T-H4B|d46Px_qQ5vnitTfdOsCNE?ae}V>S$MSf)O3_Hn%T~mR z?LaGOrd52(rK2HrSEO$Ysc)sQl0W*>5=hA^`42x~K{Ai*gj-e9c*ReoIwyu5m7;%^ zRSH2y2)f!dY{qR8eId*IP;(6F2zm@f!{t#xFjBB2u!E z@Ni7~JY4bG=g+2nzNg0%!`}bRkgI1Ka@$BgSI)>$1FzJkQQK6vNwl}^niG*mP( zF*#N!8lEF;5`}uxq%}e%|FloK%_m)AlPLM){800p8m$#+IV;ESTF@$1*B)jX^Y9$= zVKefBsu4~#q+96mrcku$?3i{q+ii!FD97>(G!SB{aY@Ci#v`Vao>2Yvz9IdlNX;rm zq;_h8r@w5@*W;kCArGKOy8R~~Glo1`EgF*yX{91L`jw+fHm@93vbiC3QKW8uUfq)C)z>6EO(p!5Y2y)!^noGGS0vUGe^os( zz@#-?g&O1+a!~R@4mSz+Qwfu}w6l8vDcSB}QIs%VmzY=V(%7%+ls5+l!@0nD!1=%h z?5Nn?;rDk>c--p34t3G3>y&^w%$TFSB+pTVMM{|?@|<4CqR!!o&e1hlh;lCq?_6Xj zQT+|TjlfO7!^4YiH184Kb$0-pLaH2VjPZ^_gg=rEtmv|s9OcEBM?4Do91T2%#Z!Vc z5YrMh8CypA0@n9P(fbC+S1@z+^ME2fVo2oLp~N<-@`xfapW?{8jxDMET}tU{e`jc) zYnl-%3q%W5!(;s$BFH=xFn5|_fb;zgNcjEfYvb9RzkUdJIXa(SmO8C}9NPVO;0eGJ zfhPe^W+m6YLd3x+x?S3Wgr(&Qy;|k5k;`8S-;*UAIXGr6!J*vIh`;o7a_N~cmq>>h zEo!H=G+|n6oa+u|fJBon@<}(_lLg~vkv#$;o#02>W=AR;FIUEwH(tN;6a6ZAqUZws* zHlB*l=``T!z%zhnhSEKlG|y&v(Mh5fs|~ER^1*RYyg$2 z)?g-XbOV+8q*Dxutj5&0t;gKX>S9=s;ex2f$2k}W=K{|Io^MUPj=)b<>-c#Y9rVgC zSjGrZer|4VGigL*lw%O-e5d&Nd(O+L`9I+0L_02g$IOf@Tt78+=98ug=acqM{Pu|z zR_x`oPr~JMZ_h0gobpddSpLyY{-YD-zr@LZNy7Z+Ir+~^I8h(rPxl}ttA*aIcgkQ_ zr1ta-QnKm!M89kxC9C1NzJJrQF`IacU&&jNSMqwlEZ6U$;*?#M`;wRC8NV#gBri+Z zYyqLQ=-5Epn~kKb!MBNa!v9X5F!rBzDj+3Gh05gI_1E*k|#{5X`Y`-*q9_lh!N~n zZT>F6hVMe)7T`s|i-DH_F9lu(yc~E1+aPk&$jZ8KAa4p+d+qPGE6Kf1WC2HTap8lU z1Iv}3V2ILCuveQp3D=2EPW(;@h{2U-K>9@glkaq{naFx-ni z+6uf6ct7v~;Df-2fDZ#70d5063VaOsIPeMJlkqn@(Y*xiww=KMCd=-PhH?T-wO33B zyW5@>8zr0-TRHJtC5*4VhlGo?dEVP=juB4$5eegKvHj(aa_MGQxpa3Do5?B`f6HRb zx<^C&DGbV|*%wiV^JDV)i*({XP6vV%3O>zb`p4&B@n5SF_uts_GN@Gu4>40 z@f*W@zXVZ~=TYMqfG+}H0=^8~4txdpsxOXQlctnA%}jQ`p6&0pPrQc4b)Y1v;w6S1 z^mi!fYo8H7N_Ivdqv~wBWVelU40VLU)BfE?T5*`oU9McLLM=8Vm=CS0%@x{nJV?pr z_;*aoU#QIJuby(M+9s}bkQZ!{sH#T9Yv}FQfp5gzj*4nbD_GQ3vEGcG$o1Jr)KbCT zLb7iI-(gz@sIrBHfl3h#7QNMS%#&SFHsk0I0}@N}$L5>K$L62xvB?~F&@~^CzV)Tu znK7wfS;HEeXMrY}yFx?~T2t#?bk=*o_t|*R0v3IsXu+%%<8Z4Y|0zbXEnp^RYC0FB zB-Kx(Cu~xoyX6&d(x1eXCXf_cBdx=;M(^Y(}aTCDP zjEejrdix{bpMie?ehmC8@Ne;pO=K@XB~>5yr^Uce5%C|u&w!r;zW{y-{0jIrEB)S{ zNYy~ab6@tnw~U14Y}Jc&y=73FZQHgB1b26L*W&K(?!nzkad$87R@~h^P>M^TxQ0@+ zxI2scdG2?;-z=F-CcnreKe%#S`?2lYc|x{Y0#|BTazHPNla!L~RHE6DfS~9W(pM>X zqSjsQRezGnr16#PI5SQ5_>Zj6Fx~C7{HFhWQ~PR{7*R#bI(n(46X*-QJX4$;+C#kD z2T1rQE$2GjKTQ2y6+E!AvfNH=6N$`!*DTdnyZ>AJlLpjwv7Xc#*AZTNwH~NBt)ppC zZhb)fasUvB_5%6f0!}38Pk6@&aU-BaMQb=*iL)HcS};o6QI&f*h|ed0h<_;`uG)f8 zx>bRDfr)t|oNc*o$eMg#ZRzI1(^BO_zFWdE|Bg@h=>-DY*Vp#;7~_OyEoYsi)mevkra0%Qcl0g^T{HWD`epKUKdEzK*kZQ^PdzX47!x?;g_IBb=J%#}gv;2&1?#5xe6^uW_ zM?t>`-r-J2d{(4cvUoHd{$XzegC> zi#H3tal01&eEd|!FQK9s@Ck<_YNw0Xr)_AB1daTSFpV^gIE~y6zdSbz5_W2a9o+$@-K%~hUy@hamd>oH z?1Z9Od{nwlZmcY4M9OLSsWk1Xm4YP9vR3VjSOwJ!EpGUxZCC~M{)z+{H5C)>+EDYT zEg_QZ1`fxdPPy8V`wV*V{6aTKDnJS*cPBNgMR@qc*$|cP8vJKnVav|;%Q0h{is?>t z*D9(+mFQ_Ishix|GP=mw*$!g-ZCBJ$w8lXkOC7msZF^vNGVW=UR)Sw~ec#G`#cR;G z&5+1~F8GnZ;P8v!>|^YUzE6O_EM4EIRD9YF!oIM2sxOg-N`{S|T&h4eT5SbozPTI# z$FA>EMLMI(iUf;V(xIAYemC|@j;ik@-K)8res$<>v#`d1V-GY+OCKit?IQxC4mefh zGA^*Aa5G)SQ}$uCZuYL4bC6Cr@09!THPGPP#xMV+g)H{%1DYh}#Xej6LllTmz*TC4 z9V7FN^0Y*5$^^6-`-yr~(V>s*m|b*XGxAHJYOKLXQh{#i=s!Kwm^T43Q&)iL+{*;UJ8N`pta zj$i6b{==!&Numt%E+SsYrIsgy2IY*^E_+e5Y#JPchm9>!W(i2WKy~SxXUZP)DPA?Sv zKH!S^_S*ctE?q!pjC3NTYKT%&;1m9qh^@&>C#Q)%Cbx zmN6>;d7!Y6$9T@Fcf6@rY42C*$-4LZK}0W1_^A zn-GKkJ`S#I(oB^)6N&#wv&l`;{o63*X#a!O$4ale<`r9i!Q(jB*d~gx*{ONHn4;q} z(ckU8($Mxg@S8feEJXmcK_IoO*4V_5r!R8BDAXwAD1Z)v4hqfkbusUG(Qe0;+>kmi zh9m*wFuaG6>fK72wL^{rF-=KThs;Ejms;Sp7_7t)ssbuTbSimS7!v@XW!P;0#7GFm zc`Oa^*XYS-Sd=MR3CTky`PRzu7y30p#M#LX4*&*&sdM7%TT{AJ_DpF zX$*S6UoG)(qnPm9bB!_WN!J;i)?3oHt~7leW;LG1RF6u86_3u#k8EYe?$hfjHLixU z14Xi*P*3bCP(?^4SyFk{2R-wjtJEBIzKKLO@xDKilv+wUrW8(;{;5*B*UT;u!YQG{ zM)|`Oa1_iE`6Y^S(=2KzZ@m6pnuJ?iWn_U5#v9(1(UmZl`QFQg`L03F^Y8GWW8@O* zLV5I{^)qduwruGb>zvX{Z_=w#p%3ymuSTy|0EryJp0MMiKuUMB@`4ndTRz(y&XKw_ zr!T5HeY@8EFN<%<4RO|`#!%1{4PVddVO!tl>Sc1xM?3Q%^APf2@=)?py8t0`F=ycX ztX9&Pg=?}HpEVyK;4)0Nr6pFhZ9@DVpNPdMPuYkqSSW|Jb75>R#R`LrR z_o?{$7{~8^OQLePQ(@2C3J<&e;@bnr0{|Wb9uyuV9v~AUGbeC9Q@`00OF&;LvSef- zW*9QlYUEkhVS=QqeF1~)6K!oKRSOO%9i$EVH}-D~DPf62p&q#tyXDNwiAd*SpBdrxkM$4U3$=MpMB}~IriKTS&09w*)4e+K$l|h zmnXiQB!w`XVQCw}^vsBI3;#|v>5#uVOHv%-Q8(qh%8Yzr^BvZ;%O8t++TY@?ilIoZ zfHQK!*LX)xRSptto*Aj6fFrlFFtx4xx3vc?wAz!oZ@v(sSC&9ZaZ?5Q6d0~X!;E}?OPV~ z^SD;AxOya0iqNU<c%nP7NE?k#{_{ zG#@3Zdsu-jwCUMHsodTl59!hNCDeni+5_+6EX!DeDmvB#b-dA_0h!~Ha{iyR#6TFcm4cCDi4%da*A)22Tmu*c%N*k z1!B~0KVK-7Id#7Vf&n175H!e-bqUl4tUvYx{)HPD3q%*93Rws%0`SLuAinScBZ2Tj z*dR?|o#FbS{Ba%#FFclZ0yclinK~faiFX_B;k7ccj^6Ow;#3nF3}fZ05IXR#8PWTR zGM2~HGo$vp4fcx}DA_Dendh&bn}^|K3$^0#iYb(LUWOG;pp9#q@y?e<;;j_?2(Qw4 zs4eGcF127d+Yvh~l|6!P7vC4-we ziugkbWmtYDE$ut^^`VfEYAe3YLr`!k9aR9yi|&1{7$1Pb}*kqBOXYihZIR69jD$IHG=7Omw5Mde(L{>f>k(#(_P zguB1$cYhJ?YTVrL0uk>1;y&NXUo+yyw)&5yrRDt)2ky||me*wv+RSjbWuRzIptoGe z;}4jH1gLN!1Vq-a!x`Vh6bwKI{)m*sDW`VSms&Z*=eO{jWmQ`nnvz3z5JlhkV0YLH_ZAJG%`z0QUy*!h0bLRs|J?yhFZ0y?lVDHF>y}Ib%G-<~fIx zdCoH!&qUU|U5W{m%5iBKo;y0^b*t(>=NV>%n|?=BD zJET7K?lYQmVYwZY|CkJ-8?BZ)GP!Fl&e`>|9TcINuFAZZFw$=LyO!-pyh6zsCV{|$a%8JqnMXMW^vPd(y(7IOqSndB`Uat^ z6td%Ukt)M--5C%aH#=vQCYLZw;#MqlMPM#Iuy^tH`lXuwo2xn!U?%l=~1_-Rz7-LCR*GZt<%U3*> zmb?(7-O6kF&nt=?85Dl$Qhl1I>vND3wTE;rOv~O)FTey&UL*$2n+j5MGqg@!aRY9V zDJ`sLb!dgC0|l4YtDR&w8h-2Ebhaa~F(aqwjNie1@4dib zP2S3(uXk_UEOAI@Fj?6n`dJ%iy;ym@DWavS#g$PXrAs=XsN;EGdll2$v)u+o+3?tc z$|#r8oV7FaG4@l=O+Q6IrCcHYd}i!T2?a@=5Wa?O#SI$ayfw}-Y2lah!rk#e`YX-~ zIeq)5bF$=NTVK3v69h#6`F*8^*QKYT{>hu;mmIQV@xo!;3v2sR;>OeQM_LeQeWr&3-=<(=&IIo85b(mmi8)uO;)dc5wnCRC9JyWgbdX z6i69nlXz?sF9L^E{pX|jO@_+?uUZ&=aB}t!__h-H*3cHG(H4j3xNoTtMHgV#RoO($ zK4?+Z;(;0HjRNJqC%qP_=gj>=3|OxY)f|@>#AHR^s}f!1nP*83{wF zNzRzNV;qEi7Oxr6PCKe}>Pg-#=JHVrimnyYwd_m&CmfU$haOM~0a6)ZIY6iU0}}=nreKqi!aKaSU^@`BbnT+1=_*N>mUlMi6yy=Uotd5CZ~) zz(JzHsz9m0sz8!L)@Cs^$FR_b93kQ}cVOQX6xo(l^%tbBlX5W^s+{S5eVp=g!&b^oK646^SI5e(Joh zSKtrmOAE?#M)OFT%~~i{h|W(Gc^CTm@BJ4`-^U|5;yYk22%J!rKe@3DflZN(po|cW zV2uje0H(c*lN)$0jx@E3^~YAHqP#qce|H=D@5$a-%Y?GfFg`gXug^#ai6KlZa5)pv z1?GN`t4s0$cyxi8;g@Ybsb({25fjL&dIWo>YDyl=B#!PZBqu87nzugOX;7T&uLLtT zeNctx6ZmssjUB7#RQxY%e!Rn;dM2lPh}lxCJ`>;K(vH*>Gtp3?M+H!t5K%sw7xWIO z0o072in+`fmif7a{4=tt7%bz5&|956+8g=}w6v1GtA+n(lVxNM2(e30t;0fr~hGf&`4V44B-sz3`L5%fh+{I0-3u>b?N^vlM^xunRuU=S#*)xF337s zoK?*)E!kTGl@Dsr*SeTby2ObItNKPq*^wGd2T+A9Kommp7PvqLXMkW0cUIU z4zgR20}T!w#ftXKCZ~v`xjEMFgz@;EHOgFkso zcMmxnE%=weddI&`7=05eU-H|}I%EjQwv`L|D4tCEX?l#W+WvMAJ{W;Fxwz!Z^V8V7 zcl*A|OdeSiE5(_GwAzGIAl~I#gIYV>7JH+{k47Uv<}E%ZIqbTH`5E)lpQGUa#CIrW zIb@nwairq%lABV!zx(fMDmRG_k-_eB#T@%nEi>~hE{iCh6m7%MoFSgT_M+1Cka|mw z6`6+uCkzts^@ead72}4fghx>{Sp&$Yfzc{ze- z6Xrf@)d7Bcd0PwYfz$n36-nt%xeNlOJLr^xobi@usE?{xmxk#MF)Z<$8GY5BM{#Rg zVL9hf2X)^4|K|SvCv5g>!NT_kbtS^s~s z=$_CKAK5Q3!0jZp4|bV=hsurzDYCpRBu_A5a$mNR_Ciu3#q4N^Dny~>qwek!@4`%OSu%=KF^Z01<}9_+JgXF?55G>%5$&)Mj(LRWt5BTV z>;I1U&k;&Nz#wo9#ot2ZIRz9p;Jq(BjJsTLoaJ-+sy}(5SBMoP+lip)u)ikK@0k?j zB*bKK-V;*`O*-A2<3g!KOSMtj)J|weC?f~k<(6+U&`>m8TjABoP7<3A&bQ~9c<%&L zl~zc}u=%6@oXivji@cgCSkt@>B~IJ$4cb4~Ty`Fvz=9yJkPk5@T7wDtm8$n!@U+H{ zTGQ6ivZI~!+_HpU{KE!)CHB;k8NQe}@=F;dhrHN5HivgQ^H znmZUF8tlf;XwswDcE&uHJys;j}*=@1P^&r)P1&cxn zwp8f-Mz+qwtHJJL038?|fQ}8B9Lbr7YDK{UF<+)XI!J?mLCn_#r^lQ~v%zZ}k1p+TD${Q3fOR5K8B?#u=RaMtYb+R&D;RZ)teV ztkuJT8~gIgr{aH@GTC)f9NnxyZ)kGpug)9sCm=6E4rLt*`MU%Vqfw;s<8mdE=xczO za&!9Ej$uqA8ShQkV1lf#yXHh8x%Oj85$3qfuTaF)rS@Ba_2g38cg$1=kIkHiMrd%{ zR)WBQh~TtTxEqub=b}|=V05jX0BJ}N6PjVrJ!r~hdM`D zl{REHxTFczC`{3)l;-K8u0qM{kC)s+fSeVtEIWzgmFZsRacb;_Gx8b~b^_7a54GoC{o}!_ROEgV=a$9UOjdRXqc6+>f`n$45DfpXlGlwpA5|xYP zdPR9nhV7Q6b^``gx*?BM{3-Lu&9hW`Wz*3>hSBp(L72D#58VBg=25y(I^abMb%p7G zeT`HisBqyI9fv0DU!0HW*1$zhHpEoUeo|>x>Kct`x5W1U$-)zMvNfVKvNV!33O^68 z@eRQ_-6@f3Ofet@=_R|39bPx~V3b;J%4Hf?=Z~YSuAbs$>62X{5$L^90OnMfa*KZm z=HPDg6Scj}n)}u4QtTQ{W`+!i&8-@f3FtOvoSsfy72lI_{gCZgX@n9T;ipev_zu{j zP+5ygGh3_1L~mm;>-Un+Zyd%AW}eHCI+X{<{z(P-1Tr4bs75?idB;D0YsLyB_Zj+g zo%Z3?am!T-%7+sOXf=*W^mdy9Ho#P}hNuWtD_hR+7Z0)#n5< z=p5X{_mWoc{vU9$iG_8$DXmj5qtD3W%k-10TO`bdZR7kL{8>WiE#w`{8QjTeY<1Zk zZBr!T9{o6ieQ&aPI|fZdX(y_chB>ynpp9(c2h9la@qmJZgabrDL_tMCMge4piD$Zu zx>syudFG&s5+AAR>JcQDexKbxC5QRUsg5#P&7bbFq;@k7s?)F8g7Lw6U_P)n7#b`E z4grM)!vW|Z=>Qesi`)`1zIwWQ^}q(Ol?Ow63_W|K~jXU%lP`-P3k03(5}mP3JstGB^sGTKh}Z8fjp;?T^_KS#*-9Cu2`z(UV7eO%3LBekgap8JAzNS z`ApqUdbQD#ee`H)`5Ey-3v37a74om;jK~}H!U*gGx(>mEV1ssEog-NR+7yr(MJ~&( zxt}5`c$cTnO^KdFw|b@P9hCOfBeU_!;jgalg=%^BY&^dZi!ln4zEb`7f+KwHz}I^^ zEAB#9IBs&S>BHi~NcMnoA->83BJl)#PygmwHlCW8MzPR z`|D&y{Xg%r$-S#3`f&w1yDqetF&kE4Ybv9~d&s+MV_aYyu&tSB_sK@z-(l4B%wnBe ziaF&p(vI}9UyEZ{8vWp38nv&;Z3I2lBxa7a=-w8>FEjZrGpXYT24e;&cHpe_f|529 z!eLL@@JcNaGINb>v-wSWmUJJxC;L;TI@&;_8l}w`d)_n4(tS$<7{ZfGmX_+`w++@b zQm;a3|dojInF6_bFAcv4;$OgDAh+YhD ztP5)}3&>E}@9%YF^Zqchj0`TCmYcKW!g`1o#5wd9OfQi)+Jy;Nhv(Ey5phj|#%PcS zjK@L|;`Knc&|n3}5mp?amt%gGYTtIK@>gb|2{TR(Y;{ z-RrIx?uc#u_wX3X)M=88K`}N?AjWqn@-YZOc)jAOQpU?}rvl!I09M5ab0OeX5k`&g zy~xe#D@dL1T_pP8U;YKjM+p9!{_n1YGpE?Kw!u$Dxcd9;7(?C_)5M6gwHye{AR$X~ zia5H<7wn6pq%!OrM`#haiEGfmhzfCYKKf_D0#gKiwQs$V2Oj`QR;=qftufV1CgXN4 z{44H-Q@!8*b+OFnR5S-^x=9pwzNd73EOpA9%A0NRiT_V({%`y{*P#J!IW1c>FSk#K zjeT?fasNOu)!YA!dqEJQ7MnLgp6*pBF5~e|*3xejBkyZc{-DAV;eUt?A@rLDeWZ?0 zI^Iw=;?J<^Z%08t9;a@cd(<$a`fmRC=5P@!n$bP(BKP769)zh)1yQ`agnXo;vs)o9 zD$cOtX+_h*~Zx*Ue#aW&Sn)=K0+0Nt2k!M-y{xEO+{} zamoWTHjI3x<+i}ot8~h4_o%g35$MInuQV4gf z<8biba96YqVMg%i(sFE)3)DdhmliTPF#5T_-mcolEi#uypfFz`a_da)>oS8?I-Nk^ z_zhwB`)JL(aQbJx=m(8P-p}74eGZe}~UA1<>xovT;h))TtbwDm4?vATj zcx8j}hc!~1Ii%Owa`buf&FP-2Q8S9Uq!h+O)Tbno(kocV0lzpNrBwFC*HGH0{~8Dw zN8>eF-1xRvo&L^RBRmTeT2gBa`|Hbwv=u7>d{+};to_~&`xyL2`71*#-d{c>UDo@Jj+O2wx>O?#H@6sZO* z6itPm8cJ!k#bXieZ4%G!n?LARQF)R_Rwk{|mKxG=D3=cUSUZT=PL;@^PDAWE-MX%X zHXMIE?TG|wBeZ_%JvB7d5pjviHWd>^C502F>Jw?ljN^aj=l^8R1jz(&57E}MI7B*r zL-+iRXlRaMn86c^lF^pcNcCmKTuR61fFiLPWDhz95%Tw_L9lEjUsYIlmnN%B=jkme z?#$@3_2?BpxK*P1?OWqj`v34Nh%jO3JcK*mnAuTlS7erU8YOGQGl``8{FnpC4@XnV zx-dGRs*QN7L8<*9S@J^X2pcbpa-(Lxts#kIzw)r)orLs{zf^qU51hpAOeSg%{`r zL<7!p5-OorG=9~J1*2A(jCs-;eB&*6hIsymiU6FUo%}bkL+6Wb)A*y9zku0Ep?{i! zz!CXBqXdL(^9`j%FLfQr5mkcr}YO#3$eF%o3r?S^VTl6i40y&p#D zQrEt`RLP2^o(Zt9j`eTH=J3mg)EDNudrx?^WJdw=9`)jT5;yZ_S$)@`M1U!0*hsp# zljpDM{fyl1udf&4fl~u@#g{9XXsEaTZF8|(b{duo)WcsFMN9Oz*~hirh$OJiAl@hX zg-Gzwj9d*N$GH&G8U4F5g`woi8erzkbSRV~wfiSLEs`K53JvA8{^hz@Y}9e|mj~h$ z3KwpUKmIKdm9PQC>yn>A%*93|If*}Jr)IWme=e|G-$kP{eqm4ymZ)A+oxCjL?~%|3 z?04rwa<`hJR7N|A;`&RCWac3$yYLjn2~>-hkLEQYsrv-T;;&_E4ICa>%5-`+jjd(W z`|DBC{df%1HyFhgAbQ9eSP~Sk5Il!lJC7f*v=l#Gb;gb5CE|nQ!{bBaBkP3dMDL8& zrcNZ2Pm605lS{uc4>$d9Hi&8jX@p<|WAwk{QvP`6H5OsBwt@ri7iQ}~qfs2g%=;Z7 z@oI^S3?doUC8rRQ5l1jF*a%FU2EJ%CUWrk2P+PFclpq1-UJ>{O5SfXL0mh%u^&dD( zXItmkBn5Pm86Ptyqt;@jx@d=~tsc7xTfWrEQ8m}X^O8bvV$T95HfoME9cyRJU3rbA zGL6(t;Wt-z(mYJE8JhW?WZTc$nEyA0T&su$T{_V7@A}qR+qs2xjrKv-oqTrS@VO@G z5!c5O09~O5^!ceKH|p^#f+HDJV>t}0dld5d)f4s&`2TMqzJlYWw^U2aB7iqC)#UMm z^I0HkiKE9&y@5}a2`|Tk8H(q0{I}YeyxS|3J{Q;V*x%$~6cAAeFQg0X7E~{Hpu+MpODoo-jfm>L z{bWhzlaaJ_0OoI9M(P%hOJ12RQj2x8Pcu@2k$SW5iGAn6&BZI-^@0GbGwMiJI*wn; zGX&7ovN4!62%*J8(NLk5@~}zYqUl2s*exV}Fw1>I=bDRTEef++v>;hp3-@h}eMdBn#4nPG+p73l%Bzt|R6Ty9;=iqw)8H5P@ z2J8o+3;)3G3g=39fx+{2-CbX@jr&}u^d9SiqnzZHo-6g__iuH>3teL)A=@bPb*PkP z&;9JHLBAERi9aHCZD>aNVriO@mOaq|@f+HyJ)BpkIsWxV;aQtDe8H)0mNVcjAi|4c zDi#B|lP%qGgl)6ZmuA}Ow9?g_aM^4yl(L9nWa)sv5IWNOUEIeQr?8AW{0ZB?&tcjE zAbi9Xd&!y_FeSJM-1L*MDZ=fVIBVO*SU$kGTNwQ+If$7u`n7xnFc|Zrjvj9_wv-XN7d_1|3T$@F( z59fD0j9VJ;BuF>7!Qcdpzx&vX1f-h`ShgU$L3PXE2_HB2X5;Qc%k+QKNQO)0#NkNy zHhr`B`FKEz{m2de3*rrSf!c=ZKP54m%h3!zZ@km6$t<7HYU?gmJ7*Mg*P#%7_e#F| zSkL!Ka(sUHDM+~ML+C2t+TbNNa*aG2f=d6tobCk?xMM3sU?bm5jUrBN`qoQfTkru* zU_n5Bo-}ZShIIVnyXj7N*|`k%$@L$=rQo~Re5v-S&3EiCFzcV;HU+eEXJj=nO)tky zEqm<$B@f{Invq&QdOMzF^3G{$y7aMEJ|Gz+<(0=ihE&eUJ`6y#Xx(M7{gu^#G@);& zv{0Ip@}=Ty>F{j7WJSKQyI1Vzte}Y*4oxXy^JwE;YzM8E3^RVE?TD|+aH=r& zH9k4Z`+J*sf1bu?j%MZO-K|SizeKZH!H?k5NFyHS;+?3mYdYn8g<}vgQ^v#wmYF>f zS~j2`NU!%yCR>+Ue2G?XZ<3Vm)=wQvCxb1b?IUMeqZ|~#e+d`A=DPSOgE~S~F_Z$9 zm(|pRBrmo@f*{@()VnHVeq3D+qh^WOx(9ky)waj~p$v?3obAn}mgm}hJ<_38XP^W@ z1I<(Fm0-^LvxwH@4n*lCiImoeW{+pUhgtn+y9M`oZsM663f69fxm*^IF%m{m*$o|8 z4XRlU8%?bYc$JO}0Tz+KC~@CS8(DgG$ptRc@n&1{p*ibli|J(!PiWs=_hypcI$Xor zp3xAA9&seUQF3pR362D>JsQ4VgvDP7TA-FOa>&G&UV*5Aoa``Ji1ARrf{#GRz;e={ z7uqFW(~{v!^iC*tB0Y}r446w2cWxmRe8QWll^sO)%Ia8q!&E2rVOI?Uxz z*2kFcD!XF8bfr048dr0ShxX-zG@K)e>2EU6kxtO>S4wYjZ*)Q-P_MHY}(p)NR?tr&f#HDP3_m>WsX@&a%ZPC0&)XAfGEN%3nX@F&vIuo zecx(^?J4d5H~b6$hLtu`*DUgGqwgF5fAWmDV*xdRTtIgq5>OGy^pQI(f&KxNsX|VJ zPeVw8Nka02Kf<73VM=rUz@A( zv+mNvSMZ@l>u+dp;HKo^g(3M6K2Cj88$Ga3p+?7U$@hLLeek?8K!q~A11*uvB-&=0 z^GU>pP%93=TNxwgjXGNfjP7*|#N)cu^!A~DZ>>8&rQz+Ur0<9(|9A<0+sE6(!QR8& z+r!!0-?N9i*JJ5)x!Q$_`;m=*z@(ORJ!w$1iL&=XIAv3&qkCYh;6ybz`=|IqMA>Fm z!|sROi9C#V+g*WG*}a9HqSGxtQO^vrZ#K25!Dwrx0dwPg(xQ^mLXMOlAMu7`ho`cw zz;eKD-(<>u=H=}8=bFEZV0ho~NZE{g=5>vfwWFi}NB2SwE-7h(-%`qJWyFv<)b~Y} z)Ik}viq>$u^uRev4J!ln7m-Vo`yRs$%RVWbG3~gwX2wnd=sI7iVL~>O3|{h4qLAGG`Dvcct(}PFkZgFHmGT(rF~~&ZoSYr$z1eIvXF~ z{$s~_m1Cl4I!e2G^(0FQSJooLkP2Ilq4IWP$x-|t6hSxZnu&Lk@;>0Qb$Ec11SoIQLlL@7NCn7w%v@5L?K0q*7$|$)_?RZ?X@uCR3$LX*EK) z-74xp{+jk&UwP{JA3*|@NX^x%lRpAl(;pUIF2dK@`$DZ~89zqdc$&nasXFwHmLT``-Kiv zA?miWw?xM~oFJl-a)`0?Oi^!*8gudW3WWOoML|Kua;S%G?Gl2~s}&vDpGQ)2_V)+= zZOslrzF+-<#$;}cza6SSHZ?{%kxsr-?2**@vV73J6jiu78DpytfAM}g=3-{imJjnQ z%k4%P#ckgAj^0;W#x9x+#r~XE`HWDHcm)-qExIos`o!L>?aMk}^B?oT|D5Yni{=R7 z2=91PSfHBU?$0^>IFKdh{w6QWAD>70RQ1N5=ld zP0{Eysn0js#G+9%6H4!lvWw~;E#Ij&-zItNa~zF-_OSVwZyt%m*ncu}=UL+lrK}$pc}-9Zs;AOnN^*PHi)pR*HH$Fk6CW#<;*D2?i@^|DZ4?AEHLuq_*=oQ z1rYht1U?%_%hj9`{gCHOF>mpWq2D?;yQ~A?ZGTR@?vLq|>NJ0*8Nv~KpfsYdJGr}i zSP_5}G<+6dg(mt)?@Bz(Oe+Y#FK%MHu+&cbCq9S-baNfy96)yz<7wF$?iiPEE^e9p zuGb_1e$KjzwE3a51`)l}FWV)c!1^i>7j+tPd=GsGThEXRCoFB+gsjM_cx~&ufF?wI zlt_P#Jz5-(ESNHPP9fVIcX^q}+9Kut<#P0!ltrrI&!OZc8}k_**_DRU*PEF+F8qv*97}23pz~k3`)z`(d{q_q@ zp{KA!w8PH!k}Ku@m*^?C*2em4sHw%!;#)&UZmU@5>ub?)Mau69h3TJ*<0x8m%cNKW zG3$yfJBwD!eZ#J141dWX;dN|qpKLvwPyOn0xRUaJrmO4`hZlaF*k(R1$HWsMa}N4rL|%ga$m47*zr_`k7QH)5r!iLBZb5T$LUL_3A8syFJ>8r zs4J={`tozKix)(+&Y-S-_2fDU9C;WjrYsB_JBY*%@0b}s>tWbXsrofZVfacd!nh9B z@g+VuCCG{&Vv?vf>37PD&Jt^`aAi|$fiBZW6q>a_hR6iyLg@M)V1Qw2HzvyUoNS
jIx??8)F3m@ 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 From c053bfd31c456d4dc5454169d5b2b59efe6ae345 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Sun, 11 Oct 2026 17:20:23 -0300 Subject: [PATCH 097/115] refactor: optimize XML parsing logic in XlsxWorkbook and XlsxXml classes --- docs/performance/benchmarks.md | 174 +++++++++--------- .../Reader/Xlsx/XlsxWorkbook.Enumerator.cs | 4 +- .../Reader/Xlsx/XlsxWorkbook.Loading.cs | 4 +- src/ExcelReader.Core/Reader/Xlsx/XlsxXml.cs | Bin 14872 -> 15496 bytes 4 files changed, 90 insertions(+), 92 deletions(-) diff --git a/docs/performance/benchmarks.md b/docs/performance/benchmarks.md index 2960ca10..665a46fd 100644 --- a/docs/performance/benchmarks.md +++ b/docs/performance/benchmarks.md @@ -15,32 +15,32 @@ Compares ExcelReader against established XLSX libraries on the same generated wo | Scenario | ExcelReader | Sylvan | OfficeIMO | SpreadCheetah | |---|---:|---:|---:|---:| -| Cell-by-cell read | 7.207 ms, 3.82 KB | 35.203 ms, 1.89 MB | 104.726 ms, 6.84 MB | - | -| Cell-by-cell read async | 7.953 ms, 3.89 KB | - | - | - | -| Typed row parsing | 10.935 ms, 3.87 MB | 56.855 ms, 10.47 MB | 104.530 ms, 5.69 MB | - | -| Typed row parsing async | 11.241 ms, 3.87 MB | 60.501 ms, 10.48 MB | - | - | -| Typed row parsing, shared strings | 10.849 ms, 2.29 MB | - | - | - | +| Cell-by-cell read | 6.847 ms, 3.57 KB | 36.131 ms, 1.89 MB | 106.861 ms, 6.84 MB | - | +| Cell-by-cell read async | 7.391 ms, 3.64 KB | - | - | - | +| Typed row parsing | 10.668 ms, 3.87 MB | 56.150 ms, 10.48 MB | 107.805 ms, 5.70 MB | - | +| Typed row parsing async | 10.833 ms, 3.87 MB | 59.036 ms, 10.48 MB | - | - | +| Typed row parsing, shared strings | 9.216 ms, 2.29 MB | - | - | - | | Workbook writing | 10.913 ms, 4.02 MB | - | 18.166 ms, 4.02 MB | 15.128 ms, 15.84 MB | | Workbook writing, shared strings | 10.848 ms, 4.06 MB | - | - | - | -ExcelReader is ~4.9x faster than Sylvan for raw XLSX reads, allocating ~508x less, and ~14.5x faster than OfficeIMO. For typed parsing it is ~5.2x faster than Sylvan (~5.4x async against async). For XLSX writing, ExcelReader is ~1.4x faster than SpreadCheetah while allocating ~3.9x less memory, and ~1.7x faster than OfficeIMO. +ExcelReader is ~5.3x faster than Sylvan for raw XLSX reads, allocating ~543x less, and ~15.6x faster than OfficeIMO. For typed parsing it is ~5.3x faster than Sylvan (~5.4x async against async). For XLSX writing, ExcelReader is ~1.4x faster than SpreadCheetah while allocating ~3.9x less memory, and ~1.7x faster than OfficeIMO. OfficeIMO.Excel's typed parsing is hand-mapped from its `OpenDataReader`, because `RowsAs` needs an `r` attribute on every row and cell, which the spec makes optional and ExcelReader's writer omits. -Reading a shared-strings XLSX workbook with typed parsing is ~1% faster than the inline-string sheet above (10.849 ms vs. 10.935 ms) and allocates ~41% less (2.29 MB vs. 3.87 MB) — each distinct string decodes once into the shared-string cache instead of once per cell occurrence. +Reading a shared-strings XLSX workbook with typed parsing is ~14% faster than the inline-string sheet above (9.216 ms vs. 10.668 ms) and allocates ~41% less (2.29 MB vs. 3.87 MB) — each distinct string decodes once into the shared-string cache instead of once per cell occurrence. ### XLSB (BIFF12) | Scenario | ExcelReader | OfficeIMO | |---|---:|---:| -| Cell-by-cell read | 5.178 ms, 4.13 KB | 10.406 ms, 6.70 MB | -| Cell-by-cell read async | 5.249 ms, 4.20 KB | - | -| Typed row parsing | 8.211 ms, 3.87 MB | - | -| Typed row parsing async | 8.261 ms, 3.87 MB | - | +| Cell-by-cell read | 4.798 ms, 3.82 KB | 11.123 ms, 6.70 MB | +| Cell-by-cell read async | 4.845 ms, 3.89 KB | - | +| Typed row parsing | 7.819 ms, 3.87 MB | - | +| Typed row parsing async | 8.061 ms, 3.87 MB | - | | Workbook writing | 7.478 ms, 4.02 MB | - | | Workbook writing, shared strings | 7.370 ms, 4.06 MB | - | -XLSB is the fastest generated Excel format in these results: raw reads are ~1.4x faster than XLSX reads, typed parsing is ~1.3x faster than XLSX parsing, and writing is ~1.5x faster than XLSX writing. Against OfficeIMO's XLSB reader, ExcelReader is ~2.0x faster while allocating ~1,660x less. The XLSB writer is also ~2.0x faster than SpreadCheetah's XLSX writer on this benchmark while allocating ~75% less memory. +XLSB is the fastest generated Excel format in these results: raw reads are ~1.4x faster than XLSX reads, typed parsing is ~1.4x faster than XLSX parsing, and writing is ~1.5x faster than XLSX writing. Against OfficeIMO's XLSB reader, ExcelReader is ~2.3x faster while allocating ~1,800x less. The XLSB writer is also ~2.0x faster than SpreadCheetah's XLSX writer on this benchmark while allocating ~75% less memory. ### XLS (BIFF8) @@ -99,14 +99,12 @@ Sequentially, ExcelReader's typed parser is ~1.1x faster than Sep on narrow ints | Dop | XLSX | XLSB | |---:|---:|---:| -| 1 | 54.38 ms, 8.02 MB | 36.55 ms, 8.02 MB | -| 2 | 37.84 ms, 8.13 MB | 25.60 ms, 8.07 MB | -| 4 | 20.07 ms, 8.11 MB | 14.94 ms, 8.07 MB | -| 8 | 19.94 ms, 8.16 MB | 15.26 ms, 8.09 MB | +| 1 | 52.60 ms, 8.02 MB | 35.77 ms, 8.02 MB | +| 2 | 36.30 ms, 8.13 MB | 24.48 ms, 8.07 MB | +| 4 | 16.89 ms, 8.11 MB | 13.32 ms, 8.07 MB | +| 8 | 17.06 ms, 8.16 MB | 13.67 ms, 8.09 MB | -This run had background load and may read a few percent high: on an idle machine a scratch harness's minimums at dop 4 were 18.85 ms for XLSX (against 20.07 ms here) and 14.44 ms for XLSB (against 14.94 ms); an idle rerun is pending. - -At dop 4 XLSX reads ~2.7x faster than sequentially and XLSB ~2.4x. Dop 2 is a single parse worker running alongside the inflating thread. Past dop 4 nothing improves: the one inflating thread is the floor (~15 ms for XLSX, ~10 ms for XLSB, plus the XLSB record walk that finds row boundaries). Allocation barely moves with dop (8.02 to 8.16 MB): it is the typed model's strings. +At dop 4 XLSX reads ~3.1x faster than sequentially and XLSB ~2.7x. Dop 2 is a single parse worker running alongside the inflating thread. Past dop 4 nothing improves: the one inflating thread is the floor (~14 ms for XLSX, ~10 ms for XLSB, see [Inflate](#inflate), plus the XLSB record walk that finds row boundaries). Allocation barely moves with dop (8.02 to 8.16 MB): it is the typed model's strings. ### Real data reads @@ -114,13 +112,13 @@ This benchmark reads a real workbook exported in multiple formats. | Format | ExcelReader | ExcelReader, prefetch | Sylvan | |---|---:|---:|---:| -| XLSX | 43.792 ms, 7.23 KB | 29.544 ms, 23.39 KB | 201.558 ms, 644.23 KB | -| XLSM | 44.486 ms, 7.23 KB | 29.169 ms, 23.42 KB | 207.288 ms, 644.30 KB | -| XLSB | 25.704 ms, 6.79 KB | 17.403 ms, 17.01 KB | 30.864 ms, 338.54 KB | -| XLS | 11.335 ms, 12.02 KB | n/a | 19.146 ms, 185.90 KB | -| CSV | 5.284 ms, 840 B | n/a | 4.630 ms, 40.26 KB | +| XLSX | 41.275 ms, 7.31 KB | 29.616 ms, 23.52 KB | 197.561 ms, 644.23 KB | +| XLSM | 43.649 ms, 7.31 KB | 29.347 ms, 23.48 KB | 194.102 ms, 644.30 KB | +| XLSB | 25.303 ms, 6.87 KB | 17.588 ms, 15.20 KB | 29.771 ms, 338.54 KB | +| XLS | 11.280 ms, 12.02 KB | n/a | 18.379 ms, 185.90 KB | +| CSV | 5.072 ms, 888 B | n/a | 4.585 ms, 40.26 KB | -On this real-data workload, ExcelReader is ~4.6x faster than Sylvan for XLSX and ~4.7x for XLSM, ~1.2x faster for XLSB, and ~1.7x faster for XLS — allocating ~89x less for XLSX and XLSM, ~51x less for XLSB and ~15x less for XLS. On CSV, where both sides read text through spans, Sylvan.Data.Csv is ~14% faster, while ExcelReader allocates ~49x less. The prefetch column is the opt-in [`PrefetchDecompression`](../guide/reading.md#prefetch-decompression-xlsxxlsb) option; XLS and CSV are uncompressed, so it does not apply to them. +On this real-data workload, ExcelReader is ~4.8x faster than Sylvan for XLSX and ~4.4x for XLSM, ~1.2x faster for XLSB, and ~1.6x faster for XLS — allocating ~88x less for XLSX and XLSM, ~49x less for XLSB and ~15x less for XLS. On CSV, where both sides read text through spans, Sylvan.Data.Csv is ~11% faster, while ExcelReader allocates ~46x less. The prefetch column is the opt-in [`PrefetchDecompression`](../guide/reading.md#prefetch-decompression-xlsxxlsb) option; XLS and CSV are uncompressed, so it does not apply to them. ### In-memory real-data reads @@ -128,18 +126,18 @@ The real-data benchmark also measures the in-memory path for workbook content lo | Method | Mean | StdDev | Allocated | |---|---:|---:|---:| -| Xlsx_ExcelReader_Memory | 44.249 ms | 0.341 ms | 6.98 KB | -| Xlsx_ExcelReader_Memory_Prefetch | 30.115 ms | 0.803 ms | 23.19 KB | -| Xlsm_ExcelReader_Memory | 45.242 ms | 0.357 ms | 6.98 KB | -| Xlsm_ExcelReader_Memory_Prefetch | 29.307 ms | 0.718 ms | 23.05 KB | -| Xlsb_ExcelReader_Memory | 25.606 ms | 0.255 ms | 8.49 KB | -| Xlsb_ExcelReader_Memory_Prefetch | 16.688 ms | 0.857 ms | 16.64 KB | -| Xls_ExcelReader_Memory | 11.330 ms | 0.105 ms | 11.88 KB | -| Csv_ExcelReader_Memory | 4.919 ms | 0.043 ms | 568 B | +| Xlsx_ExcelReader_Memory | 42.521 ms | 0.905 ms | 7.05 KB | +| Xlsx_ExcelReader_Memory_Prefetch | 29.167 ms | 0.353 ms | 23.03 KB | +| Xlsm_ExcelReader_Memory | 40.925 ms | 0.140 ms | 7.05 KB | +| Xlsm_ExcelReader_Memory_Prefetch | 30.345 ms | 1.268 ms | 23.22 KB | +| Xlsb_ExcelReader_Memory | 27.084 ms | 1.294 ms | 8.55 KB | +| Xlsb_ExcelReader_Memory_Prefetch | 18.140 ms | 0.703 ms | 16.79 KB | +| Xls_ExcelReader_Memory | 10.704 ms | 0.139 ms | 11.88 KB | +| Csv_ExcelReader_Memory | 4.759 ms | 0.022 ms | 616 B | -`Csv_ExcelReader_Memory` is both faster (4.919 ms vs. 5.284 ms for `Csv_ExcelReader`) and allocates less (568 B vs. 840 B) than its stream twin. +`Csv_ExcelReader_Memory` is both faster (4.759 ms vs. 5.072 ms for `Csv_ExcelReader`) and allocates less (616 B vs. 888 B) than its stream twin. -`Xls_ExcelReader_Memory` matches its stream twin (11.330 ms vs. 11.335 ms for `Xls_ExcelReader`), with near-identical allocation (11.88 KB vs. 12.02 KB). In earlier runs the in-memory path was ~33% faster, thanks to `BiffCursor` caching the enclosing contiguous sector run; the stream path has since caught up. +`Xls_ExcelReader_Memory` is ~5% faster than its stream twin (10.704 ms vs. 11.280 ms for `Xls_ExcelReader`), with near-identical allocation (11.88 KB vs. 12.02 KB). In earlier runs the in-memory path was ~33% faster, thanks to `BiffCursor` caching the enclosing contiguous sector run; the stream path has since mostly caught up. ### String-heavy reads @@ -147,22 +145,22 @@ The real-data corpus above is mostly numbers and dates — its shared-string tab | Format | ExcelReader | ExcelReader, prefetch | Sylvan | |---|---:|---:|---:| -| XLSX | 33.16 ms, 2.18 MB | 23.35 ms, 2.21 MB | 187.18 ms, 17.41 MB | -| XLSB | 35.92 ms, 2.18 MB | 21.86 ms, 2.21 MB | 66.81 ms, 17.38 MB | +| XLSX | 32.84 ms, 2.18 MB | 24.03 ms, 2.21 MB | 186.05 ms, 17.41 MB | +| XLSB | 32.44 ms, 2.18 MB | 21.12 ms, 2.20 MB | 65.22 ms, 17.38 MB | -Both formats handle this well: ~5.6x and ~1.9x faster than Sylvan for XLSX and XLSB respectively, at roughly ~8x less memory in both cases, with no garbage collections in either configuration. XLSB's shared-string path previously materialized its table eagerly (~27 MB here); `ParseSharedStreaming` brought it in line with the XLSX streaming/pooling path, cutting allocation by ~12x on this workload. +Both formats handle this well: ~5.7x and ~2.0x faster than Sylvan for XLSX and XLSB respectively, at roughly ~8x less memory in both cases, with no garbage collections in either configuration. XLSB's shared-string path previously materialized its table eagerly (~27 MB here); `ParseSharedStreaming` brought it in line with the XLSX streaming/pooling path, cutting allocation by ~12x on this workload. `XlsxSharedStringHotPathBenchmark` splits the XLSX read into stages on the same workbook. The stored rows re-zip it without compression, so they show what parsing costs once inflate is gone: | Stage | Mean | Allocated | |---|---:|---:| -| Whole read | 33.79 ms | 2.18 MB | -| Whole read, prefetch | 23.61 ms | 2.21 MB | -| Shared-string table only (open + first row) | 9.72 ms | 747 KB | -| Whole read, stored | 20.10 ms | 2.18 MB | -| Shared-string table only, stored | 3.24 ms | 746 KB | +| Whole read | 33.87 ms | 2.18 MB | +| Whole read, prefetch | 23.36 ms | 2.21 MB | +| Shared-string table only (open + first row) | 9.31 ms | 747 KB | +| Whole read, stored | 20.23 ms | 2.18 MB | +| Shared-string table only, stored | 3.42 ms | 746 KB | -About 40% of the default read is inflate: without it the whole read drops from 33.79 ms to 20.10 ms, and the shared-string table from 9.72 ms to 3.24 ms. What remains, ~17 ms, is the worksheet scan. +About 40% of the default read is inflate: without it the whole read drops from 33.87 ms to 20.23 ms, and the shared-string table from 9.31 ms to 3.42 ms. What remains, ~17 ms, is the worksheet scan. ### Inflate @@ -170,9 +168,9 @@ About 40% of the default read is inflate: without it the whole read drops from 3 | Entry | `DeflateStream` | `InflateStream` | Ratio | |---|---:|---:|---:| -| XLSX `sheet1.xml` (6.7 MB → 34.4 MB) | 23.433 ms, 280 B | 16.501 ms, 200 B | 0.70 | -| XLSB `sheet1.bin` (3.7 MB → 15.9 MB) | 13.230 ms, 280 B | 10.259 ms, 200 B | 0.78 | -| String-heavy `sharedStrings.xml` | 10.522 ms, 281 B | 6.659 ms, 200 B | 0.63 | +| XLSX `sheet1.xml` (6.7 MB → 34.4 MB) | 23.789 ms, 280 B | 13.741 ms, 200 B | 0.58 | +| XLSB `sheet1.bin` (3.7 MB → 15.9 MB) | 13.143 ms, 280 B | 9.734 ms, 200 B | 0.74 | +| String-heavy `sharedStrings.xml` | 10.571 ms, 281 B | 6.106 ms, 200 B | 0.58 | Without prefetch a read is inflate plus parse, so the faster inflate converts directly; with prefetch the two overlap and the read is bounded by the slower stage, which is why the prefetch columns above moved little. @@ -186,30 +184,30 @@ Real-data workbook, per format: | Format | Mean | StdDev | Allocated | |---|---:|---:|---:| -| XLSX | 46.53 ms | 0.19 ms | 16.70 KB | -| XLSM | 46.16 ms | 0.48 ms | 16.70 KB | -| XLSB | 28.46 ms | 0.58 ms | 16.26 KB | -| XLS | 13.36 ms | 0.27 ms | 21.48 KB | -| CSV | 19.38 ms | 0.10 ms | 35.71 MB | +| XLSX | 43.03 ms | 0.15 ms | 16.78 KB | +| XLSM | 43.64 ms | 0.28 ms | 16.78 KB | +| XLSB | 28.01 ms | 0.90 ms | 16.34 KB | +| XLS | 12.95 ms | 0.25 ms | 21.48 KB | +| CSV | 19.31 ms | 0.22 ms | 35.71 MB | Generated 50,000-row XLSX workbook: | Scenario | Mean | StdDev | Allocated | |---|---:|---:|---:| -| Cell-by-cell read, materialized | 8.41 ms | 0.02 ms | 1.58 MB | +| Cell-by-cell read, materialized | 7.89 ms | 0.03 ms | 1.58 MB | String-heavy workbook (65,536 rows, ~190,000 distinct shared strings): | Format | Mean | StdDev | Gen0 | Gen1 | Gen2 | Allocated | |---|---:|---:|---:|---:|---:|---:| -| XLSX | 57.19 ms | 2.01 ms | 1,111.1 | 1,000.0 | 333.3 | 15.25 MB | -| XLSB | 57.84 ms | 1.98 ms | 1,100.0 | 1,000.0 | 300.0 | 15.25 MB | +| XLSX | 55.51 ms | 0.58 ms | 1,111.1 | 1,000.0 | 333.3 | 15.25 MB | +| XLSB | 55.55 ms | 0.95 ms | 1,111.1 | 1,000.0 | 333.3 | 15.25 MB | Reading the allocation columns against the tables above gives the honest shape of the tradeoff: -- **CSV real data:** 35.71 MB materialized against 840 B for the span-based read of the same file. Every CSV field is a distinct string, so nothing dedupes — this is where zero-copy reading earns its keep outright. -- **XLSB real data:** 16.26 KB, essentially cheap. That corpus repeats a small set of values, so the shared-string table dedupes and the reader's string cache materializes each distinct value once. -- **String-heavy XLSX:** 15.25 MB materialized, against **Sylvan's 17.41 MB on the same workload** — doing matched work here, ExcelReader allocates ~12% *less* than Sylvan, with 333 Gen2 collections, and is still ~3.3x faster (57.19 ms vs. 187.18 ms). The same allocation holds for XLSB (15.25 MB vs. Sylvan's 17.38 MB, also ~12% less, 300 Gen2 collections), where matched-work time is ~14% ahead of Sylvan (57.84 ms vs. 66.81 ms). This was previously an inversion (ExcelReader allocated ~1.75x *more* than Sylvan here): the per-reader shared-string dedup cache was an unpresized `Dictionary`, and at ~190,000 distinct values its resize/rehash churn (several of the largest resizes landing on the LOH) accounted for the entire gap — the strings themselves were never the problem, since both readers retain the same ~190,000 distinct instances. Replacing the dictionary with a `string?[]` indexed by shared-string index (sized exactly from the table's known count, no resizing) cut the allocation in half and cut wall-clock time by 14-16% on this benchmark too, since the churn was costing cycles, not just memory. +- **CSV real data:** 35.71 MB materialized against 888 B for the span-based read of the same file. Every CSV field is a distinct string, so nothing dedupes — this is where zero-copy reading earns its keep outright. +- **XLSB real data:** 16.34 KB, essentially cheap. That corpus repeats a small set of values, so the shared-string table dedupes and the reader's string cache materializes each distinct value once. +- **String-heavy XLSX:** 15.25 MB materialized, against **Sylvan's 17.41 MB on the same workload** — doing matched work here, ExcelReader allocates ~12% *less* than Sylvan, with 333 Gen2 collections, and is still ~3.4x faster (55.51 ms vs. 186.05 ms). The same allocation holds for XLSB (15.25 MB vs. Sylvan's 17.38 MB, also ~12% less, 333 Gen2 collections), where matched-work time is ~17% ahead of Sylvan (55.55 ms vs. 65.22 ms). This was previously an inversion (ExcelReader allocated ~1.75x *more* than Sylvan here): the per-reader shared-string dedup cache was an unpresized `Dictionary`, and at ~190,000 distinct values its resize/rehash churn (several of the largest resizes landing on the LOH) accounted for the entire gap — the strings themselves were never the problem, since both readers retain the same ~190,000 distinct instances. Replacing the dictionary with a `string?[]` indexed by shared-string index (sized exactly from the table's known count, no resizing) cut the allocation in half and cut wall-clock time by 14-16% on this benchmark too, since the churn was costing cycles, not just memory. The takeaway is not that one column beats the other: it is that ExcelReader's headline read numbers come from a zero-copy path competitors do not expose, and when it does the same work as them, the gap narrows — and here, with the dedup cache fixed, no longer inverts even at high shared-string cardinality. @@ -245,11 +243,11 @@ A `ref struct` model (see [Parse into a ref struct](../guide/parsing.md#parse-in | Target | Mean | Allocated | |---|---:|---:| -| `class` (`ExcelParser`) | 10.94 ms | 3.87 MB | -| `struct` (`ExcelParser`) | 9.93 ms | 1.58 MB | -| `ref struct` + span binding (`ExcelParser`) | 9.30 ms | 4.36 KB | +| `class` (`ExcelParser`) | 11.22 ms | 3.87 MB | +| `struct` (`ExcelParser`) | 10.11 ms | 1.58 MB | +| `ref struct` + span binding (`ExcelParser`) | 9.65 ms | 4.12 KB | -Parsing into a `ref struct` with a `ReadOnlySpan` text column removes essentially all per-row allocation — ~99.9% less than the `class` baseline — and is ~6% faster than the `struct` target, since there's no per-row `string` allocation for the text column. It is not AOT/trim-safe (reflection-based, same tradeoff as `ExcelParser.FromAttributes`). It can be consumed with `foreach` or `await foreach` but not through `IEnumerable`/`IAsyncEnumerable`/LINQ — a `ref struct` element can't be boxed through those interfaces. +Parsing into a `ref struct` with a `ReadOnlySpan` text column removes essentially all per-row allocation — ~99.9% less than the `class` baseline — and is ~5% faster than the `struct` target, since there's no per-row `string` allocation for the text column. It is not AOT/trim-safe (reflection-based, same tradeoff as `ExcelParser.FromAttributes`). It can be consumed with `foreach` or `await foreach` but not through `IEnumerable`/`IAsyncEnumerable`/LINQ — a `ref struct` element can't be boxed through those interfaces. ### ADO.NET bridge (`IDataReader`) @@ -257,13 +255,13 @@ Parsing into a `ref struct` with a `ReadOnlySpan` text column removes esse | Access | Mean | Ratio | Allocated | |---|---:|---:|---:| -| Raw rows (`row[i].Value`, baseline) | 6.878 ms | 1.00 | 3.87 KB | -| `GetBytes` (text column only) | 7.222 ms | 1.05 | 4.55 KB | -| `GetValue` (every column) | 9.660 ms | 1.40 | 5.01 MB | -| Typed getters (`GetString`/`GetInt32`/`GetDateTime`/`GetDouble`) | 9.059 ms | 1.32 | 1.58 MB | -| `DataTable.Load` | 63.615 ms | 9.25 | 22.84 MB | +| Raw rows (`row[i].Value`, baseline) | 6.845 ms | 1.00 | 3.62 KB | +| `GetBytes` (text column only) | 7.039 ms | 1.03 | 4.31 KB | +| `GetValue` (every column) | 9.479 ms | 1.39 | 5.01 MB | +| Typed getters (`GetString`/`GetInt32`/`GetDateTime`/`GetDouble`) | 9.094 ms | 1.33 | 1.58 MB | +| `DataTable.Load` | 60.509 ms | 8.85 | 22.84 MB | -The bridge costs ~1.3–1.4x over reading rows directly. `GetValue` boxes every value (the 5 MB), and the typed getters allocate only the text column's `string`s. `GetBytes` copies UTF-8 without making a `string`, so it stays within ~5% of the raw read. `DataTable.Load` is dominated by `DataTable`'s own row storage: the same values cost 9.660 ms to read through `GetValue` alone. +The bridge costs ~1.3–1.4x over reading rows directly. `GetValue` boxes every value (the 5 MB), and the typed getters allocate only the text column's `string`s. `GetBytes` copies UTF-8 without making a `string`, so it stays within ~3% of the raw read. `DataTable.Load` is dominated by `DataTable`'s own row storage: the same values cost 9.479 ms to read through `GetValue` alone. ### Arrow conversion @@ -284,16 +282,16 @@ On the same file, letting `Excel.InferSchema` guess the schema costs about the s | Scenario | Mean | Allocated | |---|---:|---:| -| Small, plain | 0.198 ms | 9.15 KB | -| Small, encrypted, stream | 26.129 ms | 38.39 KB | -| Small, encrypted, stream, `VerifyEncryptedIntegrity` | 25.641 ms | 40.54 KB | -| Small, encrypted, in memory | 26.001 ms | 53.53 KB | -| Small, encrypted, open only | 25.780 ms | 32.72 KB | -| 300,000 rows, plain | 39.180 ms | 3.87 KB | -| 300,000 rows, encrypted | 66.278 ms | 29.79 KB | -| 300,000 rows, encrypted, open only | 25.744 ms | 29.10 KB | +| Small, plain | 0.208 ms | 8.84 KB | +| Small, encrypted, stream | 25.880 ms | 38.07 KB | +| Small, encrypted, stream, `VerifyEncryptedIntegrity` | 26.150 ms | 40.16 KB | +| Small, encrypted, in memory | 26.600 ms | 53.24 KB | +| Small, encrypted, open only | 26.378 ms | 32.54 KB | +| 300,000 rows, plain | 38.283 ms | 3.62 KB | +| 300,000 rows, encrypted | 65.485 ms | 29.53 KB | +| 300,000 rows, encrypted, open only | 26.235 ms | 28.92 KB | -Nearly all of the cost is a fixed ~26 ms paid when the workbook is opened: deriving the key through 100,000 SHA-512 iterations, which the format requires so that passwords are slow to brute-force. It does not depend on file size (open-only is 25.78 ms for 15 KB and 25.74 ms for 300,000 rows). After that, decrypting while reading costs ~3% over a plain read: 66.28 ms − 25.74 ms = 40.53 ms, against 39.18 ms unencrypted. On the small fixture, integrity verification and the in-memory path cost nothing measurable. The fixture is too small to show the extra pass that verification makes over a large stream. +Nearly all of the cost is a fixed ~26 ms paid when the workbook is opened: deriving the key through 100,000 SHA-512 iterations, which the format requires so that passwords are slow to brute-force. It does not depend on file size (open-only is 26.38 ms for 15 KB and 26.24 ms for 300,000 rows). After that, decrypting while reading costs ~3% over a plain read: 65.49 ms − 26.24 ms = 39.25 ms, against 38.28 ms unencrypted. On the small fixture, integrity verification and the in-memory path stay within ~3% of the plain encrypted read, inside the spread of the key derivation itself. The fixture is too small to show the extra pass that verification makes over a large stream. ### Cold start @@ -328,25 +326,25 @@ dotnet run --project tests/ExcelReader.Benchmarks/ExcelReader.Benchmarks.csproj | Entry point | Shape handed back | Mean | Managed allocated | |---|---|---:|---:| -| `xl_next_row` | one row per call, serialized into a caller buffer | 40.35 ms | 3.71 MB | -| `xl_read_all_blob` | whole sheet in one buffer | 43.59 ms | 21.06 MB | -| `xl_read_all_decoded` | whole sheet as native row structs | 54.56 ms | 5.71 MB | -| `xl_parse_typed` | typed native columns | 40.79 ms | 11.61 MB | -| `xl_parse_arrow` | Arrow C Data Interface arrays | 39.51 ms | 11.62 MB | +| `xl_rows_next` | one row per call, serialized into a caller buffer | 37.93 ms | 3.71 MB | +| `xl_rows_read_all_blob` | whole sheet in one buffer | 42.74 ms | 21.06 MB | +| `xl_rows_read_all_decoded` | whole sheet as native row structs | 56.73 ms | 5.71 MB | +| `xl_parse_typed` | typed native columns | 35.73 ms | 3.71 MB | +| `xl_parse_arrow` | Arrow C Data Interface arrays | 36.93 ms | 3.72 MB | -The allocation column is **managed memory only**. `MemoryDiagnoser` cannot see the native blocks that `xl_read_all_decoded`, `xl_parse_typed` and `xl_parse_arrow` return their data in. +The allocation column is **managed memory only**. `MemoryDiagnoser` cannot see the native blocks that `xl_rows_read_all_decoded`, `xl_parse_typed` and `xl_parse_arrow` return their data in. -Row by row, `xl_next_row` costs ~1.6x the managed in-memory read of the same file (40.35 ms vs. 25.606 ms for `Xlsb_ExcelReader_Memory` under [In-memory real-data reads](#in-memory-real-data-reads)). The managed figure is from a later run, after `InflateStream`; this native table has not been re-run since, so read the ratio as an upper bound. Every cell crosses the ABI as text, so each binary XLSB number is formatted to UTF-8 on the way out. `xl_read_all_blob` adds ~8% over it, because the whole sheet is buffered before it is copied out. `xl_read_all_decoded` adds ~35%, because it allocates a native block per row. `xl_parse_typed` and `xl_parse_arrow` cost about the same as a row-by-row read but return columnar native buffers with numbers still binary, so they are the path to use for bulk typed loads. +Row by row, `xl_rows_next` costs ~1.4x the managed in-memory read of the same file (37.93 ms vs. 27.084 ms for `Xlsb_ExcelReader_Memory` under [In-memory real-data reads](#in-memory-real-data-reads)). Every cell crosses the ABI as text, so each binary XLSB number is formatted to UTF-8 on the way out. `xl_rows_read_all_blob` adds ~13% over it, because the whole sheet is buffered before it is copied out. `xl_rows_read_all_decoded` adds ~50%, because it allocates a native block per row. `xl_parse_typed` and `xl_parse_arrow` are slightly faster than a row-by-row read (~3–6%) and return columnar native buffers with numbers still binary, so they are the path to use for bulk typed loads. `ChunkedParseBenchmark` compares `xl_parse_typed` on a whole sheet against draining the same sheet in batches through `xl_typed_reader_open`/`xl_typed_reader_next`. It uses the generated 50,000-row XLSX file, read from disk, with three nullable columns (string, int64, float64): | Mode | Mean | Managed allocated | |---|---:|---:| -| Whole sheet (`xl_parse_typed`) | 8.73 ms | 1.29 MB | -| Batches of 10,000 rows | 8.49 ms | 1.59 MB | -| Batches of 1,000 rows | 8.47 ms | 1.51 MB | +| Whole sheet (`xl_parse_typed`) | 8.93 ms | 10 KB | +| Batches of 10,000 rows | 8.36 ms | 16.95 KB | +| Batches of 1,000 rows | 8.53 ms | 81.29 KB | -Batching costs nothing in time: both batch sizes land within ~3% of one whole-sheet table. A caller can bound its native memory to one batch at a time without paying for it. +Batching costs nothing in time: both batch sizes are slightly faster than one whole-sheet table (~4–6%). A caller can bound its native memory to one batch at a time without paying for it. ### C++ and Rust bindings diff --git a/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Enumerator.cs b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Enumerator.cs index a9110bb9..61db4709 100644 --- a/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Enumerator.cs +++ b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Enumerator.cs @@ -510,13 +510,13 @@ private int TryEmitPlainInline(byte[] buf, int len, int p, int col, int style) return -1; } int textStart = p + 7; - int rel = buf.AsSpan(textStart, len - textStart).IndexOfAny((byte)'<', (byte)'&'); + int rel = XlsxXml.IndexOfMarkup(buf.AsSpan(textStart, len - textStart), out bool mayEscape); if (rel < 0) { return -1; } int lt = textStart + rel; - if (!buf.AsSpan(lt, Math.Min(13, len - lt)).StartsWith(""u8) || buf.AsSpan(textStart, rel).IndexOf("_x"u8) >= 0) + if (!buf.AsSpan(lt, Math.Min(13, len - lt)).StartsWith(""u8) || (mayEscape && buf.AsSpan(textStart, rel).IndexOf("_x"u8) >= 0)) { return -1; } diff --git a/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Loading.cs b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Loading.cs index 4e06ef2c..33f0baf1 100644 --- a/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Loading.cs +++ b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Loading.cs @@ -271,14 +271,14 @@ private int TryAppendPlainShared(BufferedStreamCursor io, SharedStringTokens tok return -1; } ReadOnlySpan text = window[7..]; - int rel = text.IndexOfAny((byte)'<', (byte)'&'); + int rel = XlsxXml.IndexOfMarkup(text, out bool mayEscape); if (rel < 0 || !text[rel..].StartsWith(""u8)) { return -1; } EnsureSharedFlat(flat + rel, flat); text[..rel].CopyTo(_sharedFlat.AsSpan(flat)); - flat += XlsxXml.UnescapeXString(_sharedFlat.AsSpan(flat, rel)); + flat += mayEscape ? XlsxXml.UnescapeXString(_sharedFlat.AsSpan(flat, rel)) : rel; return io.Pos + 7 + rel + 9; } diff --git a/src/ExcelReader.Core/Reader/Xlsx/XlsxXml.cs b/src/ExcelReader.Core/Reader/Xlsx/XlsxXml.cs index 783da178639a4fb111ae3e0dec011c5c4a114dc0..08465db184bbf64f3fd2c280bb20e0115a348ae2 100644 GIT binary patch delta 489 zcmaiwKTZNc7{w#;Phw(WBE$mpNx?vbMGK-4R%4;j!f*g3>##c;CgKd)nT>2k?CiI( z^9mYz6OP~wJc37X7FLXvUo&6vz4v}^p7m4oI?0aCx$qQpkHP1G(jYAY8Gw|Cg|_HN z&}^vjz;zE4#37(PMmqOayQbfVp>;`uBG4AA(iI5K~iMy z2OAv6Hug{|O&eVE4s83!!y$il+(&sQ5iiYX*0;z4j~|x{4WSV*^Y3=*Vp*O4@G8yt k>mZjiqL#@&L2VyLhGuta6yG8{e$RfcWN!AxFNG-o4Wz!8k^lez delta 19 acmeCEoKdpjpy*};iM#BK44e5CDrEptg$C;Y From f3e104a0ef8518ed59bb8dfccdbea449b476083e Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Mon, 12 Oct 2026 19:36:51 -0300 Subject: [PATCH 098/115] fix(rust): let docs.rs build the crate without the native library docs.rs builds offline, so build.rs failed downloading the release asset ("curl: (6) Could not resolve host: github.com") and excelreader 6.0.0 has no documentation there. rustdoc never links, so the build script now returns early when DOCS_RS is set. Also drops two explicit link targets rustdoc flags as redundant. Co-Authored-By: Claude Opus 5.5 (1M context) --- rust/excelreader/build.rs | 5 +++++ rust/excelreader/src/lib.rs | 2 +- rust/excelreader/src/writer_handle.rs | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/rust/excelreader/build.rs b/rust/excelreader/build.rs index 62bffe7a..07415a99 100644 --- a/rust/excelreader/build.rs +++ b/rust/excelreader/build.rs @@ -20,6 +20,11 @@ fn main() { println!("cargo:rerun-if-env-changed=EXCELREADER_NATIVE_LIB_DIR"); println!("cargo:rerun-if-changed=excelreader.def"); + // docs.rs builds offline and rustdoc never links, so the native library is not needed there. + if env::var_os("DOCS_RS").is_some() { + return; + } + let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap(); let target_env = env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default(); diff --git a/rust/excelreader/src/lib.rs b/rust/excelreader/src/lib.rs index 1f2b200a..e3c775f2 100644 --- a/rust/excelreader/src/lib.rs +++ b/rust/excelreader/src/lib.rs @@ -121,7 +121,7 @@ pub struct XlOpenOptions { /// Password for an encrypted OOXML workbook, as UTF-8 bytes. NULL (with `password_len` 0) means /// "not encrypted, or fail with `XL_STATUS_PASSWORD_REQUIRED`". Not NUL-terminated - /// `password_len` is authoritative, since a password may contain any byte. The pointer need only - /// remain valid for the duration of the call - see [`OpenOptionsRaw`](crate::options::OpenOptionsRaw). + /// remain valid for the duration of the call - see [`crate::options::OpenOptionsRaw`]. pub password: *const u8, pub password_len: i32, pub source_cache_bytes: i64, diff --git a/rust/excelreader/src/writer_handle.rs b/rust/excelreader/src/writer_handle.rs index 0da1f6ae..1f8abdf7 100644 --- a/rust/excelreader/src/writer_handle.rs +++ b/rust/excelreader/src/writer_handle.rs @@ -32,7 +32,7 @@ pub struct WriterHandle { impl WriterHandle { /// Creates `path` (truncating it if it already exists), inferring the format from its - /// extension via [`format_from_path`](crate::writer::format_from_path). + /// extension via [`crate::writer::format_from_path`]. pub fn open(path: &str, options: Option<&WriteOptions>) -> Result { Self::open_with(path, format_from_path(path), options) } From 79f7fdaa99b70aa977328b5259be9c3bb337f4d9 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Mon, 12 Oct 2026 19:36:51 -0300 Subject: [PATCH 099/115] docs(rust): use the README as the crate documentation and compile its examples lib.rs includes README.md as the crate-level docs, so docs.rs opens on it and cargo test compiles every example. The examples gained hidden setup lines and no_run, since they name files that do not exist; the HttpSource one stays ignore because the crate has no reqwest dependency. The FFI note that used to be the crate doc is now a plain comment. Co-Authored-By: Claude Opus 5.5 (1M context) --- rust/excelreader/README.md | 72 ++++++++++++++++++++++++++++--------- rust/excelreader/src/lib.rs | 7 ++-- 2 files changed, 59 insertions(+), 20 deletions(-) diff --git a/rust/excelreader/README.md b/rust/excelreader/README.md index ea970955..59549d99 100644 --- a/rust/excelreader/README.md +++ b/rust/excelreader/README.md @@ -13,7 +13,7 @@ parse, schema-driven writing, row-by-row decoded reads, and Arrow export. excelreader = "2.1" ``` -```rust +```rust,no_run use excelreader::workbook::{parse_sheet, ExcelMapper, Workbook}; #[derive(Default, ExcelMapper)] @@ -27,6 +27,7 @@ struct Row { let workbook = Workbook::open("book.xlsx")?; let table = parse_sheet::(workbook.sheet(0)?, 1)?; for row in table.iter() { /* ... */ } +# Ok::<(), excelreader::Error>(()) ``` `parse_sheet` takes a `Sheet`, which `Workbook::sheet` hands out (see below). @@ -56,7 +57,8 @@ excelreader = { version = "2.1", features = ["chrono"] } ### Sheets and schema inference -```rust +```rust,no_run +# use excelreader::workbook::Workbook; let workbook = Workbook::open("book.xlsx")?; for (index, name) in workbook.sheet_names()?.iter().enumerate() { println!("{index}: {name}"); @@ -66,6 +68,7 @@ for (index, name) in workbook.sheet_names()?.iter().enumerate() { for column in workbook.sheet(1)?.infer_schema(1, 100)? { println!("{:?} -> type {}", column.name, column.column_type); } +# Ok::<(), excelreader::Error>(()) ``` `infer_schema_parse_text(header_row, sample_size)` also types text cells (every CSV field, or @@ -76,7 +79,8 @@ sequential); XLSX, XLSB and CSV are split, and the result equals `parse_sheet`'s A workbook hands out sheets. A `Sheet` is the workbook and an index: it holds no native resource, it is `Copy`, and every read on it opens its own cursor. -```rust +```rust,no_run +# use excelreader::workbook::Workbook; let workbook = Workbook::open("report.xlsx")?; for sheet in workbook.sheets()? { @@ -89,12 +93,15 @@ while let Some(row) = rows.next_row() { let row = row?; // ... } +# Ok::<(), excelreader::Error>(()) ``` `Workbook` is `Send + Sync`, so sheets can be read in parallel over one open workbook, which loads the shared-string table once: -```rust +```rust,no_run +# use excelreader::workbook::Workbook; +# let workbook = Workbook::open("report.xlsx")?; let tables = std::thread::scope(|scope| { let handles: Vec<_> = workbook .sheets()? @@ -103,6 +110,7 @@ let tables = std::thread::scope(|scope| { .collect(); handles.into_iter().map(|handle| handle.join().expect("no panic")).collect::, _>>() })?; +# Ok::<(), excelreader::Error>(()) ``` `Workbook::open_with` takes an explicit format and `OpenOptions`; `Workbook::open_memory` reads from @@ -113,7 +121,7 @@ a byte slice. Note that format sniffing does not detect CSV - pass `XL_FORMAT_CS `OpenOptions::password` unlocks a password-protected OOXML workbook (.xlsx/.xlsb/.xlsm) through `Workbook::open_with`/`open_memory`: -```rust +```rust,no_run use excelreader::workbook::Workbook; use excelreader::{Error, OpenOptions, XL_FORMAT_AUTO}; @@ -137,12 +145,16 @@ Writing an encrypted workbook is a second step: write the plaintext package with `write_sheet`, then wrap it with `encrypt_package`, which produces an agile-encrypted (ECMA-376 4.4) CFB container - AES-256-CBC, SHA-512, 100,000 spin iterations, with a `dataIntegrity` HMAC: -```rust +```rust,no_run use excelreader::writer::{encrypt_package, write_columns}; use excelreader::XL_FORMAT_XLSX; +# use excelreader::writer::{Column, ColumnData}; +# let ids = [1i64, 2, 3]; +# let columns = [Column { name: Some("id"), data: ColumnData::I64(&ids), validity: None }]; write_columns("plain.xlsx", XL_FORMAT_XLSX, &columns, None)?; encrypt_package("plain.xlsx", "secret.xlsx", "hunter2")?; +# Ok::<(), excelreader::Error>(()) ``` `package_path` is read twice (it is not disposed or removed), so it must already be a finished file. @@ -154,7 +166,7 @@ packages can be encrypted, matching what `Workbook::open_with`/`open_memory` can Implement [`Source`] to read from anything you can read at an offset. The library calls `read_at` from several threads, fetching 4 MiB blocks it caches; every sheet can still be read in parallel. -```rust +```rust,ignore struct HttpSource { client: reqwest::blocking::Client, url: String, size: u64 } impl excelreader::Source for HttpSource { @@ -179,8 +191,10 @@ let workbook = Workbook::open_source(source, XL_FORMAT_AUTO, None)?; Anything that implements `Read` works through `open_reader`. A CSV is read as it arrives; an XLSX, XLSB or XLS is read whole first. -```rust +```rust,no_run +# use excelreader::{workbook::Workbook, XL_FORMAT_CSV}; let workbook = Workbook::open_reader(std::io::stdin(), XL_FORMAT_CSV, None)?; +# Ok::<(), excelreader::Error>(()) ``` An `io::Error` from your source, or a panic in it, comes back as the `Error` of the call that @@ -192,13 +206,21 @@ needed the bytes. excelreader = { version = "2.1", features = ["arrow"] } ``` -```rust +```rust,no_run +# #[cfg(feature = "arrow")] +# fn main() -> Result<(), excelreader::Error> { use excelreader::arrow::parse_arrow; use excelreader::workbook::Workbook; +# #[derive(Default, excelreader::workbook::ExcelMapper)] +# struct Row { #[excel(name = "Name")] name: String } let workbook = Workbook::open("book.xlsx")?; let batch = parse_arrow::(workbook.sheet(0)?, 1)?; println!("{} rows x {} columns", batch.num_rows(), batch.num_columns()); +# Ok(()) +# } +# #[cfg(not(feature = "arrow"))] +# fn main() {} ``` Off by default — arrow-rs is a large dependency and the typed-parse path needs none of it. @@ -207,9 +229,13 @@ Batched: `parse_arrow_stream` is `parse_arrow` delivered a batch at a time, retu `ArrowChunks<'_>`, an `Iterator>` that also implements `RecordBatchReader`: -```rust +```rust,no_run +# #[cfg(feature = "arrow")] +# fn main() -> Result<(), excelreader::Error> { use excelreader::arrow::parse_arrow_stream; use excelreader::workbook::Workbook; +# #[derive(Default, excelreader::workbook::ExcelMapper)] +# struct Row { #[excel(name = "Name")] name: String } let workbook = Workbook::open("book.xlsx")?; let mut chunks = parse_arrow_stream::(workbook.sheet(0)?, 1, 10_000)?; @@ -217,7 +243,10 @@ while let Some(batch) = chunks.next() { let Ok(batch) = batch else { break }; // ArrowChunks does not fuse, unlike TypedChunks println!("{} rows", batch.num_rows()); } -# Ok::<(), excelreader::Error>(()) +# Ok(()) +# } +# #[cfg(not(feature = "arrow"))] +# fn main() {} ``` Same `batch_size` semantics as `typed_chunks` below. Unlike `TypedChunks`, @@ -229,11 +258,15 @@ first `Err` rather than continuing the loop. `#[derive(ExcelMapper)]` generates both halves, so the same struct reads and writes - the field types in the table above apply unchanged: -```rust +```rust,no_run use excelreader::writer::write_sheet; use excelreader::XL_FORMAT_XLSX; +# #[derive(Default, excelreader::workbook::ExcelMapper)] +# struct Row { #[excel(name = "Name")] name: String } +# let rows = vec![Row::default()]; write_sheet("out.xlsx", XL_FORMAT_XLSX, &rows, None)?; +# Ok::<(), excelreader::Error>(()) ``` `Option` fields become an LSB-first validity bitmap: `None` writes a blank cell rather than a @@ -244,7 +277,7 @@ For buffers that are already columnar, `write_columns` borrows them and copies n lifetimes on `Column<'a>` are what turn the ABI's borrow contract into something the compiler checks: -```rust +```rust,no_run use excelreader::writer::{write_columns, Column, ColumnData}; use excelreader::XL_FORMAT_XLSX; @@ -255,6 +288,7 @@ let columns = [Column { validity: None, }]; write_columns("out.xlsx", XL_FORMAT_XLSX, &columns, None)?; +# Ok::<(), excelreader::Error>(()) ``` `validity` is checked against the row count before the call: the ABI takes the bitmap without a @@ -274,7 +308,7 @@ field. That transpose is the only copy it makes; `write_columns` pays nothing. `write_sheet`/`write_columns` build the whole table in memory first. `writer_handle::WriterHandle` is the row-by-row alternative, writing directly as each call arrives instead: -```rust +```rust,no_run use excelreader::writer_handle::WriterHandle; let mut handle = WriterHandle::open("out.xlsx", None)?; @@ -284,6 +318,7 @@ handle.write_str(Some("Name"))?; handle.write_i64(Some(42))?; handle.end_row()?; handle.end_sheet()?; +# Ok::<(), excelreader::Error>(()) ``` Call order mirrors the C ABI's `xl_writer_handle`: `open`/`open_with`/`open_memory`, then per sheet @@ -299,7 +334,7 @@ on the drop for that. `Sheet::rows` returns a cursor over that sheet. Each row borrows a buffer the cursor reuses, so iterating a sheet allocates nothing per row: -```rust +```rust,no_run use excelreader::workbook::Workbook; let workbook = Workbook::open("book.xlsx")?; @@ -318,7 +353,8 @@ Because each row borrows the cursor's buffer, only one row is alive at a time enforces it. To hold every row at once, use `read_all_decoded`, which decodes the whole sheet in one native call and keeps it alive until dropped: -```rust +```rust,no_run +# use excelreader::workbook::Workbook; let workbook = Workbook::open("book.xlsx")?; let rows = workbook.sheet(0)?.read_all_decoded()?; for row in rows.iter() { @@ -335,8 +371,10 @@ native side and never formats a cell to text. For a sheet too large to hold in memory at once, `Sheet::typed_chunks` is `parse_sheet` delivered a batch at a time: -```rust +```rust,no_run use excelreader::workbook::Workbook; +# #[derive(Default, excelreader::workbook::ExcelMapper)] +# struct Row { #[excel(name = "Name")] name: String } let workbook = Workbook::open("book.xlsx")?; let chunks = workbook.sheet(0)?.typed_chunks::(1, 10_000)?; diff --git a/rust/excelreader/src/lib.rs b/rust/excelreader/src/lib.rs index e3c775f2..82e7f00c 100644 --- a/rust/excelreader/src/lib.rs +++ b/rust/excelreader/src/lib.rs @@ -1,6 +1,7 @@ -//! Raw FFI bindings to ExcelReader's C ABI (`excelreader.h`). The `extern "C"` block below is -//! declared directly in this crate root (not a submodule), so `crate::xl_open_file` etc. resolve -//! from anywhere in the crate. See the `workbook` module for the safe wrapper built on top of this. +// Raw FFI bindings to ExcelReader's C ABI (`excelreader.h`). The `extern "C"` block below is +// declared directly in this crate root (not a submodule), so `crate::xl_open_file` etc. resolve +// from anywhere in the crate. See the `workbook` module for the safe wrapper built on top of this. +#![doc = include_str!("../README.md")] #![doc( html_logo_url = "https://raw.githubusercontent.com/GabrielMarquezMatte/ExcelReader/master/assets/icon.png", html_favicon_url = "https://raw.githubusercontent.com/GabrielMarquezMatte/ExcelReader/master/assets/icon.png" From 0a02d7ee0aeb9d8172aa7dcddb6fa275b0839038 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Mon, 12 Oct 2026 19:36:52 -0300 Subject: [PATCH 100/115] build(generator): stop adding the analyzer release files twice Microsoft.CodeAnalysis.Analyzers already adds AnalyzerReleases.Shipped.md and AnalyzerReleases.Unshipped.md as AdditionalFiles. The explicit items duplicated them, which docfx reports as duplicate source files. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/ExcelReader.Generator/ExcelReader.Generator.csproj | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/ExcelReader.Generator/ExcelReader.Generator.csproj b/src/ExcelReader.Generator/ExcelReader.Generator.csproj index a577c725..a6f7b9e0 100644 --- a/src/ExcelReader.Generator/ExcelReader.Generator.csproj +++ b/src/ExcelReader.Generator/ExcelReader.Generator.csproj @@ -36,9 +36,4 @@ $(NoWarn);RS0016;RS0026;RS0037 - - - - - From 7545986978975037e6cc8234bc7cefef79323f29 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Mon, 12 Oct 2026 19:36:52 -0300 Subject: [PATCH 101/115] docs(core): inherit only the summary and exceptions in BeginTableAsync Inheriting the whole BeginTable comment and restating its params made docfx see every param twice. CS1573 still needs the params once ct is documented, so the inheritdoc is narrowed to /summary and /exception instead. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/ExcelReader.Core/Writer/ITableSheetWriter.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ExcelReader.Core/Writer/ITableSheetWriter.cs b/src/ExcelReader.Core/Writer/ITableSheetWriter.cs index ba4ee656..d036d8e0 100644 --- a/src/ExcelReader.Core/Writer/ITableSheetWriter.cs +++ b/src/ExcelReader.Core/Writer/ITableSheetWriter.cs @@ -24,7 +24,8 @@ public interface ITableSheetWriter : ISheetWriter /// The sheet has no room left for a header row and a data row. void BeginTable(string name, IReadOnlyList columns, ExcelTableOptions? options = null); - /// + /// + /// /// The table name: unique in the workbook, ignoring case, and valid as an Excel name. /// The column names, written as the header row. /// Position and style. Defaults to . From 4e3d978afc3b0fc10ad28ef5fb3bf38857310b54 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Mon, 12 Oct 2026 19:36:52 -0300 Subject: [PATCH 102/115] docs: fix the AOT anchor in the parsing guide The heading became "Generate typed maps at compile time (Native AOT / trimming)" and the link still pointed at the old slug. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/parsing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/parsing.md b/docs/guide/parsing.md index 9c43a860..e30137ca 100644 --- a/docs/guide/parsing.md +++ b/docs/guide/parsing.md @@ -236,6 +236,6 @@ A few differences from a class or struct model: - **Span fields alias the reader's row buffer** — valid only until the next row. Copy them out (e.g. `Encoding.UTF8.GetString(span)`) if you need to keep the value past the loop body. Under `await foreach`, the same rule means the model can't be held across an `await` in the loop body. - **The non-generic `IEnumerator.Current` throws.** `IEnumerable` and `IAsyncEnumerable` both accept a `ref struct` element type on net9.0+, so the sequence flows through them normally; only the legacy non-generic `IEnumerator.Current` cannot, because it would have to box. LINQ over a `ref struct` model is still out, since its operators do not carry the `allows ref struct` anti-constraint. -- **Not AOT/trim-safe** on this path, since it reflects over `T`'s properties and compiles setters at runtime. For an AOT-clean zero-copy model, mark the `ref partial struct` `[ExcelSerializable]` and read it with `ExcelParser.Generated` — see [Generate the map at compile time](#generate-the-map-at-compile-time-aot-clean). +- **Not AOT/trim-safe** on this path, since it reflects over `T`'s properties and compiles setters at runtime. For an AOT-clean zero-copy model, mark the `ref partial struct` `[ExcelSerializable]` and read it with `ExcelParser.Generated` — see [Generate typed maps at compile time](#generate-typed-maps-at-compile-time-native-aot--trimming). - A regular `struct`/`class` model uses the same entry point — only a genuine `ref struct` model gets the extra zero-copy span-property binding. From 2d31fa7dabf66d349530037f9b95c2332a2dcd1e Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Mon, 12 Oct 2026 19:36:52 -0300 Subject: [PATCH 103/115] docs: publish a docfx site with the API reference and guides docfx.json builds a site from the Core and Arrow XML docs, the guides, the performance documents and the binding READMEs, with the root README as the landing page. The config sits at the repo root so the README's links (docs/guide/...) resolve unchanged: docfx does not match a link that climbs out of its own folder. docs.yml publishes the site to gh-pages next to the benchmark dashboard. The template sizes the navbar logo, hides the logo the binding READMEs open with, and strips rustdoc's hidden lines from Rust examples. ARCHITECTURE, STYLEGUIDE and docs/README link docs/README.md or #index instead of a folder, which docfx cannot link. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/docs.yml | 47 ++++++++++++++++++++++++++++++++++ .gitignore | 3 +++ ARCHITECTURE.md | 2 +- STYLEGUIDE.md | 6 ++--- docfx.json | 48 +++++++++++++++++++++++++++++++++++ docs/README.md | 8 +++--- docs/index.md | 5 ++++ docs/template/public/main.css | 9 +++++++ docs/template/public/main.js | 13 ++++++++++ docs/toc.yml | 36 ++++++++++++++++++++++++++ 10 files changed, 169 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/docs.yml create mode 100644 docfx.json create mode 100644 docs/index.md create mode 100644 docs/template/public/main.css create mode 100644 docs/template/public/main.js create mode 100644 docs/toc.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..2e9d01c7 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,47 @@ +name: Docs -> GitHub Pages + +on: + push: + branches: [master] + paths: + - 'src/ExcelReader.Core/**' + - 'src/ExcelReader.Arrow/**' + - 'src/ExcelReader.Native/include/excelreader.h' + - 'docs/**' + - '**.md' + - 'docfx.json' + - 'assets/**' + - '.github/workflows/docs.yml' + workflow_dispatch: + +concurrency: + group: docs-pages + cancel-in-progress: true + +permissions: + contents: write + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + + - name: Build site + run: | + dotnet tool install --global docfx --version 2.81.0 + docfx docfx.json + + # keep_files leaves dev/bench (benchmark-pages.yml) in place; pages removed from the docs + # stay on the branch until deleted by hand. + - name: Publish to gh-pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: docs/_site + publish_branch: gh-pages + keep_files: true diff --git a/.gitignore b/.gitignore index 87734d65..bea8006c 100644 --- a/.gitignore +++ b/.gitignore @@ -506,3 +506,6 @@ python/src/excelreader/_lib/* build/ # Subagent-driven-development scratch (per-plan ledgers, briefs, review packages) .superpowers/ +# docfx output (docfx.json); CI builds and publishes it to gh-pages +docs/_site/ +docs/api/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 84bc9600..a627640d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -6,7 +6,7 @@ directly rather than looking for a commentary alongside it. What the code deliberately does *not* carry is the evidence behind a decision — benchmark numbers, memory-footprint measurements, approaches that were tried and rejected. That lives in -[`docs/`](docs/), and [`STYLEGUIDE.md`](STYLEGUIDE.md#comments) says why. +[`docs/`](docs/README.md), and [`STYLEGUIDE.md`](STYLEGUIDE.md#comments) says why. ## The four format families diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md index 7cb2cb22..63275833 100644 --- a/STYLEGUIDE.md +++ b/STYLEGUIDE.md @@ -181,7 +181,7 @@ Never reference the task, the ticket, or the caller. ### Findings do not belong in code Measurements, benchmark results, memory-footprint numbers, and the reasoning that picked one -approach over another are **not comments**. They go in [`docs/`](docs/). +approach over another are **not comments**. They go in [`docs/`](docs/README.md). This includes the whole class of "why it is this way" content: a throughput figure that justified a buffer size, an allocation count that motivated pooling, a design that was prototyped and measured @@ -209,7 +209,7 @@ Comments carry maintenance cost. Two failure modes are specifically banned: - **Do not cite a file that can disappear.** Never point a comment at a plan, a spec, or a section marker ("see step Z4") as the authoritative explanation. Those are working artifacts: they get renamed, merged and deleted, and the comment then sends a reader to nothing, which is worse than - silence. A committed document under [`docs/`](docs/) is different in kind — it is permanent, and + silence. A committed document under [`docs/`](docs/README.md) is different in kind — it is permanent, and pointing at one is how a finding stays out of the code. The test is whether the target is expected to still exist in a year, not whether a link is involved. @@ -329,7 +329,7 @@ These rules apply only when a function is on a measured hot path (cell parsing, - Avoid LINQ in hot paths; use `IndexOf` on spans which benefits from SIMD. - Split cold paths (growth, throw helpers) into `[MethodImpl(MethodImplOptions.NoInlining)]` methods so they do not bloat the hot caller's IL and cost it inlining. -- Optimize against a measurement, and record what it showed in [`docs/`](docs/) — never in a comment. +- Optimize against a measurement, and record what it showed in [`docs/`](docs/README.md) — never in a comment. An optimization with no number behind it is a guess that costs readability. Caches in particular are a trade, not a win: document the workload where the cache pays and the one where it does not, with the numbers that showed it. diff --git a/docfx.json b/docfx.json new file mode 100644 index 00000000..6c1073f6 --- /dev/null +++ b/docfx.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/docfx/main/schemas/docfx.schema.json", + "metadata": [ + { + "src": [ + { + "src": "src", + "files": [ + "ExcelReader.Core/ExcelReader.Core.csproj", + "ExcelReader.Arrow/ExcelReader.Arrow.csproj" + ] + } + ], + "output": "docs/api" + } + ], + "build": { + "content": [ + { "files": [ "index.md", "toc.yml", "api/**.{yml,md}" ], "src": "docs" }, + { + "files": [ + "docs/README.md", + "docs/guide/**.md", + "docs/performance/**.md", + "python/README.md", + "rust/excelreader/README.md", + "cpp/README.md", + "ARCHITECTURE.md", + "CONTRIBUTING.md", + "CODE_OF_CONDUCT.md", + "SECURITY.md", + "STYLEGUIDE.md" + ] + } + ], + "resource": [ + { "files": [ "assets/icon.png", "LICENSE", "src/ExcelReader.Native/include/excelreader.h" ] } + ], + "output": "docs/_site", + "template": [ "default", "modern", "docs/template" ], + "globalMetadata": { + "_appName": "ExcelReader", + "_appTitle": "ExcelReader", + "_appLogoPath": "assets/icon.png", + "_enableSearch": true + } + } +} diff --git a/docs/README.md b/docs/README.md index 7385ab95..d4344412 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,7 +14,7 @@ changes. A document with the numbers, the machine and the corpus can be reproduc valuable documents here** — they are what stops the same idea being rebuilt next year. - A performance ceiling and what it is bounded by. -- Usage documentation under [`guide/`](guide/), split out of the root README so the NuGet landing +- Usage documentation under [`guide/`](#index), split out of the root README so the NuGet landing page stays short. This is prose for a caller deciding *how* to use the library, not a substitute for the `///` comments that document *what* each member does. @@ -42,6 +42,6 @@ invariant) without restating the evidence for it. each format, the ceiling on each, and the optimizations measured and rejected. - [`performance/benchmarks.md`](performance/benchmarks.md) — throughput and allocation against Sep, Sylvan, OfficeIMO and SpreadCheetah. -- [`guide/`](guide/) — usage documentation: [reading](guide/reading.md), - [parsing](guide/parsing.md), [writing](guide/writing.md), [csv](guide/csv.md), - [encryption](guide/encryption.md). +- `guide/` — usage documentation: [reading](./guide/reading.md), + [parsing](./guide/parsing.md), [writing](./guide/writing.md), [csv](./guide/csv.md), + [encryption](./guide/encryption.md). diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..be2d4c13 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,5 @@ +--- +_layout: landing +--- + +[!INCLUDE [README](../README.md)] diff --git a/docs/template/public/main.css b/docs/template/public/main.css new file mode 100644 index 00000000..24c4b26c --- /dev/null +++ b/docs/template/public/main.css @@ -0,0 +1,9 @@ +#logo { + height: 32px; + width: auto; +} + +/* The binding READMEs open with the logo for their GitHub/PyPI/crates.io pages; the navbar already shows it. */ +article img[alt="ExcelReader logo"] { + display: none; +} diff --git a/docs/template/public/main.js b/docs/template/public/main.js new file mode 100644 index 00000000..d1f73a5a --- /dev/null +++ b/docs/template/public/main.js @@ -0,0 +1,13 @@ +// The Rust README doubles as the crate's doctests: rustdoc hides lines starting with "# " and reads +// fence attributes like "rust,no_run". Do the same here before highlighting runs. +export default { + start: () => { + for (const code of document.querySelectorAll('code[class^="lang-rust"]')) { + code.className = 'lang-rust'; + code.textContent = code.textContent + .split('\n') + .filter(line => !/^\s*#(\s|$)/.test(line)) + .join('\n'); + } + } +}; diff --git a/docs/toc.yml b/docs/toc.yml new file mode 100644 index 00000000..7900f493 --- /dev/null +++ b/docs/toc.yml @@ -0,0 +1,36 @@ +- name: Guide + items: + - name: Reading rows + href: guide/reading.md + - name: Typed parsing + href: guide/parsing.md + - name: Writing workbooks + href: guide/writing.md + - name: CSV + href: guide/csv.md + - name: Encrypted workbooks + href: guide/encryption.md + - name: Migrating to v7 + href: guide/migrating-to-v7.md + - name: Migrating to v6 + href: guide/migrating-to-v6.md +- name: API + href: api/ +- name: Bindings + items: + - name: Python + href: ../python/README.md + - name: Rust + href: ../rust/excelreader/README.md + - name: Rust API (docs.rs) + href: https://docs.rs/excelreader + - name: C++ + href: ../cpp/README.md +- name: Performance + items: + - name: Benchmarks + href: performance/benchmarks.md + - name: Reader performance + href: performance/reader-performance.md + - name: Live results + href: https://gabrielmarquezmatte.github.io/ExcelReader/dev/bench/ From 128fa438f4ff497b122f8d9215bfb6bd963a8a0f Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Mon, 12 Oct 2026 19:36:52 -0300 Subject: [PATCH 104/115] docs: add a guide mapping errors across languages One table from each kind of failure to the C# exception, the XL_* status, and the Python, Rust and C++ error types, plus what each status code means. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/README.md | 2 +- docs/guide/errors.md | 47 ++++++++++++++++++++++++++++++++++++++++++++ docs/toc.yml | 2 ++ 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 docs/guide/errors.md diff --git a/docs/README.md b/docs/README.md index d4344412..dfb01feb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -44,4 +44,4 @@ invariant) without restating the evidence for it. Sep, Sylvan, OfficeIMO and SpreadCheetah. - `guide/` — usage documentation: [reading](./guide/reading.md), [parsing](./guide/parsing.md), [writing](./guide/writing.md), [csv](./guide/csv.md), - [encryption](./guide/encryption.md). + [encryption](./guide/encryption.md), [errors](./guide/errors.md). diff --git a/docs/guide/errors.md b/docs/guide/errors.md new file mode 100644 index 00000000..58afe6fd --- /dev/null +++ b/docs/guide/errors.md @@ -0,0 +1,47 @@ +# Errors across languages + +The .NET library throws exceptions. The C ABI turns each failure into an `XL_*` status code, and the +Python, Rust and C++ bindings turn that code into their own error type. This page maps one onto the +other, so a failure seen in one language can be recognized in another. + +## Status codes + +Every `xl_*` function returns one of these. After a failure, `xl_last_error_ptr` returns the message +for the calling thread. + +| Code | Constant | Meaning | +|---|---|---| +| 0 | `XL_OK` | Success. | +| -1 | `XL_EOF` | No more rows or batches. Not an error: the bindings end iteration on it. | +| -2 | `XL_BUFFER_TOO_SMALL` | The caller's buffer cannot hold the result. The bindings grow the buffer and retry, so it never reaches user code. | +| -3 | `XL_INVALID_HANDLE` | The handle is null, already closed, or was never opened. | +| -4 | `XL_INVALID_ARGUMENT` | An argument failed validation before any work started: a null pointer, a bad length, malformed options. | +| -5 | `XL_ERROR` | Anything else. The message is the message of the .NET exception behind it. | +| -6 | `XL_STATUS_PASSWORD_REQUIRED` | The workbook is encrypted and no password was supplied. | +| -7 | `XL_STATUS_PASSWORD_INCORRECT` | The supplied password did not match the workbook's verifier. | + +## By failure + +| Failure | C# | Status | Python | Rust | C++ | +|---|---|---|---|---|---| +| Encrypted, no password | `ExcelEncryptionException`, `Reason == PasswordRequired` | -6 | `PasswordRequiredError` | `Error::PasswordRequired` | `xl::Error`, `code == XL_STATUS_PASSWORD_REQUIRED` | +| Wrong password | `ExcelEncryptionException`, `Reason == PasswordIncorrect` | -7 | `PasswordIncorrectError` | `Error::PasswordIncorrect` | `xl::Error`, `code == XL_STATUS_PASSWORD_INCORRECT` | +| Unsupported encryption, or failed integrity check | `ExcelEncryptionException`, `Reason` is `UnsupportedScheme` or `IntegrityFailure` | -5 | `ExcelReaderError` | `Error::Native` | `xl::Error`, `code == XL_ERROR` | +| Corrupt or malformed file | `InvalidDataException` | -5 | `ExcelReaderError` | `Error::Native` | `xl::Error`, `code == XL_ERROR` | +| Feature the reader does not support | `NotSupportedException` | -5 | `ExcelReaderError` | `Error::Native` | `xl::Error`, `code == XL_ERROR` | +| File exceeds a configured resource limit | `ExcelLimitExceededException` | -5 | `ExcelReaderError` | `Error::Native` | `xl::Error`, `code == XL_ERROR` | +| Workbook already closed | `ObjectDisposedException` | -3 | `ExcelReaderError` | `Error::Native` | `xl::Error`, `code == XL_INVALID_HANDLE` | +| Invalid argument | `ArgumentException` and its subclasses | -4, or -5 when the check happens past the ABI boundary | `ExcelReaderError` | `Error::Native` | `xl::Error` | + +`PasswordRequiredError` and `PasswordIncorrectError` derive from `ExcelReaderError`, so +`except ExcelReaderError` catches every native failure in Python. Rust's `Error::code()` returns the +status for every variant. The C++ header throws nothing: every entry point returns +`std::expected`. + +Only the two password cases are worth retrying with different input. The rest fail the same way on +every attempt. + +Each binding also checks some arguments itself, before calling the ABI, and reports those with its +own native types: Python raises `ValueError`, `TypeError` or `IndexError`; Rust panics on an +out-of-range index (see the crate README's "Bounds and panics"); C++ returns `xl::Error` with +`code == XL_INVALID_ARGUMENT`. diff --git a/docs/toc.yml b/docs/toc.yml index 7900f493..53c23475 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -10,6 +10,8 @@ href: guide/csv.md - name: Encrypted workbooks href: guide/encryption.md + - name: Errors across languages + href: guide/errors.md - name: Migrating to v7 href: guide/migrating-to-v7.md - name: Migrating to v6 From 1fb0ad15a94f388d3d78fb70b7fec8b888a836a3 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Mon, 12 Oct 2026 19:36:53 -0300 Subject: [PATCH 105/115] docs(python): generate the Python API reference for the docfx site python/scripts/api_markdown.py writes a Markdown page per public class, a functions page and a toc.yml from excelreader's signatures and docstrings, so the reference renders with the site's own layout, navigation and search. Types are linked to their pages in parameter, return and field tables, and every generated signature is checked with ast.parse. Importing excelreader loads the native library lazily, so CI needs only the sources. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/docs.yml | 6 + .gitignore | 3 +- docfx.json | 2 +- docs/toc.yml | 2 + python/scripts/api_markdown.py | 223 +++++++++++++++++++++++++++++++++ 5 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 python/scripts/api_markdown.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 2e9d01c7..b5c582f8 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -7,6 +7,8 @@ on: - 'src/ExcelReader.Core/**' - 'src/ExcelReader.Arrow/**' - 'src/ExcelReader.Native/include/excelreader.h' + - 'python/src/**' + - 'python/scripts/api_markdown.py' - 'docs/**' - '**.md' - 'docfx.json' @@ -31,6 +33,10 @@ jobs: with: global-json-file: global.json + # Importing excelreader loads the native library lazily, so this needs only the sources. + - name: Build Python API reference + run: python3 python/scripts/api_markdown.py + - name: Build site run: | dotnet tool install --global docfx --version 2.81.0 diff --git a/.gitignore b/.gitignore index bea8006c..9001b4e0 100644 --- a/.gitignore +++ b/.gitignore @@ -506,6 +506,7 @@ python/src/excelreader/_lib/* build/ # Subagent-driven-development scratch (per-plan ledgers, briefs, review packages) .superpowers/ -# docfx output (docfx.json); CI builds and publishes it to gh-pages +# docfx output and the generated Python API pages (docfx.json, python/scripts/api_markdown.py); CI builds and publishes it to gh-pages docs/_site/ docs/api/ +docs/python-api/ diff --git a/docfx.json b/docfx.json index 6c1073f6..6e770446 100644 --- a/docfx.json +++ b/docfx.json @@ -16,7 +16,7 @@ ], "build": { "content": [ - { "files": [ "index.md", "toc.yml", "api/**.{yml,md}" ], "src": "docs" }, + { "files": [ "index.md", "toc.yml", "api/**.{yml,md}", "python-api/**.{yml,md}" ], "src": "docs" }, { "files": [ "docs/README.md", diff --git a/docs/toc.yml b/docs/toc.yml index 53c23475..b11f23a0 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -22,6 +22,8 @@ items: - name: Python href: ../python/README.md + - name: Python API + href: python-api/ - name: Rust href: ../rust/excelreader/README.md - name: Rust API (docs.rs) diff --git a/python/scripts/api_markdown.py b/python/scripts/api_markdown.py new file mode 100644 index 00000000..027fd0b7 --- /dev/null +++ b/python/scripts/api_markdown.py @@ -0,0 +1,223 @@ +"""Write the Python API reference as Markdown pages plus a toc.yml, for docfx to render. + +Run from anywhere: python python/scripts/api_markdown.py +""" + +import ast +import enum +import inspect +import re +import sys +import typing +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +OUTPUT_DIR = REPO_ROOT / "docs" / "python-api" +MAX_LINE = 88 + +sys.path.insert(0, str(REPO_ROOT / "python" / "src")) +import excelreader # type: ignore[import-not-found] # noqa: E402 + +LINKABLE = {name for name in excelreader.__all__ if inspect.isclass(getattr(excelreader, name))} + +PROTOCOLS = { + "__len__": "`len()`", + "__getitem__": "indexing", + "__iter__": "iteration", + "__contains__": "`in`", + "__enter__": "`with`", +} + + +class _Raw(str): + """An annotation kept as written, so a signature prints `int | None` rather than `'int | None'`.""" + + def __repr__(self) -> str: + return str(self) + + +def _raw(annotation: object) -> object: + annotation = getattr(annotation, "__forward_arg__", annotation) + return _Raw(annotation) if isinstance(annotation, str) else annotation + + +def signature(function: typing.Callable, drop_first: bool = False) -> str: + sig = inspect.signature(function) + parameters = [p.replace(annotation=_raw(p.annotation)) for p in sig.parameters.values()] + if drop_first: + parameters = parameters[1:] + sig = sig.replace(parameters=parameters, return_annotation=_raw(sig.return_annotation)) + one_line = f"def {function.__name__}{sig}" + if len(one_line) <= MAX_LINE: + return one_line + + lines = [] + for index, parameter in enumerate(parameters): + previous = parameters[index - 1].kind if index else None + if previous is inspect.Parameter.POSITIONAL_ONLY and parameter.kind is not previous: + lines.append("/") + if parameter.kind is inspect.Parameter.KEYWORD_ONLY and previous not in ( + inspect.Parameter.KEYWORD_ONLY, + inspect.Parameter.VAR_POSITIONAL, + ): + lines.append("*") + lines.append(str(parameter)) + if parameters and parameters[-1].kind is inspect.Parameter.POSITIONAL_ONLY: + lines.append("/") + returns = "" if sig.return_annotation is inspect.Signature.empty else f" -> {sig.return_annotation}" + body = "".join(f" {line},\n" for line in lines) + return f"def {function.__name__}(\n{body}){returns}" + + +def signatures(function: typing.Callable, drop_first: bool = False) -> str: + overloads = typing.get_overloads(function) or [function] + rendered = [signature(f, drop_first) for f in overloads] + for text in rendered: + ast.parse(f"{text}: ...") + return "\n".join(rendered) + + +def doc(obj: object) -> str: + return inspect.getdoc(obj) or "" + + +def cell(text: object) -> str: + return str(text).replace("|", "\\|") + + +def linked(annotation: object) -> str: + text = cell(_raw(annotation)).replace("[", "\\[").replace("]", "\\]") + return re.sub( + r"\b[A-Za-z_]\w*\b", + lambda m: f"[{m[0]}]({m[0]}.md)" if m[0] in LINKABLE else m[0], + text, + ) + + +def parameters_and_returns(function: typing.Callable, drop_first: bool) -> str: + sig = inspect.signature(function) + parameters = list(sig.parameters.values())[1 if drop_first else 0 :] + parts = [] + if parameters: + rows = "\n".join( + f"| `{p.name}` | " + + ("" if p.annotation is inspect.Parameter.empty else linked(p.annotation)) + + " | " + + ("" if p.default is inspect.Parameter.empty else f"`{cell(repr(p.default))}`") + + " |" + for p in parameters + ) + parts.append(f"**Parameters**\n\n| Name | Type | Default |\n|---|---|---|\n{rows}") + if sig.return_annotation not in (inspect.Signature.empty, "None", None): + parts.append(f"**Returns** {linked(sig.return_annotation)}") + return "\n\n".join(parts) + + +def code(text: str) -> str: + return f"```python\n{text}\n```" + + +def bases(cls: type) -> str: + names = [getattr(b, "__name__", str(b)) for b in getattr(cls, "__orig_bases__", cls.__bases__)] + names = [n for n in names if n != "object"] + return f"({', '.join(names)})" if names else "" + + +def class_page(cls: type) -> str: + kind = "Enum" if issubclass(cls, enum.Enum) else "Exception" if issubclass(cls, BaseException) else "Class" + parts = [f"# {kind} {cls.__name__}", doc(cls), code(f"class {cls.__name__}{bases(cls)}")] + + inherited = [b.__name__ for b in cls.__bases__ if b.__name__ in LINKABLE] + if inherited: + parts.append("**Inherits** " + ", ".join(linked(name) for name in inherited)) + + supported = [label for name, label in PROTOCOLS.items() if name in vars(cls)] + if supported: + parts.append(f"Supports {', '.join(supported)}.") + + if issubclass(cls, enum.Enum): + rows = "\n".join(f"| `{m.name}` | {cell(m.value)} |" for m in cls) + parts.append(f"## Members\n\n| Name | Value |\n|---|---|\n{rows}") + elif hasattr(cls, "_fields"): + annotations = inspect.get_annotations(cls) + defaults = getattr(cls, "_field_defaults", {}) + rows = "\n".join( + f"| `{f}` | {linked(annotations.get(f, ''))} | " + + (f"`{cell(repr(defaults[f]))}`" if f in defaults else "") + + " |" + for f in cls._fields + ) + parts.append(f"## Fields\n\n| Name | Type | Default |\n|---|---|---|\n{rows}") + + members = [(n, v) for n, v in vars(cls).items() if not n.startswith("_")] + properties = [(n, v) for n, v in members if isinstance(v, property)] + methods = [(n, v) for n, v in members if inspect.isfunction(v) or isinstance(v, (staticmethod, classmethod))] + + if properties: + parts.append("## Properties") + for name, prop in properties: + returns = inspect.signature(prop.fget).return_annotation if prop.fget else inspect.Signature.empty + typed = "" if returns is inspect.Signature.empty else f": {returns}" + parts += [f"### {name}", code(f"{name}{typed}")] + if "](" in linked(returns): + parts.append(f"**Type** {linked(returns)}") + parts.append(doc(prop)) + + if methods: + parts.append("## Methods") + for name, method in methods: + function: typing.Callable = method.__func__ if isinstance(method, (staticmethod, classmethod)) else method + drop_first = not isinstance(method, staticmethod) + parts += [ + f"### {name}", + code(signatures(function, drop_first)), + doc(function), + parameters_and_returns(function, drop_first), + ] + + return "\n\n".join(p for p in parts if p) + "\n" + + +def functions_page(functions: list) -> str: + parts = ["# Functions"] + for function in functions: + parts += [ + f"## {function.__name__}", + code(signatures(function)), + doc(function), + parameters_and_returns(function, drop_first=False), + ] + return "\n\n".join(p for p in parts if p) + "\n" + + +def toc_section(name: str, classes: list) -> str: + items = "".join(f" - name: {c.__name__}\n href: {c.__name__}.md\n" for c in classes) + return f"- name: {name}\n items:\n{items}" if classes else "" + + +def main() -> None: + public = [getattr(excelreader, name) for name in excelreader.__all__] + classes = [o for o in public if inspect.isclass(o)] + functions = [o for o in public if inspect.isfunction(o)] + enums = [c for c in classes if issubclass(c, enum.Enum)] + exceptions = [c for c in classes if issubclass(c, BaseException)] + others = [c for c in classes if c not in enums and c not in exceptions] + + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + for stale in OUTPUT_DIR.iterdir(): + stale.unlink() + for cls in classes: + (OUTPUT_DIR / f"{cls.__name__}.md").write_text(class_page(cls), encoding="utf-8") + (OUTPUT_DIR / "functions.md").write_text(functions_page(functions), encoding="utf-8") + + toc = ( + toc_section("Classes", others) + + toc_section("Enums", enums) + + toc_section("Exceptions", exceptions) + + "- name: Functions\n href: functions.md\n" + ) + (OUTPUT_DIR / "toc.yml").write_text(toc, encoding="utf-8") + + +if __name__ == "__main__": + main() From 30f640d634b58860acbddc8eade296ac2e2b810e Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Mon, 12 Oct 2026 19:36:53 -0300 Subject: [PATCH 106/115] docs(cpp): generate the C++ API reference from Doxygen XML cpp/scripts/api_markdown.py runs Doxygen over excelreader.hpp and excelreader_arrow.hpp and turns its XML into Markdown pages for docfx: classes, structs, enums, concepts and free functions, with @param, @tparam and @return in their own tables and library types linked. xl::detail, iterator internals and copy/move members are left out. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/docs.yml | 9 +- .gitignore | 3 +- cpp/scripts/api_markdown.py | 360 ++++++++++++++++++++++++++++++++++++ docfx.json | 2 +- docs/toc.yml | 2 + 5 files changed, 373 insertions(+), 3 deletions(-) create mode 100644 cpp/scripts/api_markdown.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b5c582f8..a6d6a36f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -6,9 +6,10 @@ on: paths: - 'src/ExcelReader.Core/**' - 'src/ExcelReader.Arrow/**' - - 'src/ExcelReader.Native/include/excelreader.h' + - 'src/ExcelReader.Native/include/**' - 'python/src/**' - 'python/scripts/api_markdown.py' + - 'cpp/scripts/api_markdown.py' - 'docs/**' - '**.md' - 'docfx.json' @@ -37,6 +38,12 @@ jobs: - name: Build Python API reference run: python3 python/scripts/api_markdown.py + - name: Build C++ API reference + run: | + sudo apt-get update + sudo apt-get install --yes --no-install-recommends doxygen + python3 cpp/scripts/api_markdown.py + - name: Build site run: | dotnet tool install --global docfx --version 2.81.0 diff --git a/.gitignore b/.gitignore index 9001b4e0..b0eb550c 100644 --- a/.gitignore +++ b/.gitignore @@ -506,7 +506,8 @@ python/src/excelreader/_lib/* build/ # Subagent-driven-development scratch (per-plan ledgers, briefs, review packages) .superpowers/ -# docfx output and the generated Python API pages (docfx.json, python/scripts/api_markdown.py); CI builds and publishes it to gh-pages +# docfx output and the generated Python/C++ API pages (docfx.json, */scripts/api_markdown.py); CI builds and publishes it to gh-pages docs/_site/ docs/api/ docs/python-api/ +docs/cpp-api/ diff --git a/cpp/scripts/api_markdown.py b/cpp/scripts/api_markdown.py new file mode 100644 index 00000000..34daabb4 --- /dev/null +++ b/cpp/scripts/api_markdown.py @@ -0,0 +1,360 @@ +"""Write the C++ API reference as Markdown pages plus a toc.yml, for docfx to render. + +Runs Doxygen over the public headers and reads its XML output, so Doxygen must be on PATH. + +Run from anywhere: python cpp/scripts/api_markdown.py +""" + +import re +import subprocess +import tempfile +import textwrap +import xml.etree.ElementTree as ET +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +HEADERS = ["cpp/include/xl/excelreader.hpp", "cpp/include/xl/excelreader_arrow.hpp"] +OUTPUT_DIR = REPO_ROOT / "docs" / "cpp-api" +MAX_LINE = 100 + + +def run_doxygen(output: Path) -> Path: + config = "\n".join( + [ + f"INPUT = {' '.join(HEADERS)}", + f"OUTPUT_DIRECTORY = {output.as_posix()}", + "GENERATE_HTML = NO", + "GENERATE_LATEX = NO", + "GENERATE_XML = YES", + "XML_PROGRAMLISTING = NO", + "EXTRACT_ALL = YES", + "EXCLUDE_SYMBOLS = xl::detail*", + "QUIET = YES", + "WARNINGS = NO", + ] + ) + subprocess.run(["doxygen", "-"], input=config, text=True, cwd=REPO_ROOT, check=True) + return output / "xml" + + +def tidy(text: str) -> str: + text = re.sub(r"\s*<\s*", "<", text) + text = re.sub(r"\s+>", ">", text) + return re.sub(r"\s+", " ", text).strip() + + +def flat(element) -> str: + return tidy("".join(element.itertext())) if element is not None else "" + + +SECTIONS = ("parameterlist", "simplesect") + + +def inline(node) -> str: + """A paragraph as Markdown text: inline code kept, @param/@return blocks left to their own readers.""" + out = node.text or "" + for child in node: + if child.tag not in SECTIONS: + inner = inline(child) + out += f"`{inner}`" if child.tag == "computeroutput" else inner + out += child.tail or "" + return re.sub(r"\s+", " ", out).strip() + + +def prose(element) -> str: + if element is None: + return "" + paragraphs = [inline(p) for p in element.findall("para")] + notes = [ + f"**{s.get('kind', '').capitalize()}** " + " ".join(inline(p) for p in s.findall("para")) + for p in element.findall("para") + for s in p.findall("simplesect") + if s.get("kind") not in ("return", "returns") + ] + return "\n\n".join(p for p in paragraphs + notes if p) + + +def description(node) -> str: + return "\n\n".join(p for p in (prose(node.find("briefdescription")), prose(node.find("detaileddescription"))) if p) + + +def documented_params(node, kind: str) -> dict[str, str]: + """The `@param` (kind "param") or `@tparam` (kind "templateparam") texts, by name.""" + found: dict[str, str] = {} + if node is None: + return found + for parameter_list in node.iter("parameterlist"): + if parameter_list.get("kind") != kind: + continue + for item in parameter_list.findall("parameteritem"): + text = " ".join(inline(p) for p in item.iter("para")) + for name in item.iter("parametername"): + found.setdefault(flat(name), text) + return found + + +def documented_return(node) -> str: + if node is None: + return "" + return " ".join( + inline(p) for s in node.iter("simplesect") if s.get("kind") in ("return", "returns") for p in s.findall("para") + ) + + +def type_parameters(node) -> str: + docs = documented_params(node, "templateparam") + if not docs: + return "" + rows = "\n".join(f"| `{name}` | {cell(text)} |" for name, text in docs.items()) + return f"**Type parameters**\n\n| Name | Description |\n|---|---|\n{rows}" + + +def cell(text: str) -> str: + return text.replace("|", "\\|") + + +def linked(text: str, linkable: dict[str, str]) -> str: + text = cell(text).replace("<", "<").replace(">", ">") + return re.sub( + r"(? str: + return f"```cpp\n{text}\n```" + + +def template_prefix(node) -> str: + params = node.find("templateparamlist") + if params is None: + return "" + names = [] + for param in params.iter("param"): + declared = flat(param.find("type")) + name = param.findtext("declname") or param.findtext("defname") or "" + names.append(tidy(f"{declared} {name}")) + return f"template <{', '.join(names)}>\n" + + +def signature(member) -> str: + name = member.findtext("name") + returns = flat(member.find("type")) + prefix = "static " if member.get("static") == "yes" else "" + head = tidy(f"{prefix}{returns} {name}") + args = tidy(member.findtext("argsstring") or "") + one_line = f"{template_prefix(member)}{head}{args}" + params = member.findall("param") + if len(one_line.splitlines()[-1]) <= MAX_LINE or not params: + return one_line + + lines = [] + for param in params: + text = tidy(f"{flat(param.find('type'))} {param.findtext('declname') or ''}") + default = flat(param.find("defval")) + lines.append(f"{text} = {default}" if default else text) + trailer = args[args.rindex(")") + 1 :].strip() + body = ",\n".join(f" {line}" for line in lines) + return f"{template_prefix(member)}{head}(\n{body}){(' ' + trailer) if trailer else ''}" + + +def parameters_and_returns(members: list, linkable: dict[str, str]) -> str: + parts = [type_parameters(members[0])] + docs: dict[str, str] = {} + for member in members: + for name, text in documented_params(member, "param").items(): + docs.setdefault(name, text) + + seen = set() + rows = [] + for param in (p for m in members for p in m.findall("param") if p.findtext("declname")): + name = param.findtext("declname") + type_text = flat(param.find("type")) + if (name, type_text) in seen: + continue + seen.add((name, type_text)) + default = flat(param.find("defval")) + rows.append( + f"| `{name}` | {linked(type_text, linkable)} | " + + (f"`{cell(default)}`" if default else "") + + (f" | {cell(docs.get(name or '', ''))}" if docs else "") + + " |" + ) + if rows: + header = "| Name | Type | Default | Description |\n|---|---|---|---|" if docs else "| Name | Type | Default |\n|---|---|---|" + parts.append(f"**Parameters**\n\n{header}\n" + "\n".join(rows)) + + returns = {re.sub(r"\b(constexpr|inline|static|explicit) ", "", flat(m.find("type"))) for m in members} + returns -= {"", "void"} + if returns: + text = next((documented_return(m) for m in members if documented_return(m)), "") + parts.append( + "**Returns** " + ", ".join(linked(r, linkable) for r in sorted(returns)) + (f": {text}" if text else "") + ) + return "\n\n".join(p for p in parts if p) + + +def by_name(members: list) -> dict[str, list]: + grouped: dict[str, list] = {} + for member in members: + grouped.setdefault(member.findtext("name"), []).append(member) + return grouped + + +def member_sections(title: str, members: list, linkable: dict[str, str], level: str) -> list[str]: + if not members: + return [] + parts = [f"{level} {title}"] + for name, overloads in by_name(members).items(): + parts += [ + f"{level}# {name}", + code("\n".join(signature(m) for m in overloads)), + *dict.fromkeys(description(m) for m in overloads), + parameters_and_returns(overloads, linkable), + ] + return parts + + +def is_copy_or_move(member, class_name: str) -> bool: + params = member.findall("param") + return ( + member.findtext("name") in (class_name, "operator=") + and len(params) == 1 + and re.fullmatch(rf"(const )?{re.escape(class_name)} ?&&?", flat(params[0].find("type"))) is not None + ) + + +def class_page(compound, linkable: dict[str, str]) -> str: + full_name = compound.findtext("compoundname") + short = full_name.removeprefix("xl::") + kind = compound.get("kind") + parts = [ + f"# {kind.capitalize()} {short}", + description(compound), + code(f"{template_prefix(compound)}{kind} {full_name}"), + type_parameters(compound.find("detaileddescription")), + ] + + public = [ + m + for m in compound.iter("memberdef") + if m.get("prot") == "public" and "=delete" not in (m.findtext("argsstring") or "").replace(" ", "") + ] + fields = [m for m in public if m.get("kind") == "variable"] + functions = [ + m + for m in public + if m.get("kind") == "function" and not m.findtext("name").startswith("~") and not is_copy_or_move(m, short) + ] + constructors = [m for m in functions if m.findtext("name") == short.split("<")[0]] + operators = [m for m in functions if m.findtext("name").startswith("operator")] + methods = [m for m in functions if m not in constructors and m not in operators] + + if fields: + described = any(description(m) for m in fields) + rows = "\n".join( + f"| `{m.findtext('name')}` | {linked(flat(m.find('type')), linkable)} | " + + (f"`{cell(flat(m.find('initializer')).lstrip('= '))}`" if m.find("initializer") is not None else "") + + (f" | {cell(description(m)).replace(chr(10), ' ')}" if described else "") + + " |" + for m in fields + ) + header = "| Name | Type | Default | Description |\n|---|---|---|---|" if described else "| Name | Type | Default |\n|---|---|---|" + parts.append(f"## Fields\n\n{header}\n{rows}") + + parts += member_sections("Constructors", constructors, linkable, "##") + parts += member_sections("Methods", methods, linkable, "##") + parts += member_sections("Operators", operators, linkable, "##") + return "\n\n".join(p for p in parts if p) + "\n" + + +def enum_page(member) -> str: + name = member.findtext("name") + underlying = flat(member.find("type")) + rows = "\n".join( + f"| `{v.findtext('name')}` | `{cell(flat(v.find('initializer')).lstrip('= '))}` | {cell(description(v))} |" + for v in member.iter("enumvalue") + ) + parts = [ + f"# Enum {name}", + description(member), + code(f"enum class xl::{name}" + (f" : {underlying}" if underlying else "")), + f"## Members\n\n| Name | Value | Description |\n|---|---|---|\n{rows}", + ] + return "\n\n".join(p for p in parts if p) + "\n" + + +def concept_page(concept) -> str: + name = concept.findtext("compoundname", "").removeprefix("xl::") + initializer = concept.find("initializer") + definition = textwrap.dedent("".join(initializer.itertext())).strip() if initializer is not None else "" + parts = [f"# Concept {name}", description(concept), code(definition), type_parameters(concept.find("detaileddescription"))] + return "\n\n".join(p for p in parts if p) + "\n" + + +def functions_page(functions: list, linkable: dict[str, str]) -> str: + parts = ["# Functions", "Free functions in namespace `xl`."] + for name, overloads in by_name(functions).items(): + parts += [ + f"## {name}", + code("\n".join(signature(m) for m in overloads)), + *dict.fromkeys(description(m) for m in overloads), + parameters_and_returns(overloads, linkable), + ] + return "\n\n".join(p for p in parts if p) + "\n" + + +def toc_section(title: str, names: list[str]) -> str: + items = "".join(f" - name: {n}\n href: {n}.md\n" for n in names) + return f"- name: {title}\n items:\n{items}" if names else "" + + +def main() -> None: + with tempfile.TemporaryDirectory() as scratch: + xml_dir = run_doxygen(Path(scratch)) + compounds = [] + concepts = [ET.parse(path).getroot()[0] for path in sorted(xml_dir.glob("concept*.xml"))] + for path in sorted(xml_dir.glob("*.xml")): + if path.name.startswith(("class", "struct")): + compound = ET.parse(path).getroot()[0] + name = compound.findtext("compoundname", "") + if name.count("::") == 1 and "<" not in name: + compounds.append(compound) + namespace = ET.parse(xml_dir / "namespacexl.xml").getroot()[0] + + members = list(namespace.iter("memberdef")) + enums = [m for m in members if m.get("kind") == "enum"] + functions = [m for m in members if m.get("kind") == "function"] + + pages = {c.findtext("compoundname", "").removeprefix("xl::"): c for c in compounds} + linkable = {name: f"{name}.md" for name in pages} + linkable.update({e.findtext("name", ""): f"{e.findtext('name')}.md" for e in enums}) + concept_names = [c.findtext("compoundname", "").removeprefix("xl::") for c in concepts] + linkable.update({name: f"{name}.md" for name in concept_names}) + + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + for stale in OUTPUT_DIR.iterdir(): + stale.unlink() + for name, compound in pages.items(): + (OUTPUT_DIR / f"{name}.md").write_text(class_page(compound, linkable), encoding="utf-8") + for enum in enums: + (OUTPUT_DIR / f"{enum.findtext('name')}.md").write_text(enum_page(enum), encoding="utf-8") + for name, concept in zip(concept_names, concepts): + (OUTPUT_DIR / f"{name}.md").write_text(concept_page(concept), encoding="utf-8") + (OUTPUT_DIR / "functions.md").write_text(functions_page(functions, linkable), encoding="utf-8") + + classes = sorted(n for n, c in pages.items() if c.get("kind") == "class") + structs = sorted(n for n, c in pages.items() if c.get("kind") == "struct") + toc = ( + toc_section("Classes", classes) + + toc_section("Structs", structs) + + toc_section("Enums", sorted(e.findtext("name", "") for e in enums)) + + toc_section("Concepts", sorted(concept_names)) + + "- name: Functions\n href: functions.md\n" + ) + (OUTPUT_DIR / "toc.yml").write_text(toc, encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/docfx.json b/docfx.json index 6e770446..df62eea3 100644 --- a/docfx.json +++ b/docfx.json @@ -16,7 +16,7 @@ ], "build": { "content": [ - { "files": [ "index.md", "toc.yml", "api/**.{yml,md}", "python-api/**.{yml,md}" ], "src": "docs" }, + { "files": [ "index.md", "toc.yml", "api/**.{yml,md}", "python-api/**.{yml,md}", "cpp-api/**.{yml,md}" ], "src": "docs" }, { "files": [ "docs/README.md", diff --git a/docs/toc.yml b/docs/toc.yml index b11f23a0..b504ac27 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -30,6 +30,8 @@ href: https://docs.rs/excelreader - name: C++ href: ../cpp/README.md + - name: C++ API + href: cpp-api/ - name: Performance items: - name: Benchmarks From 776d352f302d676e68bdd40d67cb220cdc611283 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Mon, 12 Oct 2026 19:36:53 -0300 Subject: [PATCH 107/115] docs(cpp): document the public C++ API with Doxygen comments Public types and members get @brief, @param, @tparam and @return comments that state the contract: ownership, how long a view stays valid, threading, how the end of data is reported, and defaults. The OpenOptions defaults come from the managed options the native layer forwards each field to. Also restores, as one line inside write_sheet and write_sheet_to_memory, the invariant a truncated comment used to state: refs borrows builders' buffers, so both must live until the write returns. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../include/excelreader.hpp | 454 +++++++++++++++--- .../include/excelreader_arrow.hpp | 28 +- 2 files changed, 407 insertions(+), 75 deletions(-) diff --git a/src/ExcelReader.Native/include/excelreader.hpp b/src/ExcelReader.Native/include/excelreader.hpp index 4094de50..29b713c6 100644 --- a/src/ExcelReader.Native/include/excelreader.hpp +++ b/src/ExcelReader.Native/include/excelreader.hpp @@ -28,10 +28,13 @@ namespace xl { + /// @brief A failure reported by any entry point. + /// + /// This header throws nothing: every function that can fail returns `std::expected`. struct Error { - int32_t code; - std::string message; + int32_t code; ///< One of the `XL_*` status codes. + std::string message; ///< The native library's description of what went wrong. }; namespace detail @@ -71,6 +74,10 @@ namespace xl } + /// @brief ABI revision of the loaded native library. + /// + /// Every open checks it against the `XL_ABI_VERSION` this header was compiled with and fails on a + /// mismatch. inline int32_t abi_version() noexcept { return xl_abi_version(); } namespace detail @@ -119,42 +126,57 @@ namespace xl } + /// @brief One column guessed by Sheet::infer_schema. struct InferredColumn { - std::optional name; - int32_t index = 0; - int32_t type = XL_T_STRING; - bool nullable = false; + std::optional name; ///< Header text; empty when the column must be matched by `index`. + int32_t index = 0; ///< 0-based column position. + int32_t type = XL_T_STRING; ///< One of the `XL_T_*` constants. + bool nullable = false; ///< Whether the column may hold empty cells. }; + /// @brief Reader limits and CSV dialect for the Workbook::open family. + /// + /// A field left at 0 or `XL_OPT_DEFAULT` keeps the library default; on/off fields take + /// `XL_OPT_TRUE` or `XL_OPT_FALSE`. The `max_*` fields bound what a malformed or hostile file can + /// make the reader allocate: lower them for untrusted input. An out-of-range value fails the open + /// with `XL_INVALID_ARGUMENT`. struct OpenOptions { - int32_t csv_sniff_dialect = XL_OPT_DEFAULT; - int32_t csv_delimiter = 0; - int32_t csv_quote = 0; - int32_t csv_detect_bom = XL_OPT_DEFAULT; - int32_t csv_max_cell_bytes = 0; - int32_t csv_intern_strings = XL_OPT_DEFAULT; - - int64_t max_total_decompressed_bytes = 0; - int32_t max_cell_bytes = 0; - int64_t max_shared_string_bytes = 0; - int32_t max_zip_entries = 0; - int32_t prefetch_decompression = XL_OPT_DEFAULT; - int32_t intern_strings = XL_OPT_DEFAULT; - int64_t source_cache_bytes = 0; - int64_t max_buffered_bytes = 0; - int32_t source_block_size = 0; - - std::string password_{}; - + int32_t csv_sniff_dialect = XL_OPT_DEFAULT; ///< Infer delimiter and quote from the start of a CSV. Off by default. + int32_t csv_delimiter = 0; ///< CSV field separator byte. Default `,`. + int32_t csv_quote = 0; ///< CSV quote byte. Default `"`. + int32_t csv_detect_bom = XL_OPT_DEFAULT; ///< Skip a leading UTF-8 byte-order mark. On by default. + int32_t csv_max_cell_bytes = 0; ///< For CSV, replaces `max_cell_bytes`. + int32_t csv_intern_strings = XL_OPT_DEFAULT; ///< For CSV, replaces `intern_strings`. + + int64_t max_total_decompressed_bytes = 0; ///< Bytes an XLSX/XLSB may inflate to, or an XLS workbook stream may declare. Default 512 MiB. + int32_t max_cell_bytes = 0; ///< Largest cell value or buffered CSV record. Default 32 MiB. + int64_t max_shared_string_bytes = 0; ///< Largest shared-string table. Default 128 MiB. + int32_t max_zip_entries = 0; ///< Most entries an XLSX/XLSB archive may hold. Default 65,536. + int32_t prefetch_decompression = XL_OPT_DEFAULT; ///< Inflate XLSX/XLSB sheet data on a background thread ahead of parsing. Off by default. + int32_t intern_strings = XL_OPT_DEFAULT; ///< Deduplicate repeated text outside the shared-string table. Off by default; pays off only for low-cardinality text. + int64_t source_cache_bytes = 0; ///< Block cache of Workbook::open_source. Default 64 MiB. + int64_t max_buffered_bytes = 0; ///< Most bytes Workbook::open_stream reads before an XLSX, XLSB or XLS opens. + int32_t source_block_size = 0; ///< Block size of Workbook::open_source's cache. Default 4 MiB; negative turns the cache off. + + std::string password_{}; ///< Set through password(). + + /// @brief Sets the password that unlocks an encrypted OOXML workbook (.xlsx/.xlsb/.xlsm). + /// + /// Open the workbook with `XL_FORMAT_AUTO`: `XL_FORMAT_XLSX` reads the encrypted container as a + /// plain ZIP and fails before the password is checked. + /// @param value The password, as UTF-8. + /// @return `*this`, for chaining. OpenOptions &password(std::string_view value) { password_ = std::string(value); return *this; } + /// @brief These options as the C ABI's `xl_open_options`. + /// @return A struct pointing into this object, valid while it lives unchanged. xl_open_options to_c() const noexcept { xl_open_options opts{}; @@ -181,16 +203,21 @@ namespace xl }; + /// @brief Settings for every writer. + /// + /// A field left at 0 or `XL_OPT_DEFAULT` keeps the library default. struct WriteOptions { - std::string_view sheet_name{}; + std::string_view sheet_name{}; ///< Default `Sheet1`; ignored for CSV. Borrowed: must outlive the write. - int32_t csv_delimiter = 0; - int32_t csv_quote = 0; + int32_t csv_delimiter = 0; ///< CSV field separator byte. Default `,`. + int32_t csv_quote = 0; ///< CSV quote byte. Default `"`. - int32_t date1904 = XL_OPT_DEFAULT; - int32_t use_shared_strings = XL_OPT_DEFAULT; + int32_t date1904 = XL_OPT_DEFAULT; ///< XLS/XLSB only: use the 1904 date system. Off by default. + int32_t use_shared_strings = XL_OPT_DEFAULT; ///< XLSX/XLSB only: store repeated text once in a shared-string table. Off by default. + /// @brief These options as the C ABI's `xl_write_options`. + /// @return A struct pointing into this object, valid while it lives unchanged. xl_write_options to_c() const noexcept { xl_write_options opts{}; @@ -207,15 +234,19 @@ namespace xl } }; + /// @brief Settings for aggregate_csv_file and aggregate_csv_memory. + /// + /// A zero field keeps the library default. struct CsvParallelOptions { - int32_t degree_of_parallelism = 0; - int32_t header_row = 0; - int32_t delimiter = 0; - int32_t quote = 0; - int32_t detect_bom = XL_OPT_DEFAULT; - int32_t max_cell_bytes = 0; + int32_t degree_of_parallelism = 0; ///< Worker threads; 0 uses every processor, 1 runs sequentially. + int32_t header_row = 0; ///< 1-based header row, not aggregated; 0 means none. + int32_t delimiter = 0; ///< Field separator byte. Default `,`. + int32_t quote = 0; ///< Quote byte. Default `"`. + int32_t detect_bom = XL_OPT_DEFAULT; ///< Skip a leading UTF-8 byte-order mark. On by default. + int32_t max_cell_bytes = 0; ///< Largest field or buffered record. Default 32 MiB. + /// @brief These options as the C ABI's `xl_csv_parallel_options`. xl_csv_parallel_options to_c() const noexcept { xl_csv_parallel_options opts{}; @@ -265,6 +296,10 @@ namespace xl } + /// @brief The format a file name's extension names. + /// @param path File name or path. Only its extension is read: .xlsx, .xlsb, .xls or .csv, in any case. + /// @return The matching `XL_FORMAT_*` constant, or `XL_FORMAT_AUTO` when none matches, which every + /// writer rejects. constexpr int32_t format_from_path(std::string_view path) noexcept { const size_t separator = path.find_last_of("/\\"); @@ -291,6 +326,10 @@ namespace xl } + /// @brief One column handed to write_columns. + /// + /// Its buffers are borrowed, not copied, and must outlive the write. Build it with i64_column, + /// string_column and the other column functions rather than by hand. struct ColumnRef { std::string_view name{}; @@ -322,42 +361,79 @@ namespace xl } + /// @brief An `XL_T_I64` column. + /// @param name Header text. Either every column of a table has one or none does. + /// @param values One value per row. + /// @param validity Optional LSB-first bitmap, one bit per row, set where the row has a value. Empty + /// means every row has one. + /// @return A column borrowing `values` and `validity`. inline constexpr ColumnRef i64_column(std::string_view name, std::span values, std::span validity = {}) noexcept { return detail::scalar_column(name, values, validity); } + /// @brief An `XL_T_F64` column. + /// @param name Header text, as for i64_column. + /// @param values One value per row. + /// @param validity Optional null bitmap, as for i64_column. + /// @return A column borrowing `values` and `validity`. inline constexpr ColumnRef f64_column(std::string_view name, std::span values, std::span validity = {}) noexcept { return detail::scalar_column(name, values, validity); } + /// @brief An `XL_T_BOOL` column. + /// @param name Header text, as for i64_column. + /// @param values One byte per row, 0 or 1. + /// @param validity Optional null bitmap, as for i64_column. + /// @return A column borrowing `values` and `validity`. inline constexpr ColumnRef bool_column(std::string_view name, std::span values, std::span validity = {}) noexcept { return detail::scalar_column(name, values, validity); } + /// @brief An `XL_T_DATE` column. + /// @param name Header text, as for i64_column. + /// @param days_since_epoch Days since 1970-01-01, one per row. + /// @param validity Optional null bitmap, as for i64_column. + /// @return A column borrowing `days_since_epoch` and `validity`. inline constexpr ColumnRef date_column(std::string_view name, std::span days_since_epoch, std::span validity = {}) noexcept { return detail::scalar_column(name, days_since_epoch, validity); } + /// @brief An `XL_T_TIME` column. + /// @param name Header text, as for i64_column. + /// @param micros_since_midnight Microseconds since midnight, one per row. + /// @param validity Optional null bitmap, as for i64_column. + /// @return A column borrowing `micros_since_midnight` and `validity`. inline constexpr ColumnRef time_column(std::string_view name, std::span micros_since_midnight, std::span validity = {}) noexcept { return detail::scalar_column(name, micros_since_midnight, validity); } + /// @brief An `XL_T_TIMESTAMP` column. + /// @param name Header text, as for i64_column. + /// @param micros_since_epoch Microseconds since 1970-01-01T00:00:00Z, one per row. + /// @param validity Optional null bitmap, as for i64_column. + /// @return A column borrowing `micros_since_epoch` and `validity`. inline constexpr ColumnRef timestamp_column(std::string_view name, std::span micros_since_epoch, std::span validity = {}) noexcept { return detail::scalar_column(name, micros_since_epoch, validity); } + /// @brief An `XL_T_STRING` column. + /// @param name Header text, as for i64_column. + /// @param offsets One entry per row plus one: row `i` is `data[offsets[i], offsets[i + 1])`. + /// @param data The UTF-8 text of every row, back to back. + /// @param validity Optional null bitmap, as for i64_column. + /// @return A column borrowing `offsets`, `data` and `validity`. inline constexpr ColumnRef string_column(std::string_view name, std::span offsets, std::span data, std::span validity = {}) noexcept @@ -436,24 +512,30 @@ namespace xl } + /// @brief What a cell holds. enum class CellType : int32_t { Empty = XL_CELL_EMPTY, String = XL_CELL_STRING, Number = XL_CELL_NUMBER, - Date = XL_CELL_DATE, + Date = XL_CELL_DATE, ///< A number in a date format; the value is its serial number. Bool = XL_CELL_BOOL, - Formula = XL_CELL_FORMULA, - Error = XL_CELL_ERROR, + Formula = XL_CELL_FORMULA, ///< No longer produced: a formula cell reports its cached result's type. + Error = XL_CELL_ERROR, ///< An error value such as `#N/A`. }; + /// @brief One cell of a row, borrowing the row's memory. struct CellView { - int32_t column{}; - CellType type{}; - std::string_view value{}; + int32_t column{}; ///< 0-based column. + CellType type{}; ///< What the cell holds. + std::string_view value{}; ///< The text as stored, so a date is its serial number. }; + /// @brief The cells of one row: a range of CellView. + /// + /// Borrowed from whatever produced it, so it is valid only as long as that RowCursor row, + /// DecodedRows or aggregation callback says. class RowView { public: @@ -466,6 +548,8 @@ namespace xl size_t size() const { return count_; } bool empty() const { return count_ == 0; } + /// @brief The `index`-th cell, counting cells rather than columns. Unchecked. + /// @param index Position among the row's cells, below size(). CellView operator[](size_t index) const { if (cells_ != nullptr) @@ -560,6 +644,9 @@ namespace xl size_t count_{}; }; + /// @brief Every row of a sheet, or every row a cursor had left, decoded in one native call. + /// + /// Owns that memory until destruction; the RowView it hands out borrow from it. class DecodedRows { public: @@ -584,6 +671,8 @@ namespace xl size_t size() const { return raw_.row_count > 0 ? static_cast(raw_.row_count) : 0; } bool empty() const { return size() == 0; } + /// @brief The row at `index`. Unchecked. + /// @param index 0-based row, below size(). RowView operator[](size_t index) const { const xl_row &row = raw_.rows[index]; @@ -636,11 +725,15 @@ namespace xl }; - /// Reads one sheet row by row. Owns a native cursor and closes it on destruction. Use one - /// cursor from one thread at a time; any number may be open on one workbook. + /// @brief Reads one sheet row by row. + /// + /// Owns a native cursor and closes it on destruction. Use one cursor from one thread at a time; + /// any number may be open on one workbook. class RowCursor { public: + /// @brief Takes ownership of a cursor opened through the C ABI. + /// @param rows Cursor from `xl_rows_open`, closed by this object. explicit RowCursor(xl_rows *rows) noexcept : rows_(rows) {} RowCursor(const RowCursor &) = delete; @@ -659,8 +752,9 @@ namespace xl ~RowCursor() { close(); } - /// The returned view points into memory the cursor owns and is valid until the next - /// next_row() on this cursor or until the cursor is destroyed. + /// @brief Reads the next row. + /// @return A view into memory the cursor owns, valid until the next next_row() or the cursor's + /// destruction. The end of the sheet arrives as an error whose code is `XL_EOF`. std::expected next_row() { xl_row row{}; @@ -676,7 +770,7 @@ namespace xl return RowView(row.cells, static_cast(row.cell_count)); } - /// Every row this cursor has not yet returned, decoded in one native call. + /// @brief Every row this cursor has not yet returned, decoded in one native call. std::expected read_all_decoded() { xl_rows_decoded raw{}; @@ -688,6 +782,8 @@ namespace xl return DecodedRows(raw); } + /// @brief The native cursor, for calling the C ABI directly. + /// @return The cursor, still owned by this object. xl_rows *handle() const noexcept { return rows_; } private: @@ -703,29 +799,38 @@ namespace xl xl_rows *rows_ = nullptr; }; + /// @brief Whether a sheet's tab is shown. enum class SheetVisibility : int32_t { Visible = XL_SHEET_VISIBLE, - Hidden = XL_SHEET_HIDDEN, - VeryHidden = XL_SHEET_VERY_HIDDEN, + Hidden = XL_SHEET_HIDDEN, ///< Hidden, but listed in Excel's Unhide dialog. + VeryHidden = XL_SHEET_VERY_HIDDEN, ///< Hidden and absent from the Unhide dialog; only code can show it. }; - /// One sheet of a Workbook: the native workbook handle and an index. Holds no resource of its - /// own, so it is cheap to copy and may be handed to another thread; each read opens what it - /// needs. After its workbook is destroyed, every read on it returns XL_INVALID_HANDLE. + /// @brief One sheet of a Workbook: the native workbook handle and an index. + /// + /// Holds no resource of its own, so it is cheap to copy and may be handed to another thread; each + /// read opens what it needs. After its workbook is destroyed, every read on it returns + /// `XL_INVALID_HANDLE`. class Sheet { public: + /// @brief A sheet of a workbook opened through the C ABI. Workbook hands these out. + /// @param handle Workbook handle, not owned. + /// @param index 0-based sheet position. Sheet(xl_workbook *handle, int32_t index) noexcept : handle_(handle), index_(index) {} + /// @brief 0-based position in the workbook. int32_t index() const noexcept { return index_; } + /// @brief The sheet's name. std::expected name() const { return detail::fill_string([this](uint8_t *buffer, int32_t capacity, int32_t *out_len) { return xl_sheet_name_at(handle_, index_, buffer, capacity, out_len); }); } + /// @brief Whether the sheet's tab is shown. std::expected visibility() const { int32_t raw = 0; @@ -737,7 +842,8 @@ namespace xl return static_cast(raw); } - /// A cursor over this sheet, from its first row. Each call opens an independent cursor. + /// @brief A cursor over this sheet, from its first row. + /// @return A new, independent cursor on every call. std::expected rows() const { xl_rows *rows = nullptr; @@ -749,7 +855,7 @@ namespace xl return RowCursor(rows); } - /// The whole sheet from its first row, decoded in one native call. + /// @brief The whole sheet from its first row, decoded in one native call. std::expected read_all_decoded() const { auto cursor = rows(); @@ -760,7 +866,12 @@ namespace xl return cursor->read_all_decoded(); } - /// flags: 0, or XL_INFER_PARSE_TEXT to also type cells that hold text. + /// @brief Guesses each column's name and type from a sample of the sheet. + /// @param header_row 1-based row holding the headers; 0 when the sheet has none. + /// @param sample_size Rows after the header to sample. + /// @param flags 0, or `XL_INFER_PARSE_TEXT` to also type cells that hold text, such as every CSV + /// field or numbers stored as text. + /// @return One InferredColumn per column, in order. std::expected, Error> infer_schema(int32_t header_row = 1, int32_t sample_size = 100, int32_t flags = 0) const @@ -797,6 +908,8 @@ namespace xl return columns; } + /// @brief The native workbook handle. + /// @return The handle, not owned: it dies with its Workbook. xl_workbook *handle() const noexcept { return handle_; } private: @@ -804,20 +917,34 @@ namespace xl int32_t index_ = 0; }; - /// Random-access bytes for Workbook::open_source. read_at is called from several threads at once. + /// @brief Random-access bytes for Workbook::open_source. + /// + /// `read_at` is called from several threads at once. class Source { public: virtual ~Source() = default; + /// @brief Total length in bytes. virtual std::uint64_t size() const = 0; + /// @brief Reads the bytes at an offset. + /// @param offset Position of the first byte. + /// @param buffer Where to put them. + /// @return How many bytes were written, or an error message. An error, or an exception, fails + /// the read that needed the bytes. virtual std::expected read_at(std::uint64_t offset, std::span buffer) const = 0; }; - /// Sequential bytes for Workbook::open_stream. read is never called concurrently. + /// @brief Sequential bytes for Workbook::open_stream. + /// + /// `read` is never called concurrently. class InputStream { public: virtual ~InputStream() = default; + /// @brief Reads the next bytes. + /// @param buffer Where to put up to `buffer.size()` bytes. + /// @return How many bytes were written, 0 at the end of the stream, or an error message. An error, + /// or an exception, fails the open or read that needed the bytes. virtual std::expected read(std::span buffer) = 0; }; @@ -873,7 +1000,10 @@ namespace xl } } - /// An open workbook. Safe to use from several threads at once: every read goes through a Sheet and opens its own cursor. + /// @brief An open workbook, closed on destruction. + /// + /// Safe to use from several threads at once: every read goes through a Sheet and opens its own + /// cursor. class Workbook { public: @@ -893,6 +1023,12 @@ namespace xl ~Workbook() { close(); } + /// @brief Opens the workbook at a path. + /// @param path File to open. + /// @param format An `XL_FORMAT_*` constant. `XL_FORMAT_AUTO` detects XLSX, XLSB and XLS from the + /// file's signature; CSV has none and needs `XL_FORMAT_CSV`. + /// @param options Reader limits and CSV dialect, or null for the defaults. + /// @return The workbook. Fails if the native library's ABI does not match this header. static std::expected open(std::string_view path, int32_t format = XL_FORMAT_AUTO, const OpenOptions *options = nullptr) { @@ -913,6 +1049,10 @@ namespace xl return Workbook(handle); } + /// @brief Opens a workbook from bytes in memory. + /// @param data The file's bytes. The native library copies them, so they need not outlive the call. + /// @param format As for Workbook::open. + /// @param options As for Workbook::open. static std::expected open_memory(std::span data, int32_t format = XL_FORMAT_AUTO, const OpenOptions *options = nullptr) { @@ -932,7 +1072,12 @@ namespace xl return Workbook(handle); } - /// Opens a workbook over a source the library owns from here on; every sheet can be read in parallel. + /// @brief Opens a workbook over random-access bytes. + /// @param source Owned by the library from here on. Read from several threads, so every sheet can + /// still be read in parallel. + /// @param format As for Workbook::open. + /// @param options As for Workbook::open; `source_block_size` and `source_cache_bytes` tune the + /// block cache. static std::expected open_source(std::unique_ptr source, int32_t format = XL_FORMAT_AUTO, const OpenOptions *options = nullptr) { @@ -965,8 +1110,14 @@ namespace xl return Workbook(handle); } - /// Opens a workbook over a stream that cannot seek: a CSV is read as it arrives, and again from the start - /// until 16 MiB of it were read; an XLSX, XLSB or XLS is read whole first. + /// @brief Opens a workbook over a stream that cannot seek. + /// + /// A CSV is read as it arrives, and again from the start until 16 MiB of it were read; an XLSX, + /// XLSB or XLS is read whole first. + /// @param stream Owned by the library from here on. + /// @param format As for Workbook::open. + /// @param options As for Workbook::open; `max_buffered_bytes` caps what an XLSX, XLSB or XLS may + /// buffer. static std::expected open_stream(std::unique_ptr stream, int32_t format = XL_FORMAT_AUTO, const OpenOptions *options = nullptr) { @@ -994,6 +1145,7 @@ namespace xl } + /// @brief Number of sheets. std::expected sheet_count() const { int32_t count = 0; @@ -1005,12 +1157,15 @@ namespace xl return count; } + /// @brief Name of the sheet at `index`. + /// @param index 0-based sheet position. std::expected sheet_name_at(int32_t index) const { return detail::fill_string([this, index](uint8_t *buffer, int32_t capacity, int32_t *out_len) { return xl_sheet_name_at(handle_, index, buffer, capacity, out_len); }); } + /// @brief Every sheet's name, in workbook order. std::expected, Error> sheet_names() const { auto count = sheet_count(); @@ -1032,11 +1187,14 @@ namespace xl return names; } - /// The sheet at `index`. Makes no native call: an index outside the workbook is reported - /// by the first read on the returned sheet. + /// @brief The sheet at `index`. + /// + /// Makes no native call: an index outside the workbook is reported by the first read on the + /// returned sheet. + /// @param index 0-based sheet position. Sheet sheet(int32_t index) const noexcept { return Sheet(handle_, index); } - /// Every sheet, in workbook order. Opens nothing. + /// @brief Every sheet, in workbook order. Opens nothing. std::expected, Error> sheets() const { auto count = sheet_count(); @@ -1053,7 +1211,9 @@ namespace xl return result; } - /// The sheet called `name`, compared without regard to case; empty when no sheet matches. + /// @brief The sheet called `name`, compared without regard to case. + /// @param name Sheet name. + /// @return The sheet, or empty when no sheet matches. std::expected, Error> sheet_by_name(std::string_view name) const { int32_t index = -1; @@ -1070,6 +1230,9 @@ namespace xl return std::optional{Sheet(handle_, index)}; } + /// @brief Whether dates count from 1904 instead of 1900. + /// + /// A date cell's serial number depends on it. std::expected is_date1904() const { int32_t flag = 0; @@ -1081,6 +1244,8 @@ namespace xl return flag != 0; } + /// @brief The native handle, for calling the C ABI directly. + /// @return The handle, still owned by this object. xl_workbook *handle() const noexcept { return handle_; } private: @@ -1099,6 +1264,11 @@ namespace xl }; + /// @brief The `XL_T_*` column type `T` reads and writes as, in `value`. + /// + /// Defined for std::string and std::string_view, integers, floating point, bool, the std::chrono + /// date and time types, and std::optional of any of them. + /// @tparam T A field type. template struct XlType; @@ -1194,6 +1364,10 @@ namespace xl } + /// @brief A member bound to a column by header name. Built by make_field. + /// @tparam Class The row type. + /// @tparam T The member's type. + /// @tparam N How many header names the binding accepts. template struct FieldBinding { @@ -1202,12 +1376,20 @@ namespace xl using FieldType = T; }; + /// @brief Binds a member to the column with one header. + /// @param name Header text. + /// @param member The member to fill or write. + /// @return The binding, for ExcelMapper::get_bindings. template constexpr FieldBinding make_field(const char *name, T Class::*member) { return {{name}, member}; } + /// @brief Binds a member to the column headed by any of several names. + /// @param names Accepted header texts. + /// @param member The member to fill or write. + /// @return The binding, for ExcelMapper::get_bindings. template constexpr FieldBinding make_field(const char *(&&names)[N], T Class::*member) { @@ -1220,6 +1402,12 @@ namespace xl return result; } + /// @brief Maps a row type to columns. + /// + /// Specialize it with a `static constexpr auto get_bindings()` that returns a std::tuple of + /// make_field results; parse_sheet, typed_reader, parse_arrow and write_sheet all read the same + /// mapping. + /// @tparam T The row type. template struct ExcelMapper; @@ -1348,6 +1536,10 @@ namespace xl } + /// @brief The rows of a typed parse, held in native columnar memory. + /// + /// Each row converts to `T` on access. Owns that memory; a random-access range of `T`. + /// @tparam T Row type, mapped by `ExcelMapper`. template class TableView { @@ -1461,8 +1653,13 @@ namespace xl int64_t size() const noexcept { return table_.row_count; } bool empty() const noexcept { return table_.row_count == 0; } + /// @brief Row `row` as `T`. Unchecked; at() is the checked read. + /// @param row 0-based row, below size(). T operator[](int64_t row) const { return detail::row_at(table_, row); } + /// @brief Row `row` as `T`, bounds-checked. + /// @param row 0-based row. + /// @return The row, or empty outside `[0, size())`. std::optional at(int64_t row) const { if (row < 0 || row >= table_.row_count) @@ -1472,6 +1669,7 @@ namespace xl return (*this)[row]; } + /// @brief Every row converted to `T`. std::vector to_vector() const { std::vector result; @@ -1483,6 +1681,8 @@ namespace xl return result; } + /// @brief Takes ownership of a table produced by the C ABI. + /// @param table Table from `xl_parse_typed` or `xl_typed_reader_next`, freed by this object. static TableView from_raw(xl_table table) { return TableView(table); } private: @@ -1492,6 +1692,13 @@ namespace xl }; + /// @brief Reads a whole sheet as `T`. + /// @tparam T Row type, mapped by `ExcelMapper`. + /// @param sheet The sheet to read. + /// @param header_row 1-based row holding the headers; 0 when the sheet has none. + /// @param degree_of_parallelism Threads to split an XLSX, XLSB or CSV across: 0 uses every + /// processor, 1 runs sequentially. The result is the same either way. + /// @return Every row after the header. template std::expected, Error> parse_sheet(Sheet sheet, int32_t header_row = 1, int32_t degree_of_parallelism = 1) @@ -1515,6 +1722,11 @@ namespace xl } + /// @brief parse_sheet delivered a batch at a time. + /// + /// An input range of `std::expected, Error>` that stops after the first error. Each + /// batch owns its memory and outlives the reader. Use from one thread at a time. + /// @tparam T Row type, mapped by `ExcelMapper`. template class TypedReader { @@ -1536,6 +1748,8 @@ namespace xl ~TypedReader() { xl_typed_reader_close(reader_); } + /// @brief Reads the next batch. + /// @return The batch, or empty at the end of the sheet. std::expected>, Error> next() { xl_table table{}; @@ -1612,6 +1826,8 @@ namespace xl iterator begin() { return iterator(this); } std::default_sentinel_t end() const noexcept { return {}; } + /// @brief Takes ownership of a reader opened through the C ABI. + /// @param reader Reader from `xl_typed_reader_open`, closed by this object. static TypedReader from_raw(xl_typed_reader *reader) { return TypedReader(reader); } private: @@ -1620,6 +1836,11 @@ namespace xl xl_typed_reader *reader_ = nullptr; }; + /// @brief Opens a TypedReader over a sheet. + /// @tparam T Row type, mapped by `ExcelMapper`. + /// @param sheet The sheet to read. + /// @param header_row As for parse_sheet. + /// @param batch_size Rows per batch: 0 makes one batch of the whole sheet; negative is an error. template std::expected, Error> typed_reader(Sheet sheet, int32_t header_row = 1, int64_t batch_size = 10000) @@ -1641,6 +1862,13 @@ namespace xl return TypedReader::from_raw(reader); } + /// @brief Writes columns as one sheet, copying nothing. + /// @param path File to create. + /// @param format An `XL_FORMAT_*` constant. `XL_FORMAT_AUTO` is rejected: a file being created has + /// no signature to detect. + /// @param columns Columns of equal length, borrowed for the call. Either every column has a name, + /// written as a header row, or none does. + /// @param options Sheet name, CSV dialect and format toggles, or null for the defaults. inline std::expected write_columns(std::string_view path, int32_t format, std::span columns, const WriteOptions *options = nullptr) @@ -1680,12 +1908,21 @@ namespace xl return {}; } + /// @brief write_columns with the format taken from the file's extension. + /// @param path File to create; its extension picks the format. + /// @param columns As for the overload that takes a format. + /// @param options Sheet name, CSV dialect and format toggles, or null for the defaults. inline std::expected write_columns(std::string_view path, std::span columns, const WriteOptions *options = nullptr) { return write_columns(path, format_from_path(path), columns, options); } + /// @brief write_columns into memory. + /// @param format As for write_columns. There is no default: no file name to infer it from. + /// @param columns As for write_columns. + /// @param options Sheet name, CSV dialect and format toggles, or null for the defaults. + /// @return The file's bytes. inline std::expected, Error> write_columns_to_memory(int32_t format, std::span columns, const WriteOptions *options = nullptr) @@ -1730,6 +1967,13 @@ namespace xl } + /// @brief Encrypts a finished XLSX or XLSB. + /// + /// The encryption parameters are fixed at Excel's own defaults; the result opens in Excel, and in + /// Workbook::open given the same password. + /// @param package_path The plaintext package. Read twice, so it must already be a finished file. + /// @param destination_path Where to write the encrypted file. + /// @param password The password, as UTF-8. inline std::expected encrypt_package(std::string_view package_path, std::string_view destination_path, std::string_view password) @@ -1997,7 +2241,12 @@ namespace xl } - // NOTE for write_sheet_to_memory below: this body is intentionally NOT factored into a shared + /// @brief Writes rows as one sheet. + /// @param path File to create. + /// @param format As for write_columns. + /// @param rows Input range of a row type with an ExcelMapper, the mapping parse_sheet reads with. + /// An empty std::optional field writes a blank cell. + /// @param options Sheet name, CSV dialect and format toggles, or null for the defaults. template std::expected write_sheet(std::string_view path, int32_t format, R &&rows, const WriteOptions *options = nullptr) @@ -2022,10 +2271,15 @@ namespace xl "a string column exceeds 2 GiB, which int32 offsets cannot address."}); } + // refs borrows pointers into builders, so both must stay in the scope of the write call. const std::array refs = detail::to_refs(builders, bindings, indices); return write_columns(path, format, refs, options); } + /// @brief write_sheet with the format taken from the file's extension. + /// @param path File to create; its extension picks the format. + /// @param rows As for the overload that takes a format. + /// @param options Sheet name, CSV dialect and format toggles, or null for the defaults. template std::expected write_sheet(std::string_view path, R &&rows, const WriteOptions *options = nullptr) @@ -2033,7 +2287,11 @@ namespace xl return write_sheet(path, format_from_path(path), std::forward(rows), options); } - // In-memory equivalent of write_sheet: same transpose (see the NOTE on write_sheet above for why + /// @brief write_sheet into memory. + /// @param format As for write_columns. There is no default: no file name to infer it from. + /// @param rows As for write_sheet. + /// @param options Sheet name, CSV dialect and format toggles, or null for the defaults. + /// @return The file's bytes. template std::expected, Error> write_sheet_to_memory(int32_t format, R &&rows, const WriteOptions *options = nullptr) @@ -2058,11 +2316,17 @@ namespace xl "a string column exceeds 2 GiB, which int32 offsets cannot address."}); } + // refs borrows pointers into builders, so both must stay in the scope of the write call. const std::array refs = detail::to_refs(builders, bindings, indices); return write_columns_to_memory(format, refs, options); } + /// @brief Writes a workbook as calls arrive instead of building it in memory first. + /// + /// Per sheet: start_sheet, then for each row start_row, one write per cell from left to right, and + /// end_row; then end_sheet. A call out of order returns an error rather than corrupting the + /// output. Closed on destruction. class WriterHandle { public: @@ -2082,6 +2346,10 @@ namespace xl ~WriterHandle() { close(); } + /// @brief Creates a file to write into. + /// @param path File to create. + /// @param format An `XL_FORMAT_*` constant other than `XL_FORMAT_AUTO`. + /// @param options Sheet name, CSV dialect and format toggles, or null for the defaults. static std::expected open(std::string_view path, int32_t format, const WriteOptions *options = nullptr) { @@ -2102,6 +2370,9 @@ namespace xl return WriterHandle(handle); } + /// @brief Writes into memory instead of a file; read the result with bytes(). + /// @param format An `XL_FORMAT_*` constant other than `XL_FORMAT_AUTO`. + /// @param options Sheet name, CSV dialect and format toggles, or null for the defaults. static std::expected open_memory(int32_t format, const WriteOptions *options = nullptr) { @@ -2120,18 +2391,25 @@ namespace xl return WriterHandle(handle); } + /// @brief Starts a sheet. + /// @param name The sheet's name. std::expected start_sheet(std::string_view name) { return status_result(xl_start_sheet(handle_, reinterpret_cast(name.data()), static_cast(name.size()))); } + /// @brief Starts a row. std::expected start_row() { return status_result(xl_start_row(handle_)); } + /// @brief Ends the current row. std::expected end_row() { return status_result(xl_end_row(handle_)); } + /// @brief Ends the current sheet. std::expected end_sheet() { return status_result(xl_end_sheet(handle_)); } + /// @brief Writes the next cell. + /// @param value Any type XlType covers; an empty std::optional writes a blank cell. template std::expected write(const T &value) { @@ -2192,8 +2470,14 @@ namespace xl } } + /// @brief Writes a blank cell. + /// @param type The column's `XL_T_*` constant. std::expected write_null(int32_t type) { return status_result(xl_write_null(handle_, type)); } + /// @brief Everything written so far, for a handle from open_memory. + /// + /// Finishes the workbook's trailing structure but leaves the handle open. + /// @return The file's bytes. std::expected, Error> bytes() { xl_buffer buffer{}; @@ -2210,6 +2494,8 @@ namespace xl return std::vector(buffer.data, buffer.data + buffer.len); } + /// @brief The native handle, for calling the C ABI directly. + /// @return The handle, still owned by this object. xl_writer_handle *handle() const noexcept { return handle_; } private: @@ -2237,7 +2523,9 @@ namespace xl }; - /// A fold over CSV records. `Acc` must be move-constructible; the wrapper returns the surviving + /// @brief A fold over CSV records. + /// + /// `Acc` must be move-constructible; the wrapper returns the surviving /// accumulator by moving out of it. `combine` must fold `next` into `*this` without destroying /// it - the library owns `next`'s lifetime and frees it afterwards. /// @@ -2254,6 +2542,7 @@ namespace xl /// All callbacks must be noexcept in effect: an exception escaping into the library is undefined /// behavior, so this wrapper catches everything and reports it as an error. See /// `excelreader.h`'s `xl_csv_aggregate_file` contract. + /// @tparam Acc The accumulator type. template concept CsvAccumulator = std::move_constructible && requires(Acc acc, Acc &next, RowView row) { @@ -2391,7 +2680,7 @@ namespace xl } } - /// Folds the CSV file at `path` into one `Acc` across several threads. + /// @brief Folds the CSV file at `path` into one `Acc` across several threads. /// /// `seed` is called at least once per partition, on worker threads, and may be called again for /// a partition that has to be re-read - a seeded accumulator can therefore be destroyed without @@ -2402,6 +2691,11 @@ namespace xl /// instead of hard-erroring inside a shim. The `remove_cvref_t` is load-bearing - `Seed` deduces /// to an lvalue reference for a named callable, and `const Seed &` would collapse the `const` /// away. + /// @tparam Acc The accumulator; see CsvAccumulator. + /// @param path CSV file to read. + /// @param seed Makes a fresh `Acc` for a partition. + /// @param options Threads, header row and CSV dialect, or null for the defaults. + /// @return The combined accumulator. template requires std::invocable &> std::expected aggregate_csv_file(std::string_view path, Seed &&seed, @@ -2424,7 +2718,11 @@ namespace xl return detail::csv_finish(context, status, state); } - /// Folds the CSV file at `path` into one `Acc`, seeding each partition with `Acc()`. + /// @brief Folds the CSV file at `path` into one `Acc`, seeding each partition with `Acc()`. + /// @tparam Acc The accumulator; see CsvAccumulator. + /// @param path CSV file to read. + /// @param options Threads, header row and CSV dialect, or null for the defaults. + /// @return The combined accumulator. template requires std::default_initializable std::expected aggregate_csv_file(std::string_view path, @@ -2434,8 +2732,14 @@ namespace xl return aggregate_csv_file(path, seed, options); } - /// Folds an in-memory CSV buffer into one `Acc`. The buffer is not copied and must stay valid - /// for the duration of the call. Otherwise identical to `aggregate_csv_file`. + /// @brief Folds an in-memory CSV buffer into one `Acc`. + /// + /// Otherwise identical to aggregate_csv_file. + /// @tparam Acc The accumulator; see CsvAccumulator. + /// @param data The CSV bytes. Not copied, so they must stay valid for the call. + /// @param seed As for aggregate_csv_file. + /// @param options Threads, header row and CSV dialect, or null for the defaults. + /// @return The combined accumulator. template requires std::invocable &> std::expected aggregate_csv_memory(std::span data, Seed &&seed, @@ -2457,7 +2761,11 @@ namespace xl return detail::csv_finish(context, status, state); } - /// Folds an in-memory CSV buffer into one `Acc`, seeding each partition with `Acc()`. + /// @brief Folds an in-memory CSV buffer into one `Acc`, seeding each partition with `Acc()`. + /// @tparam Acc The accumulator; see CsvAccumulator. + /// @param data The CSV bytes. Not copied, so they must stay valid for the call. + /// @param options Threads, header row and CSV dialect, or null for the defaults. + /// @return The combined accumulator. template requires std::default_initializable std::expected aggregate_csv_memory(std::span data, diff --git a/src/ExcelReader.Native/include/excelreader_arrow.hpp b/src/ExcelReader.Native/include/excelreader_arrow.hpp index e3017143..2a124184 100644 --- a/src/ExcelReader.Native/include/excelreader_arrow.hpp +++ b/src/ExcelReader.Native/include/excelreader_arrow.hpp @@ -16,10 +16,13 @@ namespace xl { + /// @brief A sheet exported through the Arrow C Data Interface: one struct array and its schema. + /// + /// Releases both on destruction; move them out to hand them to an Arrow library. struct ArrowTable { - ArrowArray array{}; - ArrowSchema schema{}; + ArrowArray array{}; ///< One struct array holding every column. + ArrowSchema schema{}; ///< The struct type of `array`. ArrowTable() = default; @@ -45,6 +48,7 @@ namespace xl ~ArrowTable() { release(); } + /// @brief Releases both now. Safe to call again. void release() noexcept { if (array.release != nullptr) @@ -58,6 +62,11 @@ namespace xl } }; + /// @brief parse_sheet into Arrow instead of `T`. + /// @tparam T Row type, mapped by `ExcelMapper`. + /// @param sheet The sheet to read. + /// @param header_row As for parse_sheet. + /// @param degree_of_parallelism As for parse_sheet. template std::expected parse_arrow(Sheet sheet, int32_t header_row = 1, int32_t degree_of_parallelism = 1) @@ -80,6 +89,7 @@ namespace xl return result; } + /// @brief Owns one ArrowSchema and releases it on destruction. struct ArrowSchemaGuard { ArrowSchema schema{}; @@ -115,6 +125,7 @@ namespace xl } }; + /// @brief Owns one ArrowArray and releases it on destruction. struct ArrowArrayGuard { ArrowArray array{}; @@ -150,6 +161,9 @@ namespace xl } }; + /// @brief typed_reader's Arrow counterpart: record batches pulled one at a time. + /// + /// Use from one thread at a time. class ArrowStream { public: @@ -173,6 +187,7 @@ namespace xl ~ArrowStream() { release(); } + /// @brief The schema every batch shares. std::expected schema() { if (stream_.release == nullptr) @@ -188,6 +203,8 @@ namespace xl return guard; } + /// @brief Reads the next batch. + /// @return The batch, or empty at the end of the sheet. std::expected, Error> next() { if (stream_.release == nullptr) @@ -207,6 +224,8 @@ namespace xl return std::optional(std::move(guard)); } + /// @brief Takes ownership of a stream produced by the C ABI. + /// @param stream Stream from `xl_parse_arrow_stream`, released by this object. static ArrowStream from_raw(ArrowArrayStream stream) { return ArrowStream(stream); } private: @@ -234,6 +253,11 @@ namespace xl ArrowArrayStream stream_{}; }; + /// @brief Opens an ArrowStream over a sheet. + /// @tparam T Row type, mapped by `ExcelMapper`. + /// @param sheet The sheet to read. + /// @param header_row As for parse_sheet. + /// @param batch_size As for typed_reader. template std::expected arrow_stream(Sheet sheet, int32_t header_row = 1, int64_t batch_size = 10000) From 0911fdb28f1fa58de879b0fb56d872e8898d7725 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Mon, 12 Oct 2026 19:36:53 -0300 Subject: [PATCH 108/115] ci(docs): fail on Doxygen warnings and build the docs on pull requests The C++ generator runs Doxygen with WARN_NO_PARAMDOC, WARN_IF_INCOMPLETE_DOC and FAIL_ON_WARNINGS, so an @param that no longer matches its signature fails the build. The workflow also runs on pull requests, building without publishing, and uses a per-ref concurrency group so a pull request run cannot cancel a publish. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/docs.yml | 12 +++++++++--- cpp/scripts/api_markdown.py | 9 +++++++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a6d6a36f..3863db23 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -3,7 +3,7 @@ name: Docs -> GitHub Pages on: push: branches: [master] - paths: + paths: &docs-paths - 'src/ExcelReader.Core/**' - 'src/ExcelReader.Arrow/**' - 'src/ExcelReader.Native/include/**' @@ -15,17 +15,21 @@ on: - 'docfx.json' - 'assets/**' - '.github/workflows/docs.yml' + # Builds without publishing, so a broken page or a Doxygen @param that no longer matches its + # signature fails the pull request instead of the next publish. + pull_request: + paths: *docs-paths workflow_dispatch: concurrency: - group: docs-pages + group: docs-${{ github.ref }} cancel-in-progress: true permissions: contents: write jobs: - publish: + docs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -38,6 +42,7 @@ jobs: - name: Build Python API reference run: python3 python/scripts/api_markdown.py + # Fails on any Doxygen documentation warning, such as an @param naming no parameter. - name: Build C++ API reference run: | sudo apt-get update @@ -52,6 +57,7 @@ jobs: # keep_files leaves dev/bench (benchmark-pages.yml) in place; pages removed from the docs # stay on the branch until deleted by hand. - name: Publish to gh-pages + if: github.event_name != 'pull_request' uses: peaceiris/actions-gh-pages@v4 with: github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/cpp/scripts/api_markdown.py b/cpp/scripts/api_markdown.py index 34daabb4..f5ac9f37 100644 --- a/cpp/scripts/api_markdown.py +++ b/cpp/scripts/api_markdown.py @@ -30,10 +30,15 @@ def run_doxygen(output: Path) -> Path: "EXTRACT_ALL = YES", "EXCLUDE_SYMBOLS = xl::detail*", "QUIET = YES", - "WARNINGS = NO", + "WARN_IF_UNDOCUMENTED = NO", + "WARN_IF_DOC_ERROR = YES", + "WARN_IF_INCOMPLETE_DOC = YES", + "WARN_NO_PARAMDOC = YES", + "WARN_AS_ERROR = FAIL_ON_WARNINGS", ] ) - subprocess.run(["doxygen", "-"], input=config, text=True, cwd=REPO_ROOT, check=True) + if subprocess.run(["doxygen", "-"], input=config, text=True, cwd=REPO_ROOT).returncode != 0: + raise SystemExit("Doxygen reported documentation problems in the headers; see its warnings above.") return output / "xml" From 58a279bbf79bb9f93e0956cfa44cc437c4a1434f Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Mon, 12 Oct 2026 19:46:26 -0300 Subject: [PATCH 109/115] Enhance documentation with cross-references and clarifications - Updated error handling section to include cross-references for exceptions. - Migrating guide updated to include cross-references for new types and methods. - Migration to v7 guide improved with cross-references for new options and types. - Parsing guide enhanced with cross-references for interfaces and methods. - Reading guide updated to include cross-references for workbook and sheet types. - Writing guide improved with cross-references for options and interfaces. --- .github/workflows/docs.yml | 3 ++- docs/guide/csv.md | 20 ++++++++++---------- docs/guide/encryption.md | 6 +++--- docs/guide/errors.md | 4 ++-- docs/guide/migrating-to-v6.md | 14 +++++++------- docs/guide/migrating-to-v7.md | 30 +++++++++++++++--------------- docs/guide/parsing.md | 18 +++++++++--------- docs/guide/reading.md | 30 +++++++++++++++--------------- docs/guide/writing.md | 24 ++++++++++++------------ 9 files changed, 75 insertions(+), 74 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 3863db23..16476ba7 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -49,10 +49,11 @@ jobs: sudo apt-get install --yes --no-install-recommends doxygen python3 cpp/scripts/api_markdown.py + # Fails on a broken page link or an xref naming a member that no longer exists. - name: Build site run: | dotnet tool install --global docfx --version 2.81.0 - docfx docfx.json + docfx docfx.json --warningsAsErrors # keep_files leaves dev/bench (benchmark-pages.yml) in place; pages removed from the docs # stay on the branch until deleted by hand. diff --git a/docs/guide/csv.md b/docs/guide/csv.md index 3cea059e..54b0dd49 100644 --- a/docs/guide/csv.md +++ b/docs/guide/csv.md @@ -5,7 +5,7 @@ Parallel CSV parsing is covered in [reading.md](reading.md#parallel-reading-opt- ## Read CSV -`CsvReader` is a workbook with exactly one, unnamed sheet (`csv.FirstSheet`, the same sheet as `csv.Sheets[0]`). It streams RFC 4180 CSV (quoted fields, embedded delimiters/newlines, `""`-escaped quotes) through the same `Row`/`Cell` model as the Excel workbooks, so `ExcelParser` works on it unchanged. +[`CsvReader`](xref:ExcelReader.Core.Reader.Csv.CsvReader) is a workbook with exactly one, unnamed sheet (`csv.FirstSheet`, the same sheet as `csv.Sheets[0]`). It streams RFC 4180 CSV (quoted fields, embedded delimiters/newlines, `""`-escaped quotes) through the same [`Row`](xref:ExcelReader.Core.Reader.Row)/[`Cell`](xref:ExcelReader.Core.Reader.Cell) model as the Excel workbooks, so [`ExcelParser`](xref:ExcelReader.Core.Parser.ExcelParser%601) works on it unchanged. ```csharp using ExcelReader.Core.Parser; @@ -25,18 +25,18 @@ foreach (var item in ExcelParser.FromAttributes().Parse(csv.FirstShee } ``` -`Excel.FromCsv`/`FromCsvFile`/`FromCsvAsync`/`FromCsvFileAsync` mirror the other formats' factory shape and take the same `ExcelReaderOptions`. Its `Csv` property, a `CsvReaderOptions`, changes the delimiter/quote character, supplies a non-UTF-8 `Encoding` (transcoded to UTF-8 internally), or turns off BOM detection; `MaxCellBytes` and `InternStrings` stay on `ExcelReaderOptions`, as for every format: +[`Excel.FromCsv`](xref:ExcelReader.Core.Reader.Excel.FromCsv*)/`FromCsvFile`/`FromCsvAsync`/`FromCsvFileAsync` mirror the other formats' factory shape and take the same [`ExcelReaderOptions`](xref:ExcelReader.Core.Reader.ExcelReaderOptions). Its `Csv` property, a [`CsvReaderOptions`](xref:ExcelReader.Core.Reader.Csv.CsvReaderOptions), changes the delimiter/quote character, supplies a non-UTF-8 `Encoding` (transcoded to UTF-8 internally), or turns off BOM detection; `MaxCellBytes` and `InternStrings` stay on `ExcelReaderOptions`, as for every format: ```csharp var options = new ExcelReaderOptions { Csv = new CsvReaderOptions { Delimiter = (byte)';' } }; using var csv = Excel.FromCsvFile("relatorio.csv", options); ``` -Every CSV cell is text (`CellType.ExcelString`, or `CellType.Empty` for a blank field); at the reader level there is no binary numeric or date representation, so `Cell.TryGetDateTime`/`IsDate1904` (always `false` for CSV) do not apply. The typed parser, however, is CSV-specialized: `ExcelParser.Parse(CsvSheet)` parses `DateTime`/`DateOnly` columns directly from the cell text (ISO or culture format, honoring `Culture` — e.g. pt-BR `02/07/2026`), so no `[ExcelConverter]` is needed for dates. All the usual attributes work unchanged (`[ExcelColumn]` aliases, `[ExcelRequired]`, `[ExcelConverter]`), and a converter still takes precedence over the built-in date parsing. (Holding the sheet as an `IExcelSheet` instead routes through the generic Excel pipeline, where dates use serial-number semantics — prefer the concrete `Parse(CsvSheet)` overload for CSV.) +Every CSV cell is text ([`CellType.ExcelString`](xref:ExcelReader.Core.Reader.CellType.ExcelString), or [`CellType.Empty`](xref:ExcelReader.Core.Reader.CellType.Empty) for a blank field); at the reader level there is no binary numeric or date representation, so [`Cell.TryGetDateTime`](xref:ExcelReader.Core.Reader.Cell.TryGetDateTime*)/`IsDate1904` (always `false` for CSV) do not apply. The typed parser, however, is CSV-specialized: `ExcelParser.Parse(CsvSheet)` parses `DateTime`/`DateOnly` columns directly from the cell text (ISO or culture format, honoring `Culture` — e.g. pt-BR `02/07/2026`), so no `[ExcelConverter]` is needed for dates. All the usual attributes work unchanged (`[ExcelColumn]` aliases, `[ExcelRequired]`, `[ExcelConverter]`), and a converter still takes precedence over the built-in date parsing. (Holding the sheet as an [`IExcelSheet`](xref:ExcelReader.Core.Reader.IExcelSheet) instead routes through the generic Excel pipeline, where dates use serial-number semantics — prefer the concrete `Parse(CsvSheet)` overload for CSV.) A `CsvReader` over memory, a file path or a seekable stream allows several enumerators at once, each reading the sheet from the start. Over a seekable stream, every enumerator starts at the position the stream had when the `CsvReader` was created; where the stream's position ends up afterwards is unspecified, so do not rely on it (a file stream, and a memory stream with an exposed buffer, are read without touching it). A non-seekable stream can be read once: a second enumeration throws `InvalidOperationException`. -`Excel.Open`/`OpenAsync` do **not** auto-detect CSV — plain text has no magic-byte signature to sniff. Name the format instead: every `Open`/`OpenAsync` overload takes an optional `ExcelFileFormat`, and `ExcelReaderOptions.Csv` carries the dialect, so a caller that decides the format from an extension or a content-type header configures both families through one options object: +[`Excel.Open`](xref:ExcelReader.Core.Reader.Excel.Open*)/`OpenAsync` do **not** auto-detect CSV — plain text has no magic-byte signature to sniff. Name the format instead: every `Open`/`OpenAsync` overload takes an optional [`ExcelFileFormat`](xref:ExcelReader.Core.Reader.ExcelFileFormat), and [`ExcelReaderOptions.Csv`](xref:ExcelReader.Core.Reader.ExcelReaderOptions.Csv) carries the dialect, so a caller that decides the format from an extension or a content-type header configures both families through one options object: ```csharp ExcelFileFormat format = Path.GetExtension(path) is ".csv" ? ExcelFileFormat.Csv : ExcelFileFormat.Unknown; @@ -48,11 +48,11 @@ using IExcelWorkbook workbook = Excel.Open(path, format, new ExcelReaderOptions }); ``` -`ExcelFileFormat.Unknown` keeps the signature-based detection the no-format overloads do; `EncryptedOoxml` is a detection result rather than something to open, so passing it throws `ArgumentOutOfRangeException`. +[`ExcelFileFormat.Unknown`](xref:ExcelReader.Core.Reader.ExcelFileFormat.Unknown) keeps the signature-based detection the no-format overloads do; `EncryptedOoxml` is a detection result rather than something to open, so passing it throws `ArgumentOutOfRangeException`. ## Sniff a CSV dialect -`CsvSniffer.Detect` infers the delimiter, quote character, and encoding (from a leading byte-order mark) from a sample of bytes, so a `;`-separated pt-BR export or a TSV can be read without the caller knowing the dialect up front. It is deterministic (ties break by candidate order) and never throws on arbitrary input — an indecisive sample returns `CsvDialect.Default` (comma, `"`, UTF-8). +[`CsvSniffer.Detect`](xref:ExcelReader.Core.Reader.Csv.CsvSniffer.Detect*) infers the delimiter, quote character, and encoding (from a leading byte-order mark) from a sample of bytes, so a `;`-separated pt-BR export or a TSV can be read without the caller knowing the dialect up front. It is deterministic (ties break by candidate order) and never throws on arbitrary input — an indecisive sample returns [`CsvDialect.Default`](xref:ExcelReader.Core.Reader.Csv.CsvDialect.Default) (comma, `"`, UTF-8). ```csharp using ExcelReader.Core.Reader; @@ -62,9 +62,9 @@ CsvDialect dialect = CsvSniffer.DetectFile("export.csv"); using var csv = Excel.FromCsvFile("export.csv", new ExcelReaderOptions { Csv = CsvReaderOptions.Default.WithDialect(dialect) }); ``` -`CsvSniffer.Detect` takes a span of bytes or a seekable `Stream`, `DetectFile` takes a path, and `DetectAsync`/`DetectFileAsync` are the async siblings; only the first 64 KiB are examined. The `Stream` overloads require a seekable source — they read a bounded sample and restore the stream's position — so a non-seekable stream throws `ArgumentException`; buffer it first, or pass the bytes as a span instead. Pass `CsvSnifferOptions` to change the candidate delimiters/quotes (and their priority order) or the number of sample lines considered. +`CsvSniffer.Detect` takes a span of bytes or a seekable `Stream`, `DetectFile` takes a path, and `DetectAsync`/`DetectFileAsync` are the async siblings; only the first 64 KiB are examined. The `Stream` overloads require a seekable source — they read a bounded sample and restore the stream's position — so a non-seekable stream throws `ArgumentException`; buffer it first, or pass the bytes as a span instead. Pass [`CsvSnifferOptions`](xref:ExcelReader.Core.Reader.Csv.CsvSnifferOptions) to change the candidate delimiters/quotes (and their priority order) or the number of sample lines considered. -`CsvReaderOptions.SniffDialect` does the sample-then-apply above for you, at the entry point that owns the source — the `Excel.FromCsv*` factories, and `Excel.Open` with `ExcelFileFormat.Csv`. The dialect of a parallel read is the one the CSV workbook was opened with: +[`CsvReaderOptions.SniffDialect`](xref:ExcelReader.Core.Reader.Csv.CsvReaderOptions.SniffDialect) does the sample-then-apply above for you, at the entry point that owns the source — the `Excel.FromCsv*` factories, and `Excel.Open` with [`ExcelFileFormat.Csv`](xref:ExcelReader.Core.Reader.ExcelFileFormat.Csv). The dialect of a parallel read is the one the CSV workbook was opened with: ```csharp using var csv = Excel.FromCsvFile("export.csv", new ExcelReaderOptions { Csv = CsvReaderOptions.Default with { SniffDialect = true } }); @@ -74,7 +74,7 @@ An explicitly set `Encoding` survives sniffing unless the source carries a byte- ## Write CSV -`CsvWorkbookWriter` emits RFC 4180 CSV: no styles or shared strings, so rows stream straight to the output. A CSV file is a single sheet, so `AddSheet` is called once (the name is ignored). +[`CsvWorkbookWriter`](xref:ExcelReader.Core.Writer.Csv.CsvWorkbookWriter) emits RFC 4180 CSV: no styles or shared strings, so rows stream straight to the output. A CSV file is a single sheet, so `AddSheet` is called once (the name is ignored). ```csharp using ExcelReader.Core.Writer; @@ -101,7 +101,7 @@ using (CsvRowWriter row = writer.StartRow()) Fields are quoted only when they contain the delimiter, quote character, `\r`, or `\n`; embedded quotes are doubled. `bool` writes as lowercase `true`/`false` and `DateTime`/`DateOnly` as round-trip ISO 8601 (`"O"`); `TimeOnly` as a time-of-day fraction — all matching what `ExcelParser.Parse(CsvSheet)` expects, so a file written by `CsvWorkbookWriter` parses back without configuration. `Skip(count)` writes empty fields to keep column positions aligned (CSV has no sparse-cell concept). -`CsvWriterOptions` mirrors `CsvReaderOptions`'s delimiter, quote and encoding, so a file written with one set of settings reads back with the matching set: +[`CsvWriterOptions`](xref:ExcelReader.Core.Writer.Csv.CsvWriterOptions) mirrors `CsvReaderOptions`'s delimiter, quote and encoding, so a file written with one set of settings reads back with the matching set: ```csharp using var workbook = CsvWorkbookWriter.Create(stream, leaveOpen: false, new CsvWriterOptions diff --git a/docs/guide/encryption.md b/docs/guide/encryption.md index d44b17b2..18e38baa 100644 --- a/docs/guide/encryption.md +++ b/docs/guide/encryption.md @@ -13,7 +13,7 @@ using IExcelWorkbook workbook = Excel.Open("protected.xlsx", options); foreach (Row row in workbook.FirstSheet) { /* ... */ } ``` -`ExcelEncryptionException.Reason` tells you what to do about a failure — only `PasswordRequired` and +[`ExcelEncryptionException.Reason`](xref:ExcelReader.Core.Reader.ExcelEncryptionException.Reason) tells you what to do about a failure — only `PasswordRequired` and `PasswordIncorrect` are worth re-prompting for: ```csharp @@ -37,7 +37,7 @@ different mechanism entirely, stored as hashes in the plaintext XML. only the buffers it owns: the derivation buffer and the derived key. **The `dataIntegrity` HMAC is not verified by default when reading from a stream.** -`ExcelReaderOptions.VerifyEncryptedIntegrity` defaults to `false`, because the HMAC covers the whole +[`ExcelReaderOptions.VerifyEncryptedIntegrity`](xref:ExcelReader.Core.Reader.ExcelReaderOptions.VerifyEncryptedIntegrity) defaults to `false`, because the HMAC covers the whole encrypted package: checking it means a full pass over the file before the first row, which makes time-to-first-row proportional to file size and defeats streaming. With it off, decryption still fails loudly on corrupt ciphertext (the plaintext stops being a valid ZIP), but *targeted* tampering @@ -61,7 +61,7 @@ that, reading is ~10% slower than reading the same workbook unencrypted — see [Encrypted workbooks](../performance/benchmarks.md#encrypted-workbooks) in the benchmarks. Writing an encrypted workbook is a second step: build the package with any writer, then wrap it with -`Excel.EncryptPackage`, which produces an agile-encrypted (ECMA-376 4.4) CFB container — AES-256-CBC, +[`Excel.EncryptPackage`](xref:ExcelReader.Core.Reader.Excel.EncryptPackage*), which produces an agile-encrypted (ECMA-376 4.4) CFB container — AES-256-CBC, SHA-512, 100,000 spin iterations, with a `dataIntegrity` HMAC. ```csharp diff --git a/docs/guide/errors.md b/docs/guide/errors.md index 58afe6fd..ff6fd667 100644 --- a/docs/guide/errors.md +++ b/docs/guide/errors.md @@ -24,12 +24,12 @@ for the calling thread. | Failure | C# | Status | Python | Rust | C++ | |---|---|---|---|---|---| -| Encrypted, no password | `ExcelEncryptionException`, `Reason == PasswordRequired` | -6 | `PasswordRequiredError` | `Error::PasswordRequired` | `xl::Error`, `code == XL_STATUS_PASSWORD_REQUIRED` | +| Encrypted, no password | [`ExcelEncryptionException`](xref:ExcelReader.Core.Reader.ExcelEncryptionException), `Reason == PasswordRequired` | -6 | `PasswordRequiredError` | `Error::PasswordRequired` | `xl::Error`, `code == XL_STATUS_PASSWORD_REQUIRED` | | Wrong password | `ExcelEncryptionException`, `Reason == PasswordIncorrect` | -7 | `PasswordIncorrectError` | `Error::PasswordIncorrect` | `xl::Error`, `code == XL_STATUS_PASSWORD_INCORRECT` | | Unsupported encryption, or failed integrity check | `ExcelEncryptionException`, `Reason` is `UnsupportedScheme` or `IntegrityFailure` | -5 | `ExcelReaderError` | `Error::Native` | `xl::Error`, `code == XL_ERROR` | | Corrupt or malformed file | `InvalidDataException` | -5 | `ExcelReaderError` | `Error::Native` | `xl::Error`, `code == XL_ERROR` | | Feature the reader does not support | `NotSupportedException` | -5 | `ExcelReaderError` | `Error::Native` | `xl::Error`, `code == XL_ERROR` | -| File exceeds a configured resource limit | `ExcelLimitExceededException` | -5 | `ExcelReaderError` | `Error::Native` | `xl::Error`, `code == XL_ERROR` | +| File exceeds a configured resource limit | [`ExcelLimitExceededException`](xref:ExcelReader.Core.Reader.ExcelLimitExceededException) | -5 | `ExcelReaderError` | `Error::Native` | `xl::Error`, `code == XL_ERROR` | | Workbook already closed | `ObjectDisposedException` | -3 | `ExcelReaderError` | `Error::Native` | `xl::Error`, `code == XL_INVALID_HANDLE` | | Invalid argument | `ArgumentException` and its subclasses | -4, or -5 when the check happens past the ABI boundary | `ExcelReaderError` | `Error::Native` | `xl::Error` | diff --git a/docs/guide/migrating-to-v6.md b/docs/guide/migrating-to-v6.md index ca58d48e..3c368bb5 100644 --- a/docs/guide/migrating-to-v6.md +++ b/docs/guide/migrating-to-v6.md @@ -11,12 +11,12 @@ A v5 reader owned one "current sheet" that you moved with `MoveToSheet`; in v6 t | v5 | v6 | |---|---| -| `XlsxReader`, `XlsbReader`, `XlsReader` | `XlsxWorkbook`, `XlsbWorkbook`, `XlsWorkbook` | -| `XlsxReader.Enumerator` (and the other two) | `XlsxWorkbook.Enumerator` (and `XlsbWorkbook.Enumerator`, `XlsWorkbook.Enumerator`) | -| `IExcelRowReader` | `IExcelWorkbook` and `IExcelSheet` | -| `IExcelRowReader` | `IExcelSheet` | -| `ExcelSheet`, `reader.Sheets()` | `XlsxSheet` / `XlsbSheet` / `XlsSheet` / `CsvSheet`, `workbook.Sheets` | -| `CsvReader` | `CsvReader` (same name; now a workbook with one sheet) | +| `XlsxReader`, `XlsbReader`, `XlsReader` | [`XlsxWorkbook`](xref:ExcelReader.Core.Reader.Xlsx.XlsxWorkbook), [`XlsbWorkbook`](xref:ExcelReader.Core.Reader.Xlsb.XlsbWorkbook), [`XlsWorkbook`](xref:ExcelReader.Core.Reader.Xls.XlsWorkbook) | +| `XlsxReader.Enumerator` (and the other two) | [`XlsxWorkbook.Enumerator`](xref:ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Enumerator) (and [`XlsbWorkbook.Enumerator`](xref:ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Enumerator), [`XlsWorkbook.Enumerator`](xref:ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator)) | +| `IExcelRowReader` | [`IExcelWorkbook`](xref:ExcelReader.Core.Reader.IExcelWorkbook) and [`IExcelSheet`](xref:ExcelReader.Core.Reader.IExcelSheet) | +| `IExcelRowReader` | [`IExcelSheet`](xref:ExcelReader.Core.Reader.IExcelSheet%601) | +| `ExcelSheet`, `reader.Sheets()` | [`XlsxSheet`](xref:ExcelReader.Core.Reader.Xlsx.XlsxSheet) / [`XlsbSheet`](xref:ExcelReader.Core.Reader.Xlsb.XlsbSheet) / [`XlsSheet`](xref:ExcelReader.Core.Reader.Xls.XlsSheet) / [`CsvSheet`](xref:ExcelReader.Core.Reader.Csv.CsvSheet), `workbook.Sheets` | +| [`CsvReader`](xref:ExcelReader.Core.Reader.Csv.CsvReader) | `CsvReader` (same name; now a workbook with one sheet) | An explicit `ExcelEnumerable` declaration becomes `ExcelEnumerable` (likewise for XLSX and XLSB). @@ -88,7 +88,7 @@ for (int i = 0; i < workbook.SheetCount; i++) } ``` -When you open a format directly (`Excel.FromXlsxFile` and the like), `workbook.Sheets` is a typed list: `foreach (XlsxSheet sheet in workbook.Sheets)` walks every sheet without boxing. +When you open a format directly ([`Excel.FromXlsxFile`](xref:ExcelReader.Core.Reader.Excel.FromXlsxFile*) and the like), `workbook.Sheets` is a typed list: `foreach (XlsxSheet sheet in workbook.Sheets)` walks every sheet without boxing. v5's `ExcelSheet` had a positional `Deconstruct`, so `foreach (var (i, name) in reader.Sheets())` worked. The v6 sheets do not deconstruct; read the properties instead: diff --git a/docs/guide/migrating-to-v7.md b/docs/guide/migrating-to-v7.md index 1f4e5c09..1a73bf9d 100644 --- a/docs/guide/migrating-to-v7.md +++ b/docs/guide/migrating-to-v7.md @@ -5,18 +5,18 @@ See also [reading.md](reading.md#parallel-reading-opt-in) for the full guide. ## What changed -Parallel reading now takes a sheet, and it works on XLSX and XLSB as well as CSV. `CsvParallel` is gone: its eighteen path, memory and stream methods became six `SheetParallel` methods that take any `IExcelSheet`. You open the workbook yourself, pass its sheet, and the library parallelises as far as the format allows. The contract types lost their `Csv` prefix because they now serve every format. +Parallel reading now takes a sheet, and it works on XLSX and XLSB as well as CSV. `CsvParallel` is gone: its eighteen path, memory and stream methods became six [`SheetParallel`](xref:ExcelReader.Core.Reader.SheetParallel) methods that take any [`IExcelSheet`](xref:ExcelReader.Core.Reader.IExcelSheet). You open the workbook yourself, pass its sheet, and the library parallelises as far as the format allows. The contract types lost their `Csv` prefix because they now serve every format. ## Types | v6 | v7 | |---|---| | `CsvParallel` | `SheetParallel` | -| `CsvParallelOptions` | `SheetParallelOptions` (no `Reader` property) | -| `ICsvRecord` | `IRowRecord` | -| `ICsvAccumulator` | `IRowAccumulator` | -| `CsvAggregation` | `RowAggregation` | -| `CsvRowAction` | `RowAction` | +| `CsvParallelOptions` | [`SheetParallelOptions`](xref:ExcelReader.Core.Reader.SheetParallelOptions) (no `Reader` property) | +| `ICsvRecord` | [`IRowRecord`](xref:ExcelReader.Core.Reader.IRowRecord%601) | +| `ICsvAccumulator` | [`IRowAccumulator`](xref:ExcelReader.Core.Reader.IRowAccumulator%602) | +| `CsvAggregation` | [`RowAggregation`](xref:ExcelReader.Core.Reader.RowAggregation%601) | +| `CsvRowAction` | [`RowAction`](xref:ExcelReader.Core.Reader.RowAction%601) | The old names are removed, with no `[Obsolete]` forwarders. Members, contracts and results are unchanged. @@ -86,9 +86,9 @@ await foreach (Sale s in SheetParallel.ParseAsync(workbook.FirstSheet, parser, n ## Behavior changes - The caller owns the workbook and keeps it open until the task completes or the enumeration ends. v6 opened and closed its own source. -- The CSV dialect is given when the workbook is opened (`ExcelReaderOptions.Csv`), not in the parallel options. -- A CSV in a `MemoryStream` is no longer partitioned: the stream overload of `Excel.FromCsv` reads it sequentially. Pass the bytes as `ReadOnlyMemory` to keep partitioning. -- `HeaderRow` rules are the same as v6. `ParseAsync` takes the header from `ExcelParserOptions.HeaderRow` and rejects a non-zero `SheetParallelOptions.HeaderRow`; the parser-based `ForEachAsync` and `AggregateAsync` use `SheetParallelOptions.HeaderRow`. +- The CSV dialect is given when the workbook is opened ([`ExcelReaderOptions.Csv`](xref:ExcelReader.Core.Reader.ExcelReaderOptions.Csv)), not in the parallel options. +- A CSV in a `MemoryStream` is no longer partitioned: the stream overload of [`Excel.FromCsv`](xref:ExcelReader.Core.Reader.Excel.FromCsv*) reads it sequentially. Pass the bytes as `ReadOnlyMemory` to keep partitioning. +- `HeaderRow` rules are the same as v6. `ParseAsync` takes the header from [`ExcelParserOptions.HeaderRow`](xref:ExcelReader.Core.Parser.ExcelParserOptions.HeaderRow) and rejects a non-zero [`SheetParallelOptions.HeaderRow`](xref:ExcelReader.Core.Reader.SheetParallelOptions.HeaderRow); the parser-based `ForEachAsync` and `AggregateAsync` use `SheetParallelOptions.HeaderRow`. - XLSX and XLSB sheets are now read on several threads when `DegreeOfParallelism` allows it. XLS, a table view (`ExcelTable.AsSheet()`), a sheet under 256 KB uncompressed and a CSV that cannot be partitioned still read sequentially with the same results. - `PrefetchDecompression` does not apply to a parallel XLSX or XLSB read; the pipeline already overlaps inflate and parse. @@ -98,7 +98,7 @@ These changes replace positional parameters and loose arguments with types that | v6 | v7 | |---|---| -| `ExcelParserConfig` | `ExcelParserOptions`; the `ExcelParser` factories' parameter is `options` | +| `ExcelParserConfig` | [`ExcelParserOptions`](xref:ExcelReader.Core.Parser.ExcelParserOptions); the [`ExcelParser`](xref:ExcelReader.Core.Parser.ExcelParser) factories' parameter is `options` | | `IExcelCellConverter.TryConvert(in Cell, bool isDate1904, IFormatProvider provider, out T)` | `TryConvert(in Cell, in ExcelRowContext context, out T)` | | `ExcelCellReader`, `ExcelRowParser`, `ExcelCellReaders.*`: `(…, bool isDate1904, IFormatProvider provider, …)` | `(…, in ExcelRowContext context, …)` | | `Excel.InferSchema(sheet, headerRow, sampleSize, parseText)` returning `ExcelColumnSchema[]` | `Excel.InferSchema(sheet, new SchemaInferenceOptions { HeaderRow = …, SampleSize = …, ParseText = … })` returning `IReadOnlyList` | @@ -107,9 +107,9 @@ These changes replace positional parameters and loose arguments with types that | `WriteRecordBatch(batch, sheetName, writeHeader)` and the async form | `WriteRecordBatch(batch, new ArrowWriteOptions { SheetName = …, WriteHeader = … })` | | `XlsWorkbookWriter.Create(stream, leaveOpen, date1904)` | `XlsWorkbookWriter.Create(stream, leaveOpen, new XlsWriterOptions { Date1904 = … })` | | `Excel.FromCsv*(…, CsvReaderOptions options)` | `Excel.FromCsv*(…, new ExcelReaderOptions { Csv = options })` | -| `CsvReaderOptions.MaxCellBytes`, `CsvReaderOptions.InternStrings` | `ExcelReaderOptions.MaxCellBytes`, `ExcelReaderOptions.InternStrings` | +| `CsvReaderOptions.MaxCellBytes`, `CsvReaderOptions.InternStrings` | [`ExcelReaderOptions.MaxCellBytes`](xref:ExcelReader.Core.Reader.ExcelReaderOptions.MaxCellBytes), [`ExcelReaderOptions.InternStrings`](xref:ExcelReader.Core.Reader.ExcelReaderOptions.InternStrings) | | `CellType.Formula` | Removed. A formula cell reports its cached result's type: `Number`, `Date`, `ExcelString`, `Boolean` or `Error` | -| `ExcelDataReader` (its name collided with Sylvan.Data.Excel and the ExcelDataReader package) | `SheetDataReader` | +| `ExcelDataReader` (its name collided with Sylvan.Data.Excel and the ExcelDataReader package) | [`SheetDataReader`](xref:ExcelReader.Core.Reader.SheetDataReader) | | `Cell.TryGetDateTime(out DateTime)`, which silently assumed the 1900 date system | `Cell.TryGetDateTime(workbook.IsDate1904, out DateTime)` | A converter reads the date system and culture from the context: @@ -128,10 +128,10 @@ To unit-test a converter, build the context with an initializer: `new ExcelRowCo Two behavior changes come with them: -- `Excel.Open` with `ExcelFileFormat.Csv` now applies `ExcelReaderOptions.MaxCellBytes` and `InternStrings`. v6 ignored both and used the CSV defaults. +- [`Excel.Open`](xref:ExcelReader.Core.Reader.Excel.Open*) with [`ExcelFileFormat.Csv`](xref:ExcelReader.Core.Reader.ExcelFileFormat.Csv) now applies `ExcelReaderOptions.MaxCellBytes` and `InternStrings`. v6 ignored both and used the CSV defaults. - Formula cells are typed by their cached result in every format. v6 reported an XLSX text result and an XLS numeric result as `Formula`, so an XLS formula with a date style lost its `Date` type, and XLSB never used `Formula`. - The CLI `convert` command no longer shifts dates by four years when the source workbook uses the 1904 date system. -- `XlsxWriterOptions` gains `Date1904`, which v6 lacked. It defaults to `false`, so existing output is unchanged. +- [`XlsxWriterOptions`](xref:ExcelReader.Core.Writer.Xlsx.XlsxWriterOptions) gains `Date1904`, which v6 lacked. It defaults to `false`, so existing output is unchanged. ## Native bindings @@ -141,4 +141,4 @@ The C ABI is unchanged: no signature moved, and `XL_ABI_VERSION` stays 6. Behavi `xl_write_options.date1904` now applies to XLSX as well as XLSB and XLS; v6 ignored it for XLSX. -`xl_csv_aggregate` keeps its signature. Inside, it opens a `CsvReader` with the dialect from the native struct and calls `SheetParallel`. +`xl_csv_aggregate` keeps its signature. Inside, it opens a [`CsvReader`](xref:ExcelReader.Core.Reader.Csv.CsvReader) with the dialect from the native struct and calls `SheetParallel`. diff --git a/docs/guide/parsing.md b/docs/guide/parsing.md index e30137ca..5a1179e5 100644 --- a/docs/guide/parsing.md +++ b/docs/guide/parsing.md @@ -31,7 +31,7 @@ foreach (var item in parser.Parse(workbook.FirstSheet)) Built-in property types: `string`, `bool`, `DateTime`, `DateOnly`, `TimeOnly`, `TimeSpan`, `DateTimeOffset`, `Guid`, `char`, every integral and floating type plus `decimal`, and `enum`s (matched by member name or numeric value). Each also works as a `Nullable`. Empty cells leave the property at its default; an unparseable cell is skipped (keeps the default) unless the column is required. `T` needs no parameterless-constructor constraint, so models with `required` members are supported, through both `FromAttributes` and the source generator. `required` alone does not make a column mandatory; that is what `[ExcelRequired]` does. -`Parse` also accepts the `IExcelSheet` of the `IExcelWorkbook` from `Excel.Open`, so you can parse without knowing the concrete format: +`Parse` also accepts the [`IExcelSheet`](xref:ExcelReader.Core.Reader.IExcelSheet) of the [`IExcelWorkbook`](xref:ExcelReader.Core.Reader.IExcelWorkbook) from [`Excel.Open`](xref:ExcelReader.Core.Reader.Excel.Open*), so you can parse without knowing the concrete format: ```csharp using IExcelWorkbook workbook = Excel.Open("changes.xlsx"); // or .xlsb / .xls @@ -40,7 +40,7 @@ foreach (var item in ExcelParser.FromAttributes().Parse(workbook.Firs ## Generate typed maps at compile time (Native AOT / trimming) -`ExcelParser.FromAttributes` and `ExcelRecordLayout.FromAttributes` reflect over `T` (`GetProperties`, `MakeGenericMethod`), which trimming can break and Native AOT cannot run at all. The raw `Excel.From*` readers already use no reflection; mark a model `[ExcelSerializable]` to get the same guarantee for the typed layer — a source generator emits a compile-time map from the model's own `[ExcelColumn]`/`[ExcelRequired]`/`[ExcelConverter]`/`[ExcelIgnore]` attributes, and `ExcelParser.Generated`/`ExcelRecordLayout.Generated` read and write through that map instead of reflection: +[`ExcelParser.FromAttributes`](xref:ExcelReader.Core.Parser.ExcelParser.FromAttributes*) and [`ExcelRecordLayout.FromAttributes`](xref:ExcelReader.Core.Writer.ExcelRecordLayout.FromAttributes*) reflect over `T` (`GetProperties`, `MakeGenericMethod`), which trimming can break and Native AOT cannot run at all. The raw `Excel.From*` readers already use no reflection; mark a model `[ExcelSerializable]` to get the same guarantee for the typed layer — a source generator emits a compile-time map from the model's own `[ExcelColumn]`/`[ExcelRequired]`/`[ExcelConverter]`/`[ExcelIgnore]` attributes, and [`ExcelParser.Generated`](xref:ExcelReader.Core.Parser.ExcelParser.Generated*)/[`ExcelRecordLayout.Generated`](xref:ExcelReader.Core.Writer.ExcelRecordLayout.Generated*) read and write through that map instead of reflection: ```csharp using ExcelReader.Core.Parser; @@ -76,14 +76,14 @@ await using (var sheet = workbook.AddSheet("Changes")) Notes: - `[ExcelSerializable]` requires the model — and every type it's nested inside, if any — to be `partial`; the generator emits into an additional part of the same declaration. A compile error (`EXR001`/`EXR002`) names exactly what to fix. -- Supported property types match `ExcelParser.FromAttributes`'s: `string`, `bool`, `DateTime`, `DateOnly`, `TimeOnly`, `TimeSpan`, `DateTimeOffset`, `Guid`, `char`, every integral and floating type plus `decimal`, `enum`s, and `Nullable` of each — plus `ReadOnlySpan`, which binds zero-copy through `ExcelCellReaders.Utf8`. The model may be a `ref struct`: mark it `ref partial struct` and the generator emits `IExcelRowMap` on it like any other shape, so `ExcelParser.Generated` and `ExcelParser.Build` read it AOT-clean, as do the map-driven `SheetParallel.ForEachAsync`/`SheetParallel.AggregateAsync` overloads. +- Supported property types match `ExcelParser.FromAttributes`'s: `string`, `bool`, `DateTime`, `DateOnly`, `TimeOnly`, `TimeSpan`, `DateTimeOffset`, `Guid`, `char`, every integral and floating type plus `decimal`, `enum`s, and `Nullable` of each — plus `ReadOnlySpan`, which binds zero-copy through [`ExcelCellReaders.Utf8`](xref:ExcelReader.Core.Parser.ExcelCellReaders.Utf8*). The model may be a `ref struct`: mark it `ref partial struct` and the generator emits [`IExcelRowMap`](xref:ExcelReader.Core.Parser.IExcelRowMap%601) on it like any other shape, so `ExcelParser.Generated` and [`ExcelParser.Build`](xref:ExcelReader.Core.Parser.ExcelParser.Build*) read it AOT-clean, as do the map-driven [`SheetParallel.ForEachAsync`](xref:ExcelReader.Core.Reader.SheetParallel.ForEachAsync*)/[`SheetParallel.AggregateAsync`](xref:ExcelReader.Core.Reader.SheetParallel.AggregateAsync*) overloads. - The generator requires a build via `dotnet build`/the .NET SDK. Visual Studio's or `MSBuild.exe`'s .NET Framework host can't load it, so a project built only through those tools won't see generated code — build via the SDK, or fall back to `ExcelParser.FromAttributes`/`ExcelRecordLayout.FromAttributes` for that build path. -- `ExcelParser.Generated` builds one map per model and reuses it for every reader, including CSV — unlike `ExcelParser.FromAttributes`, which swaps in a text-based date reader specifically for CSV. A `[ExcelSerializable]` model reads `DateTime`/`DateOnly`/`TimeOnly` via `ExcelCellReaders.DateTimeAuto`/`DateOnlyAuto`/`TimeOnlyAuto`: an Excel serial number first, falling back to date/time text when the cell isn't numeric — so a CSV column round-trips through the library's own writer either way. The one edge case this can't distinguish: a CSV cell that's only digits (e.g. an Excel serial number typed as plain text) is always read as a serial number, never as date text. +- `ExcelParser.Generated` builds one map per model and reuses it for every reader, including CSV — unlike `ExcelParser.FromAttributes`, which swaps in a text-based date reader specifically for CSV. A `[ExcelSerializable]` model reads `DateTime`/`DateOnly`/`TimeOnly` via [`ExcelCellReaders.DateTimeAuto`](xref:ExcelReader.Core.Parser.ExcelCellReaders.DateTimeAuto)/`DateOnlyAuto`/`TimeOnlyAuto`: an Excel serial number first, falling back to date/time text when the cell isn't numeric — so a CSV column round-trips through the library's own writer either way. The one edge case this can't distinguish: a CSV cell that's only digits (e.g. an Excel serial number typed as plain text) is always read as a serial number, never as date text. - The attribute-based reflection path keeps working unchanged; `[ExcelSerializable]` is an additive, opt-in alternative for the same model shape, not a replacement. ## Map columns at runtime (fluent API) -`[ExcelColumn]`/`[ExcelRequired]` fix the mapping at compile time. When the mapping itself is a runtime decision — loaded from a config file, chosen by a user in a UI, or different per input file — build it with `ExcelRowMapBuilder` through `ExcelParser.Build` instead: +`[ExcelColumn]`/`[ExcelRequired]` fix the mapping at compile time. When the mapping itself is a runtime decision — loaded from a config file, chosen by a user in a UI, or different per input file — build it with [`ExcelRowMapBuilder`](xref:ExcelReader.Core.Parser.ExcelRowMapBuilder%601) through `ExcelParser.Build` instead: ```csharp using ExcelReader.Core.Parser; @@ -120,7 +120,7 @@ var parser = ExcelParser.Build(builder => builder A builder that uses `PropertyAt` skips the header-row step entirely — the first row is already data. Mixing `PropertyAt` with `Property`/`PropertyNullable`/`Converted` on the same builder throws, since one map can't both wait for a header row and skip it. -`ExcelParser.BuildWithAttributeFallback` merges the builder's bindings with `[ExcelColumn]`/`[ExcelRequired]`-driven ones reflected from `T`: a builder binding replaces every attribute-driven property that shares one of its header names; a property whose header names none of the builder's bindings mention keeps its attribute-driven behavior. Useful for overriding just the one column that's a runtime decision without redeclaring the whole model — reuse one of the property's existing `[ExcelColumn]` names in the builder: +[`ExcelParser.BuildWithAttributeFallback`](xref:ExcelReader.Core.Parser.ExcelParser.BuildWithAttributeFallback*) merges the builder's bindings with `[ExcelColumn]`/`[ExcelRequired]`-driven ones reflected from `T`: a builder binding replaces every attribute-driven property that shares one of its header names; a property whose header names none of the builder's bindings mention keeps its attribute-driven behavior. Useful for overriding just the one column that's a runtime decision without redeclaring the whole model — reuse one of the property's existing `[ExcelColumn]` names in the builder: ```csharp var parser = ExcelParser.BuildWithAttributeFallback(builder => builder @@ -133,7 +133,7 @@ The match is by header name, not by property identity: configuring a *different* ## Parser configuration -Pass an `ExcelParserOptions` to control header handling and culture: +Pass an [`ExcelParserOptions`](xref:ExcelReader.Core.Parser.ExcelParserOptions) to control header handling and culture: ```csharp using System.Globalization; @@ -150,7 +150,7 @@ var options = new ExcelParserOptions var parser = ExcelParser.FromAttributes(options); ``` -`Culture` applies when parsing text-backed numeric/`Guid` cells (XLSX inline and shared strings); binary numeric cells (XLS/XLSB) carry a raw value and ignore it. `HeaderNormalization` flags (`Trim`, `CollapseSpaces`, `RemoveDiacritics`) are applied to both the sheet headers and the property names before matching. +`Culture` applies when parsing text-backed numeric/`Guid` cells (XLSX inline and shared strings); binary numeric cells (XLS/XLSB) carry a raw value and ignore it. [`HeaderNormalization`](xref:ExcelReader.Core.Parser.HeaderNormalization) flags (`Trim`, `CollapseSpaces`, `RemoveDiacritics`) are applied to both the sheet headers and the property names before matching. ## Required columns @@ -173,7 +173,7 @@ public sealed class Order ## Custom converters -For types the built-in parsers do not handle — money strings, custom formats, domain value objects — implement `IExcelCellConverter` and attach it with `[ExcelConverter]`. `T` must be the property's exact type. One instance is created and reused across all rows, so converters must be stateless. +For types the built-in parsers do not handle — money strings, custom formats, domain value objects — implement [`IExcelCellConverter`](xref:ExcelReader.Core.Parser.IExcelCellConverter%601) and attach it with `[ExcelConverter]`. `T` must be the property's exact type. One instance is created and reused across all rows, so converters must be stateless. ```csharp using System.Globalization; diff --git a/docs/guide/reading.md b/docs/guide/reading.md index fcd04718..0a45720e 100644 --- a/docs/guide/reading.md +++ b/docs/guide/reading.md @@ -26,11 +26,11 @@ foreach (var row in workbook.FirstSheet) } ``` -An opened workbook (`XlsxWorkbook`, `XlsbWorkbook`, `XlsWorkbook`, `CsvReader`) hands out its sheets: `FirstSheet` is the first one, `Sheets[i]` any of them. A sheet holds no resources of its own; iterating it starts a new, independent read. Dispose the workbook, not the sheets. +An opened workbook ([`XlsxWorkbook`](xref:ExcelReader.Core.Reader.Xlsx.XlsxWorkbook), [`XlsbWorkbook`](xref:ExcelReader.Core.Reader.Xlsb.XlsbWorkbook), [`XlsWorkbook`](xref:ExcelReader.Core.Reader.Xls.XlsWorkbook), [`CsvReader`](xref:ExcelReader.Core.Reader.Csv.CsvReader)) hands out its sheets: `FirstSheet` is the first one, `Sheets[i]` any of them. A sheet holds no resources of its own; iterating it starts a new, independent read. Dispose the workbook, not the sheets. ### Parallel reading (opt-in) -`SheetParallel` reads one sheet on several threads. It takes any sheet, so it works the same on XLSX, XLSB and CSV: +[`SheetParallel`](xref:ExcelReader.Core.Reader.SheetParallel) reads one sheet on several threads. It takes any sheet, so it works the same on XLSX, XLSB and CSV: ```csharp using XlsxWorkbook workbook = Excel.FromXlsxFile("big.xlsx"); @@ -81,7 +81,7 @@ UnitTotal total = await SheetParallel.AggregateAsync( new SheetParallelOptions { HeaderRow = 1 }); ``` -The same call takes a `RowAggregation` when you would rather pass delegates than write a type, and an `IRowRecord` type in place of the parser. +The same call takes a [`RowAggregation`](xref:ExcelReader.Core.Reader.RowAggregation%601) when you would rather pass delegates than write a type, and an `IRowRecord` type in place of the parser. A per-row callback: @@ -98,7 +98,7 @@ await SheetParallel.ForEachAsync( Unlike `ParseAsync`, the callback runs on the worker threads, so records arrive in no particular order and the callback must be safe to call from several threads at once. Spans are valid only for the duration of the call. -`HeaderRow` follows the same rule as before: `ParseAsync` takes the header from the parser's `ExcelParserOptions.HeaderRow` and rejects a non-zero `SheetParallelOptions.HeaderRow`, while the parser-based `ForEachAsync` and `AggregateAsync` use `SheetParallelOptions.HeaderRow`. +`HeaderRow` follows the same rule as before: `ParseAsync` takes the header from the parser's [`ExcelParserOptions.HeaderRow`](xref:ExcelReader.Core.Parser.ExcelParserOptions.HeaderRow) and rejects a non-zero [`SheetParallelOptions.HeaderRow`](xref:ExcelReader.Core.Reader.SheetParallelOptions.HeaderRow), while the parser-based `ForEachAsync` and `AggregateAsync` use `SheetParallelOptions.HeaderRow`. #### When it reads sequentially @@ -121,7 +121,7 @@ These cases read on one thread with the same results and no error: A callback that writes to a database or sends messages must be idempotent on CSV, and on XLSX if the sheet may be malformed, and must also tolerate records that do not exist: deduplicating on a key does not filter those out. -`ExcelParseException.Row` on `ParseAsync` counts the rows emitted so far. On `ForEachAsync` and `AggregateAsync` it is the exact row when the read is sequential and `0` when it was partitioned. +[`ExcelParseException.Row`](xref:ExcelReader.Core.Parser.ExcelParseException.Row) on `ParseAsync` counts the rows emitted so far. On `ForEachAsync` and `AggregateAsync` it is the exact row when the read is sequential and `0` when it was partitioned. See the [parallel sheet benchmarks](../performance/benchmarks.md#parallel-sheet-reading) and the [parallel CSV benchmarks](../performance/benchmarks.md#parallel-csv) for what this buys. On a real-data XLSX the sheet stage drops from 29 ms (prefetched, sequential) to about 18 ms, and on XLSB from 16 ms to about 13 ms; the floor is inflate, which runs on one thread. CSV has no such floor: its `AggregateAsync` at dop 16 is ~2.8x faster than the typed path while allocating ~410x less, with zero garbage collections. @@ -141,7 +141,7 @@ using var workbook = Excel.FromXlsxFile("report.xlsx"); Apache.Arrow.RecordBatch batch = workbook.FirstSheet.ToArrowRecordBatch(); ``` -The schema defaults to `Excel.InferSchema`'s guess; pass one in `new ArrowReadOptions { Schema = ... }` to skip inference, and set `HeaderRow` there (default 1, 0 for none). The whole sheet is materialized into one `RecordBatch` — there is no chunked/streaming variant yet. Inference costs about the same time and ~1.8x the allocation of an explicit schema on the same file. See [Arrow conversion](../performance/benchmarks.md#arrow-conversion) in the benchmarks. +The schema defaults to [`Excel.InferSchema`](xref:ExcelReader.Core.Reader.Excel.InferSchema*)'s guess; pass one in `new ArrowReadOptions { Schema = ... }` to skip inference, and set `HeaderRow` there (default 1, 0 for none). The whole sheet is materialized into one `RecordBatch` — there is no chunked/streaming variant yet. Inference costs about the same time and ~1.8x the allocation of an explicit schema on the same file. See [Arrow conversion](../performance/benchmarks.md#arrow-conversion) in the benchmarks. `WriteRecordBatch`/`WriteRecordBatchAsync` are the write-side mirror — one call from a `RecordBatch` to a sheet, for XLSX, XLSB, XLS, and CSV: @@ -158,7 +158,7 @@ Supports string, large string, string view, every integer width (signed and unsi ## Open by auto-detecting the format -`Excel.Open` picks the workbook type from the file signature (XLSX/XLSB are ZIP packages, XLS is an OLE2 document) and returns an `IExcelWorkbook`. `FirstSheet` gives the first sheet, and a sheet iterates its rows directly, so no pattern-match is needed for basic row iteration. +[`Excel.Open`](xref:ExcelReader.Core.Reader.Excel.Open*) picks the workbook type from the file signature (XLSX/XLSB are ZIP packages, XLS is an OLE2 document) and returns an [`IExcelWorkbook`](xref:ExcelReader.Core.Reader.IExcelWorkbook). `FirstSheet` gives the first sheet, and a sheet iterates its rows directly, so no pattern-match is needed for basic row iteration. ```csharp using ExcelReader.Core.Reader; @@ -171,7 +171,7 @@ foreach (var row in workbook.FirstSheet) } ``` -`SheetCount`, `SheetAt(index)` and `TryGetSheet(name)` are on `IExcelWorkbook` itself, so you can walk every sheet without knowing the format. Each `IExcelSheet` carries its own `Index`, `Name` and `Visibility`: +`SheetCount`, `SheetAt(index)` and `TryGetSheet(name)` are on `IExcelWorkbook` itself, so you can walk every sheet without knowing the format. Each [`IExcelSheet`](xref:ExcelReader.Core.Reader.IExcelSheet) carries its own `Index`, `Name` and `Visibility`: ```csharp using IExcelWorkbook workbook = Excel.Open("report.xlsx"); @@ -199,7 +199,7 @@ if (workbook.TryGetSheet("Totals", out var totals)) } ``` -When you open a format directly (`Excel.FromXlsxFile` and the like) the workbook's `Sheets` is a typed, indexable list (`Sheets.Count`, `Sheets[i]`, `foreach`) of `XlsxSheet`, `XlsbSheet` or `XlsSheet` values, so walking it boxes nothing. Reaching a sheet only looks up a name and an index; the file is not read until you iterate the sheet. +When you open a format directly ([`Excel.FromXlsxFile`](xref:ExcelReader.Core.Reader.Excel.FromXlsxFile*) and the like) the workbook's `Sheets` is a typed, indexable list (`Sheets.Count`, `Sheets[i]`, `foreach`) of [`XlsxSheet`](xref:ExcelReader.Core.Reader.Xlsx.XlsxSheet), [`XlsbSheet`](xref:ExcelReader.Core.Reader.Xlsb.XlsbSheet) or [`XlsSheet`](xref:ExcelReader.Core.Reader.Xls.XlsSheet) values, so walking it boxes nothing. Reaching a sheet only looks up a name and an index; the file is not read until you iterate the sheet. `Visibility` reports whether a sheet is shown in the workbook's tab bar, read from the format's own encoding of it (XLSX's `state` attribute, XLSB's `BrtBundleSh.hsState`, XLS's `BoundSheet8.hsState`). It is reported, never enforced — a hidden sheet enumerates its rows like any other, so converters and exporters filter on it themselves: @@ -217,13 +217,13 @@ for (int i = 0; i < workbook.SheetCount; i++) } ``` -`ExcelSheetVisibility.VeryHidden` is the state Excel's own unhide dialog does not offer; both it and `Hidden` come back here. A sheet whose format says nothing about its state — or says something no producer agrees on — reads as `Visible`. +[`ExcelSheetVisibility.VeryHidden`](xref:ExcelReader.Core.ExcelSheetVisibility.VeryHidden) is the state Excel's own unhide dialog does not offer; both it and `Hidden` come back here. A sheet whose format says nothing about its state — or says something no producer agrees on — reads as `Visible`. CSV is exposed as a single, unnamed sheet (`SheetCount == 1`, `Name == ""`, always `Visible`). Pattern-match to the concrete type only for workbook-specific internals beyond this surface. `OpenAsync` is the async counterpart. Both require a seekable stream (or a file path) so the signature can be read without consuming the input. -Detection covers the signed formats only. To open a source whose format you already know — including CSV, which has no signature to detect — pass an `ExcelFileFormat` and let `ExcelReaderOptions.Csv` carry the dialect; see [CSV](csv.md#read-csv). +Detection covers the signed formats only. To open a source whose format you already know — including CSV, which has no signature to detect — pass an [`ExcelFileFormat`](xref:ExcelReader.Core.Reader.ExcelFileFormat) and let [`ExcelReaderOptions.Csv`](xref:ExcelReader.Core.Reader.ExcelReaderOptions.Csv) carry the dialect; see [CSV](csv.md#read-csv). ```csharp using IExcelWorkbook workbook = Excel.Open("report.csv", ExcelFileFormat.Csv); @@ -250,8 +250,8 @@ if (workbook.TryGetTable("Sales", out ExcelTable? sales)) - `FirstRow`, `FirstColumn`, `LastRow` and `LastColumn` are zero-based and include the header and totals rows; `Ref` is the same range in A1 notation. - Column indexes inside `AsSheet()` rows stay those of the sheet: a table starting at column C reports - its first cell at index 2. `Excel.InferSchema` keeps those indexes in `ExcelColumnSchema.Index` but - lists only the table's columns. `SheetDataReader` ordinals are relative to the table: ordinal 0 is + its first cell at index 2. `Excel.InferSchema` keeps those indexes in [`ExcelColumnSchema.Index`](xref:ExcelReader.Core.Reader.Schema.ExcelColumnSchema.Index) but + lists only the table's columns. [`SheetDataReader`](xref:ExcelReader.Core.Reader.SheetDataReader) ordinals are relative to the table: ordinal 0 is its first column. - A table whose header row is turned off (`HeaderRowCount == 0`) still enumerates, but `Parse` throws `InvalidOperationException` for every map, and `SheetDataReader` and `InferSchema` throw it when @@ -299,7 +299,7 @@ await foreach (var row in workbook.FirstSheet) } ``` -`await foreach` binds to the sheet's `GetAsyncEnumerator(CancellationToken ct = default)` by pattern. The call itself does no I/O: opening the sheet part (and, for XLSX, loading shared strings) happens asynchronously on the first `MoveNextAsync`. Because `Row` and `Cell` are `ref struct` types, the current row cannot be held across an `await` inside the loop body: read its cells (or copy the values out) before awaiting anything else. +`await foreach` binds to the sheet's `GetAsyncEnumerator(CancellationToken ct = default)` by pattern. The call itself does no I/O: opening the sheet part (and, for XLSX, loading shared strings) happens asynchronously on the first `MoveNextAsync`. Because [`Row`](xref:ExcelReader.Core.Reader.Row) and [`Cell`](xref:ExcelReader.Core.Reader.Cell) are `ref struct` types, the current row cannot be held across an `await` inside the loop body: read its cells (or copy the values out) before awaiting anything else. `await foreach` does not accept `.WithCancellation(ct)`: `Row` being a `ref struct` rules out `IAsyncEnumerable`, so the loop binds to the pattern rather than the interface. To pass a token, or to `await` while a row is in scope, drive the enumerator manually: @@ -319,7 +319,7 @@ Errors from opening the sheet, such as cancellation or a corrupt part, surface f ## Prefetch decompression (XLSX/XLSB) XLSX and XLSB are ZIP-backed, and inflating a sheet's compressed bytes competes for -wall-clock time with parsing it. `ExcelReaderOptions.PrefetchDecompression` overlaps the +wall-clock time with parsing it. [`ExcelReaderOptions.PrefetchDecompression`](xref:ExcelReader.Core.Reader.ExcelReaderOptions.PrefetchDecompression) overlaps the two: a background thread inflates ahead while the calling thread parses. It is **opt-in, defaults to `false`**, and only affects XLSX/XLSB — XLS and CSV have nothing to decompress, so the option is silently ignored for them. diff --git a/docs/guide/writing.md b/docs/guide/writing.md index 9ed6793b..8d9e362b 100644 --- a/docs/guide/writing.md +++ b/docs/guide/writing.md @@ -41,7 +41,7 @@ await using var workbook = XlsxWorkbookWriter.Create(stream, options: new XlsxWr ## Hidden sheets -Every `IWorkbookWriter` takes an optional `ExcelSheetVisibility` alongside the sheet name, written to whatever the format uses for it — XLSX's `state` attribute, XLSB's `BrtBundleSh.hsState`, XLS's `BoundSheet8.hsState` — so it reads back through [`IExcelSheet.Visibility`](reading.md#open-by-auto-detecting-the-format): +Every [`IWorkbookWriter`](xref:ExcelReader.Core.Writer.IWorkbookWriter%601) takes an optional [`ExcelSheetVisibility`](xref:ExcelReader.Core.ExcelSheetVisibility) alongside the sheet name, written to whatever the format uses for it — XLSX's `state` attribute, XLSB's `BrtBundleSh.hsState`, XLS's `BoundSheet8.hsState` — so it reads back through [`IExcelSheet.Visibility`](reading.md#open-by-auto-detecting-the-format): ```csharp using ExcelReader.Core; // ExcelSheetVisibility, shared by the readers and writers @@ -55,7 +55,7 @@ A workbook whose sheets are *all* hidden is rejected when it is ended (`InvalidO ## Cell styles on write -Every `IWorkbookWriter` supports column- and row-level styling: a number format (currency, date, percentage), bold, and italic. Register a `CellStyle` once with `AddStyle` and apply its returned index to a column (before the first row) or to a whole row (when starting it): +Every `IWorkbookWriter` supports column- and row-level styling: a number format (currency, date, percentage), bold, and italic. Register a [`CellStyle`](xref:ExcelReader.Core.Writer.CellStyle) once with `AddStyle` and apply its returned index to a column (before the first row) or to a whole row (when starting it): ```csharp using ExcelReader.Core.Writer; @@ -90,7 +90,7 @@ Cell-level styling (one specific cell rather than a whole column or row) is out ## Read and write XLSB workbooks (BIFF12) -Use `Excel.FromXlsbFile`, `Excel.FromXlsb`, `Excel.FromXlsbFileAsync`, or `Excel.FromXlsbAsync` to open XLSB directly. For writing, use `XlsbWorkbookWriter`, `XlsbSheetWriter`, and `XlsbRowWriter`. +Use [`Excel.FromXlsbFile`](xref:ExcelReader.Core.Reader.Excel.FromXlsbFile*), [`Excel.FromXlsb`](xref:ExcelReader.Core.Reader.Excel.FromXlsb*), [`Excel.FromXlsbFileAsync`](xref:ExcelReader.Core.Reader.Excel.FromXlsbFileAsync*), or [`Excel.FromXlsbAsync`](xref:ExcelReader.Core.Reader.Excel.FromXlsbAsync*) to open XLSB directly. For writing, use [`XlsbWorkbookWriter`](xref:ExcelReader.Core.Writer.Xlsb.XlsbWorkbookWriter), [`XlsbSheetWriter`](xref:ExcelReader.Core.Writer.Xlsb.XlsbSheetWriter), and [`XlsbRowWriter`](xref:ExcelReader.Core.Writer.Xlsb.XlsbRowWriter). ```csharp using ExcelReader.Core.Writer; @@ -112,11 +112,11 @@ await using (XlsbSheetWriter sheet = workbook.AddSheet("Summary")) await workbook.EndAsync(); ``` -The XLSB writer also defaults to inline string cells. Set `XlsbWriterOptions.UseSharedStrings` to deduplicate repeated text into `sharedStrings.bin`. +The XLSB writer also defaults to inline string cells. Set [`XlsbWriterOptions.UseSharedStrings`](xref:ExcelReader.Core.Writer.Xlsb.XlsbWriterOptions.UseSharedStrings) to deduplicate repeated text into `sharedStrings.bin`. ## Write XLS workbooks (BIFF8) -`XlsWorkbookWriter` emits a binary BIFF8 `.xls` file. The sheet and row APIs are synchronous; only the final `EndAsync` (which assembles and flushes the OLE container) is async. BIFF8 is capped at 65,536 rows × 256 columns per sheet. +[`XlsWorkbookWriter`](xref:ExcelReader.Core.Writer.Xls.XlsWorkbookWriter) emits a binary BIFF8 `.xls` file. The sheet and row APIs are synchronous; only the final `EndAsync` (which assembles and flushes the OLE container) is async. BIFF8 is capped at 65,536 rows × 256 columns per sheet. ```csharp using ExcelReader.Core.Writer; @@ -147,7 +147,7 @@ await workbook.EndAsync(); ## Write typed records -The low-level writers above give you cell-by-cell control. When you just want to dump a collection of objects to a sheet, `sheet.WriteRecordsAsync(records, layout)` writes a header row followed by one row per record, with the columns coming from an `ExcelRecordLayout`. `ExcelRecordLayout.FromAttributes()` maps each public readable property to a column by reflection. It is an extension on `ISheetWriter`, so the same call works on a sheet from any of the writers above — XLSX, XLSB, XLS, and CSV. +The low-level writers above give you cell-by-cell control. When you just want to dump a collection of objects to a sheet, `sheet.WriteRecordsAsync(records, layout)` writes a header row followed by one row per record, with the columns coming from an [`ExcelRecordLayout`](xref:ExcelReader.Core.Writer.ExcelRecordLayout%601). `ExcelRecordLayout.FromAttributes()` maps each public readable property to a column by reflection. It is an extension on [`ISheetWriter`](xref:ExcelReader.Core.Writer.ISheetWriter%601), so the same call works on a sheet from any of the writers above — XLSX, XLSB, XLS, and CSV. ```csharp using ExcelReader.Core.Writer; @@ -175,13 +175,13 @@ await using (var sheet = workbook.AddSheet("Sales")) } ``` -Disposing the sheet ends it; add the next sheet after that (a duplicate name throws, ignoring case), so one workbook can hold sheets of different record types. The sheet stays open after `WriteRecordsAsync`, so you can append more rows before disposing it. `CsvWorkbookWriter` is the exception: a CSV file is a single sheet, so a second `AddSheet` throws (the sheet name is ignored). An `IAsyncEnumerable` overload streams records that are produced asynchronously. The written file round-trips straight back through `ExcelParser` because the headers are the property names. +Disposing the sheet ends it; add the next sheet after that (a duplicate name throws, ignoring case), so one workbook can hold sheets of different record types. The sheet stays open after `WriteRecordsAsync`, so you can append more rows before disposing it. [`CsvWorkbookWriter`](xref:ExcelReader.Core.Writer.Csv.CsvWorkbookWriter) is the exception: a CSV file is a single sheet, so a second `AddSheet` throws (the sheet name is ignored). An `IAsyncEnumerable` overload streams records that are produced asynchronously. The written file round-trips straight back through [`ExcelParser`](xref:ExcelReader.Core.Parser.ExcelParser%601) because the headers are the property names. Column behavior mirrors the parser attributes: - **`[ExcelColumn("Header")]`** — use a custom header instead of the property name (the first alias wins). - **`[ExcelIgnore]`** — exclude a property from both writing and parsing (for computed/transient members). -- **`[ExcelConverter(typeof(MyConverter))]`** — if the converter also implements `IExcelCellWriter`, it controls how the value is written, so a custom type round-trips through the same converter it reads with. +- **`[ExcelConverter(typeof(MyConverter))]`** — if the converter also implements [`IExcelCellWriter`](xref:ExcelReader.Core.Writer.IExcelCellWriter%601), it controls how the value is written, so a custom type round-trips through the same converter it reads with. `DateTime` and `DateOnly` are written as Excel date serials; `TimeOnly` as a time-of-day fraction. Numeric properties (including `Half`) become number cells; any other type is written as text formatted with the invariant culture — `DateTimeOffset` as ISO 8601 (`"O"`), enums by name — so the file parses back the same whatever the writing machine's culture. `Int128`/`UInt128` stay text because a number cell is a double and would drop their low digits. (`CsvWorkbookWriter` follows the CSV rules instead — see [Write CSV](csv.md#write-csv) — writing `DateTime`/`DateOnly` as ISO text and `TimeOnly` as a time-of-day fraction, all still round-tripping through `ExcelParser`.) @@ -189,10 +189,10 @@ For a model marked `[ExcelSerializable]`, pass `ExcelRecordLayout.Generated() ## Write an Excel table -The XLSX and XLSB sheet writers implement `ITableSheetWriter`. `BeginTable` writes the header row +The XLSX and XLSB sheet writers implement [`ITableSheetWriter`](xref:ExcelReader.Core.Writer.ITableSheetWriter%601). `BeginTable` writes the header row and opens a table there; every row written until `EndTable` (or the end of the sheet) belongs to it. The file opens in Excel already formatted as a table, with filter buttons and the chosen built-in style, and -reads back through `IExcelWorkbook.Tables`. +reads back through [`IExcelWorkbook.Tables`](xref:ExcelReader.Core.Reader.IExcelWorkbook.Tables). ```csharp using XlsxWorkbookWriter workbook = XlsxWorkbookWriter.Create(File.Create("report.xlsx")); @@ -214,7 +214,7 @@ For records, `WriteTableAsync` does all three steps with the same layouts as `Wr await sheet.WriteTableAsync(sales, "Sales", ExcelRecordLayout.FromAttributes()); ``` -- `ExcelTableOptions` sets `FirstColumn`, `StyleName` (a built-in `TableStyleLight1`–`21`, +- [`ExcelTableOptions`](xref:ExcelReader.Core.Writer.ExcelTableOptions) sets `FirstColumn`, `StyleName` (a built-in `TableStyleLight1`–`21`, `TableStyleMedium1`–`28` or `TableStyleDark1`–`11`, or `null` for none; default `TableStyleMedium2`) and the banding flags `ShowRowStripes`, `ShowColumnStripes`, `ShowFirstColumn`, `ShowLastColumn`. - With `FirstColumn > 0`, rows you write yourself must `Skip(FirstColumn)` to reach the table; @@ -246,7 +246,7 @@ foreach (var record in records) await sheet.EndAsync(); ``` -`XlsbWriterOptions` has the same property. XLS and CSV are uncompressed, so +[`XlsbWriterOptions`](xref:ExcelReader.Core.Writer.Xlsb.XlsbWriterOptions) has the same property. XLS and CSV are uncompressed, so they have nothing to overlap and do not offer it. Writing a 50,000-row workbook (`WriteBenchmark`, both figures from one run): From c957491cc036bc212aeb513d9115ea2b55b913ab Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Mon, 12 Oct 2026 20:00:36 -0300 Subject: [PATCH 110/115] Add snippets for CSV reading, writing, and encryption examples - Introduced new snippets for reading and writing CSV files, including options for custom delimiters and encoding. - Added examples for handling encrypted Excel files, including opening, verifying integrity, and writing encrypted workbooks. - Created a project file for snippets to ensure they compile against the current API. - Implemented global using directives for cleaner snippet code. - Added comprehensive examples for parsing Excel files with attributes, fluent interfaces, and converters. - Included parallel processing examples for reading large Excel files efficiently. - Enhanced documentation with detailed snippets for various writing scenarios, including styles, shared strings, and table writing. - Developed a synchronization script to keep snippets in sync with documentation. --- .github/workflows/ci.yml | 1 + .github/workflows/docs.yml | 4 + CONTRIBUTING.md | 8 + ExcelReader.slnx | 3 + docs/guide/csv.md | 7 + docs/guide/encryption.md | 4 + docs/guide/parsing.md | 15 +- docs/guide/reading.md | 21 +- docs/guide/writing.md | 24 +- docs/snippets/Csv.cs | 158 +++++++++ docs/snippets/Encryption.cs | 78 +++++ docs/snippets/ExcelReader.Snippets.csproj | 20 ++ docs/snippets/GlobalUsings.cs | 17 + docs/snippets/Parsing.cs | 291 ++++++++++++++++ docs/snippets/Reading.cs | 384 ++++++++++++++++++++++ docs/snippets/Writing.cs | 280 ++++++++++++++++ docs/snippets/sync.py | 64 ++++ 17 files changed, 1368 insertions(+), 11 deletions(-) create mode 100644 docs/snippets/Csv.cs create mode 100644 docs/snippets/Encryption.cs create mode 100644 docs/snippets/ExcelReader.Snippets.csproj create mode 100644 docs/snippets/GlobalUsings.cs create mode 100644 docs/snippets/Parsing.cs create mode 100644 docs/snippets/Reading.cs create mode 100644 docs/snippets/Writing.cs create mode 100644 docs/snippets/sync.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c994a4e..7953c3bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,7 @@ jobs: dotnet: - 'src/**' - 'tests/**' + - 'docs/snippets/**' - 'Directory.Build.props' - 'global.json' - 'ExcelReader.slnx' diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 16476ba7..d03d0e1d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -38,6 +38,10 @@ jobs: with: global-json-file: global.json + # The snippets themselves are compiled by ci.yml, which builds the whole solution. + - name: Check guide examples match docs/snippets + run: python3 docs/snippets/sync.py --check + # Importing excelreader loads the native library lazily, so this needs only the sources. - name: Build Python API reference run: python3 python/scripts/api_markdown.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 888cc58c..82fea814 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,6 +32,14 @@ rewrites — see [ARCHITECTURE.md](ARCHITECTURE.md) for the shape of the codebas [STYLEGUIDE.md § Untrusted Input](STYLEGUIDE.md#untrusted-input) before touching a parser: every length, offset, and size read from the file must be bounded before it drives an allocation. +- **Guide examples compile.** A C# block in `docs/guide` marked `` is copied + from `#region name` in `docs/snippets`, which builds with the solution. Edit the region, then + rewrite the guides; CI fails if they differ: + + ```bash + python docs/snippets/sync.py + ``` + ## Commit messages Use [Conventional Commits](https://www.conventionalcommits.org/): `type(scope): summary`, imperative diff --git a/ExcelReader.slnx b/ExcelReader.slnx index cd54752f..fc0e09fe 100644 --- a/ExcelReader.slnx +++ b/ExcelReader.slnx @@ -13,4 +13,7 @@ + + + diff --git a/docs/guide/csv.md b/docs/guide/csv.md index 54b0dd49..f62ca67d 100644 --- a/docs/guide/csv.md +++ b/docs/guide/csv.md @@ -7,6 +7,7 @@ Parallel CSV parsing is covered in [reading.md](reading.md#parallel-reading-opt- [`CsvReader`](xref:ExcelReader.Core.Reader.Csv.CsvReader) is a workbook with exactly one, unnamed sheet (`csv.FirstSheet`, the same sheet as `csv.Sheets[0]`). It streams RFC 4180 CSV (quoted fields, embedded delimiters/newlines, `""`-escaped quotes) through the same [`Row`](xref:ExcelReader.Core.Reader.Row)/[`Cell`](xref:ExcelReader.Core.Reader.Cell) model as the Excel workbooks, so [`ExcelParser`](xref:ExcelReader.Core.Parser.ExcelParser%601) works on it unchanged. + ```csharp using ExcelReader.Core.Parser; using ExcelReader.Core.Reader; @@ -27,6 +28,7 @@ foreach (var item in ExcelParser.FromAttributes().Parse(csv.FirstShee [`Excel.FromCsv`](xref:ExcelReader.Core.Reader.Excel.FromCsv*)/`FromCsvFile`/`FromCsvAsync`/`FromCsvFileAsync` mirror the other formats' factory shape and take the same [`ExcelReaderOptions`](xref:ExcelReader.Core.Reader.ExcelReaderOptions). Its `Csv` property, a [`CsvReaderOptions`](xref:ExcelReader.Core.Reader.Csv.CsvReaderOptions), changes the delimiter/quote character, supplies a non-UTF-8 `Encoding` (transcoded to UTF-8 internally), or turns off BOM detection; `MaxCellBytes` and `InternStrings` stay on `ExcelReaderOptions`, as for every format: + ```csharp var options = new ExcelReaderOptions { Csv = new CsvReaderOptions { Delimiter = (byte)';' } }; using var csv = Excel.FromCsvFile("relatorio.csv", options); @@ -38,6 +40,7 @@ A `CsvReader` over memory, a file path or a seekable stream allows several enume [`Excel.Open`](xref:ExcelReader.Core.Reader.Excel.Open*)/`OpenAsync` do **not** auto-detect CSV — plain text has no magic-byte signature to sniff. Name the format instead: every `Open`/`OpenAsync` overload takes an optional [`ExcelFileFormat`](xref:ExcelReader.Core.Reader.ExcelFileFormat), and [`ExcelReaderOptions.Csv`](xref:ExcelReader.Core.Reader.ExcelReaderOptions.Csv) carries the dialect, so a caller that decides the format from an extension or a content-type header configures both families through one options object: + ```csharp ExcelFileFormat format = Path.GetExtension(path) is ".csv" ? ExcelFileFormat.Csv : ExcelFileFormat.Unknown; @@ -54,6 +57,7 @@ using IExcelWorkbook workbook = Excel.Open(path, format, new ExcelReaderOptions [`CsvSniffer.Detect`](xref:ExcelReader.Core.Reader.Csv.CsvSniffer.Detect*) infers the delimiter, quote character, and encoding (from a leading byte-order mark) from a sample of bytes, so a `;`-separated pt-BR export or a TSV can be read without the caller knowing the dialect up front. It is deterministic (ties break by candidate order) and never throws on arbitrary input — an indecisive sample returns [`CsvDialect.Default`](xref:ExcelReader.Core.Reader.Csv.CsvDialect.Default) (comma, `"`, UTF-8). + ```csharp using ExcelReader.Core.Reader; using ExcelReader.Core.Reader.Csv; @@ -66,6 +70,7 @@ using var csv = Excel.FromCsvFile("export.csv", new ExcelReaderOptions { Csv = C [`CsvReaderOptions.SniffDialect`](xref:ExcelReader.Core.Reader.Csv.CsvReaderOptions.SniffDialect) does the sample-then-apply above for you, at the entry point that owns the source — the `Excel.FromCsv*` factories, and `Excel.Open` with [`ExcelFileFormat.Csv`](xref:ExcelReader.Core.Reader.ExcelFileFormat.Csv). The dialect of a parallel read is the one the CSV workbook was opened with: + ```csharp using var csv = Excel.FromCsvFile("export.csv", new ExcelReaderOptions { Csv = CsvReaderOptions.Default with { SniffDialect = true } }); ``` @@ -76,6 +81,7 @@ An explicitly set `Encoding` survives sniffing unless the source carries a byte- [`CsvWorkbookWriter`](xref:ExcelReader.Core.Writer.Csv.CsvWorkbookWriter) emits RFC 4180 CSV: no styles or shared strings, so rows stream straight to the output. A CSV file is a single sheet, so `AddSheet` is called once (the name is ignored). + ```csharp using ExcelReader.Core.Writer; using ExcelReader.Core.Writer.Csv; @@ -103,6 +109,7 @@ Fields are quoted only when they contain the delimiter, quote character, `\r`, o [`CsvWriterOptions`](xref:ExcelReader.Core.Writer.Csv.CsvWriterOptions) mirrors `CsvReaderOptions`'s delimiter, quote and encoding, so a file written with one set of settings reads back with the matching set: + ```csharp using var workbook = CsvWorkbookWriter.Create(stream, leaveOpen: false, new CsvWriterOptions { diff --git a/docs/guide/encryption.md b/docs/guide/encryption.md index 18e38baa..ad0cf358 100644 --- a/docs/guide/encryption.md +++ b/docs/guide/encryption.md @@ -7,6 +7,7 @@ Reading and writing password-protected packages. Password-protected `.xlsx`/`.xlsb`/`.xlsm` files open through the same entry points — the password goes on the options, so every overload supports it: + ```csharp var options = new ExcelReaderOptions { Password = "hunter2" }; using IExcelWorkbook workbook = Excel.Open("protected.xlsx", options); @@ -16,6 +17,7 @@ foreach (Row row in workbook.FirstSheet) { /* ... */ } [`ExcelEncryptionException.Reason`](xref:ExcelReader.Core.Reader.ExcelEncryptionException.Reason) tells you what to do about a failure — only `PasswordRequired` and `PasswordIncorrect` are worth re-prompting for: + ```csharp try { @@ -44,6 +46,7 @@ fails loudly on corrupt ciphertext (the plaintext stops being a valid ZIP), but by someone who can modify the file is not detected. Set it to `true` whenever the workbook comes from somewhere you do not control and you care that it was not altered: + ```csharp var options = new ExcelReaderOptions { @@ -64,6 +67,7 @@ Writing an encrypted workbook is a second step: build the package with any write [`Excel.EncryptPackage`](xref:ExcelReader.Core.Reader.Excel.EncryptPackage*), which produces an agile-encrypted (ECMA-376 4.4) CFB container — AES-256-CBC, SHA-512, 100,000 spin iterations, with a `dataIntegrity` HMAC. + ```csharp using ExcelReader.Core.Reader; using ExcelReader.Core.Writer; diff --git a/docs/guide/parsing.md b/docs/guide/parsing.md index 5a1179e5..badfac10 100644 --- a/docs/guide/parsing.md +++ b/docs/guide/parsing.md @@ -7,6 +7,7 @@ generator, the fluent API, converters, and the zero-copy `ref struct` path. `ExcelParser.FromAttributes()` maps worksheet columns to the public settable properties of `T`. Columns match on the property name, or on `[ExcelColumn("header")]` aliases — repeat the attribute to accept several headers. The first row is the header by default. + ```csharp using ExcelReader.Core.Parser; using ExcelReader.Core.Reader; @@ -33,6 +34,7 @@ Built-in property types: `string`, `bool`, `DateTime`, `DateOnly`, `TimeOnly`, ` `Parse` also accepts the [`IExcelSheet`](xref:ExcelReader.Core.Reader.IExcelSheet) of the [`IExcelWorkbook`](xref:ExcelReader.Core.Reader.IExcelWorkbook) from [`Excel.Open`](xref:ExcelReader.Core.Reader.Excel.Open*), so you can parse without knowing the concrete format: + ```csharp using IExcelWorkbook workbook = Excel.Open("changes.xlsx"); // or .xlsb / .xls foreach (var item in ExcelParser.FromAttributes().Parse(workbook.FirstSheet)) { /* ... */ } @@ -42,6 +44,7 @@ foreach (var item in ExcelParser.FromAttributes().Parse(workbook.Firs [`ExcelParser.FromAttributes`](xref:ExcelReader.Core.Parser.ExcelParser.FromAttributes*) and [`ExcelRecordLayout.FromAttributes`](xref:ExcelReader.Core.Writer.ExcelRecordLayout.FromAttributes*) reflect over `T` (`GetProperties`, `MakeGenericMethod`), which trimming can break and Native AOT cannot run at all. The raw `Excel.From*` readers already use no reflection; mark a model `[ExcelSerializable]` to get the same guarantee for the typed layer — a source generator emits a compile-time map from the model's own `[ExcelColumn]`/`[ExcelRequired]`/`[ExcelConverter]`/`[ExcelIgnore]` attributes, and [`ExcelParser.Generated`](xref:ExcelReader.Core.Parser.ExcelParser.Generated*)/[`ExcelRecordLayout.Generated`](xref:ExcelReader.Core.Writer.ExcelRecordLayout.Generated*) read and write through that map instead of reflection: + ```csharp using ExcelReader.Core.Parser; using ExcelReader.Core.Reader; @@ -66,8 +69,8 @@ foreach (var item in ExcelParser.Generated().Parse(workbook.FirstShee var changes = new[] { new ChangeRow { File = "README.md", LinesAdded = 12 } }; await using var stream = File.Create("changes.xlsx"); -await using var workbook = XlsxWorkbookWriter.Create(stream); // or XlsbWorkbookWriter / XlsWorkbookWriter / CsvWorkbookWriter -await using (var sheet = workbook.AddSheet("Changes")) +await using var writer = XlsxWorkbookWriter.Create(stream); // or XlsbWorkbookWriter / XlsWorkbookWriter / CsvWorkbookWriter +await using (var sheet = writer.AddSheet("Changes")) { await sheet.WriteRecordsAsync(changes, ExcelRecordLayout.Generated()); } @@ -85,6 +88,7 @@ Notes: `[ExcelColumn]`/`[ExcelRequired]` fix the mapping at compile time. When the mapping itself is a runtime decision — loaded from a config file, chosen by a user in a UI, or different per input file — build it with [`ExcelRowMapBuilder`](xref:ExcelReader.Core.Parser.ExcelRowMapBuilder%601) through `ExcelParser.Build` instead: + ```csharp using ExcelReader.Core.Parser; using ExcelReader.Core.Reader; @@ -111,6 +115,7 @@ The map is built once, when `Build` is called, from a fresh builder instance — Bind a fixed column index instead of a header name with `PropertyAt` — for files with no header row at all: + ```csharp var parser = ExcelParser.Build(builder => builder .Factory(() => new ChangeRow()) @@ -122,6 +127,7 @@ A builder that uses `PropertyAt` skips the header-row step entirely — the firs [`ExcelParser.BuildWithAttributeFallback`](xref:ExcelReader.Core.Parser.ExcelParser.BuildWithAttributeFallback*) merges the builder's bindings with `[ExcelColumn]`/`[ExcelRequired]`-driven ones reflected from `T`: a builder binding replaces every attribute-driven property that shares one of its header names; a property whose header names none of the builder's bindings mention keeps its attribute-driven behavior. Useful for overriding just the one column that's a runtime decision without redeclaring the whole model — reuse one of the property's existing `[ExcelColumn]` names in the builder: + ```csharp var parser = ExcelParser.BuildWithAttributeFallback(builder => builder .Property(["file"], ExcelCellReaders.String, (ref ChangeRow r, string v) => r.File = v.ToUpperInvariant())); @@ -135,6 +141,7 @@ The match is by header name, not by property identity: configuring a *different* Pass an [`ExcelParserOptions`](xref:ExcelReader.Core.Parser.ExcelParserOptions) to control header handling and culture: + ```csharp using System.Globalization; using ExcelReader.Core.Parser; @@ -156,6 +163,7 @@ var parser = ExcelParser.FromAttributes(options); Mark a property `[ExcelRequired]` to assert its column exists and carries a value: + ```csharp public sealed class Order { @@ -175,6 +183,7 @@ public sealed class Order For types the built-in parsers do not handle — money strings, custom formats, domain value objects — implement [`IExcelCellConverter`](xref:ExcelReader.Core.Parser.IExcelCellConverter%601) and attach it with `[ExcelConverter]`. `T` must be the property's exact type. One instance is created and reused across all rows, so converters must be stateless. + ```csharp using System.Globalization; using ExcelReader.Core.Parser; @@ -202,6 +211,7 @@ Return `false` to signal a parse failure (the property keeps its default). Empty `ExcelParser.FromAttributes` takes a `ref struct` model just as it takes a class or a struct — same attribute-driven column matching (`[ExcelColumn]`, `[ExcelRequired]`, `[ExcelConverter]`), but a `ReadOnlySpan` property binds directly to the cell's raw bytes instead of allocating a `string`: + ```csharp using System.Text; using ExcelReader.Core.Parser; @@ -223,6 +233,7 @@ foreach (ChangeRowRef item in ExcelParser.FromAttributes().Parse(w The result of `Parse` supports `await foreach` (add `.WithCancellation(ct)` to pass a token), so a `ref struct` model can be parsed asynchronously — the rows are streamed via `MoveNextAsync` while the model stays a zero-copy `ref struct`: + ```csharp await using var workbook = await Excel.FromXlsxFileAsync("changes.xlsx"); diff --git a/docs/guide/reading.md b/docs/guide/reading.md index 0a45720e..dcacb011 100644 --- a/docs/guide/reading.md +++ b/docs/guide/reading.md @@ -5,6 +5,7 @@ See also [parsing.md](parsing.md) for binding rows to types, and [csv.md](csv.md ## Read rows + ```csharp using ExcelReader.Core.Reader; @@ -32,6 +33,7 @@ An opened workbook ([`XlsxWorkbook`](xref:ExcelReader.Core.Reader.Xlsx.XlsxWorkb [`SheetParallel`](xref:ExcelReader.Core.Reader.SheetParallel) reads one sheet on several threads. It takes any sheet, so it works the same on XLSX, XLSB and CSV: + ```csharp using XlsxWorkbook workbook = Excel.FromXlsxFile("big.xlsx"); @@ -57,11 +59,12 @@ Both of the next two examples read a `ref struct` model, so text columns stay as A fold over an XLSX sheet, with one accumulator per partition merged in sheet order: + ```csharp public ref struct SaleRow { [ExcelColumn("Region")] public ReadOnlySpan Region { get; set; } - [ExcelColumn("Units")] public int Units { get; set; } + [ExcelColumn("Units")] public int Units { get; set; } } public sealed class UnitTotal : IRowAccumulator @@ -85,6 +88,7 @@ The same call takes a [`RowAggregation`](xref:ExcelReader.Core.Reader.Ro A per-row callback: + ```csharp using XlsxWorkbook workbook = Excel.FromXlsxFile("big.xlsx"); @@ -133,6 +137,7 @@ Properties need setters. A get-only property is skipped during binding and silen dotnet add package ExcelReader.Arrow ``` + ```csharp using ExcelReader.Arrow; using ExcelReader.Core.Reader; @@ -145,6 +150,7 @@ The schema defaults to [`Excel.InferSchema`](xref:ExcelReader.Core.Reader.Excel. `WriteRecordBatch`/`WriteRecordBatchAsync` are the write-side mirror — one call from a `RecordBatch` to a sheet, for XLSX, XLSB, XLS, and CSV: + ```csharp using ExcelReader.Arrow; using ExcelReader.Core.Writer; @@ -160,6 +166,7 @@ Supports string, large string, string view, every integer width (signed and unsi [`Excel.Open`](xref:ExcelReader.Core.Reader.Excel.Open*) picks the workbook type from the file signature (XLSX/XLSB are ZIP packages, XLS is an OLE2 document) and returns an [`IExcelWorkbook`](xref:ExcelReader.Core.Reader.IExcelWorkbook). `FirstSheet` gives the first sheet, and a sheet iterates its rows directly, so no pattern-match is needed for basic row iteration. + ```csharp using ExcelReader.Core.Reader; @@ -173,6 +180,7 @@ foreach (var row in workbook.FirstSheet) `SheetCount`, `SheetAt(index)` and `TryGetSheet(name)` are on `IExcelWorkbook` itself, so you can walk every sheet without knowing the format. Each [`IExcelSheet`](xref:ExcelReader.Core.Reader.IExcelSheet) carries its own `Index`, `Name` and `Visibility`: + ```csharp using IExcelWorkbook workbook = Excel.Open("report.xlsx"); @@ -189,6 +197,7 @@ for (int i = 0; i < workbook.SheetCount; i++) `TryGetSheet` matches a name ignoring case and opens nothing: + ```csharp if (workbook.TryGetSheet("Totals", out var totals)) { @@ -203,6 +212,7 @@ When you open a format directly ([`Excel.FromXlsxFile`](xref:ExcelReader.Core.Re `Visibility` reports whether a sheet is shown in the workbook's tab bar, read from the format's own encoding of it (XLSX's `state` attribute, XLSB's `BrtBundleSh.hsState`, XLS's `BoundSheet8.hsState`). It is reported, never enforced — a hidden sheet enumerates its rows like any other, so converters and exporters filter on it themselves: + ```csharp using ExcelReader.Core; // ExcelSheetVisibility, shared by the readers and writers @@ -225,6 +235,7 @@ CSV is exposed as a single, unnamed sheet (`SheetCount == 1`, `Name == ""`, alwa Detection covers the signed formats only. To open a source whose format you already know — including CSV, which has no signature to detect — pass an [`ExcelFileFormat`](xref:ExcelReader.Core.Reader.ExcelFileFormat) and let [`ExcelReaderOptions.Csv`](xref:ExcelReader.Core.Reader.ExcelReaderOptions.Csv) carry the dialect; see [CSV](csv.md#read-csv). + ```csharp using IExcelWorkbook workbook = Excel.Open("report.csv", ExcelFileFormat.Csv); ``` @@ -236,11 +247,12 @@ and creation, and found by name with `TryGetTable`, ignoring case. XLSX/XLSM and and CSV always report none. `AsSheet()` reads only the table: its header row first, then its data rows without the totals row, each cut to the table's columns, so it plugs into anything that takes a sheet. + ```csharp using IExcelWorkbook workbook = Excel.Open("sales.xlsx"); if (workbook.TryGetTable("Sales", out ExcelTable? sales)) { - foreach (SaleRow row in ExcelParser.FromAttributes().Parse(sales.AsSheet())) + foreach (ProductRow row in ExcelParser.FromAttributes().Parse(sales.AsSheet())) { Console.WriteLine($"{row.Product}: {row.Qty}"); } @@ -263,6 +275,7 @@ if (workbook.TryGetTable("Sales", out ExcelTable? sales)) This is one thread per sheet. To read a single sheet on several threads, see [Parallel reading](#parallel-reading-opt-in). Each sheet of a workbook can be read on its own thread: + ```csharp using XlsxWorkbook workbook = Excel.FromXlsxFile(path); @@ -288,6 +301,7 @@ Disposing the workbook closes it to new sheets and enumerators; its file or stre Every sheet supports `await foreach`. For XLSX files, the async enumerator buffers one row at a time and uses the same row parser as the synchronous enumerator, so sync and async reads stay behaviorally aligned while awaits happen only when more bytes are needed. + ```csharp using ExcelReader.Core.Reader; @@ -303,6 +317,7 @@ await foreach (var row in workbook.FirstSheet) `await foreach` does not accept `.WithCancellation(ct)`: `Row` being a `ref struct` rules out `IAsyncEnumerable`, so the loop binds to the pattern rather than the interface. To pass a token, or to `await` while a row is in scope, drive the enumerator manually: + ```csharp await using var workbook = await Excel.FromXlsxFileAsync("report.xlsx", ct: cancellationToken); await using var rows = workbook.FirstSheet.GetAsyncEnumerator(cancellationToken); @@ -324,6 +339,7 @@ two: a background thread inflates ahead while the calling thread parses. It is * defaults to `false`**, and only affects XLSX/XLSB — XLS and CSV have nothing to decompress, so the option is silently ignored for them. + ```csharp var options = new ExcelReaderOptions { PrefetchDecompression = true }; using var workbook = Excel.FromXlsxFile("report.xlsx", options); @@ -361,6 +377,7 @@ Writing has the same option, under a different name: see `SheetDataReader` adapts one `IExcelSheet` to `System.Data.IDataReader`, so it drops straight into `SqlBulkCopy`, `DataTable.Load`, Dapper, or any other ADO.NET consumer: + ```csharp using System.Data; using ExcelReader.Core.Reader; diff --git a/docs/guide/writing.md b/docs/guide/writing.md index 8d9e362b..650c0236 100644 --- a/docs/guide/writing.md +++ b/docs/guide/writing.md @@ -5,6 +5,7 @@ typed records, and prefetch compression. See [csv.md](csv.md) for CSV output. ## Write XLSX workbooks + ```csharp using ExcelReader.Core.Writer; using ExcelReader.Core.Writer.Xlsx; @@ -35,6 +36,7 @@ await workbook.EndAsync(); By default, the XLSX writer emits inline strings to keep memory usage flat while rows stream out. If your workbook repeats many strings and smaller files matter more than the extra lookup table, opt in to shared strings: + ```csharp await using var workbook = XlsxWorkbookWriter.Create(stream, options: new XlsxWriterOptions { UseSharedStrings = true }); ``` @@ -43,12 +45,13 @@ await using var workbook = XlsxWorkbookWriter.Create(stream, options: new XlsxWr Every [`IWorkbookWriter`](xref:ExcelReader.Core.Writer.IWorkbookWriter%601) takes an optional [`ExcelSheetVisibility`](xref:ExcelReader.Core.ExcelSheetVisibility) alongside the sheet name, written to whatever the format uses for it — XLSX's `state` attribute, XLSB's `BrtBundleSh.hsState`, XLS's `BoundSheet8.hsState` — so it reads back through [`IExcelSheet.Visibility`](reading.md#open-by-auto-detecting-the-format): + ```csharp using ExcelReader.Core; // ExcelSheetVisibility, shared by the readers and writers -await using var data = workbook.AddSheet("Data"); // visible -await using var lookups = workbook.AddSheet("Lookups", ExcelSheetVisibility.Hidden); -await using var audit = workbook.AddSheet("Audit", ExcelSheetVisibility.VeryHidden); // not in Excel's unhide dialog +await using (var data = workbook.AddSheet("Data")) { /* rows */ } // visible +await using (var lookups = workbook.AddSheet("Lookups", ExcelSheetVisibility.Hidden)) { /* rows */ } +await using (var audit = workbook.AddSheet("Audit", ExcelSheetVisibility.VeryHidden)) { /* rows */ } // not in Excel's unhide dialog ``` A workbook whose sheets are *all* hidden is rejected when it is ended (`InvalidOperationException`): the formats allow it, but Excel reports such a file as damaged, so it is caught at write time rather than shipped to a user. CSV accepts the argument and ignores it — delimited text has no tab bar to hide from. @@ -57,6 +60,7 @@ A workbook whose sheets are *all* hidden is rejected when it is ended (`InvalidO Every `IWorkbookWriter` supports column- and row-level styling: a number format (currency, date, percentage), bold, and italic. Register a [`CellStyle`](xref:ExcelReader.Core.Writer.CellStyle) once with `AddStyle` and apply its returned index to a column (before the first row) or to a whole row (when starting it): + ```csharp using ExcelReader.Core.Writer; using ExcelReader.Core.Writer.Xlsx; @@ -92,6 +96,7 @@ Cell-level styling (one specific cell rather than a whole column or row) is out Use [`Excel.FromXlsbFile`](xref:ExcelReader.Core.Reader.Excel.FromXlsbFile*), [`Excel.FromXlsb`](xref:ExcelReader.Core.Reader.Excel.FromXlsb*), [`Excel.FromXlsbFileAsync`](xref:ExcelReader.Core.Reader.Excel.FromXlsbFileAsync*), or [`Excel.FromXlsbAsync`](xref:ExcelReader.Core.Reader.Excel.FromXlsbAsync*) to open XLSB directly. For writing, use [`XlsbWorkbookWriter`](xref:ExcelReader.Core.Writer.Xlsb.XlsbWorkbookWriter), [`XlsbSheetWriter`](xref:ExcelReader.Core.Writer.Xlsb.XlsbSheetWriter), and [`XlsbRowWriter`](xref:ExcelReader.Core.Writer.Xlsb.XlsbRowWriter). + ```csharp using ExcelReader.Core.Writer; using ExcelReader.Core.Writer.Xlsb; @@ -118,6 +123,7 @@ The XLSB writer also defaults to inline string cells. Set [`XlsbWriterOptions.Us [`XlsWorkbookWriter`](xref:ExcelReader.Core.Writer.Xls.XlsWorkbookWriter) emits a binary BIFF8 `.xls` file. The sheet and row APIs are synchronous; only the final `EndAsync` (which assembles and flushes the OLE container) is async. BIFF8 is capped at 65,536 rows × 256 columns per sheet. + ```csharp using ExcelReader.Core.Writer; using ExcelReader.Core.Writer.Xls; @@ -149,6 +155,7 @@ await workbook.EndAsync(); The low-level writers above give you cell-by-cell control. When you just want to dump a collection of objects to a sheet, `sheet.WriteRecordsAsync(records, layout)` writes a header row followed by one row per record, with the columns coming from an [`ExcelRecordLayout`](xref:ExcelReader.Core.Writer.ExcelRecordLayout%601). `ExcelRecordLayout.FromAttributes()` maps each public readable property to a column by reflection. It is an extension on [`ISheetWriter`](xref:ExcelReader.Core.Writer.ISheetWriter%601), so the same call works on a sheet from any of the writers above — XLSX, XLSB, XLS, and CSV. + ```csharp using ExcelReader.Core.Writer; using ExcelReader.Core.Writer.Xlsx; @@ -194,22 +201,24 @@ and opens a table there; every row written until `EndTable` (or the end of the s file opens in Excel already formatted as a table, with filter buttons and the chosen built-in style, and reads back through [`IExcelWorkbook.Tables`](xref:ExcelReader.Core.Reader.IExcelWorkbook.Tables). + ```csharp using XlsxWorkbookWriter workbook = XlsxWorkbookWriter.Create(File.Create("report.xlsx")); XlsxSheetWriter sheet = workbook.AddSheet("Report"); -sheet.BeginTable("Sales", ["Product", "Qty", "Price"]); +sheet.BeginTable("Sales", ["Region", "Units", "Revenue"]); foreach (Sale s in sales) { using XlsxRowWriter row = sheet.StartRow(); - row.Write(s.Product); - row.Write(s.Qty); - row.Write(s.Price); + row.Write(s.Region); + row.Write(s.Units); + row.Write(s.Revenue); } sheet.EndTable(); ``` For records, `WriteTableAsync` does all three steps with the same layouts as `WriteRecordsAsync`: + ```csharp await sheet.WriteTableAsync(sales, "Sales", ExcelRecordLayout.FromAttributes()); ``` @@ -232,6 +241,7 @@ and by default that happens on the calling thread. Set `PrefetchWrite` on the wr onto a background thread, so the caller keeps building the next batch of rows while the previous one compresses: + ```csharp await using var wb = XlsxWorkbookWriter.Create(stream, leaveOpen: true, options: new XlsxWriterOptions { PrefetchWrite = true }); XlsxSheetWriter sheet = wb.AddSheet("S1"); diff --git a/docs/snippets/Csv.cs b/docs/snippets/Csv.cs new file mode 100644 index 00000000..73013dc5 --- /dev/null +++ b/docs/snippets/Csv.cs @@ -0,0 +1,158 @@ +namespace Snippets.Csv +{ + internal sealed class ChangeRow + { + [ExcelColumn("file")] + public string File { get; set; } = ""; + + [ExcelColumn("lines_added")] + public int LinesAdded { get; set; } + } +} + +namespace Snippets.Csv.Read +{ + #region csv-read + using ExcelReader.Core.Parser; + using ExcelReader.Core.Reader; + #endregion + + internal static class Snippet + { + internal static void Run() + { + #region csv-read + using var csv = Excel.FromCsvFile("report.csv"); + + foreach (var row in csv.FirstSheet) + { + Console.WriteLine(row[0].GetString()); + } + + // Typed parsing works exactly like the Excel workbooks: + foreach (var item in ExcelParser.FromAttributes().Parse(csv.FirstSheet)) + { + Console.WriteLine($"{item.File}: +{item.LinesAdded}"); + } + #endregion + } + } +} + +namespace Snippets.Csv.ReadOptions +{ + internal static class Snippet + { + internal static void Run() + { + #region csv-read-options + var options = new ExcelReaderOptions { Csv = new CsvReaderOptions { Delimiter = (byte)';' } }; + using var csv = Excel.FromCsvFile("relatorio.csv", options); + #endregion + } + } +} + +namespace Snippets.Csv.OpenByFormat +{ + internal static class Snippet + { + internal static void Run(string path, string secret) + { + #region csv-open-by-format + ExcelFileFormat format = Path.GetExtension(path) is ".csv" ? ExcelFileFormat.Csv : ExcelFileFormat.Unknown; + + using IExcelWorkbook workbook = Excel.Open(path, format, new ExcelReaderOptions + { + Password = secret, // used by the Excel formats + Csv = CsvReaderOptions.Default with { SniffDialect = true }, // used by the CSV format + }); + #endregion + } + } +} + +namespace Snippets.Csv.Sniff +{ + #region csv-sniff + using ExcelReader.Core.Reader; + using ExcelReader.Core.Reader.Csv; + #endregion + + internal static class Snippet + { + internal static void Run() + { + #region csv-sniff + CsvDialect dialect = CsvSniffer.DetectFile("export.csv"); + using var csv = Excel.FromCsvFile("export.csv", new ExcelReaderOptions { Csv = CsvReaderOptions.Default.WithDialect(dialect) }); + #endregion + } + } +} + +namespace Snippets.Csv.SniffOnOpen +{ + internal static class Snippet + { + internal static void Run() + { + #region csv-sniff-on-open + using var csv = Excel.FromCsvFile("export.csv", new ExcelReaderOptions { Csv = CsvReaderOptions.Default with { SniffDialect = true } }); + #endregion + } + } +} + +namespace Snippets.Csv.Write +{ + #region csv-write + using ExcelReader.Core.Writer; + using ExcelReader.Core.Writer.Csv; + #endregion + + internal static class Snippet + { + internal static void Run() + { + #region csv-write + using var stream = File.Create("out.csv"); + using var workbook = CsvWorkbookWriter.Create(stream); + using var writer = workbook.AddSheet("Sheet1"); + + using (CsvRowWriter row = writer.StartRow()) + { + row.Write("Name"); + row.Write("Total"); + row.Write("Created"); + } + + using (CsvRowWriter row = writer.StartRow()) + { + row.Write("Q1"); + row.Write(42); + row.Write(DateTime.UtcNow); + } + #endregion + } + } +} + +namespace Snippets.Csv.WriteOptions +{ + internal static class Snippet + { + internal static void Run(Stream stream) + { + #region csv-write-options + using var workbook = CsvWorkbookWriter.Create(stream, leaveOpen: false, new CsvWriterOptions + { + Delimiter = (byte)';', + Encoding = Encoding.Latin1, // fields are formatted as UTF-8, then transcoded on the way out + WriteByteOrderMark = true, // what makes Excel open a UTF-8 file as UTF-8 + NewLine = CsvNewLine.LineFeed, // default is CRLF, as RFC 4180 specifies + }); + #endregion + } + } +} diff --git a/docs/snippets/Encryption.cs b/docs/snippets/Encryption.cs new file mode 100644 index 00000000..8d7c2fb8 --- /dev/null +++ b/docs/snippets/Encryption.cs @@ -0,0 +1,78 @@ +namespace Snippets.Encryption.Open +{ + internal static class Snippet + { + internal static void Run() + { + #region encryption-open + var options = new ExcelReaderOptions { Password = "hunter2" }; + using IExcelWorkbook workbook = Excel.Open("protected.xlsx", options); + foreach (Row row in workbook.FirstSheet) { /* ... */ } + #endregion + } + } +} + +namespace Snippets.Encryption.Reason +{ + internal static class Snippet + { + internal static void Run(string path, ExcelReaderOptions options) + { + #region encryption-reason + try + { + using IExcelWorkbook workbook = Excel.Open(path, options); + } + catch (ExcelEncryptionException ex) when (ex.Reason is ExcelEncryptionReason.PasswordIncorrect) + { + // Ask again. UnsupportedScheme and IntegrityFailure are terminal. + } + #endregion + } + } +} + +namespace Snippets.Encryption.VerifyIntegrity +{ + internal static class Snippet + { + internal static void Run() + { + #region encryption-verify-integrity + var options = new ExcelReaderOptions + { + Password = "hunter2", + VerifyEncryptedIntegrity = true, // pay one full pass; reject tampered packages + }; + #endregion + } + } +} + +namespace Snippets.Encryption.Write +{ + #region encryption-write + using ExcelReader.Core.Reader; + using ExcelReader.Core.Writer; + using ExcelReader.Core.Writer.Xlsx; + #endregion + + internal static class Snippet + { + internal static async Task Run() + { + #region encryption-write + using var plain = new MemoryStream(); + await using (var workbook = XlsxWorkbookWriter.Create(plain, leaveOpen: true)) + { + // write sheets and rows as usual + } + plain.Position = 0; + + using var destination = File.Create("secret.xlsx"); + await Excel.EncryptPackageAsync(plain, destination, "hunter2"); + #endregion + } + } +} diff --git a/docs/snippets/ExcelReader.Snippets.csproj b/docs/snippets/ExcelReader.Snippets.csproj new file mode 100644 index 00000000..306075ec --- /dev/null +++ b/docs/snippets/ExcelReader.Snippets.csproj @@ -0,0 +1,20 @@ + + + + + net10.0 + false + + false + + + + + + + + + diff --git a/docs/snippets/GlobalUsings.cs b/docs/snippets/GlobalUsings.cs new file mode 100644 index 00000000..2b80db1b --- /dev/null +++ b/docs/snippets/GlobalUsings.cs @@ -0,0 +1,17 @@ +// Most guide blocks are fragments that leave their using directives out; the ones that show them +// still compile against these, so a missing directive in a block is not caught here. +global using ExcelReader.Arrow; +global using ExcelReader.Core; +global using ExcelReader.Core.Parser; +global using ExcelReader.Core.Reader; +global using ExcelReader.Core.Reader.Csv; +global using ExcelReader.Core.Reader.Schema; +global using ExcelReader.Core.Reader.Xls; +global using ExcelReader.Core.Reader.Xlsb; +global using ExcelReader.Core.Reader.Xlsx; +global using ExcelReader.Core.Writer; +global using ExcelReader.Core.Writer.Csv; +global using ExcelReader.Core.Writer.Xls; +global using ExcelReader.Core.Writer.Xlsb; +global using ExcelReader.Core.Writer.Xlsx; +global using System.Text; diff --git a/docs/snippets/Parsing.cs b/docs/snippets/Parsing.cs new file mode 100644 index 00000000..72dc9112 --- /dev/null +++ b/docs/snippets/Parsing.cs @@ -0,0 +1,291 @@ +namespace Snippets.Parsing +{ + internal sealed class ChangeRow + { + [ExcelColumn("file")] + public string File { get; set; } = ""; + + [ExcelColumn("lines_added")] + public int LinesAdded { get; set; } + } + + internal readonly ref struct ChangeRowRef + { + public ReadOnlySpan File { get; init; } + public int LinesAdded { get; init; } + } +} + +namespace Snippets.Parsing.Attributes +{ + #region parsing-attributes + using ExcelReader.Core.Parser; + using ExcelReader.Core.Reader; + #endregion + + #region parsing-attributes + public sealed class ChangeRow + { + [ExcelColumn("file")] + public string File { get; set; } = ""; + + [ExcelColumn("lines_added")] + public int LinesAdded { get; set; } + } + #endregion + + internal static class Snippet + { + internal static void Run() + { + #region parsing-attributes + using var workbook = Excel.FromXlsxFile("changes.xlsx"); + var parser = ExcelParser.FromAttributes(); + + foreach (var item in parser.Parse(workbook.FirstSheet)) + { + Console.WriteLine($"{item.File}: +{item.LinesAdded}"); + } + #endregion + } + } +} + +namespace Snippets.Parsing.AttributesOpen +{ + internal static class Snippet + { + internal static void Run() + { + #region parsing-attributes-open + using IExcelWorkbook workbook = Excel.Open("changes.xlsx"); // or .xlsb / .xls + foreach (var item in ExcelParser.FromAttributes().Parse(workbook.FirstSheet)) { /* ... */ } + #endregion + } + } +} + +namespace Snippets.Parsing.Generated +{ + #region parsing-generated + using ExcelReader.Core.Parser; + using ExcelReader.Core.Reader; + using ExcelReader.Core.Writer; + using ExcelReader.Core.Writer.Xlsx; + #endregion + + #region parsing-generated + [ExcelSerializable] // the model must be declared partial + public partial class ChangeRow + { + [ExcelColumn("file")] + public string File { get; set; } = ""; + + [ExcelColumn("lines_added")] + public int LinesAdded { get; set; } + } + #endregion + + internal static class Snippet + { + internal static async Task Run() + { + #region parsing-generated + using var workbook = Excel.FromXlsxFile("changes.xlsx"); + foreach (var item in ExcelParser.Generated().Parse(workbook.FirstSheet)) + { + Console.WriteLine($"{item.File}: +{item.LinesAdded}"); + } + + var changes = new[] { new ChangeRow { File = "README.md", LinesAdded = 12 } }; + await using var stream = File.Create("changes.xlsx"); + await using var writer = XlsxWorkbookWriter.Create(stream); // or XlsbWorkbookWriter / XlsWorkbookWriter / CsvWorkbookWriter + await using (var sheet = writer.AddSheet("Changes")) + { + await sheet.WriteRecordsAsync(changes, ExcelRecordLayout.Generated()); + } + #endregion + } + } +} + +namespace Snippets.Parsing.Fluent +{ + #region parsing-fluent + using ExcelReader.Core.Parser; + using ExcelReader.Core.Reader; + #endregion + + #region parsing-fluent + public sealed class ChangeRow + { + public string File { get; set; } = ""; + public int LinesAdded { get; set; } + } + #endregion + + internal static class Snippet + { + internal static void Run() + { + #region parsing-fluent + var parser = ExcelParser.Build(builder => builder + .Factory(() => new ChangeRow()) + .Property(["file"], ExcelCellReaders.String, (ref ChangeRow r, string v) => r.File = v) + .Property(["lines_added"], ExcelCellReaders.Parsable, (ref ChangeRow r, int v) => r.LinesAdded = v)); + + using var workbook = Excel.FromXlsxFile("changes.xlsx"); + foreach (var item in parser.Parse(workbook.FirstSheet)) + { + Console.WriteLine($"{item.File}: +{item.LinesAdded}"); + } + #endregion + } + } +} + +namespace Snippets.Parsing.FluentIndex +{ + internal static class Snippet + { + internal static void Run() + { + #region parsing-fluent-index + var parser = ExcelParser.Build(builder => builder + .Factory(() => new ChangeRow()) + .PropertyAt(0, ExcelCellReaders.String, (ref ChangeRow r, string v) => r.File = v) + .PropertyAt(1, ExcelCellReaders.Parsable, (ref ChangeRow r, int v) => r.LinesAdded = v)); + #endregion + } + } +} + +namespace Snippets.Parsing.FluentFallback +{ + internal static class Snippet + { + internal static void Run() + { + #region parsing-fluent-fallback + var parser = ExcelParser.BuildWithAttributeFallback(builder => builder + .Property(["file"], ExcelCellReaders.String, (ref ChangeRow r, string v) => r.File = v.ToUpperInvariant())); + #endregion + } + } +} + +namespace Snippets.Parsing.Options +{ + #region parsing-options + using System.Globalization; + using ExcelReader.Core.Parser; + #endregion + + internal static class Snippet + { + internal static void Run() + { + #region parsing-options + var options = new ExcelParserOptions + { + HeaderRow = 1, // 1-based row holding the headers + ColumnNameComparer = StringComparer.OrdinalIgnoreCase, + HeaderNormalization = HeaderNormalization.Trim | HeaderNormalization.CollapseSpaces, + Culture = CultureInfo.GetCultureInfo("pt-BR"), // parse "1.234,56" as 1234.56m + }; + + var parser = ExcelParser.FromAttributes(options); + #endregion + } + } +} + +namespace Snippets.Parsing.Required +{ + #region parsing-required + public sealed class Order + { + [ExcelRequired] + public int Id { get; set; } + + [ExcelRequired(AllowEmpty = true)] // column must exist; blank cells allowed + public string? Note { get; set; } + } + #endregion +} + +namespace Snippets.Parsing.Converter +{ + #region parsing-converter + using System.Globalization; + using ExcelReader.Core.Parser; + using ExcelReader.Core.Reader; + #endregion + + #region parsing-converter + public sealed class BrlMoneyConverter : IExcelCellConverter + { + public bool TryConvert(in Cell cell, in ExcelRowContext context, out decimal value) + { + string text = cell.GetString().Replace("R$", "", StringComparison.Ordinal).Trim(); + return decimal.TryParse(text, NumberStyles.Currency, CultureInfo.GetCultureInfo("pt-BR"), out value); + } + } + + public sealed class Invoice + { + [ExcelConverter(typeof(BrlMoneyConverter))] + public decimal Total { get; set; } + } + #endregion +} + +namespace Snippets.Parsing.RefStruct +{ + #region parsing-ref-struct + using System.Text; + using ExcelReader.Core.Parser; + using ExcelReader.Core.Reader; + #endregion + + #region parsing-ref-struct + public readonly ref struct ChangeRowRef + { + public ReadOnlySpan File { get; init; } // zero-copy — aliases the reader's row buffer + public int LinesAdded { get; init; } + } + #endregion + + internal static class Snippet + { + internal static void Run() + { + #region parsing-ref-struct + using var workbook = Excel.FromXlsxFile("changes.xlsx"); + + foreach (ChangeRowRef item in ExcelParser.FromAttributes().Parse(workbook.FirstSheet)) + { + Console.WriteLine($"{Encoding.UTF8.GetString(item.File)}: +{item.LinesAdded}"); + } + #endregion + } + } +} + +namespace Snippets.Parsing.RefStructAsync +{ + internal static class Snippet + { + internal static async Task Run() + { + #region parsing-ref-struct-async + await using var workbook = await Excel.FromXlsxFileAsync("changes.xlsx"); + + await foreach (ChangeRowRef item in ExcelParser.FromAttributes().Parse(workbook.FirstSheet)) + { + Console.WriteLine($"{Encoding.UTF8.GetString(item.File)}: +{item.LinesAdded}"); + } + #endregion + } + } +} diff --git a/docs/snippets/Reading.cs b/docs/snippets/Reading.cs new file mode 100644 index 00000000..fd91e128 --- /dev/null +++ b/docs/snippets/Reading.cs @@ -0,0 +1,384 @@ +namespace Snippets.Reading.ReadRows +{ + #region reading-read-rows + using ExcelReader.Core.Reader; + #endregion + + internal static class Snippet + { + internal static void Run() + { + #region reading-read-rows + using var workbook = Excel.FromXlsxFile("report.xlsx"); + + foreach (var row in workbook.FirstSheet) + { + string name = row[0].GetString(); + + if (row[1].TryParse(null, out int quantity)) + { + Console.WriteLine($"{name}: {quantity}"); + } + + if (row[2].Type == CellType.Date && row[2].TryGetDateTime(workbook.IsDate1904, out var date)) + { + Console.WriteLine(date); + } + } + #endregion + } + } +} + +namespace Snippets.Reading.ParallelParse +{ + internal sealed class SalesRow + { + public decimal Revenue { get; set; } + } + + internal static class Snippet + { + private static decimal Total; + + internal static async Task Run() + { + #region reading-parallel-parse + using XlsxWorkbook workbook = Excel.FromXlsxFile("big.xlsx"); + + await foreach (var row in SheetParallel.ParseAsync(workbook.FirstSheet, ExcelParser.FromAttributes(), new SheetParallelOptions { DegreeOfParallelism = 8 })) + { + Total += row.Revenue; + } + #endregion + } + } +} + +namespace Snippets.Reading.ParallelAggregate +{ + #region reading-parallel-aggregate + public ref struct SaleRow + { + [ExcelColumn("Region")] public ReadOnlySpan Region { get; set; } + [ExcelColumn("Units")] public int Units { get; set; } + } + + public sealed class UnitTotal : IRowAccumulator + { + public long Units; + + public void Add(SaleRow model) => Units += model.Units; + + public void Merge(UnitTotal following) => Units += following.Units; + } + #endregion + + internal static class Snippet + { + internal static async Task Run() + { + #region reading-parallel-aggregate + using XlsxWorkbook workbook = Excel.FromXlsxFile("big.xlsx"); + + UnitTotal total = await SheetParallel.AggregateAsync( + workbook.FirstSheet, + ExcelParser.FromAttributes(), + new SheetParallelOptions { HeaderRow = 1 }); + #endregion + } + } +} + +namespace Snippets.Reading.ParallelForeach +{ + using Snippets.Reading.ParallelAggregate; + + internal static class Snippet + { + internal static async Task Run() + { + #region reading-parallel-foreach + using XlsxWorkbook workbook = Excel.FromXlsxFile("big.xlsx"); + + long total = 0; + await SheetParallel.ForEachAsync( + workbook.FirstSheet, + ExcelParser.FromAttributes(), + row => Interlocked.Add(ref total, row.Units), + new SheetParallelOptions { DegreeOfParallelism = 8, HeaderRow = 1 }); + #endregion + } + } +} + +namespace Snippets.Reading.ArrowRead +{ + #region reading-arrow-read + using ExcelReader.Arrow; + using ExcelReader.Core.Reader; + #endregion + + internal static class Snippet + { + internal static void Run() + { + #region reading-arrow-read + using var workbook = Excel.FromXlsxFile("report.xlsx"); + Apache.Arrow.RecordBatch batch = workbook.FirstSheet.ToArrowRecordBatch(); + #endregion + } + } +} + +namespace Snippets.Reading.ArrowWrite +{ + #region reading-arrow-write + using ExcelReader.Arrow; + using ExcelReader.Core.Writer; + using ExcelReader.Core.Writer.Xlsx; + #endregion + + internal static class Snippet + { + internal static void Run(Apache.Arrow.RecordBatch batch) + { + #region reading-arrow-write + using XlsxWorkbookWriter workbook = XlsxWorkbookWriter.Create(File.Create("report.xlsx")); + workbook.WriteRecordBatch(batch); // adds the sheet, writes the header + every row, ends the workbook + #endregion + } + } +} + +namespace Snippets.Reading.Open +{ + #region reading-open + using ExcelReader.Core.Reader; + #endregion + + internal static class Snippet + { + internal static void Run() + { + #region reading-open + using IExcelWorkbook workbook = Excel.Open("report.xlsx"); // or report.xlsb / report.xls + + foreach (var row in workbook.FirstSheet) + { + Console.WriteLine(row[0].GetString()); + } + #endregion + } + } +} + +namespace Snippets.Reading.OpenEverySheet +{ + internal static class Snippet + { + internal static void Run() + { + #region reading-open-every-sheet + using IExcelWorkbook workbook = Excel.Open("report.xlsx"); + + for (int i = 0; i < workbook.SheetCount; i++) + { + IExcelSheet sheet = workbook.SheetAt(i); + Console.WriteLine(sheet.Name); + foreach (var row in sheet) + { + Console.WriteLine(row[0].GetString()); + } + } + #endregion + } + } +} + +namespace Snippets.Reading.OpenTryGetSheet +{ + internal static class Snippet + { + internal static void Run(IExcelWorkbook workbook) + { + #region reading-open-try-get-sheet + if (workbook.TryGetSheet("Totals", out var totals)) + { + foreach (var row in totals) + { + Console.WriteLine(row[0].GetString()); + } + } + #endregion + } + } +} + +namespace Snippets.Reading.OpenVisibility +{ + #region reading-open-visibility + using ExcelReader.Core; // ExcelSheetVisibility, shared by the readers and writers + #endregion + + internal static class Snippet + { + internal static void Run(IExcelWorkbook workbook) + { + #region reading-open-visibility + for (int i = 0; i < workbook.SheetCount; i++) + { + IExcelSheet sheet = workbook.SheetAt(i); + if (sheet.Visibility != ExcelSheetVisibility.Visible) + { + continue; // skip hidden and veryHidden sheets + } + Console.WriteLine(sheet.Name); + } + #endregion + } + } +} + +namespace Snippets.Reading.OpenCsv +{ + internal static class Snippet + { + internal static void Run() + { + #region reading-open-csv + using IExcelWorkbook workbook = Excel.Open("report.csv", ExcelFileFormat.Csv); + #endregion + } + } +} + +namespace Snippets.Reading.Table +{ + internal sealed class ProductRow + { + public string Product { get; set; } = ""; + public int Qty { get; set; } + } + + internal static class Snippet + { + internal static void Run() + { + #region reading-table + using IExcelWorkbook workbook = Excel.Open("sales.xlsx"); + if (workbook.TryGetTable("Sales", out ExcelTable? sales)) + { + foreach (ProductRow row in ExcelParser.FromAttributes().Parse(sales.AsSheet())) + { + Console.WriteLine($"{row.Product}: {row.Qty}"); + } + } + #endregion + } + } +} + +namespace Snippets.Reading.SheetsInParallel +{ + internal static class Snippet + { + internal static void Run(string path) + { + #region reading-sheets-in-parallel + using XlsxWorkbook workbook = Excel.FromXlsxFile(path); + + Parallel.For(0, workbook.Sheets.Count, i => + { + foreach (Row row in workbook.Sheets[i]) + { + // consume + } + }); + #endregion + } + } +} + +namespace Snippets.Reading.Async +{ + #region reading-async + using ExcelReader.Core.Reader; + #endregion + + internal static class Snippet + { + internal static async Task Run(CancellationToken cancellationToken) + { + #region reading-async + await using var workbook = await Excel.FromXlsxFileAsync("report.xlsx", ct: cancellationToken); + + await foreach (var row in workbook.FirstSheet) + { + Console.WriteLine(row[0].GetString()); + } + #endregion + } + } +} + +namespace Snippets.Reading.AsyncEnumerator +{ + internal static class Snippet + { + internal static async Task Run(CancellationToken cancellationToken) + { + #region reading-async-enumerator + await using var workbook = await Excel.FromXlsxFileAsync("report.xlsx", ct: cancellationToken); + await using var rows = workbook.FirstSheet.GetAsyncEnumerator(cancellationToken); + + while (await rows.MoveNextAsync()) + { + var row = rows.Current; + Console.WriteLine(row[0].GetString()); + } + #endregion + } + } +} + +namespace Snippets.Reading.Prefetch +{ + internal static class Snippet + { + internal static void Run() + { + #region reading-prefetch + var options = new ExcelReaderOptions { PrefetchDecompression = true }; + using var workbook = Excel.FromXlsxFile("report.xlsx", options); + + foreach (var row in workbook.FirstSheet) + { + Console.WriteLine(row[0].GetString()); + } + #endregion + } + } +} + +namespace Snippets.Reading.DataReader +{ + #region reading-data-reader + using System.Data; + using ExcelReader.Core.Reader; + #endregion + + internal static class Snippet + { + internal static void Run() + { + #region reading-data-reader + using IExcelWorkbook workbook = Excel.Open("report.xlsx"); + using IDataReader data = new SheetDataReader(workbook.FirstSheet); // header on row 1 by default + + var table = new DataTable(); + table.Load(data); + #endregion + } + } +} diff --git a/docs/snippets/Writing.cs b/docs/snippets/Writing.cs new file mode 100644 index 00000000..c69772fe --- /dev/null +++ b/docs/snippets/Writing.cs @@ -0,0 +1,280 @@ +namespace Snippets.Writing.Xlsx +{ + #region writing-xlsx + using ExcelReader.Core.Writer; + using ExcelReader.Core.Writer.Xlsx; + #endregion + + internal static class Snippet + { + internal static async Task Run() + { + #region writing-xlsx + await using var stream = File.Create("out.xlsx"); + await using var workbook = XlsxWorkbookWriter.Create(stream); + + await using (var sheet = workbook.AddSheet("Summary")) + { + await using (var row = await sheet.StartRowAsync()) + { + row.Write("Name"); + row.Write("Total"); + row.Write("Created"); + } + + await using (var row = await sheet.StartRowAsync()) + { + row.Write("Q1"); + row.Write(42); + row.Write(DateTime.UtcNow); + } + } + + await workbook.EndAsync(); + #endregion + } + } +} + +namespace Snippets.Writing.XlsxSharedStrings +{ + internal static class Snippet + { + internal static async Task Run(Stream stream) + { + #region writing-xlsx-shared-strings + await using var workbook = XlsxWorkbookWriter.Create(stream, options: new XlsxWriterOptions { UseSharedStrings = true }); + #endregion + } + } +} + +namespace Snippets.Writing.HiddenSheets +{ + #region writing-hidden-sheets + using ExcelReader.Core; // ExcelSheetVisibility, shared by the readers and writers + #endregion + + internal static class Snippet + { + internal static async Task Run(XlsxWorkbookWriter workbook) + { + #region writing-hidden-sheets + await using (var data = workbook.AddSheet("Data")) { /* rows */ } // visible + await using (var lookups = workbook.AddSheet("Lookups", ExcelSheetVisibility.Hidden)) { /* rows */ } + await using (var audit = workbook.AddSheet("Audit", ExcelSheetVisibility.VeryHidden)) { /* rows */ } // not in Excel's unhide dialog + #endregion + } + } +} + +namespace Snippets.Writing.Styles +{ + #region writing-styles + using ExcelReader.Core.Writer; + using ExcelReader.Core.Writer.Xlsx; + #endregion + + internal static class Snippet + { + internal static async Task Run(Stream stream) + { + #region writing-styles + await using var workbook = XlsxWorkbookWriter.Create(stream); + + int currency = workbook.AddStyle(new CellStyle { NumberFormat = "R$ #,##0.00" }); + int header = workbook.AddStyle(new CellStyle { Bold = true }); + + await using var sheet = workbook.AddSheet("Summary"); + sheet.SetColumnStyle(columnIndex: 1, currency); // before the first row + sheet.SetColumnWidth(columnIndex: 0, width: 20); + + await using (var row = await sheet.StartRowAsync(header)) + { + row.Write("Product"); + row.Write("Total"); + } + await using (var row = await sheet.StartRowAsync()) + { + row.Write("Widget"); + row.Write(1234.5); + } + + await workbook.EndAsync(); + #endregion + } + } +} + +namespace Snippets.Writing.Xlsb +{ + #region writing-xlsb + using ExcelReader.Core.Writer; + using ExcelReader.Core.Writer.Xlsb; + #endregion + + internal static class Snippet + { + internal static async Task Run() + { + #region writing-xlsb + await using var stream = File.Create("out.xlsb"); + await using var workbook = XlsbWorkbookWriter.Create(stream); + + await using (XlsbSheetWriter sheet = workbook.AddSheet("Summary")) + { + await using (XlsbRowWriter row = await sheet.StartRowAsync()) + { + row.Write("Name"); + row.Write("Total"); + row.Write("Created"); + } + } + + await workbook.EndAsync(); + #endregion + } + } +} + +namespace Snippets.Writing.Xls +{ + #region writing-xls + using ExcelReader.Core.Writer; + using ExcelReader.Core.Writer.Xls; + #endregion + + internal static class Snippet + { + internal static async Task Run() + { + #region writing-xls + await using var stream = File.Create("out.xls"); + await using var workbook = XlsWorkbookWriter.Create(stream); + + using (var sheet = workbook.AddSheet("Summary")) + { + using (var row = sheet.StartRow()) + { + row.Write("Name"); + row.Write("Total"); + row.Write("Created"); + } + + using (var row = sheet.StartRow()) + { + row.Write("Q1"); + row.Write(42); + row.Write(DateTime.UtcNow); + } + } + + await workbook.EndAsync(); + #endregion + } + } +} + +namespace Snippets.Writing.Records +{ + #region writing-records + using ExcelReader.Core.Writer; + using ExcelReader.Core.Writer.Xlsx; + #endregion + + #region writing-records + public sealed class Sale + { + public string? Region { get; set; } + public int Units { get; set; } + public decimal Revenue { get; set; } + public DateOnly Date { get; set; } + } + #endregion + + internal static class Snippet + { + internal static async Task Run() + { + #region writing-records + var sales = new[] + { + new Sale { Region = "North", Units = 42, Revenue = 1234.50m, Date = new DateOnly(2026, 1, 2) }, + new Sale { Region = "South", Units = 17, Revenue = 512.00m, Date = new DateOnly(2026, 1, 3) }, + }; + + await using var stream = File.Create("sales.xlsx"); + await using var workbook = XlsxWorkbookWriter.Create(stream); // or XlsbWorkbookWriter / XlsWorkbookWriter / CsvWorkbookWriter + await using (var sheet = workbook.AddSheet("Sales")) + { + await sheet.WriteRecordsAsync(sales, ExcelRecordLayout.FromAttributes()); + } + #endregion + } + } +} + +namespace Snippets.Writing.Table +{ + using Snippets.Writing.Records; + + internal static class Snippet + { + internal static void Run(Sale[] sales) + { + #region writing-table + using XlsxWorkbookWriter workbook = XlsxWorkbookWriter.Create(File.Create("report.xlsx")); + XlsxSheetWriter sheet = workbook.AddSheet("Report"); + sheet.BeginTable("Sales", ["Region", "Units", "Revenue"]); + foreach (Sale s in sales) + { + using XlsxRowWriter row = sheet.StartRow(); + row.Write(s.Region); + row.Write(s.Units); + row.Write(s.Revenue); + } + sheet.EndTable(); + #endregion + } + } +} + +namespace Snippets.Writing.TableRecords +{ + using Snippets.Writing.Records; + + internal static class Snippet + { + internal static async Task Run(XlsxSheetWriter sheet, Sale[] sales) + { + #region writing-table-records + await sheet.WriteTableAsync(sales, "Sales", ExcelRecordLayout.FromAttributes()); + #endregion + } + } +} + +namespace Snippets.Writing.Prefetch +{ + internal sealed record Metric(string Name, double Value); + + internal static class Snippet + { + internal static async Task Run(Stream stream, Metric[] records) + { + #region writing-prefetch + await using var wb = XlsxWorkbookWriter.Create(stream, leaveOpen: true, options: new XlsxWriterOptions { PrefetchWrite = true }); + XlsxSheetWriter sheet = wb.AddSheet("S1"); + + foreach (var record in records) + { + using XlsxRowWriter row = sheet.StartRow(); + row.Write(record.Name); + row.Write(record.Value); + } + + await sheet.EndAsync(); + #endregion + } + } +} diff --git a/docs/snippets/sync.py b/docs/snippets/sync.py new file mode 100644 index 00000000..e333a3b8 --- /dev/null +++ b/docs/snippets/sync.py @@ -0,0 +1,64 @@ +"""Copies the #regions of docs/snippets/*.cs into the guide code blocks that name them. + +A block opts in with `` on the line before its fence. Every region of that +name, in file order, is dedented and joined with a blank line, so a snippet can show its using +directives and type declarations above statements that have to live inside a method here. + + python docs/snippets/sync.py rewrite the guides + python docs/snippets/sync.py --check fail if a guide differs from its snippets +""" + +import pathlib +import re +import sys +import textwrap + +ROOT = pathlib.Path(__file__).resolve().parent +GUIDES = ROOT.parent / "guide" +BLOCK = re.compile(r"^\n```csharp\n.*?^```$", re.MULTILINE | re.DOTALL) + + +def regions(): + found = {} + for path in sorted(ROOT.glob("*.cs")): + name, lines = None, [] + for line in path.read_text(encoding="utf-8").splitlines(): + directive = line.strip() + if directive.startswith("#region "): + name, lines = directive.removeprefix("#region ").strip(), [] + elif directive.startswith("#endregion") and name: + found.setdefault(name, []).append(textwrap.dedent("\n".join(lines)).strip("\n")) + name = None + elif name: + lines.append(line) + return {name: "\n\n".join(parts) for name, parts in found.items()} + + +def main(): + check = "--check" in sys.argv[1:] + snippets = regions() + used, stale = set(), [] + for guide in sorted(GUIDES.glob("*.md")): + text = guide.read_text(encoding="utf-8") + + def fill(match): + name = match.group(1) + if name not in snippets: + sys.exit(f"{guide.name}: no #region {name} in docs/snippets") + used.add(name) + return f"\n```csharp\n{snippets[name]}\n```" + + updated = BLOCK.sub(fill, text) + if updated != text: + stale.append(guide.name) + if not check: + guide.write_text(updated, encoding="utf-8", newline="\n") + + unused = sorted(snippets.keys() - used) + if unused: + sys.exit("regions no guide shows: " + ", ".join(unused)) + if check and stale: + sys.exit("out of date with docs/snippets (run python docs/snippets/sync.py): " + ", ".join(stale)) + + +main() From 0fde7132759a834d5e2f60f5e3773549e6971170 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Mon, 12 Oct 2026 20:06:00 -0300 Subject: [PATCH 111/115] ci(docs): build generator project before site generation to resolve analyzer issues --- .github/workflows/docs.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index d03d0e1d..8aeea115 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -53,9 +53,12 @@ jobs: sudo apt-get install --yes --no-install-recommends doxygen python3 cpp/scripts/api_markdown.py - # Fails on a broken page link or an xref naming a member that no longer exists. + # Fails on a broken page link or an xref naming a member that no longer exists. Core loads the + # generator as an analyzer from its bin/Release output, so it is built first, or docfx warns + # FailedToResolveAnalyzer. - name: Build site run: | + dotnet build src/ExcelReader.Generator/ExcelReader.Generator.csproj --configuration Release dotnet tool install --global docfx --version 2.81.0 docfx docfx.json --warningsAsErrors From 53e31e85803e2374ba3d269a3284a11a7aa4a4cd Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Mon, 12 Oct 2026 20:18:56 -0300 Subject: [PATCH 112/115] docs: add guidance for reading untrusted files and implement related snippets --- README.md | 1 + docfx.json | 7 +- docs/404.html | 23 ++++++ docs/README.md | 3 +- docs/guide/untrusted-input.md | 138 ++++++++++++++++++++++++++++++++ docs/snippets/UntrustedInput.cs | 69 ++++++++++++++++ docs/toc.yml | 14 +++- 7 files changed, 249 insertions(+), 6 deletions(-) create mode 100644 docs/404.html create mode 100644 docs/guide/untrusted-input.md create mode 100644 docs/snippets/UntrustedInput.cs diff --git a/README.md b/README.md index 026534bc..1d410915 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ foreach (Row row in workbook.FirstSheet) - [Writing workbooks](docs/guide/writing.md) — XLSX, XLSB (BIFF12) and XLS (BIFF8) writers, cell styles, typed records, and prefetch compression. - [CSV](docs/guide/csv.md) — reading, dialect sniffing, parallel parsing, and writing delimited text. - [Encrypted workbooks](docs/guide/encryption.md) — opening password-protected packages and encrypting written ones. +- [Reading untrusted files](docs/guide/untrusted-input.md) — the resource limits that bound what one hostile file can cost, and how to reject one cleanly. - [Migrating to v7](docs/guide/migrating-to-v7.md) — porting `CsvParallel` code to `SheetParallel`, which now reads XLSX and XLSB on several threads too. - [Migrating to v6](docs/guide/migrating-to-v6.md) — porting v5 reader code to workbooks and sheets, and reading sheets in parallel. - [Benchmarks](docs/performance/benchmarks.md) — throughput and allocation against Sep, Sylvan, OfficeIMO and SpreadCheetah. [Live results](https://gabrielmarquezmatte.github.io/ExcelReader/dev/bench/). diff --git a/docfx.json b/docfx.json index df62eea3..5589a580 100644 --- a/docfx.json +++ b/docfx.json @@ -34,8 +34,13 @@ } ], "resource": [ - { "files": [ "assets/icon.png", "LICENSE", "src/ExcelReader.Native/include/excelreader.h" ] } + { "files": [ "assets/icon.png", "LICENSE", "src/ExcelReader.Native/include/excelreader.h" ] }, + { "files": [ "404.html" ], "src": "docs" } ], + "sitemap": { + "baseUrl": "https://gabrielmarquezmatte.github.io/ExcelReader/", + "changefreq": "weekly" + }, "output": "docs/_site", "template": [ "default", "modern", "docs/template" ], "globalMetadata": { diff --git a/docs/404.html b/docs/404.html new file mode 100644 index 00000000..85ed8539 --- /dev/null +++ b/docs/404.html @@ -0,0 +1,23 @@ + + + + + + + Page not found | ExcelReader + + + +

Page not found

+

This page does not exist, or it moved when the documentation was reorganized.

+
+ + diff --git a/docs/README.md b/docs/README.md index dfb01feb..ce2642d9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -44,4 +44,5 @@ invariant) without restating the evidence for it. Sep, Sylvan, OfficeIMO and SpreadCheetah. - `guide/` — usage documentation: [reading](./guide/reading.md), [parsing](./guide/parsing.md), [writing](./guide/writing.md), [csv](./guide/csv.md), - [encryption](./guide/encryption.md), [errors](./guide/errors.md). + [encryption](./guide/encryption.md), [errors](./guide/errors.md), + [untrusted input](./guide/untrusted-input.md). diff --git a/docs/guide/untrusted-input.md b/docs/guide/untrusted-input.md new file mode 100644 index 00000000..83a94565 --- /dev/null +++ b/docs/guide/untrusted-input.md @@ -0,0 +1,138 @@ +# Reading untrusted files + +What one hostile workbook can cost you, which options bound it, and how to reject a file cleanly. +Read this before you accept spreadsheets from uploads, APIs or mail. + +A spreadsheet is a container whose sizes, counts and offsets are written by whoever made the file. +The readers check every one of those values before it drives an allocation, and the options below +turn what is left — a file that is well-formed but simply enormous — into an +[`ExcelLimitExceededException`](xref:ExcelReader.Core.Reader.ExcelLimitExceededException) instead of an out-of-memory crash. The defaults suit +a desktop tool or a batch job reading files you trust. A server should set its own. + +## The limits + +All of them live on [`ExcelReaderOptions`](xref:ExcelReader.Core.Reader.ExcelReaderOptions): + +| Option | Default | Bounds | Formats | +|---|---:|---|---| +| `MaxTotalDecompressedBytes` | 512 MiB | Every byte the workbook inflates or loads from its container: sheets, shared strings, styles, and the decrypted package | XLSX, XLSB, XLS, encrypted | +| `MaxSharedStringBytes` | 128 MiB | The shared-string table, which is held in memory for the whole read | XLSX, XLSB, XLS | +| `MaxCellBytes` | 32 MiB | Each buffer that grows to hold one cell value, or one CSV record | all | +| `MaxZipEntries` | 65,536 | Entries in the ZIP directory | XLSX, XLSB | +| `MaxPasswordSpinCount` | 100,000 | Key-derivation iterations an agile-encrypted file may ask for | encrypted XLSX, XLSB | + +Two limits are fixed by Excel's grid rather than configured: a cell past column XFD (16,384) throws +with `LimitName == "Columns"`, and no single buffer may exceed .NET's array size +(`LimitName == "ArrayMaxLength"`). + +How they count: + +- **`MaxTotalDecompressedBytes` belongs to the opened workbook, not to one read.** Every sheet you + read, and every re-read of the same sheet, draws from the same budget. A workbook kept open and + read repeatedly will eventually hit it, so open one per job, or size the budget for all the + reads you make. +- **XLS has nothing to inflate, but it is not streamed:** the whole Workbook stream is read into + memory, up to `MaxTotalDecompressedBytes`. +- **CSV has no total limit.** It streams, so the size of what you hand it is the bound; only + `MaxCellBytes` applies. +- **Zero or a negative value turns a byte or entry limit off.** `MaxPasswordSpinCount` has no off + switch. In the Python, Rust and C++ bindings and the C ABI, `0` means the default instead, and a + limit cannot be turned off. +- **The limits are per workbook.** Ten uploads read at once can hold ten times the budget. + +## A profile for uploads + +Size each limit for the largest file you legitimately expect, measured rather than guessed. For a +service taking spreadsheets of a few MB, something like: + + +```csharp +var uploadOptions = new ExcelReaderOptions +{ + MaxTotalDecompressedBytes = 64L * 1024 * 1024, // everything one workbook may inflate + MaxSharedStringBytes = 16L * 1024 * 1024, + MaxCellBytes = 1024 * 1024, + MaxZipEntries = 1_000, +}; +``` + +Cap the upload itself as well, before it reaches the reader: the options bound what the file +expands to, not how many bytes you accept. [`Excel.Open`](xref:ExcelReader.Core.Reader.Excel.Open*) needs a seekable stream, so +buffer a request body to a file or to memory first. + +Leave [`PrefetchDecompression`](xref:ExcelReader.Core.Reader.ExcelReaderOptions.PrefetchDecompression) off: it adds a thread per read, which a busy server +cannot spare. For encrypted uploads, set `VerifyEncryptedIntegrity` so a tampered package is +rejected; see [Encrypted workbooks](encryption.md). + +No option bounds wall-clock time. The work a read does grows with the bytes it inflates, which the +limits bound; for a deadline, use the async entry points and enumerators, which take a +`CancellationToken`. + +## Rejecting a file + +A limit can trip when the workbook opens, when you start reading a sheet (XLSX loads its shared +strings then), or halfway down it. Wrap the whole read, not just the open, and do not commit rows until the read +has finished: + + +```csharp +try +{ + using IExcelWorkbook workbook = Excel.Open(upload, options: uploadOptions); + foreach (Row row in workbook.FirstSheet) + { + // stage the row; commit only once the loop has finished + } +} +catch (ExcelLimitExceededException ex) +{ + Console.WriteLine($"Rejected: {ex.LimitName} reached {ex.Actual}, limit {ex.Limit}"); +} +catch (Exception ex) when (IsRejectedFile(ex)) +{ + Console.WriteLine($"Rejected: {ex.Message}"); +} + +static bool IsRejectedFile(Exception ex) => + ex is InvalidDataException or EndOfStreamException or NotSupportedException + or ExcelEncryptionException or ExcelParseException + || ex.GetType() == typeof(ArgumentException) + || ex.GetType() == typeof(InvalidOperationException); +``` + +`LimitName` is the option's name (or `Columns`, `ArrayMaxLength`); `Limit` and `Actual` are in that +limit's unit — bytes, entries or iterations. + +That exception list is exactly what the fuzz suite accepts as a correct rejection of arbitrary +bytes. Anything else escaping a read of a file — an `IndexOutOfRangeException`, an +`OutOfMemoryException`, a hang — is a bug; please report it privately as described in +[SECURITY.md](../../SECURITY.md). See [Errors across languages](errors.md) for how these map onto the +bindings. + +## What the reader does not do + +- **It evaluates nothing.** A formula cell reports the result Excel cached when it saved the file, + so the value is whatever the file claims; recompute anything you rely on. Macros are never run + and external links never followed. +- **It resolves no XML entities beyond the predefined ones.** Sheet XML goes through the library's + own scanner, which decodes `&`-style and numeric references and copies anything else through + as text, so there is no entity expansion and no external fetch. The one `XmlReader`, for the + encryption descriptor, prohibits DTDs. + +## Writing untrusted text to CSV + +The writers store text as text: in XLSX, XLSB and XLS a string that starts with `=` is still a +string. CSV has no cell types, and Excel opening a `.csv` treats a field starting with `=`, `+`, +`-` or `@` as a formula. [`CsvWorkbookWriter`](xref:ExcelReader.Core.Writer.Csv.CsvWorkbookWriter) writes your text unchanged, so if a person may open the +file in a spreadsheet program, neutralize those fields yourself: + + +```csharp +static string Neutralize(string text) => + text.Length > 0 && "=+-@\t\r".Contains(text[0]) ? "'" + text : text; + +row.Write(Neutralize(comment)); +``` + +The leading `'` changes the data, so do this only for files meant for people, not for files another +program parses back. diff --git a/docs/snippets/UntrustedInput.cs b/docs/snippets/UntrustedInput.cs new file mode 100644 index 00000000..c894b537 --- /dev/null +++ b/docs/snippets/UntrustedInput.cs @@ -0,0 +1,69 @@ +namespace Snippets.UntrustedInput.Options +{ + internal static class Snippet + { + internal static ExcelReaderOptions Run() + { + #region untrusted-input-options + var uploadOptions = new ExcelReaderOptions + { + MaxTotalDecompressedBytes = 64L * 1024 * 1024, // everything one workbook may inflate + MaxSharedStringBytes = 16L * 1024 * 1024, + MaxCellBytes = 1024 * 1024, + MaxZipEntries = 1_000, + }; + #endregion + return uploadOptions; + } + } +} + +namespace Snippets.UntrustedInput.Reject +{ + internal static class Snippet + { + internal static void Run(Stream upload, ExcelReaderOptions uploadOptions) + { + #region untrusted-input-reject + try + { + using IExcelWorkbook workbook = Excel.Open(upload, options: uploadOptions); + foreach (Row row in workbook.FirstSheet) + { + // stage the row; commit only once the loop has finished + } + } + catch (ExcelLimitExceededException ex) + { + Console.WriteLine($"Rejected: {ex.LimitName} reached {ex.Actual}, limit {ex.Limit}"); + } + catch (Exception ex) when (IsRejectedFile(ex)) + { + Console.WriteLine($"Rejected: {ex.Message}"); + } + + static bool IsRejectedFile(Exception ex) => + ex is InvalidDataException or EndOfStreamException or NotSupportedException + or ExcelEncryptionException or ExcelParseException + || ex.GetType() == typeof(ArgumentException) + || ex.GetType() == typeof(InvalidOperationException); + #endregion + } + } +} + +namespace Snippets.UntrustedInput.CsvInjection +{ + internal static class Snippet + { + internal static void Run(CsvRowWriter row, string comment) + { + #region untrusted-input-csv-injection + static string Neutralize(string text) => + text.Length > 0 && "=+-@\t\r".Contains(text[0]) ? "'" + text : text; + + row.Write(Neutralize(comment)); + #endregion + } + } +} diff --git a/docs/toc.yml b/docs/toc.yml index b504ac27..d3707a7d 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -12,10 +12,8 @@ href: guide/encryption.md - name: Errors across languages href: guide/errors.md - - name: Migrating to v7 - href: guide/migrating-to-v7.md - - name: Migrating to v6 - href: guide/migrating-to-v6.md + - name: Reading untrusted files + href: guide/untrusted-input.md - name: API href: api/ - name: Bindings @@ -40,3 +38,11 @@ href: performance/reader-performance.md - name: Live results href: https://gabrielmarquezmatte.github.io/ExcelReader/dev/bench/ +- name: Releases + items: + - name: Release notes + href: https://github.com/GabrielMarquezMatte/ExcelReader/releases + - name: Migrating to v7 + href: guide/migrating-to-v7.md + - name: Migrating to v6 + href: guide/migrating-to-v6.md From 405e1956cef288d86447591d31c3f33a4a6143b3 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Mon, 12 Oct 2026 20:26:42 -0300 Subject: [PATCH 113/115] docs: clarify limits for decompressed bytes in untrusted file handling and update related code comments --- docs/guide/untrusted-input.md | 13 +-- .../Reader/ExcelReaderOptions.cs | 12 +-- .../Reader/Xlsb/XlsbWorkbook.Enumerator.cs | 4 +- .../Reader/Xlsb/XlsbWorkbook.cs | 6 +- .../Reader/Xlsx/XlsxWorkbook.Enumerator.cs | 4 +- .../Reader/Xlsx/XlsxWorkbook.cs | 6 +- .../Reader/Zip/LimitedReadStream.cs | 7 ++ .../Reader/ReaderLimitTests.cs | 90 +++++++++++++++++++ 8 files changed, 121 insertions(+), 21 deletions(-) diff --git a/docs/guide/untrusted-input.md b/docs/guide/untrusted-input.md index 83a94565..ce61e4fa 100644 --- a/docs/guide/untrusted-input.md +++ b/docs/guide/untrusted-input.md @@ -15,7 +15,7 @@ All of them live on [`ExcelReaderOptions`](xref:ExcelReader.Core.Reader.ExcelRea | Option | Default | Bounds | Formats | |---|---:|---|---| -| `MaxTotalDecompressedBytes` | 512 MiB | Every byte the workbook inflates or loads from its container: sheets, shared strings, styles, and the decrypted package | XLSX, XLSB, XLS, encrypted | +| `MaxTotalDecompressedBytes` | 512 MiB | What one read of a sheet inflates or loads from its container, counting the workbook's metadata, styles, shared strings and decrypted package | XLSX, XLSB, XLS, encrypted | | `MaxSharedStringBytes` | 128 MiB | The shared-string table, which is held in memory for the whole read | XLSX, XLSB, XLS | | `MaxCellBytes` | 32 MiB | Each buffer that grows to hold one cell value, or one CSV record | all | | `MaxZipEntries` | 65,536 | Entries in the ZIP directory | XLSX, XLSB | @@ -27,10 +27,10 @@ with `LimitName == "Columns"`, and no single buffer may exceed .NET's array size How they count: -- **`MaxTotalDecompressedBytes` belongs to the opened workbook, not to one read.** Every sheet you - read, and every re-read of the same sheet, draws from the same budget. A workbook kept open and - read repeatedly will eventually hit it, so open one per job, or size the budget for all the - reads you make. +- **`MaxTotalDecompressedBytes` is per read of a sheet.** The workbook's one-time loads (its + metadata, styles and shared strings) count toward every read, and each read adds the sheet it + inflates. Reading a sheet again starts from the one-time loads, so a workbook kept open and read + repeatedly never runs out. - **XLS has nothing to inflate, but it is not streamed:** the whole Workbook stream is read into memory, up to `MaxTotalDecompressedBytes`. - **CSV has no total limit.** It streams, so the size of what you hand it is the bound; only @@ -38,7 +38,8 @@ How they count: - **Zero or a negative value turns a byte or entry limit off.** `MaxPasswordSpinCount` has no off switch. In the Python, Rust and C++ bindings and the C ABI, `0` means the default instead, and a limit cannot be turned off. -- **The limits are per workbook.** Ten uploads read at once can hold ten times the budget. +- **Reads running at once each have their own budget.** Ten uploads read together, or several sheets + of one workbook read on different threads, can inflate ten times the budget between them. ## A profile for uploads diff --git a/src/ExcelReader.Core/Reader/ExcelReaderOptions.cs b/src/ExcelReader.Core/Reader/ExcelReaderOptions.cs index 4f2d0285..3e4f5470 100644 --- a/src/ExcelReader.Core/Reader/ExcelReaderOptions.cs +++ b/src/ExcelReader.Core/Reader/ExcelReaderOptions.cs @@ -8,11 +8,13 @@ namespace ExcelReader.Core.Reader ///