Artikel Teknis

Tabel Huffman Kustom JBIG2 di Decoder PDF Pascal Murni

PDFlibPas versi 3.539.22 mendekode tabel Huffman kustom JBIG2 secara native: decoder Pascal murni di PDFlibJBIG2.pas memparse segmen Tables (tipe 53), menetapkan canonical prefix code dalam urutan baris tabel seperti yang diminta ITU-T T.88 Annex B.3, mengonsumsi referensi tabel kustom sesuai urutan selector untuk symbol dictionary dan text region, dan membatasi setiap pembacaan pada panjang segmen yang dideklarasikan, bukan pada byte apa pun yang kebetulan menyusul

File yang memicu pekerjaan ini biasa saja dari luar. Sebuah kontrak hasil scan, dikompresi JBIG2 dengan Huffman symbol coding alih-alih arithmetic coding yang jauh lebih umum, dan encoder-nya mengirim tabel kode sendiri, bukan tabel standar B.1 sampai B.15. Dua decoder independen berbeda pendapat soal piksel refinement-nya, dan decoder PDFlibPas saat itu menghasilkan teks yang tampak seperti habis masuk mesin penghancur kertas: potongan glyph bergeser beberapa piksel, satu kolom dari setiap karakter hilang. Tidak ada yang melempar error. Itulah bentuk bug yang bertahan bertahun-tahun, karena decoder yang menolak file akan dapat tiket dukungan, sedangkan decoder yang merendernya sedikit salah akan dapat pelanggan yang mengira hasil scan-nya memang jelek

Sebenarnya apa isi segmen Tables JBIG2?

Segmen Tables adalah deskripsi ringkas satu tabel Huffman: satu byte flags, dua bound bertanda 32-bit, lalu rangkaian pasangan (prefix length, range length) yang membagi interval di antara kedua bound itu, sesuai tata letak di T.88 §7.4.13 dan Annex B.2. Bit 0 dari byte flags adalah HTOOB dan menyatakan apakah tabel punya out-of-band code. Bit 1 sampai 3 ditambah satu memberi HTPS, jumlah bit yang dipakai untuk menulis setiap prefix length; bit 4 sampai 6 ditambah satu memberi HTRS, lebar setiap field range length. Bit 7 dicadangkan, dan PDFlibPas menolak segmen itu kalau bit tersebut menyala alih-alih menebak apa maksud revisi mendatang dengan bit itu. HTLOW dan HTHIGH menyusul sebagai integer bertanda 32-bit, dan di sinilah kesalahan pertama bisa terjadi: membacanya sebagai unsigned membuat tabel yang low bound-nya negatif, hal yang wajar untuk lebar simbol ber-delta-code, tampak mulai dari empat miliar. Setiap field melewati helper lokal ReadField yang memeriksa permintaan terhadap posisi bit tempat data segmen berakhir sebelum menyentuh reader, karena tabel yang membaca melewati segmennya akan mengonsumsi header segmen berikutnya sebagai prefix length

Tata letak segmen Tables di balik decoding Huffman kustom JBIG2 di PDFlibPas: satu byte flags yang membawa HTOOB, HTPS, dan HTRS plus satu bit cadangan yang ditolak, bound HTLOW dan HTHIGH bertanda, rangkaian pasangan prefix length dan range length, serta baris escape jbig2HuffmanLOW, baris tinggi 32-bit tetap, dan jbig2HuffmanOOB opsional
Setiap field segmen dibaca lewat helper yang memeriksa batas, karena tabel yang membaca melewati akhir yang dideklarasikan akan mengonsumsi header segmen berikutnya sebagai prefix length, dan baris escape sentinel-nya sama dengan tabel standar bawaan
// TCodeTableSegment.readSegment, PDFlibJBIG2.pas
EndBit := (Int64(decoder.reader.bytePointer) +
  segmentHeader.getSegmentDataLength) * 8;
Flags := ReadField(8);
if (Flags and $80) <> 0 then
  raise EJBIG2DecodeError.Create('reserved custom Huffman table flag');
