20221225-docx格式文档详解:xml解析并用html还原

https://juejin.cn/post/7166821284087595038

一、应用场景,有什么好处

解析就是解剖并分析,解析word ,就是提取word 里面的结构和内容

一套试卷有几十道题,一道题又有十来个属性
可以通过技术手段,自己去读出word 的内容

理论上,word 里展示的所有内容,程序员都能拿到,拿到数据之后,家境差点的公司,通过代码逻辑去找试卷名、选项这类属性,家境好点的企业,通过人工智能的NLP 文本分类,可以更好地实现智能分类

而此时,录入员只负责编辑word 就可以了,这个过程就叫做 word 导入

能导入的前提,是可以解析出来word 的内容,能解析word 的前提,就是docx 格式

docx 格式:为什么出现

原来的doc 格式是加密的,只有微软自己家的软件才能打开

后来微软觉得,这样并没有让自己很神圣,反而限制了自己的发展

文件结构:怎么来解析

分析发现,在docx 中同一张图复制多次,media 里面只保留一张原图,这说明,它是个勤俭节约的好孩子,承载同样多的内容,docx 格式比doc 的体积要小

比较关键的几点

3.1 主文件document.xml

位于word 下的document.xml 文件,是docx 的主战场,可以说,文档中你能看到的所有内容,在这里都有直接或间接的记录

document.xml 是一个xml 格式的文档

1
2
3
4
5
6
7
8
9
10
<w:document>
<w:body>
<w:p>...</w:p>
<w:p>...</w:p>
<w:tbl>...</w:tbl>
<w:p>...</w:p>
...
<w:sectPr>...</w:sectPr>
</w:body>
</w:document>

我们看到,body 体力,只有3种标签,分别是<w:p> <w:tbl> 和 <w:sectPr>

一个docx 文档,基本由这三种成分组成,w 指的是word p 指的是paragraph段落,tbl 表示table 表格,sectPr 全称是section primp,这个容易扰乱思路

3.2 段落标签w:p

在docx 中,段落是最常见的,是文档中最主要的组成单元

和你理解的段落一样,不换行就属于一个段落,即便是 咔咔咔 敲上6个回车,虽然没有内容,那它也属于6个段落,在xml 中是6个 <w:p>。

另外,包含图片,流程图,公式等元素的内容,也是包含在段落中的,换句话说,它们都是小弟

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 导入解析xml的库
import xml.dom.minidom as xdom
# 加载文档
xp = xdom.parse('word/document.xml')
# 获取文档根节点
root=xp.documentElement
# 获取body节点们
bodys = root.getElementsByTagName("w:body")
# 因为getElements返回多个对象,我们只有一个
body = bodys[0]
# 循环遍历body下的节点
for i,ele in enumerate(body.childNodes):
e_name = ele.nodeName
# 打印{序号} -> {节点名称} is {对象}
print(i,"->",e_name,"is",ele)

下面该详细说一下w:p 的小弟们了

3.3 文本标签 w:t

在纷杂的xml 文件中,可以扒拉出来一个叫 w:t 标签

1
2
3
4
5
6
7
<w:p>
<w:r>
<w:t>word的doc格式原来是不开源的,后来改成了docx格式后,是开源的。</w:t>
……
</w:r>
……
</w:p>

这里面存储的内容,就是word 里面的文字,t 就是text 的简称

你在docx 里面看到的每一个字,基本上都是被 w:t w:r 所包裹的

也就是说,如果我们拿出所有w:t 标签内的文本,我们就做到了纯文本docx 解析

代码,其实很简单,就是在w:p 元素中,扫描 w:t 的标签并取出其内容

上面我们已经拿到了body 标签,所以从那里继续

1
2
3
4
5
6
7
8
# 循环body下的大单元 w:p段落,w:tbl表格
for i,ele in enumerate(body.childNodes):
# 找到包含w:t的标签,可能是多个
wts = ele.getElementsByTagName("w:t")
ele_text = "" # 记录大单元内所有文本
for wt in wts: # 循环
ele_text = ele_text + wt.text
print(ele_text) # 打印输出

提取一个docx的文本,就是那么简单,你现在可以写一个程序,可以做到把docx 转为txt

但是,有一点我要告诉你,运行代码里的wt.text 可能会报错,为了便于你理解,我特意写了伪代码,实际上,要从 w:t 中取出文本内容,可以像下面这样

1
2
3
4
5
import re # 导入正则库
# 构建一个正则,去除<>标签
pattern_del_tag = re.compile(r'<[^>]+>',re.S)
# 把<w:t>元素转为xml格式<w:t>xxx</w:t>,然后去标签
t_text = pattern_del_tag.sub('', wt.toxml())

我认为这么讲,你反而能理解,因为,不用在理解什么叫 w:t 的时候,还要分散注意力到正则表达式

3.4 连续块w:r

上面我们讲了如何去解析文字,但是,那太简单了

文字是有样式的

都在同一段 w:p 内的文字,它们的样式,可能不一样

