mirror of https://github.com/SixLabors/ImageSharp
committed by
GitHub
23 changed files with 908 additions and 149 deletions
@ -1 +1 @@ |
|||
Subproject commit a1d3ac20494631e3cc13132897573796b0e4ee6d |
|||
Subproject commit 7ac5703452348d9295db31fc0912c2bd9e419dc9 |
|||
@ -1,72 +0,0 @@ |
|||
// Copyright (c) Six Labors.
|
|||
// Licensed under the Six Labors Split License.
|
|||
|
|||
using System.Runtime.CompilerServices; |
|||
using System.Runtime.InteropServices; |
|||
|
|||
namespace SixLabors.ImageSharp.Tuples; |
|||
|
|||
/// <summary>
|
|||
/// Contains 8 element value tuples of various types.
|
|||
/// </summary>
|
|||
[StructLayout(LayoutKind.Sequential)] |
|||
internal struct Octet<T> |
|||
where T : unmanaged |
|||
{ |
|||
public T V0; |
|||
public T V1; |
|||
public T V2; |
|||
public T V3; |
|||
public T V4; |
|||
public T V5; |
|||
public T V6; |
|||
public T V7; |
|||
|
|||
/// <inheritdoc/>
|
|||
public override readonly string ToString() |
|||
{ |
|||
return $"Octet<{typeof(T)}>({this.V0},{this.V1},{this.V2},{this.V3},{this.V4},{this.V5},{this.V6},{this.V7})"; |
|||
} |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Extension methods for the <see cref="Octet{T}"/> type.
|
|||
/// </summary>
|
|||
internal static class OctetExtensions |
|||
{ |
|||
/// <summary>
|
|||
/// Loads the fields in a target <see cref="Octet{T}"/> of <see cref="uint"/> from one of <see cref="byte"/> type.
|
|||
/// </summary>
|
|||
/// <param name="destination">The target <see cref="Octet{T}"/> of <see cref="uint"/> instance.</param>
|
|||
/// <param name="source">The source <see cref="Octet{T}"/> of <see cref="byte"/> instance.</param>
|
|||
[MethodImpl(InliningOptions.ShortMethod)] |
|||
public static void LoadFrom(ref this Octet<uint> destination, ref Octet<byte> source) |
|||
{ |
|||
destination.V0 = source.V0; |
|||
destination.V1 = source.V1; |
|||
destination.V2 = source.V2; |
|||
destination.V3 = source.V3; |
|||
destination.V4 = source.V4; |
|||
destination.V5 = source.V5; |
|||
destination.V6 = source.V6; |
|||
destination.V7 = source.V7; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Loads the fields in a target <see cref="Octet{T}"/> of <see cref="byte"/> from one of <see cref="uint"/> type.
|
|||
/// </summary>
|
|||
/// <param name="destination">The target <see cref="Octet{T}"/> of <see cref="byte"/> instance.</param>
|
|||
/// <param name="source">The source <see cref="Octet{T}"/> of <see cref="uint"/> instance.</param>
|
|||
[MethodImpl(InliningOptions.ShortMethod)] |
|||
public static void LoadFrom(ref this Octet<byte> destination, ref Octet<uint> source) |
|||
{ |
|||
destination.V0 = (byte)source.V0; |
|||
destination.V1 = (byte)source.V1; |
|||
destination.V2 = (byte)source.V2; |
|||
destination.V3 = (byte)source.V3; |
|||
destination.V4 = (byte)source.V4; |
|||
destination.V5 = (byte)source.V5; |
|||
destination.V6 = (byte)source.V6; |
|||
destination.V7 = (byte)source.V7; |
|||
} |
|||
} |
|||
@ -0,0 +1,122 @@ |
|||
// Copyright (c) Six Labors.
|
|||
// Licensed under the Six Labors Split License.
|
|||
|
|||
using System.Numerics; |
|||
using SixLabors.ImageSharp.ColorProfiles; |
|||
using SixLabors.ImageSharp.ColorProfiles.WorkingSpaces; |
|||
|
|||
namespace SixLabors.ImageSharp.Tests.ColorProfiles; |
|||
|
|||
/// <summary>
|
|||
/// Tests the <see cref="RgbWorkingSpace"/> class.
|
|||
/// </summary>
|
|||
[Trait("Color", "Conversion")] |
|||
public class RgbWorkingSpaceTests |
|||
{ |
|||
private static readonly ApproximateFloatComparer TolerantComparer = new(1e-5F); |
|||
|
|||
public static readonly TheoryData<RgbWorkingSpace> WorkingSpaces = new() |
|||
{ |
|||
KnownRgbWorkingSpaces.SRgb, |
|||
KnownRgbWorkingSpaces.SRgbSimplified, |
|||
KnownRgbWorkingSpaces.Rec709, |
|||
KnownRgbWorkingSpaces.Rec2020, |
|||
KnownRgbWorkingSpaces.ECIRgbv2, |
|||
KnownRgbWorkingSpaces.AdobeRgb1998, |
|||
KnownRgbWorkingSpaces.ApplesRgb, |
|||
KnownRgbWorkingSpaces.BestRgb, |
|||
KnownRgbWorkingSpaces.BetaRgb, |
|||
KnownRgbWorkingSpaces.BruceRgb, |
|||
KnownRgbWorkingSpaces.CIERgb, |
|||
KnownRgbWorkingSpaces.ColorMatchRgb, |
|||
KnownRgbWorkingSpaces.DonRgb4, |
|||
KnownRgbWorkingSpaces.EktaSpacePS5, |
|||
KnownRgbWorkingSpaces.NTSCRgb, |
|||
KnownRgbWorkingSpaces.PALSECAMRgb, |
|||
KnownRgbWorkingSpaces.ProPhotoRgb, |
|||
KnownRgbWorkingSpaces.SMPTECRgb, |
|||
KnownRgbWorkingSpaces.WideGamutRgb |
|||
}; |
|||
|
|||
[Fact] |
|||
public void RgbWorkingSpaceEqualityRequiresSameConcreteType() |
|||
{ |
|||
RgbWorkingSpace sRgb = KnownRgbWorkingSpaces.SRgb; |
|||
RgbWorkingSpace sRgbSimplified = KnownRgbWorkingSpaces.SRgbSimplified; |
|||
RgbWorkingSpace rec709 = KnownRgbWorkingSpaces.Rec709; |
|||
|
|||
Assert.False(sRgb.Equals(sRgbSimplified)); |
|||
Assert.False(sRgbSimplified.Equals(sRgb)); |
|||
Assert.False(sRgb.Equals(rec709)); |
|||
Assert.False(rec709.Equals(sRgb)); |
|||
|
|||
Assert.NotEqual(sRgb.GetHashCode(), sRgbSimplified.GetHashCode()); |
|||
Assert.NotEqual(sRgb.GetHashCode(), rec709.GetHashCode()); |
|||
} |
|||
|
|||
[Fact] |
|||
public void RgbWorkingSpaceEqualityMatchesSameConcreteTypeValues() |
|||
{ |
|||
RgbWorkingSpace x = new SRgbWorkingSpace( |
|||
KnownRgbWorkingSpaces.SRgb.WhitePoint, |
|||
KnownRgbWorkingSpaces.SRgb.ChromaticityCoordinates); |
|||
|
|||
RgbWorkingSpace y = new SRgbWorkingSpace( |
|||
KnownRgbWorkingSpaces.SRgb.WhitePoint, |
|||
KnownRgbWorkingSpaces.SRgb.ChromaticityCoordinates); |
|||
|
|||
Assert.Equal(x, y); |
|||
Assert.Equal(x.GetHashCode(), y.GetHashCode()); |
|||
} |
|||
|
|||
[Fact] |
|||
public void GammaWorkingSpaceEqualityIncludesGamma() |
|||
{ |
|||
GammaWorkingSpace x = new( |
|||
2.2F, |
|||
KnownRgbWorkingSpaces.SRgbSimplified.WhitePoint, |
|||
KnownRgbWorkingSpaces.SRgbSimplified.ChromaticityCoordinates); |
|||
|
|||
GammaWorkingSpace y = new( |
|||
1.8F, |
|||
KnownRgbWorkingSpaces.SRgbSimplified.WhitePoint, |
|||
KnownRgbWorkingSpaces.SRgbSimplified.ChromaticityCoordinates); |
|||
|
|||
Assert.NotEqual(x, y); |
|||
Assert.NotEqual(x.GetHashCode(), y.GetHashCode()); |
|||
} |
|||
|
|||
[Theory] |
|||
[MemberData(nameof(WorkingSpaces))] |
|||
public void CompressAndExpand_RoundTripsWithTolerance(RgbWorkingSpace workingSpace) |
|||
{ |
|||
Vector4[] linear = |
|||
[ |
|||
new(0F, .001F, .18F, 1F), // Endpoint, below the sRGB breakpoint, common middle gray, and opaque alpha.
|
|||
new(.0031308F, .25F, .5F, .75F), // sRGB linear/gamma breakpoint with interior values and partial alpha.
|
|||
new(.75F, .5F, .25F, .5F), // Reversed interior values ensure channel order does not hide errors.
|
|||
new(1F, .8F, .2F, .25F) // Upper endpoint, high/low interior values, and low alpha.
|
|||
]; |
|||
|
|||
Vector4[] expectedCompressed = new Vector4[linear.Length]; |
|||
|
|||
for (int i = 0; i < linear.Length; i++) |
|||
{ |
|||
Vector4 compressed = workingSpace.Compress(linear[i]); |
|||
Vector4 expanded = workingSpace.Expand(compressed); |
|||
|
|||
expectedCompressed[i] = compressed; |
|||
|
|||
Assert.Equal(linear[i], expanded, TolerantComparer); |
|||
} |
|||
|
|||
Vector4[] actualCompressed = linear.ToArray(); |
|||
workingSpace.Compress(actualCompressed); |
|||
|
|||
Assert.Equal(expectedCompressed, actualCompressed, TolerantComparer); |
|||
|
|||
workingSpace.Expand(actualCompressed); |
|||
|
|||
Assert.Equal(linear, actualCompressed, TolerantComparer); |
|||
} |
|||
} |
|||
@ -0,0 +1,274 @@ |
|||
// Copyright (c) Six Labors.
|
|||
// Licensed under the Six Labors Split License.
|
|||
|
|||
using System.Buffers.Binary; |
|||
using SixLabors.ImageSharp.Formats; |
|||
using SixLabors.ImageSharp.Formats.Exr; |
|||
using SixLabors.ImageSharp.PixelFormats; |
|||
|
|||
namespace SixLabors.ImageSharp.Tests.Formats.Exr; |
|||
|
|||
/// <summary>
|
|||
/// Security regression tests for the EXR decoder (Findings EXR-1, EXR-2, EXR-3).
|
|||
/// The EXR decoder was merged to main but not yet included in a tagged NuGet release.
|
|||
/// Each test demonstrates a crafted-input crash present in the unfixed code.
|
|||
/// </summary>
|
|||
[Trait("Format", "Exr")] |
|||
[ValidateDisposedMemoryAllocations] |
|||
public class ExrDecoderSecurityTests |
|||
{ |
|||
/// <summary>
|
|||
/// EXR-1 — EXR DataWindow Integer Overflow Produces Negative Image Dimensions (DoS)
|
|||
///
|
|||
/// Width and Height are computed from attacker-controlled DataWindow attributes
|
|||
/// using unchecked int subtraction:
|
|||
/// this.Width = XMax - XMin + 1 // overflows to -2147483647
|
|||
///
|
|||
/// The negative Width is then passed to the Image<TPixel> constructor, which calls
|
|||
/// Guard.MustBeGreaterThan(width, 0) → ArgumentOutOfRangeException.
|
|||
///
|
|||
/// After a fix this should throw InvalidImageContentException instead.
|
|||
///
|
|||
/// Affected file:
|
|||
/// src/ImageSharp/Formats/Exr/ExrDecoderCore.cs lines 600–601
|
|||
/// </summary>
|
|||
[Fact] |
|||
public void Decode_DataWindowOverflow_NegativeWidth_Throws() |
|||
{ |
|||
// XMin = -1073741825, XMax = 1073741823
|
|||
// Width = 1073741823 - (-1073741825) + 1 = 2^31 + 1 → wraps to -2147483647
|
|||
byte[] data = BuildMinimalExr(xMin: -1073741825, yMin: 0, xMax: 1073741823, yMax: 0); |
|||
|
|||
using var stream = new MemoryStream(data); |
|||
Assert.Throws<InvalidImageContentException>( |
|||
() => ExrDecoder.Instance.Decode<Rgba32>(DecoderOptions.Default, stream)); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// EXR-2 — EXR Row Offset Table Unvalidated Seek (DoS)
|
|||
///
|
|||
/// Row offsets are read from the file and used unconditionally to seek the stream:
|
|||
/// ulong rowOffset = this.ReadUnsignedLong(stream);
|
|||
/// stream.Position = (long)rowOffset; // no bounds check
|
|||
///
|
|||
/// A crafted offset of 0xFFFFFFFFFFFFFFFF casts to −1 as long, causing
|
|||
/// an ArgumentOutOfRangeException when setting stream.Position.
|
|||
///
|
|||
/// After a fix this should throw InvalidImageContentException instead.
|
|||
///
|
|||
/// Affected file:
|
|||
/// src/ImageSharp/Formats/Exr/ExrDecoderCore.cs lines 170–175 (scanline)
|
|||
/// lines 243–248 (tile)
|
|||
/// </summary>
|
|||
[Fact] |
|||
public void Decode_CraftedRowOffsets_OutOfBounds_Throws() |
|||
{ |
|||
// Valid 2×2 image (XMin=0,YMin=0,XMax=1,YMax=1 → Width=2,Height=2).
|
|||
// Row offset table immediately follows the header null byte:
|
|||
// 2 rows × 8 bytes each, all set to 0xFFFFFFFFFFFFFFFF.
|
|||
byte[] invalidOffsets = new byte[16]; |
|||
BinaryPrimitives.WriteUInt64LittleEndian(invalidOffsets, 0xFFFFFFFFFFFFFFFF); |
|||
BinaryPrimitives.WriteUInt64LittleEndian(invalidOffsets.AsSpan(8), 0xFFFFFFFFFFFFFFFF); |
|||
|
|||
byte[] data = BuildMinimalExr( |
|||
xMin: 0, yMin: 0, xMax: 1, yMax: 1, |
|||
rowOffsetTableAppend: invalidOffsets); |
|||
|
|||
using var stream = new MemoryStream(data); |
|||
Assert.Throws<InvalidImageContentException>( |
|||
() => ExrDecoder.Instance.Decode<Rgba32>(DecoderOptions.Default, stream)); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Decode_CraftedRowOffsets_IntoHeader_Throws() |
|||
{ |
|||
// Offset 0 points back into the EXR file header and must be rejected
|
|||
// before the decoder seeks to attacker-controlled non-pixel data.
|
|||
byte[] headerOffsets = new byte[16]; |
|||
|
|||
byte[] data = BuildMinimalExr( |
|||
xMin: 0, yMin: 0, xMax: 1, yMax: 1, |
|||
rowOffsetTableAppend: headerOffsets); |
|||
|
|||
using var stream = new MemoryStream(data); |
|||
Assert.Throws<InvalidImageContentException>( |
|||
() => ExrDecoder.Instance.Decode<Rgba32>(DecoderOptions.Default, stream)); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Decode_CraftedRowOffsets_IntoOffsetTable_Throws() |
|||
{ |
|||
byte[] data = BuildMinimalExr( |
|||
xMin: 0, yMin: 0, xMax: 1, yMax: 1, |
|||
rowOffsetTableAppend: new byte[16]); |
|||
|
|||
// Point the first row offset at the second row offset entry.
|
|||
BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(data.Length - 16), (ulong)(data.Length - 8)); |
|||
|
|||
using var stream = new MemoryStream(data); |
|||
Assert.Throws<InvalidImageContentException>( |
|||
() => ExrDecoder.Instance.Decode<Rgba32>(DecoderOptions.Default, stream)); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// EXR-3 — Oversized EXR RGBA row sizing is rejected as invalid image content.
|
|||
///
|
|||
/// With 4 RGBA HALF channels and Width = 2^29, the decoded row staging and
|
|||
/// bytes-per-row arithmetic both exceed the supported buffer sizing limits.
|
|||
/// The decoder must reject this as InvalidImageContentException before any allocation.
|
|||
///
|
|||
/// Affected file:
|
|||
/// src/ImageSharp/Formats/Exr/ExrDecoderCore.cs lines 142–150, 215–223
|
|||
/// src/ImageSharp/Formats/Exr/ExrUtils.cs CalculateBytesPerRow
|
|||
/// </summary>
|
|||
[Fact] |
|||
public void Decode_RgbaRowSizingExceedsBufferLimits_Throws() |
|||
{ |
|||
// 4 RGBA HALF channels at this width cannot be represented by the decoder's
|
|||
// int-sized row staging or block buffers.
|
|||
byte[] data = BuildMinimalRgbaExr(xMin: 0, yMin: 0, xMax: 536870911, yMax: 0); |
|||
|
|||
using var stream = new MemoryStream(data); |
|||
Assert.Throws<InvalidImageContentException>( |
|||
() => ExrDecoder.Instance.Decode<Rgba32>(DecoderOptions.Default, stream)); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Decode_DataWindowWidthExceedsRowBufferLimit_Throws() |
|||
{ |
|||
// A single HALF channel keeps bytesPerBlock below int.MaxValue, but the decoder
|
|||
// still stages four color planes and must reject widths that overflow width × 4.
|
|||
byte[] data = BuildMinimalExr(xMin: 0, yMin: 0, xMax: int.MaxValue / 4, yMax: 0); |
|||
|
|||
using var stream = new MemoryStream(data); |
|||
Assert.Throws<InvalidImageContentException>( |
|||
() => ExrDecoder.Instance.Decode<Rgba32>(DecoderOptions.Default, stream)); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Identify_RowOffsetTableExceedsStream_Throws() |
|||
{ |
|||
// Identify parses the header only, so this verifies the offset table bound is
|
|||
// validated before scanline decoding reads from the table.
|
|||
byte[] data = BuildMinimalExr(xMin: 0, yMin: 0, xMax: 1, yMax: 1); |
|||
|
|||
using var stream = new MemoryStream(data); |
|||
Assert.Throws<InvalidImageContentException>( |
|||
() => ExrDecoder.Instance.Identify(DecoderOptions.Default, stream)); |
|||
} |
|||
|
|||
// -------------------------------------------------------------------------
|
|||
// Helpers: construct minimal valid-enough EXR scanline files.
|
|||
//
|
|||
// Required attributes per ParseHeaderAttributes validation:
|
|||
// channels, compression, dataWindow, displayWindow,
|
|||
// lineOrder, pixelAspectRatio, screenWindowCenter, screenWindowWidth
|
|||
// -------------------------------------------------------------------------
|
|||
|
|||
private static byte[] BuildMinimalExr( |
|||
int xMin, int yMin, int xMax, int yMax, |
|||
byte[] rowOffsetTableAppend = null) |
|||
{ |
|||
// channels: single "R" HALF channel with xSampling=1, ySampling=1
|
|||
// Layout per ReadChannelInfo: name\0 (2) + pixelType (4) + pLinear+reserved (4)
|
|||
// + xSampling (4) + ySampling (4) = 18 bytes/channel
|
|||
// + list-null (1) = 19 total
|
|||
byte[] channelData = |
|||
[ |
|||
0x52, 0x00, // "R\0"
|
|||
0x01, 0x00, 0x00, 0x00, // pixelType = Half (1)
|
|||
0x00, 0x00, 0x00, 0x00, // pLinear + 3 reserved bytes
|
|||
0x01, 0x00, 0x00, 0x00, // xSampling = 1
|
|||
0x01, 0x00, 0x00, 0x00, // ySampling = 1
|
|||
0x00, // channel-list null terminator
|
|||
]; |
|||
|
|||
return BuildExrWithChannels(xMin, yMin, xMax, yMax, channelData, rowOffsetTableAppend); |
|||
} |
|||
|
|||
private static byte[] BuildMinimalRgbaExr(int xMin, int yMin, int xMax, int yMax) |
|||
{ |
|||
// 4 HALF channels in alphabetical order (A, B, G, R) per EXR spec.
|
|||
// 18 bytes per channel × 4 channels + 1 list-null = 73 bytes total.
|
|||
byte[] channelData = |
|||
[ |
|||
0x41, 0x00, // "A\0"
|
|||
0x01, 0x00, 0x00, 0x00, // pixelType = Half (1)
|
|||
0x00, 0x00, 0x00, 0x00, // pLinear + 3 reserved bytes
|
|||
0x01, 0x00, 0x00, 0x00, // xSampling = 1
|
|||
0x01, 0x00, 0x00, 0x00, // ySampling = 1
|
|||
0x42, 0x00, // "B\0"
|
|||
0x01, 0x00, 0x00, 0x00, |
|||
0x00, 0x00, 0x00, 0x00, |
|||
0x01, 0x00, 0x00, 0x00, |
|||
0x01, 0x00, 0x00, 0x00, |
|||
0x47, 0x00, // "G\0"
|
|||
0x01, 0x00, 0x00, 0x00, |
|||
0x00, 0x00, 0x00, 0x00, |
|||
0x01, 0x00, 0x00, 0x00, |
|||
0x01, 0x00, 0x00, 0x00, |
|||
0x52, 0x00, // "R\0"
|
|||
0x01, 0x00, 0x00, 0x00, |
|||
0x00, 0x00, 0x00, 0x00, |
|||
0x01, 0x00, 0x00, 0x00, |
|||
0x01, 0x00, 0x00, 0x00, |
|||
0x00, // channel-list null terminator
|
|||
]; |
|||
|
|||
return BuildExrWithChannels(xMin, yMin, xMax, yMax, channelData); |
|||
} |
|||
|
|||
private static byte[] BuildExrWithChannels( |
|||
int xMin, int yMin, int xMax, int yMax, |
|||
byte[] channelData, |
|||
byte[] rowOffsetTableAppend = null) |
|||
{ |
|||
using var ms = new MemoryStream(); |
|||
using var bw = new BinaryWriter(ms, System.Text.Encoding.ASCII, leaveOpen: true); |
|||
|
|||
// Magic (0x01312F76 LE) + version 2, scanline (flags = 0x00)
|
|||
bw.Write(new byte[] { 0x76, 0x2F, 0x31, 0x01, 0x02, 0x00, 0x00, 0x00 }); |
|||
|
|||
void WriteAttr(string name, string type, byte[] payload) |
|||
{ |
|||
foreach (char c in name) bw.Write((byte)c); |
|||
bw.Write((byte)0); |
|||
foreach (char c in type) bw.Write((byte)c); |
|||
bw.Write((byte)0); |
|||
bw.Write(payload.Length); |
|||
bw.Write(payload); |
|||
} |
|||
|
|||
WriteAttr("channels", "chlist", channelData); |
|||
|
|||
WriteAttr("compression", "compression", [0x00]); // None
|
|||
|
|||
byte[] dw = new byte[16]; |
|||
BinaryPrimitives.WriteInt32LittleEndian(dw, xMin); |
|||
BinaryPrimitives.WriteInt32LittleEndian(dw.AsSpan(4), yMin); |
|||
BinaryPrimitives.WriteInt32LittleEndian(dw.AsSpan(8), xMax); |
|||
BinaryPrimitives.WriteInt32LittleEndian(dw.AsSpan(12), yMax); |
|||
WriteAttr("dataWindow", "box2i", dw); |
|||
|
|||
WriteAttr("displayWindow", "box2i", new byte[16]); // all zeros (0,0,0,0)
|
|||
|
|||
WriteAttr("lineOrder", "lineOrder", [0x00]); // IncreasingY
|
|||
|
|||
byte[] aspect = new byte[4]; |
|||
BinaryPrimitives.WriteSingleLittleEndian(aspect, 1.0f); |
|||
WriteAttr("pixelAspectRatio", "float", aspect); |
|||
|
|||
WriteAttr("screenWindowCenter", "v2f", new byte[8]); // (0f, 0f)
|
|||
|
|||
byte[] sww = new byte[4]; |
|||
BinaryPrimitives.WriteSingleLittleEndian(sww, 1.0f); |
|||
WriteAttr("screenWindowWidth", "float", sww); |
|||
|
|||
bw.Write((byte)0x00); // end-of-header sentinel
|
|||
|
|||
if (rowOffsetTableAppend is not null) |
|||
bw.Write(rowOffsetTableAppend); |
|||
|
|||
return ms.ToArray(); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue