Technischer Artikel

Automatisierter PDF-Preflight und Risiko-Audit mit PDFium

Ein PDF, das an einer Produktionsgrenze ankommt — einer Druckerwarteschlange, einem Archiv, einem Kunden-Upload-Portal — sollte geprüft werden, bevor irgendetwas es rendert. Die Datei könnte eine Launch-Aktion enthalten, die so programmiert ist, dass sie ein externes Programm startet, Bilder, die zu grob sind, um den Druck zu überstehen, ein Verschlüsselungs-Wörterbuch, das genau den Druckauftrag verbietet, für den sie eingereicht wurde, oder ein PDF/A-Label, das sie nicht erfüllt. Die Prüfung eines Dokuments gegen solche Regeln, bevor es in einen Workflow gelangt, wird als Preflighting bezeichnet, und die PDFium C API bietet Delphi alles, was nötig ist, um die Prüfungen direkt zu implementieren, ohne auch nur eine einzige Seite zu rendern

Dieser Artikel erstellt die Prüfungen selbst: vier Audit-Klassen, jede eine kleine Routine, die Ergebnisse an eine gemeinsame Ergebnisliste anfügt. Interaktive Elemente, Ressourcen-Metriken, Sicherheitsstatus und Standard-Markierungen erhalten alle funktionierenden Code, einschließlich der Arithmetik. Wenn Sie die Maschinerie um die Prüfungen herum benötigen — Batch-Ordnerschleifen, JSON- und HTML-Berichtsdateien, Isolation pro Datei —, so liefert die PDFium-Komponente eine vorgefertigte Preflight-Engine mit, und der Artikel über die Batch-Preflight-CLI behandelt diese Infrastruktur. Beide teilen sich absichtlich ein Vokabular für Beendigungscodes, sodass sich ein hier geschriebener Auditor direkt in diesen Batch-Treiber einfügt

Der Ergebnisdatensatz und der Beendigungscode-Vertrag

Jede Prüfung schreibt in einen flachen Datensatztyp, denn die Alternative, dass jede Prüfung ihre eigene Prosa ausgibt, kann im Nachhinein nicht gezählt, gefiltert oder mit Schwellenwerten versehen werden. Vier Felder reichen aus:

uses
  System.SysUtils, System.Math, System.IOUtils,
  System.Generics.Collections, pdfium_lib;

type
  TFindingSeverity = (fsInfo, fsWarning, fsError);

  TPreflightFinding = record
    Severity: TFindingSeverity;
    Code: string;       // stable machine key, e.g. 'ACT-LAUNCH'
    Page: Integer;      // 1-based; 0 means document level
    Message: string;    // for humans; free to reword between releases
  end;

  TFindings = TList<TPreflightFinding>;

procedure Add(Findings: TFindings; Severity: TFindingSeverity;
  const Code: string; Page: Integer; const Msg: string);
var
  F: TPreflightFinding;
begin
  F.Severity := Severity;
  F.Code := Code;
  F.Page := Page;
  F.Message := Msg;
  Findings.Add(F);
end;

Nachgelagerte Werkzeuge orientieren sich an Code, niemals am Text der Message, die sich jederzeit ändern kann. Der Exit-Code des Prozesses folgt dem gleichen dreiwertigen Vertrag wie im Batch-Artikel: 0 bedeutet, dass die Datei keine Auffälligkeiten aufweist, 1 bedeutet, dass Ergebnisse vorliegen, und 2 bedeutet, dass die Prüfung selbst nicht durchgeführt werden konnte, da die Datei nicht geparst werden konnte oder ein Passwort verlangt. Es ist wichtig, Code 2 separat zu halten. Ein Ordner mit beschädigten Scans ist ein defekter Scanner im Vorfeld, kein plötzlicher Compliance-Kollaps, und wenn man beides zusammenfasst, schickt man jemanden auf die Jagd nach dem falschen Problem

Interaktive Elemente: Skripte, Launch-Ziele, externe Links

