我用 Python 十分钟搞定了你干一个月的活!

大家好,我是 Sam Zhang。

前言

把 Excel 转换成 Word 格式的文档大家肯定都干过吧?是不是十分的枯燥无味而且浪费时间?

你可能在想:用 Excel 、Word 转换器啊!是啊,对于普通的 Excel 文档来说,这可能是个好方法。但是,如果是一个数据很多并且存在着很多人为设定的查看规则呢?普通的转换器碰上这种高度定制化的 Excel 就显得无能为力了。

前两天,一个粉丝找到我问,能不能给他做一个能这样高度定制化的转换器呢?我的第一反应是:当然啊!Python YYDS!

于是,就诞生了这篇文章(已提前争得该粉丝同意)。

项目描述

用文字表述不清,各位看图吧。

看懂要怎么看这张表了嘛……

整张表分三个 Sheet ,每个 Sheet 下存储一种数据。而这三个 Sheet 中的数据是相互关联的。例如,第一张图里的设备编号是引用的第二张图里的设备,第一张图里每一个检测对象都属于 A 列的大类别编号所对应的大类别……

不仅如此,整张表还有层级关系。对于 Sheet 1,有着三级或更多的层级嵌套。别说机器了,就是人也不能一下子明白这张表咋看。

而粉丝要求的是把这张表里的数据全部转化成 Word 文档 —— 意味着需要正确处理这些引用和层级关系。

这还不够,转换出来的 Word 还有四种不同的形式,虽然有两份是大同小异的,但剩下的可都不太一样。

然而,这表的数据量还大到离谱 —— 转换出来的 Word 文档最少也得一百页,多的甚至上千页。这要是人干,恐怕肝是要爆啊……

开发历程

读取 Excel

既然接下了这个活,那肯定得干啊。先看看怎么用 Python 读写 Excel 吧。

一开始以为 openpyxl 就可以解决问题,但仔细一看这是 Excel 2003 的工作簿…… 没办法,只能转用 xlrdxlwt

读取的过程还是比较愉快的,但期间我还是掉了不少头发……

首先,是怎么设计这个存储结构。因为最终的文档是以大类别作为一级标题的,且嵌套等级太多,我构思了好久才搞出来这个读取器。

最终,我把读取出来的数据转换为 JSON 文件,方便后续生成器进行二次读取。

生成 Word

现在最困难的事情到了。我在网上搜索了一圈后,发现了 python-docx 这个 Word 操作库。

幸运的是,python-docx 支持 Word 内表格的各种操作,没有必要再自己鼓捣了。

在看完不那么好懂的英文文档后,我一气呵成,做出了生成器的第一个版本。

我兴奋的运行了起来,在跑了一个小时之后,我发现了事情的不对劲:为啥跑这么久啊喂!

果断加上进度条再次运行,结果:

好家伙七个小时还让不让人活啦!不行,必须优化!

多进程

根据我爬虫的思路,一旦什么东西跑的太慢,那就用多进程 / 多线程优化!

于是,我开始到处搜集资料,并进行相关实验。但是,全部以报错告终。(悲

最后我给出的结论是:我太菜了,不适合用多进程(悲

研究库的底层实现

在有了多进程的失败过后,我又查看了一遍日志。而这次,我发现了一个很重要的信息!

每次执行一开始的时候,速度是非常快的,但是越往后,速度就越慢。而且变慢的速度是飞速的。

这就让我想起了指数爆炸。难道有一个深层递归在暗中执行?

我翻遍了官方文档,没有发现一句有关性能的提示。我决定深入看看 python-docx 的底层实现。

table.py 中找到 Table 类很容易,于是我就挨个函数地看源代码。这一看不要紧,关键是很多我认为是 O(1) 的方法,实际实现其实是 O(n) 的!怪不得慢。

但是那也不应该这么慢呀。继续往下看,一切都是那么的正常,直到我看到了 _cells 函数。

@property
def _cells(self):
    """
    A sequence of |_Cell| objects, one for each cell of the layout grid.
    If the table contains a span, one or more |_Cell| object references
    are repeated.
    """
    col_count = self._column_count
    cells = []
    for tc in self._tbl.iter_tcs():
        for grid_span_idx in range(tc.grid_span):
            if tc.vMerge == ST_Merge.CONTINUE:
                cells.append(cells[-col_count])
            elif grid_span_idx > 0:
                cells.append(cells[-1])
            else:
                cells.append(_Cell(tc, self))
    return cells

不想多说啥了,找到原因了。这个如此频繁使用的函数,复杂度竟然是 O(n^2) 的!小规模数据还不要紧,数据量一大,几万几万的数据写到表格里,_cells 函数的遍历次数也越来越多,执行时间也越来越长。

一个 8000 行的表格数据,写入到 Word 里可能变成 10000 行左右。这意味着什么?这意味着这段代码将会遍历整个表格!10 * 10000 的数据可能看起来没有那么可怕,但运行次数一多了,时间耗费就会非常大。

其实库的开发者这样做也是为了保证 merged cells 的正确性。但是,就是太耗时了……

知道了问题的源头,我就去 GitHub 上寻找答案。最终,我在 python-docx 的官方仓库中找到了 这个 Issue 。Issue 中有人说,一个 150 * 8 的表格需要 60 秒来创建。看来找对地方了。幸运的是,Issue 的发起者给出了一个 可行的解决方案 。但是,这个临时方案并不支持有合并单元格需求的表格。

很明显,我这个表格用不了这个方案。但是,既然有了一个样例,我可以照猫画虎嘛!

于是,我加上了我自己做的临时缓存。一开始的效果还不错,速度保持在每秒处理 5 行左右。但是跑了半个小时之后,速度又开始急速下降:每 5 秒处理一行。

这说明什么?

  1. 我的缓存方案有效
  2. 它还不够有效
  3. 我需要更有效的方案

继续优化

仔细思考一下就能知道,如果我们不能减少执行这个循环的次数,那就缩小循环范围呀!缩小循环范围,就是让我们的表格变小呀!

实现方式很简单,就是每隔一会就切断当前表格,建立一个新的。

但是,因为生成的表格中有很多合并的单元格,所以很难直接分开。最终,在实现难度和速度的综合考量下,我选择了在每个合并长度最大的单元格合并完成时拆分表格。

再次运行程序,哇!简直神速!原来跑一天不完的程序,现在只需要十分钟就可跑完!

最终,我得到了一个大小高达 674 KB 的 Word 文件(页数太多电脑短时间加载不完了)!

Victory!

UI 界面

总不能给用户用命令行吧……

我用 Flask 快速搭建了一个 API ,并部署到了云平台上可供前端调用。

前端方面我使用了 React + Next.js + Mantine 的技术架构,简单做了一个前端 UI 。

于是,这个项目的搭建就到此告一段落了。

后记

把项目交付给粉丝后,粉丝表示十分满意~

其实通过这次项目,我本人也收获了许多新的技术知识。

也欢迎其他小伙伴来和我一起探讨 Python 办公自动化的相关问题哦~

03-05 16:12