poi-docx 工具类

1,105 阅读8分钟

前言

前段时间接到了处理word文档的需求,因为没接触过,想找找相应的工具类,了解下文档处理的结构,第一时间查了hutool发现里面没有适用的工具方法,于是乎在网上扒了扒各大博客网友的轮子,加上自己的理解,写了自己适用的工具类,方便后续翻阅。

代码

RunStyle

import lombok.Builder;
import lombok.Data;
import org.apache.poi.xwpf.usermodel.XWPFRun;

/**
 * Word文档对象Run,属性对象
 *
 * @author laozhou
 * @date 2021/9/21
 */
@Builder
@Data
public class RunStyle {

    /**
     * 是否加粗:true=加粗、false=不加
     */
    private Boolean bold;

    /**
     * 字体颜色
     * 示例:#000000
     *
     * @return
     */
    private String color;

    /**
     * 字体大小
     */
    private Integer fontSize;

    /**
     * 字体主题
     */
    private String fontFamily;

    /**
     * 是否在尾部携带换行
     */
    private Boolean withNewline;

    /**
     * 设置XWPFRun的style
     *
     * @param run
     */
    public void setStyle(XWPFRun run) {
        if (null != run) {
            run.setBold(null == this.bold ? run.isBold() : this.bold);
            run.setColor(null == this.color ? run.getColor() : this.color);
            run.setFontSize(null == this.fontSize ? run.getFontSize() : this.fontSize);
            run.setFontFamily(null == this.fontFamily ? run.getFontFamily() : this.fontFamily);
            // 是否换行
            if (null != this.withNewline && this.withNewline) {
                run.addBreak();
                //run.addBreak(BreakType.TEXT_WRAPPING);
            }
        }
    }
}

WordUtil

import org.apache.poi.util.Units;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFRun;
import org.apache.poi.xwpf.usermodel.XWPFTable;
import org.apache.poi.xwpf.usermodel.XWPFTableCell;
import org.apache.poi.xwpf.usermodel.XWPFTableRow;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * <h3>poi原生-docx文档工具类</h3>
 * <h3>基本概念说明:</h3>
 * <ul>
 *     <li>XWPFDocument    代表一个docx文档,其可以用来读docx文档,也可以用来写docx文档</li>
 *     <li>XWPFParagraph   代表文档、表格、标题等种的段落,由多个XWPFRun组成</li>
 *     <li>XWPFRun         代表具有同样style的一段文本/区域</li>
 *     <li>XWPFTable       代表一个表格</li>
 *     <li>XWPFTableRow    代表表格的一行</li>
 *     <li>XWPFTableCell   代表表格的一个单元格</li>
 *     <li>XWPFChar        表示.docx文件中的图表</li>
 *     <li>XWPFHyperlink   表示超链接</li>
 *     <li>XWPFPicture     代表图片</li>
 *     <li>……</li>
 *     <li>注意:直接调用XWPFRun的setText()方法设置文本时,在底层会重新创建一个XWPFRun</li>
 * </ul>
 * <br/>
 * <h3>docx相较于doc的区别:</h3>
 * <ul>
 *      <li>在doc文件中中,微软还是用二进制存储方式;在docx中微软开始用xml方式,docx实际上成了一个打包的压缩文件;</li>
 *      <li>同样文件体积小</li>
 *      <li>对复杂对象处理更好,如公式编辑器,甚至可以在文件中播放视频和音频</li>
 *      <li>优化了加密</li>
 *      <li>……</li>
 * </ul>
 *
 * @author laozhou
 */
public class WordUtil {

    private static Logger log = LoggerFactory.getLogger(WordUtil.class);

    private WordUtil() {
    }

    /**
     * 中文Unicode编码值范围正则
     * 注:在使用正则表达式时,利用好其预编译功能,可以有效加快正则匹配速度
     */
    private static Pattern pattern = Pattern.compile("[\u4E00-\u9FA5]");

    /**
     * 创建一个word对象
     *
     * @return
     */
    public static XWPFDocument createDoc() {
        XWPFDocument document = new XWPFDocument();
        return document;
    }