PDFium klassifiziert jede gefundene Aktion nach einem Integer-Typ, und die Konstanten aus fpdf_doc.h sollten genau festgelegt werden, da falsch kopierte Werte einen Scanner im Stillen blind machen. Die eigentliche Aufzählung lautet PDFACTION_UNSUPPORTED = 0, PDFACTION_GOTO = 1, PDFACTION_REMOTEGOTO = 2, PDFACTION_URI = 3, PDFACTION_LAUNCH = 4 und PDFACTION_EMBEDDEDGOTO = 5. Beachten Sie, was fehlt: Es gibt kein JavaScript-Element. Skripte auf Dokumentenebene sind keine Link-Aktionen und tauchen niemals über FPDFAction_GetType auf; sie werden durch eine separate Familie von Aufrufen aufgezählt. Ein Auditor, der Aktionstypen gegen eine imaginäre JavaScript-Konstante testet, kompiliert, läuft und findet nichts, und zwar für immer

const
  PDFACTION_GOTO         = 1;   // in-document jump: harmless
  PDFACTION_REMOTEGOTO   = 2;   // jump into another local file
  PDFACTION_URI          = 3;   // opens an external URL
  PDFACTION_LAUNCH       = 4;   // starts an external program
  PDFACTION_EMBEDDEDGOTO = 5;   // jump into an embedded file

function ActionTarget(Doc: FPDF_DOCUMENT; Action: FPDF_ACTION;
  AType: ULONG): string;
var
  Buf: array[0..2047] of AnsiChar;
begin
  FillChar(Buf, SizeOf(Buf), 0);
  if AType = PDFACTION_URI then
    FPDFAction_GetURIPath(Doc, Action, @Buf, SizeOf(Buf))
  else
    FPDFAction_GetFilePath(Action, @Buf, SizeOf(Buf));
  Result := string(UTF8String(PAnsiChar(@Buf)));
end;

procedure AuditPageActions(Doc: FPDF_DOCUMENT; Page: FPDF_PAGE;
  PageNo: Integer; Findings: TFindings);
var
  StartPos: Integer;
  Link: FPDF_LINK;
  Action: FPDF_ACTION;
  AType: ULONG;
begin
  StartPos := 0;
  while FPDFLink_Enumerate(Page, @StartPos, @Link) <> 0 do
  begin
    Action := FPDFLink_GetAction(Link);
    if Action = nil then
      Continue;                 // destination-only link, nothing to flag
    AType := FPDFAction_GetType(Action);
    case AType of
      PDFACTION_LAUNCH:
        Add(Findings, fsError, 'ACT-LAUNCH', PageNo,
          'Launch action targets "' + ActionTarget(Doc, Action, AType) + '"');
      PDFACTION_URI:
        Add(Findings, fsWarning, 'ACT-URI', PageNo,
          'link opens ' + ActionTarget(Doc, Action, AType));
      PDFACTION_REMOTEGOTO, PDFACTION_EMBEDDEDGOTO:
        Add(Findings, fsWarning, 'ACT-XFILE', PageNo,
          'cross-file destination "' + ActionTarget(Doc, Action, AType) + '"');
    end;                        // PDFACTION_GOTO stays silent by design
  end;
end;

procedure AuditDocumentBehaviors(Doc: FPDF_DOCUMENT; Findings: TFindings);
var
  N: Integer;
begin
  N := FPDFDoc_GetJavaScriptActionCount(Doc);
  if N > 0 then
    Add(Findings, fsError, 'JS-DOC', 0,
      Format('%d document-level JavaScript action(s) run on open', [N]));
  N := FPDFDoc_GetAttachmentCount(Doc);
  if N > 0 then
    Add(Findings, fsWarning, 'ATT-EMB', 0,
      Format('%d embedded file attachment(s)', [N]));
end;

