Technical Article

Detecting Missing PDF Glyphs at Draw Time in Delphi

A missing glyph in a PDF is not an error. The producer asks for a character the selected font cannot map, the font returns glyph index zero, and the file that comes out is structurally valid, opens everywhere, and shows an empty box where a name or an amount should be. Nobody in the generating pipeline finds out. The recipient does. HotPDF closes that loop with TrackUnresolvedGlyphs: switch it on and the text-drawing path records every code point whose glyph lookup resolves to index zero, firing OnUnresolvedGlyph once per unique finding with the code point, the font it failed on, the script it belongs to, and a suggestion of fonts that would cover it

Detection is half the answer. The other half is SetFontFallbackChain, which registers an ordered list of fonts per script, so the common cases resolve themselves and only genuine gaps reach your handler. Together they turn a class of defect that used to be reported by customers into a build-time check

Why does a missing glyph not raise anything?

Because ISO 32000 places no obligation on a producer to verify coverage, and glyph index zero is a legitimate glyph. It is .notdef, whose outline the font designer chooses: usually an empty or hollow rectangle, sometimes nothing at all. A viewer that draws it is behaving correctly. Text extraction may even return the right characters, because the /ToUnicode mapping is written from the source text rather than from the outlines, so an automated round-trip check will happily pass a document whose visible text has holes in it

Diagram of why a missing PDF glyph stays silent as glyph zero draws an empty box while ToUnicode extraction passes round-trip checks
Glyph zero is a legitimate .notdef answer and /ToUnicode is written from the source text, so nothing in the pipeline is told about the gap

The practical consequence is that coverage has to be checked at the moment of drawing, when the library still knows which code point was requested and which glyph the font actually offered. Afterwards the information is gone

The detector has to watch the subset state, not the device context

This is where the first implementation went wrong, and the reason is worth understanding because it applies to any coverage check bolted onto a text pipeline. HotPDF has two text paths. One emits through a registered Unicode TrueType font with an in-memory character map built at registration time. The other is a legacy GDI path that creates a fresh device context and font handle per character run

Judging coverage from the GDI path is hopeless. Its mapping is not the mapping that ends up in the emitted content stream, and the two are not synchronised, so a detector reading GDI results reports the entire printable ASCII range as unresolved. The authoritative answer lives in the registered font: the character map that RegisterUnicodeTTF parses, queried through GetUnicodeGlyphForCodepoint. The detector is therefore gated on the subset-ready state, not on any GDI condition, and it simply does not run on documents that never registered a Unicode font, which is correct because those documents are restricted to the standard encodings anyway

A second trap sits next to it. The GDI family name of a font and the PostScript name extracted from the font binary at registration are different strings, and not in a way you can normalize: a family called Arial Unicode MS carries the PostScript name ArialMT. Any gate written as "is the currently selected font the one we registered", compared by name, is dead code that never fires. Gate on state, never on font names

HotPDF unresolved glyph detection flow showing the subset-state gate, GetUnicodeGlyphForCodepoint lookup and OnUnresolvedGlyph event wiring
Coverage is judged from the registered Unicode font map rather than GDI, and each unique code point fires one event with a font suggestion

Do not test a glyph detector with emoji

The obvious test case is a smiling face, and it will convince you the detector is broken. Common emoji code points in the astral planes resolve through a private-use synthesis path that maps them to a glyph index directly, so they never reach the general coverage branch. The detector is behaving correctly and the test is measuring the wrong path

Use an unassigned code point instead. U+0378 is permanently unallocated in Unicode, so no font can legitimately map it, and it exercises exactly the branch you want to verify. This distinction between "the feature is broken" and "the test picked an input that bypasses the feature" costs real hours, and unassigned code points are the cheapest way to avoid it

type
  TCoverageAudit = class
  private
    FFindings: TStringList;
  public
    procedure Handle(Sender: TObject;
      const Info: THPDFUnresolvedGlyphInfo);
    property Findings: TStringList read FFindings;
  end;

procedure TCoverageAudit.Handle(Sender: TObject;
  const Info: THPDFUnresolvedGlyphInfo);