PrefixBits := ((Flags shr 1) and 7) + 1;   // HTPS
RangeBits  := ((Flags shr 4) and 7) + 1;   // HTRS
LowValue   := Integer(ReadField(32));      // HTLOW bertanda
HighValue  := Integer(ReadField(32));      // HTHIGH bertanda
if LowValue >= HighValue then
  raise EJBIG2DecodeError.Create('invalid custom Huffman range bounds');
CurrentValue := LowValue;
while CurrentValue < HighValue do
begin
  PrefixLength := ReadField(PrefixBits);
  RangeLength  := ReadField(RangeBits);
  if RangeLength > 32 then
    raise EJBIG2DecodeError.Create('invalid custom Huffman range length');
  AddLine(CurrentValue, PrefixLength, RangeLength);
  Inc(CurrentValue, Int64(1) shl RangeLength);
end;
AddLine(LowValue - 1, ReadField(PrefixBits), jbig2HuffmanLOW);
AddLine(HighValue,   ReadField(PrefixBits), 32);
if (Flags and 1) <> 0 then
  AddLine(0, ReadField(PrefixBits), jbig2HuffmanOOB);

Dua baris yang ditambahkan setelah loop adalah baris escape dari Annex B.2: baris range bawah mulai dari HTLOW dikurangi satu dan menghitung ke arah bawah, baris range atas mulai dari HTHIGH dengan range 32-bit tetap, dan baris OOB opsional sama sekali tidak punya nilai. PDFlibPas menandainya dengan sentinel range length jbig2HuffmanLOW ($FFFFFFFD) dan jbig2HuffmanOOB ($FFFFFFFE), konvensi yang sama dipakai kelima belas tabel standar bawaannya, jadi loop decoding tidak peduli apakah tabel itu datang dari spesifikasi atau dari file

Mengapa prefix code harus ditetapkan dalam urutan baris tabel?

Karena encoder tidak pernah menulis kodenya. Segmen Tables JBIG2 hanya membawa prefix length, dan kedua sisi merekonstruksi pola bit sebenarnya dengan prosedur kanonik di Annex B.3: hitung berapa baris yang punya setiap panjang, tetapkan dulu kode berpanjang satu, lalu geser ke kiri dan lanjutkan, dan dalam satu panjang yang sama bagikan kode sesuai urutan kemunculan barisnya. Penyimpangan apa pun dari urutan itu diam-diam menghasilkan tabel yang berbeda. Decoder tidak akan menyadarinya, karena setiap pola bit yang dihasilkannya tetap prefix code yang valid, hanya bukan yang dipakai encoder, dan outputnya adalah bitmap yang tampak masuk akal tapi dirakit dari simbol yang salah

Penetapan canonical prefix code di decoder JBIG2 PDFlibPas: hanya prefix length yang datang di segmen, counting sort stabil atas Counts, Starts, dan Positions menjaga urutan deklarasi di dalam setiap panjang, kode berpanjang satu dibagikan lebih dulu dan kodenya digeser ke kiri per panjang, dengan oversubscription ditolak oleh pemeriksaan Kraft
Encoder tidak pernah menulis pola bit-nya, jadi penyimpangan apa pun dari urutan baris tabel diam-diam membangun prefix code lain yang tetap valid dan outputnya tampak masuk akal; baris berpanjang nol gugur sebagai tidak terpakai dan prefix lebih dari 32 bit ditolak
// THuffmanDecoder.buildTable, PDFlibJBIG2.pas
FillChar(Counts, SizeOf(Counts), 0);
for I := 0 to length - 1 do
begin
  if table[I].prefixLen > 32 then
    raise EJBIG2DecodeError.Create(
      'Huffman prefixes longer than 32 bits are not supported');
  Inc(Counts[table[I].prefixLen]);
