LibreOffice本地高保真互转:PDF/Word/Excel/PPT批量转换实践

发布时间:2026/10/28 5:56:49
LibreOffice本地高保真互转:PDF/Word/Excel/PPT批量转换实践

1. 先把这个工具能干什么、不能干什么讲透

本地文档格式转换工具,说白了就是一套跑在自己机器上的服务,把 PDF、Word、Excel、PPT 这四类文件互相倒来倒去,核心引擎用的是 LibreOffice。PDF/Word/Excel/PPT 高保真互转这件事,本质上不是"写个脚本调一下"那么简单,真正难的是排版、字体、分页、表格宽度这些看起来琐碎、错了却一眼就能看出来的东西。我做过好几个需要在离线环境里批量处理文档的活儿,最深的体会是:转换命令本身五分钟就能跑通,剩下的时间全花在"为什么这份 Word 转成 PDF 之后页脚跑到第二页去了"这类问题上。

这篇内容适合三类人看。第一类是需要在内部系统里集成文档转换能力、又不能把文件传到外部服务的开发者;第二类是经常批量处理报表、合同、课件的运营和行政岗,想给自己做一个顺手的本地小工具;第三类是想搞清楚 LibreOffice 命令行到底能干什么、边界在哪里的技术爱好者。我会把架构怎么拆、参数怎么调、字体怎么配、并发怎么扛、出问题怎么查,一条线讲完,代码和配置都能直接抄。

1.1 本地转换的真实价值到底在哪

把文档传到在线转换网站,最直接的顾虑是内容外泄。合同、财务报表、内部汇报材料,这些文件一旦离开本地,风险就不是"可能"而是"什么时候"的问题。本地转换把整条链路收进自己的机器,文件不出网卡,日志不出磁盘,这是最硬的价值。第二个价值是批量能力,在线站点一次一个文件、还要等广告和验证,本地工具可以挂一晚上跑几千份,第二天早上直接看结果目录。

第三个容易被忽略的价值是可复现。在线服务的版本、参数、字体都在别人手里,今天转出来是这样,明天可能就变了。本地工具你可以把 LibreOffice 版本、字体包、转换参数全部固定下来,同一批文件任何时候转出来的结果都一样,这对需要归档、需要审计留痕的场景非常关键。我在做文档归档的时候,就把整个运行环境打成了一个固定镜像,两年后重新跑旧文件,输出还能对得上。

第四个价值是可控的错误处理。在线接口失败了你只能重试或者放弃,本地工具可以做到超时杀进程、失败进重试队列、结果做二次校验、异常文件单独隔离,这套机制是批量任务的命脉。

1.2 LibreOffice 在链路里的位置和它的天花板

LibreOffice 在这套工具里承担的是"渲染与序列化"的角色。它内部有一套完整的文档模型,能把 docx、xlsx、pptx 解析成自己的对象树,再按照目标格式重新序列化输出。所以它不只是个文件后缀转换器,而是真的在重新排版。这也是它比很多轻量库强的地方——纯文本提取库只能拿到字,拿不到分页和样式。

但它有明确的天花板,这一点必须提前说清楚,否则后面一定会失望。第一,PDF 转 Word 的还原度很低。LibreOffice 导入 PDF 走的是 Draw 的 PDF 导入器,结果是一个绘图文档,文字能提取、图片能保留,但段落结构、表格、分栏基本还原不了,转出来的 docx 大概率是一堆文本框。这不是配置问题,是能力边界。第二,复杂排版的 Word 转 PDF 会有细微漂移,尤其是用了特殊字体、复杂的图文混排、域代码、公式的地方。第三,Excel 转 PDF 默认不处理打印区域,超出页宽的部分会被切掉。

所以我在设计工具的时候,第一件事不是写代码,而是把"承诺"划清楚:Office 转 PDF 承诺高保真,PDF 转 Office 只承诺文本可编辑、不承诺排版还原。这个边界一旦明确,后面的产品设计和用户预期都好办。