Die Aufteilung des Schweregrads kodiert eine Richtlinie. Eine Launch-Aktion ist ein Fehler, weil das Starten eines beliebigen Programms das Gefährlichste ist, was ein Klick in einem PDF anrichten kann, und keine Rechnung benötigt dies. Externe URIs sind Warnungen: Sie kommen in legitimen Dokumenten häufig vor, aber ein Gutachter sollte das Ziel sehen, ohne klicken zu müssen, da der sichtbare Linktext und das tatsächliche Ziel nicht übereinstimmen müssen. GoTo-Sprünge innerhalb des Dokuments sind Struktur, kein Verhalten, und bleiben komplett aus dem Bericht heraus — ein Preflight, der bei jedem Eintrag im Inhaltsverzeichnis falschen Alarm schlägt, gewöhnt die Benutzer daran, ihn zu ignorieren. Das Lesen der Skript-Rümpfe hinter der JavaScript-Anzahl, sowie MDP-Stufen für Signaturen und XFA-Erkennung werden im Artikel zum Security-Risk Auditing über den Objekt-Wrapper der Komponente behandelt

Ressourcen-Metriken: Effektive Bild-DPI

Ein Bild innerhalb eines PDFs hat keine eigenen DPI. Es hat Pixel, und die Seite platziert diese Pixel in einem Rechteck, das in Punkten gemessen wird, wobei 72 Punkte einen Zoll ergeben. Eine Auflösung existiert nur als das Verhältnis der beiden, weshalb dasselbe 600 mal 400 große Foto als Miniaturansicht gestochen scharf und als ganzseitiges Titelbild ein verschwommener Matsch ist. Der Audit benötigt daher beide Zahlen für jedes Bild: die Quell-Pixel-Dimensionen aus den Bild-Metadaten und das platzierte Rechteck aus den Objektgrenzen

procedure AuditPageImages(Page: FPDF_PAGE; PageNo: Integer;
  Findings: TFindings);
var
  I, ObjCount: Integer;
  Obj: FPDF_PAGEOBJECT;
  Meta: FPDF_IMAGEOBJ_METADATA;
  L, B, R, T: Single;
  WidthPt, HeightPt, DpiX, DpiY, EffDpi: Double;
begin
  ObjCount := FPDFPage_CountObjects(Page);
  for I := 0 to ObjCount - 1 do
  begin
    Obj := FPDFPage_GetObject(Page, I);
    if FPDFPageObj_GetType(Obj) <> FPDF_PAGEOBJ_IMAGE then
      Continue;
    if FPDFImageObj_GetImageMetadata(Obj, Page, @Meta) = 0 then
      Continue;
    if FPDFPageObj_GetBounds(Obj, @L, @B, @R, @T) = 0 then
      Continue;

    WidthPt  := R - L;              // placed size on the page, in points
    HeightPt := T - B;
    if (WidthPt <= 0) or (HeightPt <= 0) or
       (Meta.Width = 0) or (Meta.Height = 0) then
      Continue;

    // 72 points = 1 inch, so placed inches = points / 72, and
    // effective DPI = source pixels / placed inches.
    DpiX := Meta.Width  / (WidthPt  / 72.0);
    DpiY := Meta.Height / (HeightPt / 72.0);
    EffDpi := Min(DpiX, DpiY);      // the worse axis decides print quality

    if EffDpi < 150.0 then
      Add(Findings, fsWarning, 'IMG-LOWRES', PageNo,
        Format('image %dx%d px placed at %.1fx%.1f pt = %.0f DPI effective',
          [Meta.Width, Meta.Height, WidthPt, HeightPt, EffDpi]))
    else if EffDpi > 600.0 then
      Add(Findings, fsInfo, 'IMG-BLOAT', PageNo,
        Format('image is %.0f DPI at placed size; resampling would ' +
          'shrink the file with no visible loss', [EffDpi]));
  end;
end;

