技術文章

用 PDFium 在 Delphi 建立 PDF 軸向與徑向漸層

PDFium Component 透過 TPdf.SaveAsShadingCreated 在 Delphi 中建立 PDF 的軸向與徑向漸層。您在 TPdfShadingCreate 記錄裡描述每個漸層(類型、色彩空間、座標、顏色或停靠點、目標頁),方法寫出一份驗證過的副本,其中每次填充都是真正的 ISO 32000-1 shading 字典,註冊在頁面的 /Resources /Shading 字典裡、由 sh 運算子繪製,PDFium 因此能原生渲染它

這個需求出現在報表與證書產生器裡。設計師交來一份帶藍白柔和頁首橫帶、或印章後方徑向光暈的 mockup,而覆蓋文字、路徑與影像的頁面物件 API 對此無能為力。您可以用一百條顏色微異的細矩形假裝漸層,但放大時可見色帶、內容流膨脹、印刷效果差。PDF 從 1.3 就有平滑 shading;工夫在於把它們寫成渲染器真的肯畫的樣子

PDFium Component 為什麼把漸層寫成另一次存檔?

TPdf.SaveAsShadingCreated 是存檔操作而不是頁面物件呼叫,因為 PDFium 的公開 ABI 沒有任何函式能建立 shading 字典或發出 sh 運算子。用 PDFium Component 從零建立 PDF所用的頁面物件層負責路徑、文字與影像,到那裡為止。Shading 住在更低一層:物件圖與內容流語法裡

所以方法分段工作。PDFium 先原生保存打開的文件,保住您透過正常 API 做的每個編輯。PDFium Component 接著改寫那份暫存輸出:為每個 shading 配一個間接物件、把資源條目加進每個目標頁、向頁面的 /Contents 追加新內容流,並寫出全新的交叉參照表。在結果到達您的流或檔案之前,會以嚴格的交叉參照檢查重新載入,載入失敗方法就回傳 False 並在報告裡附訊息。檔案重載先寫進暫存檔,所以失敗的存檔不會砸掉既有的目標

建立雙色軸向漸層

軸向 shading(ISO 32000-1 §8.7.4.5.3,Type 2)沿從 x0 y0 到 x1 y1 的直線混合顏色,所以 Coords 收四個值。徑向 shading(§8.7.4.5.4,Type 3)在兩個圓之間混合,收六個:x0 y0 r0 x1 y1 r1。用單純的 StartColor 與 EndColor 時,PDFium Component 輸出帶 /N 1 的 Type 2 指數函式(§7.10.3)——即直線內插——並設 /Extend [true true],讓端點顏色延續過軸的兩端

PDFium Component 在 Delphi 中的軸向與徑向 shading 幾何:Type 2 漸層沿 x0 y0 到 x1 y1 的直線把 StartColor 混到 EndColor,/Extend true true 延續過兩端,Type 3 漸層則在半徑 r0 的起始圓與半徑 r1 的結束圓之間混合
PDFium 把 shading 畫在裁剪允許的任何地方:Type 2 的 Coords 定出 x0 y0 到 x1 y1 的混合軸,Type 3 的 Coords 定出兩個圓,/Extend [true true] 把兩端顏色送過幾何邊界
uses
  System.SysUtils, PDFium, FPdfCompress;

procedure AddHeaderBand(const InFile, OutFile: string);
var
  Pdf: TPdf;
  Fills: TPdfShadingCreateShadings;
  Report: TPdfShadingCreateReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := InFile;
    Pdf.Active := True;

    SetLength(Fills, 1);
    Fills[0] := Default(TPdfShadingCreate);
    Fills[0].ShadingType := pstAxial;
    Fills[0].ColorSpace := pscDeviceRGB;
    Fills[0].Coords := [0, 792, 0, 640];        // x0 y0 x1 y1,由上而下
    Fills[0].StartColor := [0.12, 0.35, 0.75];  // 頂邊的深藍
    Fills[0].EndColor := [1, 1, 1];             // 往下轉白
    Fills[0].PageNumber := 1;                   // 從 1 起算
    Fills[0].ResourceName := 'HeaderBand';      // 可選,預設 Sh1、Sh2...

    if not Pdf.SaveAsShadingCreated(OutFile, Fills, Report) then
      raise Exception.Create('Shading creation failed: ' + Report.ErrorMessage);
  finally
    Pdf.Free;
  end;
end;

每個顏色的分量數必須與色彩空間相符:pscDeviceGray 與 pscCalGray 一個,pscDeviceRGB、pscCalRGB 與 pscLab 三個,pscDeviceCMYK 四個。pscICCBased 的分量數來自您放進 IccProfile 的 profile 位元組,必須是 1、3 或 4 分量;profile 以帶對應 /N 的獨立 ICC 流嵌入。校準空間接受可選的 CalWhitePoint(三個值,Y 為正)、CalGamma(CalGray 一個值、CalRGB 三個值),Lab 另有六值 LabRange。每個座標與分量在寫出任何位元組之前都會做 NaN 與無限大檢查