end;
Active := 0;
Code := 0;
for Bits := 1 to 32 do
begin
  Starts[Bits]    := Active;
  Positions[Bits] := Active;
  Inc(Active, Counts[Bits]);
  if Code + UInt64(Counts[Bits]) > (UInt64(1) shl Bits) then
    raise EJBIG2DecodeError.Create('oversubscribed Huffman prefix codes');
  Code := (Code + UInt64(Counts[Bits])) shl 1;
end;
SetLength(Result, Active + 1);
for I := 0 to length - 1 do            // stabil: urutan asli dipertahankan
  if table[I].prefixLen > 0 then       // di dalam setiap prefix length
  begin
    Result[Positions[table[I].prefixLen]] := table[I];
    Inc(Positions[table[I].prefixLen]);
  end;
Code := 0;
for Bits := 1 to 32 do
begin
  for I := Starts[Bits] to Positions[Bits] - 1 do
  begin
    Result[I].prefix := Cardinal(Code);
    Inc(Code);
  end;
  Code := Code shl 1;
end;
Result[Active].rangeLen := jbig2HuffmanEOT;

THuffmanDecoder.buildTable adalah counting sort, bukan comparison sort, karena satu alasan: pass penghitungan atas Counts, Starts, dan Positions stabil secara konstruksi, jadi baris-baris dengan prefix length yang sama mendarat di hasil sesuai urutan deklarasinya, dan itulah persis urutan yang dipakai Annex B.3 untuk membagikan kode. Baris dengan prefix length nol dibuang sebelum penugasan kode, karena B.3 mendefinisikannya sebagai tidak terpakai, bukan sebagai kode satu bit. Dua penjaga duduk di loop yang sama. Pemeriksaan oversubscription menangkap tabel yang panjang-panjangnya mengklaim lebih banyak kode daripada yang bisa ditampung prefix code sedalam itu, yaitu ketaksamaan Kraft yang ditulis sebagai perbandingan integer; tanpa itu, tabel yang berniat jahat menghasilkan kode yang cocok dengan dua baris sekaligus dan decoder memilih baris yang terscan lebih dulu. Batas 32 bit ada karena prefix adalah Cardinal dan matcher di decodeInt mengakumulasi bit ke dalam satu nilai bertipe itu. T.88 mengizinkan prefix yang lebih panjang di atas kertas, PDFlibPas menolaknya dengan pesan eksplisit, dan belum pernah ada encoder sungguhan yang terlihat mengirimkannya. Aritmetika nilainya butuh kehati-hatian yang sama dengan aritmetika kodenya: THuffmanTable.val adalah Int64, dan baris range bawah didekode sebagai val - readBits(32), offset unsigned 32-bit yang dikurangkan dari HTLOW dikurangi satu. Dengan intermediate Integer, pengurangan itu wrap, dan nilai hasil wrap lalu diterima sebagai lebar simbol. Jalur 64-bit menghitung nilai sebenarnya, memeriksanya terhadap rentang 32-bit bertanda, dan melempar error kalau tidak muat, yang mengubah korupsi senyap jadi penolakan eksplisit

Kenapa tabel kustom tidak pernah aktif sebelum 3.539.22?

