Artikel Teknis

Mengukur Teks PDF untuk Tata Letak dan Bungkus Kata di Delphi

Panggilan yang menempatkan teks pada halaman PDF sangat sederhana. Anda memberikan AddText sebuah string, sebuah font, sebuah ukuran, dan sebuah posisi, lalu glyph pun muncul. Yang tidak dilakukannya adalah memberi tahu Anda seberapa lebar string tersebut nantinya setelah digambar, dan ia tidak memecah string panjang menjadi beberapa baris. Satu panggilan tunggal menggambar satu rangkaian (run) teks pada satu posisi. Jika rangkaian tersebut lebih lebar dari kolom yang Anda maksudkan untuk memuatnya, ia begitu saja melewati batas tepi, dan tidak ada apa pun dalam panggilan penggambaran yang memperingatkan Anda. Begitu Anda menginginkan sebuah paragraf alih-alih sekadar satu label, bagian yang hilang adalah lebar sebuah string dalam font dan ukuran yang dipilih, diukur sebelum Anda menempatkannya di halaman

Inilah masalah tata letak klasik. Untuk membungkus sebuah paragraf ke dalam sebuah kolom, Anda harus tahu, kata demi kata, berapa banyak ruang horizontal yang akan diambil oleh setiap baris kandidat, dan Anda harus mengetahuinya sebelum menggambar apa pun. Pembungkusan kata (word wrap) adalah sebuah loop pengukuran yang dibungkus di sekitar sebuah panggilan penggambaran, dan sebuah binding yang hanya menggambar memberi Anda separuh keduanya saja. Dukungan pengukuran teks pada komponen PDFium menutup celah tersebut dengan dua fungsi, MeasureText dan MeasureTextWidth, yang melaporkan luas area teks yang akan dihasilkan tanpa menorehkan tanda apa pun pada halaman mana pun

Mengapa pengukuran berupa class helper, bukan metode baru pada TPdf

Dukungan pengukuran hadir sebagai class helper Delphi untuk TPdf, berada dalam unit-nya sendiri, alih-alih sebagai metode baru yang ditambahkan ke dalam kelas TPdf. Class helper adalah sebuah fitur bahasa yang memungkinkan Anda melampirkan metode ke sebuah tipe yang sudah ada dari luar deklarasinya. Begitu unit tersebut berada dalam scope, metode baru tersebut dipanggil persis seolah-olah mereka adalah milik kelas itu, sehingga sebuah metode helper terbaca sebagai Pdf.MeasureTextWidth(...) tanpa objek terpisah yang perlu dibuat atau dioper

Alasan untuk melapisinya dengan cara ini adalah pemisahan. Tipe inti TPdf tetap seperti apa adanya, tanpa ada field yang ditambahkan dan tanpa signature yang ada tersentuh, sehingga sebuah proyek yang tidak pernah memerlukan tata letak tidak pernah membawa kode pengukuran. Sebuah proyek yang memang memerlukannya menambahkan satu unit ke klausa uses dan metode-metode tersebut pun aktif. Kemampuan menjadi opt-in pada granularitas satu unit tunggal, yang merupakan cara paling bersih untuk memperluas sebuah tipe yang bukan milik Anda atau yang tidak ingin Anda ganggu

uses
  PDFium, FPdfView, FPdfEdit,
  FPdfMeasure;   // unit helper; membawa MeasureText ke dalam scope pada TPdf

// Dengan unit berada dalam scope, metode-metode terbaca sebagai anggota TPdf:
var
  W, H: Double;
begin
  Pdf.MeasureText('Subtotal', 'Helvetica', 11, W, H);
  // W dan H sekarang adalah lebar dan tinggi hasil render dalam satuan pengguna PDF
end;

Mengukur tanpa menyentuh halaman

Pengukuran harus bebas dari efek samping. Ia harus melaporkan sebuah lebar tanpa meninggalkan apa pun, karena Anda memanggilnya berkali-kali saat memutuskan sebuah tata letak dan halaman harus terlihat persis seperti seharusnya jika Anda tidak pernah mengukur sama sekali. Teknik yang membuat ini mungkin adalah membangun sebuah objek teks, menanyakan ukurannya, dan membuangnya sebelum ia pernah dilampirkan ke sebuah halaman

Urutannya adalah empat panggilan PDFium. FPDFPageObj_NewTextObj membuat sebuah objek teks terhadap dokumen, dengan nama font dan ukuran yang diberikan. FPDFText_SetText mengatur string yang dibawa oleh objek tersebut. FPDFPageObj_GetBounds membaca kembali kotak pembatas (bounding box) objek tersebut. FPDFPageObj_Destroy membebaskan objek tersebut. Yang krusial, tidak ada apa pun dalam urutan tersebut yang memanggil API penyisipan halaman. Objek tersebut dibuat, ditanyai, dan dihancurkan secara terisolasi, sehingga dokumen tidak berubah saat fungsi tersebut kembali. Ini adalah sebuah probe sekali pakai yang satu-satunya keluarannya adalah empat angka kotak pembatasnya