    /**
     * 获取word文档对象
     *
     * @param filePath 文档路径
     * @return
     */
    public static XWPFDocument readDoc(String filePath) {
        InputStream in = null;
        try {
            //XWPFDocument doc = new XWPFDocument(POIXMLDocument.openPackage(filePath));
            in = new FileInputStream(filePath);
            return new XWPFDocument(in);
        } catch (Exception e) {
            throw new RuntimeException("获取word文档对象失败,可能原因:" + e.getMessage());
        } finally {
            close(in);
        }
    }

    /**
     * 获取word文档对象
     *
     * @param in 数据输入流
     * @return
     */
    public static XWPFDocument readDoc(InputStream in) {
        try {
            return new XWPFDocument(in);
        } catch (Exception e) {
            throw new RuntimeException("获取word文档对象失败,可能原因:" + e.getMessage());
        } finally {
            close(in);
        }
    }

    /**
     * 写出word文档至指定路径
     *
     * @param doc     文档对象
     * @param outPath 写出路径,包含文件名称及其后缀
     */
    public static void writeDoc(XWPFDocument doc, String outPath) {
        OutputStream out = null;
        try {
            out = new FileOutputStream(outPath);
            doc.write(out);
        } catch (Exception e) {
            throw new RuntimeException("写出word文档对象失败,可能原因:" + e.getMessage());
        } finally {
            close(out);
        }
    }

