Technischer Artikel

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

Ein veraPDF-Bericht, der meldet, dass eine Glyphenbreite nicht zum eingebetteten Fontprogramm passt, sagt fast nichts darüber, welche Glyphe betroffen ist oder warum. PDFlibPas beantwortet diese Frage, indem es jeden Zeichencode über die eingebettete cmap zu einem Glyphenindex auflöst, die Programmmetrik auf 1000 Einheiten pro em normalisiert und erst dort vergleicht

Warum weichen Glyphenbreiten voneinander ab?

Weil die beiden verglichenen Zahlen in verschiedenen Koordinatensystemen leben und nichts im PDF-Dictionary die Umrechnung verrät. Ein Font-Dictionary schreibt /Widths im Glyphenraum, den PDF auf ein Tausendstel des em festlegt (ISO 32000-1 §9.2.4). Die Tabelle hmtx im eingebetteten TrueType-Programm notiert Vorschubwerte in Font-Design-Einheiten, und die Tabelle head entscheidet, wie viele davon ein em ergeben: 2048 bei den meisten TrueType-Schnitten, 1000 bei CFF-basierten, gelegentlich etwas ganz anderes. Vergleicht man die Rohwerte, wirkt jeder 2048-upem-Font im Bestand defekt. Das ist die Falle, die ISO 14289-1 §7.21.5 jedem stellt, der Breiten prüfen will, indem er Dictionary-Felder liest

PDFlibPas-Glyphenbreiten-Audit in Delphi: ein /Widths-Eintrag im Glyphenraum und ein hmtx-Vorschub in Font-Design-Einheiten werden durch Skalierung der Programmmetrik auf 1000 Einheiten pro em in dasselbe Koordinatensystem gebracht, bevor verglichen wird
PDFlibPas skaliert jeden hmtx-Vorschub auf ein Tausendstel des em, bevor er mit der Dictionary-Breite verglichen wird, und meldet nur Abstände von mehr als einer Einheit

PDFlibPas normalisiert beim Laden. TPDFTrueTypeParser legt Advance * 1000 div unitsPerEm in seinem Breiten-Array ab, daher antwortet Parser.GetWidth(GID) bereits in denselben Tausendsteln eines em, die das PDF verwendet, und GetRawWidth bleibt verfügbar, wenn Sie Design-Einheiten brauchen. Die härtere Hälfte bleibt davon unberührt: der Weg von einem Zeichencode zu einem Glyphenindex. Bei einem einfachen TrueType-Font hängt die Route vom Symbolic-Flag im FontDescriptor ab, Bit 3 von /Flags

Parser := TPDFTrueTypeParser.Create;
try
  Parser.LoadFromString(FontProgram);
  if Symbolic then
  begin
    // Symbolische Schnitte werden direkt über die Programm-cmap adressiert,
    // mit der (3,0)-High-Byte-Konvention als Rückfallebene
    GID := Parser.GetGlyphIndex(Code);
    if GID = 0 then
      GID := Parser.GetGlyphIndex($F000 + Code);
  end
  else
  begin
    // Nicht-symbolisch: Code -> Glyphenname über die Encoding, Name -> Unicode
    // über die Adobe Glyph List, Unicode -> GID über die Programm-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;

Zwei Details in diesem Fragment haben Gewicht. Die Toleranz beträgt eine Einheit, nicht null, denn die Normalisierung ist eine Ganzzahldivision und eine korrekt erzeugte Datei kann um eine Einheit danebenliegen; genau das ist die Formulierung „innerhalb eines Tausendstel em", mit der Diagnose 10036 meldet. Und die Schutzklausel GID < Parser.GlyphCount ist kein Schmuck. GetWidth ist für Rendering-Aufrufer nachsichtig geschrieben, klemmt einen Index außerhalb des Bereichs auf den letzten Eintrag in hmtx und fällt auf 750 zurück, wenn die Tabelle fehlt. Nachsichtig ist richtig zum Rendern und falsch zum Auditieren, also verwirft die Prüfung den Index, bevor sie eine Breite anfordert, statt dem Klemmverhalten zu vertrauen

CIDFontType2 fügt eine weitere Indirektion hinzu

PDFlibPas geht zusammengesetzte Fonts genauso durch, mit /CIDToGIDMap zwischen CID und Glyphe. Die Breiten kommen im /W-Array an, dem ISO 32000-1 §9.7.4.3 zwei Formen gibt, die frei in einem Array abwechseln: eine Start-CID gefolgt von einem Array aufeinanderfolgender Breiten, oder eine erste CID, eine letzte CID und eine einzige Breite für den ganzen Lauf. Die Prüfung parst beide Formen, übergibt jedes entstandene Paar an denselben Vergleich und meldet die Summe unter der Diagnose 10037. Der Mapping-Schritt ist der Punkt, an dem sich zusammengesetzte Fonts unterscheiden, und darum zählt die Fehlende-Map-Diagnose 10021, bevor man überhaupt eine Breite liest — eine fehlende oder fehlerhafte /CIDToGIDMap verletzt nicht bloß §7.21.3.2, sie macht die Breitenfrage unbeantwortbar