Inilah cara yang andal untuk melakukannya karena PDFium tidak mengekspos lebar advance per-glyph yang praktis yang bisa Anda jumlahkan sendiri. Metrik glyph bergantung pada program font, pada encoding, dan pada bagaimana PDFium memuat face font tersebut, dan tidak ada panggilan publik yang memberi Anda advance dari setiap karakter dalam sebuah string. Kotak pembatas dari sebuah objek teks yang sesungguhnya, di sisi lain, dihitung oleh mesin yang sama yang akan menata glyph untuk digambar, sehingga ia mencerminkan luas area hasil render yang sesungguhnya alih-alih sebuah perkiraan. Membangun satu objek sekali pakai dan membaca batasnya adalah pengukuran paling andal yang dapat diberikan oleh pustaka tersebut

Diagram empat pemanggilan PDFium di balik MeasureText di Delphi, menyelidiki objek teks sekali-pakai tanpa menyentuh halaman
MeasureText membangun objek teks sekali pakai, membaca bounding box-nya, dan menghancurkannya, sehingga pengukuran membiarkan dokumen PDF tak tersentuh
// Bentuk dari MeasureText, dinyatakan berdasarkan panggilan PDFium yang telah diverifikasi.
// Sebuah objek teks dibangun, diukur, dan dihancurkan; tidak ada halaman yang terlibat.
procedure TPdfMeasureHelper.MeasureText(const Text, Font: WString;
  FontSize: Single; out Width, Height: Double);
var
  TextObject: FPDF_PAGEOBJECT;
  L, B, R, T: Single;
begin
  Width  := 0;
  Height := 0;
  if Self.Document = nil then
    Exit;
  TextObject := FPDFPageObj_NewTextObj(Self.Document,
    FPDF_BYTESTRING(AnsiString(Font)), FontSize);
  if TextObject = nil then
    Exit;
  try
    if FPDFText_SetText(TextObject, FPDF_WIDESTRING(WideString(Text))) = 0 then
      Exit;
    if FPDFPageObj_GetBounds(TextObject, L, B, R, T) <> 0 then
    begin
      Width  := R - L;
      Height := T - B;
    end;
  finally
    FPDFPageObj_Destroy(TextObject);   // probe dibuang, halaman tidak tersentuh
  end;
end;

Koordinat dan satuan hasil

Kotak pembatas kembali sebagai empat tepi, kiri, bawah, kanan, dan atas, dan kedua dimensi tersebut diperoleh melalui pengurangan. Lebar adalah kanan dikurangi kiri dan tinggi adalah atas dikurangi bawah. Keduanya dinyatakan dalam satuan pengguna PDF, di mana satu satuan adalah satu per tujuh puluh dua inci, ruang koordinat yang sama tempat Anda memposisikan teks pada halaman. Tidak ada satuan perangkat tersembunyi dan tidak ada piksel yang terlibat pada tahap ini. Lebar 36 berarti setengah inci halaman, berapa pun resolusi render yang akhirnya digunakan

Sumbu vertikal berjalan sebagaimana PDF mendefinisikannya, dengan Y bertambah ke atas, itulah sebabnya tinggi adalah atas dikurangi bawah dan bukan sebaliknya. Detail tersebut penting ketika Anda memajukan sebuah kursor menyusuri kolom ke bawah. Anda mengukur tinggi sebuah baris, lalu menguranginya dari baseline saat ini untuk menemukan baris berikutnya, karena bergerak ke bawah halaman berarti bergerak menuju Y yang lebih kecil. Jika tujuan Anda adalah sebuah layar alih-alih kertas, Anda mengonversi satuan pengguna menjadi piksel perangkat dengan resolusi tampilan: sebuah nilai dalam satuan pengguna dikalikan DPI dan dibagi 72 menghasilkan piksel, sehingga sebuah lebar kolom yang Anda atur dalam point dapat dicocokkan dengan sebuah rangkaian teks yang telah diukur sebelum Anda memutuskan ke mana pemotongan baris terjadi

Yang terjadi pada input yang merosot (degenerate)

Fungsi-fungsi tersebut ditulis untuk gagal secara diam-diam. Jika tidak ada dokumen yang terbuka, atau jika objek teks tidak dapat dibuat, hasilnya adalah luas area nol alih-alih sebuah exception yang dimunculkan. Lebar dan tinggi diinisialisasi ke nol di bagian atas dan hanya ditimpa setelah sebuah kotak pembatas berhasil dibaca kembali. String kosong, dokumen yang hilang, sebuah font yang tidak dapat diselesaikan pustaka menjadi objek, masing-masing dari ini mengembalikan nol alih-alih memunculkan exception

