PDFlibPas 在自己的 JBIG2 半色調區域解碼器裡修掉兩個獨立缺陷:v3.539.37 把 HSKIP 略過遮罩改成按 ITU-T T.88 §6.6.5.1 的定義以 HSKIP[ng, mg] 索引;v3.539.38 則讓抵達負座標的網格——HGX 或 HGY 為負、或網格旋轉——以真正的 floor 位移落點。在這兩版之前,受影響的半色調區域出來是花的或錯位的,而且不報任何錯。兩個 bug 都躲在剛好對稱或非負的測試資料後面,而第二個還牽出 Delphi 與 Free Pascal 一個遠在 JBIG2 之外也咬人的性質:帶正負號整數上的 shr 是邏輯位移,不是標準假定的算術 >>
半色調區域是 JBIG2 裡最少見的區域型別,解碼器可能先處理了幾千份掃描文件,才遇到一份把網印照片編成這種區域的。遇到了,故障還特別難纏:檔案解析得動、segment 長度加得起來、頁面尺寸正確,區域卻是一團垃圾
JBIG2 半色調區域到底在解碼什麼?
JBIG2 半色調區域是一片小點陣圖構成的網格,小圖從 pattern dictionary 裡挑,解碼器真正的活兒是為每個網格儲存格算出一個索引,以及這格落在哪個像素位置。pattern dictionary 裝著 HNUMPATS 個 HPW × HPH 像素的圖樣。半色調區域 segment 接著描述一個 HGW 欄乘 HGH 列的網格,與一張同大小的灰階影像,以 Gray 編碼的位元平面存放。每個位元平面用 generic region 程序在 HGW × HGH 點陣圖上解碼、最高有效平面在前,這些平面合起來給每格它的圖樣索引
儲存格落點用帶 8 位元小數的定點算術。網格原點 HGX、HGY 是一對 32 位元值,網格向量 HRX、HRY 描述相鄰儲存格之間的步距,所以網格可以旋轉。對網格列 mg 與網格欄 ng,T.88 §6.6.5 這樣算像素位置:
x = (HGX + mg × HRY + ng × HRX) >> 8y = (HGY + mg × HRX − ng × HRY) >> 8
略過遮罩經由選用的 HENABLESKIP 旗標進場。旗標設立時,§6.6.5.1 會建一張 HGW × HGH 的點陣圖 HSKIP,對每個圖樣完全落在區域外的儲存格把 HSKIP[ng, mg] 設為 1:x + HPW <= 0、x >= HBW、y + HPH <= 0 或 y >= HBH。灰階位元平面接著以這張遮罩當 generic region 的 skip 點陣圖來解碼,算術解碼器於是不為被跳過的儲存格讀取或更新 context。解碼器與編碼器必須在 HSKIP 的每一個位元上一致,否則兩台算術編碼器就會失步
為什麼轉置的 HSKIP 遮罩只弄壞非方形網格?
略過遮罩寫入時座標是對調的,而且只有非方形網格會暴露它,因為方形網格讓每個對調後的座標都還在遮罩之內。PDFlibPas 的點陣圖以 (column, row) 像素存取器存放,建遮罩的程式碼卻傳了 (mg, ng),列在前。灰階位元平面解碼器以 (ng, mg) 讀遮罩,讀法是對的。圖樣落點迴圈卻照建構時對調的順序讀回來,兩邊於是湊一致了,單審落點邏輯還會給它過關。一個命名陷阱讓情況更糟:落點迴圈裡叫 col 的變數迭代的是網格列,叫 Row 的迭代的是網格欄
拿 v3.539.37 的迴歸案例來說:16 × 8 區域上的 5 × 3 網格、4 × 4 圖樣。HRX = 1024、HRY = 0 時,網格欄 4 落在 x = 16、網格列 2 落在 y = 8,都在區域外。正確的遮罩該標七格:整個欄 4 與整個列 2。對調的寫入卻想在只有三列高的遮罩裡設列索引 3 與 4 的像素,點陣圖設定器悄悄忽略了這些越界寫入。留下來的是欄 2、列 0 到 2。解碼器於是跳過了編碼器編過的兩格,又解出了編碼器跳過的六格
算術解碼器在這種情況下不會失敗。它拿屬於後面儲存格的位元多解出像素,context 讀到錯的鄰居,第一處不一致之後的每個圖樣索引都是雜訊——症狀因此是整個區域變花,而不是幾格放錯位置。在方形網格上同一個 bug 往往隱形:對調的座標沒有跑出遮罩,而當區域外的儲存格對對角線對稱時——例如網格往右與往下超出的格數相同——轉置的遮罩逐位元組等於正確的那張。HENABLESKIP 又是選用的、灰階影像採 MMR 編碼時必須為 0、編碼器很少設它,所以這個 bug 露頭的機會極少。從 v3.539.37 起,建構器寫 HSKIP[ng, mg],落點迴圈按同一順序讀
半色調網格的負偏移為什麼壞在三層?
起始位置在區域左方或上方的半色調網格,把 PDFlibPas 弄壞在三個互不相干的地方,每個缺陷又把下一個藏起來。T.88 是刻意允許這種幾何的。編碼器把網屏對齊頁面而不是區域、或用旋轉網格時,自然會產生被區域裁掉的負儲存格角。v3.539.38 三層一起修,因為單修任何一層只是換個症狀
第 1 層:帶正負號的欄位被當無號讀取
T.88 §7.4.5.1.2 把 HGX 與 HGY 定義成帶正負號的 32 位元值,但解碼器用讀無號欄位的那個 32 位元輔助函式去讀,而那個函式把每個負值夾到 0。原定 HGX = -900 起筆的網格被悄悄搬上區域原點。在 v3.539.38 的迴歸案例裡,整張圖低了兩列出來。這個夾值也解釋了另外兩個缺陷為什麼活了那麼久:原點被強制非負之後,負座標只能經 HRY > 0 的旋轉網格出現——那裡 y = HGY + mg × HRX − ng × HRY 會在靠後的網格欄上跌破零
第 2 層:shr 不是 >> 8
T.88 寫 >> 8,指的是算術位移,往負無窮方向捨入。解碼器把它譯成 shr 8。在 Delphi 與 Free Pascal 裡,帶正負號整數上的 shr 是邏輯位移:移進來的符號位元是零。存著 -512 的 Integer 經 shr 8 得到 16777214,不是 -2。本該畫在 y = -2、裁到下半部的圖樣被丟到一千六百萬列以下,再當成區域外扔掉。什麼都沒崩潰;半色調的頂列就這麼消失了
第 3 層:比較定點值而不是像素
略過測試比較的是定點值、不是像素位置,而小數部分一旦非零,兩者就不等價。原本的程式碼為了避開邏輯位移,改在未位移的值上測 xx + HPW × 256 <= 0,以為這等價於 T.88 的測試。HGX = -900 配 4 像素圖樣時,算出 -900 + 1024 = 124,是正數,於是這格不跳。標準是先位移:floor(-900 / 256) = -4,-4 + 4 = 0 滿足 x + HPW <= 0,這格整個在區域外、必須跳過。編碼器跳了它,解碼器解了它,灰階影像便跟轉置遮罩案例一模一樣地漂移
v3.539.38 的迴歸案例用 12 × 10 區域上的 4 × 3 網格、4 × 4 圖樣,HGX = -900、HGY = -512、HRX = 1024。網格欄落在 x = -4、0、4 與 8,欄 0 整個在區域外、該進 HSKIP;網格列落在 y = -2、2 與 6,列 0 該裁到下面兩列像素、而不是整列丟棄。一層一層修,就會重現這張階梯:
| 已修的缺陷 | 解出的區域 |
|---|---|
| 無(v3.539.38 之前) | 網格被拉到原點,整張圖低兩列 |
| 只修帶號 HGX / HGY 讀取 | 第一列網格不見,其餘被略過測試的漂移弄花 |
| 帶號讀取、floor 位移與像素空間略過測試 | 與按 T.88 §6.6.5 算出的頁面逐像素一致,也與兩個獨立參考解碼器一致 |
修法是一個輔助函式 HalftoneGridPixel,略過遮罩建構器與落點迴圈共用。座標在 Int64 裡累加,巨大的 mg × HRX 乘積便無法迴繞;除以 256、往負無窮捨入;再夾到 ±MaxInt div 2,損壞的網格便撐不爆後續的點陣圖算術。略過測試現在把這些像素值對著 HPW、HPH、HBW 與 HBH 比較,跟 §6.6.5.1 寫的一字不差
在 Delphi 裡怎麼寫算術右位移?
Delphi 沒有算術位移運算子,正確的帶號右位移得寫成 floor 除法,而普通的 div 不是那種除法——div 朝零截斷。非負值時截斷與 floor 一致,除數整倍數的負值也一致,這就是 -512 div 256 = -2 在快速測試裡看起來沒問題的原因。其他地方全部分歧:-900 div 256 得 -3、floor 是 -4,-1 div 256 得 0、floor 是 -1。帶非零小數的 JBIG2 座標,恰恰就是 div 給錯像素的那種情況
在 Delphi 的 Win32 與 Win64 編譯器上,存著 -512 的 Integer 右移 8 位得 16777214,存著 -512 的 Int64 則得 72057594037927934。Free Pascal 同樣把 shr 定義成邏輯位移,但在它的 System 單元裡附了算術版的 SarLongint 與 SarInt64;這兩個函式在 Delphi 裡不存在,所以兩個編譯器共用的程式碼需要自己的輔助函式:
// Floor 除法:不論 A、B 正負,都往負無窮方向捨入。
// B 不得為 0,FloorDiv(Low(Integer), -1) 跟 div 一樣會溢位
function FloorDiv(A, B: Integer): Integer;
begin
Result := A div B;
if (A mod B <> 0) and ((A < 0) <> (B < 0)) then
Dec(Result);
end;
// 算術右位移(C 與 T.88 對帶號值的 ">>")。
// Value 為負時,not Value = -Value - 1 是非負的,所以那裡的
// 邏輯 shr 是安全的,外層 not 再把結果映射回來
function SarInt32(Value: Integer; Shift: Integer): Integer; // Shift 0..31
begin
if Value >= 0 then
Result := Value shr Shift
else
Result := not ((not Value) shr Shift);
end;
function SarInt64(Value: Int64; Shift: Integer): Int64; // Shift 0..63
begin
if Value >= 0 then
Result := Value shr Shift
else
Result := not ((not Value) shr Shift);
end;
not 這招從不移位負數,所以不依賴編譯器怎麼對待符號位元,也永不溢位,Low(Integer) 在內。兩個輔助函式對照 Int64 floor 參考實作,跑了幾百萬個值、0 到 31 的每個位移,以及 Low(Integer) 與 High(Integer) 兩個邊界,覆蓋 Delphi Win32、Delphi Win64 與 Free Pascal x86_64。任何碰座標的單元測試都值得留一條這樣的 sanity check:
var
V: Integer;
begin
V := -900;
Writeln(V shr 8); // 16777212 邏輯位移,舊 bug
Writeln(V div 256); // -3 朝零截斷
Writeln(FloorDiv(V, 256)); // -4 T.88 的 >> 8 指的是這個
Writeln(SarInt32(V, 8)); // -4
end;
Math.Floor(V / 256) 也回傳 -4,但它繞道 Double,對超過 253 的 Int64 值會掉精度,所以整數幾何就留在整數裡做
哪些 PDFlibPas 呼叫會跑到半色調解碼器?
PDFlibPas 用內建渲染器渲染頁面時,JBIG2 半色調解碼器就會跑起來,因為渲染需要像素。RenderPageToFile 與 RenderPageToStream 都經頁面的 JBIG2Decode 影像串流抵達它,所以重新渲染半色調頁面,是確認 v3.539.38 改變您輸出的直接辦法。同一個解碼器也處理其他 JBIG2 區域型別,見 純 Pascal 解碼器裡的 JBIG2 自訂 Huffman 表與 在 Delphi 解碼隨機存取 JBIG2 檔;渲染出的點陣圖則餵給 把 PDF 頁面渲染成 1-bit 黑白這類轉換
uses
SysUtils, PDFlibrary;
var
Lib: TPDFlib;
Page: Integer;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile('scanned-halftone.pdf', '') <> 1 then
raise Exception.CreateFmt('Load failed, error %d', [Lib.LastErrorCode]);
for Page := 1 to Lib.PageCount do
// 渲染會解碼每個 JBIG2 區域,半色調在內
if Lib.RenderPageToFile(150, Page, PDF_RENDER_PNG,
Format('page-%.3d.png', [Page])) <> 1 then
Writeln('Page ', Page, ' was not rendered');
finally
Lib.Free;
end;
end.
影像擷取平常走另一條路。GetPageImageList 以原生形式回傳 JBIG2 影像,SaveImageListItemDataToFile 或 GetImageListItemDataToString 給您一份由串流位元組組成的獨立 JBIG2 檔:檔頭、JBIG2Globals 資料,外加包住頁面資料的 end-of-file segment。GetImageListItemIntProperty 的屬性 400 對這種條目回報 6。這條路什麼都不解碼,所以「抽出來的 .jb2 在別的檢視器裡看起來正常、渲染出的頁面卻是雜訊」正是這兩個半色調 bug 的典型徵兆:
var
ListID, I: Integer;
begin
Lib.SelectPage(1);
ListID := Lib.GetPageImageList(0);
if ListID = 0 then
Exit;
try
for I := 1 to Lib.GetImageListCount(ListID) do
if Lib.GetImageListItemIntProperty(ListID, I, 400) = 6 then // 獨立 JBIG2 檔
Lib.SaveImageListItemDataToFile(ListID, I, 0,
Format('page1-image%d.jb2', [I]));
finally
Lib.ReleaseImageList(ListID);
end;
end;
遮罩或色彩轉換逼出渲染後備時,條目回來的是解碼後的點陣圖,半色調解碼器這時就會跑。影像清單的更多細節見 Delphi PDF 文字、影像與字型擷取
速查:JBIG2 半色調網格規則
- 略過遮罩以
HSKIP[ng, mg]索引、網格欄在前,凡擺放儲存格之處都按同一順序讀回(T.88 §6.6.5.1,PDFlibPas v3.539.37 修正) - 任何半色調或網格程式碼,都要用非方形網格加一組不對稱的區域外儲存格來測,方形網格能把轉置的索引藏得乾乾淨淨
HGX與HGY當帶正負號 32 位元值讀(T.88 §7.4.5.1.2),絕不走會夾掉負值的無號輔助函式- 標準的
>> 8譯成對 256 的 floor 除法,不是shr 8,也不是div 256 - 略過測試在位移後的像素位置上跑;定點形式只要小數非零就會分歧,
HGX = -900配 4 像素圖樣就是例證 - 網格座標在
Int64裡累加,交給點陣圖程式碼前先夾值,損壞的網格便無從溢位 - 文件裡有帶
HENABLESKIP、負網格原點或旋轉網格的半色調區域,就升級到 v3.539.38 以上
PDFlibPas 從 Delphi 與 C++Builder 渲染、擷取並編輯 PDF 文件,內建的原生 Pascal JBIG2 解碼器如今按 T.88 的規定處理半色調略過遮罩、負網格原點與旋轉網格。功能、版本與試用下載請見 PDFlibPas Delphi PDF 函式庫