[feat] 新增ExcelWriter,支持生成简易Excel文件
石头 authored at 2025-09-30 17:19:25
16.65 KiB
X
using System.IO.Compression;
using System.Text;
using NewLife;
using NewLife.IO;
using Xunit;

namespace XUnitTest.IO;

public class ExcelReaderTests
{
    [Fact]
    public void Test1()
    {
        var type = GetType();
        var stream = type.Assembly.GetManifestResourceStream(type.Namespace + ".excel.xlsx");
        Assert.NotNull(stream);

        var reader = new ExcelReader(stream, Encoding.UTF8);
        var rows = reader.ReadRows().ToList();
        Assert.Equal(927, rows.Count);

        var names = "序号,名字,昵称,启用,年龄,生日,时间,余额,比率,开始时间,结束时间".Split(',');
        var fields = rows[0].Cast<String>().ToArray();
        Assert.Equal(names.Length, fields.Length);
        for (var i = 0; i < names.Length; i++)
        {
            Assert.Equal(names[i], fields[i]);
        }

        var values = "111,Stone,大石头,1,36.6,1984-07-01,2020-03-04 20:08:45,323.452,0.234,11:22:00,23:59:00".Split(',');
        var row1 = rows[1];
        Assert.Equal(values.Length, row1.Length);
        for (var i = 0; i < values.Length; i++)
        {
            if (row1[i] is DateTime dt)
                Assert.Equal(values[i].ToDateTime(), dt);
            else if (row1[i] is TimeSpan ts)
                Assert.Equal(TimeSpan.Parse(values[i]), ts);
            else
                Assert.Equal(values[i], row1[i] + "");
        }
    }

    /// <summary>
    /// 构造一个内存xlsx(最小xml集合)用来测试:
    /// 1) 多字母列AA/AB索引
    /// 2) 中间缺失列补null
    /// 3) 布尔单元格解析
    /// </summary>
    [Fact]
    public void ColumnIndexAndBooleanTest()
    {
        // 生成一个简单工作簿,包含共享字符串与一个sheet,列:A(共享字符串) C(布尔) AA(数字) AB(共享字符串)
        using var ms = new MemoryStream();
        using (var za = new ZipArchive(ms, ZipArchiveMode.Create, true, Encoding.UTF8))
        {
            // [Content_Types].xml
            var entry = za.CreateEntry("[Content_Types].xml");
            using (var sw = new StreamWriter(entry.Open(), Encoding.UTF8))
            {
                sw.Write("<?xml version=\"1.0\" encoding=\"UTF-8\"?><Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><Default Extension=\"xml\" ContentType=\"application/xml\"/><Override PartName=\"/xl/workbook.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml\"/><Override PartName=\"/xl/worksheets/sheet1.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml\"/><Override PartName=\"/xl/sharedStrings.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml\"/><Override PartName=\"/xl/styles.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml\"/></Types>");
            }
            // workbook.xml
            entry = za.CreateEntry("xl/workbook.xml");
            using (var sw = new StreamWriter(entry.Open(), Encoding.UTF8))
            {
                sw.Write("<?xml version=\"1.0\" encoding=\"UTF-8\"?><workbook xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"><sheets><sheet name=\"Sheet1\" sheetId=\"1\" r:id=\"rId1\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\"/></sheets></workbook>");
            }
            // styles.xml (最小)
            entry = za.CreateEntry("xl/styles.xml");
            using (var sw = new StreamWriter(entry.Open(), Encoding.UTF8))
            {
                sw.Write("<?xml version=\"1.0\" encoding=\"UTF-8\"?><styleSheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"><cellXfs count=\"1\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\"/></cellXfs></styleSheet>");
            }
            // sharedStrings.xml
            entry = za.CreateEntry("xl/sharedStrings.xml");
            using (var sw = new StreamWriter(entry.Open(), Encoding.UTF8))
            {
                sw.Write("<?xml version=\"1.0\" encoding=\"UTF-8\"?><sst xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" count=\"2\" uniqueCount=\"2\"><si><t>Head</t></si><si><t>Tail</t></si></sst>");
            }
            // sheet1.xml: A1=共享字符串0, C1=布尔1(true), AA1=数值123, AB1=共享字符串1
            entry = za.CreateEntry("xl/worksheets/sheet1.xml");
            using (var sw = new StreamWriter(entry.Open(), Encoding.UTF8))
            {
                // Columns: A (index0), B skipped(null), C(index2 boolean), ... AA(index26 numeric), AB(index27 shared string)
                sw.Write("<?xml version=\"1.0\" encoding=\"UTF-8\"?><worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"><sheetData><row r=\"1\"><c r=\"A1\" t=\"s\"><v>0</v></c><c r=\"C1\" t=\"b\"><v>1</v></c><c r=\"AA1\"><v>123</v></c><c r=\"AB1\" t=\"s\"><v>1</v></c></row></sheetData></worksheet>");
            }
        }
        ms.Position = 0;

        var reader = new ExcelReader(ms, Encoding.UTF8);
        var rows = reader.ReadRows().ToList();
        Assert.Single(rows);

        var arr = rows[0];
        // 期望有 AB 列 => 28 列 (index 0..27)
        Assert.Equal(28, arr.Length);
        Assert.Equal("Head", arr[0]); // A
        Assert.Null(arr[1]); // B 缺失
        Assert.True(arr[2] is Boolean b && b); // C 布尔
        Assert.Equal("123", arr[26]); // AA (多字母)
        Assert.Equal("Tail", arr[27]); // AB SharedString
    }