Die Schwellenwerte sind eine Richtlinie, keine Physik: 150 DPI ist eine Untergrenze, unter der der Bürodruck sichtbar verpixelt, 300 ist das übliche kommerzielle Ziel, und alles über 600 bringt keine sichtbare Qualität, bläht aber die Dateigröße auf, weshalb es eher als informativer Ballast denn als Defekt gemeldet wird. Eine ehrliche Einschränkung: FPDFPageObj_GetBounds liefert das achsenausgerichtete Rechteck. Bei einem mit Rotation platzierten Bild unterschätzt der berechnete Wert also die tatsächliche Dichte. Die Struktur FPDF_IMAGEOBJ_METADATA enthält zudem die Felder horizontal_dpi und vertical_dpi, die PDFium aus der vollständigen Transformationsmatrix ableitet. Der Vergleich der beiden Ergebnisse ist ein einfacher Weg, um rotierte Platzierungen zu erkennen. Dieselbe Punkt-zu-Pixel-Arithmetik treibt das Rendern in die entgegengesetzte Richtung an, was im Artikel über JPEG-Export behandelt wird

Sicherheitsstatus: Verschlüsselung und Berechtigungsbits

Die PDF-Verschlüsselung definiert zwei Passwörter mit unterschiedlichen Aufgaben. Das Benutzerpasswort kontrolliert die Entschlüsselung: Ohne dieses lässt sich die Datei überhaupt nicht öffnen, und FPDF_LoadDocument liefert nil zurück, wobei FPDF_GetLastError den Wert FPDF_ERR_PASSWORD meldet. Das Besitzerpasswort kontrolliert die Berechtigungen: Eine Datei, die nur durch ein Besitzerpasswort geschützt ist, lässt sich ohne Zugangsdaten öffnen, enthält aber Einschränkungsbits, die ein konformes Leseprogramm beachten muss. Der Ladeversuch selbst ist daher die erste Sicherheitsprüfung, und die Unterscheidung entscheidet über den Exit-Code — eine Datei mit Benutzerpasswort ist nicht prüfbar (Code 2), während eine Datei mit Besitzerpasswort normal geprüft wird und lediglich Ergebnisse sammelt

const
  FPDF_ERR_PASSWORD = 4;

function AuditSecurity(const FileName: string;
  Findings: TFindings): FPDF_DOCUMENT;
var
  Perms: ULONG;
  Revision: Integer;
begin
  Result := FPDF_LoadDocument(PAnsiChar(AnsiString(FileName)), nil);
  if Result = nil then
  begin
    if FPDF_GetLastError() = FPDF_ERR_PASSWORD then
      Add(Findings, fsError, 'SEC-USERPW', 0,
        'user (open) password required; audit cannot proceed')
    else
      Add(Findings, fsError, 'DOC-BROKEN', 0, 'file failed to parse');
    Exit;
  end;

  Revision := FPDF_GetSecurityHandlerRevision(Result);
  if Revision >= 0 then       // -1 means the file is not encrypted
  begin
    // Opened with an empty password yet encrypted: owner-password-only.
    // Anyone may read it, but the permission bits restrict what a
    // conforming reader lets them do. Unencrypted files report all
    // bits set, which is why the revision gate comes first.
    Perms := FPDF_GetDocPermissions(Result);
    Add(Findings, fsInfo, 'SEC-ENC', 0,
      Format('encrypted, security handler revision %d', [Revision]));
    if (Perms and 4) = 0 then      // bit 3: print
      Add(Findings, fsWarning, 'SEC-NOPRINT', 0,
        'printing is not permitted');
    if (Perms and 16) = 0 then     // bit 5: copy / extract content
      Add(Findings, fsInfo, 'SEC-NOCOPY', 0,
        'content extraction is not permitted');
    if (Perms and 2048) = 0 then   // bit 12: high-resolution print
      Add(Findings, fsWarning, 'SEC-LOWPRINT', 0,
        'only low-resolution printing is permitted');
  end;
end;

Die Masken stammen aus Tabelle 22 der ISO 32000-1, die die Bits beginnend bei 1 nummeriert: Bit 3 des /P-Werts ist Maske 4, Bit 5 ist 16, Bit 12 ist 2048. Ob ein bestimmter Fund eine Rolle spielt, ist eine Routing-Entscheidung. Eine Druckerei sollte eine SEC-NOPRINT-Datei bei der Annahme ablehnen, damit der Einreicher eine klare Nachricht erhält, und nicht erst beim RIP drei Stunden vor Ablauf einer Frist. Ein Archiv sollte SEC-ENC an sich als Blocker behandeln, da Verschlüsselung und Langzeitarchivierung nicht zusammenpassen — ein Punkt, den die Standardprüfung gleich formal machen wird

