Repository: wangguanquan/eec
Pull request #408 (2024-12-11 11:47:08)

优化批注
简化添加批注的方法


Patch:
diff --git a/CHANGELOG b/CHANGELOG
index 15801425..afbf2b04 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -137,7 +137,7 @@ Version 0.5.3 (2022-07-25)
 
 Version 0.5.2 (2022-07-16)
 -------------
-- (严重)修复大量单元格字节超过1k时导致SST索引读取死循环问题(#258)
+- (严重)修复单元格字节超过1k时导致SST索引读取死循环问题(#258)
 - StatementSheet&ResultSetSheet添加StyleProcessor实现整行样式调整(#235)
 - 修复部分BUG(#257, #260)
 
diff --git a/src/main/java/org/ttzero/excel/entity/Comment.java b/src/main/java/org/ttzero/excel/entity/Comment.java
index 7e200ee6..e8c22a22 100644
--- a/src/main/java/org/ttzero/excel/entity/Comment.java
+++ b/src/main/java/org/ttzero/excel/entity/Comment.java
@@ -17,6 +17,8 @@
 
 package org.ttzero.excel.entity;
 
+import org.ttzero.excel.entity.style.Font;
+
 /**
  * 批注，包含标题和内容且两者且少有一个不为空
  *
@@ -35,13 +37,25 @@ public class Comment {
      * 指定批注框显示的宽和高
      */
     private Double width, height;
+    /**
+     * 指定批注字体
+     */
+    private Font titleFont, valueFont;
 
     public Comment() { }
-    public Comment(String title, String value) {
-        this.title = title;
+
+    public Comment(String value) {
         this.value = value;
     }
 
+    public Comment(String title, String value) {
+        this(title, value, null, null);
+    }
+
+    public Comment(String value, Double width, Double height) {
+        this(null, value, width, height);
+    }
+
     public Comment(String title, String value, Double width, Double height) {
         this.title = title;
         this.value = value;
@@ -53,31 +67,65 @@ public class Comment {
         return title;
     }
 
-    public void setTitle(String title) {
+    public Comment setTitle(String title) {
         this.title = title;
+        return this;
+    }
+
+    public Comment setTitle(String title, Font titleFont) {
+        this.title = title;
+        this.titleFont = titleFont;
+        return this;
     }
 
     public String getValue() {
         return value;
     }
 
-    public void setValue(String value) {
+    public Comment setValue(String value) {
+        this.value = value;
+        return this;
+    }
+
+    public Comment setValue(String value, Font valueFont) {
         this.value = value;
+        this.valueFont = valueFont;
+        return this;
     }
 
     public Double getWidth() {
         return width;
     }
 
-    public void setWidth(Double width) {
+    public Comment setWidth(Double width) {
         this.width = width;
+        return this;
     }
 
     public Double getHeight() {
         return height;
     }
 
-    public void setHeight(Double height) {
+    public Comment setHeight(Double height) {
         this.height = height;
+        return this;
+    }
+
+    public Font getTitleFont() {
+        return titleFont;
+    }
+
+    public Comment setTitleFont(Font titleFont) {
+        this.titleFont = titleFont;
+        return this;
+    }
+
+    public Font getValueFont() {
+        return valueFont;
+    }
+
+    public Comment setValueFont(Font valueFont) {
+        this.valueFont = valueFont;
+        return this;
     }
 }
diff --git a/src/main/java/org/ttzero/excel/entity/Comments.java b/src/main/java/org/ttzero/excel/entity/Comments.java
index 71d6392a..7a90e466 100644
--- a/src/main/java/org/ttzero/excel/entity/Comments.java
+++ b/src/main/java/org/ttzero/excel/entity/Comments.java
@@ -17,6 +17,7 @@
 
 package org.ttzero.excel.entity;
 
+import org.ttzero.excel.entity.style.ColorIndex;
 import org.ttzero.excel.manager.TopNS;
 import org.ttzero.excel.entity.style.Font;
 import org.ttzero.excel.manager.Const;
@@ -24,6 +25,7 @@ import org.ttzero.excel.reader.ExcelReader;
 import org.ttzero.excel.util.ExtBufferedWriter;
 import org.ttzero.excel.util.FileUtil;
 
+import java.awt.Color;
 import java.io.Closeable;
 import java.io.IOException;
 import java.nio.file.Files;
@@ -31,6 +33,7 @@ import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.List;
 
+import static org.ttzero.excel.entity.Sheet.toCoordinate;
 import static org.ttzero.excel.util.StringUtil.isNotEmpty;
 
 /**
@@ -40,71 +43,106 @@ import static org.ttzero.excel.util.StringUtil.isNotEmpty;
  */
 @TopNS(prefix = "", value = "comments", uri = Const.SCHEMA_MAIN)
 public class Comments implements Storable, Closeable {
-
     /** Comments Cache*/
     public List<C> commentList = new ArrayList<>();
     public int id;
     public String author;
 
-    public Comments() { }
+    public Comments(int id) {
+        this(id, "-");
+    }
 
     public Comments(int id, String author) {
         this.id = id;
         this.author = author;
     }
 
+    /**
+     * 添加评论到指定的单元格位置
+     *
+     * @param ref 单元格位置索引
+     * @param title 评论的标题
+     * @param value 评论的内容
+     * @return 包含新添加批注的对象
+     */
     public C addComment(String ref, String title, String value) {
-        C c = new C();
-        c.ref = ref;
-        c.text = new ArrayList<>();
-        if (isNotEmpty(title)) {
-            parse(title, true, c.text);
-        }
-        if (isNotEmpty(value)) {
-            parse(value, false, c.text);
-        }
-
-        commentList.add(c);
-
-        return c;
+        return addComment(ref, new Comment(title, value));
     }
 
+    /**
+     * 添加评论到指定的单元格位置
+     *
+     * @param ref 单元格位置索引
+     * @param comment 批注对象
+     * @return 包含新添加批注的对象
+     */
     public C addComment(String ref, Comment comment) {
         C c = new C();
         c.ref = ref;
-        c.text = new ArrayList<>();
         c.width = comment.getWidth();
         c.height = comment.getHeight();
-        if (isNotEmpty(comment.getTitle())) {
-            parse(comment.getTitle(), true, c.text);
-        }
-        if (isNotEmpty(comment.getValue())) {
-            parse(comment.getValue(), false, c.text);
-        }
-
+        boolean hasTitle = isNotEmpty(comment.getTitle()), hasValue = isNotEmpty(comment.getValue());
+        c.nodes = new R[hasTitle && hasValue ? 2 : 1];
+        int i = 0;
+        if (hasTitle) c.nodes[i++] = toR(comment.getTitle(), true, comment.getTitleFont());
+        if (hasValue) c.nodes[i] = toR(comment.getValue(), false, comment.getValueFont());
         commentList.add(c);
-
         return c;
     }
 
-    protected void parse(String val, boolean bold, List<R> list) {
+    /**
+     * 在指定行列添加批注
+     *
+     * @param row 行号，从{@code 1}开始
+     * @param col 列号，从{@code 1}开始
+     * @param value 批注内容
+     * @return 包含新添加批注的对象
+     */
+    public C addComment(int row, int col, String value) {
+        return addComment(toCoordinate(row, col), new Comment(null, value));
+    }
+
+    /**
+     * 在指定单元格添加批注
+     *
+     * @param row 行号，从{@code 1}开始
+     * @param col 列号，从{@code 1}开始
+     * @param title 批注标题
+     * @param value 批注内容
+     * @return 包含新添加批注的对象
+     */
+    public C addComment(int row, int col, String title, String value) {
+        return addComment(toCoordinate(row, col), new Comment(title, value));
+    }
+
+    /**
+     * 在指定单元格添加批注
+     *
+     * @param row 行号，从{@code 1}开始
+     * @param col 列号，从{@code 1}开始
+     * @param comment 批注对象
+     * @return 包含新添加批注的对象
+     */
+    public C addComment(int row, int col, Comment comment) {
+        return addComment(toCoordinate(row, col), comment);
+    }
+
+    protected R toR(String val, boolean isTitle,  Font font) {
         // a simple implement
         R r = new R();
-        r.rPr = new Pr("宋体", 9);
-        if (bold) {
-            r.rPr.bold();
-            if (val.charAt(val.length() - 1) != 10) {
-                val += (char) 10;
-            }
-        }
+        r.rPr = font == null ? isTitle ? DEFAULT_TITLE_PR : DEFAULT_PR : new Pr(font);
         r.t = val;
-        list.add(r);
+        return r;
     }
 
-    public void flush() {
-        // TODO Write tmp and clear cache
-
-    }
+    /**
+     * 默认字体设置，用于在没有明确指定字体时使用
+     */
+    protected static final Pr DEFAULT_PR = new Pr("宋体", 9);
+    /**
+     * 默认标题字体设置，用于在没有明确指定字体时使用
+     */
+    protected static final Pr DEFAULT_TITLE_PR = new Pr(new Font("宋体", 9, Font.Style.BOLD, Color.BLACK));
 
     @Override
     public void close() {
@@ -131,12 +169,17 @@ public class Comments implements Storable, Closeable {
                 writer.write("<comment ref=\"");
                 writer.write(c.ref);
                 writer.write("\" authorId=\"0\"><text>");
-                for (R r : c.text) {
+                boolean alf = c.nodes.length == 2;
+                for (R r : c.nodes) {
                     writer.write("<r>");
                     writer.write(r.rPr.toString());
                     writer.write("<t");
-                    writer.write((r.t.indexOf(10) > 0 ? " xml:space=\"preserve\">" : ">"));
+                    writer.write((alf || r.t.indexOf(10) >= 0 ? " xml:space=\"preserve\">" : ">"));
                     writer.escapeWrite(r.t);
+                    if (alf) {
+                        writer.write(10);
+                        alf = false;
+                    }
                     writer.write("</t></r>");
                 }
                 writer.write("</text></comment>");
@@ -170,25 +213,24 @@ public class Comments implements Storable, Closeable {
             int i = 1;
             for (C c : commentList) {
                 long cr = ExcelReader.coordinateToLong(c.ref);
-                writer.write(" <v:shape id=\"_x0000_s");writer.write(100 + i);
-                writer.write("\" type=\"#_x0000_t202\" style='width:" + (c.width != null ? c.width : 100.8D) + "pt;height:" + (c.height != null ? c.height : 60.6D) + " pt;z-index:");
+                writer.write("<v:shape id=\"_x0000_s");writer.writeInt(100 + i);
+                writer.write("\" type=\"#_x0000_t202\" style='width:");writer.write(c.width != null ? c.width : 100.8D);writer.write("pt;height:");writer.write(c.height != null ? c.height : 60.6D);writer.write("pt;z-index:");
                 writer.writeInt(i++);
-                writer.write(";  visibility:hidden' fillcolor=\"#ffffe1\" o:insetmode=\"auto\">");
-                writer.write("  <v:fill color2=\"#ffffe1\"/>");
-                writer.write("  <v:shadow on=\"t\" color=\"black\" obscured=\"t\"/>");
-                writer.write("  <v:path o:connecttype=\"none\"/>");
-                writer.write("  <v:textbox style='mso-direction-alt:auto'>");
-                writer.write("   <div style='text-align:left'></div>");
-                writer.write("  </v:textbox>");
-                writer.write("  <x:ClientData ObjectType=\"Note\">");
-                writer.write("   <x:MoveWithCells/>");
-                writer.write("   <x:SizeWithCells/>");
-                writer.write("   <x:Anchor/>");
-                writer.write("   <x:AutoFill>False</x:AutoFill>");
-                writer.write("   <x:Row>");writer.write((cr >> 16) - 1);writer.write("</x:Row>");
-                writer.write("   <x:Column>");writer.write((cr & 0x7FFF) - 1);writer.write("</x:Column>");
-                writer.write("  </x:ClientData>");
-                writer.write(" </v:shape>");
+                writer.write(";visibility:hidden' fillcolor=\"#ffffe1\" o:insetmode=\"auto\">");
+//                writer.write("<v:fill color2=\"#ffffe1\"/>");
+                writer.write("<v:shadow on=\"t\" color=\"black\" obscured=\"t\"/>");
+//                writer.write("<v:path o:connecttype=\"none\"/>");
+//                writer.write("<v:textbox style='mso-direction-alt:auto'>");
+//                writer.write("<div style='text-align:left'></div>");
+//                writer.write("</v:textbox>");
+                writer.write("<x:ClientData ObjectType=\"Note\">");
+//                writer.write("<x:MoveWithCells/>");
+//                writer.write("<x:SizeWithCells/>");
+//                writer.write("<x:Anchor/>");
+//                writer.write("<x:AutoFill>False</x:AutoFill>");
+                writer.write("<x:Row>");writer.write((cr >> 16) - 1);writer.write("</x:Row>");
+                writer.write("<x:Column>");writer.write((cr & 0x7FFF) - 1);writer.write("</x:Column>");
+                writer.write("</x:ClientData></v:shape>");
             }
             writer.write("</xml>");
         }
@@ -196,14 +238,14 @@ public class Comments implements Storable, Closeable {
 
     public static class C {
         public String ref;
-        public List<R> text;
+        public R[] nodes;
         public Double width, height;
 
         @Override
         public String toString() {
             StringBuilder buf = new StringBuilder("<comment ref=\"")
                 .append(ref).append("\" authorId=\"0\"><text>");
-            for (R r : text)
+            for (R r : nodes)
                 buf.append(r);
             buf.append("</text>").append("</comment>");
             return buf.toString();
@@ -227,12 +269,21 @@ public class Comments implements Storable, Closeable {
             super(name, size);
         }
 
+        public Pr(Font font) {
+            super(font);
+        }
+
         @Override
         public String toString() {
-            return "<rPr>" + STYLE[getStyle() & 0x07] +
-                "<sz val=\"" + getSize() + "\"/>" +
-                "<rFont val=\"" + getName() + "\"/>" +
-                "<charset val=\"" + getCharset() + "\"/></rPr>";
+            StringBuilder buf = new StringBuilder("<rPr>");
+            if (getStyle() > 0 && getStyle() < 8) buf.append(STYLE[getStyle() & 0x07]);
+            buf.append("<rFont val=\"").append(getName()).append("\"/>");
+            buf.append("<sz val=\"").append(getSize()).append("\"/>");
+            if (getCharset() > 0) buf.append("<charset val=\"").append(getCharset()).append("\"/>");
+            if (getColor() != null) buf.append("<color rgb=\"").append(ColorIndex.toARGB(getColor().getRGB())).append("\"/>");
+            if (getFamily() > 0) buf.append("<family val=\"").append(getFamily()).append("\"/>");
+            buf.append("</rPr>");
+            return buf.toString();
         }
     }
 }
diff --git a/src/main/java/org/ttzero/excel/entity/Relationship.java b/src/main/java/org/ttzero/excel/entity/Relationship.java
index ec60b2c8..58a73136 100644
--- a/src/main/java/org/ttzero/excel/entity/Relationship.java
+++ b/src/main/java/org/ttzero/excel/entity/Relationship.java
@@ -56,14 +56,18 @@ public class Relationship implements Serializable, Cloneable {
     public Relationship() { }
 
     public Relationship(String target, String type) {
-        this.target = target;
-        this.type = type;
+        this(null, target, type, null);
     }
 
     public Relationship(String id, String target, String type) {
+        this(id, target, type, null);
+    }
+
+    public Relationship(String id, String target, String type, String targetMode) {
         this.id = id;
         this.target = target;
         this.type = type;
+        this.targetMode = targetMode;
     }
 
     public String getTarget() {
diff --git a/src/main/java/org/ttzero/excel/entity/Sheet.java b/src/main/java/org/ttzero/excel/entity/Sheet.java
index 82888a12..afc01530 100644
--- a/src/main/java/org/ttzero/excel/entity/Sheet.java
+++ b/src/main/java/org/ttzero/excel/entity/Sheet.java
@@ -50,6 +50,8 @@ import java.util.Map;
 import java.util.function.BiConsumer;
 
 import static org.ttzero.excel.manager.Const.ROW_BLOCK_SIZE;
+import static org.ttzero.excel.util.ExtBufferedWriter.getChars;
+import static org.ttzero.excel.util.ExtBufferedWriter.stringSize;
 import static org.ttzero.excel.util.StringUtil.isEmpty;
 import static org.ttzero.excel.util.StringUtil.isNotEmpty;
 
@@ -547,7 +549,7 @@ public abstract class Sheet implements Cloneable, Storable {
      */
     public Comments createComments() {
         if (comments == null) {
-            comments = workbook != null ? new Comments(id, workbook.getCreator()) : new Comments();
+            comments = new Comments(id, workbook != null ? workbook.getCreator() : null);
             // FIXME Removed at excel version 2013
             if (id > 0) {
                 addRel(new Relationship("../drawings/vmlDrawing" + id + Const.Suffix.VML, Const.Relationship.VMLDRAWING));
@@ -1353,6 +1355,21 @@ public abstract class Sheet implements Cloneable, Storable {
 
     private static final char[][] cache = new char[][]{ {65}, {65, 65}, {65, 65, 65} };
 
+    /**
+     * 将行列坐标转换为 Excel 样式的单元格地址
+     *
+     * @param row 行号，从{@code 1}开始
+     * @param col 列号，从{@code 1}开始
+     * @return Excel 样式的单元格地址，例如{@code A1}、{@code B2}等
+     */
+    public static String toCoordinate(int row, int col) {
+        char[] cols = int2Col(col);
+        char[] chars = new char[cols.length + stringSize(row)];
+        System.arraycopy(cols, 0, chars, 0, cols.length);
+        getChars(row, chars.length, chars);
+        return new String(chars);
+    }
+
     /**
      * 忽略表头，调用此方法后表头将不会输出到Excel中，注意这里不是隐藏
      *
diff --git a/src/main/java/org/ttzero/excel/entity/e7/XMLWorksheetWriter.java b/src/main/java/org/ttzero/excel/entity/e7/XMLWorksheetWriter.java
index 5acf4346..af4ace7a 100644
--- a/src/main/java/org/ttzero/excel/entity/e7/XMLWorksheetWriter.java
+++ b/src/main/java/org/ttzero/excel/entity/e7/XMLWorksheetWriter.java
@@ -29,7 +29,6 @@ import org.ttzero.excel.entity.style.Styles;
 import org.ttzero.excel.manager.RelManager;
 import org.ttzero.excel.manager.TopNS;
 import org.ttzero.excel.entity.Column;
-import org.ttzero.excel.entity.Comments;
 import org.ttzero.excel.entity.ExcelWriteException;
 import org.ttzero.excel.entity.IWorksheetWriter;
 import org.ttzero.excel.entity.Panes;
@@ -79,6 +78,7 @@ import java.util.function.BiConsumer;
 import java.util.function.Supplier;
 
 import static org.ttzero.excel.entity.Sheet.int2Col;
+import static org.ttzero.excel.entity.Sheet.toCoordinate;
 import static org.ttzero.excel.reader.Cell.BINARY;
 import static org.ttzero.excel.reader.Cell.BOOL;
 import static org.ttzero.excel.reader.Cell.BYTE_BUFFER;
@@ -123,7 +123,6 @@ public class XMLWorksheetWriter implements IWorksheetWriter {
      * 全局样式，为工作表的一个指针
      */
     protected Styles styles;
-    protected Comments comments;
     protected int startRow // The first data-row index
         , startHeaderRow // The first header row index
         , totalRows
@@ -348,7 +347,6 @@ public class XMLWorksheetWriter implements IWorksheetWriter {
         e.sheetDataReady = 0;
         e.totalRows = 0;
         e.drawingsWriter = null;
-        e.comments = null;
         return copy;
     }
 
@@ -465,8 +463,7 @@ public class XMLWorksheetWriter implements IWorksheetWriter {
             for (int j = 0; j < columns.length; j++) {
                 Column hc = columnsArray[j][i];
                 if (hc.headerComment != null) {
-                    if (comments == null) comments = sheet.createComments();
-                    comments.addComment(new String(int2Col(hc.getRealColIndex())) + row, hc.headerComment);
+                    sheet.createComments().addComment(toCoordinate(row, hc.getRealColIndex()), hc.headerComment);
                 }
             }
             bw.write("</row>");
@@ -693,7 +690,7 @@ public class XMLWorksheetWriter implements IWorksheetWriter {
         if (cell.h && notEmpty) {
             Relationship rel = relManager.add(new Relationship(s, Const.Relationship.HYPERLINK).setTargetMode("External"));
             List<String> dim = hyperlinkMap.computeIfAbsent(rel.getId(), k -> new ArrayList<>());
-            dim.add(new String(int2Col(col + 1)) + row);
+            dim.add(toCoordinate(row, col + 1));
         }
 
         if (cell.xf > 0) {
@@ -1422,7 +1419,7 @@ public class XMLWorksheetWriter implements IWorksheetWriter {
         }
 
         // Compatible processing
-        else if (comments != null) {
+        else if (sheet.getComments() != null) {
             sheet.addRel(r = new Relationship("../drawings/vmlDrawing" + sheet.getId() + Const.Suffix.VML, Const.Relationship.VMLDRAWING));
             sheet.addRel(new Relationship("../comments" + sheet.getId() + Const.Suffix.XML, Const.Relationship.COMMENTS));
 
diff --git a/src/main/java/org/ttzero/excel/entity/style/Font.java b/src/main/java/org/ttzero/excel/entity/style/Font.java
index afb2aa79..52f7d505 100644
--- a/src/main/java/org/ttzero/excel/entity/style/Font.java
+++ b/src/main/java/org/ttzero/excel/entity/style/Font.java
@@ -122,6 +122,13 @@ public class Font implements Cloneable {
         if (name != null && !name.isEmpty() && name.charAt(0) > 0x4E) this.charset = Charset.GB2312;
     }
 
+    public Font(Font other) {
+        this(other.name, other.getSize(), other.style, other.color);
+        this.family = other.family;
+        this.charset = other.charset;
+        this.scheme = other.scheme;
+    }
+
     /**
      * 解析字符串为字体
      * <p>
@@ -134,7 +141,7 @@ public class Font implements Cloneable {
      * @throws IllegalArgumentException if convert failed.
      */
     public static Font parse(String fontString) {
-        if (fontString.isEmpty()) {
+        if (StringUtil.isEmpty(fontString)) {
             throw new NullPointerException("Font string empty");
         }
         String s = fontString.trim();
diff --git a/src/main/java/org/ttzero/excel/manager/Const.java b/src/main/java/org/ttzero/excel/manager/Const.java
index 6cee8c7f..461fbdc7 100644
--- a/src/main/java/org/ttzero/excel/manager/Const.java
+++ b/src/main/java/org/ttzero/excel/manager/Const.java
@@ -67,6 +67,7 @@ public interface Const {
             , VMLDRAWING = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing"
             , CHARTS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart"
             , HYPERLINK = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
+            , PRINTER_SETTINGS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/printerSettings"
             ;
     }
 
diff --git a/src/main/java/org/ttzero/excel/manager/RelManager.java b/src/main/java/org/ttzero/excel/manager/RelManager.java
index 96c8c423..2363d32f 100644
--- a/src/main/java/org/ttzero/excel/manager/RelManager.java
+++ b/src/main/java/org/ttzero/excel/manager/RelManager.java
@@ -33,7 +33,10 @@ import java.lang.reflect.Field;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
 
 import static org.ttzero.excel.util.FileUtil.exists;
 
@@ -118,6 +121,23 @@ public class RelManager implements Serializable {
         return null;
     }
 
+    public List<Relationship> getAllByTypes(String ... types) {
+        if (relationships == null || relationships.isEmpty() || types == null || types.length == 0)
+            return Collections.emptyList();
+        List<Relationship> list = new ArrayList<>();
+        Set<String> distinctTypes = new HashSet<>(Arrays.asList(types));
+        for (Relationship rel : relationships) {
+            if (distinctTypes.contains(rel.getType())) {
+                list.add(rel);
+            }
+        }
+        return list;
+    }
+
+    public int size() {
+        return relationships != null ? relationships.size() : 0;
+    }
+
     public void write(Path parent, String name) throws IOException {
         if (relationships == null || relationships.isEmpty()) {
             return;
diff --git a/src/main/java/org/ttzero/excel/reader/Dimension.java b/src/main/java/org/ttzero/excel/reader/Dimension.java
index 5999bf04..bcb95c18 100644
--- a/src/main/java/org/ttzero/excel/reader/Dimension.java
+++ b/src/main/java/org/ttzero/excel/reader/Dimension.java
@@ -16,7 +16,7 @@
 
 package org.ttzero.excel.reader;
 
-import static org.ttzero.excel.entity.Sheet.int2Col;
+import static org.ttzero.excel.entity.Sheet.toCoordinate;
 import static org.ttzero.excel.reader.ExcelReader.coordinateToLong;
 
 /**
@@ -148,8 +148,8 @@ public class Dimension {
 
     @Override
     public String toString() {
-        return new String(int2Col(firstColumn)) + this.firstRow
-            + (lastRow > firstRow || lastColumn > firstColumn ? ":" + new String(int2Col(lastColumn)) + this.lastRow
+        return toCoordinate(firstRow, firstColumn)
+            + (lastRow > firstRow || lastColumn > firstColumn ? ":" + toCoordinate(lastRow, lastColumn)
             : "");
     }
 
diff --git a/src/main/java/org/ttzero/excel/reader/Drawings.java b/src/main/java/org/ttzero/excel/reader/Drawings.java
index 9d3fc31e..2a3ff8eb 100644
--- a/src/main/java/org/ttzero/excel/reader/Drawings.java
+++ b/src/main/java/org/ttzero/excel/reader/Drawings.java
@@ -54,41 +54,41 @@ public interface Drawings {
         /**
          * 图片所在的工作表 {@link Sheet}
          */
-        Sheet sheet;
+        public Sheet sheet;
         /**
          * 图片在工作表中的位置，记录图片左上角和右下角的行列坐标
          */
-        Dimension dimension;
+        public Dimension dimension;
         /**
          * 图片的临时路径
          */
-        Path localPath;
+        public Path localPath;
         /**
          * 如果是网络图片，则此属性保留网络图片的原始链接
          */
-        String srcUrl;
+        public String srcUrl;
         /**
          * 是否为背景图片，水印图片
          */
-        boolean background;
+        public boolean background;
         /**
          * 0: Move and size with cells
          * 1: Move but don't size with cells
          * 2: Don't move or size with cells
          */
-        int property;
+        public int property;
         /**
          * Revolve -360 ~ 360
          */
-        int revolve;
+        public int revolve;
         /**
          * Padding top | right | bottom | left
          */
-        short[] padding;
+        public short[] padding;
         /**
          * Picture effects
          */
-        Effect effect;
+        public Effect effect;
 
         public Sheet getSheet() {
             return sheet;
diff --git a/src/main/java/org/ttzero/excel/reader/ExcelReader.java b/src/main/java/org/ttzero/excel/reader/ExcelReader.java
index 3de8518e..5920228a 100644
--- a/src/main/java/org/ttzero/excel/reader/ExcelReader.java
+++ b/src/main/java/org/ttzero/excel/reader/ExcelReader.java
@@ -836,11 +836,11 @@ public class ExcelReader implements Closeable {
      * @return 存在时返回键值对否则返回 {@code null}
      */
     public CustomProperties getCustomProperties() {
-        // Load custom.xml
-        SAXReader reader = SAXReader.createDefault();
         ZipEntry entry = getEntry("docProps/custom.xml");
         if (entry == null) return null;
         Document document = null;
+        // Load custom.xml
+        SAXReader reader = SAXReader.createDefault();
         try {
             document = reader.read(zipFile.getInputStream(entry));
         } catch (DocumentException | IOException e) {
diff --git a/src/main/java/org/ttzero/excel/reader/HeaderRow.java b/src/main/java/org/ttzero/excel/reader/HeaderRow.java
index 56584632..22d5e2c0 100644
--- a/src/main/java/org/ttzero/excel/reader/HeaderRow.java
+++ b/src/main/java/org/ttzero/excel/reader/HeaderRow.java
@@ -53,7 +53,7 @@ import static org.ttzero.excel.entity.IWorksheetWriter.isInt;
 import static org.ttzero.excel.entity.IWorksheetWriter.isLocalDate;
 import static org.ttzero.excel.entity.IWorksheetWriter.isLocalDateTime;
 import static org.ttzero.excel.entity.IWorksheetWriter.isLocalTime;
-import static org.ttzero.excel.entity.Sheet.int2Col;
+import static org.ttzero.excel.entity.Sheet.toCoordinate;
 import static org.ttzero.excel.util.ReflectUtil.listDeclaredFieldsUntilJavaPackage;
 import static org.ttzero.excel.util.ReflectUtil.listDeclaredMethods;
 import static org.ttzero.excel.util.ReflectUtil.writeMethodsMap;
@@ -479,7 +479,7 @@ public class HeaderRow extends Row {
             int colNum = colIndex + 1;
             CellType excelType = row.getCellType(colIndex);
             Class<?> javaType = c.clazz;
-            String msg = "The value in cell '" + new String(int2Col(colNum)) + rowNum + "' is \"" + row.getString(colIndex) + "\"(" + excelType + "), cannot cast to " + javaType.getTypeName();
+            String msg = "The value in cell '" + toCoordinate(rowNum, colNum) + "' is \"" + row.getString(colIndex) + "\"(" + excelType + "), cannot cast to " + javaType.getTypeName();
             throw new TypeCastException(rowNum, colNum, excelType, javaType, msg, ex);
         }
     }
diff --git a/src/main/java/org/ttzero/excel/reader/PreCalc.java b/src/main/java/org/ttzero/excel/reader/PreCalc.java
index f3d3814c..c3bb97f1 100644
--- a/src/main/java/org/ttzero/excel/reader/PreCalc.java
+++ b/src/main/java/org/ttzero/excel/reader/PreCalc.java
@@ -96,7 +96,7 @@ public class PreCalc {
             }
 
             Node next = new Node();
-            next.coordinate = r << 14 | c;
+            next.coordinate = ((long) r) << 14 | c;
 
             tail.next = next;
             tail = next;
diff --git a/src/main/java/org/ttzero/excel/reader/TypeCastException.java b/src/main/java/org/ttzero/excel/reader/TypeCastException.java
index 8d8e5038..2f189c00 100644
--- a/src/main/java/org/ttzero/excel/reader/TypeCastException.java
+++ b/src/main/java/org/ttzero/excel/reader/TypeCastException.java
@@ -1,6 +1,7 @@
 package org.ttzero.excel.reader;
 
 import static org.ttzero.excel.entity.Sheet.int2Col;
+import static org.ttzero.excel.entity.Sheet.toCoordinate;
 
 /**
  * 读取时类型转换异常
@@ -28,7 +29,7 @@ public class TypeCastException extends IllegalArgumentException {
     public final Class<?> to;
 
     public TypeCastException(int row, int col, CellType from, Class<?> to) {
-        super("Can't cast " + from + "to " + to + " in cell '" + new String(int2Col(col)) + row + "'");
+        super("Can't cast " + from + "to " + to + " in cell '" + toCoordinate(row, col) + "'");
         this.row = row;
         this.col = col;
         this.from = from;
diff --git a/src/main/java/org/ttzero/excel/reader/XMLDrawings.java b/src/main/java/org/ttzero/excel/reader/XMLDrawings.java
index 049120d3..75f82bbe 100644
--- a/src/main/java/org/ttzero/excel/reader/XMLDrawings.java
+++ b/src/main/java/org/ttzero/excel/reader/XMLDrawings.java
@@ -105,49 +105,30 @@ public class XMLDrawings implements Drawings {
         ZipFile zipFile = excelReader.zipFile;
         if (zipFile == null) return null;
 
-        // 兼容读取WPS内嵌图片cellimages.xml
-        ZipEntry cellImagesEntry = getEntry(zipFile, "xl/cellimages.xml");
-        // 内嵌图片临时缓存 ID: 临时路径
-        Map<String, Path> cellImagesMapper = cellImagesEntry != null ? listCellImages(zipFile, cellImagesEntry) : null;
-        boolean hasCellImages = cellImagesMapper != null && !cellImagesMapper.isEmpty();
-
-        SAXReader reader = SAXReader.createDefault();
-        Document document;
+        if (excelReader.tempDir == null) {
+            try {
+                excelReader.tempDir = FileUtil.mktmp("eec-");
+            } catch (IOException e) {
+                throw new ExcelReadException("Create temp directory failed.", e);
+            }
+        }
+        Path imagesPath = excelReader.tempDir.resolve("media");
+        if (!Files.exists(imagesPath)) {
+            // Create media path
+            try {
+                Files.createDirectory(imagesPath);
+            } catch (IOException e) {
+                throw new ExcelReadException("Create temp directory failed.", e);
+            }
+        }
 
         List<Picture> pictures = new ArrayList<>();
         for (Sheet sheet : excelReader.sheets) {
             XMLSheet xmlSheet = (XMLSheet) sheet;
-            int i = xmlSheet.path.lastIndexOf('/');
-            if (i < 0) i = xmlSheet.path.lastIndexOf('\\');
-            String fileName = xmlSheet.path.substring(i + 1);
-            ZipEntry entry = getEntry(zipFile, "xl/worksheets/_rels/" + fileName + ".rels");
-            if (entry == null) continue;
-            try {
-                document = reader.read(zipFile.getInputStream(entry));
-            } catch (DocumentException | IOException e) {
-                throw new ExcelReadException("The file format is incorrect or corrupted. [" + entry.getName() + ".rels]");
-            }
-
-            if (excelReader.tempDir == null) {
-                try {
-                    excelReader.tempDir = FileUtil.mktmp("eec-");
-                } catch (IOException e) {
-                    throw new ExcelReadException("Create temp directory failed.", e);
-                }
-            }
-            Path imagesPath = excelReader.tempDir.resolve("media");
-            if (!Files.exists(imagesPath)) {
-                // Create media path
-                try {
-                    Files.createDirectory(imagesPath);
-                } catch (IOException e) {
-                    throw new ExcelReadException("Create temp directory failed.", e);
-                }
-            }
-            List<Element> list = document.getRootElement().elements();
-            for (Element e : list) {
-                String target = e.attributeValue("Target"), type = e.attributeValue("Type");
-                entry = getEntry(zipFile, "xl/" + toZipPath(target));
+            List<Relationship> list = xmlSheet.getRelManager().getAllByTypes(Const.Relationship.DRAWINGS, Const.Relationship.IMAGE);
+            for (Relationship e : list) {
+                String target = e.getTarget(), type = e.getType();
+                ZipEntry entry = getEntry(zipFile, "xl/" + toZipPath(target));
                 // Background
                 if (Const.Relationship.IMAGE.equals(type)) {
                     Picture picture = new Picture();
@@ -175,8 +156,12 @@ public class XMLDrawings implements Drawings {
                 }
             }
 
+            // 兼容读取WPS内嵌图片cellimages.xml
+            ZipEntry cellImagesEntry = getEntry(zipFile, "xl/cellimages.xml");
+            // 内嵌图片临时缓存 ID: 临时路径
+            Map<String, Path> cellImagesMapper = cellImagesEntry != null ? listCellImages(zipFile, cellImagesEntry) : null;
             // WPS内嵌图片兼容处理
-            if (hasCellImages) {
+            if (cellImagesMapper != null && !cellImagesMapper.isEmpty()) {
                 try {
                     pictures.addAll(quickFindCellImages(sheet, cellImagesMapper));
                 } catch (IOException e) {
diff --git a/src/main/java/org/ttzero/excel/reader/XMLRow.java b/src/main/java/org/ttzero/excel/reader/XMLRow.java
index 8acef63c..fe864cef 100644
--- a/src/main/java/org/ttzero/excel/reader/XMLRow.java
+++ b/src/main/java/org/ttzero/excel/reader/XMLRow.java
@@ -594,7 +594,7 @@ class XMLFullRow extends XMLRow {
         // Function string is shared
         else if (cell.si > -1) {
             // Get from ref
-            cell.formula = getCalc(cell.si, (getRowNum() << 14) | cell.i);
+            cell.formula = getCalc(cell.si, (((long) getRowNum()) << 14) | cell.i);
         }
     }
 
diff --git a/src/main/java/org/ttzero/excel/reader/XMLSheet.java b/src/main/java/org/ttzero/excel/reader/XMLSheet.java
index d79ae52f..a537b253 100644
--- a/src/main/java/org/ttzero/excel/reader/XMLSheet.java
+++ b/src/main/java/org/ttzero/excel/reader/XMLSheet.java
@@ -20,10 +20,13 @@ import org.dom4j.Document;
 import org.dom4j.DocumentException;
 import org.dom4j.DocumentHelper;
 import org.dom4j.Element;
+import org.dom4j.io.SAXReader;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.ttzero.excel.entity.Panes;
+import org.ttzero.excel.entity.Relationship;
 import org.ttzero.excel.entity.style.Styles;
+import org.ttzero.excel.manager.RelManager;
 import org.ttzero.excel.util.StringUtil;
 
 import java.io.IOException;
@@ -81,6 +84,7 @@ public class XMLSheet implements Sheet {
         this.zipFile = sheet.zipFile;
         this.entry = sheet.entry;
         this.option = sheet.option;
+        this.relManager = sheet.relManager;
     }
 
     protected String name;
@@ -112,6 +116,8 @@ public class XMLSheet implements Sheet {
     // Simple properties
     // The low 16 bits are allocated to the header, while the high 16 bits are occupied by the sheet
     protected int option;
+    // Relationship Manager
+    protected RelManager relManager;
 
     /**
      * Setting the worksheet name
@@ -411,6 +417,37 @@ public class XMLSheet implements Sheet {
         return this;
     }
 
+    /**
+     * 获取关系管理器
+     *
+     * @return 关系管理器实例
+     */
+    public RelManager getRelManager() {
+        if (relManager == null) {
+            int i = path.lastIndexOf('/');
+            if (i < 0) i = path.lastIndexOf('\\');
+            String fileName = path.substring(i + 1);
+            ZipEntry entry = getEntry(zipFile, "xl/worksheets/_rels/" + fileName + ".rels");
+            if (entry != null) {
+                SAXReader reader = SAXReader.createDefault();
+                try {
+                    Document document = reader.read(zipFile.getInputStream(entry));
+                    List<Element> list = document.getRootElement().elements();
+                    Relationship[] rels = new Relationship[list.size()];
+                    i = 0;
+                    for (Element e : list) {
+                        rels[i++] = new Relationship(e.attributeValue("Id"), e.attributeValue("Target"), e.attributeValue("Type"), e.attributeValue("TargetMode"));
+                    }
+                    relManager = RelManager.of(rels);
+                } catch (DocumentException | IOException e) {
+                    LOGGER.error("The file format is incorrect or corrupted. [{}]", entry.getName());
+                }
+            }
+            if (relManager == null) relManager = new RelManager();
+        }
+        return relManager;
+    }
+
     @Override
     public String toString() {
         return "Sheet id: " + getId() + ", name: " + getName();// + ", dimension: " + getDimension();
@@ -524,7 +561,7 @@ public class XMLSheet implements Sheet {
      *
      * @return Row
      */
-    private XMLRow nextRow() {
+    protected XMLRow nextRow() {
         if (eof) return null;
         boolean endTag = false;
         int start = nChar;
@@ -736,7 +773,7 @@ public class XMLSheet implements Sheet {
     Read from tail and look at the line number of the last line
     to confirm the scope of the entire worksheet.
      */
-    Dimension parseDimension() {
+    protected Dimension parseDimension() {
         try (InputStream is = zipFile.getInputStream(entry)) {
             // Skips specified number of bytes of uncompressed data.
             if (lastRowMark > 0L) is.skip(lastRowMark);
@@ -831,7 +868,7 @@ public class XMLSheet implements Sheet {
         }
     }
 
-    Row createHeader(char[] cb, int start, int n) {
+    protected Row createHeader(char[] cb, int start, int n) {
         return createRow().init(sst, styles, startRow > 0 ? startRow : 1).with(cb, start, n);
     }
 
@@ -935,7 +972,7 @@ class XMLCalcSheet extends XMLFullSheet implements CalcSheet {
     }
 
     @Override
-    Row createHeader(char[] cb, int start, int n) {
+    protected Row createHeader(char[] cb, int start, int n) {
         return createRow().init(sst, styles, this.startRow > 0 ? this.startRow : 1).with(cb, start, n).asCalcRow().setCalcFun(this::findCalc);
     }
 }
@@ -974,7 +1011,7 @@ class XMLMergeSheet extends XMLFullSheet implements MergeSheet {
     }
 
     @Override
-    Row createHeader(char[] cb, int start, int n) {
+    protected Row createHeader(char[] cb, int start, int n) {
         return createRow().init(sst, styles, startRow > 0 ? startRow : 1).with(cb, start, n).asMergeRow();
     }
 }
@@ -1061,7 +1098,7 @@ class XMLFullSheet extends XMLSheet implements FullSheet {
     }
 
     @Override
-    Row createHeader(char[] cb, int start, int n) {
+    protected Row createHeader(char[] cb, int start, int n) {
         return ((XMLRow) super.createHeader(cb, start, n)).asFullRow().setCalcFun(this::findCalc);
     }
 
@@ -1089,7 +1126,7 @@ class XMLFullSheet extends XMLSheet implements FullSheet {
 
     /*
     Parse `mergeCells` tag
-    TODO parse autoFilter and dataValidation
+    TODO parse dataValidation
      */
     Map<String, Object> parseTails() {
         Map<String, Object> tags = new HashMap<>();
@@ -1182,6 +1219,7 @@ class XMLFullSheet extends XMLSheet implements FullSheet {
                                     && buf[i + 12] == 'o' && buf[i + 13] == 'n' && buf[i + 14] <= ' ') {
                                     // TODO
                                 }
+                                break;
                         }
                     }
                 } while ((len = is.read(buf, offset, limit - offset)) > 0 && (len += offset) > 0);
diff --git a/src/main/java/org/ttzero/excel/util/ExtBufferedWriter.java b/src/main/java/org/ttzero/excel/util/ExtBufferedWriter.java
index 1686f82a..7edf3d9a 100644
--- a/src/main/java/org/ttzero/excel/util/ExtBufferedWriter.java
+++ b/src/main/java/org/ttzero/excel/util/ExtBufferedWriter.java
@@ -197,15 +197,15 @@ public class ExtBufferedWriter extends BufferedWriter {
         return negative ? l + 1 : l;
     }
 
-    static void getChars(int i, int index, char[] buf) {
+    public static void getChars(int i, int maxIndex, char[] buf) {
         if (i == 0) {
-            buf[index - 1] = '0';
+            buf[maxIndex - 1] = '0';
             return;
         }
         boolean negative = i < 0;
         if (negative) i = -i;
-        for (; i > 0; buf[--index] = (char) ((i % 10) + '0'), i /= 10);
-        if (negative) buf[--index] = '-';
+        for (; i > 0; buf[--maxIndex] = (char) ((i % 10) + '0'), i /= 10);
+        if (negative) buf[--maxIndex] = '-';
     }
 
     public static char[] toChars(long i) {
@@ -233,15 +233,15 @@ public class ExtBufferedWriter extends BufferedWriter {
         return negative ? l + 1 : l;
     }
 
-    static void getChars(long i, int index, char[] buf) {
+    public static void getChars(long i, int maxIndex, char[] buf) {
         if (i == 0) {
-            buf[index - 1] = '0';
+            buf[maxIndex - 1] = '0';
             return;
         }
         boolean negative = i < 0;
         if (negative) i = -i;
-        for (; i > 0; buf[--index] = (char) ((i % 10) + '0'), i /= 10);
-        if (negative) buf[--index] = '-';
+        for (; i > 0; buf[--maxIndex] = (char) ((i % 10) + '0'), i /= 10);
+        if (negative) buf[--maxIndex] = '-';
     }
 
     // Find malformed characters and return to their location, -1 means OK


Test patch:
diff --git a/src/test/java/org/ttzero/excel/entity/CommentTest.java b/src/test/java/org/ttzero/excel/entity/CommentTest.java
index 1137d383..3c433803 100644
--- a/src/test/java/org/ttzero/excel/entity/CommentTest.java
+++ b/src/test/java/org/ttzero/excel/entity/CommentTest.java
@@ -20,8 +20,10 @@ package org.ttzero.excel.entity;
 import org.junit.Test;
 import org.ttzero.excel.annotation.HeaderComment;
 import org.ttzero.excel.annotation.ExcelColumn;
+import org.ttzero.excel.entity.style.Font;
 import org.ttzero.excel.reader.ExcelReader;
 
+import java.awt.Color;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
@@ -76,6 +78,24 @@ public class CommentTest extends WorkbookTest {
         }
     }
 
+    @Test public void testBodyComments() throws IOException {
+        List<Stock> list = new ArrayList<>();
+        list.add(new Stock(60, StockHealth.HEALTHY));
+        list.add(new Stock(40, StockHealth.NORMAL));
+        list.add(new Stock(10, StockHealth.DANGER));
+
+        Workbook workbook = new Workbook();
+        ListSheet listSheet = new ListSheet<>(list);
+        // 获取Comments
+        Comments comments = listSheet.createComments();
+        // A1单元格添加批注
+        comments.addComment(1, 1, "实际库存");
+        // B4单元格添加批注
+        comments.addComment(4, 2, new Comment("库存不足", "低于警戒线13%,请尽快添加库存").setValueFont(new Font("微软雅黑", 10, Color.RED)));
+        workbook.addSheet(listSheet);
+        workbook.writeTo(defaultTestPath.resolve("Body批注测试.xlsx"));
+    }
+
     /**
      * Annotation Object
      */
@@ -158,4 +178,44 @@ public class CommentTest extends WorkbookTest {
             return "id: " + id + ", name: " + name + ", score: " + score;
         }
     }
+
+    public static class Stock {
+        @ExcelColumn("库存")
+        private int stock;
+        @ExcelColumn(value = "库存健康度", comment = @HeaderComment(value =
+            "健康：库存大于阈值20%\n" +
+            "正常：库存高于阈值10%\n" +
+            "警告：库存高于阈值0～10%\n" +
+            "危险：库存低于阈值10%", width = 120))
+        private StockHealth stockHealth;
+
+        public Stock(int stock, StockHealth stockHealth) {
+            this.stock = stock;
+            this.stockHealth = stockHealth;
+        }
+    }
+
+    public enum StockHealth {
+        HEALTHY("健康", 2),
+        NORMAL("正常", 1),
+        WARNING("警告", 0),
+        DANGER("危险", -1);
+
+        StockHealth(String desc, int threshold) {
+            this.desc = desc;
+            this.threshold = threshold;
+        }
+
+        private final String desc;
+        private final int threshold;
+
+        public String getDesc() {
+            return desc;
+        }
+
+        @Override
+        public String toString() {
+            return desc;
+        }
+    }
 }
diff --git a/src/test/java/org/ttzero/excel/entity/ListObjectSheetTest2.java b/src/test/java/org/ttzero/excel/entity/ListObjectSheetTest2.java
index 39e3e3ba..82964c89 100644
--- a/src/test/java/org/ttzero/excel/entity/ListObjectSheetTest2.java
+++ b/src/test/java/org/ttzero/excel/entity/ListObjectSheetTest2.java
@@ -68,7 +68,9 @@ import java.util.stream.Collectors;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertTrue;
-import static org.ttzero.excel.entity.Sheet.int2Col;
+import static org.ttzero.excel.entity.Sheet.toCoordinate;
+import static org.ttzero.excel.reader.Cell.INLINESTR;
+import static org.ttzero.excel.reader.Cell.SST;
 import static org.ttzero.excel.util.StringUtil.isNotEmpty;
 
 /**
@@ -668,8 +670,7 @@ public class ListObjectSheetTest2 extends WorkbookTest {
                     for (int j = 0; j < realColumnLen; j++) {
                         Column hc = columnsArray[j][i];
                         if (hc.headerComment != null) {
-                            if (comments == null) comments = sheet.createComments();
-                            comments.addComment(new String(int2Col(hc.getRealColIndex())) + row, hc.headerComment);
+                            sheet.createComments().addComment(toCoordinate(row, hc.getRealColIndex()), hc.headerComment);
                         }
                     }
                     bw.write("</row>");
@@ -730,6 +731,98 @@ public class ListObjectSheetTest2 extends WorkbookTest {
         }
     }
 
+    @Test public void testMergeWrap() throws IOException {
+        List<Dimension> mergeList = new ArrayList<>();
+        List<Object[]> data = new ArrayList<>();
+        // Row 1
+        data.add(new String[]{"富裕可不会"});
+        mergeList.add(Dimension.of("A1:G1"));
+        // Row 2
+        data.add(new String[]{"被考核人：张三", null, null, "部门："});
+        mergeList.add(Dimension.of("A2:C2"));
+        mergeList.add(Dimension.of("D2:G2"));
+        // Row 3
+        data.add(new String[]{"序号", "考核维度", "考核内容", "否决项", "最高分", "评分", "备注"});
+        // Row N
+        int r = 4;
+        for (int i = 1; i <= 15; i++, r++) {
+            data.add(new Object[]{i, "维度" + ('A' + i), "内容" + ('A' + 1), "否", 1 + i, 1 + i, "ABC"});
+        }
+        // Row 5
+        data.add(new Object[]{"合计得分：", null, null, null, null, 42});
+        mergeList.add(Dimension.of("A" + r + ":E" + r));
+        mergeList.add(Dimension.of("F" + r + ":G" + r));
+        // Row 6
+        r++;
+        data.add(new Object[]{"考核结果："});
+        mergeList.add(Dimension.of("A" + r + ":E" + r));
+        mergeList.add(Dimension.of("F" + r + ":G" + r));
+        // Row 7
+        r++;
+        data.add(new String[]{"扣分描述：", null, null, "建议措施："});
+        mergeList.add(Dimension.of("A" + r + ":C" + r));
+        mergeList.add(Dimension.of("D" + r + ":E" + r));
+        mergeList.add(Dimension.of("F" + r + ":G" + r));
+        // Row 8
+        r++;
+        data.add(new String[]{"说明：", null, "1：否决项未达到即可得分为0分。\n2:第二行内容。\n3:第三行内容AAAA"});
+        mergeList.add(Dimension.of("A" + r + ":B" + r));
+        mergeList.add(Dimension.of("C" + r + ":G" + r));
+        // Row 9
+        r++;
+        data.add(new String[]{"考评人：", null, "zcc123(P001)", "考评日期：", null, "2024-04-08"});
+        mergeList.add(Dimension.of("A" + r + ":B" + r));
+        mergeList.add(Dimension.of("D" + r + ":E" + r));
+        mergeList.add(Dimension.of("F" + r + ":G" + r));
+
+        new Workbook()
+            .addSheet(new SimpleSheet<>(data)
+                .putExtProp(Const.ExtendPropertyKey.MERGE_CELLS, mergeList)
+                .setSheetWriter(new XMLWorksheetWriter() {
+                    @Override
+                    protected int startRow(Row row) throws IOException {
+                        int line = 1; // 记录回车的个数
+                        Cell[] cells = row.getCells();
+                        for (int i = row.fc; i < row.lc; i++) {
+                            if (cells[i].t == INLINESTR || cells[i].t == SST) {
+                                // 统计回车的个数
+                                line = Math.max(line, countLf(cells[i].stringVal) + 1);
+                            }
+                        }
+                        // 折行重算行高
+                        if (line > 1) {
+                            double rowHeight = row.getHeight() != null ? row.getHeight() : 16.5;
+                            row.setHeight(rowHeight * line);
+                        }
+                        return super.startRow(row);
+                    }
+
+                    @Override
+                    protected void writeString(Cell cell, int row, int col) throws IOException {
+                        // 判断是否包含“回车”
+                        if (cell.stringVal.indexOf("\n") >= 0) {
+                            int xf = cell.xf;
+                            int style = styles.getStyleByIndex(xf);
+                            // 包含“回车”符时默认设置折行
+                            if (!Styles.hasWrapText(style)) {
+                                style = styles.modifyWrapText(style, true);
+                            }
+                            cell.xf = styles.of(style);
+                        }
+                        super.writeString(cell, row, col);
+                    }
+                }))
+            .writeTo(defaultTestPath.resolve("MergeWrap.xlsx"));
+    }
+
+    static int countLf(String s) {
+        int i = s.indexOf(10), c = 0;
+        if (i >= 0) {
+            for (int len = s.length(); i > 0 && c++ < len; i = s.indexOf(10, i + 1)) ;
+        }
+        return c;
+    }
+
     @TreeStyle
     public static class TreeNode {
         @ExcelColumn
@@ -1158,4 +1251,10 @@ public class ListObjectSheetTest2 extends WorkbookTest {
         }
     }
 
+    /**
+     * 支持纵向工作表
+     */
+    public static class VerticalWorksheetWriter extends XMLWorksheetWriter {
+
+    }
 }
diff --git a/src/test/java/org/ttzero/excel/reader/ExcelReaderTest2.java b/src/test/java/org/ttzero/excel/reader/ExcelReaderTest2.java
index 138cdce2..191231ab 100644
--- a/src/test/java/org/ttzero/excel/reader/ExcelReaderTest2.java
+++ b/src/test/java/org/ttzero/excel/reader/ExcelReaderTest2.java
@@ -25,7 +25,10 @@ import org.ttzero.excel.entity.ListMapSheet;
 import org.ttzero.excel.entity.ListObjectSheetTest;
 import org.ttzero.excel.entity.ListSheet;
 import org.ttzero.excel.entity.Panes;
+import org.ttzero.excel.entity.Relationship;
 import org.ttzero.excel.entity.Workbook;
+import org.ttzero.excel.manager.Const;
+import org.ttzero.excel.manager.RelManager;
 import org.ttzero.excel.util.CSVUtil;
 import org.ttzero.excel.util.DateUtil;
 import org.ttzero.excel.util.StringUtil;
@@ -337,6 +340,29 @@ public class ExcelReaderTest2 {
         }
     }
 
+    @Test public void testReadRelManager() throws IOException {
+        try (ExcelReader reader = ExcelReader.read(testResourceRoot().resolve("template2.xlsx"))) {
+            XMLSheet sheet = (XMLSheet) reader.sheet(2);
+            RelManager rel = sheet.getRelManager();
+            assertEquals(rel.size(), 4);
+            Relationship rId1 = rel.getById("rId1");
+            assertEquals(rId1.getTarget(), "../printerSettings/printerSettings3.bin");
+            assertEquals(rId1.getType(), Const.Relationship.PRINTER_SETTINGS);
+
+            Relationship rId2 = rel.getById("rId2");
+            assertEquals(rId2.getTarget(), "../drawings/drawing1.xml");
+            assertEquals(rId2.getType(), Const.Relationship.DRAWINGS);
+
+            Relationship rId3 = rel.getById("rId3");
+            assertEquals(rId3.getTarget(), "../drawings/vmlDrawing1.vml");
+            assertEquals(rId3.getType(), Const.Relationship.VMLDRAWING);
+
+            Relationship rId4 = rel.getById("rId4");
+            assertEquals(rId4.getTarget(), "../comments1.xml");
+            assertEquals(rId4.getType(), Const.Relationship.COMMENTS);
+        }
+    }
+
     public static <T> boolean listEquals(List<T> list, List<T> expectList) {
         if (list == expectList) return true;
         if (list == null || expectList == null) return false;
diff --git a/src/test/java/org/ttzero/excel/reader/ReadStyleTest.java b/src/test/java/org/ttzero/excel/reader/ReadStyleTest.java
index d7b27aee..1e5c004f 100644
--- a/src/test/java/org/ttzero/excel/reader/ReadStyleTest.java
+++ b/src/test/java/org/ttzero/excel/reader/ReadStyleTest.java
@@ -33,6 +33,7 @@ import java.io.IOException;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertTrue;
 import static org.ttzero.excel.reader.ExcelReaderTest.testResourceRoot;
+import static org.ttzero.excel.entity.Sheet.toCoordinate;
 
 /**
  * @author guanquan.wang at 2023-01-03 20:57
@@ -42,7 +43,7 @@ public class ReadStyleTest {
         try (ExcelReader reader = ExcelReader.read(testResourceRoot().resolve("4.xlsx"))) {
             reader.sheet(0).rows().forEach(row -> {
                 for (int i = row.fc; i < row.lc; i++) {
-                    String rc = new String(org.ttzero.excel.entity.Sheet.int2Col(i + 1)) + row.getRowNum();
+                    String rc = toCoordinate(row.getRowNum(), i + 1);
                     Cell cell = row.getCell(i);
                     Styles styles = row.getStyles();
                     int style = row.getCellStyle(cell);