Drei Wege, die PDFlibPas in Delphi von einem Zeichencode zu einem Glyphenindex nimmt: die Programm-cmap für symbolische TrueType-Fonts, ein Umweg über Encoding und Adobe Glyph List für nicht-symbolische, und ein CMap plus /CIDToGIDMap-Schritt für CIDFontType2
Der Breitenvergleich kann erst beginnen, wenn der Zeichencode zu einem Glyphenindex aufgelöst ist, und jede Font-Art erreicht diesen Index auf einem anderen Weg
// /CIDToGIDMap ist der Name /Identity oder ein Stream von Big-Endian-16-Bit-
// Glyphenindizes, einer pro CID (ISO 32000-1 Abschnitt 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 sind 1-basiert
  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;

Was tut ein Auditor, wenn sich das Fontprogramm nicht dekodieren lässt?

Schweigen. Die Vollständigkeitsprüfungen für /CharSet und /CIDSet, die ISO 14289-1 §7.21.4.2 verlangt — die Diagnosen 10038 und 10039 — sind der Ort, an dem ein übereifriger Validator zur Belastung wird, denn ein Bericht „Ihr CharSet ist unvollständig" ist für den Leser nicht von „unser Type-1-Dekoder hat aufgegeben" zu unterscheiden. PDFlibPas meldet einen fehlenden Eintrag deshalb nur, wenn drei Dinge gleichzeitig gelingen: das Fontprogramm dekodiert, die Code-zu-Glyph-Zuordnung sich auflöst und die Menge selbst dekodiert. TPDFType1Decoder.LoadPFBFromString muss True zurückgeben und eine Charstring-Anzahl liefern, bevor ein Glyphenname gegen den /CharSet-String geprüft wird; der /CIDSet-Pfad braucht den entpackten Stream und eine positive Glyphenzahl, bevor ein einziges Bit getestet wird. Jede Ausnahme auf dem Weg kollabiert zu „kein Befund", nicht zu einem Defekt

Die konservative Melderegel im PDFlibPas-PDF/UA-Audit: ein fehlender /CharSet- oder /CIDSet-Eintrag wird nur gemeldet, wenn das Fontprogramm dekodiert, die Code-zu-Glyph-Zuordnung sich auflöst und die Menge selbst dekodiert, und jeder Fehler schweigt
Drei unabhängige Erfolge sind nötig, bevor ein Fehlende-Eintrag-Befund ausgegeben wird; ein Dekoder, der aufgibt, kostet Sie also ein Falsch-Negativ statt einer falschen Anschuldigung

Das ist eine bewusste Neigung zu Falsch-Negativen, und sie lohnt sich, klar ausgesprochen statt versteckt zu werden. Eine beschädigte CFF-Tabelle, eine nicht unterstützte Type-1-Variante oder ein /CIDSet kürzer als der Glyphenbereich erzeugen allesamt Schweigen statt einer Diagnose. Die Überlegung: PDF/UA-Audits wandern zu Autoren weiter, die das Werkzeug nicht gebaut haben, und eine falsche Anschuldigung kostet mehr als ein versäumter Befund. Der Autor verbrennt einen Tag damit, nachzuweisen, dass eine konforme Datei konform ist, und hört auf, dem ganzen Bericht zu vertrauen. Das Matterhorn Protocol zieht dieselbe Grenze in anderer Form, wenn es Checks, die eine Maschine entscheiden kann, von solchen trennt, die ein Mensch muss, und sein Fonts-Checkpoint (31) ist deren Zuhause. Wer die strengere Lesart braucht, fährt PDFlibPas als schnelles Tor und einen dedizierten Validator als Zweitmeinung — genau dieses Paar beschreibt der Artikel zur PDF/A- und PDF/UA-Preflight

Page /Contents ist eine Liste, kein Stream

Der teuerste Einzelfehler im Content-Stream-Audit ist es, /Contents als einen Stream zu behandeln. ISO 32000-1 §7.7.3.3 erlaubt einer Seite ein Array von Streams, deren Verkettung, mit Leerraum zwischen den Teilen, das Seitenprogramm ist; Produzenten trennen an beliebigen Stellen, und ein BT kann in einem Member sitzen, sein zugehöriges ET im nächsten. Ein Content-Prozessor hält Zustand — die Verschachtelungstiefe von Marked Content, den vom letzten Tf gewählten Font, das Textobjekt-Flag — und Process setzt diesen Zustand beim Eintritt zurück. Ruft man es einmal pro Array-Member auf, beginnt jeder Stream nach dem ersten ohne aktuellen Font, und sauber getaggter Text liest sich als ungetaggtes, fontloses Rauschen. PDFlibPas verkettet zuerst und verarbeitet einmal

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;