Dua cacat saling menyembunyikan. Yang pertama adalah bug setter satu baris: TTextRegionHuffmanFlags.setFlags menerima argumennya dengan nama yang sama persis dengan field tempat ia menyimpannya, jadi Self.flagsAsInt := flagsAsInt menugaskan field yang belum diinisialisasi ke dirinya sendiri dan setiap selector terbaca nol, yang membuat text region yang meminta tabel kustom malah lewat tabel standar F, H, dan K. Cacat kedua berarti memperbaiki yang pertama saja tetap akan menghasilkan simbol rusak. Ketika Huffman symbol dictionary menyimpan simbolnya sebagai collective bitmap tak terkompresi, byte terakhir setiap baris bersifat parsial, dan loop penyalinan lama memperlakukan padding, yang berisi jumlah bit valid, sebagai posisi bit valid terendah; baris selebar 63 piksel menyalin satu bit dari byte terakhirnya alih-alih tujuh. Loop yang diperbaiki berjalan for bitPointer := 7 downto ((8 - padding) and 7), dan fixture sintetis berlebar 7 bit dan 9 bit memaku kedua sisi batas byte itu. Dengan selector yang terbaca benar, tabel dibagikan sesuai urutan yang dicantumkan spesifikasi, yang ditetapkan T.88 §7.4.3.1.2 untuk text region sebagai FS, DS, DT, RDW, RDH, RDX, RDY, dan RSIZE, serta §7.4.2.1.1 untuk symbol dictionary sebagai DH, DW, BMSIZE, dan AGGINST. Setiap selector dua bit berarti tabel standar 0 atau 1, dicadangkan untuk 2 pada field yang hanya punya dua tabel standar, dan kustom untuk 3, dan setiap pemilihan kustom mengonsumsi segmen Tables berikutnya di antara segmen yang dirujuk sesuai urutan rujukannya. NextCustomHuffmanTable melakukan penelusuran itu persis, dan melempar missing custom Huffman table reference ketika sebuah region merujuk lebih sedikit tabel daripada yang diminta selector-nya. Satu baris lagi termasuk dalam perbaikan yang sama: Huffman symbol dictionary yang simbol input dan simbol barunya berjumlah satu menghitung symbol code length nol dari rumus log2, sementara varian Huffman format ini menulis setiap symbol ID dengan minimal satu bit, jadi if sdHuffman and (symbolCodeLength = 0) then symbolCodeLength := 1 di TSymbolDictionarySegment mencegah jalur refinement dan aggregate membaca nol bit per symbol ID

Apa jaminan batas segmen?

PDFlibPas memperlakukan panjang data segmen di setiap header sebagai kontrak yang harus dipatuhi kedua arah: segmen tidak boleh membaca melewati akhir yang dideklarasikan, dan tidak boleh berhenti terlalu cepat lalu meninggalkan header berikutnya di offset yang tak bisa diprediksi. Aturan yang lahir dari kontrak itu kecil-kecil satuannya. Panjang data dengan bit 31 menyala adalah penanda panjang tak diketahui dari T.88 §7.2.7, dan handleSegmentDataLength memetakannya ke nilai negatif yang langsung ditolak readSegments alih-alih memindai ke depan mencari terminator. Setiap nomor segmen yang dirujuk harus lebih kecil dari nomor segmen saat ini dan harus sudah ada, jadi rujukan ke depan atau rujukan menggantung gagal sebelum region mana pun mencoba meresolusinya. END_OF_PAGE dan END_OF_FILE harus mendeklarasikan nol byte data. Segmen Profiles (tipe 52) membawa count 32-bit diikuti sebanyak itu identifier 32-bit dan sama sekali tanpa piksel, jadi ia diperiksa sebagai 4 ditambah 4 kali count terhadap panjang yang dideklarasikan, dilewati, dan tetap disimpan di daftar segmen hanya supaya segmen berikutnya masih bisa merujuknya lewat nomor. Identifier profil yang tidak dikenal bukanlah encoding yang tidak dikenal, dan memperlakukannya begitu akan menolak file yang sebenarnya terdekode dengan baik

// TJBIG2StreamDecoder.readSegments, PDFlibJBIG2.pas
DataLength := segmentHeader.getSegmentDataLength;
if DataLength < 0 then
  raise EJBIG2DecodeError.Create(Context +
    'unknown or oversized segment length is not supported');
if DataLength > Length(reader.Data) - reader.bytePointer then
  raise EJBIG2DecodeError.Create(Context + 'truncated segment data');
DataEnd := reader.bytePointer + DataLength;
for I := 0 to noOfReferredToSegments - 1 do
  if (referredToSegments[I] >= segmentHeader.getSegmentNumber) or
     (findSegment(referredToSegments[I]) = nil) then
    raise EJBIG2DecodeError.Create(Context + 'invalid segment reference');
// ... buat objek segmen untuk tipe ini ...
reader.SegmentEnd := DataEnd;
segment.readSegment;
if reader.bytePointer > DataEnd then
  raise EJBIG2DecodeError.Create(Context +
    'decoded data exceeds declared segment length');