    // 构造最小/可变 excel 的工具方法
    private static MemoryStream BuildExcel(String sheetXml, String? sharedStrings = null, String? styles = null, String sheetName = "Sheet1", Boolean includeStyles = true, Boolean includeShared = true)
    {
        var ms = new MemoryStream();
        using var za = new ZipArchive(ms, ZipArchiveMode.Create, true, Encoding.UTF8);
        // content types
        using (var sw = new StreamWriter(za.CreateEntry("[Content_Types].xml").Open(), Encoding.UTF8))
        {
            sw.Write("<?xml version=\"1.0\" encoding=\"UTF-8\"?><Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><Default Extension=\"xml\" ContentType=\"application/xml\"/><Override PartName=\"/xl/workbook.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml\"/><Override PartName=\"/xl/worksheets/sheet1.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml\"/>{0}{1}</Types>"
                .Replace("{0}", includeShared ? "<Override PartName=\"/xl/sharedStrings.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml\"/>" : String.Empty)
                .Replace("{1}", includeStyles ? "<Override PartName=\"/xl/styles.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml\"/>" : String.Empty));
        }
        // workbook
        using (var sw = new StreamWriter(za.CreateEntry("xl/workbook.xml").Open(), Encoding.UTF8))
        {
            sw.Write($"<?xml version=\"1.0\" encoding=\"UTF-8\"?><workbook xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"><sheets><sheet name=\"{sheetName}\" sheetId=\"1\" r:id=\"rId1\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\"/></sheets></workbook>");
        }
        // styles
        if (includeStyles)
        {
            using var sw = new StreamWriter(za.CreateEntry("xl/styles.xml").Open(), Encoding.UTF8);
            sw.Write(styles ?? "<?xml version=\"1.0\" encoding=\"UTF-8\"?><styleSheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"><cellXfs count=\"1\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\"/></cellXfs></styleSheet>");
        }
        // sharedStrings
        if (includeShared)
        {
            using var sw = new StreamWriter(za.CreateEntry("xl/sharedStrings.xml").Open(), Encoding.UTF8);
            sw.Write(sharedStrings ?? "<?xml version=\"1.0\" encoding=\"UTF-8\"?><sst xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" count=\"1\" uniqueCount=\"1\"><si><t>Str</t></si></sst>");
        }
        // sheet
        using (var sw = new StreamWriter(za.CreateEntry("xl/worksheets/sheet1.xml").Open(), Encoding.UTF8))
        {
            sw.Write(sheetXml);
        }

        // 全部写完再关闭
        za.Dispose();

        ms.Position = 0;
        return ms;
    }

    [Fact]
    public void EmptySheet_NoShared_NoStyles()
    {
        var sheetXml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"><sheetData/></worksheet>";
        using var ms = BuildExcel(sheetXml, includeShared: false, includeStyles: false);
        var reader = new ExcelReader(ms, Encoding.UTF8);
        var rows = reader.ReadRows().ToList();
        Assert.Empty(rows);
    }

