Technical Article

Bug EndDoc Yang Secara Diam-Diam Menonaktifkan Subsetting Font

Hasilan laporan, sematkan font TrueType, dan output akan terbuka dengan benar di setiap viewer yang Anda coba. Glyf-nya benar, teks dapat dipilih, file valid. Satu-satunya hal yang salah adalah ukurannya. Dokumen yang menggunakan beberapa lusin karakter Latin membawa seluruh font sebesar 350 KB. Dokumen yang mencetak paragraf bahasa Mandarin membawa font CJK sebesar 14 MB, bukannya potongan setengah megabyte yang seharusnya dibutuhkan. Tidak ada pengecualian yang muncul, tidak ada peringatan yang dicatat, dan file tersebut lolos validasi. Seperti inilah tampilan langkah finalisasi yang salah urut dari luar: tidak ada yang gagal, dan satu-satunya bukti adalah angka ukuran file yang terlalu besar.

Bug yang menyebabkannya sempat ada di HotPDF selama satu lini rilis dan sejak itu telah diperbaiki. Bug ini layak dibahas bukan sebagai pemberitahuan cacat produk tetapi sebagai pelajaran, karena bentuk kesalahan ini bersifat umum. Setiap mesin dokumen memiliki tahap finalisasi yang mengubah objek tepat sebelum menulisnya, dan kebenaran tahap tersebut sepenuhnya bergantung pada urutan langkah-langkahnya terhadap serialisasi. Jika satu langkah berada di sisi penulisan yang salah, langkah tersebut tidak akan melakukan apa pun secara diam-diam.

Apa yang seharusnya dilakukan oleh subsetting font

Font subset adalah bagian dari file TrueType yang benar-benar digunakan oleh dokumen. ISO 32000-1 §9.9 menjelaskan bagaimana program font tersemat berada di dalam stream yang dirujuk oleh deskriptor font, dan untuk program TrueType stream tersebut adalah /FontFile2 dengan /Length1 yang memberikan jumlah byte yang tidak dikompresi. Subsetting menulis ulang tabel glyf and loca sehingga hanya berisi glyph yang dirujuk dokumen, menomori ulang pengidentifikasi glyph, dan menambahkan awalan nama /BaseFont dengan tag enam huruf seperti ABCDEF+ untuk menandai font sebagai subset, persis seperti yang disyaratkan oleh spesifikasi. Font Latin yang di-subset menjadi sepuluh atau lima belas kilobyte adalah pembeda antara PDF yang ringkas dan PDF yang mengirimkan seluruh jenis font demi satu tajuk saja.

Titik di mana proses ini terjadi sangatlah penting. Subsetting bukanlah transformasi yang Anda terapkan pada byte yang sudah ada di disk. Proses ini mengedit graf objek di dalam memori: memperkecil konten stream /FontFile2, memperbaiki /Length1, dan menulis ulang string /BaseFont. Semua itu harus sudah siap saat serialis berjalan menelusuri graf objek dan mengeluarkan byte. Jika pengeditan dilakukan setelah byte ditulis, maka pengeditan tersebut hanya memperbarui objek yang tidak akan pernah dibaca oleh siapa pun.

Gejalanya, dan mengapa tidak ada peringatan atau keluhan

Perilaku yang dilaporkan adalah font lengkap di dalam output tanpa adanya diagnostik. Pengguna yang mendaftarkan font Unicode TrueType dan menghasilkan dokumen normal menemukan bahwa objek font tersemat memiliki panjang yang sama dengan file .ttf sumber, dan nama /BaseFont tidak membawa awalan subset enam huruf. Output tidak pernah menyusut antara eksekusi yang menggunakan sepuluh glyph dan eksekusi yang menggunakan sepuluh ribu glyph.

Tidak adanya error adalah bagian yang membuat kelas bug ini sangat merugikan. Rutinitas subsetting yang berjalan pada waktu yang salah tetap berjalan. Rutinitas tersebut menelusuri penggunaan codepoint yang terkumpul, membangun subset yang sangat benar, dan menerapkannya pada graf objek di memori. Secara internal pekerjaan selesai dan pemanggilan selesai dengan bersih. Satu-satunya hal yang salah adalah graf objek yang dieditnya bukan lagi hal yang ditulis, karena penulisnya sudah selesai. Dari sudut pandang pemanggil, dokumen diproduksi dan disimpan tanpa kendala, yang merupakan kesan dari kegagalan senyap.

Penyebab utamanya adalah urutan finalisasi

