Java实现Word文档批注/评论功能

1.前言

本文章将结合网上大佬的一些案例来介绍Java利用poi对Word文档批注(评论的删除、新增、修改、查询)功能的实现。

2.Word文档docx类型简介及批注实现

首先,先简单介绍下docx类型的Word文档的结构。

先创建一个docx类型的文档,然后写一些内容,并选择部分内容进行批注。

输入批注文本。

其中, "基金" 左右两边的"|",也就是淡红色区域是批注的范围,右边的"mocheng"是批注的作者,"不合法词汇"是批注的内容。这些是重点,需要记住下。

然后复制一份文档,将后缀名改完zip。

右键zip压缩包选择目录进行解压,内容如下。

进入word目录

这里有两个重要的文件:comments.xml -- 存放批注内容 、 document.xml -- 存放正文内容,我们将其都复制到idea中打开进行格式化,这样容易看些。

document.xml

这个就是刚刚我对 "投资者开户当日, 是否可以购买基金" 这句话中的 "基金" 进行批注的内容。

这里有几个重要的标签需要知道:

<w:p> : 代表段落, 标签包含的内容即为一个段落的全部内容。
<w:r> : 段落中的样式串内容域(个人理解), 一个段落中每添加一种样式就会有一个这个标签进行包裹样式的内容, 所以一个段落样式多的话会有很多的这种标签。
<w:commentRangeStart w:id=""/> 和 <w:commentRangeEnd w:id=""/> : 这个两个标签是用来包裹被标记内容的, 我对 "基金" 进行了批注, 所以单独用一个<w:r> 包裹住了这个词语, 然后再用这两个标签包裹住了,这就是我上面说的批注的范围。
<w:commentReference w:id=""/>: 批注的引用内容。

comment.xml

<w:comment w:author="mocheng" w:initials="" w:date="2023-08-25T17:21:16.414+08:00" w:id="9"> : 这个标签内就是我的批注内容, 属性 w:id="9" 是这个批注内容的id , 所以上面我们的范围标签和引用标签内的id的值要跟这个保持一致, 这就是批注实现标记的原理。

其实,对docx类型的Word文档进行批注就是操作的这两个文件,原理:将标记内容从原先的<w:r> 中拆分出来用单独的<w:r> 包裹,然后在comment.xml文件中先插入一个批注内容获得id的值,再生成批注引用和范围标记的标签并设置id值。

下面开始实现。

3.环境

poi版本:4.1.2

jdk:1.8以上

        <dependency>
            <groupId>org.apache.poi</groupId>
            <artifactId>poi</artifactId>
            <version>4.1.2</version>
        </dependency>
        <dependency>
            <groupId>org.apache.poi</groupId>
            <artifactId>poi-ooxml</artifactId>
            <version>4.1.2</version>
        </dependency>
        <dependency>
            <groupId>org.apache.poi</groupId>
            <artifactId>poi-scratchpad</artifactId>
            <version>4.1.2</version>
        </dependency>

4.实现

继承POIXMLDocumentPart 实现批注内容的基本操作。

读取comment.xml文件

重写commit方法, 实现新增内容的保存。

读取文档处理正文:

这里需要注意,表格包含段落 , 而不是段落包含表格。

