Technisch artikel

PDF-tekst zoeken in Delphi met hitcoördinaten: PDFlibPas

De tekst van een pagina extraheren is de makkelijke helft van het probleem. Op het moment dat een gebruiker een woord in een zoekvak typt en verwacht dat de viewer ernaartoe springt en er een gele box omheen tekent, heb je iets nodig dat de platte tekststring je niet geeft: de pagina waarop elke match staat, en de rechthoek die hij in PDF-coördinaten inneemt. Een string die over een pagina wordt samengevoegd, heeft die geometrie al verloren. Je kunt de substring vinden, maar je kunt er niet naar wijzen

PDFlibPas is een native Object Pascal PDF-bibliotheek voor Delphi en C++Builder, en vanaf v3.78.0 beantwoordt hij precies die vraag. Drie query-API's zitten bovenop de bestaande text-block extractor: SearchText loopt een page range af en geeft elke hit terug met zijn pagina en as-aligned rechthoek, EnumPageElements somt alles op één pagina op, zowel tekstblokken als ingesloten afbeeldingen, en GetTextInAreaEx rapporteert de rechthoek van elk blok binnen een regio in plaats van ze plat te slaan tot een stringlijst. Geen van hen raakt het write-pad; het zijn pure read-side toevoegingen bovenop mechaniek die de bibliotheek al had

Waarom de geometrie in de text-block lijst leeft, niet in de funnel

De natuurlijke reflex is om te hergebruiken wat GetPageText intern draait. Dat pad gaat door een tijdelijke extraction funnel die de pagestring produceert en zich daarna vrijgeeft voordat de call terugkeert. Tegen de tijd dat je het resultaat hebt, zijn de per-block coördinaten weg. Ze waren nooit van jou om te bewaren

De coördinaten overleven wel in een andere structuur. ExtractPageTextBlocks(3) geeft een text-block-lijsthandle terug waarvan de items elk een bounding quad van acht doubles dragen, plus een fontnaam, een fontgrootte en de tekst van het blok. Die handle is de enige plek waar de geometrie na extractie bewaard blijft, en daarom bouwen alle nieuwe query-API's daarop voort in plaats van op de funnel. Door de bloklijst te hergebruiken delen search, enumeration en region queries één extractiepass en één definitie van waar een blok ligt

De vorm van SearchText volgt daaruit. Voor elke pagina in de range extraheert hij de bloklijst, leest de tekst van elk blok met GetTextBlockText, test die tegen de query en reduceert voor de passende blokken de quad tot een rechthoek. De hit die hij teruggeeft is een klein record:

De bound array is X/Y-geïnterleaved, niet vier hoeken

Dit is het detail dat het eerst bijt. GetTextBlockBound(ListID, Index, BoundIndex) neemt een BoundIndex van 1 tot 8, en die acht waarden zijn niet "corner 1, corner 2, corner 3, corner 4" met per hoek twee velden gegroepeerd zoals je zou raden. Het zijn X, Y, X, Y, X, Y, X, Y: de oneven indices zijn X-coördinaten, de even indices zijn Y-coördinaten, vier punten in totaal. Lees je ze in de verkeerde koppeling, dan wordt je rechthoek onzin

De reden dat er überhaupt een quad is en niet een gewone rechthoek, is rotatie. Een text block dat schuin staat heeft een echte vierpunts bounding polygon, en de acht doubles beschrijven die trouw. Voor het highlight-and-jump-gebruik wil je meestal liever een rechtopstaande box, dus de bibliotheek reduceert de quad tot een as-aligned rechthoek door de vier punten te scannen op hun minimale en maximale X en Y. Gedraaide tekst valt terug naar de rechtopstaande box die hem omsluit, en dat is precies wat een highlight-overlay nodig heeft:

Let op dat de rechthoek in PDF user-space points staat met de oorsprong linksonder op de pagina, hetzelfde coördinatensysteem dat je naar tekenen en annotatiecalls stuurt. Dat is opzettelijk: de rechthoek die je uit een search hit terugkrijgt, kun je rechtstreeks aan een highlight-annotatie of een "scroll hier"-commando geven zonder iets om te rekenen

Case sensitivity, whole words en waar CJK verschilt

De tweede parameter is een TPDFlibSearchOptions-set, opgebouwd uit soCaseSensitive en soWholeWord. De lege set [] is het gewone geval: een case-insensitive substring search. Voeg soCaseSensitive toe om Indemnity en indemnity verschillend te maken, voeg soWholeWord toe om te voorkomen dat sign matcht binnen signature, of combineer beide

Whole-word matching heeft een definitie van wat een woordgrens is, en die regel is de moeite waard om expliciet te noemen omdat hij bewust ASCII-centrisch is. Een karakter telt als onderdeel van een woord wanneer het een ASCII-letter, een ASCII-cijfer of een underscore is: de [A-Za-z0-9_]-klasse die je kent uit identifierregels. Een match telt alleen als whole word wanneer de tekens direct ervoor en erna geen woordkarakters zijn, of wanneer de match op de rand van het blok staat

De consequentie voor niet-Latijnse scripts is iets om te weten voor je een meertalige zoekbox uitrolt. Omdat Han-tekens, kana en andere niet-ASCII letters buiten die klasse vallen, leest elke grens naast hen als een non-word edge. In de praktijk betekent dat dat whole-word search over CJK-tekst zich gedraagt alsof elke positie een geldige woordgrens is, dus de vlag degradeert daar effectief tot substring matching. Dat is een gedocumenteerde beperking, geen bug, en het past bij het gedrag waarop de feature is gemodelleerd. Als je corpus hoofdzakelijk CJK is, zal whole-word mode je niet de segmentatie geven die een dedicated tokenizer wel zou; plan daar omheen in plaats van erop te vertrouwen