// Ein Process-Aufruf über die gesamte Verkettung, nie ein Aufruf pro Member
Scanner.Process(ContentObjectData(FDoc, PageDict.FindValueByKeyName('Contents')));

Welche Form XObjects zählen wirklich als unstrukturiert?

Nur diejenigen, die eine Seite wirklich aufruft, von einer Aufrufstelle außerhalb von Marked Content, deren eigener Inhalt Text zeigt. Diagnose 10040 setzt ISO 14289-1 §7.20 durch, indem sie pro Objektnummer drei unabhängige Fakten aufzeichnet — hat Text, wurde aufgerufen, wurde innerhalb von Marked Content aufgerufen — und nur die Schnittmenge der ersten beiden abzüglich der dritten meldet. Jeder der beiden Abkürzungen ist auf eine Art falsch, die man ausliefern würde: Jede texttragende Form in /Resources zu markieren bestraft eine Vorlagenbibliothek, aus der niemand zeichnet, und jede aufgerufene Form zu markieren bestraft Vektorlogos ohne Text, die kein Tagging brauchen. Die Aufrufstelle wird über die Objektnummer aufgelöst, nicht über den Ressourcennamen, denn dieselbe Form erreicht man routinemäßig über verschiedene Namen auf verschiedenen Seiten. Die Schwesterdiagnose 10041 läuft für §7.21.8 über dasselbe verkettete Programm, löst jeden Text zeigenden Operanden über den Font im Gültigkeitsbereich auf und zählt die Codes, die auf .notdef landen, was unabhängig vom Textdarstellungsmodus verboten ist — auch im unsichtbaren Modus hinter gescannten Bildern. Wie die überlebenden Formen eingepackt gehören, ist eine Strukturbaum-Frage und im Artikel zum Aufbau getaggter PDF-Struktur behandelt

Fonts ganz ohne FontDescriptor

Ein nicht eingebetteter Font ist eine legitime Eingabe für dieses Audit, kein Fehlerzustand, und jede Hilfsroutine unterhalb der Einbettungsprüfung muss das überleben. Findet PDFlibPas keinen /FontDescriptor oder einen Descriptor ohne FontFile, FontFile2 oder FontFile3, notiert es die Diagnose 10020 — oder 10022, wenn der Name zu den Standard 14 gehört, die §7.21.4 NOTE 5 auffällig weigert auszunehmen — und arbeitet den Rest der Datei weiter durch. Das ist der ganze Sinn eines Berichts: Ein Autor will alle Befunde in einem Durchlauf, nicht einen Befund pro Lauf. Die an die Breiten-, cmap-, CharSet- und CIDSet-Hilfsroutinen gereichte Descriptor-Referenz kann also Nil sein, und jede davon testet das beim Eintritt, statt anzunehmen, eine frühere Prüfung habe das Audit abgebrochen. Soll die Lösung sein, was fehlt einzubetten, steht das Handwerk im Hinweis zum Einbetten fehlender Fonts in ein bestehendes PDF

Das Audit ausführen

Ein Aufruf, an einer Datei, die nicht unbedingt von Ihnen stammt. TPDFlib.CheckFileCompliance nimmt einen Compliance-Test-Selektor — 2 für PDF/UA-1 unter ISO 14289-1:2014 — und liefert entweder null oder ein String-List-Handle, dessen Einträge aus numerischem Code, Doppelpunkt und lesbarer Nachricht bestehen. Die hier besprochenen Font- und Content-Stream-Befunde belegen in diesem Bereich 10020 bis 10041, zahlenmäßig getrennt von den 00xxx-PDF/A-Codes, damit ein gemischtes Log lesbar bleibt. Übergibt man 1 in Options, bricht der Lauf beim ersten Befund ab, was man in einem Build-Gate will und nicht in einem Authoring-Werkzeug. Für ein noch im Speicher offenes Dokument führt GetPDFUADiagnostics die äquivalente Prüfung aus, ohne den Umweg über die Platte

var
  Issues, Count, I: Integer;
begin
  // ComplianceTest = 2 wählt PDF/UA-1; Options = 0 meldet jeden Befund
  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));   // z. B. 10037 CIDFontType2 ...
  end;
end;

Nichts davon braucht eine externe Validator-Binärdatei auf der Maschine, und das ist der Unterschied zwischen einer Prüfung, die bei jedem Build läuft, und einer, die läuft, wenn jemand daran denkt. Die hier beschriebenen Compliance- und Diagnose-APIs kommen in der Standard-PDFlibPas Delphi PDF Library, deren Produktseite die komplette Diagnose-Code-Tabelle für PDF/UA-1 neben den PDF/A-, PDF/X- und PDF/E-Testsuiten führt