    /**
     * 写出word文档至字节数组
     *
     * @param doc 文档对象
     */
    public static byte[] writeDoc(XWPFDocument doc) {
        try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
            doc.write(out);
            return out.toByteArray();
        } catch (Exception e) {
            throw new RuntimeException("写出word文档对象失败,可能原因:" + e.getMessage());
        } finally {
        }
    }

    /**
     * 根据段落,创建一段区域
     * 注:setText方法可以设置文本,但新的文本不会替换旧的文本,而是会追加到原来的文本之后
     *
     * @param paragraph 段落对象
     * @param index     插入的run的位置,从0开始;为空代表则直接创建,即插入尾部;
     * @param text      待写入的文本
     * @return
     */
    public static XWPFRun createRun(XWPFParagraph paragraph, Integer index, String text, RunStyle style) {
        try {
            XWPFRun run;
            if (null == index) {
                run = paragraph.createRun();
            } else {
                for (int i = index; i < text.length() + index; i++) {
                    paragraph.removeRun(i);
                }
                run = paragraph.insertNewRun(index);
            }
            run.setText(text);
            if (null != style) {
                style.setStyle(run);
            }

            return run;
        } catch (Exception e) {
            throw new RuntimeException(e.getMessage());
        }
    }

    /**
     * 创建段落/文本/区域
     *
     * @param cell
     * @param text
     */
    public static XWPFParagraph createParagraph(XWPFTableCell cell, String text, RunStyle style) {
        // 新建一个标题段落对象(就是一段文字)
        XWPFParagraph paragraph = cell.addParagraph();
        // 样式居中
        //paragraph.setAlignment(ParagraphAlignment.CENTER);
        // 创建文本对象
        XWPFRun run = paragraph.createRun();
        // 设置文本名称
        run.setText(text);
        if (null != style) {
            style.setStyle(run);
        }

        return paragraph;
    }

    /**
     * 创建段落/文本/区域
     *
     * @param doc
     * @param text
     */
    public static XWPFParagraph createParagraph(XWPFDocument doc, String text, RunStyle style) {
        // 新建一个标题段落对象(就是一段文字)
        XWPFParagraph paragraph = doc.createParagraph();
        // 样式居中
        //paragraph.setAlignment(ParagraphAlignment.CENTER);
        // 创建文本对象
        XWPFRun run = paragraph.createRun();
        // 设置文本名称
        run.setText(text);
        if (null != style) {
            style.setStyle(run);
        }

        return paragraph;
    }

    /**
     * 覆盖段落/文本/区域
     *
     * @param doc
     * @param index 段落索引号,从0开始
     * @param text
     * @param style 段落文本样式
     */
    public static XWPFParagraph converParagraph(XWPFDocument doc, int index, String text, RunStyle style) {
        // 新建一个标题段落对象(就是一段文字)
        XWPFParagraph paragraph = doc.createParagraph();
        List<XWPFParagraph> paragraphs = doc.getParagraphs();
        // 创建文本对象
        XWPFRun run = paragraph.createRun();
        // 设置文本名称
        run.setText(text);
        // 样式设置,必须放在段落移除之前,否则报错
        if (null != style) {
            style.setStyle(run);
        }
        int indexLen = paragraphs.size() - 1;
        if (index != indexLen) {
            // 覆盖段落
            doc.setParagraph(paragraph, index);
            // 去除尾部的段落
            doc.removeBodyElement(doc.getPosOfParagraph(paragraphs.get(indexLen)));
        }
        return paragraph;
    }

    /**
     * 根据模板生成新word文档
     * 判断替换/插入逻辑:有$为替换,无$为插入
     * 注:如果遇到poi读取例如{name}不能识别为一个整体,可以使用word的域操作;
     * 如果不太清楚域的使用,可以这么操作,先在text文档中写好,例如{name},然后再整个复制到word中,不要一个一个在word中敲,不然有可能不会被poi识别为一个整体
     *
     * @param doc       word文档对象
     * @param textMap   需要替换的信息集合
     * @param tableList 需要插入的表格信息集合
     * @return 成功返回true, 失败返回false
     */
    public static boolean changWord(XWPFDocument doc,
                                    Map<String, String> textMap, List<String[]> tableList) {
        boolean flag = true;
        try {
            // 解析替换文本段落对象
            changeText(doc, textMap);
            // 解析替换表格对象
            changeTable(doc, textMap, tableList);
        } catch (Exception e) {
            flag = false;
        }
        return flag;
    }

    /**
     * 替换段落文本
     *
     * @param document docx解析对象
     * @param textMap  需要替换的信息集合
     */
    public static void changeText(XWPFDocument document, Map<String, String> textMap) {
        // 获取段落集合
        List<XWPFParagraph> paragraphs = document.getParagraphs();
        for (XWPFParagraph paragraph : paragraphs) {
            // 判断此段落时候需要进行替换
            String text = paragraph.getText();
            if (checkText(text)) {
                List<XWPFRun> runs = paragraph.getRuns();
                for (XWPFRun run : runs) {
                    // 替换模板原来位置
                    run.setText(changeValue(run.toString(), textMap), 0);
                }
            }
        }
    }

    /**
     * 替换表格对象方法
     *
     * @param document  docx解析对象
     * @param textMap   需要替换的信息集合
     * @param tableList 需要插入的表格信息集合
     */
    public static void changeTable(XWPFDocument document, Map<String, String> textMap,
                                   List<String[]> tableList) {
        // 获取表格对象集合
        List<XWPFTable> tables = document.getTables();
        for (int i = 0; i < tables.size(); i++) {
            // 只处理行数大于等于2的表格,且不循环表头
            XWPFTable table = tables.get(i);
            if (table.getRows().size() > 1) {
                // 判断表格是需要替换还是需要插入,判断逻辑有$为替换,表格无$为插入
                if (checkText(table.getText())) {
                    List<XWPFTableRow> rows = table.getRows();
                    // 遍历表格,并替换模板
                    eachTable(rows, textMap);
                } else {
                    insertTable(table, tableList);
                }
            }
        }
    }

    /**
     * 遍历表格
     *
     * @param rows    表格行对象
     * @param textMap 需要替换的信息集合
     */
    public static void eachTable(List<XWPFTableRow> rows, Map<String, String> textMap) {
        for (XWPFTableRow row : rows) {
            List<XWPFTableCell> cells = row.getTableCells();
            for (XWPFTableCell cell : cells) {
                // 判断单元格是否需要替换
                if (!checkText(cell.getText())) {
                    continue;
                }
                List<XWPFParagraph> paragraphs = cell.getParagraphs();
                for (XWPFParagraph paragraph : paragraphs) {
                    List<XWPFRun> runs = paragraph.getRuns();
                    for (XWPFRun run : runs) {
                        run.setText(changeValue(run.toString(), textMap), 0);
                    }
                }
            }
        }
    }

    /**
     * 为制式表格插入数据,行数不够添加新行
     *
     * @param table     需要插入数据的表格
     * @param tableList 插入数据集合
     */
    public static void insertTable(XWPFTable table, List<String[]> tableList) {
        if (null == tableList || 0 == tableList.size()) {
            return;
        }
        // 创建行,根据需要插入的数据添加新行,不处理表头
        for (int i = 1; i < tableList.size(); i++) {
            table.createRow();
        }
        // 遍历表格插入数据
        List<XWPFTableRow> rows = table.getRows();
        for (int i = 1; i < rows.size(); i++) {
            XWPFTableRow newRow = table.getRow(i);
            List<XWPFTableCell> cells = newRow.getTableCells();
            for (int j = 0; j < cells.size(); j++) {
                XWPFTableCell cell = cells.get(j);
                cell.setText(tableList.get(i - 1)[j]);
            }
        }
    }

    /**
     * 插入word表格的指定位置
     *
     * @param table  word表格对象
     * @param rowNum 行号索引,从0开始
     * @param colNum 列号索引,从0开始
     * @param text   待插入的文本
     * @description
     */
    public static void insertText(XWPFTable table, int rowNum, int colNum, String text) {
        try {
            List<XWPFTableRow> rows = table.getRows();
            XWPFTableRow row = rows.get(rowNum);
            List<XWPFTableCell> cells = row.getTableCells();
            XWPFTableCell cell = cells.get(colNum);
            cell.setText(text);
            //cell.setVerticalAlignment(XWPFTableCell.XWPFVertAlign.CENTER);
        } catch (Exception e) {
            throw new RuntimeException(e.getMessage());
        }
    }

    /**
     * 重构段落不合格的占位符
     * 比如:${abcd}分开为${a,b..,cd}
     *
     * @param paragraph
     */
    public static void refactorParagraph(XWPFParagraph paragraph) {
        int start = -1;
        int end = -1;
        List<XWPFRun> runs = paragraph.getRuns();
        for (int i = 0; i < runs.size(); i++) {
            String runText = runs.get(i).toString();
            if ('$' == runText.charAt(0) && '}' == runText.charAt(runText.length() - 1)) {
                continue;
            }
            if ('$' == runText.charAt(0)) {
                start = i;
            }
            if ('}' == runText.charAt(runText.length() - 1)) {
                end = i;
                break;
            }
        }
        if (start != -1) {
            mergeRun(paragraph, start, end);
            // 递归
            refactorParagraph(paragraph);
        }
    }

    /**
     * 合并
     *
     * @param paragraph
     * @param start
     * @param end
     */
    public static void mergeRun(XWPFParagraph paragraph, int start, int end) {
        // 删除次数
        int removeCount = end - start;
        // 删除开始位置
        int removeIndex = start + 1;
        List<XWPFRun> runs = paragraph.getRuns();
        StringBuilder sb = new StringBuilder();
        sb.append(runs.get(start).toString());

        for (int i = 0; i < removeCount; i++) {
            sb.append(runs.get(removeIndex).toString());
            paragraph.removeRun(removeIndex);
        }
        runs.get(start).setText(sb.toString(), 0);
    }

    /**
     * 匹配传入信息集合与模板
     *
     * @param value   模板需要替换的区域
     * @param textMap 传入信息集合
     * @return 模板需要替换区域信息集合对应值
     */
    public static String changeValue(String value, Map<String, String> textMap) {
        Set<Map.Entry<String, String>> textSets = textMap.entrySet();
        for (Map.Entry<String, String> textSet : textSets) {
            // 匹配模板与替换值 格式${key}
            String key = "${" + textSet.getKey() + "}";
            if (value.indexOf(key) != -1) {
                value = textSet.getValue();
            }
        }
        // 模板未匹配到区域替换为空
        if (checkText(value)) {
            value = "";
        }
        return value;
    }

    /**
     * 判断文本中时候包含$
     *
     * @param text 文本
     * @return 包含返回true, 不包含返回false
     */
    public static boolean checkText(String text) {
        boolean check = false;
        if (text.indexOf("$") != -1) {
            check = true;
        }
        return check;
    }

    /**
     * 根据空格数量创建空行
     *
     * @param count 空格数量
     * @return
     */
    public static String createSpace(int count) {
        StringBuffer sb = new StringBuffer();
        String space = " ";
        for (int i = 0; i < count; i++) {
            sb.append(space);
        }
        return sb.toString();
    }

    /**
     * 根据空格数量创建空行
     *
     * @param totalCount 空格数量
     * @param text       占用字符串
     * @return
     */
    public static String createSpaceByUse(int totalCount, String text) {
        int useSpaceCount = getUseSpace(text);
        return createSpace(totalCount - useSpaceCount);
    }

    /**
     * 获取字符串所占用的空格数
     *
     * @param str 目标字符串
     * @return
     * @describe 中文字符默认占2个空格,其他为1
     */
    public static int getUseSpace(String str) {
        if (null == str || 0 == str.length()) {
            return 0;
        }
        int totalLen = 0;
        for (int i = 0; i < str.length(); i++) {
            char ch = str.charAt(i);
            boolean b = isChinese(ch);
            totalLen += b ? 2 : 1;
        }
        return totalLen;
    }

    /**
     * 根据TAB数量创建TAB
     *
     * @param count TAB数量
     * @return
     */
    public static String createTab(int count) {
        StringBuffer sb = new StringBuffer();
        String space = "\t";
        for (int i = 0; i < count; i++) {
            sb.append(space);
        }
        return sb.toString();
    }

    /**
     * 关闭流
     *
     * @param closeable
     */
    public static void close(AutoCloseable closeable) {
        if (null != closeable) {
            try {
                closeable.close();
            } catch (Exception e) {
            }
        }
    }

    /**
     * 校验一个字符是否是中文(包括汉字、标点符号)
     * 方法:根据byte的长度判断,英文的字母数字好标点符号都是一个byte,且值在0-255之间
     *
     * @param ch 待校验字符
     * @return true代表是中文汉字
     */
    public static boolean isChinese(char ch) {
        try {
            return String.valueOf(ch).getBytes("UTF-8").length > 1;
        } catch (Exception e) {
            return false;
        }
    }

    /**
     * 定制化构建尾部段落
     *
     * @param doc   doc文档对象
     * @param index 索引
     * @param text1 文本一
     * @param in    图片流一
     * @param text2 文本二
     * @param in2   图片流二
     * @param style 段落样式对象
     * @description 图片占位说明:图片宽度设为60,大概占12位空格,比例5:1;图片会根据所设宽度自适应比例
     */
    public static XWPFParagraph converTailParagraph4Export(XWPFDocument doc, int index, String text1, InputStream in, String text2, InputStream in2, RunStyle style) {
        try {
            int totalSpace = 32;
            int width = 60;
            int heigth = 14;
            int picSpace = width / 5;
            XWPFParagraph paragraph = doc.createParagraph();
            // 填充文本一
            XWPFRun run1 = paragraph.createRun();
            run1.setText(WordUtil.createSpace(4) + text1);
            // 填充图片一
            XWPFRun run2 = paragraph.createRun();
            if (null != in) {
                // 在run后面追加图片,不会覆盖该run本身
                run2.addPicture(in, XWPFDocument.PICTURE_TYPE_JPEG, "fileName", Units.toEMU(width), Units.toEMU(heigth));
            } else {
                run2.setText(WordUtil.createSpace(12));
            }
            // 填充文本二
            XWPFRun run3 = paragraph.createRun();
            run3.setText(WordUtil.createSpace(totalSpace - picSpace) + text2);
            // 填充图片二
            XWPFRun run4 = paragraph.createRun();
            if (null != in2) {
                run4.addPicture(in2, XWPFDocument.PICTURE_TYPE_JPEG, "fileName", Units.toEMU(width), Units.toEMU(heigth));
            } else {
            }
            // 统一设置样式,样式设置必须放在元素移除之前,否则报错:XmlValueDisconnectedException
            if (null != style) {
                style.setStyle(run1);
                style.setStyle(run3);
            }
            // 段落覆盖处理
            List<XWPFParagraph> paragraphs = doc.getParagraphs();
            int indexLen = paragraphs.size() - 1;
            if (index != indexLen) {
                doc.setParagraph(paragraph, index);
                doc.removeBodyElement(doc.getPosOfParagraph(paragraphs.get(indexLen)));
            }

            return paragraph;
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException(e.getMessage());
        } finally {
            //close(in);
            //close(in2);
        }
    }

    /**
     * 定制化构建头部段落
     *
     * @param doc     doc文档对象
     * @param index   索引
     * @param text1   文本一
     * @param dateStr 日期字符串
     * @param text2   文本二
     * @param numStr  索引号
     * @param style   样式对象
     * @description 注意事项:
     * 1、调用示例:WordUtil.converHeadParagraph4Export(doc, 1, "日期:", dateStr, "索引号:", resp.getAuditRecordNo(), RunStyle.builder().bold(true).build());
     * 2、调用时若不设置字体样式,则会沿用上个段落文本的样式
     */
    public static XWPFParagraph converHeadParagraph4Export(XWPFDocument doc, int index, String text1, String dateStr, String text2, String numStr, RunStyle style) {
        try {
            int totalSpace = 40;
            XWPFParagraph paragraph = doc.createParagraph();
            // 填充文本一
            XWPFRun run1 = paragraph.createRun();
            run1.setText(text1);
            run1.setBold(true);
            // 填充日期字符串
            XWPFRun run2 = paragraph.createRun();
            run2.setText(dateStr);
            XWPFRun run2HalfRun = paragraph.createRun();
            run2HalfRun.setText(WordUtil.createSpaceByUse(totalSpace, dateStr));
            // 填充文本二
            XWPFRun run3 = paragraph.createRun();
            run3.setText(text2);
            // 填充索引号
            XWPFRun run4 = paragraph.createRun();
            run4.setText(numStr);
            // 统一设置样式,样式设置必须放在元素移除之前,否则报错:XmlValueDisconnectedException
            if (null != style) {
                style.setStyle(run1);
                style.setStyle(run3);
            }
            // 段落覆盖处理,先进行覆盖处理
            List<XWPFParagraph> paragraphs = doc.getParagraphs();
            int indexLen = paragraphs.size() - 1;
            if (index != indexLen) {
                doc.setParagraph(paragraph, index);
                doc.removeBodyElement(doc.getPosOfParagraph(paragraphs.get(indexLen)));
            }

            return paragraph;
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException(e.getMessage());
        }
    }

    /**
     * 将Run插入Paragraph中
     *
     * @param doc       docx文档对象
     * @param paraIndex 段落索引号,从0开始
     * @param runIndex  待插入Run的目标索引号,从0开始,要根据已存在的run的index来插入,否则会报错
     * @param text      待插入的文本
     * @return
     */
    public static XWPFParagraph breakInParagraphByRun(XWPFDocument doc, int paraIndex, int runIndex, String text, RunStyle style) {
        try {
            List<XWPFParagraph> paragraphs = doc.getParagraphs();
            XWPFParagraph para = paragraphs.get(paraIndex);
            XWPFRun run = para.insertNewRun(runIndex);
            run.setText(text);
            if (null != style) {
                style.setStyle(run);
            }
            return para;
        } catch (Exception e) {
            throw new RuntimeException(e.getMessage());
        }
    }
}

poi操作docx文档心得

  1. 上述工具代码涵盖功能:读/写docx文档XWPFDocument、创建/覆盖段落XWPFParagraph、创建/合并/插入文本区域XWPFRun、填充/插入表格XWPFTable、填充图片、使用${自定义名称}作模板关键字的替换等,适合用于文档定制化开发。

  2. 开发中应对问题的一些处理方法和注意事项:

    • 使用空格计算并填充文本区域,把样式撑起来,如下图;
    • 使用模板文件开发时,模板文件的一些空白的文本区域尽量使用空格号来填充;
    • 填充图片时,注意图片宽度与空格的比例,避免在复杂需求中文档样式会错乱;
    • 填充n张图片时,就需要n个不同的流对象(对于重复的图片流,poi只读取一次,生成唯一的标识,后续的填充数据则会被处理为null)。

图.png