if reader.bytePointer < DataEnd then
begin
  reader.bytePointer := DataEnd;   // MMR bisa membiarkan EOFB belum terbaca
  reader.bitPointer := 7;
end;

Ekor loop itulah tempat versi decoder yang lebih lama salah pada region ber-MMR. Decoder MMR tahu dirinya selesai ketika piksel terakhir dari baris terakhir sudah dihasilkan, dan itu bisa terjadi sebelum ia mengonsumsi terminator EOFB yang diletakkan T.88 §6.2.5.7 di akhir data. Kode lamanya mengasumsikan reader sudah berada di header berikutnya, jadi sisa byte terminator diparse sebagai nomor segmen dan stream-nya gagal beberapa byte kemudian dengan error yang menyesatkan. Sekarang akhir yang dideklarasikan yang menang: membaca melewatinya adalah error, berhenti lebih pendek itu normal, dan reader digeser ke DataEnd dengan bit pointer direset supaya header berikutnya dibaca dari tempat yang dikatakan file. Disiplin yang sama muncul di mana pun PDFlibPas memparse struktur PDF yang tidak tepercaya: panjang yang dideklarasikan adalah batasnya, dan decoder tidak pergi mencari batas yang lebih bersahabat

Dari mana jalur refinement Huffman membaca ukuran bitmap-nya?

Sebelum arithmetic decoder mulai, dan dari field yang hanya ada di mode Huffman. Ketika sebuah instance text region membawa refinement (RI bukan nol) dan SBHUFF menyala, T.88 §6.4.11 meminta decoder membaca RDW, RDH, RDX, dan RDY dengan tabel yang dipilihnya, lalu BMSIZE dengan tabel RSIZE, kemudian menyelaraskan ke batas byte, dan baru setelah itu menjalankan decoding refinement generik atas tepat BMSIZE byte. Text region bermode aritmetika tidak punya field macam itu, dan decoder yang memakai satu jalur kode untuk kedua mode akan melewatinya, memulai arithmetic decoder dua byte atau lebih lebih awal, lalu merefine setiap simbol terhadap sampah. Jalur symbol dictionary dengan REFAGG dan satu instance refinement, yang dijelaskan di §6.5.8.2.2, punya field BMSIZE yang sama dengan akibat yang sama. Di PDFlibPas batas atas ukuran itu adalah TStreamReader.SegmentEnd, akhir segmen saat ini seperti yang disetel readSegments, bukan akhir seluruh stream, karena BMSIZE yang hanya bisa dipenuhi dengan meminjam byte dari segmen berikutnya itu malformed, dan memvalidasinya terhadap panjang stream akan membiarkan arithmetic decoder membaca masuk ke header berikutnya. Batas bawahnya dua byte mencerminkan pasangan byte awal yang selalu dikonsumsi arithmetic decoder, dan setelah refinement reader melompat ke RefinementEnd tanpa peduli seberapa jauh arithmetic decoder membaca ke depan, karena posisi akhirnya bukan posisi field berkode Huffman berikutnya

Batas refinement mode Huffman di decoder JBIG2 PDFlibPas: RDW, RDH, RDX, dan RDY didekode dari tabelnya masing-masing, BMSIZE didekode dari tabel RSIZE dan diselaraskan ke byte, lalu arithmetic decoder merefine tepat BMSIZE byte yang ditahan di antara RefinementEnd dan SegmentEnd, menolak ukuran di bawah dua atau yang melewati batas segmen
Batas bawah dua byte mencerminkan pasangan awal yang selalu dikonsumsi arithmetic decoder, batas atasnya adalah segmen saat ini dan bukan seluruh stream, dan setelah refinement reader melompat ke RefinementEnd tanpa peduli pembacaan ke depan
// Decoding text region TJBIG2Bitmap, jalur refinement Huffman
RefinementSize := huffmanDecoder.decodeInt(huffmanRSizeTable).intResult;
huffmanDecoder.consumeRemainingBits;
if (RefinementSize < 2) or
   (RefinementSize > huffmanDecoder.reader.SegmentEnd -
                     huffmanDecoder.reader.bytePointer) then
  raise EJBIG2DecodeError.Create('invalid refinement bitmap size');
