Skip to content

Commit 32bb7e5

Browse files
committed
feat(io): expose leaveOpen on Read/ReadAsync and WriteAsync(path) overload; fail fast on ZIP32 4GB limit
- Xlsx.Read/ReadAsync 增加 leaveOpen 参数,默认释放流,可选保留\n- 新增 Xlsx.WriteAsync(path) 便利重载\n- ForwardOnlyZipWriter 在超过 ZIP32(4GB)上限时提前抛出清晰异常\n- Directory.Build.props NoWarn 加入 NU5119,使 TreatWarningsAsErrors 下打包成功\n- 补充 WriteAsync(path)/leaveOpen 保留与默认释放的单元测试
1 parent 48903ea commit 32bb7e5

6 files changed

Lines changed: 140 additions & 13 deletions

File tree

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
<Version>2.9.0</Version>
3434
<IncludeSymbols>true</IncludeSymbols>
3535
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
36-
<NoWarn>1701;1702;CS1591;CS1573;1591;NU1507</NoWarn>
36+
<NoWarn>1701;1702;CS1591;CS1573;1591;NU1507;NU5119</NoWarn>
3737
<CheckEolTargetFramework>false</CheckEolTargetFramework>
3838
<NuGetAuditMode>direct</NuGetAuditMode>
3939
</PropertyGroup>

src/Magicodes.IE.IO/Engine/XlsxReader.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ namespace Magicodes.IE.IO
2020

2121
/// <summary>
2222
/// Advanced streaming reader for xlsx workbooks.
23-
/// For ordinary reads, use <see cref="Xlsx.Read{T}(Stream, XlsxReadOptions{T}?, Action{XlsxReadErrorInfo}?)"/>.
23+
/// For ordinary reads, use <see cref="Xlsx.Read{T}(Stream, XlsxReadOptions{T}?, Action{XlsxReadErrorInfo}?, bool)"/>.
2424
/// </summary>
2525
[EditorBrowsable(EditorBrowsableState.Never)]
2626
public sealed class XlsxReader : IDisposable

