From b8e31c2ab56e6af7d5c31e8771c6d3ff5a7132a5 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Mon, 12 Oct 2026 23:16:41 -0300 Subject: [PATCH 01/42] Implement BIFF8 enhancements for XLS writer - Added new BIFF records for improved handling of Excel features, including LabelSst, CalcMode, and Print settings. - Introduced XlsSharedStrings class to manage shared strings efficiently, ensuring long text is stored only once. - Enhanced XlsSheetLayout to correctly structure worksheet substreams, including row blocks and cell data. - Updated XlsWorkbookWriter to integrate shared strings and manage workbook size dynamically. - Added comprehensive conformance tests to validate the structure of generated XLS files against Excel's requirements. --- src/ExcelReader.Core/Writer/Xls/BiffRecord.cs | 22 ++ .../Writer/Xls/BiffRecordWriter.cs | 135 ++++++---- src/ExcelReader.Core/Writer/Xls/XlsGlobals.cs | 19 +- .../Writer/Xls/XlsSharedStrings.cs | 231 ++++++++++++++++++ .../Writer/Xls/XlsSheetLayout.cs | 135 ++++++++++ .../Writer/Xls/XlsSheetWriter.cs | 43 +++- .../Writer/Xls/XlsWorkbookWriter.cs | 110 +++++---- .../Writer/Xls/XlsConformanceTests.cs | 206 ++++++++++++++++ 8 files changed, 801 insertions(+), 100 deletions(-) create mode 100644 src/ExcelReader.Core/Writer/Xls/XlsSharedStrings.cs create mode 100644 src/ExcelReader.Core/Writer/Xls/XlsSheetLayout.cs create mode 100644 tests/ExcelReader.Tests/Writer/Xls/XlsConformanceTests.cs diff --git a/src/ExcelReader.Core/Writer/Xls/BiffRecord.cs b/src/ExcelReader.Core/Writer/Xls/BiffRecord.cs index 53f15481..c2cdc87d 100644 --- a/src/ExcelReader.Core/Writer/Xls/BiffRecord.cs +++ b/src/ExcelReader.Core/Writer/Xls/BiffRecord.cs @@ -30,7 +30,29 @@ internal static class BiffRecord internal const int Blank = 0x0201; internal const int Number = 0x0203; internal const int Label = 0x0204; + internal const int LabelSst = 0x00FD; internal const int BoolErr = 0x0205; + internal const int Index = 0x020B; + internal const int Row = 0x0208; + internal const int DbCell = 0x00D7; + internal const int DefColWidth = 0x0055; + internal const int DefaultRowHeight = 0x0225; + internal const int CalcMode = 0x000D; + internal const int CalcCount = 0x000C; + internal const int CalcRefMode = 0x000F; + internal const int CalcIter = 0x0011; + internal const int CalcDelta = 0x0010; + internal const int CalcSaveRecalc = 0x005F; + internal const int PrintRowCol = 0x002A; + internal const int PrintGrid = 0x002B; + internal const int GridSet = 0x0082; + internal const int Guts = 0x0080; + internal const int WsBool = 0x0081; + internal const int WinProtect = 0x0019; + internal const int Protect = 0x0012; + internal const int Password = 0x0013; + internal const int Prot4Rev = 0x01AF; + internal const int Prot4RevPass = 0x01BC; internal const int Biff8Version = 0x0600; internal const int SubstreamGlobals = 0x0005; diff --git a/src/ExcelReader.Core/Writer/Xls/BiffRecordWriter.cs b/src/ExcelReader.Core/Writer/Xls/BiffRecordWriter.cs index 1b205a00..c9720d63 100644 --- a/src/ExcelReader.Core/Writer/Xls/BiffRecordWriter.cs +++ b/src/ExcelReader.Core/Writer/Xls/BiffRecordWriter.cs @@ -1,4 +1,3 @@ -using ExcelReader.Core.Reader.Internal; using ExcelReader.Core.Writer.Internal; namespace ExcelReader.Core.Writer.Xls @@ -10,10 +9,11 @@ internal static void WriteBof(BiffBuffer buffer, int substreamType) int len = buffer.BeginRecord(BiffRecord.Bof); buffer.WriteU16(BiffRecord.Biff8Version); buffer.WriteU16(substreamType); - for (int i = 0; i < 12; i++) - { - buffer.WriteByte(0); - } + // [MS-XLS] requires rupYear to be 1996 or 1997, and verLowestBiff to be 6. + buffer.WriteU16(0x0DBB); + buffer.WriteU16(0x07CC); + buffer.WriteI32(0); + buffer.WriteI32(6); buffer.EndRecord(len); } @@ -54,8 +54,10 @@ internal static void WriteWriteAccess(BiffBuffer buffer) { int len = buffer.BeginRecord(BiffRecord.WriteAccess); ReadOnlySpan name = "ExcelReader"u8; + buffer.WriteU16(name.Length); + buffer.WriteByte(0); buffer.Write(name); - for (int i = name.Length; i < 112; i++) + for (int i = 3 + name.Length; i < 112; i++) { buffer.WriteByte((byte)' '); } @@ -81,18 +83,29 @@ internal static void WriteFnGroupCount(BiffBuffer buffer) { WriteU16Record(buffer, BiffRecord.FnGroupCount, 17); } - private static ReadOnlySpan WriteWindow1Payload => [ - 0xFF, 0x7F, 0xFF, 0x7F, 0x80, 0x70, 0x30, 0x39, - 0x38, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0xC0, 0x20, - ]; - internal static void WriteWindow1(BiffBuffer buffer) + internal static void WriteWindow1(BiffBuffer buffer, int activeSheet) { int len = buffer.BeginRecord(BiffRecord.Window1); - buffer.Write(WriteWindow1Payload); + buffer.WriteU16(0x7FFF); + buffer.WriteU16(0x7FFF); + buffer.WriteU16(0x7080); + buffer.WriteU16(0x3930); + buffer.WriteU16(0x0038); + buffer.WriteU16(activeSheet); + buffer.WriteU16(0); + buffer.WriteU16(1); + buffer.WriteU16(600); buffer.EndRecord(len); } + internal static void WriteU16Records(BiffBuffer buffer, ReadOnlySpan ids, int value) + { + foreach (int id in ids) + { + WriteU16Record(buffer, id, value); + } + } + internal static void WriteBackup(BiffBuffer buffer) { WriteU16Record(buffer, BiffRecord.Backup, 0); @@ -210,14 +223,66 @@ internal static void WriteDimension(BiffBuffer buffer, int rowCount, int colCoun buffer.WriteU16(0); buffer.EndRecord(len); } - private static ReadOnlySpan ThirdPayload => [0xB6, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; - internal static void WriteWindow2(BiffBuffer buffer) + internal static void WriteWindow2(BiffBuffer buffer, bool selected) { int len = buffer.BeginRecord(BiffRecord.Window2); - buffer.Write(ThirdPayload); + buffer.WriteU16(selected ? 0x06B6 : 0x00B6); + buffer.WriteU16(0); + buffer.WriteU16(0); + buffer.WriteU16(0x0040); + buffer.WriteU16(0); + buffer.WriteU16(0); + buffer.WriteU16(0); + buffer.WriteI32(0); + buffer.EndRecord(len); + } + + internal static void WriteSheetSettings(BiffBuffer buffer) + { + WriteU16Record(buffer, BiffRecord.CalcMode, 1); + WriteU16Record(buffer, BiffRecord.CalcCount, 100); + WriteU16Record(buffer, BiffRecord.CalcRefMode, 1); + WriteU16Record(buffer, BiffRecord.CalcIter, 0); + int delta = buffer.BeginRecord(BiffRecord.CalcDelta); + buffer.WriteDouble(0.001); + buffer.EndRecord(delta); + WriteU16Record(buffer, BiffRecord.CalcSaveRecalc, 1); + WriteU16Record(buffer, BiffRecord.PrintRowCol, 0); + WriteU16Record(buffer, BiffRecord.PrintGrid, 0); + WriteU16Record(buffer, BiffRecord.GridSet, 1); + int guts = buffer.BeginRecord(BiffRecord.Guts); + buffer.WriteI32(0); + buffer.WriteI32(0); + buffer.EndRecord(guts); + int rowHeight = buffer.BeginRecord(BiffRecord.DefaultRowHeight); + buffer.WriteU16(0); + buffer.WriteU16(DefaultRowHeightTwips); + buffer.EndRecord(rowHeight); + WriteU16Record(buffer, BiffRecord.WsBool, 0x04C1); + } + + internal const int DefaultRowHeightTwips = 255; + + internal static void WriteDefColWidth(BiffBuffer buffer) + { + WriteU16Record(buffer, BiffRecord.DefColWidth, 8); + } + + internal static void WriteRow(BiffBuffer buffer, int row, int colMic, int colMac) + { + int len = buffer.BeginRecord(BiffRecord.Row); + buffer.WriteU16(row); + buffer.WriteU16(colMic); + buffer.WriteU16(colMac); + buffer.WriteU16(DefaultRowHeightTwips); + buffer.WriteI32(0); + buffer.WriteU16(0x0100); + buffer.WriteU16(GeneralStyleXf); buffer.EndRecord(len); } + private const int GeneralStyleXf = 15; + internal static void WriteBlank(BiffBuffer buffer, int row, int col, int xf) { int len = buffer.BeginRecord(BiffRecord.Blank); @@ -236,37 +301,21 @@ internal static void WriteNumber(BiffBuffer buffer, int row, int col, int xf, do internal static void WriteLabel(BiffBuffer buffer, int row, int col, int xf, ReadOnlySpan value) { - ExcelLimits.ThrowIfCellTextTooLong(value.Length, nameof(value)); bool compressed = BiffStringEncoder.CanCompress(value); - int charBytes = compressed ? value.Length : value.Length * 2; - if (9 + charBytes <= BiffRecord.MaxPayload) - { - int len = buffer.BeginRecord(BiffRecord.Label); - WriteCellHeader(buffer, row, col, xf); - buffer.WriteU16(value.Length); - buffer.WriteByte((byte)(compressed ? 0 : 1)); - BiffStringEncoder.WriteChars(buffer, value, compressed); - buffer.EndRecord(len); - return; - } - int firstChars = compressed ? 8215 : 4107; - int lenSplit = buffer.BeginRecord(BiffRecord.Label); + int len = buffer.BeginRecord(BiffRecord.Label); WriteCellHeader(buffer, row, col, xf); buffer.WriteU16(value.Length); buffer.WriteByte((byte)(compressed ? 0 : 1)); - BiffStringEncoder.WriteChars(buffer, value[..firstChars], compressed); - buffer.EndRecord(lenSplit); - int offset = firstChars; - int maxContChars = compressed ? 8223 : 4111; - while (offset < value.Length) - { - int take = Math.Min(maxContChars, value.Length - offset); - int contLen = buffer.BeginRecord(0x003C); - buffer.WriteByte((byte)(compressed ? 0 : 1)); - BiffStringEncoder.WriteChars(buffer, value.Slice(offset, take), compressed); - buffer.EndRecord(contLen); - offset += take; - } + BiffStringEncoder.WriteChars(buffer, value, compressed); + buffer.EndRecord(len); + } + + internal static void WriteLabelSst(BiffBuffer buffer, int row, int col, int xf, int sharedStringIndex) + { + int len = buffer.BeginRecord(BiffRecord.LabelSst); + WriteCellHeader(buffer, row, col, xf); + buffer.WriteI32(sharedStringIndex); + buffer.EndRecord(len); } internal static void WriteBool(BiffBuffer buffer, int row, int col, int xf, bool value) diff --git a/src/ExcelReader.Core/Writer/Xls/XlsGlobals.cs b/src/ExcelReader.Core/Writer/Xls/XlsGlobals.cs index 287be875..46dfe743 100644 --- a/src/ExcelReader.Core/Writer/Xls/XlsGlobals.cs +++ b/src/ExcelReader.Core/Writer/Xls/XlsGlobals.cs @@ -14,7 +14,8 @@ internal static int CustomXf(int abstractStyleIndex) } internal static int[] Write(BiffBuffer buffer, ReadOnlySpan sheetNames, - ReadOnlySpan sheetVisibilities, bool date1904, StyleTable styles) + ReadOnlySpan sheetVisibilities, int activeSheet, bool date1904, StyleTable styles, + XlsSharedStrings sharedStrings) { BiffRecordWriter.WriteBof(buffer, BiffRecord.SubstreamGlobals); BiffRecordWriter.WriteInterfaceHdr(buffer, 1200); @@ -25,17 +26,20 @@ internal static int[] Write(BiffBuffer buffer, ReadOnlySpan sheetNames, BiffRecordWriter.WriteDsf(buffer); BiffRecordWriter.WriteTabId(buffer, sheetNames.Length); BiffRecordWriter.WriteFnGroupCount(buffer); - BiffRecordWriter.WriteWindow1(buffer); + BiffRecordWriter.WriteU16Records(buffer, + [BiffRecord.WinProtect, BiffRecord.Protect, BiffRecord.Password, BiffRecord.Prot4Rev, BiffRecord.Prot4RevPass], 0); + BiffRecordWriter.WriteWindow1(buffer, activeSheet); BiffRecordWriter.WriteBackup(buffer); BiffRecordWriter.WriteHideObj(buffer); - if (date1904) - { - BiffRecordWriter.WriteDate1904(buffer, date1904: true); - } + BiffRecordWriter.WriteDate1904(buffer, date1904); BiffRecordWriter.WritePrecision(buffer); BiffRecordWriter.WriteRefreshAll(buffer); BiffRecordWriter.WriteBookBool(buffer); - BiffRecordWriter.WriteFont(buffer); + // Excel writes at least four FONT records; every XF here refers to the first. + for (int i = 0; i < 4; i++) + { + BiffRecordWriter.WriteFont(buffer); + } Dictionary numFmtIds = styles.AssignCustomNumberFormatIds(); foreach (var (format, id) in numFmtIds) { @@ -62,6 +66,7 @@ internal static int[] Write(BiffBuffer buffer, ReadOnlySpan sheetNames, BiffRecordWriter.WriteBoundSheet(buffer, sheetOffset: 0, sheetNames[i], sheetVisibilities[i]); } + sharedStrings.Write(buffer); BiffRecordWriter.WriteEof(buffer); return offsetPositions; } diff --git a/src/ExcelReader.Core/Writer/Xls/XlsSharedStrings.cs b/src/ExcelReader.Core/Writer/Xls/XlsSharedStrings.cs new file mode 100644 index 00000000..0191d8eb --- /dev/null +++ b/src/ExcelReader.Core/Writer/Xls/XlsSharedStrings.cs @@ -0,0 +1,231 @@ +using System.Buffers.Binary; +using System.Runtime.InteropServices; +using ExcelReader.Core.Writer.Internal; + +namespace ExcelReader.Core.Writer.Xls +{ + /// + /// The SST and EXTSST records for text longer than : Excel truncates a + /// LABEL cell to that length on load and refuses a LABEL continued past one record, so longer text + /// goes through LABELSST instead. + /// + /// + /// Each string is encoded once, straight into its SST form (character count, flags, characters), + /// and deduplicated by comparing those bytes; the record boundaries are only drawn in + /// . + /// + internal sealed class XlsSharedStrings : IDisposable + { + internal const int MaxLabelLength = 255; + + private const int Continue = 0x003C; + private const int Sst = 0x00FC; + private const int ExtSst = 0x00FF; + private const int EntryHeaderLength = 3; + + // Strings are found by a hash of a few sampled bytes. Text that differs only between the samples + // piles into one chain; once a chain holds this many, its strings move to a hash of every byte, + // so no chain is ever searched linearly past this length. + private const int MaxSampleChain = 8; + private const int Crowded = -1; + + private BiffBuffer? _entries; + private readonly List _starts = []; + private readonly List _next = []; + private readonly Dictionary _bySample = []; + private readonly Dictionary _byFullHash = []; + private int _references; + + internal int Add(ReadOnlySpan value) + { + _references++; + BiffBuffer entries = _entries ??= new BiffBuffer(64 * 1024); + int start = entries.Length; + bool compressed = BiffStringEncoder.CanCompress(value); + entries.WriteU16(value.Length); + entries.WriteByte((byte)(compressed ? 0 : 1)); + BiffStringEncoder.WriteChars(entries, value, compressed); + ReadOnlySpan entry = entries.Span[start..]; + + ref int head = ref CollectionsMarshal.GetValueRefOrAddDefault(_bySample, SampleHash(entry), out bool exists); + if (!exists) + { + head = Append(start, next: -1); + return head; + } + if (head != Crowded) + { + int length = 0; + for (int candidate = head; candidate >= 0; candidate = _next[candidate], length++) + { + if (Entry(candidate, start).SequenceEqual(entry)) + { + entries.Truncate(start); + return candidate; + } + } + if (length < MaxSampleChain) + { + head = Append(start, next: head); + return head; + } + MoveToFullHash(head, start); + head = Crowded; + } + + ref int fullHead = ref CollectionsMarshal.GetValueRefOrAddDefault(_byFullHash, FullHash(entry), out bool hashed); + if (hashed) + { + for (int candidate = fullHead; candidate >= 0; candidate = _next[candidate]) + { + if (Entry(candidate, start).SequenceEqual(entry)) + { + entries.Truncate(start); + return candidate; + } + } + } + fullHead = Append(start, next: hashed ? fullHead : -1); + return fullHead; + } + + private int Append(int start, int next) + { + _starts.Add(start); + _next.Add(next); + return _starts.Count - 1; + } + + private void MoveToFullHash(int chain, int end) + { + for (int index = chain; index >= 0;) + { + int following = _next[index]; + ref int fullHead = ref CollectionsMarshal.GetValueRefOrAddDefault(_byFullHash, FullHash(Entry(index, end)), out bool hashed); + _next[index] = hashed ? fullHead : -1; + fullHead = index; + index = following; + } + } + + /// An upper bound on what adds, so the globals buffer can be sized once. + internal int EstimatedLength + { + get + { + if (_entries is null) + { + return 0; + } + int records = (_entries.Length / (BiffRecord.MaxPayload - EntryHeaderLength)) + 2; + return _entries.Length + (records * 5) + 12 + ((_starts.Count / 8) + 1) * 8; + } + } + + /// Writes SST and EXTSST at the buffer's end, which must be their position in the Workbook stream. + internal void Write(BiffBuffer buffer) + { + int count = _starts.Count; + if (_entries is null || count == 0) + { + return; + } + int perBucket = Math.Max(8, (count + 127) / 128); + var buckets = new List<(int Position, int RecordOffset)>((count + perBucket - 1) / perBucket); + + int lengthPos = buffer.BeginRecord(Sst); + buffer.WriteI32(_references); + buffer.WriteI32(count); + for (int i = 0; i < count; i++) + { + ReadOnlySpan entry = Entry(i, _entries.Length); + byte flags = entry[2]; + int charSize = flags == 0 ? 1 : 2; + if (Room(buffer, lengthPos) < EntryHeaderLength + charSize) + { + lengthPos = NextRecord(buffer, lengthPos); + } + if (i % perBucket == 0) + { + buckets.Add((buffer.Length, buffer.Length - (lengthPos - 2))); + } + buffer.Write(entry[..EntryHeaderLength]); + ReadOnlySpan chars = entry[EntryHeaderLength..]; + while (!chars.IsEmpty) + { + int take = Math.Min(Room(buffer, lengthPos) / charSize * charSize, chars.Length); + if (take == 0) + { + lengthPos = NextRecord(buffer, lengthPos); + buffer.WriteByte(flags); + continue; + } + buffer.Write(chars[..take]); + chars = chars[take..]; + } + } + buffer.EndRecord(lengthPos); + + int extSst = buffer.BeginRecord(ExtSst); + buffer.WriteU16(perBucket); + foreach ((int position, int recordOffset) in buckets) + { + buffer.WriteI32(position); + buffer.WriteU16(recordOffset); + buffer.WriteU16(0); + } + buffer.EndRecord(extSst); + } + + public void Dispose() + { + _entries?.Dispose(); + } + + private ReadOnlySpan Entry(int index, int end) + { + int start = _starts[index]; + int next = index + 1 < _starts.Count ? _starts[index + 1] : end; + return _entries!.Span[start..next]; + } + + /// + /// Hashes the length, the first and last 32 bytes and three 8-byte samples in between: hashing + /// all of every long string cost more than the rest of writing it. Every entry is longer than + /// 255 bytes, so each read is in range. + /// + private static int SampleHash(ReadOnlySpan entry) + { + ReadOnlySpan head = MemoryMarshal.Cast(entry[..32]); + ReadOnlySpan tail = MemoryMarshal.Cast(entry[^32..]); + var hasher = default(HashCode); + hasher.Add(entry.Length); + hasher.Add(head[0] ^ tail[0]); + hasher.Add(head[1] ^ tail[1]); + hasher.Add(head[2] ^ tail[2]); + hasher.Add(head[3] ^ tail[3]); + hasher.Add(BinaryPrimitives.ReadUInt64LittleEndian(entry[(entry.Length / 4)..])); + hasher.Add(BinaryPrimitives.ReadUInt64LittleEndian(entry[(entry.Length / 2)..])); + hasher.Add(BinaryPrimitives.ReadUInt64LittleEndian(entry[(entry.Length * 3 / 4)..])); + return hasher.ToHashCode(); + } + + private static int FullHash(ReadOnlySpan entry) + { + var hasher = default(HashCode); + hasher.AddBytes(entry); + return hasher.ToHashCode(); + } + + private static int Room(BiffBuffer buffer, int lengthPos) + { + return BiffRecord.MaxPayload - (buffer.Length - lengthPos - 2); + } + + private static int NextRecord(BiffBuffer buffer, int lengthPos) + { + buffer.EndRecord(lengthPos); + return buffer.BeginRecord(Continue); + } + } +} diff --git a/src/ExcelReader.Core/Writer/Xls/XlsSheetLayout.cs b/src/ExcelReader.Core/Writer/Xls/XlsSheetLayout.cs new file mode 100644 index 00000000..e598e252 --- /dev/null +++ b/src/ExcelReader.Core/Writer/Xls/XlsSheetLayout.cs @@ -0,0 +1,135 @@ +using System.Runtime.InteropServices; +using ExcelReader.Core.Writer.Internal; + +namespace ExcelReader.Core.Writer.Xls +{ + /// + /// Lays out one worksheet substream in the order [MS-XLS] requires: INDEX, the sheet settings, + /// DEFCOLWIDTH and COLINFO, DIMENSIONS, the cell table in blocks of up to 32 rows (their ROW records, + /// their cells, then a DBCELL pointing back at both), WINDOW2 and EOF. The cells stay in the sheet's + /// own buffer and are written between the framing records rather than copied. + /// + internal sealed class XlsSheetLayout : IDisposable + { + private const int RowsPerBlock = 32; + private const int RowRecordLength = 4 + 16; + + private readonly BiffBuffer _frame = new(1024); + private readonly ReadOnlyMemory _cells; + private readonly List _segments = []; + private readonly List<(int Position, int Relative)> _filePointers = []; + + internal XlsSheetLayout(XlsSheetWriter sheet, bool selected) + { + _cells = sheet.CellsMemory; + ReadOnlySpan rows = sheet.Rows; + int blockCount = (rows.Length + RowsPerBlock - 1) / RowsPerBlock; + + BiffRecordWriter.WriteBof(_frame, BiffRecord.SubstreamWorksheet); + int index = _frame.BeginRecord(BiffRecord.Index); + _frame.WriteI32(0); + _frame.WriteI32(0); + _frame.WriteI32(rows.Length); + int defColWidthPointer = _frame.Length; + _frame.WriteI32(0); + int dbCellPointers = _frame.Length; + for (int i = 0; i < blockCount; i++) + { + _frame.WriteI32(0); + } + _frame.EndRecord(index); + + BiffRecordWriter.WriteSheetSettings(_frame); + _filePointers.Add((defColWidthPointer, _frame.Length)); + BiffRecordWriter.WriteDefColWidth(_frame); + _frame.Write(sheet.ColInfoMemory.Span); + BiffRecordWriter.WriteDimension(_frame, sheet.RowCount, sheet.ColCount); + AddFrame(0); + + int position = _frame.Length; + for (int block = 0; block < blockCount; block++) + { + int first = block * RowsPerBlock; + int count = Math.Min(RowsPerBlock, rows.Length - first); + int cellsStart = rows[first].CellStart; + int cellsEnd = first + count < rows.Length ? rows[first + count].CellStart : _cells.Length; + + int rowsFrame = _frame.Length; + for (int i = first; i < first + count; i++) + { + BiffRecordWriter.WriteRow(_frame, i, rows[i].ColMic, rows[i].ColMac); + } + AddFrame(rowsFrame); + _segments.Add(new Segment(Cells: true, cellsStart, cellsEnd - cellsStart)); + int blockLength = (count * RowRecordLength) + (cellsEnd - cellsStart); + position += blockLength; + + _filePointers.Add((dbCellPointers + (4 * block), position)); + int dbCellFrame = _frame.Length; + int dbCell = _frame.BeginRecord(BiffRecord.DbCell); + _frame.WriteI32(blockLength); + // The first offset runs from the block's second ROW record to the first row's cells; + // each later one is the length of the previous row's cells. + _frame.WriteU16((count - 1) * RowRecordLength); + for (int i = first + 1; i < first + count; i++) + { + _frame.WriteU16(Math.Min(rows[i].CellStart - rows[i - 1].CellStart, ushort.MaxValue)); + } + _frame.EndRecord(dbCell); + AddFrame(dbCellFrame); + position += _frame.Length - dbCellFrame; + } + + int tailFrame = _frame.Length; + BiffRecordWriter.WriteWindow2(_frame, selected); + BiffRecordWriter.WriteEof(_frame); + AddFrame(tailFrame); + Length = position + (_frame.Length - tailFrame); + } + + internal int Length { get; } + + /// INDEX holds absolute stream positions, known only once the globals have been sized. + internal void Place(int substreamStart) + { + foreach ((int pointer, int relative) in _filePointers) + { + _frame.PatchI32(pointer, substreamStart + relative); + } + } + + internal void WriteTo(Stream destination) + { + foreach (Segment segment in _segments) + { + destination.Write(Slice(segment).Span); + } + } + + internal async ValueTask WriteToAsync(Stream destination, CancellationToken ct) + { + foreach (Segment segment in _segments) + { + await destination.WriteAsync(Slice(segment), ct).ConfigureAwait(false); + } + } + + public void Dispose() + { + _frame.Dispose(); + } + + private void AddFrame(int start) + { + _segments.Add(new Segment(Cells: false, start, _frame.Length - start)); + } + + private ReadOnlyMemory Slice(Segment segment) + { + return (segment.Cells ? _cells : _frame.Memory).Slice(segment.Start, segment.Length); + } + + [StructLayout(LayoutKind.Auto)] + private readonly record struct Segment(bool Cells, int Start, int Length); + } +} diff --git a/src/ExcelReader.Core/Writer/Xls/XlsSheetWriter.cs b/src/ExcelReader.Core/Writer/Xls/XlsSheetWriter.cs index 86517f61..0a82d080 100644 --- a/src/ExcelReader.Core/Writer/Xls/XlsSheetWriter.cs +++ b/src/ExcelReader.Core/Writer/Xls/XlsSheetWriter.cs @@ -1,4 +1,7 @@ using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using ExcelReader.Core.Reader.Internal; using ExcelReader.Core.Writer.Internal; namespace ExcelReader.Core.Writer.Xls @@ -14,8 +17,6 @@ public sealed class XlsSheetWriter : ISheetWriter internal const int MaxColumn = 255; private const int MaxSheetNameLength = 31; - private const int FramingBytes = 20 + 18 + 22 + 4; - private readonly XlsWorkbookWriter _owner; private readonly bool _date1904; private readonly bool _isContinuation; @@ -29,6 +30,12 @@ public sealed class XlsSheetWriter : ISheetWriter private int _maxRow = -1; private int _maxCol = -1; private int _rowNumber = -1; + [SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", + Justification = "Read while the workbook is written; released with _cells via ReleaseBuffer.")] + private readonly BiffBuffer _rows = new(4096); + private int _rowCellStart; + private int _rowColMic; + private int _rowColMax; private WriterState _state = WriterState.Created; private bool _rowActive; private bool _rowHasCells; @@ -52,13 +59,12 @@ internal XlsSheetWriter(XlsWorkbookWriter owner, string name, bool date1904, internal ExcelSheetVisibility Visibility { get; } - internal int SubstreamLength => FramingBytes + _colInfos.Length + _cells.Length; - internal int RowCount => _maxRow + 1; internal int ColCount => _maxCol + 1; internal ReadOnlyMemory CellsMemory => _cells.Memory; internal ReadOnlyMemory ColInfoMemory => _colInfos.Memory; - internal bool ResourcesReleased => _cells.IsReleased && _colInfos.IsReleased; + internal ReadOnlySpan Rows => MemoryMarshal.Cast(_rows.Span); + internal bool ResourcesReleased => _cells.IsReleased && _colInfos.IsReleased && _rows.IsReleased; private bool HasActiveRow => _rowActive || (_continuation?.HasActiveRow ?? false); [SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", @@ -160,6 +166,7 @@ public XlsRowWriter StartRow(int styleId) _activeRowStyle = styleId; _rowActive = true; _rowHasCells = false; + _rowCellStart = _cells.Length; _rowWriter ??= new XlsRowWriter(this, _rowNumber); _rowWriter.Reset(_rowNumber); return _rowWriter; @@ -173,6 +180,12 @@ internal void NotifyRowEnded() BiffRecordWriter.WriteBlank(_cells, _rowNumber, 0, XlsGlobals.GeneralXf); Track(_rowNumber, 0); } + if (_rowActive) + { + var extent = new XlsRowExtent(_rowCellStart, _rowColMic, _rowColMax + 1); + MemoryMarshal.Write(_rows.GetSpan(Unsafe.SizeOf()), in extent); + _rows.Advance(Unsafe.SizeOf()); + } _rowActive = false; } @@ -196,8 +209,17 @@ internal void EmitDate(int row, int col, DateTime value) internal void EmitLabel(int row, int col, ReadOnlySpan value) { ValidateColumn(col); + ExcelLimits.ThrowIfCellTextTooLong(value.Length, nameof(value)); int abstractStyle = EffectiveStyle(col); - BiffRecordWriter.WriteLabel(_cells, row, col, abstractStyle == 0 ? XlsGlobals.GeneralXf : XlsGlobals.CustomXf(abstractStyle), value); + int xf = abstractStyle == 0 ? XlsGlobals.GeneralXf : XlsGlobals.CustomXf(abstractStyle); + if (value.Length > XlsSharedStrings.MaxLabelLength) + { + BiffRecordWriter.WriteLabelSst(_cells, row, col, xf, _owner.AddSharedString(value)); + } + else + { + BiffRecordWriter.WriteLabel(_cells, row, col, xf, value); + } Track(row, col); } @@ -213,10 +235,16 @@ internal void ReleaseBuffer() { _cells.Dispose(); _colInfos.Dispose(); + _rows.Dispose(); } private void Track(int row, int col) { + if (!_rowHasCells) + { + _rowColMic = col; + } + _rowColMax = col; _rowHasCells = true; if (row > _maxRow) { _maxRow = row; } if (col > _maxCol) { _maxCol = col; } @@ -299,4 +327,7 @@ public ValueTask DisposeAsync() return ValueTask.CompletedTask; } } + + [StructLayout(LayoutKind.Auto)] + internal readonly record struct XlsRowExtent(int CellStart, int ColMic, int ColMac); } diff --git a/src/ExcelReader.Core/Writer/Xls/XlsWorkbookWriter.cs b/src/ExcelReader.Core/Writer/Xls/XlsWorkbookWriter.cs index 2e88bd17..d9f429ae 100644 --- a/src/ExcelReader.Core/Writer/Xls/XlsWorkbookWriter.cs +++ b/src/ExcelReader.Core/Writer/Xls/XlsWorkbookWriter.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; using ExcelReader.Core.Writer.Internal; using ExcelReader.Core.Writer.Xlsx; @@ -20,6 +21,9 @@ public sealed class XlsWorkbookWriter : IWorkbookWriter private readonly bool _leaveOpen; private readonly bool _date1904; private readonly StyleTable _styles = new(); + [SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", + Justification = "Released with the sheet buffers once End or EndAsync has written the workbook.")] + private readonly XlsSharedStrings _sharedStrings = new(); private readonly List _sheets = []; private bool _ended; private XlsSheetWriter? _activeSheet; @@ -76,6 +80,11 @@ public int AddStyle(CellStyle style) internal int StyleCount => _styles.Count; + internal int AddSharedString(ReadOnlySpan value) + { + return _sharedStrings.Add(value); + } + internal void RegisterSheet(XlsSheetWriter sheet) { _sheets.Add(sheet); @@ -87,31 +96,39 @@ internal void NotifySheetEnded() _activeSheet = null; } - private BiffBuffer BuildGlobals(out int workbookSize) + private BiffBuffer BuildGlobals(List layouts, out int workbookSize) { string[] names = new string[_sheets.Count]; ExcelSheetVisibility[] visibilities = new ExcelSheetVisibility[_sheets.Count]; - bool anyVisible = false; + int activeSheet = -1; for (int i = 0; i < _sheets.Count; i++) { names[i] = _sheets[i].Name; visibilities[i] = _sheets[i].Visibility; - anyVisible |= visibilities[i] is ExcelSheetVisibility.Visible; + if (activeSheet < 0 && visibilities[i] is ExcelSheetVisibility.Visible) + { + activeSheet = i; + } + } + WriterStateGuard.RequireVisibleSheet(activeSheet >= 0, nameof(XlsWorkbookWriter)); + + for (int i = 0; i < _sheets.Count; i++) + { + layouts.Add(new XlsSheetLayout(_sheets[i], selected: i == activeSheet)); } - WriterStateGuard.RequireVisibleSheet(anyVisible, nameof(XlsWorkbookWriter)); - BiffBuffer globals = new(1024); + BiffBuffer globals = new(4096 + _sharedStrings.EstimatedLength); try { - int[] offsetPositions = XlsGlobals.Write(globals, names, visibilities, _date1904, _styles); + int[] offsetPositions = XlsGlobals.Write(globals, names, visibilities, activeSheet, _date1904, _styles, _sharedStrings); int offset = globals.Length; - workbookSize = offset; - for (int i = 0; i < _sheets.Count; i++) + for (int i = 0; i < layouts.Count; i++) { globals.PatchI32(offsetPositions[i], offset); - offset += _sheets[i].SubstreamLength; - workbookSize += _sheets[i].SubstreamLength; + layouts[i].Place(offset); + offset += layouts[i].Length; } + workbookSize = offset; return globals; } catch @@ -144,8 +161,17 @@ private ValueTask ReleaseStreamAsync() return _faulted ? FailureCleanup.DisposeAsync(_stream) : _stream.DisposeAsync(); } + private static void DisposeLayouts(List layouts) + { + foreach (XlsSheetLayout layout in layouts) + { + layout.Dispose(); + } + } + private void ReleaseSheetBuffers() { + _sharedStrings.Dispose(); foreach (ref readonly var sheet in CollectionsMarshal.AsSpan(_sheets)) { sheet.ReleaseBuffer(); @@ -169,25 +195,23 @@ public void End() throw new InvalidOperationException("A workbook must contain at least one sheet."); } - using BiffBuffer globals = BuildGlobals(out int workbookSize); - using BiffBuffer frame = new(64); - OleCompoundWriter.Write(_stream, workbookSize, dest => + List layouts = []; + try { - dest.Write(globals.Span); - foreach (ref readonly var sheet in CollectionsMarshal.AsSpan(_sheets)) + using BiffBuffer globals = BuildGlobals(layouts, out int workbookSize); + OleCompoundWriter.Write(_stream, workbookSize, dest => { - frame.Reset(); - BiffRecordWriter.WriteBof(frame, BiffRecord.SubstreamWorksheet); - BiffRecordWriter.WriteDimension(frame, sheet.RowCount, sheet.ColCount); - BiffRecordWriter.WriteWindow2(frame); - dest.Write(frame.Span); - dest.Write(sheet.ColInfoMemory.Span); - dest.Write(sheet.CellsMemory.Span); - frame.Reset(); - BiffRecordWriter.WriteEof(frame); - dest.Write(frame.Span); - } - }); + dest.Write(globals.Span); + foreach (XlsSheetLayout layout in layouts) + { + layout.WriteTo(dest); + } + }); + } + finally + { + DisposeLayouts(layouts); + } } catch { @@ -218,25 +242,23 @@ public async ValueTask EndAsync(CancellationToken ct = default) throw new InvalidOperationException("A workbook must contain at least one sheet."); } - using BiffBuffer globals = BuildGlobals(out int workbookSize); - using BiffBuffer frame = new(64); - await OleCompoundWriter.WriteAsync(_stream, workbookSize, async (dest, canc) => + List layouts = []; + try { - await dest.WriteAsync(globals.Memory, canc).ConfigureAwait(false); - foreach (XlsSheetWriter sheet in _sheets) + using BiffBuffer globals = BuildGlobals(layouts, out int workbookSize); + await OleCompoundWriter.WriteAsync(_stream, workbookSize, async (dest, canc) => { - frame.Reset(); - BiffRecordWriter.WriteBof(frame, BiffRecord.SubstreamWorksheet); - BiffRecordWriter.WriteDimension(frame, sheet.RowCount, sheet.ColCount); - BiffRecordWriter.WriteWindow2(frame); - await dest.WriteAsync(frame.Memory, canc).ConfigureAwait(false); - await dest.WriteAsync(sheet.ColInfoMemory, canc).ConfigureAwait(false); - await dest.WriteAsync(sheet.CellsMemory, canc).ConfigureAwait(false); - frame.Reset(); - BiffRecordWriter.WriteEof(frame); - await dest.WriteAsync(frame.Memory, canc).ConfigureAwait(false); - } - }, ct).ConfigureAwait(false); + await dest.WriteAsync(globals.Memory, canc).ConfigureAwait(false); + foreach (XlsSheetLayout layout in layouts) + { + await layout.WriteToAsync(dest, canc).ConfigureAwait(false); + } + }, ct).ConfigureAwait(false); + } + finally + { + DisposeLayouts(layouts); + } } catch { diff --git a/tests/ExcelReader.Tests/Writer/Xls/XlsConformanceTests.cs b/tests/ExcelReader.Tests/Writer/Xls/XlsConformanceTests.cs new file mode 100644 index 00000000..a970d84a --- /dev/null +++ b/tests/ExcelReader.Tests/Writer/Xls/XlsConformanceTests.cs @@ -0,0 +1,206 @@ +using System.Buffers.Binary; +using ExcelReader.Core.Crypto; +using ExcelReader.Core.Reader; +using ExcelReader.Core.Writer.Xls; + +namespace ExcelReader.Tests.Writer.Xls +{ + /// + /// Excel refuses a BIFF8 file whose records break the [MS-XLS] grammar, while this library's own + /// reader accepts it, so a round trip proves nothing here. These pin the structure Excel checks. + /// + public class XlsConformanceTests + { + private const int Bof = 0x0809; + private const int Eof = 0x000A; + private const int Index = 0x020B; + private const int DefColWidth = 0x0055; + private const int Row = 0x0208; + private const int DbCell = 0x00D7; + private const int Window1 = 0x003D; + private const int Window2 = 0x023E; + private const int WriteAccess = 0x005C; + private const int Date1904 = 0x0022; + private const int Label = 0x0204; + private const int LabelSst = 0x00FD; + private const int Sst = 0x00FC; + private const int ExtSst = 0x00FF; + + private readonly record struct Record(int Position, int Id, byte[] Payload); + + private static byte[] Write(Action build) + { + var ms = new MemoryStream(); + using (var wb = XlsWorkbookWriter.Create(ms, leaveOpen: true)) + { + build(wb); + wb.End(); + } + return ms.ToArray(); + } + + private static List Records(byte[] xls) + { + using var cfb = CfbContainer.Parse(new MemoryStream(xls), ownsSource: true, ExcelReaderOptions.Default); + byte[] stream = cfb.ReadStream("Workbook", maxBytes: 64 * 1024 * 1024); + var records = new List(); + int pos = 0; + while (pos + 4 <= stream.Length) + { + int id = BinaryPrimitives.ReadUInt16LittleEndian(stream.AsSpan(pos)); + if (id == 0) + { + break; + } + int length = BinaryPrimitives.ReadUInt16LittleEndian(stream.AsSpan(pos + 2)); + records.Add(new Record(pos, id, stream.AsSpan(pos + 4, length).ToArray())); + pos += 4 + length; + } + return records; + } + + private static List Worksheet(List records) + { + int start = records.FindLastIndex(r => r.Id == Bof); + return records.GetRange(start, records.Count - start); + } + + [Fact] + public void WorksheetSubstreamIndexesItsRowBlocks() + { + byte[] xls = Write(wb => + { + using var sheet = wb.AddSheet("S"); + for (int i = 0; i < 40; i++) + { + using var row = sheet.StartRow(); + row.Write(i); + row.Write("text"); + } + }); + + List sheet = Worksheet(Records(xls)); + Dictionary byPosition = sheet.ToDictionary(r => r.Position); + + Assert.Equal(Bof, sheet[0].Id); + Assert.Equal(Index, sheet[1].Id); + Assert.Equal(Eof, sheet[^1].Id); + Assert.Equal(Window2, sheet[^2].Id); + + byte[] index = sheet[1].Payload; + Assert.Equal(40, BinaryPrimitives.ReadInt32LittleEndian(index.AsSpan(8))); + Assert.Equal(DefColWidth, byPosition[BinaryPrimitives.ReadInt32LittleEndian(index.AsSpan(12))].Id); + + int blocks = (index.Length - 16) / 4; + Assert.Equal(2, blocks); + for (int block = 0; block < blocks; block++) + { + Record dbCell = byPosition[BinaryPrimitives.ReadInt32LittleEndian(index.AsSpan(16 + (4 * block)))]; + Assert.Equal(DbCell, dbCell.Id); + int firstRow = dbCell.Position - BinaryPrimitives.ReadInt32LittleEndian(dbCell.Payload); + Assert.Equal(Row, byPosition[firstRow].Id); + Assert.Equal(block * 32, BinaryPrimitives.ReadUInt16LittleEndian(byPosition[firstRow].Payload)); + } + + Assert.Equal(40, sheet.Count(r => r.Id == Row)); + Assert.True(sheet.FindIndex(r => r.Id == Window2) > sheet.FindLastIndex(r => r.Id == DbCell)); + } + + [Fact] + public void GlobalsRecordsHaveTheirSpecifiedShape() + { + byte[] xls = Write(wb => + { + using var sheet = wb.AddSheet("S"); + using var row = sheet.StartRow(); + row.Write(1); + }); + + List records = Records(xls); + Assert.Equal(0x07CC, BinaryPrimitives.ReadUInt16LittleEndian(records[0].Payload.AsSpan(6))); + + byte[] writeAccess = records.Single(r => r.Id == WriteAccess).Payload; + Assert.Equal(112, writeAccess.Length); + Assert.Equal("ExcelReader".Length, BinaryPrimitives.ReadUInt16LittleEndian(writeAccess)); + Assert.Equal(0, writeAccess[2]); + + Assert.Equal(18, records.Single(r => r.Id == Window1).Payload.Length); + Assert.Equal([0, 0], records.Single(r => r.Id == Date1904).Payload); + } + + [Fact] + public void LongTextDifferingOnlyBetweenHashSamplesIsStillStoredOnce() + { + static string Text(int i) + { + return new string('a', 97) + i.ToString("D6", System.Globalization.CultureInfo.InvariantCulture) + new string('b', 197); + } + + const int distinct = 200; + byte[] xls = Write(wb => + { + using var sheet = wb.AddSheet("S"); + for (int i = 0; i < 2 * distinct; i++) + { + using var row = sheet.StartRow(); + row.Write(Text(i % distinct)); + } + }); + + byte[] sst = Records(xls).Single(r => r.Id == Sst).Payload; + Assert.Equal(2 * distinct, BinaryPrimitives.ReadInt32LittleEndian(sst)); + Assert.Equal(distinct, BinaryPrimitives.ReadInt32LittleEndian(sst.AsSpan(4))); + + using var reader = Excel.FromXls(new MemoryStream(xls)); + using var rows = reader.FirstSheet.GetEnumerator(); + for (int i = 0; i < 2 * distinct; i++) + { + Assert.True(rows.MoveNext()); + Assert.Equal(Text(i % distinct), rows.Current[0].GetString()); + } + Assert.False(rows.MoveNext()); + } + + [Theory] + [InlineData(255, 'x', Label)] + [InlineData(256, 'x', LabelSst)] + [InlineData(20_000, 'x', LabelSst)] + [InlineData(9_000, 'é', LabelSst)] + [InlineData(5_000, '日', LabelSst)] + public void TextKeepsItsFullLength(int length, char fill, int expectedRecord) + { + string text = new(fill, length); + byte[] xls = Write(wb => + { + using var sheet = wb.AddSheet("S"); + using (var row = sheet.StartRow()) + { + row.Write(text); + row.Write(text); + } + using (var row = sheet.StartRow()) + { + row.Write("short"); + } + }); + + List records = Records(xls); + Assert.Equal(2, Worksheet(records).Count(r => r.Id == expectedRecord)); + if (expectedRecord == LabelSst) + { + Assert.Single(records, r => r.Id == Sst); + Assert.Single(records, r => r.Id == ExtSst); + Assert.Equal([2, 0, 0, 0, 1, 0, 0, 0], records.Single(r => r.Id == Sst).Payload.AsSpan(0, 8).ToArray()); + } + + using var reader = Excel.FromXls(new MemoryStream(xls)); + using var rows = reader.FirstSheet.GetEnumerator(); + Assert.True(rows.MoveNext()); + Assert.Equal(text, rows.Current[0].GetString()); + Assert.Equal(text, rows.Current[1].GetString()); + Assert.True(rows.MoveNext()); + Assert.Equal("short", rows.Current[0].GetString()); + Assert.False(rows.MoveNext()); + } + } +} From c5b09a659eb275b72147353c33537cf4a0a15170 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Mon, 12 Oct 2026 23:22:15 -0300 Subject: [PATCH 02/42] refactor: optimize cell record writing for improved performance --- .../Writer/Xls/BiffRecordWriter.cs | 77 ++++++++++++------- 1 file changed, 50 insertions(+), 27 deletions(-) diff --git a/src/ExcelReader.Core/Writer/Xls/BiffRecordWriter.cs b/src/ExcelReader.Core/Writer/Xls/BiffRecordWriter.cs index c9720d63..857aaa0c 100644 --- a/src/ExcelReader.Core/Writer/Xls/BiffRecordWriter.cs +++ b/src/ExcelReader.Core/Writer/Xls/BiffRecordWriter.cs @@ -1,3 +1,7 @@ +using System.Buffers; +using System.Buffers.Binary; +using System.Runtime.InteropServices; +using System.Text; using ExcelReader.Core.Writer.Internal; namespace ExcelReader.Core.Writer.Xls @@ -283,57 +287,76 @@ internal static void WriteRow(BiffBuffer buffer, int row, int colMic, int colMac private const int GeneralStyleXf = 15; + // Cell records are written whole into one span: they are most of a sheet's bytes, and going + // through BeginRecord/WriteU16/EndRecord field by field measured as most of their cost. internal static void WriteBlank(BiffBuffer buffer, int row, int col, int xf) { - int len = buffer.BeginRecord(BiffRecord.Blank); - WriteCellHeader(buffer, row, col, xf); - buffer.EndRecord(len); + Span record = CellRecord(buffer, BiffRecord.Blank, 6, row, col, xf); + buffer.Advance(record.Length); } internal static void WriteNumber(BiffBuffer buffer, int row, int col, int xf, double value) { CellValueGuards.ThrowIfNonFinite(value, nameof(value)); - int len = buffer.BeginRecord(BiffRecord.Number); - WriteCellHeader(buffer, row, col, xf); - buffer.WriteDouble(value); - buffer.EndRecord(len); + Span record = CellRecord(buffer, BiffRecord.Number, 14, row, col, xf); + BinaryPrimitives.WriteDoubleLittleEndian(record[10..], value); + buffer.Advance(record.Length); } internal static void WriteLabel(BiffBuffer buffer, int row, int col, int xf, ReadOnlySpan value) { - bool compressed = BiffStringEncoder.CanCompress(value); - int len = buffer.BeginRecord(BiffRecord.Label); - WriteCellHeader(buffer, row, col, xf); - buffer.WriteU16(value.Length); - buffer.WriteByte((byte)(compressed ? 0 : 1)); - BiffStringEncoder.WriteChars(buffer, value, compressed); - buffer.EndRecord(len); + Span dest = buffer.GetSpan(13 + (value.Length * 2)); + Span chars = dest[13..]; + int charBytes; + byte flags = 0; + if (Ascii.FromUtf16(value, chars, out _) == OperationStatus.Done) + { + charBytes = value.Length; + } + else if (BiffStringEncoder.CanCompress(value)) + { + charBytes = Encoding.Latin1.GetBytes(value, chars); + } + else + { + MemoryMarshal.AsBytes(value).CopyTo(chars); + charBytes = value.Length * 2; + flags = 1; + } + Span record = CellRecord(buffer, BiffRecord.Label, 9 + charBytes, row, col, xf); + BinaryPrimitives.WriteUInt16LittleEndian(record[10..], (ushort)value.Length); + record[12] = flags; + buffer.Advance(record.Length); } internal static void WriteLabelSst(BiffBuffer buffer, int row, int col, int xf, int sharedStringIndex) { - int len = buffer.BeginRecord(BiffRecord.LabelSst); - WriteCellHeader(buffer, row, col, xf); - buffer.WriteI32(sharedStringIndex); - buffer.EndRecord(len); + Span record = CellRecord(buffer, BiffRecord.LabelSst, 10, row, col, xf); + BinaryPrimitives.WriteInt32LittleEndian(record[10..], sharedStringIndex); + buffer.Advance(record.Length); } internal static void WriteBool(BiffBuffer buffer, int row, int col, int xf, bool value) { - int len = buffer.BeginRecord(BiffRecord.BoolErr); - WriteCellHeader(buffer, row, col, xf); - buffer.WriteByte((byte)(value ? 1 : 0)); - buffer.WriteByte(0); - buffer.EndRecord(len); + Span record = CellRecord(buffer, BiffRecord.BoolErr, 8, row, col, xf); + record[10] = (byte)(value ? 1 : 0); + record[11] = 0; + buffer.Advance(record.Length); } - private static void WriteCellHeader(BiffBuffer buffer, int row, int col, int xf) + /// Reserves a cell record and fills its header and row/col/xf; the caller writes the rest of the payload, then advances by the returned length. + private static Span CellRecord(BiffBuffer buffer, int id, int payload, int row, int col, int xf) { - buffer.WriteU16(row); - buffer.WriteU16(col); - buffer.WriteU16(xf); + Span record = buffer.GetSpan(4 + payload)[..(4 + payload)]; + BinaryPrimitives.WriteUInt16LittleEndian(record, (ushort)id); + BinaryPrimitives.WriteUInt16LittleEndian(record[2..], (ushort)payload); + BinaryPrimitives.WriteUInt16LittleEndian(record[4..], (ushort)row); + BinaryPrimitives.WriteUInt16LittleEndian(record[6..], (ushort)col); + BinaryPrimitives.WriteUInt16LittleEndian(record[8..], (ushort)xf); + return record; } + private static void WriteU16Record(BiffBuffer buffer, int id, int value) { int len = buffer.BeginRecord(id); From 69b2c3270c5d1d80f369e0568eaa494b10bada59 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Mon, 12 Oct 2026 23:39:35 -0300 Subject: [PATCH 03/42] feat: implement RK and MULRK record handling for improved number encoding in XLS --- src/ExcelReader.Core/Writer/Xls/BiffRecord.cs | 2 + .../Writer/Xls/BiffRecordWriter.cs | 39 ++++++++ src/ExcelReader.Core/Writer/Xls/XlsRk.cs | 65 +++++++++++++ .../Writer/Xls/XlsSheetWriter.cs | 44 ++++++++- .../Writer/Xls/XlsConformanceTests.cs | 91 +++++++++++++++++++ 5 files changed, 239 insertions(+), 2 deletions(-) create mode 100644 src/ExcelReader.Core/Writer/Xls/XlsRk.cs diff --git a/src/ExcelReader.Core/Writer/Xls/BiffRecord.cs b/src/ExcelReader.Core/Writer/Xls/BiffRecord.cs index c2cdc87d..50eeb169 100644 --- a/src/ExcelReader.Core/Writer/Xls/BiffRecord.cs +++ b/src/ExcelReader.Core/Writer/Xls/BiffRecord.cs @@ -31,6 +31,8 @@ internal static class BiffRecord internal const int Number = 0x0203; internal const int Label = 0x0204; internal const int LabelSst = 0x00FD; + internal const int Rk = 0x027E; + internal const int MulRk = 0x00BD; internal const int BoolErr = 0x0205; internal const int Index = 0x020B; internal const int Row = 0x0208; diff --git a/src/ExcelReader.Core/Writer/Xls/BiffRecordWriter.cs b/src/ExcelReader.Core/Writer/Xls/BiffRecordWriter.cs index 857aaa0c..73adeb96 100644 --- a/src/ExcelReader.Core/Writer/Xls/BiffRecordWriter.cs +++ b/src/ExcelReader.Core/Writer/Xls/BiffRecordWriter.cs @@ -303,6 +303,45 @@ internal static void WriteNumber(BiffBuffer buffer, int row, int col, int xf, do buffer.Advance(record.Length); } + /// + /// Opens a run of RK cells: header, row and first column now, then 6 bytes (ixfe, RK) per cell. + /// A run of one is an RK record, whose layout is exactly that; a longer run is a MULRK record, + /// which only adds the last column. fixes the id and length either way. + /// + internal static int BeginRkRun(BiffBuffer buffer, int row, int firstCol) + { + int start = buffer.Length; + Span header = buffer.GetSpan(8); + BinaryPrimitives.WriteUInt16LittleEndian(header[4..], (ushort)row); + BinaryPrimitives.WriteUInt16LittleEndian(header[6..], (ushort)firstCol); + buffer.Advance(8); + return start; + } + + internal static void AppendRk(BiffBuffer buffer, int xf, int rk) + { + Span cell = buffer.GetSpan(6); + BinaryPrimitives.WriteUInt16LittleEndian(cell, (ushort)xf); + BinaryPrimitives.WriteInt32LittleEndian(cell[2..], rk); + buffer.Advance(6); + } + + internal static void EndRkRun(BiffBuffer buffer, int start, int firstCol, int count) + { + Span header = buffer.Slice(start, 4); + if (count == 1) + { + BinaryPrimitives.WriteUInt16LittleEndian(header, BiffRecord.Rk); + BinaryPrimitives.WriteUInt16LittleEndian(header[2..], 10); + return; + } + BinaryPrimitives.WriteUInt16LittleEndian(header, BiffRecord.MulRk); + BinaryPrimitives.WriteUInt16LittleEndian(header[2..], (ushort)(4 + (6 * count) + 2)); + Span last = buffer.GetSpan(2); + BinaryPrimitives.WriteUInt16LittleEndian(last, (ushort)(firstCol + count - 1)); + buffer.Advance(2); + } + internal static void WriteLabel(BiffBuffer buffer, int row, int col, int xf, ReadOnlySpan value) { Span dest = buffer.GetSpan(13 + (value.Length * 2)); diff --git a/src/ExcelReader.Core/Writer/Xls/XlsRk.cs b/src/ExcelReader.Core/Writer/Xls/XlsRk.cs new file mode 100644 index 00000000..ee5ce5d7 --- /dev/null +++ b/src/ExcelReader.Core/Writer/Xls/XlsRk.cs @@ -0,0 +1,65 @@ +namespace ExcelReader.Core.Writer.Xls +{ + /// + /// Encodes a number as an [MS-XLS] RkNumber: a 30-bit integer, or the top 30 bits of a double, either + /// one optionally divided by 100. A value is encoded only when decoding gives back the same bits, so + /// RK never changes a number; anything else stays a NUMBER record. + /// + internal static class XlsRk + { + private const double IntLimit = 1 << 29; + private const long LowBits = 0x3_FFFF_FFFF; + + internal static bool TryEncode(double value, out int rk) + { + long bits = BitConverter.DoubleToInt64Bits(value); + if (Math.Abs(value) < IntLimit) + { + int whole = (int)value; + if (whole == value && bits != long.MinValue) + { + rk = (whole << 2) | 2; + return true; + } + } + if ((bits & LowBits) == 0) + { + rk = (int)(bits >> 32) & ~3; + return true; + } + + // Only these two forms divide by 100 on decoding, so only they need decoding to prove exact. + // value * 100 can miss an integer by an ulp (0.07 * 100) while n / 100 still gives value back. + double scaled = value * 100; + if (Math.Abs(scaled) < IntLimit) + { + double hundredths = Math.Round(scaled); + if (Math.Abs(scaled - hundredths) < 1e-6 && Matches((((int)hundredths) << 2) | 3, bits, out rk)) + { + return true; + } + } + long scaledBits = BitConverter.DoubleToInt64Bits(scaled); + if ((scaledBits & LowBits) == 0 && Matches(((int)(scaledBits >> 32) & ~3) | 1, bits, out rk)) + { + return true; + } + rk = 0; + return false; + } + + internal static double Decode(int rk) + { + double value = (rk & 0x02) != 0 + ? rk >> 2 + : BitConverter.Int64BitsToDouble((long)((ulong)(uint)(rk & ~3) << 32)); + return (rk & 0x01) != 0 ? value / 100.0 : value; + } + + private static bool Matches(int candidate, long bits, out int rk) + { + rk = candidate; + return BitConverter.DoubleToInt64Bits(Decode(candidate)) == bits; + } + } +} diff --git a/src/ExcelReader.Core/Writer/Xls/XlsSheetWriter.cs b/src/ExcelReader.Core/Writer/Xls/XlsSheetWriter.cs index 0a82d080..8f517320 100644 --- a/src/ExcelReader.Core/Writer/Xls/XlsSheetWriter.cs +++ b/src/ExcelReader.Core/Writer/Xls/XlsSheetWriter.cs @@ -36,6 +36,12 @@ public sealed class XlsSheetWriter : ISheetWriter private int _rowCellStart; private int _rowColMic; private int _rowColMax; + + // Numbers in consecutive columns of the current row are appended to one open MULRK record, ended + // by a gap or another kind of cell; see BiffRecordWriter.BeginRkRun. + private int _rkRecord; + private int _rkCount; + private int _rkFirstCol; private WriterState _state = WriterState.Created; private bool _rowActive; private bool _rowHasCells; @@ -175,6 +181,7 @@ public XlsRowWriter StartRow(int styleId) /// A row left without cells gets one blank cell, since BIFF8 has no other trace of it for a reader to find. internal void NotifyRowEnded() { + FlushRkRun(); if (_rowActive && !_rowHasCells) { BiffRecordWriter.WriteBlank(_cells, _rowNumber, 0, XlsGlobals.GeneralXf); @@ -193,7 +200,7 @@ internal void EmitNumber(int row, int col, double value) { ValidateColumn(col); int abstractStyle = EffectiveStyle(col); - BiffRecordWriter.WriteNumber(_cells, row, col, abstractStyle == 0 ? XlsGlobals.GeneralXf : XlsGlobals.CustomXf(abstractStyle), value); + WriteNumberCell(row, col, abstractStyle == 0 ? XlsGlobals.GeneralXf : XlsGlobals.CustomXf(abstractStyle), value); Track(row, col); } @@ -202,7 +209,7 @@ internal void EmitDate(int row, int col, DateTime value) ValidateColumn(col); int abstractStyle = EffectiveStyle(col); double serial = ExcelEpoch.OADateToSerial(value.ToOADate(), _date1904); - BiffRecordWriter.WriteNumber(_cells, row, col, abstractStyle == 0 ? XlsGlobals.DateXf : XlsGlobals.CustomXf(abstractStyle), serial); + WriteNumberCell(row, col, abstractStyle == 0 ? XlsGlobals.DateXf : XlsGlobals.CustomXf(abstractStyle), serial); Track(row, col); } @@ -212,6 +219,7 @@ internal void EmitLabel(int row, int col, ReadOnlySpan value) ExcelLimits.ThrowIfCellTextTooLong(value.Length, nameof(value)); int abstractStyle = EffectiveStyle(col); int xf = abstractStyle == 0 ? XlsGlobals.GeneralXf : XlsGlobals.CustomXf(abstractStyle); + FlushRkRun(); if (value.Length > XlsSharedStrings.MaxLabelLength) { BiffRecordWriter.WriteLabelSst(_cells, row, col, xf, _owner.AddSharedString(value)); @@ -227,10 +235,42 @@ internal void EmitBool(int row, int col, bool value) { ValidateColumn(col); int abstractStyle = EffectiveStyle(col); + FlushRkRun(); BiffRecordWriter.WriteBool(_cells, row, col, abstractStyle == 0 ? XlsGlobals.GeneralXf : XlsGlobals.CustomXf(abstractStyle), value); Track(row, col); } + private void WriteNumberCell(int row, int col, int xf, double value) + { + CellValueGuards.ThrowIfNonFinite(value, nameof(value)); + if (!XlsRk.TryEncode(value, out int rk)) + { + FlushRkRun(); + BiffRecordWriter.WriteNumber(_cells, row, col, xf, value); + return; + } + if (_rkCount > 0 && col != _rkFirstCol + _rkCount) + { + FlushRkRun(); + } + if (_rkCount == 0) + { + _rkFirstCol = col; + _rkRecord = BiffRecordWriter.BeginRkRun(_cells, row, col); + } + BiffRecordWriter.AppendRk(_cells, xf, rk); + _rkCount++; + } + + private void FlushRkRun() + { + if (_rkCount > 0) + { + BiffRecordWriter.EndRkRun(_cells, _rkRecord, _rkFirstCol, _rkCount); + _rkCount = 0; + } + } + internal void ReleaseBuffer() { _cells.Dispose(); diff --git a/tests/ExcelReader.Tests/Writer/Xls/XlsConformanceTests.cs b/tests/ExcelReader.Tests/Writer/Xls/XlsConformanceTests.cs index a970d84a..28df75d0 100644 --- a/tests/ExcelReader.Tests/Writer/Xls/XlsConformanceTests.cs +++ b/tests/ExcelReader.Tests/Writer/Xls/XlsConformanceTests.cs @@ -1,4 +1,5 @@ using System.Buffers.Binary; +using System.Diagnostics.CodeAnalysis; using ExcelReader.Core.Crypto; using ExcelReader.Core.Reader; using ExcelReader.Core.Writer.Xls; @@ -25,6 +26,9 @@ public class XlsConformanceTests private const int LabelSst = 0x00FD; private const int Sst = 0x00FC; private const int ExtSst = 0x00FF; + private const int Rk = 0x027E; + private const int MulRk = 0x00BD; + private const int Number = 0x0203; private readonly record struct Record(int Position, int Id, byte[] Payload); @@ -128,6 +132,93 @@ public void GlobalsRecordsHaveTheirSpecifiedShape() Assert.Equal([0, 0], records.Single(r => r.Id == Date1904).Payload); } + [Theory] + [InlineData(0.0, true)] + [InlineData(42.0, true)] + [InlineData(-536870912.0, true)] + [InlineData(536870912.0, true)] + [InlineData(1.5, true)] + [InlineData(0.1, true)] + [InlineData(45292.25, true)] + [InlineData(1234.56, true)] + [InlineData(0.5, true)] + [InlineData(Math.PI, false)] + [InlineData(1e-300, false)] + [InlineData(123456789.123, false)] + public void RkEncodesOnlyNumbersItGivesBackUnchanged(double value, bool encodable) + { + Assert.Equal(encodable, XlsRk.TryEncode(value, out int rk)); + if (encodable) + { + Assert.Equal(BitConverter.DoubleToInt64Bits(value), BitConverter.DoubleToInt64Bits(XlsRk.Decode(rk))); + } + } + + [Fact] + [SuppressMessage("Security", "CA5394:Do not use insecure randomness", + Justification = "A seeded generator picks reproducible test inputs; nothing here is secret.")] + public void RkNeverChangesAValue() + { + Assert.True(XlsRk.TryEncode(-0.0, out int negativeZero)); + Assert.Equal(BitConverter.DoubleToInt64Bits(-0.0), BitConverter.DoubleToInt64Bits(XlsRk.Decode(negativeZero))); + + var random = new Random(1234); + for (int i = 0; i < 100_000; i++) + { + double value = (i % 4) switch + { + 0 => random.Next(-1_000_000_000, 1_000_000_000), + 1 => Math.Round(random.NextDouble() * 1e7, 2), + 2 => random.NextDouble() * Math.Pow(10, random.Next(-20, 20)), + _ => BitConverter.Int64BitsToDouble(random.NextInt64() & ~0x3_FFFF_FFFFL), + }; + if (double.IsFinite(value) && XlsRk.TryEncode(value, out int rk)) + { + Assert.Equal(BitConverter.DoubleToInt64Bits(value), BitConverter.DoubleToInt64Bits(XlsRk.Decode(rk))); + } + } + } + + [Fact] + public void ConsecutiveNumbersShareOneMulRkRecord() + { + byte[] xls = Write(wb => + { + using var sheet = wb.AddSheet("S"); + using var row = sheet.StartRow(); + row.Write(1); + row.Write(2.5); + row.Write(new DateTime(2026, 1, 2)); + row.Write("x"); + row.Write(3); + row.Skip(1); + row.Write(4); + row.Write(Math.PI); + }); + + List cells = Worksheet(Records(xls)) + .Where(r => r.Id is MulRk or Rk or Number or Label) + .Select(r => r.Id) + .ToList(); + Assert.Equal([MulRk, Label, Rk, Rk, Number], cells); + + using var reader = Excel.FromXls(new MemoryStream(xls)); + using var rows = reader.FirstSheet.GetEnumerator(); + Assert.True(rows.MoveNext()); + Row read = rows.Current; + Assert.Equal("1", read[0].GetString()); + Assert.Equal("2.5", read[1].GetString()); + Assert.Equal(CellType.Date, read[2].Type); + Assert.True(read[2].TryGetDateTime(isDate1904: false, out DateTime date)); + Assert.Equal(new DateTime(2026, 1, 2), date); + Assert.Equal("x", read[3].GetString()); + Assert.Equal("3", read[4].GetString()); + Assert.Equal(CellType.Empty, read[5].Type); + Assert.Equal("4", read[6].GetString()); + Assert.True(read[7].TryParse(System.Globalization.CultureInfo.InvariantCulture, out double pi)); + Assert.Equal(Math.PI, pi); + } + [Fact] public void LongTextDifferingOnlyBetweenHashSamplesIsStillStoredOnce() { From 58ccf40d5e77f3c28d199a0a57826b9f9b70011b Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 00:02:39 -0300 Subject: [PATCH 04/42] refactor: streamline cell writing methods and enhance Rk number encoding --- .../Writer/Internal/CellFormatter.cs | 216 ++++++++---------- src/ExcelReader.Core/Writer/Xls/XlsRk.cs | 19 +- .../Writer/Xlsb/XlsbSheetWriter.cs | 20 +- .../Writer/Xlsb/XlsbWriterTests.cs | 16 +- 4 files changed, 115 insertions(+), 156 deletions(-) diff --git a/src/ExcelReader.Core/Writer/Internal/CellFormatter.cs b/src/ExcelReader.Core/Writer/Internal/CellFormatter.cs index d3616f74..cb9040d1 100644 --- a/src/ExcelReader.Core/Writer/Internal/CellFormatter.cs +++ b/src/ExcelReader.Core/Writer/Internal/CellFormatter.cs @@ -19,54 +19,73 @@ internal static class CellFormatter private static readonly SearchValues SpecialBytes = SearchValues.Create(Encoding.ASCII.GetBytes(Special)); - [SkipLocalsInit] - private static void WriteRef(BiffBuffer xml, int columnIndex, int rowNumber) - { - Span buf = stackalloc byte[10]; - int len = ColumnName.Write(buf, columnIndex); - Utf8Formatter.TryFormat(rowNumber, buf[len..], out int rowLen); - xml.Write(buf[..(len + rowLen)]); - } + // + private const int MaxCellOpenLength = 48; + // -2.2250738585072014E-308 + private const int MaxValueLength = 40; - private static void WriteCellOpen(BiffBuffer xml, int columnIndex, int rowNumber, bool includeReference, - int styleId, ReadOnlySpan typeAttr, bool selfClose) + /// + /// Writes the cell's start tag, through its closing >, at the start of + /// and returns its length. Cells are written whole into one span: separate small writes to the + /// buffer, each checking capacity, cost more than formatting the cell. + /// + private static int WriteCellOpen(Span dest, int columnIndex, int rowNumber, bool includeReference, + int styleId, ReadOnlySpan typeAttr) { - xml.Write(""u8 : ">"u8); + typeAttr.CopyTo(dest[pos..]); + pos += typeAttr.Length; + dest[pos++] = (byte)'>'; + return pos; } internal static void WriteEmpty(BiffBuffer xml, int columnIndex, int rowNumber, bool includeReference, int styleId = 0) { - WriteCellOpen(xml, columnIndex, rowNumber, includeReference, styleId, default, selfClose: true); + Span dest = xml.GetSpan(MaxCellOpenLength + 1); + int pos = WriteCellOpen(dest, columnIndex, rowNumber, includeReference, styleId, default); + "/>"u8.CopyTo(dest[(pos - 1)..]); + xml.Advance(pos + 1); } internal static void WriteString(BiffBuffer xml, ReadOnlySpan value, int columnIndex, int rowNumber, bool includeReference, int styleId = 0) { - WriteCellOpen(xml, columnIndex, rowNumber, includeReference, styleId, " t=\"inlineStr\""u8, selfClose: false); - xml.Write(HasEdgeWhitespace(value) ? ""u8 : ""u8); + ReadOnlySpan textOpen = HasEdgeWhitespace(value) ? ""u8 : ""u8; + Span dest = xml.GetSpan(MaxCellOpenLength + textOpen.Length); + int pos = WriteCellOpen(dest, columnIndex, rowNumber, includeReference, styleId, " t=\"inlineStr\""u8); + textOpen.CopyTo(dest[pos..]); + xml.Advance(pos + textOpen.Length); WriteEscaped(xml, value); xml.Write(""u8); } internal static void WritePlainUtf8String(BiffBuffer xml, ReadOnlySpan utf8, int columnIndex, int rowNumber, bool includeReference, int styleId = 0) { - WriteCellOpen(xml, columnIndex, rowNumber, includeReference, styleId, " t=\"inlineStr\""u8, selfClose: false); - xml.Write(""u8); - xml.Write(utf8); - xml.Write(""u8); + Span dest = xml.GetSpan(MaxCellOpenLength + 20 + utf8.Length); + int pos = WriteCellOpen(dest, columnIndex, rowNumber, includeReference, styleId, " t=\"inlineStr\""u8); + ""u8.CopyTo(dest[pos..]); + pos += 7; + utf8.CopyTo(dest[pos..]); + pos += utf8.Length; + ""u8.CopyTo(dest[pos..]); + xml.Advance(pos + 13); } /// @@ -97,101 +116,77 @@ private static bool HasEdgeWhitespace(ReadOnlySpan value) internal static void WriteSharedString(BiffBuffer xml, int sharedStringIndex, int columnIndex, int rowNumber, bool includeReference, int styleId = 0) { - WriteCellOpen(xml, columnIndex, rowNumber, includeReference, styleId, " t=\"s\""u8, selfClose: false); - xml.Write(""u8); - WriteValue(xml, sharedStringIndex, sizeHint: 16); - xml.Write(""u8); + Span dest = xml.GetSpan(MaxCellOpenLength + MaxValueLength); + int pos = WriteValueOpen(dest, columnIndex, rowNumber, includeReference, styleId, " t=\"s\""u8); + Utf8Formatter.TryFormat(sharedStringIndex, dest[pos..], out int written); + WriteValueClose(xml, dest, pos + written); } internal static void WriteBool(BiffBuffer xml, bool value, int columnIndex, int rowNumber, bool includeReference, int styleId = 0) { - WriteCellOpen(xml, columnIndex, rowNumber, includeReference, styleId, " t=\"b\""u8, selfClose: false); - xml.Write(""u8); - xml.WriteByte(value ? (byte)'1' : (byte)'0'); - xml.Write(""u8); + Span dest = xml.GetSpan(MaxCellOpenLength + MaxValueLength); + int pos = WriteValueOpen(dest, columnIndex, rowNumber, includeReference, styleId, " t=\"b\""u8); + dest[pos] = value ? (byte)'1' : (byte)'0'; + WriteValueClose(xml, dest, pos + 1); } 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), sizeHint: 32); - xml.Write(""u8); - } - - internal static void WriteNumber(BiffBuffer xml, T value, int columnIndex, int rowNumber, bool includeReference, int styleId = 0) - where T : IUtf8SpanFormattable - { - WriteCellOpen(xml, columnIndex, rowNumber, includeReference, styleId, default, selfClose: false); - xml.Write(""u8); - WriteValue(xml, value, sizeHint: 64); - xml.Write(""u8); - } - - internal static void WriteNumber(BiffBuffer xml, int value, int columnIndex, int rowNumber, bool includeReference, int styleId = 0) - { - WriteCellOpen(xml, columnIndex, rowNumber, includeReference, styleId, default, selfClose: false); - xml.Write(""u8); - WriteValue(xml, value, sizeHint: 16); - xml.Write(""u8); - } - - internal static void WriteNumber(BiffBuffer xml, long value, int columnIndex, int rowNumber, bool includeReference, int styleId = 0) - { - WriteCellOpen(xml, columnIndex, rowNumber, includeReference, styleId, default, selfClose: false); - xml.Write(""u8); - WriteValue(xml, value, sizeHint: 32); - xml.Write(""u8); + WriteNumber(xml, ExcelEpoch.OADateToSerial(value.ToOADate(), date1904), columnIndex, rowNumber, includeReference, styleId); } internal static void WriteNumber(BiffBuffer xml, double value, int columnIndex, int rowNumber, bool includeReference, int styleId = 0) { - WriteCellOpen(xml, columnIndex, rowNumber, includeReference, styleId, default, selfClose: false); - xml.Write(""u8); - WriteValue(xml, value, sizeHint: 32); - xml.Write(""u8); - } - - internal static void WriteNumber(BiffBuffer xml, decimal value, int columnIndex, int rowNumber, bool includeReference, int styleId = 0) - { - WriteCellOpen(xml, columnIndex, rowNumber, includeReference, styleId, default, selfClose: false); - xml.Write(""u8); - WriteValue(xml, value, sizeHint: 64); - xml.Write(""u8); + CellValueGuards.ThrowIfNonFinite(value, nameof(value)); + Span dest = xml.GetSpan(MaxCellOpenLength + MaxValueLength); + int pos = WriteValueOpen(dest, columnIndex, rowNumber, includeReference, styleId, default); + TryFormatDouble(value, dest[pos..], out int written); + WriteValueClose(xml, dest, pos + written); } - private static void WriteValue(BiffBuffer xml, int value, int sizeHint) + internal static void WriteNumber(BiffBuffer xml, T value, int columnIndex, int rowNumber, bool includeReference, int styleId = 0) + where T : IUtf8SpanFormattable { - int size = sizeHint; - int written; - while (!Utf8Formatter.TryFormat(value, xml.GetSpan(size), out written)) + if (typeof(T) == typeof(double)) + { + WriteNumber(xml, Unsafe.As(ref value), columnIndex, rowNumber, includeReference, styleId); + return; + } + if (typeof(T) == typeof(float)) + { + CellValueGuards.ThrowIfNonFinite(Unsafe.As(ref value), nameof(value)); + } + int size = 64; + while (true) { + Span dest = xml.GetSpan(MaxCellOpenLength + size); + int pos = WriteValueOpen(dest, columnIndex, rowNumber, includeReference, styleId, default); + if (value.TryFormat(dest[pos..^ValueCloseLength], out int written, default, CultureInfo.InvariantCulture)) + { + if (!CellValueGuards.IsAlwaysFinite() && typeof(T) != typeof(float)) + { + CellValueGuards.ThrowIfNotFiniteNumberText(dest.Slice(pos, written), typeof(T), nameof(value)); + } + WriteValueClose(xml, dest, pos + written); + return; + } size = checked(size * 2); } - xml.Advance(written); } - private static void WriteValue(BiffBuffer xml, long value, int sizeHint) + private const int ValueCloseLength = 8; + + private static int WriteValueOpen(Span dest, int columnIndex, int rowNumber, bool includeReference, int styleId, ReadOnlySpan typeAttr) { - int size = sizeHint; - int written; - while (!Utf8Formatter.TryFormat(value, xml.GetSpan(size), out written)) - { - size = checked(size * 2); - } - xml.Advance(written); + int pos = WriteCellOpen(dest, columnIndex, rowNumber, includeReference, styleId, typeAttr); + ""u8.CopyTo(dest[pos..]); + return pos + 3; } - private static void WriteValue(BiffBuffer xml, double value, int sizeHint) + private static void WriteValueClose(BiffBuffer xml, Span dest, int pos) { - CellValueGuards.ThrowIfNonFinite(value, nameof(value)); - int size = sizeHint; - int written; - while (!TryFormatDouble(value, xml.GetSpan(size), out written)) - { - size = checked(size * 2); - } - xml.Advance(written); + ""u8.CopyTo(dest[pos..]); + xml.Advance(pos + ValueCloseLength); } /// @@ -249,33 +244,6 @@ private static bool TryFormatScaled(long scaled, int scale, Span destinati return true; } - private static void WriteValue(BiffBuffer xml, T value, int sizeHint) - where T : IUtf8SpanFormattable - { - if (typeof(T) == typeof(double)) - { - WriteValue(xml, Unsafe.As(ref value), sizeHint); - return; - } - if (typeof(T) == typeof(float)) - { - CellValueGuards.ThrowIfNonFinite(Unsafe.As(ref value), nameof(value)); - } - int size = sizeHint; - int written; - Span destination = xml.GetSpan(size); - while (!value.TryFormat(destination, out written, default, CultureInfo.InvariantCulture)) - { - size = checked(size * 2); - destination = xml.GetSpan(size); - } - if (!CellValueGuards.IsAlwaysFinite() && typeof(T) != typeof(double) && typeof(T) != typeof(float)) - { - CellValueGuards.ThrowIfNotFiniteNumberText(destination[..written], typeof(T), nameof(value)); - } - xml.Advance(written); - } - internal static void WriteEscaped(BiffBuffer xml, ReadOnlySpan value) { int start = 0; diff --git a/src/ExcelReader.Core/Writer/Xls/XlsRk.cs b/src/ExcelReader.Core/Writer/Xls/XlsRk.cs index ee5ce5d7..2b3dbe5e 100644 --- a/src/ExcelReader.Core/Writer/Xls/XlsRk.cs +++ b/src/ExcelReader.Core/Writer/Xls/XlsRk.cs @@ -1,9 +1,9 @@ namespace ExcelReader.Core.Writer.Xls { /// - /// Encodes a number as an [MS-XLS] RkNumber: a 30-bit integer, or the top 30 bits of a double, either - /// one optionally divided by 100. A value is encoded only when decoding gives back the same bits, so - /// RK never changes a number; anything else stays a NUMBER record. + /// Encodes a number as an RkNumber, the same in [MS-XLS] and [MS-XLSB]: a 30-bit integer, or the top + /// 30 bits of a double, either one optionally divided by 100. A value is encoded only when decoding + /// gives back the same bits, so RK never changes a number; anything else keeps an 8-byte double. /// internal static class XlsRk { @@ -12,21 +12,22 @@ internal static class XlsRk internal static bool TryEncode(double value, out int rk) { + // Excel picks the truncated double whenever it is exact, so that form goes first. long bits = BitConverter.DoubleToInt64Bits(value); + if ((bits & LowBits) == 0) + { + rk = (int)(bits >> 32); + return true; + } if (Math.Abs(value) < IntLimit) { int whole = (int)value; - if (whole == value && bits != long.MinValue) + if (whole == value) { rk = (whole << 2) | 2; return true; } } - if ((bits & LowBits) == 0) - { - rk = (int)(bits >> 32) & ~3; - return true; - } // Only these two forms divide by 100 on decoding, so only they need decoding to prove exact. // value * 100 can miss an integer by an ulp (0.07 * 100) while n / 100 still gives value back. diff --git a/src/ExcelReader.Core/Writer/Xlsb/XlsbSheetWriter.cs b/src/ExcelReader.Core/Writer/Xlsb/XlsbSheetWriter.cs index 61baab2f..8cd8cf45 100644 --- a/src/ExcelReader.Core/Writer/Xlsb/XlsbSheetWriter.cs +++ b/src/ExcelReader.Core/Writer/Xlsb/XlsbSheetWriter.cs @@ -4,6 +4,7 @@ using ExcelReader.Core.Reader.Internal; using ExcelReader.Core.Reader.Xlsb; using ExcelReader.Core.Writer.Internal; +using ExcelReader.Core.Writer.Xls; namespace ExcelReader.Core.Writer.Xlsb { @@ -614,8 +615,6 @@ private void WriteCell(int columnIndex, XlsbCell cell) } private const int CellHeaderLength = 8; - private const int RkIntMin = -(1 << 29); - private const int RkIntMax = (1 << 29) - 1; internal void WriteStringCell(int columnIndex, string? value) { @@ -682,12 +681,12 @@ private void WriteDoubleCellCore(int columnIndex, double value, int style) ValidateColumn(columnIndex); CellValueGuards.ThrowIfNonFinite(value, nameof(value)); TrackColumn(columnIndex); - if (TryEncodeRk(value, out uint rk)) + if (XlsRk.TryEncode(value, out int rk)) { const int RkLength = CellHeaderLength + 4; Biff12RecordWriter.WriteFixedRecord(_records, Brt.CellRk, RkLength, out Span rkPayload); Biff12RecordWriter.WriteCellHeader(rkPayload, columnIndex, style); - BinaryPrimitives.WriteUInt32LittleEndian(rkPayload.Slice(8, 4), rk); + BinaryPrimitives.WriteInt32LittleEndian(rkPayload.Slice(8, 4), rk); MaybeFlush(); return; } @@ -698,19 +697,6 @@ private void WriteDoubleCellCore(int columnIndex, double value, int style) MaybeFlush(); } - private static bool TryEncodeRk(double value, out uint rk) - { - rk = 0; - if (value != Math.Truncate(value) || value < RkIntMin || value > RkIntMax - || (value == 0 && double.IsNegative(value))) - { - return false; - } - ulong bits = BitConverter.DoubleToUInt64Bits(value); - rk = (bits & 0x3_FFFF_FFFFUL) == 0 ? (uint)(bits >> 32) : ((uint)(int)value << 2) | 0x02; - return true; - } - private static void ValidateColumn(int columnIndex) { ExcelLimits.ThrowIfColumnOutOfRange(columnIndex); diff --git a/tests/ExcelReader.Tests/Writer/Xlsb/XlsbWriterTests.cs b/tests/ExcelReader.Tests/Writer/Xlsb/XlsbWriterTests.cs index 1e21e32c..1f3e1c9e 100644 --- a/tests/ExcelReader.Tests/Writer/Xlsb/XlsbWriterTests.cs +++ b/tests/ExcelReader.Tests/Writer/Xlsb/XlsbWriterTests.cs @@ -406,7 +406,11 @@ public async Task WriteOverflowingFormattableThrowsNamingTheSourceType() [InlineData(-1d)] [InlineData(536_870_911d)] [InlineData(-536_870_912d)] - public async Task IntegralNumberInRkRangeUsesFourByteRkCell(double value) + [InlineData(536_870_912d)] + [InlineData(0.5d)] + [InlineData(1234.56d)] + [InlineData(45_292.25d)] + public async Task NumberRkGivesBackExactlyUsesFourByteRkCell(double value) { (int Id, int PayloadLength)[] cells; double readBack; @@ -417,11 +421,11 @@ public async Task IntegralNumberInRkRangeUsesFourByteRkCell(double value) } [Theory] - [InlineData(536_870_912d)] [InlineData(-536_870_913d)] - [InlineData(0.5d)] + [InlineData(Math.PI)] + [InlineData(123_456_789.123d)] [InlineData(1e300d)] - public async Task NumberOutsideRkRangeKeepsEightByteRealCell(double value) + public async Task NumberRkWouldChangeKeepsEightByteRealCell(double value) { (int Id, int PayloadLength)[] cells; double readBack; @@ -432,13 +436,13 @@ public async Task NumberOutsideRkRangeKeepsEightByteRealCell(double value) } [Fact] - public async Task NegativeZeroKeepsRealCellSoItsSignSurvives() + public async Task NegativeZeroKeepsItsSignInAnRkCell() { (int Id, int PayloadLength)[] cells; double readBack; (cells, readBack) = await NumericCellAsync(-0.0); - Assert.Equal([(Brt.CellReal, CellHeaderLength + 8)], cells); + Assert.Equal([(Brt.CellRk, CellHeaderLength + 4)], cells); Assert.True(double.IsNegative(readBack)); } From 0eeff88e7f69919d3b47c53b5b51aa53aa05fa56 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 01:06:13 -0300 Subject: [PATCH 05/42] feat: add vectorized CRC-32 for the ZIP writer Co-Authored-By: Claude Haiku 5.5 --- src/ExcelReader.Core/Writer/Zip/Crc32.cs | 221 ++++++++++++++++++ .../Writer/Zip/Crc32Tests.cs | 95 ++++++++ 2 files changed, 316 insertions(+) create mode 100644 src/ExcelReader.Core/Writer/Zip/Crc32.cs create mode 100644 tests/ExcelReader.Tests/Writer/Zip/Crc32Tests.cs diff --git a/src/ExcelReader.Core/Writer/Zip/Crc32.cs b/src/ExcelReader.Core/Writer/Zip/Crc32.cs new file mode 100644 index 00000000..70937932 --- /dev/null +++ b/src/ExcelReader.Core/Writer/Zip/Crc32.cs @@ -0,0 +1,221 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Runtime.Intrinsics; +using System.Runtime.Intrinsics.X86; +using ArmCrc32 = System.Runtime.Intrinsics.Arm.Crc32; + +namespace ExcelReader.Core.Writer.Zip +{ + /// + /// The ZIP CRC-32: reflected polynomial 0xEDB88320, register inverted on entry and exit. + /// chains, so Append(Append(0, a), b) equals Append(0, a + b). + /// + /// + /// Long inputs are folded with carry-less multiplication, after Intel's "Fast CRC Computation for + /// Generic Polynomials Using PCLMULQDQ Instruction". The folded 128-bit remainder is finished by + /// running its 16 bytes through the byte table from a zero register. + /// + internal static class Crc32 + { + private const uint ReflectedPolynomial = 0xEDB8_8320; + private const uint Polynomial = 0x04C1_1DB7; + + private static readonly uint[] Table = BuildTable(); + private static readonly Vector128 Fold128By128 = FoldConstants(128); + private static readonly Vector128 Fold128By512 = FoldConstants(512); + private static readonly Vector256 Fold256By256 = Vector256.Create(FoldConstants(256), FoldConstants(256)); + private static readonly Vector256 Fold256By1024 = Vector256.Create(FoldConstants(1024), FoldConstants(1024)); + + internal static uint Append(uint crc, ReadOnlySpan data) + { + if (Pclmulqdq.V256.IsSupported && data.Length >= 256) + { + return AppendFold256(crc, data); + } + if (Pclmulqdq.IsSupported && data.Length >= 128) + { + return AppendFold128(crc, data); + } + if (ArmCrc32.Arm64.IsSupported) + { + return AppendArm64(crc, data); + } + return AppendTable(crc, data); + } + + internal static uint AppendTable(uint crc, ReadOnlySpan data) + { + return ~TableUpdate(~crc, data); + } + + internal static uint AppendArm64(uint crc, ReadOnlySpan data) + { + uint register = ~crc; + ref byte source = ref MemoryMarshal.GetReference(data); + int index = 0; + for (; index + 8 <= data.Length; index += 8) + { + register = ArmCrc32.Arm64.ComputeCrc32(register, Unsafe.ReadUnaligned(ref Unsafe.Add(ref source, index))); + } + for (; index < data.Length; index++) + { + register = ArmCrc32.ComputeCrc32(register, Unsafe.Add(ref source, index)); + } + return ~register; + } + + internal static uint AppendFold128(uint crc, ReadOnlySpan data) + { + if (data.Length < 64) + { + return AppendTable(crc, data); + } + uint register = Fold128(~crc, ref data); + return ~TableUpdate(register, data); + } + + internal static uint AppendFold256(uint crc, ReadOnlySpan data) + { + if (data.Length < 128) + { + return AppendTable(crc, data); + } + uint register = Fold256(~crc, ref data); + return ~TableUpdate(register, data); + } + + private static uint TableUpdate(uint register, ReadOnlySpan data) + { + ref uint table = ref MemoryMarshal.GetArrayDataReference(Table); + foreach (byte value in data) + { + register = Unsafe.Add(ref table, (byte)(register ^ value)) ^ (register >> 8); + } + return register; + } + + private static uint Fold256(uint register, ref ReadOnlySpan data) + { + ref byte source = ref MemoryMarshal.GetReference(data); + Vector256 by1024 = Fold256By1024; + Vector256 by256 = Fold256By256; + Vector256 x0 = Load256(ref source, 0) ^ Vector256.CreateScalar((ulong)register); + Vector256 x1 = Load256(ref source, 32); + Vector256 x2 = Load256(ref source, 64); + Vector256 x3 = Load256(ref source, 96); + int position = 128; + while (data.Length - position >= 128) + { + x0 = Fold(x0, by1024) ^ Load256(ref source, position); + x1 = Fold(x1, by1024) ^ Load256(ref source, position + 32); + x2 = Fold(x2, by1024) ^ Load256(ref source, position + 64); + x3 = Fold(x3, by1024) ^ Load256(ref source, position + 96); + position += 128; + } + x1 ^= Fold(x0, by256); + x2 ^= Fold(x1, by256); + x3 ^= Fold(x2, by256); + Vector128 folded = Fold(x3.GetLower(), Fold128By128) ^ x3.GetUpper(); + return Finish(folded, ref source, position, ref data); + } + + private static uint Fold128(uint register, ref ReadOnlySpan data) + { + ref byte source = ref MemoryMarshal.GetReference(data); + Vector128 by512 = Fold128By512; + Vector128 by128 = Fold128By128; + Vector128 x0 = Load128(ref source, 0) ^ Vector128.CreateScalar((ulong)register); + Vector128 x1 = Load128(ref source, 16); + Vector128 x2 = Load128(ref source, 32); + Vector128 x3 = Load128(ref source, 48); + int position = 64; + while (data.Length - position >= 64) + { + x0 = Fold(x0, by512) ^ Load128(ref source, position); + x1 = Fold(x1, by512) ^ Load128(ref source, position + 16); + x2 = Fold(x2, by512) ^ Load128(ref source, position + 32); + x3 = Fold(x3, by512) ^ Load128(ref source, position + 48); + position += 64; + } + x1 ^= Fold(x0, by128); + x2 ^= Fold(x1, by128); + Vector128 folded = x3 ^ Fold(x2, by128); + return Finish(folded, ref source, position, ref data); + } + + private static uint Finish(Vector128 folded, ref byte source, int position, ref ReadOnlySpan data) + { + while (data.Length - position >= 16) + { + folded = Fold(folded, Fold128By128) ^ Load128(ref source, position); + position += 16; + } + data = data[position..]; + Span remainder = stackalloc byte[16]; + folded.AsByte().CopyTo(remainder); + return TableUpdate(0, remainder); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Vector256 Fold(Vector256 lanes, Vector256 constants) + { + return Pclmulqdq.V256.CarrylessMultiply(lanes, constants, 0x00) ^ Pclmulqdq.V256.CarrylessMultiply(lanes, constants, 0x11); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Vector128 Fold(Vector128 lanes, Vector128 constants) + { + return Pclmulqdq.CarrylessMultiply(lanes, constants, 0x00) ^ Pclmulqdq.CarrylessMultiply(lanes, constants, 0x11); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Vector256 Load256(ref byte source, int offset) + { + return Vector256.LoadUnsafe(ref Unsafe.Add(ref source, offset)).AsUInt64(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Vector128 Load128(ref byte source, int offset) + { + return Vector128.LoadUnsafe(ref Unsafe.Add(ref source, offset)).AsUInt64(); + } + + // Moving a 128-bit lane forward by D bits multiplies its low qword by k(D + 32) and its high + // qword by k(D - 32), where k(e) = reflect32(x^e mod P) << 1. + private static Vector128 FoldConstants(int distanceBits) + { + return Vector128.Create(FoldConstant(distanceBits + 32), FoldConstant(distanceBits - 32)); + } + + private static ulong FoldConstant(int exponent) + { + uint remainder = 1; + for (int i = 0; i < exponent; i++) + { + uint carry = 0u - (remainder >> 31); + remainder = (remainder << 1) ^ (Polynomial & carry); + } + uint reflected = 0; + for (int bit = 0; bit < 32; bit++) + { + reflected = (reflected << 1) | ((remainder >> bit) & 1); + } + return (ulong)reflected << 1; + } + + private static uint[] BuildTable() + { + var table = new uint[256]; + for (uint index = 0; index < 256; index++) + { + uint entry = index; + for (int bit = 0; bit < 8; bit++) + { + entry = (entry >> 1) ^ (ReflectedPolynomial & (0u - (entry & 1))); + } + table[index] = entry; + } + return table; + } + } +} diff --git a/tests/ExcelReader.Tests/Writer/Zip/Crc32Tests.cs b/tests/ExcelReader.Tests/Writer/Zip/Crc32Tests.cs new file mode 100644 index 00000000..5ffb7267 --- /dev/null +++ b/tests/ExcelReader.Tests/Writer/Zip/Crc32Tests.cs @@ -0,0 +1,95 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.Intrinsics.X86; +using ExcelReader.Core.Writer.Zip; +using ArmCrc32 = System.Runtime.Intrinsics.Arm.Crc32; + +namespace ExcelReader.Tests.Writer.Zip +{ + public class Crc32Tests + { + private delegate uint AppendFunction(uint crc, ReadOnlySpan data); + + internal static uint BitwiseCrc(ReadOnlySpan data) + { + uint register = uint.MaxValue; + foreach (byte value in data) + { + register ^= value; + for (int bit = 0; bit < 8; bit++) + { + uint mask = 0u - (register & 1); + register = (register >> 1) ^ (0xEDB8_8320 & mask); + } + } + return ~register; + } + + private static List<(string Name, AppendFunction Append)> AvailablePaths() + { + var paths = new List<(string Name, AppendFunction Append)> + { + ("dispatch", Crc32.Append), + ("table", Crc32.AppendTable), + }; + if (Pclmulqdq.IsSupported) + { + paths.Add(("fold128", Crc32.AppendFold128)); + } + if (Pclmulqdq.V256.IsSupported) + { + paths.Add(("fold256", Crc32.AppendFold256)); + } + if (ArmCrc32.Arm64.IsSupported) + { + paths.Add(("arm64", Crc32.AppendArm64)); + } + return paths; + } + + [Fact] + public void EveryPathGivesTheStandardCheckValue() + { + foreach ((string name, AppendFunction append) in AvailablePaths()) + { + uint actual = append(0, "123456789"u8); + Assert.True(actual == 0xCBF4_3926, $"{name}: {actual:X8}"); + } + } + + [Fact] + public void EmptyInputLeavesTheCrcUnchanged() + { + foreach ((string name, AppendFunction append) in AvailablePaths()) + { + uint actual = append(0x1234_5678, []); + Assert.True(actual == 0x1234_5678, $"{name}: {actual:X8}"); + } + } + + [Fact] + [SuppressMessage("Security", "CA5394:Do not use insecure randomness", + Justification = "A seeded generator picks reproducible test inputs; nothing here is secret.")] + public void EveryPathMatchesTheBitwiseDefinitionWholeAndSplit() + { + var random = new Random(5); + byte[] buffer = new byte[70_064]; + random.NextBytes(buffer); + List<(string Name, AppendFunction Append)> paths = AvailablePaths(); + for (int trial = 0; trial < 900; trial++) + { + int start = random.Next(0, 64); + int length = trial < 600 ? trial : random.Next(600, 70_000); + ReadOnlySpan slice = buffer.AsSpan(start, length); + uint expected = BitwiseCrc(slice); + int cut = random.Next(0, length + 1); + foreach ((string name, AppendFunction append) in paths) + { + uint whole = append(0, slice); + uint split = append(append(0, slice[..cut]), slice[cut..]); + Assert.True(whole == expected, $"{name} whole, length {length}: {whole:X8} != {expected:X8}"); + Assert.True(split == expected, $"{name} split at {cut} of {length}: {split:X8} != {expected:X8}"); + } + } + } + } +} From 35193f5c9a23c7cecf3ebd6d8a06e72d3e4bc3b9 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 01:10:01 -0300 Subject: [PATCH 06/42] feat: add streaming fixed-Huffman deflate encoder --- .../Writer/Zip/DeflateEncoder.cs | 247 ++++++++++++++++++ .../Writer/Zip/FixedHuffmanCodes.cs | 104 ++++++++ .../Writer/Zip/DeflateEncoderTests.cs | 176 +++++++++++++ 3 files changed, 527 insertions(+) create mode 100644 src/ExcelReader.Core/Writer/Zip/DeflateEncoder.cs create mode 100644 src/ExcelReader.Core/Writer/Zip/FixedHuffmanCodes.cs create mode 100644 tests/ExcelReader.Tests/Writer/Zip/DeflateEncoderTests.cs diff --git a/src/ExcelReader.Core/Writer/Zip/DeflateEncoder.cs b/src/ExcelReader.Core/Writer/Zip/DeflateEncoder.cs new file mode 100644 index 00000000..28345c2f --- /dev/null +++ b/src/ExcelReader.Core/Writer/Zip/DeflateEncoder.cs @@ -0,0 +1,247 @@ +using System.Buffers; +using System.Buffers.Binary; +using System.Numerics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using ExcelReader.Core.Writer.Internal; + +namespace ExcelReader.Core.Writer.Zip +{ + /// + /// Raw deflate (RFC 1951) for CompressionLevel.Fastest: one hash candidate per position, a + /// 4-byte minimum match, no lazy matching and fixed Huffman codes. Input is encoded in blocks of at + /// most bytes, and a block the fixed codes would enlarge is written + /// stored instead. + /// + internal sealed class DeflateEncoder : IDisposable + { + internal const int MaxBlockLength = 65_535; + + private const int HashBits = 16; + private const int HistoryLength = 32_768; + private const int MinMatch = 4; + private const int MaxMatch = 258; + private const int WindowCapacity = HistoryLength + (1 << 20); + // Every byte a 9-bit literal, plus the block header, end-of-block, pending bits and a 4-byte flush. + private const int MaxEncodedBlock = (MaxBlockLength * 9 / 8) + 16; + + private byte[] _window; + private uint[] _table; + private int _encoded; + private int _end; + private uint _windowStart; + private ulong _bits; + private int _bitCount; + + internal DeflateEncoder() + { + _window = ArrayPool.Shared.Rent(WindowCapacity); + _table = ArrayPool.Shared.Rent(1 << HashBits); + Array.Clear(_table, 0, 1 << HashBits); + } + + internal void Write(ReadOnlySpan input, BiffBuffer output) + { + while (!input.IsEmpty) + { + if (_end == WindowCapacity) + { + Slide(); + } + int take = Math.Min(input.Length, WindowCapacity - _end); + input[..take].CopyTo(_window.AsSpan(_end)); + _end += take; + input = input[take..]; + while (_end - _encoded >= MaxBlockLength) + { + EncodeBlock(_encoded + MaxBlockLength, final: false, output); + } + } + } + + internal void Finish(BiffBuffer output) + { + EncodeBlock(_end, final: true, output); + Span tail = output.GetSpan(sizeof(ulong)); + BinaryPrimitives.WriteUInt64LittleEndian(tail, _bits); + output.Advance((_bitCount + 7) / 8); + _bits = 0; + _bitCount = 0; + } + + public void Dispose() + { + if (_window.Length == 0) + { + return; + } + ArrayPool.Shared.Return(_window); + ArrayPool.Shared.Return(_table); + _window = []; + _table = []; + } + + // Keeps the last 32 KB already encoded as history. The hash table holds absolute positions, so + // moving the window only moves _windowStart; it never rewrites the table. + private void Slide() + { + int keepFrom = Math.Max(0, _encoded - HistoryLength); + _window.AsSpan(keepFrom, _end - keepFrom).CopyTo(_window); + _windowStart += (uint)keepFrom; + _encoded -= keepFrom; + _end -= keepFrom; + } + + private void EncodeBlock(int blockEnd, bool final, BiffBuffer output) + { + Span destination = output.GetSpan(MaxEncodedBlock); + int length = blockEnd - _encoded; + var state = new BitState + { + Bits = _bits | ((final ? 3UL : 2UL) << _bitCount), + Count = _bitCount + 3, + }; + int next = EncodeMatches(blockEnd, destination, ref state); + EncodeLiterals(next, blockEnd, destination, ref state); + state.Count += 7; + FlushWord(destination, ref state); + long fixedBits = (8L * state.Written) + state.Count - _bitCount; + if (fixedBits <= StoredBits(length)) + { + output.Advance(state.Written); + _bits = state.Bits; + _bitCount = state.Count; + } + else + { + output.Advance(WriteStored(destination, length, final)); + } + _encoded = blockEnd; + } + + private long StoredBits(int length) + { + int padding = (8 - ((_bitCount + 3) & 7)) & 7; + return 3 + padding + 32 + (8L * length); + } + + private int WriteStored(Span destination, int length, bool final) + { + ulong header = _bits | ((final ? 1UL : 0UL) << _bitCount); + int headerBytes = (_bitCount + 3 + 7) / 8; + BinaryPrimitives.WriteUInt64LittleEndian(destination, header); + BinaryPrimitives.WriteUInt16LittleEndian(destination[headerBytes..], (ushort)length); + BinaryPrimitives.WriteUInt16LittleEndian(destination[(headerBytes + 2)..], (ushort)~length); + _window.AsSpan(_encoded, length).CopyTo(destination[(headerBytes + 4)..]); + _bits = 0; + _bitCount = 0; + return headerBytes + 4 + length; + } + + private int EncodeMatches(int blockEnd, Span destination, ref BitState state) + { + ref byte window = ref MemoryMarshal.GetArrayDataReference(_window); + ref uint table = ref MemoryMarshal.GetArrayDataReference(_table); + ref byte output = ref MemoryMarshal.GetReference(destination); + ref uint literals = ref MemoryMarshal.GetArrayDataReference(FixedHuffmanCodes.Literals); + ref uint lengths = ref MemoryMarshal.GetArrayDataReference(FixedHuffmanCodes.Lengths); + ref uint distances = ref MemoryMarshal.GetArrayDataReference(FixedHuffmanCodes.Distances); + ulong bits = state.Bits; + int count = state.Count; + int written = state.Written; + int index = _encoded; + int lastMatchStart = blockEnd - MinMatch; + while (index <= lastMatchStart) + { + uint current = Unsafe.ReadUnaligned(ref Unsafe.Add(ref window, index)); + ref uint slot = ref Unsafe.Add(ref table, (int)((current * 2654435761u) >> (32 - HashBits))); + uint position = _windowStart + (uint)index; + uint distance = position - slot; + slot = position; + if (distance - 1 < HistoryLength && distance <= (uint)index + && Unsafe.ReadUnaligned(ref Unsafe.Add(ref window, index - (int)distance)) == current) + { + int length = MatchLength(ref window, index - (int)distance, index, Math.Min(MaxMatch, blockEnd - index)); + uint lengthEntry = Unsafe.Add(ref lengths, length); + uint distanceEntry = Unsafe.Add(ref distances, (int)distance); + int lengthBits = (int)(lengthEntry & FixedHuffmanCodes.LengthMask); + ulong code = (lengthEntry >> FixedHuffmanCodes.LengthShift) + | ((ulong)(distanceEntry >> FixedHuffmanCodes.LengthShift) << lengthBits); + bits |= code << count; + count += lengthBits + (int)(distanceEntry & FixedHuffmanCodes.LengthMask); + index += length; + } + else + { + uint literal = Unsafe.Add(ref literals, Unsafe.Add(ref window, index)); + bits |= (ulong)(literal >> FixedHuffmanCodes.LengthShift) << count; + count += (int)(literal & FixedHuffmanCodes.LengthMask); + index++; + } + if (count >= 32) + { + Unsafe.WriteUnaligned(ref Unsafe.Add(ref output, written), (uint)bits); + written += 4; + bits >>= 32; + count -= 32; + } + } + state.Bits = bits; + state.Count = count; + state.Written = written; + return index; + } + + private void EncodeLiterals(int index, int blockEnd, Span destination, ref BitState state) + { + for (; index < blockEnd; index++) + { + uint literal = FixedHuffmanCodes.Literals[_window[index]]; + state.Bits |= (ulong)(literal >> FixedHuffmanCodes.LengthShift) << state.Count; + state.Count += (int)(literal & FixedHuffmanCodes.LengthMask); + FlushWord(destination, ref state); + } + } + + private static void FlushWord(Span destination, ref BitState state) + { + if (state.Count < 32) + { + return; + } + BinaryPrimitives.WriteUInt32LittleEndian(destination[state.Written..], (uint)state.Bits); + state.Written += 4; + state.Bits >>= 32; + state.Count -= 32; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int MatchLength(ref byte window, int candidate, int current, int maxLength) + { + int length = MinMatch; + while (length + 8 <= maxLength) + { + ulong difference = Unsafe.ReadUnaligned(ref Unsafe.Add(ref window, candidate + length)) + ^ Unsafe.ReadUnaligned(ref Unsafe.Add(ref window, current + length)); + if (difference != 0) + { + return length + (BitOperations.TrailingZeroCount(difference) >> 3); + } + length += 8; + } + while (length < maxLength && Unsafe.Add(ref window, candidate + length) == Unsafe.Add(ref window, current + length)) + { + length++; + } + return length; + } + + [StructLayout(LayoutKind.Auto)] + private struct BitState + { + internal ulong Bits; + internal int Count; + internal int Written; + } + } +} diff --git a/src/ExcelReader.Core/Writer/Zip/FixedHuffmanCodes.cs b/src/ExcelReader.Core/Writer/Zip/FixedHuffmanCodes.cs new file mode 100644 index 00000000..ebebf025 --- /dev/null +++ b/src/ExcelReader.Core/Writer/Zip/FixedHuffmanCodes.cs @@ -0,0 +1,104 @@ +namespace ExcelReader.Core.Writer.Zip +{ + /// + /// The fixed Huffman codes of RFC 1951 section 3.2.6, bit-reversed for LSB-first output. Each entry + /// packs the code, with any extra bits already appended above it, over a 5-bit total length. + /// + internal static class FixedHuffmanCodes + { + internal const int LengthShift = 5; + internal const uint LengthMask = 31; + + private static readonly int[] LengthBases = [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]; + private static readonly int[] LengthExtraBits = [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]; + private static readonly int[] DistanceBases = [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]; + private static readonly int[] DistanceExtraBits = [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]; + + /// Indexed by byte value. + internal static readonly uint[] Literals = BuildLiterals(); + + /// Indexed by match length, 3 to 258. + internal static readonly uint[] Lengths = BuildLengths(); + + /// Indexed by match distance, 1 to 32768. + internal static readonly uint[] Distances = BuildDistances(); + + private static uint[] BuildLiterals() + { + var table = new uint[256]; + for (int literal = 0; literal < table.Length; literal++) + { + table[literal] = Symbol(literal); + } + return table; + } + + private static uint[] BuildLengths() + { + var table = new uint[259]; + for (int index = 0; index < LengthBases.Length; index++) + { + uint symbol = Symbol(257 + index); + int count = Math.Min(1 << LengthExtraBits[index], table.Length - LengthBases[index]); + for (int extra = 0; extra < count; extra++) + { + table[LengthBases[index] + extra] = AppendExtra(symbol, extra, LengthExtraBits[index]); + } + } + return table; + } + + private static uint[] BuildDistances() + { + var table = new uint[32_769]; + for (int index = 0; index < DistanceBases.Length; index++) + { + uint code = Pack(Reverse((uint)index, 5), 5); + for (int extra = 0; extra < 1 << DistanceExtraBits[index]; extra++) + { + table[DistanceBases[index] + extra] = AppendExtra(code, extra, DistanceExtraBits[index]); + } + } + return table; + } + + private static uint Symbol(int symbol) + { + if (symbol < 144) + { + return Pack(Reverse((uint)(0x30 + symbol), 8), 8); + } + if (symbol < 256) + { + return Pack(Reverse((uint)(0x190 + symbol - 144), 9), 9); + } + if (symbol < 280) + { + return Pack(Reverse((uint)(symbol - 256), 7), 7); + } + return Pack(Reverse((uint)(0xC0 + symbol - 280), 8), 8); + } + + private static uint AppendExtra(uint packed, int extra, int extraBits) + { + int length = (int)(packed & LengthMask); + uint code = (packed >> LengthShift) | ((uint)extra << length); + return Pack(code, length + extraBits); + } + + private static uint Pack(uint code, int length) + { + return (code << LengthShift) | (uint)length; + } + + private static uint Reverse(uint code, int length) + { + uint reversed = 0; + for (int bit = 0; bit < length; bit++) + { + reversed = (reversed << 1) | ((code >> bit) & 1); + } + return reversed; + } + } +} diff --git a/tests/ExcelReader.Tests/Writer/Zip/DeflateEncoderTests.cs b/tests/ExcelReader.Tests/Writer/Zip/DeflateEncoderTests.cs new file mode 100644 index 00000000..0892ca26 --- /dev/null +++ b/tests/ExcelReader.Tests/Writer/Zip/DeflateEncoderTests.cs @@ -0,0 +1,176 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.IO.Compression; +using System.Text; +using ExcelReader.Core.Reader.Zip.Inflate; +using ExcelReader.Core.Writer.Internal; +using ExcelReader.Core.Writer.Zip; + +namespace ExcelReader.Tests.Writer.Zip +{ + public class DeflateEncoderTests + { + internal static byte[] Encode(ReadOnlySpan input, Func nextChunk) + { + using var encoder = new DeflateEncoder(); + using var output = new BiffBuffer(4096); + while (!input.IsEmpty) + { + int take = Math.Clamp(nextChunk(input.Length), 1, input.Length); + encoder.Write(input[..take], output); + input = input[take..]; + } + encoder.Finish(output); + return output.Span.ToArray(); + } + + internal static byte[] SheetXml(int rows) + { + var builder = new StringBuilder(); + for (int row = 1; row <= rows; row++) + { + builder.Append(CultureInfo.InvariantCulture, + $"item {row % 97}{row}{row * 1.25}"); + } + return Encoding.UTF8.GetBytes(builder.ToString()); + } + + private static byte[] Inflate(byte[] compressed, bool library) + { + using var source = new MemoryStream(compressed); + Stream inflate; + if (library) + { + inflate = new InflateStream(source); + } + else + { + inflate = new DeflateStream(source, CompressionMode.Decompress); + } + using (inflate) + { + using var output = new MemoryStream(); + inflate.CopyTo(output); + return output.ToArray(); + } + } + + private static void AssertRoundTrips(byte[] input, byte[] compressed) + { + byte[] viaZlib = Inflate(compressed, library: false); + byte[] viaLibrary = Inflate(compressed, library: true); + Assert.True(viaZlib.AsSpan().SequenceEqual(input), $"DeflateStream gave {viaZlib.Length} bytes for {input.Length}"); + Assert.True(viaLibrary.AsSpan().SequenceEqual(input), $"InflateStream gave {viaLibrary.Length} bytes for {input.Length}"); + } + + [SuppressMessage("Security", "CA5394:Do not use insecure randomness", + Justification = "A seeded generator picks reproducible test inputs; nothing here is secret.")] + private static byte[] RandomBytes(int length, int seed) + { + byte[] bytes = new byte[length]; + new Random(seed).NextBytes(bytes); + return bytes; + } + + [Fact] + public void EmptyInputIsOneEmptyFinalBlock() + { + byte[] compressed = Encode([], static _ => 1); + Assert.Equal([0x03, 0x00], compressed); + AssertRoundTrips([], compressed); + } + + [Fact] + public void SingleByteRoundTrips() + { + byte[] input = [(byte)'x']; + AssertRoundTrips(input, Encode(input, static _ => 1)); + } + + [Fact] + public void SheetXmlRoundTripsAndShrinks() + { + byte[] input = SheetXml(50_000); + byte[] compressed = Encode(input, static _ => 64 * 1024); + AssertRoundTrips(input, compressed); + Assert.True(compressed.Length < input.Length / 4, $"{compressed.Length} of {input.Length}"); + } + + [Fact] + public void TenMegabytesOfZerosUseLongMatches() + { + byte[] input = new byte[10 << 20]; + byte[] compressed = Encode(input, static _ => 1 << 20); + AssertRoundTrips(input, compressed); + Assert.True(compressed.Length < input.Length / 50, $"{compressed.Length} of {input.Length}"); + } + + [Fact] + public void RandomBytesFallBackToStoredBlocks() + { + byte[] input = RandomBytes(1 << 20, seed: 3); + byte[] compressed = Encode(input, static _ => 64 * 1024); + AssertRoundTrips(input, compressed); + long bound = input.Length + (5L * ((input.Length / DeflateEncoder.MaxBlockLength) + 1)) + 16; + Assert.True(compressed.Length <= bound, $"{compressed.Length} > {bound}"); + } + + [Fact] + public void CjkTextRoundTrips() + { + var builder = new StringBuilder(); + for (int i = 0; i < 20_000; i++) + { + builder.Append(CultureInfo.InvariantCulture, $"日本語のテキスト{i % 313}、中文文本。"); + } + byte[] input = Encoding.UTF8.GetBytes(builder.ToString()); + byte[] compressed = Encode(input, static _ => 4096); + AssertRoundTrips(input, compressed); + Assert.True(compressed.Length < input.Length, $"{compressed.Length} of {input.Length}"); + } + + [Fact] + [SuppressMessage("Security", "CA5394:Do not use insecure randomness", + Justification = "A seeded generator picks reproducible test inputs; nothing here is secret.")] + public void RandomWriteSizesRoundTrip() + { + byte[] input = [.. SheetXml(30_000), .. RandomBytes(300_000, seed: 4), .. SheetXml(30_000)]; + var random = new Random(9); + AssertRoundTrips(input, Encode(input, _ => random.Next(1, 1 << 20))); + } + + [Fact] + public void OneHugeWriteSlidesTheWindowMidWrite() + { + byte[] input = SheetXml(120_000); + Assert.True(input.Length > 4 << 20); + AssertRoundTrips(input, Encode(input, static remaining => remaining)); + } + + [Fact] + public void ByteAtATimeWritesRoundTrip() + { + byte[] input = SheetXml(3_000); + AssertRoundTrips(input, Encode(input, static _ => 1)); + } + + [Fact] + public void MatchesAtAndPastTheWindowEdgeRoundTrip() + { + byte[] history = RandomBytes(32_768, seed: 6); + byte[] input = [.. history, .. history.AsSpan(0, 1_000), .. history]; + AssertRoundTrips(input, Encode(input, static _ => 64 * 1024)); + } + + [Fact] + public void FixedCodesFollowRfc1951() + { + Assert.Equal(0x188u, FixedHuffmanCodes.Literals[0]); + Assert.Equal(0x269u, FixedHuffmanCodes.Literals[144]); + Assert.Equal(0x807u, FixedHuffmanCodes.Lengths[3]); + Assert.Equal(0x1468u, FixedHuffmanCodes.Lengths[258]); + Assert.Equal(0x5u, FixedHuffmanCodes.Distances[1]); + Assert.Equal(0x7FFEF2u, FixedHuffmanCodes.Distances[32_768]); + } + } +} From f347c900491e23bd21ef3815e82fbccc5a9607ca Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 01:15:25 -0300 Subject: [PATCH 07/42] fix: flush bit buffer at deflate block start --- .../Writer/Zip/DeflateEncoder.cs | 2 ++ .../Writer/Zip/DeflateEncoderTests.cs | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/ExcelReader.Core/Writer/Zip/DeflateEncoder.cs b/src/ExcelReader.Core/Writer/Zip/DeflateEncoder.cs index 28345c2f..e065cf1d 100644 --- a/src/ExcelReader.Core/Writer/Zip/DeflateEncoder.cs +++ b/src/ExcelReader.Core/Writer/Zip/DeflateEncoder.cs @@ -101,6 +101,8 @@ private void EncodeBlock(int blockEnd, bool final, BiffBuffer output) Bits = _bits | ((final ? 3UL : 2UL) << _bitCount), Count = _bitCount + 3, }; + // Up to 31 pending bits plus the 3 header bits can exceed 32; flush so a 31-bit symbol still fits. + FlushWord(destination, ref state); int next = EncodeMatches(blockEnd, destination, ref state); EncodeLiterals(next, blockEnd, destination, ref state); state.Count += 7; diff --git a/tests/ExcelReader.Tests/Writer/Zip/DeflateEncoderTests.cs b/tests/ExcelReader.Tests/Writer/Zip/DeflateEncoderTests.cs index 0892ca26..2427c0cc 100644 --- a/tests/ExcelReader.Tests/Writer/Zip/DeflateEncoderTests.cs +++ b/tests/ExcelReader.Tests/Writer/Zip/DeflateEncoderTests.cs @@ -162,6 +162,26 @@ public void MatchesAtAndPastTheWindowEdgeRoundTrip() AssertRoundTrips(input, Encode(input, static _ => 64 * 1024)); } + [Fact] + public void FarMatchAtBlockStartSurvivesPendingBits() + { + byte[] run = RandomBytes(300, seed: 1); + run[0] = 0xAB; + for (int prefix = 0; prefix < 256; prefix++) + { + foreach (int distance in new[] { 30_000, 22_000, 18_000 }) + { + byte[] input = new byte[DeflateEncoder.MaxBlockLength + 2_000]; + RandomBytes(prefix, seed: prefix).CopyTo(input, 0); + run.CopyTo(input, DeflateEncoder.MaxBlockLength - distance); + run.AsSpan(0, 200).CopyTo(input.AsSpan(DeflateEncoder.MaxBlockLength)); + RandomBytes(1_800, seed: 1_000 + prefix).CopyTo(input, DeflateEncoder.MaxBlockLength + 200); + input[DeflateEncoder.MaxBlockLength + 200] = (byte)(run[200] ^ 0xFF); + AssertRoundTrips(input, Encode(input, static remaining => remaining)); + } + } + } + [Fact] public void FixedCodesFollowRfc1951() { From 9edc304d29600c099f90e382165db0fbc81799e9 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 01:20:24 -0300 Subject: [PATCH 08/42] feat: add ZIP package writer mirroring ZipArchive's layout with Zip64 Co-Authored-By: Claude Sonnet 5.5 --- .../Writer/Zip/ZipEntryRecord.cs | 71 +++ .../Writer/Zip/ZipEntryStream.cs | 416 ++++++++++++++++++ .../Writer/Zip/ZipPackageWriter.cs | 133 ++++++ src/ExcelReader.Core/Writer/Zip/ZipRecords.cs | 204 +++++++++ .../Writer/Zip/ZipPackageWriterTests.cs | 355 +++++++++++++++ .../Writer/Zip/ZipTestSupport.cs | 221 ++++++++++ 6 files changed, 1400 insertions(+) create mode 100644 src/ExcelReader.Core/Writer/Zip/ZipEntryRecord.cs create mode 100644 src/ExcelReader.Core/Writer/Zip/ZipEntryStream.cs create mode 100644 src/ExcelReader.Core/Writer/Zip/ZipPackageWriter.cs create mode 100644 src/ExcelReader.Core/Writer/Zip/ZipRecords.cs create mode 100644 tests/ExcelReader.Tests/Writer/Zip/ZipPackageWriterTests.cs create mode 100644 tests/ExcelReader.Tests/Writer/Zip/ZipTestSupport.cs diff --git a/src/ExcelReader.Core/Writer/Zip/ZipEntryRecord.cs b/src/ExcelReader.Core/Writer/Zip/ZipEntryRecord.cs new file mode 100644 index 00000000..5a4ab602 --- /dev/null +++ b/src/ExcelReader.Core/Writer/Zip/ZipEntryRecord.cs @@ -0,0 +1,71 @@ +using System.IO.Compression; +using System.Text; + +namespace ExcelReader.Core.Writer.Zip +{ + internal sealed class ZipEntryRecord + { + internal ZipEntryRecord(string name, CompressionLevel level, long offset, DateTime modified) + { + Name = Encoding.UTF8.GetBytes(name); + Level = level; + Offset = offset; + (Time, Date) = ZipRecords.DosDateTime(modified); + Flags = ZipRecords.LevelFlags(level); + if (!Ascii.IsValid(name)) + { + Flags |= ZipRecords.FlagUtf8Name; + } + Method = ZipRecords.MethodDeflate; + if (level == CompressionLevel.NoCompression) + { + Method = ZipRecords.MethodStored; + } + } + + internal byte[] Name { get; } + + internal CompressionLevel Level { get; } + + /// Absolute position of the local header in the output stream. + internal long Offset { get; } + + internal ushort Time { get; } + + internal ushort Date { get; } + + internal ushort Flags { get; set; } + + internal ushort Method { get; set; } + + internal uint Crc { get; set; } + + internal long CompressedSize { get; set; } + + internal long UncompressedSize { get; set; } + + internal int LocalHeaderLength + { + get + { + return ZipRecords.LocalHeaderLength + Name.Length; + } + } + + internal bool NeedsZip64Sizes + { + get + { + return CompressedSize >= uint.MaxValue || UncompressedSize >= uint.MaxValue; + } + } + + internal bool NeedsZip64Offset + { + get + { + return Offset >= uint.MaxValue; + } + } + } +} diff --git a/src/ExcelReader.Core/Writer/Zip/ZipEntryStream.cs b/src/ExcelReader.Core/Writer/Zip/ZipEntryStream.cs new file mode 100644 index 00000000..c9f873d1 --- /dev/null +++ b/src/ExcelReader.Core/Writer/Zip/ZipEntryStream.cs @@ -0,0 +1,416 @@ +using System.Diagnostics.CodeAnalysis; +using System.IO.Compression; +using ExcelReader.Core.Writer.Internal; + +namespace ExcelReader.Core.Writer.Zip +{ + /// + /// The write-only stream of one entry. It checksums and compresses + /// what it is given; closing it completes the local header in place, by seeking back, or with a + /// data descriptor. + /// + internal sealed class ZipEntryStream : Stream + { + private const int FlushThreshold = 64 * 1024; + + private readonly ZipPackageWriter _owner; + private readonly ZipEntryRecord _record; + [SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", + Justification = "Released by Release(), which every close path and Abandon call.")] + private readonly BiffBuffer _pending = new(FlushThreshold * 2); + [SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", + Justification = "Released by Release(), which every close path and Abandon call.")] + private readonly DeflateEncoder? _encoder; + [SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", + Justification = "Finished by FinishCompression and released by Release().")] + private readonly DeflateStream? _zlib; + private uint _crc; + private long _uncompressed; + private long _compressed; + private bool _headerFlushed; + private bool _closed; + + internal ZipEntryStream(ZipPackageWriter owner, ZipEntryRecord record) + { + _owner = owner; + _record = record; + if (!owner.CanSeek) + { + record.Flags |= ZipRecords.FlagDataDescriptor; + } + ZipRecords.WriteLocalHeader(_pending, record); + if (record.Level == CompressionLevel.Fastest) + { + _encoder = new DeflateEncoder(); + } + else if (record.Method == ZipRecords.MethodDeflate) + { + _zlib = new DeflateStream(new BufferSink(_pending), record.Level); + } + } + + private enum Completion + { + Done, + PatchHeader, + PatchZip64, + } + + public override bool CanRead + { + get + { + return false; + } + } + + public override bool CanSeek + { + get + { + return false; + } + } + + public override bool CanWrite + { + get + { + return !_closed; + } + } + + public override long Length + { + get + { + throw new NotSupportedException(); + } + } + + public override long Position + { + get + { + throw new NotSupportedException(); + } + set + { + throw new NotSupportedException(); + } + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + + 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) + { + Write(buffer.AsSpan(offset, count)); + } + + public override void Write(ReadOnlySpan buffer) + { + Accept(buffer); + if (_pending.Length >= FlushThreshold) + { + FlushPending(); + } + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + Accept(buffer.Span); + if (_pending.Length >= FlushThreshold) + { + await FlushPendingAsync(cancellationToken).ConfigureAwait(false); + } + } + + internal void Abandon() + { + if (_closed) + { + return; + } + _closed = true; + Release(); + } + + protected override void Dispose(bool disposing) + { + if (disposing && !_closed) + { + _closed = true; + try + { + Completion completion = PrepareCompletion(); + FlushPending(); + CompleteAfterFlush(completion); + _owner.EntryClosed(_record); + } + catch + { + _owner.EntryFailed(); + throw; + } + finally + { + Release(); + } + } + base.Dispose(disposing); + } + + public override async ValueTask DisposeAsync() + { + if (!_closed) + { + _closed = true; + try + { + Completion completion = PrepareCompletion(); + await FlushPendingAsync(CancellationToken.None).ConfigureAwait(false); + await CompleteAfterFlushAsync(completion).ConfigureAwait(false); + _owner.EntryClosed(_record); + } + catch + { + _owner.EntryFailed(); + throw; + } + finally + { + Release(); + } + } + await base.DisposeAsync().ConfigureAwait(false); + } + + private void Accept(ReadOnlySpan buffer) + { + ObjectDisposedException.ThrowIf(_closed, this); + _crc = Crc32.Append(_crc, buffer); + _uncompressed += buffer.Length; + if (_encoder is not null) + { + _encoder.Write(buffer, _pending); + return; + } + if (_zlib is not null) + { + _zlib.Write(buffer); + return; + } + _pending.Write(buffer); + } + + private Completion PrepareCompletion() + { + if (_uncompressed == 0 && !_headerFlushed) + { + MarkEmpty(); + return Completion.Done; + } + FinishCompression(); + _record.Crc = _crc; + _record.UncompressedSize = _uncompressed; + _record.CompressedSize = _compressed + _pending.Length - PendingHeaderLength(); + if ((_record.Flags & ZipRecords.FlagDataDescriptor) != 0) + { + ZipRecords.WriteDataDescriptor(_pending, _record); + return Completion.Done; + } + if (!_headerFlushed) + { + ZipRecords.CrcAndSizes(_record).CopyTo(_pending.Slice(ZipRecords.LocalCrcOffset, 12)); + return Completion.Done; + } + if (_record.NeedsZip64Sizes) + { + _record.Flags |= ZipRecords.FlagDataDescriptor; + return Completion.PatchZip64; + } + return Completion.PatchHeader; + } + + private void CompleteAfterFlush(Completion completion) + { + if (completion == Completion.PatchHeader) + { + _owner.Patch(_record.Offset + ZipRecords.LocalCrcOffset, ZipRecords.CrcAndSizes(_record)); + return; + } + if (completion == Completion.PatchZip64) + { + _owner.Patch(_record.Offset + ZipRecords.LocalVersionOffset, ZipRecords.VersionAndFlags(_record)); + ZipRecords.WriteDataDescriptor(_pending, _record); + FlushPending(); + } + } + + private async ValueTask CompleteAfterFlushAsync(Completion completion) + { + if (completion == Completion.PatchHeader) + { + await _owner.PatchAsync(_record.Offset + ZipRecords.LocalCrcOffset, ZipRecords.CrcAndSizes(_record), CancellationToken.None).ConfigureAwait(false); + return; + } + if (completion == Completion.PatchZip64) + { + await _owner.PatchAsync(_record.Offset + ZipRecords.LocalVersionOffset, ZipRecords.VersionAndFlags(_record), CancellationToken.None).ConfigureAwait(false); + ZipRecords.WriteDataDescriptor(_pending, _record); + await FlushPendingAsync(CancellationToken.None).ConfigureAwait(false); + } + } + + // ZipArchive writes an entry that received no data as stored, with no data descriptor. + private void MarkEmpty() + { + _record.Method = ZipRecords.MethodStored; + _record.Flags = (ushort)(_record.Flags & ~ZipRecords.FlagDataDescriptor); + _pending.Reset(); + ZipRecords.WriteLocalHeader(_pending, _record); + } + + private void FinishCompression() + { + _encoder?.Finish(_pending); + _zlib?.Dispose(); + } + + private int PendingHeaderLength() + { + return _headerFlushed ? 0 : _record.LocalHeaderLength; + } + + private void FlushPending() + { + _owner.WriteRaw(_pending.Span); + CountFlushed(); + } + + private async ValueTask FlushPendingAsync(CancellationToken ct) + { + await _owner.WriteRawAsync(_pending.Memory, ct).ConfigureAwait(false); + CountFlushed(); + } + + private void CountFlushed() + { + _compressed += _pending.Length - PendingHeaderLength(); + _headerFlushed = true; + _pending.Reset(); + } + + private void Release() + { + _encoder?.Dispose(); + FailureCleanup.Dispose(_zlib); + _pending.Dispose(); + } + + private sealed class BufferSink : Stream + { + private readonly BiffBuffer _target; + + internal BufferSink(BiffBuffer target) + { + _target = target; + } + + public override bool CanRead + { + get + { + return false; + } + } + + public override bool CanSeek + { + get + { + return false; + } + } + + public override bool CanWrite + { + get + { + return true; + } + } + + public override long Length + { + get + { + throw new NotSupportedException(); + } + } + + public override long Position + { + get + { + throw new NotSupportedException(); + } + set + { + throw new NotSupportedException(); + } + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + + 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) + { + _target.Write(buffer.AsSpan(offset, count)); + } + + public override void Write(ReadOnlySpan buffer) + { + _target.Write(buffer); + } + } + } +} diff --git a/src/ExcelReader.Core/Writer/Zip/ZipPackageWriter.cs b/src/ExcelReader.Core/Writer/Zip/ZipPackageWriter.cs new file mode 100644 index 00000000..8c3e82ba --- /dev/null +++ b/src/ExcelReader.Core/Writer/Zip/ZipPackageWriter.cs @@ -0,0 +1,133 @@ +using System.Diagnostics.CodeAnalysis; +using System.IO.Compression; +using ExcelReader.Core.Writer.Internal; + +namespace ExcelReader.Core.Writer.Zip +{ + /// + /// Writes a ZIP archive one entry at a time to a stream it does not own, in the layout + /// ZipArchive uses in create mode. Offsets are absolute positions in that stream. + /// + [SuppressMessage("Design", "CA1001:Types that own disposable fields should be disposable", + Justification = "The open entry belongs to the caller that opened it and is completed by its Dispose; Abandon releases it without writing.")] + internal sealed class ZipPackageWriter + { + private readonly Stream _output; + private readonly long _origin; + private readonly List _entries = []; + private ZipEntryStream? _open; + private long _written; + private bool _closed; + private bool _broken; + + internal ZipPackageWriter(Stream output) + { + _output = output; + CanSeek = output.CanSeek; + if (CanSeek) + { + _origin = output.Position; + } + } + + internal bool CanSeek { get; } + + internal long Position + { + get + { + return _origin + _written; + } + } + + internal Stream OpenEntry(string name, CompressionLevel level) + { + ObjectDisposedException.ThrowIf(_closed, this); + if (!Enum.IsDefined(level)) + { + throw new ArgumentOutOfRangeException(nameof(level), level, "Unknown compression level."); + } + if (_open is not null) + { + throw new InvalidOperationException("The previous ZIP entry must be closed before another is opened."); + } + _open = new ZipEntryStream(this, new ZipEntryRecord(name, level, Position, DateTime.Now)); + return _open; + } + + internal void Finish() + { + using BiffBuffer directory = BuildCentralDirectory(); + WriteRaw(directory.Span); + } + + internal async ValueTask FinishAsync(CancellationToken ct = default) + { + using BiffBuffer directory = BuildCentralDirectory(); + await WriteRawAsync(directory.Memory, ct).ConfigureAwait(false); + } + + internal void Abandon() + { + _closed = true; + _open?.Abandon(); + _open = null; + } + + internal void WriteRaw(ReadOnlySpan bytes) + { + _output.Write(bytes); + _written += bytes.Length; + } + + internal async ValueTask WriteRawAsync(ReadOnlyMemory bytes, CancellationToken ct) + { + await _output.WriteAsync(bytes, ct).ConfigureAwait(false); + _written += bytes.Length; + } + + internal void Patch(long position, ReadOnlySpan bytes) + { + _output.Seek(position, SeekOrigin.Begin); + _output.Write(bytes); + _output.Seek(Position, SeekOrigin.Begin); + } + + internal async ValueTask PatchAsync(long position, ReadOnlyMemory bytes, CancellationToken ct) + { + _output.Seek(position, SeekOrigin.Begin); + await _output.WriteAsync(bytes, ct).ConfigureAwait(false); + _output.Seek(Position, SeekOrigin.Begin); + } + + internal void EntryClosed(ZipEntryRecord record) + { + _entries.Add(record); + _open = null; + } + + internal void EntryFailed() + { + _open = null; + _broken = true; + } + + private BiffBuffer BuildCentralDirectory() + { + ObjectDisposedException.ThrowIf(_closed, this); + if (_open is not null || _broken) + { + throw new InvalidOperationException("Every ZIP entry must be closed successfully before the archive is finished."); + } + _closed = true; + long directoryOffset = Position; + var directory = new BiffBuffer(256 + (_entries.Count * 128)); + foreach (ZipEntryRecord entry in _entries) + { + ZipRecords.WriteCentralHeader(directory, entry); + } + ZipRecords.WriteEnd(directory, _entries.Count, directoryOffset, directory.Length); + return directory; + } + } +} diff --git a/src/ExcelReader.Core/Writer/Zip/ZipRecords.cs b/src/ExcelReader.Core/Writer/Zip/ZipRecords.cs new file mode 100644 index 00000000..e6fe0604 --- /dev/null +++ b/src/ExcelReader.Core/Writer/Zip/ZipRecords.cs @@ -0,0 +1,204 @@ +using System.Buffers.Binary; +using System.IO.Compression; +using ExcelReader.Core.Writer.Internal; + +namespace ExcelReader.Core.Writer.Zip +{ + /// + /// The ZIP records of PKWARE's APPNOTE.TXT section 4.3, with the field values ZipArchive + /// writes in create mode, and Zip64 only where a 32-bit field overflows. + /// + internal static class ZipRecords + { + internal const int LocalHeaderLength = 30; + internal const int LocalVersionOffset = 4; + internal const int LocalCrcOffset = 14; + internal const ushort FlagDataDescriptor = 0x0008; + internal const ushort FlagUtf8Name = 0x0800; + internal const ushort MethodStored = 0; + internal const ushort MethodDeflate = 8; + internal const ushort VersionDefault = 20; + internal const ushort VersionZip64 = 45; + + private const uint LocalHeaderSignature = 0x0403_4B50; + private const uint DataDescriptorSignature = 0x0807_4B50; + private const uint CentralHeaderSignature = 0x0201_4B50; + private const uint Zip64EndSignature = 0x0606_4B50; + private const uint Zip64LocatorSignature = 0x0706_4B50; + private const uint EndSignature = 0x0605_4B50; + private const ushort VersionMadeBy = 0x0014; + private const ushort Zip64ExtraId = 0x0001; + private const ulong Zip64EndRecordSize = 44; + private const int MaxClassicCount = 0xFFFF; + + internal static ushort LevelFlags(CompressionLevel level) + { + switch (level) + { + case CompressionLevel.Fastest: + return 0x0006; + case CompressionLevel.SmallestSize: + return 0x0002; + default: + return 0; + } + } + + internal static (ushort Time, ushort Date) DosDateTime(DateTime value) + { + DateTime clamped = value; + if (clamped.Year < 1980) + { + clamped = new DateTime(1980, 1, 1, 0, 0, 0, DateTimeKind.Local); + } + if (clamped.Year > 2107) + { + clamped = new DateTime(2107, 12, 31, 23, 59, 58, DateTimeKind.Local); + } + ushort time = (ushort)((clamped.Hour << 11) | (clamped.Minute << 5) | (clamped.Second / 2)); + ushort date = (ushort)(((clamped.Year - 1980) << 9) | (clamped.Month << 5) | clamped.Day); + return (time, date); + } + + internal static void WriteLocalHeader(BiffBuffer buffer, ZipEntryRecord entry) + { + buffer.WriteU32(LocalHeaderSignature); + buffer.WriteU16(VersionDefault); + buffer.WriteU16(entry.Flags); + buffer.WriteU16(entry.Method); + buffer.WriteU16(entry.Time); + buffer.WriteU16(entry.Date); + buffer.WriteU32(0); + buffer.WriteU32(0); + buffer.WriteU32(0); + buffer.WriteU16(entry.Name.Length); + buffer.WriteU16(0); + buffer.Write(entry.Name); + } + + /// The 12 bytes at : CRC-32 and 32-bit sizes. + internal static byte[] CrcAndSizes(ZipEntryRecord entry) + { + byte[] bytes = new byte[12]; + BinaryPrimitives.WriteUInt32LittleEndian(bytes, entry.Crc); + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(4), (uint)entry.CompressedSize); + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(8), (uint)entry.UncompressedSize); + return bytes; + } + + /// The 4 bytes at : version needed and flags. + internal static byte[] VersionAndFlags(ZipEntryRecord entry) + { + byte[] bytes = new byte[4]; + BinaryPrimitives.WriteUInt16LittleEndian(bytes, VersionZip64); + BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan(2), entry.Flags); + return bytes; + } + + internal static void WriteDataDescriptor(BiffBuffer buffer, ZipEntryRecord entry) + { + buffer.WriteU32(DataDescriptorSignature); + buffer.WriteU32(entry.Crc); + if (entry.NeedsZip64Sizes) + { + WriteU64(buffer, (ulong)entry.CompressedSize); + WriteU64(buffer, (ulong)entry.UncompressedSize); + return; + } + buffer.WriteU32((uint)entry.CompressedSize); + buffer.WriteU32((uint)entry.UncompressedSize); + } + + internal static void WriteCentralHeader(BiffBuffer buffer, ZipEntryRecord entry) + { + bool zip64Sizes = entry.NeedsZip64Sizes; + bool zip64Offset = entry.NeedsZip64Offset; + int extraFields = (zip64Sizes ? 2 : 0) + (zip64Offset ? 1 : 0); + int extraLength = extraFields == 0 ? 0 : 4 + (8 * extraFields); + buffer.WriteU32(CentralHeaderSignature); + buffer.WriteU16(VersionMadeBy); + buffer.WriteU16(extraFields == 0 ? VersionDefault : VersionZip64); + buffer.WriteU16(entry.Flags); + buffer.WriteU16(entry.Method); + buffer.WriteU16(entry.Time); + buffer.WriteU16(entry.Date); + buffer.WriteU32(entry.Crc); + buffer.WriteU32(Field(entry.CompressedSize, zip64Sizes)); + buffer.WriteU32(Field(entry.UncompressedSize, zip64Sizes)); + buffer.WriteU16(entry.Name.Length); + buffer.WriteU16(extraLength); + buffer.WriteU16(0); + buffer.WriteU16(0); + buffer.WriteU16(0); + buffer.WriteU32(0); + buffer.WriteU32(Field(entry.Offset, zip64Offset)); + buffer.Write(entry.Name); + if (extraFields == 0) + { + return; + } + buffer.WriteU16(Zip64ExtraId); + buffer.WriteU16(8 * extraFields); + if (zip64Sizes) + { + WriteU64(buffer, (ulong)entry.UncompressedSize); + WriteU64(buffer, (ulong)entry.CompressedSize); + } + if (zip64Offset) + { + WriteU64(buffer, (ulong)entry.Offset); + } + } + + internal static void WriteEnd(BiffBuffer buffer, int count, long directoryOffset, long directoryLength) + { + bool zip64 = count >= MaxClassicCount || directoryOffset >= uint.MaxValue || directoryLength >= uint.MaxValue; + if (zip64) + { + WriteZip64End(buffer, count, directoryOffset, directoryLength); + } + buffer.WriteU32(EndSignature); + buffer.WriteU16(0); + buffer.WriteU16(0); + buffer.WriteU16(Math.Min(count, MaxClassicCount)); + buffer.WriteU16(Math.Min(count, MaxClassicCount)); + buffer.WriteU32(Field(directoryLength, directoryLength >= uint.MaxValue)); + buffer.WriteU32(Field(directoryOffset, directoryOffset >= uint.MaxValue)); + buffer.WriteU16(0); + } + + private static void WriteZip64End(BiffBuffer buffer, int count, long directoryOffset, long directoryLength) + { + long zip64EndOffset = directoryOffset + directoryLength; + buffer.WriteU32(Zip64EndSignature); + WriteU64(buffer, Zip64EndRecordSize); + buffer.WriteU16(VersionZip64); + buffer.WriteU16(VersionZip64); + buffer.WriteU32(0); + buffer.WriteU32(0); + WriteU64(buffer, (ulong)count); + WriteU64(buffer, (ulong)count); + WriteU64(buffer, (ulong)directoryLength); + WriteU64(buffer, (ulong)directoryOffset); + buffer.WriteU32(Zip64LocatorSignature); + buffer.WriteU32(0); + WriteU64(buffer, (ulong)zip64EndOffset); + buffer.WriteU32(1); + } + + private static uint Field(long value, bool overflows) + { + if (overflows) + { + return uint.MaxValue; + } + return (uint)value; + } + + private static void WriteU64(BiffBuffer buffer, ulong value) + { + BinaryPrimitives.WriteUInt64LittleEndian(buffer.GetSpan(sizeof(ulong)), value); + buffer.Advance(sizeof(ulong)); + } + } +} diff --git a/tests/ExcelReader.Tests/Writer/Zip/ZipPackageWriterTests.cs b/tests/ExcelReader.Tests/Writer/Zip/ZipPackageWriterTests.cs new file mode 100644 index 00000000..32a5bd65 --- /dev/null +++ b/tests/ExcelReader.Tests/Writer/Zip/ZipPackageWriterTests.cs @@ -0,0 +1,355 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.IO.Compression; +using System.Text; +using ExcelReader.Core.Writer.Zip; + +namespace ExcelReader.Tests.Writer.Zip +{ + public class ZipPackageWriterTests + { + private const int ChunkSize = 8192; + private static readonly (string Name, byte[] Content)[] Entries = BuildEntries(); + + [SuppressMessage("Security", "CA5394:Do not use insecure randomness", + Justification = "A seeded generator picks reproducible test inputs; nothing here is secret.")] + private static (string Name, byte[] Content)[] BuildEntries() + { + var random = new Random(11); + var digits = new StringBuilder(); + while (digits.Length < 1_000_000) + { + digits.Append(random.Next().ToString(CultureInfo.InvariantCulture)).Append(','); + } + return + [ + ("a.xml", "hello hello hello"u8.ToArray()), + ("empty.xml", []), + ("big.xml", Encoding.ASCII.GetBytes(digits.ToString())), + ("ünïcode.xml", ""u8.ToArray()), + ]; + } + + public static TheoryData LayoutCases() + { + var data = new TheoryData(); + foreach (CompressionLevel level in Enum.GetValues()) + { + data.Add(level, true, 0, false); + data.Add(level, true, 0, true); + data.Add(level, false, 0, false); + data.Add(level, false, 0, true); + data.Add(level, true, 100, false); + } + return data; + } + + private static Stream Target(MemoryStream ms, bool seekable) + { + if (seekable) + { + return ms; + } + return new ForwardOnlyWriteStream(ms); + } + + private static void WriteInChunks(Stream entry, byte[] content) + { + for (int offset = 0; offset < content.Length; offset += ChunkSize) + { + entry.Write(content, offset, Math.Min(ChunkSize, content.Length - offset)); + } + } + + private static byte[] WriteWithZipArchive(CompressionLevel level, bool seekable, int prefix) + { + var ms = new MemoryStream(); + ms.Write(new byte[prefix]); + using (var zip = new ZipArchive(Target(ms, seekable), ZipArchiveMode.Create, leaveOpen: true)) + { + foreach ((string name, byte[] content) in Entries) + { + using Stream entry = zip.CreateEntry(name, level).Open(); + WriteInChunks(entry, content); + } + } + return ms.ToArray(); + } + + private static async Task WriteWithPackageAsync(CompressionLevel level, bool seekable, int prefix, bool useAsync) + { + CancellationToken ct = TestContext.Current.CancellationToken; + var ms = new MemoryStream(); + ms.Write(new byte[prefix]); + var zip = new ZipPackageWriter(Target(ms, seekable)); + foreach ((string name, byte[] content) in Entries) + { + Stream entry = zip.OpenEntry(name, level); + if (!useAsync) + { + WriteInChunks(entry, content); + entry.Dispose(); + continue; + } + for (int offset = 0; offset < content.Length; offset += ChunkSize) + { + await entry.WriteAsync(content.AsMemory(offset, Math.Min(ChunkSize, content.Length - offset)), ct); + } + await entry.DisposeAsync(); + } + if (useAsync) + { + await zip.FinishAsync(ct); + } + else + { + zip.Finish(); + } + return ms.ToArray(); + } + + private static void AssertContentAndCrc(byte[] zipBytes) + { + using var zip = new ZipArchive(new MemoryStream(zipBytes), ZipArchiveMode.Read); + Assert.Equal(Entries.Length, zip.Entries.Count); + foreach ((string name, byte[] content) in Entries) + { + ZipArchiveEntry entry = zip.GetEntry(name)!; + using Stream data = entry.Open(); + using var copy = new MemoryStream(); + data.CopyTo(copy); + Assert.True(copy.ToArray().AsSpan().SequenceEqual(content), name); + Assert.Equal(Crc32Tests.BitwiseCrc(content), entry.Crc32); + } + } + + private static void AssertSameShape(ZipCentral expected, ZipCentral actual, byte[] expectedZip, byte[] actualZip) + { + Assert.Equal(expected.Name, actual.Name); + Assert.Equal(expected.MadeBy, actual.MadeBy); + Assert.Equal(expected.Version, actual.Version); + Assert.Equal(expected.Flags, actual.Flags); + Assert.Equal(expected.Method, actual.Method); + Assert.Equal(expected.Crc, actual.Crc); + Assert.Equal(expected.UncompressedSize, actual.UncompressedSize); + Assert.Equal(expected.Extra, actual.Extra); + ZipLocal expectedLocal = ZipLayout.ReadLocal(expectedZip, expected.LocalOffset); + ZipLocal actualLocal = ZipLayout.ReadLocal(actualZip, actual.LocalOffset); + Assert.Equal(expectedLocal.Version, actualLocal.Version); + Assert.Equal(expectedLocal.Flags, actualLocal.Flags); + Assert.Equal(expectedLocal.Method, actualLocal.Method); + Assert.Equal(expectedLocal.Crc, actualLocal.Crc); + Assert.Equal(expectedLocal.UncompressedSize, actualLocal.UncompressedSize); + Assert.Equal(expectedLocal.CompressedSize == 0, actualLocal.CompressedSize == 0); + if (actualLocal.CompressedSize != 0) + { + Assert.Equal(actual.CompressedSize, actualLocal.CompressedSize); + } + } + + [Theory] + [MemberData(nameof(LayoutCases))] + public async Task LayoutMatchesZipArchive(CompressionLevel level, bool seekable, int prefix, bool useAsync) + { + byte[] expected = WriteWithZipArchive(level, seekable, prefix); + byte[] actual = await WriteWithPackageAsync(level, seekable, prefix, useAsync); + List expectedEntries = ZipLayout.ReadCentral(expected); + List actualEntries = ZipLayout.ReadCentral(actual); + Assert.Equal(expectedEntries.Count, actualEntries.Count); + for (int i = 0; i < expectedEntries.Count; i++) + { + AssertSameShape(expectedEntries[i], actualEntries[i], expected, actual); + } + if (level == CompressionLevel.NoCompression) + { + Assert.Equal(ZipLayout.WithoutTimestamps(expected), ZipLayout.WithoutTimestamps(actual)); + } + AssertContentAndCrc(actual); + } + + [Fact] + public void ZeroEntriesWriteTheEmptyArchive() + { + var expected = new MemoryStream(); + using (new ZipArchive(expected, ZipArchiveMode.Create, leaveOpen: true)) + { + } + var actual = new MemoryStream(); + new ZipPackageWriter(actual).Finish(); + Assert.Equal(expected.ToArray(), actual.ToArray()); + using var read = new ZipArchive(new MemoryStream(actual.ToArray()), ZipArchiveMode.Read); + Assert.Empty(read.Entries); + } + + [Fact] + public void OpeningASecondEntryWhileOneIsOpenThrows() + { + var zip = new ZipPackageWriter(new MemoryStream()); + using Stream first = zip.OpenEntry("a.xml", CompressionLevel.Fastest); + Assert.Throws(() => zip.OpenEntry("b.xml", CompressionLevel.Fastest)); + Assert.Throws(zip.Finish); + } + + [Fact] + public void UndefinedCompressionLevelIsRejected() + { + var zip = new ZipPackageWriter(new MemoryStream()); + Assert.Throws(() => zip.OpenEntry("a.xml", (CompressionLevel)42)); + } + + [Fact] + public async Task DisposingAnEntryAgainIsHarmless() + { + var ms = new MemoryStream(); + var zip = new ZipPackageWriter(ms); + Stream entry = zip.OpenEntry("a.xml", CompressionLevel.Fastest); + entry.Write(""u8); + await entry.DisposeAsync(); + entry.Dispose(); + await entry.DisposeAsync(); + Assert.Throws(() => entry.Write("x"u8)); + zip.Finish(); + Assert.Single(ZipLayout.ReadCentral(ms.ToArray())); + } + + [Fact] + public void WriteByteBuildsTheSameEntry() + { + byte[] content = DeflateEncoderTests.SheetXml(200); + var ms = new MemoryStream(); + var zip = new ZipPackageWriter(ms); + using (Stream entry = zip.OpenEntry("bytes.xml", CompressionLevel.Fastest)) + { + foreach (byte value in content) + { + entry.WriteByte(value); + } + } + zip.Finish(); + using var read = new ZipArchive(new MemoryStream(ms.ToArray()), ZipArchiveMode.Read); + ZipArchiveEntry stored = read.GetEntry("bytes.xml")!; + using var copy = new MemoryStream(); + using (Stream data = stored.Open()) + { + data.CopyTo(copy); + } + Assert.True(copy.ToArray().AsSpan().SequenceEqual(content)); + Assert.Equal(Crc32Tests.BitwiseCrc(content), stored.Crc32); + } + + [Fact] + public void AFailedWriteLeavesNoCentralDirectory() + { + var output = new FailingWriteStream(limit: 100_000); + var zip = new ZipPackageWriter(output); + Stream entry = zip.OpenEntry("big.xml", CompressionLevel.NoCompression); + byte[] chunk = new byte[64 * 1024]; + Assert.Throws(() => + { + for (int i = 0; i < 10; i++) + { + entry.Write(chunk); + } + }); + Assert.Throws(entry.Dispose); + Assert.Throws(zip.Finish); + zip.Abandon(); + zip.Abandon(); + Assert.Equal(-1, output.Written.ToArray().AsSpan().IndexOf("PK\u0001\u0002"u8)); + Assert.Throws(() => zip.OpenEntry("next.xml", CompressionLevel.Fastest)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AnEntryOver4GiBUsesZip64AndSoDoLaterOffsets(bool seekable) + { + const long Size = (1L << 32) + (1 << 20); + var sink = new CapturingStream(seekable); + var zip = new ZipPackageWriter(sink); + byte[] chunk = new byte[1 << 20]; + uint crc = 0; + using (Stream big = zip.OpenEntry("big.bin", CompressionLevel.NoCompression)) + { + for (long written = 0; written < Size; written += chunk.Length) + { + big.Write(chunk); + crc = Crc32.Append(crc, chunk); + } + } + using (Stream after = zip.OpenEntry("after.xml", CompressionLevel.Fastest)) + { + after.Write(""u8); + } + zip.Finish(); + + ZipLocal local = ZipLayout.ReadLocal(sink.Head, 0); + Assert.Equal(ZipRecords.FlagDataDescriptor, local.Flags & ZipRecords.FlagDataDescriptor); + Assert.Equal(seekable ? 45 : 20, local.Version); + + byte[] tail = sink.Tail; + long descriptorAt = ZipRecords.LocalHeaderLength + "big.bin".Length + Size; + long d = descriptorAt - CapturingStream.TailStart; + Assert.Equal(0x0807_4B50u, ZipLayout.U32(tail, d)); + Assert.Equal(crc, ZipLayout.U32(tail, d + 4)); + Assert.Equal((ulong)Size, ZipLayout.U64(tail, d + 8)); + Assert.Equal((ulong)Size, ZipLayout.U64(tail, d + 16)); + + long end = tail.Length - 22; + Assert.Equal(0x0605_4B50u, ZipLayout.U32(tail, end)); + Assert.Equal(uint.MaxValue, ZipLayout.U32(tail, end + 16)); + long locator = end - 20; + Assert.Equal(0x0706_4B50u, ZipLayout.U32(tail, locator)); + long zip64End = (long)ZipLayout.U64(tail, locator + 8) - CapturingStream.TailStart; + Assert.Equal(0x0606_4B50u, ZipLayout.U32(tail, zip64End)); + Assert.Equal(2UL, ZipLayout.U64(tail, zip64End + 32)); + long central = (long)ZipLayout.U64(tail, zip64End + 48) - CapturingStream.TailStart; + + Assert.Equal(0x0201_4B50u, ZipLayout.U32(tail, central)); + Assert.Equal(45, ZipLayout.U16(tail, central + 6)); + Assert.Equal(uint.MaxValue, ZipLayout.U32(tail, central + 20)); + Assert.Equal(uint.MaxValue, ZipLayout.U32(tail, central + 24)); + Assert.Equal(20, ZipLayout.U16(tail, central + 30)); + Assert.Equal(0u, ZipLayout.U32(tail, central + 42)); + long extra = central + 46 + "big.bin".Length; + Assert.Equal(1, ZipLayout.U16(tail, extra)); + Assert.Equal(16, ZipLayout.U16(tail, extra + 2)); + Assert.Equal((ulong)Size, ZipLayout.U64(tail, extra + 4)); + Assert.Equal((ulong)Size, ZipLayout.U64(tail, extra + 12)); + + long second = extra + 20; + Assert.Equal(0x0201_4B50u, ZipLayout.U32(tail, second)); + Assert.Equal(45, ZipLayout.U16(tail, second + 6)); + Assert.Equal(uint.MaxValue, ZipLayout.U32(tail, second + 42)); + long secondExtra = second + 46 + "after.xml".Length; + Assert.Equal(1, ZipLayout.U16(tail, secondExtra)); + Assert.Equal(8, ZipLayout.U16(tail, secondExtra + 2)); + Assert.Equal((ulong)(descriptorAt + 24), ZipLayout.U64(tail, secondExtra + 4)); + } + + [Fact] + public void MoreThan65534EntriesUseZip64EndRecords() + { + var ms = new MemoryStream(); + var zip = new ZipPackageWriter(ms); + for (int i = 0; i < 70_000; i++) + { + using Stream entry = zip.OpenEntry($"e{i}.bin", CompressionLevel.NoCompression); + entry.WriteByte((byte)i); + } + zip.Finish(); + byte[] bytes = ms.ToArray(); + int end = bytes.Length - 22; + Assert.Equal(0xFFFF, ZipLayout.U16(bytes, end + 10)); + int locator = end - 20; + Assert.Equal(0x0706_4B50u, ZipLayout.U32(bytes, locator)); + long zip64End = (long)ZipLayout.U64(bytes, locator + 8); + Assert.Equal(0x0606_4B50u, ZipLayout.U32(bytes, zip64End)); + Assert.Equal(70_000UL, ZipLayout.U64(bytes, zip64End + 32)); + using var read = new ZipArchive(new MemoryStream(bytes), ZipArchiveMode.Read); + Assert.Equal(70_000, read.Entries.Count); + using Stream last = read.GetEntry("e69999.bin")!.Open(); + Assert.Equal(69_999 & 0xFF, last.ReadByte()); + } + } +} diff --git a/tests/ExcelReader.Tests/Writer/Zip/ZipTestSupport.cs b/tests/ExcelReader.Tests/Writer/Zip/ZipTestSupport.cs new file mode 100644 index 00000000..9f3a8f38 --- /dev/null +++ b/tests/ExcelReader.Tests/Writer/Zip/ZipTestSupport.cs @@ -0,0 +1,221 @@ +using System.Buffers.Binary; +using System.Text; + +namespace ExcelReader.Tests.Writer.Zip +{ + internal sealed record ZipCentral( + int Position, ushort MadeBy, ushort Version, ushort Flags, ushort Method, uint Crc, + uint CompressedSize, uint UncompressedSize, uint LocalOffset, string Name, byte[] Extra); + + internal sealed record ZipLocal(ushort Version, ushort Flags, ushort Method, uint Crc, uint CompressedSize, uint UncompressedSize); + + internal static class ZipLayout + { + internal static ushort U16(ReadOnlySpan data, long offset) + { + return BinaryPrimitives.ReadUInt16LittleEndian(data[(int)offset..]); + } + + internal static uint U32(ReadOnlySpan data, long offset) + { + return BinaryPrimitives.ReadUInt32LittleEndian(data[(int)offset..]); + } + + internal static ulong U64(ReadOnlySpan data, long offset) + { + return BinaryPrimitives.ReadUInt64LittleEndian(data[(int)offset..]); + } + + /// Reads a classic (non-Zip64) central directory through the end record at the end of the file. + internal static List ReadCentral(byte[] zip) + { + int end = zip.Length - 22; + Assert.Equal(0x0605_4B50u, U32(zip, end)); + int count = U16(zip, end + 10); + int position = (int)U32(zip, end + 16); + var entries = new List(count); + for (int i = 0; i < count; i++) + { + Assert.Equal(0x0201_4B50u, U32(zip, position)); + int nameLength = U16(zip, position + 28); + int extraLength = U16(zip, position + 30); + int commentLength = U16(zip, position + 32); + entries.Add(new ZipCentral( + position, U16(zip, position + 4), U16(zip, position + 6), U16(zip, position + 8), U16(zip, position + 10), + U32(zip, position + 16), U32(zip, position + 20), U32(zip, position + 24), U32(zip, position + 42), + Encoding.UTF8.GetString(zip, position + 46, nameLength), + zip.AsSpan(position + 46 + nameLength, extraLength).ToArray())); + position += 46 + nameLength + extraLength + commentLength; + } + return entries; + } + + internal static ZipLocal ReadLocal(ReadOnlySpan zip, long offset) + { + Assert.Equal(0x0403_4B50u, U32(zip, offset)); + return new ZipLocal(U16(zip, offset + 4), U16(zip, offset + 6), U16(zip, offset + 8), + U32(zip, offset + 14), U32(zip, offset + 18), U32(zip, offset + 22)); + } + + /// Zeroes the DOS time and date of every local and central header. + internal static byte[] WithoutTimestamps(byte[] zip) + { + byte[] copy = (byte[])zip.Clone(); + foreach (ZipCentral entry in ReadCentral(zip)) + { + copy.AsSpan((int)entry.LocalOffset + 10, 4).Clear(); + copy.AsSpan(entry.Position + 12, 4).Clear(); + } + return copy; + } + } + + /// A write-only stream that refuses to seek, like a network or pipe stream. + internal sealed class ForwardOnlyWriteStream(Stream inner) : Stream + { + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() + { + inner.Flush(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + + 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) + { + inner.Write(buffer, offset, count); + } + + public override void Write(ReadOnlySpan buffer) + { + inner.Write(buffer); + } + } + + /// Accepts writes until bytes, then throws on every write. + internal sealed class FailingWriteStream(long limit) : Stream + { + internal MemoryStream Written { get; } = new(); + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() { } + public override int Read(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + + 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) + { + Write(buffer.AsSpan(offset, count)); + } + + public override void Write(ReadOnlySpan buffer) + { + if (Written.Length + buffer.Length > limit) + { + throw new IOException("disk full"); + } + Written.Write(buffer); + } + } + + /// + /// Counts every byte but keeps only the first 4 KB and everything at or past 4 GiB, so a Zip64 + /// archive can be inspected without holding it. + /// + internal sealed class CapturingStream(bool seekable) : Stream + { + internal const long TailStart = 1L << 32; + private readonly MemoryStream _tail = new(); + private long _position; + private long _length; + + internal byte[] Head { get; } = new byte[4096]; + internal byte[] Tail => _tail.ToArray(); + public override bool CanRead => false; + public override bool CanSeek => seekable; + public override bool CanWrite => true; + public override long Length => _length; + + public override long Position + { + get => _position; + set => Seek(value, SeekOrigin.Begin); + } + + public override void Flush() { } + public override int Read(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + Write(buffer.AsSpan(offset, count)); + } + + public override long Seek(long offset, SeekOrigin origin) + { + if (!seekable || origin != SeekOrigin.Begin) + { + throw new NotSupportedException(); + } + _position = offset; + return _position; + } + + public override void Write(ReadOnlySpan buffer) + { + long start = _position; + if (start < Head.Length) + { + int headPart = (int)Math.Min(buffer.Length, Head.Length - start); + buffer[..headPart].CopyTo(Head.AsSpan((int)start)); + } + long end = start + buffer.Length; + if (end > TailStart) + { + long from = Math.Max(start, TailStart); + _tail.Position = from - TailStart; + _tail.Write(buffer[(int)(from - start)..]); + } + _position = end; + _length = Math.Max(_length, end); + } + } +} From 9a699b3a579c0d8c9aa03755431be6a21aec2cc2 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 01:26:58 -0300 Subject: [PATCH 09/42] fix: a failed entry write kills the entry and the archive --- .../Writer/Zip/ZipEntryStream.cs | 39 ++++++++-- .../Writer/Zip/ZipPackageWriter.cs | 4 + .../Writer/Zip/ZipPackageWriterTests.cs | 75 ++++++++++++++++++- .../Writer/Zip/ZipTestSupport.cs | 28 +++++++ 4 files changed, 136 insertions(+), 10 deletions(-) diff --git a/src/ExcelReader.Core/Writer/Zip/ZipEntryStream.cs b/src/ExcelReader.Core/Writer/Zip/ZipEntryStream.cs index c9f873d1..65b2181e 100644 --- a/src/ExcelReader.Core/Writer/Zip/ZipEntryStream.cs +++ b/src/ExcelReader.Core/Writer/Zip/ZipEntryStream.cs @@ -126,10 +126,19 @@ public override void Write(byte[] buffer, int offset, int count) public override void Write(ReadOnlySpan buffer) { - Accept(buffer); - if (_pending.Length >= FlushThreshold) + ObjectDisposedException.ThrowIf(_closed, this); + try { - FlushPending(); + Accept(buffer); + if (_pending.Length >= FlushThreshold) + { + FlushPending(); + } + } + catch + { + Fail(); + throw; } } @@ -140,10 +149,19 @@ public override Task WriteAsync(byte[] buffer, int offset, int count, Cancellati public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) { - Accept(buffer.Span); - if (_pending.Length >= FlushThreshold) + ObjectDisposedException.ThrowIf(_closed, this); + try + { + Accept(buffer.Span); + if (_pending.Length >= FlushThreshold) + { + await FlushPendingAsync(cancellationToken).ConfigureAwait(false); + } + } + catch { - await FlushPendingAsync(cancellationToken).ConfigureAwait(false); + Fail(); + throw; } } @@ -157,6 +175,14 @@ internal void Abandon() Release(); } + // A failed write leaves the output position unknown, so the entry and the archive are dead. + private void Fail() + { + _closed = true; + _owner.EntryFailed(); + Release(); + } + protected override void Dispose(bool disposing) { if (disposing && !_closed) @@ -209,7 +235,6 @@ public override async ValueTask DisposeAsync() private void Accept(ReadOnlySpan buffer) { - ObjectDisposedException.ThrowIf(_closed, this); _crc = Crc32.Append(_crc, buffer); _uncompressed += buffer.Length; if (_encoder is not null) diff --git a/src/ExcelReader.Core/Writer/Zip/ZipPackageWriter.cs b/src/ExcelReader.Core/Writer/Zip/ZipPackageWriter.cs index 8c3e82ba..405a3316 100644 --- a/src/ExcelReader.Core/Writer/Zip/ZipPackageWriter.cs +++ b/src/ExcelReader.Core/Writer/Zip/ZipPackageWriter.cs @@ -51,6 +51,10 @@ internal Stream OpenEntry(string name, CompressionLevel level) { throw new InvalidOperationException("The previous ZIP entry must be closed before another is opened."); } + if (_broken) + { + throw new InvalidOperationException("A ZIP entry failed, so the archive cannot take more entries."); + } _open = new ZipEntryStream(this, new ZipEntryRecord(name, level, Position, DateTime.Now)); return _open; } diff --git a/tests/ExcelReader.Tests/Writer/Zip/ZipPackageWriterTests.cs b/tests/ExcelReader.Tests/Writer/Zip/ZipPackageWriterTests.cs index 32a5bd65..94929838 100644 --- a/tests/ExcelReader.Tests/Writer/Zip/ZipPackageWriterTests.cs +++ b/tests/ExcelReader.Tests/Writer/Zip/ZipPackageWriterTests.cs @@ -251,15 +251,84 @@ public void AFailedWriteLeavesNoCentralDirectory() entry.Write(chunk); } }); - Assert.Throws(entry.Dispose); - Assert.Throws(zip.Finish); + AssertFailedEntryIsDead(zip, entry, output.Written); zip.Abandon(); zip.Abandon(); - Assert.Equal(-1, output.Written.ToArray().AsSpan().IndexOf("PK\u0001\u0002"u8)); Assert.Throws(() => zip.OpenEntry("next.xml", CompressionLevel.Fastest)); } + private static void AssertFailedEntryIsDead(ZipPackageWriter zip, Stream entry, MemoryStream written) + { + long length = written.Length; + entry.Dispose(); + Assert.Throws(() => entry.Write(new byte[1])); + Assert.Throws(zip.Finish); + Assert.Throws(() => zip.OpenEntry("next.xml", CompressionLevel.Fastest)); + Assert.Equal(length, written.Length); + Assert.Equal(-1, written.ToArray().AsSpan().IndexOf("PK"u8)); + } + + [Fact] + public void ATransientWriteFailureKillsTheEntry() + { + var output = new FailOnceWriteStream(failOnCall: 1); + var zip = new ZipPackageWriter(output); + Stream entry = zip.OpenEntry("big.xml", CompressionLevel.NoCompression); + Assert.Throws(() => entry.Write(new byte[64 * 1024])); + AssertFailedEntryIsDead(zip, entry, output.Written); + } + + [Fact] + public async Task ATransientAsyncWriteFailureKillsTheEntry() + { + CancellationToken ct = TestContext.Current.CancellationToken; + var output = new FailOnceWriteStream(failOnCall: 1); + var zip = new ZipPackageWriter(output); + Stream entry = zip.OpenEntry("big.xml", CompressionLevel.NoCompression); + await Assert.ThrowsAsync(async () => await entry.WriteAsync(new byte[64 * 1024], ct)); + await entry.DisposeAsync(); + await Assert.ThrowsAsync(async () => await zip.FinishAsync(ct)); + AssertFailedEntryIsDead(zip, entry, output.Written); + } + + [Fact] + public async Task ACancelledWriteKillsTheEntryEvenWhenDisposedWithNoToken() + { + CancellationToken ct = TestContext.Current.CancellationToken; + var output = new MemoryStream(); + var zip = new ZipPackageWriter(output); + Stream entry = zip.OpenEntry("big.xml", CompressionLevel.NoCompression); + using var cancelled = new CancellationTokenSource(); + await cancelled.CancelAsync(); + await Assert.ThrowsAnyAsync( + async () => await entry.WriteAsync(new byte[64 * 1024], cancelled.Token)); + await entry.DisposeAsync(); + await Assert.ThrowsAsync(async () => await zip.FinishAsync(ct)); + AssertFailedEntryIsDead(zip, entry, output); + } + + [Theory] + [InlineData(CompressionLevel.Fastest)] + [InlineData(CompressionLevel.Optimal)] + [InlineData(CompressionLevel.NoCompression)] + public void AbandonReleasesTheOpenEntryWithoutWriting(CompressionLevel level) + { + var output = new MemoryStream(); + var zip = new ZipPackageWriter(output); + Stream entry = zip.OpenEntry("a.xml", level); + entry.Write(DeflateEncoderTests.SheetXml(2000)); + long length = output.Length; + zip.Abandon(); + Assert.Equal(length, output.Length); + Assert.Throws(() => entry.Write(new byte[1])); + entry.Dispose(); + zip.Abandon(); + Assert.Equal(length, output.Length); + Assert.Throws(zip.Finish); + } + [Theory] + [Trait("Category", "Slow")] [InlineData(true)] [InlineData(false)] public void AnEntryOver4GiBUsesZip64AndSoDoLaterOffsets(bool seekable) diff --git a/tests/ExcelReader.Tests/Writer/Zip/ZipTestSupport.cs b/tests/ExcelReader.Tests/Writer/Zip/ZipTestSupport.cs index 9f3a8f38..25b22fe5 100644 --- a/tests/ExcelReader.Tests/Writer/Zip/ZipTestSupport.cs +++ b/tests/ExcelReader.Tests/Writer/Zip/ZipTestSupport.cs @@ -149,6 +149,34 @@ public override void Write(ReadOnlySpan buffer) } } + /// Throws on write number without storing it, then accepts every later write. + internal sealed class FailOnceWriteStream(int failOnCall) : Stream + { + private int _calls; + + internal MemoryStream Written { get; } = new(); + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() { } + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + 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) => Write(buffer.AsSpan(offset, count)); + + public override void Write(ReadOnlySpan buffer) + { + _calls++; + if (_calls == failOnCall) + { + throw new IOException("transient"); + } + Written.Write(buffer); + } + } + /// /// Counts every byte but keeps only the first 4 KB and everything at or past 4 GiB, so a Zip64 /// archive can be inspected without holding it. From f8617ba823fe42ad243b3e5ef197f050ebc6afaa Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 01:28:34 -0300 Subject: [PATCH 10/42] style: block bodies in FailOnceWriteStream --- .../Writer/Zip/ZipTestSupport.cs | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/tests/ExcelReader.Tests/Writer/Zip/ZipTestSupport.cs b/tests/ExcelReader.Tests/Writer/Zip/ZipTestSupport.cs index 25b22fe5..abbcb01a 100644 --- a/tests/ExcelReader.Tests/Writer/Zip/ZipTestSupport.cs +++ b/tests/ExcelReader.Tests/Writer/Zip/ZipTestSupport.cs @@ -161,10 +161,25 @@ internal sealed class FailOnceWriteStream(int failOnCall) : Stream public override long Length => throw new NotSupportedException(); public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } public override void Flush() { } - public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); - 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) => Write(buffer.AsSpan(offset, count)); + public override int Read(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + + 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) + { + Write(buffer.AsSpan(offset, count)); + } public override void Write(ReadOnlySpan buffer) { From 45e95e77f05975e4bc83df44c109d7b3ca498749 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 01:41:10 -0300 Subject: [PATCH 11/42] feat: Abandon writes the central directory best-effort and entry Flush reaches the output Matches what ZipArchive.Dispose does on a faulted archive, which the writer failure tests pin. --- .../Writer/Zip/ZipEntryStream.cs | 30 +++ .../Writer/Zip/ZipPackageWriter.cs | 31 ++- .../Writer/Zip/ZipPackageWriterTests.cs | 205 ++++++++++++++---- 3 files changed, 224 insertions(+), 42 deletions(-) diff --git a/src/ExcelReader.Core/Writer/Zip/ZipEntryStream.cs b/src/ExcelReader.Core/Writer/Zip/ZipEntryStream.cs index 65b2181e..e0be2f0f 100644 --- a/src/ExcelReader.Core/Writer/Zip/ZipEntryStream.cs +++ b/src/ExcelReader.Core/Writer/Zip/ZipEntryStream.cs @@ -102,6 +102,36 @@ public override long Position public override void Flush() { + if (_closed) + { + return; + } + try + { + _owner.FlushOutput(); + } + catch + { + Fail(); + throw; + } + } + + public override async Task FlushAsync(CancellationToken cancellationToken) + { + if (_closed) + { + return; + } + try + { + await _owner.FlushOutputAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + Fail(); + throw; + } } public override int Read(byte[] buffer, int offset, int count) diff --git a/src/ExcelReader.Core/Writer/Zip/ZipPackageWriter.cs b/src/ExcelReader.Core/Writer/Zip/ZipPackageWriter.cs index 405a3316..9816dbd2 100644 --- a/src/ExcelReader.Core/Writer/Zip/ZipPackageWriter.cs +++ b/src/ExcelReader.Core/Writer/Zip/ZipPackageWriter.cs @@ -9,7 +9,7 @@ namespace ExcelReader.Core.Writer.Zip /// ZipArchive uses in create mode. Offsets are absolute positions in that stream. /// [SuppressMessage("Design", "CA1001:Types that own disposable fields should be disposable", - Justification = "The open entry belongs to the caller that opened it and is completed by its Dispose; Abandon releases it without writing.")] + Justification = "The open entry belongs to the caller that opened it and is completed by its Dispose; Abandon releases it without completing it.")] internal sealed class ZipPackageWriter { private readonly Stream _output; @@ -71,11 +71,35 @@ internal async ValueTask FinishAsync(CancellationToken ct = default) await WriteRawAsync(directory.Memory, ct).ConfigureAwait(false); } + [SuppressMessage("Design", "CA1031:Do not catch general exception types", + Justification = "Called while an earlier failure propagates; the directory is best effort and must not hide that failure.")] internal void Abandon() { + if (_closed) + { + return; + } _closed = true; _open?.Abandon(); _open = null; + try + { + using BiffBuffer directory = BuildDirectory(); + WriteRaw(directory.Span); + } + catch (Exception) + { + } + } + + internal void FlushOutput() + { + _output.Flush(); + } + + internal Task FlushOutputAsync(CancellationToken ct) + { + return _output.FlushAsync(ct); } internal void WriteRaw(ReadOnlySpan bytes) @@ -124,6 +148,11 @@ private BiffBuffer BuildCentralDirectory() throw new InvalidOperationException("Every ZIP entry must be closed successfully before the archive is finished."); } _closed = true; + return BuildDirectory(); + } + + private BiffBuffer BuildDirectory() + { long directoryOffset = Position; var directory = new BiffBuffer(256 + (_entries.Count * 128)); foreach (ZipEntryRecord entry in _entries) diff --git a/tests/ExcelReader.Tests/Writer/Zip/ZipPackageWriterTests.cs b/tests/ExcelReader.Tests/Writer/Zip/ZipPackageWriterTests.cs index 94929838..e1d7992d 100644 --- a/tests/ExcelReader.Tests/Writer/Zip/ZipPackageWriterTests.cs +++ b/tests/ExcelReader.Tests/Writer/Zip/ZipPackageWriterTests.cs @@ -238,65 +238,52 @@ public void WriteByteBuildsTheSameEntry() } [Fact] - public void AFailedWriteLeavesNoCentralDirectory() + public void AFailedWriteKillsTheArchiveAndAbandonListsTheCompletedEntries() { - var output = new FailingWriteStream(limit: 100_000); + var output = new FailOnceWriteStream(failOnCall: 2); var zip = new ZipPackageWriter(output); + CompleteEntry(zip, "done.xml", Done); Stream entry = zip.OpenEntry("big.xml", CompressionLevel.NoCompression); - byte[] chunk = new byte[64 * 1024]; - Assert.Throws(() => - { - for (int i = 0; i < 10; i++) - { - entry.Write(chunk); - } - }); - AssertFailedEntryIsDead(zip, entry, output.Written); + Assert.Throws(() => entry.Write(new byte[64 * 1024])); + long length = AssertFailedArchiveRejectsEverything(zip, entry, output.Written); + zip.Abandon(); + byte[] package = output.Written.ToArray(); + Assert.True(package.Length > length); + Assert.Equal(1, EndRecordCount(package)); + AssertEntries(package, ("done.xml", Done)); zip.Abandon(); + Assert.Equal(package.Length, output.Written.Length); Assert.Throws(() => zip.OpenEntry("next.xml", CompressionLevel.Fastest)); - } - - private static void AssertFailedEntryIsDead(ZipPackageWriter zip, Stream entry, MemoryStream written) - { - long length = written.Length; - entry.Dispose(); - Assert.Throws(() => entry.Write(new byte[1])); - Assert.Throws(zip.Finish); - Assert.Throws(() => zip.OpenEntry("next.xml", CompressionLevel.Fastest)); - Assert.Equal(length, written.Length); - Assert.Equal(-1, written.ToArray().AsSpan().IndexOf("PK"u8)); - } - - [Fact] - public void ATransientWriteFailureKillsTheEntry() - { - var output = new FailOnceWriteStream(failOnCall: 1); - var zip = new ZipPackageWriter(output); - Stream entry = zip.OpenEntry("big.xml", CompressionLevel.NoCompression); - Assert.Throws(() => entry.Write(new byte[64 * 1024])); - AssertFailedEntryIsDead(zip, entry, output.Written); + Assert.Throws(zip.Finish); } [Fact] - public async Task ATransientAsyncWriteFailureKillsTheEntry() + public async Task AFailedAsyncWriteKillsTheArchiveAndAbandonListsTheCompletedEntries() { CancellationToken ct = TestContext.Current.CancellationToken; - var output = new FailOnceWriteStream(failOnCall: 1); + var output = new FailOnceWriteStream(failOnCall: 2); var zip = new ZipPackageWriter(output); + CompleteEntry(zip, "done.xml", Done); Stream entry = zip.OpenEntry("big.xml", CompressionLevel.NoCompression); await Assert.ThrowsAsync(async () => await entry.WriteAsync(new byte[64 * 1024], ct)); await entry.DisposeAsync(); await Assert.ThrowsAsync(async () => await zip.FinishAsync(ct)); - AssertFailedEntryIsDead(zip, entry, output.Written); + AssertFailedArchiveRejectsEverything(zip, entry, output.Written); + + zip.Abandon(); + byte[] package = output.Written.ToArray(); + Assert.Equal(1, EndRecordCount(package)); + AssertEntries(package, ("done.xml", Done)); } [Fact] - public async Task ACancelledWriteKillsTheEntryEvenWhenDisposedWithNoToken() + public async Task ACancelledWriteKillsTheArchiveEvenWhenDisposedWithNoToken() { CancellationToken ct = TestContext.Current.CancellationToken; var output = new MemoryStream(); var zip = new ZipPackageWriter(output); + CompleteEntry(zip, "done.xml", Done); Stream entry = zip.OpenEntry("big.xml", CompressionLevel.NoCompression); using var cancelled = new CancellationTokenSource(); await cancelled.CancelAsync(); @@ -304,27 +291,163 @@ await Assert.ThrowsAnyAsync( async () => await entry.WriteAsync(new byte[64 * 1024], cancelled.Token)); await entry.DisposeAsync(); await Assert.ThrowsAsync(async () => await zip.FinishAsync(ct)); - AssertFailedEntryIsDead(zip, entry, output); + AssertFailedArchiveRejectsEverything(zip, entry, output); + + zip.Abandon(); + byte[] package = output.ToArray(); + Assert.Equal(1, EndRecordCount(package)); + AssertEntries(package, ("done.xml", Done)); + } + + [Fact] + public void AFailedFlushKillsTheArchiveAndAbandonListsTheCompletedEntries() + { + var output = new FlushFailingStream(); + var zip = new ZipPackageWriter(output); + CompleteEntry(zip, "done.xml", Done); + Stream entry = zip.OpenEntry("next.xml", CompressionLevel.NoCompression); + entry.Write(Done); + output.FailFlush = true; + Assert.Throws(entry.Flush); + output.FailFlush = false; + AssertFailedArchiveRejectsEverything(zip, entry, output); + + zip.Abandon(); + byte[] package = output.ToArray(); + Assert.Equal(1, EndRecordCount(package)); + AssertEntries(package, ("done.xml", Done)); + } + + [Fact] + public async Task AFailedAsyncFlushKillsTheArchive() + { + CancellationToken ct = TestContext.Current.CancellationToken; + var output = new FlushFailingStream { FailFlush = true }; + var zip = new ZipPackageWriter(output); + Stream entry = zip.OpenEntry("a.xml", CompressionLevel.NoCompression); + await Assert.ThrowsAsync(() => entry.FlushAsync(ct)); + output.FailFlush = false; + AssertFailedArchiveRejectsEverything(zip, entry, output); + } + + [Fact] + public void FlushingAnEntryFlushesTheOutputAndFlushingAClosedEntryDoesNot() + { + var output = new FlushFailingStream(); + var zip = new ZipPackageWriter(output); + Stream entry = zip.OpenEntry("a.xml", CompressionLevel.NoCompression); + entry.Flush(); + Assert.Equal(1, output.Flushes); + entry.Dispose(); + entry.Flush(); + Assert.Equal(1, output.Flushes); } [Theory] [InlineData(CompressionLevel.Fastest)] [InlineData(CompressionLevel.Optimal)] [InlineData(CompressionLevel.NoCompression)] - public void AbandonReleasesTheOpenEntryWithoutWriting(CompressionLevel level) + public void AbandonWithAnOpenEntryListsTheCompletedEntriesAndReleasesTheOpenOne(CompressionLevel level) { var output = new MemoryStream(); var zip = new ZipPackageWriter(output); + CompleteEntry(zip, "done.xml", Done); Stream entry = zip.OpenEntry("a.xml", level); entry.Write(DeflateEncoderTests.SheetXml(2000)); - long length = output.Length; zip.Abandon(); - Assert.Equal(length, output.Length); + + byte[] package = output.ToArray(); + Assert.Equal(1, EndRecordCount(package)); + AssertEntries(package, ("done.xml", Done)); Assert.Throws(() => entry.Write(new byte[1])); entry.Dispose(); zip.Abandon(); - Assert.Equal(length, output.Length); + Assert.Equal(package.Length, output.Length); Assert.Throws(zip.Finish); + Assert.Throws(() => zip.OpenEntry("next.xml", level)); + } + + [Fact] + public void AbandonOnAnOutputThatAlwaysFailsDoesNotThrow() + { + var output = new FailingWriteStream(limit: 0); + var zip = new ZipPackageWriter(output); + Stream entry = zip.OpenEntry("big.xml", CompressionLevel.NoCompression); + Assert.Throws(() => entry.Write(new byte[64 * 1024])); + zip.Abandon(); + zip.Abandon(); + Assert.Equal(0, output.Written.Length); + } + + private static readonly byte[] Done = "content"u8.ToArray(); + + private static void CompleteEntry(ZipPackageWriter zip, string name, byte[] content) + { + using Stream entry = zip.OpenEntry(name, CompressionLevel.NoCompression); + entry.Write(content); + } + + private static long AssertFailedArchiveRejectsEverything(ZipPackageWriter zip, Stream entry, MemoryStream written) + { + long length = written.Length; + entry.Dispose(); + Assert.Throws(() => entry.Write(new byte[1])); + Assert.Throws(zip.Finish); + Assert.Throws(() => zip.OpenEntry("next.xml", CompressionLevel.Fastest)); + Assert.Equal(length, written.Length); + return length; + } + + private static int EndRecordCount(byte[] package) + { + int count = 0; + int from = 0; + ReadOnlySpan signature = [0x50, 0x4B, 0x05, 0x06]; + while (true) + { + int found = package.AsSpan(from).IndexOf(signature); + if (found < 0) + { + return count; + } + count++; + from += found + signature.Length; + } + } + + private static void AssertEntries(byte[] package, params (string Name, byte[] Content)[] expected) + { + using var archive = new ZipArchive(new MemoryStream(package), ZipArchiveMode.Read); + Assert.Equal(expected.Select(static e => e.Name), archive.Entries.Select(static e => e.FullName), StringComparer.Ordinal); + for (int i = 0; i < expected.Length; i++) + { + using Stream data = archive.Entries[i].Open(); + using var copy = new MemoryStream(); + data.CopyTo(copy); + Assert.True(copy.ToArray().AsSpan().SequenceEqual(expected[i].Content)); + Assert.Equal(Crc32Tests.BitwiseCrc(expected[i].Content), archive.Entries[i].Crc32); + } + } + + private sealed class FlushFailingStream : MemoryStream + { + internal bool FailFlush { get; set; } + internal int Flushes { get; private set; } + + public override void Flush() + { + Flushes++; + if (FailFlush) + { + throw new IOException("flush failed"); + } + } + + public override Task FlushAsync(CancellationToken cancellationToken) + { + Flush(); + return Task.CompletedTask; + } } [Theory] From 091e7a4c688c973ef7ed68fcb5bf36aba3375654 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 01:41:10 -0300 Subject: [PATCH 12/42] feat: write XLSX and XLSB packages through the managed ZIP writer --- .../Writer/Internal/ZipEntryWriter.cs | 16 +-- .../Writer/Xlsb/XlsbSheetWriter.cs | 14 +- .../Writer/Xlsb/XlsbWorkbookWriter.cs | 26 ++-- .../Writer/Xlsx/XlsxSheetWriter.cs | 11 +- .../Writer/Xlsx/XlsxWorkbookWriter.cs | 26 ++-- .../Writer/Zip/WriterZipIntegrationTests.cs | 127 ++++++++++++++++++ 6 files changed, 167 insertions(+), 53 deletions(-) create mode 100644 tests/ExcelReader.Tests/Writer/Zip/WriterZipIntegrationTests.cs diff --git a/src/ExcelReader.Core/Writer/Internal/ZipEntryWriter.cs b/src/ExcelReader.Core/Writer/Internal/ZipEntryWriter.cs index 0f05fb83..87659d80 100644 --- a/src/ExcelReader.Core/Writer/Internal/ZipEntryWriter.cs +++ b/src/ExcelReader.Core/Writer/Internal/ZipEntryWriter.cs @@ -1,6 +1,7 @@ using System.Buffers; using System.IO.Compression; using System.Text; +using ExcelReader.Core.Writer.Zip; namespace ExcelReader.Core.Writer.Internal { @@ -8,7 +9,7 @@ internal static class ZipEntryWriter { private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); - internal static async ValueTask WriteTextAsync(ZipArchive zip, string entryName, string content, CompressionLevel compression, CancellationToken ct) + internal static async ValueTask WriteTextAsync(ZipPackageWriter zip, string entryName, string content, CompressionLevel compression, CancellationToken ct) { byte[] rented = ArrayPool.Shared.Rent(Utf8NoBom.GetMaxByteCount(content.Length)); try @@ -22,10 +23,9 @@ internal static async ValueTask WriteTextAsync(ZipArchive zip, string entryName, } } - internal static async ValueTask WriteBytesAsync(ZipArchive zip, string entryName, ReadOnlyMemory content, CompressionLevel compression, CancellationToken ct) + internal static async ValueTask WriteBytesAsync(ZipPackageWriter zip, string entryName, ReadOnlyMemory content, CompressionLevel compression, CancellationToken ct) { - ZipArchiveEntry entry = zip.CreateEntry(entryName, compression); - Stream stream = await entry.OpenAsync(ct).ConfigureAwait(false); + Stream stream = zip.OpenEntry(entryName, compression); await using (stream.ConfigureAwait(false)) { await stream.WriteAsync(content, ct).ConfigureAwait(false); @@ -38,8 +38,7 @@ internal static ValueTask FlushAsync(Stream stream, CancellationToken ct) return new ValueTask(stream.FlushAsync(ct)); } - - internal static void WriteText(ZipArchive zip, string entryName, string content, CompressionLevel compression) + internal static void WriteText(ZipPackageWriter zip, string entryName, string content, CompressionLevel compression) { byte[] rented = ArrayPool.Shared.Rent(Utf8NoBom.GetMaxByteCount(content.Length)); try @@ -53,10 +52,9 @@ internal static void WriteText(ZipArchive zip, string entryName, string content, } } - internal static void WriteBytes(ZipArchive zip, string entryName, ReadOnlySpan content, CompressionLevel compression) + internal static void WriteBytes(ZipPackageWriter zip, string entryName, ReadOnlySpan content, CompressionLevel compression) { - ZipArchiveEntry entry = zip.CreateEntry(entryName, compression); - using Stream stream = entry.Open(); + using Stream stream = zip.OpenEntry(entryName, compression); stream.Write(content); } diff --git a/src/ExcelReader.Core/Writer/Xlsb/XlsbSheetWriter.cs b/src/ExcelReader.Core/Writer/Xlsb/XlsbSheetWriter.cs index 8cd8cf45..f0dd7105 100644 --- a/src/ExcelReader.Core/Writer/Xlsb/XlsbSheetWriter.cs +++ b/src/ExcelReader.Core/Writer/Xlsb/XlsbSheetWriter.cs @@ -5,6 +5,7 @@ using ExcelReader.Core.Reader.Xlsb; using ExcelReader.Core.Writer.Internal; using ExcelReader.Core.Writer.Xls; +using ExcelReader.Core.Writer.Zip; namespace ExcelReader.Core.Writer.Xlsb { @@ -14,7 +15,7 @@ public sealed class XlsbSheetWriter : ISheetWriter, ITableSheetWr private const int SpillThreshold = 64 * 1024; private readonly XlsbWorkbookWriter _owner; - private readonly ZipArchive _zip; + private readonly ZipPackageWriter _zip; private readonly bool _date1904; private readonly CompressionLevel _compression; private readonly bool _offloadWrite; @@ -41,7 +42,7 @@ public sealed class XlsbSheetWriter : ISheetWriter, ITableSheetWr internal XlsbSheetWriter( XlsbWorkbookWriter owner, - ZipArchive zip, + ZipPackageWriter zip, string name, int sheetId, ExcelSheetVisibility visibility, @@ -405,8 +406,7 @@ private void EnsureStream() { return; } - ZipArchiveEntry entry = _zip.CreateEntry($"xl/worksheets/sheet{SheetId}.bin", _compression); - Stream stream = entry.Open(); + Stream stream = _zip.OpenEntry($"xl/worksheets/sheet{SheetId}.bin", _compression); _stream = _offloadWrite ? new WriteOffloadStream(stream) : stream; } @@ -438,15 +438,13 @@ private void FlushRecords() private void WriteBufferedSheet() { - ZipArchiveEntry entry = _zip.CreateEntry($"xl/worksheets/sheet{SheetId}.bin", _compression); - using Stream stream = entry.Open(); + using Stream stream = _zip.OpenEntry($"xl/worksheets/sheet{SheetId}.bin", _compression); stream.Write(_records.Span); } private async ValueTask WriteBufferedSheetAsync(CancellationToken ct) { - ZipArchiveEntry entry = _zip.CreateEntry($"xl/worksheets/sheet{SheetId}.bin", _compression); - Stream stream = await entry.OpenAsync(ct).ConfigureAwait(false); + Stream stream = _zip.OpenEntry($"xl/worksheets/sheet{SheetId}.bin", _compression); await using (stream.ConfigureAwait(false)) { await stream.WriteAsync(_records.Memory, ct).ConfigureAwait(false); diff --git a/src/ExcelReader.Core/Writer/Xlsb/XlsbWorkbookWriter.cs b/src/ExcelReader.Core/Writer/Xlsb/XlsbWorkbookWriter.cs index ab7b373d..f029124b 100644 --- a/src/ExcelReader.Core/Writer/Xlsb/XlsbWorkbookWriter.cs +++ b/src/ExcelReader.Core/Writer/Xlsb/XlsbWorkbookWriter.cs @@ -4,13 +4,14 @@ using System.Text; using ExcelReader.Core.Reader.Xlsb; using ExcelReader.Core.Writer.Internal; +using ExcelReader.Core.Writer.Zip; namespace ExcelReader.Core.Writer.Xlsb { /// Writes an .xlsb workbook (BIFF12 binary format) to a stream, one sheet at a time. public sealed class XlsbWorkbookWriter : IWorkbookWriter { - private readonly ZipArchive _zip; + private readonly ZipPackageWriter _zip; private readonly Stream _stream; private readonly bool _leaveOpen; private readonly bool _date1904; @@ -26,7 +27,7 @@ public sealed class XlsbWorkbookWriter : IWorkbookWriter private bool _zipClosed; private bool _disposed; - private XlsbWorkbookWriter(ZipArchive zip, Stream stream, bool leaveOpen, XlsbWriterOptions options) + private XlsbWorkbookWriter(ZipPackageWriter zip, Stream stream, bool leaveOpen, XlsbWriterOptions options) { _zip = zip; _stream = stream; @@ -46,7 +47,7 @@ private XlsbWorkbookWriter(ZipArchive zip, Stream stream, bool leaveOpen, XlsbWr public static XlsbWorkbookWriter Create(Stream stream, bool leaveOpen = false, XlsbWriterOptions? options = null) { ArgumentNullException.ThrowIfNull(stream); - ZipArchive zip = new(stream, ZipArchiveMode.Create, leaveOpen: true); + ZipPackageWriter zip = new(stream); return new XlsbWorkbookWriter(zip, stream, leaveOpen, options ?? XlsbWriterOptions.Default); } @@ -162,7 +163,7 @@ public async ValueTask EndAsync(CancellationToken ct = default) await WriteAppPropertiesAsync(ct).ConfigureAwait(false); await WriteTablesAsync(ct).ConfigureAwait(false); await WriteContentTypesAsync(ct).ConfigureAwait(false); - await CloseZipAsync().ConfigureAwait(false); + await CloseZipAsync(ct).ConfigureAwait(false); } catch { @@ -174,13 +175,13 @@ public async ValueTask EndAsync(CancellationToken ct = default) private void CloseZip() { _zipClosed = true; - _zip.Dispose(); + _zip.Finish(); } - private ValueTask CloseZipAsync() + private ValueTask CloseZipAsync(CancellationToken ct = default) { _zipClosed = true; - return _zip.DisposeAsync(); + return _zip.FinishAsync(ct); } private void AbandonZip() @@ -189,19 +190,14 @@ private void AbandonZip() if (!_zipClosed) { _zipClosed = true; - FailureCleanup.Dispose(_zip); + _zip.Abandon(); } } private ValueTask AbandonZipAsync() { - _faulted = true; - if (_zipClosed) - { - return ValueTask.CompletedTask; - } - _zipClosed = true; - return FailureCleanup.DisposeAsync(_zip); + AbandonZip(); + return ValueTask.CompletedTask; } private void ReleaseStream() diff --git a/src/ExcelReader.Core/Writer/Xlsx/XlsxSheetWriter.cs b/src/ExcelReader.Core/Writer/Xlsx/XlsxSheetWriter.cs index 9bb2c5f1..c74e863a 100644 --- a/src/ExcelReader.Core/Writer/Xlsx/XlsxSheetWriter.cs +++ b/src/ExcelReader.Core/Writer/Xlsx/XlsxSheetWriter.cs @@ -5,6 +5,7 @@ using ExcelReader.Core.Reader; using ExcelReader.Core.Reader.Internal; using ExcelReader.Core.Writer.Internal; +using ExcelReader.Core.Writer.Zip; namespace ExcelReader.Core.Writer.Xlsx { @@ -15,7 +16,7 @@ public sealed class XlsxSheetWriter : ISheetWriter, ITableSheetWr { private readonly TableTracker _tables = new(); private readonly XlsxWorkbookWriter _owner; - private readonly ZipArchive _zip; + private readonly ZipPackageWriter _zip; private readonly CompressionLevel _compression; private readonly bool _offloadWrite; private readonly BiffBuffer _rowBuffer = new(512); @@ -29,7 +30,7 @@ public sealed class XlsxSheetWriter : ISheetWriter, ITableSheetWr private Dictionary? _columnStyles; private Dictionary? _columnWidths; - internal XlsxSheetWriter(XlsxWorkbookWriter owner, ZipArchive zip, string name, int sheetId, + internal XlsxSheetWriter(XlsxWorkbookWriter owner, ZipPackageWriter zip, string name, int sheetId, ExcelSheetVisibility visibility, CompressionLevel compression, bool offloadWrite) { _owner = owner; @@ -88,8 +89,7 @@ private void EnsureStarted() } try { - ZipArchiveEntry entry = _zip.CreateEntry($"xl/worksheets/sheet{SheetId}.xml", _compression); - Stream stream = entry.Open(); + Stream stream = _zip.OpenEntry($"xl/worksheets/sheet{SheetId}.xml", _compression); _stream = _offloadWrite ? new WriteOffloadStream(stream) : stream; _rowBuffer.Reset(); _rowBuffer.WriteUtf8( @@ -117,8 +117,7 @@ private async ValueTask StartCoreAsync(CancellationToken ct) { try { - ZipArchiveEntry entry = _zip.CreateEntry($"xl/worksheets/sheet{SheetId}.xml", _compression); - Stream stream = await entry.OpenAsync(ct).ConfigureAwait(false); + Stream stream = _zip.OpenEntry($"xl/worksheets/sheet{SheetId}.xml", _compression); _stream = _offloadWrite ? new WriteOffloadStream(stream) : stream; _rowBuffer.Reset(); _rowBuffer.WriteUtf8( diff --git a/src/ExcelReader.Core/Writer/Xlsx/XlsxWorkbookWriter.cs b/src/ExcelReader.Core/Writer/Xlsx/XlsxWorkbookWriter.cs index 34b40541..93fb21b3 100644 --- a/src/ExcelReader.Core/Writer/Xlsx/XlsxWorkbookWriter.cs +++ b/src/ExcelReader.Core/Writer/Xlsx/XlsxWorkbookWriter.cs @@ -3,6 +3,7 @@ using System.Security; using System.Text; using ExcelReader.Core.Writer.Internal; +using ExcelReader.Core.Writer.Zip; namespace ExcelReader.Core.Writer.Xlsx { @@ -11,7 +12,7 @@ namespace ExcelReader.Core.Writer.Xlsx /// public sealed class XlsxWorkbookWriter : IWorkbookWriter { - private readonly ZipArchive _zip; + private readonly ZipPackageWriter _zip; private readonly Stream _stream; private readonly bool _leaveOpen; private readonly CompressionLevel _compression; @@ -28,7 +29,7 @@ public sealed class XlsxWorkbookWriter : IWorkbookWriter private bool _zipClosed; private bool _disposed; - private XlsxWorkbookWriter(ZipArchive zip, Stream stream, bool leaveOpen, XlsxWriterOptions options) + private XlsxWorkbookWriter(ZipPackageWriter zip, Stream stream, bool leaveOpen, XlsxWriterOptions options) { _zip = zip; _stream = stream; @@ -50,7 +51,7 @@ private XlsxWorkbookWriter(ZipArchive zip, Stream stream, bool leaveOpen, XlsxWr public static XlsxWorkbookWriter Create(Stream stream, bool leaveOpen = false, XlsxWriterOptions? options = null) { ArgumentNullException.ThrowIfNull(stream); - ZipArchive zip = new(stream, ZipArchiveMode.Create, leaveOpen: true); + ZipPackageWriter zip = new(stream); return new XlsxWorkbookWriter(zip, stream, leaveOpen, options ?? XlsxWriterOptions.Default); } @@ -218,7 +219,7 @@ public async ValueTask EndAsync(CancellationToken ct = default) await WriteWorkbookAsync(ct).ConfigureAwait(false); await WriteWorkbookRelsAsync(ct).ConfigureAwait(false); await WriteContentTypesAsync(ct).ConfigureAwait(false); - await CloseZipAsync().ConfigureAwait(false); + await CloseZipAsync(ct).ConfigureAwait(false); } catch { @@ -230,13 +231,13 @@ public async ValueTask EndAsync(CancellationToken ct = default) private void CloseZip() { _zipClosed = true; - _zip.Dispose(); + _zip.Finish(); } - private ValueTask CloseZipAsync() + private ValueTask CloseZipAsync(CancellationToken ct = default) { _zipClosed = true; - return _zip.DisposeAsync(); + return _zip.FinishAsync(ct); } private void AbandonZip() @@ -245,19 +246,14 @@ private void AbandonZip() if (!_zipClosed) { _zipClosed = true; - FailureCleanup.Dispose(_zip); + _zip.Abandon(); } } private ValueTask AbandonZipAsync() { - _faulted = true; - if (_zipClosed) - { - return ValueTask.CompletedTask; - } - _zipClosed = true; - return FailureCleanup.DisposeAsync(_zip); + AbandonZip(); + return ValueTask.CompletedTask; } private void ReleaseStream() diff --git a/tests/ExcelReader.Tests/Writer/Zip/WriterZipIntegrationTests.cs b/tests/ExcelReader.Tests/Writer/Zip/WriterZipIntegrationTests.cs new file mode 100644 index 00000000..8f36cb9d --- /dev/null +++ b/tests/ExcelReader.Tests/Writer/Zip/WriterZipIntegrationTests.cs @@ -0,0 +1,127 @@ +using System.Globalization; +using System.IO.Compression; +using ExcelReader.Core.Reader; +using ExcelReader.Core.Reader.Xlsb; +using ExcelReader.Core.Reader.Xlsx; +using ExcelReader.Core.Writer.Xlsb; +using ExcelReader.Core.Writer.Xlsx; + +namespace ExcelReader.Tests.Writer.Zip +{ + public class WriterZipIntegrationTests + { + private const int Rows = 30_000; + + public static TheoryData Cases() + { + var data = new TheoryData(); + foreach (CompressionLevel level in Enum.GetValues()) + { + foreach (bool seekable in new[] { true, false }) + { + data.Add(level, seekable, false); + data.Add(level, seekable, true); + } + } + return data; + } + + private static Stream Target(MemoryStream ms, bool seekable) + { + if (seekable) + { + return ms; + } + return new ForwardOnlyWriteStream(ms); + } + + private static void AssertEveryEntryCrc(byte[] package) + { + using var zip = new ZipArchive(new MemoryStream(package), ZipArchiveMode.Read); + Assert.NotEmpty(zip.Entries); + foreach (ZipArchiveEntry entry in zip.Entries) + { + using Stream data = entry.Open(); + using var copy = new MemoryStream(); + data.CopyTo(copy); + Assert.Equal(Crc32Tests.BitwiseCrc(copy.ToArray()), entry.Crc32); + } + } + + private static void AssertRows(IExcelWorkbook reader) + { + using IExcelRowEnumerator rows = reader.SheetAt(0).GetEnumerator(); + for (int i = 0; i < Rows; i++) + { + Assert.True(rows.MoveNext()); + Assert.Equal(i.ToString(CultureInfo.InvariantCulture), rows.Current[0].GetString()); + Assert.Equal($"text {i % 101}", rows.Current[1].GetString()); + } + Assert.False(rows.MoveNext()); + using IExcelRowEnumerator second = reader.SheetAt(1).GetEnumerator(); + Assert.True(second.MoveNext()); + Assert.Equal("second", second.Current[0].GetString()); + } + + [Theory] + [MemberData(nameof(Cases))] + public async Task XlsxReadsBackFromEveryLevelAndOutput(CompressionLevel level, bool seekable, bool prefetch) + { + CancellationToken ct = TestContext.Current.CancellationToken; + var ms = new MemoryStream(); + var options = new XlsxWriterOptions { Compression = level, PrefetchWrite = prefetch, UseSharedStrings = true }; + await using (XlsxWorkbookWriter wb = XlsxWorkbookWriter.Create(Target(ms, seekable), leaveOpen: true, options)) + { + XlsxSheetWriter sheet = wb.AddSheet("Data"); + for (int i = 0; i < Rows; i++) + { + await using XlsxRowWriter row = await sheet.StartRowAsync(ct); + row.Write(i); + row.Write($"text {i % 101}"); + row.Write(i * 1.5); + } + await sheet.EndAsync(ct); + XlsxSheetWriter second = wb.AddSheet("Second"); + await using (XlsxRowWriter row = await second.StartRowAsync(ct)) + { + row.Write("second"); + } + await second.EndAsync(ct); + await wb.EndAsync(ct); + } + AssertEveryEntryCrc(ms.ToArray()); + using XlsxWorkbook reader = Excel.FromXlsx(new MemoryStream(ms.ToArray())); + AssertRows(reader); + } + + [Theory] + [MemberData(nameof(Cases))] + public void XlsbReadsBackFromEveryLevelAndOutput(CompressionLevel level, bool seekable, bool prefetch) + { + var ms = new MemoryStream(); + var options = new XlsbWriterOptions { Compression = level, PrefetchWrite = prefetch, UseSharedStrings = true }; + using (XlsbWorkbookWriter wb = XlsbWorkbookWriter.Create(Target(ms, seekable), leaveOpen: true, options)) + { + XlsbSheetWriter sheet = wb.AddSheet("Data"); + for (int i = 0; i < Rows; i++) + { + using XlsbRowWriter row = sheet.StartRow(); + row.Write(i); + row.Write($"text {i % 101}"); + row.Write(i * 1.5); + } + sheet.End(); + XlsbSheetWriter second = wb.AddSheet("Second"); + using (XlsbRowWriter row = second.StartRow()) + { + row.Write("second"); + } + second.End(); + wb.End(); + } + AssertEveryEntryCrc(ms.ToArray()); + using XlsbWorkbook reader = Excel.FromXlsb(new MemoryStream(ms.ToArray())); + AssertRows(reader); + } + } +} From ac2fe911126e99e679b546dcec8ad28779b724cb Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 01:49:07 -0300 Subject: [PATCH 13/42] fix: abandon the ZIP archive asynchronously after an async fault AbandonAsync writes the directory with WriteAsync, seeks to the counted end first on seekable output, and releases the open entry inside the best-effort block. --- .../Writer/Xlsb/XlsbWorkbookWriter.cs | 9 +- .../Writer/Xlsx/XlsxWorkbookWriter.cs | 9 +- .../Writer/Zip/ZipPackageWriter.cs | 47 +++++- .../Writer/Zip/ZipPackageWriterTests.cs | 144 ++++++++++++++++++ 4 files changed, 200 insertions(+), 9 deletions(-) diff --git a/src/ExcelReader.Core/Writer/Xlsb/XlsbWorkbookWriter.cs b/src/ExcelReader.Core/Writer/Xlsb/XlsbWorkbookWriter.cs index f029124b..bf381d9e 100644 --- a/src/ExcelReader.Core/Writer/Xlsb/XlsbWorkbookWriter.cs +++ b/src/ExcelReader.Core/Writer/Xlsb/XlsbWorkbookWriter.cs @@ -196,8 +196,13 @@ private void AbandonZip() private ValueTask AbandonZipAsync() { - AbandonZip(); - return ValueTask.CompletedTask; + _faulted = true; + if (_zipClosed) + { + return ValueTask.CompletedTask; + } + _zipClosed = true; + return _zip.AbandonAsync(); } private void ReleaseStream() diff --git a/src/ExcelReader.Core/Writer/Xlsx/XlsxWorkbookWriter.cs b/src/ExcelReader.Core/Writer/Xlsx/XlsxWorkbookWriter.cs index 93fb21b3..4fde2814 100644 --- a/src/ExcelReader.Core/Writer/Xlsx/XlsxWorkbookWriter.cs +++ b/src/ExcelReader.Core/Writer/Xlsx/XlsxWorkbookWriter.cs @@ -252,8 +252,13 @@ private void AbandonZip() private ValueTask AbandonZipAsync() { - AbandonZip(); - return ValueTask.CompletedTask; + _faulted = true; + if (_zipClosed) + { + return ValueTask.CompletedTask; + } + _zipClosed = true; + return _zip.AbandonAsync(); } private void ReleaseStream() diff --git a/src/ExcelReader.Core/Writer/Zip/ZipPackageWriter.cs b/src/ExcelReader.Core/Writer/Zip/ZipPackageWriter.cs index 9816dbd2..d2ebe75a 100644 --- a/src/ExcelReader.Core/Writer/Zip/ZipPackageWriter.cs +++ b/src/ExcelReader.Core/Writer/Zip/ZipPackageWriter.cs @@ -75,16 +75,13 @@ internal async ValueTask FinishAsync(CancellationToken ct = default) Justification = "Called while an earlier failure propagates; the directory is best effort and must not hide that failure.")] internal void Abandon() { - if (_closed) + if (!TryClose()) { return; } - _closed = true; - _open?.Abandon(); - _open = null; try { - using BiffBuffer directory = BuildDirectory(); + using BiffBuffer directory = PrepareAbandonedDirectory(); WriteRaw(directory.Span); } catch (Exception) @@ -92,6 +89,24 @@ internal void Abandon() } } + [SuppressMessage("Design", "CA1031:Do not catch general exception types", + Justification = "Called while an earlier failure propagates; the directory is best effort and must not hide that failure.")] + internal async ValueTask AbandonAsync() + { + if (!TryClose()) + { + return; + } + try + { + using BiffBuffer directory = PrepareAbandonedDirectory(); + await WriteRawAsync(directory.Memory, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception) + { + } + } + internal void FlushOutput() { _output.Flush(); @@ -151,6 +166,28 @@ private BiffBuffer BuildCentralDirectory() return BuildDirectory(); } + private bool TryClose() + { + if (_closed) + { + return false; + } + _closed = true; + return true; + } + + private BiffBuffer PrepareAbandonedDirectory() + { + ZipEntryStream? open = _open; + _open = null; + open?.Abandon(); + if (CanSeek) + { + _output.Seek(Position, SeekOrigin.Begin); + } + return BuildDirectory(); + } + private BiffBuffer BuildDirectory() { long directoryOffset = Position; diff --git a/tests/ExcelReader.Tests/Writer/Zip/ZipPackageWriterTests.cs b/tests/ExcelReader.Tests/Writer/Zip/ZipPackageWriterTests.cs index e1d7992d..7c316efa 100644 --- a/tests/ExcelReader.Tests/Writer/Zip/ZipPackageWriterTests.cs +++ b/tests/ExcelReader.Tests/Writer/Zip/ZipPackageWriterTests.cs @@ -379,6 +379,81 @@ public void AbandonOnAnOutputThatAlwaysFailsDoesNotThrow() Assert.Equal(0, output.Written.Length); } + [Fact] + public async Task AbandonAsyncWritesTheDirectoryOverAnOutputThatRejectsSyncWrites() + { + CancellationToken ct = TestContext.Current.CancellationToken; + var output = new AsyncOnlyStream(); + var zip = new ZipPackageWriter(output); + await CompleteEntryAsync(zip, "done.xml", Done, ct); + Stream entry = zip.OpenEntry("open.xml", CompressionLevel.NoCompression); + await entry.WriteAsync(Done, ct); + + await zip.AbandonAsync(); + + byte[] package = output.ToArray(); + Assert.Equal(1, EndRecordCount(package)); + AssertEntries(package, ("done.xml", Done)); + Assert.Throws(() => entry.Write(new byte[1])); + Assert.Throws(() => zip.OpenEntry("next.xml", CompressionLevel.Fastest)); + } + + [Fact] + public async Task AbandonAsyncIsIdempotentAndBlocksALaterAbandon() + { + CancellationToken ct = TestContext.Current.CancellationToken; + var output = new MemoryStream(); + var zip = new ZipPackageWriter(output); + await CompleteEntryAsync(zip, "done.xml", Done, ct); + + await zip.AbandonAsync(); + long length = output.Length; + await zip.AbandonAsync(); + zip.Abandon(); + + Assert.Equal(length, output.Length); + Assert.Equal(1, EndRecordCount(output.ToArray())); + } + + [Fact] + public async Task AbandonAsyncOnAnOutputThatAlwaysFailsDoesNotThrow() + { + var output = new FailingWriteStream(limit: 0); + var zip = new ZipPackageWriter(output); + Stream entry = zip.OpenEntry("big.xml", CompressionLevel.NoCompression); + await Assert.ThrowsAsync(async () => await entry.WriteAsync(new byte[64 * 1024], TestContext.Current.CancellationToken)); + await zip.AbandonAsync(); + await zip.AbandonAsync(); + Assert.Equal(0, output.Written.Length); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AbandonSeeksBackToTheEndAfterAFailedHeaderPatch(bool useAsync) + { + var output = new FailOnBackwardWriteStream(); + var zip = new ZipPackageWriter(output); + CompleteEntry(zip, "done.xml", Done); + Stream entry = zip.OpenEntry("big.xml", CompressionLevel.NoCompression); + entry.Write(new byte[70_000]); + output.Armed = true; + Assert.Throws(entry.Dispose); + + if (useAsync) + { + await zip.AbandonAsync(); + } + else + { + zip.Abandon(); + } + + byte[] package = output.ToArray(); + Assert.Equal(1, EndRecordCount(package)); + AssertEntries(package, ("done.xml", Done)); + } + private static readonly byte[] Done = "content"u8.ToArray(); private static void CompleteEntry(ZipPackageWriter zip, string name, byte[] content) @@ -387,6 +462,15 @@ private static void CompleteEntry(ZipPackageWriter zip, string name, byte[] cont entry.Write(content); } + private static async Task CompleteEntryAsync(ZipPackageWriter zip, string name, byte[] content, CancellationToken ct) + { + Stream entry = zip.OpenEntry(name, CompressionLevel.NoCompression); + await using (entry.ConfigureAwait(false)) + { + await entry.WriteAsync(content, ct); + } + } + private static long AssertFailedArchiveRejectsEverything(ZipPackageWriter zip, Stream entry, MemoryStream written) { long length = written.Length; @@ -429,6 +513,66 @@ private static void AssertEntries(byte[] package, params (string Name, byte[] Co } } + private sealed class AsyncOnlyStream : MemoryStream + { + private bool _inAsync; + + public override void Write(byte[] buffer, int offset, int count) + { + RejectSync(); + base.Write(buffer, offset, count); + } + + public override void Write(ReadOnlySpan buffer) + { + RejectSync(); + base.Write(buffer); + } + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + _inAsync = true; + try + { + Write(buffer.Span); + } + finally + { + _inAsync = false; + } + await Task.Yield(); + } + + private void RejectSync() + { + if (!_inAsync) + { + throw new NotSupportedException("sync write"); + } + } + } + + private sealed class FailOnBackwardWriteStream : MemoryStream + { + internal bool Armed { get; set; } + + public override void Write(ReadOnlySpan buffer) + { + if (Armed && Position < Length) + { + Armed = false; + throw new IOException("patch failed"); + } + base.Write(buffer); + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + Write(buffer.Span); + return ValueTask.CompletedTask; + } + } + private sealed class FlushFailingStream : MemoryStream { internal bool FailFlush { get; set; } From 5db9ada9535e2791f6c2033365bdeb59dca26d2c Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 01:51:22 -0300 Subject: [PATCH 14/42] test: fuzz the deflate encoder through both inflaters --- tests/ExcelReader.Fuzz/Harnesses.cs | 38 ++++++++++++++++++++++++++++ tests/ExcelReader.Fuzz/Program.cs | 1 + tests/ExcelReader.Fuzz/SeedCorpus.cs | 19 ++++++++++++++ 3 files changed, 58 insertions(+) diff --git a/tests/ExcelReader.Fuzz/Harnesses.cs b/tests/ExcelReader.Fuzz/Harnesses.cs index 5f97c322..6840f9ff 100644 --- a/tests/ExcelReader.Fuzz/Harnesses.cs +++ b/tests/ExcelReader.Fuzz/Harnesses.cs @@ -10,6 +10,8 @@ using ExcelReader.Core.Reader.Xlsb; using ExcelReader.Core.Reader.Xlsx; using ExcelReader.Core.Reader.Zip.Inflate; +using ExcelReader.Core.Writer.Internal; +using ExcelReader.Core.Writer.Zip; namespace ExcelReader.Fuzz { @@ -249,6 +251,42 @@ internal static void InflateDivergenceSelfCheck() throw new InvalidOperationException("The fuzz oracle swallows an inflate divergence."); } + internal static void Deflate(ReadOnlySpan data) + { + if (data.IsEmpty) + { + return; + } + int chunk = 1 + (data[0] * 4099 % 70_000); + ReadOnlySpan payload = data[1..]; + if (payload.Length > MaxInflateOutput) + { + payload = payload[..MaxInflateOutput]; + } + byte[] compressed = DeflateWithEncoder(payload, chunk); + byte[] viaZlib = InflateWith(static source => new DeflateStream(source, CompressionMode.Decompress), compressed); + byte[] viaLibrary = InflateWith(static source => new InflateStream(source), compressed); + if (!viaZlib.AsSpan().SequenceEqual(payload) || !viaLibrary.AsSpan().SequenceEqual(payload)) + { + throw new OracleDivergenceException( + $"Deflate round trip changed {payload.Length} bytes written in {chunk}-byte pieces: DeflateStream gave {viaZlib.Length}, InflateStream {viaLibrary.Length}."); + } + } + + private static byte[] DeflateWithEncoder(ReadOnlySpan payload, int chunk) + { + using var encoder = new DeflateEncoder(); + using var output = new BiffBuffer(4096); + while (!payload.IsEmpty) + { + int take = Math.Min(chunk, payload.Length); + encoder.Write(payload[..take], output); + payload = payload[take..]; + } + encoder.Finish(output); + return output.Span.ToArray(); + } + private static void RequireSameInflate(byte[] actual, byte[] reference, bool prefixOnly) { bool same = prefixOnly ? reference.AsSpan().StartsWith(actual) : actual.AsSpan().SequenceEqual(reference); diff --git a/tests/ExcelReader.Fuzz/Program.cs b/tests/ExcelReader.Fuzz/Program.cs index f60d1b89..09c133cd 100644 --- a/tests/ExcelReader.Fuzz/Program.cs +++ b/tests/ExcelReader.Fuzz/Program.cs @@ -31,6 +31,7 @@ internal static class Program ["csv-sniff"] = Harnesses.CsvSniff, ["csv-parallel"] = Harnesses.CsvParallel, ["inflate"] = Harnesses.Inflate, + ["deflate"] = Harnesses.Deflate, }; private static async Task Main(string[] args) diff --git a/tests/ExcelReader.Fuzz/SeedCorpus.cs b/tests/ExcelReader.Fuzz/SeedCorpus.cs index a63119a4..9246efb3 100644 --- a/tests/ExcelReader.Fuzz/SeedCorpus.cs +++ b/tests/ExcelReader.Fuzz/SeedCorpus.cs @@ -50,6 +50,7 @@ private static readonly (string Target, string[] Seeds)[] _layout = ("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"]), + ("deflate", ["deflate-text", "deflate-zeros", "deflate-noise"]), ]; internal static async Task GenerateAsync(string rootDirectory) @@ -101,6 +102,10 @@ private static async Task> BuildAllAsync() { seeds[name] = bytes; } + foreach ((string name, byte[] bytes) in DeflateSeeds()) + { + seeds[name] = bytes; + } return seeds; } @@ -113,6 +118,20 @@ private static async Task> BuildAllAsync() yield return ("inflate-dynamic", Deflate(sheet, CompressionLevel.Optimal)); } + private static IEnumerable<(string Name, byte[] Bytes)> DeflateSeeds() + { + byte[] sheet = Utf8(string.Concat(Enumerable.Range(1, 400).Select(static row => string.Create( + CultureInfo.InvariantCulture, $"{row * 1.5}")))); + byte[] noise = new byte[5000]; + for (int i = 0; i < noise.Length; i++) + { + noise[i] = (byte)((uint)(i * 2654435761u) >> 24); + } + yield return ("deflate-text", [16, .. sheet]); + yield return ("deflate-zeros", [255, .. new byte[70_000]]); + yield return ("deflate-noise", [1, .. noise]); + } + private static byte[] Deflate(byte[] raw, CompressionLevel level) { using var compressed = new MemoryStream(); From 5d70e79393f376e57c46b58d6aef5915c4fec51f Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 02:03:59 -0300 Subject: [PATCH 15/42] docs: record the managed ZIP write path and its benchmarks --- ARCHITECTURE.md | 9 ++++++ docs/performance/benchmarks.md | 57 +++++++++++++++++++++++++--------- 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a627640d..1c7c4bfc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -246,6 +246,15 @@ against a shared bug, so the fixtures in `tests/ExcelReader.Tests/data/encrypted plaintext produced by an independent implementation) remain the only correctness oracle for decryption specifically. +XLSX and XLSB packages are written by `Writer/Zip/ZipPackageWriter`, not `ZipArchive`. It writes the +layout `ZipArchive` uses in create mode (patched local headers on seekable streams, data descriptors +otherwise, Zip64 only when a field overflows). `CompressionLevel.Fastest` entries go through +`DeflateEncoder`, a fixed-Huffman deflate with a stored-block fallback, and are checksummed by +`Crc32`, which folds 128 or 256 bits at a time with PCLMULQDQ. `Optimal` and `SmallestSize` keep +zlib through `DeflateStream`. When a write faults, `Abandon` still writes a central directory for +the entries that completed, as disposing `ZipArchive` did; a failed entry write marks the whole +archive failed, so nothing more can be added to it. + ## The `excelreader` CLI `src/ExcelReader.Cli/` is a thin `dotnet tool` shell (`excelreader`) over Core's public API — it diff --git a/docs/performance/benchmarks.md b/docs/performance/benchmarks.md index 665a46fd..afda6d5a 100644 --- a/docs/performance/benchmarks.md +++ b/docs/performance/benchmarks.md @@ -20,10 +20,10 @@ Compares ExcelReader against established XLSX libraries on the same generated wo | 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 | - | - | - | +| Workbook writing | 8.337 ms, 4.02 MB | - | 18.437 ms, 4.02 MB | 15.332 ms, 15.84 MB | +| Workbook writing, shared strings | 8.358 ms, 4.06 MB | - | - | - | -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. +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.8x faster than SpreadCheetah while allocating ~3.9x less memory, and ~2.2x 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. @@ -37,10 +37,10 @@ Reading a shared-strings XLSX workbook with typed parsing is ~14% faster than th | 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 | - | +| Workbook writing | 5.572 ms, 4.02 MB | - | +| Workbook writing, shared strings | 5.595 ms, 4.05 MB | - | -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. +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.8x faster than SpreadCheetah's XLSX writer on this benchmark while allocating ~75% less memory. ### XLS (BIFF8) @@ -50,7 +50,7 @@ XLSB is the fastest generated Excel format in these results: raw reads are ~1.4x | Cell-by-cell read async | 3.236 ms, 2.91 KB | - | - | | Workbook writing | 5.296 ms, 16.03 MB | - | - | -ExcelReader is ~1.3x faster than Sylvan for generated XLS reads while allocating ~605x less memory, and ~1.8x faster than OfficeIMO. The XLS writer is ~2.1x faster than the XLSX writer in this benchmark (`XlsWriteBenchmark`, 5.296 ms vs 10.964 ms in the same run). Most of its 16.03 MB is the benchmark's pre-sized 16 MB destination `MemoryStream`; the typed-record table below measures it at 4.03 MB like the other formats. +ExcelReader is ~1.3x faster than Sylvan for generated XLS reads while allocating ~605x less memory, and ~1.8x faster than OfficeIMO. The XLS writer is ~1.7x faster than the XLSX writer in this benchmark (`XlsWriteBenchmark`, 4.859 ms vs 8.375 ms in the same run). Most of its 16.31 MB is the benchmark's pre-sized 16 MB destination `MemoryStream`; the typed-record table below measures it at 4.03 MB like the other formats. ### CSV @@ -174,6 +174,35 @@ About 40% of the default read is inflate: without it the whole read drops from 3 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. +### Write path: managed deflate and CRC-32 + +XLSX and XLSB packages are written by the library's own ZIP writer. `CompressionLevel.Fastest`, the default, compresses with a managed fixed-Huffman deflate encoder and checksums with a managed CRC-32 that folds with PCLMULQDQ. Each piece alone, against what `ZipArchive` used before, on the same Ryzen 7 5700X: + +| | zlib-ng `Fastest` / System.IO.Hashing | Managed | Ratio | +|---|---:|---:|---:| +| Deflate, writer XLSX sheet (6.0 MB) | 4.75 ms | 2.98 ms | 1.59x, same size | +| Deflate, writer XLSB sheet (4.6 MB) | 4.60 ms | 2.52 ms | 1.82x, same size | +| Deflate, 65K real XLSX sheet (34.4 MB) | 60.3 ms | 38.4 ms | 1.57x, same size | +| CRC-32, 64 KB chunks in cache | 16.4 GB/s | 35.8 GB/s | 2.19x | + +Whole writes of 50,000 rows by four columns (text, integer, date, number) into a `MemoryStream`, before and after the switch. Each figure is the median of three interleaved runs of 41 writes: + +| Write | `ZipArchive` | Managed ZIP writer | Change | File size | +|---|---:|---:|---:|---:| +| XLSX, `Fastest` | 10.43 ms | 9.11 ms | -12.7% | 773,487 B → 774,344 B | +| XLSX, `Fastest` + `PrefetchWrite` | 6.15 ms | 5.84 ms | -5.0% | same as above | +| XLSX, `Optimal` | 30.90 ms | 31.38 ms | within noise | 483,613 B → 483,607 B | +| XLSB, `Fastest` | 7.66 ms | 5.42 ms | -29.2% | 861,060 B → 861,568 B | +| XLSB, `Fastest` + `PrefetchWrite` | 5.37 ms | 3.08 ms | -42.6% | same as above | +| XLSB, `Optimal` | 38.17 ms | 37.98 ms | within noise | 466,917 B → 466,917 B | + +XLSB gains more because deflate was a larger share of its write; with `PrefetchWrite` an XLSX write is bounded by building the XML, so the faster deflate moves it little. + +The encoder's limits are deliberate: + +- It emits fixed-Huffman blocks only, so `Optimal` and `SmallestSize` still go through zlib, inside the same container. +- A block covers at most 65,535 input bytes, so any block that would grow is written stored instead. Incompressible input grows by at most 5 bytes per block. + ### Matched-work reads (`*_Materialized`) Every read benchmark class has a `*_Materialized` sibling that calls `cell.GetString()` per text cell — the same UTF-16 materialization Sylvan's ADO.NET-style API is forced to pay — while keeping `TryParse`/`TryGetDateTime` for numeric and date cells, exactly mirroring the competitor accumulator. These are the matched-work counterparts to the zero-copy rows above. @@ -217,12 +246,12 @@ The takeaway is not that one column beats the other: it is that ExcelReader's he | Format | Mean | Allocated | |---|---:|---:| -| XLSX | 12.099 ms | 4.02 MB | -| XLSB | 7.597 ms | 4.02 MB | +| XLSX | 8.922 ms | 4.02 MB | +| XLSB | 6.113 ms | 4.02 MB | | XLS | 5.021 ms | 4.03 MB | | CSV | 4.762 ms | 4.00 MB | -Relative ordering: CSV and XLS close at the front, then XLSB, then XLSX — the record-mapping layer adds little overhead over hand-written cell-by-cell writes (XLSB 7.597 ms vs. 7.478 ms, XLSX 12.099 ms vs. 10.913 ms). +Relative ordering: CSV and XLS close at the front, then XLSB, then XLSX — the record-mapping layer adds little overhead over hand-written cell-by-cell writes (XLSB 6.113 ms vs. 5.572 ms, XLSX 8.922 ms vs. 8.337 ms). ### Native and Arrow string writes @@ -230,12 +259,12 @@ Relative ordering: CSV and XLS close at the front, then XLSB, then XLSX — the | Scenario | Mean | Allocated | |---|---:|---:| -| Native string columns → XLSX | 20.487 ms | 18.02 KB | +| Native string columns → XLSX | 14.559 ms | 15.74 KB | | Native string columns → CSV | 5.098 ms | 416 B | -| Arrow `StringArray` → XLSX | 26.644 ms | 18.06 KB | -| Styled rows → XLSX | 7.068 ms | 18.20 KB | +| Arrow `StringArray` → XLSX | 20.343 ms | 15.79 KB | +| Styled rows → XLSX | 5.411 ms | 15.92 KB | -Text arrives through `IRowWriter.WriteUtf8`, which the XLSX and CSV writers copy through as bytes whenever the text needs no escaping, so none of these allocate per cell: the ~18 KB is per-workbook ZIP and part setup, which CSV does not have. +Text arrives through `IRowWriter.WriteUtf8`, which the XLSX and CSV writers copy through as bytes whenever the text needs no escaping, so none of these allocate per cell: the ~16 KB is per-workbook ZIP and part setup, which CSV does not have. ### Ref struct typed parsing (zero-copy) From fe02b33a2ae4c473ba5e51f5568fad1951b98fbf Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 03:18:57 -0300 Subject: [PATCH 16/42] fix: fault the active sheet before abandoning the ZIP A workbook disposed with a row left open abandoned the archive while the PrefetchWrite consumer was still compressing into the open entry, so the encoder's pooled buffers were returned under it (AccessViolationException). The sheet's open-row guard throws before the sheet faults, so nothing joined the consumer. AbandonZip now faults the active sheet first, which disposes the offload stream and waits for its consumer. --- .../Writer/Xlsb/XlsbSheetWriter.cs | 10 ++ .../Writer/Xlsb/XlsbWorkbookWriter.cs | 14 +- .../Writer/Xlsx/XlsxSheetWriter.cs | 10 ++ .../Writer/Xlsx/XlsxWorkbookWriter.cs | 14 +- .../Writer/Zip/PrefetchAbandonTests.cs | 167 ++++++++++++++++++ 5 files changed, 205 insertions(+), 10 deletions(-) create mode 100644 tests/ExcelReader.Tests/Writer/Zip/PrefetchAbandonTests.cs diff --git a/src/ExcelReader.Core/Writer/Xlsb/XlsbSheetWriter.cs b/src/ExcelReader.Core/Writer/Xlsb/XlsbSheetWriter.cs index f0dd7105..384fb134 100644 --- a/src/ExcelReader.Core/Writer/Xlsb/XlsbSheetWriter.cs +++ b/src/ExcelReader.Core/Writer/Xlsb/XlsbSheetWriter.cs @@ -352,6 +352,16 @@ private async ValueTask FaultAsync() Release(faulted: true); } + internal void Abandon() + { + Fault(); + } + + internal ValueTask AbandonAsync() + { + return FaultAsync(); + } + private void Release(bool faulted) { _state = WriterState.Ended; diff --git a/src/ExcelReader.Core/Writer/Xlsb/XlsbWorkbookWriter.cs b/src/ExcelReader.Core/Writer/Xlsb/XlsbWorkbookWriter.cs index bf381d9e..9fa1ab22 100644 --- a/src/ExcelReader.Core/Writer/Xlsb/XlsbWorkbookWriter.cs +++ b/src/ExcelReader.Core/Writer/Xlsb/XlsbWorkbookWriter.cs @@ -187,6 +187,7 @@ private ValueTask CloseZipAsync(CancellationToken ct = default) private void AbandonZip() { _faulted = true; + _activeSheet?.Abandon(); if (!_zipClosed) { _zipClosed = true; @@ -194,15 +195,18 @@ private void AbandonZip() } } - private ValueTask AbandonZipAsync() + private async ValueTask AbandonZipAsync() { _faulted = true; - if (_zipClosed) + if (_activeSheet is not null) { - return ValueTask.CompletedTask; + await _activeSheet.AbandonAsync().ConfigureAwait(false); + } + if (!_zipClosed) + { + _zipClosed = true; + await _zip.AbandonAsync().ConfigureAwait(false); } - _zipClosed = true; - return _zip.AbandonAsync(); } private void ReleaseStream() diff --git a/src/ExcelReader.Core/Writer/Xlsx/XlsxSheetWriter.cs b/src/ExcelReader.Core/Writer/Xlsx/XlsxSheetWriter.cs index c74e863a..d93ddd0e 100644 --- a/src/ExcelReader.Core/Writer/Xlsx/XlsxSheetWriter.cs +++ b/src/ExcelReader.Core/Writer/Xlsx/XlsxSheetWriter.cs @@ -344,6 +344,16 @@ private async ValueTask FaultAsync() Release(faulted: true); } + internal void Abandon() + { + Fault(); + } + + internal ValueTask AbandonAsync() + { + return FaultAsync(); + } + private void Release(bool faulted) { _state = WriterState.Ended; diff --git a/src/ExcelReader.Core/Writer/Xlsx/XlsxWorkbookWriter.cs b/src/ExcelReader.Core/Writer/Xlsx/XlsxWorkbookWriter.cs index 4fde2814..fadfdf87 100644 --- a/src/ExcelReader.Core/Writer/Xlsx/XlsxWorkbookWriter.cs +++ b/src/ExcelReader.Core/Writer/Xlsx/XlsxWorkbookWriter.cs @@ -243,6 +243,7 @@ private ValueTask CloseZipAsync(CancellationToken ct = default) private void AbandonZip() { _faulted = true; + _activeSheet?.Abandon(); if (!_zipClosed) { _zipClosed = true; @@ -250,15 +251,18 @@ private void AbandonZip() } } - private ValueTask AbandonZipAsync() + private async ValueTask AbandonZipAsync() { _faulted = true; - if (_zipClosed) + if (_activeSheet is not null) { - return ValueTask.CompletedTask; + await _activeSheet.AbandonAsync().ConfigureAwait(false); + } + if (!_zipClosed) + { + _zipClosed = true; + await _zip.AbandonAsync().ConfigureAwait(false); } - _zipClosed = true; - return _zip.AbandonAsync(); } private void ReleaseStream() diff --git a/tests/ExcelReader.Tests/Writer/Zip/PrefetchAbandonTests.cs b/tests/ExcelReader.Tests/Writer/Zip/PrefetchAbandonTests.cs new file mode 100644 index 00000000..64ad00e7 --- /dev/null +++ b/tests/ExcelReader.Tests/Writer/Zip/PrefetchAbandonTests.cs @@ -0,0 +1,167 @@ +using System.Diagnostics; +using ExcelReader.Core.Writer.Xlsb; +using ExcelReader.Core.Writer.Xlsx; + +namespace ExcelReader.Tests.Writer.Zip +{ + public class PrefetchAbandonTests + { + private const int Iterations = 200; + private const int Rows = 4_000; + private static readonly string[] Texts = BuildTexts(); + + private static string[] BuildTexts() + { + uint state = 11; + var texts = new string[Rows]; + var chars = new char[120]; + for (int i = 0; i < texts.Length; i++) + { + for (int c = 0; c < chars.Length; c++) + { + state = (state * 1_664_525) + 1_013_904_223; + chars[c] = (char)('0' + (state >> 26)); + } + texts[i] = new string(chars); + } + return texts; + } + + private static (IDisposable Workbook, Func Released) StartXlsx(Stream output) + { + XlsxWorkbookWriter wb = XlsxWorkbookWriter.Create(output, leaveOpen: true, new XlsxWriterOptions { PrefetchWrite = true }); + XlsxSheetWriter sheet = wb.AddSheet("Data"); + for (int i = 0; i < Rows; i++) + { + using XlsxRowWriter row = sheet.StartRow(); + row.Write(i); + row.Write(Texts[i]); + } + sheet.StartRow().Write("left open"); + return (wb, () => sheet.ResourcesReleased); + } + + private static (IDisposable Workbook, Func Released) StartXlsb(Stream output) + { + XlsbWorkbookWriter wb = XlsbWorkbookWriter.Create(output, leaveOpen: true, new XlsbWriterOptions { PrefetchWrite = true }); + XlsbSheetWriter sheet = wb.AddSheet("Data"); + for (int i = 0; i < Rows; i++) + { + using XlsbRowWriter row = sheet.StartRow(); + row.Write(i); + row.Write(Texts[i]); + } + sheet.StartRow().Write("left open"); + return (wb, () => sheet.ResourcesReleased); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task DisposingWithARowLeftOpenStopsTheBackgroundWriterFirst(bool xlsb, bool async) + { + var outputs = new List(Iterations); + for (int iteration = 0; iteration < Iterations; iteration++) + { + var output = new ProbeStream(); + outputs.Add(output); + (IDisposable workbook, Func released) = xlsb ? StartXlsb(output) : StartXlsx(output); + if (async) + { + await Assert.ThrowsAsync(() => ((IAsyncDisposable)workbook).DisposeAsync().AsTask()); + } + else + { + Assert.Throws(workbook.Dispose); + } + output.Seal(); + Assert.True(released()); + } + await Task.Delay(100, TestContext.Current.CancellationToken); + Assert.All(outputs, static output => + { + Assert.True(output.Writes > 1); + Assert.Equal(0, output.Overlaps); + Assert.Equal(0, output.LateWrites); + }); + } + + /// A slow forward-only output that counts overlapping writes and writes made after . + private sealed class ProbeStream : Stream + { + private int _active; + private int _overlaps; + private int _lateWrites; + private int _writes; + private volatile bool _sealed; + + internal int Overlaps => Volatile.Read(ref _overlaps); + internal int LateWrites => Volatile.Read(ref _lateWrites); + internal int Writes => Volatile.Read(ref _writes); + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + + internal void Seal() + { + _sealed = true; + } + + public override void Flush() { } + + public override int Read(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + + 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) + { + Record(); + } + + public override void Write(ReadOnlySpan buffer) + { + Record(); + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + Record(); + return ValueTask.CompletedTask; + } + + private void Record() + { + Interlocked.Increment(ref _writes); + if (_sealed) + { + Interlocked.Increment(ref _lateWrites); + } + if (Interlocked.Increment(ref _active) != 1) + { + Interlocked.Increment(ref _overlaps); + } + long until = Stopwatch.GetTimestamp() + (Stopwatch.Frequency / 5_000); + while (Stopwatch.GetTimestamp() < until) + { + Thread.SpinWait(20); + } + Interlocked.Decrement(ref _active); + } + } + } +} From 0be7a6a7147dc83811fe8f487338e8defe174c41 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 03:22:25 -0300 Subject: [PATCH 17/42] fix: harden the ZIP writer against disposed use, huge writes and big-endian - DeflateEncoder throws ObjectDisposedException instead of reading through unchecked refs into arrays it already returned to the pool. - ZipEntryStream feeds one Write in pieces of at most 1 MB, flushing between them, so a huge write no longer holds its whole compressed output. - The managed encoder is used only on little-endian; Fastest goes through zlib elsewhere. - Drop the unused ZipEntryWriter.Flush and a computed-branch ternary. --- .../Writer/Internal/ZipEntryWriter.cs | 5 --- .../Writer/Zip/DeflateEncoder.cs | 8 +++- .../Writer/Zip/ZipEntryStream.cs | 28 +++++++++--- src/ExcelReader.Core/Writer/Zip/ZipRecords.cs | 6 ++- .../Writer/Zip/DeflateEncoderTests.cs | 12 +++++ .../Writer/Zip/ZipPackageWriterTests.cs | 44 +++++++++++++++++++ .../Writer/Zip/ZipTestSupport.cs | 18 ++++++++ 7 files changed, 106 insertions(+), 15 deletions(-) diff --git a/src/ExcelReader.Core/Writer/Internal/ZipEntryWriter.cs b/src/ExcelReader.Core/Writer/Internal/ZipEntryWriter.cs index 87659d80..4a7aca7d 100644 --- a/src/ExcelReader.Core/Writer/Internal/ZipEntryWriter.cs +++ b/src/ExcelReader.Core/Writer/Internal/ZipEntryWriter.cs @@ -57,10 +57,5 @@ internal static void WriteBytes(ZipPackageWriter zip, string entryName, ReadOnly using Stream stream = zip.OpenEntry(entryName, compression); stream.Write(content); } - - internal static void Flush(Stream stream) - { - stream.Flush(); - } } } diff --git a/src/ExcelReader.Core/Writer/Zip/DeflateEncoder.cs b/src/ExcelReader.Core/Writer/Zip/DeflateEncoder.cs index e065cf1d..efca223e 100644 --- a/src/ExcelReader.Core/Writer/Zip/DeflateEncoder.cs +++ b/src/ExcelReader.Core/Writer/Zip/DeflateEncoder.cs @@ -42,6 +42,7 @@ internal DeflateEncoder() internal void Write(ReadOnlySpan input, BiffBuffer output) { + ObjectDisposedException.ThrowIf(_window.Length == 0, this); while (!input.IsEmpty) { if (_end == WindowCapacity) @@ -142,8 +143,11 @@ private int WriteStored(Span destination, int length, bool final) private int EncodeMatches(int blockEnd, Span destination, ref BitState state) { - ref byte window = ref MemoryMarshal.GetArrayDataReference(_window); - ref uint table = ref MemoryMarshal.GetArrayDataReference(_table); + byte[] windowArray = _window; + uint[] tableArray = _table; + ObjectDisposedException.ThrowIf(windowArray.Length < WindowCapacity || tableArray.Length < 1 << HashBits, this); + ref byte window = ref MemoryMarshal.GetArrayDataReference(windowArray); + ref uint table = ref MemoryMarshal.GetArrayDataReference(tableArray); ref byte output = ref MemoryMarshal.GetReference(destination); ref uint literals = ref MemoryMarshal.GetArrayDataReference(FixedHuffmanCodes.Literals); ref uint lengths = ref MemoryMarshal.GetArrayDataReference(FixedHuffmanCodes.Lengths); diff --git a/src/ExcelReader.Core/Writer/Zip/ZipEntryStream.cs b/src/ExcelReader.Core/Writer/Zip/ZipEntryStream.cs index e0be2f0f..c9eefdf1 100644 --- a/src/ExcelReader.Core/Writer/Zip/ZipEntryStream.cs +++ b/src/ExcelReader.Core/Writer/Zip/ZipEntryStream.cs @@ -12,6 +12,7 @@ namespace ExcelReader.Core.Writer.Zip internal sealed class ZipEntryStream : Stream { private const int FlushThreshold = 64 * 1024; + private const int MaxWriteSlice = 1 << 20; private readonly ZipPackageWriter _owner; private readonly ZipEntryRecord _record; @@ -39,7 +40,8 @@ internal ZipEntryStream(ZipPackageWriter owner, ZipEntryRecord record) record.Flags |= ZipRecords.FlagDataDescriptor; } ZipRecords.WriteLocalHeader(_pending, record); - if (record.Level == CompressionLevel.Fastest) + // DeflateEncoder writes and compares native-order words, which is only deflate's bit order on little-endian. + if (record.Level == CompressionLevel.Fastest && BitConverter.IsLittleEndian) { _encoder = new DeflateEncoder(); } @@ -159,11 +161,17 @@ public override void Write(ReadOnlySpan buffer) ObjectDisposedException.ThrowIf(_closed, this); try { - Accept(buffer); - if (_pending.Length >= FlushThreshold) + do { - FlushPending(); + int take = Math.Min(buffer.Length, MaxWriteSlice); + Accept(buffer[..take]); + buffer = buffer[take..]; + if (_pending.Length >= FlushThreshold) + { + FlushPending(); + } } + while (!buffer.IsEmpty); } catch { @@ -182,11 +190,17 @@ public override async ValueTask WriteAsync(ReadOnlyMemory buffer, Cancella ObjectDisposedException.ThrowIf(_closed, this); try { - Accept(buffer.Span); - if (_pending.Length >= FlushThreshold) + do { - await FlushPendingAsync(cancellationToken).ConfigureAwait(false); + int take = Math.Min(buffer.Length, MaxWriteSlice); + Accept(buffer.Span[..take]); + buffer = buffer[take..]; + if (_pending.Length >= FlushThreshold) + { + await FlushPendingAsync(cancellationToken).ConfigureAwait(false); + } } + while (!buffer.IsEmpty); } catch { diff --git a/src/ExcelReader.Core/Writer/Zip/ZipRecords.cs b/src/ExcelReader.Core/Writer/Zip/ZipRecords.cs index e6fe0604..2d244300 100644 --- a/src/ExcelReader.Core/Writer/Zip/ZipRecords.cs +++ b/src/ExcelReader.Core/Writer/Zip/ZipRecords.cs @@ -114,7 +114,11 @@ internal static void WriteCentralHeader(BiffBuffer buffer, ZipEntryRecord entry) bool zip64Sizes = entry.NeedsZip64Sizes; bool zip64Offset = entry.NeedsZip64Offset; int extraFields = (zip64Sizes ? 2 : 0) + (zip64Offset ? 1 : 0); - int extraLength = extraFields == 0 ? 0 : 4 + (8 * extraFields); + int extraLength = 0; + if (extraFields != 0) + { + extraLength = 4 + (8 * extraFields); + } buffer.WriteU32(CentralHeaderSignature); buffer.WriteU16(VersionMadeBy); buffer.WriteU16(extraFields == 0 ? VersionDefault : VersionZip64); diff --git a/tests/ExcelReader.Tests/Writer/Zip/DeflateEncoderTests.cs b/tests/ExcelReader.Tests/Writer/Zip/DeflateEncoderTests.cs index 2427c0cc..a49eb6bb 100644 --- a/tests/ExcelReader.Tests/Writer/Zip/DeflateEncoderTests.cs +++ b/tests/ExcelReader.Tests/Writer/Zip/DeflateEncoderTests.cs @@ -192,5 +192,17 @@ public void FixedCodesFollowRfc1951() Assert.Equal(0x5u, FixedHuffmanCodes.Distances[1]); Assert.Equal(0x7FFEF2u, FixedHuffmanCodes.Distances[32_768]); } + + [Fact] + public void UsingADisposedEncoderThrows() + { + using var output = new BiffBuffer(4096); + var encoder = new DeflateEncoder(); + encoder.Write(new byte[100], output); + encoder.Dispose(); + Assert.Throws(() => encoder.Write(new byte[1], output)); + Assert.Throws(() => encoder.Write(new byte[DeflateEncoder.MaxBlockLength], output)); + Assert.Throws(() => encoder.Finish(output)); + } } } diff --git a/tests/ExcelReader.Tests/Writer/Zip/ZipPackageWriterTests.cs b/tests/ExcelReader.Tests/Writer/Zip/ZipPackageWriterTests.cs index 7c316efa..ea6d424f 100644 --- a/tests/ExcelReader.Tests/Writer/Zip/ZipPackageWriterTests.cs +++ b/tests/ExcelReader.Tests/Writer/Zip/ZipPackageWriterTests.cs @@ -687,5 +687,49 @@ public void MoreThan65534EntriesUseZip64EndRecords() using Stream last = read.GetEntry("e69999.bin")!.Open(); Assert.Equal(69_999 & 0xFF, last.ReadByte()); } + + public static TheoryData HugeWriteCases() + { + var data = new TheoryData(); + foreach (CompressionLevel level in Enum.GetValues()) + { + data.Add(level, false); + data.Add(level, true); + } + return data; + } + + [Theory] + [MemberData(nameof(HugeWriteCases))] + [SuppressMessage("Security", "CA5394:Do not use insecure randomness", + Justification = "A seeded generator picks reproducible test inputs; nothing here is secret.")] + public async Task OneHugeWriteReachesTheOutputInBoundedPieces(CompressionLevel level, bool useAsync) + { + byte[] content = new byte[5 << 20]; + new Random(7).NextBytes(content); + var output = new LargestWriteStream(); + var zip = new ZipPackageWriter(output); + Stream entry = zip.OpenEntry("huge.bin", level); + if (useAsync) + { + await entry.WriteAsync(content, TestContext.Current.CancellationToken); + await entry.DisposeAsync(); + } + else + { + entry.Write(content); + entry.Dispose(); + } + zip.Finish(); + + Assert.InRange(output.Largest, 1, (1 << 20) + (64 * 1024)); + using var read = new ZipArchive(new MemoryStream(output.ToArray()), ZipArchiveMode.Read); + ZipArchiveEntry stored = read.GetEntry("huge.bin")!; + using Stream data = stored.Open(); + using var copy = new MemoryStream(); + await data.CopyToAsync(copy, TestContext.Current.CancellationToken); + Assert.True(copy.ToArray().AsSpan().SequenceEqual(content)); + Assert.Equal(Crc32Tests.BitwiseCrc(content), stored.Crc32); + } } } diff --git a/tests/ExcelReader.Tests/Writer/Zip/ZipTestSupport.cs b/tests/ExcelReader.Tests/Writer/Zip/ZipTestSupport.cs index abbcb01a..5221c7e2 100644 --- a/tests/ExcelReader.Tests/Writer/Zip/ZipTestSupport.cs +++ b/tests/ExcelReader.Tests/Writer/Zip/ZipTestSupport.cs @@ -261,4 +261,22 @@ public override void Write(ReadOnlySpan buffer) _length = Math.Max(_length, end); } } + + /// A seekable in-memory output that remembers the size of its largest single write. + internal sealed class LargestWriteStream : MemoryStream + { + internal int Largest { get; private set; } + + public override void Write(byte[] buffer, int offset, int count) + { + Largest = Math.Max(Largest, count); + base.Write(buffer, offset, count); + } + + public override void Write(ReadOnlySpan buffer) + { + Largest = Math.Max(Largest, buffer.Length); + base.Write(buffer); + } + } } From 2f1cd5a0a37e40d7a0319e30ff620193f5dde298 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 03:24:02 -0300 Subject: [PATCH 18/42] docs: align write benchmark tables with the latest run --- docs/guide/writing.md | 6 +++--- docs/performance/benchmarks.md | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/guide/writing.md b/docs/guide/writing.md index 650c0236..0d56bce8 100644 --- a/docs/guide/writing.md +++ b/docs/guide/writing.md @@ -263,10 +263,10 @@ Writing a 50,000-row workbook (`WriteBenchmark`, both figures from one run): | Workload | Default | `PrefetchWrite = true` | Gain | |---|---:|---:|---:| -| XLSX | 10.910 ms | 6.865 ms | 37% | -| XLSB | 7.555 ms | 5.614 ms | 26% | +| XLSX | 8.337 ms | 5.175 ms | 38% | +| XLSB | 5.572 ms | 3.419 ms | 39% | -Allocations are unchanged (4.02 MB vs. 4.03–4.04 MB) — the background writer hands over buffers the row +Allocations are unchanged (4.02 MB either way) — the background writer hands over buffers the row writer already owns rather than copying them. The same caveat as the read side applies: **do not enable it for concurrent server workloads**. A diff --git a/docs/performance/benchmarks.md b/docs/performance/benchmarks.md index afda6d5a..5190a91f 100644 --- a/docs/performance/benchmarks.md +++ b/docs/performance/benchmarks.md @@ -48,9 +48,9 @@ XLSB is the fastest generated Excel format in these results: raw reads are ~1.4x |---|---:|---:|---:| | Cell-by-cell read | 3.916 ms, 2.84 KB | 5.173 ms, 1,717.73 KB | 6.856 ms, 10,499.25 KB | | Cell-by-cell read async | 3.236 ms, 2.91 KB | - | - | -| Workbook writing | 5.296 ms, 16.03 MB | - | - | +| Workbook writing | 4.859 ms, 16.31 MB | - | - | -ExcelReader is ~1.3x faster than Sylvan for generated XLS reads while allocating ~605x less memory, and ~1.8x faster than OfficeIMO. The XLS writer is ~1.7x faster than the XLSX writer in this benchmark (`XlsWriteBenchmark`, 4.859 ms vs 8.375 ms in the same run). Most of its 16.31 MB is the benchmark's pre-sized 16 MB destination `MemoryStream`; the typed-record table below measures it at 4.03 MB like the other formats. +ExcelReader is ~1.3x faster than Sylvan for generated XLS reads while allocating ~605x less memory, and ~1.8x faster than OfficeIMO. The XLS writer is ~1.7x faster than the XLSX writer in this benchmark (`XlsWriteBenchmark`, 4.859 ms vs 8.375 ms in the same run). Most of its 16.31 MB is the benchmark's pre-sized 16 MB destination `MemoryStream`; the typed-record table below measures it at 4.31 MB, close to the other formats. ### CSV @@ -176,7 +176,7 @@ Without prefetch a read is inflate plus parse, so the faster inflate converts di ### Write path: managed deflate and CRC-32 -XLSX and XLSB packages are written by the library's own ZIP writer. `CompressionLevel.Fastest`, the default, compresses with a managed fixed-Huffman deflate encoder and checksums with a managed CRC-32 that folds with PCLMULQDQ. Each piece alone, against what `ZipArchive` used before, on the same Ryzen 7 5700X: +XLSX and XLSB packages are written by the library's own ZIP writer. `CompressionLevel.Fastest`, the default, compresses with a managed fixed-Huffman deflate encoder and checksums with a managed CRC-32 that folds with PCLMULQDQ. Each piece alone on the same Ryzen 7 5700X: the deflate rows against zlib-ng at `Fastest`, which `ZipArchive` used, and the CRC row against `System.IO.Hashing.Crc32`, the fastest managed alternative (`ZipArchive` itself calls zlib's native CRC, which was not measured): | | zlib-ng `Fastest` / System.IO.Hashing | Managed | Ratio | |---|---:|---:|---:| @@ -248,10 +248,10 @@ The takeaway is not that one column beats the other: it is that ExcelReader's he |---|---:|---:| | XLSX | 8.922 ms | 4.02 MB | | XLSB | 6.113 ms | 4.02 MB | -| XLS | 5.021 ms | 4.03 MB | -| CSV | 4.762 ms | 4.00 MB | +| XLS | 6.292 ms | 4.31 MB | +| CSV | 4.824 ms | 4.00 MB | -Relative ordering: CSV and XLS close at the front, then XLSB, then XLSX — the record-mapping layer adds little overhead over hand-written cell-by-cell writes (XLSB 6.113 ms vs. 5.572 ms, XLSX 8.922 ms vs. 8.337 ms). +Relative ordering: CSV at the front, XLSB and XLS close behind it, then XLSX — the record-mapping layer adds little overhead over hand-written cell-by-cell writes (XLSB 6.113 ms vs. 5.572 ms, XLSX 8.922 ms vs. 8.337 ms). ### Native and Arrow string writes From 6709416a1844a6b1d9e326c84f246b3cf0b195aa Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 03:40:51 -0300 Subject: [PATCH 19/42] fix: update assertions in tests for corrupt sheet entries and XLS writer to handle edge cases --- .../ExcelReader.Tests/Reader/Xlsx/CorruptSheetEntryTests.cs | 6 ++++-- tests/ExcelReader.Tests/Writer/Xls/XlsConformanceTests.cs | 4 +++- tests/ExcelReader.Tests/Writer/Xls/XlsWriterTests.cs | 5 +++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/ExcelReader.Tests/Reader/Xlsx/CorruptSheetEntryTests.cs b/tests/ExcelReader.Tests/Reader/Xlsx/CorruptSheetEntryTests.cs index 89f8c156..b6b165bc 100644 --- a/tests/ExcelReader.Tests/Reader/Xlsx/CorruptSheetEntryTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xlsx/CorruptSheetEntryTests.cs @@ -28,7 +28,8 @@ private static int CountRows(XlsxWorkbook workbook) return count; } - // Damage at +4096 decodes as a stream that ends early: before the fix every reader stopped at 298 rows without an error. + // The damage either breaks the deflate stream or decodes as garbage XML that trips a limit. + // Either way the read must fail, not stop early without an error. [Theory] [InlineData(4096, false)] [InlineData(4096, true)] @@ -40,7 +41,8 @@ public async Task ADamagedSheetEntryThrowsInsteadOfEndingEarly(int offset, bool Array.Fill(bytes, (byte)0xFF, entry + offset, 512); using XlsxWorkbook workbook = Excel.FromXlsx(bytes, new ExcelReaderOptions { PrefetchDecompression = prefetch }); - Assert.Throws(() => CountRows(workbook)); + Exception failure = Assert.ThrowsAny(() => CountRows(workbook)); + Assert.True(failure is InvalidDataException or ExcelLimitExceededException, failure.GetType().Name); } } } diff --git a/tests/ExcelReader.Tests/Writer/Xls/XlsConformanceTests.cs b/tests/ExcelReader.Tests/Writer/Xls/XlsConformanceTests.cs index 28df75d0..40dc4248 100644 --- a/tests/ExcelReader.Tests/Writer/Xls/XlsConformanceTests.cs +++ b/tests/ExcelReader.Tests/Writer/Xls/XlsConformanceTests.cs @@ -276,7 +276,9 @@ public void TextKeepsItsFullLength(int length, char fill, int expectedRecord) }); List records = Records(xls); - Assert.Equal(2, Worksheet(records).Count(r => r.Id == expectedRecord)); + // "short" is a LABEL too. + int expectedCount = expectedRecord == Label ? 3 : 2; + Assert.Equal(expectedCount, Worksheet(records).Count(r => r.Id == expectedRecord)); if (expectedRecord == LabelSst) { Assert.Single(records, r => r.Id == Sst); diff --git a/tests/ExcelReader.Tests/Writer/Xls/XlsWriterTests.cs b/tests/ExcelReader.Tests/Writer/Xls/XlsWriterTests.cs index 7fa3c62d..dfa1087d 100644 --- a/tests/ExcelReader.Tests/Writer/Xls/XlsWriterTests.cs +++ b/tests/ExcelReader.Tests/Writer/Xls/XlsWriterTests.cs @@ -342,7 +342,8 @@ public async Task LargeWorkbookForcesDifatSectors() r.Write(i); for (int c = 1; c < cols; c++) { - r.Write(i + (c * 0.5)); + // Thirds fit no RK form, so each cell stays an 8-byte NUMBER. + r.Write(i + (c / 3.0)); } } s.End(); @@ -360,7 +361,7 @@ public async Task LargeWorkbookForcesDifatSectors() Assert.True(e.Current[0].TryParse(Inv, out int first)); Assert.Equal(read, first); Assert.True(e.Current[cols - 1].TryParse(Inv, out double last)); - Assert.Equal(read + ((cols - 1) * 0.5), last); + Assert.Equal(read + ((cols - 1) / 3.0), last); } read++; } From 222a04376a54d96f2d8feade3c73b5e15842a35e Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 10:57:33 -0300 Subject: [PATCH 20/42] fix: update MaxEncodedBlock calculation and optimize EncodeMatches method --- .../Writer/Zip/DeflateEncoder.cs | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/ExcelReader.Core/Writer/Zip/DeflateEncoder.cs b/src/ExcelReader.Core/Writer/Zip/DeflateEncoder.cs index efca223e..72a724cd 100644 --- a/src/ExcelReader.Core/Writer/Zip/DeflateEncoder.cs +++ b/src/ExcelReader.Core/Writer/Zip/DeflateEncoder.cs @@ -22,8 +22,9 @@ internal sealed class DeflateEncoder : IDisposable private const int MinMatch = 4; private const int MaxMatch = 258; private const int WindowCapacity = HistoryLength + (1 << 20); - // Every byte a 9-bit literal, plus the block header, end-of-block, pending bits and a 4-byte flush. - private const int MaxEncodedBlock = (MaxBlockLength * 9 / 8) + 16; + // Every byte a 9-bit literal, plus the block header, end-of-block, pending bits and the 8 bytes + // each symbol writes ahead of the position it advances to. + private const int MaxEncodedBlock = (MaxBlockLength * 9 / 8) + 24; private byte[] _window; private uint[] _table; @@ -141,6 +142,9 @@ private int WriteStored(Span destination, int length, bool final) return headerBytes + 4 + length; } + // Called once per block, so tiered compilation would run the loop as on-stack-replacement code + // for the first blocks of a process. + [MethodImpl(MethodImplOptions.AggressiveOptimization)] private int EncodeMatches(int blockEnd, Span destination, ref BitState state) { byte[] windowArray = _window; @@ -184,13 +188,12 @@ private int EncodeMatches(int blockEnd, Span destination, ref BitState sta count += (int)(literal & FixedHuffmanCodes.LengthMask); index++; } - if (count >= 32) - { - Unsafe.WriteUnaligned(ref Unsafe.Add(ref output, written), (uint)bits); - written += 4; - bits >>= 32; - count -= 32; - } + // Whole bytes leave the buffer after every symbol, so fewer than 8 bits stay pending + // and the next symbol, at most 31 bits, always fits. + Unsafe.WriteUnaligned(ref Unsafe.Add(ref output, written), bits); + written += count >> 3; + bits >>= count & ~7; + count &= 7; } state.Bits = bits; state.Count = count; From f22a3aa2743bf0c0a3bf958c66002684640e863d Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 12:58:54 -0300 Subject: [PATCH 21/42] feat: combine two CRC-32 values without the data Co-Authored-By: Claude Sonnet 5.5 --- src/ExcelReader.Core/Writer/Zip/Crc32.cs | 56 +++++++++++++++++++ .../Writer/Zip/Crc32Tests.cs | 44 +++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/src/ExcelReader.Core/Writer/Zip/Crc32.cs b/src/ExcelReader.Core/Writer/Zip/Crc32.cs index 70937932..baccde2a 100644 --- a/src/ExcelReader.Core/Writer/Zip/Crc32.cs +++ b/src/ExcelReader.Core/Writer/Zip/Crc32.cs @@ -21,6 +21,7 @@ internal static class Crc32 private const uint Polynomial = 0x04C1_1DB7; private static readonly uint[] Table = BuildTable(); + private static readonly uint[] SquaredPowersOfX = BuildSquaredPowersOfX(); private static readonly Vector128 Fold128By128 = FoldConstants(128); private static readonly Vector128 Fold128By512 = FoldConstants(512); private static readonly Vector256 Fold256By256 = Vector256.Create(FoldConstants(256), FoldConstants(256)); @@ -217,5 +218,60 @@ private static uint[] BuildTable() } return table; } + + /// + /// The CRC of two byte runs laid end to end, from the CRC of each and the length of the second. + /// + internal static uint Combine(uint first, uint second, long secondLength) + { + ArgumentOutOfRangeException.ThrowIfNegative(secondLength); + return MultiplyModP(PowerOfX(secondLength), first) ^ second; + } + + // x^(8 * byteCount) mod P, by square and multiply over the bits of byteCount. Entry k of the table + // is x^(2^k); the sequence repeats every 32 entries, and bytes start three squarings in. + private static uint PowerOfX(long byteCount) + { + uint power = 1u << 31; + int exponent = 3; + while (byteCount != 0) + { + if ((byteCount & 1) != 0) + { + power = MultiplyModP(SquaredPowersOfX[exponent & 31], power); + } + byteCount >>= 1; + exponent++; + } + return power; + } + + // Polynomial product mod P in the reflected domain, where bit 31 is x^0. + private static uint MultiplyModP(uint a, uint b) + { + uint product = 0; + for (uint mask = 1u << 31; mask != 0; mask >>= 1) + { + if ((a & mask) != 0) + { + product ^= b; + } + b = (b >> 1) ^ (ReflectedPolynomial & (0u - (b & 1))); + } + return product; + } + + private static uint[] BuildSquaredPowersOfX() + { + var table = new uint[32]; + uint power = 1u << 30; + table[0] = power; + for (int index = 1; index < table.Length; index++) + { + power = MultiplyModP(power, power); + table[index] = power; + } + return table; + } } } diff --git a/tests/ExcelReader.Tests/Writer/Zip/Crc32Tests.cs b/tests/ExcelReader.Tests/Writer/Zip/Crc32Tests.cs index 5ffb7267..d019d5c4 100644 --- a/tests/ExcelReader.Tests/Writer/Zip/Crc32Tests.cs +++ b/tests/ExcelReader.Tests/Writer/Zip/Crc32Tests.cs @@ -91,5 +91,49 @@ public void EveryPathMatchesTheBitwiseDefinitionWholeAndSplit() } } } + + [Fact] + [SuppressMessage("Security", "CA5394:Do not use insecure randomness", + Justification = "A seeded generator picks reproducible test inputs; nothing here is secret.")] + public void CombineMatchesTheCrcOfTheConcatenation() + { + var random = new Random(11); + byte[] buffer = new byte[200_000]; + random.NextBytes(buffer); + for (int trial = 0; trial < 400; trial++) + { + int total = trial < 40 ? trial : random.Next(40, buffer.Length); + int cut = random.Next(0, total + 1); + ReadOnlySpan whole = buffer.AsSpan(0, total); + uint first = Crc32.Append(0, whole[..cut]); + uint second = Crc32.Append(0, whole[cut..]); + uint combined = Crc32.Combine(first, second, total - cut); + Assert.True(combined == BitwiseCrc(whole), $"total {total}, cut {cut}: {combined:X8}"); + } + } + + [Fact] + public void CombineWithNothingKeepsTheCrc() + { + Assert.Equal(0x1234_5678u, Crc32.Combine(0x1234_5678, 0, 0)); + Assert.Equal(0u, Crc32.Combine(0, 0, 0)); + } + + [Fact] + public void CombineChainsOverThreeParts() + { + byte[] a = "first part "u8.ToArray(); + byte[] b = "second, longer part of the input "u8.ToArray(); + byte[] c = "third"u8.ToArray(); + uint ab = Crc32.Combine(Crc32.Append(0, a), Crc32.Append(0, b), b.Length); + uint abc = Crc32.Combine(ab, Crc32.Append(0, c), c.Length); + Assert.Equal(BitwiseCrc([.. a, .. b, .. c]), abc); + } + + [Fact] + public void CombineRejectsANegativeLength() + { + Assert.Throws(() => Crc32.Combine(1, 2, -1)); + } } } From 869ae44c2a19a6398ffcf313d10778701f7a9aa6 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 13:11:56 -0300 Subject: [PATCH 22/42] feat: compress byte-aligned deflate segments that concatenate --- src/ExcelReader.Core/Writer/Zip/BufferSink.cs | 97 +++++++++++++++++++ .../Writer/Zip/DeflateEncoder.cs | 16 +++ .../Writer/Zip/SegmentCompressor.cs | 51 ++++++++++ .../Writer/Zip/ZipEntryStream.cs | 83 ---------------- tests/ExcelReader.Fuzz/Harnesses.cs | 35 +++++++ tests/ExcelReader.Fuzz/Program.cs | 1 + tests/ExcelReader.Fuzz/SeedCorpus.cs | 4 + .../Writer/Zip/SegmentCompressorTests.cs | 94 ++++++++++++++++++ 8 files changed, 298 insertions(+), 83 deletions(-) create mode 100644 src/ExcelReader.Core/Writer/Zip/BufferSink.cs create mode 100644 src/ExcelReader.Core/Writer/Zip/SegmentCompressor.cs create mode 100644 tests/ExcelReader.Tests/Writer/Zip/SegmentCompressorTests.cs diff --git a/src/ExcelReader.Core/Writer/Zip/BufferSink.cs b/src/ExcelReader.Core/Writer/Zip/BufferSink.cs new file mode 100644 index 00000000..2a910804 --- /dev/null +++ b/src/ExcelReader.Core/Writer/Zip/BufferSink.cs @@ -0,0 +1,97 @@ +using ExcelReader.Core.Writer.Internal; + +namespace ExcelReader.Core.Writer.Zip +{ + /// + /// A write-only stream that appends to a it does not own. While + /// is set it drops what it is given, which is how the final block a + /// DeflateStream writes on dispose is kept out of a stream of segments. + /// + internal sealed class BufferSink : Stream + { + private readonly BiffBuffer _target; + + internal BufferSink(BiffBuffer target) + { + _target = target; + } + + internal bool Discard { get; set; } + + public override bool CanRead + { + get + { + return false; + } + } + + public override bool CanSeek + { + get + { + return false; + } + } + + public override bool CanWrite + { + get + { + return true; + } + } + + public override long Length + { + get + { + throw new NotSupportedException(); + } + } + + public override long Position + { + get + { + throw new NotSupportedException(); + } + set + { + throw new NotSupportedException(); + } + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + + 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) + { + Write(buffer.AsSpan(offset, count)); + } + + public override void Write(ReadOnlySpan buffer) + { + if (!Discard) + { + _target.Write(buffer); + } + } + } +} diff --git a/src/ExcelReader.Core/Writer/Zip/DeflateEncoder.cs b/src/ExcelReader.Core/Writer/Zip/DeflateEncoder.cs index 72a724cd..80b41fe5 100644 --- a/src/ExcelReader.Core/Writer/Zip/DeflateEncoder.cs +++ b/src/ExcelReader.Core/Writer/Zip/DeflateEncoder.cs @@ -71,6 +71,22 @@ internal void Finish(BiffBuffer output) _bitCount = 0; } + /// + /// Ends the pending input as a non-final block and appends an empty stored block, so the output + /// ends on a byte boundary and another deflate stream may be laid after it. + /// + internal void FlushSync(BiffBuffer output) + { + EncodeBlock(_end, final: false, output); + Span tail = output.GetSpan(sizeof(ulong) + 4); + int headerBytes = (_bitCount + 3 + 7) / 8; + BinaryPrimitives.WriteUInt64LittleEndian(tail, _bits); + BinaryPrimitives.WriteUInt32LittleEndian(tail[headerBytes..], 0xFFFF_0000); + output.Advance(headerBytes + 4); + _bits = 0; + _bitCount = 0; + } + public void Dispose() { if (_window.Length == 0) diff --git a/src/ExcelReader.Core/Writer/Zip/SegmentCompressor.cs b/src/ExcelReader.Core/Writer/Zip/SegmentCompressor.cs new file mode 100644 index 00000000..af4780af --- /dev/null +++ b/src/ExcelReader.Core/Writer/Zip/SegmentCompressor.cs @@ -0,0 +1,51 @@ +using System.IO.Compression; +using ExcelReader.Core.Writer.Internal; + +namespace ExcelReader.Core.Writer.Zip +{ + /// + /// Compresses one run of bytes into a deflate segment: data that ends on a byte boundary and has + /// no final block, so segments laid end to end, followed by , are one + /// valid deflate stream. A segment never refers to bytes outside itself. + /// + internal static class SegmentCompressor + { + /// An empty fixed-Huffman block marked final: it ends a stream of segments. + internal static ReadOnlySpan FinalBlock + { + get + { + return [0x03, 0x00]; + } + } + + internal static void Compress(ReadOnlySpan input, CompressionLevel level, BiffBuffer output) + { + if (level == CompressionLevel.NoCompression) + { + output.Write(input); + return; + } + // DeflateEncoder writes and compares native-order words, which is only deflate's bit order on little-endian. + if (level == CompressionLevel.Fastest && BitConverter.IsLittleEndian) + { + using var encoder = new DeflateEncoder(); + encoder.Write(input, output); + encoder.FlushSync(output); + return; + } + CompressWithZlib(input, level, output); + } + + // DeflateStream.Flush is zlib's sync flush, which ends on a byte boundary; the final block that + // Dispose writes afterwards is dropped. + private static void CompressWithZlib(ReadOnlySpan input, CompressionLevel level, BiffBuffer output) + { + var sink = new BufferSink(output); + using var zlib = new DeflateStream(sink, level); + zlib.Write(input); + zlib.Flush(); + sink.Discard = true; + } + } +} diff --git a/src/ExcelReader.Core/Writer/Zip/ZipEntryStream.cs b/src/ExcelReader.Core/Writer/Zip/ZipEntryStream.cs index c9eefdf1..96ce9bdc 100644 --- a/src/ExcelReader.Core/Writer/Zip/ZipEntryStream.cs +++ b/src/ExcelReader.Core/Writer/Zip/ZipEntryStream.cs @@ -398,88 +398,5 @@ private void Release() FailureCleanup.Dispose(_zlib); _pending.Dispose(); } - - private sealed class BufferSink : Stream - { - private readonly BiffBuffer _target; - - internal BufferSink(BiffBuffer target) - { - _target = target; - } - - public override bool CanRead - { - get - { - return false; - } - } - - public override bool CanSeek - { - get - { - return false; - } - } - - public override bool CanWrite - { - get - { - return true; - } - } - - public override long Length - { - get - { - throw new NotSupportedException(); - } - } - - public override long Position - { - get - { - throw new NotSupportedException(); - } - set - { - throw new NotSupportedException(); - } - } - - public override void Flush() - { - } - - public override int Read(byte[] buffer, int offset, int count) - { - throw new NotSupportedException(); - } - - 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) - { - _target.Write(buffer.AsSpan(offset, count)); - } - - public override void Write(ReadOnlySpan buffer) - { - _target.Write(buffer); - } - } } } diff --git a/tests/ExcelReader.Fuzz/Harnesses.cs b/tests/ExcelReader.Fuzz/Harnesses.cs index 6840f9ff..384e9800 100644 --- a/tests/ExcelReader.Fuzz/Harnesses.cs +++ b/tests/ExcelReader.Fuzz/Harnesses.cs @@ -287,6 +287,41 @@ private static byte[] DeflateWithEncoder(ReadOnlySpan payload, int chunk) return output.Span.ToArray(); } + internal static void DeflateSegments(ReadOnlySpan data) + { + if (data.IsEmpty) + { + return; + } + int chunk = 1 + (data[0] * 4099 % 70_000); + ReadOnlySpan payload = data[1..]; + if (payload.Length > MaxInflateOutput) + { + payload = payload[..MaxInflateOutput]; + } + byte[] compressed = DeflateInSegments(payload, chunk); + byte[] viaZlib = InflateWith(static source => new DeflateStream(source, CompressionMode.Decompress), compressed); + byte[] viaLibrary = InflateWith(static source => new InflateStream(source), compressed); + if (!viaZlib.AsSpan().SequenceEqual(payload) || !viaLibrary.AsSpan().SequenceEqual(payload)) + { + throw new OracleDivergenceException( + $"Deflate segments changed {payload.Length} bytes cut into {chunk}-byte segments: DeflateStream gave {viaZlib.Length}, InflateStream {viaLibrary.Length}."); + } + } + + private static byte[] DeflateInSegments(ReadOnlySpan payload, int chunk) + { + using var output = new BiffBuffer(4096); + while (!payload.IsEmpty) + { + int take = Math.Min(chunk, payload.Length); + SegmentCompressor.Compress(payload[..take], CompressionLevel.Fastest, output); + payload = payload[take..]; + } + output.Write(SegmentCompressor.FinalBlock); + return output.Span.ToArray(); + } + private static void RequireSameInflate(byte[] actual, byte[] reference, bool prefixOnly) { bool same = prefixOnly ? reference.AsSpan().StartsWith(actual) : actual.AsSpan().SequenceEqual(reference); diff --git a/tests/ExcelReader.Fuzz/Program.cs b/tests/ExcelReader.Fuzz/Program.cs index 09c133cd..023a6a64 100644 --- a/tests/ExcelReader.Fuzz/Program.cs +++ b/tests/ExcelReader.Fuzz/Program.cs @@ -32,6 +32,7 @@ internal static class Program ["csv-parallel"] = Harnesses.CsvParallel, ["inflate"] = Harnesses.Inflate, ["deflate"] = Harnesses.Deflate, + ["deflate-segments"] = Harnesses.DeflateSegments, }; private static async Task Main(string[] args) diff --git a/tests/ExcelReader.Fuzz/SeedCorpus.cs b/tests/ExcelReader.Fuzz/SeedCorpus.cs index 9246efb3..ef8f01ab 100644 --- a/tests/ExcelReader.Fuzz/SeedCorpus.cs +++ b/tests/ExcelReader.Fuzz/SeedCorpus.cs @@ -51,6 +51,7 @@ private static readonly (string Target, string[] Seeds)[] _layout = "csv-parallel-3", "csv-parallel-4", "csv-parallel-5", "csv-parallel-6"]), ("inflate", ["inflate-stored", "inflate-fixed", "inflate-dynamic"]), ("deflate", ["deflate-text", "deflate-zeros", "deflate-noise"]), + ("deflate-segments", ["deflate-segments-text", "deflate-segments-zeros", "deflate-segments-noise"]), ]; internal static async Task GenerateAsync(string rootDirectory) @@ -130,6 +131,9 @@ private static async Task> BuildAllAsync() yield return ("deflate-text", [16, .. sheet]); yield return ("deflate-zeros", [255, .. new byte[70_000]]); yield return ("deflate-noise", [1, .. noise]); + yield return ("deflate-segments-text", [1, .. sheet]); + yield return ("deflate-segments-zeros", [2, .. new byte[70_000]]); + yield return ("deflate-segments-noise", [0, .. noise]); } private static byte[] Deflate(byte[] raw, CompressionLevel level) diff --git a/tests/ExcelReader.Tests/Writer/Zip/SegmentCompressorTests.cs b/tests/ExcelReader.Tests/Writer/Zip/SegmentCompressorTests.cs new file mode 100644 index 00000000..a6baa300 --- /dev/null +++ b/tests/ExcelReader.Tests/Writer/Zip/SegmentCompressorTests.cs @@ -0,0 +1,94 @@ +using System.Diagnostics.CodeAnalysis; +using System.IO.Compression; +using ExcelReader.Core.Reader.Zip.Inflate; +using ExcelReader.Core.Writer.Internal; +using ExcelReader.Core.Writer.Zip; + +namespace ExcelReader.Tests.Writer.Zip +{ + public class SegmentCompressorTests + { + public static TheoryData DeflateLevels() + { + return [CompressionLevel.Fastest, CompressionLevel.Optimal, CompressionLevel.SmallestSize]; + } + + private static byte[] Inflate(Func open, byte[] compressed) + { + using Stream inflate = open(new MemoryStream(compressed)); + using var copy = new MemoryStream(); + inflate.CopyTo(copy); + return copy.ToArray(); + } + + internal static void AssertInflatesTo(byte[] expected, byte[] compressed) + { + Assert.Equal(expected, Inflate(static s => new DeflateStream(s, CompressionMode.Decompress), compressed)); + Assert.Equal(expected, Inflate(static s => new InflateStream(s), compressed)); + } + + [Theory] + [MemberData(nameof(DeflateLevels))] + [SuppressMessage("Security", "CA5394:Do not use insecure randomness", + Justification = "A seeded generator picks reproducible test inputs; nothing here is secret.")] + public void SegmentsConcatenateIntoOneStream(CompressionLevel level) + { + var random = new Random(3); + byte[] noise = new byte[70_000]; + random.NextBytes(noise); + byte[][] parts = + [ + DeflateEncoderTests.SheetXml(2_000), + [], + noise, + [42], + DeflateEncoderTests.SheetXml(9_000), + ]; + using var output = new BiffBuffer(4096); + foreach (byte[] part in parts) + { + SegmentCompressor.Compress(part, level, output); + } + output.Write(SegmentCompressor.FinalBlock); + + AssertInflatesTo([.. parts.SelectMany(static p => p)], output.Span.ToArray()); + } + + [Fact] + public void NoCompressionCopiesTheBytes() + { + using var output = new BiffBuffer(64); + SegmentCompressor.Compress("plain"u8, CompressionLevel.NoCompression, output); + Assert.Equal("plain"u8.ToArray(), output.Span.ToArray()); + } + + [Fact] + public void FlushSyncLeavesTheEncoderUsable() + { + byte[] first = DeflateEncoderTests.SheetXml(3_000); + byte[] second = DeflateEncoderTests.SheetXml(1_000); + using var encoder = new DeflateEncoder(); + using var output = new BiffBuffer(4096); + encoder.Write(first, output); + encoder.FlushSync(output); + int aligned = output.Length; + encoder.FlushSync(output); + encoder.Write(second, output); + encoder.Finish(output); + + Assert.Equal([0x00, 0x00, 0xFF, 0xFF], output.Span.Slice(aligned - 4, 4).ToArray()); + AssertInflatesTo([.. first, .. second], output.Span.ToArray()); + } + + [Fact] + public void BufferSinkDropsWritesWhileDiscarding() + { + using var target = new BiffBuffer(64); + using var sink = new BufferSink(target); + sink.Write("kept"u8); + sink.Discard = true; + sink.Write("dropped"u8); + Assert.Equal("kept"u8.ToArray(), target.Span.ToArray()); + } + } +} From 91aee75f7344fa1cc027c3fffc23a3d4f9aa3cfa Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 13:20:31 -0300 Subject: [PATCH 23/42] feat: append pre-compressed segments to a ZIP entry --- .../Writer/Zip/ZipEntryStream.cs | 100 +++++++++++++- .../Writer/Zip/ZipPackageWriter.cs | 2 +- .../Writer/Zip/ZipEntrySegmentTests.cs | 129 ++++++++++++++++++ 3 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 tests/ExcelReader.Tests/Writer/Zip/ZipEntrySegmentTests.cs diff --git a/src/ExcelReader.Core/Writer/Zip/ZipEntryStream.cs b/src/ExcelReader.Core/Writer/Zip/ZipEntryStream.cs index 96ce9bdc..41d01fbf 100644 --- a/src/ExcelReader.Core/Writer/Zip/ZipEntryStream.cs +++ b/src/ExcelReader.Core/Writer/Zip/ZipEntryStream.cs @@ -21,10 +21,14 @@ internal sealed class ZipEntryStream : Stream private readonly BiffBuffer _pending = new(FlushThreshold * 2); [SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", Justification = "Released by Release(), which every close path and Abandon call.")] - private readonly DeflateEncoder? _encoder; + private DeflateEncoder? _encoder; [SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", Justification = "Finished by FinishCompression and released by Release().")] - private readonly DeflateStream? _zlib; + private DeflateStream? _zlib; + [SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", + Justification = "Owns no resources; the DeflateStream over it disposes it.")] + private BufferSink? _zlibSink; + private bool _segmented; private uint _crc; private long _uncompressed; private long _compressed; @@ -47,7 +51,8 @@ internal ZipEntryStream(ZipPackageWriter owner, ZipEntryRecord record) } else if (record.Method == ZipRecords.MethodDeflate) { - _zlib = new DeflateStream(new BufferSink(_pending), record.Level); + _zlibSink = new BufferSink(_pending); + _zlib = new DeflateStream(_zlibSink, record.Level); } } @@ -209,6 +214,82 @@ public override async ValueTask WriteAsync(ReadOnlyMemory buffer, Cancella } } + /// + /// Appends data that already compressed at this entry's level, + /// with the CRC and length of the bytes it was made from. + /// + internal void AppendSegment(ReadOnlySpan segment, uint crc, long uncompressedLength) + { + ObjectDisposedException.ThrowIf(_closed, this); + try + { + BeginSegments(); + MergeSegment(crc, uncompressedLength); + _pending.Write(segment); + if (_pending.Length >= FlushThreshold) + { + FlushPending(); + } + } + catch + { + Fail(); + throw; + } + } + + /// + internal async ValueTask AppendSegmentAsync(ReadOnlyMemory segment, uint crc, long uncompressedLength, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_closed, this); + try + { + BeginSegments(); + MergeSegment(crc, uncompressedLength); + _pending.Write(segment.Span); + if (_pending.Length >= FlushThreshold) + { + await FlushPendingAsync(cancellationToken).ConfigureAwait(false); + } + } + catch + { + Fail(); + throw; + } + } + + // From here on every piece of the entry is self-contained: a compressor kept across a segment + // could refer back to bytes the reader sees before that segment. + private void BeginSegments() + { + if (_segmented) + { + return; + } + _segmented = true; + if (_encoder is not null) + { + _encoder.FlushSync(_pending); + _encoder.Dispose(); + _encoder = null; + return; + } + if (_zlib is not null) + { + _zlib.Flush(); + _zlibSink!.Discard = true; + _zlib.Dispose(); + _zlib = null; + } + } + + private void MergeSegment(uint crc, long uncompressedLength) + { + _crc = Crc32.Combine(_crc, crc, uncompressedLength); + _uncompressed += uncompressedLength; + } + internal void Abandon() { if (_closed) @@ -281,6 +362,11 @@ private void Accept(ReadOnlySpan buffer) { _crc = Crc32.Append(_crc, buffer); _uncompressed += buffer.Length; + if (_segmented) + { + SegmentCompressor.Compress(buffer, _record.Level, _pending); + return; + } if (_encoder is not null) { _encoder.Write(buffer, _pending); @@ -364,6 +450,14 @@ private void MarkEmpty() private void FinishCompression() { + if (_segmented) + { + if (_record.Method == ZipRecords.MethodDeflate) + { + _pending.Write(SegmentCompressor.FinalBlock); + } + return; + } _encoder?.Finish(_pending); _zlib?.Dispose(); } diff --git a/src/ExcelReader.Core/Writer/Zip/ZipPackageWriter.cs b/src/ExcelReader.Core/Writer/Zip/ZipPackageWriter.cs index d2ebe75a..e3c51a59 100644 --- a/src/ExcelReader.Core/Writer/Zip/ZipPackageWriter.cs +++ b/src/ExcelReader.Core/Writer/Zip/ZipPackageWriter.cs @@ -40,7 +40,7 @@ internal long Position } } - internal Stream OpenEntry(string name, CompressionLevel level) + internal ZipEntryStream OpenEntry(string name, CompressionLevel level) { ObjectDisposedException.ThrowIf(_closed, this); if (!Enum.IsDefined(level)) diff --git a/tests/ExcelReader.Tests/Writer/Zip/ZipEntrySegmentTests.cs b/tests/ExcelReader.Tests/Writer/Zip/ZipEntrySegmentTests.cs new file mode 100644 index 00000000..bfc1f2d2 --- /dev/null +++ b/tests/ExcelReader.Tests/Writer/Zip/ZipEntrySegmentTests.cs @@ -0,0 +1,129 @@ +using System.IO.Compression; +using ExcelReader.Core.Writer.Internal; +using ExcelReader.Core.Writer.Zip; + +namespace ExcelReader.Tests.Writer.Zip +{ + public class ZipEntrySegmentTests + { + public static TheoryData Cases() + { + var data = new TheoryData(); + foreach (CompressionLevel level in Enum.GetValues()) + { + data.Add(level, true); + data.Add(level, false); + } + return data; + } + + private static Stream Target(MemoryStream ms, bool seekable) + { + if (seekable) + { + return ms; + } + return new ForwardOnlyWriteStream(ms); + } + + private static (byte[] Segment, uint Crc) Segment(byte[] raw, CompressionLevel level) + { + using var buffer = new BiffBuffer(4096); + SegmentCompressor.Compress(raw, level, buffer); + return (buffer.Span.ToArray(), Crc32.Append(0, raw)); + } + + private static void AssertEntry(byte[] package, byte[] expected) + { + using var zip = new ZipArchive(new MemoryStream(package), ZipArchiveMode.Read); + ZipArchiveEntry entry = Assert.Single(zip.Entries); + using Stream data = entry.Open(); + using var copy = new MemoryStream(); + data.CopyTo(copy); + Assert.Equal(expected, copy.ToArray()); + Assert.Equal(Crc32Tests.BitwiseCrc(expected), entry.Crc32); + Assert.Equal(expected.Length, entry.Length); + } + + [Theory] + [MemberData(nameof(Cases))] + public void SerialWritesAndSegmentsInterleave(CompressionLevel level, bool seekable) + { + byte[] a = DeflateEncoderTests.SheetXml(1_500); + byte[] b = DeflateEncoderTests.SheetXml(4_000); + byte[] c = ""u8.ToArray(); + byte[] d = DeflateEncoderTests.SheetXml(2_500); + byte[] e = DeflateEncoderTests.SheetXml(700); + var ms = new MemoryStream(); + var zip = new ZipPackageWriter(Target(ms, seekable)); + using (ZipEntryStream entry = zip.OpenEntry("sheet.xml", level)) + { + entry.Write(a); + (byte[] segment, uint crc) = Segment(b, level); + entry.AppendSegment(segment, crc, b.Length); + entry.Write(c); + (segment, crc) = Segment(d, level); + entry.AppendSegment(segment, crc, d.Length); + (segment, crc) = Segment([], level); + entry.AppendSegment(segment, crc, 0); + entry.Write(e); + } + zip.Finish(); + + AssertEntry(ms.ToArray(), [.. a, .. b, .. c, .. d, .. e]); + } + + [Theory] + [MemberData(nameof(Cases))] + public async Task ASegmentCanBeTheFirstAndOnlyContent(CompressionLevel level, bool seekable) + { + CancellationToken ct = TestContext.Current.CancellationToken; + byte[] raw = DeflateEncoderTests.SheetXml(3_000); + var ms = new MemoryStream(); + var zip = new ZipPackageWriter(Target(ms, seekable)); + ZipEntryStream entry = zip.OpenEntry("sheet.xml", level); + await using (entry) + { + (byte[] segment, uint crc) = Segment(raw, level); + await entry.AppendSegmentAsync(segment, crc, raw.Length, ct); + } + await zip.FinishAsync(ct); + + AssertEntry(ms.ToArray(), raw); + } + + // A zlib stream kept across a segment could point back at bytes the reader sees before the segment. + [Fact] + public void SerialWritesAfterASegmentStartAFreshStream() + { + byte[] repeated = DeflateEncoderTests.SheetXml(2_000); + var ms = new MemoryStream(); + var zip = new ZipPackageWriter(ms); + using (ZipEntryStream entry = zip.OpenEntry("sheet.xml", CompressionLevel.Optimal)) + { + entry.Write(repeated); + (byte[] segment, uint crc) = Segment(""u8.ToArray(), CompressionLevel.Optimal); + entry.AppendSegment(segment, crc, 8); + entry.Write(repeated); + } + zip.Finish(); + + AssertEntry(ms.ToArray(), [.. repeated, .. ""u8, .. repeated]); + } + + [Fact] + public void AFailedAppendKillsTheEntryAndTheArchive() + { + byte[] raw = DeflateEncoderTests.SheetXml(20_000); + var zip = new ZipPackageWriter(new FailingWriteStream(limit: 0)); + ZipEntryStream entry = zip.OpenEntry("sheet.xml", CompressionLevel.Fastest); + (byte[] segment, uint crc) = Segment(raw, CompressionLevel.Fastest); + + Assert.Throws(() => entry.AppendSegment(segment, crc, raw.Length)); + entry.Dispose(); + Assert.Throws(() => entry.AppendSegment(segment, crc, raw.Length)); + Assert.Throws(zip.Finish); + zip.Abandon(); + } + } +} From cf5b7536e80c9ff15f7f64dd1eaf88b158d59521 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 13:29:01 -0300 Subject: [PATCH 24/42] feat: drain the offload writer without ending it Co-Authored-By: Claude Sonnet 5.5 --- .../Writer/Internal/WriteOffloadStream.cs | 37 +++++++++++- .../Writer/WriteOffloadDrainTests.cs | 60 +++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 tests/ExcelReader.Tests/Writer/WriteOffloadDrainTests.cs diff --git a/src/ExcelReader.Core/Writer/Internal/WriteOffloadStream.cs b/src/ExcelReader.Core/Writer/Internal/WriteOffloadStream.cs index 8a30666e..034a5fba 100644 --- a/src/ExcelReader.Core/Writer/Internal/WriteOffloadStream.cs +++ b/src/ExcelReader.Core/Writer/Internal/WriteOffloadStream.cs @@ -9,10 +9,14 @@ internal sealed class WriteOffloadStream : Stream { private const int ChannelCapacity = 4; + // A queue item with this length carries no bytes: it marks the point a DrainAsync call waits for. + private const int BarrierLength = -1; + private readonly Stream _inner; private readonly Channel<(byte[] Buffer, int Length, bool Owned)> _channel; private readonly Task _consumer; private ExceptionDispatchInfo? _consumerException; + private TaskCompletionSource? _barrier; private bool _writerCompleted; private bool _disposed; @@ -174,6 +178,28 @@ public override async Task FlushAsync(CancellationToken cancellationToken) await _inner.FlushAsync(cancellationToken).ConfigureAwait(false); } + /// + /// Waits until everything enqueued so far has reached the inner stream and the consumer is idle. + /// The stream stays writable, which does not allow. + /// + internal async ValueTask DrainAsync(CancellationToken cancellationToken = default) + { + ThrowIfFaulted(); + var barrier = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _barrier = barrier; + try + { + await _channel.Writer.WriteAsync(([], BarrierLength, Owned: false), cancellationToken).ConfigureAwait(false); + } + catch (ChannelClosedException) + { + ThrowIfFaulted(); + throw; + } + await barrier.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + ThrowIfFaulted(); + } + private void CompleteWriterOnce() { if (!_writerCompleted) @@ -196,6 +222,11 @@ private async Task ConsumeAsync() { await foreach ((byte[] buf, int len, bool owned) in _channel.Reader.ReadAllAsync().ConfigureAwait(false)) { + if (len == BarrierLength) + { + _barrier?.TrySetResult(); + continue; + } try { await _inner.WriteAsync(buf.AsMemory(0, len)).ConfigureAwait(false); @@ -212,8 +243,12 @@ private async Task ConsumeAsync() _channel.Writer.TryComplete(); while (_channel.Reader.TryRead(out (byte[] Buffer, int Length, bool Owned) queued)) { - ReturnBuffer(queued.Buffer, queued.Owned); + if (queued.Length != BarrierLength) + { + ReturnBuffer(queued.Buffer, queued.Owned); + } } + _barrier?.TrySetResult(); } } diff --git a/tests/ExcelReader.Tests/Writer/WriteOffloadDrainTests.cs b/tests/ExcelReader.Tests/Writer/WriteOffloadDrainTests.cs new file mode 100644 index 00000000..6c0097d3 --- /dev/null +++ b/tests/ExcelReader.Tests/Writer/WriteOffloadDrainTests.cs @@ -0,0 +1,60 @@ +using ExcelReader.Core.Writer.Internal; +using ExcelReader.Tests.Writer.Zip; + +namespace ExcelReader.Tests.Writer +{ + public class WriteOffloadDrainTests + { + private sealed class SlowStream : MemoryStream + { + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + await Task.Delay(15, cancellationToken); + await base.WriteAsync(buffer, cancellationToken); + } + } + + [Fact] + public async Task DrainWaitsForEverythingWrittenBefore() + { + CancellationToken ct = TestContext.Current.CancellationToken; + var inner = new SlowStream(); + await using var offload = new WriteOffloadStream(inner); + for (int i = 0; i < 6; i++) + { + await offload.WriteAsync(new byte[] { (byte)i, (byte)i }, ct); + } + + await offload.DrainAsync(ct); + + Assert.Equal([0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5], inner.ToArray()); + } + + [Fact] + public async Task TheStreamStaysWritableAfterADrain() + { + CancellationToken ct = TestContext.Current.CancellationToken; + var inner = new MemoryStream(); + var offload = new WriteOffloadStream(inner); + await offload.WriteAsync("ab"u8.ToArray(), ct); + await offload.DrainAsync(ct); + await offload.DrainAsync(ct); + await offload.WriteAsync("cd"u8.ToArray(), ct); + await offload.FlushAsync(ct); + + Assert.Equal("abcd"u8.ToArray(), inner.ToArray()); + await offload.DisposeAsync(); + } + + [Fact] + public async Task DrainSurfacesAConsumerFailureWithItsType() + { + CancellationToken ct = TestContext.Current.CancellationToken; + var offload = new WriteOffloadStream(new FailingWriteStream(0)); + await offload.WriteAsync("ab"u8.ToArray(), ct); + + await Assert.ThrowsAsync(async () => await offload.DrainAsync(ct)); + await Assert.ThrowsAsync(async () => await offload.DisposeAsync()); + } + } +} From de31f346030b2f92fa8526f4ee24d028f17f7868 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 13:33:28 -0300 Subject: [PATCH 25/42] feat: run record batches in parallel with ordered, bounded output Co-Authored-By: Claude Sonnet 5.5 --- .../PublicAPI/PublicAPI.Unshipped.txt | 7 + .../Writer/Parallel/BatchRunner.cs | 151 ++++++++++++++ .../Writer/Parallel/BatchSegment.cs | 46 ++++ .../Writer/Parallel/RecordSource.cs | 22 ++ .../Writer/ParallelWriteOptions.cs | 30 +++ .../Writer/Parallel/BatchRunnerTests.cs | 196 ++++++++++++++++++ 6 files changed, 452 insertions(+) create mode 100644 src/ExcelReader.Core/Writer/Parallel/BatchRunner.cs create mode 100644 src/ExcelReader.Core/Writer/Parallel/BatchSegment.cs create mode 100644 src/ExcelReader.Core/Writer/Parallel/RecordSource.cs create mode 100644 src/ExcelReader.Core/Writer/ParallelWriteOptions.cs create mode 100644 tests/ExcelReader.Tests/Writer/Parallel/BatchRunnerTests.cs diff --git a/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt b/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt index 772cc5ed..15aed17e 100644 --- a/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt @@ -56,6 +56,13 @@ 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.Writer.ParallelWriteOptions +ExcelReader.Core.Writer.ParallelWriteOptions.BatchRows.get -> int +ExcelReader.Core.Writer.ParallelWriteOptions.BatchRows.init -> void +ExcelReader.Core.Writer.ParallelWriteOptions.DegreeOfParallelism.get -> int +ExcelReader.Core.Writer.ParallelWriteOptions.DegreeOfParallelism.init -> void +ExcelReader.Core.Writer.ParallelWriteOptions.ParallelWriteOptions() -> void +static ExcelReader.Core.Writer.ParallelWriteOptions.Default.get -> ExcelReader.Core.Writer.ParallelWriteOptions! ExcelReader.Core.Reader.SheetParallel ExcelReader.Core.Reader.SheetParallelOptions ExcelReader.Core.Reader.SheetParallelOptions.DegreeOfParallelism.get -> int diff --git a/src/ExcelReader.Core/Writer/Parallel/BatchRunner.cs b/src/ExcelReader.Core/Writer/Parallel/BatchRunner.cs new file mode 100644 index 00000000..e79019be --- /dev/null +++ b/src/ExcelReader.Core/Writer/Parallel/BatchRunner.cs @@ -0,0 +1,151 @@ +using System.Buffers; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using ExcelReader.Core.Reader.Internal; + +namespace ExcelReader.Core.Writer.Parallel +{ + /// + /// Cuts a record source into batches, formats at most a fixed number of them at once and hands the + /// results to the caller in order. The source is enumerated, and results are emitted, only on the + /// calling flow. + /// + internal sealed class BatchRunner + { + private readonly Queue> _inFlight = new(); + private readonly Func _format; + private readonly Func _emit; + private readonly CancellationToken _ct; + private readonly int _degree; + private readonly int _batchRows; + private T[] _batch; + private int _count; + private int _rowsBefore; + + internal BatchRunner(ParallelWriteOptions options, int rowsBefore, Func format, + Func emit, CancellationToken ct) + { + _degree = options.DegreeOfParallelism == 0 ? Environment.ProcessorCount : options.DegreeOfParallelism; + _batchRows = options.BatchRows; + _rowsBefore = rowsBefore; + _format = format; + _emit = emit; + _ct = ct; + _batch = ArrayPool.Shared.Rent(_batchRows); + } + + internal async ValueTask RunAsync(RecordSource source) + { + try + { + if (source.Sync is not null) + { + foreach (T record in source.Sync) + { + await AddAsync(record).ConfigureAwait(false); + } + } + else + { + await foreach (T record in source.Async!.WithCancellation(_ct).ConfigureAwait(false)) + { + await AddAsync(record).ConfigureAwait(false); + } + } + await CompleteAsync().ConfigureAwait(false); + } + catch + { + await AbandonAsync().ConfigureAwait(false); + throw; + } + } + + private ValueTask AddAsync(T record) + { + _batch[_count++] = record; + return _count == _batchRows ? DispatchAsync() : ValueTask.CompletedTask; + } + + private async ValueTask CompleteAsync() + { + if (_count > 0) + { + await DispatchAsync().ConfigureAwait(false); + } + while (_inFlight.Count > 0) + { + await EmitOldestAsync().ConfigureAwait(false); + } + ReturnBatch(_batch); + _batch = []; + } + + private async ValueTask DispatchAsync() + { + _ct.ThrowIfCancellationRequested(); + if (_rowsBefore + (long)_count > ExcelLimits.MaxRows) + { + ExcelLimits.ThrowRowLimit(_rowsBefore + (long)_count); + } + if (_inFlight.Count == _degree) + { + await EmitOldestAsync().ConfigureAwait(false); + } + T[] batch = _batch; + int count = _count; + int rowsBefore = _rowsBefore; + _batch = ArrayPool.Shared.Rent(_batchRows); + _count = 0; + _rowsBefore += count; + _inFlight.Enqueue(_degree == 1 + ? Task.FromResult(Format(batch, count, rowsBefore)) + : Task.Run(() => Format(batch, count, rowsBefore), CancellationToken.None)); + } + + private BatchSegment Format(T[] batch, int count, int rowsBefore) + { + try + { + return _format(batch, count, rowsBefore); + } + finally + { + ReturnBatch(batch); + } + } + + private async ValueTask EmitOldestAsync() + { + using BatchSegment segment = await _inFlight.Dequeue().ConfigureAwait(false); + await _emit(segment, _ct).ConfigureAwait(false); + } + + // After a failure: no worker may outlive the call, and every finished batch gives its buffer back. + [SuppressMessage("Design", "CA1031:Do not catch general exception types", + Justification = "The caller is already rethrowing the first failure; a later batch's failure must not replace it.")] + private async ValueTask AbandonAsync() + { + while (_inFlight.Count > 0) + { + try + { + (await _inFlight.Dequeue().ConfigureAwait(false)).Dispose(); + } + catch (Exception) + { + } + } + ReturnBatch(_batch); + _batch = []; + } + + private static void ReturnBatch(T[] batch) + { + if (batch.Length > 0) + { + ArrayPool.Shared.Return(batch, RuntimeHelpers.IsReferenceOrContainsReferences()); + } + } + } +} diff --git a/src/ExcelReader.Core/Writer/Parallel/BatchSegment.cs b/src/ExcelReader.Core/Writer/Parallel/BatchSegment.cs new file mode 100644 index 00000000..5b677783 --- /dev/null +++ b/src/ExcelReader.Core/Writer/Parallel/BatchSegment.cs @@ -0,0 +1,46 @@ +using System.IO.Compression; +using ExcelReader.Core.Writer.Internal; +using ExcelReader.Core.Writer.Zip; + +namespace ExcelReader.Core.Writer.Parallel +{ + /// + /// One batch of rows after formatting and compression: the deflate segment, the CRC and length of + /// the rows it was made from, and the range of rows and columns they used. + /// + internal sealed class BatchSegment : IDisposable + { + internal BatchSegment(int rows) + { + Rows = rows; + } + + internal BiffBuffer Data { get; } = new(1 << 16); + + internal int Rows { get; } + + internal uint Crc { get; private set; } + + internal long RawLength { get; private set; } + + internal int RowFirst { get; set; } = -1; + + internal int RowLast { get; set; } = -1; + + internal int ColumnFirst { get; set; } = int.MaxValue; + + internal int ColumnLast { get; set; } = -1; + + internal void Fill(ReadOnlySpan raw, CompressionLevel level) + { + Crc = Crc32.Append(0, raw); + RawLength = raw.Length; + SegmentCompressor.Compress(raw, level, Data); + } + + public void Dispose() + { + Data.Dispose(); + } + } +} diff --git a/src/ExcelReader.Core/Writer/Parallel/RecordSource.cs b/src/ExcelReader.Core/Writer/Parallel/RecordSource.cs new file mode 100644 index 00000000..8e84150f --- /dev/null +++ b/src/ExcelReader.Core/Writer/Parallel/RecordSource.cs @@ -0,0 +1,22 @@ +namespace ExcelReader.Core.Writer.Parallel +{ + /// A record source that is either synchronous or asynchronous. + internal readonly struct RecordSource + { + internal RecordSource(IEnumerable records) + { + Sync = records; + Async = null; + } + + internal RecordSource(IAsyncEnumerable records) + { + Sync = null; + Async = records; + } + + internal IEnumerable? Sync { get; } + + internal IAsyncEnumerable? Async { get; } + } +} diff --git a/src/ExcelReader.Core/Writer/ParallelWriteOptions.cs b/src/ExcelReader.Core/Writer/ParallelWriteOptions.cs new file mode 100644 index 00000000..761af9c7 --- /dev/null +++ b/src/ExcelReader.Core/Writer/ParallelWriteOptions.cs @@ -0,0 +1,30 @@ +using System.Diagnostics.CodeAnalysis; + +namespace ExcelReader.Core.Writer +{ + /// Configures parallel record writes. + public sealed class ParallelWriteOptions + { + /// Gets the default options: one batch in flight per processor, 8,192 rows per batch. + public static ParallelWriteOptions Default { get; } = new(); + + /// + /// Gets the most batches formatted and compressed at once. 0, the default, means + /// . With 1 the batches run on the calling thread. + /// + public int DegreeOfParallelism { get; init; } + + /// + /// Gets the number of rows per batch. Each batch in flight holds its formatted rows and their + /// compressed form, so this and bound the memory used. + /// + public int BatchRows { get; init; } = 8192; + + [SuppressMessage("Design", "MA0015:Specify the parameter name in ArgumentException", Justification = "The invalid values are properties of this options object, so the property name is the right parameter name.")] + internal void Validate() + { + ArgumentOutOfRangeException.ThrowIfNegative(DegreeOfParallelism, nameof(DegreeOfParallelism)); + ArgumentOutOfRangeException.ThrowIfLessThan(BatchRows, 1, nameof(BatchRows)); + } + } +} diff --git a/tests/ExcelReader.Tests/Writer/Parallel/BatchRunnerTests.cs b/tests/ExcelReader.Tests/Writer/Parallel/BatchRunnerTests.cs new file mode 100644 index 00000000..858391e3 --- /dev/null +++ b/tests/ExcelReader.Tests/Writer/Parallel/BatchRunnerTests.cs @@ -0,0 +1,196 @@ +using ExcelReader.Core.Reader; +using ExcelReader.Core.Writer; +using ExcelReader.Core.Writer.Parallel; + +namespace ExcelReader.Tests.Writer.Parallel +{ + public class BatchRunnerTests + { + private sealed class Probe + { + private int _running; + private int _peak; + private int _formats; + + internal List<(int RowsBefore, int Rows)> Emitted { get; } = []; + internal int Peak => _peak; + internal int Running => Volatile.Read(ref _running); + internal int Formats => Volatile.Read(ref _formats); + internal int FailAtRowsBefore { get; init; } = -1; + + internal BatchSegment Format(int[] batch, int count, int rowsBefore) + { + int now = Interlocked.Increment(ref _running); + Interlocked.Increment(ref _formats); + InterlockedMax(ref _peak, now); + try + { + Thread.Sleep(2); + if (rowsBefore == FailAtRowsBefore) + { + throw new FormatException("batch " + rowsBefore); + } + Assert.Equal(rowsBefore, batch[0]); + return new BatchSegment(count) { RowFirst = rowsBefore }; + } + finally + { + Interlocked.Decrement(ref _running); + } + } + + internal ValueTask Emit(BatchSegment segment, CancellationToken ct) + { + Emitted.Add((segment.RowFirst, segment.Rows)); + return ValueTask.CompletedTask; + } + + private static void InterlockedMax(ref int target, int value) + { + int seen = Volatile.Read(ref target); + while (value > seen) + { + int previous = Interlocked.CompareExchange(ref target, value, seen); + if (previous == seen) + { + return; + } + seen = previous; + } + } + } + + private static BatchRunner Runner(Probe probe, int degree, int batchRows, CancellationToken ct, int rowsBefore = 0) + { + var options = new ParallelWriteOptions { DegreeOfParallelism = degree, BatchRows = batchRows }; + return new BatchRunner(options, rowsBefore, probe.Format, probe.Emit, ct); + } + + private static async IAsyncEnumerable Async(int count) + { + for (int i = 0; i < count; i++) + { + await Task.Yield(); + yield return i; + } + } + + [Theory] + [InlineData(0, 0)] + [InlineData(1, 1)] + [InlineData(10, 1)] + [InlineData(11, 2)] + [InlineData(95, 10)] + public async Task BatchesComeOutInOrderWithAPartialLastOne(int records, int expectedBatches) + { + foreach (int degree in new[] { 1, 4 }) + { + var probe = new Probe(); + await Runner(probe, degree, 10, TestContext.Current.CancellationToken) + .RunAsync(new RecordSource(Enumerable.Range(0, records))); + + Assert.Equal(expectedBatches, probe.Emitted.Count); + Assert.Equal(records, probe.Emitted.Sum(static e => e.Rows)); + Assert.Equal(probe.Emitted.OrderBy(static e => e.RowsBefore), probe.Emitted); + Assert.True(probe.Peak <= degree); + } + } + + [Fact] + public async Task AnAsyncSourceGivesTheSameBatches() + { + var probe = new Probe(); + await Runner(probe, 3, 10, TestContext.Current.CancellationToken).RunAsync(new RecordSource(Async(45))); + + Assert.Equal([(0, 10), (10, 10), (20, 10), (30, 10), (40, 5)], probe.Emitted); + } + + [Fact] + public async Task RowsBeforeOffsetsEveryBatch() + { + var probe = new Probe(); + int[] source = [.. Enumerable.Range(7, 25)]; + await Runner(probe, 2, 10, TestContext.Current.CancellationToken, rowsBefore: 7).RunAsync(new RecordSource(source)); + + Assert.Equal([(7, 10), (17, 10), (27, 5)], probe.Emitted); + } + + [Fact] + public async Task AFailingBatchStopsTheRunAndLeavesNoWorker() + { + var probe = new Probe { FailAtRowsBefore = 30 }; + BatchRunner runner = Runner(probe, 4, 10, TestContext.Current.CancellationToken); + + FormatException failure = await Assert.ThrowsAsync( + async () => await runner.RunAsync(new RecordSource(Enumerable.Range(0, 500)))); + + Assert.Equal("batch 30", failure.Message); + Assert.Equal(0, probe.Running); + Assert.All(probe.Emitted, static e => Assert.True(e.RowsBefore < 30)); + } + + [Fact] + public async Task AThrowingSourceSurfacesItsOwnException() + { + static IEnumerable Source() + { + for (int i = 0; i < 25; i++) + { + yield return i; + } + throw new InvalidDataException("source"); + } + var probe = new Probe(); + + await Assert.ThrowsAsync( + async () => await Runner(probe, 4, 10, TestContext.Current.CancellationToken).RunAsync(new RecordSource(Source()))); + Assert.Equal(0, probe.Running); + } + + [Fact] + public async Task CancellationStopsBetweenBatches() + { + using var cts = new CancellationTokenSource(); + var probe = new Probe(); + IEnumerable Source() + { + for (int i = 0; i < 1_000; i++) + { + if (i == 35) + { + cts.Cancel(); + } + yield return i; + } + } + + await Assert.ThrowsAnyAsync( + async () => await Runner(probe, 2, 10, cts.Token).RunAsync(new RecordSource(Source()))); + Assert.Equal(0, probe.Running); + Assert.True(probe.Emitted.Count < 100); + } + + [Fact] + public async Task TheRowLimitThrowsBeforeTheOverflowingBatchIsFormatted() + { + var probe = new Probe(); + int start = 1_048_576 - 15; + int[] source = [.. Enumerable.Range(start, 30)]; + + await Assert.ThrowsAsync( + async () => await Runner(probe, 1, 10, TestContext.Current.CancellationToken, rowsBefore: start).RunAsync(new RecordSource(source))); + Assert.Equal(1, probe.Formats); + Assert.DoesNotContain(probe.Emitted, e => e.RowsBefore > start); + } + + [Fact] + public void OptionsRejectValuesOutOfRange() + { + Assert.Throws(() => new ParallelWriteOptions { DegreeOfParallelism = -1 }.Validate()); + Assert.Throws(() => new ParallelWriteOptions { BatchRows = 0 }.Validate()); + ParallelWriteOptions.Default.Validate(); + Assert.Equal(8192, ParallelWriteOptions.Default.BatchRows); + Assert.Equal(0, ParallelWriteOptions.Default.DegreeOfParallelism); + } + } +} From e4b8ecf04ef4506d91c00fba929a1854d3e2878f Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 13:38:30 -0300 Subject: [PATCH 26/42] fix: keep a cancelled drain from leaving its barrier for the next one Co-Authored-By: Claude Opus 5.5 (1M context) --- src/ExcelReader.Core/Writer/Internal/WriteOffloadStream.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ExcelReader.Core/Writer/Internal/WriteOffloadStream.cs b/src/ExcelReader.Core/Writer/Internal/WriteOffloadStream.cs index 034a5fba..690f507e 100644 --- a/src/ExcelReader.Core/Writer/Internal/WriteOffloadStream.cs +++ b/src/ExcelReader.Core/Writer/Internal/WriteOffloadStream.cs @@ -196,7 +196,7 @@ internal async ValueTask DrainAsync(CancellationToken cancellationToken = defaul ThrowIfFaulted(); throw; } - await barrier.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + await barrier.Task.ConfigureAwait(false); ThrowIfFaulted(); } From 3acd81e5240ba17f29e0548c115a4643780fcbeb Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 13:38:30 -0300 Subject: [PATCH 27/42] feat: write XLSX records in parallel batches Co-Authored-By: Claude Opus 5.5 (1M context) --- .../PublicAPI/PublicAPI.Unshipped.txt | 7 + .../Writer/Internal/SharedStringTable.cs | 6 + .../Writer/Parallel/IBatchSheet.cs | 38 ++ .../Writer/Parallel/ParallelRecordWriter.cs | 118 ++++++ .../Writer/Parallel/WorkerStringCache.cs | 74 ++++ .../Writer/ParallelSheetWriterExtensions.cs | 115 ++++++ .../Writer/ParallelWriteOptions.cs | 2 +- .../Writer/SheetWriterExtensions.cs | 4 +- .../Writer/Xlsx/XlsxSheetWriter.cs | 125 ++++++- .../Writer/Xlsx/XlsxWorkbookWriter.cs | 10 + .../Parallel/ParallelRecordWriteTests.cs | 349 ++++++++++++++++++ 11 files changed, 836 insertions(+), 12 deletions(-) create mode 100644 src/ExcelReader.Core/Writer/Parallel/IBatchSheet.cs create mode 100644 src/ExcelReader.Core/Writer/Parallel/ParallelRecordWriter.cs create mode 100644 src/ExcelReader.Core/Writer/Parallel/WorkerStringCache.cs create mode 100644 src/ExcelReader.Core/Writer/ParallelSheetWriterExtensions.cs create mode 100644 tests/ExcelReader.Tests/Writer/Parallel/ParallelRecordWriteTests.cs diff --git a/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt b/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt index 15aed17e..7899015a 100644 --- a/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt @@ -63,6 +63,13 @@ ExcelReader.Core.Writer.ParallelWriteOptions.DegreeOfParallelism.get -> int ExcelReader.Core.Writer.ParallelWriteOptions.DegreeOfParallelism.init -> void ExcelReader.Core.Writer.ParallelWriteOptions.ParallelWriteOptions() -> void static ExcelReader.Core.Writer.ParallelWriteOptions.Default.get -> ExcelReader.Core.Writer.ParallelWriteOptions! +ExcelReader.Core.Writer.ParallelSheetWriterExtensions +static ExcelReader.Core.Writer.ParallelSheetWriterExtensions.WriteRecordsParallelAsync(this ExcelReader.Core.Writer.Xlsx.XlsxSheetWriter! sheet, System.Collections.Generic.IAsyncEnumerable! records, ExcelReader.Core.Writer.ExcelRecordLayout! layout, ExcelReader.Core.Writer.ParallelWriteOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Core.Writer.ParallelSheetWriterExtensions.WriteRecordsParallelAsync(this ExcelReader.Core.Writer.Xlsx.XlsxSheetWriter! sheet, System.Collections.Generic.IAsyncEnumerable! records, System.Action! writeRow, ExcelReader.Core.Writer.ParallelWriteOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Core.Writer.ParallelSheetWriterExtensions.WriteRecordsParallelAsync(this ExcelReader.Core.Writer.Xlsx.XlsxSheetWriter! sheet, System.Collections.Generic.IEnumerable! records, ExcelReader.Core.Writer.ExcelRecordLayout! layout, ExcelReader.Core.Writer.ParallelWriteOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Core.Writer.ParallelSheetWriterExtensions.WriteRecordsParallelAsync(this ExcelReader.Core.Writer.Xlsx.XlsxSheetWriter! sheet, System.Collections.Generic.IEnumerable! records, System.Action! writeRow, ExcelReader.Core.Writer.ParallelWriteOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Core.Writer.ParallelSheetWriterExtensions.WriteTableParallelAsync(this ExcelReader.Core.Writer.Xlsx.XlsxSheetWriter! sheet, System.Collections.Generic.IAsyncEnumerable! records, string! name, ExcelReader.Core.Writer.ExcelRecordLayout! layout, ExcelReader.Core.Writer.ExcelTableOptions? tableOptions = null, ExcelReader.Core.Writer.ParallelWriteOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Core.Writer.ParallelSheetWriterExtensions.WriteTableParallelAsync(this ExcelReader.Core.Writer.Xlsx.XlsxSheetWriter! sheet, System.Collections.Generic.IEnumerable! records, string! name, ExcelReader.Core.Writer.ExcelRecordLayout! layout, ExcelReader.Core.Writer.ExcelTableOptions? tableOptions = null, ExcelReader.Core.Writer.ParallelWriteOptions? 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 diff --git a/src/ExcelReader.Core/Writer/Internal/SharedStringTable.cs b/src/ExcelReader.Core/Writer/Internal/SharedStringTable.cs index 2ea79cb6..f3b9c53e 100644 --- a/src/ExcelReader.Core/Writer/Internal/SharedStringTable.cs +++ b/src/ExcelReader.Core/Writer/Internal/SharedStringTable.cs @@ -48,6 +48,12 @@ internal int GetOrAdd(ReadOnlySpan value) return index; } + /// Counts references that were resolved without calling . + internal void AddReferences(int count) + { + Count += count; + } + internal BiffBuffer ToXlsxBytes() { BiffBuffer xml = new(Math.Max(256, _values.Count * 32)); diff --git a/src/ExcelReader.Core/Writer/Parallel/IBatchSheet.cs b/src/ExcelReader.Core/Writer/Parallel/IBatchSheet.cs new file mode 100644 index 00000000..1f6c1cc7 --- /dev/null +++ b/src/ExcelReader.Core/Writer/Parallel/IBatchSheet.cs @@ -0,0 +1,38 @@ +using System.IO.Compression; + +namespace ExcelReader.Core.Writer.Parallel +{ + /// + /// What a sheet writer offers the parallel record path. The first five members act on the real + /// sheet, from the calling flow. The rest act on a detached copy, which formats batches into its + /// own buffer on a pool thread and never touches the output. + /// + internal interface IBatchSheet + where TSelf : class, IBatchSheet + where TRow : IRowWriter + { + CompressionLevel BatchCompression { get; } + + /// Starts a parallel stretch and returns how many rows the sheet already holds. + ValueTask BeginSegmentsAsync(CancellationToken ct); + + /// Writes one batch to the sheet's entry; called in batch order. + ValueTask AppendSegmentAsync(BatchSegment segment, CancellationToken ct); + + void EndSegments(); + + /// Faults the sheet after a failed parallel stretch. Never throws. + ValueTask FailSegmentsAsync(); + + TSelf CreateDetached(); + + void BeginBatch(int rowsBefore); + + TRow StartBatchRow(); + + /// Ends the batch, records its used range in and returns its bytes. + ReadOnlySpan EndBatch(BatchSegment segment); + + void ReleaseDetached(); + } +} diff --git a/src/ExcelReader.Core/Writer/Parallel/ParallelRecordWriter.cs b/src/ExcelReader.Core/Writer/Parallel/ParallelRecordWriter.cs new file mode 100644 index 00000000..309a7cd9 --- /dev/null +++ b/src/ExcelReader.Core/Writer/Parallel/ParallelRecordWriter.cs @@ -0,0 +1,118 @@ +using System.Collections.Concurrent; + +namespace ExcelReader.Core.Writer.Parallel +{ + /// Drives a parallel record write for any sheet writer that implements . + internal static class ParallelRecordWriter + { + internal static async ValueTask WriteRecordsAsync(TSheet sheet, RecordSource records, + ExcelRecordLayout layout, ParallelWriteOptions? options, CancellationToken ct) + where TSheet : class, ISheetWriter, IBatchSheet + where TRow : IRowWriter + { + ParallelWriteOptions effective = Resolve(options); + (string[] headers, Action writeRow) = layout.Columns(); + await SheetWriterExtensions.WriteHeaderAsync(sheet, headers, ct).ConfigureAwait(false); + await RunAsync(sheet, records, writeRow, effective, ct).ConfigureAwait(false); + } + + internal static ValueTask WriteRowsAsync(TSheet sheet, RecordSource records, + Action writeRow, ParallelWriteOptions? options, CancellationToken ct) + where TSheet : class, IBatchSheet + where TRow : IRowWriter + { + return RunAsync(sheet, records, writeRow, Resolve(options), ct); + } + + internal static async ValueTask WriteTableAsync(TSheet sheet, RecordSource records, string name, + ExcelRecordLayout layout, ExcelTableOptions? tableOptions, ParallelWriteOptions? options, CancellationToken ct) + where TSheet : class, ITableSheetWriter, IBatchSheet + where TRow : IRowWriter + { + ParallelWriteOptions effective = Resolve(options); + (string[] headers, Action writeRow) = layout.Columns(); + await sheet.BeginTableAsync(name, headers, tableOptions, ct).ConfigureAwait(false); + await RunAsync(sheet, records, SheetWriterExtensions.ShiftToTable(writeRow, tableOptions), effective, ct).ConfigureAwait(false); + sheet.EndTable(); + } + + private static ParallelWriteOptions Resolve(ParallelWriteOptions? options) + { + ParallelWriteOptions effective = options ?? ParallelWriteOptions.Default; + effective.Validate(); + return effective; + } + + private static async ValueTask RunAsync(TSheet sheet, RecordSource records, + Action writeRow, ParallelWriteOptions options, CancellationToken ct) + where TSheet : class, IBatchSheet + where TRow : IRowWriter + { + int rowsBefore = await sheet.BeginSegmentsAsync(ct).ConfigureAwait(false); + var workers = new ConcurrentBag(); + var runner = new BatchRunner(options, rowsBefore, + (batch, count, before) => FormatBatch(sheet, workers, writeRow, batch, count, before), + sheet.AppendSegmentAsync, ct); + try + { + await runner.RunAsync(records).ConfigureAwait(false); + } + catch + { + await sheet.FailSegmentsAsync().ConfigureAwait(false); + throw; + } + finally + { + foreach (TSheet worker in workers) + { + worker.ReleaseDetached(); + } + } + sheet.EndSegments(); + } + + private static BatchSegment FormatBatch(TSheet sheet, ConcurrentBag workers, + Action writeRow, T[] batch, int count, int rowsBefore) + where TSheet : class, IBatchSheet + where TRow : IRowWriter + { + if (!workers.TryTake(out TSheet? worker)) + { + worker = sheet.CreateDetached(); + } + try + { + worker.BeginBatch(rowsBefore); + for (int i = 0; i < count; i++) + { + TRow row = worker.StartBatchRow(); + writeRow(row, batch[i]); + row.Dispose(); + } + return Compress(sheet, worker, count); + } + finally + { + workers.Add(worker); + } + } + + private static BatchSegment Compress(TSheet sheet, TSheet worker, int rows) + where TSheet : class, IBatchSheet + where TRow : IRowWriter + { + var segment = new BatchSegment(rows); + try + { + segment.Fill(worker.EndBatch(segment), sheet.BatchCompression); + return segment; + } + catch + { + segment.Dispose(); + throw; + } + } + } +} diff --git a/src/ExcelReader.Core/Writer/Parallel/WorkerStringCache.cs b/src/ExcelReader.Core/Writer/Parallel/WorkerStringCache.cs new file mode 100644 index 00000000..2218aedb --- /dev/null +++ b/src/ExcelReader.Core/Writer/Parallel/WorkerStringCache.cs @@ -0,0 +1,74 @@ +using ExcelReader.Core.Writer.Internal; + +namespace ExcelReader.Core.Writer.Parallel +{ + /// + /// One worker's view of the workbook's shared-string table: texts it has already resolved are + /// answered locally, and only a text new to this worker takes the table's lock. + /// + internal sealed class WorkerStringCache + { + private const int MaxEntries = 65_536; + + private readonly SharedStringTable _table; + private readonly Lock _gate; + private readonly Dictionary _indexes = new(StringComparer.Ordinal); + private readonly Dictionary.AlternateLookup> _spanIndexes; + private int _localHits; + + internal WorkerStringCache(SharedStringTable table, Lock gate) + { + _table = table; + _gate = gate; + _spanIndexes = _indexes.GetAlternateLookup>(); + } + + internal int GetOrAdd(string value) + { + if (_indexes.TryGetValue(value, out int index)) + { + _localHits++; + return index; + } + return Resolve(value); + } + + internal int GetOrAdd(ReadOnlySpan value) + { + if (_spanIndexes.TryGetValue(value, out int index)) + { + _localHits++; + return index; + } + return Resolve(value.ToString()); + } + + // The table counts every reference; ones answered locally are reported in bulk. + internal void FlushReferences() + { + if (_localHits == 0) + { + return; + } + lock (_gate) + { + _table.AddReferences(_localHits); + } + _localHits = 0; + } + + private int Resolve(string value) + { + int index; + lock (_gate) + { + index = _table.GetOrAdd(value); + } + if (_indexes.Count < MaxEntries) + { + _indexes.Add(value, index); + } + return index; + } + } +} diff --git a/src/ExcelReader.Core/Writer/ParallelSheetWriterExtensions.cs b/src/ExcelReader.Core/Writer/ParallelSheetWriterExtensions.cs new file mode 100644 index 00000000..d48ed26f --- /dev/null +++ b/src/ExcelReader.Core/Writer/ParallelSheetWriterExtensions.cs @@ -0,0 +1,115 @@ +using ExcelReader.Core.Writer.Parallel; +using ExcelReader.Core.Writer.Xlsx; + +namespace ExcelReader.Core.Writer +{ + /// + /// Record writes that format and compress batches of rows on several threads and write them in + /// order. Memory is bounded by batches of + /// rows. + /// + /// + /// The source is enumerated on the calling thread only. A writeRow delegate runs on pool + /// threads, several at once, so whatever it captures must be safe to use concurrently; the layouts + /// from are. Rows may be written through the normal API before + /// and after a call, but not while one is running. With shared strings on, string indexes depend + /// on thread timing, so two runs give the same content in files that differ byte for byte. + /// + public static class ParallelSheetWriterExtensions + { + /// Writes a header row from , then one row per record, in parallel batches. + /// The record type being written. + /// The sheet to write rows to. + /// The records to write, one row each, in enumeration order. + /// The columns to write, from . + /// Degree of parallelism and batch size. Defaults to . + /// A token to cancel the operation between batches. + /// , or is . + /// holds a negative degree or a batch size below 1. + /// The sheet has already been ended. + /// A row is still active on the sheet. + /// The records do not fit in the sheet's 1,048,576 rows. + public static ValueTask WriteRecordsParallelAsync(this XlsxSheetWriter sheet, IEnumerable records, + ExcelRecordLayout layout, ParallelWriteOptions? options = null, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(sheet); + ArgumentNullException.ThrowIfNull(records); + ArgumentNullException.ThrowIfNull(layout); + return ParallelRecordWriter.WriteRecordsAsync(sheet, new RecordSource(records), layout, options, ct); + } + + /// + public static ValueTask WriteRecordsParallelAsync(this XlsxSheetWriter sheet, IAsyncEnumerable records, + ExcelRecordLayout layout, ParallelWriteOptions? options = null, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(sheet); + ArgumentNullException.ThrowIfNull(records); + ArgumentNullException.ThrowIfNull(layout); + return ParallelRecordWriter.WriteRecordsAsync(sheet, new RecordSource(records), layout, options, ct); + } + + /// Writes one row per record in parallel batches, calling to populate each row. No header is written. + /// The record type being written. + /// The sheet to write rows to. + /// The records to write, one row each, in enumeration order. + /// Populates a row's cells from a single record. It runs on pool threads, several at once. + /// Degree of parallelism and batch size. Defaults to . + /// A token to cancel the operation between batches. + /// , or is . + /// holds a negative degree or a batch size below 1. + /// The sheet has already been ended. + /// A row is still active on the sheet. + /// The records do not fit in the sheet's 1,048,576 rows. + public static ValueTask WriteRecordsParallelAsync(this XlsxSheetWriter sheet, IEnumerable records, + Action writeRow, ParallelWriteOptions? options = null, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(sheet); + ArgumentNullException.ThrowIfNull(records); + ArgumentNullException.ThrowIfNull(writeRow); + return ParallelRecordWriter.WriteRowsAsync(sheet, new RecordSource(records), writeRow, options, ct); + } + + /// + public static ValueTask WriteRecordsParallelAsync(this XlsxSheetWriter sheet, IAsyncEnumerable records, + Action writeRow, ParallelWriteOptions? options = null, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(sheet); + ArgumentNullException.ThrowIfNull(records); + ArgumentNullException.ThrowIfNull(writeRow); + return ParallelRecordWriter.WriteRowsAsync(sheet, new RecordSource(records), writeRow, options, ct); + } + + /// + /// Writes as an Excel table named : a header row from + /// , one row per record in parallel batches, then closes the table. + /// + /// The record type being written. + /// The sheet to write to. + /// The records to write, one row each, in enumeration order. + /// The table name; see . + /// The columns to write, from . + /// Position and style. Defaults to . + /// Degree of parallelism and batch size. Defaults to . + /// A token to cancel the operation between batches. + /// , , or is . + /// holds a negative degree or a batch size below 1. + public static ValueTask WriteTableParallelAsync(this XlsxSheetWriter sheet, IEnumerable records, string name, + ExcelRecordLayout layout, ExcelTableOptions? tableOptions = null, ParallelWriteOptions? options = null, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(sheet); + ArgumentNullException.ThrowIfNull(records); + ArgumentNullException.ThrowIfNull(layout); + return ParallelRecordWriter.WriteTableAsync(sheet, new RecordSource(records), name, layout, tableOptions, options, ct); + } + + /// + public static ValueTask WriteTableParallelAsync(this XlsxSheetWriter sheet, IAsyncEnumerable records, string name, + ExcelRecordLayout layout, ExcelTableOptions? tableOptions = null, ParallelWriteOptions? options = null, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(sheet); + ArgumentNullException.ThrowIfNull(records); + ArgumentNullException.ThrowIfNull(layout); + return ParallelRecordWriter.WriteTableAsync(sheet, new RecordSource(records), name, layout, tableOptions, options, ct); + } + } +} diff --git a/src/ExcelReader.Core/Writer/ParallelWriteOptions.cs b/src/ExcelReader.Core/Writer/ParallelWriteOptions.cs index 761af9c7..83d51fc0 100644 --- a/src/ExcelReader.Core/Writer/ParallelWriteOptions.cs +++ b/src/ExcelReader.Core/Writer/ParallelWriteOptions.cs @@ -2,7 +2,7 @@ namespace ExcelReader.Core.Writer { - /// Configures parallel record writes. + /// Configures the parallel record writes of . public sealed class ParallelWriteOptions { /// Gets the default options: one batch in flight per processor, 8,192 rows per batch. diff --git a/src/ExcelReader.Core/Writer/SheetWriterExtensions.cs b/src/ExcelReader.Core/Writer/SheetWriterExtensions.cs index f312fed4..939a1bc2 100644 --- a/src/ExcelReader.Core/Writer/SheetWriterExtensions.cs +++ b/src/ExcelReader.Core/Writer/SheetWriterExtensions.cs @@ -112,7 +112,7 @@ public static async ValueTask WriteTableAsync(this ITableSheetWriter ShiftToTable(Action writeRow, ExcelTableOptions? options) + internal static Action ShiftToTable(Action writeRow, ExcelTableOptions? options) where TRow : IRowWriter { int firstColumn = (options ?? ExcelTableOptions.Default).FirstColumn; @@ -127,7 +127,7 @@ private static Action ShiftToTable(Action writeRow, E }; } - private static async ValueTask WriteHeaderAsync(ISheetWriter sheet, string[] headers, CancellationToken ct) + internal static async ValueTask WriteHeaderAsync(ISheetWriter sheet, string[] headers, CancellationToken ct) where TRow : IRowWriter { TRow row = await sheet.StartRowAsync(ct).ConfigureAwait(false); diff --git a/src/ExcelReader.Core/Writer/Xlsx/XlsxSheetWriter.cs b/src/ExcelReader.Core/Writer/Xlsx/XlsxSheetWriter.cs index d93ddd0e..32f402fa 100644 --- a/src/ExcelReader.Core/Writer/Xlsx/XlsxSheetWriter.cs +++ b/src/ExcelReader.Core/Writer/Xlsx/XlsxSheetWriter.cs @@ -1,10 +1,12 @@ using System.Buffers.Text; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO.Compression; using System.Text; using ExcelReader.Core.Reader; using ExcelReader.Core.Reader.Internal; using ExcelReader.Core.Writer.Internal; +using ExcelReader.Core.Writer.Parallel; using ExcelReader.Core.Writer.Zip; namespace ExcelReader.Core.Writer.Xlsx @@ -12,7 +14,7 @@ namespace ExcelReader.Core.Writer.Xlsx /// /// Writes a single worksheet's XML into an XLSX ZIP archive, buffering rows and flushing them to the entry stream once they cross a size threshold. /// - public sealed class XlsxSheetWriter : ISheetWriter, ITableSheetWriter + public sealed class XlsxSheetWriter : ISheetWriter, ITableSheetWriter, IBatchSheet { private readonly TableTracker _tables = new(); private readonly XlsxWorkbookWriter _owner; @@ -22,6 +24,10 @@ public sealed class XlsxSheetWriter : ISheetWriter, ITableSheetWr private readonly BiffBuffer _rowBuffer = new(512); // ponytail: flush to the deflate stream once buffered rows pass 64 KB — bounds memory on huge private const int FlushThreshold = 64 * 1024; + private readonly int _flushThreshold = FlushThreshold; + private readonly WorkerStringCache? _workerStrings; + [SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", Justification = "An alias of the entry that _stream is or wraps; disposing _stream disposes it.")] + private ZipEntryStream? _entry; private XlsxRowWriter? _rowWriter; private Stream? _stream; private int _rowNumber; @@ -42,6 +48,25 @@ internal XlsxSheetWriter(XlsxWorkbookWriter owner, ZipPackageWriter zip, string _offloadWrite = offloadWrite; } + // A detached copy formats batches for the parallel record path: it has its own row buffer, + // never flushes it and never touches the ZIP. + private XlsxSheetWriter(XlsxSheetWriter source) + { + _owner = source._owner; + _zip = source._zip; + Name = source.Name; + SheetId = source.SheetId; + Visibility = source.Visibility; + _compression = source._compression; + _columnStyles = source._columnStyles; + _flushThreshold = int.MaxValue; + _state = WriterState.Started; + if (_owner.UseSharedStrings) + { + _workerStrings = new WorkerStringCache(_owner.SharedStrings!, _owner.SharedStringsGate); + } + } + internal string Name { get; } internal int SheetId { get; } internal ExcelSheetVisibility Visibility { get; } @@ -52,12 +77,12 @@ internal XlsxSheetWriter(XlsxWorkbookWriter owner, ZipPackageWriter zip, string internal int GetSharedStringIndex(string value) { - return _owner.GetSharedStringIndex(value); + return _workerStrings is null ? _owner.GetSharedStringIndex(value) : _workerStrings.GetOrAdd(value); } internal int GetSharedStringIndex(ReadOnlySpan value) { - return _owner.GetSharedStringIndex(value); + return _workerStrings is null ? _owner.GetSharedStringIndex(value) : _workerStrings.GetOrAdd(value); } internal int GetColumnStyle(int columnIndex) @@ -89,8 +114,9 @@ private void EnsureStarted() } try { - Stream stream = _zip.OpenEntry($"xl/worksheets/sheet{SheetId}.xml", _compression); - _stream = _offloadWrite ? new WriteOffloadStream(stream) : stream; + ZipEntryStream entry = _zip.OpenEntry($"xl/worksheets/sheet{SheetId}.xml", _compression); + _entry = entry; + _stream = _offloadWrite ? new WriteOffloadStream(entry) : entry; _rowBuffer.Reset(); _rowBuffer.WriteUtf8( "" + @@ -117,8 +143,9 @@ private async ValueTask StartCoreAsync(CancellationToken ct) { try { - Stream stream = _zip.OpenEntry($"xl/worksheets/sheet{SheetId}.xml", _compression); - _stream = _offloadWrite ? new WriteOffloadStream(stream) : stream; + ZipEntryStream entry = _zip.OpenEntry($"xl/worksheets/sheet{SheetId}.xml", _compression); + _entry = entry; + _stream = _offloadWrite ? new WriteOffloadStream(entry) : entry; _rowBuffer.Reset(); _rowBuffer.WriteUtf8( "" + @@ -358,6 +385,7 @@ private void Release(bool faulted) { _state = WriterState.Ended; _stream = null; + _entry = null; _rowBuffer.Dispose(); _owner.NotifySheetEnded(faulted); } @@ -445,7 +473,7 @@ private void CloseBufferedRow() internal void EndBufferedRow() { CloseBufferedRow(); - if (_rowBuffer.Length >= FlushThreshold) + if (_rowBuffer.Length >= _flushThreshold) { FlushRowBuffer(); } @@ -474,7 +502,7 @@ private void FlushRowBuffer() internal ValueTask EndBufferedRowAsync(CancellationToken ct = default) { CloseBufferedRow(); - return _rowBuffer.Length >= FlushThreshold ? FlushRowBufferAsync(ct) : ValueTask.CompletedTask; + return _rowBuffer.Length >= _flushThreshold ? FlushRowBufferAsync(ct) : ValueTask.CompletedTask; } private async ValueTask FlushRowBufferAsync(CancellationToken ct) @@ -498,5 +526,84 @@ private async ValueTask FlushRowBufferAsync(CancellationToken ct) throw; } } + + CompressionLevel IBatchSheet.BatchCompression + { + get + { + return _compression; + } + } + + async ValueTask IBatchSheet.BeginSegmentsAsync(CancellationToken ct) + { + WriterStateGuard.ThrowIfEnded(_state, this); + WriterStateGuard.RequireNoActiveRowForStart(_rowActive, nameof(XlsxRowWriter)); + await EnsureStartedAsync(ct).ConfigureAwait(false); + if (_rowBuffer.Length > 0) + { + await FlushRowBufferAsync(ct).ConfigureAwait(false); + } + if (_stream is WriteOffloadStream offload) + { + try + { + await offload.DrainAsync(ct).ConfigureAwait(false); + } + catch + { + await FaultAsync().ConfigureAwait(false); + throw; + } + } + // Held for the whole stretch, so StartRow and End are refused until it is over. + _rowActive = true; + return _rowNumber; + } + + async ValueTask IBatchSheet.AppendSegmentAsync(BatchSegment segment, CancellationToken ct) + { + await _entry!.AppendSegmentAsync(segment.Data.Memory, segment.Crc, segment.RawLength, ct).ConfigureAwait(false); + _rowNumber += segment.Rows; + } + + void IBatchSheet.EndSegments() + { + _rowActive = false; + } + + async ValueTask IBatchSheet.FailSegmentsAsync() + { + _rowActive = false; + await FaultAsync().ConfigureAwait(false); + } + + XlsxSheetWriter IBatchSheet.CreateDetached() + { + return new XlsxSheetWriter(this); + } + + void IBatchSheet.BeginBatch(int rowsBefore) + { + _rowBuffer.Reset(); + _rowNumber = rowsBefore; + _rowActive = false; + } + + XlsxRowWriter IBatchSheet.StartBatchRow() + { + return StartRow(); + } + + ReadOnlySpan IBatchSheet.EndBatch(BatchSegment segment) + { + _workerStrings?.FlushReferences(); + return _rowBuffer.Span; + } + + void IBatchSheet.ReleaseDetached() + { + _rowBuffer.Dispose(); + } } } diff --git a/src/ExcelReader.Core/Writer/Xlsx/XlsxWorkbookWriter.cs b/src/ExcelReader.Core/Writer/Xlsx/XlsxWorkbookWriter.cs index fadfdf87..2969f907 100644 --- a/src/ExcelReader.Core/Writer/Xlsx/XlsxWorkbookWriter.cs +++ b/src/ExcelReader.Core/Writer/Xlsx/XlsxWorkbookWriter.cs @@ -146,6 +146,16 @@ internal int GetSharedStringIndex(ReadOnlySpan value) return _sharedStrings!.GetOrAdd(value); } + internal SharedStringTable? SharedStrings + { + get + { + return _sharedStrings; + } + } + + internal Lock SharedStringsGate { get; } = new(); + /// public int AddStyle(CellStyle style) { diff --git a/tests/ExcelReader.Tests/Writer/Parallel/ParallelRecordWriteTests.cs b/tests/ExcelReader.Tests/Writer/Parallel/ParallelRecordWriteTests.cs new file mode 100644 index 00000000..67a557bd --- /dev/null +++ b/tests/ExcelReader.Tests/Writer/Parallel/ParallelRecordWriteTests.cs @@ -0,0 +1,349 @@ +using System.Globalization; +using System.IO.Compression; +using ExcelReader.Core.Reader; +using ExcelReader.Core.Reader.Xlsx; +using ExcelReader.Core.Writer; +using ExcelReader.Core.Writer.Xlsx; +using ExcelReader.Tests.Writer.Zip; + +namespace ExcelReader.Tests.Writer.Parallel +{ + public class ParallelRecordWriteTests + { + internal sealed record Item(int Id, string Name, double Value, DateTime Day); + + internal const int Columns = 4; + + internal static List Items(int count, int distinctNames = 37) + { + return [.. Enumerable.Range(0, count).Select(i => new Item( + i, string.Create(CultureInfo.InvariantCulture, $"name {i % distinctNames}"), i * 1.5, new DateTime(2024, 1, 1).AddDays(i % 400)))]; + } + + internal static void Fill(TRow row, Item item) + where TRow : IRowWriter + { + row.Write(item.Id); + row.Write(item.Name); + row.Write(item.Value); + row.Write(item.Day); + } + + internal static List Cells(IExcelWorkbook workbook, int sheet = 0) + { + var rows = new List(); + using IExcelRowEnumerator enumerator = workbook.SheetAt(sheet).GetEnumerator(); + while (enumerator.MoveNext()) + { + string[] cells = new string[Columns]; + for (int c = 0; c < Columns; c++) + { + cells[c] = enumerator.Current[c].GetString(); + } + rows.Add(cells); + } + return rows; + } + + internal static byte[] Entry(byte[] package, string name) + { + using var zip = new ZipArchive(new MemoryStream(package), ZipArchiveMode.Read); + ZipArchiveEntry entry = zip.GetEntry(name)!; + using Stream data = entry.Open(); + using var copy = new MemoryStream(); + data.CopyTo(copy); + Assert.Equal(Crc32Tests.BitwiseCrc(copy.ToArray()), entry.Crc32); + return copy.ToArray(); + } + + public static TheoryData Modes() + { + var data = new TheoryData(); + foreach (CompressionLevel level in Enum.GetValues()) + { + foreach (bool prefetch in new[] { false, true }) + { + data.Add(level, prefetch, false); + data.Add(level, prefetch, true); + } + } + return data; + } + + private static async Task WriteAsync(XlsxWriterOptions options, Func body, bool seekable = true) + { + var ms = new MemoryStream(); + Stream target = seekable ? ms : new ForwardOnlyWriteStream(ms); + await using (XlsxWorkbookWriter wb = XlsxWorkbookWriter.Create(target, leaveOpen: true, options)) + { + XlsxSheetWriter sheet = wb.AddSheet("Data"); + await body(sheet); + await sheet.EndAsync(TestContext.Current.CancellationToken); + await wb.EndAsync(TestContext.Current.CancellationToken); + } + return ms.ToArray(); + } + + private static ParallelWriteOptions Parallel(int degree, int batchRows = 500) + { + return new ParallelWriteOptions { DegreeOfParallelism = degree, BatchRows = batchRows }; + } + + private static List Read(byte[] package) + { + using XlsxWorkbook workbook = Excel.FromXlsx(new MemoryStream(package)); + return Cells(workbook); + } + + [Theory] + [MemberData(nameof(Modes))] + public async Task ParallelGivesTheSameCellsAsSerial(CompressionLevel level, bool prefetch, bool sharedStrings) + { + CancellationToken ct = TestContext.Current.CancellationToken; + List items = Items(6_300); + var options = new XlsxWriterOptions { Compression = level, PrefetchWrite = prefetch, UseSharedStrings = sharedStrings }; + byte[] serial = await WriteAsync(options, async sheet => + await sheet.WriteRecordsAsync(items, (Action)Fill, ct)); + foreach (bool seekable in new[] { true, false }) + { + byte[] parallel = await WriteAsync(options, async sheet => + await sheet.WriteRecordsParallelAsync(items, Fill, Parallel(4), ct), seekable); + + Assert.Equal(Read(serial), Read(parallel)); + Entry(parallel, "xl/worksheets/sheet1.xml"); + } + } + + [Theory] + [InlineData(CompressionLevel.Fastest)] + [InlineData(CompressionLevel.Optimal)] + [InlineData(CompressionLevel.NoCompression)] + public async Task OneWorkerWritesTheSameSheetBytesAsSerial(CompressionLevel level) + { + CancellationToken ct = TestContext.Current.CancellationToken; + List items = Items(2_750); + var options = new XlsxWriterOptions { Compression = level }; + byte[] serial = await WriteAsync(options, async sheet => + await sheet.WriteRecordsAsync(items, (Action)Fill, ct)); + byte[] parallel = await WriteAsync(options, async sheet => + await sheet.WriteRecordsParallelAsync(items, Fill, Parallel(1), ct)); + + Assert.Equal(Entry(serial, "xl/worksheets/sheet1.xml"), Entry(parallel, "xl/worksheets/sheet1.xml")); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(500)] + [InlineData(501)] + [InlineData(1_733)] + public async Task BatchEdges(int count) + { + CancellationToken ct = TestContext.Current.CancellationToken; + List items = Items(count); + byte[] package = await WriteAsync(XlsxWriterOptions.Default, async sheet => + await sheet.WriteRecordsParallelAsync(items, Fill, Parallel(3), ct)); + + List rows = Read(package); + Assert.Equal(count, rows.Count); + for (int i = 0; i < count; i++) + { + Assert.Equal(i.ToString(CultureInfo.InvariantCulture), rows[i][0]); + Assert.Equal(items[i].Name, rows[i][1]); + } + } + + [Fact] + public async Task ParallelAsTheFirstWriteOnTheSheet() + { + CancellationToken ct = TestContext.Current.CancellationToken; + List items = Items(1_200); + byte[] package = await WriteAsync(XlsxWriterOptions.Default with { PrefetchWrite = true }, async sheet => + await sheet.WriteRecordsParallelAsync(items, Fill, Parallel(4), ct)); + + Assert.Equal(1_200, Read(package).Count); + } + + [Theory] + [InlineData(CompressionLevel.Fastest)] + [InlineData(CompressionLevel.Optimal)] + [InlineData(CompressionLevel.SmallestSize)] + [InlineData(CompressionLevel.NoCompression)] + public async Task SerialRowsBeforeAndAfterSurvive(CompressionLevel level) + { + CancellationToken ct = TestContext.Current.CancellationToken; + List items = Items(1_300); + byte[] package = await WriteAsync(new XlsxWriterOptions { Compression = level }, async sheet => + { + await sheet.WriteRecordsAsync(items, (Action)Fill, ct); + await sheet.WriteRecordsParallelAsync(items, Fill, Parallel(4), ct); + await sheet.WriteRecordsAsync(items, (Action)Fill, ct); + await sheet.WriteRecordsParallelAsync(items, Fill, Parallel(2), ct); + using XlsxRowWriter last = sheet.StartRow(); + last.Write("end"); + }); + + List rows = Read(package); + Assert.Equal((4 * 1_300) + 1, rows.Count); + for (int block = 0; block < 4; block++) + { + Assert.Equal("0", rows[block * 1_300][0]); + Assert.Equal("1299", rows[(block * 1_300) + 1_299][0]); + Assert.Equal(items[777].Name, rows[(block * 1_300) + 777][1]); + } + Assert.Equal("end", rows[^1][0]); + } + + [Fact] + public async Task ALayoutWritesItsHeaderFirst() + { + CancellationToken ct = TestContext.Current.CancellationToken; + List items = Items(900); + byte[] serial = await WriteAsync(XlsxWriterOptions.Default, async sheet => + await sheet.WriteRecordsAsync(items, ExcelRecordLayout.FromAttributes(), ct)); + byte[] parallel = await WriteAsync(XlsxWriterOptions.Default, async sheet => + await sheet.WriteRecordsParallelAsync(items, ExcelRecordLayout.FromAttributes(), Parallel(4, 100), ct)); + + Assert.Equal(Read(serial), Read(parallel)); + Assert.Equal(901, Read(parallel).Count); + } + + [Fact] + public async Task AnAsyncSourceWorks() + { + CancellationToken ct = TestContext.Current.CancellationToken; + List items = Items(1_050); + async IAsyncEnumerable Source() + { + foreach (Item item in items) + { + await Task.Yield(); + yield return item; + } + } + byte[] package = await WriteAsync(XlsxWriterOptions.Default, async sheet => + await sheet.WriteRecordsParallelAsync(Source(), Fill, Parallel(4, 100), ct)); + + Assert.Equal(1_050, Read(package).Count); + } + + [Fact] + public async Task AParallelTableCoversItsRows() + { + CancellationToken ct = TestContext.Current.CancellationToken; + List items = Items(820); + byte[] serial = await WriteAsync(XlsxWriterOptions.Default, async sheet => + await sheet.WriteTableAsync(items, "Records", ExcelRecordLayout.FromAttributes(), ct: ct)); + byte[] parallel = await WriteAsync(XlsxWriterOptions.Default, async sheet => + await sheet.WriteTableParallelAsync(items, "Records", ExcelRecordLayout.FromAttributes(), options: Parallel(4, 100), ct: ct)); + + Assert.Equal(Read(serial), Read(parallel)); + Assert.Equal(Entry(serial, "xl/tables/table1.xml"), Entry(parallel, "xl/tables/table1.xml")); + } + + [Fact] + public async Task SharedStringsGiveEveryCellItsOwnText() + { + CancellationToken ct = TestContext.Current.CancellationToken; + List items = Items(4_000, distinctNames: 613); + for (int round = 0; round < 25; round++) + { + byte[] package = await WriteAsync(new XlsxWriterOptions { UseSharedStrings = true }, async sheet => + await sheet.WriteRecordsParallelAsync(items, Fill, Parallel(8, 50), ct)); + + List rows = Read(package); + Assert.Equal(items.Count, rows.Count); + for (int i = 0; i < items.Count; i++) + { + Assert.Equal(items[i].Name, rows[i][1]); + } + string sst = System.Text.Encoding.UTF8.GetString(Entry(package, "xl/sharedStrings.xml")); + Assert.Contains("count=\"4000\" uniqueCount=\"613\"", sst, StringComparison.Ordinal); + } + } + + [Fact] + public async Task AThrowingRowFaultsTheSheetAndStopsTheWorkers() + { + CancellationToken ct = TestContext.Current.CancellationToken; + List items = Items(5_000); + int running = 0; + void Throwing(XlsxRowWriter row, Item item) + { + Interlocked.Increment(ref running); + try + { + row.Write(item.Id); + if (item.Id == 2_345) + { + throw new FormatException("row 2345"); + } + row.Write(item.Name); + } + finally + { + Interlocked.Decrement(ref running); + } + } + var ms = new MemoryStream(); + XlsxWorkbookWriter wb = XlsxWorkbookWriter.Create(ms, leaveOpen: true); + XlsxSheetWriter sheet = wb.AddSheet("Data"); + + FormatException failure = await Assert.ThrowsAsync( + async () => await sheet.WriteRecordsParallelAsync(items, Throwing, Parallel(4, 100), ct)); + + Assert.Equal("row 2345", failure.Message); + Assert.Equal(0, Volatile.Read(ref running)); + Assert.True(sheet.ResourcesReleased); + Assert.Throws(() => sheet.StartRow()); + await wb.DisposeAsync(); + } + + [Fact] + public async Task TheSheetRefusesRowsWhileAParallelWriteRuns() + { + CancellationToken ct = TestContext.Current.CancellationToken; + var ms = new MemoryStream(); + await using XlsxWorkbookWriter wb = XlsxWorkbookWriter.Create(ms, leaveOpen: true); + XlsxSheetWriter sheet = wb.AddSheet("Data"); + InvalidOperationException? seen = null; + IEnumerable Source() + { + foreach (Item item in Items(50)) + { + if (item.Id == 20) + { + seen = Assert.Throws(() => sheet.StartRow()); + } + yield return item; + } + } + + await sheet.WriteRecordsParallelAsync(Source(), Fill, Parallel(2, 10), ct); + + Assert.NotNull(seen); + await sheet.EndAsync(ct); + await wb.EndAsync(ct); + } + + [Fact] + public async Task InvalidArgumentsThrowBeforeAnythingIsWritten() + { + CancellationToken ct = TestContext.Current.CancellationToken; + var ms = new MemoryStream(); + await using XlsxWorkbookWriter wb = XlsxWorkbookWriter.Create(ms, leaveOpen: true); + XlsxSheetWriter sheet = wb.AddSheet("Data"); + List items = Items(3); + + await Assert.ThrowsAsync(async () => + await sheet.WriteRecordsParallelAsync(items, ExcelRecordLayout.FromAttributes(), new ParallelWriteOptions { BatchRows = 0 }, ct)); + await Assert.ThrowsAsync(async () => + await sheet.WriteRecordsParallelAsync((IEnumerable)null!, Fill, ct: ct)); + await sheet.WriteRecordsParallelAsync(items, Fill, ct: ct); + await sheet.EndAsync(ct); + await wb.EndAsync(ct); + + Assert.Equal(3, Read(ms.ToArray()).Count); + } + } +} From 6534120824350183d4331beccf8f78421c3a74ea Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 13:46:08 -0300 Subject: [PATCH 28/42] feat: write XLSB records in parallel batches Co-Authored-By: Claude Opus 5.5 (1M context) --- .../PublicAPI/PublicAPI.Unshipped.txt | 6 + .../Writer/ParallelSheetWriterExtensions.cs | 61 +++++ .../Writer/Xlsb/XlsbSheetWriter.cs | 164 +++++++++++++- .../Writer/Xlsb/XlsbWorkbookWriter.cs | 10 + .../Parallel/ParallelXlsbRecordWriteTests.cs | 208 ++++++++++++++++++ 5 files changed, 444 insertions(+), 5 deletions(-) create mode 100644 tests/ExcelReader.Tests/Writer/Parallel/ParallelXlsbRecordWriteTests.cs diff --git a/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt b/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt index 7899015a..de7a9974 100644 --- a/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt @@ -70,6 +70,12 @@ static ExcelReader.Core.Writer.ParallelSheetWriterExtensions.WriteRecordsParalle static ExcelReader.Core.Writer.ParallelSheetWriterExtensions.WriteRecordsParallelAsync(this ExcelReader.Core.Writer.Xlsx.XlsxSheetWriter! sheet, System.Collections.Generic.IEnumerable! records, System.Action! writeRow, ExcelReader.Core.Writer.ParallelWriteOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static ExcelReader.Core.Writer.ParallelSheetWriterExtensions.WriteTableParallelAsync(this ExcelReader.Core.Writer.Xlsx.XlsxSheetWriter! sheet, System.Collections.Generic.IAsyncEnumerable! records, string! name, ExcelReader.Core.Writer.ExcelRecordLayout! layout, ExcelReader.Core.Writer.ExcelTableOptions? tableOptions = null, ExcelReader.Core.Writer.ParallelWriteOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static ExcelReader.Core.Writer.ParallelSheetWriterExtensions.WriteTableParallelAsync(this ExcelReader.Core.Writer.Xlsx.XlsxSheetWriter! sheet, System.Collections.Generic.IEnumerable! records, string! name, ExcelReader.Core.Writer.ExcelRecordLayout! layout, ExcelReader.Core.Writer.ExcelTableOptions? tableOptions = null, ExcelReader.Core.Writer.ParallelWriteOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Core.Writer.ParallelSheetWriterExtensions.WriteRecordsParallelAsync(this ExcelReader.Core.Writer.Xlsb.XlsbSheetWriter! sheet, System.Collections.Generic.IAsyncEnumerable! records, ExcelReader.Core.Writer.ExcelRecordLayout! layout, ExcelReader.Core.Writer.ParallelWriteOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Core.Writer.ParallelSheetWriterExtensions.WriteRecordsParallelAsync(this ExcelReader.Core.Writer.Xlsb.XlsbSheetWriter! sheet, System.Collections.Generic.IAsyncEnumerable! records, System.Action! writeRow, ExcelReader.Core.Writer.ParallelWriteOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Core.Writer.ParallelSheetWriterExtensions.WriteRecordsParallelAsync(this ExcelReader.Core.Writer.Xlsb.XlsbSheetWriter! sheet, System.Collections.Generic.IEnumerable! records, ExcelReader.Core.Writer.ExcelRecordLayout! layout, ExcelReader.Core.Writer.ParallelWriteOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Core.Writer.ParallelSheetWriterExtensions.WriteRecordsParallelAsync(this ExcelReader.Core.Writer.Xlsb.XlsbSheetWriter! sheet, System.Collections.Generic.IEnumerable! records, System.Action! writeRow, ExcelReader.Core.Writer.ParallelWriteOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Core.Writer.ParallelSheetWriterExtensions.WriteTableParallelAsync(this ExcelReader.Core.Writer.Xlsb.XlsbSheetWriter! sheet, System.Collections.Generic.IAsyncEnumerable! records, string! name, ExcelReader.Core.Writer.ExcelRecordLayout! layout, ExcelReader.Core.Writer.ExcelTableOptions? tableOptions = null, ExcelReader.Core.Writer.ParallelWriteOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Core.Writer.ParallelSheetWriterExtensions.WriteTableParallelAsync(this ExcelReader.Core.Writer.Xlsb.XlsbSheetWriter! sheet, System.Collections.Generic.IEnumerable! records, string! name, ExcelReader.Core.Writer.ExcelRecordLayout! layout, ExcelReader.Core.Writer.ExcelTableOptions? tableOptions = null, ExcelReader.Core.Writer.ParallelWriteOptions? 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 diff --git a/src/ExcelReader.Core/Writer/ParallelSheetWriterExtensions.cs b/src/ExcelReader.Core/Writer/ParallelSheetWriterExtensions.cs index d48ed26f..8067214a 100644 --- a/src/ExcelReader.Core/Writer/ParallelSheetWriterExtensions.cs +++ b/src/ExcelReader.Core/Writer/ParallelSheetWriterExtensions.cs @@ -1,4 +1,5 @@ using ExcelReader.Core.Writer.Parallel; +using ExcelReader.Core.Writer.Xlsb; using ExcelReader.Core.Writer.Xlsx; namespace ExcelReader.Core.Writer @@ -111,5 +112,65 @@ public static ValueTask WriteTableParallelAsync(this XlsxSheetWriter sheet, I ArgumentNullException.ThrowIfNull(layout); return ParallelRecordWriter.WriteTableAsync(sheet, new RecordSource(records), name, layout, tableOptions, options, ct); } + + /// + public static ValueTask WriteRecordsParallelAsync(this XlsbSheetWriter sheet, IEnumerable records, + ExcelRecordLayout layout, ParallelWriteOptions? options = null, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(sheet); + ArgumentNullException.ThrowIfNull(records); + ArgumentNullException.ThrowIfNull(layout); + return ParallelRecordWriter.WriteRecordsAsync(sheet, new RecordSource(records), layout, options, ct); + } + + /// + public static ValueTask WriteRecordsParallelAsync(this XlsbSheetWriter sheet, IAsyncEnumerable records, + ExcelRecordLayout layout, ParallelWriteOptions? options = null, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(sheet); + ArgumentNullException.ThrowIfNull(records); + ArgumentNullException.ThrowIfNull(layout); + return ParallelRecordWriter.WriteRecordsAsync(sheet, new RecordSource(records), layout, options, ct); + } + + /// + public static ValueTask WriteRecordsParallelAsync(this XlsbSheetWriter sheet, IEnumerable records, + Action writeRow, ParallelWriteOptions? options = null, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(sheet); + ArgumentNullException.ThrowIfNull(records); + ArgumentNullException.ThrowIfNull(writeRow); + return ParallelRecordWriter.WriteRowsAsync(sheet, new RecordSource(records), writeRow, options, ct); + } + + /// + public static ValueTask WriteRecordsParallelAsync(this XlsbSheetWriter sheet, IAsyncEnumerable records, + Action writeRow, ParallelWriteOptions? options = null, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(sheet); + ArgumentNullException.ThrowIfNull(records); + ArgumentNullException.ThrowIfNull(writeRow); + return ParallelRecordWriter.WriteRowsAsync(sheet, new RecordSource(records), writeRow, options, ct); + } + + /// + public static ValueTask WriteTableParallelAsync(this XlsbSheetWriter sheet, IEnumerable records, string name, + ExcelRecordLayout layout, ExcelTableOptions? tableOptions = null, ParallelWriteOptions? options = null, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(sheet); + ArgumentNullException.ThrowIfNull(records); + ArgumentNullException.ThrowIfNull(layout); + return ParallelRecordWriter.WriteTableAsync(sheet, new RecordSource(records), name, layout, tableOptions, options, ct); + } + + /// + public static ValueTask WriteTableParallelAsync(this XlsbSheetWriter sheet, IAsyncEnumerable records, string name, + ExcelRecordLayout layout, ExcelTableOptions? tableOptions = null, ParallelWriteOptions? options = null, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(sheet); + ArgumentNullException.ThrowIfNull(records); + ArgumentNullException.ThrowIfNull(layout); + return ParallelRecordWriter.WriteTableAsync(sheet, new RecordSource(records), name, layout, tableOptions, options, ct); + } } } diff --git a/src/ExcelReader.Core/Writer/Xlsb/XlsbSheetWriter.cs b/src/ExcelReader.Core/Writer/Xlsb/XlsbSheetWriter.cs index 384fb134..c11e025d 100644 --- a/src/ExcelReader.Core/Writer/Xlsb/XlsbSheetWriter.cs +++ b/src/ExcelReader.Core/Writer/Xlsb/XlsbSheetWriter.cs @@ -1,16 +1,18 @@ using System.Buffers.Binary; +using System.Diagnostics.CodeAnalysis; using System.IO.Compression; using System.Runtime.InteropServices; using ExcelReader.Core.Reader.Internal; using ExcelReader.Core.Reader.Xlsb; using ExcelReader.Core.Writer.Internal; +using ExcelReader.Core.Writer.Parallel; using ExcelReader.Core.Writer.Xls; using ExcelReader.Core.Writer.Zip; namespace ExcelReader.Core.Writer.Xlsb { /// Writes a single worksheet's rows into an .xlsb workbook produced by . - public sealed class XlsbSheetWriter : ISheetWriter, ITableSheetWriter + public sealed class XlsbSheetWriter : ISheetWriter, ITableSheetWriter, IBatchSheet { private const int SpillThreshold = 64 * 1024; @@ -21,6 +23,12 @@ public sealed class XlsbSheetWriter : ISheetWriter, ITableSheetWr private readonly bool _offloadWrite; private readonly BiffBuffer _records = new(4096); private readonly TableTracker _tables = new(); + private readonly int _spillThreshold = SpillThreshold; + private readonly WorkerStringCache? _workerStrings; + [SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", Justification = "An alias of the entry that _stream is or wraps; disposing _stream disposes it.")] + private ZipEntryStream? _entry; + private bool _segmentsOpen; + [SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", Justification = "End disposes it and Fault cleans it up; the analyzer loses it once _entry aliases the same entry.")] private Stream? _stream; private WriterState _state = WriterState.Created; private bool _rowActive; @@ -60,6 +68,26 @@ internal XlsbSheetWriter( _offloadWrite = offloadWrite; } + // A detached copy formats batches for the parallel record path: it has its own record buffer, + // never spills it and never touches the ZIP. + private XlsbSheetWriter(XlsbSheetWriter source) + { + _owner = source._owner; + _zip = source._zip; + Name = source.Name; + SheetId = source.SheetId; + Visibility = source.Visibility; + _date1904 = source._date1904; + _compression = source._compression; + _columnStyles = source._columnStyles; + _spillThreshold = int.MaxValue; + _state = WriterState.Started; + if (_owner.UseSharedStrings) + { + _workerStrings = new WorkerStringCache(_owner.SharedStrings!, _owner.SharedStringsGate); + } + } + internal string Name { get; } internal int SheetId { get; } internal ExcelSheetVisibility Visibility { get; } @@ -366,6 +394,7 @@ private void Release(bool faulted) { _state = WriterState.Ended; _stream = null; + _entry = null; ReleaseBuffers(); if (!faulted && !_registered) { @@ -404,7 +433,7 @@ internal void WriteRecord(int id, ReadOnlySpan payload = default) private void MaybeFlush() { - if (!_rowActive && _records.Length >= SpillThreshold) + if (!_rowActive && _records.Length >= _spillThreshold) { FlushRecords(); } @@ -416,8 +445,9 @@ private void EnsureStream() { return; } - Stream stream = _zip.OpenEntry($"xl/worksheets/sheet{SheetId}.bin", _compression); - _stream = _offloadWrite ? new WriteOffloadStream(stream) : stream; + ZipEntryStream entry = _zip.OpenEntry($"xl/worksheets/sheet{SheetId}.bin", _compression); + _entry = entry; + _stream = _offloadWrite ? new WriteOffloadStream(entry) : entry; } private void FlushRecords() @@ -646,7 +676,7 @@ private void WriteTextCell(int columnIndex, ReadOnlySpan value, string? ow int style = EffectiveStyle(columnIndex); if (_owner.UseSharedStrings) { - int index = owned is null ? _owner.GetSharedStringIndex(value) : _owner.GetSharedStringIndex(owned); + int index = SharedStringIndex(value, owned); const int Length = CellHeaderLength + 4; Biff12RecordWriter.WriteFixedRecord(_records, Brt.CellIsst, Length, out Span shared); Biff12RecordWriter.WriteCellHeader(shared, columnIndex, style); @@ -662,6 +692,15 @@ private void WriteTextCell(int columnIndex, ReadOnlySpan value, string? ow MaybeFlush(); } + private int SharedStringIndex(ReadOnlySpan value, string? owned) + { + if (_workerStrings is not null) + { + return owned is null ? _workerStrings.GetOrAdd(value) : _workerStrings.GetOrAdd(owned); + } + return owned is null ? _owner.GetSharedStringIndex(value) : _owner.GetSharedStringIndex(owned); + } + internal void WriteBoolCell(int columnIndex, bool value) { ValidateColumn(columnIndex); @@ -709,5 +748,120 @@ private static void ValidateColumn(int columnIndex) { ExcelLimits.ThrowIfColumnOutOfRange(columnIndex); } + + CompressionLevel IBatchSheet.BatchCompression + { + get + { + return _compression; + } + } + + ValueTask IBatchSheet.BeginSegmentsAsync(CancellationToken ct) + { + WriterStateGuard.ThrowIfEnded(_state, this); + WriterStateGuard.RequireNoActiveRowForStart(_rowActive, nameof(XlsbRowWriter)); + ct.ThrowIfCancellationRequested(); + EnsureStarted(); + // Held for the whole stretch, so StartRow and End are refused until it is over. + _rowActive = true; + _segmentsOpen = false; + return ValueTask.FromResult(_rowNumber + 1); + } + + async ValueTask IBatchSheet.AppendSegmentAsync(BatchSegment segment, CancellationToken ct) + { + MergeUsedRange(segment); + if (!_segmentsOpen) + { + await OpenSegmentsAsync(ct).ConfigureAwait(false); + } + await _entry!.AppendSegmentAsync(segment.Data.Memory, segment.Crc, segment.RawLength, ct).ConfigureAwait(false); + _rowNumber += segment.Rows; + } + + // The sheet header leaves the buffer here, not when the stretch begins, so the dimension record + // it carries sees the first batch's rows and columns. + private async ValueTask OpenSegmentsAsync(CancellationToken ct) + { + FlushRecords(); + try + { + EnsureStream(); + if (_stream is WriteOffloadStream offload) + { + await offload.DrainAsync(ct).ConfigureAwait(false); + } + } + catch + { + await FaultAsync().ConfigureAwait(false); + throw; + } + _segmentsOpen = true; + } + + private void MergeUsedRange(BatchSegment segment) + { + if (segment.RowLast < 0) + { + return; + } + if (_usedRowFirst < 0) + { + _usedRowFirst = segment.RowFirst; + } + _usedRowLast = segment.RowLast; + _usedColFirst = Math.Min(_usedColFirst, segment.ColumnFirst); + _usedColLast = Math.Max(_usedColLast, segment.ColumnLast); + } + + void IBatchSheet.EndSegments() + { + _rowActive = false; + _segmentsOpen = false; + } + + async ValueTask IBatchSheet.FailSegmentsAsync() + { + _rowActive = false; + await FaultAsync().ConfigureAwait(false); + } + + XlsbSheetWriter IBatchSheet.CreateDetached() + { + return new XlsbSheetWriter(this); + } + + void IBatchSheet.BeginBatch(int rowsBefore) + { + _records.Reset(); + _rowNumber = rowsBefore - 1; + _rowActive = false; + _usedRowFirst = -1; + _usedRowLast = -1; + _usedColFirst = int.MaxValue; + _usedColLast = -1; + } + + XlsbRowWriter IBatchSheet.StartBatchRow() + { + return StartRow(); + } + + ReadOnlySpan IBatchSheet.EndBatch(BatchSegment segment) + { + _workerStrings?.FlushReferences(); + segment.RowFirst = _usedRowFirst; + segment.RowLast = _usedRowLast; + segment.ColumnFirst = _usedColFirst; + segment.ColumnLast = _usedColLast; + return _records.Span; + } + + void IBatchSheet.ReleaseDetached() + { + ReleaseBuffers(); + } } } diff --git a/src/ExcelReader.Core/Writer/Xlsb/XlsbWorkbookWriter.cs b/src/ExcelReader.Core/Writer/Xlsb/XlsbWorkbookWriter.cs index 9fa1ab22..436c7c96 100644 --- a/src/ExcelReader.Core/Writer/Xlsb/XlsbWorkbookWriter.cs +++ b/src/ExcelReader.Core/Writer/Xlsb/XlsbWorkbookWriter.cs @@ -97,6 +97,16 @@ internal int GetSharedStringIndex(ReadOnlySpan value) return _sharedStrings!.GetOrAdd(value); } + internal SharedStringTable? SharedStrings + { + get + { + return _sharedStrings; + } + } + + internal Lock SharedStringsGate { get; } = new(); + /// public int AddStyle(CellStyle style) { diff --git a/tests/ExcelReader.Tests/Writer/Parallel/ParallelXlsbRecordWriteTests.cs b/tests/ExcelReader.Tests/Writer/Parallel/ParallelXlsbRecordWriteTests.cs new file mode 100644 index 00000000..ef7ef51c --- /dev/null +++ b/tests/ExcelReader.Tests/Writer/Parallel/ParallelXlsbRecordWriteTests.cs @@ -0,0 +1,208 @@ +using System.Globalization; +using System.IO.Compression; +using ExcelReader.Core.Reader; +using ExcelReader.Core.Reader.Xlsb; +using ExcelReader.Core.Writer; +using ExcelReader.Core.Writer.Xlsb; +using ExcelReader.Tests.Writer.Zip; +using Item = ExcelReader.Tests.Writer.Parallel.ParallelRecordWriteTests.Item; + +namespace ExcelReader.Tests.Writer.Parallel +{ + public class ParallelXlsbRecordWriteTests + { + public static TheoryData Modes() + { + return ParallelRecordWriteTests.Modes(); + } + + private static async Task WriteAsync(XlsbWriterOptions options, Func body, bool seekable = true) + { + var ms = new MemoryStream(); + Stream target = seekable ? ms : new ForwardOnlyWriteStream(ms); + await using (XlsbWorkbookWriter wb = XlsbWorkbookWriter.Create(target, leaveOpen: true, options)) + { + XlsbSheetWriter sheet = wb.AddSheet("Data"); + await body(sheet); + await sheet.EndAsync(TestContext.Current.CancellationToken); + await wb.EndAsync(TestContext.Current.CancellationToken); + } + return ms.ToArray(); + } + + private static ParallelWriteOptions Parallel(int degree, int batchRows = 500) + { + return new ParallelWriteOptions { DegreeOfParallelism = degree, BatchRows = batchRows }; + } + + private static List Read(byte[] package) + { + using XlsbWorkbook workbook = Excel.FromXlsb(new MemoryStream(package)); + return ParallelRecordWriteTests.Cells(workbook); + } + + private static void Fill(XlsbRowWriter row, Item item) + { + ParallelRecordWriteTests.Fill(row, item); + } + + [Theory] + [MemberData(nameof(Modes))] + public async Task ParallelGivesTheSameCellsAsSerial(CompressionLevel level, bool prefetch, bool sharedStrings) + { + CancellationToken ct = TestContext.Current.CancellationToken; + List items = ParallelRecordWriteTests.Items(6_300); + var options = new XlsbWriterOptions { Compression = level, PrefetchWrite = prefetch, UseSharedStrings = sharedStrings }; + byte[] serial = await WriteAsync(options, async sheet => await sheet.WriteRecordsAsync(items, Fill, ct)); + foreach (bool seekable in new[] { true, false }) + { + byte[] parallel = await WriteAsync(options, async sheet => + await sheet.WriteRecordsParallelAsync(items, Fill, Parallel(4), ct), seekable); + + Assert.Equal(Read(serial), Read(parallel)); + ParallelRecordWriteTests.Entry(parallel, "xl/worksheets/sheet1.bin"); + } + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(500)] + [InlineData(501)] + [InlineData(1_733)] + public async Task BatchEdges(int count) + { + CancellationToken ct = TestContext.Current.CancellationToken; + List items = ParallelRecordWriteTests.Items(count); + byte[] package = await WriteAsync(XlsbWriterOptions.Default, async sheet => + await sheet.WriteRecordsParallelAsync(items, Fill, Parallel(3), ct)); + + List rows = Read(package); + Assert.Equal(count, rows.Count); + for (int i = 0; i < count; i++) + { + Assert.Equal(i.ToString(CultureInfo.InvariantCulture), rows[i][0]); + Assert.Equal(items[i].Name, rows[i][1]); + } + } + + [Fact] + public async Task ParallelAsTheFirstWriteOnTheSheet() + { + CancellationToken ct = TestContext.Current.CancellationToken; + List items = ParallelRecordWriteTests.Items(1_200); + byte[] package = await WriteAsync(XlsbWriterOptions.Default with { PrefetchWrite = true }, async sheet => + await sheet.WriteRecordsParallelAsync(items, Fill, Parallel(4), ct)); + + Assert.Equal(1_200, Read(package).Count); + } + + [Theory] + [InlineData(CompressionLevel.Fastest)] + [InlineData(CompressionLevel.Optimal)] + [InlineData(CompressionLevel.NoCompression)] + public async Task SerialRowsBeforeAndAfterSurvive(CompressionLevel level) + { + CancellationToken ct = TestContext.Current.CancellationToken; + List items = ParallelRecordWriteTests.Items(1_300); + byte[] package = await WriteAsync(new XlsbWriterOptions { Compression = level }, async sheet => + { + await sheet.WriteRecordsAsync(items, Fill, ct); + await sheet.WriteRecordsParallelAsync(items, Fill, Parallel(4), ct); + await sheet.WriteRecordsAsync(items, Fill, ct); + await sheet.WriteRecordsParallelAsync(items, Fill, Parallel(2), ct); + using XlsbRowWriter last = sheet.StartRow(); + last.Write("end"); + }); + + List rows = Read(package); + Assert.Equal((4 * 1_300) + 1, rows.Count); + for (int block = 0; block < 4; block++) + { + Assert.Equal("0", rows[block * 1_300][0]); + Assert.Equal("1299", rows[(block * 1_300) + 1_299][0]); + } + Assert.Equal("end", rows[^1][0]); + } + + [Fact] + public async Task ALayoutAndATableMatchSerial() + { + CancellationToken ct = TestContext.Current.CancellationToken; + List items = ParallelRecordWriteTests.Items(900); + byte[] serial = await WriteAsync(XlsbWriterOptions.Default, async sheet => + await sheet.WriteTableAsync(items, "Records", ExcelRecordLayout.FromAttributes(), ct: ct)); + byte[] parallel = await WriteAsync(XlsbWriterOptions.Default, async sheet => + await sheet.WriteTableParallelAsync(items, "Records", ExcelRecordLayout.FromAttributes(), options: Parallel(4, 100), ct: ct)); + + Assert.Equal(Read(serial), Read(parallel)); + Assert.Equal(ParallelRecordWriteTests.Entry(serial, "xl/tables/table1.bin"), ParallelRecordWriteTests.Entry(parallel, "xl/tables/table1.bin")); + } + + [Fact] + public async Task SharedStringsGiveEveryCellItsOwnText() + { + CancellationToken ct = TestContext.Current.CancellationToken; + List items = ParallelRecordWriteTests.Items(4_000, distinctNames: 613); + for (int round = 0; round < 25; round++) + { + byte[] package = await WriteAsync(new XlsbWriterOptions { UseSharedStrings = true }, async sheet => + await sheet.WriteRecordsParallelAsync(items, Fill, Parallel(8, 50), ct)); + + List rows = Read(package); + Assert.Equal(items.Count, rows.Count); + for (int i = 0; i < items.Count; i++) + { + Assert.Equal(items[i].Name, rows[i][1]); + } + Assert.Equal((4_000u, 613u), SharedStringCounts(package)); + } + } + + // BrtBeginSst opens the part: a two-byte record id, a one-byte size, then the reference count + // and the unique count as 32-bit integers. + private static (uint References, uint Unique) SharedStringCounts(byte[] package) + { + byte[] part = ParallelRecordWriteTests.Entry(package, "xl/sharedStrings.bin"); + return (BitConverter.ToUInt32(part, 3), BitConverter.ToUInt32(part, 7)); + } + + [Fact] + public async Task SharedStringCountsMatchASerialWrite() + { + CancellationToken ct = TestContext.Current.CancellationToken; + List items = ParallelRecordWriteTests.Items(4_000, distinctNames: 613); + var options = new XlsbWriterOptions { UseSharedStrings = true }; + byte[] serial = await WriteAsync(options, async sheet => await sheet.WriteRecordsAsync(items, Fill, ct)); + byte[] parallel = await WriteAsync(options, async sheet => + await sheet.WriteRecordsParallelAsync(items, Fill, Parallel(8, 50), ct)); + + Assert.Equal(SharedStringCounts(serial), SharedStringCounts(parallel)); + } + + [Fact] + public async Task AThrowingRowFaultsTheSheet() + { + CancellationToken ct = TestContext.Current.CancellationToken; + List items = ParallelRecordWriteTests.Items(5_000); + static void Throwing(XlsbRowWriter row, Item item) + { + row.Write(item.Id); + if (item.Id == 2_345) + { + throw new FormatException("row 2345"); + } + } + var ms = new MemoryStream(); + XlsbWorkbookWriter wb = XlsbWorkbookWriter.Create(ms, leaveOpen: true); + XlsbSheetWriter sheet = wb.AddSheet("Data"); + + await Assert.ThrowsAsync( + async () => await sheet.WriteRecordsParallelAsync(items, Throwing, Parallel(4, 100), ct)); + + Assert.True(sheet.ResourcesReleased); + Assert.Throws(() => sheet.StartRow()); + await wb.DisposeAsync(); + } + } +} From b51388737909d78be561df92fe5187271381f063 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 13:56:16 -0300 Subject: [PATCH 29/42] docs: describe parallel record writes and their benchmarks Co-Authored-By: Claude Opus 5.5 (1M context) --- ARCHITECTURE.md | 8 +++++ docs/guide/writing.md | 64 ++++++++++++++++++++++++++++++++++ docs/performance/benchmarks.md | 17 +++++++++ docs/snippets/Writing.cs | 28 +++++++++++++++ 4 files changed, 117 insertions(+) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1c7c4bfc..09653c1f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -255,6 +255,14 @@ zlib through `DeflateStream`. When a write faults, `Abandon` still writes a cent the entries that completed, as disposing `ZipArchive` did; a failed entry write marks the whole archive failed, so nothing more can be added to it. +`WriteRecordsParallelAsync` and `WriteTableParallelAsync` (`Writer/Parallel/`) cut a record source into +batches. Each batch is formatted on a pool thread by a detached copy of the sheet writer, which has +its own buffer and never touches the ZIP, then compressed into a byte-aligned deflate segment that +refers to nothing outside itself. The calling flow appends segments in order through +`ZipEntryStream.AppendSegmentAsync`, which merges CRCs with `Crc32.Combine`. Once an entry has +received a segment, everything written to it is a segment, including later serial rows, so no +compressor ever carries history across one. + ## The `excelreader` CLI `src/ExcelReader.Cli/` is a thin `dotnet tool` shell (`excelreader`) over Core's public API — it diff --git a/docs/guide/writing.md b/docs/guide/writing.md index 0d56bce8..b2377f07 100644 --- a/docs/guide/writing.md +++ b/docs/guide/writing.md @@ -273,3 +273,67 @@ The same caveat as the read side applies: **do not enable it for concurrent serv caller already writing many files in parallel is CPU-saturated, and an extra background thread per writer only doubles thread demand for no gain. It's meant for single-file batch work. +## Write records in parallel (XLSX/XLSB) + +`PrefetchWrite` moves compression off the calling thread, but the rows are still built on one thread, +and building them is most of a write. `WriteRecordsParallelAsync` and `WriteTableParallelAsync` use +several cores for both: the records are cut into batches, each batch is formatted and compressed on +a pool thread, and the batches reach the file in order. They are extensions on +[`XlsxSheetWriter`](xref:ExcelReader.Core.Writer.Xlsx.XlsxSheetWriter) and +[`XlsbSheetWriter`](xref:ExcelReader.Core.Writer.Xlsb.XlsbSheetWriter), and take an `IEnumerable` +or an `IAsyncEnumerable`. The cell-by-cell API stays single-threaded, and XLS and CSV have no +parallel form. + + +```csharp +await using var wb = XlsxWorkbookWriter.Create(stream, leaveOpen: true); +XlsxSheetWriter sheet = wb.AddSheet("S1"); + +// Header row from the layout, then one row per record. +await sheet.WriteRecordsParallelAsync(records, ExcelRecordLayout.FromAttributes()); + +// Or format each row yourself; this delegate runs on several threads at once. +await sheet.WriteRecordsParallelAsync(records, static (XlsxRowWriter row, Metric record) => +{ + row.Write(record.Name); + row.Write(record.Value); +}, new ParallelWriteOptions { DegreeOfParallelism = 4 }); + +await sheet.EndAsync(); +``` + +The rules: + +- **Your delegate runs on several threads at once**, though never twice at once on the same row + writer. Whatever it captures must be thread-safe. The library's own layouts are. +- **Order is kept.** Rows come out in the order the source yields them, and the source itself is + enumerated by one flow at a time, never concurrently. +- **Rows may be written before and after the call, not during it.** Row numbering carries on, and + the call can be made more than once on a sheet. +- **A failure faults the sheet**, as a failing row does in a serial write. The original exception is + rethrown once every batch in flight has stopped. +- **With `UseSharedStrings`, string indexes depend on thread timing**, so two runs give files that + differ byte for byte. Their content is the same. +- More than 1,048,576 rows throws `ExcelLimitExceededException`. + +[`ParallelWriteOptions`](xref:ExcelReader.Core.Writer.ParallelWriteOptions) has two knobs. +`DegreeOfParallelism` is the most batches in flight at once (0, the default, means one per core). +`BatchRows` is the rows per batch, 8,192 by default. Memory grows with their product: at the +defaults each batch in flight holds about 3 MB (its formatted rows, the compressor's window and the +compressed result), whatever the size of the sheet. + +Writing 1,000,000 rows of four columns (text, integer, date, number) to a `MemoryStream` at +`Fastest`, on a Ryzen 7 5700X, median of 9 runs: + +| Write | XLSX | XLSB | +|---|---:|---:| +| Serial | 187.3 ms | 123.6 ms | +| Serial, `PrefetchWrite` | 117.8 ms | 67.6 ms | +| Parallel, 1 batch in flight | 172.7 ms | 120.6 ms | +| Parallel, 2 | 92.1 ms | 63.2 ms | +| Parallel, 4 | 49.9 ms | 40.9 ms | +| Parallel, 8 | 35.7 ms | 27.7 ms | + +The file comes out 0.18% (XLSX) or 0.04% (XLSB) larger, because each batch is compressed on its own. +As with `PrefetchWrite`, this is for single-file batch work: a server already writing many files at +once has no idle cores to give it. diff --git a/docs/performance/benchmarks.md b/docs/performance/benchmarks.md index 5190a91f..b90636e2 100644 --- a/docs/performance/benchmarks.md +++ b/docs/performance/benchmarks.md @@ -203,6 +203,23 @@ The encoder's limits are deliberate: - It emits fixed-Huffman blocks only, so `Optimal` and `SmallestSize` still go through zlib, inside the same container. - A block covers at most 65,535 input bytes, so any block that would grow is written stored instead. Incompressible input grows by at most 5 bytes per block. +### Parallel record writes + +`WriteRecordsParallelAsync` formats and compresses batches of 8,192 records on pool threads and appends them to the sheet in order. Writing 1,000,000 rows by four columns (text, integer, date, number) into a pre-sized `MemoryStream` at `Fastest`, on the same Ryzen 7 5700X. Each figure is the median of 9 writes after 3 warm-up writes, from a console harness rather than BenchmarkDotNet: + +| Write | XLSX | vs serial | XLSB | vs serial | +|---|---:|---:|---:|---:| +| Serial | 187.3 ms | 1.0x | 123.6 ms | 1.0x | +| Serial, `PrefetchWrite` | 117.8 ms | 1.6x | 67.6 ms | 1.8x | +| Parallel, 1 batch in flight | 172.7 ms | 1.1x | 120.6 ms | 1.0x | +| Parallel, 2 | 92.1 ms | 2.0x | 63.2 ms | 2.0x | +| Parallel, 4 | 49.9 ms | 3.8x | 40.9 ms | 3.0x | +| Parallel, 8 | 35.7 ms | 5.2x | 27.7 ms | 4.5x | + +It scales because a write is bound by building rows, not by deflate: `PrefetchWrite` already takes deflate off the calling thread and stops at 1.6x to 1.8x, so compressing chunks in parallel would add nothing on top of it, while formatting the rows in parallel does. + +Each batch is compressed with no reference to the one before it, which costs 0.18% of file size for XLSX (15,481,372 B to 15,508,760 B) and 0.04% for XLSB (16,863,131 B to 16,869,306 B). The process's peak working set rose by 16 MB over the serial write with 4 batches in flight and by 28 MB with 8, about 3 MB per batch, and does not depend on the number of rows. + ### Matched-work reads (`*_Materialized`) Every read benchmark class has a `*_Materialized` sibling that calls `cell.GetString()` per text cell — the same UTF-16 materialization Sylvan's ADO.NET-style API is forced to pay — while keeping `TryParse`/`TryGetDateTime` for numeric and date cells, exactly mirroring the competitor accumulator. These are the matched-work counterparts to the zero-copy rows above. diff --git a/docs/snippets/Writing.cs b/docs/snippets/Writing.cs index c69772fe..b2f8b115 100644 --- a/docs/snippets/Writing.cs +++ b/docs/snippets/Writing.cs @@ -278,3 +278,31 @@ internal static async Task Run(Stream stream, Metric[] records) } } } + +namespace Snippets.Writing.Parallel +{ + public sealed record Metric(string Name, double Value); + + internal static class Snippet + { + internal static async Task Run(Stream stream, IEnumerable records) + { + #region writing-parallel + await using var wb = XlsxWorkbookWriter.Create(stream, leaveOpen: true); + XlsxSheetWriter sheet = wb.AddSheet("S1"); + + // Header row from the layout, then one row per record. + await sheet.WriteRecordsParallelAsync(records, ExcelRecordLayout.FromAttributes()); + + // Or format each row yourself; this delegate runs on several threads at once. + await sheet.WriteRecordsParallelAsync(records, static (XlsxRowWriter row, Metric record) => + { + row.Write(record.Name); + row.Write(record.Value); + }, new ParallelWriteOptions { DegreeOfParallelism = 4 }); + + await sheet.EndAsync(); + #endregion + } + } +} From e7ac95fde57e619b82a885c6730196408a2ed56c Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 14:10:00 -0300 Subject: [PATCH 30/42] fix: harden parallel record writes after the final review Clamp BatchRows to the row limit and build the runner inside the fault path, so a failed rent no longer leaves the sheet stuck with an active row. Guard AppendSegmentAsync against an ended sheet, pre-size detached row buffers, replace call-branch ternaries, and document that a failed or cancelled parallel call ends the sheet and its workbook. --- .../Writer/Parallel/BatchRunner.cs | 19 ++++++-- .../Writer/Parallel/IBatchSheet.cs | 3 +- .../Writer/Parallel/ParallelRecordWriter.cs | 8 ++-- .../Writer/ParallelSheetWriterExtensions.cs | 10 ++-- .../Writer/ParallelWriteOptions.cs | 3 +- .../Writer/Xlsb/XlsbSheetWriter.cs | 30 +++++++++--- .../Writer/Xlsx/XlsxSheetWriter.cs | 28 ++++++++--- .../Writer/Parallel/BatchRunnerTests.cs | 2 +- .../Parallel/ParallelRecordWriteTests.cs | 46 ++++++++++++++++--- .../Parallel/ParallelXlsbRecordWriteTests.cs | 32 +++++++++++-- .../Writer/Zip/ZipEntrySegmentTests.cs | 16 +++++++ 11 files changed, 158 insertions(+), 39 deletions(-) diff --git a/src/ExcelReader.Core/Writer/Parallel/BatchRunner.cs b/src/ExcelReader.Core/Writer/Parallel/BatchRunner.cs index e79019be..f9f19b1b 100644 --- a/src/ExcelReader.Core/Writer/Parallel/BatchRunner.cs +++ b/src/ExcelReader.Core/Writer/Parallel/BatchRunner.cs @@ -26,7 +26,7 @@ internal BatchRunner(ParallelWriteOptions options, int rowsBefore, Func emit, CancellationToken ct) { _degree = options.DegreeOfParallelism == 0 ? Environment.ProcessorCount : options.DegreeOfParallelism; - _batchRows = options.BatchRows; + _batchRows = Math.Min(options.BatchRows, ExcelLimits.MaxRows); _rowsBefore = rowsBefore; _format = format; _emit = emit; @@ -64,7 +64,11 @@ internal async ValueTask RunAsync(RecordSource source) private ValueTask AddAsync(T record) { _batch[_count++] = record; - return _count == _batchRows ? DispatchAsync() : ValueTask.CompletedTask; + if (_count == _batchRows) + { + return DispatchAsync(); + } + return ValueTask.CompletedTask; } private async ValueTask CompleteAsync() @@ -98,9 +102,14 @@ private async ValueTask DispatchAsync() _batch = ArrayPool.Shared.Rent(_batchRows); _count = 0; _rowsBefore += count; - _inFlight.Enqueue(_degree == 1 - ? Task.FromResult(Format(batch, count, rowsBefore)) - : Task.Run(() => Format(batch, count, rowsBefore), CancellationToken.None)); + if (_degree == 1) + { + _inFlight.Enqueue(Task.FromResult(Format(batch, count, rowsBefore))); + } + else + { + _inFlight.Enqueue(Task.Run(() => Format(batch, count, rowsBefore), CancellationToken.None)); + } } private BatchSegment Format(T[] batch, int count, int rowsBefore) diff --git a/src/ExcelReader.Core/Writer/Parallel/IBatchSheet.cs b/src/ExcelReader.Core/Writer/Parallel/IBatchSheet.cs index 1f6c1cc7..e84a633e 100644 --- a/src/ExcelReader.Core/Writer/Parallel/IBatchSheet.cs +++ b/src/ExcelReader.Core/Writer/Parallel/IBatchSheet.cs @@ -24,7 +24,8 @@ internal interface IBatchSheet /// Faults the sheet after a failed parallel stretch. Never throws. ValueTask FailSegmentsAsync(); - TSelf CreateDetached(); + /// Makes a detached copy whose buffer is sized for batches of rows. + TSelf CreateDetached(int batchRows); void BeginBatch(int rowsBefore); diff --git a/src/ExcelReader.Core/Writer/Parallel/ParallelRecordWriter.cs b/src/ExcelReader.Core/Writer/Parallel/ParallelRecordWriter.cs index 309a7cd9..b500abcb 100644 --- a/src/ExcelReader.Core/Writer/Parallel/ParallelRecordWriter.cs +++ b/src/ExcelReader.Core/Writer/Parallel/ParallelRecordWriter.cs @@ -50,11 +50,11 @@ private static async ValueTask RunAsync(TSheet sheet, RecordSou { int rowsBefore = await sheet.BeginSegmentsAsync(ct).ConfigureAwait(false); var workers = new ConcurrentBag(); - var runner = new BatchRunner(options, rowsBefore, - (batch, count, before) => FormatBatch(sheet, workers, writeRow, batch, count, before), - sheet.AppendSegmentAsync, ct); try { + var runner = new BatchRunner(options, rowsBefore, + (batch, count, before) => FormatBatch(sheet, workers, writeRow, batch, count, before), + sheet.AppendSegmentAsync, ct); await runner.RunAsync(records).ConfigureAwait(false); } catch @@ -79,7 +79,7 @@ private static BatchSegment FormatBatch(TSheet sheet, Concurren { if (!workers.TryTake(out TSheet? worker)) { - worker = sheet.CreateDetached(); + worker = sheet.CreateDetached(count); } try { diff --git a/src/ExcelReader.Core/Writer/ParallelSheetWriterExtensions.cs b/src/ExcelReader.Core/Writer/ParallelSheetWriterExtensions.cs index 8067214a..1a61249e 100644 --- a/src/ExcelReader.Core/Writer/ParallelSheetWriterExtensions.cs +++ b/src/ExcelReader.Core/Writer/ParallelSheetWriterExtensions.cs @@ -10,11 +10,12 @@ namespace ExcelReader.Core.Writer /// rows. /// /// - /// The source is enumerated on the calling thread only. A writeRow delegate runs on pool + /// The source is enumerated by one flow at a time, never concurrently. A writeRow delegate runs on pool /// threads, several at once, so whatever it captures must be safe to use concurrently; the layouts /// from are. Rows may be written through the normal API before - /// and after a call, but not while one is running. With shared strings on, string indexes depend - /// on thread timing, so two runs give the same content in files that differ byte for byte. + /// and after a call, but not while one is running. A call that throws, a cancelled one included, + /// ends the sheet and its workbook. With shared strings on, string indexes depend on thread + /// timing, so two runs give the same content in files that differ byte for byte. /// public static class ParallelSheetWriterExtensions { @@ -30,6 +31,7 @@ public static class ParallelSheetWriterExtensions /// The sheet has already been ended. /// A row is still active on the sheet. /// The records do not fit in the sheet's 1,048,576 rows. + /// was cancelled. Unlike a serial write cancelled between rows, this ends the sheet and its workbook. public static ValueTask WriteRecordsParallelAsync(this XlsxSheetWriter sheet, IEnumerable records, ExcelRecordLayout layout, ParallelWriteOptions? options = null, CancellationToken ct = default) { @@ -61,6 +63,7 @@ public static ValueTask WriteRecordsParallelAsync(this XlsxSheetWriter sheet, /// The sheet has already been ended. /// A row is still active on the sheet. /// The records do not fit in the sheet's 1,048,576 rows. + /// was cancelled. Unlike a serial write cancelled between rows, this ends the sheet and its workbook. public static ValueTask WriteRecordsParallelAsync(this XlsxSheetWriter sheet, IEnumerable records, Action writeRow, ParallelWriteOptions? options = null, CancellationToken ct = default) { @@ -94,6 +97,7 @@ public static ValueTask WriteRecordsParallelAsync(this XlsxSheetWriter sheet, /// A token to cancel the operation between batches. /// , , or is . /// holds a negative degree or a batch size below 1. + /// was cancelled. Unlike a serial write cancelled between rows, this ends the sheet and its workbook. public static ValueTask WriteTableParallelAsync(this XlsxSheetWriter sheet, IEnumerable records, string name, ExcelRecordLayout layout, ExcelTableOptions? tableOptions = null, ParallelWriteOptions? options = null, CancellationToken ct = default) { diff --git a/src/ExcelReader.Core/Writer/ParallelWriteOptions.cs b/src/ExcelReader.Core/Writer/ParallelWriteOptions.cs index 83d51fc0..5d0d0760 100644 --- a/src/ExcelReader.Core/Writer/ParallelWriteOptions.cs +++ b/src/ExcelReader.Core/Writer/ParallelWriteOptions.cs @@ -10,7 +10,8 @@ public sealed class ParallelWriteOptions /// /// Gets the most batches formatted and compressed at once. 0, the default, means - /// . With 1 the batches run on the calling thread. + /// . With 1 the batches run on the calling flow, + /// without the thread pool. /// public int DegreeOfParallelism { get; init; } diff --git a/src/ExcelReader.Core/Writer/Xlsb/XlsbSheetWriter.cs b/src/ExcelReader.Core/Writer/Xlsb/XlsbSheetWriter.cs index c11e025d..2e086496 100644 --- a/src/ExcelReader.Core/Writer/Xlsb/XlsbSheetWriter.cs +++ b/src/ExcelReader.Core/Writer/Xlsb/XlsbSheetWriter.cs @@ -21,7 +21,7 @@ public sealed class XlsbSheetWriter : ISheetWriter, ITableSheetWr private readonly bool _date1904; private readonly CompressionLevel _compression; private readonly bool _offloadWrite; - private readonly BiffBuffer _records = new(4096); + private readonly BiffBuffer _records; private readonly TableTracker _tables = new(); private readonly int _spillThreshold = SpillThreshold; private readonly WorkerStringCache? _workerStrings; @@ -66,12 +66,14 @@ internal XlsbSheetWriter( _date1904 = date1904; _compression = compression; _offloadWrite = offloadWrite; + _records = new BiffBuffer(4096); } // A detached copy formats batches for the parallel record path: it has its own record buffer, // never spills it and never touches the ZIP. - private XlsbSheetWriter(XlsbSheetWriter source) + private XlsbSheetWriter(XlsbSheetWriter source, int batchRows) { + _records = new BiffBuffer(DetachedCapacity(batchRows)); _owner = source._owner; _zip = source._zip; Name = source.Name; @@ -88,6 +90,11 @@ private XlsbSheetWriter(XlsbSheetWriter source) } } + private static int DetachedCapacity(int batchRows) + { + return (int)Math.Min(1 << 20, (long)batchRows * 128); + } + internal string Name { get; } internal int SheetId { get; } internal ExcelSheetVisibility Visibility { get; } @@ -694,11 +701,19 @@ private void WriteTextCell(int columnIndex, ReadOnlySpan value, string? ow private int SharedStringIndex(ReadOnlySpan value, string? owned) { - if (_workerStrings is not null) + if (_workerStrings is null) + { + if (owned is null) + { + return _owner.GetSharedStringIndex(value); + } + return _owner.GetSharedStringIndex(owned); + } + if (owned is null) { - return owned is null ? _workerStrings.GetOrAdd(value) : _workerStrings.GetOrAdd(owned); + return _workerStrings.GetOrAdd(value); } - return owned is null ? _owner.GetSharedStringIndex(value) : _owner.GetSharedStringIndex(owned); + return _workerStrings.GetOrAdd(owned); } internal void WriteBoolCell(int columnIndex, bool value) @@ -771,6 +786,7 @@ ValueTask IBatchSheet.BeginSegmentsAsync(Ca async ValueTask IBatchSheet.AppendSegmentAsync(BatchSegment segment, CancellationToken ct) { + WriterStateGuard.ThrowIfEnded(_state, this); MergeUsedRange(segment); if (!_segmentsOpen) { @@ -828,9 +844,9 @@ async ValueTask IBatchSheet.FailSegmentsAsync() await FaultAsync().ConfigureAwait(false); } - XlsbSheetWriter IBatchSheet.CreateDetached() + XlsbSheetWriter IBatchSheet.CreateDetached(int batchRows) { - return new XlsbSheetWriter(this); + return new XlsbSheetWriter(this, batchRows); } void IBatchSheet.BeginBatch(int rowsBefore) diff --git a/src/ExcelReader.Core/Writer/Xlsx/XlsxSheetWriter.cs b/src/ExcelReader.Core/Writer/Xlsx/XlsxSheetWriter.cs index 32f402fa..54d45218 100644 --- a/src/ExcelReader.Core/Writer/Xlsx/XlsxSheetWriter.cs +++ b/src/ExcelReader.Core/Writer/Xlsx/XlsxSheetWriter.cs @@ -21,7 +21,7 @@ public sealed class XlsxSheetWriter : ISheetWriter, ITableSheetWr private readonly ZipPackageWriter _zip; private readonly CompressionLevel _compression; private readonly bool _offloadWrite; - private readonly BiffBuffer _rowBuffer = new(512); + private readonly BiffBuffer _rowBuffer; // ponytail: flush to the deflate stream once buffered rows pass 64 KB — bounds memory on huge private const int FlushThreshold = 64 * 1024; private readonly int _flushThreshold = FlushThreshold; @@ -46,12 +46,14 @@ internal XlsxSheetWriter(XlsxWorkbookWriter owner, ZipPackageWriter zip, string Visibility = visibility; _compression = compression; _offloadWrite = offloadWrite; + _rowBuffer = new BiffBuffer(512); } // A detached copy formats batches for the parallel record path: it has its own row buffer, // never flushes it and never touches the ZIP. - private XlsxSheetWriter(XlsxSheetWriter source) + private XlsxSheetWriter(XlsxSheetWriter source, int batchRows) { + _rowBuffer = new BiffBuffer(DetachedCapacity(batchRows)); _owner = source._owner; _zip = source._zip; Name = source.Name; @@ -67,6 +69,11 @@ private XlsxSheetWriter(XlsxSheetWriter source) } } + private static int DetachedCapacity(int batchRows) + { + return (int)Math.Min(1 << 20, (long)batchRows * 128); + } + internal string Name { get; } internal int SheetId { get; } internal ExcelSheetVisibility Visibility { get; } @@ -77,12 +84,20 @@ private XlsxSheetWriter(XlsxSheetWriter source) internal int GetSharedStringIndex(string value) { - return _workerStrings is null ? _owner.GetSharedStringIndex(value) : _workerStrings.GetOrAdd(value); + if (_workerStrings is null) + { + return _owner.GetSharedStringIndex(value); + } + return _workerStrings.GetOrAdd(value); } internal int GetSharedStringIndex(ReadOnlySpan value) { - return _workerStrings is null ? _owner.GetSharedStringIndex(value) : _workerStrings.GetOrAdd(value); + if (_workerStrings is null) + { + return _owner.GetSharedStringIndex(value); + } + return _workerStrings.GetOrAdd(value); } internal int GetColumnStyle(int columnIndex) @@ -563,6 +578,7 @@ async ValueTask IBatchSheet.BeginSegmentsAs async ValueTask IBatchSheet.AppendSegmentAsync(BatchSegment segment, CancellationToken ct) { + WriterStateGuard.ThrowIfEnded(_state, this); await _entry!.AppendSegmentAsync(segment.Data.Memory, segment.Crc, segment.RawLength, ct).ConfigureAwait(false); _rowNumber += segment.Rows; } @@ -578,9 +594,9 @@ async ValueTask IBatchSheet.FailSegmentsAsync() await FaultAsync().ConfigureAwait(false); } - XlsxSheetWriter IBatchSheet.CreateDetached() + XlsxSheetWriter IBatchSheet.CreateDetached(int batchRows) { - return new XlsxSheetWriter(this); + return new XlsxSheetWriter(this, batchRows); } void IBatchSheet.BeginBatch(int rowsBefore) diff --git a/tests/ExcelReader.Tests/Writer/Parallel/BatchRunnerTests.cs b/tests/ExcelReader.Tests/Writer/Parallel/BatchRunnerTests.cs index 858391e3..410597e4 100644 --- a/tests/ExcelReader.Tests/Writer/Parallel/BatchRunnerTests.cs +++ b/tests/ExcelReader.Tests/Writer/Parallel/BatchRunnerTests.cs @@ -167,7 +167,7 @@ IEnumerable Source() await Assert.ThrowsAnyAsync( async () => await Runner(probe, 2, 10, cts.Token).RunAsync(new RecordSource(Source()))); Assert.Equal(0, probe.Running); - Assert.True(probe.Emitted.Count < 100); + Assert.True(probe.Emitted.Count <= 3); } [Fact] diff --git a/tests/ExcelReader.Tests/Writer/Parallel/ParallelRecordWriteTests.cs b/tests/ExcelReader.Tests/Writer/Parallel/ParallelRecordWriteTests.cs index 67a557bd..e49b50a4 100644 --- a/tests/ExcelReader.Tests/Writer/Parallel/ParallelRecordWriteTests.cs +++ b/tests/ExcelReader.Tests/Writer/Parallel/ParallelRecordWriteTests.cs @@ -165,15 +165,19 @@ public async Task ParallelAsTheFirstWriteOnTheSheet() } [Theory] - [InlineData(CompressionLevel.Fastest)] - [InlineData(CompressionLevel.Optimal)] - [InlineData(CompressionLevel.SmallestSize)] - [InlineData(CompressionLevel.NoCompression)] - public async Task SerialRowsBeforeAndAfterSurvive(CompressionLevel level) + [InlineData(CompressionLevel.Fastest, false)] + [InlineData(CompressionLevel.Optimal, false)] + [InlineData(CompressionLevel.SmallestSize, false)] + [InlineData(CompressionLevel.NoCompression, false)] + [InlineData(CompressionLevel.Fastest, true)] + [InlineData(CompressionLevel.Optimal, true)] + [InlineData(CompressionLevel.SmallestSize, true)] + [InlineData(CompressionLevel.NoCompression, true)] + public async Task SerialRowsBeforeAndAfterSurvive(CompressionLevel level, bool prefetch) { CancellationToken ct = TestContext.Current.CancellationToken; List items = Items(1_300); - byte[] package = await WriteAsync(new XlsxWriterOptions { Compression = level }, async sheet => + byte[] package = await WriteAsync(new XlsxWriterOptions { Compression = level, PrefetchWrite = prefetch }, async sheet => { await sheet.WriteRecordsAsync(items, (Action)Fill, ct); await sheet.WriteRecordsParallelAsync(items, Fill, Parallel(4), ct); @@ -345,5 +349,35 @@ await Assert.ThrowsAsync(async () => Assert.Equal(3, Read(ms.ToArray()).Count); } + + [Fact] + public async Task ABatchSizeBeyondTheRowLimitStillWrites() + { + CancellationToken ct = TestContext.Current.CancellationToken; + List items = Items(7); + byte[] package = await WriteAsync(XlsxWriterOptions.Default, async sheet => + await sheet.WriteRecordsParallelAsync(items, Fill, Parallel(2, int.MaxValue), ct)); + + Assert.Equal(7, Read(package).Count); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AFailingOutputSurfacesItsOwnException(bool prefetch) + { + CancellationToken ct = TestContext.Current.CancellationToken; + List items = Items(20_000); + using var target = new FailingWriteStream(limit: 4096); + XlsxWorkbookWriter wb = XlsxWorkbookWriter.Create(target, leaveOpen: true, new XlsxWriterOptions { PrefetchWrite = prefetch }); + XlsxSheetWriter sheet = wb.AddSheet("Data"); + + IOException failure = await Assert.ThrowsAsync( + async () => await sheet.WriteRecordsParallelAsync(items, Fill, Parallel(4), ct)); + + Assert.Equal("disk full", failure.Message); + Assert.True(sheet.ResourcesReleased); + await wb.DisposeAsync(); + } } } diff --git a/tests/ExcelReader.Tests/Writer/Parallel/ParallelXlsbRecordWriteTests.cs b/tests/ExcelReader.Tests/Writer/Parallel/ParallelXlsbRecordWriteTests.cs index ef7ef51c..693f117a 100644 --- a/tests/ExcelReader.Tests/Writer/Parallel/ParallelXlsbRecordWriteTests.cs +++ b/tests/ExcelReader.Tests/Writer/Parallel/ParallelXlsbRecordWriteTests.cs @@ -98,14 +98,17 @@ public async Task ParallelAsTheFirstWriteOnTheSheet() } [Theory] - [InlineData(CompressionLevel.Fastest)] - [InlineData(CompressionLevel.Optimal)] - [InlineData(CompressionLevel.NoCompression)] - public async Task SerialRowsBeforeAndAfterSurvive(CompressionLevel level) + [InlineData(CompressionLevel.Fastest, false)] + [InlineData(CompressionLevel.Optimal, false)] + [InlineData(CompressionLevel.NoCompression, false)] + [InlineData(CompressionLevel.Fastest, true)] + [InlineData(CompressionLevel.Optimal, true)] + [InlineData(CompressionLevel.NoCompression, true)] + public async Task SerialRowsBeforeAndAfterSurvive(CompressionLevel level, bool prefetch) { CancellationToken ct = TestContext.Current.CancellationToken; List items = ParallelRecordWriteTests.Items(1_300); - byte[] package = await WriteAsync(new XlsbWriterOptions { Compression = level }, async sheet => + byte[] package = await WriteAsync(new XlsbWriterOptions { Compression = level, PrefetchWrite = prefetch }, async sheet => { await sheet.WriteRecordsAsync(items, Fill, ct); await sheet.WriteRecordsParallelAsync(items, Fill, Parallel(4), ct); @@ -204,5 +207,24 @@ await Assert.ThrowsAsync( Assert.Throws(() => sheet.StartRow()); await wb.DisposeAsync(); } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AFailingOutputSurfacesItsOwnException(bool prefetch) + { + CancellationToken ct = TestContext.Current.CancellationToken; + List items = ParallelRecordWriteTests.Items(20_000); + using var target = new FailingWriteStream(limit: 4096); + XlsbWorkbookWriter wb = XlsbWorkbookWriter.Create(target, leaveOpen: true, new XlsbWriterOptions { PrefetchWrite = prefetch }); + XlsbSheetWriter sheet = wb.AddSheet("Data"); + + IOException failure = await Assert.ThrowsAsync( + async () => await sheet.WriteRecordsParallelAsync(items, Fill, Parallel(4), ct)); + + Assert.Equal("disk full", failure.Message); + Assert.True(sheet.ResourcesReleased); + await wb.DisposeAsync(); + } } } diff --git a/tests/ExcelReader.Tests/Writer/Zip/ZipEntrySegmentTests.cs b/tests/ExcelReader.Tests/Writer/Zip/ZipEntrySegmentTests.cs index bfc1f2d2..d53d8a89 100644 --- a/tests/ExcelReader.Tests/Writer/Zip/ZipEntrySegmentTests.cs +++ b/tests/ExcelReader.Tests/Writer/Zip/ZipEntrySegmentTests.cs @@ -125,5 +125,21 @@ public void AFailedAppendKillsTheEntryAndTheArchive() Assert.Throws(zip.Finish); zip.Abandon(); } + + [Fact] + public async Task AFailedAsyncAppendKillsTheEntryAndTheArchive() + { + CancellationToken ct = TestContext.Current.CancellationToken; + byte[] raw = DeflateEncoderTests.SheetXml(20_000); + var zip = new ZipPackageWriter(new FailingWriteStream(limit: 0)); + ZipEntryStream entry = zip.OpenEntry("sheet.xml", CompressionLevel.Fastest); + (byte[] segment, uint crc) = Segment(raw, CompressionLevel.Fastest); + + await Assert.ThrowsAsync(async () => await entry.AppendSegmentAsync(segment, crc, raw.Length, ct)); + await entry.DisposeAsync(); + await Assert.ThrowsAsync(async () => await entry.AppendSegmentAsync(segment, crc, raw.Length, ct)); + await Assert.ThrowsAsync(async () => await zip.FinishAsync(ct)); + zip.Abandon(); + } } } From fb49d58a4455beef45c226e38bc6a404517eae24 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 14:10:00 -0300 Subject: [PATCH 31/42] docs: say a cancelled parallel write ends the sheet and workbook --- docs/guide/writing.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/guide/writing.md b/docs/guide/writing.md index b2377f07..b3e52711 100644 --- a/docs/guide/writing.md +++ b/docs/guide/writing.md @@ -310,7 +310,8 @@ The rules: enumerated by one flow at a time, never concurrently. - **Rows may be written before and after the call, not during it.** Row numbering carries on, and the call can be made more than once on a sheet. -- **A failure faults the sheet**, as a failing row does in a serial write. The original exception is +- **A failure or a cancellation ends the sheet and the workbook.** A serial `WriteRecordsAsync` + cancelled between rows leaves the sheet usable; a parallel call does not. The original exception is rethrown once every batch in flight has stopped. - **With `UseSharedStrings`, string indexes depend on thread timing**, so two runs give files that differ byte for byte. Their content is the same. From 0d5e828deb4146f812d91fefce536f87b3be430a Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 14:14:38 -0300 Subject: [PATCH 32/42] docs: update the parallel write memory figures after pre-sizing worker buffers Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/performance/benchmarks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/performance/benchmarks.md b/docs/performance/benchmarks.md index b90636e2..67bce0fd 100644 --- a/docs/performance/benchmarks.md +++ b/docs/performance/benchmarks.md @@ -218,7 +218,7 @@ The encoder's limits are deliberate: It scales because a write is bound by building rows, not by deflate: `PrefetchWrite` already takes deflate off the calling thread and stops at 1.6x to 1.8x, so compressing chunks in parallel would add nothing on top of it, while formatting the rows in parallel does. -Each batch is compressed with no reference to the one before it, which costs 0.18% of file size for XLSX (15,481,372 B to 15,508,760 B) and 0.04% for XLSB (16,863,131 B to 16,869,306 B). The process's peak working set rose by 16 MB over the serial write with 4 batches in flight and by 28 MB with 8, about 3 MB per batch, and does not depend on the number of rows. +Each batch is compressed with no reference to the one before it, which costs 0.18% of file size for XLSX (15,481,372 B to 15,508,760 B) and 0.04% for XLSB (16,863,131 B to 16,869,306 B). The process's peak working set rose by 12 MB over the serial write with 4 batches in flight and by 23 MB with 8, about 3 MB per batch, and does not depend on the number of rows. ### Matched-work reads (`*_Materialized`) From 5a8a79468fde8b5c6d58f80e77ca17cd0847bd94 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 14:45:41 -0300 Subject: [PATCH 33/42] fix: adjust row limit checks and add test for exceeding row limit --- .../Writer/Xlsb/XlsbSheetWriter.cs | 4 ++-- .../Writer/Xlsb/XlsbWriterTests.cs | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/ExcelReader.Core/Writer/Xlsb/XlsbSheetWriter.cs b/src/ExcelReader.Core/Writer/Xlsb/XlsbSheetWriter.cs index 2e086496..6f592396 100644 --- a/src/ExcelReader.Core/Writer/Xlsb/XlsbSheetWriter.cs +++ b/src/ExcelReader.Core/Writer/Xlsb/XlsbSheetWriter.cs @@ -514,9 +514,9 @@ private void BeginRow(int styleId) WriterStateGuard.ThrowIfEnded(_state, this); WriterStateGuard.RequireNoActiveRowForStart(_rowActive, nameof(XlsbRowWriter)); EnsureStarted(); - if (_rowNumber >= ExcelLimits.MaxRows) + if (_rowNumber >= ExcelLimits.MaxRows - 1) { - ExcelLimits.ThrowRowLimit(_rowNumber + 1L); + ExcelLimits.ThrowRowLimit(_rowNumber + 2L); } _rowNumber++; _activeRowStyle = styleId; diff --git a/tests/ExcelReader.Tests/Writer/Xlsb/XlsbWriterTests.cs b/tests/ExcelReader.Tests/Writer/Xlsb/XlsbWriterTests.cs index 1f3e1c9e..b9dadaea 100644 --- a/tests/ExcelReader.Tests/Writer/Xlsb/XlsbWriterTests.cs +++ b/tests/ExcelReader.Tests/Writer/Xlsb/XlsbWriterTests.cs @@ -375,6 +375,20 @@ public async Task SkipBeyondColumnLimitThrows() Assert.Throws(() => row.Skip(16_385)); } + [Fact] + public void ARowBeyondTheRowLimitThrows() + { + using XlsbWorkbookWriter wb = XlsbWorkbookWriter.Create(Stream.Null, leaveOpen: true); + XlsbSheetWriter sheet = wb.AddSheet("Sheet1"); + for (int i = 0; i < 1_048_576; i++) + { + sheet.StartRow().Dispose(); + } + + ExcelLimitExceededException error = Assert.Throws(() => sheet.StartRow()); + Assert.Equal(1_048_577, error.Actual); + } + [Fact] public async Task WriteNonNumericFormattableThrowsArgumentException() { From 25f4857fdcd643d52c5aa299847db03f4880db85 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 14:58:48 -0300 Subject: [PATCH 34/42] fix: reject or escape writer input that Excel refuses or alters --- .../Writer/IWorkbookWriter.cs | 6 +- .../Writer/Internal/CellFormatter.cs | 14 +- .../Writer/Internal/SheetColumnValidation.cs | 10 +- .../Writer/Internal/StyleTable.cs | 26 +++ .../Writer/Internal/WriterStateGuard.cs | 20 ++ .../Writer/Xls/XlsSheetWriter.cs | 4 +- .../Writer/Xlsb/XlsbSheetWriter.cs | 4 +- .../Writer/Xlsx/XlsxSheetWriter.cs | 4 +- .../Writer/WriterEdgeValidationTests.cs | 194 ++++++++++++++++++ 9 files changed, 267 insertions(+), 15 deletions(-) create mode 100644 tests/ExcelReader.Tests/Writer/WriterEdgeValidationTests.cs diff --git a/src/ExcelReader.Core/Writer/IWorkbookWriter.cs b/src/ExcelReader.Core/Writer/IWorkbookWriter.cs index 176a7d42..f39559bf 100644 --- a/src/ExcelReader.Core/Writer/IWorkbookWriter.cs +++ b/src/ExcelReader.Core/Writer/IWorkbookWriter.cs @@ -77,6 +77,8 @@ public interface IWorkbookWriter : IDisposable, IAsyncDisposable /// style; index 1 is always the builtin date style every date cell already uses. /// /// The number format/bold/italic combination to register. + /// The style's number format is longer than 255 characters. + /// The workbook already has 200 distinct number formats, the most Excel opens. /// The style's index, for use with and . int AddStyle(CellStyle style); } @@ -131,7 +133,7 @@ public interface ISheetWriter : IDisposable, IAsyncDisposable /// /// The 0-based column index. /// The style to apply, from . - /// is negative, or is negative or was never returned by . + /// is negative or beyond the format's last column, or is negative or was never returned by . /// The first row has already been started. void SetColumnStyle(int columnIndex, int styleId); @@ -141,7 +143,7 @@ public interface ISheetWriter : IDisposable, IAsyncDisposable /// /// The 0-based column index. /// The column width, in characters. - /// or is negative. + /// is negative or beyond the format's last column, or is negative or greater than 255. /// The first row has already been started. void SetColumnWidth(int columnIndex, double width); diff --git a/src/ExcelReader.Core/Writer/Internal/CellFormatter.cs b/src/ExcelReader.Core/Writer/Internal/CellFormatter.cs index cb9040d1..88bd04ee 100644 --- a/src/ExcelReader.Core/Writer/Internal/CellFormatter.cs +++ b/src/ExcelReader.Core/Writer/Internal/CellFormatter.cs @@ -10,14 +10,17 @@ namespace ExcelReader.Core.Writer.Internal { internal static class CellFormatter { + // A raw carriage return is escaped too: an XML parser reads it as a line feed. private const string Special = "&<>\"'_" + - "\u0000\u0001\u0002\u0003\u0004\u0005\u0006\u0007\u0008\u000B\u000C" + + "\u0000\u0001\u0002\u0003\u0004\u0005\u0006\u0007\u0008\u000B\u000C\u000D" + "\u000E\u000F\u0010\u0011\u0012\u0013\u0014\u0015\u0016\u0017\u0018\u0019\u001A\u001B\u001C\u001D\u001E\u001F"; - private static readonly SearchValues SpecialChars = SearchValues.Create(Special); + // U+FFFE and U+FFFF are not XML characters, so they are written as _xHHHH_ as well. + private static readonly SearchValues SpecialChars = SearchValues.Create(Special + "\uFFFE\uFFFF"); - private static readonly SearchValues SpecialBytes = SearchValues.Create(Encoding.ASCII.GetBytes(Special)); + // 0xEF leads the UTF-8 form of U+FFFE and U+FFFF, and of every other U+F000..U+FFFF character. + private static readonly SearchValues SpecialBytes = SearchValues.Create([.. Encoding.ASCII.GetBytes(Special), 0xEF]); // private const int MaxCellOpenLength = 48; @@ -91,8 +94,9 @@ internal static void WritePlainUtf8String(BiffBuffer xml, ReadOnlySpan utf /// /// True when would emit 's own bytes unchanged: /// non-empty, within the cell limit, valid UTF-8, nothing to escape, and no whitespace at either - /// end. Every escaped character is ASCII, and UTF-8 multi-byte sequences never contain an ASCII - /// byte, so a byte search over the same set is exact. + /// end. Every escaped ASCII character is searched as its own byte, which no UTF-8 multi-byte + /// sequence contains; text with a character from U+F000 up is never plain, so that U+FFFE and + /// U+FFFF are escaped. /// internal static bool IsPlainUtf8Text(ReadOnlySpan utf8) { diff --git a/src/ExcelReader.Core/Writer/Internal/SheetColumnValidation.cs b/src/ExcelReader.Core/Writer/Internal/SheetColumnValidation.cs index 263d5690..b8015939 100644 --- a/src/ExcelReader.Core/Writer/Internal/SheetColumnValidation.cs +++ b/src/ExcelReader.Core/Writer/Internal/SheetColumnValidation.cs @@ -2,11 +2,15 @@ namespace ExcelReader.Core.Writer.Internal { internal static class SheetColumnValidation { + // Excel's widest column; BIFF8 stores the width in 16 bits as 256ths of a character. + private const double MaxColumnWidth = 255; + internal static void SetColumnStyle( - ref Dictionary? columnStyles, int columnIndex, int styleId, int styleCount, + ref Dictionary? columnStyles, int columnIndex, int styleId, int styleCount, int maxColumns, WriterState state, object writer) { ArgumentOutOfRangeException.ThrowIfNegative(columnIndex); + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(columnIndex, maxColumns); ArgumentOutOfRangeException.ThrowIfNegative(styleId); ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(styleId, styleCount); RequireNotStarted(state, writer); @@ -15,11 +19,13 @@ internal static void SetColumnStyle( } internal static void SetColumnWidth( - ref Dictionary? columnWidths, int columnIndex, double width, + ref Dictionary? columnWidths, int columnIndex, double width, int maxColumns, WriterState state, object writer) { ArgumentOutOfRangeException.ThrowIfNegative(columnIndex); + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(columnIndex, maxColumns); ArgumentOutOfRangeException.ThrowIfNegative(width); + ArgumentOutOfRangeException.ThrowIfGreaterThan(width, MaxColumnWidth); RequireNotStarted(state, writer); columnWidths ??= []; columnWidths[columnIndex] = width; diff --git a/src/ExcelReader.Core/Writer/Internal/StyleTable.cs b/src/ExcelReader.Core/Writer/Internal/StyleTable.cs index c0d7dfd4..b8a11dc7 100644 --- a/src/ExcelReader.Core/Writer/Internal/StyleTable.cs +++ b/src/ExcelReader.Core/Writer/Internal/StyleTable.cs @@ -1,4 +1,5 @@ using System.Runtime.InteropServices; +using ExcelReader.Core.Reader; namespace ExcelReader.Core.Writer.Internal { @@ -6,8 +7,14 @@ internal sealed class StyleTable { internal static readonly CellStyle DateStyle = new() { NumberFormat = "mm-dd-yy" }; + // Excel refuses a workbook with more custom number formats than this (its documented limit + // is "between 200 and 250"), and a BIFF12 format string longer than 255 characters. + private const int MaxCustomNumberFormats = 200; + private const int MaxNumberFormatLength = 255; + private readonly Dictionary _indices = []; private readonly List _styles = []; + private readonly HashSet _customFormats = new(StringComparer.Ordinal); internal StyleTable() { @@ -27,12 +34,31 @@ internal int Add(CellStyle style) { return index; } + if (style.NumberFormat is { Length: > MaxNumberFormatLength }) + { + throw new ArgumentException( + $"A number format cannot be longer than {MaxNumberFormatLength} characters ({style.NumberFormat.Length} chars).", nameof(style)); + } + ClaimNumberFormat(style.NumberFormat); index = _styles.Count; _styles.Add(style); _indices[style] = index; return index; } + private void ClaimNumberFormat(string? format) + { + if (format is null || _customFormats.Contains(format)) + { + return; + } + if (_customFormats.Count == MaxCustomNumberFormats) + { + throw new ExcelLimitExceededException("NumberFormats", MaxCustomNumberFormats, MaxCustomNumberFormats + 1L); + } + _customFormats.Add(format); + } + internal Dictionary AssignCustomNumberFormatIds() { var ids = new Dictionary(StringComparer.Ordinal); diff --git a/src/ExcelReader.Core/Writer/Internal/WriterStateGuard.cs b/src/ExcelReader.Core/Writer/Internal/WriterStateGuard.cs index 3e49c90c..700ae88e 100644 --- a/src/ExcelReader.Core/Writer/Internal/WriterStateGuard.cs +++ b/src/ExcelReader.Core/Writer/Internal/WriterStateGuard.cs @@ -72,6 +72,26 @@ private static void ValidateSheetName(string name) { throw new ArgumentException("Sheet names cannot contain : \\ / ? * [ or ].", nameof(name)); } + if (name[0] == '\'' || name[^1] == '\'') + { + throw new ArgumentException("Sheet names cannot begin or end with an apostrophe.", nameof(name)); + } + if (HasUnwritableCharacter(name)) + { + throw new ArgumentException("Sheet names cannot contain control characters, U+FFFE or U+FFFF.", nameof(name)); + } + } + + private static bool HasUnwritableCharacter(string name) + { + foreach (char c in name) + { + if (c < ' ' || c >= '\uFFFE') + { + return true; + } + } + return false; } } } diff --git a/src/ExcelReader.Core/Writer/Xls/XlsSheetWriter.cs b/src/ExcelReader.Core/Writer/Xls/XlsSheetWriter.cs index 8f517320..23af5443 100644 --- a/src/ExcelReader.Core/Writer/Xls/XlsSheetWriter.cs +++ b/src/ExcelReader.Core/Writer/Xls/XlsSheetWriter.cs @@ -82,7 +82,7 @@ internal XlsSheetWriter(XlsWorkbookWriter owner, string name, bool date1904, /// The sheet has already been started. public void SetColumnStyle(int columnIndex, int styleId) { - SheetColumnValidation.SetColumnStyle(ref _columnStyles, columnIndex, styleId, _owner.StyleCount, _state, this); + SheetColumnValidation.SetColumnStyle(ref _columnStyles, columnIndex, styleId, _owner.StyleCount, MaxColumn + 1, _state, this); } /// @@ -90,7 +90,7 @@ public void SetColumnStyle(int columnIndex, int styleId) /// The sheet has already been started. public void SetColumnWidth(int columnIndex, double width) { - SheetColumnValidation.SetColumnWidth(ref _columnWidths, columnIndex, width, _state, this); + SheetColumnValidation.SetColumnWidth(ref _columnWidths, columnIndex, width, MaxColumn + 1, _state, this); } private int EffectiveStyle(int columnIndex) diff --git a/src/ExcelReader.Core/Writer/Xlsb/XlsbSheetWriter.cs b/src/ExcelReader.Core/Writer/Xlsb/XlsbSheetWriter.cs index 6f592396..a4ad1730 100644 --- a/src/ExcelReader.Core/Writer/Xlsb/XlsbSheetWriter.cs +++ b/src/ExcelReader.Core/Writer/Xlsb/XlsbSheetWriter.cs @@ -115,7 +115,7 @@ internal IReadOnlyList Tables /// The first row has already been started. public void SetColumnStyle(int columnIndex, int styleId) { - SheetColumnValidation.SetColumnStyle(ref _columnStyles, columnIndex, styleId, _owner.StyleCount, _state, this); + SheetColumnValidation.SetColumnStyle(ref _columnStyles, columnIndex, styleId, _owner.StyleCount, ExcelLimits.MaxColumns, _state, this); } /// @@ -123,7 +123,7 @@ public void SetColumnStyle(int columnIndex, int styleId) /// The first row has already been started. public void SetColumnWidth(int columnIndex, double width) { - SheetColumnValidation.SetColumnWidth(ref _columnWidths, columnIndex, width, _state, this); + SheetColumnValidation.SetColumnWidth(ref _columnWidths, columnIndex, width, ExcelLimits.MaxColumns, _state, this); } private int EffectiveStyle(int columnIndex) diff --git a/src/ExcelReader.Core/Writer/Xlsx/XlsxSheetWriter.cs b/src/ExcelReader.Core/Writer/Xlsx/XlsxSheetWriter.cs index 54d45218..20340061 100644 --- a/src/ExcelReader.Core/Writer/Xlsx/XlsxSheetWriter.cs +++ b/src/ExcelReader.Core/Writer/Xlsx/XlsxSheetWriter.cs @@ -110,7 +110,7 @@ internal int GetColumnStyle(int columnIndex) /// The first row has already been started. public void SetColumnStyle(int columnIndex, int styleId) { - SheetColumnValidation.SetColumnStyle(ref _columnStyles, columnIndex, styleId, _owner.StyleCount, _state, this); + SheetColumnValidation.SetColumnStyle(ref _columnStyles, columnIndex, styleId, _owner.StyleCount, ExcelLimits.MaxColumns, _state, this); } /// @@ -118,7 +118,7 @@ public void SetColumnStyle(int columnIndex, int styleId) /// The first row has already been started. public void SetColumnWidth(int columnIndex, double width) { - SheetColumnValidation.SetColumnWidth(ref _columnWidths, columnIndex, width, _state, this); + SheetColumnValidation.SetColumnWidth(ref _columnWidths, columnIndex, width, ExcelLimits.MaxColumns, _state, this); } private void EnsureStarted() diff --git a/tests/ExcelReader.Tests/Writer/WriterEdgeValidationTests.cs b/tests/ExcelReader.Tests/Writer/WriterEdgeValidationTests.cs new file mode 100644 index 00000000..da72edd3 --- /dev/null +++ b/tests/ExcelReader.Tests/Writer/WriterEdgeValidationTests.cs @@ -0,0 +1,194 @@ +using System.IO.Compression; +using System.Text; +using ExcelReader.Core.Reader; +using ExcelReader.Core.Writer; +using ExcelReader.Core.Writer.Xls; +using ExcelReader.Core.Writer.Xlsb; +using ExcelReader.Core.Writer.Xlsx; + +namespace ExcelReader.Tests.Writer +{ + /// + /// Inputs the writers used to accept and Excel then refused or altered. Each was confirmed against + /// Excel 16; these tests pin the writer side. + /// + public sealed class WriterEdgeValidationTests + { + public static TheoryData Formats => ["xlsx", "xlsb", "xls"]; + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void XlsxEscapesCarriageReturnsAndNonCharacters(bool sharedStrings) + { + string[] texts = ["a\rb", "a\r\nb", "\r", "a￾b", "a￿b", "plain"]; + using MemoryStream ms = new(); + using (XlsxWorkbookWriter wb = XlsxWorkbookWriter.Create(ms, leaveOpen: true, new XlsxWriterOptions { UseSharedStrings = sharedStrings })) + { + XlsxSheetWriter sheet = wb.AddSheet("S1"); + using (XlsxRowWriter row = sheet.StartRow()) + { + foreach (string text in texts) + { + row.Write(text); + } + row.WriteUtf8(Encoding.UTF8.GetBytes("u￾v")); + } + sheet.End(); + wb.End(); + } + + byte[] xml = Entry(ms.ToArray(), sharedStrings ? "xl/sharedStrings.xml" : "xl/worksheets/sheet1.xml"); + Assert.DoesNotContain((byte)'\r', xml); + Assert.Equal(-1, xml.AsSpan().IndexOf("￾"u8)); + Assert.Equal(-1, xml.AsSpan().IndexOf("￿"u8)); + Assert.True(xml.AsSpan().IndexOf("a_x000D_b"u8) >= 0); + Assert.True(xml.AsSpan().IndexOf("a_xFFFE_b"u8) >= 0); + Assert.True(xml.AsSpan().IndexOf("a_xFFFF_b"u8) >= 0); + + using IExcelWorkbook read = Excel.Open(ms.ToArray()); + using IExcelRowEnumerator rows = read.FirstSheet.GetEnumerator(); + Assert.True(rows.MoveNext()); + for (int i = 0; i < texts.Length; i++) + { + Assert.Equal(texts[i], rows.Current[i].GetString()); + } + Assert.Equal("u￾v", rows.Current[texts.Length].GetString()); + } + + [Theory] + [MemberData(nameof(Formats))] + public void SheetNamesExcelRefusesAreRejected(string format) + { + foreach (string name in new[] { "'a", "a'", "'", "a\u0001b", "a\tb", "a\nb", "a￾b", "a￿b" }) + { + Assert.Throws(() => WithWorkbook(format, wb => wb.AddSheet(name))); + } + WithWorkbook(format, wb => wb.AddSheet("it's")); + } + + [Theory] + [MemberData(nameof(Formats))] + public void ColumnSettingsBeyondTheFormatAreRejected(string format) + { + int columns = string.Equals(format, "xls", StringComparison.Ordinal) ? 256 : 16_384; + Assert.Throws(() => WithWorkbook(format, wb => wb.Width(columns, 10))); + Assert.Throws(() => WithWorkbook(format, wb => wb.Style(columns))); + Assert.Throws(() => WithWorkbook(format, wb => wb.Width(0, 255.5))); + Assert.Throws(() => WithWorkbook(format, wb => wb.Width(0, double.PositiveInfinity))); + WithWorkbook(format, wb => + { + wb.Width(columns - 1, 255); + wb.Style(columns - 1); + }); + } + + [Theory] + [MemberData(nameof(Formats))] + public void NumberFormatsStopAtWhatExcelOpens(string format) + { + WithWorkbook(format, wb => + { + for (int i = 0; i < 200; i++) + { + wb.AddStyle(new CellStyle { NumberFormat = $"0.00\"u{i}\"" }); + } + wb.AddStyle(new CellStyle { NumberFormat = "0.00\"u7\"", Bold = true }); + ExcelLimitExceededException error = Assert.Throws( + () => wb.AddStyle(new CellStyle { NumberFormat = "0.000" })); + Assert.Equal(200, error.Limit); + wb.AddSheet("S1"); + }); + Assert.Throws( + () => WithWorkbook(format, wb => wb.AddStyle(new CellStyle { NumberFormat = new string('0', 256) }))); + WithWorkbook(format, wb => + { + wb.AddStyle(new CellStyle { NumberFormat = new string('0', 255) }); + wb.AddSheet("S1"); + }); + } + + private static void WithWorkbook(string format, Action body) + { + using MemoryStream ms = new(); + Probe probe = format switch + { + "xlsx" => Probe.Of(XlsxWorkbookWriter.Create(ms, leaveOpen: true)), + "xlsb" => Probe.Of(XlsbWorkbookWriter.Create(ms, leaveOpen: true)), + _ => Probe.Of(XlsWorkbookWriter.Create(ms, leaveOpen: true)), + }; + try + { + body(probe); + } + finally + { + probe.Abandon(); + } + } + + private static byte[] Entry(byte[] package, string name) + { + using var zip = new ZipArchive(new MemoryStream(package), ZipArchiveMode.Read); + using Stream data = zip.GetEntry(name)!.Open(); + using var copy = new MemoryStream(); + data.CopyTo(copy); + return copy.ToArray(); + } + + /// The few workbook calls these tests make, over any of the three writers. + private sealed class Probe + { + private Action _addSheet = static _ => { }; + private Action _width = static (_, _) => { }; + private Action _style = static _ => { }; + private Func _addStyle = static _ => 0; + private Action _abandon = static () => { }; + + internal static Probe Of(TWorkbook workbook) + where TWorkbook : IWorkbookWriter, IDisposable + where TSheet : ISheetWriter + where TRow : IRowWriter + { + return new Probe + { + _addSheet = name => workbook.AddSheet(name).End(), + _width = (column, width) => + { + TSheet sheet = workbook.AddSheet("S1"); + sheet.SetColumnWidth(column, width); + sheet.End(); + }, + _style = column => + { + TSheet sheet = workbook.AddSheet("S2"); + sheet.SetColumnStyle(column, 1); + sheet.End(); + }, + _addStyle = workbook.AddStyle, + _abandon = () => + { + try + { + workbook.Dispose(); + } + catch (InvalidOperationException) + { + // A workbook left without a finished sheet refuses to end; the probe only checks arguments. + } + }, + }; + } + + internal void AddSheet(string name) => _addSheet(name); + + internal void Width(int column, double width) => _width(column, width); + + internal void Style(int column) => _style(column); + + internal int AddStyle(CellStyle style) => _addStyle(style); + + internal void Abandon() => _abandon(); + } + } +} From f93ab72e1458f9874182ebac5cf020981b789a6f Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 14:59:35 -0300 Subject: [PATCH 35/42] refactor: improve readability by expanding single-line methods into multi-line format --- .../Reader/Internal/FastDouble.cs | 10 ++++++-- .../Read/XlsxSharedStringHotPathBenchmark.cs | 25 +++++++++++++++---- .../Native/CallbackSourceTests.cs | 22 ++++++++++------ .../Native/CallbackStreamTests.cs | 5 +++- .../ParallelCsv/AggregateCsvModelTests.cs | 18 ++++++++++--- .../Parser/SheetPipeline/XlsxPipelineTests.cs | 22 +++++++++++++--- .../Writer/WriterEdgeValidationTests.cs | 25 +++++++++++++++---- 7 files changed, 99 insertions(+), 28 deletions(-) diff --git a/src/ExcelReader.Core/Reader/Internal/FastDouble.cs b/src/ExcelReader.Core/Reader/Internal/FastDouble.cs index 1051fb16..f602ec77 100644 --- a/src/ExcelReader.Core/Reader/Internal/FastDouble.cs +++ b/src/ExcelReader.Core/Reader/Internal/FastDouble.cs @@ -12,10 +12,16 @@ internal static class FastDouble private const int MaxPowerOfFive = 64; [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool TryParse(ReadOnlySpan s, out double value) => TryParse(s, MaxMantissaDigits, out value); + public static bool TryParse(ReadOnlySpan s, out double value) + { + return TryParse(s, MaxMantissaDigits, out value); + } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool TryParseLossless(ReadOnlySpan s, out double value) => TryParse(s, MaxLosslessDigits, out value); + public static bool TryParseLossless(ReadOnlySpan s, out double value) + { + return TryParse(s, MaxLosslessDigits, out value); + } [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool TryParse(ReadOnlySpan s, int maxDigits, out double value) diff --git a/tests/ExcelReader.Benchmarks/Read/XlsxSharedStringHotPathBenchmark.cs b/tests/ExcelReader.Benchmarks/Read/XlsxSharedStringHotPathBenchmark.cs index e7354893..4833186b 100644 --- a/tests/ExcelReader.Benchmarks/Read/XlsxSharedStringHotPathBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/Read/XlsxSharedStringHotPathBenchmark.cs @@ -68,19 +68,34 @@ private static bool Prologue(byte[] workbook) } [Benchmark(Baseline = true)] - public long EndToEnd() => ReadAll(_xlsx, null); + public long EndToEnd() + { + return ReadAll(_xlsx, null); + } [Benchmark] - public long EndToEnd_Prefetch() => ReadAll(_xlsx, _prefetchOptions); + public long EndToEnd_Prefetch() + { + return ReadAll(_xlsx, _prefetchOptions); + } [Benchmark] - public bool SharedStringPrologue() => Prologue(_xlsx); + public bool SharedStringPrologue() + { + return Prologue(_xlsx); + } [Benchmark] - public long Stored_EndToEnd() => ReadAll(_stored, null); + public long Stored_EndToEnd() + { + return ReadAll(_stored, null); + } [Benchmark] - public bool Stored_SharedStringPrologue() => Prologue(_stored); + public bool Stored_SharedStringPrologue() + { + return Prologue(_stored); + } [Benchmark] public long IndexParse_Utf8Parser() diff --git a/tests/ExcelReader.Tests/Native/CallbackSourceTests.cs b/tests/ExcelReader.Tests/Native/CallbackSourceTests.cs index 29cc16c6..602084ca 100644 --- a/tests/ExcelReader.Tests/Native/CallbackSourceTests.cs +++ b/tests/ExcelReader.Tests/Native/CallbackSourceTests.cs @@ -24,14 +24,17 @@ private sealed class Pinned(TestSource source) : IDisposable { private GCHandle _handle = GCHandle.Alloc(source); - internal NativeSourceRaw Raw(long? length = null) => new() + internal NativeSourceRaw Raw(long? length = null) { - StructSize = sizeof(NativeSourceRaw), - UserData = (void*)GCHandle.ToIntPtr(_handle), - Length = length ?? source.Bytes.Length, - ReadAt = &ReadAt, - Release = &Release, - }; + return new() + { + StructSize = sizeof(NativeSourceRaw), + UserData = (void*)GCHandle.ToIntPtr(_handle), + Length = length ?? source.Bytes.Length, + ReadAt = &ReadAt, + Release = &Release, + }; + } public void Dispose() { @@ -72,7 +75,10 @@ private static int Open(Pinned pinned, int format, out NativeHandle? handle, Nat return ReadApi.OpenSource(source!, format, options, out handle); } - private static NativeOpenOptionsRaw Options() => new() { StructSize = sizeof(NativeOpenOptionsRaw) }; + private static NativeOpenOptionsRaw Options() + { + return new() { StructSize = sizeof(NativeOpenOptionsRaw) }; + } private static byte[] BuildPrefetchedXlsx() { diff --git a/tests/ExcelReader.Tests/Native/CallbackStreamTests.cs b/tests/ExcelReader.Tests/Native/CallbackStreamTests.cs index 4fac787a..97bc5e37 100644 --- a/tests/ExcelReader.Tests/Native/CallbackStreamTests.cs +++ b/tests/ExcelReader.Tests/Native/CallbackStreamTests.cs @@ -66,7 +66,10 @@ private static int Open(Pinned pinned, int format, out NativeHandle? handle, Nat return ReadApi.OpenStream(stream!, format, options, out handle); } - private static NativeOpenOptionsRaw Options() => new() { StructSize = sizeof(NativeOpenOptionsRaw) }; + private static NativeOpenOptionsRaw Options() + { + return new() { StructSize = sizeof(NativeOpenOptionsRaw) }; + } private static int CountRows(NativeHandle handle, int sheet) { diff --git a/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvModelTests.cs b/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvModelTests.cs index 4c108968..96a14747 100644 --- a/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvModelTests.cs +++ b/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvModelTests.cs @@ -356,9 +356,21 @@ public struct WideRow : IEquatable { public int Last { get; set; } - public override bool Equals(object? obj) => obj is WideRow row && Equals(row); - public readonly bool Equals(WideRow other) => Last == other.Last; - public override readonly int GetHashCode() => Last.GetHashCode(); + public override bool Equals(object? obj) + { + return obj is WideRow row && Equals(row); + } + + public readonly bool Equals(WideRow other) + { + return Last == other.Last; + } + + public override readonly int GetHashCode() + { + return Last.GetHashCode(); + } + public static bool operator ==(WideRow left, WideRow right) => left.Equals(right); public static bool operator !=(WideRow left, WideRow right) => !left.Equals(right); } diff --git a/tests/ExcelReader.Tests/Parser/SheetPipeline/XlsxPipelineTests.cs b/tests/ExcelReader.Tests/Parser/SheetPipeline/XlsxPipelineTests.cs index f58ccccd..e2621211 100644 --- a/tests/ExcelReader.Tests/Parser/SheetPipeline/XlsxPipelineTests.cs +++ b/tests/ExcelReader.Tests/Parser/SheetPipeline/XlsxPipelineTests.cs @@ -99,7 +99,10 @@ private sealed class CancelAfterArmedStream(Stream inner) : Stream { private long _remaining = long.MaxValue; - public void Arm(long budget) => _remaining = budget; + public void Arm(long budget) + { + _remaining = budget; + } public override bool CanRead => inner.CanRead; public override bool CanSeek => inner.CanSeek; @@ -136,9 +139,20 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation 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(); + public override long Seek(long offset, SeekOrigin origin) + { + return 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) { diff --git a/tests/ExcelReader.Tests/Writer/WriterEdgeValidationTests.cs b/tests/ExcelReader.Tests/Writer/WriterEdgeValidationTests.cs index da72edd3..188dc72b 100644 --- a/tests/ExcelReader.Tests/Writer/WriterEdgeValidationTests.cs +++ b/tests/ExcelReader.Tests/Writer/WriterEdgeValidationTests.cs @@ -180,15 +180,30 @@ internal static Probe Of(TWorkbook workbook) }; } - internal void AddSheet(string name) => _addSheet(name); + internal void AddSheet(string name) + { + _addSheet(name); + } - internal void Width(int column, double width) => _width(column, width); + internal void Width(int column, double width) + { + _width(column, width); + } - internal void Style(int column) => _style(column); + internal void Style(int column) + { + _style(column); + } - internal int AddStyle(CellStyle style) => _addStyle(style); + internal int AddStyle(CellStyle style) + { + return _addStyle(style); + } - internal void Abandon() => _abandon(); + internal void Abandon() + { + _abandon(); + } } } } From 0394099f550b86360f714f1511cc12b427a0cd3d Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 15:27:17 -0300 Subject: [PATCH 36/42] feat: verify ZIP entry checksums on read; unify writer exceptions --- README.md | 4 +- docs/guide/migrating-to-v7.md | 13 ++ docs/guide/untrusted-input.md | 11 ++ .../PublicAPI/PublicAPI.Unshipped.txt | 2 + .../Reader/ExcelLimitExceededException.cs | 10 +- .../Reader/ExcelReaderOptions.cs | 7 + .../Reader/Internal/WorkbookLookups.cs | 6 +- .../Reader/Zip/LimitedReadStream.cs | 30 +++- src/ExcelReader.Core/Reader/Zip/ZipIndex.cs | 31 +++- .../Writer/Internal/CellValueGuards.cs | 2 +- .../Writer/Xlsb/XlsbRowWriter.cs | 25 ++- tests/ExcelReader.Fuzz/Harnesses.cs | 3 + .../Reader/Zip/ZipChecksumTests.cs | 153 ++++++++++++++++++ .../Reader/Zip/ZipIndexTests.cs | 14 +- .../Writer/WriterEdgeValidationTests.cs | 41 +++++ 15 files changed, 323 insertions(+), 29 deletions(-) create mode 100644 tests/ExcelReader.Tests/Reader/Zip/ZipChecksumTests.cs diff --git a/README.md b/README.md index 1d410915..9e48640f 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ foreach (Row row in workbook.FirstSheet) - [Reading rows](docs/guide/reading.md) — opening a workbook, enumerating rows, auto-detecting the format, async reads, prefetch decompression, and the ADO.NET `IDataReader` bridge. - [Typed parsing](docs/guide/parsing.md) — binding rows to your own types with attributes, the compile-time generator (Native AOT / trimming), the fluent API, required columns, custom converters, and the zero-copy `ref struct` path. -- [Writing workbooks](docs/guide/writing.md) — XLSX, XLSB (BIFF12) and XLS (BIFF8) writers, cell styles, typed records, and prefetch compression. +- [Writing workbooks](docs/guide/writing.md) — XLSX, XLSB (BIFF12) and XLS (BIFF8) writers, cell styles, typed records, prefetch compression, and parallel record writes. - [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. @@ -85,8 +85,10 @@ errors to stderr, so `convert` is safe to pipe. - String conversion allocates only when you call `GetString()`. - The XLSX scanner accepts the SpreadsheetML shapes commonly emitted by non-Excel producers, including single-quoted attributes, comments in `sheetData`, and CDATA text runs. - Readers bound untrusted input by default: 512 MB total decompressed ZIP data, 32 MB per cell/row value buffer, and 128 MB for shared strings. Pass `ExcelReaderOptions` to the `Excel.From*`/`Excel.Open*` factories to tune these limits; set a limit to `0` to opt out and restore unlimited behavior for that limit. `CsvReader` has its own `CsvReaderOptions.MaxCellBytes` (default 32 MB) for the same purpose. +- Each ZIP entry of an XLSX or XLSB package is checked against its CRC-32 as it is read, so a damaged file throws `InvalidDataException` instead of returning wrong cells. `ExcelReaderOptions.VerifyZipChecksums = false` turns the check off. - The XLSX writer emits a compact workbook with strings, numbers, booleans, dates, and blank cells; shared strings are opt-in. - The XLSB writer emits BIFF12 workbook parts inside the standard XLSB ZIP package; shared strings are opt-in. +- `WriteRecordsParallelAsync` and `WriteTableParallelAsync` format and compress batches of records on several cores and write them in order, for XLSX and XLSB. On a Ryzen 7 5700X a 1,000,000-row sheet writes in 50 ms as XLSX with 4 batches in flight and 36 ms with 8, against 187 ms serially; XLSB takes 41 ms and 28 ms against 124 ms. Memory stays at about 3 MB per batch in flight. See [Write records in parallel](docs/guide/writing.md#write-records-in-parallel-xlsxxlsb) and the [benchmarks](docs/performance/benchmarks.md#parallel-record-writes). - The XLS writer buffers records in memory and assembles the OLE container at `EndAsync`; choose it when write throughput matters more than peak allocation. - `Excel.EncryptPackage`/`EncryptPackageAsync` wrap a written XLSX/XLSB package in agile ECMA-376 encryption; see [Encrypted workbooks](docs/guide/encryption.md). diff --git a/docs/guide/migrating-to-v7.md b/docs/guide/migrating-to-v7.md index 1a73bf9d..0d25efd7 100644 --- a/docs/guide/migrating-to-v7.md +++ b/docs/guide/migrating-to-v7.md @@ -133,6 +133,19 @@ Two behavior changes come with them: - The CLI `convert` command no longer shifts dates by four years when the source workbook uses the 1904 date system. - [`XlsxWriterOptions`](xref:ExcelReader.Core.Writer.Xlsx.XlsxWriterOptions) gains `Date1904`, which v6 lacked. It defaults to `false`, so existing output is unchanged. +## Stricter reading and writing + +v7 refuses several inputs that v6 accepted and then got wrong. + +- **Damaged packages.** Each ZIP entry of an XLSX or XLSB package is checked against its CRC-32. A mismatch throws `InvalidDataException` when the entry's last byte is read, so rows before the damage may already have been returned. v6 checked only the entry's size. Set [`ExcelReaderOptions.VerifyZipChecksums`](xref:ExcelReader.Core.Reader.ExcelReaderOptions.VerifyZipChecksums) to `false` to read a package whose producer wrote wrong checksums. The check costs 2% to 5% of a read. +- **Sheet names.** `AddSheet` throws `ArgumentException` for a name that begins or ends with an apostrophe, or contains a control character (tab and line feed included), U+FFFE or U+FFFF. Excel refuses a workbook with such a name. +- **Column settings.** `SetColumnWidth` and `SetColumnStyle` throw `ArgumentOutOfRangeException` for a column past the format's last one (16,384 columns; 256 for XLS) and for a width above 255, Excel's maximum. v6 wrote a file Excel refused for the first, and an XLS width of 256 came out as a hidden column. +- **Number formats.** `AddStyle` throws `ExcelLimitExceededException` for the 201st distinct number format of a workbook, and `ArgumentException` for a format longer than 255 characters. Excel does not open a workbook past either. +- **Rows in XLSB.** A sheet stops at 1,048,576 rows. v6 accepted one more, which Excel does not read. +- **Values with no numeric form.** `Write` with a type that does not convert to a number, such as `char`, throws `ArgumentException` in every writer. XLSB and XLS threw `InvalidCastException`. +- **Text in XLSX.** A carriage return, U+FFFE and U+FFFF in cell text are now written escaped. v6 wrote them raw: Excel read the carriage return as a line feed and refused a file with either of the other two. +- **`ExcelLimitExceededException.Message`** no longer says "bytes" for limits counted in rows, columns or entries. `LimitName`, `Limit` and `Actual` are 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. diff --git a/docs/guide/untrusted-input.md b/docs/guide/untrusted-input.md index ce61e4fa..5922d20d 100644 --- a/docs/guide/untrusted-input.md +++ b/docs/guide/untrusted-input.md @@ -110,6 +110,17 @@ bytes. Anything else escaping a read of a file — an `IndexOutOfRangeException` [SECURITY.md](../../SECURITY.md). See [Errors across languages](errors.md) for how these map onto the bindings. +## Damaged packages + +An XLSX or XLSB package is a ZIP archive, and each of its entries carries a CRC-32 of its data. +The reader checks it as the entry is read and throws `InvalidDataException` on a mismatch, so a file +cut short or altered in transit does not come back as plausible but wrong cells. A sheet is checked +when its last byte is read, which means the rows before the damage may already have been returned: +treat the exception as invalidating everything read from that file. The checksum guards against +accidents, not tampering, since whoever alters the data can recompute it. +[`ExcelReaderOptions.VerifyZipChecksums`](xref:ExcelReader.Core.Reader.ExcelReaderOptions.VerifyZipChecksums) +turns the check off. + ## What the reader does not do - **It evaluates nothing.** A formula cell reports the result Excel cached when it saved the file, diff --git a/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt b/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt index de7a9974..5d3628e0 100644 --- a/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt @@ -160,3 +160,5 @@ 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! +ExcelReader.Core.Reader.ExcelReaderOptions.VerifyZipChecksums.get -> bool +ExcelReader.Core.Reader.ExcelReaderOptions.VerifyZipChecksums.init -> void diff --git a/src/ExcelReader.Core/Reader/ExcelLimitExceededException.cs b/src/ExcelReader.Core/Reader/ExcelLimitExceededException.cs index f8dc22c6..ade3e303 100644 --- a/src/ExcelReader.Core/Reader/ExcelLimitExceededException.cs +++ b/src/ExcelReader.Core/Reader/ExcelLimitExceededException.cs @@ -29,10 +29,10 @@ public ExcelLimitExceededException(string message, Exception innerException) /// Initializes a new instance of the class for a specific exceeded limit, building the message from the given values and populating , , and . /// The name of the exceeded limit (e.g. the corresponding property name). - /// The configured limit, in bytes. - /// The actual size, in bytes, that exceeded the limit. + /// The limit, in the unit the named limit is expressed in (bytes, rows, columns, entries, ...). + /// The value, in the same unit, that exceeded the limit. public ExcelLimitExceededException(string limitName, long limit, long actual) - : base($"{limitName} limit exceeded: {actual} bytes exceeds {limit} bytes.") + : base($"{limitName} limit exceeded: {actual} exceeds {limit}.") { LimitName = limitName; Limit = limit; @@ -42,10 +42,10 @@ public ExcelLimitExceededException(string limitName, long limit, long actual) /// Gets the name of the limit that was exceeded. public string LimitName { get; } = string.Empty; - /// Gets the configured limit, in bytes, that was exceeded. + /// Gets the limit that was exceeded, in the unit is expressed in. public long Limit { get; } - /// Gets the actual size, in bytes, that exceeded . + /// Gets the value, in the same unit, that exceeded . public long Actual { get; } } } diff --git a/src/ExcelReader.Core/Reader/ExcelReaderOptions.cs b/src/ExcelReader.Core/Reader/ExcelReaderOptions.cs index 3e4f5470..111858cb 100644 --- a/src/ExcelReader.Core/Reader/ExcelReaderOptions.cs +++ b/src/ExcelReader.Core/Reader/ExcelReaderOptions.cs @@ -35,6 +35,13 @@ public sealed record ExcelReaderOptions /// with work already saturating the CPU. public bool PrefetchDecompression { get; init; } + /// Gets a value indicating whether each ZIP entry read from an XLSX or XLSB package is + /// checked against the CRC-32 its central directory records. Defaults to . + /// A mismatch throws when the entry's last byte is read, so + /// rows before the damage may already have been returned. Turn it off to read a package whose + /// producer wrote wrong checksums; damaged data then surfaces only if it also breaks parsing. + public bool VerifyZipChecksums { get; init; } = true; + /// /// 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 diff --git a/src/ExcelReader.Core/Reader/Internal/WorkbookLookups.cs b/src/ExcelReader.Core/Reader/Internal/WorkbookLookups.cs index 8d13eb95..ea2b7b54 100644 --- a/src/ExcelReader.Core/Reader/Internal/WorkbookLookups.cs +++ b/src/ExcelReader.Core/Reader/Internal/WorkbookLookups.cs @@ -81,13 +81,13 @@ internal static ZipEntryRef GetWorksheetEntry(ZipIndex memZip, string path) internal static LimitedReadStream Wrap( Stream opened, DecompressedByteCounter counter, ExcelReaderOptions options, - string entryLimitName, long entryLimit, long uncompressedSize) + string entryLimitName, long entryLimit, long uncompressedSize, uint? expectedCrc = null) { if (!options.PrefetchDecompression || uncompressedSize < PrefetchMinUncompressedSize) { - return new LimitedReadStream(opened, uncompressedSize, counter, entryLimitName, entryLimit); + return new LimitedReadStream(opened, uncompressedSize, counter, entryLimitName, entryLimit, expectedCrc); } - return new LimitedReadStream(new PrefetchStream(opened), uncompressedSize, counter, entryLimitName, entryLimit); + return new LimitedReadStream(new PrefetchStream(opened), uncompressedSize, counter, entryLimitName, entryLimit, expectedCrc); } 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 b805477c..fff4ad26 100644 --- a/src/ExcelReader.Core/Reader/Zip/LimitedReadStream.cs +++ b/src/ExcelReader.Core/Reader/Zip/LimitedReadStream.cs @@ -1,3 +1,5 @@ +using ExcelReader.Core.Writer.Zip; + namespace ExcelReader.Core.Reader.Zip { internal sealed class DecompressedByteCounter @@ -37,24 +39,31 @@ internal void Add(long bytes) internal sealed class LimitedReadStream : Stream { + internal const string ChecksumMismatch = "The ZIP entry's data does not match its CRC-32."; + private readonly Stream _inner; private readonly DecompressedByteCounter? _totalCounter; private readonly DecompressedByteCounter? _entryCounter; private readonly long _declaredLength; + private readonly uint? _expectedCrc; private long _produced; + private uint _crc; // 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. + // expectedCrc, when given, is compared once the declared length has been produced. internal LimitedReadStream( Stream inner, long declaredLength, DecompressedByteCounter? totalCounter, string entryLimitName = "", - long entryLimit = 0) + long entryLimit = 0, + uint? expectedCrc = null) { _inner = inner; _declaredLength = declaredLength; _totalCounter = totalCounter; + _expectedCrc = expectedCrc; _entryCounter = entryLimit > 0 ? new DecompressedByteCounter(entryLimit, entryLimitName) : null; } @@ -76,17 +85,17 @@ public override void Flush() public override int Read(byte[] buffer, int offset, int count) { - return Count(_inner.Read(buffer, offset, count), count); + return Count(_inner.Read(buffer, offset, count), buffer.AsSpan(offset, count)); } public override int Read(Span buffer) { - return Count(_inner.Read(buffer), buffer.Length); + return Count(_inner.Read(buffer), buffer); } public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) { - return Count(await _inner.ReadAsync(buffer, cancellationToken).ConfigureAwait(false), buffer.Length); + return Count(await _inner.ReadAsync(buffer, cancellationToken).ConfigureAwait(false), buffer.Span); } public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) @@ -124,11 +133,11 @@ public override async ValueTask DisposeAsync() await base.DisposeAsync().ConfigureAwait(false); } - private int Count(int read, int requested) + private int Count(int read, ReadOnlySpan buffer) { if (read <= 0) { - if (requested > 0 && _produced != _declaredLength) + if (buffer.Length > 0 && _produced != _declaredLength) { throw new InvalidDataException("The ZIP entry produced less data than its declared uncompressed size."); } @@ -141,6 +150,15 @@ private int Count(int read, int requested) } _totalCounter?.Add(read); _entryCounter?.Add(read); + if (_expectedCrc is not uint expected) + { + return read; + } + _crc = Crc32.Append(_crc, buffer[..read]); + if (_produced == _declaredLength && _crc != expected) + { + throw new InvalidDataException(ChecksumMismatch); + } return read; } } diff --git a/src/ExcelReader.Core/Reader/Zip/ZipIndex.cs b/src/ExcelReader.Core/Reader/Zip/ZipIndex.cs index a796ef9d..e1215363 100644 --- a/src/ExcelReader.Core/Reader/Zip/ZipIndex.cs +++ b/src/ExcelReader.Core/Reader/Zip/ZipIndex.cs @@ -1,9 +1,11 @@ using System.Buffers; using System.Buffers.Binary; +using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; using ExcelReader.Core.Reader.Internal; using ExcelReader.Core.Reader.Sources; using ExcelReader.Core.Reader.Zip.Inflate; +using ExcelReader.Core.Writer.Zip; namespace ExcelReader.Core.Reader.Zip { @@ -15,6 +17,7 @@ internal readonly struct ZipEntryRef internal long LocalHeaderOffset { get; init; } internal long CompressedSize { get; init; } internal long UncompressedSize { get; init; } + internal uint Crc { get; init; } internal ushort Method { get; init; } internal ushort Flags { get; init; } } @@ -65,9 +68,11 @@ internal sealed class ZipIndex : IDisposable private readonly ReadOnlyMemory _directory; private readonly byte[]? _directoryRented; private readonly ZipEntryRef[] _entries; + private readonly bool _verifyChecksums; private int _disposed; - private ZipIndex(ByteSource source, ReadOnlyMemory directory, byte[]? directoryRented, ZipEntryRef[] entries, int count) + private ZipIndex( + ByteSource source, ReadOnlyMemory directory, byte[]? directoryRented, ZipEntryRef[] entries, int count, bool verifyChecksums) { _source = source; HasMemory = source.TryGetMemory(out _file); @@ -75,6 +80,7 @@ private ZipIndex(ByteSource source, ReadOnlyMemory directory, byte[]? dire _directoryRented = directoryRented; _entries = entries; Count = count; + _verifyChecksums = verifyChecksums; } internal int Count { get; } @@ -237,7 +243,7 @@ private static ZipIndex Build(ByteSource source, ReadOnlyMemory directory, { int count = WalkCentralDirectory(directory.Span, ref entries, options); ThrowIfDuplicateEntryNames(directory, entries, count); - return new ZipIndex(source, directory, directoryRented, entries, count); + return new ZipIndex(source, directory, directoryRented, entries, count, options.VerifyZipChecksums); } catch { @@ -335,6 +341,11 @@ internal ZipPart OpenPart(in ZipEntryRef entry, DecompressedByteCounter counter, { part = ReadPart(dataOffset, entry); } + if (_verifyChecksums && Crc32.Append(0, part.Memory.Span) != entry.Crc) + { + part.Dispose(); + ThrowChecksumMismatch(); + } counter.Add(entry.UncompressedSize); return part; } @@ -371,6 +382,10 @@ internal async ValueTask OpenPartAsync( throw new InvalidDataException("The ZIP entry produced more data than its declared uncompressed size."); } } + if (_verifyChecksums && Crc32.Append(0, rented.AsSpan(0, size)) != entry.Crc) + { + ThrowChecksumMismatch(); + } } catch { @@ -416,7 +431,8 @@ private LimitedReadStream WrapEntryStream( ? ToReadableMemoryStream(CompressedSlice(dataOffset, entry.CompressedSize)) : new ByteSourceStream(_source, dataOffset, entry.CompressedSize); Stream opened = entry.Method == 0 ? raw : new InflateStream(raw); - return WorkbookLookups.Wrap(opened, counter, options, entryLimitName, entryLimit, entry.UncompressedSize); + return WorkbookLookups.Wrap( + opened, counter, options, entryLimitName, entryLimit, entry.UncompressedSize, _verifyChecksums ? entry.Crc : null); } private static void ThrowIfPartTooLarge(in ZipEntryRef entry, DecompressedByteCounter counter, string entryLimitName, long entryLimit) @@ -694,6 +710,7 @@ private static int WalkCentralDirectory(ReadOnlySpan directory, ref ZipEnt LocalHeaderOffset = localOffset, CompressedSize = compressed, UncompressedSize = uncompressed, + Crc = fields.Crc, Method = fields.Method, Flags = fields.Flags, }; @@ -712,6 +729,7 @@ private readonly struct CdFixedFields internal ushort NameLength { get; init; } internal ushort ExtraLength { get; init; } internal ushort CommentLength { get; init; } + internal uint Crc { get; init; } internal uint CompressedSize { get; init; } internal uint UncompressedSize { get; init; } internal uint LocalHeaderOffset { get; init; } @@ -723,6 +741,7 @@ private static CdFixedFields ParseCentralDirectoryFixed(ReadOnlySpan recor { Flags = BinaryPrimitives.ReadUInt16LittleEndian(record[8..]), Method = BinaryPrimitives.ReadUInt16LittleEndian(record[10..]), + Crc = BinaryPrimitives.ReadUInt32LittleEndian(record[16..]), CompressedSize = BinaryPrimitives.ReadUInt32LittleEndian(record[20..]), UncompressedSize = BinaryPrimitives.ReadUInt32LittleEndian(record[24..]), NameLength = BinaryPrimitives.ReadUInt16LittleEndian(record[28..]), @@ -831,6 +850,12 @@ private static ZipPart InflateToPart(ReadOnlyMemory compressed, long uncom } } + [DoesNotReturn] + private static void ThrowChecksumMismatch() + { + throw new InvalidDataException(LimitedReadStream.ChecksumMismatch); + } + private static MemoryStream ToReadableMemoryStream(ReadOnlyMemory compressed) { if (MemoryMarshal.TryGetArray(compressed, out ArraySegment segment)) diff --git a/src/ExcelReader.Core/Writer/Internal/CellValueGuards.cs b/src/ExcelReader.Core/Writer/Internal/CellValueGuards.cs index fe059de9..a8b28b71 100644 --- a/src/ExcelReader.Core/Writer/Internal/CellValueGuards.cs +++ b/src/ExcelReader.Core/Writer/Internal/CellValueGuards.cs @@ -50,7 +50,7 @@ internal static void ThrowIfNonFiniteConversion(double value, Type sourceType, s [DoesNotReturn] [MethodImpl(MethodImplOptions.NoInlining)] - private static void ThrowUnrepresentable(Type sourceType, string paramName) + internal static void ThrowUnrepresentable(Type sourceType, string paramName) { throw new ArgumentException( $"Cannot write a value of type '{sourceType}' to a spreadsheet cell: it does not convert to a finite number.", diff --git a/src/ExcelReader.Core/Writer/Xlsb/XlsbRowWriter.cs b/src/ExcelReader.Core/Writer/Xlsb/XlsbRowWriter.cs index 2d89071d..48ee3a69 100644 --- a/src/ExcelReader.Core/Writer/Xlsb/XlsbRowWriter.cs +++ b/src/ExcelReader.Core/Writer/Xlsb/XlsbRowWriter.cs @@ -215,9 +215,7 @@ internal static double ToDouble(T value) where T : IUtf8SpanFormattable } if (value is IConvertible convertible) { - double converted = convertible.ToDouble(CultureInfo.InvariantCulture); - CellValueGuards.ThrowIfNonFiniteConversion(converted, typeof(T), nameof(value)); - return converted; + return ConvertToDouble(convertible, typeof(T)); } Span bytes = stackalloc byte[64]; if (value.TryFormat(bytes, out int written, default, CultureInfo.InvariantCulture) && @@ -226,8 +224,25 @@ internal static double ToDouble(T value) where T : IUtf8SpanFormattable CellValueGuards.ThrowIfNonFiniteConversion(parsed, typeof(T), nameof(value)); return parsed; } - throw new ArgumentException( - $"Cannot convert value of type '{typeof(T)}' to a numeric cell value.", nameof(value)); + CellValueGuards.ThrowUnrepresentable(typeof(T), nameof(value)); + return default; + } + + // IConvertible types with no numeric form (char, DateTime) throw InvalidCastException from ToDouble. + private static double ConvertToDouble(IConvertible value, Type sourceType) + { + double converted; + try + { + converted = value.ToDouble(CultureInfo.InvariantCulture); + } + catch (InvalidCastException) + { + CellValueGuards.ThrowUnrepresentable(sourceType, nameof(value)); + return default; + } + CellValueGuards.ThrowIfNonFiniteConversion(converted, sourceType, nameof(value)); + return converted; } /// diff --git a/tests/ExcelReader.Fuzz/Harnesses.cs b/tests/ExcelReader.Fuzz/Harnesses.cs index 384e9800..619a55c2 100644 --- a/tests/ExcelReader.Fuzz/Harnesses.cs +++ b/tests/ExcelReader.Fuzz/Harnesses.cs @@ -21,10 +21,12 @@ namespace ExcelReader.Fuzz /// internal static class Harnesses { + // Checksums stay off: a mutated entry would otherwise fail its CRC-32 before reaching a parser. private static readonly ExcelReaderOptions Limits = new() { MaxCellBytes = 1 << 20, MaxSharedStringBytes = 1 << 22, + VerifyZipChecksums = false, }; private static readonly CsvReaderOptions CsvLimits = new() @@ -37,6 +39,7 @@ internal static class Harnesses MaxCellBytes = 1 << 20, MaxSharedStringBytes = 1 << 22, Password = "hunter2", + VerifyZipChecksums = false, }; internal static void Xlsx(ReadOnlySpan data) diff --git a/tests/ExcelReader.Tests/Reader/Zip/ZipChecksumTests.cs b/tests/ExcelReader.Tests/Reader/Zip/ZipChecksumTests.cs new file mode 100644 index 00000000..0e104524 --- /dev/null +++ b/tests/ExcelReader.Tests/Reader/Zip/ZipChecksumTests.cs @@ -0,0 +1,153 @@ +using System.IO.Compression; +using System.Text; +using ExcelReader.Core.Reader; +using ExcelReader.Core.Writer.Xlsb; +using ExcelReader.Core.Writer.Xlsx; + +namespace ExcelReader.Tests.Reader.Zip +{ + /// A package whose entry data no longer matches its CRC-32 is refused, unless the check is turned off. + public sealed class ZipChecksumTests + { + private const int Rows = 20_000; + + private static readonly ExcelReaderOptions Unchecked = new() { VerifyZipChecksums = false }; + + public static TheoryData Cases + { + get + { + var data = new TheoryData(); + foreach (bool xlsb in new[] { false, true }) + { + foreach (bool stream in new[] { false, true }) + { + data.Add(xlsb, stream, false); + data.Add(xlsb, stream, true); + } + } + return data; + } + } + + [Theory] + [MemberData(nameof(Cases))] + public void ADamagedSheetIsRefused(bool xlsb, bool fromStream, bool prefetch) + { + byte[] package = Damage(Write(xlsb), xlsb, xlsb ? "xl/worksheets/sheet1.bin" : "xl/worksheets/sheet1.xml"); + var options = new ExcelReaderOptions { PrefetchDecompression = prefetch }; + + InvalidDataException error = Assert.Throws(() => Sum(package, fromStream, options)); + Assert.Contains("CRC-32", error.Message, StringComparison.Ordinal); + } + + [Theory] + [MemberData(nameof(Cases))] + public void TheCheckCanBeTurnedOff(bool xlsb, bool fromStream, bool prefetch) + { + byte[] package = Damage(Write(xlsb), xlsb, xlsb ? "xl/worksheets/sheet1.bin" : "xl/worksheets/sheet1.xml"); + + Sum(package, fromStream, Unchecked with { PrefetchDecompression = prefetch }); + } + + [Theory] + [MemberData(nameof(Cases))] + public void AnIntactPackageStillReads(bool xlsb, bool fromStream, bool prefetch) + { + long sum = Sum(Write(xlsb), fromStream, new ExcelReaderOptions { PrefetchDecompression = prefetch }); + + Assert.Equal((long)Rows * (Rows + 1) / 2, sum); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ADamagedSharedStringTableIsRefused(bool xlsb) + { + byte[] package = Damage(Write(xlsb, sharedStrings: true), xlsb, xlsb ? "xl/sharedStrings.bin" : "xl/sharedStrings.xml"); + + Assert.Throws(() => Sum(package, fromStream: false, ExcelReaderOptions.Default)); + Assert.Throws(() => Sum(package, fromStream: true, ExcelReaderOptions.Default)); + } + + private static long Sum(byte[] package, bool fromStream, ExcelReaderOptions options) + { + using IExcelWorkbook workbook = fromStream + ? Excel.Open(new MemoryStream(package, writable: false), leaveOpen: false, options) + : Excel.Open(package, options); + long sum = 0; + using IExcelRowEnumerator rows = workbook.FirstSheet.GetEnumerator(); + while (rows.MoveNext()) + { + rows.Current[0].GetString(); + Assert.True(rows.Current[1].TryGetDouble(out double number)); + sum += (long)number; + } + return sum; + } + + private static byte[] Write(bool xlsb, bool sharedStrings = false) + { + using MemoryStream ms = new(); + if (xlsb) + { + using XlsbWorkbookWriter wb = XlsbWorkbookWriter.Create(ms, leaveOpen: true, new XlsbWriterOptions { Compression = CompressionLevel.NoCompression, UseSharedStrings = sharedStrings }); + XlsbSheetWriter sheet = wb.AddSheet("S1"); + for (int i = 1; i <= Rows; i++) + { + using XlsbRowWriter row = sheet.StartRow(); + row.Write("text" + (i % 50)); + row.Write(i); + } + sheet.End(); + wb.End(); + } + else + { + using XlsxWorkbookWriter wb = XlsxWorkbookWriter.Create(ms, leaveOpen: true, new XlsxWriterOptions { Compression = CompressionLevel.NoCompression, UseSharedStrings = sharedStrings }); + XlsxSheetWriter sheet = wb.AddSheet("S1"); + for (int i = 1; i <= Rows; i++) + { + using XlsxRowWriter row = sheet.StartRow(); + row.Write("text" + (i % 50)); + row.Write(i); + } + sheet.End(); + wb.End(); + } + return ms.ToArray(); + } + + // The package is stored, not deflated, so one changed letter of the cell text "text" stays valid + // XML or BIFF12 and only the checksum can tell. + private static byte[] Damage(byte[] package, bool xlsb, string entryName) + { + byte[] content; + using (var zip = new ZipArchive(new MemoryStream(package), ZipArchiveMode.Read)) + { + using Stream data = zip.GetEntry(entryName)!.Open(); + using var copy = new MemoryStream(); + data.CopyTo(copy); + content = copy.ToArray(); + } + // BIFF12 stores text as UTF-16. + byte[] needle = xlsb ? Encoding.Unicode.GetBytes("text") : Encoding.ASCII.GetBytes("text"); + int inEntry = Find(content, needle, content.Length / 2); + int inPackage = Find(package, content.AsSpan(inEntry - 16, 64), 0) + 16; + byte[] damaged = (byte[])package.Clone(); + damaged[inPackage] = (byte)'T'; + return damaged; + } + + private static int Find(ReadOnlySpan haystack, ReadOnlySpan needle, int from) + { + int at = haystack[from..].IndexOf(needle); + if (at < 0 && from > 0) + { + at = haystack.IndexOf(needle) - from; + } + Assert.True(from + at >= 0, "needle not found"); + return from + at; + } + } +} diff --git a/tests/ExcelReader.Tests/Reader/Zip/ZipIndexTests.cs b/tests/ExcelReader.Tests/Reader/Zip/ZipIndexTests.cs index 4328dfc1..3d9156bb 100644 --- a/tests/ExcelReader.Tests/Reader/Zip/ZipIndexTests.cs +++ b/tests/ExcelReader.Tests/Reader/Zip/ZipIndexTests.cs @@ -392,7 +392,7 @@ public void CompressedSizeEndingExactlyAtTheFileEndIsAcceptedAndOneMoreIsRejecte long exact = probe.Bytes.Length - probe.DataOffset; Zip64Layout fits = BuildStoredZip64(Zip64Payload, compressedSize: exact, uncompressedSize: exact); - using (ZipIndex index = ZipIndex.Create(fits.Bytes, ExcelReaderOptions.Default)) + using (ZipIndex index = ZipIndex.Create(fits.Bytes, Unchecked)) { Assert.True(index.TryGetEntry("a.txt"u8, out ZipEntryRef entry)); using ZipPart part = index.OpenPart(entry, new DecompressedByteCounter(0)); @@ -508,12 +508,16 @@ public void LocalExtraLengthPushingDataPastTheFileEndThrowsInvalidDataOnOpen() private static readonly bool[] Modes = [false, true]; - private static async Task CreatePositionalAsync(byte[] bytes, bool useAsync, CancellationToken ct) + // The size-boundary tests stretch a stored entry over bytes its recorded CRC-32 never covered. + private static readonly ExcelReaderOptions Unchecked = new() { VerifyZipChecksums = false }; + + private static async Task CreatePositionalAsync( + byte[] bytes, bool useAsync, CancellationToken ct, ExcelReaderOptions? options = null) { ByteSource source = ByteSource.FromStream(new MemoryStream(bytes, writable: false), leaveOpen: false); return useAsync - ? await ZipIndex.CreateAsync(source, ExcelReaderOptions.Default, ct) - : ZipIndex.Create(source, ExcelReaderOptions.Default); + ? await ZipIndex.CreateAsync(source, options ?? ExcelReaderOptions.Default, ct) + : ZipIndex.Create(source, options ?? ExcelReaderOptions.Default); } private static async Task CreateFailsAsync(byte[] bytes, bool useAsync, CancellationToken ct) @@ -675,7 +679,7 @@ public async Task PositionalCompressedSizeEndingExactlyAtTheFileEndIsAcceptedAnd long exact = probe.Bytes.Length - probe.DataOffset; Zip64Layout fits = BuildStoredZip64(Zip64Payload, compressedSize: exact, uncompressedSize: exact); - using (ZipIndex index = await CreatePositionalAsync(fits.Bytes, useAsync, ct)) + using (ZipIndex index = await CreatePositionalAsync(fits.Bytes, useAsync, ct, Unchecked)) { Assert.True(index.TryGetEntry("a.txt"u8, out ZipEntryRef entry)); using ZipPart part = await OpenPartPositionalAsync(index, entry, useAsync, ct); diff --git a/tests/ExcelReader.Tests/Writer/WriterEdgeValidationTests.cs b/tests/ExcelReader.Tests/Writer/WriterEdgeValidationTests.cs index 188dc72b..a2a0ba7d 100644 --- a/tests/ExcelReader.Tests/Writer/WriterEdgeValidationTests.cs +++ b/tests/ExcelReader.Tests/Writer/WriterEdgeValidationTests.cs @@ -108,6 +108,47 @@ public void NumberFormatsStopAtWhatExcelOpens(string format) }); } + [Fact] + public void AValueWithNoNumericFormIsRefusedTheSameWayEverywhere() + { + using MemoryStream ms = new(); + using (XlsxWorkbookWriter wb = XlsxWorkbookWriter.Create(ms, leaveOpen: true)) + { + XlsxSheetWriter sheet = wb.AddSheet("S1"); + using (XlsxRowWriter row = sheet.StartRow()) + { + Assert.Throws(() => row.Write('c')); + } + sheet.End(); + } + using (XlsbWorkbookWriter wb = XlsbWorkbookWriter.Create(ms, leaveOpen: true)) + { + XlsbSheetWriter sheet = wb.AddSheet("S1"); + using (XlsbRowWriter row = sheet.StartRow()) + { + Assert.Throws(() => row.Write('c')); + } + sheet.End(); + } + using (XlsWorkbookWriter wb = XlsWorkbookWriter.Create(ms, leaveOpen: true)) + { + XlsSheetWriter sheet = wb.AddSheet("S1"); + using (XlsRowWriter row = sheet.StartRow()) + { + Assert.Throws(() => row.Write('c')); + } + sheet.End(); + } + } + + [Fact] + public void ALimitMessageNamesNoUnit() + { + var error = new ExcelLimitExceededException("Rows", 1_048_576, 1_048_577); + + Assert.Equal("Rows limit exceeded: 1048577 exceeds 1048576.", error.Message); + } + private static void WithWorkbook(string format, Action body) { using MemoryStream ms = new(); From b106c9d021ed672acb489f1e2ce0dbd647a0ec13 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 15:36:56 -0300 Subject: [PATCH 37/42] feat: expose verify_zip_checksums in the bindings; checksum on the prefetch thread --- docs/guide/migrating-to-v7.md | 2 +- python/README.md | 4 +++ python/src/excelreader/_native.py | 2 ++ python/src/excelreader/types.py | 5 ++++ rust/excelreader/src/lib.rs | 1 + rust/excelreader/src/options.rs | 11 +++++++- .../Reader/ExcelReaderOptions.cs | 2 +- .../Reader/Internal/WorkbookLookups.cs | 3 ++- .../Reader/Zip/PrefetchStream.cs | 20 +++++++++++++- src/ExcelReader.Native/NativeOpenOptions.cs | 11 +++++++- src/ExcelReader.Native/include/excelreader.h | 2 ++ .../include/excelreader.hpp | 2 ++ .../Native/OpenOptionsLayoutTests.cs | 27 +++++++++++++++++++ 13 files changed, 86 insertions(+), 6 deletions(-) diff --git a/docs/guide/migrating-to-v7.md b/docs/guide/migrating-to-v7.md index 0d25efd7..80d64df9 100644 --- a/docs/guide/migrating-to-v7.md +++ b/docs/guide/migrating-to-v7.md @@ -137,7 +137,7 @@ Two behavior changes come with them: v7 refuses several inputs that v6 accepted and then got wrong. -- **Damaged packages.** Each ZIP entry of an XLSX or XLSB package is checked against its CRC-32. A mismatch throws `InvalidDataException` when the entry's last byte is read, so rows before the damage may already have been returned. v6 checked only the entry's size. Set [`ExcelReaderOptions.VerifyZipChecksums`](xref:ExcelReader.Core.Reader.ExcelReaderOptions.VerifyZipChecksums) to `false` to read a package whose producer wrote wrong checksums. The check costs 2% to 5% of a read. +- **Damaged packages.** Each ZIP entry of an XLSX or XLSB package is checked against its CRC-32. A mismatch throws `InvalidDataException` when the entry's last byte is read, so rows before the damage may already have been returned. v6 checked only the entry's size. Set [`ExcelReaderOptions.VerifyZipChecksums`](xref:ExcelReader.Core.Reader.ExcelReaderOptions.VerifyZipChecksums) to `false` to read a package whose producer wrote wrong checksums; the bindings have the same switch as `verify_zip_checksums`. The check costs 2% to 4% of a read, and under 1% with `PrefetchDecompression`, where it runs on the background thread. - **Sheet names.** `AddSheet` throws `ArgumentException` for a name that begins or ends with an apostrophe, or contains a control character (tab and line feed included), U+FFFE or U+FFFF. Excel refuses a workbook with such a name. - **Column settings.** `SetColumnWidth` and `SetColumnStyle` throw `ArgumentOutOfRangeException` for a column past the format's last one (16,384 columns; 256 for XLS) and for a width above 255, Excel's maximum. v6 wrote a file Excel refused for the first, and an XLS width of 256 came out as a hidden column. - **Number formats.** `AddStyle` throws `ExcelLimitExceededException` for the 201st distinct number format of a workbook, and `ArgumentException` for a format longer than 255 characters. Excel does not open a workbook past either. diff --git a/python/README.md b/python/README.md index 16bf8f9f..d694c42f 100644 --- a/python/README.md +++ b/python/README.md @@ -463,6 +463,10 @@ options = OpenOptions( single-file batch work, not for a server already reading many files in parallel. See the root README for the measured trade. +Each ZIP entry of an XLSX or XLSB is checked against its CRC-32 as it is read, so a damaged file +raises `ExcelReaderError` instead of returning wrong cells. `verify_zip_checksums=False` turns the +check off, for a file whose producer wrote wrong checksums. + ### Encrypted workbooks `open_workbook()`/`open_bytes()` take a `password` keyword to open a password-protected OOXML diff --git a/python/src/excelreader/_native.py b/python/src/excelreader/_native.py index c67def55..cf8f5b25 100644 --- a/python/src/excelreader/_native.py +++ b/python/src/excelreader/_native.py @@ -266,6 +266,7 @@ class NativeOpenOptions(ctypes.Structure): ("source_cache_bytes", ctypes.c_int64), ("max_buffered_bytes", ctypes.c_int64), ("source_block_size", ctypes.c_int32), + ("verify_zip_checksums", ctypes.c_int32), ] @@ -334,6 +335,7 @@ def to_native_open_options(options: OpenOptions) -> NativeOpenOptions: raw.csv_intern_strings = _opt_state(options.csv_intern_strings) raw.prefetch_decompression = _opt_state(options.prefetch_decompression) raw.intern_strings = _opt_state(options.intern_strings) + raw.verify_zip_checksums = _opt_state(options.verify_zip_checksums) raw.csv_delimiter = _opt_number(options.csv_delimiter) raw.csv_quote = _opt_number(options.csv_quote) raw.csv_max_cell_bytes = _opt_number(options.csv_max_cell_bytes) diff --git a/python/src/excelreader/types.py b/python/src/excelreader/types.py index c562a61a..f7f4ebc7 100644 --- a/python/src/excelreader/types.py +++ b/python/src/excelreader/types.py @@ -121,6 +121,10 @@ class OpenOptions(NamedTuple): 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. + `verify_zip_checksums` (on by default) checks each ZIP entry of an XLSX or XLSB against its + CRC-32, so a damaged file raises instead of returning wrong cells. Set it to `False` to read a + file whose producer wrote wrong checksums. + 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. @@ -141,6 +145,7 @@ class OpenOptions(NamedTuple): source_block_size: int | None = None source_cache_bytes: int | None = None max_buffered_bytes: int | None = None + verify_zip_checksums: bool | None = None class WriteOptions(NamedTuple): diff --git a/rust/excelreader/src/lib.rs b/rust/excelreader/src/lib.rs index 82e7f00c..64e81bc5 100644 --- a/rust/excelreader/src/lib.rs +++ b/rust/excelreader/src/lib.rs @@ -128,6 +128,7 @@ pub struct XlOpenOptions { pub source_cache_bytes: i64, pub max_buffered_bytes: i64, pub source_block_size: i32, + pub verify_zip_checksums: i32, } #[repr(C)] diff --git a/rust/excelreader/src/options.rs b/rust/excelreader/src/options.rs index a14c58ae..8f9342ec 100644 --- a/rust/excelreader/src/options.rs +++ b/rust/excelreader/src/options.rs @@ -37,6 +37,8 @@ pub struct OpenOptions { pub max_zip_entries: Option, pub prefetch_decompression: Option, pub intern_strings: Option, + /// XLSX/XLSB only: check each ZIP entry against its CRC-32. `None` is `true`. + pub verify_zip_checksums: Option, /// `open_source` only: bytes fetched per `read_at` call. `None` is 4 MiB; a negative value turns /// the cache off. @@ -98,6 +100,9 @@ impl OpenOptions { prefetch_decompression: bool, /// XLS/XLSX/XLSB only. Library default is `false`. intern_strings: bool, + /// XLSX/XLSB only: check each ZIP entry against its CRC-32, so a damaged file fails the read + /// instead of returning wrong cells. Library default is `true`. + verify_zip_checksums: 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, @@ -148,6 +153,7 @@ impl OpenOptions { 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), + verify_zip_checksums: opt_state(self.verify_zip_checksums), }; if let Some(bytes) = password { raw.password = bytes.as_ptr(); @@ -304,11 +310,13 @@ mod tests { fn booleans_lower_to_distinct_true_and_false_states() { let options = OpenOptions::new() .prefetch_decompression(true) - .intern_strings(false); + .intern_strings(false) + .verify_zip_checksums(false); let guard = options.to_raw(); let raw = guard.raw; assert_eq!(raw.prefetch_decompression, XL_OPT_TRUE); assert_eq!(raw.intern_strings, XL_OPT_FALSE); + assert_eq!(raw.verify_zip_checksums, XL_OPT_FALSE); assert_eq!(raw.csv_detect_bom, XL_OPT_DEFAULT); } @@ -393,6 +401,7 @@ mod tests { 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::offset_of!(XlOpenOptions, verify_zip_checksums), 108); assert_eq!(std::mem::size_of::(), 40); assert_eq!(std::mem::size_of::(), 32); } diff --git a/src/ExcelReader.Core/Reader/ExcelReaderOptions.cs b/src/ExcelReader.Core/Reader/ExcelReaderOptions.cs index 111858cb..be69e168 100644 --- a/src/ExcelReader.Core/Reader/ExcelReaderOptions.cs +++ b/src/ExcelReader.Core/Reader/ExcelReaderOptions.cs @@ -37,7 +37,7 @@ public sealed record ExcelReaderOptions /// Gets a value indicating whether each ZIP entry read from an XLSX or XLSB package is /// checked against the CRC-32 its central directory records. Defaults to . - /// A mismatch throws when the entry's last byte is read, so + /// A mismatch throws when the entry's last bytes are read, so /// rows before the damage may already have been returned. Turn it off to read a package whose /// producer wrote wrong checksums; damaged data then surfaces only if it also breaks parsing. public bool VerifyZipChecksums { get; init; } = true; diff --git a/src/ExcelReader.Core/Reader/Internal/WorkbookLookups.cs b/src/ExcelReader.Core/Reader/Internal/WorkbookLookups.cs index ea2b7b54..9df6af38 100644 --- a/src/ExcelReader.Core/Reader/Internal/WorkbookLookups.cs +++ b/src/ExcelReader.Core/Reader/Internal/WorkbookLookups.cs @@ -87,7 +87,8 @@ internal static LimitedReadStream Wrap( { return new LimitedReadStream(opened, uncompressedSize, counter, entryLimitName, entryLimit, expectedCrc); } - return new LimitedReadStream(new PrefetchStream(opened), uncompressedSize, counter, entryLimitName, entryLimit, expectedCrc); + return new LimitedReadStream( + new PrefetchStream(opened, uncompressedSize, expectedCrc), uncompressedSize, counter, entryLimitName, entryLimit); } internal static int InitialBufferCapacity(long entryLength) diff --git a/src/ExcelReader.Core/Reader/Zip/PrefetchStream.cs b/src/ExcelReader.Core/Reader/Zip/PrefetchStream.cs index b7f7469f..5d8eea70 100644 --- a/src/ExcelReader.Core/Reader/Zip/PrefetchStream.cs +++ b/src/ExcelReader.Core/Reader/Zip/PrefetchStream.cs @@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.ExceptionServices; using System.Threading.Channels; +using ExcelReader.Core.Writer.Zip; namespace ExcelReader.Core.Reader.Zip { @@ -14,15 +15,21 @@ internal sealed class PrefetchStream : Stream private readonly Channel<(byte[] Buffer, int Length)> _channel; private readonly CancellationTokenSource _cts; private readonly Task _producer; + private readonly long _declaredLength; + private readonly uint? _expectedCrc; private byte[]? _currentBuffer; private int _currentLength; private int _currentOffset; private ExceptionDispatchInfo? _producerException; private bool _disposed; - internal PrefetchStream(Stream inner) + // With expectedCrc, the producer checksums what it reads and fails instead of handing over the + // chunk that completes declaredLength, so the check runs off the consumer's thread. + internal PrefetchStream(Stream inner, long declaredLength = 0, uint? expectedCrc = null) { _inner = inner; + _declaredLength = declaredLength; + _expectedCrc = expectedCrc; _channel = Channel.CreateBounded<(byte[] Buffer, int Length)>(new BoundedChannelOptions(ChannelCapacity) { SingleReader = true, @@ -190,6 +197,8 @@ private int ConsumeAvailableChunks(Span destination) private async Task ProduceAsync(CancellationToken token) { byte[]? pending = null; + long produced = 0; + uint crc = 0; try { while (true) @@ -200,6 +209,15 @@ private async Task ProduceAsync(CancellationToken token) { break; } + if (_expectedCrc is uint expected) + { + crc = Crc32.Append(crc, pending.AsSpan(0, read)); + produced += read; + if (produced == _declaredLength && crc != expected) + { + throw new InvalidDataException(LimitedReadStream.ChecksumMismatch); + } + } await _channel.Writer.WriteAsync((pending, read), token).ConfigureAwait(false); pending = null; } diff --git a/src/ExcelReader.Native/NativeOpenOptions.cs b/src/ExcelReader.Native/NativeOpenOptions.cs index 5728fe5a..70e5a36e 100644 --- a/src/ExcelReader.Native/NativeOpenOptions.cs +++ b/src/ExcelReader.Native/NativeOpenOptions.cs @@ -74,6 +74,8 @@ internal struct NativeOpenOptionsRaw public long SourceCacheBytes; public long MaxBufferedBytes; public int SourceBlockSize; + // Occupies what was tail padding, so the struct's size did not change when it was added. + public int VerifyZipChecksums; internal const int V6Size = 88; @@ -114,6 +116,7 @@ internal readonly struct NativeOpenOptions internal int? MaxZipEntries { get; init; } internal bool? PrefetchDecompression { get; init; } internal bool? InternStrings { get; init; } + internal bool? VerifyZipChecksums { get; init; } internal string? Password { get; init; } @@ -174,6 +177,10 @@ internal ExcelReaderOptions ToExcelReaderOptions() { options = options with { InternStrings = internStrings }; } + if (VerifyZipChecksums is bool verifyChecksums) + { + options = options with { VerifyZipChecksums = verifyChecksums }; + } if (Password is not null) { options = options with { Password = Password }; @@ -209,7 +216,8 @@ internal static bool TryDecode(NativeOpenOptionsRaw raw, out NativeOpenOptions o || !NativeOptionDecode.TryState(raw.CsvDetectBom, OptionsName, "csv_detect_bom", out bool? detectBom, out error) || !NativeOptionDecode.TryState(raw.CsvInternStrings, OptionsName, "csv_intern_strings", out bool? csvInternStrings, out error) || !NativeOptionDecode.TryState(raw.PrefetchDecompression, OptionsName, "prefetch_decompression", out bool? prefetch, out error) - || !NativeOptionDecode.TryState(raw.InternStrings, OptionsName, "intern_strings", out bool? internStrings, out error)) + || !NativeOptionDecode.TryState(raw.InternStrings, OptionsName, "intern_strings", out bool? internStrings, out error) + || !NativeOptionDecode.TryState(raw.VerifyZipChecksums, OptionsName, "verify_zip_checksums", out bool? verifyChecksums, out error)) { return false; } @@ -247,6 +255,7 @@ internal static bool TryDecode(NativeOpenOptionsRaw raw, out NativeOpenOptions o MaxZipEntries = maxZipEntries, PrefetchDecompression = prefetch, InternStrings = internStrings, + VerifyZipChecksums = verifyChecksums, Password = password, SourceBlockSize = raw.SourceBlockSize == 0 ? null : raw.SourceBlockSize, SourceCacheBytes = sourceCacheBytes, diff --git a/src/ExcelReader.Native/include/excelreader.h b/src/ExcelReader.Native/include/excelreader.h index 12cf422c..b17f5a93 100644 --- a/src/ExcelReader.Native/include/excelreader.h +++ b/src/ExcelReader.Native/include/excelreader.h @@ -83,6 +83,8 @@ typedef struct xl_open_options { int64_t source_cache_bytes; int64_t max_buffered_bytes; int32_t source_block_size; + /* XL_OPT_*: check each XLSX/XLSB ZIP entry against its CRC-32. On by default. */ + int32_t verify_zip_checksums; } xl_open_options; int32_t xl_open_file(const uint8_t* path, int32_t path_len, int32_t format, diff --git a/src/ExcelReader.Native/include/excelreader.hpp b/src/ExcelReader.Native/include/excelreader.hpp index 29b713c6..67fb8abe 100644 --- a/src/ExcelReader.Native/include/excelreader.hpp +++ b/src/ExcelReader.Native/include/excelreader.hpp @@ -160,6 +160,7 @@ namespace xl 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. + int32_t verify_zip_checksums = XL_OPT_DEFAULT; ///< Check each XLSX/XLSB ZIP entry against its CRC-32. On by default; a mismatch fails the read. std::string password_{}; ///< Set through password(). @@ -196,6 +197,7 @@ namespace xl opts.source_cache_bytes = source_cache_bytes; opts.max_buffered_bytes = max_buffered_bytes; opts.source_block_size = source_block_size; + opts.verify_zip_checksums = verify_zip_checksums; opts.password = password_.empty() ? nullptr : reinterpret_cast(password_.data()); opts.password_len = static_cast(password_.size()); return opts; diff --git a/tests/ExcelReader.Tests/Native/OpenOptionsLayoutTests.cs b/tests/ExcelReader.Tests/Native/OpenOptionsLayoutTests.cs index e3e690fd..c49fd572 100644 --- a/tests/ExcelReader.Tests/Native/OpenOptionsLayoutTests.cs +++ b/tests/ExcelReader.Tests/Native/OpenOptionsLayoutTests.cs @@ -12,6 +12,33 @@ public void The_6_0_Layout_Ends_Where_The_New_Fields_Begin() Assert.Equal(112, sizeof(NativeOpenOptionsRaw)); } + [Fact] + public void The_Checksum_Switch_Sits_In_The_Former_Tail_Padding() + { + Assert.Equal(108, (int)System.Runtime.InteropServices.Marshal.OffsetOf(nameof(NativeOpenOptionsRaw.VerifyZipChecksums))); + } + + [Theory] + [InlineData(0, true)] + [InlineData(1, false)] + [InlineData(2, true)] + public void The_Checksum_Switch_Reaches_The_Reader_Options(int state, bool expected) + { + NativeOpenOptionsRaw raw = new() { StructSize = sizeof(NativeOpenOptionsRaw), VerifyZipChecksums = state }; + + Assert.True(NativeOpenOptions.TryDecode(raw, out NativeOpenOptions options, out string? error), error); + Assert.Equal(expected, options.ToExcelReaderOptions().VerifyZipChecksums); + } + + [Fact] + public void An_Unknown_Checksum_State_Is_Rejected() + { + NativeOpenOptionsRaw raw = new() { StructSize = sizeof(NativeOpenOptionsRaw), VerifyZipChecksums = 3 }; + + Assert.False(NativeOpenOptions.TryDecode(raw, out _, out string? error)); + Assert.Contains("verify_zip_checksums", error, StringComparison.Ordinal); + } + [Fact] public void A_6_0_Sized_Struct_Is_Read_As_A_Prefix() { From 0f038ae4a1caffa320f966d02cf321654c473848 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 16:11:51 -0300 Subject: [PATCH 38/42] fix: read Excel's line breaks, attributed and XLS shared-formula strings --- docs/guide/migrating-to-v7.md | 5 + .../Reader/Xls/XlsWorkbook.Enumerator.cs | 6 + .../Reader/Xls/XlsWorkbook.cs | 3 + .../Reader/Xlsx/XlsxWorkbook.Enumerator.cs | 33 ++++- src/ExcelReader.Core/Reader/Xlsx/XlsxXml.cs | 23 +++- .../Reader/ExcelWrittenTextTests.cs | 125 ++++++++++++++++++ .../data/excel-formula-strings.xls | Bin 0 -> 26112 bytes .../data/excel-formula-strings.xlsx | Bin 0 -> 9362 bytes 8 files changed, 188 insertions(+), 7 deletions(-) create mode 100644 tests/ExcelReader.Tests/Reader/ExcelWrittenTextTests.cs create mode 100644 tests/ExcelReader.Tests/data/excel-formula-strings.xls create mode 100644 tests/ExcelReader.Tests/data/excel-formula-strings.xlsx diff --git a/docs/guide/migrating-to-v7.md b/docs/guide/migrating-to-v7.md index 80d64df9..555605d6 100644 --- a/docs/guide/migrating-to-v7.md +++ b/docs/guide/migrating-to-v7.md @@ -138,6 +138,11 @@ Two behavior changes come with them: v7 refuses several inputs that v6 accepted and then got wrong. - **Damaged packages.** Each ZIP entry of an XLSX or XLSB package is checked against its CRC-32. A mismatch throws `InvalidDataException` when the entry's last byte is read, so rows before the damage may already have been returned. v6 checked only the entry's size. Set [`ExcelReaderOptions.VerifyZipChecksums`](xref:ExcelReader.Core.Reader.ExcelReaderOptions.VerifyZipChecksums) to `false` to read a package whose producer wrote wrong checksums; the bindings have the same switch as `verify_zip_checksums`. The check costs 2% to 4% of a read, and under 1% with `PrefetchDecompression`, where it runs on the background thread. +- **Line breaks in XLSX text.** A cell with a line break now reads with ` +`, as XLSB and XLS already did. Excel stores the break as a CR LF pair in the XML, which v6 returned as ` +`. A carriage return stored as data (`_x000D_` or ` `) is still returned. Code that split XLSX text on ` +` should split on ` +`. - **Sheet names.** `AddSheet` throws `ArgumentException` for a name that begins or ends with an apostrophe, or contains a control character (tab and line feed included), U+FFFE or U+FFFF. Excel refuses a workbook with such a name. - **Column settings.** `SetColumnWidth` and `SetColumnStyle` throw `ArgumentOutOfRangeException` for a column past the format's last one (16,384 columns; 256 for XLS) and for a width above 255, Excel's maximum. v6 wrote a file Excel refused for the first, and an XLS width of 256 came out as a hidden column. - **Number formats.** `AddStyle` throws `ExcelLimitExceededException` for the 201st distinct number format of a workbook, and `ArgumentException` for a format longer than 255 characters. Excel does not open a workbook past either. diff --git a/src/ExcelReader.Core/Reader/Xls/XlsWorkbook.Enumerator.cs b/src/ExcelReader.Core/Reader/Xls/XlsWorkbook.Enumerator.cs index 84beb389..59815c39 100644 --- a/src/ExcelReader.Core/Reader/Xls/XlsWorkbook.Enumerator.cs +++ b/src/ExcelReader.Core/Reader/Xls/XlsWorkbook.Enumerator.cs @@ -294,6 +294,12 @@ private void ParseFormula(ReadOnlySpan data) _acc.AddError(col, style, result[2]); break; case 0: + // The first cell of a shared or array formula, or of a data table, has that + // definition's record between its FORMULA and its STRING. + if (_cursor.PeekId() is Rec.ShrFmla or Rec.Array or Rec.Table) + { + _cursor.TryReadRecord(out _, out _); + } if (_cursor.PeekId() == Rec.StringRec && _cursor.TryReadRecord(out _, out ReadOnlySpan str) && str.Length >= 3) { int start = _acc.ValueLength; diff --git a/src/ExcelReader.Core/Reader/Xls/XlsWorkbook.cs b/src/ExcelReader.Core/Reader/Xls/XlsWorkbook.cs index 0953fcc8..cb70b660 100644 --- a/src/ExcelReader.Core/Reader/Xls/XlsWorkbook.cs +++ b/src/ExcelReader.Core/Reader/Xls/XlsWorkbook.cs @@ -507,6 +507,9 @@ private static class Rec internal const int Blank = 0x0201; internal const int MulBlank = 0x00BE; internal const int StringRec = 0x0207; + internal const int ShrFmla = 0x04BC; + internal const int Array = 0x0221; + internal const int Table = 0x0236; } } } diff --git a/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Enumerator.cs b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Enumerator.cs index 23b97aea..a36b7ac1 100644 --- a/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Enumerator.cs +++ b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Enumerator.cs @@ -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 || v.IndexOf("_x"u8) >= 0))) + if (kind == Kind.IsoDate || (kind == Kind.Formula && (v.IndexOfAny((byte)'&', (byte)'\r') >= 0 || v.IndexOf("_x"u8) >= 0))) { EmitScalarValue(kind, v, col, style); return; @@ -950,13 +950,42 @@ private static ReadOnlySpan ElementText(ReadOnlySpan inner, ReadOnly int s = inner.IndexOf(openTag); if (s < 0) { - return default; + return AttributedElementText(inner, openTag, closeTag); } s += openTag.Length; int e = inner[s..].IndexOf(closeTag); return e < 0 ? default : inner.Slice(s, e); } + // Excel writes for a formula's text result with whitespace at either end. + [MethodImpl(MethodImplOptions.NoInlining)] + private static ReadOnlySpan AttributedElementText(ReadOnlySpan inner, ReadOnlySpan openTag, ReadOnlySpan closeTag) + { + ReadOnlySpan name = openTag[..^1]; + int from = 0; + while (true) + { + int s = inner[from..].IndexOf(name); + if (s < 0) + { + return default; + } + int afterName = from + s + name.Length; + if (afterName < inner.Length && inner[afterName] is (byte)' ' or (byte)'\t' or (byte)'\r' or (byte)'\n') + { + int gt = inner[afterName..].IndexOf((byte)'>'); + if (gt < 0 || inner[afterName + gt - 1] == (byte)'/') + { + return default; + } + int textStart = afterName + gt + 1; + int e = inner[textStart..].IndexOf(closeTag); + return e < 0 ? default : inner.Slice(textStart, e); + } + from = afterName; + } + } + private void AppendDecoded(ReadOnlySpan src) { if (src.IsEmpty) diff --git a/src/ExcelReader.Core/Reader/Xlsx/XlsxXml.cs b/src/ExcelReader.Core/Reader/Xlsx/XlsxXml.cs index 08465db1..4b89e551 100644 --- a/src/ExcelReader.Core/Reader/Xlsx/XlsxXml.cs +++ b/src/ExcelReader.Core/Reader/Xlsx/XlsxXml.cs @@ -8,6 +8,8 @@ namespace ExcelReader.Core.Reader.Xlsx { internal static class XlsxXml { + private static readonly SearchValues MarkupOrEscape = SearchValues.Create("<&_\r"u8); + internal static int ParseIntOr(ReadOnlySpan source, int fallback) { return Utf8Parser.TryParse(source, out int value, out _) ? value : fallback; @@ -77,7 +79,7 @@ public static int Decode(ReadOnlySpan src, Span dest) int w = 0; while (!src.IsEmpty) { - int special = src.IndexOfAny((byte)'&', (byte)'<'); + int special = src.IndexOfAny((byte)'&', (byte)'<', (byte)'\r'); if (special < 0) { src.CopyTo(dest[w..]); @@ -87,6 +89,15 @@ public static int Decode(ReadOnlySpan src, Span dest) w += special; src = src[special..]; + // XML end-of-line handling: a literal CR LF pair, or a CR alone, reads as one LF. A carriage + // return meant as data arrives as or _x000D_, which this does not touch. + if (src[0] == (byte)'\r') + { + dest[w++] = (byte)'\n'; + src = src[(src.Length > 1 && src[1] == (byte)'\n' ? 2 : 1)..]; + continue; + } + if (src[0] == (byte)'<') { if (!src.StartsWith(" body, Span dest, ReadO return raw.Length; } - // Finds the first '<' or '&'. Only text with a '_' ahead of it can hold an _xHHHH_ escape, so the - // usual underscore-free text costs one scan instead of a second pass for "_x". + // Finds the first '<', '&' or carriage return. Only text with a '_' ahead of it can hold an _xHHHH_ + // escape, so the usual underscore-free text costs one scan instead of a second pass for "_x". + // A carriage return stops the scan because text holding one needs Decode's end-of-line handling; + // callers that expect a closing tag at the returned index then fall back to it. internal static int IndexOfMarkup(ReadOnlySpan text, out bool mayEscape) { - int i = text.IndexOfAny((byte)'<', (byte)'&', (byte)'_'); + int i = text.IndexOfAny(MarkupOrEscape); mayEscape = i >= 0 && text[i] == (byte)'_'; if (!mayEscape) { return i; } - int rest = text[i..].IndexOfAny((byte)'<', (byte)'&'); + int rest = text[i..].IndexOfAny((byte)'<', (byte)'&', (byte)'\r'); return rest < 0 ? -1 : i + rest; } diff --git a/tests/ExcelReader.Tests/Reader/ExcelWrittenTextTests.cs b/tests/ExcelReader.Tests/Reader/ExcelWrittenTextTests.cs new file mode 100644 index 00000000..6f4c7079 --- /dev/null +++ b/tests/ExcelReader.Tests/Reader/ExcelWrittenTextTests.cs @@ -0,0 +1,125 @@ +using System.IO.Compression; +using System.Text; +using ExcelReader.Core.Reader; + +namespace ExcelReader.Tests.Reader +{ + /// + /// Text as Excel itself stores it. excel-formula-strings was saved by Excel 16 in both formats: + /// A1 holds a line break, B1 and C1 formulas whose text has whitespace at an end, B2:B4 a formula + /// filled down, and C2:C4 an array formula. + /// + public sealed class ExcelWrittenTextTests + { + private static readonly string[][] Expected = + [ + ["a\nb", "\nnl", " pad "], + ["1", "1s", "1a"], + ["2", "2s", "2a"], + ["3", "3s", "3a"], + ]; + + [Theory] + [InlineData("xlsx")] + [InlineData("xls")] + public void AnExcelFileReadsAsExcelShowsIt(string extension) + { + string path = Path.Combine(AppContext.BaseDirectory, "data", "excel-formula-strings." + extension); + + using IExcelWorkbook workbook = Excel.Open(path); + + Assert.Equal(Expected, Rows(workbook, 3)); + } + + [Fact] + public void ALiteralCarriageReturnIsAnXmlLineEnd() + { + byte[] package = Xlsx( + "" + + "012" + + "in\r\nline" + + "Xfor\r\nmula" + + "34" + + "", + "a\r\nb" + + "lone\rcr" + + "kept \r\nref and _x000D_\r\nescape" + + "rich\r\nrun" + + "a_b\r\nc"); + + using IExcelWorkbook workbook = Excel.Open(package); + + string[] row = Assert.Single(Rows(workbook, 7)); + Assert.Equal( + ["a\nb", "lone\ncr", "kept\r\nref and \r\nescape", "in\nline", "for\nmula", "rich\nrun", "a_b\nc"], + row); + } + + [Fact] + public void AValueElementMayCarryAttributes() + { + byte[] package = Xlsx( + "" + + "X padded " + + "42" + + "Xplain" + + "X" + + "0" + + "", + "shared"); + + using IExcelWorkbook workbook = Excel.Open(package); + + string[] row = Assert.Single(Rows(workbook, 5)); + Assert.Equal([" padded ", "42", "plain", "", "shared"], row); + } + + private static List Rows(IExcelWorkbook workbook, int columns) + { + var rows = new List(); + using IExcelRowEnumerator enumerator = workbook.FirstSheet.GetEnumerator(); + while (enumerator.MoveNext()) + { + string[] cells = new string[columns]; + for (int c = 0; c < columns; c++) + { + cells[c] = enumerator.Current[c].GetString(); + } + rows.Add(cells); + } + return rows; + } + + private static byte[] Xlsx(string rows, string sharedItems) + { + const string Main = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + const string Rel = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + using MemoryStream ms = new(); + using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + Add(zip, "[Content_Types].xml", + "" + + "" + + ""); + Add(zip, "_rels/.rels", + "" + + $""); + Add(zip, "xl/workbook.xml", + $""); + Add(zip, "xl/_rels/workbook.xml.rels", + "" + + $"" + + $""); + Add(zip, "xl/sharedStrings.xml", $"{sharedItems}"); + Add(zip, "xl/worksheets/sheet1.xml", $"{rows}"); + } + return ms.ToArray(); + } + + private static void Add(ZipArchive zip, string name, string content) + { + using Stream entry = zip.CreateEntry(name).Open(); + entry.Write(Encoding.UTF8.GetBytes(content)); + } + } +} diff --git a/tests/ExcelReader.Tests/data/excel-formula-strings.xls b/tests/ExcelReader.Tests/data/excel-formula-strings.xls new file mode 100644 index 0000000000000000000000000000000000000000..36010934224de1c3a230d92d70e1d944258403cb GIT binary patch literal 26112 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 WQc$4S`1KV70$4jp``d|sUHu>7wjd7x literal 0 HcmV?d00001 From 904063a501a7ee800d0f20046d17dd05a9d87ecb Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 16:34:45 -0300 Subject: [PATCH 39/42] fix: bindings timestamp zone handling, Python int overflow and as_date --- python/src/excelreader/types.py | 18 ++++++++-- python/src/excelreader/writer_handle.py | 11 ++++++- python/tests/test_reader.py | 42 ++++++++++++++++++++++++ python/tests/test_writer_handle.py | 15 +++++++++ src/ExcelReader.Native/Typed/TypedApi.cs | 5 +++ 5 files changed, 88 insertions(+), 3 deletions(-) diff --git a/python/src/excelreader/types.py b/python/src/excelreader/types.py index f7f4ebc7..5a143485 100644 --- a/python/src/excelreader/types.py +++ b/python/src/excelreader/types.py @@ -31,6 +31,10 @@ class SheetVisibility(IntEnum): VERY_HIDDEN = _native.XL_SHEET_VERY_HIDDEN +# One past 9999-12-31, the last day Excel shows as a date. +_MAX_SERIAL = 2_958_466 + + class Cell(NamedTuple): """One cell. `value` is the raw text as stored, so DATE cells are Excel serial numbers.""" @@ -42,11 +46,21 @@ def as_date(self, date1904: bool = False) -> datetime.date | None: """Converts a DATE cell's serial value to a date. Returns None for any other cell type. `date1904` should come from `Workbook.is_date1904` for the workbook this cell came from. + Raises `ValueError` for a serial outside what Excel can show as a date. """ if self.type is not CellType.DATE: return None - epoch = datetime.date(1904, 1, 1) if date1904 else datetime.date(1899, 12, 30) - return epoch + datetime.timedelta(days=int(float(self.value))) + serial = float(self.value) + if not 0 <= serial < _MAX_SERIAL: + raise ValueError(f"{self.value} is not an Excel date serial") + days = int(serial) + if date1904: + return datetime.date(1904, 1, 1) + datetime.timedelta(days=days) + # Excel counts a 29 February 1900 that never existed, so serials before it sit one day later + # against this epoch than those after. Serial 60, that day itself, reads as 28 February. + if days < 60: + days += 1 + return datetime.date(1899, 12, 30) + datetime.timedelta(days=days) class ColumnarSheet(NamedTuple): diff --git a/python/src/excelreader/writer_handle.py b/python/src/excelreader/writer_handle.py index afa171c2..589f9dc6 100644 --- a/python/src/excelreader/writer_handle.py +++ b/python/src/excelreader/writer_handle.py @@ -18,6 +18,8 @@ _EPOCH_DATE = datetime.date(1970, 1, 1) _EPOCH_DATETIME = datetime.datetime(1970, 1, 1) +_INT64_MIN = -(2**63) +_INT64_MAX = 2**63 - 1 def _encode(value: str) -> tuple[Any, int]: @@ -74,9 +76,13 @@ def write_str(self, value: str | None) -> None: _check(self._lib.xl_write_string(handle, pointer, length)) def write_i64(self, value: int | None) -> None: + """Writes the next cell as a whole number. Raises `OverflowError` outside the signed 64-bit range.""" if value is None: self.write_null(ColumnType.I64) return + # ctypes would keep the low 64 bits of a larger value without complaint. + if not _INT64_MIN <= value <= _INT64_MAX: + raise OverflowError(f"{value} does not fit a 64-bit integer cell; write it with write_f64 or write_str") _check(self._lib.xl_write_int64(self._require_handle(), value)) def write_f64(self, value: float | None) -> None: @@ -126,7 +132,8 @@ def write_row(self, values: Sequence[Any]) -> None: The type of each value picks the write method. `None` becomes a blank *string* cell — it is the one case inference cannot resolve; use `write_null(ColumnType.X)` for a typed blank. - Raises `TypeError`, before writing anything, for a value whose type has no mapping. + Raises, before writing anything, `TypeError` for a value whose type has no mapping and + `OverflowError` for an `int` outside the signed 64-bit range. """ for position, value in enumerate(values): if not _is_writable(value): @@ -134,6 +141,8 @@ def write_row(self, values: Sequence[Any]) -> None: f"write_row cannot infer a cell type for {type(value).__name__} " f"at position {position}" ) + if type(value) is int and not _INT64_MIN <= value <= _INT64_MAX: + raise OverflowError(f"{value} at position {position} does not fit a 64-bit integer cell") self.start_row() for value in values: diff --git a/python/tests/test_reader.py b/python/tests/test_reader.py index 1f9888ba..896dbe7f 100644 --- a/python/tests/test_reader.py +++ b/python/tests/test_reader.py @@ -823,3 +823,45 @@ def test_infer_schema_without_parse_text_keeps_csv_columns_as_strings(): schema = workbook.sheets[0].infer_schema() assert {spec.type for spec in schema} == {ColumnType.STRING} + + +@pytest.mark.parametrize( + ("serial", "date1904", "expected"), + [ + ("1", False, (1900, 1, 1)), + ("59", False, (1900, 2, 28)), + ("61", False, (1900, 3, 1)), + ("45000.75", False, (2023, 3, 15)), + ("2958465", False, (9999, 12, 31)), + ("0", True, (1904, 1, 1)), + ("43538", True, (2023, 3, 15)), + ], +) +def test_as_date_follows_excels_calendar(serial, date1904, expected): + from datetime import date + + assert Cell(0, CellType.DATE, serial).as_date(date1904) == date(*expected) + + +@pytest.mark.parametrize("serial", ["-1", "2958466", "1e400", "nan"]) +def test_as_date_rejects_a_serial_excel_cannot_show(serial): + with pytest.raises(ValueError, match="not an Excel date serial"): + Cell(0, CellType.DATE, serial).as_date() + + +@pytest.mark.parametrize( + ("text", "expected_micros"), + [ + ("2024-01-15T13:30:05.0000000Z", 1_705_325_405_000_000), + ("2024-01-15T13:30:05+03:00", 1_705_314_605_000_000), + ("2024-01-15T13:30:05", 1_705_325_405_000_000), + ], +) +def test_parse_typed_timestamp_text_does_not_depend_on_the_local_time_zone(tmp_path, text, expected_micros): + path = tmp_path / "stamps.csv" + path.write_text(f"at\n{text}\n", encoding="utf-8") + + with open_workbook(path) as workbook: + table = workbook.sheets[0].parse_typed([ColumnSpec(ColumnType.TIMESTAMP, name="at")]) + + assert list(table.columns[0]) == [expected_micros] diff --git a/python/tests/test_writer_handle.py b/python/tests/test_writer_handle.py index 9f09469a..7b3b6042 100644 --- a/python/tests/test_writer_handle.py +++ b/python/tests/test_writer_handle.py @@ -219,3 +219,18 @@ def test_write_workbook_to_bytes_matches_write_workbook(tmp_path, xlsx_path): from_memory = [[cell.value for cell in row] for row in workbook.sheets[0].rows()] assert from_file == from_memory + + +@pytest.mark.parametrize("value", [2**63, -(2**63) - 1, 2**64 + 5]) +def test_an_integer_beyond_64_bits_is_refused_not_wrapped(tmp_path, value): + with open_writer(tmp_path / "big.xlsx") as writer: + writer.start_sheet("Data") + with pytest.raises(OverflowError): + writer.write_row(["fine", value]) + writer.start_row() + with pytest.raises(OverflowError): + writer.write_i64(value) + writer.write_i64(2**63 - 1) + writer.write_i64(-(2**63)) + writer.end_row() + writer.end_sheet() diff --git a/src/ExcelReader.Native/Typed/TypedApi.cs b/src/ExcelReader.Native/Typed/TypedApi.cs index a7766d07..b2b0fb29 100644 --- a/src/ExcelReader.Native/Typed/TypedApi.cs +++ b/src/ExcelReader.Native/Typed/TypedApi.cs @@ -467,6 +467,11 @@ private bool AppendTimestamp(in Cell cell, bool isDate1904) { bool ok = ColumnParserFactory.ReadDateTime(in cell, new ExcelRowContext { IsDate1904 = isDate1904 }, out DateTime value) || ColumnParserFactory.ReadTextDateTime(in cell, new ExcelRowContext { IsDate1904 = isDate1904 }, out value); + // Text with a "Z" or an offset parses to the machine's local time; the wire value is UTC. + if (value.Kind == DateTimeKind.Local) + { + value = value.ToUniversalTime(); + } return Append(_longs, ok, (value - DateTime.UnixEpoch).Ticks / 10); } From 3e7eebe62b45e8118159bfb08c565e190cd98186 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 16:59:24 -0300 Subject: [PATCH 40/42] perf(native): regenerate the static PGO profile --- src/ExcelReader.Native/pgo/excelreader.mibc | Bin 102103 -> 102906 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 4e912f649529bb940073f8762654b0daa65ee4f9..d9c38b97217cc276fdd628c945103c0d550bd97b 100644 GIT binary patch literal 102906 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

T&_Y$uSWQ0rk>C6kt=jBIziLP% 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 literal 102103 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̰WU}%mD^D$_X3hYd94O{#Edr_ z*LGxJXlNRQb5_-C)OZ$6bfPK{fC^ZhUa*c5mZq_{uBcGzi`yAw8qGcs0mvkbgB;=y$WToLj?^$k%-7ts2!3b#BBqK0or?nc613c< zl!uq=lP95kU@AZ`3$7THLas0BftLj=g@pAZ1G8ZmHf*?xe_HRgohQ{$yQ)XvUm{+@ z%VdF_)GwW^Gl&-`u%pfLMMZH8ErpKYDo*a1dD}7oCdJL1;Uc6QL6*0I`1`Jcf`-dk zUde-PMJAkPSRo}bhYzY4fjG(600F!i3oBb&58(&bzloc)@-E9RxUHIJQrptyE$(S+ z0d`?A^!>c;0g{CN*>~I&8{rm5Z>5fIa?Nm25Fxi_sjq8A%Y6a@E7t-q=oY-bCh@_y zQbYp<@87vXhjQqCG?$In@b(up@3?;0l@?IAS_>?Udyz!9pO(zRR^-ybJxmEbFC{9r z2MaHpZ3!Nt;eyX7$t|zepi;yY8PswnATp^gQIB+GO zD=xzH#Ys9P5mYi)94>=H3|}%hcBPCUEIur_!CE?-if96Wk@t4lVES)Ltl+m%9}J;A zg>9w89q{%EhyC;iZ*VUX&J6|J>h2`z$X27s<1UkDt^_E}0b<#a&fzYaCc(*tAbG}O z7~N}!gK*I<%Nte4cI`!P3mVve+X6(p!rF&=H?c;6CelA%OE`r|_1kZvA>va}j0_}! zxaw=f=}`=aDi#7f{!UF5MEG=tm6OtC?iGbLEbm}1=p$3M1Abr-AKxoA9Pp|Lx_}D8 zZ3;dTk9CVt;0xh`D3_&i-KwF;q^vq&oRkw3ESc!Du4`8z+7*3K5G#sd$qG!xDhT*K z1(Y}KDk$)f3a@+AR&B`k1rjE@kZM(&_zVR_Ua4TR#Z!u)K$cSkm*-Z|O-yEEX*qZb z)*5%&HkAx;gOaaK3YDrxdnbapyEl}}zn7)$q82Gfhj19$2hW2?P6dO@J zeaV%l1D6Pehb*uqDAjW1k8-|^=&I(0Fo7~%#xIf85&_ZtY~eU*K~AuIz+q2B$g{X1 zq|jMs9RW*?w+M=XI2oGFs*$3nl`h3+659y})6_3;R$o<1S8n5Jzn@1Al`vA-IDz<5K6!$+LG$(Mz4^h6am3`JfzU&iywp8p~O`oEa zR9v_l+|IWzP0i3=qDocokE393!MjLy#nV8*aZK@5BvS0wnuvbjw#e;t8||`zi?mo` zr78wHpkcQq*YUtx*+pqJgb+sq z?Nx{evFz9u`opO?+SMJ>GEX6beFxMwK~JHyIiS*;#Vv(0iire6N^^;n>yd=zfPwCj zIJs6!IIJg@SaF9*om&!v!wo(Py@o|4z_+>;XDjn1K>F?w66Nc{QSB|UxWY*Lp89HO z7Y$`>MUvW3aHk?zeQ>Wb^Mi#FvszqKXrLSlK_d1NZD;7OGTX3RI`RY_YzPSjxAak_ z121)fz=XI4Mr@^c%{{zf*{E;De*vczFqjj3(MQObYd*q{k``nUY#Je?P(YqiuIiO!f{nXEwNIBX%}Z7 zbRL<t9rT-9|gG42s#89|A*wZ9N8mJ^3HbP0P)Z9o|=w&1r zSOj5kxqg}sB!#4~;P4omg5xbTO2zh(+^$LMYbQ27J_otYN{B&2IIn@ zf@Ky!=O}DnFvuuy&*B!D!3|K_7u+BYV!Ogxu6AqAn6zIa{lNFwzoq7mw%S z(=T_ij;DJW}%H2!8qnhX@ zZZ6Uogb;>;K)s5s19Mm0lb3bq`Y^GYLx3xA9uH!<6G}vg3LB@fV@cPG;~3SiKF1)m zPEvuvL9Zu$u=Y@ur=I&^}X=u zV(>31NqG{dyuBJI2_cP+mXJ{HPA?WMad1-Va$VLIE?Dwfo^W=`dcw6Y>!DD90zuY$ z8AFrYN9lf?tS2sJwpX!S7bFb=Xc<7^XO`Ew?g_C!a4m=J!>Z|yxK3T3@qiB$w}Su{ z-wK0Cz+wbQ$VKm30p<0&NU7fT!IcxV%~d^l>Ky_t0!Zt1=vs9;ZN!8-4a#%^RkB^U zR1bVQ1O@}sQ*zufAg;UV?-cD15hLcffER=g)Qu&PTYMMcTe-~mQRkdBHYCpG2vVnA*c zixct!7w9O#EJT?(mu@=?-nwPrc3_BtMa4hR=t)}2< zf`Uq{HFHs3CWA8zIEVm4GgU(V0miYWL`@lMr|Trj__08VL8FrsA{{W!Y|b`d;ldg; z{G>Ppq~sz083ge>rZ6H0SXl8zQaUq$sz;zy{jV(MU(Zhb%Suw7#LkCU?HC0jz^jU) zph*G%WD}7DKA`}TGNv#A^YoT6gW?!sXt?l=Jxus$;ow%W=++q^#FmUM6*}-PaEn|v zxYLp4;kq^q8o*|`)wZ0XlqK#WWr7zEZhvk+5$7|!TZg8>W8Hq9pZGme#1b1z7_aIl zexFph^dfTP4GTtbHE+1OPf=oKDZ7^v8V)uXvHS(n-^+@J;jCyC26X~I#~s`lTO=S= z0D_g_j83k)crSsJLX>8DSvM|dEYS+$oYjx_sPF)LrOzvmZWY^MSUCkV2PGq%@(+Tf}0(Rdcr)Ro*j^k07?n%7)tvjmG?5w@tWKT z#4@O6#nUCYYY|M-n$p>ZX9rQX$ZMRE!k{HwiBRPtbT$pyPM9LSw<5TPPMrI;kN}cV zBxErv44jVw`fG)X#4=={9V)?xf?z!QT7V_36=|`3jDG@NFmD0u7m_OmTzd(6+zHpf zl8B(5D%M}BLfAk<*7B^1^dD7`B%=#g(*UKMfCm)vIDRcLdM@Gr4VFu>rvD^g5MD7u zFW9DE9OZk#9h62TiXFiP)#$JRe_L-8^%>~^GhyH@Xy|b3dn3ceX>}|nP`iko3&vJ( z03c99A|#n8DI`~Hgx+k_@vqRXIAS1U3S4mf!|G792-Rq}Ah%fXiRMSAu>?YJznGSu zFTq;_NFLG&a^hA)V9v!LW%PE(0Dn-&!;4*J;f`!VH>tsx@M)J;%a#t4XuAasqrrCT zbhZiXe}Ol+fIP>>6!JRdWk`~v2XTD471&ql8*PXcc+LQRS$Mwz*Mv7|&|zX^%is)) z?%#G?x>1mL-1O8RjZ762dj>xN}CVD zcrzmMjO6|SI~W^pcw59ZEH-HWgufHq^&GoduG$UHzubG=hJsP&iN_W_-36uy_-wIh z!ET$g>J5-fOp{W6a7F@gr0_K{5g-9)9x*|(mrRl`G(wWySJJ{}L^#~?WI||IrGvi& z15KgaxK(P{xi3V*GU;wWALnj?la^q?@kn%QY<}e=8&vU)cr>M@hW?Q&Sp<2dOBiaG zt2VY(QjWXlq*{w=>Ym#+0Jap=n}#O|-a5Pk(OPfARIfaS(joA?y$UJ51RMG(-ke-xd3{aD2sKVAQs5Bmw!qNj zu=OY?7(W!tToypue}dm^)G6Wna8aykK$SU&AEaI+j9}MA01v?UA#boHg25uXq%9)Qs2G6pwPG{uQmIvqnEm4s;Vg;Cm*=&(Vd z2fkiiCKoCSZ7o$Nm=+g7Qh^Y2BnKuvdfb9*o5ZUpSL$97QelGAYE?!eDlO4jADM#oB60yD)s$m|bgzF9kqMaE&rRa#2G-n6gOe-dKPn zZ1FOtDl9tzApE)l1Uv;J5Ym$EhP3c_YC>B0T1rPh>R+)Bie@3k#zzK=tdEk$0vQ6- zC#VCVjW~lBtf}vU=&$N~{Iq|=BOIEBJB#7Y4N*PcWmUKYEH;29n0TLf+HzGgo*Mwa zC`*(R=!|_-$>B9R3NKbl7fE(~UW9E3zHhc+DXw6$$QHLToVY<2sKVr$UiF}ya6}|U zYEr@>ktYC50q`mXCoSD+wsJ(|GfYjhqAw97+i}9cF9Z~gaTy4sxJb9pOX&cv4u!^o zoG58c7;e=*#~thziX|p)-5ZB-V~bakqn_C41)xj-n7RThZb=RhFjz%03l~1hA~A+4 zLNu=x(v*(27qo%PG_B~w-bftA+n}Vv@JN)j*Z8&^c1AbPj&q1&YX(ShSss1|uG6@k zP|D}6A&4-cazZyoew>I>43!EN-Ch(hxE)k{u>UadR4(jEgpspYvZ#91@S2i0C4d+a z9~3Mm4HX|N8rg^Gc2yrFu?Y7>0Na$v@_heRI^-Zw=(hJ>$j5(-f?>P3ajW#duGm)+ zGyUJF*qp^>!8mf^(BlFe7i!Y}jhu*r#DFGrpa_9mWqOV~#v$+8aHoO=I6 z(IJ!!rcaR2QVNP6@x*MZWGP&#f+l0^$|+_uhpjN3d2Ehx<}jBT%w`s2T_bFm-OO05 z$Xv$y&M<>ntj27%3UGIpIjqW-8S7eOhuJ~K`es>!S-tvOo}Z#S9AIIH?KcLm;JRn}k* z)(*byC}Xb=KV4!Dz75{Wm2a7523vsqGe2JbSI|bMe=;SuwCJI5sTH=2r9ggYyCp1n znd!{JmNc0QPo4RfJoO5`vHCA5_s8s+?90ES!gS`cvp60~IO=sVR!k^?{po-IKkt9; zyH6C~`x{B^#mw?ISd=j>e&7Fk@Jqk@tIeP5VNtEGFWT1^jmP`by^(Y*9qY(wTD&I{ z(c=B-l(vgSGfeBqXz_Rl)8g?i#`;rxwRnt01_uW_n6{(4vp;>UmX5_4gTyY@#k6Fs zFLhHa!;+~(od6{VJDH~SrEVf97GqjEI~dDoTAvoput+S!`cgM_WLP>IPik5!m)yrX zdbBiT97@l0vdB=MHq^K*B3(bW0%z41MJ;7#$p@ff^t zsEbA8Nr_CxyICZeI{#oY9*?tVq)&^aaxtbQ`l2zW#qoErFWSMh#9%C`vD9QohNX@} zZPQtxrH)54Y;Z?Xi)zVzEV+;EiS}sGIE&)9?di%yv|VhM){}`w;BV^uCbVB7nql!+ zMoY%KJ6W{5yL(4GsYUjP$74~(BAqM}NhBjN7SR%k4yHxmXD9*iP@)rllaY8P64}L) zyR@#1mfWT7VjWD=h7y?_T6b4hO#BB`NS(iw*qQ0Tzp3+2?oGs*mOB4r>im=4nJ8lS zCgPvGeg6K`7kl2&%_5Q1`Aezumv*oSG|naX7h^1a{<*FUc6ls@znz(g)~Cf|nTR$t z7-w4g{8v-wFLm{&&%dChW8E-{Qs*zFV~I>e>rbEmhL%46SnB*YJ2RRl{-n=;3$nim z-J8+0T?~rc#UfoSGB}i4*E*S&Uhf`)?;q;wPp$WA=~xfb1~IvdrC68NmpXqb9_#7p zORewe(~_AkD6}soQJtBn2FXk(;RQOGVOY)n)cW2?YW?a6c`p6C#YTcbo>rSnY#3e1pSZaL)+9e)Ogx}K*Ex3M2 z+r{GeH?@9s502gQFF*%o*e7bZq7lrok0s+o38CV>Neu?IKoZzR{Y{;}6uAniZ;G^2>9-H*GujUNjb9<)a6xTF3~cjC7`8B`EjOFx1y|{N-INs_h*b9O|aF z*t?H)vS=ich`^En`6G!4G;n&I!(5Fd5>dveobEX5h$p&XJ*9*OU2xUSi5@cpN*7X^C!FRpF<*yDP&mV^30x^kHSYVTI7T`}Q$h#v)?zjU|#Ab}M%3KDJZ# z831w_g7KMNe{>KA>y6Q^~X9hK!9NbeGj8;)t-cznbg=m z7@DccXod~NZU(TcKfV4bEgkD*+AfyP#xg9u{!C{^8-!V#(X?a=zG-N%3toLWorS;I z^!nv4przL@cV<{Jz5Ycce>t6n^?oMCv5&Ar9W}{ z)3XCMl=Xj%BocdL30ND~j|eyppLqT;tlf+F`@<_UaV@<8Ygs(C5sReOUreq4P-Nei z>DE#kF#)B|KPEDYT(|~q#AxbcmMCLGaq9M6tUD8pr#4c3xU{6te>Jh2MF$gCc6VvP z%GRg#X?-k=i8GjCFf20B2(ELqHpE$nHn=wykM|@sNbO^B*tWI7L>COJeJl~}(O_4P z#A(%t#WPWDFRU6^ML_Lhoh%aH8;isKojwm^M@w&XW5@QTZt7(ndt>Pt_|(+Kwa`Et zBi$KoZ+hcUYU9Z6OlN;;FttAx2WV@NYUsgZi1BndshKGCKi@)W&3L zBi{{^20ta8=>Rl+D-nT5!Ip;>O>Io#?iP#FH(Zz60ML98I)k!W(C2H-xn4_YD8*@Kks{`5u@Qz;fr?!qj%NU~(4mqpVX?|>@I zQ17HS9>h6&8PgZ=cYQ~YE3%VC_xeCAqeVnr-`kam>>Wza0DrZ2D4k7bBQQp@>1>ik z>96SNcR+KeHy+c{S?Hn1ps56#wu^OTwDb&p|6XLJv~(6$qbw{Hk%;`8noLc0vqJaM%D(&@j6khwVL?+PDP$o!WR3(>qzGHW-g5 z<9+c|E|H9g?G)DJcxO)}wehJq>_T#r1uUlHJ(;K$PeK{b02c?7EVc1$d_RMSo=v7U zo=C4>PHjA!&PGHflj&?Sy>U4S%^y!~Ji8+kP0z&l%2rKnJc+B+=XYeHspIjzspET7 z$8qYeU&hS>_JBxwO*Rf2I>)m>nB0`q*@)Lkuq@!6HIS#t&h_clW0r&`@;g%xEz3VHbY@ z}Dy#d=qYU4CWF7ZAPfcEskheZI#q7f7^Vod8!#yVM~8-d84J;=EA zrye*$Y|f5MhZcvu_nBm*57MxkrXDyBL+yIDQ%h|;0c03V*R}nFu_L2JhW`I{z8*%3WLq;LvNAI=vnnehdTOTn z-m1RYPr^VeR$ySTTBL35j)8%+ZD1h1)~dA+X^}2^;ytkvS|L%Dm6cglU|_U?fiVUK z21Xkg7-Niq(b8g!fw2Y#1_nkO7-L|dxBAYBtS;u>-Df>%rG5NVMn* zo8AHL*@G(NnM6`?ON@(%S~Q|u{#962lS61<5n@8zHmE}5!hti+Cp|rBs z=!MJ2c6u%*9mjgyPQK8gJOaGVUHK$|%qgGR$(N+)AO)z7{v%9A<#Jmd<EKfx*$ zwtNmH?)LN4C}mg*XQ>+4@`c2F=#hXPd->cW!3^Mxvk(d@*M)NX5HwK7xyelq`yIGE z(1bp9kjf}Esy$6o6%B0p5}84p*-rzXN!CZ?4}l=@k9?qe=yRb{wosy6bQl^jE=nNe z_8{XlWXPVK?#ECOH{2dC_tvU`lpuMqZNlvgxMJT__?+#2 z*blZzj0Xt}sXU|UnqjSB!eyCJ9f?wAdppM@Tbrvfk9XK5TXkP6|n1}!yNeuuA zX>e0^@+&|OpI-wC3A-Y6d;P#oCrS+yPs#LyKC+pYp(|S=aOEjwphrJTTmD8PML=XM zgQV~}Mq_1uTTrhPg>vPoG7!YdmcJQIn7aH;{~$ujpc|RfqBW9&d!jtmgDE_d{&{_x zlMt0GB0H%HBEpf8G4yONIL(fT25uID1*LHX2vaHuGKe@&Okncs9pR;((Ub_xj>raX zW|f@{>};DjT|680Ng4RrL(j3ZF=j?T)LoE^O!&-p?HTg%5^+dEeKW@&tdabxJA$g; z5xS84?ioq$NaHD48ra!6Zcx{RARYLEOGCZBGO)ArGWw$lf{hi%OTaHPBD~HPpZ{$e z7jFKBskF20+^t~>=&+oxM|SDs>~&n^&w9t2vG(u*RmQD4f)&MtlXn!X46 z?Wp*7VJY)cM%|**R<9H@L(@rLgwoC~bGEkE^uW%pDi;n7jWk}Kjd%&qgg4>WQZD|M zS@g_b9|XN0c(CjmSYppjCk!>dMndMmZ$jpF`VQ>)^ajA+hTg`D_(LltBUq;8Gh03b zg9h~RCSh?Ag@`D|_N&8}H6)8!55I&Ejy-1N2Grd=}&HiqMO{N`9Vw#*nZkl%2fi8S@_G;6~L%W1##vR9z850c-r%@ z9t^_-`fPUW_D9rZlM^??1Re3v&F+LkrD;m$-R$QaCdY}JoaE!s&0e$7@oPQ~HB@pu zu(LNR47DKph&(U-5d0U_Z_6KH(>;7g)YOPRhJT zPR0?@4#(*D@|ZfFq4oFh!A#{2h(j?5!U)B+?=nhtTNI`BI}th+Z=~B@SSSzhoob3?aMn5U!*ATj}ZT6jDVS zzMR(H@aHKkL)c>)b}lEdW=sIrpw{qqxbkVh2dytk9HDPnkOx~uho^Jk9Z_y_^#>y( zrPrrxuT&1Ok*rTULIz1+bhL@^mm9oOU8|6R&*$cyA4ll}C_76h+wY%Z?t#;{g(4+}T{%UN`#&h5* zgjhI1$cBd1XITRAn}x6r^L$Hmes?o@fpngCH!9&c2^d}`)G899Z>#f(khZ#DY;_4l zh^;Q*igc4h$PJl;@o|$|xx(`Fs2YDUfXspH2;DX6>?BFyKPg|z7}h}?!$)d@AkYcj z5=D_t1y7Njnxsq~6GLEfVhX%P4-SFy$=6x~uMz zj}o|N98$;!w1%|ReL#-Z$g|Zw)&U#fR>;J!UR&KWtRePDLts5aURL+OnuQpBLk3`Y zW&nl=TG3I|2Ceil$sQi(ZPbW{a}oLlzLDIlDGPr{icseozDe)|mp~vDFg>r`1tztEY^=Ty+%FSXM`_Iszi6TYCaw7_AMS7H5lw zs1O%wCxVR<^b-m>4)ZM$x$0$Zt7BWevej!_{hY!Pv(>Ri*q3&b=P4YZ)l1!%39PaW7^{0C z(5`yv9W9u))z2`os`svXnf56-;W?3ceOtW;Ni-aY4!u^WjjKNT=6wo;NC^Ah@>EFm z3DB6b)?k#WPy6Kev;8b}drpKe z4uBbRaz)H(@yx@v2+VgnP?4}($Un^!OybW{Z=Qga#fh7TmKw}0wbfTQM=%uHm<#3R zmM^re#7I;A_BuZk(#^;IO$it9g>xB|bMvvAOVE7KK3`-=i)wzplcs6PJt`z8%m!29 z<4Ryxk7bw|@FtXW5Z06HL3?*4pY4Fz|Aj6J4d+H{4x@xvge4YN(OFz#bXmU2W#Na*^;vHyhxxOX;cu(f>@eg zGJ;iBXvTJa2~Qw!gqIM~&VQt2%mWkqV-gf1{!Vnhd1tNd9Nub46=`3zJ*E&v+RY^b z$@~kUSVZqbhs$)RdQxLF7=?IEjm!Hjc5HJhi`tbYk_}LsjLY#Bcm^@H^Ba5-{#YeP zBh+yqK^%Dvq()hBmW~H@eya_mm>z5u;n@rtb6d&o!iY%Jb+?`K_fdbE_X=g_6P^z9 z2i=0DNoZ)SNHxqafBLEK^SJ{;sA8&{*fhLensx=Zoz7_OJm!-vR(#bL3OKRzCkQwx z)QF#f^H3KZiQpQQ6d@;kzZ`Mn5f(eYEE5QFc$&6%#9ENU1E^J68(^fAb^=9{F`Jg%uKY8dq}=7!nOte zPU?zo(b0)tmGn}tFyu^hqxt+)Zd+c&zH8{WUqp8Pnv(RhMdt=?AUJbyNJ1eqIMh{^ zB`{z4VnSd6Nm5MnpIgO*2K?-Ml_V6MZwXpGetp2`oWDT_C%Fj-ue<3%4DY<1zYBy& zdG;ldP^n3w-arsRdZU6L1Z;}Qb->U0`*r_@gcgbNqqBCr=m!%&gqoaxEXAPGK=0uw_Xn*Ywi5K_fl}h zeXOdFXXm^gK`u{Nb5(@F57tvbl zM948$6%n1O@>cY>^1eOwHYFHIpVJc{DQKb^9dtc}qBAh=Tpvh1&QAfq=U=6*C3Kye zTaAI$ZRU>XFx_4!Zoa)S^ltvZ)jdreGM3>VK=rwLAYHxP-avIQsIFxS<;T}!POu4E z59F3$bx{KzkVL9}i-ofFZmu;er7`3z63PHxRUh@(*1Ou+`n(hISq9}Gj;g*OU1&vg zb}2QkzMP6c@trwXk31tN_fZ>HBYMMCp#NNbjRDp@#2#vxdI8BVm?*AB1fHuY8e?v$ z_`d$`A{809`sUezg{uV!tq(xjq_#fj7RuI#RAMyBijJ)h-RyXz$RC=bR{?k0^_qg_ z<)qHDA{c8|-*WRKW@qO|nqGji5Iw1ohD9V}LlPU8>f6{p&`RtC@p@xzw6+9=ZYsU5 zAotPHlrts^5+`{P`HD0`#{Nb`n@jF@YqVXTq%5~{TZC@@i&vch6{#yCpX1*)Ppv?f zn;*3qX_u7x5lZA~k0&P1ZG8YcIqiWWpC9R5D_b9smO$jI!hvyDeb3{r`W{Wddwn=k z>+3Z7V{LMBvDVkMA;Ytt#6_T>a;$)u#*5NlTUS`)b;U1MgruKf1aO?W)_SZ-y-?Z| z36`*}7uwd3dkUry=B=$4>(paEmwO-+P#X!y`iYP9p{5fIsqc||u=Tx2$Z3E>A^^>ty224J>13O!ct$%`3kP?iSAjkUXDqH_# zZ2gO|HNr5d>{}vDdZAFg9=Iu6zs0lC9(p>pHF@>w6I;Kf-5T{<^Fp|!BX2*xSokB)ohJl&zKNQV)2yluRL9Mm7O%vFU^b_MJK(OzdJNeri&M%)n^OEKEYHFuyJO^Uzh zyyi7OX^*Da2~Cm{+q^}K6+1PXcL`iEph00B`V_a}gX#@j^AYprlXT4~9y!93q-)-z zh_VeL8>DT%CZgjO;V@5VM!Mz!#skt1XNwTlrB)-mfT$k1X3sURv28}Ug@rO2K_ZVR z6E_aQ{wrm#X&-lbOaIURvGq@HFTCI98R(i_w9WFIcFies3b!I5#;$n}9A;dzOF3kS z67>7fgRggb%hey;;@pTJG~XT%+~N|2^$XYRB6MqL60>cFde=x^O*_KXp9gMnnQ-EQ zEFR#6gLq&Uv`qujcySfR@I^_xq!Osr1;S-x!#S}(=UNxn{%^Iq#dX@1D(qt4kwbpL z7?o~=cVlr=V)J7lDQ^oh_<*p;;()z1sG&J^1Gl)vJ=G3n7xHs)8@?ROD2Y2!;Q__W ztM;NI^Ko%U`UD7>fnD5_m>HHMI^3-NReNF~Vr_k(25#|M zL6YVJxA`@2tn%;{A7I z-nD1mzCjN!o}&d1d`n*VBeu3A)G^+LxtRDXhyM7=m%xKL=fq!m>yN)m7aE~lv&-I< zW*37Bv43v6=+Gnq>-g{^&pz9XU2_hj4`Da{6AW}J(pJ!xVm-7CB+c1e^ZLs7*qS># z9{nzxCQ|(Y`e&GI57FNrp}| z{S?@tJE$TI#J@2zLdnGfp5%56>4vu(YlZHjRes779L{1+!F>#*LkrKZPD5$x-7~$%@t?V+i%LMI9rCs*A z8;hP<+@-~So@HW}ua=^RQ2xgFk^2J#$-IZGi`oR0Jm|5<^#z4t~jrBz`2l5R3 z>EbR-VDMwMJ^&Ru+7c1Tp*yHW!a0tL1wCTP)U{on=fDLdIOCjD^coj{y_T1v^rE7M znjM)U!Vm=<1xGF~Ym(%)xs2U#zL4?r5`mp=c?Fq12SUv*ua0={HAtp(;Ffy|_lL9* zwf=yewy4N0uaLEcr}0eJ>#jjF@wN!l@kB#pmp}5Wf+Sp?qt7LHOt0F2(y-sQ-%kug z$BaWlm&1G>c>~{M;2>Y%jOi`(8|)vljQ|t~4`M|kW_?G7 zDTF6&?w2u0VArs_u|5P9wLAf+2v_$^p=%UoCePRb?GISqQeA9IAf80`$Sz#o=3V*` zhl<)|LQT3e*m$2aooBx7);7a6r`#(56<*T-(k??q-9sHpI|rq<%ezo9x@VVnDXN}v z37};!vCDe^d9oK{i;s-p!S1zx;#kwnwhirIS|$|3k5woAg7ja_#=wldBm^F`~+9gX>!~lBSMqnm|x}KD@OjV*`?XybM-b! zB=6%KBfMz&3tu5Xx4c4FzS-6EOuOu5?epxiM{Dauan)?lNVNq&wq3rZR=)L$tw-3n=Xu9^;nj= z>4G*-K5)~8+i$mEFE+Pe<0PRybyOn-dQZ@Es~*w+J%s=ziorUh?@>^|Qm2qPc8j^y zuA3ZEr1RtuflOprtOlG0k%F# zdsJke6$+h7x8tqwHmGkp8M>-Mdmnxs(g4kmTp3c2Q*ZQmmyh=dP>jlB8q(>)RTLzT zu38{5op6FfW)Vum0kO2Bq)@F$XUTerg5GfyV)`KmYYrxs)*F;5GS-T8a`KS-pjk9Gjhu>S{b{#j@eH;W$o$~ zIWX`I7hn>%d`}&B7i$-5=w1d83EhlK0I>P?7^d9nev5hiRJ_Mu;~~omTg;kWVt7Wz zt|lCL&juWHn_L&AkmdCi?A1YCnCX*z;8u&Xk`%1KRx&Aj*PKhp@EW^jHxObQ-;wIu z)f3fMUAjOdZLMO^w1!Lu{V7GX~AMv1~R%kie9&Q_D@E?s3ksJ-Tk;GRx zauKRgLM=>8Kg8z&d~=`mWr7AIebraJ_K|c(sOH2rFA|}Weu%=raUgAha1xN#2=j+u z8Qr{w=HUJ6gAr|T@CMN8IWcnefrl@vyV&61nq6a8FOBHf)oZQY>X>>;|2w0!UA@Lu z$uX#0s(D{UI)_z37rZA;eZ;$}d1z*2gok$8$OwR%oQ!t@vn)0TM zhqU>XzNCe~@Q26PBmy~hE4(Q2U7%Fj)w}*hzh8}m+yW;8TVC48FdDn)V$0h-@tKGg zAeo>o+J1jY8Xc!W9NX3Z!uGUn3X7K8;9uAyv_m^e;W5V8lmh@U?{1hwl2z2W~~N@c2F$qFuF6IqksY{aD!J z##^u43h30Wj>*Pbostk*9pgh+tIx;)OXQXxbV^0H1nqsoIr(h8u|?G7Ic5gVF{Js0 ze%~;@TAikbw9V=iLU?RfUv#R-Tf-&_rW@E-j$TUPAA;13(L@*XpBpTg)$= zxw2RE*&bJ4N7#T{@z)wc-ZTktXRy!8fAYg;yVX}?9}#c)h^f~{Nm5ArD8vnj7Xp1j zJgj{b(xI6|*fnd`P!#~ffYGbJFcbx#C$xtUuTeU1k1Qu)P(B)v&7$q20e$3bb&iFB z58Z!cNzy=&Xo~g6cRfaCMBBgGVntw=_s2cmBLfGH4ZFN=m-l_DXP5W0HqS2avkO6W zQWa9XEj;as4~PW~)&^|Q*w@&8)ZH=Y0j91k2Wr7Rl6^{s z_|%zwbOCPj81@TdxG-TuKqoP_^*w+O6PnEb6+!mRW>SEnEXyJcxXv=sfhP_W_G9dl z#^;=E^TRIV=iQDY*0%D1^@s60096206G8w0EdnN G0000*xVoeO From a70c5aee93919fe75bf1526ffb02318cf3e40718 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 17:26:40 -0300 Subject: [PATCH 41/42] fix: bind numeric cells to bool, enums and parsable types in XLSB and XLS; TimeOnly from text --- .../Parser/ExcelCellReaders.cs | 3 +- .../Parser/Internal/ColumnParserFactory.cs | 22 +- .../Parser/TypedCellFormatParityTests.cs | 191 ++++++++++++++++++ 3 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 tests/ExcelReader.Tests/Parser/TypedCellFormatParityTests.cs diff --git a/src/ExcelReader.Core/Parser/ExcelCellReaders.cs b/src/ExcelReader.Core/Parser/ExcelCellReaders.cs index b186ce01..da0833dd 100644 --- a/src/ExcelReader.Core/Parser/ExcelCellReaders.cs +++ b/src/ExcelReader.Core/Parser/ExcelCellReaders.cs @@ -76,8 +76,7 @@ private static bool ReadDateOnlyAuto(in Cell cell, in ExcelRowContext context, o private static bool ReadTimeOnlyAuto(in Cell cell, in ExcelRowContext context, out TimeOnly value) { - return ColumnParserFactory.ReadTimeOnly(in cell, in context, out value) - || ColumnParserFactory.ReadTextTimeOnly(in cell, in context, out value); + return ColumnParserFactory.ReadTimeOnlyAuto(in cell, in context, out value); } /// diff --git a/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs b/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs index 0af63c74..055cd8bb 100644 --- a/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs +++ b/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs @@ -119,7 +119,7 @@ internal static ColumnParser BuildConverter(PropertyInfo prop, Type conver } if (propType == typeof(TimeOnly)) { - return BuildValue(prop, ReadTimeOnly); + return BuildValue(prop, ReadTimeOnlyAuto); } if (propType.IsEnum) { @@ -161,7 +161,7 @@ internal static ColumnParser BuildConverter(PropertyInfo prop, Type conver } if (innerType == typeof(TimeOnly)) { - return BuildNullableValue(prop, ReadTimeOnly); + return BuildNullableValue(prop, ReadTimeOnlyAuto); } if (innerType.IsEnum) { @@ -272,6 +272,11 @@ internal static bool ReadTimeOnly(in Cell cell, in ExcelRowContext context, out return true; } + internal static bool ReadTimeOnlyAuto(in Cell cell, in ExcelRowContext context, out TimeOnly value) + { + return ReadTimeOnly(in cell, in context, out value) || ReadTextTimeOnly(in cell, in context, out value); + } + internal static bool ReadTextDateTime(in Cell cell, in ExcelRowContext context, out DateTime value) { return TryParseDateTimeText(in cell, context.FormatProvider, out value); @@ -292,6 +297,11 @@ internal static bool ReadTextTimeOnly(in Cell cell, in ExcelRowContext context, internal static bool TryParseSpanParsable(in Cell cell, IFormatProvider provider, [MaybeNullWhen(false)] out TValue value) where TValue : ISpanParsable { + // XLSB and XLS keep a number in binary, with no text in Value to parse. + if (cell.Value.IsEmpty && cell.Type is CellType.Number or CellType.Date) + { + return TValue.TryParse(cell.GetString(), provider, out value); + } Span stack = stackalloc char[Utf8Text.StackChars]; ReadOnlySpan chars = Utf8Text.Decode(cell.Value, stack, out char[]? rented); try @@ -480,7 +490,7 @@ private static FrozenDictionary BuildValueMap() [SkipLocalsInit] public static bool TryParse(in Cell cell, out TEnum value) { - if (cell.Type == CellType.Number && cell.TryGetDouble(out double d)) + if (cell.Type is CellType.Number or CellType.Date && cell.TryGetDouble(out double d)) { if (d != Math.Truncate(d) || d < long.MinValue || d > long.MaxValue) { @@ -585,6 +595,12 @@ private static bool TryParseBool(in Cell cell, out bool value) } if (Ascii.EqualsIgnoreCase(v, "true"u8)) { value = true; return true; } if (Ascii.EqualsIgnoreCase(v, "false"u8)) { value = false; return true; } + // XLSB and XLS keep a number in binary, with no text in Value to compare. + if (cell.Type is CellType.Number or CellType.Date && cell.TryGetDouble(out double number) && number is 0 or 1) + { + value = number == 1; + return true; + } value = false; return false; } diff --git a/tests/ExcelReader.Tests/Parser/TypedCellFormatParityTests.cs b/tests/ExcelReader.Tests/Parser/TypedCellFormatParityTests.cs new file mode 100644 index 00000000..e4d771ab --- /dev/null +++ b/tests/ExcelReader.Tests/Parser/TypedCellFormatParityTests.cs @@ -0,0 +1,191 @@ +using ExcelReader.Core.Parser; +using ExcelReader.Core.Reader; +using ExcelReader.Core.Writer; +using ExcelReader.Core.Writer.Xls; +using ExcelReader.Core.Writer.Xlsb; +using ExcelReader.Core.Writer.Xlsx; + +namespace ExcelReader.Tests.Parser +{ + /// + /// A typed property binds the same way whichever format the cell came from. XLSX carries a number as + /// text; XLSB and XLS carry it in binary, with nothing in . + /// + public sealed class TypedCellFormatParityTests + { + public static TheoryData Formats => ["xlsx", "xlsb", "xls"]; + + private sealed class Flags + { + public bool On { get; set; } + public bool? Maybe { get; set; } + } + + private sealed class Spans + { + public TimeSpan Days { get; set; } + public TimeSpan? MaybeDays { get; set; } + } + + private sealed class Shifts + { + public TimeOnly Start { get; set; } + public TimeOnly? End { get; set; } + } + + private sealed class Days + { + public DayOfWeek Day { get; set; } + } + + [Theory] + [MemberData(nameof(Formats))] + public void ANumericZeroOrOneBindsToBool(string format) + { + byte[] bytes = Write(format, ["On", "Maybe"], row => + { + row.Write(1); + row.Write(0); + }, row => + { + row.Write(0.0); + row.Write(1.0); + }, row => + { + row.Write(2); + row.Write(0.5); + }); + + List rows = Parse(bytes); + + Assert.Equal([true, false, false], rows.Select(r => r.On)); + Assert.Equal([false, true, null], rows.Select(r => r.Maybe)); + } + + [Theory] + [MemberData(nameof(Formats))] + public void ANumberOtherThanZeroOrOneIsNotABool(string format) + { + byte[] bytes = Write(format, ["On", "Maybe"], row => + { + row.Write(2); + row.Write(1); + }); + var strict = new ExcelParserOptions { ThrowOnParseFailure = true }; + + using IExcelWorkbook workbook = Excel.Open(bytes); + Assert.Throws(() => + { + foreach (Flags _ in ExcelParser.FromAttributes(strict).Parse(workbook.FirstSheet)) + { + } + }); + } + + [Theory] + [MemberData(nameof(Formats))] + public void ANumberBindsToAParsableType(string format) + { + byte[] bytes = Write(format, ["Days", "MaybeDays"], row => + { + row.Write(3); + row.Write(300); + }); + + Spans row = Assert.Single(Parse(bytes)); + + Assert.Equal(TimeSpan.FromDays(3), row.Days); + Assert.Equal(TimeSpan.FromDays(300), row.MaybeDays); + } + + [Theory] + [MemberData(nameof(Formats))] + public void ATimeBindsFromItsFractionOrItsText(string format) + { + byte[] bytes = Write(format, ["Start", "End"], row => + { + row.Write(new TimeOnly(8, 30)); + row.Write("17:45"); + }, row => + { + row.Write("09:15"); + row.Write(0.75); + }); + + List rows = Parse(bytes); + + Assert.Equal([new TimeOnly(8, 30), new TimeOnly(9, 15)], rows.Select(r => r.Start)); + Assert.Equal([new TimeOnly(17, 45), new TimeOnly(18, 0)], rows.Select(r => r.End)); + } + + [Theory] + [MemberData(nameof(Formats))] + public void ADateStyledNumberBindsToAnEnumByValue(string format) + { + byte[] bytes = Write(format, ["Day"], row => row.Write(new DateTime(1900, 1, 1)), row => row.Write(5)); + + Assert.Equal([DayOfWeek.Monday, DayOfWeek.Friday], Parse(bytes).Select(r => r.Day)); + } + + private static List Parse(byte[] bytes) + where T : new() + { + using IExcelWorkbook workbook = Excel.Open(bytes); + var rows = new List(); + foreach (T row in ExcelParser.FromAttributes().Parse(workbook.FirstSheet)) + { + rows.Add(row); + } + return rows; + } + + private static byte[] Write(string format, string[] header, params Action[] rows) + { + using MemoryStream ms = new(); + switch (format) + { + case "xlsx": + using (XlsxWorkbookWriter wb = XlsxWorkbookWriter.Create(ms, leaveOpen: true)) + { + Fill(wb.AddSheet("S1"), header, rows); + wb.End(); + } + break; + case "xlsb": + using (XlsbWorkbookWriter wb = XlsbWorkbookWriter.Create(ms, leaveOpen: true)) + { + Fill(wb.AddSheet("S1"), header, rows); + wb.End(); + } + break; + default: + using (XlsWorkbookWriter wb = XlsWorkbookWriter.Create(ms, leaveOpen: true)) + { + Fill(wb.AddSheet("S1"), header, rows); + wb.End(); + } + break; + } + return ms.ToArray(); + } + + private static void Fill(TSheet sheet, string[] header, Action[] rows) + where TSheet : ISheetWriter + where TRow : IRowWriter, IDisposable + { + using (TRow row = sheet.StartRow()) + { + foreach (string name in header) + { + row.Write(name); + } + } + foreach (Action write in rows) + { + using TRow row = sheet.StartRow(); + write(row); + } + sheet.End(); + } + } +} From 6c46480dddb0ee16edf26d474f71e621e1c32929 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 13 Oct 2026 17:43:35 -0300 Subject: [PATCH 42/42] fix: enhance Zip package writer tests to ignore OS-specific fields in assertions --- .../Writer/Zip/ZipPackageWriterTests.cs | 7 +++++-- tests/ExcelReader.Tests/Writer/Zip/ZipTestSupport.cs | 11 +++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/ExcelReader.Tests/Writer/Zip/ZipPackageWriterTests.cs b/tests/ExcelReader.Tests/Writer/Zip/ZipPackageWriterTests.cs index ea6d424f..e7e6db48 100644 --- a/tests/ExcelReader.Tests/Writer/Zip/ZipPackageWriterTests.cs +++ b/tests/ExcelReader.Tests/Writer/Zip/ZipPackageWriterTests.cs @@ -126,7 +126,10 @@ private static void AssertContentAndCrc(byte[] zipBytes) private static void AssertSameShape(ZipCentral expected, ZipCentral actual, byte[] expectedZip, byte[] actualZip) { Assert.Equal(expected.Name, actual.Name); - Assert.Equal(expected.MadeBy, actual.MadeBy); + // ZipArchive puts the operating system in the high byte; the package writer always writes 0, + // so a file does not depend on the machine that wrote it. + Assert.Equal((byte)expected.MadeBy, (byte)actual.MadeBy); + Assert.Equal(0, actual.MadeBy >> 8); Assert.Equal(expected.Version, actual.Version); Assert.Equal(expected.Flags, actual.Flags); Assert.Equal(expected.Method, actual.Method); @@ -162,7 +165,7 @@ public async Task LayoutMatchesZipArchive(CompressionLevel level, bool seekable, } if (level == CompressionLevel.NoCompression) { - Assert.Equal(ZipLayout.WithoutTimestamps(expected), ZipLayout.WithoutTimestamps(actual)); + Assert.Equal(ZipLayout.WithoutTimestampsAndHost(expected), ZipLayout.WithoutTimestampsAndHost(actual)); } AssertContentAndCrc(actual); } diff --git a/tests/ExcelReader.Tests/Writer/Zip/ZipTestSupport.cs b/tests/ExcelReader.Tests/Writer/Zip/ZipTestSupport.cs index 5221c7e2..70d03c98 100644 --- a/tests/ExcelReader.Tests/Writer/Zip/ZipTestSupport.cs +++ b/tests/ExcelReader.Tests/Writer/Zip/ZipTestSupport.cs @@ -57,14 +57,21 @@ internal static ZipLocal ReadLocal(ReadOnlySpan zip, long offset) U32(zip, offset + 14), U32(zip, offset + 18), U32(zip, offset + 22)); } - /// Zeroes the DOS time and date of every local and central header. - internal static byte[] WithoutTimestamps(byte[] zip) + /// + /// Zeroes what legitimately differs between two writers of the same content: the DOS time and date + /// of every local and central header, and the two central fields + /// fills from the operating system (the host byte of "version made by", and the external attributes, + /// which hold Unix permissions there). + /// + internal static byte[] WithoutTimestampsAndHost(byte[] zip) { byte[] copy = (byte[])zip.Clone(); foreach (ZipCentral entry in ReadCentral(zip)) { copy.AsSpan((int)entry.LocalOffset + 10, 4).Clear(); copy.AsSpan(entry.Position + 12, 4).Clear(); + copy[entry.Position + 5] = 0; + copy.AsSpan(entry.Position + 38, 4).Clear(); } return copy; }