注意座標在預設使用者空間裡,原點在頁面左下。還要記住軸只定義混合發生在哪裡:畫出來的區域是裁剪允許的部分,這件事比聽起來要緊,後面幾節會看到

多停靠點漸層編譯成 Type 3 拼接函式

PDFium Component 把 Stops 陣列變成 PDF 閱讀器期望的函式:兩個停靠點成為單個 Type 2 函式,3 到 256 個停靠點成為 Type 3 拼接函式(ISO 32000-1 §7.10.4),由每對相鄰停靠點一段線性 Type 2 組成。每個 TPdfShadingCreateStop 帶 Position 與 Color。Stops 非空時完全取代 StartColor 與 EndColor

拼接函式需要三個手工很容易出細微錯誤的陣列。/Functions 裝片段,/Bounds 只裝內部停靠點位置(k 段需要 k-1 個界),/Encode 把每個子域映回 [0 1],讓每段看到完整輸入範圍。元件從您的停靠點導出這三個,所以對輸入很嚴格:位置必須有限、嚴格遞增、從 0 開始、到 1 結束(容差 1e-6)。從 0.1 開始的停靠點清單會讓一部分域沒有定義的函式,方法寧可拒絕也不猜

function Stop(Position: Double; const Rgb: array of Double): TPdfShadingCreateStop;
var
  I: Integer;
begin
  Result.Position := Position;
  SetLength(Result.Color, Length(Rgb));
  for I := 0 to High(Rgb) do
    Result.Color[I] := Rgb[I];
end;

procedure BuildSealGlow(var Fill: TPdfShadingCreate);
begin
  Fill := Default(TPdfShadingCreate);
  Fill.ShadingType := pstRadial;
  Fill.ColorSpace := pscDeviceRGB;
  // x0 y0 r0 x1 y1 r1:印章中心的一個點,長成 90 pt 的圓
  Fill.Coords := [450, 160, 0, 450, 160, 90];
  Fill.Stops := [Stop(0.0, [1.00, 0.95, 0.70]),
                 Stop(0.4, [0.95, 0.75, 0.30]),
                 Stop(1.0, [1.00, 1.00, 1.00])];
  Fill.PageNumber := 1;
end;

三停靠點漸層產出帶兩段 Type 2 的 /Functions、/Bounds [0.4] 與 /Encode [0 1 0 1]。片段邊界是硬性顏色接點,停靠點清單不會跨越它們做平滑;接點看起來太利,就加停靠點,別指望閱讀器插出曲率

PDFium Component 在 Delphi 中的多停靠點漸層編譯:位置 0.0、0.4 與 1.0 的三個 Stops 變成 Type 3 拼接函式,每對相鄰停靠點一段 Type 2,內部位置配 /Bounds [0.4],/Encode [0 1 0 1] 把每個子域映回完整範圍
每對相鄰停靠點成為 Type 3 函式裡的一段 Type 2;手工寫拼接函式通常錯的就是導出的 /Bounds 與 /Encode 陣列

PDFium 為什麼靜默地什麼都不畫?

PDFium 只在兩個細節都對的時候才渲染新建的 shading:/ShadingType 必須是整數,而且 sh 呼叫必須待在裁剪路徑裡。錯了任何一個,檔案照樣打開、結構驗證照樣通過、頁面顯示原樣。沒有錯誤、沒有警告、沒有日誌

陷阱一:/ShadingType 是數字,不是名稱

ISO 32000-1 表 78 把 /ShadingType 定義為整數:軸向 2、徑向 3。這個寫入器的第一版輸出了 /ShadingType /Axial,因為那樣在十六進位傾印裡更好讀。PDFium 用整數 getter 讀鍵,名稱得到 0,把 shading 歸為無效、載入失敗,然後一聲不吭地跳過 sh 運算子。頁面渲染成空白,PDFium 什麼都沒說。病因是在手工造了一份最小 PDF、用第二家獨立渲染器打開之後才現形的——對方回報未知的 shading 類型 0。教訓推廣到每個 PDF 寫入器:規範把列舉定型為整數時,就寫整數,自我描述的名稱再誘人也一樣

陷阱二:只含 sh 的內容流沒有邊界方塊

sh 運算子(ISO 32000-1 §8.7.4.2)把 shading 畫在目前裁剪區域上,所以在紙面上由頁面自身的範圍框住。但 PDFium 建立 shading 頁面物件時,矩形要嘛來自作用中的裁剪路徑,要嘛來自同一條流裡先前繪圖運算子累積的邊界方塊,而渲染器會剔除空矩形的物件。剛追加、除了 /Sh1 sh 什麼都不含的流兩者皆無,漸層就在點陣化之前被丟棄。TPdf.SaveAsShadingCreated 因此讀取每頁的 crop box(退回 media box,再退回頁面尺寸),並把每次呼叫包進顯式的 re W n 裁剪。原點為零的 US Letter 頁面上,追加的流長這樣:

q
0 0 612 792 re W n
/HeaderBand sh
Q