Di HotPDF, pekerjaan penutupan terjadi di dalam EndDoc. Langkah subsetting adalah rutinitas internal bernama BuildAndApplyUnicodeFontSubset. Rutinitas ini membaca kumpulan codepoint yang digunakan per dokumen, disimpan dalam bitmap yang diisi oleh jalur keluaran teks saat glyph ditampilkan, memetakan setiap codepoint yang digunakan melalui tabel codepoint-ke-glyph yang di-cache ke pengidentifikasi glyph nyata, dan menulis ulang program font di sekitar penutupan tersebut. Ketika font Unicode TrueType didaftarkan, jalur keluaran menetapkan bit dalam set codepoints-yang-digunakan untuk setiap karakter yang digambarnya, sehingga pada saat dokumen ditutup, mesin mengetahui dengan tepat glyph mana yang harus dipertahankan oleh subset.

Cacatnya adalah BuildAndApplyUnicodeFontSubset dipanggil setelah SaveToStream atau SaveToFile selesai menserialisasi dokumen. Pengeditan subsetter pada /FontFile2, koreksi /Length1, dan awalan /BaseFont enam huruf semuanya dihitung terhadap graf objek yang sudah diubah menjadi byte. Perbaikannya adalah pengaturan ulang urutan satu baris: pindahkan pemanggilan subset sebelum serialisasi, sehingga penulis mengeluarkan font yang di-subset daripada aslinya. Urutan yang diperbaiki menjalankan subsetter terlebih dahulu dan menserialisasikannya setelahnya.

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.RegisterUnicodeTTF('C:\Fonts\NotoSansSC-Regular.ttf');
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Noto Sans SC', [], 12);
    Pdf.CurrentPage.TextOut(72, 760, 0, '报表标题 Report Heading');
    Pdf.EndDoc;                 // subsetting runs here, before the write
    Pdf.SaveToFile('Report.pdf');
  finally
    Pdf.Free;
  end;
end;

Dengan urutan yang diperbaiki, tidak ada perubahan pada kode pemanggil. Subsetting aktif secara default setelah font Unicode TrueType didaftarkan. Anda mendaftarkan font, memulai dokumen, menggambar, dan mengakhirinya, dan subset dibuat dari glyph yang Anda gunakan sebelum byte keluar dari memori.

Mengapa satu langkah yang salah tempat bisa menjadi satu kategori utuh

Alasan mengapa hal ini layak dipelajari dan bukan sekadar catatan kaki adalah karena EndDoc mengeluarkan daftar langkah penutupan, dan setiap langkah sensitif terhadap posisinya relatif terhadap penulisan. Subsetting font adalah salah satunya. Output PDF/A memerlukan stream /CIDSet yang menghitung dengan tepat pengidentifikasi glyph yang ada dalam subset, batasan yang diterapkan ISO 19005 sehingga validator dapat memastikan program tersemat cocok dengan apa yang diklaim deskriptor font; stream tersebut dikeluarkan pada jendela finalisasi yang sama dan bergantung pada subset yang telah dibuat terlebih dahulu. PDF/UA-1 mensyaratkan, menurut ISO 14289-1 §7.18.3, bahwa setiap halaman yang membawa anotasi mendeklarasikan /Tabs dengan nilai /S, dan rutinitas internal bernama EnsurePDFUATabsOnAnnotatedPages membubuhkan kunci tersebut selama tahap yang sama. Pemeriksaan tujuan output (output-intent) juga berjalan di sana.

Kesalahan urutan yang sama yang menonaktifkan subsetting juga menghilangkan kunci urutan tab PDF/UA pada halaman beranotasi, karena langkah tersebut berada di sisi penulisan yang salah. veraPDF dan PAC melaporkan hilangnya /Tabs /S sebagai pelanggaran terhadap Matterhorn protocol checkpoint 21-001. Jadi, satu pemanggilan yang salah tempat tidak hanya memperbesar ukuran file; hal itu juga secara diam-diam merusak persyaratan kesesuaian aksesibilitas pada saat yang sama, dengan ketiadaan error yang sama. Itulah bahaya dari tahap finalisasi: langkah-langkahnya berbagi prasyarat, dan satu kesalahan urutan dapat merusak beberapa langkah sekaligus sementara setiap pemanggilan tetap mengembalikan status sukses.

Bagaimana kegagalan keluaran senyap sebenarnya ditangkap

