今天继续实现word翻译功能,上次的代码翻译完后会丢失图片等元素,让deepseek改了好几版代码都还是有问题,我决定先搞懂根本原因再改代码。

经调查,Word 的文档结构(通过 COM 对象模型)如下:

Document
  └─ Paragraphs
       └─ Range
            ├─ Runs (文本块)
            ├─ InlineShapes (内联图片、图表等)
            └─ Fields (域代码,如目录)

其中,Paragraphs(段落)的定义是:一个段落是用户按下回车键结束的一段内容。一个 Paragraph 对象包含一个 RangeRange 覆盖该段落的所有内容(包括文本、图片、表格、形状等)。段落是文档流的基本块,每个段落可以包含多种元素(文本、内联图片、域代码等)。

 Range(范围)是文档中连续的任意区域,可以是一个字符、一个段落、整个文档。Range 可以包含多个 Paragraph 和多个 InlineShape(内联对象),并且可以通过 Range.Runs 访问该范围内的所有文本块(Runs)。通过 Range 可以操作文本、格式、以及插入或删除内容。

Runs(文本块)是 Range 中具有相同格式的连续文本序列。Word 在内部将文本按格式变化自动分割为多个 Run。例如,同一段落中字体加粗、颜色变化、或插入图片都会导致 Run 的拆分。

  • 每个 Run 是纯文本包含内嵌对象(如 InlineShapeField 等)。

  • 一个 Run 中如果有图片,则这个 Run 的 InlineShapes.Count > 0,且 Run.Text 通常为空或只包含占位符字符。

  • 图片本身不是一个 Run,而是附着在某个 Run 上的对象。

之前的代码中,我们通过操作Range,提取出Range中的所有文本,然后翻译完再替换掉。这就会导致原本存在于Range中的图片等元素也被覆盖掉,所以会丢失元素。

deepseek给出的解决方案是逐 Run 处理,不再整个替换掉Range。但是我想到,既然Run会按照格式变化划分,那”这是一句完整的话”这样的句子就会被划分为“这是一句”“完整”“的话”三个Run,这样喂给AI翻译效果肯定不行,于是我放弃了这个方案。

既然这条路不通,那就回过头去重新开始。实际上python有专门的库python-docx来操作word,但是这个库处理不了复杂的元素,如插入的图形,文本框等,所以我一开始才会采用win32com。在我实际的业务中,word文件里面文本框等复杂元素有不少,所以这个问题必须要解决。

随着调查的深入,我发现 Word 文档(.docx)实际上是一个 ZIP 压缩包,包含多个 XML 文件。python-docx 库主要封装了 document.xml 中主流元素(段落、表格、图片)的操作,但文本框(TextBox)在 XML 中属于绘图对象(DrawingML),结构复杂且多变,官方库未提供直接 API。但是我可以通过直接解析 document.xml,查找 <w:txbxContent> 标签(文本框内容的容器),提取并修改其中的文本。

        root = doc.part.element
        # 查找所有 w:txbxContent 元素(忽略命名空间)
        txbx_contents = root.xpath('.//*[local-name()="txbxContent"]')
        for txbx in txbx_contents:
            # 遍历文本框内的所有段落
            for para_elem in txbx.xpath('.//*[local-name()="p"]'):
                # 提取所有文本节点内容
                original = ''.join(para_elem.xpath('.//*[local-name()="t"]/text()')).strip()
                if original and len(original) > 1:
                    translated = translate_text(original, target_lang, delay, context=context)
                    if translated:
                        # 替换所有 w:t 节点的文本
                        for t_elem in para_elem.xpath('.//*[local-name()="t"]'):
                            t_elem.text = translated

完美解决!

下一步就是多线程并行翻译了,Excel和PPT都有,word也一定要有!

我的PPT是按页,Excel是按sheet并行翻译,但是word是流式文档,不存在天然可拆分的单位,即使word有页码,但每一页也并不是独立的,甚至一句话都能跨两页。所以只能先把要翻译的元素全部提取出来,再多线程并行翻译,最后单线程顺序写回。这既能大幅提升性能(API 调用成为瓶颈),又能避免并发修改文档导致的数据损坏。

  1. 收集阶段(单线程):遍历文档,收集所有需要翻译的文本单元(段落、表格单元格内的段落、文本框内的段落)。每个单元包含:唯一标识(如索引)、原始文本、对象引用(用于写回)。

  2. 并行翻译阶段(多线程):使用线程池,将文本单元分配给多个线程,每个线程调用 translate_text 进行翻译,返回 (单元索引, 译文)

  3. 写回阶段(单线程):按索引顺序将译文写回对应的文档对象。

我本来想着PPT都能多线程写回去,为什么word不行?原因如下:

  • python-pptx(PPT):每个 Slide 对象在内部对应 XML 树的不同分支,且 python-pptx 在加载整个文档后,各个幻灯片之间的 XML 节点树是相对独立的。多线程同时修改不同幻灯片的形状(shape.text = ...),实际上是在操作不同子树,只要没有同时修改同一个对象(例如同一张幻灯片),通常不会引发冲突。python-pptx 也没有在内部维护复杂的全局缓存(如 xpath 缓存),所以实践中并行是安全的。

  • python-docx(Word):文档的段落、表格、文本框等元素都位于同一棵 XML 树中,且 python-docx 内部会维护一些缓存和索引(例如段落编号)。多个线程同时修改不同位置的文本,虽然理论上可能不会直接冲突,但 lxml 底层在修改节点时可能会触发父节点重新序列化或缓存失效,存在潜在风险。更稳妥的做法是只并行翻译,不并行写回

但是我看了设计思路后觉得还有问题,如果只是把所有的翻译文本直接分给多个线程翻译,那么每个线程拿到的文本块都是零散的,为了保证翻译效果,我在把文档喂给AI翻译时是传递了上下文的,所以只有上下文连贯地发给AI翻译才能取得最好的翻译效果。于是我将翻译文本全部提取出来后,按线程数将翻译文本分组,每一组拿到的文本都是连贯的,最后再多线程分组翻译。

嗯,效果拔群,完美!

下次继续实现自动写脚本的Agent吧。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