    [Fact]
    public void MultipleSheets_SelectByName_And_Invalid()
    {
        // 两个工作表:Sheet1 有一行,Sheet2 有两行
        var sheet1 = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"><sheetData><row r=\"1\"><c r=\"A1\"><v>1</v></c></row></sheetData></worksheet>";
        var sheet2 = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"><sheetData><row r=\"1\"><c r=\"A1\"><v>2</v></c></row><row r=\"2\"><c r=\"A2\"><v>3</v></c></row></sheetData></worksheet>";
        // 先构造 zip 再手动添加第二个 sheet
        using var ms = BuildExcel(sheet1);
        using (var za = new ZipArchive(ms, ZipArchiveMode.Update, true, Encoding.UTF8))
        {
            var entry = za.CreateEntry("xl/worksheets/sheet2.xml");
            using var sw = new StreamWriter(entry.Open(), Encoding.UTF8);
            sw.Write(sheet2);
        }
        ms.Position = 0;

        var reader = new ExcelReader(ms, Encoding.UTF8);
        // 默认取第一个 sheet (Sheet1)
        var rows1 = reader.ReadRows().ToList();
        Assert.Single(rows1);
        Assert.Equal("1", rows1[0][0] + "");

        // 指定 Sheet2
        Assert.Throws<ArgumentOutOfRangeException>(() => reader.ReadRows("Str").ToList());
        var rows2 = reader.ReadRows("sheet2")?.ToList(); 
    }

    [Fact]
    public void Styles_AllBranches_And_NullStyleEntry()
    {
        // styles: 8 个xf
        var styles = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><styleSheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"><cellXfs count=\"8\">" +
                     // 0: General(0)
                     "<xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\"/>" +
                     // 1: Date (14)
                     "<xf numFmtId=\"14\" fontId=\"0\" fillId=\"0\" borderId=\"0\"/>" +
                     // 2: Time (20)
                     "<xf numFmtId=\"20\" fontId=\"0\" fillId=\"0\" borderId=\"0\"/>" +
                     // 3: Int only (1)
                     "<xf numFmtId=\"1\" fontId=\"0\" fillId=\"0\" borderId=\"0\"/>" +
                     // 4: Decimal (2)
                     "<xf numFmtId=\"2\" fontId=\"0\" fillId=\"0\" borderId=\"0\"/>" +
                     // 5: Double(9)
                     "<xf numFmtId=\"9\" fontId=\"0\" fillId=\"0\" borderId=\"0\"/>" +
                     // 6: Text (49)
                     "<xf numFmtId=\"49\" fontId=\"0\" fillId=\"0\" borderId=\"0\"/>" +
                     // 7: Unknown (999) -> null style entry
                     "<xf numFmtId=\"999\" fontId=\"0\" fillId=\"0\" borderId=\"0\"/>" +
                     "</cellXfs></styleSheet>";
        // Row cells: A1 General 2147483648 (long), B1 Date serial 5 => 1900-01-04, C1 Time 0.5 => 12:00, D1 Int style decimal won't parse => string, E1 Decimal 123.45 => decimal/double, F1 Double 0.25 => double, G1 Text 123.456 => string, H1 Unknown style 99 => remains string, I1 style index=99(越界) remains string
        var sheet = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"><sheetData><row r=\"1\">" +
                     "<c r=\"A1\" s=\"0\"><v>2147483648</v></c>" +
                     "<c r=\"B1\" s=\"1\"><v>5</v></c>" +
                     "<c r=\"C1\" s=\"2\"><v>0.5</v></c>" +
                     "<c r=\"D1\" s=\"3\"><v>12.34</v></c>" +
                     "<c r=\"E1\" s=\"4\"><v>123.45</v></c>" +
                     "<c r=\"F1\" s=\"5\"><v>0.25</v></c>" +
                     "<c r=\"G1\" s=\"6\"><v>123.456</v></c>" +
                     "<c r=\"H1\" s=\"7\"><v>99</v></c>" +
                     "<c r=\"I1\" s=\"99\"><v>7</v></c>" +
                     "</row></sheetData></worksheet>";
        using var ms = BuildExcel(sheet, styles: styles);
        var reader = new ExcelReader(ms, Encoding.UTF8);
        var row = reader.ReadRows().First();
        Assert.Equal(9, row.Length);
        Assert.IsType<Int64>(row[0]);
        Assert.IsType<DateTime>(row[1]);
        Assert.Equal(new DateTime(1900, 1, 4), (DateTime)row[1]);
        Assert.IsType<TimeSpan>(row[2]);
        Assert.Equal(TimeSpan.FromHours(12), (TimeSpan)row[2]);
        Assert.Equal("12.34", row[3]); // 未转换
        Assert.True(row[4] is Decimal or Double);
        Assert.True(row[5] is Double);
        Assert.IsType<String>(row[6]);
        Assert.Equal("99", row[7]); // 未知样式未转换
        Assert.Equal("7", row[8]); // 越界 style 索引
    }

