
前阵子一个做档案数字化的老朋友找过来说他手上攒了六千多个 XPS 文件都是早年间一套业务系统批量导出的现在系统要下线得整体迁移。目标说得很清楚全量转成 PDF 存档每份抽首页出一张 PNG 做缩略图再把正文文字和版面坐标导成 JSON 喂进检索库。他先试了几个在线转换站点单个文件还行一上批量就卡死大文件提示超时更要命的是那些材料本身不适合往外部传。我看了看时间成本用 Python 搭一套本地流水线是最省事的方向——这也是我这些年处理python 处理 xps 文件这类活儿的惯用思路xps 转 pdf交给成熟渲染器去做xps 转 png从同一份渲染结果派生xps 转 json则分高层提取和底层解析两条腿走。XPS 这个格式说冷门也冷门说常见也常见。系统自带的打印到文件默认就吐它不少政务、医疗、制造业的老系统导出的存档也是它。它长得像 PDF内部却是完全另一套东西。下面我把这条流水线从格式认知到代码落地完整拆一遍参数怎么算、坑在哪、哪些地方必须人工抽查都写清楚。1. 先把XPS拆开看一个ZIP包的内部构造动手写代码之前花二十分钟把 XPS 的内部结构摸清楚比后面调三天 bug 划算得多。很多人第一次接触这个格式会下意识把它当成另一个 PDF去找类似 PyPDF2 那样的库来读写结果发现生态完全不是一回事。原因很简单XPS 是一套基于 XML 的文档描述规范它的组织方式更接近 Office 的 Open XML而不是 PDF 那种对象流 交叉引用表的二进制结构。1.1 目录结构长什么样怎么用zipfile摸清XPS 全称 XML Paper Specification容器用的是 OPCOpen Packaging Conventions说白了就是一个 ZIP 包只是后缀被改成了 .xps。你把文件后缀临时改成 .zip用任何解压工具都能打开。里面的典型结构是这样的/ ├── [Content_Types].xml ├── _rels/.rels ├── FixedDocumentSequence.fdseq ├── Documents/ │ └── 1/ │ ├── FixedDocument.fdoc │ ├── Pages/ │ │ ├── 1.fpage │ │ └── 2.fpage │ └── Resources/ │ ├── 1a2b3c4d-....odttf │ └── Images/1.png.fdseq是文档序列指向一个或多个.fdoc.fdoc是单个固定文档按顺序引用若干.fpage.fpage就是一页的实际内容根节点是FixedPage。所有页面资源字体、图片、颜色配置都在Resources目录下。我拿到一批陌生来源的 XPS第一件事永远是先跑一遍zipfile.namelist()把目录打出来看。原因是不管理论上结构多标准实际导出的文件层级会有差异——有的工具把Pages平铺在Documents/1/下有的塞进DocumentParts子目录还有的连Documents这层都省了。硬编码路径写死遇上变体就全崩。import zipfile with zipfile.ZipFile(sample.xps) as z: for name in z.namelist(): print(name)这一步的输出还有一个额外价值你能立刻看出这份文件里有没有图片资源、用了几个字体从而判断后续的解析工作量。如果Resources/Images下挂了上百个文件那这份文档大概率是扫描件拼的文本提取意义不大重心要放在图片质量上。1.2 96 DPI这个单位坑早搞懂少走三天弯路这是我见过最多人栽跟头的地方。打开一个.fpage文件根节点大概是这样的FixedPage xmlnshttp://schemas.microsoft.com/xps/2005/06 Width816 Height1056816 乘 1056第一反应往往是这什么鬼尺寸既不是 A4 也不是 Letter。其实它是 96 DPI 的逻辑像素816 ÷ 96 8.5 英寸1056 ÷ 96 11 英寸标准的美国 Letter 纸。A4 换算过来是 210 ÷ 25.4 × 96 ≈ 793.7297 ÷ 25.4 × 96 ≈ 1122.5。而 PDF 用的是 72 pt 点。同一张 A4 在 PDF 里是 595 × 842。两套体系之间差了一个 96 ÷ 72 4/3 的系数也就是 1.3333。这个系数不搞清楚转出来的 PDF 页面尺寸会整体差 33%。表现是什么打印时内容被裁掉一截或者四周留出一大圈莫名其妙的白边还得手动缩放。我见过一个项目因为这个系数没换算把两万多页文档转废了最后靠重新跑一遍才救回来。所以任何涉及坐标、尺寸、字号的操作脑子里都要先过一遍现在这个数值是 96 体系的还是 72 体系的。好消息是成熟的渲染库内部会帮你做这个换算。PyMuPDF 打开 XPS 之后page.rect直接给出的是 pt 单位的尺寸。但如果你要自己解析 XML 做坐标提取这个换算必须手动做一行都省不掉。1.3 文本为什么藏在Glyphs里而不是普通标签PDF 有独立的文本流可以按内容流顺序读XPS 不是。它的文本用Glyphs元素表达页面里可能是这么写的Glyphs Origin96,120 FontUri/Resources/1a2b3c4d.odttf FontRenderingEmSize12 UnicodeString档案编号 AdvanceWidths6.7,6.7,6.7,6.7 BidiLevel0 Fill#FF000000 /几个属性必须理解到位Origin是文本基线起点的坐标注意是基线不是顶部。想算文本框的顶边得自己加上 ascent。FontRenderingEmSize是字号单位仍然是 96 DPI 那套逻辑单位。AdvanceWidths是每个字符的步进宽度单位是 em 的倍数。比如 6.7 配合字号 12单个字符的实际宽度是 6.7 × 12 80.4 个逻辑单位。UnicodeString是实际文本内容。Indices是字形索引用于复杂的字形映射场景。这里有个细节值得单独拎出来说AdvanceWidths的长度很多时候和字符数对不上。原因是文本可能被拆成多个 Glyphs 元素也可能一个 Glyphs 里混了不同字体。做坐标还原时如果不按AdvanceWidths逐字累加而是拿字符数 × 平均字宽拍脑袋还原出来的位置会越往后偏得越离谱。我早年做票据版面还原时就吃过这个亏最后老老实实逐字累加才对齐。1.4 现成转换工具的三个硬伤既然结构清楚为什么不直接用现成工具我试过一圈结论是各有各的硬伤在线转换站点单个文件免费一上批量就要付费而且材料外传这个事在很多场景下直接不可行。系统自带的阅读器只能看没有批量导出能力几千个文件靠人工点工期不允许。虚拟打印机打印到 PDF一次处理一个文件还得手动确认对话框六千个文件能等到过年。办公套件 headless 模式能转但每个文件都要启动一次进程启动开销比转换本身还大复杂版式偶尔还有偏差。所以自己写流水线是正解。写一次后面所有批量活儿都能复用还能按需加日志、加校验、加断点续跑。2. 方案选型三条路线的取舍与组合选型这一步值得单独花一节讲因为不同的选择直接决定了后面代码的复杂度和可维护性。处理 XPS 大致有三条路我把它们摆在一起对比过很多次结论一直没变。2.1 纯解析、渲染库、外部进程横向对比路线典型做法优势短板适用场景纯 Python 解析zipfile lxml 解析 fpage XML零外部依赖能拿到最底层的原始属性不做渲染转 PDF/PNG 得自己实现绘制逻辑只需要文本和结构做检索索引调用渲染库PyMuPDF 直接打开一个库覆盖 PDF、PNG、文本提取需要装 wheel版本有边界主力方案覆盖 90% 需求外部进程调用办公套件或渲染引擎的命令行兼容性经过长期验证边界情况兜底慢部署重错误信息不友好疑难文件兜底纯解析这条路代码量不算大但它有个致命短板你拿到的是结构不是画面。要做 PDF 或 PNG等于得自己实现一个渲染器处理字形、路径、渐变、图像变换矩阵工作量直接上天。所以它只适合做数据提取不适合做格式转换。外部进程那条路胜在稳定很多疑难文件它都能啃下来。但代价是每处理一个文件都要付一次进程启动开销几百毫秒到几秒不等。六千个文件乘下来多等几个小时。而且进程崩了错误信息往往只有一句转换失败排查起来非常痛苦。2.2 我为什么主力押PyMuPDFPyMuPDF 是这十来年里我用得最顺的一个库。它对 XPS 的支持是内置的打开的时候不需要做任何格式判断fitz.open(a.xps)直接就能拿到 Document 对象页数、页面尺寸、文本块都是现成的。转 PDF 一句save转 PNG 一句get_pixmap转 JSON 用get_text(dict)自己组装三个需求一个库全包。更关键的是它的坐标体系是统一的不管源文件是 XPS 还是 PDF打开之后page.rect都是 pt文本块的 bbox 也是 pt省掉了一大堆换算的心智负担。这一点在批处理混合格式目录时特别好用。版本上有一个必须注意的边界1.21 之前的版本对某些 XPS 实现的支持不全我实际遇到过转出来整页空白的情况升级到 1.23 之后就好了。所以依赖里我把版本下限锁在 1.23宁可装不上也不装老版本。2.3 底层解析什么时候必须上PyMuPDF 提取文本很方便但它返回的是渲染后的结果。有些信息在这一层已经被规整过了拿不到原始状态。比如需要知道某个文本段用的是哪个字体资源文件.odttf的 GUID需要拿到AdvanceWidths的原始数组做精确排版还原需要分析文档里嵌了哪些图片资源、各自的原始尺寸需要检查文档结构是否完整判断文件有没有损坏这些场景就得回到 zipfile lxml 那条路上去。所以我的最终方案是两条腿走路主流程用 PyMuPDF做格式转换和常规文本提取遇到需要原始属性的场景临时切到底层解析。3. 环境准备与依赖安装的实操细节环境这块看着简单实际踩过的坑不少。写清楚一点省得后面照着抄还装不上。3.1 依赖清单与版本边界pip install pymupdf1.23 lxml pillow tqdm四个包的职责很清晰PyMuPDF主力渲染与转换包名是pymupdf导入名是fitz新版也支持import pymupdf但老代码兼容性考虑我习惯用fitz。lxml底层 XML 解析比标准库的 ElementTree 快一个量级命名空间处理也更顺手。Pillow白边裁剪、图片后处理、格式另存。tqdm批量进度条几千个文件没有进度条你根本不知道是卡住了还是在跑。版本上我只锁了 PyMuPDF 的下限其余的不锁。原因是 PyMuPDF 的 XPS 支持是逐步完善的锁下限能避免踩到已知的坑其他三个库的 API 稳定升级风险低。3.2 各平台安装踩过的坑Windows 上直接pip install就行wheel 是预编译的不需要额外的 C 编译器。唯一要注意的是 Python 版本别太老3.8 以下有些新版 wheel 装不上。Linux 上也不需要额外装系统库wheel 自带依赖。但如果用 Alpine 这类 musl 基础镜像可能会找不到匹配的 wheel得走源码编译那时候就得补gcc、make、freetype-dev这些。我的建议是直接用 glibc 基础镜像省事。macOS 上注意架构。M 系列芯片要确保 pip 装的是 arm64 的 wheel如果之前用 Rosetta 装过 Python可能会拿到 x86_64 的包跑起来性能差一截。用python -c import platform; print(platform.machine())确认一下架构。还有一个容易忽略的点PyMuPDF 不是线程安全的。这一点在后面讲并发的时候会重点说但环境准备阶段就要有这个意识——不要想着用多线程跑批量会随机崩溃且崩得毫无规律。必须用多进程。4. XPS转PDF最省事的20行代码和它背后的参数转 PDF 是三个需求里最简单的但简单不代表没有细节。参数选错输出体积能翻三倍尺寸没核对打印时能出洋相。4.1 最小可用实现import fitz def xps_to_pdf(src: str, dst: str) - None: doc fitz.open(src) try: doc.save(dst, garbage4, deflateTrue, cleanTrue) finally: doc.close()就这几行。fitz.open会根据文件内容自动识别格式XPS 和 PDF 都吃得下不需要你手动判断后缀。三个save参数值得展开说说garbage4清理无用对象取值 0 到 44 是最彻底。XPS 转 PDF 的过程中会产生大量中间对象不清的话输出文件里全是垃圾。deflateTrue对流内容做 zlib 压缩。文本密集的文档压缩比很高图像为主的文档效果有限但也没坏处。cleanTrue做一遍语法层面的清理把冗余的操作符合并掉。这三个一起开我实测过的典型场景里输出体积能压到原始 XPS 的一半左右。如果只开garbage不开deflate体积大约只能降三成。4.2 页面尺寸核对与手动纠正转完之后必须核对页面尺寸这是我最强调的一步。代码很简单doc fitz.open(sample.xps) for i, page in enumerate(doc): print(i, page.rect)正常输出应该是这种形态Rect(0.0, 0.0, 612.0, 792.0)。612 × 792 就是 Letter 在 72 DPI 下的尺寸说明库内部已经把 96 体系的 816 × 1056 正确换算过来了。如果你看到的是Rect(0.0, 0.0, 816.0, 1056.0)那就是换算没生效输出会比预期大 33%。遇到这种情况先检查版本绝大多数是版本太低。版本没问题还这样那就手动缩放import fitz def normalize_page_size(src, dst, target(595, 842)): doc fitz.open(src) for page in doc: r page.rect if abs(r.width - target[0]) 1 or abs(r.height - target[1]) 1: mat fitz.Matrix(target[0] / r.width, target[1] / r.height) page.set_mediabox(fitz.Rect(0, 0, target[0], target[1])) doc.save(dst, garbage4, deflateTrue) doc.close()这里要注意单纯改 mediabox 只是改了页面声明尺寸内容不会跟着缩放。真要缩放内容得用page.set_rotation配合变换矩阵重绘那属于另一个复杂度了。所以这一招只适合页面声明错了但内容比例正确的情况改完记得肉眼抽查几页。4.3 输出体积与质量怎么平衡体积和质量之间的权衡主要看用途。如果只是存档不做二次编辑那garbage4, deflateTrue, cleanTrue全开就行画质无损体积也能接受。如果要长期保存海量文件、对体积敏感可以考虑对图像做降采样doc.save(dst, garbage4, deflateTrue, deflate_imagesTrue, deflate_fontsTrue)deflate_images会对图像流单独压缩deflate_fonts会对字体流压缩。这两个开关在图像多的文档上效果明显文本为主的文档差别不大。反过来如果文档里有精细的矢量图或者工程图纸不要开cleanTrue。语法清理偶尔会把一些冷门的绘图操作符合并掉导致细线丢失或者图形错位。我吃过一次亏一批机械图纸转完之后标注的箭头全没了重跑一遍关掉 clean 才恢复正常。4.4 批量转换的命名与幂等单个转是玩具批量才是正经活。批量脚本里有两个设计点必须提前考虑好。第一是命名。直接用stem最直观但不同子目录下可能有同名文件会互相覆盖。我的做法是用相对路径拼出唯一名字斜杠换成下划线from pathlib import Path def make_out_name(src: Path, root: Path, suffix: str) - str: rel src.relative_to(root).with_suffix() parts list(rel.parts) return _.join(parts) suffix第二是幂等。几千个文件跑到一半出错重跑时没有幂等机制就是全量重来时间成本翻倍。判断逻辑很简单输出文件存在且大小大于 0 就跳过。注意一定要判大小因为中断时可能留下一个 0 字节的空壳文件只看存在性会误判成已完成。def batch_xps_to_pdf(src_dir, dst_dir): src_dir, dst_dir Path(src_dir), Path(dst_dir) dst_dir.mkdir(parentsTrue, exist_okTrue) files sorted(src_dir.rglob(*.xps)) failed, skipped [], 0 for f in files: out dst_dir / make_out_name(f, src_dir, .pdf) if out.exists() and out.stat().st_size 0: skipped 1 continue try: xps_to_pdf(str(f), str(out)) except Exception as e: failed.append((str(f), repr(e))) return len(files), skipped, failed这个函数返回三个数总数、跳过数、失败列表。跑完之后先看失败列表如果集中是某一批文件失败那大概率是那批文件的来源工具有问题需要单独处理。4.5 带签名的文件怎么处理有些 XPS 是带数字签名的fitz.open打开时可能抛异常或者打开后内容异常。我的处理原则是不硬解先分类。try: doc fitz.open(src) if doc.is_encrypted: raise ValueError(encrypted) doc.save(dst) except Exception as e: record_failure(src, e)带签名的文件转成 PDF 之后签名必然失效这是格式转换的固有代价。如果业务上签名本身有价值那这类文件应该单独走一条原件归档 另行生成可检索副本的路径而不是一锅端全转掉。这个判断必须提前和需求方对齐转完再发现签名没了返工成本很高。5. XPS转PNGDPI换算、内存控制和裁白边出缩略图这事听起来比转 PDF 简单实际上坑更多。DPI 算错图片要么糊成一片要么大到传不动内存没管好批量跑到一半被系统杀掉白边不裁缩略图里主体只有一小块看着非常业余。5.1 DPI与像素的换算公式核心公式就一行像素宽 页面宽pt÷ 72 × dpi以 A4 为例页面宽 595 pt那么dpi 96 时宽约 793 pxdpi 150 时宽约 1240 pxdpi 300 时宽约 2479 px按用途选 DPI我的经验值是这样用途建议 DPI说明列表缩略图80 到 100够看版式体积小屏幕阅读120 到 150文字清晰单页 300KB 到 500KBOCR 识别300低于 200 时小字号识别率明显下降精细图纸400 到 600只有线条极细的场景才需要超过 600 dpi 基本没有实际意义。文件大到无法传输OCR 引擎也不见得能多识别出字来——识别率在 300 dpi 左右就基本饱和了再往上只是把已有信息放大成马赛克。内存必须提前估。一张 A4 在 300 dpi 下的 RGB 图是 2479 × 3508 × 3 字节约 26 MB。如果并发 8 个进程同时渲染光图像缓冲就是 200 MB 往上还没算库本身的常驻内存。老机器上跑到一半被系统 OOM 杀掉通常就是这个原因。5.2 渲染代码与逐页内存管理from pathlib import Path import fitz def xps_to_png(src: str, out_dir: str, dpi: int 150, fmt: str png) - list: out_dir Path(out_dir) out_dir.mkdir(parentsTrue, exist_okTrue) doc fitz.open(src) paths [] try: stem Path(src).stem for i, page in enumerate(doc, 1): pix page.get_pixmap(dpidpi, alphaFalse) p out_dir / f{stem}_p{i:03d}.{fmt} pix.save(str(p)) paths.append(p) del pix finally: doc.close() return paths有两个细节值得说。第一个是alphaFalse。默认渲染会带透明通道PNG 存下来体积能多三分之一。绝大部分文档页面是不透明的白底透明通道纯属浪费关掉就好。只有在需要叠底合成比如把页面叠加到某张背景图上的时候才需要保留。第二个是del pix。PyMuPDF 的 pixmap 是 C 层对象内存占用量大Python 的垃圾回收机制不一定会立刻释放。页数多的时候如果不显式删除内存会一路涨上去。加上这一行之后处理几百页的文档内存曲线是平的。页码格式用:03d补零是为了保证按文件名排序时顺序正确。不补零的话p10会排在p2前面后面做批量拼图或者组装 PDF 时顺序全乱。5.3 白边裁剪的正确姿势XPS 转出来的页面经常带大片白边尤其是那些从老系统导出的表格内容只占中间一小块四周全是空白。直接出图缩略图里主体小得看不清。裁剪思路很简单找出非白色像素的包围盒按盒子裁。用 Pillow 实现from PIL import Image, ImageChops def trim_white(path, padding8): img Image.open(path).convert(RGB) bg Image.new(RGB, img.size, (255, 255, 255)) diff ImageChops.difference(img, bg) bbox diff.getbbox() if not bbox: return False left max(0, bbox[0] - padding) top max(0, bbox[1] - padding) right min(img.width, bbox[2] padding) bottom min(img.height, bbox[3] padding) img.crop((left, top, right, bottom)).save(path) return True留padding是必要的不留的话内容会紧贴边缘视觉上很挤。这个方法有一个明显的性能问题整图遍历一遍算差异大图上很慢。一张 300 dpi 的 A4 要几百毫秒。所以批量的时候我一般把它做成开关只有缩略图场景才开存档用的大图不动。还有一种情况要特殊处理整页都是白色的。getbbox()返回 None这时候直接跳过裁剪保持原样。否则会裁出一个空图。5.4 只转首页的加速技巧六千个文件每个只出首页缩略图没必要把整份文档都渲染一遍。PyMuPDF 支持按页取doc fitz.open(src) page doc[0] pix page.get_pixmap(dpi120, alphaFalse) pix.save(out_path) doc.close()只取第一页比全量渲染快一个数量级。对于几十页的文档时间从几秒降到几百毫秒。有一个例外要注意有些 XPS 的首页是封面纯图无字用作缩略图完全没有辨识度。这种情况可以在配置里加一条规则——首页文本块少于某个阈值时往后翻一页再取。判断逻辑用page.get_text().strip()的长度就行简单有效。6. XPS转JSON高层提取和底层解析两条腿转 JSON 这块的需求差异最大。有人只要文字有人要坐标有人要字体信息还有人要图片资源的原始尺寸。所以我把它拆成两条路径按需选用。6.1 高层提取get_text的输出结构拆解PyMuPDF 的get_text(dict)返回一个嵌套字典结构大致是顶层有blocks每个 block 有type字段0 表示文本、1 表示图像文本 block 下面有linesline 下面有spansspan 里带text、font、size、bbox、color、flags这些字段。import json from pathlib import Path import fitz def xps_to_json(src: str, dst: str) - None: doc fitz.open(src) result {source: str(src), page_count: doc.page_count, pages: []} try: for i, page in enumerate(doc, 1): d page.get_text(dict) result[pages].append({ page: i, width: round(page.rect.width, 2), height: round(page.rect.height, 2), blocks: d.get(blocks, []), }) finally: doc.close() Path(dst).write_text( json.dumps(result, ensure_asciiFalse, indent2), encodingutf-8 )ensure_asciiFalse这个参数必须加否则中文会被转成\uXXXX序列可读性全无调试时看着头疼。文件写入用encodingutf-8显式指定避免 Windows 默认编码带来的问题。bbox的格式是[x0, y0, x1, y1]坐标原点在页面左上角单位是 pt。这个体系在做版面分析时最方便判断两个块是否在同一行、是否上下相邻直接比 y 坐标就行。flags字段里编码了粗体、斜体、等宽、衬线等信息用位运算取。这个在做标题识别时有用——标题通常是字号大加粗体两个条件一起筛准确率不低。6.2 底层解析从fpage XML里抠出Glyphs需要原始属性的时候就得自己开 ZIP 读 XML。第一步是定位页面文件路径不一定是标准的Documents/1/Pages/1.fpage稳妥的做法是按后缀扫描再排序import zipfile from lxml import etree XPS_NS http://schemas.microsoft.com/xps/2005/06 def load_fpage(path: str, index: int 0): with zipfile.ZipFile(path) as z: names [n for n in z.namelist() if n.endswith(.fpage)] if not names: raise ValueError(no fpage found) names.sort() data z.read(names[index]) return etree.fromstring(data)然后按命名空间查 Glyphsns {x: XPS_NS} def extract_glyphs(root): out [] for g in root.findall(.//x:Glyphs, ns): origin g.get(Origin, 0,0) ox, oy [float(v) for v in origin.split(,)] em float(g.get(FontRenderingEmSize, 0) or 0) adv_raw g.get(AdvanceWidths, ) widths [float(v) for v in adv_raw.split(,)] if adv_raw else [] out.append({ text: g.get(UnicodeString, ), x_dip: ox, y_dip: oy, size_dip: em, advance_dip: [w * em for w in widths], font_uri: g.get(FontUri, ), fill: g.get(Fill, ), }) return out这里输出里我特意用_dip后缀标出单位就是在提醒自己这是 96 体系的设备无关像素不是 pt。养成这个习惯之后后面跟别人对接数据时能少吵很多架。6.3 坐标系换算别让对接方骂人96 体系和 72 体系之间的换算就是乘除 0.75 或者 1.3333但换算的时机和方向很容易搞混。我的经验是在数据出口处一次性统一中间环节只标单位不换算。具体做法是底层解析拿到的所有值都保留原始 DIP 单位只在最终写 JSON 之前做一次批量转换SCALE 72.0 / 96.0 # DIP - pt def to_pt(record): return { text: record[text], x: round(record[x_dip] * SCALE, 3), y: round(record[y_dip] * SCALE, 3), size: round(record[size_dip] * SCALE, 3), font: record[font_uri], }这样做的价值在于可维护性。如果哪天需求方说我们系统用的是 DIP你只需要把SCALE改成 1.0 就行不用去翻遍代码找换算点。我见过一个项目因为换算散落在十几个函数里改一次口径花了整整两天。另外要注意Origin是基线坐标不是文本框的左上角。如果对接方要的是文本框位置得自己按字号估一下 ascent经验上 ascent 约为字号的 0.8 倍所以框的顶边 y 大约是y - size * 0.8。这个估值不够精确但用于版面分区足够了。6.4 odttf字体还原的完整步骤.odttf是混淆过的 OpenType 字体。混淆算法很朴素拿资源 GUID 当密钥取前 32 字节做异或运算。还原代码如下def deobfuscate_odttf(data: bytes, guid: str) - bytes: key bytes.fromhex(guid.replace(-, ))[::-1] buf bytearray(data) for i in range(min(32, len(buf))): buf[i] ^ key[i % 16] return bytes(buf)GUID 从哪来在.fpage里FontUri属性通常长得像/Resources/1a2b3c4d-5e6f-....odttf去掉目录前缀和后缀就是 GUID。这个还原操作我一般只在两种场景用一是需要做字体合规检查确认文档里嵌了哪些字体二是文字提取出来是乱码需要把字体还原出来分析字形映射。日常的 JSON 提取用不到这一步因为UnicodeString属性已经是解码好的文本了。顺带说一句文字提取乱码这个事在 XPS 上出现概率比 PDF 低得多因为 XPS 的 Glyphs 直接带UnicodeString。真遇到了八成是文件本身被某个不规范的导出工具破坏了而不是你解析姿势有问题。7. 批量化工程封装并发、日志与断点续跑单文件能跑通只是及格线能稳定处理几千个文件才算交付。这一节讲工程化封装里几个真正影响成败的点。7.1 配置与目录规划先把配置和代码分离别把路径写死在函数里。用 YAML 或者 JSON 都行YAML 可读性好一点input_dir: ./raw output_dir: ./output pdf: enabled: true png: enabled: true dpi: 150 pages: first json: enabled: true mode: high max_workers: 4 skip_existing: true输出目录按类型分output/ ├── pdf/ ├── png/ ├── json/ └── logs/这个结构看起来平平无奇价值在于出问题时好排查。比如客户说PDF 打不开你只要去pdf目录抽查就行不用在一堆混杂文件里翻。日志单独放一个目录跑批量的历史记录都留着出问题能回溯。7.2 多进程并发与内存水位前面提过 PyMuPDF 不是线程安全的所以并发只能用多进程。用ProcessPoolExecutorfrom concurrent.futures import ProcessPoolExecutor, as_completed def process_one(args): xps_path, cfg args # 单个文件的完整处理逻辑 ... def run_batch(files, cfg): results [] with ProcessPoolExecutor(max_workerscfg[max_workers]) as ex: futures {ex.submit(process_one, (f, cfg)): f for f in files} for fut in as_completed(futures): src futures[fut] try: results.append((src, ok, fut.result())) except Exception as e: results.append((src, fail, repr(e))) return results进程数怎么定经验公式是CPU 核心数 × 0.75 到 1.0。4 核机器开 3 到 4 个8 核开 6 到 8 个。超过核心数收益递减还会因为内存争抢导致整体变慢。内存水位要盯。每个进程渲染一张 300 dpi 的 A4 大约占 26 MB加上库本身常驻的几十兆4 个进程差不多 300 MB。如果批量里有超大页面比如 A0 图纸单张能到几百兆这时候要么降 DPI要么把并发降到 2。一个实用的保护措施是分片每处理 200 个文件重启一次进程池。这样即使有内存泄漏也不会累积到把机器拖垮。7.3 断点续跑和失败重试断点续跑靠幂等前面讲过了。失败重试得单独设计因为不是所有失败都值得重试。我的分类原则文件损坏类重试没用直接记入失败清单人工处理。资源竞争类比如临时目录写满、文件被占用这类可以重试间隔几秒再来一次。未知异常重试一次还失败就归入人工清单。import time def retry_once(fn, *args, delay2.0, **kwargs): try: return fn(*args, **kwargs) except (OSError, PermissionError): time.sleep(delay) return fn(*args, **kwargs)注意只对明确的资源类异常重试不要把逻辑错误也套进去重试那只会浪费时间还掩盖问题。日志方面每个文件至少记三样源路径、耗时、结果。耗时的作用很大——如果某个文件耗时是平均值的几十倍那它多半有问题可能页面巨大或者结构异常值得单独看一眼。8. 常见问题速查与避坑经验把上面这些内容跑通之后实际还会遇到一些零散的坑。整理成速查表出问题时对照着排查比从头想快得多。8.1 问题速查表现象可能原因处理方式转出 PDF 整页空白版本对 XPS 支持不全升级到 1.23 以上或先走外部工具兜底页面尺寸整体大 33%96/72 单位没换算检查 page.rect按 0.75 系数修正页面尺寸正常但内容偏移坐标原点理解反了确认 y 轴方向XPS 是左上原点向下为正文字提取出现方框字体资源缺失或未还原解 .odttf或改用 Glyphs 的 UnicodeStringPNG 边缘内容被切裁剪时没留 padding至少留 8 像素或按内容外接框判断批量跑到中途进程被杀内存累积超限降 DPI、减并发、分片重启进程池中文文件名写入失败编码未显式指定统一用 Path 对象和 utf-8 encoding转换速度慢得离谱高 DPI 加全