RefinementEnd := huffmanDecoder.reader.bytePointer + RefinementSize;
arithmeticDecoder.start;
// ... readGenericRefinementRegion ...
if huffmanDecoder.reader.bytePointer > RefinementEnd then
  raise EJBIG2DecodeError.Create('refinement data exceeds declared size');
huffmanDecoder.reader.bytePointer := RefinementEnd;
huffmanDecoder.reader.bitPointer := 7;

Apa yang sudah diverifikasi, dan apa yang masih ditolak

Sampel yang memulai semua ini, gambar JBIG2 500 kali 473 piksel dengan tabel kustom dan refinement Huffman, sekarang terdekode jadi bitmap dengan nol piksel berbeda dibanding satu decoder independen, dan fixture collective bitmap sintetis berlebar 7 bit serta 9 bit menghasilkan baris yang diharapkan di keduanya. Dua decoder independen yang berbeda pendapat pada sampel aslinya tetap berbeda pendapat satu sama lain; PDFlibPas cocok dengan salah satunya, dan pernyataan yang jujur adalah bahwa output native-nya sepakat dengan satu implementasi independen dan dengan spesifikasi sebagaimana dibaca, bukan bahwa semua decoder di dunia sepakat. Sisi malformed dari suite-nya mencakup:

  • bit flags yang dicadangkan atau nilai selector yang dicadangkan
  • tabel yang terpotong di tengah baris
  • prefix length yang oversubscribed dan prefix lebih dari 32 bit
  • region yang selector-nya meminta lebih banyak tabel kustom daripada yang dirujuknya
  • konfirmasi bahwa output basi dibersihkan setelah decode yang gagal, bukan dibiarkan di tempatnya supaya pemanggil salah mengira itu hasilnya

Tiga batas tetap dipertahankan dengan sengaja. Organisasi stream random-access, di mana semua header segmen mendahului semua data segmen, memunculkan JBIG2 random-access organisation is not supported begitu flag di file header dibaca, karena tidak ada sampel representatif untuk memvalidasinya dan jalur yang setengah jadi lebih buruk daripada penolakan yang punya nama. Tabel kustom dibatasi 65.536 baris dan prefix 32 bit. Dan entry decoding publik, TPLJBIG2Decoder.LoadFromByteArray, mengembalikan bitmap halaman pertama dalam urutan stream lewat getPageAsJBIG2Bitmap(0), yaitu segmen page-information pertama yang ditemui, alih-alih mencari page association nol; stream PDF yang disematkan biasanya menomori satu halamannya dengan 1, dan meminta halaman 0 lewat asosiasi tidak akan menemukan apa pun. Teks kegagalannya mendarat di TPLJBIG2Decoder.LastError, diagnostik internal decoder yang membawa nomor segmen, tipe, dan offset byte dari fault-nya, dan itu bukan hal yang sama dengan TPDFlib.LastErrorCode di level library. Tak satu pun dari ini menyentuh sisi encoding, yang dibahas di catatan soal backend encoder JBIG2 dan cara penautannya; jalur baca harus menerima apa pun yang diputuskan encoder orang lain untuk dikirim, dan ia berbagi aturan dengan sisa tumpukan gambar, termasuk decoder TIFF bawaan beserta penolakannya atas BigTIFF dan tata letak bertile: tolak dengan menyebut namanya, jangan pernah meminjam byte melewati batas yang dideklarasikan, dan jaga aritmetikanya cukup lebar supaya hasil antara yang wrap tidak bisa lolos sebagai jawaban yang valid. Kalau Anda sedang menilai jalur baca JBIG2 native untuk Delphi atau C++Builder, decoder dan sisa penanganan gambarnya didokumentasikan di halaman PDF Library for Delphi