Att extrahera texten på en sida är den enkla halvan av problemet. I samma ögonblick som en användare skriver in ett ord i ett sökfält och förväntar sig att visaren hoppar till det och ritar en gul ruta runt det, behöver du något som den platta textsträngen inte kan ge dig: vilken sida varje träff ligger på, och rektangeln den upptar i PDF-koordinater. En sträng som har slagits ihop över en sida har förlorat den geometrin. Du kan hitta delsträngen, men du kan inte peka ut den
PDFlibPas är ett inbyggt Object Pascal-PDF-bibliotek för Delphi och C++Builder, och från v3.78.0 svarar det på just den frågan. Tre fråge-API:er ligger ovanpå den befintliga textblocksextraheraren: SearchText söker ett sidintervall och returnerar varje träff med sin sida och axelräta rektangel, EnumPageElements listar allt på en sida, både textblock och inbäddade bilder, och GetTextInAreaEx rapporterar rektangeln för varje block i ett område i stället för att platta ut dem till en stränglista. Ingen av dem rör skrivvägen; de är rena lästillägg ovanpå den mekanik biblioteket redan hade
Varför geometrin bor i textblockslistan och inte i extraheraren
Det naturliga första försöket är att återanvända det som GetPageText kör internt. Den vägen går genom en övergående extraktions-"tratt" som producerar sidsträngen och sedan frigör sig själv innan anropet returnerar. När du väl håller i resultatet är koordinaterna per block borta. De var aldrig dina att behålla
Koordinaterna finns kvar i en annan struktur. ExtractPageTextBlocks(3) returnerar ett handtag till en textblockslista där varje post bär en omslutande kvadrilateral med åtta dubbeltal, ett typsnittsnamn, en typsnittsstorlek och blockets text. Det handtaget är den enda plats där geometrin bevaras efter extraktion, vilket är varför alla de nya fråge-API:erna bygger på det i stället för på extraheraren. Återanvändning av blocklistan betyder att sök, uppräkning och regionsfrågor alla delar samma extraktionspass och samma definition av var ett block ligger
Så formen på SearchText följer av den begränsningen. För varje sida i intervallet extraherar den blocklistan, läser varje blocks text med GetTextBlockText, testar den mot frågan och reducerar kvadrilateralen till en rektangel för de block som matchar. Träffen som returneras är en liten post:
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;
Den bundna arrayen är X/Y-växlad, inte fyra hörn
Det här är detaljen som biter först. GetTextBlockBound(ListID, Index, BoundIndex) tar en BoundIndex från 1 till 8, och de åtta värdena är inte "hörn 1, hörn 2, hörn 3, hörn 4" med två fält vardera grupperade ihop som man kanske tror. De är X, Y, X, Y, X, Y, X, Y: de udda indexen är X-koordinater, de jämna indexen är Y-koordinater, fyra punkter totalt. Läs dem i fel parning och din rektangel blir nonsens
Anledningen till att det alls finns en kvadrilateral, i stället för en vanlig rektangel, är rotation. Ett textblock som ligger i en vinkel har en riktig fyrpunktsomslutande polygon, och de åtta dubbla talen beskriver den troget. För användningsfallet markera-och-hoppa vill du nästan alltid ha en upprätt ruta i stället, så biblioteket reducerar kvadrilateralen till en axelrät rektangel genom att svepa de fyra punkterna för deras minsta och största X och Y. Roterad text kollapsar till den upprätta ruta som omsluter den, vilket är precis vad ett markeringslager behöver:
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;
Observera att rektangeln anges i PDF:s användarutrymmespunkter med origo nere till vänster på sidan, samma koordinatsystem som du skickar till ritnings- och annoteringsanrop. Det är avsiktligt: rektangeln du får tillbaka från en sökträff är rektangeln du kan lämna direkt till en markeringsannotering eller ett "hoppa hit"-kommando utan att konvertera något
Skiftlägeskänslighet, hela ord och var CJK skiljer sig
Den andra parametern är en TPDFlibSearchOptions mängd som hämtas från soCaseSensitive och soWholeWord. Den tomma mängden [] är det vanliga fallet: en skiftlägesokänslig delsökning. Lägg till soCaseSensitive för att göra Indemnity och indemnity åtskilda, lägg till soWholeWord för att hindra sign från att matcha inne i signature, eller kombinera båda
Matchning av hela ord behöver en definition av vad en ordboundary är, och här är regeln värd att säga rakt ut eftersom den medvetet är ASCII-centrerad. Ett tecken räknas som en del av ett ord när det är en ASCII-bokstav, en ASCII-siffra eller ett understreck: den [A-Za-z0-9_] klass som är bekant från identifieringsregler. En träff räknas som ett helt ord bara när tecknen direkt före och efter den inte ordtecken (eller så ligger träffen vid blockets kant)
Konsekvensen för icke-latinska skript är något att känna till innan du släpper ett flerspråkigt sökfält. Eftersom han-tecken, kana och andra icke-ASCII-bokstäver ligger utanför den klassen läses varje gräns intill dem som en icke-ordsgräns. I praktiken betyder det att sökning på hela ord över CJK-text beter sig som om varje position vore en giltig ordgräns, så flaggan i praktiken degraderas till delsökning där. Det är en dokumenterad begränsning, inte en bugg, och den stämmer med beteendet funktionen modellerades efter. Om ditt textkorpus främst är CJK kommer läget för hela ord inte att ge dig den segmentering en särskild tokenizer skulle göra; planera runt det i stället för att förlita dig på det
En implementationnot som förklarar en klass av subtila fel på andra håll: den skiftlägesokänsliga jämförelsen använder UpperCase på WideString, inte AnsiUpperCase. Den Ansi-varianten returnerar ett AnsiString, vilket inte skulle linjera med den WideString som resten av sökvägen använder, och att blanda de två ger typkonflikter och, värre, förlustbringande vikning för tecken utanför den aktiva kodsidan. Unicode in, Unicode ut hela vägen
En parser för sidintervall för hela biblioteket
En sidintervallsträng som "1,3,5-9". Det finns inget specialbyggt i hur den tolkas: samma PLParsePageRangeList parser som ligger bakom PrintPages och sidkopieringsrutinerna hanterar den här också, så ett intervall som skrivs ut rätt söks också rätt. En tom intervallsträng är signalen för "varje sida", och i så fall bygger SearchText själv den fullständiga listan
Omfånget spelar roll för kostnaden. Att söka ett tio sidor långt utsnitt i ett dokument på tusen sidor extraherar block för tio sidor, inte tusen, eftersom loopen bara väljer och extraherar de sidor intervallet nämner. När du redan vet att en klausul ligger i bilagan, säg det i intervallet och hoppa över resten av filen
internt ändrar både sök och uppräkning den valda sidan när de itererar, så var och en sparar den valda sidan vid inläsning och återställer den i en finally block. Anropa SearchText mitt i att bygga en sida och ditt sidval är exakt där du lämnade det när anropet returnerar. Den där spara-och-återställ-kontraktet är den sorts sak man bara märker när den saknas, vilket är precis varför den finns där
Att enumerera en hel sida: text och bilder i en lista
Sök svarar på "var är det här ordet". Den andra halvan av introspektionen är "vad finns över huvud taget på den här sidan", och det är EnumPageElements. Den returnerar en enda gemensam lista där varje element antingen är ett textblock eller en inbäddad bild, skiljt åt av ett Kind fält:
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;
Textelement kommer från samma ExtractPageTextBlocks pass, så varje sådant landar redan med sin rektangel, sitt typsnittsnamn och sin storlek ifyllda. Bildelement kommer från sidans lista över inbäddade bilder via FindImages och GetImageID; den ImageID de bär är handtaget du matar till SelectImage för att inspektera bilden vidare. De två typerna hamnar i en array så att en enda genomgång av en sida ser allt som finns på den
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;
Här finns en räknekonvention som följer resten av biblioteket och som du måste respektera annars läser du oinitierat minne. Returvärdet är det totala elementantalet, som kan vara större än arrayen du skickade in. Funktionen fyller bara så många platser som får plats och fortsätter räkna resten, precis som signaturenumerering fungerar. Så skyddsregeln är alltid densamma: begränsa din loop till det mindre av det returnerade antalet och High(array), iterera aldrig blint till antalet. Exemplen ovan visar I <= High(...) kontrollen av just den anledningen. Om returvärdet överskrider din buffert, använd en större array och anropa igen
Om du har använt bibliotekets lägre nivåns textblocksanrop är detta det typade, geometrimedvetna lagret ovanpå dem; den underliggande extraktionen är samma som beskrivs i Delphi PDF-text-, bild- och typsnittsextraktion med PDFlibPas. Och när målet inte är "var finns den här texten" utan "hur är det här dokumentet strukturerat för hjälpmedelsteknik", är den parallella lästilläggsberättelsen taggade PDF-strukturträdet, som exponerar den logiska läsordningen snarare än den fysiska blocklayouten
Regionsfrågor när du redan vet var du ska leta
Ibland har du inte något sökord alls; du har en rektangel. En formulärmall placerar alltid fakturanumret uppe till höger, eller en scannad layout reserverar ett fast band för en tabell. GetTextInAreaEx hanterar det fallet. Det är den gränsbärande motsvarigheten till GetTextInArea: där den äldre funktionen lämnar tillbaka en platt lista med strängar för ett område, returnerar den nya rektangeln för varje bevarat block bredvid dess text, så att du inte bara ser vad som finns i rutan utan också var inne i den varje rad ligger
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;
Två saker att hålla isär. GetTextInAreaEx arbetar på den sida som för tillfället är vald, så anropa SelectPage först; till skillnad från SearchText, tar den inte något intervall. Och ett block behålls när det intersects frågerektangeln, inte bara när det ligger helt inuti den, så en rad som går över gränsen följer ändå med. Det är oftast vad du vill ha för en handritad markeringsruta, men om du behöver strikt inneslutning kan du filtrera de returnerade rektanglarna själv, eftersom du nu har dem
Att få det att fungera
Röda tråden genom alla tre anropen är att geometrin inte längre är något du rekonstruerar i efterhand. En sökträff känner sin sida och sin ruta. Ett sidelement känner sin rektangel och, för text, sitt typsnitt. En regionsfråga rapporterar var varje rad hamnar. Det räcker för att bygga en riktig hitta-och-markera-funktion, ett klicka-för-att-lokalisera-index eller en layoutmedveten extraherare utan att falla under det publika API:et eller bygga om textextraktionspipen för hand
Dessa fråge-API:er levereras som en del av PDFlibPas Delphi PDF Library, tillsammans med det fullständiga textblocksextraktionslagret de bygger på och resten av lästsidans introspektionsyta för Delphi och C++Builder