Technischer Artikel

PDF/A-Preflight-Validierung in Delphi mit PDFium VCL

Ein Archivierungs-Gateway lehnte eine Charge von „PDF/A-2b“-Dateien ab, die sich in jedem Viewer auf dem Schreibtisch problemlos öffnen ließen. Der Lieferant schwor, sie seien konform. Das waren sie nicht: Jede einzelne enthielt eine im Katalog vergrabene JavaScript-Aktion, also etwas, das man beim flüchtigen Hinsehen niemals bemerkt, das ein vollständiger PDF/A-Validator wie veraPDF jedoch sofort bemängelt. Die Schwierigkeit bestand darin, dass niemand eine Java-Toolchain an einen Delphi-Batch-Dienst anbinden wollte, nur um pro Datei eine einfache Ja/Nein-Frage zu beantworten. Dies ist die Lücke, die ValidatePdfACompliance in der PDFium-Komponente schließt, und es lohnt sich zu verstehen, wie sie zu einem Ergebnis gelangt, ohne jemals einen Content-Stream vollständig zu parsen

Warum PDFium selbst dies nicht beantworten kann

Das Erste, worüber man sich im Klaren sein muss: Die mitgelieferte pdfium.dll verfügt über keinerlei PDF/A-Funktionen. Es gibt kein ConvertToPDFA, keinen OutputIntent-Writer und keine XMP-API in der öffentlichen Schnittstelle. Jeder PDF/A-Teil in dieser Bibliothek – sowohl auf der Schreib- als auch auf der Prüfseite – ist in reinem Pascal in FPdfPdfa.pas implementiert und arbeitet mit Byte-Level-Parsing sowie inkrementellen Updates. Wenn Sie also den Validator aufrufen, fragen Sie den Renderer von Chromium nach nichts. Sie lassen einen Pascal-Token-Scanner über die strukturellen Bytes der Datei laufen

Die öffentliche API ist bewusst klein gehalten. Eine Funktion liest einen Stream ab Position 0 und gibt einen Record zurück:

function ValidatePdfACompliance(Source: TStream): TPdfAValidationResult;

type
  TPdfAValidationResult = record
    Conformance: TPdfAConformance;        // pacUnknown, pacNone, pac1b, pac2u, ...
    Issues: TPdfAValidationIssues;        // a set of TPdfAValidationIssue
    function IsCompliant: Boolean;        // True only when level <> unknown/none
  end;                                    // AND Issues is empty

IsCompliant kodiert die Regel, auf die es bei einem Gateway ankommt: Eine Datei gilt nur dann als bestanden, wenn eine echte Konformitätsstufe erkannt wurde und die menge der Probleme leer ist. Ein erfolgreicher Parse-Vorgang, der jedoch keinen pdfaid-Marker findet, führt zu pacNone, was ausdrücklich kein Bestehen bedeutet. Dies ist derselbe Punkt, den das Batch-Preflight-Bericht CLI von außen betrachtet verdeutlicht: Eine leere Fundliste bei einer nicht erkannten Datei ist kein Beleg für Mängelfreiheit

Bereinigen von Stream-Inhalten vor dem Token-Scan

Hier ist das wichtigste Implementierungsdetail und dasjenige, das man am leichtesten falsch macht, wenn man einen eigenen Scanner schreibt. Der Detektor findet Verstöße, indem er nach abgegrenzten Namenstoken sucht, wie z. B. /JavaScript, /LZWDecode oder /BM. Wenn Sie die rohen Dateibytes scannen, enthalten die eingebetteten binären Stream-Inhalte, komprimierten Bilder, ICC-Profile und Schriftprogramme zufällig Byte-Sequenzen, die wie diese Token aussehen. Sie würden fälschlicherweise melden, dass /AA oder /3D „gefunden“ wurde, weil drei Bytes in einem JPEG zufällig so lauteten. Das ist eine Quelle für Fehlalarme

Die Lösung ist PdfStructureBytes: Es durchläuft die Datei und ersetzt die Bytes zwischen jedem stream- und endstream-Schlüsselwort durch Leerzeichen, sodass die Dictionary-Struktur intakt bleibt. Erst danach wird der Scan durchgeführt. Jede Namenstoken-Prüfung im Validator arbeitet auf dieser bereinigten Kopie. Wenn Sie eine einzige Idee aus diesem Artikel mitnehmen, dann diese. Dieselbe Methode spiegelt sich im PDF/UA-Validator wider, der eine eigene Kopie dieser Routine vorhält, da sich die beiden Standards unabhängig voneinander entwickeln

Die 29 Probleme und was jedes davon bedeutet

