Technical Article

Pluggable Text Shaping: Uniscribe and HarfBuzz in Delphi

Text shaping in the PDFium component goes through one installable object. ConfigureTextShaper installs the shaper that every shaping entry point routes through, replacing and freeing whatever was there; ActiveTextShaper returns the installed one and creates the platform default on first use; ActiveTextShaperName reports which backend is live; ClearTextShaper drops the installation and lets the default be created again. On Windows the default is TPdfUniscribeTextShaper. Under Free Pascal there is TPdfHarfBuzzTextShaper, which binds libharfbuzz at run time so a missing library is a reported condition rather than a load failure

Pluggable text shaping architecture in the PDFium Delphi component: ConfigureTextShaper, ActiveTextShaper and ClearTextShaper manage one installed backend, Uniscribe on Windows and a run-time bound HarfBuzz under Free Pascal
Every shaping call routes through the single installed shaper object, with a platform default on each target

One interface, two backends that divide the work completely differently. Understanding that asymmetry is what stops the portable path from producing text that is shaped correctly and positioned wrongly

Why is the Windows backend one class and the portable one three pieces?

Because Uniscribe is four APIs pretending to be one. ScriptItemize segments a string by script and resolves bidirectional levels; ScriptShape maps characters to glyphs; ScriptPlace computes advances and offsets; ScriptLayout puts the resulting runs into visual order. A backend built on it therefore has nothing left to add, which is why the Windows shaper is a single class with a single method

HarfBuzz covers the middle two. It shapes and places a run whose direction and script the caller has already decided, and it has no opinion about how a paragraph splits into runs or what order those runs appear in. So the portable backend supplies the rest: the bidirectional algorithm resolves embedding levels, the HarfBuzz Unicode functions segment the text by script, and the runs are laid out in the visual order that UAX #9 rule L2 produces. The bidirectional half is substantial enough to be its own unit, described in the UAX #9 embedding levels article

Shaping pipeline comparison for PDF text: Uniscribe supplies ScriptItemize, ScriptShape, ScriptPlace and ScriptLayout inside one class, while HarfBuzz covers only shaping and placement around the component's own UAX #9 stages
Uniscribe covers all four stages; the portable path must supply itemization and visual order itself

The shaper does not resolve fonts, and that is deliberate

Uniscribe reads the font binary out of a GDI device context. There is no portable equivalent of that, and inventing one inside a shaping unit would mean deciding, on behalf of every application, whether fonts come from fontconfig, from CoreText, from an application font folder, or from a database. So the HarfBuzz backend takes a resolver: a callback that maps a font name to the TrueType or OpenType bytes. Returning False fails the shaping request the same way an unreadable GDI font fails it on Windows

uses
  FPdfTextShaping
{$IFDEF FPC}
  , FPdfTextShapingHb
{$ENDIF}
  ;

function TFontCatalogue.Resolve(const FontName: WideString;
  out FontData: TBytes): Boolean;
var
  Path: string;
begin
  // Your policy: fontconfig, CoreText, an app font folder, a database
  Result := FLookup.TryGetValue(LowerCase(FontName), Path);
  if Result then
    FontData := TFile.ReadAllBytes(Path);
end;

procedure InstallShaper(Catalogue: TFontCatalogue);
begin
{$IFDEF FPC}
  // Ownership passes to the unit; call once during start-up,
  // before anything shapes text
  ConfigureTextShaper(TPdfHarfBuzzTextShaper.Create(Catalogue.Resolve));
{$ENDIF}
  // On Delphi the platform default (Uniscribe) is created on demand,
  // so no installation is needed at all
  LogInfo('shaping backend: ' + ActiveTextShaperName);
end;

Keeping font discovery outside the shaper has a second benefit that shows up in servers: the same process can shape with an embedded font set that has nothing to do with what is installed on the machine, which is what you want when output must be byte-reproducible across hosts. The component also exposes a host system font provider for the cases where you do want installed fonts, covered in the system font provider article

