PDFlibPas 3.539.23 版解碼採用 ITU-T T.88 Annex D.2 隨機存取組織的獨立 JBIG2 檔案:所有區段標頭排在最前面,區段資料按相同順序跟在後面。PDFlibJBIG2.pas 裡的原生 Pascal 解碼器把標頭位移一路索引到強制的 end-of-file 標頭、檢查區段編號遞增、確認宣告的資料長度加起來剛好等於剩餘的位元組,然後按標頭順序解碼每個主體,不拷貝也不重排壓縮資料。這個版本之前,同一個檔案在標頭 flags 被讀到的瞬間,就換來一句平板的「random-access organisation is not supported」
隨機存取的 JBIG2 檔案很罕見,而這正是它們出現時特別傷的原因。它們出自封存管線與文件影像系統,這些系統希望讀取器在碰任何一個壓縮位元組之前,先看過每一個區段標頭,也就看到了每一個頁面與字典依賴。批次把掃描檔案庫轉成 PDF 的 Delphi 應用,通常是在幾百個循序檔案順利通過之後、工作中途撞上這麼一個檔案,而對格式良好的檔案當場罷工的解碼器,比渲染出垃圾的解碼器好不了多少。同一條版本線剛教會解碼器JBIG2 自訂 Huffman 表與標準前綴碼,隨機存取就是解碼器文件上剩的最後一個組織能力缺口
JBIG2 的隨機存取組織是什麼?
隨機存取組織是 T.88 Annex D 允許的三種區段佈局之一:循序(D.1)把標頭與資料交錯排列,隨機存取(D.2)把所有標頭放前面、所有資料放後面,內嵌(D.3)是無標頭形式,用在 PDF 這類外層容器裡。獨立的 .jb2 檔以八位元組識別碼 97 4A 42 32 0D 0A 1A 0A 開頭,跟著一個 flags 位元組,頁數已知時再加一個四位元組頁數。flags 位元組的 bit 0 選組織,1 是循序、0 是隨機存取;bit 1 設起表示頁數未知、四位元組計數不存在。PDFlibPas 在 checkHeader 與 setFileHeaderFlags 裡讀這些,保留的 bit 2 到 7 是容忍而不是拒絕
// TJBIG2StreamDecoder.setFileHeaderFlags, PDFlibJBIG2.pas
headerFlags := reader.readByte;
fileOrganisation := headerFlags and 1; // 0 = 隨機存取(D.2)
randomAccessOrganisation := fileOrganisation = 0;
pagesKnown := headerFlags and 2; // 1 = 省略頁數
noOfPagesKnown := pagesKnown = 0;
// TJBIG2StreamDecoder.decodeJBIG2
validFile := checkHeader; // 97 4A 42 32 0D 0A 1A 0A
if not validFile then
begin
// PDF 串流:無檔案標頭、內嵌組織、單頁
noOfPagesKnown := True;
randomAccessOrganisation := False;
noOfPages := 1;
end
else
begin
setFileHeaderFlags;
if noOfPagesKnown then
noOfPages := getNoOfPages;
end;
PDF 本身從不承載這種佈局。ISO 32000-1 §7.4.7 所述的 JBIG2Decode 影像串流只裝內嵌組織的頁面區段,共用的符號字典移進獨立的 JBIG2Globals 串流,沒有檔案標頭、end-of-page 或 end-of-file 區段。當 decodeJBIG2 找不到八位元組識別碼,它假設的正是這種情況,並強制循序、單頁解碼。原生的 JBIG2 影像匯出走另一個方向,把 PDF 的區段包進一份獨立檔案,flags 位元組是 $03,循序、頁數未知,後面附加一個 end-of-file 標頭。所以隨機存取的工作只碰一條路徑:直接交給 TPLJBIG2Decoder 的獨立檔案,通常是在它們被轉換或重壓縮成 PDF 之前,而輸出側的活兒由 PDFlibPas 的 JBIG2 編碼器後端負責
為什麼隨機存取檔案不能照檔案順序讀?
因為除了 end-of-file 區段標頭本身,位元組流裡沒有任何東西標出標頭區塊在哪裡結束、資料區塊從哪裡開始。JBIG2 區段標頭是變長的:被參照區段數可以是三位元的短形式,或帶保留位元圖的長形式;被參照區段編號依區段自身編號佔一、二或四個位元組;頁面關聯欄位是一或四個位元組。天真的循序讀取器剖析第一個標頭、讀它的資料長度,然後把第二個標頭的前幾個位元組當成那個區段的資料。解碼器要到很久之後才會發覺自己出錯,這就是為什麼舊程式碼直接拒絕這種組織,連試都不試
PDFlibPas 怎麼為隨機存取的區段標頭建索引?
PDFlibPas 用一次預掃 IndexRandomHeaders 為隨機存取標頭建索引:剖析每個標頭、只記下它的位元組位移、停在第一個 end-of-file 標頭(區段型別 51)。每個標頭都被完整剖析後丟棄,所以索引是整數陣列而不是物件清單,預掃一路累加宣告的資料長度。掃描結束時,讀取器停在第一個區段資料的第一個位元組上,這個位置成為 NextBodyOffset
// IndexRandomHeaders,TJBIG2StreamDecoder.readSegments 的區域程序
while not reader.isFinished do
begin
Offset := reader.bytePointer;
Header := TSegmentHeader.Create;
try
readSegmentHeader(Header);
if reader.BufferOverrun then
raise EJBIG2DecodeError.CreateFmt(
'JBIG2 truncated random-access header at byte %d', [Offset]);
if (HeaderCount > 0) and
(Cardinal(Header.getSegmentNumber) <= Cardinal(PreviousNumber)) then
raise EJBIG2DecodeError.Create('JBIG2 random-access segment numbers must increase');
PreviousNumber := Header.getSegmentNumber;
Count := Header.getSegmentDataLength;
if Count < 0 then
raise EJBIG2DecodeError.Create('JBIG2 unknown or oversized segment length is not supported');
Inc(TotalLength, Count); // Int64 累加器
HeaderOffsets[HeaderCount] := Offset; // 分塊成長
Inc(HeaderCount);
if Header.getSegmentType = JBIG2_END_OF_FILE then
begin
if Count <> 0 then
raise EJBIG2DecodeError.Create('JBIG2 invalid end segment length');
FoundEnd := True;
Break;
end;
finally
Header.Free;
end;
end;
if not FoundEnd then
raise EJBIG2DecodeError.Create('JBIG2 random-access file is missing its end-of-file header');
if TotalLength > Length(reader.Data) - reader.bytePointer then
raise EJBIG2DecodeError.Create('JBIG2 truncated random-access segment data');
if TotalLength < Length(reader.Data) - reader.bytePointer then
raise EJBIG2DecodeError.Create('JBIG2 trailing random-access data');
NextBodyOffset := reader.bytePointer;
那個迴圈裡的每一個檢查都存在,因為隨機存取檔案的冗餘比循序檔案少。區段編號必須以無號數比較、嚴格遞增,因為兩個標頭宣稱同一個編號,會讓後面區域的參照清單指的主體變得模稜兩可。資料長度欄位由 handleSegmentDataLength 讀取,任何最高位設起的值,包括 0xFFFFFFFF 的「未知長度」標記,都映射成 -1;在隨機存取佈局裡沒有別的辦法找到下一個主體的起點,所以 PDFlibPas 立即拒絕那個長度,而不是掃描找結束標記。總長必須在兩個方向上都與剩餘位元組完全相符,最後一個主體之後多出一個位元組,就以「trailing random-access data」失敗。這份嚴格是刻意的:在這種佈局裡,長度不匹配代表錯誤點之後的每個主體都位移了,而對一個多餘位元組聳聳肩的解碼器,無從判斷它是無害的填補,還是資料錯位的第一個症狀
最後一個 end-of-page 區段為什麼消失了?
因為解碼迴圈的第一版保留了循序版的終止測試 while not reader.isFinished,而在隨機存取佈局裡,資料流會在標頭索引用完之前先耗盡。end-of-page(型別 49)與 end-of-file 區段帶零位元組資料,它們通常是檔案裡最後的標頭。最後一個區域主體被消化完之後,讀取器正好停在緩衝區結尾,迴圈就退出了,那些零長度區段永遠等不到分派,頁面於是收不了尾。修法讓隨機存取迴圈改數標頭而不是數位元組。每次迭代把讀取器跳到下一個被索引的標頭、把 bitPointer 重設為 7(因為前一個主體可能結束在位元組中間)、重新剖析那個標頭,然後把 bytePointer 移到 NextBodyOffset 並越過主體前進。既有的區段處理器、被參照區段檢查與 Context 診斷原樣運行,錯誤訊息照舊回報標頭的原始位元組位移,不是主體的位置
// TJBIG2StreamDecoder.readSegments, main loop
if randomAccessOrganisation then
IndexRandomHeaders;
while (randomAccessOrganisation and (HeaderIndex < HeaderCount)) or
((not randomAccessOrganisation) and (not reader.isFinished)) do
begin
if randomAccessOrganisation then
begin
reader.bytePointer := HeaderOffsets[HeaderIndex];
reader.bitPointer := 7; // 位元組中途收尾後重新對齊
Inc(HeaderIndex);
end;
SegmentOffset := reader.bytePointer; // 用在錯誤診斷脈絡
readSegmentHeader(segmentHeader);
if randomAccessOrganisation then
reader.bytePointer := NextBodyOffset; // 跳到這個區段的資料
DataLength := segmentHeader.getSegmentDataLength;
DataEnd := reader.bytePointer + DataLength;
NextBodyOffset := DataEnd;
// ... 分派給既有的區段處理器,然後定位到 DataEnd
end;
隨機存取的驗證實際上證明了什麼?
驗證證明的是:重組過的位元組解出的像素與循序版原檔一致,畸形的隨機存取輸入乾淨地失敗;它沒有證明涵蓋任意編碼器產出的隨機存取檔案。共用的 Pascal 迴歸用一份 235 位元組的合成檔,建構在自訂表治具上,必須解出一列 7 x 1 的黑像素,頁數已知與移除計數欄位兩種情況都測;接著把那份檔案的每一個截斷前綴、一個重複的區段編號、一個多出的尾位元組與一個未知資料長度逐一餵給解碼器,每次都斷言 LoadFromByteArray 回傳 False、Width 與 Height 留在零。真實影像案例是一張 500 x 473 的自訂表 refinement 影像,它的區段被重組成隨機存取佈局,每個原始標頭與壓縮位元組都保留;其 SHA-256 與審閱過的循序基準完全相符。那份檔案是組織轉換產出的衍生品,不是野外找到的天然隨機存取文件,而這樣的天然樣本當時並不存在。測試套件通過的情況:Delphi Win32 1,598 項、Delphi Win64 影像套件 42 項、FPC Win32 48 項、FPC Win64 46 項,外加三個既有的循序像素案例
載入隨機存取 .jb2 檔案,以及它的極限
應用程式碼不用改:TPLJBIG2Decoder.LoadFromByteArray 自己偵測檔案標頭與組織,任何被拒的輸入回傳 False 並把原因放進 LastError,解出的頁面透過 Width、Height 與每像素一個位元組的 GetScanline 取用
uses
SysUtils, Classes, PDFlibJBIG2;
function ReadJb2(const FileName: string): TJBIG2ByteArray;
var
FS: TFileStream;
begin
FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
try
SetLength(Result, FS.Size);
if Length(Result) > 0 then
FS.ReadBuffer(Result[0], Length(Result));
finally
FS.Free;
end;
end;
function CountBlackPixels(const FileName: string): Integer;
var
Decoder: TPLJBIG2Decoder;
Row: TJBIG2ByteArray;
X, Y: Integer;
begin
Result := 0;
Decoder := TPLJBIG2Decoder.Create;
try
// 循序與隨機存取的獨立檔案走同一個呼叫
if not Decoder.LoadFromByteArray(ReadJb2(FileName)) then
raise Exception.Create('JBIG2 rejected: ' + Decoder.LastError);
for Y := 0 to Decoder.Height - 1 do
if Decoder.GetScanline(Y, Row) then
for X := 0 to Decoder.Width - 1 do
if Row[X] = 1 then
Inc(Result);
finally
Decoder.Free;
end;
end;
邊界值得說明白。隨機存取支援是一個檔案組織功能,不是隨機頁面 API:TPLJBIG2Decoder 仍然回傳第一頁的點陣圖,沒有從 40 頁檔案裡挑第 7 頁、或把頁面留到需要時再解的呼叫。未知資料長度的區段在隨機存取檔案裡被拒,自訂 Huffman 前綴長度與表條目數的既有上限不變。這些限制夠窄,Delphi 應用可以照 LastError 把被拒的案例導去別處,而影像管線其餘部分,從 PDF 影像擷取到 JBIG2 編碼,都在 PDFlibPas Delphi PDF 函式庫產品頁上有說明