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 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
// /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
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