src/Magicodes.IE.IO/Internal/ForwardOnlyZipWriter.cs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ internal static ValueTask DisposeEntryStreamAsync(Stream entryStream)
119119
internal void WriteRaw(byte[] buffer, int offset, int count)
120120
{
121121
if (count == 0) return;
122+
if (_position + count > uint.MaxValue) ThrowZip32LimitExceeded();
122123
#if NETSTANDARD2_0
123124
_output.Write(buffer, offset, count);
124125
#else
@@ -130,6 +131,7 @@ internal void WriteRaw(byte[] buffer, int offset, int count)
130131
internal async Task WriteRawAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
131132
{
132133
if (count == 0) return;
134+
if (_position + count > uint.MaxValue) ThrowZip32LimitExceeded();
133135
#if NETSTANDARD2_0
134136
await _output.WriteAsync(buffer, offset, count, cancellationToken).ConfigureAwait(false);
135137
_position += count;
@@ -143,6 +145,7 @@ internal async Task WriteRawAsync(byte[] buffer, int offset, int count, Cancella
143145
internal ValueTask WriteRawAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken)
144146
{
145147
if (buffer.Length == 0) return default;
148+
if (_position + buffer.Length > uint.MaxValue) ThrowZip32LimitExceeded();
146149
var vt = _output.WriteAsync(buffer, cancellationToken);
147150
if (vt.IsCompletedSuccessfully)
148151
{
@@ -461,10 +464,18 @@ private static Utf8NameBuffer RentUtf8Name(string name)
461464

462465
internal static uint CheckedToUInt32(long value)
463466
{
464-
if ((ulong)value > uint.MaxValue) throw new NotSupportedException("ZIP64 is not supported by this forward-only zip writer.");
467+
if ((ulong)value > uint.MaxValue) ThrowZip32LimitExceeded();
465468
return (uint)value;
466469
}
467470

471+
private static void ThrowZip32LimitExceeded()
472+
{
473+
throw new NotSupportedException(
474+
"The output exceeds the 4 GB (ZIP32) size limit supported by this forward-only zip writer. " +
475+
"Magicodes.IE.IO does not emit ZIP64 archives. To write workbooks larger than 4 GB, " +
476+
"split the data across multiple files or sheets, or write to a consumer that supports ZIP64.");
477+
}
478+
468479
private void EnsureNotDisposed()
469480
{
470481
if (_disposed) throw new ObjectDisposedException(nameof(ForwardOnlyZipWriter));
@@ -1004,7 +1015,8 @@ public static DosDateTime From(DateTime value)
10041015

10051016
private static readonly uint[] Crc32Table = BuildCrc32Table();
10061017

1007-
#if DEBUG
1018+
// Self-check the CRC-32 implementation at type load. This guards the SIMD/intrinsic path
1019+
// (active on ARM64 in Release builds) so a wrong CRC never silently corrupts every xlsx.
10081020
static ForwardOnlyZipWriter()
10091021
{
10101022
var data = System.Text.Encoding.ASCII.GetBytes("123456789");
@@ -1020,7 +1032,6 @@ static ForwardOnlyZipWriter()
10201032
if (sliced.Result != expected)
10211033
throw new InvalidOperationException($"SlicingBy8Crc32 self-test failed: {sliced.Result:X8}");
10221034
}
1023-
#endif
10241035

10251036
internal interface ICrc32
10261037
{

src/Magicodes.IE.IO/Magicodes.IE.IO.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<TargetFrameworks>netstandard2.0;net6.0;net8.0;net10.0</TargetFrameworks>
55
<LangVersion>latest</LangVersion>
66
<Nullable>enable</Nullable>
7-
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
7+
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
88
<PackageId>Magicodes.IE.IO</PackageId>
99
<Description>
1010
High-performance, low-allocation Excel (.xlsx) I/O library for .NET, with streaming write APIs and a simple high-level API for import and export.

src/Magicodes.IE.IO/Xlsx.cs

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ namespace Magicodes.IE.IO
1313
/// </summary>
1414
/// <remarks>
1515
/// <para><b>Export:</b> use the <see cref="Write{T}(System.String, IEnumerable{T}, System.Action{ExportProfile{T}}, XlsxWriteOptions)"/> overloads to write rows to a file, stream, <see cref="IBufferWriter{T}"/>, or <see cref="byte"/> array.</para>
16-
/// <para><b>Import:</b> use the <see cref="Read{T}(Stream, XlsxReadOptions{T}, Action{XlsxReadErrorInfo})"/> overloads to stream deserialized rows from a workbook.</para>
16+
/// <para><b>Import:</b> use the <see cref="Read{T}(Stream, XlsxReadOptions{T}?, Action{XlsxReadErrorInfo}?, bool)"/> overloads to stream deserialized rows from a workbook.</para>
1717
/// <para><b>Multi-sheet:</b> <see cref="WriteWorkbook(Stream, IReadOnlyList{SheetBase})"/>. <b>Template export:</b> <see cref="ExportByTemplateAsync{T}(System.String, System.String, T, CancellationToken)"/>.</para>
1818
/// <para>When no <see cref="ExportProfile{T}"/> is supplied, headers and formats are inferred from property names and from <c>[Display]</c>, <c>[Description]</c>, and <c>[DisplayFormat]</c> attributes. The underlying writer/reader is a dependency-free, streaming, low-allocation OOXML implementation and does not depend on EPPlus.</para>
1919
/// <para><b>Lifetime:</b> overloads that take a <c>path</c> own and dispose the underlying <see cref="FileStream"/>; overloads that take a <see cref="Stream"/> or <see cref="IBufferWriter{T}"/> do not, and the caller is responsible for disposal. Note: the Read/ReadAsync stream overloads DO own and dispose the supplied stream when enumeration completes.</para>
@@ -161,6 +161,65 @@ public static async Task WriteAsync<T>(Stream output, IEnumerable<T> data, Expor
161161
await XlsxWritePipeline.RunAsync(writer, data, profile, cancellationToken).ConfigureAwait(false);
162162
}
163163

164+
/// <summary>
165+
/// Asynchronously writes the specified rows to the .xlsx file at <paramref name="path"/>.
166+
/// </summary>
167+
/// <remarks>Equivalent to opening a <see cref="FileStream"/> and calling the stream overload; the file stream is owned and disposed by this method.</remarks>
168+
public static async Task WriteAsync<T>(string path, IEnumerable<T> data, Action<ExportProfile<T>>? configure = null, XlsxWriteOptions? options = null, CancellationToken cancellationToken = default)
169+
{
170+
ValidatePath(path);
171+
if (data is null) throw new ArgumentNullException(nameof(data));
172+
var compression = options?.Compression ?? System.IO.Compression.CompressionLevel.Fastest;
173+
var strictCellReferences = options?.StrictCellReferences ?? true;
174+
using var fs = File.Create(path);
175+
await using var writer = new XlsxWriter(fs, sheetName: null, compression, defaultRowHeight: 0, strictCellReferences);
176+
await XlsxWritePipeline.RunAsync(writer, data, configure, cancellationToken).ConfigureAwait(false);
177+
}
178+
179+
/// <summary>
180+
/// Asynchronously writes the specified rows to the .xlsx file at <paramref name="path"/> using a pre-built profile.
181+
/// </summary>
182+
public static async Task WriteAsync<T>(string path, IEnumerable<T> data, ExportProfile<T> profile, XlsxWriteOptions? options = null, CancellationToken cancellationToken = default)
183+
{
184+
ValidatePath(path);
185+
if (data is null) throw new ArgumentNullException(nameof(data));
186+
if (profile is null) throw new ArgumentNullException(nameof(profile));
187+
var compression = options?.Compression ?? System.IO.Compression.CompressionLevel.Fastest;
188+
var strictCellReferences = options?.StrictCellReferences ?? true;
189+
using var fs = File.Create(path);
190+
await using var writer = new XlsxWriter(fs, sheetName: null, compression, defaultRowHeight: 0, strictCellReferences);
191+
await XlsxWritePipeline.RunAsync(writer, data, profile, cancellationToken).ConfigureAwait(false);
192+
}
193+
194+
/// <summary>
195+
/// Asynchronously streams rows from an <see cref="IAsyncEnumerable{T}"/> to the .xlsx file at <paramref name="path"/>.
196+
/// </summary>
197+
public static async Task WriteAsync<T>(string path, IAsyncEnumerable<T> data, Action<ExportProfile<T>>? configure = null, XlsxWriteOptions? options = null, CancellationToken cancellationToken = default)
198+
{
199+
ValidatePath(path);
200+
if (data is null) throw new ArgumentNullException(nameof(data));
201+
var compression = options?.Compression ?? System.IO.Compression.CompressionLevel.Fastest;
202+
var strictCellReferences = options?.StrictCellReferences ?? true;
203+
using var fs = File.Create(path);
204+
await using var writer = new XlsxWriter(fs, sheetName: null, compression, defaultRowHeight: 0, strictCellReferences);
205+
await XlsxWritePipeline.RunAsync(writer, data, configure, cancellationToken).ConfigureAwait(false);
206+
}
207+
208+
/// <summary>
209+
/// Asynchronously streams rows from an <see cref="IAsyncEnumerable{T}"/> to the .xlsx file at <paramref name="path"/> using a pre-built profile.
210+
/// </summary>
211+
public static async Task WriteAsync<T>(string path, IAsyncEnumerable<T> data, ExportProfile<T> profile, XlsxWriteOptions? options = null, CancellationToken cancellationToken = default)
212+
{
213+
ValidatePath(path);
214+
if (data is null) throw new ArgumentNullException(nameof(data));
215+
if (profile is null) throw new ArgumentNullException(nameof(profile));
216+
var compression = options?.Compression ?? System.IO.Compression.CompressionLevel.Fastest;
217+
var strictCellReferences = options?.StrictCellReferences ?? true;
218+
using var fs = File.Create(path);
219+
await using var writer = new XlsxWriter(fs, sheetName: null, compression, defaultRowHeight: 0, strictCellReferences);
220+
await XlsxWritePipeline.RunAsync(writer, data, profile, cancellationToken).ConfigureAwait(false);
221+
}
222+
164223
/// <summary>
165224
/// Exports the specified rows to a <see cref="byte"/> array.
166225
/// </summary>
@@ -205,9 +264,9 @@ private static int EstimateToBytesCapacity<T>(IEnumerable<T> data)
205264
/// <param name="onParseError">Optional callback invoked when a cell cannot be parsed. When omitted, a <see cref="XlsxException"/> is thrown on the first parse error.</param>
206265
/// <returns>A lazy <see cref="IEnumerable{T}"/>. Each row is parsed on demand as you iterate; do not enumerate the result more than once.</returns>
207266
/// <remarks>When a source-generated reader is available for <typeparamref name="T"/>, it is used for reflection-free reading; otherwise reflection is used.</remarks>
208-
public static IEnumerable<T> Read<T>(Stream stream, XlsxReadOptions<T>? profile = null, Action<XlsxReadErrorInfo>? onParseError = null) where T : new()
267+
public static IEnumerable<T> Read<T>(Stream stream, XlsxReadOptions<T>? profile = null, Action<XlsxReadErrorInfo>? onParseError = null, bool leaveOpen = false) where T : new()
209268
{
210-
using var reader = new XlsxReader(stream, leaveOpen: false);
269+
using var reader = new XlsxReader(stream, leaveOpen);
211270
var headers = reader.ReadHeader();
212271
var converters = profile?.GetConverters();
213272
int rowIndex = 0;
@@ -268,12 +327,12 @@ private static int EstimateToBytesCapacity<T>(IEnumerable<T> data)
268327
/// <summary>
269328
/// Reads the first worksheet of a .xlsx workbook asynchronously and returns the deserialized rows as <typeparamref name="T"/>.
270329
/// </summary>
271-
/// <remarks>Supports cancellation via <paramref name="cancellationToken"/>. Otherwise the behavior matches <see cref="Read{T}(Stream, XlsxReadOptions{T}, Action{XlsxReadErrorInfo})"/>.</remarks>
330+
/// <remarks>Supports cancellation via <paramref name="cancellationToken"/>. Otherwise the behavior matches <see cref="Read{T}(Stream, XlsxReadOptions{T}?, Action{XlsxReadErrorInfo}?, bool)"/>.</remarks>
272331
/// <returns>A lazy <see cref="IAsyncEnumerable{T}"/>. Do not enumerate the result more than once.</returns>
273-
public static async IAsyncEnumerable<T> ReadAsync<T>(Stream stream, XlsxReadOptions<T>? profile = null, Action<XlsxReadErrorInfo>? onParseError = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) where T : new()
332+
public static async IAsyncEnumerable<T> ReadAsync<T>(Stream stream, XlsxReadOptions<T>? profile = null, Action<XlsxReadErrorInfo>? onParseError = null, [EnumeratorCancellation] CancellationToken cancellationToken = default, bool leaveOpen = false) where T : new()
274333
{
275334
cancellationToken.ThrowIfCancellationRequested();
276-
using var reader = new XlsxReader(stream, leaveOpen: false);
335+
using var reader = new XlsxReader(stream, leaveOpen);
277336
var headers = await reader.ReadHeaderAsync(cancellationToken).ConfigureAwait(false);
278337
var converters = profile?.GetConverters();
279338
int rowIndex = 0;
@@ -330,7 +389,7 @@ private static int EstimateToBytesCapacity<T>(IEnumerable<T> data)
330389
{
331390
ValidatePath(path);
332391
using var fs = File.OpenRead(path);
333-
await foreach (var item in ReadAsync<T>(fs, profile, onParseError, cancellationToken).ConfigureAwait(false))
392+
await foreach (var item in ReadAsync<T>(fs, profile, onParseError, cancellationToken: cancellationToken).ConfigureAwait(false))
334393
yield return item;
335394
}
336395

tests/Magicodes.IE.IO.Tests/XlsxIO_Reader_Tests.cs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -792,5 +792,62 @@ private static async IAsyncEnumerable<T> DataToAsync<T>(IEnumerable<T> data, [Sy
792792
await Task.Yield();
793793
}
794794
}
795+
796+
[Fact]
797+
public async Task WriteAsync_PathOverload_WritesReadableFile()
798+
{
799+
var path = Path.Combine(Path.GetTempPath(), $"io_writeasync_{Guid.NewGuid():N}.xlsx");
800+
try
801+
{
802+
var data = new[]
803+
{
804+
new OrderDto { OrderNo = "WA1", Amount = 7m, CreatedAt = new DateTime(2024, 7, 7) },
805+
new OrderDto { OrderNo = "WA2", Amount = 8m, CreatedAt = new DateTime(2024, 7, 8) },
806+
};
807+
await Xlsx.WriteAsync(path, data);
808+
809+
var list = Xlsx.Read<OrderDto>(path).ToList();
810+
list.Count.ShouldBe(2);
811+
list[0].OrderNo.ShouldBe("WA1");
812+
list[0].Amount.ShouldBe(7m);
813+
list[1].OrderNo.ShouldBe("WA2");
814+
}
815+
finally
816+
{
817+
if (File.Exists(path)) File.Delete(path);
818+
}
819+
}
820+
821+
[Fact]
822+
public void Read_Stream_LeaveOpen_KeepsStreamUsable()
823+
{
824+
var bytes = Xlsx.ToBytes(new[] { new OrderDto { OrderNo = "LO", Amount = 1m } });
825+
using var ms = new MemoryStream(bytes);
826+
var list = Xlsx.Read<OrderDto>(ms, leaveOpen: true).ToList();
827+
list.Count.ShouldBe(1);
828+
// Stream must still be readable because we asked the reader to leave it open.
829+
ms.Position = 0;
830+
ms.Length.ShouldBe(bytes.Length);
831+
ms.Dispose();
832+
}
833+
834+
[Fact]
835+
public void Read_Stream_DefaultDisposesStream()
836+
{
837+
var path = Path.Combine(Path.GetTempPath(), $"io_read_dispose_{Guid.NewGuid():N}.xlsx");
838+
try
839+
{
840+
Xlsx.Write(path, new[] { new OrderDto { OrderNo = "DX" } });
841+
var fs = File.OpenRead(path);
842+
var list = Xlsx.Read<OrderDto>(fs).ToList();
843+
list.Count.ShouldBe(1);
844+
// Default behavior disposes the stream; touching it afterwards must fail.
845+
Should.Throw<ObjectDisposedException>(() => fs.ReadByte());
846+
}
847+
finally
848+
{
849+
if (File.Exists(path)) File.Delete(path);
850+
}
851+
}
795852
}
796853
}

0 commit comments

Comments
 (0)