Standard-Markierungen: Ein PDF/A-Zertifikat lesen

Eine Datei deklariert die PDF/A-Konformität in ihrem XMP-Metadatenpaket, und zwar über die Eigenschaft pdfaid:part (1 bis 4) und pdfaid:conformance (der Level-Buchstabe, wie b für visuelle Wiedergabetreue oder a für vollständiges strukturelles Tagging). Die C-API von PDFium bietet keinen XMP-Zugriff; FPDF_GetMetaText liest nur das Info-Wörterbuch, wo sich diese Identifikation jedoch nicht befindet. Der Ausweg ist eine Regel im Standard selbst: ISO 19005 fordert, dass der XMP-Metadaten-Stream unkomprimiert gespeichert wird, genau damit Werkzeuge ihn ohne einen vollständigen PDF-Parser finden können. Ein Scan der rohen Bytes ist daher ein legitimer Zertifikatsdetektor — und eine Datei, deren Zertifikat sich in einem komprimierten Stream versteckt, hat bereits gegen den Standard verstoßen, den sie beansprucht

function PdfAClaim(const FileName: string): string;
var
  Bytes: TBytes;
  S: RawByteString;
  P, Limit: Integer;
begin
  Result := '';                     // empty = no PDF/A claim present
  Bytes := TFile.ReadAllBytes(FileName);
  if Length(Bytes) = 0 then
    Exit;
  SetString(S, PAnsiChar(@Bytes[0]), Length(Bytes));
  P := Pos('pdfaid:part', S);       // XMP identification schema
  if P = 0 then
    Exit;
  // Handles both <pdfaid:part>2</pdfaid:part> and pdfaid:part="2":
  // take the first digit after the property name.
  Limit := Min(P + 32, Length(S));
  Inc(P, Length('pdfaid:part'));
  while (P <= Limit) and not (S[P] in ['1'..'4']) do
    Inc(P);
  if P <= Limit then
    Result := 'PDF/A-' + Char(S[P]);
end;

Das Ergebnis, das hier produziert wird, ist absichtlich informativ, da ein Zertifikat eine Deklaration ist, keine Eigenschaft der Datei. Der XMP-Eintrag ist eine Zeile XML, die jeder Erzeuger schreiben kann, einschließlich fehlerhafter; Konformität bedeutet, dass die Datei tatsächlich hunderte von Regeln bezüglich eingebetteter Schriftarten, geräteunabhängiger Farben und verbotener Funktionen erfüllt. Die Erkennung des Zertifikats sagt Ihnen lediglich, welche Dateien an eine echte Validierung weitergeleitet werden sollen, und nicht mehr. Die eingebaute Preflight-Engine der Komponente führt diese Validierung für PDF/A-, PDF/UA- und PDF/X-Profile durch, und der Artikel zur Batch-CLI zeigt, wie man sie in eine Pipeline mit Berichten einbindet, die ein Auditor später öffnen kann

Ein Lauf gegen eine problematische Datei

Der Treiber reiht die Prüfungen aneinander: zuerst die Sicherheit, da sie entscheidet, ob das Audit überhaupt läuft, dann Verhaltensweisen auf Dokumentenebene und das Standard-Zertifikat, gefolgt von einer Seitenschleife für Aktionen und Bilder

function AuditFile(const FileName: string; Findings: TFindings): Integer;
var
  Doc: FPDF_DOCUMENT;
  Page: FPDF_PAGE;
  I: Integer;
  Claim: string;