2. 整体架构:一个能扛住批量的转换器长什么样

单文件转换和质量保证是两件事,批量转换和单文件转换又是两件事。我见过太多人写了个 for 循环遍历目录、挨个调 soffice,跑十个文件没问题,跑一千个就卡死、内存爆掉、进程僵在那不动。问题出在架构上,不是出在命令上。所以这一节先把结构讲清楚,再往下钻参数细节。

2.1 三层拆分:调度层、转换层、校验层

我把整个工具拆成三层。调度层负责扫描输入目录、生成任务、控制并发数、管理重试队列;转换层是一个个独立的 soffice 子进程,每个进程只干一件事,干完就退;校验层在转换完成后检查输出文件是否存在、大小是否合理、页数是否对得上,不合格的打回重试或者标记失败。

这么拆的理由很实际。转换层是黑盒,LibreOffice 内部出什么问题你没法干预,唯一能做的就是隔离和兜底。把转换层做成一次性进程,好处是内存泄漏、状态污染、崩溃僵死这些问题都只影响单个任务,不会拖垮整个服务。调度层和校验层是纯 Python 逻辑,好测试、好改。

三层之间通过文件系统和任务状态表通信,不搞复杂的消息队列。文件系统就是最朴素的任务队列:输入目录里的文件就是待办,输出目录里存在的文件就是已完成,失败目录里的就是需要人看的。这套设计在几千份文件的量级上完全够用,而且调试极其方便——出问题直接看目录就知道卡在哪。

2.2 为什么走子进程而不是常驻 UNO 服务

LibreOffice 提供了 UNO 接口,可以用 Python 连上一个常驻的 soffice 进程,通过 API 直接操作文档对象。这条路理论上更优雅,不用反复启动进程,冷启动开销省了,还能精细控制导出参数。我试过,结论是:单机小批量、对参数要求极细的场景可以用,批量转换不建议。

原因有三个。第一,常驻进程的状态会累积。转了几百个文件之后,内存占用一路上涨,偶尔还会莫名其妙地卡住,你没法优雅地重启它而不影响正在跑的任务。第二,UNO 的连接会断。进程崩了、网络套接字出问题,客户端这边就是一堆超时异常,恢复逻辑比子进程模式复杂得多。第三,也是最关键的,并发。一个 soffice 进程同时处理多个文档,内部是单线程模型,实际上还是排队;想真并发就得多开进程,那又回到了子进程模式。

所以我选的是"一任务一进程"的子进程模式,用独立的用户配置目录做隔离,用超时强杀做兜底。代价是每个任务多几百毫秒到几秒的启动开销,换来的是稳定性和可预测性。批量场景下这个交换非常值。

2.3 任务模型与目录约定

目录结构我固定成四个:inbox放待转换文件,outbox放成功结果,failed放失败文件加一份同名.log,tmp放运行时的临时文件和独立的 profile 目录。这套约定看起来土,但用起来顺手,运维同事不需要看代码就知道怎么补文件、怎么捞失败件。

命名上有一个坑必须提前处理:soffice 的输出文件名默认跟输入文件同名,只改扩展名。如果inbox里同时有报告.docx和报告.xlsx,都转 PDF,输出就撞名了,后转的会覆盖先转的,而且不报错。我的做法是在调度层给每个任务分配一个唯一 ID 前缀,转换完成后再改回可读的名字,或者在输出名前拼上源文件的完整名字加哈希短码。

还有一点,任务状态我写在 SQLite 里,字段就四个:文件路径、状态、尝试次数、错误摘要。不上 Redis,不上消息队列,单机场景 SQLite 足够,还能直接 SQL 查"哪些文件重试超过三次了"。

3. 决定成败的核心参数与细节

命令行的部分看着简单,soffice --headless --convert-to pdf 文件.docx一行就能跑。但真要做到高保真和稳定,参数层面的细节非常多。这一节把关键的几个点摊开讲,包括过滤器名称、配置目录隔离、字体、PDF 导出选项。

