Repository: wangguanquan/eec
Pull request #311 (2022-11-29 13:38:10)

Fix#306


Patch:
diff --git a/src/main/java/org/ttzero/excel/entity/Column.java b/src/main/java/org/ttzero/excel/entity/Column.java
index 9624eabc..e51b0ee5 100644
--- a/src/main/java/org/ttzero/excel/entity/Column.java
+++ b/src/main/java/org/ttzero/excel/entity/Column.java
@@ -341,6 +341,7 @@ public class Column {
      */
     public Column(Column other) {
         from(other);
+        if (other.next != null) addSubColumn(new Column(other.next));
     }
 
     /**
@@ -372,9 +373,7 @@ public class Column {
         int i;
         if ((i = other.getHeaderStyleIndex()) > 0) this.headerStyleIndex = i;
         if ((i = other.getCellStyleIndex()) > 0) this.cellStyleIndex = i;
-//        if (other.next != null) {
-//            addSubColumn(new Column(other.next));
-//        }
+
         return this;
     }
     /**
diff --git a/src/main/java/org/ttzero/excel/entity/ResultSetSheet.java b/src/main/java/org/ttzero/excel/entity/ResultSetSheet.java
index 39ef0743..06ebeeac 100644
--- a/src/main/java/org/ttzero/excel/entity/ResultSetSheet.java
+++ b/src/main/java/org/ttzero/excel/entity/ResultSetSheet.java
@@ -388,7 +388,7 @@ public class ResultSetSheet extends Sheet {
         return clazz;
     }
 
-    private static class SQLColumn extends org.ttzero.excel.entity.Column {
+    public static class SQLColumn extends org.ttzero.excel.entity.Column {
         int sqlType, ri; // ResultSet index
 
         public SQLColumn(String name, int sqlType, Class<?> clazz) {
diff --git a/src/main/java/org/ttzero/excel/entity/Sheet.java b/src/main/java/org/ttzero/excel/entity/Sheet.java
index 728ff2ae..2ea0b8f1 100644
--- a/src/main/java/org/ttzero/excel/entity/Sheet.java
+++ b/src/main/java/org/ttzero/excel/entity/Sheet.java
@@ -1439,7 +1439,7 @@ public abstract class Sheet implements Cloneable, Storable {
             for (Dimension dim : mergeCells) {
                 org.ttzero.excel.entity.Column col = array[(dim.firstColumn - 1) * y + (y - dim.lastRow)];
                 Comment headerComment = col.headerComment;
-                org.ttzero.excel.entity.Column tmp = new org.ttzero.excel.entity.Column(col);
+                org.ttzero.excel.entity.Column tmp = new org.ttzero.excel.entity.Column().from(col);
 
                 // Clear name in merged cols range
                 for (int m = dim.firstColumn - 1; m < dim.lastColumn; m++) {
diff --git a/src/main/java/org/ttzero/excel/reader/Cell.java b/src/main/java/org/ttzero/excel/reader/Cell.java
index d84c31db..92cbe1c9 100644
--- a/src/main/java/org/ttzero/excel/reader/Cell.java
+++ b/src/main/java/org/ttzero/excel/reader/Cell.java
@@ -24,6 +24,7 @@ import java.math.BigDecimal;
 public class Cell {
     public Cell() { }
     public Cell(short i) { this.i = i; }
+    public Cell(int i) { this.i = (short) (i & 0x7FFF); }
     public static final char SST         = 's';
     public static final char BOOL        = 'b';
     public static final char FUNCTION    = 'f';
@@ -103,69 +104,82 @@ public class Cell {
      */
     public transient short i;
 
-    public void setT(char t) {
+    public Cell setT(char t) {
         this.t = t;
+        return this;
     }
 
-    public void setSv(String sv) {
+    public Cell setSv(String sv) {
         this.t = INLINESTR;
         this.sv = sv;
+        return this;
     }
 
-    public void setNv(int nv) {
+    public Cell setNv(int nv) {
         this.t = NUMERIC;
         this.nv = nv;
+        return this;
     }
 
-    public void setDv(double dv) {
+    public Cell setDv(double dv) {
         this.t = DOUBLE;
         this.dv = dv;
+        return this;
     }
 
-    public void setBv(boolean bv) {
+    public Cell setBv(boolean bv) {
         this.t = BOOL;
         this.bv = bv;
+        return this;
     }
 
-    public void setCv(char c) {
+    public Cell setCv(char c) {
         this.t = CHARACTER;
         this.cv = c;
+        return this;
     }
 
-    public void blank() {
+    public Cell blank() {
         this.t = BLANK;
+        return this;
     }
 
-    public void emptyTag() {
+    public Cell emptyTag() {
         this.t = EMPTY_TAG;
+        return this;
     }
 
-    public void setLv(long lv) {
+    public Cell setLv(long lv) {
         this.t = LONG;
         this.lv = lv;
+        return this;
     }
 
-    public void setMv(BigDecimal mv) {
+    public Cell setMv(BigDecimal mv) {
         this.t = DECIMAL;
         this.mv = mv;
+        return this;
     }
 
-    public void setIv(double i) {
+    public Cell setIv(double i) {
         this.t = DATETIME;
         this.dv = i;
+        return this;
     }
 
-    public void setAv(int a) {
+    public Cell setAv(int a) {
         this.t = DATE;
         this.nv = a;
+        return this;
     }
 
-    public void setTv(double t) {
+    public Cell setTv(double t) {
         this.t = TIME;
         this.dv = t;
+        return this;
     }
 
-    public void clear() {
+    public Cell clear() {
         this.t  = UNALLOCATED;
         this.sv = null;
         this.nv = 0;
@@ -178,6 +192,7 @@ public class Cell {
         this.fv = null;
         this.f  = false;
         this.si = -1;
+        return this;
     }
 
     public Cell from(Cell cell) {
diff --git a/src/main/java/org/ttzero/excel/reader/Grid.java b/src/main/java/org/ttzero/excel/reader/Grid.java
index c173b21d..b2c128d2 100644
--- a/src/main/java/org/ttzero/excel/reader/Grid.java
+++ b/src/main/java/org/ttzero/excel/reader/Grid.java
@@ -23,8 +23,6 @@ import java.util.Map;
 import java.util.StringJoiner;
 
 import static java.lang.Integer.numberOfTrailingZeros;
-import static org.ttzero.excel.reader.Cell.BLANK;
-import static org.ttzero.excel.reader.Cell.EMPTY_TAG;
 import static org.ttzero.excel.util.StringUtil.formatBinarySize;
 
 /**
@@ -77,8 +75,9 @@ public interface Grid {
      *
      * @param r row number (from one)
      * @param cell column number (from one)
+     * @return not a mered cell will returns 0, 1 means current cell is the first merged cells otherwise returns 2
      */
-    void merge(int r, Cell cell);
+    int merge(int r, Cell cell);
 
     /**
      * Returns count of merged cells
@@ -151,18 +150,22 @@ public interface Grid {
         }
 
         @Override
-        public void merge(int r, Cell cell) {
-            if (!test(r, cell.i)) return;
+        public int merge(int r, Cell cell) {
+            Scanner.Entry e;
+            if (!test(r, cell.i) || (e = scanner.get(r, cell.i)) == null) return 0;
 
-            Scanner.Entry e = scanner.get(r, cell.i);
-            if (cell.t == EMPTY_TAG || cell.t == BLANK) {
+            Dimension dim = e.getDim();
+            int i = 2;
+            if (dim.firstRow != r || dim.firstColumn != cell.i) {
                 // Copy value from the first merged cell
                 cell.from(e.getCell());
             }
-            // Current cell has value
+            // First merged cell
             else {
                 e.getCell().from(cell);
+                i = 1;
             }
+            return i;
         }
 
         @Override
@@ -215,6 +218,7 @@ public interface Grid {
         private final int fr, fc, lr, lc; // Start index of Row and Column(One base)
         private final Map<Long, Cell> index;
         private int size;
+        private long[] topCells;
         IndexGrid(Dimension dim, int n) {
             fr = dim.firstRow;
             lr = dim.lastRow;
@@ -222,6 +226,7 @@ public interface Grid {
             lc = dim.lastColumn;
 
             index = new HashMap<>(n);
+            topCells = new long[1 << 6];
         }
 
         @Override
@@ -232,28 +237,48 @@ public interface Grid {
                     index.put(((long) i) << 16 | j, cell);
                 }
             }
+
+            if (topCells.length <= size) {
+                long[] _cells = new long[size + 64];
+                System.arraycopy(topCells, 0, _cells, 0, size);
+                topCells = _cells;
+            }
+
+            long v = ((long) dim.firstRow) << 16 | dim.firstColumn;
+            int i = Arrays.binarySearch(topCells, 0, size, v);
+            insert(topCells, size, i > 0 ? i : ~i, v);
+
             size++;
         }
 
+        // Insert value into array, Ignore bound check
+        static void insert(long[] array, int size, int index, long value) {
+            if (index < size) System.arraycopy(array, index, array, index + 1, size - index);
+            array[index] = value;
+        }
+
         @Override
         public boolean test(int r, int c) {
             return range(r, c) && index.containsKey(((long) r) << 16 | c);
         }
 
         @Override
-        public void merge(int r, Cell cell) {
-            if (!range(r, cell.i)) return;
-            Cell c = index.get(((long) r) << 16 | cell.i);
-            if (c == null) return;
+        public int merge(int r, Cell cell) {
+            Cell c;
+            long v;
+            if ((c = index.get(v = (((long) r) << 16 | cell.i))) == null) return 0;
 
-            if (cell.t == EMPTY_TAG || cell.t == BLANK) {
+            int i = 2;
+            if (Arrays.binarySearch(topCells, 0, size, v) < 0) {
                 // Copy value from the first merged cell
                 cell.from(c);
             }
             // Current cell has value
             else {
                 c.from(cell);
+                i = 1;
             }
+            return i;
         }
 
         @Override
@@ -295,19 +320,22 @@ public interface Grid {
         }
 
         @Override
-        public void merge(int r, Cell cell) {
-            if (!range(r, cell.i)) return;
-            Scanner.Entry e = scanner.get(r, cell.i);
-            if (e == null) return;
+        public int merge(int r, Cell cell) {
+            Scanner.Entry e;
+            if (!range(r, cell.i) || (e = scanner.get(r, cell.i)) == null) return 0;
 
-            if (cell.t == EMPTY_TAG || cell.t == BLANK) {
+            Dimension dim = e.getDim();
+            int i = 2;
+            if (dim.firstRow != r || dim.firstColumn != cell.i) {
                 // Copy value from the first merged cell
                 cell.from(e.getCell());
             }
-            // Current cell has value
+            // First merged cell
             else {
                 e.getCell().from(cell);
+                i = 1;
             }
+            return i;
         }
 
         @Override
diff --git a/src/main/java/org/ttzero/excel/reader/HeaderRow.java b/src/main/java/org/ttzero/excel/reader/HeaderRow.java
index cb56dd52..655faad7 100644
--- a/src/main/java/org/ttzero/excel/reader/HeaderRow.java
+++ b/src/main/java/org/ttzero/excel/reader/HeaderRow.java
@@ -17,6 +17,7 @@
 package org.ttzero.excel.reader;
 
 import org.ttzero.excel.annotation.ExcelColumn;
+import org.ttzero.excel.annotation.ExcelColumns;
 import org.ttzero.excel.annotation.IgnoreImport;
 import org.ttzero.excel.annotation.RowNum;
 import org.ttzero.excel.entity.ListSheet;
@@ -26,6 +27,7 @@ import org.ttzero.excel.util.StringUtil;
 import java.beans.IntrospectionException;
 import java.beans.Introspector;
 import java.beans.PropertyDescriptor;
+import java.lang.annotation.Annotation;
 import java.lang.reflect.AccessibleObject;
 import java.lang.reflect.Field;
 import java.lang.reflect.InvocationTargetException;
@@ -35,11 +37,12 @@ import java.time.DateTimeException;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.time.LocalTime;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.HashMap;
-import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.StringJoiner;
 import java.util.stream.Collectors;
@@ -70,6 +73,8 @@ public class HeaderRow extends Row {
 
     // `detailMessage` field declare in Throwable
     protected static final Field detailMessageField;
+    // Specify total rows of header
+    protected int headRows;
 
     static {
         Field field = null;
@@ -82,15 +87,23 @@ public class HeaderRow extends Row {
         detailMessageField = field;
     }
 
-    protected HeaderRow() { }
+    public HeaderRow with(Row ... rows) {
+        return with(null, rows.length, rows);
+    }
+
+    public HeaderRow with(int headRows, Row ... rows) {
+        return with(null, headRows, rows);
+    }
 
-    public HeaderRow with(Row row) {
+    public HeaderRow with(List<Dimension> mergeCells, Row ... rows) {
+        return with(mergeCells, rows.length, rows);
+    }
+
+    public HeaderRow with(List<Dimension> mergeCells, int headRows, Row ... rows) {
+        this.headRows = headRows;
+        Row row = rows[rows.length - 1];
         this.names = new String[row.lc];
         this.mapping = new HashMap<>();
-        for (int i = row.fc; i < row.lc; i++) {
-            this.names[i] = row.getString(i);
-            this.mapping.put(this.names[i], i);
-        }
         // Extends from row
         this.fc = row.fc;
         this.lc = row.lc;
@@ -99,15 +112,42 @@ public class HeaderRow extends Row {
         for (int i = 0; i < row.fc; i++) {
             this.cells[i] = new Cell();
         }
-        for (int i = row.fc; i < row.lc; i++) {
-            Cell cell = new Cell();
-            cell.setSv(this.names[i]);
-            this.cells[i] = cell;
+
+        if (headRows == 1) {
+            for (int i = row.fc; i < row.lc; i++) {
+                this.names[i] = row.getString(i);
+                this.mapping.put(this.names[i], i);
+
+                Cell cell = new Cell();
+                cell.setSv(this.names[i]);
+                this.cells[i] = cell;
+            }
+        } else {
+            // Copy on merge cells
+            mergeCellsIfNull(mergeCells, rows);
+
+            StringBuilder buf = new StringBuilder();
+            for (int i = row.fc; i < row.lc; i++) {
+                buf.delete(0, buf.length());
+                for (Row r : rows) {
+                    String tmp = r.getString(i);
+                    if (StringUtil.isNotEmpty(tmp)) {
+                        buf.append(tmp).append(':');
+                    }
+                }
+                if (buf.length() > 1) buf.deleteCharAt(buf.length() - 1);
+                this.names[i] = buf.toString();
+                this.mapping.put(this.names[i], i);
+
+                Cell cell = new Cell();
+                cell.setSv(this.names[i]);
+                this.cells[i] = cell;
+            }
         }
         return this;
     }
 
-    final boolean is(Class<?> clazz) {
+    public final boolean is(Class<?> clazz) {
         return this.clazz != null && this.clazz == clazz;
     }
 
@@ -135,7 +175,8 @@ public class HeaderRow extends Row {
             LOGGER.warn("Get class {} methods failed.", clazz);
         }
 
-        Map<String, ListSheet.EntryColumn> columnMap = new LinkedHashMap<>();
+        List<ListSheet.EntryColumn> list = new ArrayList<>();
+//        Map<String, ListSheet.EntryColumn> columnMap = new LinkedHashMap<>();
         ListSheet.EntryColumn column, other;
         for (int i = 0; i < declaredFields.length; i++) {
             Field f = declaredFields[i];
@@ -147,27 +188,31 @@ public class HeaderRow extends Row {
             if (method != null) {
                 column = createColumn(method);
                 if (column != null) {
-                    column.method = method;
-                    if (StringUtil.isEmpty(column.name)) column.name = method.getName();
-                    if (column.colIndex < 0) column.colIndex = check(column.name, gs);
-                    if (column.clazz == null) column.clazz = method.getParameterTypes()[0];
-                    if ((other = columnMap.get(column.getName())) == null || other.getMethod() == null) columnMap.put(column.name, column);
+                    ListSheet.EntryColumn tail = column.tail != null ? (ListSheet.EntryColumn) column.tail : column;
+                    tail.method = method;
+                    if (StringUtil.isEmpty(tail.name)) tail.name = method.getName();
+                    if (tail.clazz == null) tail.clazz = method.getParameterTypes()[0];
+//                    if (tail.colIndex < 0) tail.colIndex = check(tail.name, gs);
+//                    if ((other = columnMap.get(tail.getName())) == null || other.getMethod() == null) columnMap.put(tail.name, column);
+                    list.add(column);
                     continue;
                 }
             }
 
             column = createColumn(f);
             if (column != null) {
-                if (StringUtil.isEmpty(column.name)) column.name = gs;
+                ListSheet.EntryColumn tail = column.tail != null ? (ListSheet.EntryColumn) column.tail : column;
+                if (StringUtil.isEmpty(tail.name)) tail.name = gs;
                 if (method != null) {
-                    column.method = method;
-                    if (column.clazz == null) column.clazz = method.getParameterTypes()[0];
+                    tail.method = method;
+                    if (tail.clazz == null) tail.clazz = method.getParameterTypes()[0];
                 } else {
-                    column.field = f;
-                    if (column.clazz == null) column.clazz = declaredFields[i].getType();
+                    tail.field = f;
+                    if (tail.clazz == null) tail.clazz = declaredFields[i].getType();
                 }
-                if (column.colIndex < 0) column.colIndex = check(column.name, gs);
-                if ((other = columnMap.get(column.getName())) == null || other.getMethod() == null) columnMap.put(column.name, column);
+//                if (tail.colIndex < 0) tail.colIndex = check(tail.name, gs);
+//                if ((other = columnMap.get(tail.getName())) == null || other.getMethod() == null) columnMap.put(tail.name, column);
+                list.add(column);
             }
         }
 
@@ -178,24 +223,92 @@ public class HeaderRow extends Row {
             for (Map.Entry<String, Method> entry : otherColumns.entrySet()) {
                 column = createColumn(entry.getValue());
                 if (column == null) column = new ListSheet.EntryColumn(entry.getKey());
-                if (StringUtil.isEmpty(column.name)) column.name = entry.getKey();
-                column.method = entry.getValue();
-                if (column.colIndex < 0) column.colIndex = getIndex(column.name);
-                if (column.clazz == null) column.clazz = entry.getValue().getParameterTypes()[0];
+                ListSheet.EntryColumn tail = column.tail != null ? (ListSheet.EntryColumn) column.tail : column;
+                if (StringUtil.isEmpty(tail.name)) tail.name = entry.getKey();
+                tail.method = entry.getValue();
+                if (tail.clazz == null) tail.clazz = entry.getValue().getParameterTypes()[0];
+//                if (tail.colIndex < 0) tail.colIndex = getIndex(tail.name);
+//                if ((other = columnMap.get(tail.getName())) == null || other.getMethod() == null) columnMap.put(tail.name, column);
+                list.add(column);
+            }
+        }
 
-                // Check if exists
-                if ((other = columnMap.get(column.getName())) == null || other.getMethod() == null) columnMap.put(column.name, column);
+        // Merge cells
+        org.ttzero.excel.entity.Sheet listSheet = new ListSheet<Object>() {
+            @Override
+            public org.ttzero.excel.entity.Column[] getAndSortHeaderColumns() {
+                columns = new org.ttzero.excel.entity.Column[list.size()];
+                list.toArray(columns);
+                headerReady = true;
+
+                // Reverse
+                reverseHeadColumn();
+
+                // Add merge cell properties
+                mergeHeaderCellsIfEquals();
+
+                // Sort column index
+                sortColumns(columns);
+
+                // Turn to one-base
+                calculateRealColIndex();
+
+                return columns;
+            }
+        };
+        org.ttzero.excel.entity.Column[] columns = listSheet.getAndSortHeaderColumns();
+        @SuppressWarnings("unchecked")
+        List<Dimension> mergeCells = (List<Dimension>) listSheet.getExtPropValue(Const.ExtendPropertyKey.MERGE_CELLS);
+        // Ignore all vertical merged cells
+        mergeCells = mergeCells != null && !mergeCells.isEmpty() ? mergeCells.stream().filter(c -> c.firstColumn < c.lastColumn).collect(Collectors.toList()) : null;
+        if (mergeCells != null && !mergeCells.isEmpty()) {
+            Grid mergedGrid = GridFactory.create(mergeCells);
+            for (org.ttzero.excel.entity.Column c : columns) {
+                org.ttzero.excel.entity.Column[] sub = c.toArray();
+                for (int j = sub.length - 1, t; j >= 0; j--) {
+                    if (mergedGrid.test(t = sub.length - j, c.realColIndex) && isTopRow(mergeCells, t, c.realColIndex)) {
+                        Cell cell = new Cell((short) c.realColIndex);
+                        if (StringUtil.isNotEmpty(sub[j].getName())) cell.setSv(sub[j].getName());
+                        else cell.t = Cell.EMPTY_TAG;
+                        mergedGrid.merge(t, cell);
+                        sub[j].name = cell.sv;
+                    }
+                }
             }
         }
 
-        this.columns = columnMap.values().stream()
+        for (int i = 0, len = columns.length; i < len; i++) {
+            org.ttzero.excel.entity.Column c = columns[i];
+            if (c.tail != null) {
+                StringJoiner joiner = new StringJoiner(":");
+                org.ttzero.excel.entity.Column[] sub = c.toArray();
+                for (int j = sub.length - 1; j >= 0; j--) {
+                    if (StringUtil.isNotEmpty(sub[j].getName())) joiner.add(sub[j].getName());
+                }
+                c.name = joiner.toString();
+            } else if (!(c instanceof ListSheet.EntryColumn)) {
+                columns[i] = c = new ListSheet.EntryColumn(c);
+            }
+
+            if (c.colIndex < 0) c.colIndex = getIndex(c.name);
+        }
+
+        this.columns = Arrays.stream(columns)
                 .filter(c -> c.colIndex >= 0 || c.clazz == RowNum.class)
                 .sorted(Comparator.comparingInt(a -> a.colIndex))
+                .map(e -> (e instanceof ListSheet.EntryColumn) ? (ListSheet.EntryColumn) e : new ListSheet.EntryColumn(e))
                 .toArray(ListSheet.EntryColumn[]::new);
 
         return this;
     }
 
+    static boolean isTopRow(List<Dimension> mergeCells, int row, int col) {
+        for (Dimension dim : mergeCells) {
+            if (dim.checkRange(row, col) && row == dim.firstRow) return true;
+        }
+        return false;
+    }
+
     protected int check(String first, String second) {
         int n = getIndex(first);
         if (n == -1) n = getIndex(second);
@@ -224,7 +337,7 @@ public class HeaderRow extends Row {
     }
 
     @SuppressWarnings("unchecked")
-    protected <T> T getT() {
+    public <T> T getT() {
         return (T) t;
     }
 
@@ -248,6 +361,15 @@ public class HeaderRow extends Row {
         return names[columnIndex];
     }
 
+    /**
+     * Get the name of columns
+     *
+     * @return name of columns
+     */
+    public String[] getNames() {
+        return this.names;
+    }
+
     /**
      * Returns the position in cell range
      *
@@ -255,7 +377,7 @@ public class HeaderRow extends Row {
      * @return the position if found otherwise -1
      */
     public int getIndex(String columnName) {
-        Integer index = mapping.get(columnName);
+        Integer index = mapping != null ? mapping.get(columnName) : null;
         return index != null ? index : -1;
     }
 
@@ -579,17 +701,76 @@ public class HeaderRow extends Row {
         if (ignoreColumn(ao)) return null;
 
         ao.setAccessible(true);
-        ExcelColumn ec = ao.getAnnotation(ExcelColumn.class);
-        if (ec != null) {
-            ListSheet.EntryColumn column = new ListSheet.EntryColumn(ec.value());
-            column.setColIndex(ec.colIndex());
-            return column;
+        // Support multi header columns
+        ExcelColumns cs = ao.getAnnotation(ExcelColumns.class);
+        if (cs != null) {
+            ExcelColumn[] ecs = cs.value();
+            ListSheet.EntryColumn root = null;
+            for (int i = Math.max(0, ecs.length - headRows); i < ecs.length; i++) {
+                ListSheet.EntryColumn column = createColumnByAnnotation(ecs[i]);
+                if (root == null) {
+                    root = column;
+                } else {
+                    root.addSubColumn(column);
+                }
+            }
+            return root;
         }
+        // Single header column
+        ExcelColumn ec = ao.getAnnotation(ExcelColumn.class);
+        if (ec != null) return createColumnByAnnotation(ec);
         // Row Num
         RowNum rowNum = ao.getAnnotation(RowNum.class);
-        if (rowNum != null) {
-            return new ListSheet.EntryColumn(EMPTY, RowNum.class);
-        }
+        if (rowNum != null) return createColumnByAnnotation(rowNum);
         return null;
     }
+
+    /**
+     * Create column by {@code ExcelColumn} annotation
+     *
+     * @param anno an java annotation
+     * @return {@link org.ttzero.excel.entity.Column} or null if annotation is null
+     */
+    protected ListSheet.EntryColumn createColumnByAnnotation(Annotation anno) {
+        ListSheet.EntryColumn column = null;
+        if (anno instanceof ExcelColumn) {
+            ExcelColumn ec = (ExcelColumn) anno;
+            column = new ListSheet.EntryColumn(ec.value());
+            column.setColIndex(ec.colIndex());
+            // Hidden Column
+            if (ec.hide()) column.hide();
+        } else if (anno instanceof RowNum) {
+            column = new ListSheet.EntryColumn(EMPTY, RowNum.class);
+        }
+        return column;
+    }
+
+    /**
+     * Copy column name on merge cells
+     *
+     * @param mergeCells merged cell in header rows
+     * @param rows the header rows
+     */
+    protected void mergeCellsIfNull(List<Dimension> mergeCells, Row[] rows) {
+        if (mergeCells == null) return;
+        mergeCells = mergeCells.stream().filter(d -> d.getWidth() > 1).collect(Collectors.toList());
+        if (mergeCells.isEmpty()) return;
+
+        Map<Long, Dimension> map = mergeCells.stream().collect(Collectors.toMap(a -> ((long) a.firstRow) << 16 | a.firstColumn, a -> a, (a, b) -> a));
+
+        for (Row row : rows) {
+            for (int i = row.fc; i < row.lc; ) {
+                Cell cell = row.cells[i];
+                Dimension d = map.get(((long) row.getRowNum()) << 16 | cell.i);
+                if (d != null) {
+                    String v = row.getString(cell);
+                    if (d.lastColumn > row.lc) {
+                        row.cells = row.copyCells(d.lastColumn);
+                        row.lc = d.lastColumn;
+                    }
+                    for (int j = d.firstColumn + 1; j <= d.lastColumn; j++) row.cells[++i].setSv(v);
+                } else i++;
+            }
+        }
+    }
 }
diff --git a/src/main/java/org/ttzero/excel/reader/Row.java b/src/main/java/org/ttzero/excel/reader/Row.java
index 630ef237..79c43e21 100644
--- a/src/main/java/org/ttzero/excel/reader/Row.java
+++ b/src/main/java/org/ttzero/excel/reader/Row.java
@@ -62,9 +62,9 @@ public abstract class Row {
     protected final Logger LOGGER = LoggerFactory.getLogger(getClass());
     // Index to row
     protected int index = -1;
-    // Index to first column (zero base)
+    // Index to first column (zero base, inclusive)
     protected int fc = 0;
-    // Index to last column (zero base)
+    // Index to last column (zero base, exclusive)
     protected int lc = -1;
     // Share cell
     protected Cell[] cells;
@@ -1426,6 +1426,70 @@ public abstract class Row {
         return sharedCalc[i].get(coordinate);
     }
 
+    /**
+     * Returns deep clone cells
+     *
+     * @return cells
+     */
+    public Cell[] copyCells() {
+        return copyCells(cells.length);
+    }
+
+    /**
+     * Returns deep clone cells
+     *
+     * @param newLength the length of the copy to be returned
+     * @return cells
+     */
+    public Cell[] copyCells(int newLength) {
+        Cell[] newCells = new Cell[newLength];
+        int oldRow = cells.length;
+        for (int k = 0; k < newLength; k++) {
+            newCells[k] = new Cell((short) (k + 1));
+            // Copy values
+            if (k < oldRow && cells[k] != null) {
+                newCells[k].from(cells[k]);
+            }
+        }
+        return newCells;
+    }
+
+    /**
+     * Setting custom {@link Cell}
+     *
+     * @param cells row cells
+     * @return current Row
+     */
+    public Row setCells(Cell[] cells) {
+        this.cells = cells;
+        this.fc = 0;
+        this.lc = cells.length;
+        return this;
+    }
+
+    /**
+     * Setting custom {@link Cell}
+     *
+     * @param cells row cells
+     * @param fromIndex specify the first cells index(one base)
+     * @param toIndex specify the last cells index(one base)
+     * @return current Rows
+     */
+    public Row setCells(Cell[] cells, int fromIndex, int toIndex) {
+        if (fromIndex < 0)
+            throw new IndexOutOfBoundsException("fromIndex = " + fromIndex);
+        if (toIndex > cells.length)
+            throw new IndexOutOfBoundsException("toIndex = " + toIndex);
+        if (fromIndex > toIndex)
+            throw new IllegalArgumentException("fromIndex(" + fromIndex +
+                ") > toIndex(" + toIndex + ")");
+
+        this.cells = cells;
+        this.fc = fromIndex;
+        this.lc = toIndex;
+        return this;
+    }
+
     /**
      * Convert to column index
      *
diff --git a/src/main/java/org/ttzero/excel/reader/Sheet.java b/src/main/java/org/ttzero/excel/reader/Sheet.java
index 0e164b83..49c79a5c 100644
--- a/src/main/java/org/ttzero/excel/reader/Sheet.java
+++ b/src/main/java/org/ttzero/excel/reader/Sheet.java
@@ -107,9 +107,48 @@ public interface Sheet extends Closeable {
         return !isHidden();
     }
 
+    /**
+     * Specify the header rows endpoint
+     *
+     * @param fromRowNum low endpoint (inclusive) of the worksheet (one base)
+     * @return current {@link Sheet}
+     * @throws IndexOutOfBoundsException if {@code fromRow} less than 1
+     */
+    default Sheet header(int fromRowNum) {
+        return header(fromRowNum, fromRowNum);
+    }
+
+    /**
+     * Specify the header rows endpoint
+     * <p>
+     * Note: After specifying the header row number, the row-pointer will move to the
+     * next row of the header range. The {@link #bind(Class)}, {@link #bind(Class, int)},
+     * {@link #bind(Class, int, int)}, {@link #rows()}, {@link #dataRows()}, {@link #iterator()},
+     * and {@link #dataIterator()} will all be affected.
+     *
+     * @param fromRowNum low endpoint (inclusive) of the worksheet (one base)
+     * @param toRowNum high endpoint (inclusive) of the worksheet (one base)
+     * @return current {@link Sheet}
+     * @throws IndexOutOfBoundsException if {@code fromRow} less than 1
+     * @throws IllegalArgumentException if {@code toRow} less than {@code fromRow}
+     */
+    Sheet header(int fromRowNum, int toRowNum);
+
     /**
      * Returns the header of the list.
-     * The first non-empty line defaults to the header information.
+     *
+     * The first non-empty line defaults to the header information. You can also call {@link #header(int, int)}
+     * to specify multiple header rows. If there are multiple rows of headers, ':' will be used for stitching.
+     *
+     * <blockquote><pre>
+     * +-----------------------------+
+     * |       |        COMMON       |
+     * | TITLE +-------+------+------+
+     * |       |  SUB1 | SUB2 | SUB3 |
+     * +------+-------+-------+------+
+     * </pre></blockquote>
+     *
+     * The above table will return "TITLE", "COMMON:SUB1", "COMMON:SUB2", "COMMON:SUB3"
      *
      * @return the {@link HeaderRow}
      */
@@ -123,6 +162,38 @@ public interface Sheet extends Closeable {
      */
     Sheet bind(Class<?> clazz);
 
+    /**
+     * Set the binding type
+     *
+     * @param clazz the binding type
+     * @param fromRowNum low endpoint (inclusive) of the worksheet (one base)
+     * @return the {@link Sheet}
+     */
+    default Sheet bind(Class<?> clazz, int fromRowNum) {
+        return bind(clazz, header(fromRowNum).getHeader());
+    }
+
+    /**
+     * Set the binding type
+     *
+     * @param clazz the binding type
+     * @param fromRowNum low endpoint (inclusive) of the worksheet (one base)
+     * @param toRowNum high endpoint (inclusive) of the worksheet (one base)
+     * @return the {@link Sheet}
+     */
+    default Sheet bind(Class<?> clazz, int fromRowNum, int toRowNum) {
+        return bind(clazz, header(fromRowNum, toRowNum).getHeader());
+    }
+
+    /**
+     * Set the binding type
+     *
+     * @param clazz the binding type
+     * @param row specify a custom header row
+     * @return the {@link Sheet}
+     */
+    Sheet bind(Class<?> clazz, Row row);
+
     /**
      * Load the sheet data
      *
@@ -316,6 +387,13 @@ interface MergeSheet extends Sheet {
      * Returns CellMerged info
      */
     Grid getMergeGrid();
+
+    /**
+     * Returns all merged cells
+     *
+     * @return If no merged cells are returned, Null is returned
+     */
+    List<Dimension> getMergeCells();
 }
 
 //interface FullSheet extends CalcSheet, MergeSheet { }
diff --git a/src/main/java/org/ttzero/excel/reader/XMLRow.java b/src/main/java/org/ttzero/excel/reader/XMLRow.java
index 2845b659..9cf2d335 100644
--- a/src/main/java/org/ttzero/excel/reader/XMLRow.java
+++ b/src/main/java/org/ttzero/excel/reader/XMLRow.java
@@ -25,6 +25,7 @@ import static org.ttzero.excel.reader.Cell.NUMERIC;
 import static org.ttzero.excel.reader.Cell.FUNCTION;
 import static org.ttzero.excel.reader.Cell.SST;
 import static org.ttzero.excel.reader.Cell.INLINESTR;
+import static org.ttzero.excel.reader.Cell.UNALLOCATED;
 import static org.ttzero.excel.reader.SharedStrings.toInt;
 import static org.ttzero.excel.reader.SharedStrings.unescape;
 import static org.ttzero.excel.util.StringUtil.swap;
@@ -213,7 +214,7 @@ public class XMLRow extends Row {
                         throw new TooManyColumnsException(i, Const.Limit.MAX_COLUMNS_ON_SHEET);
                     }
                     // Resize cell buffer
-                    cells = cellCopyOf(Math.min(i + 99, Const.Limit.MAX_COLUMNS_ON_SHEET));
+                    cells = copyCells(Math.min(i + 99, Const.Limit.MAX_COLUMNS_ON_SHEET));
                 }
                 cell = cells[i - 1];
             }
@@ -289,19 +290,6 @@ public class XMLRow extends Row {
         return true;
     }
 
-    protected Cell[] cellCopyOf(int newLength) {
-        Cell[] newCells = new Cell[newLength];
-        int oldRow = cells.length;
-        for (int k = 0; k < newLength; k++) {
-            newCells[k] = new Cell((short) (k + 1));
-            // Copy values
-            if (k < oldRow && cells[k] != null) {
-                newCells[k].from(cells[k]);
-            }
-        }
-        return newCells;
-    }
-
     /* Found specify target  */
     protected int get(char c) {
         // Ignore all attributes
@@ -415,6 +403,7 @@ public class XMLRow extends Row {
                     cell.blank(); // Reset type to BLANK if null value
                 }
                 break;
+            case UNALLOCATED: return; // Not [break] header
             default:
                 a = getV();
                 if (a < cursor) {
@@ -635,7 +624,9 @@ class XMLCalcRow extends XMLRow {
  */
 class XMLMergeRow extends XMLRow {
     // InterfaceFunction
-    private MergeValueFunc func;
+    protected MergeValueFunc func;
+    // A merge cells grid
+    protected Grid mergeCells;
 
     XMLMergeRow(XMLRow row) {
         this.sst = row.sst;
@@ -644,11 +635,46 @@ class XMLMergeRow extends XMLRow {
         this.buf = row.buf;
     }
 
-    XMLMergeRow setCopyValueFunc(MergeValueFunc func) {
+    XMLMergeRow setCopyValueFunc(Grid mergeCells, MergeValueFunc func) {
+        this.mergeCells = mergeCells;
         this.func = func;
         return this;
     }
 
+
+    /**
+     * Loop parse cell
+     */
+    @Override
+    protected void parseCells() {
+        cursor = searchSpan();
+        for (; cb[cursor++] != '>'; ) ;
+        unknownLength = lc < 0;
+
+        // Parse cell value
+        int i = 1, r = getRowNum();
+        for (Cell cell; (cell = nextCell()) != null; ) {
+            if (cell.i > i) for (; i < cell.i; i++) parseCellValue(cells[i - 1]);
+            parseCellValue(cell);
+            i++;
+        }
+
+        /*
+         Some tools handle merged cells that ignore all cells
+          in the merged range except for the first one,
+          so compatibility is required here for cells that are outside the spans range
+         */
+        for (; mergeCells.test(r, i); i++) {
+            if (lc < i) {
+                // Give a new cells
+                if (cells.length < i) cells = copyCells(i);
+                lc = i;
+            }
+            parseCellValue(cells[i - 1]);
+        }
+    }
+
+
     /**
      * Parse cell value
      *
diff --git a/src/main/java/org/ttzero/excel/reader/XMLSheet.java b/src/main/java/org/ttzero/excel/reader/XMLSheet.java
index 63dd9602..d65f7d45 100644
--- a/src/main/java/org/ttzero/excel/reader/XMLSheet.java
+++ b/src/main/java/org/ttzero/excel/reader/XMLSheet.java
@@ -34,6 +34,7 @@ import java.util.Iterator;
 import java.util.List;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
+import java.util.stream.Collectors;
 
 /**
  * The open-xml format Worksheet
@@ -66,6 +67,8 @@ public class XMLSheet implements Sheet {
         this.mark = sheet.mark;
         this.sRow = sheet.sRow;
         this.lastRowMark = sheet.lastRowMark;
+        this.hrf = sheet.hrf;
+        this.hrl = sheet.hrl;
     }
 
     protected String name;
@@ -89,6 +92,8 @@ public class XMLSheet implements Sheet {
     protected Dimension dimension;
     // XMLDrawings
     protected Drawings drawings;
+    // Header row
+    protected int hrf, hrl;
 
 
     /**
@@ -225,6 +230,28 @@ public class XMLSheet implements Sheet {
         return hidden;
     }
 
+    /**
+     * Specify the header rows endpoint
+     * <p>
+     * Note: After specifying the header row number, the row-pointer will move to the
+     * next row of the header range. The {@link #bind(Class)}, {@link #bind(Class, int)},
+     * {@link #bind(Class, int, int)}, {@link #rows()}, {@link #dataRows()}, {@link #iterator()},
+     * and {@link #dataIterator()} will all be affected.
+     *
+     * @param fromRowNum low endpoint (inclusive) of the worksheet (one base)
+     * @param toRowNum high endpoint (inclusive) of the worksheet (one base)
+     * @return current {@link Sheet}
+     * @throws IndexOutOfBoundsException if {@code fromRow} less than 1
+     * @throws IllegalArgumentException if {@code toRow} less than {@code fromRow}
+     */
+    @Override
+    public Sheet header(int fromRowNum, int toRowNum) {
+        rangeCheck(fromRowNum, toRowNum);
+        this.hrf = fromRowNum;
+        this.hrl = toRowNum;
+        return this;
+    }
+
     /**
      * Set Worksheet state
      */
@@ -242,15 +269,76 @@ public class XMLSheet implements Sheet {
     @Override
     public Row getHeader() {
         if (header == null && !heof) {
-            Row row = findRow0(this::createHeader);
+            Row row = hrf == 0 ? findRow0(this::createHeader) : getHeader(hrf, hrl);
             if (row != null) {
-                header = row.asHeader();
+                header = row instanceof HeaderRow ? (HeaderRow) row : row.asHeader();
                 sRow.setHr(header);
             }
+        } else if (hrl > 0 && hrl > sRow.index) {
+            for (Row row = nextRow(); row != null && row.getRowNum() < hrl; row = nextRow()) ;
         }
         return header;
     }
 
+    protected Row getHeader(int fromRowNum, int toRowNum) {
+        if (header != null) return header;
+        rangeCheck(fromRowNum, toRowNum);
+        if (sRow.index > -1 && fromRowNum < sRow.index)
+            throw new IndexOutOfBoundsException("Current row num " + sRow.index + " is great than fromRowNum " + fromRowNum + ". Use Sheet#reset() to reset cursor.");
+        // Mutable header rows
+        if (toRowNum - fromRowNum > 0) {
+            Row[] rows = new Row[toRowNum - fromRowNum + 1];
+            int i = 0, lc = -1;
+            boolean changeLc = false;
+            for (Row row = nextRow(); row != null; row = nextRow()) {
+                if (row.getRowNum() >= fromRowNum) {
+                    if (row.lc > lc) {
+                        lc = row.lc;
+                        if (i > 0) changeLc = true;
+                    }
+                    Row r = new Row() { };
+                    r.fc = row.fc;
+                    r.lc = row.lc;
+                    r.index = row.index;
+                    r.sst = row.sst;
+                    r.cells = row.copyCells();
+                    rows[i++] = r;
+                }
+                if (row.getRowNum() >= toRowNum) break;
+            }
+            if (changeLc) {
+                for (Row row : rows) {
+                    row.lc = lc;
+                    row.cells = row.copyCells(lc);
+                }
+            }
+
+            // Parse merged cells
+            XMLMergeSheet tmp = new XMLSheet(this).asMergeSheet();
+            tmp.reader = null; // Prevent streams from being consumed by mistake
+            List<Dimension> mergeCells = tmp.parseMerge();
+            if (mergeCells != null) {
+                mergeCells = mergeCells.stream().filter(dim -> dim.firstRow < toRowNum || dim.lastRow > fromRowNum).collect(Collectors.toList());
+            }
+
+            return new HeaderRow().with(mergeCells, rows);
+        }
+        // Single row
+        else {
+            Row row = nextRow();
+            for (; row != null && row.getRowNum() < fromRowNum; row = nextRow());
+            return new HeaderRow().with(row);
+        }
+    }
+
+    // Range check
+    static void rangeCheck(int fromRowNum, int toRowNum) {
+        if (fromRowNum <= 0)
+            throw new IndexOutOfBoundsException("fromIndex = " + fromRowNum);
+        if (fromRowNum > toRowNum)
+            throw new IllegalArgumentException("fromIndex(" + fromRowNum + ") > toIndex(" + toRowNum + ")");
+    }
+
     /**
      * Set the binding type
      *
@@ -269,6 +357,21 @@ public class XMLSheet implements Sheet {
         return this;
     }
 
+    @Override
+    public Sheet bind(Class<?> clazz, Row row) {
+        if (row == null) throw new IllegalArgumentException("Specify the bind row must not be null.");
+        if (!row.equals(header)) {
+            header = row instanceof HeaderRow ? (HeaderRow) row : row.asHeader();
+            sRow.setHr(header);
+        }
+        try {
+            header.setClassOnce(clazz);
+        } catch (IllegalAccessException | InstantiationException e) {
+            throw new ExcelReadException(e);
+        }
+        return this;
+    }
+
     @Override
     public String toString() {
         return "Sheet id: " + getId() + ", name: " + getName() + ", dimension: " + getDimension();
@@ -281,6 +384,7 @@ public class XMLSheet implements Sheet {
     protected boolean eof = false, heof = false; // OPTIONS = false
     protected long mark;
 
+    // Shared row data, Record the current row
     protected XMLRow sRow;
     protected long lastRowMark;
 
@@ -503,6 +607,8 @@ public class XMLSheet implements Sheet {
      */
     @Override
     public Iterator<Row> iterator() {
+        // If the header row number is specified, the header will be parsed first
+        if (hrf > 0) getHeader();
         return new RowSetIterator(this::nextRow, false);
     }
 
@@ -513,9 +619,15 @@ public class XMLSheet implements Sheet {
      */
     @Override
     public Iterator<Row> dataIterator() {
+        // If the header row number is specified, the header will be parsed first
+        if (hrf > 0) getHeader();
         // iterator data rows
         Iterator<Row> nIter = new RowSetIterator(this::nextRow, true);
-        if (nIter.hasNext()) {
+        /*
+        If the header is not specified, the first row will be automatically
+         used as the header, if there is a header, the row will not be skipped
+         */
+        if (hrf == 0 && nIter.hasNext()) {
             Row row = nIter.next();
             if (header == null) header = row.asHeader();
             row.setHr(header);
@@ -556,6 +668,11 @@ public class XMLSheet implements Sheet {
     public XMLSheet reset() {
         LOGGER.debug("Reset {}", path.toString());
         try {
+            hrf = 0;
+            hrl = 0;
+            header = null;
+            sRow.fc = 0;
+            sRow.index = sRow.lc = -1;
             // Close the opening reader
             if (reader != null) {
                 reader.close();
@@ -913,11 +1030,16 @@ class XMLMergeSheet extends XMLSheet implements MergeSheet {
     void load0() {
         if (ready) return;
         if (mergeCells == null && !eof) {
-            parseMerge();
+            List<Dimension> mergeCells = parseMerge();
+
+            if (mergeCells != null && !mergeCells.isEmpty()) {
+                this.mergeCells = GridFactory.create(mergeCells);
+                LOGGER.debug("Grid: Size: {} ==> {}", this.mergeCells.size(), this.mergeCells);
+            }
         }
 
         if (!eof && !(sRow instanceof XMLMergeRow) && mergeCells != null) {
-            sRow = sRow.asMergeRow().setCopyValueFunc(this::mergeCell);
+            sRow = sRow.asMergeRow().setCopyValueFunc(mergeCells, this::mergeCell);
         }
         ready = true;
     }
@@ -925,7 +1047,7 @@ class XMLMergeSheet extends XMLSheet implements MergeSheet {
     /*
     Parse `mergeCells` tag
      */
-    void parseMerge() {
+    List<Dimension> parseMerge() {
         try (SeekableByteChannel channel = Files.newByteChannel(path, StandardOpenOption.READ)) {
             long position = Files.size(path);
             final int block = (int) Math.min(1 << 11, position), c = 12;
@@ -938,7 +1060,7 @@ class XMLMergeSheet extends XMLSheet implements MergeSheet {
                 channel.position(lastRowMark);
                 channel.read(buffer);
                 buffer.flip();
-                getit = true; // Unknown
+                getit = true;
             } else {
                 left = new byte[12];
                 for (; ; ) {
@@ -985,16 +1107,17 @@ class XMLMergeSheet extends XMLSheet implements MergeSheet {
 
             if (getit) {
                 // Find mergeCells tag
-                parseMerge(channel, buffer, block);
+                return parseMerge(channel, buffer, block);
             }
         } catch (IOException e) {
             // Ignore error
             LOGGER.warn("", e);
         }
+        return null;
     }
 
     // Find mergeCell tags
-    void parseMerge(SeekableByteChannel channel, ByteBuffer buffer, int block) throws IOException {
+    List<Dimension> parseMerge(SeekableByteChannel channel, ByteBuffer buffer, int block) throws IOException {
         List<Dimension> mergeCells = new ArrayList<>();
         boolean eof = false;
         int limit = buffer.limit(), i = buffer.position(), f, n;
@@ -1046,10 +1169,7 @@ class XMLMergeSheet extends XMLSheet implements MergeSheet {
             }
         }
 
-        if (!mergeCells.isEmpty()) {
-            this.mergeCells = GridFactory.create(mergeCells);
-            LOGGER.debug("Grid: Size: {} ==> {}", this.mergeCells.size(), this.mergeCells);
-        }
+        return mergeCells;
     }
 
     private void mergeCell(int row, Cell cell) {
@@ -1060,4 +1180,9 @@ class XMLMergeSheet extends XMLSheet implements MergeSheet {
     public Grid getMergeGrid() {
         return mergeCells;
     }
+
+    @Override
+    public List<Dimension> getMergeCells() {
+        return parseMerge();
+    }
 }
\ No newline at end of file


Test patch:
diff --git a/src/test/java/org/ttzero/excel/entity/ListMapSheetTest.java b/src/test/java/org/ttzero/excel/entity/ListMapSheetTest.java
index fe1ad14b..3a1596b7 100644
--- a/src/test/java/org/ttzero/excel/entity/ListMapSheetTest.java
+++ b/src/test/java/org/ttzero/excel/entity/ListMapSheetTest.java
@@ -264,7 +264,7 @@ public class ListMapSheetTest extends WorkbookTest {
         Map<String, Object> data2 = new HashMap<>();
         data2.put("id", 2);
         data2.put("name", "xyz");
-        new Workbook()
+        new Workbook("ListMapSheet Array Map")
             .watch(Print::println)
             .addSheet(new ListMapSheet().setData(Arrays.asList(data1, data2)))
             .writeTo(defaultTestPath);
@@ -275,7 +275,7 @@ public class ListMapSheetTest extends WorkbookTest {
         data.put("id", 1);
         data.put("name", "abc");
 
-        new Workbook()
+        new Workbook("ListMapSheet Single List Map")
             .watch(Print::println)
             .addSheet(new ListMapSheet().setData(Collections.singletonList(data)))
             .writeTo(defaultTestPath);
diff --git a/src/test/java/org/ttzero/excel/entity/ListObjectSheetTest.java b/src/test/java/org/ttzero/excel/entity/ListObjectSheetTest.java
index e243406f..c12adcca 100644
--- a/src/test/java/org/ttzero/excel/entity/ListObjectSheetTest.java
+++ b/src/test/java/org/ttzero/excel/entity/ListObjectSheetTest.java
@@ -20,16 +20,21 @@ import org.junit.Test;
 import org.ttzero.excel.Print;
 import org.ttzero.excel.annotation.ExcelColumn;
 import org.ttzero.excel.annotation.IgnoreExport;
+import org.ttzero.excel.entity.e7.XMLWorksheetWriter;
 import org.ttzero.excel.entity.style.Fill;
 import org.ttzero.excel.entity.style.Font;
+import org.ttzero.excel.entity.style.Horizontals;
 import org.ttzero.excel.entity.style.PatternType;
 import org.ttzero.excel.entity.style.Styles;
+import org.ttzero.excel.manager.Const;
 import org.ttzero.excel.manager.docProps.Core;
 import org.ttzero.excel.processor.ConversionProcessor;
 import org.ttzero.excel.processor.StyleProcessor;
+import org.ttzero.excel.reader.Cell;
 import org.ttzero.excel.reader.Dimension;
 import org.ttzero.excel.reader.ExcelReader;
 import org.ttzero.excel.reader.ExcelReaderTest;
+import org.ttzero.excel.util.StringUtil;
 
 import java.awt.Color;
 import java.beans.IntrospectionException;
@@ -54,6 +59,18 @@ import java.util.function.Supplier;
 import java.util.stream.Collectors;
 
 import static org.ttzero.excel.Print.println;
+import static org.ttzero.excel.reader.Cell.BLANK;
+import static org.ttzero.excel.reader.Cell.BOOL;
+import static org.ttzero.excel.reader.Cell.CHARACTER;
+import static org.ttzero.excel.reader.Cell.DATE;
+import static org.ttzero.excel.reader.Cell.DATETIME;
+import static org.ttzero.excel.reader.Cell.DECIMAL;
+import static org.ttzero.excel.reader.Cell.DOUBLE;
+import static org.ttzero.excel.reader.Cell.INLINESTR;
+import static org.ttzero.excel.reader.Cell.LONG;
+import static org.ttzero.excel.reader.Cell.NUMERIC;
+import static org.ttzero.excel.reader.Cell.SST;
+import static org.ttzero.excel.reader.Cell.TIME;
 import static org.ttzero.excel.reader.ExcelReaderTest.testResourceRoot;
 
 /**
@@ -300,7 +317,7 @@ public class ListObjectSheetTest extends WorkbookTest {
     }
 
     @Test public void testArray() throws IOException {
-        new Workbook()
+        new Workbook("ListSheet Array as List")
             .watch(Print::println)
             .addSheet(new ListSheet<>()
                 .setData(Arrays.asList(new Item(1, "abc"), new Item(2, "xyz"))))
@@ -308,7 +325,7 @@ public class ListObjectSheetTest extends WorkbookTest {
     }
 
     @Test public void testSingleList() throws IOException {
-        new Workbook()
+        new Workbook("ListSheet Single List")
             .watch(Print::println)
             .addSheet(new ListSheet<>()
                 .setData(Collections.singletonList(new Item(1, "a b c"))))
@@ -521,7 +538,7 @@ public class ListObjectSheetTest extends WorkbookTest {
 
     // #132
     @Test public void testEmptyList() throws IOException {
-        new Workbook().addSheet(new ListSheet<>(new ArrayList<>())).writeTo(defaultTestPath);
+        new Workbook("ListObject empty list").addSheet(new ListSheet<>(new ArrayList<>())).writeTo(defaultTestPath);
     }
     
     @Test public void testNoForceExport() throws IOException {
@@ -1308,4 +1325,310 @@ public class ListObjectSheetTest extends WorkbookTest {
         }
     }
 
+    public static class Template {
+        @ExcelColumn(maxWidth = 12.0D, wrapText = true)
+        String v1;
+        @ExcelColumn(maxWidth = 20.0, wrapText = true)
+        String v2;
+        @ExcelColumn(maxWidth = 25.0D, wrapText = true)
+        String v3;
+
+        static Template of(String v1, String v2, String v3) {
+            Template v = new Template();
+            v.v1 = v1;
+            v.v2 = v2;
+            v.v3 = v3;
+            return v;
+        }
+    }
+
+    @Test public void testCustomerRowHeight() throws IOException {
+        List<Template> list = new ArrayList<>();
+        list.add(Template.of("备注说明\r\n第二行\r\n第三行\r\n第四行", "岗位名称", "岁位"));
+        list.add(Template.of("字段名称", "*岗位名称", "岗位描述"));
+        list.add(Template.of("示例", "生产统计员", "按照产品规格、价格、工序、员工、车间等不同对象和要求进行统计数据资料分析"));
+
+        new Workbook("Customer row height").addSheet(
+            new ListSheet<>(list).setStyleProcessor(new TemplateStyleProcessor())
+                .cancelOddStyle().ignoreHeader().putExtProp(Const.ExtendPropertyKey.MERGE_CELLS, Collections.singletonList(Dimension.of("A1:B1")))
+                .setSheetWriter(new XMLWorksheetWriter() {
+                    protected int startRow(int rows, int columns) throws IOException {
+                        // Row number
+                        int r = rows + startRow;
+                        // logging
+                        if (r % 1_0000 == 0) {
+                            sheet.what("0014", String.valueOf(r));
+                        }
+
+                        bw.write("<row r=\"");
+                        bw.writeInt(r);
+                        // default data row height 16.5
+                        bw.write("\" spans=\"1:");
+                        bw.writeInt(columns);
+                        bw.write("\" ht=\"62.25\" customHeight=\"1\">");
+                        return r;
+                    }
+                })
+        ).writeTo(defaultTestPath);
+    }
+
+    public static class TemplateStyleProcessor implements StyleProcessor<Template> {
+        String k;
+        int c = 0;
+        @Override
+        public int build(Template o, int style, Styles sst) {
+            if (!o.v1.equals(k)) {
+                k = o.v1;
+                c = 0;
+            }
+            switch (o.v1) {
+                case "备注说明":
+                    if (c > 0)
+                        style = Styles.clearFill(style) | sst.addFill(new Fill(PatternType.solid, new Color(188, 219, 162)));
+                    break;
+                case "字段名称":
+                    Font font = sst.getFont(style);
+                    style = Styles.clearFont(style) | sst.addFont(font.clone().bold());
+                    if (c > 0)
+                        style = Styles.clearHorizontal(style) | Horizontals.CENTER;
+                    break;
+                case "示例":
+                    if (c == 1)
+                        style = Styles.clearHorizontal(style) | Horizontals.CENTER;
+                    break;
+            }
+            c++;
+            return style;
+        }
+    }
+
+
+    @Test public void testTileWriter() throws IOException {
+        List<TileEntity> data = TileEntity.randomTestData();
+        new Workbook("Dynamic title").cancelOddFill().addSheet(new ListSheet<>(data).setSheetWriter(new TileXMLWorksheetWriter(3, LocalDate.now().toString()))).writeTo(defaultTestPath);
+    }
+
+    public static class TileEntity {
+        @ExcelColumn("{date} 拣货单")
+        @ExcelColumn(value = "差异", maxWidth = 8.6D)
+        private String diff;
+        @ExcelColumn("{date} 拣货单")
+        @ExcelColumn(value = "序号", maxWidth = 6.8D)
+        private Integer no;
+        @ExcelColumn("{date} 拣货单")
+        @ExcelColumn(value = "商品", maxWidth = 12.0D)
+        private String product;
+        @ExcelColumn("{date} 拣货单")
+        @ExcelColumn(value = "数量", maxWidth = 6.8D)
+        private Integer num;
+
+        public static List<TileEntity> randomTestData() {
+            int n = 23;
+            List<TileEntity> list = new ArrayList<>(n);
+            for (int i = 0; i < n; i++) {
+                TileEntity e = new TileEntity();
+                e.no = i + 1;
+                e.product = getRandomString(10);
+                e.num = random.nextInt(20) + 1;
+                list.add(e);
+            }
+            return list;
+        }
+    }
+
+    /**
+     * 自定义平铺WorksheetWriter
+     */
+    public static class TileXMLWorksheetWriter extends XMLWorksheetWriter {
+        private int tile; // 平铺的数量，也就是每行重复输出多少条数据
+        private String date; // 可忽略，仅仅是表头上的日期
+
+        public TileXMLWorksheetWriter(int tile) {
+            this.tile = tile;
+        }
+
+        public TileXMLWorksheetWriter(int tile, String date) {
+            this.tile = tile;
+            this.date = date;
+        }
+
+        public int getTile() {
+            return tile;
+        }
+
+        public void setTile(int tile) {
+            this.tile = tile;
+        }
+
+        public String getDate() {
+            return date;
+        }
+
+        public void setDate(String date) {
+            this.date = date;
+        }
+
+        @Override
+        protected void writeBefore() throws IOException {
+            // The header columns
+            columns = sheet.getAndSortHeaderColumns();
+            // Give new columns
+            tileColumns();
+
+            boolean nonHeader = sheet.getNonHeader() == 1;
+
+            bw.write(Const.EXCEL_XML_DECLARATION);
+            // Declaration
+            bw.newLine();
+            // Root node
+            writeRootNode();
+
+            // Dimension
+            writeDimension();
+
+            // SheetViews default value
+            writeSheetViews();
+
+            // Default row height and width
+            int fillSpace = 6;
+            BigDecimal width = BigDecimal.valueOf(!nonHeader ? sheet.getDefaultWidth() : 8.38D);
+            String defaultWidth = width.setScale(2, BigDecimal.ROUND_HALF_UP).toString();
+            writeSheetFormat(fillSpace, defaultWidth);
+
+            // cols
+            writeCols(fillSpace, defaultWidth);
+
+            // Write body data
+            beforeSheetDate(nonHeader);
+        }
+
+        protected void tileColumns() {
+            if (tile == 1) return;
+
+            int x = columns.length, y = x * tile, t = columns[columns.length - 1].getRealColIndex();
+            // Bound check
+            if (y > Const.Limit.MAX_COLUMNS_ON_SHEET)
+                throw new TooManyColumnsException(y, Const.Limit.MAX_COLUMNS_ON_SHEET);
+
+            Column[] _columns = new Column[y];
+            for (int i = 0; i < y; i++) {
+                // 第一个对象的表头不需要复制
+                Column col = i < x ? columns[i] : new Column(columns[i % x]).addSubColumn(new Column());
+                col.realColIndex = columns[i % x].realColIndex + t * (i / x);
+                _columns[i] = col;
+
+                // 替换拣货单上的日期
+                Column _col = col;
+                do {
+                    if (StringUtil.isNotEmpty(_col.getName()) && _col.getName().contains("{date}"))
+                        _col.setName(_col.getName().replace("{date}", date));
+                }
+                while ((_col = _col.next) != null);
+            }
+
+            this.columns = _columns;
+
+            // FIXME 这里强行指定合并替换掉原本的头
+            List<Dimension> mergeCells = Collections.singletonList(new Dimension(1, (short) 1, 1, (short) y));
+            sheet.putExtProp(Const.ExtendPropertyKey.MERGE_CELLS, mergeCells);
+        }
+
+        @Override
+        protected void writeRow(Row row) throws IOException {
+            Cell[] cells = row.getCells();
+            int len = cells.length, r = row.getIndex() / tile + startRow, c = columns[columns.length - 1].realColIndex / tile, y = row.getIndex() % tile;
+            if (y == 0) startRow(r - startRow, columns[columns.length - 1].realColIndex);
+
+            for (int i = 0; i < len; i++) {
+                Cell cell = cells[i];
+                int xf = cell.xf, col = i + c * y;
+                switch (cell.t) {
+                    case INLINESTR:
+                    case SST:
+                        writeString(cell.sv, r, col, xf);
+                        break;
+                    case NUMERIC:
+                        writeNumeric(cell.nv, r, col, xf);
+                        break;
+                    case LONG:
+                        writeNumeric(cell.lv, r, col, xf);
+                        break;
+                    case DATE:
+                    case DATETIME:
+                    case DOUBLE:
+                    case TIME:
+                        writeDouble(cell.dv, r, col, xf);
+                        break;
+                    case BOOL:
+                        writeBool(cell.bv, r, col, xf);
+                        break;
+                    case DECIMAL:
+                        writeDecimal(cell.mv, r, col, xf);
+                        break;
+                    case CHARACTER:
+                        writeChar(cell.cv, r, col, xf);
+                        break;
+                    case BLANK:
+                        writeNull(r, col, xf);
+                        break;
+                    default:
+                }
+            }
+            // 注意这里可能不会关闭row需要在writeAfter进行二次处理
+            if (y == tile - 1)
+                bw.write("</row>");
+        }
+
+        @Override
+        protected void writeRowAutoSize(Row row) throws IOException {
+            Cell[] cells = row.getCells();
+            int len = cells.length, r = row.getIndex() / tile + startRow, c = columns[columns.length - 1].realColIndex / tile, y = row.getIndex() % tile;
+            if (y == 0) startRow(r - startRow, columns[columns.length - 1].realColIndex);
+
+            for (int i = 0; i < len; i++) {
+                Cell cell = cells[i];
+                int xf = cell.xf, col = i + c * y;
+                switch (cell.t) {
+                    case INLINESTR:
+                    case SST:
+                        writeStringAutoSize(cell.sv, r, col, xf);
+                        break;
+                    case NUMERIC:
+                        writeNumericAutoSize(cell.nv, r, col, xf);
+                        break;
+                    case LONG:
+                        writeNumericAutoSize(cell.lv, r, col, xf);
+                        break;
+                    case DATE:
+                    case DATETIME:
+                    case DOUBLE:
+                    case TIME:
+                        writeDoubleAutoSize(cell.dv, r, col, xf);
+                        break;
+                    case BOOL:
+                        writeBool(cell.bv, r, col, xf);
+                        break;
+                    case DECIMAL:
+                        writeDecimalAutoSize(cell.mv, r, col, xf);
+                        break;
+                    case CHARACTER:
+                        writeChar(cell.cv, r, col, xf);
+                        break;
+                    case BLANK:
+                        writeNull(r, col, xf);
+                        break;
+                    default:
+                }
+            }
+            // 注意这里可能不会关闭row需要在writeAfter进行二次处理
+            if (y == tile - 1)
+                bw.write("</row>");
+        }
+
+        @Override
+        protected void writeAfter(int total) throws IOException {
+            if (total > 0 && (total - 1) % tile < tile - 1) bw.write("</row>");
+            super.writeAfter(total);
+        }
+    }
 }
diff --git a/src/test/java/org/ttzero/excel/entity/MultiHeaderColumnsTest.java b/src/test/java/org/ttzero/excel/entity/MultiHeaderColumnsTest.java
index 2eb98afb..e62ba3de 100644
--- a/src/test/java/org/ttzero/excel/entity/MultiHeaderColumnsTest.java
+++ b/src/test/java/org/ttzero/excel/entity/MultiHeaderColumnsTest.java
@@ -21,8 +21,11 @@ import org.junit.Test;
 import org.ttzero.excel.annotation.ExcelColumn;
 import org.ttzero.excel.annotation.HeaderComment;
 import org.ttzero.excel.entity.e7.XMLWorksheetWriter;
+import org.ttzero.excel.reader.Cell;
+import org.ttzero.excel.reader.ExcelReader;
 import org.ttzero.excel.entity.style.Font;
 import org.ttzero.excel.entity.style.Horizontals;
+import org.ttzero.excel.reader.HeaderRow;
 
 import java.awt.Color;
 import java.io.IOException;
@@ -31,16 +34,29 @@ import java.sql.SQLException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
 
 /**
  * @author guanquan.wang at 2022-06-27 23:24
  */
 public class MultiHeaderColumnsTest extends SQLWorkbookTest {
     @Test public void testRepeatAnnotations() throws IOException {
-        new Workbook("Repeat Columns Annotation").setWaterMark(WaterMark.of("勿外传"))
+        List<RepeatableEntry> list = RepeatableEntry.randomTestData();
+        new Workbook().setWaterMark(WaterMark.of("勿外传"))
             .setAutoSize(true)
-            .addSheet(new ListSheet<>(RepeatableEntry.randomTestData()))
-            .writeTo(defaultTestPath);
+            .addSheet(new ListSheet<>(list))
+            .writeTo(defaultTestPath.resolve("Repeat Columns Annotation.xlsx"));
+
+        try (ExcelReader reader = ExcelReader.read(defaultTestPath.resolve("Repeat Columns Annotation.xlsx"))) {
+            List<RepeatableEntry> readList = reader.sheet(0).header(1, 4).bind(RepeatableEntry.class).rows()
+                .map(row -> (RepeatableEntry) row.get()).collect(Collectors.toList());
+
+            assert list.size() == readList.size();
+            for (int i = 0, len = list.size(); i < len; i++)
+                assert list.get(i).equals(readList.get(i));
+        }
     }
 
     @Test public void testPagingRepeatAnnotations() throws IOException {
@@ -95,6 +111,97 @@ public class MultiHeaderColumnsTest extends SQLWorkbookTest {
         }
     }
 
+    @Test public void testRepeatAnnotations2() throws IOException {
+        List<RepeatableEntry> list = RepeatableEntry.randomTestData();
+        new Workbook()
+            .addSheet(new ListSheet<>(list))
+            .writeTo(defaultTestPath.resolve("Repeat Columns Annotation2.xlsx"));
+
+        try (ExcelReader reader = ExcelReader.read(defaultTestPath.resolve("Repeat Columns Annotation2.xlsx"))) {
+            List<RepeatableEntry> readList = reader.sheet(0).header(2, 4).bind(RepeatableEntry.class).rows()
+                .map(row -> (RepeatableEntry) row.get()).collect(Collectors.toList());
+
+            assert list.size() == readList.size();
+            for (int i = 0, len = list.size(); i < len; i++)
+                assert list.get(i).equals(readList.get(i));
+
+            // Specify single header row
+            org.ttzero.excel.reader.Row headerRow = new org.ttzero.excel.reader.Row() {};
+            Cell[] cells = new Cell[6];
+            cells[0] = new Cell((short) 1).setSv("订单号");
+            cells[1] = new Cell((short) 2).setSv("收件人");
+            cells[2] = new Cell((short) 3).setSv("省");
+            cells[3] = new Cell((short) 4).setSv("市");
+            cells[4] = new Cell((short) 5).setSv("区");
+            cells[5] = new Cell((short) 6).setSv("详细地址");
+            headerRow.setCells(cells);
+            readList = reader.sheet(0).reset().header(4).bind(RepeatableEntry.class, new HeaderRow().with(headerRow))
+                .rows().map(row -> (RepeatableEntry) row.get()).collect(Collectors.toList());
+            assert list.size() == readList.size();
+            for (int i = 0, len = list.size(); i < len; i++)
+                assert list.get(i).equals(readList.get(i));
+
+            // Specify 2 header rows
+            org.ttzero.excel.reader.Row headerRow2 = new org.ttzero.excel.reader.Row() {};
+            Cell[] cells2 = new Cell[6];
+            cells2[0] = new Cell((short) 1).setSv("订单号");
+            cells2[1] = new Cell((short) 2).setSv("A:收件人");
+            cells2[2] = new Cell((short) 3).setSv("A:省");
+            cells2[3] = new Cell((short) 4).setSv("A:市");
+            cells2[4] = new Cell((short) 5).setSv("B:区");
+            cells2[5] = new Cell((short) 6).setSv("B:详细地址");
+            headerRow2.setCells(cells2);
+            readList = reader.sheet(0).reset().header(4).bind(RepeatableEntry.class, new HeaderRow().with(2, headerRow2))
+                .rows().map(row -> (RepeatableEntry) row.get()).collect(Collectors.toList());
+            assert list.size() == readList.size();
+            for (int i = 0, len = list.size(); i < len; i++)
+                assert list.get(i).equals(readList.get(i));
+        }
+    }
+
+    @Test public void testRepeatAnnotations3() throws IOException {
+        List<RepeatableEntry3> list = RepeatableEntry3.randomTestData();
+        new Workbook()
+            .addSheet(new ListSheet<>(list))
+            .writeTo(defaultTestPath.resolve("Repeat Columns Annotation3.xlsx"));
+
+        try (ExcelReader reader = ExcelReader.read(defaultTestPath.resolve("Repeat Columns Annotation3.xlsx"))) {
+            List<RepeatableEntry3> readList;
+
+            // header row 4
+//            readList = reader.sheet(0).header(4).bind(RepeatableEntry3.class).rows()
+//                .map(row -> (RepeatableEntry3) row.get()).collect(Collectors.toList());
+//
+//            assert list.size() == readList.size();
+//            for (int i = 0, len = list.size(); i < len; i++)
+//                assert list.get(i).equals(readList.get(i));
+//
+//            // header rows 3-4
+//            readList = reader.sheet(0).reset().header(3, 4).bind(RepeatableEntry3.class).rows()
+//                .map(row -> (RepeatableEntry3) row.get()).collect(Collectors.toList());
+//
+//            assert list.size() == readList.size();
+//            for (int i = 0, len = list.size(); i < len; i++)
+//                assert list.get(i).equals(readList.get(i));
+//
+//            // header rows 2-4
+//            readList = reader.sheet(0).reset().header(2, 4).bind(RepeatableEntry3.class).rows()
+//                .map(row -> (RepeatableEntry3) row.get()).collect(Collectors.toList());
+//
+//            assert list.size() == readList.size();
+//            for (int i = 0, len = list.size(); i < len; i++)
+//                assert list.get(i).equals(readList.get(i));
+
+            // header rows 1-4
+            readList = reader.sheet(0).reset().header(1, 4).bind(RepeatableEntry3.class).rows()
+                .map(row -> (RepeatableEntry3) row.get()).collect(Collectors.toList());
+
+            assert list.size() == readList.size();
+            for (int i = 0, len = list.size(); i < len; i++)
+                assert list.get(i).equals(readList.get(i));
+        }
+    }
+
     public static final String[] provinces = {"江苏省", "湖北省", "浙江省", "广东省"};
     public static final String[][] cities = {{"南京市", "苏州市", "无锡市", "徐州市"}
         , {"武汉市", "黄冈市", "黄石市", "孝感市", "宜昌市"}
@@ -124,21 +231,35 @@ public class MultiHeaderColumnsTest extends SQLWorkbookTest {
     }};
 
     public static class RepeatableEntry {
+        @ExcelColumn("TOP")
+        @ExcelColumn("K")
+        @ExcelColumn
         @ExcelColumn("订单号")
         private String orderNo;
+        @ExcelColumn("TOP")
+        @ExcelColumn("K")
+        @ExcelColumn("A")
         @ExcelColumn("收件人")
         private String recipient;
+        @ExcelColumn("TOP")
         @ExcelColumn("收件地址")
+        @ExcelColumn("A")
         @ExcelColumn("省")
         private String province;
+        @ExcelColumn("TOP")
         @ExcelColumn("收件地址")
+        @ExcelColumn("A")
         @ExcelColumn("市")
         private String city;
+        @ExcelColumn("TOP")
         @ExcelColumn("收件地址")
+        @ExcelColumn("B")
         @ExcelColumn("区")
         private String area;
+        @ExcelColumn("TOP")
         @ExcelColumn(value = "收件地址", comment = @HeaderComment("精确到门牌号"))
-        @ExcelColumn(value = "详细地址")
+        @ExcelColumn("B")
+        @ExcelColumn("详细地址")
         private String detail;
 
         public RepeatableEntry() {}
@@ -188,5 +309,108 @@ public class MultiHeaderColumnsTest extends SQLWorkbookTest {
         public String getDetail() {
             return detail;
         }
+
+        @Override
+        public int hashCode() {
+            return orderNo.hashCode();
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (o instanceof RepeatableEntry) {
+                RepeatableEntry other = (RepeatableEntry) o;
+                return Objects.equals(orderNo, other.orderNo)
+                    && Objects.equals(recipient, other.recipient)
+                    && Objects.equals(province, other.province)
+                    && Objects.equals(city, other.city)
+                    && Objects.equals(detail, other.detail);
+            }
+            return false;
+        }
+
+        @Override
+        public String toString() {
+            return orderNo + " | " + recipient + " | " + province + " | " + city + " | " + area + " | " + detail;
+        }
+    }
+
+    public static class RepeatableEntry3 {
+        @ExcelColumn("TOP")
+        @ExcelColumn("K")
+        @ExcelColumn
+        @ExcelColumn("订单号")
+        private String orderNo;
+        @ExcelColumn("TOP")
+        @ExcelColumn("K")
+        @ExcelColumn("A")
+        @ExcelColumn("收件人")
+        private String recipient;
+        @ExcelColumn("TOP")
+        @ExcelColumn("收件地址")
+        @ExcelColumn("A")
+        @ExcelColumn("省")
+        private String province;
+        @ExcelColumn("TOP")
+        @ExcelColumn("市")
+        @ExcelColumn("市")
+        @ExcelColumn("市")
+        private String city;
+        @ExcelColumn("TOP")
+        @ExcelColumn("收件地址")
+        @ExcelColumn("B")
+        @ExcelColumn("区")
+        private String area;
+        @ExcelColumn("详细地址")
+        @ExcelColumn(value = "详细地址", comment = @HeaderComment("精确到门牌号"))
+        @ExcelColumn("详细地址")
+        @ExcelColumn("详细地址")
+        private String detail;
+
+        public RepeatableEntry3() {}
+
+        public RepeatableEntry3(String orderNo, String recipient, String province, String city, String area, String detail) {
+            this.orderNo = orderNo;
+            this.recipient = recipient;
+            this.province = province;
+            this.city = city;
+            this.area = area;
+            this.detail = detail;
+        }
+
+        public static List<RepeatableEntry3> randomTestData(int n) {
+            List<RepeatableEntry3> list = new ArrayList<>(n);
+            for (int i = 0, p, c; i < n; i++) {
+                list.add(new RepeatableEntry3(Integer.toString(Math.abs(random.nextInt())), getRandomString(8) + 2, provinces[p = random.nextInt(provinces.length)], cities[p][c = random.nextInt(cities[p].length)], areas[p][c][random.nextInt(areas[p][c].length)], "xx街" + (random.nextInt(10) + 1) + "号"));
+            }
+            return list;
+        }
+
+        public static List<RepeatableEntry3> randomTestData() {
+            int n = random.nextInt(100) + 1;
+            return randomTestData(n);
+        }
+
+        @Override
+        public int hashCode() {
+            return orderNo.hashCode();
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (o instanceof RepeatableEntry3) {
+                RepeatableEntry3 other = (RepeatableEntry3) o;
+                return Objects.equals(orderNo, other.orderNo)
+                    && Objects.equals(recipient, other.recipient)
+                    && Objects.equals(province, other.province)
+                    && Objects.equals(city, other.city)
+                    && Objects.equals(detail, other.detail);
+            }
+            return false;
+        }
+
+        @Override
+        public String toString() {
+            return orderNo + " | " + recipient + " | " + province + " | " + city + " | " + area + " | " + detail;
+        }
     }
 }
diff --git a/src/test/java/org/ttzero/excel/entity/ReportDesignTest.java b/src/test/java/org/ttzero/excel/entity/ReportDesignTest.java
index 825659b8..7d4c6c8d 100644
--- a/src/test/java/org/ttzero/excel/entity/ReportDesignTest.java
+++ b/src/test/java/org/ttzero/excel/entity/ReportDesignTest.java
@@ -38,15 +38,13 @@ import java.util.List;
  */
 public class ReportDesignTest extends WorkbookTest {
 
-    @Test
-    public void testMergedCells() throws IOException {
-        new Workbook().cancelOddFill().setAutoSize(true)
-            .addSheet(new ListSheet<>(testData(), createColumns()).setStyleProcessor(new GroupStyleProcessor<>()))
+    @Test public void testMergedCells() throws IOException {
+        new Workbook("Group Style Processor").cancelOddFill().setAutoSize(true)
+            .addSheet(new ListSheet<>(testData(), createColumns()).setStyleProcessor(new GroupStyleProcessor<>()).hideGridLines())
             .writeTo(defaultTestPath);
     }
 
-    @Test
-    public void testMergedCells1() throws IOException {
+    @Test public void testMergedCells1() throws IOException {
         List<E> list = testData();
         // 用于保存合并单元格
         List<Dimension> mergeCells = new ArrayList<>();
@@ -109,10 +107,10 @@ public class ReportDesignTest extends WorkbookTest {
         row++;
         mergeCells.add(new Dimension(row, (short) 1, row, (short) 5));
 
-        new Workbook().cancelOddFill().setAutoSize(true)
+        new Workbook("Report Design").cancelOddFill().setAutoSize(true)
             .addSheet(new ListSheet<>(list, createColumns())
                 .setStyleProcessor(new GroupStyleProcessor2<>())
-                .putExtProp(Const.ExtendPropertyKey.MERGE_CELLS, mergeCells)).writeTo(defaultTestPath);
+                .putExtProp(Const.ExtendPropertyKey.MERGE_CELLS, mergeCells).hideGridLines()).writeTo(defaultTestPath);
 
     }
 
diff --git a/src/test/java/org/ttzero/excel/entity/ValidationTest.java b/src/test/java/org/ttzero/excel/entity/ValidationTest.java
index ef0fa9f6..6249a0d9 100644
--- a/src/test/java/org/ttzero/excel/entity/ValidationTest.java
+++ b/src/test/java/org/ttzero/excel/entity/ValidationTest.java
@@ -46,7 +46,7 @@ public class ValidationTest extends WorkbookTest {
         validations.add(new DateValidation().between("2022-01-01", "2022-12-31").dimension(Dimension.of("A2")));
         // 限制时间小于下午6点（因为此时下班...）
         validations.add(new TimeValidation().lessThan(DateUtil.toTimeValue(Time.valueOf("18:00:00"))).dimension(Dimension.of("B2")));
-        new Workbook().addSheet(new EmptySheet().putExtProp("dataValidations", validations).setSheetWriter(new ValidationWorksheetWriter())).writeTo(defaultTestPath);
+        new Workbook("Validation Test").addSheet(new EmptySheet().putExtProp("dataValidations", validations).setSheetWriter(new ValidationWorksheetWriter())).writeTo(defaultTestPath);
     }
 
     public static abstract class Validation {
diff --git a/src/test/java/org/ttzero/excel/entity/csv/ListMapSheetTest.java b/src/test/java/org/ttzero/excel/entity/csv/ListMapSheetTest.java
index ef20fa18..8799eeb0 100644
--- a/src/test/java/org/ttzero/excel/entity/csv/ListMapSheetTest.java
+++ b/src/test/java/org/ttzero/excel/entity/csv/ListMapSheetTest.java
@@ -211,7 +211,7 @@ public class ListMapSheetTest extends WorkbookTest {
         Map<String, Object> data2 = new HashMap<>();
         data2.put("id", 2);
         data2.put("name", "xyz");
-        new Workbook()
+        new Workbook("ListMapSheet array to csv")
             .watch(Print::println)
             .addSheet(new ListMapSheet().setData(Arrays.asList(data1, data2)))
             .saveAsCSV()
@@ -223,7 +223,7 @@ public class ListMapSheetTest extends WorkbookTest {
         data.put("id", 1);
         data.put("name", "abc");
 
-        new Workbook()
+        new Workbook("ListMapSheet single list to csv")
             .watch(Print::println)
             .addSheet(new ListMapSheet().setData(Collections.singletonList(data)))
             .saveAsCSV()
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 6671aff2..4dfc64d8 100644
--- a/src/test/java/org/ttzero/excel/entity/csv/ListObjectSheetTest.java
+++ b/src/test/java/org/ttzero/excel/entity/csv/ListObjectSheetTest.java
@@ -162,7 +162,7 @@ public class ListObjectSheetTest extends WorkbookTest{
     }
 
     @Test public void testArray() throws IOException {
-        new Workbook()
+        new Workbook("ListObjectSheet array to csv")
             .watch(Print::println)
             .addSheet(new ListSheet<>()
                 .setData(Arrays.asList(new Item(1, "abc"), new Item(2, "xyz"))))
@@ -171,7 +171,7 @@ public class ListObjectSheetTest extends WorkbookTest{
     }
 
     @Test public void testSingleList() throws IOException {
-        new Workbook()
+        new Workbook("ListObject single list to csv")
             .watch(Print::println)
             .addSheet(new ListSheet<>()
                 .setData(Collections.singletonList(new Item(1, "a b c"))))
diff --git a/src/test/java/org/ttzero/excel/reader/ExcelReaderTest.java b/src/test/java/org/ttzero/excel/reader/ExcelReaderTest.java
index 1d86590e..66a87a15 100644
--- a/src/test/java/org/ttzero/excel/reader/ExcelReaderTest.java
+++ b/src/test/java/org/ttzero/excel/reader/ExcelReaderTest.java
@@ -355,19 +355,6 @@ public class ExcelReaderTest {
         }
     }
 
-
-    @Test public void testMergeExcel() throws IOException {
-        try (ExcelReader reader = ExcelReader.read(testResourceRoot().resolve("merge.xlsx"))) {
-            reader.parseFormula().sheets().flatMap(Sheet::rows).forEach(Print::println);
-        }
-    }
-
-    @Test public void testMergeExcel2() throws IOException {
-        try (ExcelReader reader = ExcelReader.read(testResourceRoot().resolve("#150.xlsx"))) {
-            reader.sheets().flatMap(Sheet::rows).forEach(Print::println);
-        }
-    }
-
     @Ignore
     @Test public void testReaderLarge() throws IOException {
         try (ExcelReader reader = ExcelReader.read(WorkbookTest.getOutputTestPath().resolve("large07.xlsx"))) {
@@ -549,35 +536,16 @@ public class ExcelReaderTest {
 
             assert "rowNum: 8 => fbaNo: FBA15DRV4JP4U000001, refId: 2Z91JHMR, price: 0.08, weight: 0.070000000000000007, brand: TEYASI, productName: 手机充电头".equals(list[0].toString());
             assert "rowNum: 9 => fbaNo: FBA15DRV4JP4U000002, refId: 2Z91JHMR, price: 0.08, weight: 0.070000000000000007, brand: TEYASI, productName: 手机充电头".equals(list[1].toString());
-        }
-    }
 
-    @Test public void testLargeMerge() throws IOException {
-        try (ExcelReader reader = ExcelReader.read(testResourceRoot().resolve("largeMerged.xlsx"))) {
-            MergeSheet mergeSheet = reader.sheet(0).asMergeSheet();
-            Grid grid = mergeSheet.getMergeGrid();
-            assert grid.size() == 2608;
-            assert grid.test(3, 1);
-            assert grid.test(382, 1);
-            assert grid.test(722, 2);
-            assert grid.test(1374, 2);
-            assert grid.test(2101, 10);
-            assert grid.test(2201, 6);
-            assert !grid.test(2113, 5);
-            long count = mergeSheet.rows().count();
-
-            Sheet sheet = mergeSheet.asCalcSheet();
-            assert sheet.getClass() == XMLCalcSheet.class;
-            assert sheet.reset().rows().count() == count;
-
-            sheet = sheet.asSheet();
-            assert sheet.getClass() == XMLSheet.class;
-            assert sheet.reset().rows().count() == count;
-
-            sheet = sheet.asMergeSheet();
-            assert sheet.getClass() == XMLMergeSheet.class;
-            assert sheet.reset().rows().count() == count;
+            // Specify header rows
+            list = reader.sheet(0).reset().header(7).rows().map(row -> row.to(OO.class)).toArray(OO[]::new);
+            assert "rowNum: 8 => fbaNo: FBA15DRV4JP4U000001, refId: 2Z91JHMR, price: 0.08, weight: 0.070000000000000007, brand: TEYASI, productName: 手机充电头".equals(list[0].toString());
+            assert "rowNum: 9 => fbaNo: FBA15DRV4JP4U000002, refId: 2Z91JHMR, price: 0.08, weight: 0.070000000000000007, brand: TEYASI, productName: 手机充电头".equals(list[1].toString());
 
+            // Bind Java bean
+            list = reader.sheet(0).reset().bind(OO.class, 7).rows().map(row -> (OO) row.get()).toArray(OO[]::new);
+            assert "rowNum: 8 => fbaNo: FBA15DRV4JP4U000001, refId: 2Z91JHMR, price: 0.08, weight: 0.070000000000000007, brand: TEYASI, productName: 手机充电头".equals(list[0].toString());
+            assert "rowNum: 9 => fbaNo: FBA15DRV4JP4U000002, refId: 2Z91JHMR, price: 0.08, weight: 0.070000000000000007, brand: TEYASI, productName: 手机充电头".equals(list[1].toString());
         }
     }
 
diff --git a/src/test/java/org/ttzero/excel/reader/GridTest.java b/src/test/java/org/ttzero/excel/reader/GridTest.java
index 2983e763..5e3e62c2 100644
--- a/src/test/java/org/ttzero/excel/reader/GridTest.java
+++ b/src/test/java/org/ttzero/excel/reader/GridTest.java
@@ -192,13 +192,33 @@ public class GridTest {
     }
 
     @Test public void testIndexGrid() {
-        Grid grid = GridFactory.create(Collections.singletonList(Dimension.of("B2:E2")));
-
-        assert !grid.test(1, 1);
-        assert grid.test(2, 2);
-        assert grid.test(2, 5);
-        assert !grid.test(3, 3);
-        assert !grid.test(6, 2);
+        Dimension range = new Dimension(1, (short)1, 2, (short)17);
+        List<Dimension> list = Arrays.asList(Dimension.of("H1:I1"), Dimension.of("J1:K1")
+            , Dimension.of("L1:M1"), Dimension.of("N1:O1"), Dimension.of("P1:Q1"), Dimension.of("R1:S1")
+            , Dimension.of("T1:U1"), Dimension.of("V1:W1"), Dimension.of("X1:Y1"), Dimension.of("Z1:AA1")
+            , Dimension.of("A1:A2"), Dimension.of("B1:B2"), Dimension.of("C1:C2"), Dimension.of("D1:D2")
+            , Dimension.of("E1:E2"), Dimension.of("F1:F2"), Dimension.of("G1:G2")
+        );
+
+        Grid grid = new Grid.IndexGrid(range, 2 * 17);
+        for (Dimension dim : list) grid.mark(dim);
+
+        Cell c = new Cell((short) 1);
+        assert grid.merge(1, c) == 1;
+        assert grid.merge(2, c) == 2;
+        c.i = 7;
+        assert grid.merge(1, c) == 1;
+        assert grid.merge(2, c) == 2;
+
+        c.i = 8;
+        assert grid.merge(1, c) == 1;
+        c.i = 9;
+        assert grid.merge(1, c) == 2;
+
+        c.i = 26;
+        assert grid.merge(1, c) == 1;
+        c.i = 27;
+        assert grid.merge(1, c) == 2;
     }
 
     @Test public void testFractureGrid() {
diff --git a/src/test/java/org/ttzero/excel/reader/MultiHeaderReaderTest.java b/src/test/java/org/ttzero/excel/reader/MultiHeaderReaderTest.java
new file mode 100644
index 00000000..64ff83a7
--- /dev/null
+++ b/src/test/java/org/ttzero/excel/reader/MultiHeaderReaderTest.java
@@ -0,0 +1,112 @@
+/*
+ * Copyright (c) 2017-2019, guanquan.wang@yandex.com All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.ttzero.excel.reader;
+
+import org.junit.Test;
+import org.ttzero.excel.Print;
+import org.ttzero.excel.manager.Const;
+
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.util.Objects;
+
+import static org.ttzero.excel.reader.ExcelReaderTest.testResourceRoot;
+
+/**
+ * @author guanquan.wang at 2019-04-26 17:42
+ */
+public class MultiHeaderReaderTest {
+
+
+    @Test public void testMergeExcel() throws IOException {
+        try (ExcelReader reader = ExcelReader.read(testResourceRoot().resolve("merge.xlsx"))) {
+            reader.parseFormula().sheets().flatMap(Sheet::rows).forEach(Print::println);
+        }
+    }
+
+    @Test public void testMergeExcel2() throws IOException {
+        try (ExcelReader reader = ExcelReader.read(testResourceRoot().resolve("#150.xlsx"))) {
+            reader.sheets().flatMap(Sheet::rows).forEach(Print::println);
+        }
+    }
+
+    @Test public void testLargeMerge() throws IOException {
+        try (ExcelReader reader = ExcelReader.read(testResourceRoot().resolve("largeMerged.xlsx"))) {
+            MergeSheet mergeSheet = reader.sheet(0).asMergeSheet();
+            Grid grid = mergeSheet.getMergeGrid();
+            assert grid.size() == 2608;
+            assert grid.test(3, 1);
+            assert grid.test(382, 1);
+            assert grid.test(722, 2);
+            assert grid.test(1374, 2);
+            assert grid.test(2101, 10);
+            assert grid.test(2201, 6);
+            assert !grid.test(2113, 5);
+            long count = mergeSheet.rows().count();
+
+            Sheet sheet = mergeSheet.asCalcSheet();
+            assert sheet.getClass() == XMLCalcSheet.class;
+            assert sheet.reset().rows().count() == count;
+
+            sheet = sheet.asSheet();
+            assert sheet.getClass() == XMLSheet.class;
+            assert sheet.reset().rows().count() == count;
+
+            sheet = sheet.asMergeSheet();
+            assert sheet.getClass() == XMLMergeSheet.class;
+            assert sheet.reset().rows().count() == count;
+        }
+    }
+
+    @Test public void testFractureMerged() {
+        try (ExcelReader reader = ExcelReader.read(testResourceRoot().resolve("fracture merged.xlsx"))) {
+            Sheet sheet = reader.sheet(0);
+            Row header = sheet.header(1, 2).getHeader();
+            String headerString = header.toString();
+            assert "姓名 | 二级机构名称 | 三级机构名称 | 四级机构名称 | 参与次数 | 日均参与率(%) | 日均得分 | 2021-07-01:得分 | 2021-07-01:考试时长 | 2021-07-02:得分 | 2021-07-02:考试时长 | 2021-07-03:得分 | 2021-07-03:考试时长 | 2021-07-04:得分 | 2021-07-04:考试时长 | 2021-07-05:得分 | 2021-07-05:考试时长 | 2021-07-06:得分 | 2021-07-06:考试时长 | 2021-07-07:得分 | 2021-07-07:考试时长 | 2021-07-08:得分 | 2021-07-08:考试时长 | 2021-07-09:得分 | 2021-07-09:考试时长 | 2021-07-10:得分 | 2021-07-10:考试时长".equals(headerString.substring(0, headerString.indexOf(Const.lineSeparator)));
+
+            sheet.rows().forEach(row -> {
+                if (row.getRowNum() == 3) {
+                    assert row.getString("姓名").equals("张三1");
+                    assert row.getInt("参与次数").equals(7);
+                    assert row.getDecimal("日均得分").setScale(2, BigDecimal.ROUND_HALF_DOWN).toString().equals("41.43");
+                    assert row.getInt("2021-07-01:得分").equals(30);
+                    assert row.getInt("2021-07-01:考试时长").equals(19);
+                    assert Objects.isNull(row.getInt("2021-07-04:得分"));
+                    assert row.getInt("2021-07-09:得分").equals(70);
+                    assert row.getInt("2021-07-09:考试时长").equals(20);
+                    assert Objects.isNull(row.getInt("2021-07-10:得分"));
+                    assert Objects.isNull(row.getInt("2021-07-10:考试时长"));
+                } else if (row.getRowNum() == 4) {
+                    assert row.getString("姓名").equals("张三2");
+                    assert row.getInt("参与次数").equals(0);
+                    assert Objects.isNull(row.getDecimal("日均得分"));
+                    assert Objects.isNull(row.getInt("2021-07-01:得分"));
+                    assert Objects.isNull(row.getInt("2021-07-01:考试时长"));
+                    assert Objects.isNull(row.getInt("2021-07-04:得分"));
+                    assert Objects.isNull(row.getInt("2021-07-09:得分"));
+                    assert Objects.isNull(row.getInt("2021-07-09:考试时长"));
+                    assert Objects.isNull(row.getInt("2021-07-10:得分"));
+                    assert Objects.isNull(row.getInt("2021-07-10:考试时长"));
+                }
+            });
+        } catch (IOException ex) {
+            ex.printStackTrace();
+        }
+    }
+
+}