TPdfAValidationIssue is a documented contract. Die Ordinalzahlen sind festgeschrieben, da DUnitX-Tests, die Demos und die Berichtsebene alle von ihnen abhängen. Neue Erkenntnisse werden daher immer nur am Ende angehängt. Seit v1.63.0 gibt es 29 Mitglieder. Sie lassen sich in einige Familien unterteilen:

  • Metadaten und Identität: pvaiMissingXmpMetadata, pvaiMissingPdfAIdentifier, pvaiMissingTrailerId (ISO 19005-1 6.1.3), pvaiMissingXmpDates
  • Farbe und Ausgabe: pvaiMissingOutputIntent, pvaiMissingIccProfile und pvaiMixedDeviceColorSpaces, wenn sowohl DeviceRGB und DeviceCMYK vorkommen (6.2.3.3)
  • Strikte Verbote für jeden Teil: pvaiEncryptionPresent (ein /Encrypt-Dictionary ist generell verboten), pvaiJavaScriptPresent, pvaiForbiddenAction, pvaiAdditionalActions, pvaiLzwUsed, pvaiXfaPresent, pvaiNeedAppearancesTrue, pvaiForbiddenAnnotation
  • Schriften: pvaiFontNotEmbedded und das strengere pvaiUnembeddedFont, plus pvaiUnicodeMappingMissing für eine Level-U-Deklaration ohne /ToUnicode
  • Tagging: pvaiLevelAStructureMissing, wenn eine Conformance=A-Deklaration keine getaggte Struktur aufweist

