Technical Article

PDF/UA Font Audit in Delphi: Widths, CharSet, CIDSet

A veraPDF report saying a glyph width disagrees with the embedded font program tells you almost nothing about which glyph, or why. PDFlibPas answers that question by resolving each character code through the embedded cmap to a glyph index, normalizing the program metric to 1000 units per em, and comparing there

Why do glyph widths disagree?

Because the two numbers being compared live in different coordinate systems, and nothing in the PDF dictionary tells you the conversion. A font dictionary writes /Widths in glyph space, which PDF fixes at one thousandth of the em (ISO 32000-1 §9.2.4). The hmtx table inside the embedded TrueType program writes advances in font design units, and the head table decides how many of those make an em: 2048 for most TrueType faces, 1000 for CFF-derived ones, occasionally something else entirely. Compare the raw values and every 2048-upem font in your corpus looks broken. That is the trap ISO 14289-1 §7.21.5 sets for anyone who tries to audit widths by reading dictionary fields

PDFlibPas glyph width audit in Delphi: a /Widths entry in glyph space and an hmtx advance in font design units are brought into the same coordinate system by scaling the program metric to 1000 units per em before any comparison is made
PDFlibPas scales every hmtx advance to a thousandth of the em before comparing it with the dictionary width, and reports only what is more than one unit apart

PDFlibPas normalizes on load. TPDFTrueTypeParser stores Advance * 1000 div unitsPerEm in its width array, so Parser.GetWidth(GID) already answers in the same thousandths-of-an-em the PDF uses, and GetRawWidth stays available when you need design units back. That still leaves the harder half: getting from a character code to a glyph index. For a simple TrueType font the route depends on the Symbolic flag in the FontDescriptor, bit 3 of /Flags

Parser := TPDFTrueTypeParser.Create;
try
  Parser.LoadFromString(FontProgram);
  if Symbolic then
  begin
    // Symbolic faces are addressed through the program cmap directly,
    // with the (3,0) high-byte convention as the fallback
    GID := Parser.GetGlyphIndex(Code);
    if GID = 0 then
      GID := Parser.GetGlyphIndex($F000 + Code);
  end
  else
  begin
    // Non-symbolic: code -> glyph name via the encoding, name -> Unicode
    // via the Adobe Glyph List, Unicode -> GID via the program cmap
    UnicodeValue := GetGlyphUnicode(EncodingNames[Code and $FF]);
    if UnicodeValue = 0 then
      Continue;
    GID := Parser.GetGlyphIndex(UnicodeValue);
  end;
  if (GID > 0) and (GID < Parser.GlyphCount) then
    if Abs(PDFWidth - Parser.GetWidth(GID)) > 1 then
      Inc(MismatchCount);
finally
  Parser.Free;
end;

Two details in that fragment carry weight. The tolerance is one unit, not zero, because normalization is integer division and a legitimately produced file can land a unit off; that is exactly the "within one thousandth of an em" wording diagnostic 10036 reports. And the GID < Parser.GlyphCount guard is not decoration. GetWidth is written to be forgiving for rendering callers, clamping an out-of-range index to the last entry in hmtx and falling back to 750 when the table is absent. Forgiving is right for rendering and wrong for auditing, so the audit rejects the index before asking for a width instead of trusting the clamp

CIDFontType2 adds one more indirection

PDFlibPas walks composite fonts the same way, with /CIDToGIDMap inserted between the CID and the glyph. Widths arrive in the /W array, which ISO 32000-1 §9.7.4.3 gives two shapes that alternate freely in one array: a start CID followed by an array of consecutive widths, or a first CID, a last CID, and a single width applied across the run. The audit parses both, then hands every resulting pair to the same comparison, and reports the total under diagnostic 10037. The mapping step is where composite fonts differ, and it is why the missing-map diagnostic 10021 matters before you read any width at all — an absent or malformed /CIDToGIDMap does not merely violate §7.21.3.2, it makes the width question unanswerable

Three routes PDFlibPas takes from a character code to a glyph index in Delphi: the program cmap for symbolic TrueType fonts, an encoding and Adobe Glyph List detour for non-symbolic ones, and a CMap plus /CIDToGIDMap step for CIDFontType2
The width comparison cannot begin until the character code resolves to a glyph index, and each kind of font reaches that index by a different route
// /CIDToGIDMap is the name /Identity or a stream of big-endian 16-bit
// glyph indices, one per CID (ISO 32000-1 section 9.7.4.2)
Obj := DerefIndRef(FDoc, CIDFont.FindValueByKeyName('CIDToGIDMap'));
if (Obj is TPDFName) and (TPDFName(Obj).Name = 'Identity') then
begin
  GID := CID;
  Result := True;
end
else if Obj is TPDFStream then
begin
  Data := TPDFStream(Obj).GetDecodedStream;
  P := CID * 2 + 1;                       // Pascal strings are 1-based
  if (P >= 1) and (P + 1 <= Length(Data)) then
  begin
    GID := (Integer(Byte(Data[P])) shl 8) or Integer(Byte(Data[P + 1]));
    Result := True;
  end;
end;

What should an auditor do when the font program will not decode?

Say nothing. The /CharSet and /CIDSet completeness checks that ISO 14289-1 §7.21.4.2 requires — diagnostics 10038 and 10039 — are the place where an over-eager validator turns into a liability, because a report of "your CharSet is incomplete" is indistinguishable, to the person reading it, from "our Type 1 decoder gave up". PDFlibPas therefore reports a missing entry only when three things all succeed: the font program decodes, the code-to-glyph mapping resolves, and the set itself decodes. TPDFType1Decoder.LoadPFBFromString must return True and yield a charstring count before any glyph name is checked against the /CharSet string; the /CIDSet path needs the stream to inflate and the glyph count to come back positive before a single bit is tested. Any exception on the way collapses to "no finding", not to a defect