处理段落,先读取原始段落,进行前提处理

 /**
     * 对段落中的原始run进行处理 , 并设置图片的批注
     *
     * @return 当前段落的文本
     */
    private String dealAllSourceRunData(XWPFParagraph paragraph, Map<Integer, XWPFRun> charRunMap,
                                        Map<XWPFRun, List<Integer>> runCharMap,
                                        Map<XWPFRun, Integer> runMap) {
​
        // 获取删除的批注内容
        List<BigInteger> clearCommentIdList = docxComments.getClearCommentIdList();
        // 过滤器, 用于删除对应批注的标签
        Predicate<CTMarkup> p = ctMarkup -> clearCommentIdList.contains(ctMarkup.getId());
        // 段落文本
        StringBuilder paragraphText = new StringBuilder(64);
​
        // 当前段落文本长度
        int length = 0;
        for (int i = 0; i < paragraph.getRuns().size(); i++) {
            XWPFRun run = paragraph.getRuns().get(i);
            if (!CollectionUtils.isEmpty(run.getCTR().getDelTextList())) {
                // 如果启用了审阅(修订)并且这是一次已删除的run,则不包括此run
                continue;
            }
            // WPS在线编辑生成的docx文档, 因在线WPS在线编辑插入的内容和其它内容不在同一级 , 每个run都遍历并清一遍父节点下的所有批注范围标签
            DocxHelper.clearRunCommentStartAndEndXml(run, clearCommentIdList);
            runMap.put(run, i);
            // 删除需要删除的批注的引用
            run.getCTR().getCommentReferenceList().removeIf(p);
            String text = run.text();
            if (!StringUtils.isEmpty(text)) {
                paragraphText.append(text);
                // 处理段落每个字符索引所对应的run
                for (int ch = 0; ch < text.length(); ch++) {
                    int index = ch + length;
                    charRunMap.put(index, run);
                    if (runCharMap.containsKey(run)) {
                        List<Integer> indexList = runCharMap.get(run);
                        indexList.add(index);
                    } else {
                        List<Integer> indexList = new ArrayList<>();
                        indexList.add(index);
                        runCharMap.put(run, indexList);
                    }
                }
​
                length += text.length();
            }
​
            //TODO 图片内容提取并检测批注
            // 获取run中的图片id
            /*List<String> imageInRunList = DocxUtils.getImageInRun(run);
            for (String blipId : imageInRunList) {
                XWPFPictureData pictureData = paragraph.getDocument().getPictureDataByID(blipId);
                byte[] data = pictureData.getData();
                File imageFile = new File(fileDirectory + File.separator + UUID.randomUUID() + ".jpg");
            }*/
        }
​
        return paragraphText.toString();
    }
 

注: XWPFParagraph 为段落 <w:p>XWPFRun 为段落中的 <w:r>

新增批注:

暂时存储每个run所包含的commentId

然后统一处理插入范围和引用标签。 注:处理完段落后统一处理是为了减少拆分run的时候导致标签错乱的问题

批注核心

/**
     * 在指定索引位置切割run
     *
     * @param index 所切割字符所在当前段落文本的索引位置, 在字符的左边切割
     */
    public XWPFRun splitRunOnIndex(XWPFParagraph paragraph, Map<Integer, XWPFRun> charRunMap, Map<XWPFRun, List<Integer>> runCharMap,
                                   Map<XWPFRun, Integer> runMap, Integer index, XWPFRun run, List<Integer> indexList) {
        // 当前需要处理的段落字符所属的run的字符的所在run文本的索引
        int runTextIndex = 0;
        // 新的原始run的文本字符索引集
        List<Integer> newSourceIndexList = new ArrayList<>();
        // 新的原始run的文本字符索引集
        List<Integer> newRunIndexList = new ArrayList<>();
        // 获取当前不合法词汇字符索引所在run文本的位置
        for (int i = 0; i < indexList.size(); i++) {
            Integer textCharIndex = indexList.get(i);
            if (textCharIndex.compareTo(index) < 0) {
                newSourceIndexList.add(textCharIndex);
                continue;
            }
            if (Objects.equals(textCharIndex, index)) {
                runTextIndex = i;
            }
            newRunIndexList.add(textCharIndex);
        }
​
        String runText = run.text();
        String sourceRunText = runText.substring(0, runTextIndex);
        // pos 为run中的非修订删除的文本标签集合的索引 , 此时无法找到<w:delText>(修订的文本)标签的内容
        run.setText(sourceRunText, 0);
​
        String newRunText = runText.substring(runTextIndex);
​
        int newRunIndex = runMap.get(run) + 1;
        // 在旧run的索引之后新增一个run, 保存切割后的新文本
        XWPFRun newRun;
        if (newRunIndex == runMap.size()) {
            // 要指定新增的索引位置已超出段落以后的run的索引( 旧的run已是当前段落的最后一个run )
            newRun = paragraph.createRun();
        } else {
            // 指定位置插入新的run
            int rSize = Optional.ofNullable(paragraph)
                    .map(XWPFParagraph::getCTP)
                    .map(CTP::getRList)
                    .map(List::size)
                    .orElse(0);
            if (rSize <= newRunIndex) {
                for (int i = 0; i <= newRunIndex - rSize; i++) {
                    paragraph.getCTP().addNewR();
                }
            }
            newRun = paragraph.insertNewRun(newRunIndex);
        }
        newRun.setText(newRunText);
        DocxHelper.copyStyle(run, newRun);
        // 更新字符索引集
        for (int i = sourceRunText.length(); i > 0; i--) {
            charRunMap.put(index - i, run);
        }
        for (int i = 0; i < newRunText.length(); i++) {
            charRunMap.put(index + i, newRun);
        }
        // 更新run的索引集
        runCharMap.put(run, newSourceIndexList);
        runCharMap.put(newRun, newRunIndexList);
        // 更新段落的run的索引
        for (Map.Entry<XWPFRun, Integer> entry : runMap.entrySet()) {
            Integer value = entry.getValue();
            XWPFRun key = entry.getKey();
            if (value.compareTo(runMap.get(run)) > 0) {
                runMap.put(key, value + 1);
            }
        }
        runMap.put(newRun, runMap.get(run) + 1);
        return newRun;
    }