Pilihan tersebut menjaga sebuah loop pengukuran tetap sederhana, karena sebuah loop yang berjalan melewati ribuan kata bukanlah tempat untuk penanganan exception pada setiap iterasi. Biayanya adalah pemanggil yang membawa pemeriksaan tersebut. Lebar nol adalah sebuah sentinel, bukan sebuah fakta tentang teks tersebut, sehingga kode yang membagi dengan lebar yang terukur atau mengasumsikan nilai positif harus menjaga diri terhadap nol sebelum mempercayainya. Perlakukan nol sebagai "tidak dapat diukur" dan kontraknya jelas; abaikan itu dan sebuah input yang merosot secara diam-diam menjadi sebuah tata letak dengan kolom glyph yang saling bertindihan

Pembungkusan kata rakus (greedy) yang dibangun di atas pengukuran

Dengan sebuah fungsi lebar di tangan, pembungkusan kata adalah sebuah loop rakus (greedy) yang singkat. Anda memecah paragraf menjadi kata-kata, menyimpan sebuah baris saat ini, dan untuk setiap kata Anda mengukur seperti apa baris tersebut jika Anda menambahkan kata itu. Selama baris percobaan masih muat dalam lebar kolom Anda terus menambahkan; ketika baris tersebut akan meluap Anda mengalirkan (flush) baris saat ini dengan AddText dan memulai baris baru dengan kata yang tidak muat tersebut. Akumulasi tersebut dilakukan sepenuhnya dengan MeasureTextWidth, dan satu-satunya hal yang pernah mencapai halaman adalah sebuah baris yang telah Anda konfirmasi muat

Diagram loop word wrap rakus di Delphi yang mengukur baris percobaan dengan MeasureTextWidth dan memenggal di kata terakhir yang muat
Loop wrap greedy mengukur setiap baris uji terhadap lebar kolom dan hanya membuang baris yang terkonfirmasi muat
procedure WrapParagraph(Pdf: TPdf; const Para, Font: WString;
  FontSize: Single; X, TopY, ColumnWidth, LineHeight: Double);
var
  Words: TArray<string>;
  Line, Trial: WideString;
  I: Integer;
  Y: Double;
begin
  Words := string(Para).Split([' ']);
  Line  := '';
  Y     := TopY;
  for I := 0 to High(Words) do
  begin
    if Line = '' then
      Trial := Words[I]
    else
      Trial := Line + ' ' + Words[I];
    // Ukur baris kandidat sebelum menggambar apa pun.
    if (Line <> '') and (Pdf.MeasureTextWidth(Trial, Font, FontSize) > ColumnWidth) then
    begin
      Pdf.AddText(Line, Font, FontSize, X, Y);   // alirkan baris yang muat
      Y    := Y - LineHeight;                    // Y berkurang seiring turun ke bawah
      Line := Words[I];                          // kata yang meluap memulai baris berikutnya
    end
    else
      Line := Trial;
  end;
  if Line <> '' then
    Pdf.AddText(Line, Font, FontSize, X, Y);      // alirkan baris terakhir
end;

Loop tersebut mengukur baris percobaan alih-alih mengukur setiap kata dan menjumlahkannya, karena lebar sebuah baris bukanlah jumlah dari lebar kata-katanya. Spasi di antara kata-kata turut berkontribusi, dan sebuah rangkaian teks yang terukur menangkap hal itu secara langsung. Aturan rakus, muat sebanyak mungkin kata yang diizinkan kolom dan pecah pada kata terakhir yang muat, adalah aturan yang sama yang mengisi celah antara sebuah AddText mentah dan sebuah paragraf sungguhan. Panggilan penggambaran itu tidak pernah menjadi bagian yang sulit. Pengukuran yang harus mendahuluinya itulah yang sulit, dan itulah persis yang disediakan oleh helper tersebut

Di mana ini cocok digunakan

Pengukuran adalah lapisan antara menghasilkan konten dan me-render-nya, sehingga ia berpasangan secara alami dengan sisa alur kerja dokumen dari awal (from-scratch). Jika Anda sedang menyusun halaman dan menempatkan teks sejak awal, dasar-dasarnya ada di membuat dokumen PDF dari awal dengan komponen PDFium di Delphi, di mana AddText dan pengaturan halaman dibahas secara lengkap. Ketika font yang Anda ukur sama pentingnya dengan string itu sendiri, karena metrik bergantung pada face font, menganalisis properti font PDF dengan komponen PDFium di Delphi menunjukkan bagaimana pustaka tersebut melaporkan informasi font yang mendorong kotak-kotak pembatas tersebut. Keduanya dibangun di atas binding yang sama, PDFium Component untuk Delphi dan Lazarus, tempat helper pengukuran dikirimkan bersama API dokumen, halaman, dan teks yang dijelaskan di seluruh blog ini