Artikel Teknis

Renderer PDF Tidak Menggambar: Empat Bug Diam di Delphi

Sebuah renderer PDF yang tidak menggambar apa pun biasanya sama sekali tidak punya bug di kode gambarnya. Di HotPDF Component untuk Delphi dan C++Builder, empat defek terpisah membuat halaman rendernya kosong sementara setiap baris log tetap bersih: operand nama yang membawa slash di depan, konkatenasi cm yang terbalik, dan sebuah indeks token yang terbaca nol. Tidak satu pun dari mereka memunculkan exception. Tidak satu pun mencatat log. Content stream ter-tokenisasi dengan benar, operator dispatcher mengenali setiap operator, image XObject didekode menjadi bitmap yang valid, dan kemudian halamannya keluar kosong. Kombinasi itu — sebuah pipeline yang melaporkan sukses di setiap tahap dan tidak menghasilkan apa pun yang terlihat — adalah ciri khas sebuah lookup atau indeks yang diam-diam meleset, bukan gagal. Ini adalah post-mortem dari satu keluarga bug semacam itu, dan dari disiplin pengujian yang membiarkannya bertahan selama 38 rilis

Kenapa renderer PDF sama sekali tidak menggambar apa pun?

Karena sebuah resource lookup yang gagal dalam renderer PDF tidak bisa dibedakan dari halaman kosong. Operand nama content-stream dan key resource-dictionary adalah dua ruang string yang berbeda, dan HotPDF membandingkan keduanya tanpa normalisasi. Tokenizer membaca /Im0 dan mempertahankan solidus-nya, karena memang begitulah bentuk token itu; dictionary /Resources /XObject yang dimuat menyimpan key-nya sebagai Im0, karena parser membuang delimiter saat membangun key dictionary. Setiap FindValue terhadap sebuah nama operand karena itu selalu mengembalikan -1. Radius ledakannya lebih luas daripada sekadar gambar. ISO 32000-1 §8.9 mencakup Do, §8.4 mencakup gs dan lookup /ExtGState-nya, §8.6 mencakup cs dan CS, dan §8.7.4.3 mencakup sh. Kelima operator itu mengunci sub-dictionary resource-nya dengan operand mentah, sehingga kelimanya meleset. Colour space bernama jatuh kembali ke DeviceGray, yang mengubah 1 scn menjadi tinta putih di atas halaman putih. Image XObject sama sekali tidak pernah dilukis — jalur gambar bitmap, dalam praktiknya, tidak pernah bekerja sejak hari ia dirilis. Perbaikannya adalah sebuah helper level-unit yang diterapkan pada setiap lookup yang dikunci operand, yang merupakan satu-satunya cara agar konvensi ini tidak melenceng lagi

// Page content stream, the ordinary image-placement idiom:
//   q
//   /GS0 gs
//   200 0 0 120 60 400 cm
//   /Im0 Do
//   Q
// The operand token is '/Im0'. The resource dictionary key is 'Im0'.

function HPDFStripNameSlash(const N: AnsiString): AnsiString;
begin
  Result := N;
  if (Result <> '') and (Result[1] = '/') then
    Delete(Result, 1, 1);
end;

// Every resource lookup keyed by an operand name goes through the helper.
Name := HPDFStripNameSlash(Name);
XObjIdx := FPageResources.FindValue('XObject');
if XObjIdx < 0 then
  Exit;
// The /XObject sub-dictionary may itself be an indirect reference.
XObjDict := FAccess.ResolveDictionary(FAccess.Context,
  FPageResources.GetIndexedItem(XObjIdx));
if XObjDict = nil then
  Exit;

Ada miss kedua yang terkait, satu lapisan lebih dalam. Renderer hanya memiliki resolver bertipe untuk stream dan dictionary saja, sehingga sebuah indirect reference yang menunjuk ke objek array level-atas — /CS0 5 0 R yang umum dengan [/Separation ...] di ujung lainnya — meresolusi menjadi nil lewat keduanya dan jatuh kembali ke link yang tidak teresolusi. Menambahkan sebuah generic object resolver memperbaiki colour space bernama dan function array dalam satu langkah. Bila Anda sedang menghubungkan shading dictionary, disiplin resolusi yang sama berlaku untuk jalur axial dan radial shading, tempat entri /Function sangat sering bersifat indirect

Operator cm dan sebuah konkatenasi yang ditulis terbalik

Defek kedua menempatkan gambar kira-kira seratus ribu piksel di luar halaman, yang terlihat persis seperti tidak menggambarnya sama sekali. ISO 32000-1 §8.3.4 mendefinisikan transformasi PDF dengan row vector, dan operator cm mengonkatenasi matriks operand M ke matriks transformasi saat ini sebagai M × CTM — M berlaku lebih dulu, CTM yang sudah ada berlaku sesudahnya. HotPDF menyusun matriks lewat HPDFMatMul(A, B), yang menerapkan B sebelum A. Pemanggilan yang benar karena itu meneruskan CTM lama sebagai A. Kode yang dirilis meneruskan matriks operand sebagai A, menghasilkan CTM × M

