Artykuł techniczny

Dodawanie przeszukiwalnej warstwy tekstu do zeskanowanych PDF-ów w Delphi

PDFium Component dodaje z Delphi przeszukiwalną warstwę tekstu do zeskanowanych stron PDF za pomocą ApplyOcrSearchLayer. Renderuje każdą wybraną stronę, przekazuje piksele dostawcy OCR, którego sam dostarczasz, i zapisuje z powrotem rozpoznane słowa jako niewidoczne obiekty tekstowe umieszczone nad słowami na skanie. Oryginalny obraz strony nigdy nie jest dekodowany, ponownie kodowany ani zastępowany, więc wizualny wynik to bit po bicie ta sama strona, od której zacząłeś

Silnik rozpoznawania celowo nie jest częścią biblioteki. PDFium udostępnia renderowanie stron, mapowanie współrzędnych, wczytywanie fontów, tworzenie obiektów tekstowych i niewidoczne tryby renderowania, ale nie zawiera silnika OCR, a udawanie inaczej oznaczałoby wpakowanie czyjegoś produktu rozpoznającego do komponentu PDF. Zamiast tego rozpoznawanie mieszka za interfejsem IPdfOcrProvider: biblioteka przekazuje piksele BGRA o stałym układzie i początku w górnym rogu, a dostawca zwraca tekst Unicode, wartości pewności i czworokąty słów

Czym dokładnie jest przeszukiwalna warstwa tekstu?

Zeskanowany PDF to zdjęcie dokumentu. Treść strony to jeden duży obraz i nie ma tam nic do zaznaczenia, wyszukania, skopiowania ani zindeksowania. Przeszukiwalna warstwa tekstu dodaje prawdziwe obiekty tekstowe na wierzchu tego obrazu, z trybem renderowania ustawionym na niewidoczny, więc przeglądarki nic nie rysują, a mimo to zaznaczanie, wyszukiwanie i ekstrakcja znajdują słowa dokładnie tam, gdzie się pojawiają

Pozycjonowanie to cała gra. Jeśli niewidoczny tekst siedzi kilka punktów obok, podświetlenie zaznaczenia ląduje obok słów, a nie na nich, a skopiowanie akapitu daje tekst w złej kolejności. Dlatego geometria musi pochodzić z tych samych transformacji, których PDFium używa do renderowania strony, a nie z proporcjonalnego odgadywania

Implementowanie dostawcy

Kontrakt dostawcy to jedna metoda. Otrzymuje rekord obrazu strony niosący wymiary, stride, DPI, format pikseli i same bajty pikseli, plus token anulowania, a zwraca słowa albo komunikat błędu:

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 zawiera wiersze BGRA o początku w górnym rogu, po Image.Stride bajtów.
  // Przekaż je swojemu silnikowi, następnie wypełnij jeden wpis na każde rozpoznane słowo
  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;

Czworokąty zamiast prostokątów, ponieważ skan rzadko jest idealnie prostopadły do strony. Słowo na lekko obróconej stronie zajmuje równoległobok, a TPdfOcrQuad niesie cztery punkty narożne, dzięki czemu przekrzywione i obrócone słowa zachowują dokładny region zaznaczenia. Silniki zgłaszające tylko prostokąty wyrównane do osi mogą użyć FromRectangle, które buduje zdegenerowany czworokąt

Dlaczego pozycji słów nie można skalować proporcjonalnie?

Kusi, by przeliczyć współrzędną piksela na współrzędną strony, dzieląc przez szerokość renderowania i mnożąc przez szerokość strony. To działa tylko dla stron bez obrotu, z CropBox identycznym z MediaBox oraz początkiem w zerze, a mnóstwo zeskanowanych dokumentów nie spełnia co najmniej jednego z tych warunków

PDFium Component mapuje każdy z czterech narożników czworokąta indywidualnie przez FPDF_DeviceToPage, to samo mapowanie, którego renderer użył do wytworzenia pikseli, więc wpisy /Rotate i przesunięte pola przycięcia są obsłużone konstrukcyjnie. Macierz afiniczna dla obiektu tekstowego jest następnie budowana z trzech zmapowanych punktów — narożników dolno-lewego, dolno-prawego i górno-lewego — co jest dokładnie wystarczające, by wyrazić pozycję, skalę, obrót i pochylenie

Sam obiekt tekstowy jest tworzony przy jednostkowym rozmiarze fontu, tak by można było zmierzyć jego rzeczywiste granice, a zmierzone granice obiektu są następnie mapowane na docelowy czworokąt. Dobieranie rozmiaru przez zgadywaną wielkość punktową w nadziei, że będzie pasować do zeskanowanego słowa, dryfowałoby przy każdej podmianie fontu; wcześniejszy pomiar sprawia, że dopasowanie jest niezależne od tego, jakiego fontu używa warstwa

