HotPDF 在 Delphi 和 C++Builder 内把 XPS 和 OpenXPS 包转成 PDF,不经过打印驱动:每个 96-DPI 固定页坐标经一个 0.75 缩放、Y 翻转的页面矩阵映射;每个 VisualBrush 发布为共享 Form XObject;ImageBrush 平铺模式变成原生 PDF 平铺图案而不是重复的图像绘制
把多数 Windows 团队拖进这件事的场景平淡且躲不开。某个东西已经在往 Microsoft XPS Document Writer 打印,可能是一套遗留 ERP 报表、一张已签表单、一批对账单,而归档策略要 PDF。XPS 是完美的捕获格式,却是十年后交给档案系统的糟糕格式。所以后台打印文件必须变成逐页对应的 PDF,而你一动手写这个转换器就会发现,有意思的部分不是 XML。是 XPS 和 PDF 在原点在哪、一个单位值多少、画刷可以是什么上全都谈不拢
从包到 PDF 一遍完成
入口点是文档处理器注册表,不是什么专门的 XPS 类。THPDFDocumentHandlerRegistry.RegisterStandardHandlers 安装 XPS、EPUB 和 CBZ 处理器;识别基于内容,于是携带 [Content_Types].xml 加至少一个 .fpage 部件的包得 95 分,即便文件扩展名撒谎,而光秃秃的 .xps 或 .oxps 扩展名只得 10 分。接受上传时这个排序很要紧,因为把 EPUB 改名成 .xps 的攻击者不应该能操纵管线
var
Handled: IHPDFHandledDocument;
Info: THPDFDocumentHandlerInfo;
Options: THPDFDocumentHandlerOptions;
Registry: THPDFDocumentHandlerRegistry;
Output: TFileStream;
begin
Registry := THPDFDocumentHandlerRegistry.Create;
Output := TFileStream.Create('spool.pdf', fmCreate);
try
Registry.RegisterStandardHandlers;
Options := THPDFDocumentHandlerOptions.Default;
if not Registry.OpenFromFile('spool.xps', '', Options, Handled, Info) then
raise Exception.Create('no registered handler recognised the package');
if Handled.Format = hdfXPS then
Handled.WritePDF(Output, Options, Info);
// Info.UnsupportedFeatureCount 是这次转换的诚实记分
finally
Output.Free;
Registry.Free;
end;
end;
转换的一切在动手之前就有预算。THPDFDocumentHandlerOptions.Default 把归档条目封顶 10,000、展开归档字节 1 GiB、压缩比 200、资源 4,096、页面 10,000,并带可选 CancellationToken,于是服务端作业可以在包处理中途被叫停。之后读 Info.UnsupportedFeatureCount 并把非零值当真实发现:HotPDF 刻意数出它映射不了的东西,而不是画个近似然后闭口不提
为什么 XPS 页面要矩阵而不是重写坐标?
因为重写坐标会丢掉变换栈。XPS FixedPage 以 96-DPI 单位规定,原点左上,Y 向下增长;PDF 用户空间是 72-DPI,原点左下,Y 向上增长。天真的修法是边发射边把每个数乘 0.75、把每个 Y 用页高去减。对一条扁平路径这能行,而 RenderTransform、嵌套 Canvas 或画刷局部矩阵一进来就散架,因为那些变换定义在 XPS 空间里,你的逐坐标重写已经离开了那个空间。所以 HotPDF 把投影保持为矩阵并做复合。HPDFXPSPageMatrix 每页返回一次固定常量,HPDFMultiplyXPSMatrix 把它与累积路径变换拼接,结果作为单个 cm 操作符在几何之前发出。路径数据随后以未改动的 XPS 数字写出,这也是为什么缩写几何语法能复用 SVG 路径数据那个有界解析器,只有开头的 F0 或 F1 填充规则记号由 XPS 适配器处理。如果你顺着同样推理看过 EMF 和 WMF 矢量导入,这个论证形状是熟悉的:导入格式一律用矩阵转换,绝不对叶子坐标做算术
function HPDFXPSPageMatrix(PageHeight: Double): THPDFXPSMatrix;
begin
Result.A := 0.75; // 96-DPI XPS 单位到 72-DPI PDF 点
Result.B := 0;
Result.C := 0;
Result.D := -0.75; // XPS 的 Y 向下,PDF 的 Y 向上
Result.E := 0;
Result.F := PageHeight; // PDF 页高,单位是点
end;
// 每个图元一个复合 CTM,在任何路径操作符之前发出
Effective := HPDFMultiplyXPSMatrix(HPDFXPSPageMatrix(PageHeightPDF), PathMatrix);
Page.AppendRawContent(HPDFXPSMatrixOperator(Effective));
如何复用 VisualBrush 而不画两遍?
VisualBrush 把任意可视树,包括 Canvas、Path 和 Glyphs 子节点,绘进一个区域,并可能在其中重复。HotPDF 把该可视树编译一次成 PDF Form XObject 再摆放,这与经 Form XObject 导入 SVG所述是同一资源策略。两个细节决定它成不成。第一,可视树必须按直接 XML 子节点走:平扫可平铺元素会把嵌套可视树提到页面顶层,毁掉资源作用域和绘制顺序。第二,内容是在 XPS 转 PDF 页面矩阵已施加的情况下捕获的,于是发布 Form 要乘该矩阵的逆,否则每次摆放都再施一遍 0.75 缩放和 Y 翻转。Form 还必须拥有自己的资源:HotPDF 只复制被捕获内容流实际引用的字体、XObject、图案、ExtGState 和色彩空间;克隆整页资源字典会把正在登记的 Form 拖进它自己的资源图,造出一个环。字体在普通页面上留在直接字典里,仅当被捕获内容真含 Tf 时才提升为共享间接字典,于是一份没有可复用可视树的文档不必为这套机制付费。报缺陷前还值得知道一条规范边界:ECMA-388 第 13.4 节要求 VisualBrush 的 ViewboxUnits 和 ViewportUnits 都为 Absolute,所以相对单位不是缺失特性,它们是不合规输入,HotPDF 拒绝为它们发明坐标语义
ImageBrush 平铺:四种模式,四种单元尺寸
XPS 平铺模式映射到 ISO 32000-1 第 8.7.3 节的 PDF 平铺图案,而不是展开成覆盖区域上的重复图像摆放,这让输出大小和转换时间与画刷覆盖多大页面无关。映射一旦看穿就是机械的:镜像靠在一个图案单元里放镜像摆放并相应放大单元来表达
Tile—— 一次摆放,单元保持 1×1 视口FlipX—— 两次摆放,单元加宽到 2×1FlipY—— 两次摆放,单元加高到 1×2FlipXY—— 四次摆放,单元扩到 2×2
每次摆放带自己的裁剪矩形,因为冲出子单元的 Viewbox 映射会渗进相邻镜像。图案 /Matrix 是让人栽跟头的部分。平铺图案锚定在其父内容流的默认用户空间,而不是选中图案那一刻的图形状态,所以矩阵必须显式复合全部三层,即固定页投影、Path 变换、画刷局部 Transform,而不是依赖环境 CTM。HotPDF 也在分配之前验证:RegisterImageTilingPattern 限制一个图案最多 1,024 次摆放,并拒绝退化裁剪、不可逆矩阵和非法图像索引。想要这背后的 PDF 侧通用模型,平铺图案与 Pattern 色彩空间覆盖了底层操作符
// 固定页投影折进图案矩阵,再复合画刷局部矩阵
PatternMatrix := HPDFMatFromOps( 0.75 * PathMatrix.A, -0.75 * PathMatrix.B,
0.75 * PathMatrix.C, -0.75 * PathMatrix.D,
0.75 * PathMatrix.E,
PageHeight - 0.75 * PathMatrix.F);
if Brush.HasTransform then
PatternMatrix := HPDFMatMul(PatternMatrix, BrushMatrix);
PatternName := Document.RegisterImageTilingPattern(Resource.ImageIndex,
Brush.Viewport.Left, Brush.Viewport.Top,
Brush.Viewport.Left + CellWidth, Brush.Viewport.Top + CellHeight,
CellWidth, CellHeight, Placements, pttNoDistortion, PatternMatrix);
径向渐变不是圆时怎么办?
XPS 用 GradientOrigin、Center、RadiusX 和 RadiusY 定义 RadialGradientBrush,所以画刷是椭圆。ISO 32000-1 第 8.7.4.5.4 节的 PDF 渐变类型 3 在两个圆之间混合,没有办法直接表达椭圆。把两个半径平均成一个数是诱人的捷径,而在任何不接近圆的画刷上错得肉眼可见。HotPDF 改为把问题挪进坐标系:把 Y 按 RadiusY / RadiusX 缩放,在缩放空间里登记一个诚实的圆形渐变,选中图案,随即发出倒数缩放,让接下来写的路径几何仍在原始 XPS 用户空间
ScaleY := Brush.RadiusY / Brush.RadiusX;
PatternName := Document.RegisterMultiStopRadialGradient(
Brush.StartX, Brush.StartY / ScaleY, 0,
Brush.EndX, Brush.EndY / ScaleY, Brush.RadiusX,
StopPositions, StopColours, 3);
if Abs(ScaleY - 1) > 0.000001 then
Page.AppendRawContent('1 0 0 ' + HPDFPDFNumber(ScaleY) + ' 0 0 cm'#10);
Page.SetFillPattern(PatternName); // 图案在此刻捕获 CTM
if Abs(ScaleY - 1) > 0.000001 then
Page.AppendRawContent('1 0 0 ' + HPDFPDFNumber(1 / ScaleY) + ' 0 0 cm'#10);
那段代码的顺序是全部诀窍,而且不是风格问题。PDF 渐变图案在被选为当前颜色的那一刻捕获当前变换矩阵,所以临时缩放必须在 SetFillPattern 或 SetStrokePattern 之前发出,倒数缩放必须跟在选中之后、路径操作符之前。任一方向顺序错了,你得到的都是第一条路径上渲染正确、之后每条都漂移的渐变。相对坐标模式还有一个相关约束:RadiusX 和 RadiusY 必须分别对路径宽和高解析,因为两个都按同一条边长缩放会悄悄改变任何非方形路径上椭圆的宽高比
转换在何处如实亮出边界
有些 XPS 构造被近似转换,有些完全不转,而从头到尾的设计选择是数出来而不是装出来。TIFF 和 JPEG XR 部件经 WIC 栅格化,不承诺保留 alpha;带有效 alpha 通道的 PNG 拆成基础图像加 /SMask。图像固有尺寸按 pixel * 96 / DPI 推导,先读 PNG pHYs 或 JPEG JFIF 密度,回退到 96 DPI,于是一个坏密度头落在可预测的尺寸而不是任意尺寸。解析不了的矩阵资源、非标准相对变换、ColorConvertedBitmap、不支持的渐变铺展模式和畸形几何全部计入 UnsupportedFeatureCount,畸形输入失败关闭,而不是降级成一幅悄悄不同的图
这正是归档转换器该有的姿态:悄悄近似的转换比告诉你哪四个元素表达不了的转换更糟,因为只有后者给你在文档封进档案系统之前可检查的东西。如果你在把 XPS 和 OpenXPS 转换与文档管线其余部分,即页面合成、字体、签名、PDF/A 输出,一起评估,HotPDF Delphi PDF 组件页列出了完整特性和支持的 Delphi 与 C++Builder 版本