    [Fact]
    public void BooleanVariants_And_SharedString_OutOfRange()
    {
        var shared = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><sst xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" count=\"1\" uniqueCount=\"1\"><si><t>Only</t></si></sst>";
        // A1 shared index 0 => Only, B1 shared index 5 越界 => null, C1 bool 0 false, D1 bool 1 true, E1 bool TRUE true, F1 bool false false
        var sheet = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"><sheetData><row r=\"1\">" +
                    "<c r=\"A1\" t=\"s\"><v>0</v></c>" +
                    "<c r=\"B1\" t=\"s\"><v>5</v></c>" +
                    "<c r=\"C1\" t=\"b\"><v>0</v></c>" +
                    "<c r=\"D1\" t=\"b\"><v>1</v></c>" +
                    "<c r=\"E1\" t=\"b\"><v>TRUE</v></c>" +
                    "<c r=\"F1\" t=\"b\"><v>false</v></c>" +
                    "</row></sheetData></worksheet>";
        using var ms = BuildExcel(sheet, sharedStrings: shared);
        var reader = new ExcelReader(ms, Encoding.UTF8);
        var row = reader.ReadRows().First();
        Assert.Equal("Only", row[0]);
        Assert.Null(row[1]); // 越界
        Assert.False((Boolean)row[2]!);
        Assert.True((Boolean)row[3]!);
        Assert.True((Boolean)row[4]!);
        Assert.False((Boolean)row[5]!);
    }

    [Fact]
    public void InlineStr_And_FormulaString()
    {
        // inlineStr: A1, str: B1
        var sheet = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"><sheetData><row r=\"1\">" +
                    "<c r=\"A1\" t=\"inlineStr\"><is><t>Inline Text</t></is></c>" +
                    "<c r=\"C1\" t=\"str\"><v>FormulaResult</v></c>" + // 中间缺失 B => null
                    "</row></sheetData></worksheet>";
        using var ms = BuildExcel(sheet, includeShared: false);
        var reader = new ExcelReader(ms, Encoding.UTF8);
        var row = reader.ReadRows().First();
        Assert.Equal(3, row.Length);
        Assert.Equal("Inline Text", row[0]);
        Assert.Null(row[1]);
        Assert.Equal("FormulaResult", row[2]);
    }

    [Fact]
    public void MissingManyLeadingColumns()
    {
        // 只有 AC1 = 1 (AC 是第 29 列,索引 28) => 前面应补 28 个 null
        var sheet = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"><sheetData><row r=\"1\"><c r=\"AC1\"><v>1</v></c></row></sheetData></worksheet>";
        using var ms = BuildExcel(sheet, includeShared: false, includeStyles: false);
        var reader = new ExcelReader(ms, Encoding.UTF8);
        var row = reader.ReadRows().First();
        Assert.Equal(29, row.Length);
        for (var i = 0; i < 28; i++) Assert.Null(row[i]);
        Assert.Equal("1", row[28]);
    }

    [Fact]
    public void Dispose_Then_Read_ShouldThrow()
    {
        var sheet = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"><sheetData/></worksheet>";
        using var ms = BuildExcel(sheet, includeShared: false, includeStyles: false);
        var reader = new ExcelReader(ms, Encoding.UTF8);
        reader.Dispose();
        Assert.Throws<ObjectDisposedException>(() => reader.ReadRows().ToList());
    }
}