Uruchamianie na dokumencie

Rekord opcji kontroluje rozdzielczość, filtrowanie i każdy budżet. Filtrowanie pewności ma większe znaczenie, niż się wydaje: śmieciowe słowa o niskiej pewności trwale zanieczyszczają wyniki wyszukiwania, a w przeciwieństwie do błędnego renderowania nikt tego nie zauważa, dopóki wyszukiwanie nie zwróci bzdur:

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;                  // rozdzielczość rozpoznawania
    Options.MinConfidence := 0.60;       // odrzuć niepewne słowa
    Options.SkipPagesWithText := True;   // zostaw w spokoju strony natywnie cyfrowe
    Options.ContinueOnError := True;     // jedna zła strona nie może zatrzymać zadania
    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 zasługuje na podkreślenie w mieszanych archiwach. PDF, który już niesie prawdziwy tekst, czy to natywnie cyfrowy, czy wcześniej przetworzony, dostaje drugą warstwę tekstu, jeśli uruchomisz na nim OCR bez zastanowienia, a duplikat sprawia, że ekstrakcja zwraca każde słowo dwukrotnie. Status per-stronę popsSkippedExistingText mówi dokładnie, które strony pozostawiono w spokoju

Budżety, anulowanie i ograniczanie awarii

Każda wielkość, którą wrogi albo po prostu ogromny dokument może rozdąć, ma pułap: piksele na stronę i łącznie, słowa na stronę i łącznie, oraz znaki na słowo. Wszystkie są sprawdzane przed zapisaniem strony, nie po, a szacunek pikseli jest obliczany z wymiarów strony i DPI, zanim jakakolwiek mapa bitowa zostanie zaalokowana. Podniesienie DPI ze 150 do 300 czterokrotnie zwiększa pamięć na stronę, więc pułap na stronę to parametr, który należy dostroić najpierw, gdy zadanie wsadowe zaczyna zawodzić na dużych formatach

Token anulowania przewija się przez całą ścieżkę: renderowanie progresywne, wywołanie dostawcy i pętlę wstawiania słowo po słowie. Oznacza to, że użytkownik anulujący w trakcie rozpoznawania 400-stronicowego pliku zatrzymuje się w obrębie jednej strony, a nie na końcu dokumentu, a ten sam wzorzec tokenu, użyty gdzie indziej w komponencie i opisany w anulowalnym renderowaniu progresywnym, stosuje się tutaj bez zmian

Ograniczanie awarii działa na poziomie strony. Biblioteka gromadzi uchwyty obiektów, które wstawiła na stronie, i wywołuje FPDFPage_GenerateContent raz, po umieszczeniu wszystkich słów. Jeśli w połowie coś zawiedzie, czy to błąd dostawcy, czy problem z fontem, obiekty wstawione na tej stronie są usuwane w odwrotnej kolejności, a treść strony jest regenerowana, więc strona, na której wystąpiła awaria, wraca do stanu początkowego zamiast zachować połowę warstwy tekstu. Pętla dokumentu następnie kontynuuje albo zatrzymuje się zgodnie z ContinueOnError, a aktywna strona jest zawsze przywracana

Weryfikacja, czy obraz naprawdę pozostał nietknięty

Najsilniejsze dostępne sprawdzenie jest też najprostsze: wyrenderuj stronę przed i po zastosowaniu warstwy w tym samym rozmiarze i porównaj mapy bitowe. Powinny być identyczne bit po bicie, ponieważ niewidoczny tekst niczego nie rysuje, a strumień obrazu nigdy nie został zdekodowany. Jakakolwiek różnica oznacza, że coś innego niż warstwa tekstu zmieniło stronę

Następnie zweryfikuj stronę tekstową, wyodrębniając tekst z przetworzonego pliku i potwierdzając, że pozycje słów lądują na skanie. Ścieżka ekstrakcji jest tą samą, która opisana jest w ekstrakcji tekstu z dokumentów PDF, a do szybkiego wizualnego sprawdzenia wyrównania renderowanie stron do obrazów, jak w konwersji stron PDF do JPEG, pozwala nałożyć ramki słów na skan

Nakładanie warstwy OCR, renderowanie, ekstrakcja i edycja działają na tym samym obiekcie dokumentu w Delphi, C++Builder i Lazarus; pełna powierzchnia API jest opisana na stronie PDFium Component dla Delphi