3.1 命令行是最小可用面,但每个参数都有讲究

最基础的调用长这样:

soffice --headless --norestore --invisible \ --convert-to pdf \ --outdir /data/outbox \ /data/inbox/季度汇报.docx

--headless是不弹界面,服务器上没有 X 服务必须加。--norestore是崩溃后不要弹恢复对话框,不加的话某些情况下进程会挂在那里等交互,批量任务直接卡死。--invisible是老参数,新版可以不加,加上也无害。--outdir指定输出目录,不指定就输出到当前工作目录,容易找不到文件。

有一个行为要注意:soffice 转换成功时退出码是 0,但转换失败时退出码也可能是 0。我踩过这个坑,脚本里判断returncode == 0就当成功,结果一批文件全是 0 字节的 PDF 或者压根没生成文件。所以判断成功的唯一可靠依据是:目标文件存在,且大小大于一个阈值(我一般用 1KB 作为下限,正常文档不可能更小)。退出码只用来辅助判断"进程是不是正常结束"。

另一个细节是文件路径里有空格和中文。子进程调用时不要用 shell=True 拼字符串,直接传列表参数,让操作系统处理转义,中文路径在 Linux 下只要 locale 是 UTF-8 就没问题,Windows 下要把控制台编码设成 UTF-8 或者用宽字符接口。

3.2 过滤器名称对照表:不写清楚就会转错格式

--convert-to后面跟的不只是扩展名,完整写法是格式:过滤器名。只写扩展名的时候 LibreOffice 会猜一个默认过滤器,大部分情况能猜对,但有几个地方会翻车,尤其是同一种扩展名对应多个过滤器的时候。常用的对照我整理成表:

目标格式完整写法说明
PDF(文档类)pdf:writer_pdf_ExportWord/ODT 转 PDF
PDF(表格类)pdf:calc_pdf_ExportExcel/ODS 转 PDF
PDF(演示类)pdf:impress_pdf_ExportPPT/ODP 转 PDF
docxdocx:"MS Word 2007 XML"必须带引号,含空格
xlsxxlsx:"Calc MS Excel 2007 XML"表格工作簿
pptxpptx:"Impress MS PowerPoint 2007 XML"演示文稿
htmlhtml:"HTML (StarWriter)"文档转网页
txttxt:"Text (encoded):UTF8"纯文本,编码必须指定

这里最容易错的点是:转 PDF 的时候,三种文档类型用的是三个不同的过滤器。如果你用writer_pdf_Export去转一个 xlsx,LibreOffice 会先把它当作文档打开,结果可能是只有第一张表、或者整个表格被压成一页。反过来用一个通用写法--convert-to pdf让程序自己猜,大部分时候是对的,但批量场景下我强烈建议显式指定,因为猜错的时候往往是静默的,你不会收到任何报错。

引号那块也要注意。过滤器名里有空格,在 shell 里必须用引号包住整段,在 Python 的 subprocess 里则是作为一个完整字符串传进去,不要自己拆。写成--convert-to和docx:"MS Word 2007 XML"两个列表元素,前者是参数名,后者是参数值。

3.3 用户配置目录必须隔离,这是并发的前提

LibreOffice 启动的时候会在用户目录下建一个 profile,路径大概是~/.config/libreoffice/4。这个目录里有锁文件、缓存、最近文档列表、扩展配置等等。同一个 profile 同时被两个 soffice 进程使用,第二个进程要么直接退出,要么卡住等锁,现象就是"我明明发了十个任务,只有一个在跑"。

解决办法是每个进程指定独立的 profile:

soffice --headless --norestore \ -env:UserInstallation=file:///tmp/lo_profile_7f3a \ --convert-to pdf --outdir /data/outbox /data/inbox/a.docx