Urutan terbalik tidak berbahaya untuk satu cm tunggal dan menjadi bencana untuk idiom dua-langkah standar. Tempatkan sebuah gambar dengan 1 0 0 1 x y cm diikuti w 0 0 h 0 0 cm dan cascade yang benar menskalakan unit square dengan (w, h) lalu menerjemahkannya dengan (x, y). Di bawah cascade yang terbalik, translasi masuk lebih dulu dan skala mengalikannya, sehingga sebuah gambar yang nominalnya di (60, 400) diskalakan menjadi 200 kali 120 mendarat di (12000, 48000). Clip test di puncak blit menolaknya, blit-nya dilewati, dan tidak ada apa pun di mana pun yang melaporkan masalah

// HPDFMatMul(A, B) applies B first, then A.
// ISO 32000-1 cm semantics: new CTM = M x CTM, so M must be B.

// Wrong, and shipped for 38 versions:
GS.CTM := HPDFMatMul(HPDFMatFromOps(NumAt(6), NumAt(5), NumAt(4),
                                    NumAt(3), NumAt(2), NumAt(1)), GS.CTM);

// Correct:
GS.CTM := HPDFMatMul(GS.CTM, HPDFMatFromOps(NumAt(6), NumAt(5), NumAt(4),
                                            NumAt(3), NumAt(2), NumAt(1)));

Yang membuat kasus ini instruktif adalah bahwa file sumber yang sama sudah memuat urutan yang benar. Entri /Matrix pada sebuah Form XObject memiliki komposisi terbalik yang sama, tetapi jalur glyph Type 3 dan jalur outline glyph yang di-embed keduanya benar sejak awal, karena penempatan glyph runtuh secara terlihat ke titik origin ketika Anda membaliknya dan seseorang sudah terpaksa memperbaikinya. Dua konvensi hidup berdampingan dalam satu unit selama tiga lusin rilis, masing-masing benar dalam fungsinya sendiri, dan tidak ada reviewer yang menyadarinya karena kedua call site tidak terlihat salah bila dilihat sendiri-sendiri

Apa yang terjadi ketika sebuah indeks token meleset satu?

Anda mendapat dua belas operator yang ditangani secara sintaksis tapi mati secara semantik. Accessor operand pada renderer adalah NumAt(Back), yang membaca Tokens[OpIndex - Back], dan OpIndex adalah indeks dari token operator itu sendiri. Sebuah operator ber-operand-tunggal karena itu menemukan angkanya pada back 1. Dua belas di antaranya ditulis sebagai NumAt(0), yang membaca token operator, gagal pada pemeriksaan jenis ctOperandNumber, dan mengembalikan default nol. Daftarnya adalah Tc, Tw, Tz, TL, Ts, dan Tr dari operator text state ISO 32000-1 §9.3, ditambah w, J, j, M, ri, dan i dari operator graphics state §8.4.3. Character dan word spacing menjadi no-op, horizontal scaling tidak pernah diterapkan, leading tetap nol sehingga T* tidak pernah memajukan sebuah baris, text rise tidak melakukan apa-apa, render mode selalu fill, dan setiap stroke di setiap dokumen keluar sebagai hairline 1-piksel apa pun lebar garis yang dideklarasikan. Operator multi-operand seperti m, rg, dan Tm memakai NumAt(1..6) dan semuanya benar, sehingga seorang reviewer yang memindai fungsi tersebut melihat sederet aritmatika indeks yang tampak masuk akal dengan dua belas entri salah tertanam di dalamnya

function NumAt(Back: Integer): Double;
begin
  Result := 0;
  if (OpIndex - Back >= 0)
    and (Tokens[OpIndex - Back].Kind = ctOperandNumber) then
    Result := Tokens[OpIndex - Back].NumValue;
end;

// OpIndex addresses the operator token, so a lone operand sits at back 1.
else if Op = 'Tc' then GS.Text.CharSpace := NumAt(1)   // previously NumAt(0)
else if Op = 'TL' then GS.Text.Leading   := NumAt(1)   // previously NumAt(0)
else if Op = 'Tr' then GS.Text.RenderMode := Round(NumAt(1))
else if Op = 'w'  then GS.LineWidth      := NumAt(1)   // previously NumAt(0)

Kenapa test suite tetap hijau selama 38 versi?

