HotPDF 在 Delphi 與 C++Builder 內部把 XPS 與 OpenXPS 套件轉成 PDF,不需列印驅動程式:每個 96-DPI 固定頁座標都經過同一個 0.75 縮放、Y 軸翻轉的頁面矩陣對映,每個 VisualBrush 發佈為共用的 Form XObject,ImageBrush 的並貼模式則轉成原生 PDF 並貼模式(tiling pattern),而不是重複繪製影像
把大多數 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 時才提升為共用間接字典,所以沒有可重用視覺的文件不必為這套機制買單。回報 bug 前還有一條規格邊界值得知道: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,所以筆刷是橢圓。PDF 漸層類型 3(ISO 32000-1 第 8.7.4.5.4 節)在兩個圓之間混合,沒有辦法直接表達橢圓。把兩個半徑平均成一個數字是誘人的捷徑,而在任何不接近正圓的筆刷上錯得一目了然。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 版本