begin
  // Fires once per unique code point, not once per occurrence
  FFindings.Add(Format('U+%.4X missing in %s (script %d), try: %s',
    [Info.CodePoint, String(Info.FontName), Ord(Info.Script),
     String(Info.SuggestedFonts)]));
end;

// Wiring it into a generating job
Pdf := THotPDF.Create(nil);
try
  Pdf.TrackUnresolvedGlyphs := True;
  Pdf.OnUnresolvedGlyph := Audit.Handle;
  Pdf.RegisterUnicodeTTF('C:\Windows\Fonts\arial.ttf');
  Pdf.BeginDoc;
  Pdf.CurrentPage.SetFont('Arial', [], 11);
  Pdf.CurrentPage.TextOut(50, 720, 0, CustomerName);
  Pdf.EndDoc;
  if Audit.Findings.Count > 0 then
    // Fail the job rather than shipping a page with boxes on it
    raise Exception.Create(Audit.Findings.Text);
finally
  Pdf.Free;
end;

Fallback chains are per script, not per font

The reason fallback is scoped by script rather than by source font is that coverage gaps cluster by writing system. A Latin text font is missing Devanagari, Thai, Han and emoji, all at once, and the substitute for each is a different font. Declaring one chain per script therefore describes the real deployment: one Latin font for body text, one CJK font, one emoji font, one catch-all

Per-script font fallback diagram mapping hfsCJK, hfsArabic, hfsEmoji and hfsOther scripts to ordered substitute font chains in HotPDF
Each script gets its own ordered chain, so a Latin body font missing Han, Arabic or emoji falls through to a font that covers it
// THPDFFontScript covers hfsCommon, hfsLatin, hfsGreek, hfsCyrillic,
// hfsHebrew, hfsArabic, hfsIndic, hfsSoutheastAsian, hfsCJK, hfsKana,
// hfsHangul, hfsEmoji and hfsOther
Pdf.SetFontFallbackChain(hfsCJK,
  ['Microsoft YaHei', 'SimSun', 'Yu Gothic']);
Pdf.SetFontFallbackChain(hfsArabic, ['Segoe UI', 'Arial']);
Pdf.SetFontFallbackChain(hfsEmoji, ['Segoe UI Emoji']);
Pdf.SetFontFallbackChain(hfsOther, ['Arial Unicode MS']);

Fallback and detection are complementary rather than alternative. Chains handle the coverage you anticipated; the detector reports the coverage you did not, which on a system processing arbitrary customer data is the interesting half. Note that substituting a font changes metrics, so a paragraph that falls back may reflow; if the layout matters, the closure and subsetting behavior of the substituted font is worth reading up in the font subset closure article, and scripts that need reordering or joining are handled by the shaping stage described in complex script text shaping

How to retrofit behavior without risking the existing path

The same release added a legacy kern table fallback for pair spacing, and the way it was scoped is a pattern worth copying. Rather than adding a new decision point to the kerning logic, the fallback hangs off the early-exit branch that already existed for fonts with no GPOS table. A modern font with GPOS never reaches it, so its behavior is unchanged by construction rather than by testing. Paths that do not register a Unicode font produce two zero offsets, so they are unchanged as well

That is the general shape of a low-risk retrofit in a mature rendering library: find the branch that currently produces nothing and put the new behavior there. It converts "we believe this did not regress anything" into "this cannot have regressed anything", which is a much better thing to say about a text engine that other people's invoices go through

Make it a gate, not a log

Coverage findings are only useful if something fails on them. In a document-generating service the productive arrangement is to keep tracking on in the nightly regression job against a corpus of real customer names, addresses and product descriptions, and to fail the job on any finding. Because the event fires once per unique code point rather than once per occurrence, the output stays small enough to read even when a whole script is missing

In production the same handler is better used as telemetry: record the code point and font, keep serving the document, and let the aggregate tell you which script to add to the deployment font set next. Rendering behavior for embedded and substituted fonts is covered further in rendering embedded font glyphs, and the full property list including TrackUnresolvedGlyphs is documented on the HotPDF Delphi PDF component product page