Artikel Teknis

Seleksi Baris Teks Visual PDF dengan Char Box PDFium di Delphi

Text page PDF mengekspos karakter dan kotak, tidak pernah baris. PDFium Component membangun baris visual dengan mengelompokkan kotak karakter yang pusat vertikalnya jatuh dalam setengah tinggi karakter seed, memindai keluar dari karakter yang diklik hingga toleransinya terlampaui. Setiap jalur seleksi di viewer memanggil satu helper itu, jadi mouse, keyboard, dan kode sepakat

Gejala yang mengirim Anda mencari ini spesifik dan tidak menyenangkan. Pengguna triple-click sebuah paragraf dalam laporan dua-kolom dan mendapat separuh halaman. Atau mereka triple-click sel tabel dan seleksinya menelan seluruh baris ditambah nomor halaman di footer. Viewer-nya tidak rusak; ia sedang menjawab pertanyaan yang tidak bisa dijawab file itu. Tidak ada baris dalam PDF untuk dipilih, dan implementasi mana pun yang berpura-pura sebaliknya sedang menebak. Artikel ini soal membuat tebakan itu disengaja dan membuatnya konsisten. Jika yang sebenarnya Anda butuhkan adalah menarik teks keluar dari dokumen, lihat mengekstrak teks dari dokumen PDF dengan PDFium; jika Anda sedang menyusun teks dan butuh lebar, lihat pengukuran teks dan word wrap. Di sini subjeknya lebih sempit: memutuskan di mana sebuah baris visual mulai dan berakhir, dan memilih persis itu

Kenapa text page PDF tidak punya objek baris?