Die sechs neuesten Mitglieder, die an den Ordinalstellen 24 bis 29 hinzugefügt wurden, decken die subtilen Fälle ab, über die Prüfer tatsächlich stolpern: pvaiTrappedTrue (ein /Trapped /True im Info-Dictionary, ein „falscher Freund“, da der Wert False oder Unknown sein muss), pvaiForbiddenActionSubtype (Sound oder Movie als Aktion verwendet, nicht nur als Annotation), pvaiTransparentColorSpace (ein anderer Blend-Modus als Normal oder ein /CA//ca ungleich 1.0), pvaiAnnotationDictViolation, pvaiUnembeddedFont und pvaiMixedDeviceColorSpaces

Teilspezifische Filterung: PDF/A-1 ist streng, PDF/A-2 und PDF/A-3 lockern die Regeln

PDF/A ist kein einheitliches Regelwerk. Drei Dinge, die PDF/A-1 verbietet, sind ab PDF/A-2 ausdrücklich erlaubt: Transparenz (eine /Transparency-Gruppe oder eine aktive /SMask, 6.4), optionaler Inhalt (/OCProperties, 6.1.13) und eingebettete Dateien (/EmbeddedFiles oder /EF, 6.1.11). Ein naiver Validator, der alle drei Punkte für jede Datei anlastet, würde perfekt gültige PDF/A-2-Dokumente massenhaft ablehnen

Daher liest der Validator die Teilenummer aus dem pdfaid-Marker über PdfAPartOf aus und schaltet diese Prüfungen nur frei, wenn PartNo = 1 gilt. Die Prüfungen des Mischmodus und des Annotations-Alphas für die neuen Transparenzprobleme gelten ebenfalls nur für Teil 1:

if PartNo = 1 then
begin
  if PdfHasName(Struct, '/BM') then
    if not PdfHasBMNormal(Struct) then          // only /Normal or /Compatible allowed
      Include(Result.Issues, pvaiTransparentColorSpace);
  if PdfHasCaNotOne(Struct, '/CA') or PdfHasCaNotOne(Struct, '/ca') then
    Include(Result.Issues, pvaiTransparentColorSpace);
end;

Ein konservativer Standardwert verdient Erwähnung: Wenn überhaupt kein pdfaid-Marker vorhanden ist, wird der Teil als 1 behandelt, also als der strengste. Die Begründung dafür ist, dass eine nicht identifizierte Datei an die strengsten Regeln gehalten werden sollte, anstatt einfach durchgewinkt zu werden. JavaScript, verbotene Aktionen, LZW, XFA, NeedAppearances, verbotene Annotationen und nicht eingebettete Schriften bleiben für jeden Teil verboten, sodass diese Prüfungen niemals gefiltert werden

Erweitern von Objekt-Streams, damit nichts verborgen bleibt

PDF 1.5 führte den Cross-Reference-Stream und den Objekt-Stream (/Type /ObjStm) ein, und diese erzeugen einen blinden Fleck für einen naiven Byte-Scanner. Ein Katalog, ein OutputIntent, ein Aktions-Dictionary – alles, was selbst kein Stream ist, kann innerhalb eines ObjStm mittels Flate komprimiert sein. Wenn Sie die rohe Struktur scannen, sehen Sie nichts davon und melden dann eine saubere Datei, die alles andere als das ist

PdfExpandObjectStreams schließt diese Lücke. Bevor eine Prüfung durchgeführt wird, macht der Validator Data := PdfExpandObjectStreams(Data). Die Routine findet jedes ObjStm, liest dessen /N- und /First-Header, um die enthaltenen Objektnummern und Offsets zu erhalten, entkomprimiert den Inhalt mit PdfInflate (der RTL-Zlib, System.ZLib unter Delphi und zstream unter FPC) und hängt jedes enthaltene Objekt als gewöhnliches N 0 obj ... endobj an das Ende einer Kopie der Bytes an. Die vorhandenen Token-Prüfungen finden diese Objekte dann ohne Änderungen an ihrer Logik

Zwei Einschränkungen machen dies sauber statt fehleranfällig. Stream-Objekte – also die Metadaten, das ICC-Profil und die Schriftprogramme – können nicht in einem Objekt-Stream liegen; dies ist nur für Nicht-Stream-Dictionaries erlaubt. Die Erweiterung befasst sich daher nur mit Dictionaries, und die angehängten Objekte tragen kein stream-Schlüsselwort, das den Bereinigungsschritt der Stream-Inhalte stören könnte. Und da der angehängte Inhalt nach %%EOF landet, findet die Rückwärtssuche ab startxref immer noch den ursprünglichen Trailer. Der Trailer des Cross-Reference-Streams selbst wurde bereits früher, in v1.49.3, behandelt, indem Root, Size und ID direkt aus dem Klartext-Xref-Stream-Dictionary gelesen wurden, ein Thema, das im Begleitartikel über die Validierung von Objekt- und Cross-Reference-Streams untersucht wird; die Arbeit mit Objekt-Streams musste lediglich den Entkomprimierungsschritt hinzufügen, ohne Typ-2-Xref-Einträge decodieren oder einen PNG-Prädiktor zurückrechnen zu müssen

Die ehrlichen Grenzen eines Byte-Level-Prüfers

Dies ist ein Preflight-Werkzeug, kein zertifizierter Validator, und die Grenzen sind real. Die Schrifteneinbettung ist eine Zählheuristik, und deren korrekte Umsetzung erforderte eine Korrektur, die man kennen sollte. Die ursprüngliche Prüfung verwendete PdfCountName('/FontDescriptor'), aber jede Schrift steuert zwei /FontDescriptor-Token bei – eine Referenz aus dem Schrift-Dictionary und ein /Type im Descriptor-Objekt selbst –, sodass die Anzahl 2N gegenüber N eingebetteten Programmen betrug und der Test immer wahr war. Die Lösung ist PdfCountDescriptorRefs, das nur das Referenzformat /FontDescriptor N G R zählt (eines pro Schrift-Dictionary) und pvaiUnembeddedFont nur dann auslöst, wenn die eingebetteten Programme tatsächlich in der Unterzahl sind:

K := PdfCountDescriptorRefs(Struct);                 // one per font dict
Emb := PdfCountName(Struct, '/FontFile')
     + PdfCountName(Struct, '/FontFile2')
     + PdfCountName(Struct, '/FontFile3');
if (K > 0) and (Emb < K) then
  Include(Result.Issues, pvaiUnembeddedFont);

Selbst korrigiert ist es grob: Ein gemischtes Dokument, bei dem zufällig jeder Descriptor irgendein FontFile besitzt, kann dennoch eine einzelne nicht konforme Schrift durchschlüpfen lassen. Das Erweitern von Objekt-Streams hat zudem einen bekannten Nebeneffekt: Es legt die Standard-14-Ressourcen offen, die ein AcroForm-/DR trägt, wie z. B. /Helv, und die Heuristik meldet sie pflichtbewusst als nicht eingebettet, obwohl veraPDF sie durchgehen lässt, weil sie niemals tatsächlich zum Rendern verwendet werden. Prüfungen auf Inhaltsebene für Content-Stream-Operatoren (6.2.10) liegen vollständig außerhalb des Rahmens, da sie ein vollständiges Parsen des Inhalts anstelle eines Byte-Scans erfordern würden. Betrachten Sie den Validator als ein schnelles, abhängigkeitsfreies erstes Tor, das die Verstöße abfängt, die die Marker-Injektion nicht beheben kann, und reservieren Sie einen vollständigen Validator für die endgültige Zertifizierung

Dies ist die prüfende Hälfte der Geschichte. Die komplementäre schreibende Seite, auf der SaveAsPdfA das XMP, den OutputIntent und das sRGB-ICC-Profil injiziert und eine Level-A-Anforderung ohne getaggte Struktur ehrlich herabstuft, baut auf derselben Byte-Level-Maschinerie auf. Beide Hälften werden in der PDFium-Komponente für Delphi ausgeliefert, einem VCL-Paket über einer reinen Pascal-PDF/A-Implementierung ohne externe Laufzeitumgebung