begin
  Doc := AuditSecurity(FileName, Findings);
  if Doc = nil then
    Exit(2);                        // audit failure, not a verdict
  try
    AuditDocumentBehaviors(Doc, Findings);
    Claim := PdfAClaim(FileName);
    if Claim <> '' then
      Add(Findings, fsInfo, 'STD-PDFA', 0,
        Claim + ' conformance claimed (declaration only, not validated)');
    for I := 0 to FPDF_GetPageCount(Doc) - 1 do
    begin
      Page := FPDF_LoadPage(Doc, I);
      if Page = nil then
      begin
        Add(Findings, fsError, 'PAGE-BROKEN', I + 1, 'page failed to parse');
        Continue;
      end;
      try
        AuditPageActions(Doc, Page, I + 1, Findings);
        AuditPageImages(Page, I + 1, Findings);
      finally
        FPDF_ClosePage(Page);
      end;
    end;
  finally
    FPDF_CloseDocument(Doc);
  end;
  if Findings.Count > 0 then
    Result := 1
  else
    Result := 0;
end;

Bei einer Broschüre, die von einer externen Agentur zurückkam, sieht die Ausgabe wie folgt aus:

> preflight_audit brochure_final.pdf
brochure_final.pdf: 5 finding(s)
  [ERROR]   ACT-LAUNCH   page 3   Launch action targets "..\tools\setup.exe"
  [ERROR]   JS-DOC       doc      2 document-level JavaScript action(s) run on open
  [WARNING] IMG-LOWRES   page 7   image 412x287 px placed at 396.0x275.8 pt = 75 DPI effective
  [WARNING] SEC-NOPRINT  doc      printing is not permitted
  [INFO]    STD-PDFA     doc      PDF/A-2 conformance claimed (declaration only, not validated)
exit code 1

Jede Zeile ist für sich genommen umsetzbar, aber die Kombination ist das eigentliche Urteil. Diese Datei beansprucht PDF/A-2, während sie ein Verschlüsselungswörterbuch und aktives JavaScript enthält, und PDF/A verbietet beides rundheraus — das Zertifikat ist also nachweislich falsch, bevor überhaupt ein tieferer Validator läuft. Das ist die Art von Widerspruch, die eine flache Ergebnisliste ans Tageslicht bringt und die ein bloßes Bestanden/Durchgefallen verbirgt

Was dieser Audit Ihnen nicht sagen kann

Ehrlichkeit bezüglich des Umfangs ist das, was das Vertrauen in ein Preflight-Tool erhält. Alles oben genannte liest das, was die Datei über sich selbst deklariert: PDFium parst die Struktur, und dieser Audit inventarisiert sie. Er führt keine PDF/A-Validierung durch — keine Prüfungen der Glyphen-Abdeckung gegen eingebettete Schriften, keine Farbraum-Analyse gegen Ausgabe-Intents, keine der Regeln auf Klausel-Ebene, die ein Zertifikat von der Konformität trennen; dafür benötigen Sie einen speziellen Validator wie die Preflight-Engine der Komponente oder veraPDF. Berechtigungsbits sind Deklarationen, die von konformen Leseprogrammen beachtet werden, keine kryptografischen Mauern, sodass SEC-NOPRINT eher eine Absicht als eine Durchsetzung beschreibt. Der Aktions-Scan deckt Link-Anmerkungen und Skripte auf Dokumentenebene ab; Skripte, die in Event-Wörterbüchern von Formularfeldern vergraben sind, benötigen zusätzlich die Formular-APIs. Und eine Signaturprüfung, falls Sie den Audit um eine solche erweitern, meldet die deklarierte Absicht, keine verifizierte Kryptografie — die Validierung der Zertifikatskette ist eine separate Aufgabe. Ein Preflight-Audit ist das Aufnahmegespräch, nicht das Gerichtsverfahren: Seine Aufgabe ist es, die Routing-Entscheidung fundiert, schnell und wiederholbar zu machen

Hinweis: Die in diesem Audit verwendeten APIs für Dokument-, Seiten-, Anmerkungs- und Bildobjekte werden zusammen mit einem High-Level-Delphi-Wrapper und einer vollständigen Standard-Validierungs-Preflight-Engine mit der PDFium-Komponente ausgeliefert