注意-env:是单横线,不是双横线,这是 LibreOffice 的扩展参数语法,写错了会被当成文件路径。路径必须用file://开头的 URI 形式,相对路径和裸绝对路径都可能失败。

隔离之后还有两个现实问题。第一,新 profile 首次启动会做一堆初始化,冷启动时间明显变长,大概多出两到三秒。第二,profile 目录会占磁盘,一个空 profile 大概几十兆,跑一千个任务就是几十 G,必须做清理。我的做法是给每个任务建一个临时 profile,任务结束后整个目录删掉,用tempfile.mkdtemp()管理生命周期,异常时在 finally 里清理。

还有一个可优化点:可以预先准备一个"干净模板 profile",里面已经装好了必要的扩展和字体配置,任务开始时直接复制一份,比从零初始化快得多。这个技巧在任务量大的时候能省不少时间。

3.4 字体是"高保真"的第一道生死线

我见过最典型的问题:一份 Word 文档在本地打开好好的,转成 PDF 之后全是方框,或者中文变成了宋体、英文变成了 Times New Roman,行距全乱。原因几乎都是字体缺失。LibreOffice 找不到文档里声明的字体,就用替代字体顶上,替代字体的字宽、字高、基线都跟原字体不一样,整个排版就跟着移位。

解决方案分三步。第一步查清文档用了什么字体。docx 本质是个 zip,解压后看word/fontTable.xml和word/document.xml,里面的w:rFonts节点就写着字体名。批量场景下可以写个脚本把所有文档的字体名扫一遍,做一次统计,你就知道该装哪些字体了。

第二步装字体。Linux 下把 ttf/otf 文件丢进/usr/share/fonts/truetype/下的一个自建目录,然后跑fc-cache -fv刷新缓存。这一步不能省,不刷缓存 fontconfig 不认识新装的字体。装完用fc-list | grep 字体名确认能被识别到。中文文档一般至少需要一套宋体、一套黑体、一套楷体、一套仿宋,再加上常用英文字体。

第三步做映射。有些商业字体没法装,就得用fontconfig的别名机制做替代。在/etc/fonts/conf.d/下加一个配置文件,把缺失的字体名映射到已装的近似字体上,比如把某个宋体变体映射到思源宋体。这样至少在字宽上接近,排版漂移会小很多。

提示:字体这件事没有捷径,只能老老实实做需求统计。我在一个项目里因为漏装了一套等线字体,导致几百份 PPT 转出来的标题全部换行位置错位,返工重跑花了大半天。装字体成本极低,不装代价极高。

3.5 PDF 导出参数的精细调节

LibreOffice 7.x 之后支持用 JSON 形式传过滤器参数,这就打开了精细调节的口子。写法是把 JSON 直接拼在--convert-to的值后面:

soffice --headless \ --convert-to 'pdf:writer_pdf_Export:{"SelectPdfVersion":{"type":"long","value":"1"},"EmbedStandardFonts":{"type":"boolean","value":"true"},"UseTaggedPDF":{"type":"boolean","value":"true"},"ExportBookmarks":{"type":"boolean","value":"true"},"MaxImageResolution":{"type":"long","value":"300"}}' \ --outdir /data/outbox /data/inbox/a.docx

几个参数值得单独说。SelectPdfVersion设成 1 是对应 PDF/A-1,适合归档场景,好处是强制嵌入字体、不允许加密,保证十年后还能打开。EmbedStandardFonts打开后会把标准字体也嵌进去,文件会大一些,但换机器打开绝对一致,做交付件的时候我都开着。UseTaggedPDF生成带标签的 PDF,无障碍阅读和后续用工具提取结构都用得上,代价是文件略大、转换略慢。MaxImageResolution控制图片压缩后的最大 DPI,设 300 基本能保住印刷质量,设 150 能把文件压到原来的一半以下。

还有一个和 PDF 相关的隐藏坑:文档里的超链接、书签、交叉引用,默认导出行为在不同版本里不一样。如果你的 PDF 是给外部看的,ExportBookmarks记得开;如果文档里有大量内部链接,导出后点不动是很常见的投诉,原因就是参数没配。

