Artykuł techniczny

Zautomatyzowany preflight PDF i audyt ryzyka z PDFium

Plik PDF trafiający na granicę produkcyjną — do kolejki wydruku, archiwum, portalu przesyłania plików przez klienta — powinien zostać poddany audytowi, zanim cokolwiek go wyrenderuje. Plik może zawierać akcję Launch skonfigurowaną do uruchomienia zewnętrznego programu, obrazy o zbyt niskiej rozdzielczości, by nadawały się do druku, słownik szyfrowania, który zabrania dokładnie tego zadania drukowania, do którego został przesłany, lub etykietę PDF/A, której nie spełnia. Sprawdzanie dokumentu pod kątem takich reguł, zanim trafi on do cyklu pracy, nazywane jest preflightem (wstępną weryfikacją), a API C PDFium daje Delphi wszystko, co potrzebne, by bezpośrednio wdrożyć te kontrole, bez renderowania ani jednej strony

Ten artykuł buduje same kontrole: cztery klasy audytu, z których każda jest małą procedurą dodającą ustalenia do wspólnej listy wyników. Interaktywne elementy, metryki zasobów, stan bezpieczeństwa i znaczniki standardów zyskują działający kod, łącznie z arytmetyką. Jeśli potrzebujesz maszynerii wokół tych kontroli — pętli wsadowych po folderach, plików raportów JSON i HTML, izolacji dla każdego pliku — komponent PDFium (PDFium Component) jest dostarczany z gotowym silnikiem preflightu, a artykuł o wsadowym CLI do raportów preflight omawia tę infrastrukturę. Oba celowo współdzielą to samo słownictwo kodów wyjścia, więc audytor napisany w tym miejscu można bezpośrednio podpiąć pod ten sterownik wsadowy

Rekord ustaleń i umowa kodów wyjścia

Każda kontrola zapisuje dane do jednego płaskiego typu rekordu, ponieważ alternatywa – każda kontrola wypisująca własną prozę – uniemożliwia późniejsze zliczanie, filtrowanie lub progowanie. Wystarczą cztery pola

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;

Narzędzia w dalszej części łańcucha bazują na Code, nigdy na tekście Message, który może swobodnie ulegać zmianom. Kod wyjścia procesu postępuje zgodnie z tą samą trójwartościową umową, co w artykule o przetwarzaniu wsadowym: 0 oznacza, że plik nie wygenerował żadnych ustaleń, 1 oznacza obecność ustaleń, a 2 oznacza, że sam audyt nie mógł zostać uruchomiony, ponieważ plik nie dał się przetworzyć (sparować) lub wymaga hasła. Oddzielenie kodu 2 ma znaczenie. Folder uszkodzonych skanów to po prostu zepsuty skaner na wcześniejszym etapie, a nie nagłe załamanie zgodności, a łączenie obu przypadków w jeden posyła kogoś do szukania problemu nie tam, gdzie trzeba

Elementy interaktywne: skrypty, cele uruchamiania, linki zewnętrzne

PDFium klasyfikuje każdą znalezioną akcję według typu całkowitoliczbowego, a stałe z pliku fpdf_doc.h warto precyzyjnie przypiąć, ponieważ błędnie skopiowane wartości mogą sprawić, że skaner będzie bezgłośnie ślepy. Rzeczywiste wyliczenie to PDFACTION_UNSUPPORTED = 0, PDFACTION_GOTO = 1, PDFACTION_REMOTEGOTO = 2, PDFACTION_URI = 3, PDFACTION_LAUNCH = 4 i PDFACTION_EMBEDDEDGOTO = 5. Zauważ, czego brakuje: nie ma tam członka dla JavaScriptu. Skrypty na poziomie dokumentu nie są akcjami linków i nigdy nie pojawiają się przez FPDFAction_GetType; są one wyliczane przez odrębną rodzinę wywołań. Audytor testujący typy akcji względem wyobrażonej stałej JavaScript pomyślnie się kompiluje, uruchamia i nic nie znajduje, na zawsze

const
  PDFACTION_GOTO         = 1;   // in-document jump: harmless
  PDFACTION_REMOTEGOTO   = 2;   // jump into another local file
  PDFACTION_URI          = 3;   // opens external URL
  PDFACTION_LAUNCH       = 4;   // starts external program
  PDFACTION_EMBEDDEDGOTO = 5;   // jump into 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;