q/Q 對把裁剪與任何變換和既有頁面內容隔離,頁面上後來畫的東西不會繼承它們。如果您在別的工具裡自己寫 shading 呼叫,照抄這個形狀:同一條流裡的顯式裁剪很便宜,並解除對先前內容的依賴

看似有效的 PDF shading 在 PDFium 裡為什麼什麼都不畫:寫成名稱 Axial 的 /ShadingType 被整數 getter 讀成 0、sh 運算子被靜默跳過,而只含 sh 的內容流在顯式 re W n 裁剪給它矩形之前沒有邊界方塊
兩種隱形 shading 失敗在 PDFium 裡都是靜默的,所以 SaveAsShadingCreated 自己寫整數 ShadingType,並把每個 sh 呼叫包進 crop box 的 re W n 裁剪

自訂漸層矩陣怎麼與裁剪互動?

PDFium Component 的漸層矩陣以 cm 運算子套用,位置在裁剪之後、sh 之前,所以裁剪保持全頁,只有漸層幾何被變換。把 HasMatrix 設 True、填 MatrixA 到 MatrixF,與任何 PDF 變換矩陣相同的六個數。值必須有限、行列式非零,奇異矩陣會把 shading 壓成一條線。元件也把六個值記成 shading 字典裡的 /Matrix,但對直接 sh 呼叫而言,定義 PDFium 繪製座標系的是內容流裡的 cm

順序在這裡很要緊,這是第三個陷阱。re 的運算元按目前變換矩陣解讀。把 cm 放前面,頁面尺寸的裁剪矩形就跟著漸層一起縮放、旋轉或歪斜,壓扁橢圓的矩陣也壓扁了可見區域,頁面部分區域失去填充。先在未變換的使用者空間裁剪、再串接矩陣,得到期望結果:一個覆蓋整頁的變換漸層。需要自己組矩陣的話,Form XObject 印章及其六數矩陣指南逐一解釋每個矩陣分量做什麼

procedure MakeEllipticalGlow(var Fill: TPdfShadingCreate);
begin
  BuildSealGlow(Fill);       // 以 450,160 為中心的徑向填充
  Fill.Coords := [0, 0, 0, 0, 0, 90];  // 圓在原點
  Fill.HasMatrix := True;
  Fill.MatrixA := 1.6;       // 水平拉伸
  Fill.MatrixB := 0;
  Fill.MatrixC := 0;
  Fill.MatrixD := 0.8;       // 垂直壓縮
  Fill.MatrixE := 450;       // 再移到印章中心
  Fill.MatrixF := 160;
end;

讀取報告並了解限制

TPdfShadingCreateReport 告訴您寫了什麼:SourceByteCount 與 OutputByteCount、CreatedShadingObjectNumbers(每個輸入項一個物件編號,按順序)、AttachedShadingCount、Succeeded 與 ErrorMessage。驗證錯誤會指名條目與欄位,例如不嚴格遞增的停靠點清單,值得原樣記進日誌

if Pdf.SaveAsShadingCreated(OutFile, Fills, Report) then
  Writeln(Format('%d shading(s) attached, %d -> %d bytes',
    [Report.AttachedShadingCount, Report.SourceByteCount,
     Report.OutputByteCount]))
else
  Writeln('Rejected: ' + Report.ErrorMessage);

這個 profile 刻意有界。它只建立軸向與徑向 shading;函式型與網格 shading(Type 1 與 Type 4 到 7)以及 pattern 色彩空間一律拒收。一次呼叫收 1 到 4096 個填充,頁碼必須存在,資源名稱限 127 個字元的字母、數字、底線、減號與句點。加密輸入在 trailer 階段就被拒。已簽署的文件需要 AllowSignedDocument := True,而由於輸出是完整改寫而不是增量更新,既有簽章將不再驗得過,所以請先加漸層再簽章。每個填充畫在頁面內容的末尾、蓋在既有內容之上;要背景效果,先建漸層再加文字與影像;填充需要可切換時,用在 Delphi 中控制 PDF 可選內容圖層一文描述的可選內容

FAQ

SaveAsShadingCreated 支援多停靠點漸層嗎?

支援。在 Stops 裡傳 2 到 256 個條目,位置從 0 到 1 嚴格遞增。兩個停靠點產生 Type 2 函式,三個以上產生 Type 3 拼接函式,附導出的 /Bounds 與 /Encode 陣列

檔案看起來有效,漸層在 PDFium 裡為什麼隱形?

檢查 /ShadingType 是整數 2 或 3 而不是名稱,且 sh 運算子在 re W n 裁剪之內、同一條內容流裡執行。PDFium 會靜默跳過無效的 shading 類型,並剔除空矩形的 shading 物件

建立的 shading 能用哪些色彩空間?

TPdfShadingCreate 接受 DeviceGray、DeviceRGB、DeviceCMYK、CalGray、CalRGB、Lab 與帶 1、3、4 分量 profile 的 ICCBased。Pattern 與 Indexed 色彩空間不在建立 profile 之內

漸層建立、可選內容與其餘分段寫入器都隨PDFium Component for Delphi and C++Builder 提供,為 Delphi、C++Builder 與 Lazarus 專案包裝 PDFium