4. 从零跑起来:实操全流程

前面讲的是"为什么",这一节讲"怎么做"。我会按顺序走一遍:环境准备、单文件脚本、批量并发、结果校验,每一步都给可直接用的代码或命令。

4.1 环境准备与自检

Linux 下装 LibreOffice 建议装完整包,不要只装 core。命令是apt install libreoffice libreoffice-writer libreoffice-calc libreoffice-impress,加上fonts-noto-cjk把中文字体一起带上。装完之后先跑一遍自检:

soffice --version fc-list :lang=zh | head -20

第一条确认版本,第二条确认系统中文字体情况。如果fc-list一条中文都没有,那后面所有转换都会出问题,先解决字体再说。Windows 下路径是C:\Program Files\LibreOffice\program\soffice.exe,注意程序目录下有个soffice.com,那是控制台版本,做子进程调用时用.com更容易拿到输出,.exe是窗口版本,在某些情况下不返回 stdout。

还有一点,服务器上如果没有图形环境,--headless是必须的,但有些老版本仍然会尝试连接显示服务。稳妥的做法是装一个libreoffice-headless包,或者设置环境变量SAL_USE_VCLPLUGIN=svp,强制用无头后端。

4.2 单文件转换的最小可用脚本

先写一个能跑通的最小版本,把所有关键参数都带上:

import os import subprocess import tempfile import shutil SOFFICE = "/usr/bin/soffice" def convert(src, outdir, fmt="pdf:writer_pdf_Export", timeout=180): profile = tempfile.mkdtemp(prefix="lo_profile_") cmd = [ SOFFICE, "--headless", "--norestore", "--invisible", f"-env:UserInstallation=file://{profile}", "--convert-to", fmt, "--outdir", outdir, src, ] try: proc = subprocess.run( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout, start_new_session=True, ) detail = (proc.stdout + proc.stderr).decode("utf-8", "ignore") return proc.returncode, detail except subprocess.TimeoutExpired: return -1, "timeout" finally: shutil.rmtree(profile, ignore_errors=True)

这里每一个参数都有来历。start_new_session=True是关键,它让 soffice 在独立进程组里跑,超时的时候可以整组杀掉。如果不开这个,subprocess的 timeout 只会杀掉父进程,soffice 派生的子进程会变成孤儿,继续占着内存和 CPU,跑几百个任务之后机器就废了。

超时值 180 秒是给大文件留的余量。一般几十页的 Word 转 PDF 在 5 到 15 秒之间,上百页的、图片特别多的可能要 30 到 60 秒。设太短会误杀正常任务,设太长又会让卡死的任务占着并发槽。我的经验值是 180 秒,超过这个时间的文件基本都有问题,值得人工看一眼。

4.3 并发池与超时强杀

并发数的选择要按机器配置来。每个 soffice 进程转换时占 100 到 300MB 内存,CPU 主要吃在首次启动和渲染阶段。我的经验公式是:并发数 = min(CPU 核心数 / 2, 内存GB / 0.5)。八核十六 G 的机器,开四个并发比较稳,开八个也能跑但内存容易打满,遇到大文件就 OOM。

并发实现用concurrent.futures.ThreadPoolExecutor就够了,因为真正的并行发生在子进程里,线程只是负责发起和等待。每个线程内部再调上面的convert函数。

超时强杀这一段要单独写清楚,因为默认的 kill 行为不够:

import os, signal, subprocess proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True) try: out, err = proc.communicate(timeout=180) except subprocess.TimeoutExpired: os.killpg(os.getpgid(proc.pid), signal.SIGTERM) try: proc.communicate(timeout=10) except subprocess.TimeoutExpired: os.killpg(os.getpgid(proc.pid), signal.SIGKILL) raise

