Estrarre il testo di una pagina è la metà facile del problema. Nel momento in cui un utente digita una parola in una casella di ricerca e si aspetta che il visualizzatore vi salti e disegni attorno un riquadro giallo, serve qualcosa che la semplice stringa di testo non può dare: la pagina in cui si trova ogni corrispondenza e il rettangolo che occupa nelle coordinate PDF. Una stringa concatenata su tutta la pagina ha perso quella geometria. Puoi trovare la sottostringa, ma non puoi indicarla
PDFlibPas è una libreria PDF nativa Object Pascal per Delphi e C++Builder e, dalla v3.78.0, risponde esattamente a questa esigenza. Tre API di query si appoggiano all'estrattore di blocchi di testo esistente: SearchText scorre un intervallo di pagine e restituisce ogni risultato con la pagina e il rettangolo allineato agli assi, EnumPageElements elenca tutto ciò che c'è su una pagina singola, sia blocchi di testo sia immagini incorporate, e GetTextInAreaEx restituisce il rettangolo di ogni blocco dentro una regione invece di appiattirli in un elenco di stringhe. Nessuna di queste tocca il percorso di scrittura; sono aggiunte pure del lato lettura sopra meccanismi che la libreria già aveva
Perché la geometria vive nella lista dei blocchi di testo, non nel funnel
L'istinto naturale è riusare qualunque GetPageTextesegua internamente. Quel percorso passa attraverso un funnel di estrazione temporaneo che produce la stringa della pagina e poi si libera prima che la chiamata ritorni. Quando hai il risultato in mano, le coordinate per blocco sono già sparite. Non sono mai state tue da conservare
Le coordinate sopravvivono in un'altra struttura. ExtractPageTextBlocks(3)restituisce un handle di lista di blocchi di testo i cui elementi portano ciascuno un quad di bounding a otto double, un nome di font, una dimensione del font e il testo del blocco. Quell'handle è l'unico punto in cui la geometria resta disponibile dopo l'estrazione, ed è per questo che tutte le nuove API di query si basano su di lui e non sul funnel. Riutilizzare la lista dei blocchi significa che ricerca, enumerazione e query di regione condividono un solo passaggio di estrazione e una sola definizione di dove si trova un blocco
Quindi la forma di SearchTextdiscende da quel vincolo. Per ogni pagina nell'intervallo estrae la lista dei blocchi, legge il testo di ciascun blocco con GetTextBlockText, lo confronta con la query e, per i blocchi che corrispondono, riduce il quad a un rettangolo. Il risultato che restituisce è un piccolo record:
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;
L'array dei limiti è intercalato X/Y, non quattro angoli
Questo è il dettaglio che colpisce per primo. GetTextBlockBound(ListID, Index, BoundIndex)prende un BoundIndexda 1 a 8, e quei otto valori non sono "angolo 1, angolo 2, angolo 3, angolo 4" con due campi raggruppati come potresti immaginare. Sono X, Y, X, Y, X, Y, X, Y: gli indici dispari sono coordinate X, quelli pari sono coordinate Y, in tutto quattro punti. Leggerli con l'accoppiamento sbagliato rende il rettangolo privo di senso
Il motivo per cui esiste un quad, invece di un semplice rettangolo, è la rotazione. Un blocco di testo impostato con un angolo ha davvero un poligono di bounding a quattro punti, e gli otto double lo descrivono fedelmente. Per il caso d'uso evidenzia-e-salta di solito vuoi invece un riquadro dritto, quindi la libreria riduce il quad a un rettangolo allineato agli assi passando in rassegna i quattro punti per trovare X e Y minimi e massimi. Il testo ruotato collassa nel riquadro dritto che lo contiene, ed è proprio ciò che serve a un overlay di evidenziazione:
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;
Nota che il rettangolo è espresso in punti dello spazio utente PDF con l'origine nell'angolo in basso a sinistra della pagina, lo stesso sistema di coordinate che passi alle chiamate di disegno e annotazione. È una scelta deliberata: il rettangolo che ricevi da un risultato di ricerca è il rettangolo che puoi passare direttamente a un'annotazione di evidenziazione o a un comando "scorri qui" senza convertire nulla
Sensibilità alle maiuscole, parole intere e dove il CJK differisce
Il secondo parametro è un TPDFlibSearchOptionsset costruito a partire da soCaseSensitivee soWholeWord. L'insieme vuoto []è il caso comune: una ricerca di sottostringa senza distinzione tra maiuscole e minuscole. Aggiungi soCaseSensitiveper rendere Indemnitye indemnitydistinti, aggiungi soWholeWordper impedire che signcorrisponda dentro signature, oppure combina entrambe le opzioni
La corrispondenza per parola intera richiede una definizione di confine di parola, e qui la regola merita di essere detta chiaramente perché per progetto è centrata su ASCII. Un carattere conta come parte di una parola quando è una lettera ASCII, una cifra ASCII o un trattino basso: la [A-Za-z0-9_]classe familiare dalle regole degli identificatori. Una corrispondenza vale come parola intera solo quando i caratteri immediatamente prima e dopo sono non caratteri di parola (o la corrispondenza si trova al bordo del blocco)
La conseguenza per gli script non latini è qualcosa da conoscere prima di spedire una casella di ricerca multilingue. Poiché i caratteri Han, kana e altre lettere non ASCII non rientrano in quella classe, ogni confine accanto a loro viene letto come margine non di parola. In pratica questo significa che la ricerca per parola intera sul testo CJK si comporta come se ogni posizione fosse un confine valido di parola, quindi in quel contesto il flag degrada di fatto a una ricerca per sottostringa. È una limitazione documentata, non un bug, ed è coerente con il comportamento a cui la funzionalità è stata modellata. Se il tuo corpus è soprattutto CJK, la modalità parola intera non ti darà la segmentazione che offrirebbe un tokenizer dedicato; pianifica di conseguenza invece di farvi affidamento
Una nota di implementazione che spiega una classe di fallimenti sottili altrove: il confronto senza distinzione tra maiuscole usa UpperCase su WideString, non AnsiUpperCase. La variante Ansi restituisce un AnsiString, che non si allineerebbe con il WideString che il resto del percorso usa, e mescolare i due produce incongruenze di tipo e, peggio ancora, una conversione con perdita per i caratteri fuori dal code page attivo. Unicode in, Unicode out, fino in fondo
Un solo parser degli intervalli di pagina per tutta la libreria
Il terzo parametro è una stringa di intervallo di pagine come "1,3,5-9". Non c'è nulla di personalizzato nel modo in cui viene analizzata: la stessa PLParsePageRangeListche supporta PrintPagese le routine di copia pagina la gestisce anche qui, quindi un intervallo che stampa correttamente cerca correttamente. Una stringa di intervallo vuota è il segnaposto per "ogni pagina", nel qual caso SearchTextcostruisce da solo l'elenco completo
Il costo dipende dal perimetro. Cercare una sezione di dieci pagine in un documento di mille estrae i blocchi di dieci pagine, non di mille, perché il ciclo seleziona ed estrae solo le pagine nominate dall'intervallo. Quando sai già che una clausola si trova nell'appendice, dichiaralo nell'intervallo e salta il resto del file
Internamente, ricerca ed enumerazione cambiano entrambe la pagina selezionata mentre iterano, quindi ognuna salva la pagina selezionata dal chiamante all'ingresso e la ripristina in un blocco finally. Chiama SearchTextnel mezzo della costruzione di una pagina e la tua selezione resta esattamente dov'era quando la chiamata ritorna. Quel contratto di salvataggio e ripristino è il tipo di cosa che noti solo quando manca, ed è proprio per questo che esiste
Enumerare un'intera pagina: testo e immagini in un unico elenco
La ricerca risponde a "dov'è questa parola". L'altra metà dell'introspezione è "che cosa c'è in questa pagina, in generale", e questo è EnumPageElements. Restituisce un elenco unificato in cui ogni elemento è un blocco di testo oppure un'immagine incorporata, distinta da un campo 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;
Gli elementi di testo provengono dallo stesso ExtractPageTextBlockspassaggio, quindi arrivano già con rettangolo, nome del font e dimensione compilati. Gli elementi immagine provengono dall'elenco delle immagini incorporate della pagina tramite FindImagese GetImageID; l'ImageIDche portano è l'handle da passare a SelectImageper ispezionare ulteriormente l'immagine. I due tipi finiscono nello stesso array così un solo passaggio su una pagina vede tutto ciò che contiene
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;
Qui c'è una convenzione di conteggio che segue il resto della libreria e che devi rispettare, altrimenti leggerai memoria non inizializzata. Il valore di ritorno è il conteggio totale degli elementi, che può essere maggiore dell'array passato in ingresso. La funzione riempie solo le caselle che entrano e continua a contare le altre, esattamente come funziona l'enumerazione delle firme. Quindi la regola di guardia è sempre la stessa: limita il ciclo al minore tra il conteggio restituito e High(array), non iterare mai alla cieca fino al conteggio. Gli esempi sopra mostrano il controllo I <= High(...)per questo motivo. Se il valore di ritorno supera il buffer, alloca un array più grande e richiama la funzione
Se hai già usato le chiamate di livello più basso per i blocchi di testo, questa è la loro versione tipizzata e consapevole della geometria; l'estrazione sottostante è la stessa descritta in Delphi PDF text, image, and font extraction with PDFlibPas. E quando l'obiettivo non è "dov'è questo testo" ma "come è strutturato questo documento per le tecnologie assistive", la storia parallela del lato lettura è il tagged-PDF structure tree, che espone l'ordine logico di lettura anziché il layout fisico dei blocchi
Query di regione quando sai già dove guardare
A volte non hai affatto un termine di ricerca; hai un rettangolo. Un modello di modulo mette sempre il numero fattura in alto a destra, oppure un layout scansionato riserva una fascia fissa per una tabella. GetTextInAreaEx serve per questo caso. È la controparte che porta i limiti del GetTextInArea: dove la chiamata precedente restituisce un elenco piatto di stringhe per una regione, quella nuova restituisce il rettangolo di ogni blocco mantenuto insieme al suo testo, così non sai solo che cosa c'è nella casella ma anche dove si trova ogni riga al suo interno
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;
Due cose da tenere distinte. GetTextInAreaEx lavora sulla pagina attualmente selezionata, quindi chiama SelectPageper primo; a differenza di SearchText, non accetta un intervallo. E un blocco viene mantenuto quando intersecail rettangolo di query, non solo quando è interamente contenuto, quindi una riga che attraversa il confine passa comunque. Di solito è ciò che vuoi per una casella di selezione disegnata a mano, ma se ti serve un contenimento rigoroso puoi filtrare tu i rettangoli restituiti, visto che ora li hai
Metterlo al lavoro
Il filo conduttore tra tutte e tre le chiamate è che la geometria non è più qualcosa che ricostruisci dopo il fatto. Un risultato di ricerca conosce la sua pagina e la sua casella. Un elemento di pagina conosce il suo rettangolo e, per il testo, il suo font. Una query di regione indica dove cade ogni riga. Basta per costruire una vera funzione trova-e-evidenzia, un indice click-to-locate o un estrattore consapevole del layout senza scendere sotto l'API pubblica o ricostruire a mano la pipeline di estrazione del testo
Queste API di query sono incluse nel PDFlibPas Delphi PDF Library, insieme all'intero livello di estrazione dei blocchi di testo su cui si basano e al resto della superficie di introspezione lato lettura per Delphi e C++Builder