比如前两个字是红色,那么这两个字样式一样,但是,后两个字是绿色,和前面又不一样

为了解决这个问题,docx 把具有相同样式的文字,用w:r 标签包裹

r 代表run ,关于这个run 的解释,很多国内文档都直接翻译为运行

其实,run 在英文中有很多解释,我觉得在这里更适合它的释义应该是 一段 一系列 连续上演 因此,我个人给这个标签起名叫 连续块,表示在这个标签之内的文本,是一个系列的,他们的特点是连续不间断的

代码依然是处理xml 文件的那一套,不是找标签就是拿属性

还有其他的样式标签,你可以自己研究,我这里先抛砖引玉,举两个例子

比如上面例子中的字体颜色,一般在w:rPr 标签内,w 依然表示word r 表示run Pr 表示 Primp 是修饰,装饰的意思,w:rPr 这个标签的释义就是 连续块的样式说明,类似的还有w:pPr 表示对段落paragraph 的样式说明,w:tcPr 表示对表格单元格 table cell 的样式说明

我们来看以下,连续块修饰 w:rPr 是如何定义的

比如对于粗体、斜体的说明

用代码进行判断,主要是tag 的读取,能找到tag,就说明有此种样式的标记

1
2
3
4
5
6
w_i = w_rPr.getElementsByTagName("w:i")
if w_i:
print("是斜体")
w_b = w_rPr.getElementsByTagName("w:b")
if w_i:
print("是粗体")

再比如对于各种线条的说明

上面的例子,如果用代码进行判断的话,除了对tag 存在的判断,还需要获取属性值 w:val 表示用了哪一类具体的样式

1
2
3
4
5
6
7
8
9
10
11
12
w_u = w_rPr.getElementsByTagName("w:u")
if w_u:
# 获取属性值用 getAttribute
line_value = w_u[0].getAttribute("w:val")
if line_value == "single":
print("是下划线")
if line_value == "double":
print("是双划线")
if line_value == "wave":
print("是波浪形线")
if line_value == "dotted":
print("是虚线")

我可以很负责地说,只要是文档中呈现的信息,在xml 文件中都可以找到对应的标注

我们可以看到,包括 字号,字体,字色,标线都可以复原

3.5 图像标签w:drawing

docx 中的图片是如何从xml 中提取出来的呢

你在连续块 w:r 中会发现,有一个w:drawing 标签,这里面主要存放的,就是图画相关的信息

图片仅仅是 w:drawing 中的一个小分类,除了图片,还有图表、形状、流程图等

今天咱们说个最简单的,那就是如何提取图片

图片的标签是pic:pic ,他在xml 中如下定义

1
2
3
4
5
6
7
<w:drawing>
<pic:pic>
<pic:blipFill>
<a:blip r:embed="rId9"/>
</pic:blipFill>
</pic:pic>
</w:drawing>

其中图片就藏在<a:blip r:embed="rId9"/>中,里面的rId9 就是捕获图片的线索

那个media 文件夹,里面有好多图片

是有图片,但这也不是rId9

有个文件专门做关联这件事情,他就是解压缩之后的word/_rels/document.xml.rels文件。

1
2
3
4
5
6
7
<?xml version="1.0" encoding="UTF-8" standalone="true"?>
<Relationships>
……
<Relationship Target="media/image1.png" Id="rId9"/>
<Relationship Target="media/image3.png" Id="rId11"/>
<Relationship Target="media/image2.GIF" Id="rId10"/>
</Relationships>

这里有档案,记录了哪个id 指向哪个文件,于是,我们解析这个文件,就可以拿到对应关系

然后,遇到pic 和id 等于rId9 的,就把media/image1.png 这个图片文件展示出来

这,就实现了图片的解析

3.6 表格标签w:tbl

表格标签w:tbl 的地位很重,它和段落标签 w:p 平级

分析整个文档的顶层组成元素,我们不难发现,除了 w:tbl 就是 w:p

一开始,我不理解,为什么图表、流程图那么复杂的元素,却不配得到表格那么高的地位

单纯表格的结构,其实不复杂,但是,表格里的每一个单元格,却可以容纳另一个word 文档,表格里面,图片,图表、文本样式,甚至表格里再来一个表格,什么都可以添加,它甚至是包含了w:p 大佬,w:tbl 和w:p 并列,它反而是委屈的

我们来看一下表格w:tbl 的基本结构

1
2
3
4
5
6
7
8
9
10
11
12
13
<w:tbl>
<w:tblGrid>
<w:gridCol/>
<w:gridCol/>
</w:tblGrid>
<w:tr>
<w:tc><w:p>...</w:p></w:tc>
<w:tc>...</w:tc>
</w:tr>
<w:tr>
...
</w:tr>
</w:tbl>

节点元素中,主要有两部分内容,一个 w:tblGrid 介绍表格列的个数,另一个是w:tr 包含表格行的信息,tr 是table row 的缩写

