news 2026/11/3 17:31:01

Python批量处理XPS:转PDF、PNG缩略图与JSON提取实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python批量处理XPS:转PDF、PNG缩略图与JSON提取实战

前阵子一个做档案数字化的老朋友找过来,说他手上攒了六千多个 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,容器用的是 OPC(Open 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 xmlns="http://schemas.microsoft.com/xps/2005/06" Width="816" Height="1056">

816 乘 1056,第一反应往往是"这什么鬼尺寸,既不是 A4 也不是 Letter"。其实它是 96 DPI 的逻辑像素:816 ÷ 96 = 8.5 英寸,1056 ÷ 96 = 11 英寸,标准的美国 Letter 纸。A4 换算过来是 210 ÷ 25.4 × 96 ≈ 793.7,297 ÷ 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 Origin="96,120" FontUri="/Resources/1a2b3c4d.odttf" FontRenderingEmSize="12" UnicodeString="档案编号" AdvanceWidths="6.7,6.7,6.7,6.7" BidiLevel="0" 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 我为什么主力押PyMuPDF

PyMuPDF 是这十来年里我用得最顺的一个库。它对 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 "pymupdf>=1.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, garbage=4, deflate=True, clean=True) finally: doc.close()

就这几行。fitz.open会根据文件内容自动识别格式,XPS 和 PDF 都吃得下,不需要你手动判断后缀。

三个save参数值得展开说说:

  • garbage=4:清理无用对象,取值 0 到 4,4 是最彻底。XPS 转 PDF 的过程中会产生大量中间对象,不清的话输出文件里全是垃圾。
  • deflate=True:对流内容做 zlib 压缩。文本密集的文档压缩比很高,图像为主的文档效果有限但也没坏处。
  • clean=True:做一遍语法层面的清理,把冗余的操作符合并掉。

这三个一起开,我实测过的典型场景里,输出体积能压到原始 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, garbage=4, deflate=True) doc.close()

这里要注意,单纯改 mediabox 只是改了"页面声明尺寸",内容不会跟着缩放。真要缩放内容,得用page.set_rotation配合变换矩阵重绘,那属于另一个复杂度了。所以这一招只适合"页面声明错了但内容比例正确"的情况,改完记得肉眼抽查几页。

4.3 输出体积与质量怎么平衡

体积和质量之间的权衡,主要看用途。

如果只是存档,不做二次编辑,那garbage=4, deflate=True, clean=True全开就行,画质无损,体积也能接受。

如果要长期保存海量文件、对体积敏感,可以考虑对图像做降采样:

doc.save(dst, garbage=4, deflate=True, deflate_images=True, deflate_fonts=True)

deflate_images会对图像流单独压缩,deflate_fonts会对字体流压缩。这两个开关在图像多的文档上效果明显,文本为主的文档差别不大。

反过来,如果文档里有精细的矢量图或者工程图纸,不要开clean=True。语法清理偶尔会把一些冷门的绘图操作符合并掉,导致细线丢失或者图形错位。我吃过一次亏,一批机械图纸转完之后,标注的箭头全没了,重跑一遍关掉 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(parents=True, exist_ok=True) 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转PNG:DPI换算、内存控制和裁白边

出缩略图这事听起来比转 PDF 简单,实际上坑更多。DPI 算错,图片要么糊成一片要么大到传不动;内存没管好,批量跑到一半被系统杀掉;白边不裁,缩略图里主体只有一小块,看着非常业余。

5.1 DPI与像素的换算公式

核心公式就一行:

像素宽 = 页面宽(pt)÷ 72 × dpi

以 A4 为例,页面宽 595 pt,那么:

  • dpi = 96 时,宽约 793 px
  • dpi = 150 时,宽约 1240 px
  • dpi = 300 时,宽约 2479 px

按用途选 DPI,我的经验值是这样:

用途建议 DPI说明
列表缩略图80 到 100够看版式,体积小
屏幕阅读120 到 150文字清晰,单页 300KB 到 500KB
OCR 识别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(parents=True, exist_ok=True) doc = fitz.open(src) paths = [] try: stem = Path(src).stem for i, page in enumerate(doc, 1): pix = page.get_pixmap(dpi=dpi, alpha=False) p = out_dir / f"{stem}_p{i:03d}.{fmt}" pix.save(str(p)) paths.append(p) del pix finally: doc.close() return paths

有两个细节值得说。

第一个是alpha=False。默认渲染会带透明通道,PNG 存下来体积能多三分之一。绝大部分文档页面是不透明的白底,透明通道纯属浪费,关掉就好。只有在需要叠底合成(比如把页面叠加到某张背景图上)的时候才需要保留。

