Technisch artikel

Een doorzoekbare tekstlaag toevoegen aan gescande PDF's in Delphi

PDFium Component voegt vanuit Delphi via ApplyOcrSearchLayer een doorzoekbare tekstlaag toe aan gescande PDF-pagina's. Het rendert elke geselecteerde pagina, geeft de pixels door aan een OCR-provider die u zelf levert, en schrijft de herkende woorden terug als onzichtbare tekstobjecten die over de woorden in de scan zijn gepositioneerd. De originele pagina-afbeelding wordt nooit gedecodeerd, opnieuw gecodeerd of vervangen, dus het visuele resultaat is byte voor byte de pagina waarmee u begon

De herkenningsengine is bewust geen onderdeel van de bibliotheek. PDFium biedt paginarendering, coördinaatafbeelding, lettertypeladen, aanmaak van tekstobjecten en onzichtbare rendermodi, maar bevat geen OCR-engine, en doen alsof dat wel zo was, zou betekenen dat iemands herkenningsproduct in een PDF-component wordt gebundeld. In plaats daarvan bevindt herkenning zich achter de IPdfOcrProvider-interface: de bibliotheek geeft vast-layout, top-origin BGRA-pixels door, en de provider retourneert Unicode-tekst, betrouwbaarheidswaarden en woordvierhoeken

Wat is een doorzoekbare tekstlaag precies?

Een gescande PDF is een foto van een document. De pagina-inhoud is één grote afbeelding, en er is niets om te selecteren, doorzoeken, kopiëren of indexeren. Een doorzoekbare tekstlaag voegt echte tekstobjecten toe bovenop die afbeelding, met de rendermodus ingesteld op onzichtbaar, zodat viewers niets tekenen, maar selectie, zoeken en extractie de woorden precies vinden waar ze verschijnen

Positionering is het hele spel. Als de onzichtbare tekst een paar punten verschoven staat, landen selectiemarkeringen naast de woorden in plaats van erop, en het kopiëren van een alinea produceert tekst in de verkeerde volgorde. Daarom moet de geometrie afkomstig zijn van dezelfde transformaties die PDFium gebruikt om de pagina te renderen, in plaats van van een proportionele schatting

De provider implementeren

Het providercontract is één methode. Het ontvangt een paginaafbeeldingsrecord met afmetingen, stride, DPI, pixelformaat en de pixelbytes zelf, plus een annuleringstoken, en retourneert woorden of een foutmelding:

uses
  PDFium;

type
  TMyOcrProvider = class(TInterfacedObject, IPdfOcrProvider)
  public
    function RecognizePage(const Image: TPdfOcrImage;
      const CancellationToken: IPdfCancellationToken;
      out Words: TPdfOcrWords; out ErrorMessage: string): Boolean;
  end;

function TMyOcrProvider.RecognizePage(const Image: TPdfOcrImage;
  const CancellationToken: IPdfCancellationToken;
  out Words: TPdfOcrWords; out ErrorMessage: string): Boolean;
var
  I: Integer;
begin
  // Image.Pixels bevat top-origin BGRA-rijen van Image.Stride bytes.
  // Geef ze door aan uw engine en vul dan één item per herkend woord
  SetLength(Words, RecognisedCount);
  for I := 0 to RecognisedCount - 1 do
  begin
    Words[I].Text := EngineWordText(I);
    Words[I].Confidence := EngineWordConfidence(I);   // 0..1
    Words[I].Quad := TPdfOcrQuad.FromRectangle(
      EngineLeft(I), EngineTop(I), EngineRight(I), EngineBottom(I));
  end;
  ErrorMessage := '';
  Result := True;
end;

Quads in plaats van rechthoeken, omdat een scan zelden haaks op de pagina staat. Een woord op een licht gedraaide pagina neemt een parallellogram in beslag, en TPdfOcrQuad draagt vier hoekpunten zodat scheve en geroteerde woorden een nauwkeurig selectiegebied behouden. Engines die alleen assengelijnde vakken rapporteren, kunnen FromRectangle gebruiken, dat de gedegenereerde quad opbouwt

Waarom kunnen woordposities niet proportioneel worden geschaald?

Het is verleidelijk om een pixelcoördinaat om te zetten naar een paginacoördinaat door te delen door de renderbreedte en te vermenigvuldigen met de paginabreedte. Dat werkt alleen voor pagina's zonder rotatie, met een CropBox identiek aan de MediaBox, en een oorsprong bij nul, en heel wat gescande documenten voldoen niet aan minstens een van die voorwaarden

PDFium Component beeldt elk van de vier quadhoeken afzonderlijk af via FPDF_DeviceToPage, dezelfde afbeelding die de renderer gebruikte om de pixels te produceren, dus /Rotate-vermeldingen en verschoven crop boxes worden door constructie afgehandeld. De affiene matrix voor het tekstobject wordt vervolgens opgebouwd uit drie van de afgebeelde punten, de linksonder-, rechtsonder- en linksbovenhoek, wat precies genoeg is om positie, schaal, rotatie en schuinte uit te drukken

