Technisch artikel

Tekst extraheren uit PDF-bestanden met PDFium Component in Delphi

Tekstextractie uit PDF-bestanden lijkt eenvoudig totdat u een document tegenkomt waarin de tekstlaag ontbreekt, beschadigd is of is opgesplitst in tientallen kleine karakterreeksen zonder betekenisvolle volgorde. PDFium Component biedt u twee invoerpunten: de array Character[] voor ruwe, op index gebaseerde toegang tot elke glyph op een pagina, en ReadablePageContent voor een gestructureerde weergave die alinea's en koppen reconstrueert uit de tag-structuur van de PDF of via een heuristische analyse. Geen van beide is altijd de juiste keuze, dus het is belangrijk om te begrijpen wat ze elk blootleggen

Het document openen en de valkuil van geruisloze fouten

TPdf opent een bestand door FileName in te stellen en Active := True in te schakelen. Het kritieke detail: Active := True roept nooit een uitzondering op. Als het bestand ontbreekt, met een wachtwoord is beveiligd of beschadigd is, PDFium de fout intern opvangt en blijft Active simpelweg op False staan. Dit betekent dat elke extractielus hiertegen moet waken:

Pdf := TPdf.Create(nil);
try
  Pdf.FileName := 'report.pdf';
  Pdf.Active := True;
  if not Pdf.Active then
  begin
    ShowMessage('Kon PDF niet openen (beschadigd of verkeerd wachtwoord)');
    Exit;
  end;
  // extractie volgt hier
finally
  Pdf.Active := False;
  Pdf.Free;
end;

Bestanden die met een wachtwoord zijn beveiligd, vereisen dat Pdf.Password := '...' wordt ingesteld voordat Active := True wordt ingeschakeld. Er is geen tweede kans: zodra Active faalt, sluit u het bestand en opent u het opnieuw met het juiste wachtwoord

Pagina-voor-pagina extractie met Character[]

De meest basale benadering doorloopt elk karakter op elke pagina. Stel Pdf.PageNumber in om de tekstlaag voor die pagina te laden, en doorloop vervolgens de CharacterCount-vermeldingen met behulp van de eigenschap Character[]. Twee vlaggen op elke vermelding zijn het controleren waard: CharacterGenerated[i] markeert synthetische glyphs die door de renderer zijn ingevoegd (zoals zachte afbreektekens aan het einde van een regel) die geen echte Unicode-waarde hebben, en CharacterMapError[i] geeft aan dat PDFium de glyph niet kon toewijzen aan een code point, wat gebeurt bij lettertype-coderingen die een ToUnicode-tabel missen

procedure ExtractAllText(Pdf: TPdf; Output: TStrings);
var
  Page, I: Integer;
  Line: string;
  Ch: WideChar;
begin
  for Page := 1 to Pdf.PageCount do
  begin
    Pdf.PageNumber := Page;
    Line := '';
    for I := 0 to Pdf.CharacterCount - 1 do
    begin
      if Pdf.CharacterGenerated[I] or Pdf.CharacterMapError[I] then
        Continue;
      Ch := Pdf.Character[I];
      if Ch = #13 then
        Ch := #10;   // CR normaliseren naar LF
      Line := Line + Ch;
    end;
    Output.Add(Line);
  end;
end;

Het resultaat is een platte string van Unicode-code points in de volgorde waarin PDFium ze opsomt. Dit is de volgorde waarin ze in de inhoudsstroom verschijnen, en niet noodzakelijkerwijs de leesvolgorde van links naar rechts. Voor de meeste Latijnse documenten die met standaard kantoorsoftware zijn geproduceerd is dit prima. Voor gescande PDF's die via OCR zijn verwerkt met ongebruikelijke glyph-volgordes, of voor tekst die van rechts naar links leest, kan de volgorde onjuist zijn. In dat geval is ReadablePageContent nuttiger

Gestructureerde extractie met ReadablePageContent

ReadablePageContent gaat een niveau hoger: het retourneert een TPdfReadableContent-record waarvan de array Fragments getagde inhoudsfragmenten bevat, elk met een Kind die alinea's, koppen, lijstitems, tabelcellen, enzovoort identificeert. Wanneer de PDF een structuurboom bevat (controleer Pdf.IsTagged), is de bron rosStructure en is de leesvolgorde gezaghebbend. Voor niet-getagde bestanden valt PDFium terug op rosHeuristic, die karakters op basis van hun begrenzingskaders groepeert in aannemelijke leeseenheden, maar de nauwkeurigheid niet kan garanderen

procedure ExtractStructured(Pdf: TPdf; Output: TStrings);
var
  Page: Integer;
  Content: TPdfReadableContent;
  Fragment: TPdfContentFragment;
begin
  for Page := 1 to Pdf.PageCount do
  begin
    Content := Pdf.ReadablePageContent(Page);
    for Fragment in Content.Fragments do
    begin
      case Fragment.Kind of
        cfHeading   : Output.Add('# ' + Fragment.Text);
        cfParagraph : Output.Add(Fragment.Text);
        cfListItem  : Output.Add('- ' + Fragment.Text);
      else
        Output.Add(Fragment.Text);
      end;
    end;
  end;