先发SIGTERM让进程有机会清理临时文件,等十秒还不退再SIGKILL。直接上SIGKILL的问题是可能在 profile 目录里留下锁文件,虽然我们已经做了目录隔离、用完就删,但有的版本的 soffice 会在系统临时目录留东西,日积月累也是麻烦。

4.4 结果校验:怎么判断"转成功了"

前面说过退出码不能信,所以要自己做校验。校验分三层,从轻到重。

第一层看文件存在和大小。目标路径存在、大小超过 1KB,基本可以认为生成了有效文件。第二层看文件能否被解析,PDF 可以用pdfinfo读页数,或者用 PyPDF2 打开一次,读不出来就是坏的。第三层看内容合理性,比如源文档有 30 页,转出来的 PDF 只有 2 页,那肯定是渲染出错,需要人工介入。

from PyPDF2 import PdfReader def check_pdf(path, min_pages=1): if not os.path.exists(path) or os.path.getsize(path) < 1024: return False, "文件不存在或过小" try: reader = PdfReader(path) n = len(reader.pages) except Exception as e: return False, f"解析失败: {e}" if n < min_pages: return False, f"页数异常: {n}" return True, f"ok, {n} 页"

页数校验的参考值不好自动获取,我的做法是把同批次的文档做一个中位数统计,页数明显偏离中位数的(比如只有中位数的十分之一)标黄,人工抽查。这套规则拦下了不少问题,比如扫描版 PDF 被误当作可编辑文档处理后变成空白。

5. 各格式互转的真实效果与边界

这一节讲实际体验。工具做出来能不能用,取决于你对每种转换路径的预期是否合理。我按 Office 转 PDF、PDF 转 Office、以及 Excel/PPT 的特殊处理三条线讲。

5.1 Office 转 PDF:最稳的一条路,但要盯住排版漂移

Word 转 PDF、Excel 转 PDF、PPT 转 PDF,是这套工具里质量最高的三条路径,也是主要使用场景。正常情况下还原度能到九成以上,字体装齐、页边距标准、没有复杂图形的文档,基本可以做到肉眼无差。

漂移主要出现在几个地方。一是分页位置。Word 的分页依赖于渲染引擎的度量,LibreOffice 和 Word 在行高、字间距的计算上有细微差别,长文档的最后一页可能差一两行。二是表格宽度。用了自动调整的表格,转出来可能某一列被挤得很窄。三是页眉页脚的域代码,比如自动页码、总页数,一般能正常渲染,但用了复杂域的地方可能显示异常。四是图表和公式,简单的柱状图饼图没问题,带复杂格式的、用了 Office 独有公式编辑器的,转出来会变形。

应对办法是建立一份"回归样本库"。我这边的做法是攒二十个有代表性的文档:带页眉页脚的长文档、多级列表、复杂表格、图文混排、带图表的报表、带公式的技术文档,每次调整参数或者升级 LibreOffice 版本,都拿这批文件跑一遍,人工看一遍输出。这个习惯能挡住九成的版本升级事故。

5.2 PDF 转 Office:只承诺文本可编辑,别承诺排版

前面提过,这条路还原度很低,但需求又特别旺盛,所以要把能力和话术都设计好。PDF 导入 LibreOffice 走的是 Draw 的导入器,结果是绘图文档,每一行文字可能是一个独立的文本框,图片是独立对象,页面上看起来跟原 PDF 一模一样,但你打开编辑的时候就会发现全是散件。

我的处理策略是分两档。第一档是"提取可编辑文本",把 PDF 转成 txt 或者 docx,接受段落结构丢失,只保证文字内容完整可用。第二档是"保留视觉外观",转成 odg 或者直接渲染成图片再嵌进 docx,文字不可编辑但外观一致。用户需要哪一档,界面上让他选,别指望一个按钮解决所有问题。