Eén implementatievoetnoot die een klasse subtiele fouten elders verklaart: de case-insensitive vergelijking gebruikt UpperCase op de WideString, niet AnsiUpperCase. De Ansi-variant geeft een AnsiString terug, wat niet zou aansluiten op de WideString die de rest van het pad gebruikt, en die twee mengen levert type mismatches op en, erger, verliesgevende folding voor karakters buiten de actieve codepage. Unicode erin, Unicode eruit, het hele pad door

Eén page range parser voor de hele bibliotheek

De derde parameter is een page-range string zoals "1,3,5-9". Er is niets custom aan hoe die wordt geparseerd: dezelfde PLParsePageRangeList die PrintPages en de page-copy-routines bedient, doet dit hier ook, dus een range die correct print, zoekt ook correct. Een lege range string is het signaal voor "elke pagina", en in dat geval bouwt SearchText zelf de volledige lijst

Scope maakt uit voor de kosten. Een tienpagina-slice van een document van duizend pagina's zoekt blokken voor tien pagina's, niet voor duizend, omdat de lus alleen de pagina's selecteert en extraheert die de range noemt. Als je al weet dat een clausule in de appendix staat, zeg dat dan in de range en sla de rest van het bestand over

Intern veranderen search en enumeration de geselecteerde pagina terwijl ze itereren, dus elk van beide bewaart de door de aanroeper geselecteerde pagina bij binnenkomst en herstelt die in een finally-blok. Roep SearchText midden in het bouwen van een pagina aan, en je selectie staat exact terug waar je hem liet toen de call terugkeerde. Dat save-and-restore-contract merk je alleen wanneer het ontbreekt, en juist daarom zit het er

Een hele pagina enumereren: tekst en afbeeldingen in één lijst

Search beantwoordt "waar staat dit woord". De andere helft van introspectie is "wat staat er überhaupt op deze pagina", en dat is EnumPageElements. Die geeft één uniforme lijst terug waarin elk element óf een tekstblok óf een ingesloten afbeelding is, onderscheiden door een Kind-veld:

Tekstelementen komen uit dezelfde ExtractPageTextBlocks-pass, dus elk van hen arriveert met zijn rechthoek, fontnaam en grootte al ingevuld. Afbeeldingselementen komen uit de ingesloten-afbeeldingenlijst van de pagina via FindImages en GetImageID; de ImageID die ze dragen is de handle die je aan SelectImage geeft om de afbeelding verder te inspecteren. De twee kinds landen in één array zodat één wandeling over een pagina alles erop ziet

Er zit hier een telconventie die de rest van de bibliotheek volgt en die je moet respecteren, anders lees je ongeïnitialiseerd geheugen. De return value is het totale elementenaantal, en dat kan groter zijn dan de array die je hebt meegegeven. De functie vult alleen zoveel slots als passen en blijft de rest tellen, precies zoals signature enumeration werkt. Dus de guard is altijd dezelfde: clamp je lus tot de kleinere van de returned count en High(array), nooit blind tot de count itereren. De voorbeelden hierboven tonen daarom de I <= High(...)-check. Is de returnwaarde groter dan je buffer, neem dan een grotere array en roep opnieuw aan

Als je de lagere-level text-block calls van de bibliotheek kent, dan is dit de getypeerde, geometrie-bewuste laag daarbovenop; de onderliggende extractie is dezelfde als beschreven in Delphi PDF text, image, and font extraction with PDFlibPas. En als het doel niet "waar staat deze tekst" is maar "hoe is dit document gestructureerd voor assistive technology", dan is het parallelle read-side-verhaal de tagged-PDF structure tree, die de logische leesvolgorde blootlegt in plaats van de fysieke bloklay-out

Region queries wanneer je al weet waar je moet kijken

Soms heb je geen zoekterm maar een rechthoek. Een formsjabloon zet het factuurnummer altijd rechtsboven, of een gescande lay-out reserveert een vaste band voor een tabel. GetTextInAreaEx bedient dat geval. Het is het bounds-dragende tegenstuk van GetTextInArea: waar de oudere call een vlakke lijst strings voor een regio teruggeeft, geeft deze per behouden blok ook de rechthoek mee, zodat je niet alleen leert wat er in de box staat, maar ook waar binnen die box elke regel zit

Twee dingen moeten scherp blijven. GetTextInAreaEx werkt op de momenteel geselecteerde pagina, dus roep eerst SelectPage aan; anders dan SearchText neemt het geen range. En een blok wordt behouden wanneer het de query-rechthoek raakt, niet alleen wanneer het volledig erin zit, dus een regel die de grens kruist komt nog steeds door. Dat is meestal wat je wilt voor een handgetekende selectiekader, maar als je strikte containment nodig hebt, kun je de teruggegeven rechthoeken zelf filteren, want die heb je nu

Het gebruiken in de praktijk

De rode draad door alle drie calls is dat geometrie niet langer iets is wat je achteraf reconstrueert. Een search hit weet zijn pagina en zijn box. Een page element kent zijn rechthoek en, voor tekst, zijn font. Een region query rapporteert waar elke regel valt. Dat is genoeg om een echte find-and-highlight-feature, een click-to-locate-index of een layoutbewuste extractor te bouwen zonder onder de publieke API te duiken of de text-extraction-pijplijn handmatig opnieuw te bouwen

Deze query-API's worden meegeleverd als onderdeel van de PDFlibPas Delphi PDF Library, samen met de volledige text-block extractielaag waarop ze gebouwd zijn en de rest van de read-side introspectiesurface voor Delphi en C++Builder

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;
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;
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;
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;
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;