Bug yang tidak menimbulkan pengecualian tidak dapat ditangkap dengan menjalankan program. Ini ditangkap dengan memeriksa output dan membandingkannya dengan apa yang seharusnya dihasilkan oleh input. Untuk subsetting font, pemeriksaannya konkret. Bandingkan ukuran file output dengan perkiraan kasar: dokumen yang menyentuh sedikit glyph seharusnya tidak seukuran font penuh. Buka objek font yang disematkan dan baca panjang byte-nya; /FontFile2 yang di-subset untuk font Latin adalah sebagian kecil dari file sumber. Baca nama /BaseFont dan konfirmasikan bahwa awalan enam huruf ada, karena ketidakhadirannya adalah sinyal langsung bahwa tidak ada subset yang diterapkan.

var
  Pdf: THotPDF;
  Output: TMemoryStream;
begin
  Output := TMemoryStream.Create;
  try
    Pdf := THotPDF.Create(nil);
    try
      Pdf.RegisterUnicodeTTF('C:\Fonts\DejaVuSans.ttf');
      Pdf.BeginDoc;
      Pdf.CurrentPage.SetFont('DejaVu Sans', [], 11);
      Pdf.CurrentPage.TextOut(72, 760, 0, 'Subset me');
      Pdf.EndDoc;
      Pdf.SaveToStream(Output);
    finally
      Pdf.Free;
    end;
    // A few glyphs from a ~700 KB face must not yield a multi-hundred-KB stream.
    if Output.Size > 100 * 1024 then
      raise Exception.Create('Font subset did not shrink the output');
  finally
    Output.Free;
  end;
end;

Untuk output PDF/A, pemeriksaannya bahkan lebih tajam lagi, karena validator melakukan pekerjaan tersebut untuk Anda. Tetapkan tingkat kesesuaian dan jalankan hasilnya melalui veraPDF: /CIDSet yang hilang, atau subset yang tidak cocok dengan deskriptor, dilaporkan sebagai klausul yang gagal, bukannya dibiarkan untuk Anda sadari sendiri. Sakelar kesesuaian yang mendorong pekerjaan finalisasi ini adalah properti pada dokumen. PDFACompliance menerima string seperti '2B' untuk PDF/A-2 Level B, dan PDFUACompliance adalah boolean yang mengaktifkan persyaratan tagged-PDF dan urutan tab.

Pdf := THotPDF.Create(nil);
try
  Pdf.PDFACompliance := '2B';     // PDF/A-2 Level B, drives /CIDSet emission
  Pdf.PDFUACompliance := True;    // stamps /Tabs /S on annotated pages
  Pdf.RegisterUnicodeTTF('C:\Fonts\NotoSansSC-Regular.ttf');
  Pdf.BeginDoc;
  Pdf.CurrentPage.SetFont('Noto Sans SC', [], 12);
  Pdf.CurrentPage.TextOut(72, 760, 0, '合规报告');
  Pdf.EndDoc;
  Pdf.SaveToFile('Report_PDFA.pdf');
finally
  Pdf.Free;
end;

Pelajaran rekayasa perangkat lunak

Ada dua aturan yang dihasilkan dari sini. Pertama, setiap langkah finalisasi yang mengubah objek harus berjalan sebelum objek tersebut diserialisasikan, dan tahap penutupan mesin dokumen harus dibaca sebagai pipeline terurut di mana serialisasi adalah tindakan terakhir, bukan salah satu tindakan di antara beberapa tindakan. Kedua adalah hal yang memakan waktu paling banyak di sini: untuk langkah keluaran, tidak adanya error bukanlah bukti keberhasilan. Rutinitas yang membangun subset yang tepat dan menerapkannya pada graf objek yang salah dan sudah ditulis tidak akan melaporkan kesalahan apa pun, karena dari perspektifnya sendiri tidak ada yang salah. Verifikasi harus melihat artefak, bukan kode kembalian (return code). Periksa ukuran output, baca panjang byte font yang disematkan dan awalan /BaseFont-nya, dan biarkan veraPDF menilai output PDF/A di mana /CIDSet yang hilang mengubah kegagalan senyap menjadi kegagalan yang nyata.

Sisi produsen dari penanganan font, bagaimana jenis huruf didaftarkan dan disematkan untuk output laporan, dibahas dalam artikel kami tentang font dan gambar dalam output laporan. Sisi validasi, tempat langkah-langkah finalisasi ini diperiksa terhadap standar, dibahas dalam panduan tentang validasi PDF/A and PDF/UA. Keduanya berpasangan dengan pekerjaan subsetting dan kesesuaian yang dijelaskan di sini, yang dikirimkan sebagai bagian dari HotPDF Component untuk Delphi dan C++Builder bersama dengan API pemuatan, pengeditan, enkripsi, dan penandatanganan yang dibahas di bagian lain di blog ini.