如果确实需要高保真的 PDF 转 Word,那得引入版面分析能力,识别文字块、段落、表格、图片位置,再重新构建 docx。这已经超出 LibreOffice 能做的范围,属于另一个技术栈。我的建议是把这个需求明确排除在工具的核心范围之外,避免做出一个"什么都能转但什么都转不好"的东西。

5.3 Excel 和 PPT 的特殊坑

Excel 转 PDF 最大的问题是打印区域和缩放。默认情况下 LibreOffice 按表格的实际内容分页,宽表会被横向切成好几页,右边的内容跑到第二页去了,看起来很怪。解决办法有两个:一是在转换前用宏或者 UNO 脚本设置页面属性为横向、缩放到一页宽;二是在转换命令里指定 calc 的 PDF 过滤器参数,控制分页行为。

用脚本设置页面属性的方式更可控。思路是先用 Python 的 UNO 接口打开文件,设置PageStyle的ScaleToPagesX = 1、IsLandscape = True,保存后再转 PDF。这条路要引入常驻进程,但对于表格类文档,质量提升是明显的。折中方案是只对宽度超过阈值的表格做这个处理,正常表格直接转。

PPT 转 PDF 相对干净,主要问题是动画、切换效果、演讲者备注。转成 PDF 之后动画自然没有了,这个要提前告知。另一个问题是字体替换,PPT 里炫技用的特殊字体最容易被替换,一旦替换,文本框宽度不够就会换行,整个版式就歪了。所以字体检查在 PPT 场景下比在文档场景下更重要。还有一个细节是页面尺寸,宽屏 16:9 的 PPT 转成 PDF 之后,如果 PDF 导出参数里没指定页面尺寸,可能变成 A4 比例,两侧留白或者内容被压缩,最好在导出参数里显式指定跟源文件一致的尺寸。

6. 常见问题排查实录

这一节是我这些年踩过的坑汇总,按现象分类,每条都给排查路径。遇到问题先查这张表,能省掉大量试错时间。

6.1 中文乱码、方框、字体替换

现象是转换后的 PDF 里中文全是方框,或者变成了完全不像的字体。排查顺序是这样的:先跑fc-list :lang=zh确认系统里有多少中文字体;再用fc-match "文档里声明的字体名"看实际匹配到了哪个字体,如果返回的是 DejaVu 之类的西文字体,那就说明没匹配上;最后检查fc-cache有没有刷新。

还有一种情况是字体装了但 LibreOffice 没认。这是因为 LibreOffice 有自己的字体缓存,位置在 profile 目录里。做目录隔离的时候每个新 profile 都要重新扫描字体,第一次启动会慢一点。如果发现装了字体还是方框,可以试试删掉 profile 目录重新跑,或者干脆用系统默认 profile 跑一次让它刷新缓存。

Windows 下的乱码原因不太一样,多半是编码问题。源文件是 GBK 编码的 txt,或者文件路径里有非 ASCII 字符,子进程调用时没有正确传递。解决办法是统一用 UTF-8,Python 里显式给encoding="utf-8",路径用os.fsencode处理。

6.2 进程僵死与并发冲突

现象是任务提交上去之后一直不返回,CPU 占用为零,也没有任何输出。九成是 profile 冲突。检查方法很简单:看命令行里有没有-env:UserInstallation,路径是不是每个进程都不一样。如果两个进程用了同一个 profile,第二个会卡在等锁上,永远不会超时退出,只能手动杀。

第二种僵死是 LibreOffice 崩溃后留下的恢复对话框。虽然加了--norestore,但某些异常情况下还是可能弹出。彻底的解决办法是在 profile 里预置配置关掉恢复功能,或者用SAL_DISABLE_OPENCL=1之类的环境变量规避一些已知的崩溃路径。

第三种是内存耗尽。多个大文件同时转换,每个进程吃几百兆,机器 swap 打满之后所有进程都变慢,看起来像僵死。监控内存占用,把并发数调低,或者给单个任务加内存上限(用 cgroup 或者ulimit)都是办法。