end;

Als Content.Source = rosHeuristic en uw uitvoer er verminkt uitziet, is de tekstlaag van het document waarschijnlijk niet geschreven met de leesvolgorde in het achterhoofd. Op dat moment is de enige betrouwbare oplossing het opnieuw exporteren vanuit de bronapplicatie met de juiste tagging, of het uitvoeren van een nabewerkingsstap die de karakteroorsprongen sorteert op Y en vervolgens op X

Wat CharacterOrigin en CharacterRectangle u bieden

Beide eigenschappen retourneren de positie van een karakter in de paginaruimte (in punten, met de oorsprong in de linkerbenedenhoek en Y die naar boven toe toeneemt). CharacterOrigin[i] is het basislijnas-ankerpunt van de glyph; CharacterRectangle[i] is het volledige begrenzingskader (bounding box). Dit zijn de bouwstenen voor alles wat verder gaat dan platte tekst: het detecteren van kolomgrenzen, het groeperen van karakters in regels door Y-coördinaten binnen een tolerantie te vergelijken, of het bouwen van een hit-testkaart voor tekstselectie in een viewer. Als u wilt weten welk karakter zich onder een muisklik bevindt, voert CharacterIndexAtPos(X, Y, ToleranceX, ToleranceY) die zoekopdracht rechtstreeks uit zonder dat u de rechthoeken hoeft te doorlopen

De DLL op zijn plaats krijgen

PDFium Component delegeert alle PDF-ontleding aan een systeemeigen DLL, ofwel pdfium32.dll of pdfium64.dll, afhankelijk van uw doelplatform. De component wordt geleverd met een CopyDlls.bat-script dat het juiste bestand naar de Windows-systeemmap kopieert. Dit script eenmalig als Administrator uitvoeren op een ontwikkelmachine is voldoende; voor distributie kopieert u de DLL in plaats daarvan naast het uitvoerbare bestand van de applicatie. De V8-compatibele varianten (pdfium32v8.dll, pdfium64v8.dll) zijn aanzienlijk groter en alleen nodig als uw PDF's JavaScript bevatten dat moet worden uitgevoerd. Voor pure tekstextractie is de standaard build de juiste keuze

Als de DLL tijdens runtime ontbreekt, zal Active := True geruisloos falen, net zoals bij een ontbrekend bestand, omdat de component de laadfout intern opvangt. Test altijd op een schone machine voordat u de applicatie distribueert

FontSize[] gebruiken naast Character[] voor lay-outanalyse

Naast platte tekst stelt de API op karakterniveau FontSize[i] beschikbaar, die de gerenderde puntgrootte van elke glyph retourneert. Gecombineerd met CharacterOrigin[i] en CharacterRectangle[i] stelt dit u in staat om hoofdtekst te onderscheiden van koppen zonder afhankelijk te zijn van de structuurboom. Een karakterreeks waarin de lettergrootte boven een drempelwaarde stijgt, is in een niet-getagd document vrijwel zeker een kop. Dezelfde techniek kan worden toegepast om bijschriften (kleine tekst onder het begrenzingskader van een afbeelding) of voetnoten (kleine tekst onderaan de pagina) te detecteren. Dit vereist geen rendering; alle drie de eigenschappen lezen rechtstreeks uit de tekstlaag die PDFium opbouwt tijdens Active := True

Eén nuance: FontSize[i] weerspiegelt de grootte nadat de CTM (current transformation matrix) van de pagina is toegepast. Een document waarin de auteur de hele pagina heeft geschaald, zal dus proportioneel aangepaste groottes rapporteren. Als u groottes vergelijkt over pagina's met verschillende pagina-afmetingen, normaliseer dan ten opzichte van de MediaBox-hoogte van elke pagina voordat u beslissingen neemt over drempelwaarden

De uitvoer naar een bestand schrijven

De TStringList van Delphi handelt UTF-8-uitvoer sinds XE probleemloos af. Stel WriteBOM := False in als u een BOM-vrij bestand nodig hebt (veel downstream-verbruikers verslikken zich in een leidende BOM):

var
  Lines: TStringList;
begin
  Lines := TStringList.Create;
  try
    ExtractAllText(Pdf, Lines);
    Lines.WriteBOM := False;
    Lines.SaveToFile('output.txt', TEncoding.UTF8);
  finally
    Lines.Free;
  end;
end;

Voor zeer grote documenten waarbij het geheugen een rol speelt, schrijft u in de paginalus rechtstreeks naar een TStreamWriter met TEncoding.UTF8, in plaats van eerst alles in een lijst te verzamelen

De Character[]-, CharacterCount-, CharacterOrigin[]-, CharacterRectangle[]-, ReadablePageContent- en CharacterIndexAtPos-API's die hier worden getoond, maken deel uit van het PDFium Component voor Delphi en C++Builder