Karena content stream PDF menggambarkan menggambar, bukan struktur. ISO 32000-1 §9.4 mendefinisikan objek teks sebagai pasangan BT / ET yang berisi operator positioning dan showing. Operator positioning dari §9.4.2 (Td, TD, Tm, T*) menggerakkan text matrix di sekitar halaman, dan operator showing dari §9.4.3 (Tj, TJ, ', ") mengecat glyph di mana pun matriks itu sedang menunjuk. Tidak ada apa pun dalam model itu yang berkata "rangkaian glyph ini adalah sebuah baris". Baris adalah apa yang dilihat manusia setelah pengecatan selesai

Producer membuat ini lebih buruk dengan cara yang tidak bisa Anda kendalikan. Paragraf justified bisa diterbitkan sebagai satu array TJ per baris, atau sebagai satu Tj per kata dengan Tm eksplisit sebelum masing-masing, atau sebagai satu operasi show tunggal dengan penyesuaian kerning yang membawa spasinya. Layout dua-kolom bisa menerbitkan kolom kiri dari atas ke bawah lalu kolom kanan, atau bisa meng-interleave-kan mereka jika producer menelusuri daftar objek internalnya sendiri dalam urutan berbeda. Urutan karakter yang diberikan PDFium kepada Anda mengikuti content stream, dan content stream-nya mengikuti apa pun yang dirasa cocok oleh aplikasi pembuatnya. Jadi dua fungsi yang sebenarnya Anda dapatkan adalah FPDFText_CountChars, yang melaporkan berapa banyak karakter yang dipegang halaman, dan FPDFText_GetCharBox, yang mengembalikan bounding box satu karakter dalam page space. Itu seluruh kosakata mentahnya. Segalanya di atasnya, kata, baris, paragraf, kolom, adalah inferensi yang Anda lakukan atas geometri

Kenapa deteksi CR dan LF adalah test yang salah?

Karena karakter yang akan Anda uji tidak hadir secara andal, dan saat mereka hadir mereka tidak andal milik Anda. PDFium menyuntikkan karakter sintetis ke dalam text page agar teks yang diekstrak bisa dibaca: sebuah spasi di mana dua run terpisah secara visual, sebuah CR atau LF di mana run berikutnya mulai pada baseline baru. FPDFText_IsGenerated ada persis agar Anda bisa membedakan mereka dari karakter yang berasal dari file, dan PDFium Component mengeksposnya sebagai properti CharacterGenerated

Pisahkan berdasarkan karakter-karakter itu dan Anda mewarisi setiap keputusan yang dibuat PDFium saat mensintesisnya. Line break keras di dalam paragraf yang wrap dan wrap lunak terlihat identik setelah sintesis. Baris tabel yang diterbitkan producer sel demi sel mungkin tidak mendapat break sama sekali di antara sel terakhir dan sel pertama baris berikutnya, karena baseline-nya kebetulan cukup dekat. Sementara itu heading diikuti body text pada ukuran berbeda mungkin mendapat dua break di mana manusia melihat satu. Karakter yang dihasilkan itu adalah kemudahan rendering untuk ekstraksi halaman penuh; mereka bukan model baris, dan mereka mendegradasi persis pada dokumen tempat seleksi paling penting

Mengelompokkan kotak karakter berdasarkan pusat vertikal

Sinyal yang andal adalah geometri. Ambil karakter yang diklik pengguna sebagai seed, hitung pusat vertikal kotaknya, dan berjalan keluar di kedua arah selama kotak tetangga menjaga pusat vertikalnya dalam toleransi. PDFium Component memakai setengah tinggi kotak seed sebagai toleransi itu, dengan lantai 0.5 unit halaman sehingga kotak degeneratif, sebuah titik, spasi tipis, glyph dengan kotak tinggi hampir-nol, tidak meruntuhkan toleransinya menjadi tidak ada apa-apa dan memotong baris setelah satu karakter

function TPdfView.LineRangeAt(TxtPage: FPDF_TEXTPAGE; CharIndex: Integer;
  out StartIndex, Count: Integer): Boolean;
var
  Lo, Hi, Total: Integer;
  SeedBox, Box: TPdfRectangle;
  SeedYMid, BoxYMid, HalfH: Double;
begin
  Result := False;
  StartIndex := -1;
  Count := 0;
  Total := FPDFText_CountChars(TxtPage);
  if (CharIndex < 0) or (CharIndex >= Total) then
    Exit;

  if FPDFText_GetCharBox(TxtPage, CharIndex, SeedBox.Left, SeedBox.Right,
    SeedBox.Bottom, SeedBox.Top) = 0 then
    Exit;
  SeedYMid := (SeedBox.Top + SeedBox.Bottom) / 2;
  HalfH := Abs(SeedBox.Top - SeedBox.Bottom) / 2;
  if HalfH < 0.5 then          // floor for degenerate boxes
    HalfH := 0.5;

  Lo := CharIndex;
  Hi := CharIndex;
  while Lo > 0 do
  begin
    if FPDFText_GetCharBox(TxtPage, Lo - 1, Box.Left, Box.Right,
      Box.Bottom, Box.Top) = 0 then
      Break;
    BoxYMid := (Box.Top + Box.Bottom) / 2;
    if Abs(BoxYMid - SeedYMid) > HalfH then
      Break;
    Dec(Lo);
  end;
  while Hi < Total - 1 do
  begin
    if FPDFText_GetCharBox(TxtPage, Hi + 1, Box.Left, Box.Right,
      Box.Bottom, Box.Top) = 0 then
      Break;
    BoxYMid := (Box.Top + Box.Bottom) / 2;
    if Abs(BoxYMid - SeedYMid) > HalfH then
      Break;
    Inc(Hi);
  end;
  StartIndex := Lo;
  Count := Hi - Lo + 1;
  Result := True;
end;

Tiga detail dalam loop itu memiliki tempatnya. Toleransinya diturunkan dari seed alih-alih dari konstanta, jadi heading 24pt mendapat band lebar dan teks footnote 7pt mendapat band sempit, dan tidak satu pun mencuri karakter dari yang lain. Perbandingannya memakai pusat vertikal alih-alih baseline atau bagian atas kotak, yang menjaga superscript, run ukuran-berbeda inline, atau kalimat font-campuran tetap pada baris yang sama dengan tetangganya. Dan FPDFText_GetCharBox yang gagal menghentikan pemindaian alih-alih dilewati, karena karakter tanpa geometri yang bisa diambil tidak memberi Anda bukti ke arah mana pun, dan melanjutkan melewatinya akan membiarkan pemindaian melompati batas sungguhan berdasarkan kekuatan karakter yang lebih jauh

Kenapa setiap jalur seleksi harus berbagi satu helper?

Karena tiga jalur kode yang masing-masing mengimplementasikan "baris" akan menyimpang, dan mereka akan menyimpang diam-diam. Di PDFium Component, ekspansi triple-click, Shift+Home, Shift+End, dan metode publik SelectLineAt semuanya menyelesaikan batasnya lewat pemanggilan LineRangeAt yang sama. Triple-click men-seed-nya dari anchor seleksi; tombol shift men-seed-nya dari kursor seleksi dan hanya menggerakkan ujung itu; SelectLineAt men-seed-nya dari indeks karakter yang disediakan pemanggil dan menyerahkan hasilnya ke SelectTextRange, validator range yang sama yang dipakai jalur mouse. Duplikasi logikanya sebagai gantinya dan kegagalannya bukan crash, ia adalah drift lambat. Seseorang menyetel toleransi triple-click untuk memperbaiki laporan dengan leading ketat, dan sekarang Shift+End berhenti satu karakter lebih pendek dari tempat triple-click berhenti pada paragraf yang sama. Pengguna memilih baris dengan mouse, memperluasnya dengan keyboard, dan menyaksikan seleksinya menyusut. Karena SelectLineAt memasok pipeline seleksi biasa, seleksi programatik juga tetap independen dari apakah input mouse diaktifkan, dan tetap mendapat validasi range, repaint, dan notifikasi OnSelectionChange secara gratis

// Select the visual line under a client-space point, then read it back
procedure TForm1.SelectLineUnderCursor(X, Y: Integer);
var
  CharIndex: Integer;
begin
  CharIndex := PdfView1.CharacterIndexAtPos(X, Y, 6.0, 6.0);
  if CharIndex < 0 then
    Exit;
  if PdfView1.SelectLineAt(PdfView1.CurrentPage, CharIndex) then
    Memo1.Lines.Add(PdfView1.SelectedText);
end;

Perhatikan argumen toleransi pada CharacterIndexAtPos. Hit testing punya kelonggarannya sendiri, dinyatakan dalam unit halaman, dan itu perhatian terpisah dari toleransi baris. Klik yang mendarat di leading antara dua baris terselesaikan ke karakter mana pun yang terdekat dalam kotak itu; pemindaian baris lalu berjalan dari karakter apa pun yang ternyata itu. Memasukkan toleransi hit yang terlalu longgar ke dalam seed adalah salah satu cara termudah untuk memilih baris yang tidak sedang ditunjuk pengguna

Dua ruang indeks: indeks karakter dan indeks teks

Begitu Anda punya sebuah range, tahan godaan untuk memakainya sebagai offset string. FPDFText_GetText mengembalikan teks halaman sebagai buffer UTF-16, tapi indeksnya bukan ruang indeks yang sama dengan indeks karakter yang dipakai FPDFText_GetCharBox dan FPDFText_CountChars. Karakter yang dihasilkan yang dibahas sebelumnya duduk di buffer teks sambil menempati slot karakter tanpa geometri yang bisa dipakai, dan kedua penomoran itu menyimpang seiring halaman berlanjut. Jembatannya adalah FPDFText_GetTextIndexFromCharIndex dan FPDFText_GetCharIndexFromTextIndex, dibungkus PDFium Component sebagai CharacterIndexToTextIndex dan TextIndexToCharacterIndex

var
  TextStart, TextEnd: Integer;
begin
  // char-index range from LineRangeAt -> offsets into the page text buffer
  TextStart := Pdf.CharacterIndexToTextIndex(StartIndex);
  TextEnd   := Pdf.CharacterIndexToTextIndex(StartIndex + Count - 1);
  if (TextStart >= 0) and (TextEnd >= TextStart) then
    Caption := Pdf.Text(TextStart, TextEnd - TextStart + 1);
end;

Arah yang menggigit paling keras adalah arah baliknya. Pencarian yang diimplementasikan atas string yang diekstrak memberi Anda indeks teks, dan meneruskannya langsung ke API kotak atau seleksi diam-diam mengalamati karakter yang salah, dengan error yang membesar semakin jauh Anda turun ke halaman. Konversi dengan TextIndexToCharacterIndex sebelum apa pun yang geometris menyentuh angkanya. Pasangan surrogate menambahkan masalah offset kedua yang independen di atas ini, yang dibahas di artikel tentang emoji, CJK, dan pasangan surrogate

Di mana heuristik ini melengkung

Bersikaplah jujur pada diri sendiri soal batasannya, karena mereka nyata dan bisa dicapai. Teks berputar adalah kasus paling jelas: kotak karakter adalah persegi panjang axis-aligned dalam page space, jadi untuk teks yang diputar 90 derajat, kotak dari satu baris visual punya pusat vertikal yang tersebar melintasi halaman, dan pemindaiannya berhenti hampir seketika. Yang Anda dapatkan adalah seleksi pendek alih-alih yang salah, yang merupakan mode kegagalan lebih baik, tapi itu tetap kegagalan. Mode penulisan vertikal berperilaku sama karena alasan yang sama. Layout dua-kolom berfungsi saat kolom-kolomnya bergeser vertikal satu sama lain dan gagal saat tidak. Jika kedua kolom berbagi grid baseline yang sama, karakter dari kolom kanan duduk dalam toleransi baris kolom kiri, dan pemindaiannya akan berjalan lurus melintasi gutter, karena dalam geometri murni tidak ada apa pun di sana untuk berhenti. Mendeteksi itu butuh test celah horizontal di atas pengelompokan vertikal, dan memilih ambang celahnya adalah keputusan tersendiri tentang dokumen mana yang bersedia Anda salahkan. Ukuran font campuran adalah kasus yang ditangani baik oleh toleransi relatif-seed: sebuah span kode 8pt inline di dalam body text 11pt menjaga pusatnya di dalam band, dan heading 24pt pada baseline berikutnya tidak menarik baris body ke dalam dirinya

Semantik seleksi baris yang dijelaskan di sini hadir dalam PDFium Component untuk Delphi dan C++Builder, bersama API hit testing, range seleksi, dan indeks teks yang dipakai dalam contoh-contohnya; halaman produknya membawa referensi lengkap untuk text page dan model seleksinya