Technical Article

Pencarian Teks PDF Delphi dengan Koordinat Hasil: PDFlibPas

Mengekstrak teks dari sebuah halaman adalah separuh masalah yang paling mudah. Begitu pengguna mengetikkan sebuah kata di kotak pencarian dan mengharapkan penampil langsung melompat ke sana lalu menggambar kotak kuning di sekelilingnya, Anda membutuhkan sesuatu yang tidak bisa diberikan oleh string teks datar: halaman tempat setiap kecocokan berada, dan persegi panjang yang ditempatinya dalam koordinat PDF. String yang digabungkan sepanjang halaman sudah kehilangan geometri itu. Anda bisa menemukan substring-nya, tetapi Anda tidak bisa menunjuk ke lokasinya.

PDFlibPas adalah pustaka PDF Object Pascal native untuk Delphi dan C++Builder, dan sejak v3.78.0 ia menjawab pertanyaan itu secara langsung. Tiga API kueri berdiri di atas ekstraktor blok teks yang sudah ada: SearchText menelusuri rentang halaman dan mengembalikan setiap hasil beserta halaman dan persegi panjang sejajar sumbunya, EnumPageElements mencantumkan semua yang ada pada satu halaman, baik blok teks maupun gambar tertanam, dan GetTextInAreaEx melaporkan persegi panjang setiap blok di dalam suatu area alih-alih meratakannya menjadi daftar string. Tidak satu pun menyentuh jalur tulis; semuanya adalah tambahan murni di sisi baca atas mesin yang memang sudah dimiliki library.

Mengapa geometri tinggal di daftar blok teks, bukan di funnel

Insting yang paling wajar adalah memakai ulang apa pun yang dijalankan GetPageText secara internal. Jalur itu melewati "funnel" ekstraksi sementara yang menghasilkan string halaman lalu membebaskan dirinya sebelum pemanggilan kembali. Saat Anda memegang hasilnya, koordinat per blok sudah hilang. Sejak awal memang bukan milik Anda untuk disimpan.

Koordinat itu tetap bertahan di struktur lain. ExtractPageTextBlocks(3) mengembalikan handle daftar blok teks, dan setiap item di dalamnya membawa quad batas delapan double, nama font, ukuran font, dan teks blok. Handle itulah satu-satunya tempat geometri dipertahankan setelah ekstraksi, itulah sebabnya semua API kueri baru dibangun di atasnya, bukan di atas funnel. Memakai ulang daftar blok berarti pencarian, enumerasi, dan kueri area semuanya berbagi satu kali proses ekstraksi dan satu definisi tentang di mana sebuah blok berada.

Jadi bentuk SearchText mengikuti batasan itu. Untuk setiap halaman dalam rentang, ia mengekstrak daftar blok, membaca teks tiap blok dengan GetTextBlockText, mengujinya terhadap kueri, lalu untuk blok yang cocok ia mereduksi quad menjadi persegi panjang. Hasil yang dikembalikan berupa record kecil:

type
  TPDFlibSearchHit = record
    Page: Integer;                       // 1-based page of the match
    Left, Top, Right, Bottom: Double;    // axis-aligned hit rectangle
    MatchText: WideString;               // the block text that contained the query
  end;

Array batasnya berurutan X/Y, bukan empat sudut

Ini detail yang paling cepat menjebak. GetTextBlockBound(ListID, Index, BoundIndex) menerima BoundIndex dari 1 sampai 8, dan delapan nilai itu bukan "sudut 1, sudut 2, sudut 3, sudut 4" dengan dua bidang yang dikelompokkan seperti dugaan Anda mungkin. Susunannya adalah X, Y, X, Y, X, Y, X, Y: indeks ganjil adalah koordinat X, indeks genap adalah koordinat Y, totalnya empat titik. Jika dibaca dengan pasangan yang salah, persegi panjang Anda akan menjadi tidak masuk akal.