Karena assertion-nya terlalu lemah untuk membedakan halaman yang terender dari yang terender sebagian. Smoke test rendering menegaskan hal-hal seperti bitmap output tidak seluruhnya hitam, atau halaman tidak kosong, atau digest gambar bukan nol. Setiap hal itu tetap benar ketika teks terender tapi gambar tidak. Teks tergambar dengan baik, sehingga frame buffer tidak pernah seragam, digest-nya tidak pernah nol, dan suite melaporkan sukses padahal seluruh pipeline gambar sebenarnya adalah dead code. Assertion yang lemah menggoda untuk grafis justru karena assertion yang kuat terlihat rapuh. Tidak ada yang mau sebuah test yang gagal ketika tepi anti-aliasing bergeser satu piksel, sehingga langkah mundur yang wajar adalah menegaskan sesuatu yang tidak mungkin dilanggar oleh perubahan yang wajar mana pun — dan langkah mundur itu membawa Anda ke predikat yang juga tidak bisa dilanggar oleh perubahan yang tidak wajar sekalipun. Sebuah test colour-space separation menegaskan output bisa dibedakan dari hitam; abu-abu di atas putih lolos, begitu pula putih di atas putih. Test itu tidak mengukur apakah warna yang benar telah dilukis. Ia mengukur apakah ada apa pun yang terjadi sama sekali di canvas

Bagaimana Anda menulis sebuah rendering assertion yang benar-benar bisa gagal?

Hitung piksel dengan warna yang diharapkan, dalam jumlah yang diharapkan, dan biarkan posisi serta ukuran mengalir dari hitungan itu. Disiplin penggantinya adalah sebuah PDF minimal yang dibuat tangan, satu fakta visual per berkas, dan sebuah assertion atas berapa banyak piksel yang jatuh dalam toleransi dari sebuah RGB triple tertentu. Sebuah gambar merah murni berukuran 200 kali 120 yang ditempatkan pada offset yang diketahui harus menghasilkan kira-kira 24000 piksel merah. Jika resource lookup meleset, hitungannya 0. Jika cascade cm terbalik, hitungannya 0. Jika gambar dirender dalam colour space yang salah, hitungannya 0. Satu angka menangkap ketiganya, dan pita toleransinya menyerap noise anti-aliasing yang tadinya membuat orang enggan melakukan perbandingan persis

function CountPixelsNear(Bmp: TBitmap; R, G, B, Tol: Integer): Integer;
var
  X, Y: Integer;
  C: TColor;
begin
  Result := 0;
  for Y := 0 to Bmp.Height - 1 do
    for X := 0 to Bmp.Width - 1 do
    begin
      C := Bmp.Canvas.Pixels[X, Y];
      if (Abs(GetRValue(C) - R) <= Tol)
        and (Abs(GetGValue(C) - G) <= Tol)
        and (Abs(GetBValue(C) - B) <= Tol) then
        Inc(Result);
    end;
end;

// A 200x120 red image placed at 60,400 must paint about 24000 red pixels.
Check(CountPixelsNear(Bmp, 255, 0, 0, 12) > 20000,
  'image XObject was never drawn');

Empat smoke test ditulis ulang dengan cara ini — sebuah Type 4 tint transform, sebuah penempatan Do gambar, sebuah kasus visibilitas optional-content, dan sebuah mode stroke Tr — dan di antara mereka semua bug tersebut terungkap. Itulah pelajaran sesungguhnya, dan ia berlaku jauh melampaui codebase ini: dalam sebuah rendering pipeline, assertion-nya harus menyebut warnanya. Apa pun yang lebih lunak dari itu hanyalah pemeriksaan bahwa renderer-nya berjalan, bukan pemeriksaan bahwa ia benar-benar menggambar. Bila Anda sedang membangun harness page-to-bitmap Anda sendiri, penjelasan lengkap rasterisasi halaman adalah tempat yang wajar untuk memasang helper penghitung piksel pada regresi pertama Anda

Batasan yang jujur

Ada dua batasan yang layak dinyatakan dengan terus terang. Render mode text clipping 4 hingga 7 digambar sebagai mode fill atau stroke dasarnya, karena renderer tidak memodelkan clip path yang terakumulasi dari outline glyph; dokumen yang mengandalkan clipping berbentuk-teks akan merender teksnya, bukan artwork yang ter-clip di baliknya. Dan disiplin penghitungan piksel yang dijelaskan di sini adalah sebuah teknik smoke-test, bukan sebuah conformance suite — ia membuktikan bahwa sebuah fakta visual tertentu sampai ke frame buffer, yang merupakan bar yang jauh lebih rendah daripada membuktikan output cocok dengan sebuah rasteriser referensi. Namun, itu persis bar yang gagal dilewati keempat bug ini selama tiga tahun rilis

Renderer yang dibahas di sini hadir sebagai bagian dari HotPDF Component standar untuk Delphi dan C++Builder; halaman produk memuat referensi API page-rendering lengkap, termasuk entry point bitmap cache dan background prefetch