其中,w:tr 里面有 w:tc,这里面是格子的内容,我们发现其内容,居然是一个它的兄弟节点 w:p

tc 是table cell 的缩写,表示单元格的意思
从结构上看,我们会感觉 table cell 更适合语境

先说基本表格的解析,如下图所示

解析的代码参考如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
w_table = body.childNodes[0] # 拿到表格节点
# 获取所有的行
w_trs = w_table.getElementsByTagName("w:tr")
rows_text = [] # 存放行的文本
for r_index, tr in enumerate(w_trs):
# 获取所有的单元格
cells = tr.getElementsByTagName("w:tc")
cells_text = [] # 存放单元格的文本
for c_index, cell in enumerate(cells):
# 获取所有的文本
wts = cell.getElementsByTagName("w:t")
for wt in wts: # 把文本拼接
cells_text.append(wt.text)
rows_text.append(cells_text)
print(rows_text) # 打印结果,二维数组[[r1c1,r1c2],[r2c1,r2c2]]

运行一下代码,结果如下

这里面我又挖坑了,除了前面讲 w:t 时的 wt.text 陷阱之外,w:tc 的内容应该是去解析 完整的w:p 结构,而这里我只取了w:t 文本,这样最简单,因为,我们是要理解表格的结构,因此,其他的可以假装看不见

但是,也由此可见,做好表格解析的前提,是做好段落解析,因为表格单元格里是段落

我相信,有了表格的解析结果 二维数组,你很容易就可以把它还原成表格页面用于展示,只需要循环就好,第一层循环,第二层循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
rows_text = [['名称', '后缀名'], ['Word', 'docx']]
table_html = ["<table>"]
for row in rows_text:
table_html.append("<tr>")
for cell in row:
table_html.append("<td>"+cell+"</td>")
table_html.append("</tr>")
table_html.append("</table>")
table_html = "".join(table_html)
print(table_html)
# <table>
# <tr><td>名称</td><td>后缀名</td></tr>
# <tr><td>Word</td><td>docx</td></tr>
# </table>

据我所知,世界上的表格除了上面那种简单的,还有一些稍微复杂的,那就是带有合并单元格的表格

比如下面这种

这类表格的解析稍微复杂一些,它们属于复杂里面最简单的

我们来看看他们的xml 数据是如何定义的

首先来看带有跨行的表格的例子

对于跨行的情况,我们发现表格的xml 数据,该有的行和列,数量都没有变,只是在要合并的单元格上标记了一个w:vMerge 标签

这个标签表示有跨行的单元格,v 表示vertical ,是竖直方向的意思,vMerge 表示竖直合并,这个标签里面还有属性值 w:val 当值为restart 时表示此单元格开始出现合并,continue 表示此单元格没有结束,继续保持,直到遇到非continue 情况

再来看看跨列的情况

跨列因为发生在行内,是行内矛盾,不影响其他行,所以,我们看到只有在第一行的第一格中,采用 w:gridSpan 标签,说明本行有跨列的单元格,val 值是2,表示跨2 个单位

为什么我前面说,tc 是table cell 的缩写,我的依据就在这里,其实这个表格的结构是2行2列,如果c 指的是column 的话,它应该有2个 w:tc 后一个复用前一个,但是,我们看上图里的结构,它只有一个w:tc ,那我们称呼它叫单元格更贴切,因为它只有一个框,你可以反驳我,

带有合并单元格的页面还原,逻辑稍微复杂,但是底层还是和普通表格一样,通过循环行和列,你只需要遇到合并的时候,搞一个colspan=2 或者 rowspan =3 就行

搞合并单元格时,花费了2天时间才完成,

对于复杂表格,最终的数据结构用一维数组更恰当。这个结构可以像这样
[{"x_start":0, "x_end":1, "y_start":0, "y_end":0, "content":"不要问我"}]


前端实现docx pdf 格式文件在线预览

在业务中,如果遇到文档管理类的功能,会出现需要在线预览的业务需求,
本文主要是通过第三方库来实现文档预览功能,并将其封装成preview 组件

docx 的实现需要使用 docx-preview 插件

PDF 的预览需要使用PDFJS 这个插件,通过将文件流解析写到canvas 上实现预览效果

此处pdf的渲染数据this.fileData必须是一个ArrayBuffer格式的数据,如果请求的的数据是Blob格式必须要先使用Blob.arrayBuffer()转换

PDF 的放大和缩小

PDF 文件渲染后如果不能调整大小会因为源文件的大小和文件内容,出现模糊的问题,所以进行缩放渲染是有必要的

多格式的文件渲染函数映射

因为将多种文件渲染放在一个文件中,所以处理函数需要做映射处理,执行对应格式的文件渲染

不支持的文件提示处理

在这个文件中,目前只支持docx 和pdf 的预览,如果出现了不支持的文件,需要增加一个提示处理,告知用户,例如如下的文件提示

打赏
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!

扫一扫,分享到微信

微信分享二维码
  • Copyrights © 2015-2024 TeX_baitu
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~