(五)、JAVA基于OPENXML的word文档插入、合并、替换操作系列之word文件合并[支持多文件]
二、word合并的多种方案简单比较
- 基于jacob控件的实现
jacob一个Java-com中间件,通过这个组件利用Java程序调用win上面的dll来操作office实现文件的处理,这个因为是基于office本身的功能,效果上还可以,不过受限了操作系统,如果你的程序要放在linux上部署,这个就无法实现了,可以参考一下这位仁兄的 参考案例 - 基于pageoffice的实现
上面jacob是基于java后端的,那这个就是基于前端的, pageoffice是一个基于类似于ActiveX类的控件,它是将office以控件的形式加载到浏览器端来操作,当然我们只需要懂js,通过js api来调用就可以,这个方案解决了跨平台的问题,但pageoffice是一款商业软件,是需要收费的,具体也可参考一下这位仁兄的参考案例 - 基于docx4j的实现
docx4j还是挺强大的,我挺看好它,所以我认为这个实现是比较好的一种方案了,但实际上我自己没怎么用这个方案,因为在我自己折腾出办法以后才发现了它,我也就懒得去改了,并且我比较信赖自己的劳动成果(请允许我自恋一下),有兴趣可以看看这位仁兄的 docx的拆分和合并 还有这个 参考案例 - 基于POI的实现
这个方案可能是网上使用比较多的,我曾经也用过这个方案, 但这个方案似乎对于复杂的文档并不那么友好,比如有图片的文档似乎合并后图片会有问题,当然不排除个人对它的了解层度不够深或许也有别的办法能处理好,只是我不知道, 具体可以自行尝试一下,参考案例 - 其他实现方案
似乎像itext
这类库也可以、还有一些偏门的独家秘方的我了解的就不多了, 我能想到的还有比如通过python、.NET等一些语言把这个功能单独写成一个独立的服务,然后通过java去调用这个服务来实现也是可以的,只是看你愿不愿意这么做,值不值得在这个事上大展身手了。
三、基于Open Xml WordprocessingML的word合并
前面在word基础篇时提过,word解压后核心的一个文件document.xml
它里面就是WordprocessingML的结构,这算是word的老底了,我们的合并就是基于这货的,所以我这个方法比较粗鲁,但是透彻呀!
广告插播: 建议你往下看之前,如果你不了解WordprocessingML也没看过我前面的几篇笔记,强烈建议你先点上面传送过去看看,尤其是系列之二《图片在word结构中的存放、插入、替换图片》,广告完毕!
还记得我前面系列笔记之一《基础篇》中那个word文档中的document.xml
文件的结构吗,它是一个有着特殊格式的xml文件,精简一下它是这样的:
<xml-fragment
xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"
…… 省略更多 ……
>
<!-- 以上为内容区、截取一部分 -->
<w:p>
<w:pPr>
<w:spacing w:after="450"/>
<w:ind w:left="120"/>
<w:jc w:val="center"/>
</w:pPr>
<w:r>
<w:drawing>
<wp:inline distT="0" distB="0" distL="0" distR="0">
<wp:extent cx="8466666" cy="5872268"/>
<wp:effectExtent l="0" t="0" r="0" b="0"/>
<wp:docPr id="0" name="" descr=""/>
<wp:cNvGraphicFramePr>
<a:graphicFrameLocks noChangeAspect="true"/>
</wp:cNvGraphicFramePr>
<a:graphic>
<a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">
<pic:pic>
<pic:nvPicPr>
<pic:cNvPr id="1" name=""/>
<pic:cNvPicPr/>
</pic:nvPicPr>
<pic:blipFill>
<a:blip r:embed="rId4"/>
<a:stretch>
<a:fillRect/>
</a:stretch>
</pic:blipFill>
<pic:spPr>
<a:xfrm>
<a:off x="0" y="0"/>
<a:ext cx="8466666" cy="5872268"/>
</a:xfrm>
<a:prstGeom prst="rect">
<a:avLst/>
</a:prstGeom>
</pic:spPr>
</pic:pic>
</a:graphicData>
</a:graphic>
</wp:inline>
</w:drawing>
</w:r>
</w:p>
<w:sectPr>
<w:pgSz w:w="11907" w:h="16839" w:code="9"/>
<w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/>
</w:sectPr>
</xml-fragment>
如果单纯的把上面的东西当一个xml看的话,xml-fragment
应该叫根节点,它里面定义了许多的xmlns:xx
,我们把它叫作命名空间,每个文件所包含的命名空间都不一样,所以整体的文件合并都要包含命名空间、内容、图片
三块,合并大致分作以下五个步骤进行
step 0、准备工作
首先要加载文件,并把它转换为上面的xml文件格式,这里我也用到了docx4j、poi、dom4j中的一些东西,来对word、WordprocessingML的快速解析与处理.
在这里我准备了两个文件,source1.docx
、source2.docx
,它们的内容如下:
然后通过代码读入 source1.docx
source2.docx
如下:
// 本例借用dom4j来操作xml (WordprocessingML)
SAXReader saxReader = new SAXReader();
//取source1的内容,并转换 document来操作
XWPFDocument source1 = new XWPFDocument(OPCPackage.open("/Users/tenney/Desktop/source1.docx"));
CTBody templateBody = source1.getDocument().getBody();
org.dom4j.Document document1 = saxReader.read(new ByteArrayInputStream(templateBody.xmlText().getBytes(Charset.defaultCharset())));
//读取 source2的内容
XWPFDocument source2 = new XWPFDocument(OPCPackage.open("/Users/tenney/Desktop/source2.docx"));
CTBody templateBody2 = source1.getDocument().getBody();
org.dom4j.Document document2 = saxReader.read(new ByteArrayInputStream(templateBody.xmlText().getBytes(Charset.defaultCharset())));
step 1、命名空间合并
我们知道每个文档转成WordprocessingML格式xml后,根节点xml-fragment
下面都有 xmlns:xx
的“命名空间”,要确保合并后的文档所有内容能被识别,这些 xmlns:xx
也必须被合并,这一步我们要做的事就是这个。
我们以source1.docx
为目标文件,将source2.docx
中的内容读取过来:
//通过正则,解析形如: xmlns:w16="http://schemas.microsoft.com/office/word/2018/wordml" ,转换为 键值对,
//schems 的结构为: xmlns:w16 = { xmlns:w16 : "http://schemas.microsoft.com/office/word/2018/wordml" }
Map<String, NameValuePair<String>> schemas = new HashMap<>();
Matcher matcher = Pattern.compile("^<\\S+?\\s+([^><]+\\=[^><]+)>").matcher(templateBody2.xmlText());
if (matcher.find()){
String[] schemaArr = matcher.group(1).split("\\s+");
for (String attr : schemaArr){
String[] s = attr.split("=");
if(s.length > 1){
schemas.put(s[0], new NameValuePair<>(s[0], s[1]));
}
else {
// logger.warn("不符合要求的xmlns定义:{}", attr);
}
}
}
/**
* 执行合并, 通过dom4j的方法获取 source1的根节点, 也就是 xml-fragment,然后 通过 addAttribute() 来将 source2中的所有命名空间合并进去
* 这个地方也可以通过字符的操作,在xml中直接合并到,但需要自己行处理重复的 命名空间, 通过dom4j的方法,不需要考虑,它会自动处理
*/
Element root = document1.getRootElement();
schemas.values().forEach(s->root.addAttribute(s.getName(), s.getValue().replace("\"","")));
step 2、文档内图片合并
图片的合并分两步,第一步是将图片资源拷贝并追加到目标文档中, 第二步是处理图片的资源关联标识rId
(如果不记得了这是什么东东,请回去看系列文章之二篇)代码如下:
//得到文档2中的所有图片
List<XWPFPictureData> allPictures = source2.getAllPictures();
final String PICRID_PREFIX = "_PIC_ID_PREFIX";
if(allPictures != null && !allPictures.isEmpty()){
//步骤一、 将文档1的图片复制到文档2中
//这个对象用来存储图片在原文档中的关联的rID,以及合并到新文档中产生的新的rId关联关系
Map<String,String> picMap = new HashMap<>();
// 记录图片合并前及合并后的ID
for (XWPFPictureData picture : allPictures) {
String before = source2.getRelationId(picture);
//将原文档中的图片加入到目标文档中
//一张图片对应一个<w:drawing>标签,图片存在缓存中 ,并由一个ID来标签图片ID <a:blip r:embed="rId4"/>
//合并xml时,图片不会带过来,所以先添加图片,将生成的ID替换原来的ID
String after = null;
try {
if(picture.getData() == null || picture.getData().length < 1){
logger.warn("图片数据丢失:{} - {}", before, picture.getFileName());
}
after = source1.addPictureData(picture.getData(), picture.getPictureType());
picMap.put(before, after.replace("rId",PICRID_PREFIX)); //操作_1
} catch (InvalidFormatException e) {
logger.warn("提取文档图片失败:{}", e.getMessage(), e);
}
}
//步骤二、处理图片在新文档的关联关系, 此时内容尚未合并到新文档,只是为合并做做准备
// 将文档2直接转成xml,并处理掉图片rId关联关系
String IMG_PATTERN = "r:embed=\"%s\"";
String richText = source2.getDocument().getBody().xmlText();
for (Map.Entry<String, String> set : picMap.entrySet()) {
// 防止图片数量超10时,出现如 rId1 替换掉了 rId1, rId1x, rId1xxx 等,导致ID引用不正确,
richText = richText.replace(String.format(IMG_PATTERN, set.getKey()), String.format(IMG_PATTERN, set.getValue()));
// richText = richText.replace(set.getKey(), set.getValue());
}
richText = richText.replace(PICRID_PREFIX, "rId");//操作_2
}
本小节可能有点绕,稍微解释一下:
- 关于rId : 假设文档1、文档2中各有一张图片, 那么文档1中的图片1的
rId=“rId1”
同理文档2中的图片1也是rId=“rId1”
,如果将文档2中的图片1合并到文档1中,此时在文档1中生成的标识就变成了rId="rId2"
,因为文档1之前已经存在了一张图片, 所以合并内容时,也需要将该标识进行相应的替换,否则合并后的文档就是会因为资源引用错乱而无法打开, 也就是上面的 步骤一 - PICRID_PREFIX: 此操作是为了避免图片数量巨多时, 在进行替换时,出现部分字串替换的问题,比如直接用
rId5
替换目标文档中的内容,将会把rId5
、rId50
、rId5x
等所有ID都替换而出错,所以定义一个比较复杂的前缀来处理这个问题。
一般情况下建议先处理图片后,再处理内容,因为处理完图片后,在被合并的文档中一并处理掉图片关联rId, 然后再将处理好的内容一块合并至目标文档,避免合并到目标文档后再去替换rId不小心替换掉了不该替换的内容。
step 3、内容区域合并
这部分内容就比较简单的,简单到就是xml文件或者说字符串的合并,代码如下:
//内容合并 (这块合并比较简单,方法也比较多,反正就是xml文件的合并而已,我这里采用了dom4j来操作)
org.dom4j.Document newDoc = saxReader.read(new ByteArrayInputStream(richText.getBytes(Charset.defaultCharset())));
//得到待合并的文档根节点以下的所有元素
List<Element> appends = newDoc.getDocument().getRootElement().elements();
//sectPr 节点是用来定义文档的背景、宽度等设置的, 同一文档不能有多个,所以合并的时候忽略该节点
final Set<String> mergeIgnoreNodes = new HashSet<>(Arrays.asList("sectPr"));
//获取文档1的元素列表
List<Element> elements = document1.getDocument().getRootElement().elements();
for (Element e: appends){
if(!mergeIgnoreNodes.contains(e.getName())){
//从当前位置之后添加新元素,看到这行代码,是不是有点别的想法啊, 对的, 你想在什么位置插入都可以,并不一定要是追加到最后
// elements.add(++idx, (Element) e.detach());
elements.add((Element) e.detach());
}
}
step 4、合并整合输出
没什么可说的,看输出结果吧。
//文档输出
FileOutputStream fos = new FileOutputStream("/Users/tenney/Desktop/merge.docx");
//将最终的内容重新设置到 word的CTBody中
CTBody makeBody = CTBody.Factory.parse( document1.asXML());
templateBody.set(makeBody);
source1.write(fos);
fos.close();
整点总结显得流弊
似乎这一堆的东西看起来有点避轻就重的感脚,为什么网上那么多简便的方法不用,非整的这么复杂,这个问题老头我想,即然你能看到这里也就不用我过多解释了。
我个人认为主要是了解它的原理,其次就是为了根据自己的需要去折腾它了,比如系列文章的后续章节,你将会看到更有趣的东西.
更多折腾可前往围观