Alasan ada quad, bukan persegi panjang biasa, adalah rotasi. Blok teks yang diputar memiliki poligon batas empat titik yang nyata, dan delapan double itu mendeskripsikannya dengan tepat. Untuk kasus highlight dan lompat, Anda hampir selalu menginginkan kotak tegak, jadi library mereduksi quad menjadi persegi panjang sejajar sumbu dengan mengambil nilai minimum dan maksimum X dan Y dari keempat titik. Teks yang diputar akan runtuh menjadi kotak tegak yang mengurungnya, dan itulah yang dibutuhkan overlay highlight:

var
  Pdf: TPDFlib;
  Hits: array[0..255] of TPDFlibSearchHit;
  Found, I: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('contract.pdf', '');
    // Search pages 1 to 10, case-insensitive, substring match.
    Found := Pdf.SearchText('indemnity', [], '1-10', Hits);
    for I := 0 to Found - 1 do
      if I <= High(Hits) then
        WriteLn(Format('p%d: [%.1f %.1f %.1f %.1f] %s',
          [Hits[I].Page, Hits[I].Left, Hits[I].Top,
           Hits[I].Right, Hits[I].Bottom, Hits[I].MatchText]));
  finally
    Pdf.Free;
  end;
end;

Perhatikan bahwa persegi panjang itu berada di titik ruang-user PDF dengan origin di kiri bawah halaman, sama seperti sistem koordinat yang Anda kirim ke panggilan menggambar dan anotasi. Itu memang disengaja: persegi panjang yang Anda terima dari hasil pencarian adalah persegi panjang yang bisa langsung Anda berikan ke anotasi highlight atau perintah "scroll ke sini" tanpa konversi apa pun.

Kepekaan huruf besar kecil, kata utuh, dan perbedaan untuk CJK

Parameter kedua adalah himpunan TPDFlibSearchOptions yang diambil dari soCaseSensitive dan soWholeWord. Himpunan kosong [] adalah kasus yang paling umum: pencarian substring tanpa membedakan huruf besar kecil. Tambahkan soCaseSensitive agar Indemnity dan indemnity dianggap berbeda, tambahkan soWholeWord untuk mencegah sign cocok di dalam signature, atau gabungkan keduanya.

Pencocokan kata utuh memerlukan definisi tentang apa itu batas kata, dan di sini aturannya layak dijelaskan secara lugas karena memang dirancang berpusat pada ASCII. Sebuah karakter dihitung sebagai bagian dari kata jika itu huruf ASCII, digit ASCII, atau underscore: kelas [A-Za-z0-9_] yang akrab dari aturan identifier. Sebuah kecocokan hanya memenuhi syarat kata utuh jika karakter tepat sebelum dan sesudahnya bukan karakter kata, atau jika kecocokan berada di tepi blok.

Konsekuensinya untuk skrip non-Latin adalah sesuatu yang perlu Anda pahami sebelum merilis kotak pencarian multibahasa. Karena karakter Han, kana, dan huruf non-ASCII lain berada di luar kelas itu, setiap batas di sebelahnya terbaca sebagai tepi non-kata. Secara praktik, itu berarti pencarian kata utuh pada teks CJK berperilaku seolah-olah setiap posisi adalah batas kata yang valid, sehingga flag ini secara efektif turun menjadi pencocokan substring di sana. Itu keterbatasan yang didokumentasikan, bukan bug, dan sesuai dengan perilaku yang dijadikan model fitur ini. Jika korpus Anda terutama CJK, mode kata utuh tidak akan memberi segmentasi seperti yang dihasilkan tokenizer khusus; rencanakan di sekitarnya, jangan bergantung padanya.

Satu catatan implementasi yang menjelaskan kelas kegagalan halus di tempat lain: perbandingan tanpa memperhatikan huruf besar kecil memakai UpperCase pada WideString, bukan AnsiUpperCase. Varian Ansi mengembalikan AnsiString, yang tidak akan selaras dengan WideString yang dipakai sisa jalur, dan mencampur keduanya menimbulkan ketidakcocokan tipe dan, lebih buruk lagi, folding yang kehilangan data untuk karakter di luar code page aktif. Unicode masuk, Unicode keluar, sepanjang alurnya.

