Repository: wangguanquan/eec
Pull request #386 (2024-08-09 00:26:50)

提升读取csv文件的兼容性


Patch:
diff --git a/pom.xml b/pom.xml
index 9a7d0ab5..bdf0790c 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,7 +5,7 @@
 
   <groupId>org.ttzero</groupId>
   <artifactId>eec</artifactId>
-  <version>0.5.18-SNAPSHOT</version>
+  <version>0.5.17</version>
   <name>Excel Write/Read Framework</name>
   <packaging>jar</packaging>
   <url>https://www.ttzero.org</url>
diff --git a/src/main/java/org/ttzero/excel/entity/CSVSheet.java b/src/main/java/org/ttzero/excel/entity/CSVSheet.java
index c3982c02..de2432d3 100644
--- a/src/main/java/org/ttzero/excel/entity/CSVSheet.java
+++ b/src/main/java/org/ttzero/excel/entity/CSVSheet.java
@@ -25,6 +25,7 @@ import java.io.BufferedWriter;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.Reader;
+import java.nio.charset.Charset;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.StandardCopyOption;
@@ -44,20 +45,24 @@ public class CSVSheet extends Sheet {
     /**
      * csv文件路径
      */
-    private Path path;
+    protected Path path;
     /**
      * csv行迭代器，配合工作表输出协议获取数据可以极大降低内存消耗
      */
-    private CSVUtil.RowsIterator iterator;
+    protected CSVUtil.RowsIterator iterator;
     /**
      * 是否需要清理临时资源，实例化时如果传入{@code InputStream}或{@code Reader}时会先将数据保存到临时文件
      * 然后创建迭代器逐行读取数据，这个过程产生的临时文件会在关闭工作表时被一起清理
      */
-    private boolean shouldClean;
+    protected boolean shouldClean;
     /**
      * 是否包含表头，如果csv文件无表头时可将此值置为{@code false}
      */
-    private boolean hasHeader;
+    protected boolean hasHeader;
+    /**
+     * 指定读取CSV使用的字符集
+     */
+    protected Charset charset;
 
     /**
      * 实例化工作表，未指定工作表名称时默认以{@code 'Sheet'+id}命名
@@ -168,7 +173,7 @@ public class CSVSheet extends Sheet {
     // Create CSV iterator
     private void init() throws IOException {
         assert path != null && exists(path);
-        iterator = CSVUtil.newReader(path).sharedIterator();
+        iterator = CSVUtil.newReader(path, charset).sharedIterator();
     }
 
     /**
@@ -235,4 +240,14 @@ public class CSVSheet extends Sheet {
     @Override
     protected void mergeHeaderCellsIfEquals() { }
 
+    /**
+     * 设置读取CSV使用的字符集
+     *
+     * @param charset 指定字符集
+     * @return 当前工作表
+     */
+    public CSVSheet setCharset(Charset charset) {
+        this.charset = charset;
+        return this;
+    }
 }
diff --git a/src/main/java/org/ttzero/excel/entity/Workbook.java b/src/main/java/org/ttzero/excel/entity/Workbook.java
index 76a5ab4d..72965c49 100644
--- a/src/main/java/org/ttzero/excel/entity/Workbook.java
+++ b/src/main/java/org/ttzero/excel/entity/Workbook.java
@@ -31,6 +31,7 @@ import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
+import java.nio.charset.Charset;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.Arrays;
@@ -611,6 +612,36 @@ public class Workbook implements Storable {
         return this;
     }
 
+    /**
+     * 另存为Comma-Separated Values格式并保存BOM，默认使用','逗号分隔
+     *
+     * @return 当前工作薄
+     */
+    public Workbook saveAsCSVWithBom() {
+        workbookWriter = new CSVWorkbookWriter(this, true);
+        return this;
+    }
+
+    /**
+     * 以指定字符集保存为Comma-Separated Values格式，默认使用','逗号分隔
+     *
+     * @return 当前工作薄
+     */
+    public Workbook saveAsCSV(Charset charset) {
+        workbookWriter = new CSVWorkbookWriter(this).setCharset(charset);
+        return this;
+    }
+
+    /**
+     * 以指定字符集保存为Comma-Separated Values格式并保存BOM，默认使用','逗号分隔
+     *
+     * @return 当前工作薄
+     */
+    public Workbook saveAsCSVWithBom(Charset charset) {
+        workbookWriter = new CSVWorkbookWriter(this, true).setCharset(charset);
+        return this;
+    }
+
     /**
      * 确认边距并在越界时自动扩容
      */