The conservative reporting rule in the PDFlibPas PDF/UA audit: a missing /CharSet or /CIDSet entry is reported only when the font program decodes, the code-to-glyph mapping resolves and the set itself decodes, and any failure produces silence
Three independent successes are required before a missing-entry finding is emitted, so a decoder that gives up costs you a false negative rather than a false accusation

That is a deliberate bias toward false negatives, and it is worth stating plainly rather than burying. A corrupt CFF table, an unsupported Type 1 variant, or a /CIDSet shorter than the glyph range all produce silence instead of a diagnostic. The reasoning is that PDF/UA audits get forwarded to authors who did not build the tooling, and a false accusation costs more than a missed one: the author burns a day proving a compliant file is compliant, and stops trusting the whole report. The Matterhorn Protocol makes the same distinction in a different form when it separates checks a machine can decide from checks a human must, and its Fonts checkpoint (31) is where these live. If you need the stricter reading, run PDFlibPas as the fast gate and a dedicated validator as the second opinion — that pairing is the same one described in the PDF/A and PDF/UA preflight walkthrough

Page /Contents is a list, not a stream

The single most expensive mistake in content-stream auditing is treating /Contents as one stream. ISO 32000-1 §7.7.3.3 lets a page hold an array of streams whose concatenation, with whitespace between the parts, is the page program; producers split at arbitrary points, and a BT can sit in one member with its matching ET in the next. A content processor keeps state — the marked-content nesting depth, the font selected by the last Tf, the text-object flag — and Process resets that state on entry. Call it once per array member and every stream after the first starts with no current font, so text that was perfectly well tagged reads as untagged, unfonted noise. PDFlibPas concatenates first and processes once

function ContentObjectData(FDoc: TSmartPDFDocument; Obj: TPDFObject): AnsiString;
var
  I: Integer;
begin
  Result := '';
  Obj := DerefIndRef(FDoc, Obj);
  if Obj is TPDFStream then
    Result := TPDFStream(Obj).GetDecodedStream
  else if Obj is TPDFArray then
    for I := 0 to TPDFArray(Obj).Count - 1 do
      Result := Result + ContentObjectData(FDoc, TPDFArray(Obj).Item[I]) + #10;
end;

// One Process call over the whole concatenation, never one call per member
Scanner.Process(ContentObjectData(FDoc, PageDict.FindValueByKeyName('Contents')));

Which Form XObjects actually count as unstructured?

Only the ones a page really invokes, from a call site outside marked content, whose own content shows text. Diagnostic 10040 enforces ISO 14289-1 §7.20 by recording three independent facts per object number — has text, was invoked, was invoked inside marked content — and reporting only the intersection of the first two minus the third. Each of the two shortcuts is wrong in a way you would ship: flagging every text-bearing Form in /Resources punishes a template library nobody draws from, and flagging every invoked Form punishes vector logos that carry no text and need no tagging. The invocation site is resolved by object number rather than by resource name, since the same Form is routinely reached through different names on different pages. The companion diagnostic 10041 walks the same concatenated program for §7.21.8, resolving each text-showing operand through the font in scope and counting the codes that land on .notdef, which is forbidden regardless of the text rendering mode — including the invisible mode used behind scanned images. How the surviving Forms should be wrapped is a structure-tree question, covered in the article on building tagged PDF structure

Fonts with no FontDescriptor at all

An unembedded font is a legitimate input to this audit, not an error state, and every helper below the embedding check has to survive it. When PDFlibPas finds no /FontDescriptor, or a descriptor with no FontFile, FontFile2, or FontFile3, it records diagnostic 10020 — or 10022 when the name is one of the Standard 14, which §7.21.4 NOTE 5 pointedly refuses to exempt — and then keeps going through the rest of the file. That is the whole point of a report: an author wants every finding in one pass, not one finding per run. So the descriptor reference handed to the width, cmap, CharSet and CIDSet helpers can be Nil, and each of them tests for it on entry instead of assuming an earlier check aborted the audit. If the fix is to embed what is missing, the mechanics are in the note on embedding missing fonts into an existing PDF

Running the audit

One call, on a file you did not necessarily produce. TPDFlib.CheckFileCompliance takes a compliance test selector — 2 for PDF/UA-1 under ISO 14289-1:2014 — and returns either zero or a string-list handle whose entries are a numeric code, a colon, and a readable message. The font and content-stream findings discussed here occupy 10020 through 10041 in that range, kept numerically apart from the 00xxx PDF/A codes so a mixed log stays readable. Passing 1 in Options short-circuits on the first finding, which is what you want in a build gate rather than in an authoring tool. For a document still open in memory, GetPDFUADiagnostics runs the equivalent inspection without a round trip through disk

var
  Issues, Count, I: Integer;
begin
  // ComplianceTest = 2 selects PDF/UA-1; Options = 0 reports every finding
  Issues := PDF.CheckFileCompliance('delivery.pdf', '', 2, 0);
  if Issues = 0 then
    WriteLn('delivery.pdf: PDF/UA-1 conformant')
  else
  begin
    Count := PDF.GetStringListCount(Issues);
    for I := 1 to Count do
      WriteLn('  ', PDF.GetStringListItem(Issues, I));   // e.g. 10037 CIDFontType2 ...
  end;
end;

None of this needs an external validator binary on the machine, which is the difference between a check that runs on every build and a check that runs when someone remembers. The compliance and diagnostic APIs described here ship in the standard PDFlibPas Delphi PDF Library, whose product page carries the full diagnostic code table for PDF/UA-1 alongside the PDF/A, PDF/X and PDF/E test suites