Podział powag koduje politykę. Akcja Launch to błąd, ponieważ uruchomienie dowolnego programu to najbardziej niebezpieczna rzecz, jaką może spowodować kliknięcie w PDF-ie, a żadna faktura tego nie potrzebuje. Zewnętrzne URI to ostrzeżenia: powszechne w autentycznych dokumentach, jednak recenzent powinien zobaczyć cel bez konieczności klikania, ponieważ widoczny tekst linku i rzeczywiste miejsce docelowe nie muszą być ze sobą zgodne. Wewnętrzne skoki w dokumencie (GoTo) to struktura, nie zachowanie, i całkowicie omijają raport – preflight, który bije na alarm przy każdym wpisie spisu treści, trenuje ludzi, by go ignorowali. Aby dowiedzieć się o odczytywaniu treści skryptów, które stoją za licznikiem JavaScriptu, oraz wykrywaniu poziomów MDP podpisów i XFA, artykuł dotyczący audytu ryzyka bezpieczeństwa przeprowadza przez ten sam obszar za pomocą obiektu opakowującego (object wrapper) z komponentu

Metryki zasobów: efektywne DPI obrazów

Obraz wewnątrz pliku PDF nie posiada własnego DPI. Składa się z pikseli, a strona umieszcza te piksele w prostokącie mierzonym w punktach, gdzie 72 punkty stanowią cal. Rozdzielczość istnieje jedynie jako stosunek tych dwóch wartości, dlatego to samo zdjęcie o rozmiarze 600 na 400 będzie ostre jak brzytwa jako miniaturka i będzie jedną wielką plamą na pełnej stronie. Z tego powodu audyt potrzebuje obu wartości dla każdego obrazu: wymiarów źródłowych w pikselach z metadanych obrazu oraz umiejscowionego prostokąta z granic obiektu

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;

Progi to kwestia polityki, nie fizyki: 150 DPI to minimum, poniżej którego drukowanie biurowe ulega wyraźnej pikselizacji, 300 to zwykły cel komercyjny, a wszystko powyżej 600 nie daje widocznej jakości, za to zwiększa rozmiar pliku, dlatego zgłaszane jest informacyjnie jako tzw. "bloat" (nadmierny rozmiar pliku), a nie jako defekt. Szczerze zaznaczając: FPDFPageObj_GetBounds zwraca wyrównane do osi ramy ograniczające (axis-aligned box), więc dla obrazu umieszczonego z rotacją wyliczona wartość zaniża rzeczywistą gęstość. Struktura FPDF_IMAGEOBJ_METADATA przenosi również pola horizontal_dpi i vertical_dpi, które PDFium wywodzi z pełnej macierzy przekształcenia, a porównywanie wyników z obu metod to tani sposób na wykrycie obróconych umiejscowień. Taka sama arytmetyka zamiany punktów na piksele napędza renderowanie w przeciwnym kierunku, co zostało opisane w artykule o eksporcie do JPEG

Stan bezpieczeństwa: szyfrowanie i bity uprawnień

Szyfrowanie PDF definiuje dwa hasła pełniące różne zadania. Hasło użytkownika ogranicza dostęp do odszyfrowywania: bez niego plik w ogóle się nie otworzy, a FPDF_LoadDocument zwraca nil, podczas gdy FPDF_GetLastError raportuje FPDF_ERR_PASSWORD. Hasło właściciela ogranicza dostęp do uprawnień: plik chroniony tylko hasłem właściciela otwiera się bez uwierzytelniania, ale przenosi bity ograniczeń, które zgodny z formatem czytnik musi honorować. Próba wczytywania sama w sobie stanowi więc pierwszą weryfikację bezpieczeństwa, a z tego zróżnicowania wynika kod wyjścia – plik z hasłem użytkownika jest niesprawdzalny (kod 2), podczas gdy audyt pliku z hasłem właściciela przebiega normalnie, polegając jedynie na gromadzeniu ustaleń

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;

Maski pochodzą z Tabeli 22 normy ISO 32000-1, w której bity są numerowane od 1: bit 3 z wartości /P to maska 4, bit 5 to 16, bit 12 to 2048. To, czy dane ustalenie ma znaczenie, to decyzja zależąca od celu routingu dokumentu. Drukarnia powinna odbijać plik z kodem SEC-NOPRINT już na etapie przyjmowania, dając przesyłającemu jasny komunikat, zamiast zostawić to do momentu rastrowania (RIP) trzy godziny przed deadlinem. Archiwum powinno potraktować SEC-ENC w zasadzie jako bloker, ponieważ szyfrowanie z długoterminowym przechowywaniem nie idą w parze — fakt ten ma zamiar formalnie podkreślić kontrola pod kątem zgodności ze standardami

Znaczniki standardów: odczytywanie deklaracji PDF/A