现象最可能的原因处理方式
输出 0 字节过滤器名写错 / 源文件损坏显式指定过滤器,先用 GUI 打开源文件验证
中文变方框中文字体缺失装字体,fc-cache -fv刷新
进程卡住不退出profile 冲突每个进程独立 UserInstallation 目录
宽表被切断未设置打印区域转换前设置横向、缩放一页宽
部分文件无输出但退出码 0静默失败用文件存在性和大小做校验,不信任退出码
转换极慢首次启动 / 图片过多预热 profile,压缩图片分辨率
内存持续上涨常驻进程泄漏改为一任务一进程,用完即退

6.3 性能数据与调优方向

实测数据放在这里,方便你对号入座。八核十六 G 的机器,单进程冷启动转换一份 5 页的 Word 到 PDF,大约 3 到 5 秒,其中启动占 2 秒左右。同样文件热启动(profile 已存在)大约 1.5 到 2.5 秒。一份 50 页带图的文档,转换时间 10 到 20 秒。一份 20 张幻灯片的 PPT,5 到 10 秒。一份 1000 行的 Excel 转 PDF,3 到 8 秒,如果设置了缩放到一页宽会慢一些。

并发四个进程时,整体吞吐大概是单进程的三倍左右,不是四倍,因为启动阶段的 IO 和 CPU 会互相争抢。并发八个的时候吞吐提升有限,反而容易出现内存压力。所以我的建议是并发数不要超过核心数的一半。

优化方向按收益排序:第一是预热 profile,能省掉每个任务的初始化开销;第二是减少不必要的格式转换,中间不要绕道;第三是控制图片分辨率,超过 300 DPI 的图片对屏幕阅读毫无意义;第四是给输出做缓存,同一个文件的哈希值算出来,如果之前转过且参数没变,直接复用结果。缓存这一条在重复处理场景下收益巨大,尤其是那种每天跑一遍全量归档的任务。

7. 几条我反复验证过的硬经验

做这类工具,最值钱的不是代码,是那些踩过之后才明白的常识。第一条,不要相信任何"转换成功"的信号,只相信输出文件本身。退出码、标准输出里的 "convert ... -> ..." 提示、甚至文件的修改时间,都可能骗你。我现在的校验逻辑是先删掉可能存在的旧输出文件,转换完再检查文件是否重新出现,这样能排除掉"这次失败了但上次的残留还在"的误判。

第二条,把字体问题当成项目的一等公民来对待。开工第一件事就是统计需求文档用到的所有字体,列一个清单,缺什么补什么,能拿到授权的装原字体,拿不到的做映射。这件事花半天,能省掉后面无数次的返工和解释。我见过最惨的情况是转换服务上线两周后才发现所有对外文档的标题字体都被换掉了,因为这个错误不会报错,只有设计师一眼看出来。

第三条,给每一种转换路径都准备一份"责任声明"。Office 转 PDF 承诺排版一致,PDF 转 Office 明确说明只做文本提取,扫描件需要走 OCR 且不在本工具范围内,带宏的文件会丢失宏,带外部链接的表格链接会断开。这些话提前写清楚,比事后解释一百遍都管用。

第四条,日志要记到能复现的程度。我的日志里记源文件路径、目标格式、完整命令行、耗时、退出码、输出文件大小、profile 路径。出问题的时候能拿着命令行原样重跑一次,比翻代码快十倍。日志按天切分,保留三十天,体积不大但够用。

最后分享一个小技巧。如果你要批量处理历史文档,别一股脑全塞进去跑。先按文件大小和页数做分层,小的先跑一批看看质量,中等的一批,大的单独跑。因为大文件最可能出问题,混在一起跑的时候,一个大文件卡住会拖慢整个队列,你分不清是普遍问题还是个别问题。分层跑,出问题的范围一眼就清楚了。这套流程我在几个不同规模的归档任务里都用过,每次都能在早期发现配置问题,避免了几千份文件全部重跑的灾难。