​
​


   /**
     * 对当前段落进行批注
     * 前提: 正文内容不会发生增或减
     * 原理: 1.先记录段落每个字符的索引所对应的run, 记录每个run所包含的字符索引, 记录每个run的索引
     * 2.然后获取不合法词汇的首尾字符在段落中的索引, 在此索引位置分割run , 刷新上一步的每个集合, 并保存run的标签前所设置的批注的开始和结束范围标签
     * 3.最后统一处理在对应的run中设置范围标签和批注引用
     *
     * @param paragraph 当前段落
     */
    private void dealDocxParagraph(XWPFParagraph paragraph) {
        // 当前段落每个字符索引索对应的run集合
        Map<Integer, XWPFRun> charRunMap = new HashMap<>((int) (paragraph.getText().length() / 0.75));
        // 当前段落每个run所包含的文字的全部索引, 索引集正序
        Map<XWPFRun, List<Integer>> runCharMap = new HashMap<>((int) (paragraph.getRuns().size() / 0.75));
        // 当前段落每个run所属段落的索引
        Map<XWPFRun, Integer> runMap = new HashMap<>((int) (paragraph.getRuns().size() / 0.75));
​
        // 批注标签的范围标签集合
        Map<XWPFRun, List<BigInteger>> commentRangeStartMap = new HashMap<>(16);
        Map<XWPFRun, List<BigInteger>> commentRangeEndMap = new HashMap<>(16);
​
        // 处理原始段落所有run的数据
        String paragraphText = dealAllSourceRunData(paragraph, charRunMap, runCharMap, runMap);
        // 智检
        Map<String, List<Integer>> matchList = acMatchUtils.match(paragraphText);
​
        for (Map.Entry<String, List<Integer>> entry : matchList.entrySet()) {
            String word = entry.getKey();
            for (Integer startIndex : entry.getValue()) {
                // 创建当前不合法词的批注
                BigInteger commentId = docxComments.createComment(addCommentMap.get(word));
​
                // -------------处理批注范围的开始标签-------------
                // 当前索引所属字符所属的run
                XWPFRun run = charRunMap.get(startIndex);
                // 当前run的文本字符原始索引集
                List<Integer> indexList = runCharMap.get(run);
                if (Objects.equals(startIndex, indexList.get(0))) {
                    // 新增当前敏感词批注范围的开始标签
                    addCommentIdToMap(commentRangeStartMap, commentId, run);
                } else {
                    XWPFRun newRun = splitRunOnIndex(paragraph, charRunMap, runCharMap, runMap, startIndex, run, indexList);
                    // 新增当前敏感词批注范围的开始标签
                    addCommentIdToMap(commentRangeStartMap, commentId, newRun);
                }
​
                // -------------处理批注范围的结束标签-------------
                // 当前批注文字的结束字符所在段落文本的索引
                int endIndex = startIndex + word.length() - 1;
                if (endIndex == paragraphText.length() - 1) {
                    paragraph.createRun();
                }
                XWPFRun endRun = charRunMap.get(endIndex);
                // 当前run的文本字符原始索引集
                indexList = runCharMap.get(endRun);
                if (!Objects.equals(endIndex, indexList.get(indexList.size() - 1))) {
                    // 在结束字符的下一个字符索引位置切割
                    splitRunOnIndex(paragraph, charRunMap, runCharMap, runMap, endIndex + 1, endRun, indexList);
                } else {
                    // 当前位置是旧的run的文本的结束位置, 如果旧的run有结束标签, 则调整结束标签的位置
                    if (commentRangeEndMap.containsKey(run)) {
                        for (int i = 0; i < commentRangeEndMap.get(run).size(); i++) {
                            addCommentIdToMap(commentRangeEndMap, commentRangeEndMap.get(run).get(i), endRun);
                            commentRangeEndMap.get(run).remove(i);
                        }
                    }
                }
                // 新增当前敏感词批注范围的结束标签
                addCommentIdToMap(commentRangeEndMap, commentId, endRun);
            }
        }
​
        // 开始统一处理批注的范围标签, 若在新增批注的遍历中同时新增范围标签, 可能会因为拆分run并在指定位置插入新run的时候导致范围标签位置错误
        addCommentLabel(commentRangeStartMap, true);
        addCommentLabel(commentRangeEndMap, false);
    }