@@ -772,7 +803,6 @@ public class Workbook implements Storable {
      */
     protected void init() {
         // 创建全局字符串共享区
-        // FIXME 根据worksheet配置来判断是否需要创建字符串共享区
         if (sst == null) {
             sst = new SharedStrings();
         }
diff --git a/src/main/java/org/ttzero/excel/entity/csv/CSVWorkbookWriter.java b/src/main/java/org/ttzero/excel/entity/csv/CSVWorkbookWriter.java
index 3a2cdd32..adaeedf1 100644
--- a/src/main/java/org/ttzero/excel/entity/csv/CSVWorkbookWriter.java
+++ b/src/main/java/org/ttzero/excel/entity/csv/CSVWorkbookWriter.java
@@ -32,6 +32,7 @@ import org.ttzero.excel.util.ZipUtil;
 import java.io.File;
 import java.io.IOException;
 import java.io.OutputStream;
+import java.nio.charset.Charset;
 import java.nio.file.Files;
 import java.nio.file.Path;
 
@@ -45,9 +46,13 @@ public class CSVWorkbookWriter implements IWorkbookWriter {
      * LOGGER
      */
     protected Logger LOGGER = LoggerFactory.getLogger(getClass());
-    private Workbook workbook;
+    protected Workbook workbook;
     // The csv suffix
     private String suffix = Const.Suffix.CSV;
+    // Write BOM header
+    protected boolean withBom;
+    // Charset 默认UTF-8
+    protected Charset charset;
 
     public CSVWorkbookWriter() { }
 
@@ -55,6 +60,11 @@ public class CSVWorkbookWriter implements IWorkbookWriter {
         this.workbook = workbook;
     }
 
+    public CSVWorkbookWriter(Workbook workbook, boolean withBom) {
+        this.workbook = workbook;
+        this.withBom = withBom;
+    }
+
     @Override
     public void setWorkbook(Workbook workbook) {
         this.workbook = workbook;
@@ -197,9 +207,20 @@ public class CSVWorkbookWriter implements IWorkbookWriter {
     @Override
     public void close() throws IOException { }
 
+    /**
+     * 设置字符集
+     *
+     * @param charset Charset
+     * @return 当前Writer
+     */
+    public CSVWorkbookWriter setCharset(Charset charset) {
+        this.charset = charset;
+        return this;
+    }
+
     // --- Customize worksheet writer
 
     public IWorksheetWriter getWorksheetWriter(Sheet sheet) {
-        return new CSVWorksheetWriter(sheet);
+        return new CSVWorksheetWriter(sheet, withBom).setCharset(charset);
     }
 }
diff --git a/src/main/java/org/ttzero/excel/entity/csv/CSVWorksheetWriter.java b/src/main/java/org/ttzero/excel/entity/csv/CSVWorksheetWriter.java
index ecb99634..6b413519 100644
--- a/src/main/java/org/ttzero/excel/entity/csv/CSVWorksheetWriter.java
+++ b/src/main/java/org/ttzero/excel/entity/csv/CSVWorksheetWriter.java
@@ -28,6 +28,7 @@ import org.ttzero.excel.util.CSVUtil;
 import org.ttzero.excel.util.DateUtil;
 
 import java.io.IOException;
+import java.nio.charset.Charset;
 import java.nio.file.Path;
 import java.util.function.BiConsumer;
 import java.util.function.Supplier;
@@ -55,11 +56,20 @@ public class CSVWorksheetWriter implements IWorksheetWriter {
      * A progress window
      */
     protected BiConsumer<Sheet, Integer> progressConsumer;
+    // Write BOM header
+    protected boolean withBom;
+    // Charset 默认UTF-8
+    protected Charset charset;
 
     public CSVWorksheetWriter(Sheet sheet) {
         this.sheet = sheet;
     }
 
+    public CSVWorksheetWriter(Sheet sheet, boolean withBom) {
+        this.sheet = sheet;
+        this.withBom = withBom;
+    }
+
     /**
      * The row limit
      *
@@ -186,7 +196,15 @@ public class CSVWorksheetWriter implements IWorksheetWriter {
 
     protected Path initWriter(Path root) throws IOException {
         Path workSheetPath = root.resolve(sheet.getName() + Const.Suffix.CSV);
-        writer = CSVUtil.newWriter(workSheetPath);
+        if (charset == null) {
+            writer = CSVUtil.newWriter(workSheetPath);
+            if (withBom) writer.writeWithBom();
+        } else {
+            writer = CSVUtil.newWriter(workSheetPath, charset);
+            // Write BOM only support utf
+            if (withBom && charset.name().toLowerCase().startsWith("utf"))
+                writer.writeWithBom();
+        }
         // Init progress window
         progressConsumer = sheet.getProgressConsumer();
 
@@ -249,4 +267,15 @@ public class CSVWorksheetWriter implements IWorksheetWriter {
         }
         writer.newLine();
     }
+
+    /**
+     * 设置字符集
+     *
+     * @param charset Charset
+     * @return 当前Writer
+     */
+    public CSVWorksheetWriter setCharset(Charset charset) {
+        this.charset = charset;
+        return this;
+    }
 }
diff --git a/src/main/java/org/ttzero/excel/util/CSVUtil.java b/src/main/java/org/ttzero/excel/util/CSVUtil.java
index 2e557ace..27193381 100644
--- a/src/main/java/org/ttzero/excel/util/CSVUtil.java
+++ b/src/main/java/org/ttzero/excel/util/CSVUtil.java
@@ -95,7 +95,6 @@ public class CSVUtil {
      * @param <T> the result type
      * @return ArrayList of clazz
      * @throws IOException if I/O error occur
-     * @since 1.7
      */
     public static <T> List<T> read(Path path, Class<T> clazz) throws IOException {
         return read(path, clazz, null);
@@ -107,7 +106,6 @@ public class CSVUtil {
      * @param path the csv file path
      * @return ArrayList of string
      * @throws IOException if I/O error occur
-     * @since 1.7
      */
     public static List<String[]> read(Path path) throws IOException {
         return read(path, (Charset) null);
@@ -120,7 +118,6 @@ public class CSVUtil {
      * @param separator the separator character
      * @return ArrayList of string
      * @throws IOException if I/O error occur
-     * @since 1.7
      */
     public static List<String[]> read(Path path, char separator) throws IOException {
         return read(path, separator, null);
@@ -135,7 +132,6 @@ public class CSVUtil {
      * @param <T> the result type
      * @return ArrayList of clazz
      * @throws IOException if I/O error occur
-     * @since 1.7
      */
     public static <T> List<T> read(Path path, Class<T> clazz, Charset charset) throws IOException {
         throw new UnsupportedOperationException();
@@ -148,7 +144,6 @@ public class CSVUtil {
      * @param charset the charset to use for encoding
      * @return ArrayList of string
      * @throws IOException if I/O error occur
-     * @since 1.7
      */
     public static List<String[]> read(Path path, Charset charset) throws IOException {
         return read(path, (char) 0x0, charset);
@@ -162,11 +157,10 @@ public class CSVUtil {
      * @param charset the charset to use for encoding
      * @return ArrayList of string
      * @throws IOException if I/O error occur
-     * @since 1.7
      */
     public static List<String[]> read(Path path, char separator, Charset charset) throws IOException {
         // Check comma character and column
-        // FileNotFoundException will be occur
+        // FileNotFoundException will be occurred
         O o = init(path, separator, charset);
         // Empty file
         if (o == null) {
@@ -174,7 +168,7 @@ public class CSVUtil {
         }
 
         // Use iterator
-        try (RowsIterator iter = new RowsIterator(o, path, charset)) {
+        try (RowsIterator iter = new RowsIterator(o, path, o.charset)) {
             List<String[]> result = new ArrayList<>();
             while (iter.hasNext()) {
                 result.add(iter.next());
@@ -330,8 +324,6 @@ public class CSVUtil {
 
     /**
      * A CSV format file reader.
-     *
-     * @since 1.8
      */
     public static class Reader implements Closeable {
 
@@ -352,7 +344,6 @@ public class CSVUtil {
          * @param clazz the class convert to
          * @param <T> the result type
          * @return a stream of clazz array
-         * @since 1.8
          */
         public <T> Stream<T> stream(Class<T> clazz) {
             throw new UnsupportedOperationException();
@@ -363,11 +354,10 @@ public class CSVUtil {
          *
          * @return a stream of string array
          * @throws IOException file not exists or read file error.
-         * @since 1.8
          */
         public Stream<String[]> stream() throws IOException {
             // Check comma character and column
-            // FileNotFoundException will be occur
+            // FileNotFoundException will be occurred
             O o = init(path, separator, charset);
             // Empty file
             if (o == null) {
@@ -375,7 +365,7 @@ public class CSVUtil {
             }
 
             // Use iterator
-            iterator = new RowsIterator(o, path, charset);
+            iterator = new RowsIterator(o, path, o.charset);
 
             return StreamSupport.stream(Spliterators.spliteratorUnknownSize(
                 iterator, Spliterator.ORDERED | Spliterator.NONNULL), false);
@@ -387,7 +377,6 @@ public class CSVUtil {
          *
          * @return a stream of sheared string array
          * @throws IOException file not exists or read file error.
-         * @since 1.8
          */
         public Stream<String[]> sharedStream() throws IOException {
             return sharedStream((char) 0x0);
@@ -400,11 +389,10 @@ public class CSVUtil {
          * @param separator the separator character
          * @return a stream of sheared string array
          * @throws IOException file not exists or read file error.
-         * @since 1.8
          */
         public Stream<String[]> sharedStream(char separator) throws IOException {
             // Check comma character and column
-            // FileNotFoundException will be occur
+            // FileNotFoundException will be occurred
             O o = init(path, separator, charset);
             // Empty file
             if (o == null) {
@@ -434,7 +422,7 @@ public class CSVUtil {
             }
 
             // Use iterator
-            return new RowsIterator(o, path, charset);
+            return new RowsIterator(o, path, o.charset);
         }
 
         /**
@@ -497,6 +485,7 @@ public class CSVUtil {
         int offset, line;
         String value;
         boolean newLine;
+        Charset charset;
         O(int offset) { this.offset = offset; }
     }
 
@@ -547,6 +536,17 @@ public class CSVUtil {
                         // EOF
                         if (n <= 0) {
                             EOF = true;
+                            // End of {comma}
+                            if (chars[o.offset - 1] == comma) {
+                                // Contain more than standard comma-separated fields
+                                if (i == column) {
+                                    String[] _array = new String[++column];
+                                    System.arraycopy(nextRow, 0, _array, 0, column - 1);
+                                    nextRow = _array;
+                                }
+                                nextRow[i++] = EMPTY;
+                                _i = i;
+                            }
                             return nextRow[0] != null;
                         }
                         n += offset;
@@ -760,6 +760,7 @@ public class CSVUtil {
 
             O o = new O(0);
             o.line = bom != null ? 1 : 0;
+            o.charset = charset;
             n = 0;
             // Find the final comma and column
             for (int i = 0; i < nc.length; i++) {
@@ -838,10 +839,10 @@ public class CSVUtil {
      * @return true if the char array has more integral string
      */
     private static boolean parse(char[] chars, int len, O o, char comma) {
-        int offset = o.offset, i = offset, iq = -1;
+        int offset = o.offset, i = offset, iq = -1, qn = 0;
         // the first character is '"'
         boolean quoted = chars[i] == QUOTE
-            // a integral string
+            // an integral string
             , integral = false
             // if data size less than block length
             , last_block = len < chars.length;
@@ -850,50 +851,47 @@ public class CSVUtil {
         for (; i < len; i++) {
             char c = chars[i];
             if (c == QUOTE) {
-                if (!quoted) {
-                    throw new RuntimeException("LINE-NUMBER (zero-base): " + o.line + ". Comma-separated values" +
-                        " format error.\nFields containing a line-break, double-quote or commas should be quoted.");
-                }
                 if (i >= len - 1) {
                     integral = true;
                     i++;
                     break;
                 }
-                if (chars[i + 1] == QUOTE) {
-                    iq = ++i;
-                    continue;
-                }
+                if (quoted) {
+                    if (chars[i + 1] == QUOTE) {
+                        iq = ++i;
+                        continue;
+                    } else if (chars[i + 1] != comma && chars[i + 1] != LF && !(chars[i + 1] == CR && i < len - 2 && chars[i + 2] == LF)) {
+                        throw new RuntimeException("line-number: " + o.line + " (zero-base). Comma-separated values" +
+                            " format error.\nInvalid char between encapsulated token and delimiter.");
+                    }
+                } else qn++;
                 if (!last_block && chars[i + 1] != comma && chars[i + 1] != LF
                     && !(chars[i + 1] == CR && i < len - 2 && chars[i + 2] == LF)) {
-                    throw new RuntimeException("LINE-NUMBER (zero-base): " + o.line + ". Comma-separated values " +
-                        "format error.\nA (double) quote character in a field must be represented by " +
-                        "two (double) quote characters.");
+                    throw new RuntimeException("line-number: " + o.line + " (zero-base). Comma-separated values " +
+                        "format error.\nA (double) quote character in a field must be represented by two (double) quote characters.");
                 }
                 i++;
-                integral = true;
-                break;
+                if (qn == 0) {
+                    integral = true;
+                    break;
+                }
             } else if (c == comma || c == LF) {
                 if (!quoted) {
                     integral = true;
                     break;
                 }
-            } else if (c == HT && !quoted) {
-                throw new RuntimeException("LINE-NUMBER (zero-base): " + o.line + ". Comma-separated values " +
-                    "format error.\nA (double) quote character in a field must be represented by " +
-                    "two (double) quote characters.");
             }
         }
 
         if (!integral && last_block) {
             if (!quoted) integral = true;
-            else throw new RuntimeException("LINE-NUMBER (zero-base): " + o.line + ". Comma-separated values " +
-                "format error.\nA (double) quote character in a field must be represented by " +
-                "two (double) quote characters.");
+            else throw new RuntimeException("line-number: " + o.line + " (zero-base). Comma-separated values " +
+                "format error.\nEOF reached before encapsulated token finished.");
         }
 
         if (integral) {
             if (quoted) offset++;
-            // a integral string
+            // an integral string
             if (offset == i && chars[offset] == LF || offset - i == 1 && chars[offset] == CR && chars[i] == LF)
                 o.value = null;
             else {
@@ -916,7 +914,7 @@ public class CSVUtil {
 
     /**
      * Returns a string whose value is this string, with any leading and trailing
-     * whitespace removed and double quoted character convert to single quoted character.
+     * whitespace removed and double-quoted character convert to single-quoted character.
      *
      * @param chars a block data
      * @param offset initial offset of the block data.
@@ -946,8 +944,6 @@ public class CSVUtil {
 
     /**
      * A CSV format file writer.
-     *
-     * @since 1.8
      */
     public static class Writer implements Closeable {
 
@@ -974,8 +970,7 @@ public class CSVUtil {
          * @throws IOException If an I/O error occurs
          */
         private Writer(Path path) throws IOException {
-            this.writer = Files.newBufferedWriter(path);
-            init();
+            this(path, StandardCharsets.UTF_8);
         }
 
         /**
@@ -1004,6 +999,21 @@ public class CSVUtil {
             cb = new char[length];
         }
 
+        /**
+         * Write csv bytes with BOM
+         *
+         * <p>Note: This property must be set before writing, otherwise it will be ignored</p>
+         *
+         * @return current writer
+         */
+        public Writer writeWithBom() {
+            if (offset == 0 && i == 0 && column == 0) {
+                // Write UTF BOM
+                cb[offset++] = '\uFEFF';
+            }
+            return this;
+        }
+
         /**
          * Writes a single character
          *


Test patch:
diff --git a/src/test/java/org/ttzero/excel/entity/csv/CsvToExcelTest.java b/src/test/java/org/ttzero/excel/entity/csv/CsvToExcelTest.java
index 7f5b3e8d..33784455 100644
--- a/src/test/java/org/ttzero/excel/entity/csv/CsvToExcelTest.java
+++ b/src/test/java/org/ttzero/excel/entity/csv/CsvToExcelTest.java
@@ -22,16 +22,22 @@ import org.ttzero.excel.entity.CSVSheet;
 import org.ttzero.excel.entity.Workbook;
 import org.ttzero.excel.entity.WorkbookTest;
 import org.ttzero.excel.reader.ExcelReader;
+import org.ttzero.excel.reader.Row;
 import org.ttzero.excel.util.CSVUtil;
 import org.ttzero.excel.util.StringUtil;
 
 import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.Iterator;
 import java.util.List;
 
+import static org.junit.Assert.assertArrayEquals;
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 import static org.ttzero.excel.util.FileUtil.exists;
 
@@ -177,4 +183,111 @@ public class CsvToExcelTest extends WorkbookTest {
             }
         }
     }
+
+    @Test public void testWriterCharsetGBK() throws IOException {
+        String[] expectList = {"中文", "123"};
+        Path distPath = getOutputTestPath().resolve("write-with-gbk.csv");
+        try (CSVUtil.Writer writer = CSVUtil.newWriter(distPath, Charset.forName("GBK"))) {
+            for (String v : expectList) {
+                writer.write(v);
+            }
+        }
+
+        try (CSVUtil.Reader reader = CSVUtil.newReader(distPath, Charset.forName("GBK"))) {
+            CSVUtil.RowsIterator iter = reader.iterator();
+            assertTrue(iter.hasNext());
+            String[] readList = iter.next();
+            assertArrayEquals(expectList, readList);
+        } catch (IOException e) {
+            e.printStackTrace();
+        }
+
+        // CSV to Excel
+        new Workbook().addSheet(new CSVSheet(distPath).setCharset(Charset.forName("GBK")).ignoreHeader()).writeTo(defaultTestPath.resolve("write-with-gbk.xlsx"));
+
+        try (ExcelReader reader = ExcelReader.read(defaultTestPath.resolve("write-with-gbk.xlsx"))) {
+            Iterator<Row> iter = reader.sheet(0).rows().iterator();
+            assertTrue(iter.hasNext());
+            Row row = iter.next();
+            assertEquals(row.getString(0), expectList[0]);
+            assertEquals(row.getString(1), expectList[1]);
+            assertFalse(iter.hasNext());
+        }
+    }
+
+    @Test public void testUTF8BOM() throws IOException {
+        String[] expectList = {"中文", "123"};
+        Path distPath = getOutputTestPath().resolve("write-with-utf8-bom.csv");
+        try (CSVUtil.Writer writer = CSVUtil.newWriter(distPath, StandardCharsets.UTF_8).writeWithBom()) {
+            for (String v : expectList) {
+                writer.write(v);
+            }
+        }
+
+        try (InputStream is = Files.newInputStream(distPath)) {
+            byte[] bytes = new byte[3];
+            int n = is.read(bytes);
+            assertEquals(3, n);
+            assertArrayEquals(bytes, new byte[] {(byte) 239, (byte) 187, (byte) 191});
+        }
+
+        try (CSVUtil.Reader reader = CSVUtil.newReader(distPath)) {
+            CSVUtil.RowsIterator iter = reader.iterator();
+            assertTrue(iter.hasNext());
+            String[] readList = iter.next();
+            assertArrayEquals(expectList, readList);
+        } catch (IOException e) {
+            e.printStackTrace();
+        }
+
+        // CSV to Excel
+        new Workbook().addSheet(new CSVSheet(distPath).ignoreHeader()).writeTo(defaultTestPath.resolve("write-with-utf8-bom.xlsx"));
+
+        try (ExcelReader reader = ExcelReader.read(defaultTestPath.resolve("write-with-utf8-bom.xlsx"))) {
+            Iterator<Row> iter = reader.sheet(0).rows().iterator();
+            assertTrue(iter.hasNext());
+            Row row = iter.next();
+            assertEquals(row.getString(0), expectList[0]);
+            assertEquals(row.getString(1), expectList[1]);
+            assertFalse(iter.hasNext());
+        }
+    }
+
+    @Test public void testUTF16BEBOM() throws IOException {
+        String[] expectList = {"中文", "123"};
+        Path distPath = getOutputTestPath().resolve("write-with-utf16BE-bom.csv");
+        try (CSVUtil.Writer writer = CSVUtil.newWriter(distPath, StandardCharsets.UTF_16BE).writeWithBom()) {
+            for (String v : expectList) {
+                writer.write(v);
+            }
+        }
+
+        try (InputStream is = Files.newInputStream(distPath)) {
+            byte[] bytes = new byte[2];
+            int n = is.read(bytes);
+            assertEquals(2, n);
+            assertArrayEquals(bytes, new byte[] {-2, -1});
+        }
+
+        try (CSVUtil.Reader reader = CSVUtil.newReader(distPath)) {
+            CSVUtil.RowsIterator iter = reader.iterator();
+            assertTrue(iter.hasNext());
+            String[] readList = iter.next();
+            assertArrayEquals(expectList, readList);
+        } catch (IOException e) {
+            e.printStackTrace();
+        }
+
+        // CSV to Excel
+        new Workbook().addSheet(new CSVSheet(distPath).setCharset(StandardCharsets.UTF_16BE).ignoreHeader()).writeTo(defaultTestPath.resolve("write-with-utf16BE-bom.xlsx"));
+
+        try (ExcelReader reader = ExcelReader.read(defaultTestPath.resolve("write-with-utf16BE-bom.xlsx"))) {
+            Iterator<Row> iter = reader.sheet(0).rows().iterator();
+            assertTrue(iter.hasNext());
+            Row row = iter.next();
+            assertEquals(row.getString(0), expectList[0]);
+            assertEquals(row.getString(1), expectList[1]);
+            assertFalse(iter.hasNext());
+        }
+    }
 }
diff --git a/src/test/java/org/ttzero/excel/entity/csv/ListObjectSheetTest.java b/src/test/java/org/ttzero/excel/entity/csv/ListObjectSheetTest.java
index d6238bca..322ec006 100644
--- a/src/test/java/org/ttzero/excel/entity/csv/ListObjectSheetTest.java
+++ b/src/test/java/org/ttzero/excel/entity/csv/ListObjectSheetTest.java
@@ -82,8 +82,7 @@ public class ListObjectSheetTest extends WorkbookTest {
         }
     }
 
-    @Test
-    public void testWrite() throws IOException {
+    @Test public void testWrite() throws IOException {
         new Workbook("test object")
             .addSheet(new ListSheet<>(Item.randomTestData()))
             .saveAsCSV()
@@ -373,4 +372,11 @@ public class ListObjectSheetTest extends WorkbookTest {
             .saveAsCSV()
             .writeTo(defaultTestPath);
     }
+
+    @Test public void testWriteWithBom() throws IOException {
+        new Workbook("test object with utf8 bom")
+            .addSheet(new ListSheet<>(Item.randomTestData()))
+            .saveAsCSVWithBom()
+            .writeTo(getOutputTestPath());
+    }
 }