XPS(XML Paper Specification)是微软随 .NET Framework 3.0 推出的固定版式文档格式,设计目标与 PDF 几乎一致:精确描述「每一页上有什么、位于哪里」。正因如此,XPS 转 PDF 常被误以为只是换个容器,实际却要跨过 XAML 解析、单位换算、字形映射和 OXPS 兼容四道坎。本文按这四层拆解,并给出可批量化落地的方案。

两者都把页面描述为「绘制指令流」,但底层模型差异很大:
FixedPage 为单位,内容用 XAML 的 Canvas、Path、Glyphs 元素表达,字体与图片作为独立资源分部存放。这个 0.75 是最隐蔽的 bug 来源:转换时若忘记缩放,输出的 PDF 会比原页面小 25%,而在缩略图上几乎看不出来,往往等印刷或拼接时才暴露。
FixedDocumentSequence.fdseq → FixedDocument.fdoc → FixedPage.fpage 三级结构,配合 .rels 关系文件能完整还原页序与资源引用。优点是跨平台,Linux 服务器上也能跑。XpsDocument 或 XPS 打印驱动渲染,再重新封装成 PDF。还原度高,但强依赖 Windows,且默认输出常是位图,会牺牲矢量清晰度。工程上优先选第一条路线:可控、可批量、可在容器里跑。第二条仅用于兜底那些 XAML 特性过于冷门的文件。
| 格式 | 容器 | 说明 | 处理要点 |
|---|---|---|---|
| XPS | ZIP,UTF-8 命名 | 早期规范,命名空间 2005/06 | 按标准 XAML 解析即可 |
| OXPS | OpenXPS,OPC 容器 | Win8 起默认导出 | 命名空间不同,解析器需分支 |
| 后缀错标 | 实际为 OXPS | 文件名 .xps 内容却是 OpenXPS | 读命名空间判定,勿信后缀 |
最常见的失败是「用 XPS 的解析器去读 OXPS」:两者元素名相近但命名空间 URI 不同,解析器要么抛异常,要么静默返回空页。正确做法是打开 FixedDocumentSequence 的第一行,读它的 xmlns 命名空间,再决定用哪套解析规则,绝不能只看文件后缀。
XPS 里的文字常以 Glyphs 元素出现,用字形索引而非字符编码引用字体。转换时必须把字形索引重新映射回 Unicode,否则 PDF 里会出现「字符选不中、复制出来是乱码」的情况。处理原则有三条:
Glyphs 的 UnicodeString 属性,它保存了真实文本。FontUri 找到字体子集,结合 Indices 与 Cmap 反查。.odttf(XPS 的混淆字体),需按 GUID 做去混淆还原后才能嵌入 PDF。只有字体嵌入正确,搜索、复制与无障碍阅读才有基础;否则输出的只是一个「看起来对」的空壳。
把上述流程固化成脚本,核心逻辑如下:
import zipfile
from lxml import etree
with zipfile.ZipFile("in.xps") as z:
seq = etree.fromstring(z.read("FixedDocumentSequence.fdseq"))
ns_uri = etree.QName(seq).namespace # 用命名空间判断 XPS / OXPS
for ref in seq.iter():
if etree.QName(ref).localname == "DocumentReference":
fdoc = ref.get("Source") # 逐文档展开,再进 Pages
page = etree.fromstring(z.read(fdoc))
# 缩放系数 0.75:XPS 1/96 inch -> PDF 1/72 inch
scale = 0.75
关键参数只有三个:命名空间分支、缩放系数 0.75、以及页序(务必按 .rels 而不是文件名排序,否则多文档 XPS 会乱序)。
转换完不要只看第一页,建议内置四条自动断言:页数与源文件一致、页面尺寸在预期范围(A4 约 595×842 点)、可提取字符数不为零、嵌入了至少一个字体子集。只要第一条与第三条同时通过,绝大多数「空页」和「乱码」问题都能被拦在交付之前。
XPS 转 PDF 的难点不在「转换」这个动作,而在四层语义的对齐:命名空间决定走哪条解析路线,0.75 决定页面是否等比,字形到 Unicode 的映射决定文字能否被检索,字体子集决定跨设备一致性。把这四层逐一对齐,批量转换的稳定性就会有质的变化。
标签: PDF转换 技术升级 效率提升