Shaped glyphs render as .notdef boxes when a font subsetter keeps only the glyphs reachable from emitted code points. HotPDF, the native VCL PDF component for Delphi and C++Builder, carried exactly that defect until version 2.435.0: OpenType GSUB output was recorded in an internal usage bitmap that the subsetter declared it would honour and then never actually read
This is a different failure from the one described in the EndDoc bug that silently disabled font subsetting. That bug was about when subsetting ran relative to serialization, and it disabled subsetting wholesale. This one is about what the subset contains when subsetting runs perfectly on schedule. The pipeline fires at the right moment, the six-letter subset prefix appears on /BaseFont exactly as ISO 32000-1 §9.6.4 requires, the file gets smaller, every Latin page proofs clean, and an Arabic page comes out as a row of empty rectangles. Ordering bugs are loud once you look. Closure bugs stay quiet forever, because the subset is structurally valid and only wrong about its own membership list
Why do shaped glyphs render as .notdef?
Because the set of code points a document emits is not the set of glyphs the document draws, and a subsetter that conflates the two drops every glyph produced by shaping. Text shaping turns a logical character sequence into a positioned glyph sequence, and its whole purpose is to produce glyphs that no single input character maps to: an Arabic medial heh, an fi ligature, a Devanagari conjunct, a contextual alternate selected by the rclt feature. Each of those is a glyph ID that a GSUB lookup manufactured, not one the cmap table hands you for any character in your string. A subsetter driven purely by the cmap is therefore walking the wrong index. It faithfully retains every glyph the text could have used before shaping and discards precisely the glyphs the text does use after shaping. The renderer then asks the embedded font for GID 1847, the subset has zeroed that entry in loca, and glyph index 0 comes back instead. Glyph index 0 is .notdef by OpenType definition, which is why the failure signature is an empty box rather than a wrong letter or a crash. Nothing in the PDF is malformed; the font simply does not contain the glyph the content stream asked for
Code points are not glyphs: the three sources of a subset
A correct subset closure has to union three independent sources, each with its own accumulator. The first is the code-point-derived set: HotPDF accumulates FUnicodeUsedCps as BMP characters are emitted and FUnicodeSmpUsed for supplementary-plane characters reached through surrogate pairs, then maps each through FUnicodeCpToGid to a glyph ID. The second is the shaping-derived set, the glyph IDs a GSUB substitution produced, recorded through MarkUnicodeGlyphUsed and EnableShapingFeatureForSubset into FUnicodeExtraUsedGlyphs. The third is composite closure: a glyph whose numberOfContours is -1 in glyf is assembled from component glyph IDs, and keeping the composite while dropping its components yields an empty outline rather than a .notdef, which is arguably worse because it reads as a spacing bug
HotPDF has always handled the first and third. BuildAndApplyUnicodeFontSubset, the subsetting entry point that EndDoc calls before serialization, seeds the used-glyph array with GID 0, walks the BMP code points, walks the SMP usage list, and hands the array to a subset builder that resolves composite components internally. The second source was written but never consumed, and because the three sources fail on different content, the gap can hide for years in a codebase whose regression corpus is mostly Latin
The array that was written and never read
The contract was documented in three places and honoured in none of them. The declaration of FUnicodeExtraUsedGlyphs stated that the EndDoc subsetter unions it with the code-point-derived usage; the header comment on ApplyArabicGSUBRefinement promised that every emitted substitute GID is passed through MarkUnicodeGlyphUsed so the subsetter pulls the glyph into the embedded font; the same promise appears verbatim on ApplyArabicGSUBContextualRefinement for the rclt path. Both callers upheld their half. A grep over every reference to the field settled the other half in about ninety seconds: one declaration, one SetLength allocation inside RegisterUnicodeTTF, and writes in the two marking routines. Not a single read. That is the diagnostic worth internalizing, because it generalizes well past fonts. When a field is written by several call sites and read by none, the feature it represents does not exist, however thoroughly it is commented. Step 1 of the subsetter is small enough to read in one screen, and the gap is obvious once you know to look for it
// Step 1: derive the used-glyph set (as it stood before 2.435.0)
SetLength(UsedGlyphs, FUnicodeNumGlyphs);
for I := 0 to FUnicodeNumGlyphs - 1 do
UsedGlyphs[I] := False;
UsedGlyphs[0] := True; // .notdef is always present
for Cp := 0 to $FFFF do // source 1a: BMP code points
if (Cp < Length(FUnicodeUsedCps)) and FUnicodeUsedCps[Cp]
and (Cp < Length(FUnicodeCpToGid)) then
begin
GID := FUnicodeCpToGid[Cp];
if (GID > 0) and (GID < FUnicodeNumGlyphs) then
UsedGlyphs[GID] := True;
end;
for I := 0 to High(FUnicodeSmpUsed) do // source 1b: SMP code points
begin
GID := FUnicodeSmpUsed[I].GID;
if (GID > 0) and (GID < FUnicodeNumGlyphs) then
UsedGlyphs[GID] := True;
end;
// source 2 was missing here: nothing ever consulted FUnicodeExtraUsedGlyphs
The one loop fix, and marking glyphs yourself
The repair is a union, and its safety argument comes from the direction of the operation: it only sets bits, never clears them, so no glyph that used to survive the subset can start being dropped
// v2.435.0: pull GSUB-derived extra glyphs into the subset.
// MarkUnicodeGlyphUsed / EnableShapingFeatureForSubset record GIDs that
// shaping produced but that no emitted code point maps to directly.
for I := 0 to FUnicodeNumGlyphs - 1 do
if (I < Length(FUnicodeExtraUsedGlyphs)) and FUnicodeExtraUsedGlyphs[I] then
UsedGlyphs[I] := True;
Three properties make this a low-risk change rather than a font-engine rewrite. It is monotone, as above. It is a no-op on fonts that never shaped anything, since FUnicodeExtraUsedGlyphs stays all-False and byte output for a Latin-only document is unchanged. And it lands before Step 2, so both subset builders inherit it: the sparse builder that preserves original GID numbering, and the compact builder _BuildCompactSubsetTTF that HotPDF selects under PDF/A to renumber kept glyphs into a dense range, shrink maxp.numGlyphs, and emit the old-to-new mapping as the /CIDToGIDMap stream required by ISO 32000-1 §9.7.4.2. Both call _TTFWalkCompositeClosure internally, so a shaped glyph that happens to be composite now also drags its components in. Composite closure was never broken; it was simply never reached for these glyph IDs, because the glyph IDs were not in the set it walks. If you drive the GSUB engine directly instead of relying on the built-in refinement passes, closure becomes your responsibility, and every substitute glyph ID you emit must be marked before EndDoc freezes the used-glyph set
var
Pdf: THotPDF;
GIDs: array[0..1] of Word;
LigGID: Word;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.FileName := 'shaped.pdf';
Pdf.BeginDoc;
Pdf.RegisterUnicodeTTF('C:\Fonts\NotoNaskhArabic-Regular.ttf');
Pdf.ShapingFeatures := [sfArabicGSUB, sfStandardLigatures,
sfContextualAlternates];
GIDs[0] := Pdf.GetUnicodeGlyphForCodepoint($0644); // lam
GIDs[1] := Pdf.GetUnicodeGlyphForCodepoint($0627); // alef
if Pdf.ApplyLigatureSubstitution(GIDs, 0, 'liga', LigGID) then
Pdf.MarkUnicodeGlyphUsed(LigGID); // omit this and you get .notdef
Pdf.EnableShapingFeatureForSubset('rclt');
Pdf.CurrentPage.RtLTextOut(50, 700, 0, WideString(ArabicText));
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
EnableShapingFeatureForSubset is the batch counterpart to the single-GID call, and it is deliberately conservative. It walks the GSUB lookup list for the lookups wired to one four-byte feature tag under the currently selected script and language path, and marks the substitute glyph IDs those lookups can produce. It is a defensive no-op when the font carries no GSUB table or when the feature is absent from that path, so calling it unconditionally is safe. It is also an over-approximation by design: it may retain glyphs a given document never draws. For subsetting, over-inclusion costs bytes and under-inclusion costs correctness, which makes that trade an easy one. The structure of these lookups, and the coverage tables deciding which glyphs participate, is covered in the walkthrough of GSUB stylistic alternates in pure Delphi
How do you prove the glyph is actually in the subset?
By reading the emitted font, not by eyeballing the page in a viewer that may be substituting a system font behind your back. The check that catches this whole class of bug is mechanical: extract the /FontFile2 stream from the output PDF, parse loca, and confirm that the glyph ID you expect carries a non-empty entry, meaning its start and end offsets differ. An empty entry is the subsetter having decided the glyph is unused. Two habits then make it much harder to ship the failure again. Keep a shaped-script page in the automated smoke corpus rather than only in the manual proofing set, because Arabic, Devanagari, and Khmer exercise closure paths that no amount of Latin coverage will touch. And whenever an accumulator exists, assert that something consumes it, since a write-only field is a feature that compiles, tests green on the wrong corpus, and does nothing
Where the fix stops
Subset closure is necessary for a shaped glyph to render, and it is not sufficient. The glyph also has to be addressable from the content stream, which is a separate problem with its own boundary. The HotPDF built-in Arabic refinement passes commit a substitution only when every substitute glyph ID is reachable through a Unicode presentation-form code point via a reverse cmap scan over roughly 690 code points in U+FB50 to U+FDFF and U+FE70 to U+FEFF. When a substitute lands on a glyph ID outside that range, the input window passes through unchanged rather than emitting something the reader cannot address; font-specific alternates at arbitrary glyph IDs need a synthetic private-use code point allocated in U+E000 to U+F8FF to carry them through the emit path. So the honest summary is that the 2.435.0 fix removed a hard blocker rather than completing the story. Before it, a glyph could be shaped correctly, emitted correctly, and still vanish at subset time, which meant the shaping engine could not be trusted end to end however good its lookups were. What remains is addressability, and that constraint at least fails visibly at the point of emission rather than silently in a build step running after everything you were watching. For the emit side of the same pipeline, see the guide to Arabic and RTL text shaping in Delphi PDFs
The font subsetting, GSUB engine, and complex-script shaping described here ship in the standard HotPDF Component for Delphi and C++Builder; the product page carries the full API reference for the Unicode font and shaping calls named above