Word 与排版

DOCX 翻译为什么会丢加粗?先保住 run,再谈整段替换

用 python-docx 保留 run 格式:保存后重读验收,拒绝复杂结构与不匹配分段。

阅读文本 / Reading text
“翻译准确,但是格式全没了”是文档交付里很常见的返工。一个段落在 Word 里看起来只有一句话,在 DOCX 中却可能包含多个 run:普通文本、强调文本、字号变化,以及不属于普通 run 列表的超链接等结构。 使用 python-docx 时,直接给 paragraph.text 赋值,会用新的文本替换段落现有内容。段落级样式可以保留,但原有 run 级格式不会因此自动保留。这个行为在 python-docx 文本 API 中有明确说明。 这张图展示本地合成 DOCX 的测试记录,不是排版引擎截图。本文解决的是“受约束替换不丢格式”,不是自动解决跨语言句法重排,也不是保证翻译后分页不变。 先限定可安全修改的范围 这份最小实现只接受由普通文字 run 组成的段落。段落包含超链接、图片、域、换行、制表符或其他复杂 XML 节点时,明确拒绝,让调用方转入专门流程。代码不会悄悄把这些元素扁平化。 翻译输入也不是一个随意的字符串列表:每个结果必须带原始 run 的序号,数量、顺序、类型全部匹配后才开始修改。验证放在写入之前,避免更新一半才发现后面的字段有问题。 一个可以重复跑的最小测试 测试环境为 python-docx==1.2.0 。这里的英文来自手写固定对照,用于验证结构,不调用机器翻译。保存后重新打开 translated-runs.docx 再验收,避免仅检查内存里的对象。 from docx import Document from docx.oxml.ns import qn def replace_plain_runs(paragraph, translated): if any(child.tag not in {qn("w:pPr"), qn("w:r")} for child in paragraph._p): raise ValueError("paragraph contains unsupported structures") runs = paragraph.runs for run in runs: if any(child.tag not in {qn("w:rPr"), qn("w:t")} for child in run._r): raise ValueError("run contains a field, image or control") if len(translated) != len(runs): raise ValueError("segment count mismatch") for index, item in enumerate(translated): if (not isinstance(item, dict) or item.get("id") != index or not isinstance(item.get("text"), str) or any(ch in item["text"] for ch in "\t\r\n")): raise ValueError("segment identity or text mismatch") for run, item in zip(runs, translated): run.text = item["text"] def signature(paragraph): return [(r.text, r.bold, r.italic) for r in paragraph.runs] doc = Document() p = doc.add_paragraph() p.add_run("请在 ") p.add_run("周五之前").bold = True p.add_run(" 提交报告。") before = signature(p) try: replace_plain_runs(p, [{"id": 0, "text": "Incomplete"}]) raise AssertionError("bad segments accepted") except ValueError: assert signature(p) == before replace_plain_runs(p, [ {"id": 0, "text": "Submit the report "}, {"id": 1, "text": "before Friday"}, {"id": 2, "text": "."}, ]) doc.save("translated-runs.docx") restored = Document("translated-runs.docx").paragraphs[0] assert restored.text == "Submit the report before Friday." assert restored.runs[1].bold is True assert len(restored.runs) == 3 complex_p = doc.add_paragraph("keep") complex_p.runs[0].add_break() try: replace_plain_runs(complex_p, [{"id": 0, "text": "reject"}]) raise AssertionError("line break accepted") except ValueError: assert complex_p.text == "keep\n" print("PASS count mismatch: no partial write") print("PASS save/reopen: 3 runs, emphasis preserved") print("PASS unsupported control: rejected") print(signature(restored)) 为什么不把每个 run 单独交给翻译器 run 是格式边界,不是语义边界。Word 的编辑历史也可能把一个词切成多个 run。逐个孤立翻译会丢上下文;不同语言的语序还可能把强调部分移到句首或句尾。 实际的译文生成应以完整句段提供上下文,再返回经过校验的结构化分段。返回结果和原来不等长时,不要硬塞到现有 run 中:需要一个明确的重新排版策略或人工复核。本文简单例子刚好可以保持三个片段,不能把它推广成所有语言都能一一映射。 把验收拆成三层 层次 检查内容 本文是否覆盖 数据结构 run 数量、顺序、加粗签名与保存后恢复 覆盖这个受限样例 语义内容 术语、否定词、日期、金额、遗漏 不覆盖;需要独立校对 视觉版式 字体回退、断行、页码、表格溢出 不覆盖;需要渲染再检查 _p 和 _r 是这里为了检查 XML 使用的内部属性。升级 python-docx 时要固定版本并重新跑测试,不能假设内部结构永远不变。需要处理表格、页眉、页脚、文本框和修订痕迹时,也要分别枚举内容范围; document.paragraphs 不能代表文档中的所有文字。 交付前的停止条件 不支持的结构、分段不匹配、字体缺字、重要字段改变,任何一个都应该阻止自动交付。把原文件只读保存,译文写到新文件,再附上语言、处理版本和人工验收记录,这比“保存成功”更能说明质量。 如果你需要从上传文档进入翻译和审阅流程,可以查看 OmniDoc 产品入口 。本文的代码示例是独立的开源库教学样例,不代表 OmniDoc 当前导入器或排版引擎的实现。核心原则只有一个:把格式当成要验收的数据,而不是翻译后的附赠品。 - 1 -
更新于 2026-10-09