Satu parser rentang halaman untuk seluruh library

Parameter ketiga adalah string rentang halaman seperti "1,3,5-9". Tidak ada yang khusus dalam cara string itu diurai: PLParsePageRangeList yang sama, yang menjadi dasar PrintPages dan rutinitas salin halaman, juga menangani ini di sini, jadi rentang yang tercetak dengan benar juga dicari dengan benar. String rentang kosong menjadi penanda untuk "semua halaman", dan dalam kasus itu SearchText membangun daftar lengkapnya sendiri.

Ruang lingkup menentukan biaya. Mencari potongan sepuluh halaman dari dokumen seribu halaman mengekstrak blok untuk sepuluh halaman, bukan seribu, karena loop hanya memilih dan mengekstrak halaman yang disebut dalam rentang. Saat Anda sudah tahu sebuah klausul berada di lampiran, sebutkan itu di rentang dan lewati sisa berkas.

Secara internal, pencarian dan enumerasi sama-sama mengganti halaman terpilih saat mereka melakukan iterasi, jadi masing-masing menyimpan halaman terpilih milik pemanggil saat masuk dan memulihkannya di blok finally. Memanggil SearchText di tengah membangun sebuah halaman membuat pilihan Anda kembali persis ke tempat semula saat pemanggilan selesai. Kontrak simpan-dan-pulih seperti itu biasanya baru terasa penting ketika tidak ada, dan justru karena itu kontrak itu ada.

Menampilkan seluruh halaman: teks dan gambar dalam satu daftar

Pencarian menjawab "di mana kata ini berada". Separuh inspeksi yang lain adalah "apa saja yang ada di halaman ini", dan itulah EnumPageElements. Ia mengembalikan satu daftar terpadu yang setiap elemennya adalah blok teks atau gambar tertanam, dibedakan oleh field Kind:

type
  TPDFlibPageElementKind = (ekText, ekImage);

  TPDFlibPageElement = record
    Kind: TPDFlibPageElementKind;
    Page: Integer;
    Left, Top, Right, Bottom: Double;
    Text: WideString;        // ekText
    FontName: WideString;    // ekText
    FontSize: Double;        // ekText
    ImageID: Integer;        // ekImage; usable with SelectImage / GetImageID
  end;

Elemen teks berasal dari proses ExtractPageTextBlocks yang sama, jadi masing-masing sudah tiba dengan persegi panjangnya, nama font-nya, dan ukurannya terisi. Elemen gambar berasal dari daftar gambar tertanam pada halaman melalui FindImages dan GetImageID; ImageID yang mereka bawa adalah handle yang Anda masukkan ke SelectImage untuk memeriksa gambar lebih lanjut. Dua jenis itu masuk ke satu array supaya satu kali penelusuran halaman bisa melihat semuanya.

var
  Pdf: TPDFlib;
  Elems: array[0..511] of TPDFlibPageElement;
  Total, I: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('report.pdf', '');
    Total := Pdf.EnumPageElements(1, Elems);
    for I := 0 to Total - 1 do
      if I <= High(Elems) then
        if Elems[I].Kind = ekText then
          WriteLn(Format('text  %s/%.1f  "%s"',
            [Elems[I].FontName, Elems[I].FontSize, Elems[I].Text]))
        else
          WriteLn(Format('image id=%d', [Elems[I].ImageID]));
  finally
    Pdf.Free;
  end;
end;

