Technical Article

Add a Searchable Text Layer to Scanned PDFs in Delphi

PDFium Component adds a searchable text layer to scanned PDF pages from Delphi through ApplyOcrSearchLayer. It renders each selected page, hands the pixels to an OCR provider that you supply, and writes the recognised words back as invisible text objects positioned over the words in the scan. The original page image is never decoded, re-encoded or replaced, so the visual result is byte-for-byte the page you started with

The recognition engine is deliberately not part of the library. PDFium exposes page rendering, coordinate mapping, font loading, text object creation and invisible render modes, but it contains no OCR engine, and pretending otherwise would mean bundling somebody's recognition product into a PDF component. Instead, recognition lives behind the IPdfOcrProvider interface: the library passes fixed-layout, top-origin BGRA pixels, and the provider returns Unicode text, confidence values and word quadrilaterals

What exactly is a searchable text layer?

A scanned PDF is a picture of a document. The page content is one large image, and there is nothing to select, search, copy or index. A searchable text layer adds real text objects on top of that image with the render mode set to invisible, so viewers draw nothing but selection, search and extraction find the words exactly where they appear

Positioning is the whole game. If the invisible text sits a few points off, selection highlights land beside the words rather than on them, and copying a paragraph produces text in the wrong order. This is why the geometry has to come from the same transforms PDFium uses to render the page rather than from a proportional guess

Implementing the provider

The provider contract is one method. It receives a page image record carrying dimensions, stride, DPI, pixel format and the pixel bytes themselves, plus a cancellation token, and returns words or an error message:

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 holds top-origin BGRA rows of Image.Stride bytes.
  // Hand them to your engine, then fill one entry per recognised word
  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 rather than rectangles, because a scan is rarely square to the page. A word on a slightly rotated page occupies a parallelogram, and TPdfOcrQuad carries four corner points so that skewed and rotated words keep an accurate selection region. Engines that report only axis-aligned boxes can use FromRectangle, which builds the degenerate quad

Why can word positions not be scaled proportionally?

It is tempting to convert a pixel coordinate to a page coordinate by dividing by the render width and multiplying by the page width. That works only for pages with no rotation, a CropBox identical to the MediaBox, and an origin at zero, and plenty of scanned documents fail at least one of those conditions

PDFium Component maps each of the four quad corners individually through FPDF_DeviceToPage, the same mapping the renderer used to produce the pixels, so /Rotate entries and offset crop boxes are handled by construction. The affine matrix for the text object is then built from three of the mapped points, the bottom-left, bottom-right and top-left corners, which is exactly enough to express position, scale, rotation and shear

The text object itself is created at unit font size so its real font bounds can be measured, and the measured object bounds are then mapped onto the target quad. Sizing by a guessed point size and hoping it matches the scanned word would drift with every font substitution; measuring first makes the fit independent of which font the layer uses

Running it over a document

The options record controls resolution, filtering and every budget. Confidence filtering matters more than it looks: garbage words at low confidence pollute search results permanently, and unlike a wrong rendering, nobody notices until a search returns nonsense:

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;                  // recognition resolution
    Options.MinConfidence := 0.60;       // drop uncertain words
    Options.SkipPagesWithText := True;   // leave born-digital pages alone
    Options.ContinueOnError := True;     // one bad page must not stop the job
    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 deserves emphasis in mixed archives. A PDF that already carries real text, whether born digital or previously processed, gets a second text layer if you run OCR over it blindly, and the duplicate makes extraction return every word twice. The per-page status popsSkippedExistingText tells you exactly which pages were left alone

Budgets, cancellation and failure containment

Every quantity that a hostile or merely enormous document can inflate has a ceiling: pixels per page and in total, words per page and in total, and characters per word. All of them are checked before the page is written, not after, and the pixel estimate is computed from page dimensions and DPI before any bitmap is allocated. Raising DPI from 150 to 300 quadruples memory per page, so the per-page ceiling is the parameter to tune first when a batch job starts failing on large formats

The cancellation token threads through the whole path: progressive rendering, the provider call and the per-word insertion loop. That means a user who cancels during recognition of a 400-page file stops within one page rather than at the end of the document, and the same token pattern used elsewhere in the component, described in cancellable progressive rendering, applies here unchanged

Failure containment is per page. The library collects the object handles it inserted on a page and calls FPDFPage_GenerateContent once, after all words are placed. If anything fails midway, whether a provider error or a font problem, the objects inserted on that page are removed in reverse and the page content is regenerated, so a failed page reverts to its original state instead of keeping half a text layer. The document loop then continues or stops according to ContinueOnError, and the active page is always restored

Verifying that the image really was untouched

The strongest check available is also the simplest: render the page before and after applying the layer at the same size and compare the bitmaps. They should be identical byte for byte, because invisible text draws nothing and the image stream was never decoded. Any difference means something other than the text layer changed the page

After that, verify the text side by extracting from the processed file and confirming that word positions land on the scan. The extraction path is the same one described in extracting text from PDF documents, and for a quick visual check of alignment, rendering pages to images as in converting PDF pages to JPEG lets you overlay word boxes on the scan

OCR layering, rendering, extraction and editing all run against the same document object in Delphi, C++Builder and Lazarus; the full API surface is described on the PDFium Component for Delphi page