一份 PDF 裡的 CMYK JPEG 如果跑出混濁、色偏或負片的顏色,幾乎都代表解碼器搞錯了兩件事實之一:取樣當初是不是以 YCCK 儲存、需要轉換,或者是不是按 Adobe 慣例以反轉形式儲存。HotPDF 的解碼器對三通道與四通道影像遵守 DCTDecode 的 /ColorTransform 參數,拒絕規格未定義的值,並讓 JPEG 資料內的 Adobe APP14 marker 優先於那個字典條目——這是 ISO 32000-1 Table 13 的要求。同一族陷阱還有兩條相鄰規則:多層 filter 的串流必須帶階段對齊的 /DecodeParms 陣列,JPEG 2000 影像則保留明確的 /Decode 映射
這三件事共用一個主題:PDF 本身已經把意思講清楚,陷阱在於渲染器回答了稍微不同的問題
為什麼 PDF 裡的 CMYK JPEG 會渲染成負片或泛白?
PDF 裡的 CMYK JPEG 出問題只有兩種原因,而且兩者在畫面上長得不一樣。負片影像——白紙變黑、淺色變深——指向反轉儲存;泛白或色相偏移的影像——膚色變綠、黑色變灰,但 K 的細節依然銳利——指向該做的色彩轉換被跳過,或不該做的轉換被做了
轉換問題源自 JPEG 壓縮色彩的方式。Baseline JPEG 在亮度與色度分離時壓得最好,所以編碼器通常在壓縮前把 RGB 轉成 YCbCr;四通道編碼器把 CMY 轉成 YCC、K 原樣通過,這就是所謂的 YCCK。解碼器必須精確還原編碼器做過的那次轉換;還原一場沒發生過的轉換,或跳過一場發生過的,每個像素都會位移。黑色通道無論如何都活得下來,這就是壞掉的 CMYK 影像常常黑字依然銳利、其他全都飄走的原因
反轉問題則是歷史包袱。Photoshop 與其他 Adobe 工具寫 CMYK JPEG 時,每個取樣都以 255 - value 儲存,並用 Adobe APP14 marker 標記檔案;JPEG 標準裡沒有這條規定,這是個慣例,每個消費端都得知道。在 PDF 裡,反轉應該由產生器在影像字典裡表達,而不是留給人猜
/ColorTransform 對解碼器說了什麼?
/ColorTransform 是 PDF 層級的宣告,說明解碼器必須還原哪次轉換。ISO 32000-1 §7.4.8 的 Table 13 定義兩個值:零代表不做轉換,解碼器原樣交回壓縮時的取樣;一代表編碼器做過標準轉換,三通道影像從 YCbCr 轉回 RGB,四通道影像從 YCCK 轉回 CMYK。這個參數對一通道與兩通道影像無效。Table 13 還定了一條容易漏掉的優先序:當編碼資料帶著含自家 transform 代碼的 Adobe marker 時,由那個代碼做主,字典條目被忽略;只有沒有該 marker 的 JPEG 才退回字典,而連條目也沒有時,三通道影像預設做轉換、四通道影像預設不做
常見的錯誤,是影像路徑收下了參數字典卻從沒把它交給 JPEG 解碼器。一個像素、顏色 (128, 63, 31) 的 RGB JPEG 會立刻暴露這種錯:這種解碼器對 /ColorTransform 0 和 /ColorTransform 1 都回傳 [128, 63, 31],而零的正確答案是原始 YCbCr 三元組 [79, 101, 163]
HotPDF 在位元組層級套用這個參數。HPDFDecodeHeadlessTerminalImage 先走過 JPEG 區段結構直到 start-of-scan marker,並尋找 Adobe APP14 區段;comment 區段裡那些只是長得像的位元組不算數。資料帶有 marker 時由它的 transform 代碼做主,字典值被忽略;沒有 marker 時,解碼器造一份 JPEG 私有副本,在 start-of-scan marker 前插入一個帶 transform 代碼 0、1,或(值為 1 的四通道影像)YCCK 代碼 2 的 APP14 區段。三通道影像的有效轉換為 0 時,副本還會解除 JFIF 檔頭的武裝,因為 JFIF 檔頭本身會讓 libjpeg-turbo 假定 YCbCr。呼叫端的緩衝區絕不會被碰動;插入區段時,那 16 個額外位元組會在解碼器配置任何東西之前先計入工作位元組預算
只在沒有 marker 時才插入,這一點很重要,起因於 libjpeg 的細節:它認的是最後看到的那個 APP14 區段。在既有 marker 之後再插入一個,字典裡的明確值就會悄悄蓋過資料裡相衝突的 marker,方向恰好與 Table 13 相反。HotPDF 在完整影像 XObject 路徑與行內影像路徑上都套用 Table 13 的順序
當單張影像看起來不對、你想在不渲染頁面的情況下看到它的取樣時,可以直接呼叫同一個終端解碼器。函式位於 HPDFHeadlessImageCodecs 單元,吃 filter 名稱、編碼位元組、選用的參數字典,以及來自影像字典的幾何資訊——後者必須與 JPEG frame 檔頭一致
uses
SysUtils, HPDFObjs, HPDFHeadlessImageCodecs;
// ColorTransform < 0 表示「無此設定值」:交給 JPEG marker 決定
function DecodeDCT(const Encoded: TBytes; Width, Height, Channels,
ColorTransform: Integer): TBytes;
var
Parms: THPDFDictionaryObject;
begin
Parms := nil;
if ColorTransform >= 0 then
begin
Parms := THPDFDictionaryObject.Create(nil);
Parms.AddNumericValue('ColorTransform', ColorTransform);
end;
try
Result := HPDFDecodeHeadlessTerminalImage('DCTDecode', Encoded, nil, Parms,
Width, Height, Channels, 8, 64 * 1024 * 1024, nil);
finally
Parms.Free;
end;
end;
這個解碼器對「什麼才算合法值」很嚴格。明確的 -1、2,或 0.5 這類非整數,都會擲出 EHPDFHeadlessImageError 而不是退回預設,因為規格未定義的值沒給解碼器任何可信的行動依據;就算資料裡的 Adobe marker 已讓條目變得無關緊要,這道檢查仍然在,因為未定義值是文件本身的結構性錯誤。預算小到裝不下轉換後副本時,也以同樣方式失敗,訊息會指名工作位元組預算
明確的 null 是另一回事,因為它根本不是一個值。ISO 32000-1 §7.3.9 說值為 null 的字典條目等同於條目不存在,所以 /ColorTransform null 直接落到 marker 或通道數預設,就像鍵不存在一樣;HotPDF 在每條路徑——無頭與 VCL、串流與行內——都如此對待
還有一個預設值值得點名,因為這是 HotPDF 刻意超出 Table 13 字面之處。三通道 JPEG 若既無 JFIF 檔頭也無 Adobe marker,而 frame 檔頭把元件標為 R、G 與 B,它自己的位元組就已經說明它裝的是 RGB;Table 13 仍會把它預設成要做轉換,但 libjpeg 與建立在它之上的閱讀器都不轉換地解碼,HotPDF 也一樣。無頭與 VCL 解碼器都如此對待,紅與藍就留在資料放它們的位置;明確的字典 0 或 1 仍然蓋過這個推論
uses
System.IOUtils;
var
Jpeg, Stored, Converted, ByMarkers: TBytes;
begin
// 640 x 480 CMYK 影像的原始 DCTDecode 位元組,從 PDF 串流存出
Jpeg := TFile.ReadAllBytes('suspect-image.jpg');
try
// 資料裡帶 Adobe APP14 marker 時,三個呼叫結果一致:
// marker 說了算,字典值被忽略
Stored := DecodeDCT(Jpeg, 640, 480, 4, 0); // 無 marker:取樣照壓縮原樣
Converted := DecodeDCT(Jpeg, 640, 480, 4, 1); // 無 marker:YCCK 還原回 CMYK
ByMarkers := DecodeDCT(Jpeg, 640, 480, 4, -1); // 無 marker:四通道預設,不轉換
Writeln(Format('pixel 0: stored %d,%d,%d,%d converted %d,%d,%d,%d',
[Stored[0], Stored[1], Stored[2], Stored[3],
Converted[0], Converted[1], Converted[2], Converted[3]]));
except
on E: EHPDFHeadlessImageError do
Writeln('Decoder refused the image: ', E.Message);
end;
end;
一個四通道參考像素能顯示該看什麼。以 YCCK 儲存的 CMYK 像素 (10, 20, 30, 40),在 YCCK 轉換未被還原時回來是 [245, 235, 225, 215],被還原時是 [0, 116, 0, 215];K 位元組 215 兩邊完全相同。如果 K 平面看起來正常而其他三個不對,第一個嫌疑犯就是錯配的轉換
反轉 CMYK 慣例歸 /Decode 管
HotPDF 的終端解碼器原樣回傳 libjpeg-turbo 產出的 CMYK 取樣,不會憑一個 Adobe marker 就自行翻轉。上面的數字把慣例攤在陽光下:245、235、225、215 不過是 255 減去 10、20、30、40,正是 Adobe 風格編碼器儲存的反轉形式;把它翻回墨量是影像字典的工作,內嵌這種 JPEG 的產生器應該寫上 /Decode [1 0 1 0 1 0 1 0]。渲染器把 /Decode 陣列套到每個通道,檔案寫對了,列印就正確
反面也值得直說:內嵌了 Adobe 反轉 CMYK JPEG 卻沒帶對應 /Decode 陣列的 PDF,在 HotPDF 裡就會渲染成負片,因為檔案自己就是那樣描述影像的。有些檢視器會偵測 APP14 marker 並悄悄補償;HotPDF 不加這種啟發式,這種檔案的修正屬於寫出它的軟體
為什麼兩個 filter 共用一個 DecodeParms 字典是錯誤?
/Filter 是多階段陣列的串流,必須提供等長的 /DecodeParms 陣列,每個階段一個參數字典或 null。ISO 32000-1 §7.3.8.2 的 Table 5 寫明這條規則,HotPDF 也強制執行:提供單一共用字典的多層 filter 串流會被回報為無效文件,而不是被解碼
寬鬆的替代做法比看起來糟。只在 /DecodeParms 已經是陣列時才檢查長度的解碼器,遇到單一字典配兩個 filter 時,會悄悄在每個階段重複使用那個字典;字典是空的時候無害,不是空的時候就有破壞力——給內層 Flate 階段的 /Predictor 12 會連外層也被套上,輸出變成看似合理的垃圾而不是乾淨的錯誤。這種檔案的意圖無從還原,所以 HotPDF 一律拒收,連空字典的情況也一樣,例如帶 /Filter [/FlateDecode /FlateDecode] 與 /DecodeParms << >> 的頁面內容串流
接受與拒絕的形狀很好列舉:
- 接受:省略
/DecodeParms,或為null,filter 數量不限 - 接受:長度等於 filter 數的陣列,每個項目是字典或
null - 接受:
/Filter只指名一個 filter 時,使用單一字典 - 拒絕:單一字典被兩個以上 filter 共用,診斷訊息為「Stream filter chain requires array DecodeParms」
- 拒絕:陣列長度與 filter 數不符,訊息為「DecodeParms array must match the filter chain」
普通內容串流,以及最外層 filter 是 DCTDecode 這類終端編解碼器的影像串流,都跑同一道檢查,所以無論串流裝的是什麼,畸形的鏈都在同一處失敗;與解壓縮上限的互動收錄在filter 鏈預算與 PDF 解碼炸彈一文。從應用程式碼看,錯誤在頁面渲染時浮現
uses
SysUtils, HPDFHeadlessDocument, HPDFHeadlessCairoDevice;
var
Document: THPDFHeadlessDocument;
Options: THPDFHeadlessRenderOptions;
Info: THPDFHeadlessRenderResult;
begin
Document := THPDFHeadlessDocument.Create;
try
try
Document.LoadFromFile('catalog-cmyk.pdf');
Options := THPDFHeadlessRenderOptions.Default;
Info := HPDFRenderHeadlessPagePNGFile(Document.GetRenderAccess(0),
'page1.png', Options);
Writeln('images=', Info.Images, ' decoded bytes=', Info.DecodedBytes);
except
on E: Exception do
Writeln('Rejected: ', E.ClassName, ': ', E.Message);
end;
finally
Document.Free;
end;
end;
JPEG 2000 影像保留明確的 /Decode
JPEG 2000 影像只有在字典沒有 /ColorSpace 且不是影像遮罩時,才會忽略 /Decode;ISO 32000-1 §8.9.5 把這條件寫清楚,HotPDF 照辦。對每個非 stencil JPX 影像一律丟掉 /Decode 是省事的捷徑,卻會讓明確的反轉直接憑空消失:/Decode [1 0] 下的 DeviceGray JPX 取樣 64 必須渲染成 191,不是 64
只做對這一個條件還不夠。只要影像是 JPX 就設預設通道範圍的 Lab、ICCBased 等色彩空間分支,會把已經正確套用的陣列再覆寫一次,所以渲染器記錄有效 /Decode 陣列是否真的被套用過,只在沒套用時才填預設範圍;明確的 Lab 與 ICCBased 範圍因此活得下來,字典沉默時仍由 codestream 的色彩宣告做主。格式的解碼側收錄在JPEG 2000 與 JPXDecode一文
哪些解碼路徑遵循這些規則?
兩條管線都遵循。無頭側包括 THPDFHeadlessDocument 載入、共用的串流解碼器,以及 HPDFRenderHeadlessPagePNGFile 背後以 Cairo 為基礎的頁面渲染器;VCL 側包括載入影像抽取到 TBitmap 與 VCL 頁面渲染,兩者在完整影像與縮減區域解碼上一律套用 Table 13 順序,所以 JFIF 檔頭壓不過 Adobe transform 0,非法值也會讓影像解不開。行內 DCT 影像遵循與影像 XObject 相同的 marker、null、預設與拒絕規則,縮寫鍵名與完整鍵名一視同仁,包括 /DP << … >> 字典與 /F [/AHx /DCT] 這類縮寫 filter 陣列;帶非法參數的行內影像會被跳過,頁面其餘部分照常渲染。從已載入 PDF 抽取影像指南涵蓋該 API 其餘的 filter 處理
把行為交付給正式產品前,有幾條實務備註:
- JPEG 解碼在執行期需要相容的 TurboJPEG 函式庫;
HOTPDF_TURBOJPEG_LIBRARY可覆寫預設的turbojpeg.dll或libturbojpeg.so.0 - 嚴格的結構檢查會退回寬鬆閱讀器打得開的檔案,最著名的就是共用的空
/DecodeParms字典;這類檔案的修正屬於產生端
對講色彩管理的印刷工作流程,實務結論很短:寫 CMYK JPEG 時給出與資料內任何 Adobe marker 一致的明確 /ColorTransform,加上符合其儲存形式的 /Decode 陣列;給每條多層 filter 串流對齊階段的 /DecodeParms 陣列;然後就等著 HotPDF 逐字追究檔案真正說了什麼
這裡展示的無頭載入器、串流解碼器與 Cairo 頁面渲染器隨HotPDF Delphi PDF component 出貨,供 Delphi、C++Builder 與 Lazarus 使用,經典 VCL 文件引擎亦同