5.doc文档

由于doc文件的结构性,poi是不支持对doc文档进行批注的 ,所以想对doc文档进行批注的话,可以换个思路:在window环境下, 调用Word或WPS的api将doc文件转换为docx文件。

5.1 jacob简介

官方描述(谷歌翻译): Jacob 是一个 Java 库,它允许 Java 应用程序与 Microsoft Windows DLL 或 COM 库进行通信。它通过使用自定义 DLL 来实现这一点,Jacob Java 类通过 JNI 与该 DLL 进行通信。Java 库和 dll 将 Java 开发人员与底层 Windows 库隔离,以便 Java 开发人员不必编写自定义 JNI 代码。Jacob 不用于创建 ActiveX 插件或 Microsoft Windows 应用程序内部的其他模块。

下载地址: Releases · freemansoft/jacob-project · GitHub

5.2实现

1.下载jacob并解压

2.将jacob-1.20/jacob-1.20-x64.dlljacob-1.20/jacob-1.20-x64.dll 两个文件放到JAVA_HOME/bin目录下, 其实是为了放到环境变量中,,可自行配置。

3.导入依赖到Maven仓库(我是Maven工程)

mvn install:install-file -DgroupId=com.jacob -DartifactId=jacob -Dversion=1.20 -Dpackaging=jar -Dfile=jacob-1.20/jacob.jar
​
-Dfile:jacob的jar包路径 ,可写成解压后的绝对路径

4.核心实现