第二个是del pix。PyMuPDF 的 pixmap 是 C 层对象,内存占用量大,Python 的垃圾回收机制不一定会立刻释放。页数多的时候如果不显式删除,内存会一路涨上去。加上这一行之后,处理几百页的文档内存曲线是平的。

页码格式用:03d补零,是为了保证按文件名排序时顺序正确。不补零的话,p10会排在p2前面,后面做批量拼图或者组装 PDF 时顺序全乱。

5.3 白边裁剪的正确姿势

XPS 转出来的页面经常带大片白边,尤其是那些从老系统导出的表格,内容只占中间一小块,四周全是空白。直接出图,缩略图里主体小得看不清。

裁剪思路很简单:找出非白色像素的包围盒,按盒子裁。用 Pillow 实现:

from PIL import Image, ImageChops def trim_white(path, padding=8): 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(dpi=120, alpha=False) 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 下面有lines,line 下面有spans,span 里带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_ascii=False, indent=2), encoding="utf-8" )

ensure_ascii=False这个参数必须加,否则中文会被转成\uXXXX序列,可读性全无,调试时看着头疼。文件写入用encoding="utf-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)

然后按命名空间查 Glyphs:

ns = {"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 不是线程安全的,所以并发只能用多进程。用ProcessPoolExecutor:

from concurrent.futures import ProcessPoolExecutor, as_completed def process_one(args): xps_path, cfg = args # 单个文件的完整处理逻辑 ... def run_batch(files, cfg): results = [] with ProcessPoolExecutor(max_workers=cfg["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, delay=2.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 的 UnicodeString
PNG 边缘内容被切裁剪时没留 padding至少留 8 像素,或按内容外接框判断
批量跑到中途进程被杀内存累积超限降 DPI、减并发、分片重启进程池
中文文件名写入失败编码未显式指定统一用 Path 对象和 utf-8 encoding
转换速度慢得离谱高 DPI 加全
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/3 17:30:43

基于SMA-WNN的智能交通流量预测模型优化

1. 项目背景与核心价值交通流量预测一直是智能交通系统&#xff08;ITS&#xff09;中的关键问题。准确的短时交通流量预测能有效缓解拥堵、优化信号灯配时、提升道路利用率。传统方法如时间序列分析&#xff08;ARIMA&#xff09;、卡尔曼滤波等在非线性、非平稳交通数据面前表…

作者头像 李华
网站建设 2026/11/3 17:30:20

SnapGene引物设计:参数、场景与避坑全流程

引物设计这件事&#xff0c;真正难的不是在软件里点几下按钮&#xff0c;而是点完之后你敢不敢把序列发出去合成。我见过太多人&#xff0c;SnapGene 里点开 Primer Design&#xff0c;看一眼候选列表&#xff0c;挑个顺眼的复制走人&#xff0c;等到 PCR 跑完胶一片空白&#…

作者头像 李华
网站建设 2026/11/3 17:29:27

CC-Switch工具:高效切换AI模型的Windows安装与配置指南

1. CC-Switch工具概述 在AI开发领域&#xff0c;模型切换工具已经成为提升工作效率的必备利器。CC-Switch作为一款专为ClaudeCode设计的轻量级工具&#xff0c;能够帮助开发者快速在不同大语言模型(LLM)之间进行切换。这个工具特别适合需要频繁测试不同模型效果的场景&#xff…

作者头像 李华
网站建设 2026/11/3 17:28:42

用Go搭建AI Agent流水线:从商品图到电商详情页一键生成

朋友在淘宝开店&#xff0c;拿着一张随手拍的保温杯照片问我&#xff1a;能不能帮他出一整套商品详情页&#xff1f;我说能&#xff0c;但要给我两天时间。他接着问&#xff1a;那二十个SKU呢&#xff1f;我当场沉默了。那会儿我电脑里还摊着PS、AI和各种排版软件&#xff0c;来…

作者头像 李华
网站建设 2026/11/3 17:28:10

C# Winform数据采集上位机开发与优化实践

1. 项目概述 这款基于C# Winform开发的数据采集通用上位机源码V2&#xff0c;是我在工业自动化项目中经过多次迭代优化的成果。它主要解决了设备数据采集、存储、可视化和导出的一站式需求&#xff0c;特别适合中小型数据采集场景。相比市面上臃肿的商业软件&#xff0c;这个方…

作者头像 李华
网站建设 2026/11/3 17:28:08

机器学习投票法:集成学习中的民主决策机制

1. 投票法入门&#xff1a;从生活案例理解集成学习想象一下你正在参加一个知识竞赛&#xff0c;面对一道难题时&#xff0c;你有三种选择&#xff1a;1&#xff09;相信自己的第一直觉&#xff1b;2&#xff09;询问身边最聪明的朋友&#xff1b;3&#xff09;让现场观众投票决…

作者头像 李华