Het tekstobject zelf wordt aangemaakt op eenheidslettergrootte, zodat de werkelijke lettertypegrenzen kunnen worden gemeten, en de gemeten objectgrenzen worden vervolgens afgebeeld op de doelquad. Formaat bepalen op basis van een geschatte puntgrootte en hopen dat die overeenkomt met het gescande woord, zou bij elke lettertypevervanging afdrijven; eerst meten maakt de pasvorm onafhankelijk van welk lettertype de laag gebruikt

Het over een document laten lopen

Het optiesrecord bepaalt resolutie, filtering en elk budget. Betrouwbaarheidsfiltering doet er meer toe dan het lijkt: rommelwoorden met een lage betrouwbaarheid vervuilen zoekresultaten permanent, en anders dan bij een verkeerde rendering merkt niemand het totdat een zoekopdracht onzin oplevert:

var
  Pdf: TPdf;
  Options: TPdfOcrOptions;
  Report: TPdfOcrReport;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'scanned-contract.pdf';
    Pdf.LoadDocument;

    Options := TPdfOcrOptions.Default;
    Options.Dpi := 300;                  // herkenningsresolutie
    Options.MinConfidence := 0.60;       // laat onzekere woorden vallen
    Options.SkipPagesWithText := True;   // laat born-digital pagina's met rust
    Options.ContinueOnError := True;     // één slechte pagina mag de taak niet stoppen
    Options.MaxPixelsPerPage := 40 * 1000 * 1000;

    if Pdf.ApplyOcrSearchLayer(TMyOcrProvider.Create, Options, Report) then
      Pdf.SaveAs('scanned-contract-searchable.pdf');

    for I := 0 to High(Report.Pages) do
      if Report.Pages[I].Status = popsFailed then
        Writeln(Format('page %d failed: %s',
          [Report.Pages[I].PageNumber, Report.Pages[I].ErrorMessage]));
    Writeln(Format('%d word(s) inserted, %d rejected, %d page(s) skipped',
      [Report.InsertedWordCount, Report.RejectedWordCount,
       Report.SkippedPageCount]));
  finally
    Pdf.Free;
  end;
end;

SkipPagesWithText verdient nadruk in gemengde archieven. Een PDF die al echte tekst draagt, ongeacht of deze born digital is of eerder verwerkt, krijgt een tweede tekstlaag als u er blindelings OCR overheen laat lopen, en het duplicaat zorgt ervoor dat extractie elk woord tweemaal retourneert. De per-pagina-status popsSkippedExistingText vertelt u precies welke pagina's met rust zijn gelaten

Budgetten, annulering en foutindamming

Elke grootheid die een vijandig of gewoon enorm document kan opblazen, heeft een plafond: pixels per pagina en in totaal, woorden per pagina en in totaal, en tekens per woord. Ze worden allemaal gecontroleerd voordat de pagina wordt geschreven, niet erna, en de pixelschatting wordt berekend op basis van paginadimensies en DPI voordat er een bitmap wordt toegewezen. Een DPI-verhoging van 150 naar 300 verviervoudigt het geheugen per pagina, dus het plafond per pagina is de parameter om als eerste af te stellen wanneer een batchtaak begint te falen op grote formaten

Het annuleringstoken loopt door het hele pad: progressieve rendering, de provideraanroep en de per-woord invoeglus. Dat betekent dat een gebruiker die annuleert tijdens de herkenning van een bestand van 400 pagina's binnen één pagina stopt in plaats van aan het einde van het document, en hetzelfde tokenpatroon dat elders in het component wordt gebruikt, beschreven in annuleerbare progressieve rendering, geldt hier ongewijzigd

Foutindamming gebeurt per pagina. De bibliotheek verzamelt de objecthandles die het op een pagina heeft ingevoegd en roept FPDFPage_GenerateContent eenmaal aan, nadat alle woorden zijn geplaatst. Als er halverwege iets misgaat, of het nu een providerfout of een lettertypeprobleem is, worden de op die pagina ingevoegde objecten in omgekeerde volgorde verwijderd en wordt de pagina-inhoud opnieuw gegenereerd, zodat een mislukte pagina terugkeert naar zijn oorspronkelijke staat in plaats van een halve tekstlaag te behouden. De documentlus gaat vervolgens door of stopt volgens ContinueOnError, en de actieve pagina wordt altijd hersteld

Verifiëren dat de afbeelding echt onaangeroerd bleef

De sterkste beschikbare controle is ook de eenvoudigste: render de pagina vóór en na het toepassen van de laag op dezelfde grootte en vergelijk de bitmaps. Ze zouden byte voor byte identiek moeten zijn, omdat onzichtbare tekst niets tekent en de afbeeldingsstream nooit is gedecodeerd. Elk verschil betekent dat iets anders dan de tekstlaag de pagina heeft veranderd

Verifieer daarna de tekstkant door te extraheren uit het verwerkte bestand en te bevestigen dat woordposities op de scan terechtkomen. Het extractiepad is hetzelfde als beschreven in tekst extraheren uit PDF-documenten, en voor een snelle visuele controle van de uitlijning kunt u met het renderen van pagina's naar afbeeldingen zoals in PDF-pagina's converteren naar JPEG woordvakken over de scan leggen

OCR-laagvorming, rendering, extractie en bewerking draaien allemaal tegen hetzelfde documentobject in Delphi, C++Builder en Lazarus; het volledige API-oppervlak wordt beschreven op de PDFium Component voor Delphi-pagina