Plik deklaruje zgodność z PDF/A w swoim pakiecie metadanych XMP, za pomocą właściwości pdfaid:part (od 1 do 4) oraz pdfaid:conformance (poziom w postaci litery, takiej jak b, odpowiadającej za wierność wizualną lub a do pełnego, strukturalnego tagowania). API C PDFium nie oferuje żadnego akcesora XMP; FPDF_GetMetaText odczytuje tylko słownik Info, a nie tam umiejscowiona jest identyfikacja. Wyjściem awaryjnym (escape hatch) okazuje się reguła znajdująca się w samym standardzie: ISO 19005 wymaga, by strumień metadanych XMP był przechowywany w formie nieskompresowanej, z myślą właśnie o tym, by różnego rodzaju narzędzia mogły go odnaleźć bez pełnego parsera PDF. Skan surowych bajtów jest więc prawomocnym detektorem deklaracji (claim detector) — a plik, którego deklaracja chowa się wewnątrz skompresowanego strumienia, właściwie od razu ulega złamaniu standardu, do którego pretenduje

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;

Generowane przez to znalezisko jest celowo informacyjne, ponieważ deklaracja to jedynie roszczenie, a nie właściwość pliku. Wpis XMP to jedna linia XML, którą może utworzyć dowolny generator (nawet wadliwy); natomiast rzeczywista zgodność oznacza, że plik faktycznie spełnia setki reguł dotyczących osadzonych czcionek, kolorów niezależnych od urządzenia oraz zakazanych funkcji. Wykrycie deklaracji mówi nam tylko, które pliki należy skierować do właściwej walidacji - i nic więcej. Wbudowany w komponent silnik preflightowy wykonuje tę walidację dla profili PDF/A, PDF/UA i PDF/X, a artykuł o CLI dla wsadowego preflightu pokazuje, jak włączyć go w cykl przetwarzania z raportami, które audytor będzie mógł później otworzyć

Uruchomienie dla problematycznego pliku

Sterownik łączy te kontrole ze sobą: na pierwszym miejscu stawia sprawdzanie bezpieczeństwa, ponieważ właśnie od niego zależy to, czy audyt w ogóle zadziała, a zaraz po nim na liście znajdują się m.in. zachowania operujące na poziomie całego dokumentu oraz deklaracja standardów (standards claim). Potem rozpoczyna się sprawdzanie operujące w pętli dla każdej strony, by ocenić akcje oraz obrazy

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;

W odniesieniu do ulotki, która do nas trafiła ze współpracującej z nami zewnętrznej agencji, otrzymany wynik mógłby kształtować się w następujący sposób

> 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

Każda linia sama w sobie stanowi podstawę do podjęcia działań, ale ich połączenie jest prawdziwym werdyktem. Ten plik deklaruje standard PDF/A-2, podczas gdy niesie za sobą słownik szyfrujący i aktywny JavaScript, a PDF/A zabrania wręcz ich obu – tak więc deklaracja okazuje się obiektywnie fałszywa zanim jeszcze jakikolwiek gruntowny walidator zacznie działać. To jest właśnie ten rodzaj sprzeczności, który uwidacznia płaska lista ustaleń, a który zostałby ukryty w logicznym wyniku (boolean) pass/fail (zaliczony/oblany)

Czego ten audyt Ci nie powie

Uczciwość co do zakresu jest tym, co buduje zaufanie do narzędzia preflight. Wszystko powyżej odczytuje to, co plik deklaruje sam o sobie: PDFium parsuje strukturę, a ten audyt ją inwentaryzuje. Nie przeprowadza on walidacji PDF/A — nie ma tu weryfikacji pokrycia glifów dla osadzonych czcionek, analizy przestrzeni barw w stosunku do docelowych profili wyjściowych (output intents), ani żadnych reguł na poziomie poszczególnych punktów (klauzul), które odróżniałyby deklarację od rzeczywistej zgodności; do tego potrzebujesz dedykowanego walidatora, takiego jak silnik preflightu tego komponentu lub veraPDF. Bity uprawnień to deklaracje, które honorują zgodne czytniki, a nie ściany kryptograficzne, więc SEC-NOPRINT opisuje raczej zamiar niż egzekwowanie blokady. Skan akcji obejmuje adnotacje z linkami i skrypty na poziomie dokumentu; skrypty ukryte w słownikach zdarzeń pól formularzy potrzebują interfejsów API formularzy. Ponadto, weryfikacja podpisów, jeśli o nią rozszerzysz audyt, zgłasza deklarowany zamiar, a nie zweryfikowaną kryptografię — walidacja łańcucha certyfikatów to oddzielne zadanie. Audyt preflight to wywiad wstępny, a nie rozprawa sądowa: jego zadaniem jest sprawienie, aby decyzje o przepływie (routingu) były poinformowane, szybkie i powtarzalne

Uwaga: Używane w całym tym audycie API dla dokumentu, strony, adnotacji i obiektów obrazów, razem z wysokopoziomowym wrapperem Delphi oraz pełnym silnikiem preflightu walidującym standardy, są dostarczane z komponentem PDFium Component