/**
     * doc文件转docx文件
     *
     * @param multipartFile 被转换的文件, 所在目录必须可访问 , 会把转换后的文件放在当前目录下
     * @return 转换后的文件
     */
    public File convertDocToDocx(MultipartFile multipartFile) throws Exception {
​
        final String originalFilename = multipartFile.getOriginalFilename();
​
        final int index = originalFilename.lastIndexOf(".");
        String suffix = originalFilename.substring(index);
​
​
        final String uuId = UUID.randomUUID().toString();
        // 原始文件路径
        String filePathOriginal = "/opt/" + uuId + suffix;
        File fileOriginal = new File(filePathOriginal);
        // 转换后的文件路径
        String converterFileName = "/opt/" + uuId + ".docx";
​
        // 金山WPS : KWPS.Application
        // word应用程序 : Word.Application
        // office的PPT : PowerPoint.Application
        ActiveXComponent app = new ActiveXComponent(ApplicationType.KWPS.getKey());
        try (FileOutputStream out = new FileOutputStream(fileOriginal);
             final InputStream in = multipartFile.getInputStream()) {
            FileCopyUtils.copy(in, out);
            log.info("*********正在转换************filePathOriginal:{}", filePathOriginal);
            // 设置应用程序不可见
            app.setProperty("Visible", new Variant(false));
            // documents表示word程序的所有文档窗口,(word是多文档应用程序), Presentations表示PDF
            Dispatch docs = app.getProperty("Documents").toDispatch();
            // 打开要转换的word文件
            Dispatch doc = Dispatch.invoke(
                            docs,
                            "Open",
                            Dispatch.Method,
                            new Object[]{fileOriginal.getAbsolutePath(), new Variant(false), new Variant(true)},
                            new int[1])
                    .toDispatch();
​
            // 作为type格式保存到临时文件
            // *Variant(0):doc
            // *Variant(1):dot
            // *Variant(2-5),Variant(7):txt
            // *Variant(6):rft
            // *Variant(8),Variant(10):htm
            // *Variant(9):mht
            // *Variant(11),Variant(19-22):xml
            // *Variant(12):docx
            // *Variant(13):docm
            // *Variant(14):dotx
            // *Variant(15):dotm
            // *Variant(16)、Variant(24):docx
            // *Variant(17):pdf
            // *Variant(18):xps
            // *Variant(23):odt
            // *Variant(25):与Office2003与2007的转换程序相关,执行本程序后弹出一个警告框说是需要更高版本的 Microsoft
            int type = 12;
            Dispatch.invoke(doc,
                    "SaveAs",
                    Dispatch.Method,
                    new Object[]{converterFileName, new Variant(type)},
                    new int[1]);
            // 关闭文件
            Dispatch.call(doc, "Close", new Variant(false));
        } catch (Exception e) {
            log.info("*******转换异常********filePathOriginal:{}", filePathOriginal);
        } finally {
            // 关闭word应用程序
            app.invoke("Quit", new Variant[]{});
        }
        log.info("*******转换完毕********converterFileName:{}", converterFileName);
​
        return new File(converterFileName);
    }

这是网上大佬的实现 , 我就是拿来改了下。 根据这些,自己在捣鼓下 , 就可以写成一个springboot程序。

5.自动重启

在window中,我们可以借助window的定时任务机制实现服务重启。

首先,需要编写一个bat文件的监听脚本来实现我们程序的检测

SET program=file-convert.jar
SET command=javaw -jar %program% --spring.profiles.active=./application.yml --server.port=8080
SET time=%time%
SET day=%date:~0,4%%date:~5,2%%date:~8,2%

for /f "usebackq tokens=1-2" %%a in (`jps -l ^| FINDSTR %program%`) do (
	SET pid=%%a
	SET image_name=%%b
)

if "%pid%" == "" (
    ECHO [%date%][%time%] process %program% does not exists, do COMMAN=[%command%]
    START %command%
    ECHO [%date%][%time%] process %program% do start ok !!!
) else (
    ECHO [%date%][%time%] process %program%  does exists
)

可根据需求增加日志打印。

program: 是你的项目打包后的jar包路径,我的脚本和jar包在同一个目录所以就没有其他路径。

然后找到电脑的 任务计划程序 不同window版本可能不同 , 可百度

此电脑 -> 右键 -> 管理 -> 计算机管理 -> 系统工具 -> 任务计划程序

创建定时任务, 先选择默认配置

这里要选择上面编写好的脚本

然后找到我们创建好的定时任务 , 双击

我们需要找到这里的高级设置 , 设置重复间隔执行这个任务 , 来执行我们的脚本进行监听。

其它配置可根据需求自己调整修改。

6.结语

想要获取完整代码可到本人的GitHub下载:

GitHub - suchangqin1/WordDocx

公众号求关注,感谢支持:

评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值