O pagină de text PDF expune caractere și cutii, niciodată rânduri. PDFium Component construiește un rând vizual grupând cutiile de caractere ale căror centre verticale cad în jumătatea înălțimii caracterului sămânță, scanând spre exterior de la caracterul clicat până când toleranța este depășită. Fiecare cale de selecție din vizualizator apelează acel singur helper, așa că mouse-ul, tastatura și codul sunt de acord
Simptomul care vă trimite căutând aici este specific și neplăcut. Un utilizator dă triple-click pe un paragraf dintr-un raport pe două coloane și primește jumătate din pagină. Sau dă triple-click pe o celulă de tabel, iar selecția înghite întregul rând plus numărul de pagină din subsol. Vizualizatorul nu este stricat; pune o întrebare la care fișierul nu poate răspunde. Nu există niciun rând într-un PDF de selectat, iar orice implementare care pretinde altfel ghicește. Acest articol este despre a face ghicitul deliberat și a-l face consistent. Dacă de ce aveți nevoie de fapt este extragerea textului dintr-un document, vedeți extragerea textului din documente PDF cu PDFium; dacă așezați text în pagină și aveți nevoie de lățimi, vedeți măsurarea textului și încadrarea pe cuvinte. Aici subiectul este mai restrâns: decizia unde începe și se termină un rând vizual, și selectarea exact a aceluia
De ce o pagină de text PDF nu are obiecte de rând?
Pentru că un flux de conținut PDF descrie desenarea, nu structura. ISO 32000-1 §9.4 definește un obiect de text ca o pereche BT / ET ce conține operatori de poziționare și afișare. Operatorii de poziționare din §9.4.2 (Td, TD, Tm, T*) mută o matrice de text pe pagină, iar operatorii de afișare din §9.4.3 (Tj, TJ, ', ") pictează glife oriunde arată acea matrice în acel moment. Nimic din acel model nu spune "această succesiune de glife este un rând". Un rând este ceea ce vede un om după ce pictarea s-a terminat
Producătorii înrăutățesc asta în moduri pe care nu le puteți controla. Un paragraf justificat poate fi emis ca un array TJ per rând, sau ca un Tj per cuvânt cu un Tm explicit înaintea fiecăruia, sau ca o singură operație de afișare cu ajustări de kerning purtând spațierea. Un layout pe două coloane poate emite coloana stângă de sus în jos și apoi coloana dreaptă, sau le poate intercala dacă producătorul și-a parcurs propria listă internă de obiecte într-o altă ordine. Secvența de caractere pe care v-o predă PDFium urmează fluxul de conținut, iar fluxul de conținut urmează orice a dorit aplicația generatoare. Așa că cele două funcții pe care le primiți de fapt sunt FPDFText_CountChars, care raportează câte caractere conține pagina, și FPDFText_GetCharBox, care returnează caseta de delimitare a unui caracter în spațiul paginii. Acesta este întregul vocabular brut. Tot ce este deasupra, cuvinte, rânduri, paragrafe, coloane, este o inferență pe care o efectuați dumneavoastră pe geometrie
De ce este detectarea CR și LF testul greșit?
Pentru că caracterele față de care ați testa nu sunt prezente în mod fiabil, iar când sunt prezente, nu sunt ale dumneavoastră în mod fiabil. PDFium injectează caractere sintetice în pagina de text pentru a face textul extras lizibil: un spațiu unde două rulaje sunt vizual separate, un CR sau LF unde următorul rulaj începe pe o linie de bază nouă. FPDFText_IsGenerated există exact pentru a le putea distinge de caracterele care au ieșit din fișier, iar PDFium Component o expune ca proprietatea CharacterGenerated
Divizați pe acele caractere și moșteniți fiecare decizie de judecată pe care PDFium a luat-o în timp ce le sintetiza. O întrerupere de rând dură în interiorul unui paragraf încadrat automat și o încadrare moale arată identic după sintetizare. Un rând de tabel pe care producătorul l-a emis celulă cu celulă poate să nu primească deloc nicio întrerupere între ultima celulă și prima celulă a rândului următor, deoarece liniile de bază se întâmplă să fie suficient de apropiate. Între timp, un titlu urmat de text de corp la o dimensiune diferită poate primi două întreruperi unde un om vede una. Caracterele generate sunt o comoditate de randare pentru extragerea de pagină întreagă; nu sunt un model de rând, și se degradează exact în documentele unde selecția contează cel mai mult
Gruparea cutiilor de caractere după centrul vertical
Semnalul fiabil este geometria. Luați caracterul pe care l-a clicat utilizatorul ca sămânță, calculați centrul vertical al cutiei lui, și parcurgeți spre exterior în ambele direcții cât timp cutiile vecine își păstrează centrele verticale în toleranță. PDFium Component folosește jumătate din înălțimea cutiei sămânță ca acea toleranță, cu un plafon minim de 0.5 unități de pagină, astfel încât cutiile degenerate, un punct, un spațiu subțire, o glifă cu o cutie de înălțime aproape zero, să nu colapseze toleranța la nimic și să taie rândul după un caracter
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;
Trei detalii din acea buclă își câștigă locul. Toleranța derivă din sămânță, nu dintr-o constantă, așa că un titlu de 24pt primește o bandă largă, iar textul de notă de subsol de 7pt primește una îngustă, iar niciunul nu fură caractere de la celălalt. Comparația folosește centre verticale, nu linii de bază sau vârfurile cutiilor, ceea ce păstrează un exponent, un rulaj inline de dimensiune diferită, sau o propoziție cu font mixt pe același rând ca vecinii lui. Iar un FPDFText_GetCharBox eșuat termină scanarea, în loc să fie omis, deoarece un caracter fără geometrie recuperabilă nu vă oferă nicio dovadă în niciun sens, iar continuarea dincolo de el ar permite parcurgerii să sară peste o graniță genuină pe baza unui caracter mai departe
De ce trebuie ca fiecare cale de selecție să partajeze un singur helper?
Pentru că trei căi de cod care fiecare implementează "rândul" vor diverge, și vor diverge tacit. În PDFium Component, expansiunea triple-click, Shift+Home, Shift+End, și metoda publică SelectLineAt își rezolvă toate limitele prin același apel LineRangeAt. Triple-click-ul îl seamănă din ancora de selecție; tastele shift îl seamănă din cursorul de selecție și mută doar acel capăt; SelectLineAt îl seamănă dintr-un index de caracter furnizat de apelant și predă rezultatul lui SelectTextRange, același validator de interval pe care îl folosește calea mouse-ului. Duplicați logica în schimb, iar eșecul nu este o cădere, este o derivă lentă. Cineva reglează toleranța de triple-click pentru a repara un raport cu interlinie strânsă, iar acum Shift+End se oprește cu un caracter mai devreme decât se oprește triple-click-ul pe același paragraf. Un utilizator selectează un rând cu mouse-ul, îl extinde cu tastatura și privește selecția micșorându-se. Deoarece SelectLineAt alimentează pipeline-ul obișnuit de selecție, selecția programatică rămâne și ea independentă de dacă intrarea de la mouse este activată, și primește gratuit validarea de interval, repictarea și notificarea OnSelectionChange
// 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;
Observați argumentele de toleranță de pe CharacterIndexAtPos. Testarea de lovitură are propria ei marjă, exprimată în unități de pagină, și este o preocupare separată de toleranța de rând. Un clic care aterizează în interlinia dintre două rânduri se rezolvă la orice caracter este cel mai apropiat în acea cutie; scanarea de rând rulează apoi din orice caracter s-a dovedit a fi acela. Alimentarea unei toleranțe de lovitură prea generoase în sămânță este unul dintre modurile mai ușoare de a selecta un rând spre care utilizatorul nu indica
Două spații de index: index de caracter și index de text
Odată ce aveți un interval, rezistați tentației de a-l folosi ca offset de string. FPDFText_GetText returnează textul paginii ca un buffer UTF-16, dar indicii lui nu sunt același spațiu de index ca indicii de caracter folosiți de FPDFText_GetCharBox și FPDFText_CountChars. Caracterele generate discutate mai devreme stau în buffer-ul de text în timp ce ocupă sloturi de caracter fără geometrie utilizabilă, iar cele două numerotări se depărtează pe parcursul paginii. Punțile sunt FPDFText_GetTextIndexFromCharIndex și FPDFText_GetCharIndexFromTextIndex, învelite de PDFium Component ca CharacterIndexToTextIndex și 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;
Direcția care mușcă cel mai tare este cea inversă. O căutare implementată peste string-ul extras vă dă indici de text, iar predarea lor direct unui API de cutie sau selecție adresează tacit caracterele greșite, cu o eroare care crește cu cât mergeți mai jos pe pagină. Convertiți cu TextIndexToCharacterIndex înainte ca ceva geometric să atingă numărul. Perechile surogat adaugă o a doua problemă de offset, independentă, peste asta, tratată în articolul despre emoji, CJK și perechi surogat
Unde se îndoaie euristica
Fiți sinceri cu dumneavoastră înșivă despre limite, deoarece sunt reale și sunt atinse. Textul rotit este cazul cel mai clar: o cutie de caracter este un dreptunghi aliniat la axă în spațiul paginii, așa că pentru text rotit la 90 de grade, cutiile unui rând vizual au centre verticale împrăștiate pe pagină, iar scanarea se oprește aproape imediat. Ce obțineți este o selecție scurtă, nu una greșită, ceea ce este modul de eșec mai bun, dar tot este un eșec. Modurile de scriere verticală se comportă la fel din același motiv. Layout-urile pe două coloane funcționează când coloanele sunt decalate vertical una față de alta și se strică atunci când nu sunt. Dacă ambele coloane partajează o grilă de linii de bază, caracterele din coloana dreaptă stau în toleranță față de rândul coloanei stângi, iar scanarea va traversa direct șanțul dintre coloane, deoarece în geometrie pură nu există nimic acolo de care să se oprească. Detectarea asta are nevoie de un test de gol orizontal peste gruparea verticală, iar alegerea pragului de gol este propria ei decizie de judecată despre ce documente sunteți dispuși să greșiți. Dimensiunile de font mixte sunt cazul pe care toleranța relativă la sămânță îl gestionează bine: un rulaj de cod inline de 8pt în interiorul textului de corp de 11pt își păstrează centrul în interiorul benzii, iar un titlu de 24pt pe linia de bază următoare nu trage rândul de corp în el
Semantica de selecție de rând descrisă aici vine în PDFium Component pentru Delphi și C++Builder, alături de API-urile de testare de lovitură, interval de selecție și index de text folosite în exemple; pagina de produs conține referința completă pentru pagina de text și modelul de selecție