Ada konvensi penghitungan di sini yang mengikuti sisa library dan harus Anda patuhi kalau tidak ingin membaca memori yang belum diinisialisasi. Nilai baliknya adalah jumlah elemen total, yang bisa lebih besar daripada array yang Anda kirim. Fungsi ini hanya mengisi slot sebanyak yang muat dan terus menghitung sisanya, persis seperti cara enumerasi signature bekerja. Jadi penjagaannya selalu sama: batasi loop pada nilai yang lebih kecil antara jumlah yang dikembalikan dan High(array), jangan pernah mengiterasi sampai jumlah itu secara buta. Contoh di atas memperlihatkan pengecekan I <= High(...) untuk alasan itu. Jika nilai balik melebihi buffer Anda, perbesar array dan panggil lagi.

Jika Anda pernah memakai panggilan blok teks level lebih rendah dari library ini, ini adalah lapisan bertipe yang sadar geometri di atasnya; ekstraksi dasarnya sama seperti yang dijelaskan di ekstraksi teks, gambar, dan font PDF Delphi dengan PDFlibPas. Dan saat tujuannya bukan "di mana teks ini" melainkan "bagaimana struktur dokumen ini untuk teknologi bantu", cerita paralel di sisi baca adalah pohon struktur tagged-PDF, yang menampilkan urutan baca logis alih-alih tata letak blok fisik.

Kueri area saat Anda sudah tahu harus mencari di mana

Terkadang Anda tidak punya istilah pencarian sama sekali, Anda punya persegi panjang. Template formulir selalu menempatkan nomor faktur di sudut kanan atas, atau tata letak hasil pindai menyediakan pita tetap untuk tabel. GetTextInAreaEx melayani kasus itu. Ini adalah padanan GetTextInArea yang membawa batas: jika panggilan yang lebih lama mengembalikan daftar string datar untuk sebuah area, yang baru mengembalikan persegi panjang setiap blok yang dipertahankan bersama teksnya, sehingga Anda bukan hanya tahu apa yang ada di dalam kotak, tetapi juga di mana setiap baris berada di dalamnya.

var
  Pdf: TPDFlib;
  Hits: array[0..63] of TPDFlibSearchHit;
  Found, I: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('invoice.pdf', '');
    Pdf.SelectPage(1);
    // Left, Top, Width, Height in PDF points on the selected page.
    Found := Pdf.GetTextInAreaEx(360, 720, 180, 60, Hits);
    for I := 0 to Found - 1 do
      if I <= High(Hits) then
        WriteLn(Hits[I].MatchText);
  finally
    Pdf.Free;
  end;
end;

Ada dua hal yang perlu dijaga tetap jelas. GetTextInAreaEx bekerja pada halaman yang sedang dipilih, jadi panggil SelectPage terlebih dahulu; tidak seperti SearchText, ia tidak menerima rentang. Dan sebuah blok dipertahankan ketika ia beririsan dengan persegi panjang kueri, bukan hanya saat ia sepenuhnya berada di dalamnya, jadi baris yang melintasi batas tetap ikut muncul. Itu biasanya yang Anda inginkan untuk kotak seleksi yang digambar tangan, tetapi jika Anda butuh containment yang ketat Anda bisa memfilter sendiri persegi panjang yang dikembalikan, karena sekarang Anda sudah memilikinya.

Menerapkannya

Benang merah di antara ketiga pemanggilan ini adalah bahwa geometri bukan lagi sesuatu yang Anda rekonstruksi belakangan. Sebuah hasil pencarian tahu halaman dan kotaknya. Sebuah elemen halaman tahu persegi panjangnya dan, untuk teks, font-nya. Sebuah kueri area melaporkan di mana setiap baris jatuh. Itu sudah cukup untuk membangun fitur find-and-highlight yang nyata, indeks klik-untuk-lokasikan, atau ekstraktor yang sadar tata letak tanpa turun di bawah API publik atau membangun ulang pipeline ekstraksi teks dengan tangan.

API kueri ini dikirim sebagai bagian dari PDFlibPas Delphi PDF Library, bersama lapisan ekstraksi blok teks penuh yang menjadi dasarnya dan seluruh permukaan inspeksi sisi baca untuk Delphi dan C++Builder.