The result record is backend-neutral, and clusters are the reason

Both backends fill the same TPdfShapedText: the source text, font name, size, font bytes, an array of runs, the total width, the glyph count and the logical character count. Each TPdfShapedRun carries its span in the source text, its visual X position, its width, its bidirectional level and a right-to-left flag, plus its glyphs. Each TPdfShapedGlyph carries a glyph identifier, an advance, X and Y offsets, and the cluster it belongs to as a start and a length in the source text

Those cluster fields are what make the record usable rather than merely informative. Shaping is not a one-to-one mapping: a Devanagari syllable becomes one glyph from four characters, an Arabic ligature merges two, and a single character can produce several marks. Without cluster spans you cannot place a caret, hit-test a click, or highlight a selection, because you cannot say which characters a glyph belongs to. With them, the arithmetic is local and the same code works for both backends

Glyph cluster spans in TPdfShapedText: one Devanagari syllable glyph from four characters, an Arabic ligature from two, and a base plus mark from one character, each mapped back through ClusterStart and ClusterLength
Cluster spans map every glyph back to its source characters so carets, hit tests and selections work
var
  Shaped: TPdfShapedText;
  R, G: Integer;
begin
  if ShapePdfText(Line, 'Noto Sans Arabic', 14, ptdAuto, Shaped) then
    for R := 0 to High(Shaped.Runs) do
    begin
      // Runs already arrive in visual order with VisualX filled in
      X := Shaped.Runs[R].VisualX;
      for G := 0 to High(Shaped.Runs[R].Glyphs) do
      begin
        EmitGlyph(Shaped.Runs[R].Glyphs[G].GlyphID,
          X + Shaped.Runs[R].Glyphs[G].OffsetX,
          Shaped.Runs[R].Glyphs[G].OffsetY);
        X := X + Shaped.Runs[R].Glyphs[G].Advance;
      end;
    end;
end;

Budgets belong in the options record

TPdfTextShapingOptions carries a direction plus three caps: maximum characters, maximum glyphs and maximum runs, with a Default class function that fills sensible values. The caps are not paranoia about malformed input; they are arithmetic. Shaping expands: a font with aggressive contextual substitution can emit more glyphs than input characters, and a paragraph that alternates scripts every few characters produces a run per switch. A document assembled to maximize both turns a modest string into a large allocation, and a service that shapes text from untrusted PDFs needs a limit it chose rather than a limit the machine imposes

Setting the direction explicitly rather than leaving it on automatic is worth doing whenever you already know it. Automatic applies the paragraph-direction rules to guess from the first strong character, which is right for free text and wrong for a form field whose direction is a property of the field rather than of the value someone typed into it

Run-time binding, not a build dependency

The HarfBuzz backend loads the library dynamically. That is a deployment decision with real consequences: one binary runs on a machine with HarfBuzz and on a machine without it, reporting reduced capability in the second case instead of failing to start. For a library shipped to other developers that is the only workable arrangement, because you cannot require every consumer of a PDF component to acquire and version-match a shaping library they may not need

The corresponding rule for callers is to check. ActiveTextShaper returns nil when the platform has no default and none was configured, and the shaping entry point reports that as an unavailable shaper rather than as a shaping failure. Those are different problems and deserve different messages: one is a deployment gap, the other is a font or text problem

Install once, before anything shapes

Installation replaces and frees the previous shaper, so calling it repeatedly is safe but pointless, and calling it while another thread is shaping is not safe at all. Do it during start-up. If you need to fall back to the platform default later, pass nil, which is also how you undo a test double at the end of a test

Once a backend is installed, measurement and wrapping behave the same on both platforms, since they consume the run and glyph metrics rather than calling the platform directly; the wrapping model is described in the text measurement and word wrap article. Supported platforms and toolchains for the component are listed on the PDFium Delphi component product page