Technický článek

Automatizovaný PDF preflight a audit rizik s PDFium

PDF, které dorazí na hranici produkce — do tiskové fronty, do archivu, do zákaznického portálu pro nahrávání — by mělo být prověřeno dřív, než jej cokoli vykreslí. Soubor může nést akci Launch napojenou na spuštění externího programu, obrázky příliš hrubé na to, aby přežily tisk, šifrovací slovník, jenž zakazuje právě tu tiskovou úlohu, kvůli které byl odeslán, nebo označení PDF/A, kterému nedostojí. Prověřování dokumentu proti takovým pravidlům dřív, než vstoupí do workflow, se nazývá preflight a C API knihovny PDFium dává Delphi vše potřebné k tomu, aby tyto kontroly implementovalo přímo, bez vykreslení jediné stránky

Tento článek staví samotné kontroly: čtyři třídy auditu, každou jako malou rutinu, jež připojuje nálezy do sdíleného seznamu výsledků. Interaktivní prvky, metriky zdrojů, stav zabezpečení i značky standardů dostanou funkční kód včetně aritmetiky. Pokud potřebujete mašinerii kolem kontrol — dávkové procházení složek, soubory s reporty v JSON a HTML, izolaci jednotlivých souborů — PDFium Component přináší hotový preflight engine a článek o dávkovém preflight CLI tuto instalatérskou práci popisuje. Obojí záměrně sdílí jeden slovník návratových kódů, takže auditor napsaný zde zapadne rovnou pod onen dávkový ovladač

Diagram zpracování PDF: vstupní PDF se rozvětví do čtyř tříd kontrol, jejichž nálezy se sbíhají do jednoho záznamu TPreflightFinding, který se mapuje na návratový kód podle prahů
Audit rozvětví nedůvěryhodný soubor do čtyř tříd kontrol — interaktivní prvky, metriky zdrojů, stav zabezpečení a značky standardů — sesbírá každý výsledek do jednoho spočitatelného záznamu nálezů a promění jej v jediný návratový kód

Záznam nálezu a smlouva o návratových kódech

Každá kontrola zapisuje do jednoho plochého typu záznamu, protože alternativu, kdy si každá kontrola tiskne vlastní prózu, nelze později spočítat, filtrovat ani prahovat. Čtyři položky stačí

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

type
  TFindingSeverity = (fsInfo, fsWarning, fsError);

  TPreflightFinding = record
    Severity: TFindingSeverity;
    Code: string;       // stabilní strojový klíč, např. 'ACT-LAUNCH'
    Page: Integer;      // počítáno od 1; 0 znamená úroveň dokumentu
    Message: string;    // pro lidi; mezi vydáními se smí přeformulovat
  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;

Navazující nástroje se klíčují podle Code, nikdy podle textu v Message, který se smí měnit. Návratový kód procesu se řídí toutéž trojhodnotovou smlouvou jako dávkový článek: 0 znamená, že soubor nevyprodukoval žádné nálezy, 1 znamená, že nálezy existují, a 2 znamená, že audit sám nemohl proběhnout, protože se soubor nepodařilo rozparsovat nebo vyžaduje heslo. Držet kód 2 odděleně má význam. Složka poškozených skenů je rozbitý skener na vstupu, ne náhlý kolaps souladu s pravidly, a smíchat obojí dohromady pošle někoho honit se za špatným problémem

Interaktivní prvky: skripty, cíle Launch, externí odkazy

PDFium každou nalezenou akci třídí podle celočíselného typu a konstanty z fpdf_doc.h stojí za to přesně přišpendlit, protože špatně opsané hodnoty udělají ze skeneru tiše slepého. Skutečný výčet je PDFACTION_UNSUPPORTED = 0, PDFACTION_GOTO = 1, PDFACTION_REMOTEGOTO = 2, PDFACTION_URI = 3, PDFACTION_LAUNCH = 4 a PDFACTION_EMBEDDEDGOTO = 5. Všimněte si, co chybí: žádný člen pro JavaScript. Skripty na úrovni dokumentu nejsou akce odkazů a přes FPDFAction_GetType se nikdy neobjeví; vyjmenovává je samostatná rodina volání. Auditor, který testuje typy akcí proti vymyšlené konstantě pro JavaScript, se zkompiluje, poběží a nikdy nic nenajde

const
  PDFACTION_GOTO         = 1;   // skok uvnitř dokumentu: neškodný
  PDFACTION_REMOTEGOTO   = 2;   // skok do jiného místního souboru
  PDFACTION_URI          = 3;   // otevře externí URL
  PDFACTION_LAUNCH       = 4;   // spustí externí program
  PDFACTION_EMBEDDEDGOTO = 5;   // skok do vloženého souboru

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;                 // odkaz jen s cílem, není co hlásit
    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 záměrně mlčí
  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;

Rozdělení závažností kóduje politiku. Akce Launch je chyba, protože spuštění libovolného programu je to nejnebezpečnější, co klik v PDF umí, a žádná faktura to nepotřebuje. Externí URI jsou varování: u legitimních dokumentů běžná, ale recenzent by měl cíl vidět, aniž by klikal, protože viditelný text odkazu a skutečný cíl se shodovat nemusí. Skoky GoTo uvnitř dokumentu jsou struktura, ne chování, a do reportu se vůbec nedostanou — preflight, jenž vyje na každou položku obsahu, naučí lidi jej ignorovat. Ke čtení těl skriptů za počtem JavaScriptu a k úrovním MDP u podpisů i k detekci XFA prochází touž plochu přes objektovou obálku komponenty článek o auditu bezpečnostních rizik

Metriky zdrojů: efektivní DPI obrázku

Obrázek uvnitř PDF žádné vlastní DPI nemá. Má pixely a stránka tyto pixely umístí do obdélníku měřeného v bodech, kde 72 bodů dělá palec. Rozlišení existuje jen jako poměr obojího, a proto je táž fotografie 600 na 400 jako miniatura ostrá jako břitva a přes celou stránku rozmazaná kaše. Audit tedy potřebuje u každého obrázku obě čísla: rozměry zdroje v pixelech z metadat obrázku a umístěný obdélník z hranic objektu

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;              // umístěná velikost na stránce, v bodech
    HeightPt := T - B;
    if (WidthPt <= 0) or (HeightPt <= 0) or
       (Meta.Width = 0) or (Meta.Height = 0) then
      Continue;

    // 72 bodů = 1 palec, takže umístěné palce = body / 72 a
    // efektivní DPI = pixely zdroje / umístěné palce.
    DpiX := Meta.Width  / (WidthPt  / 72.0);
    DpiY := Meta.Height / (HeightPt / 72.0);
    EffDpi := Min(DpiX, DpiY);      // horší osa rozhoduje o kvalitě tisku

    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;

Prahy jsou politika, ne fyzika: 150 DPI je podlaha, pod kterou kancelářský tisk viditelně pixeluje, 300 je obvyklý komerční cíl a cokoli nad 600 nekoupí žádnou viditelnou kvalitu, jen nafoukne velikost souboru, a proto se hlásí jako informativní nadbytek, ne jako vada. Jedna poctivá výhrada: FPDFPageObj_GetBounds vrací obdélník zarovnaný s osami, takže u obrázku umístěného s rotací spočítané číslo skutečnou hustotu podceňuje. Struktura FPDF_IMAGEOBJ_METADATA nese i položky horizontal_dpi a vertical_dpi, které PDFium odvozuje z celé transformační matice, a porovnat oba výsledky je levný způsob, jak rotovaná umístění odhalit. Táž aritmetika mezi body a pixely žene vykreslování opačným směrem, jak popisuje článek o exportu do JPEG

Stav zabezpečení: šifrování a bity oprávnění

Šifrování PDF definuje dvě hesla s různými úkoly. Uživatelské heslo hlídá dešifrování: bez něj se soubor vůbec neotevře a FPDF_LoadDocument vrátí nil, přičemž FPDF_GetLastError hlásí FPDF_ERR_PASSWORD. Heslo vlastníka hlídá oprávnění: soubor chráněný jen heslem vlastníka se otevře bez přihlašovacích údajů, ale nese omezující bity, které vyhovující čtečka musí ctít. První bezpečnostní sondou je tedy sám pokus o načtení a tento rozdíl rozhoduje o návratovém kódu — soubor s uživatelským heslem je neauditovatelný (kód 2), kdežto soubor s heslem vlastníka se audituje normálně a jen sbírá nálezy

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 znamená, že soubor není šifrovaný
  begin
    // Otevřeno s prázdným heslem, a přesto šifrované: jen heslo vlastníka.
    // Číst je smí kdokoli, ale bity oprávnění omezují, co mu vyhovující
    // čtečka dovolí udělat. Nešifrované soubory hlásí všechny bity
    // nastavené, a proto přijde kontrola revize na řadu první.
    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: tisk
      Add(Findings, fsWarning, 'SEC-NOPRINT', 0,
        'printing is not permitted');
    if (Perms and 16) = 0 then     // bit 5: kopírování / extrakce obsahu
      Add(Findings, fsInfo, 'SEC-NOCOPY', 0,
        'content extraction is not permitted');
    if (Perms and 2048) = 0 then   // bit 12: tisk ve vysokém rozlišení
      Add(Findings, fsWarning, 'SEC-LOWPRINT', 0,
        'only low-resolution printing is permitted');
  end;
end;

Masky pocházejí z tabulky 22 normy ISO 32000-1, která bity čísluje od 1: bit 3 hodnoty /P je maska 4, bit 5 je 16, bit 12 je 2048. Zda daný nález něco znamená, je rozhodnutí o směrování. Tiskové studio by mělo soubor SEC-NOPRINT odmítnout na příjmu, kde odesílatel dostane jasnou zprávu, ne až na RIPu tři hodiny před termínem. Archiv by měl brát jako blokátor už samo SEC-ENC, protože šifrování a dlouhodobé uchovávání se nesnesou — což kontrola standardů vzápětí řekne formálně

Značky standardů: čtení nároku na PDF/A

Soubor deklaruje shodu s PDF/A ve svém metadatovém balíčku XMP, vlastností pdfaid:part (1 až 4) a pdfaid:conformance (písmeno úrovně, třeba b pro vizuální věrnost nebo a pro plné strukturální tagování). C API knihovny PDFium žádný přístup k XMP nenabízí; FPDF_GetMetaText čte jen slovník Info, kde identifikace nesídlí. Únikovým východem je pravidlo v samotné normě: ISO 19005 vyžaduje, aby byl metadatový proud XMP uložen nekomprimovaně, právě proto, aby jej nástroje našly bez plného parseru PDF. Syrové procházení bajtů je tedy legitimní detektor nároku — a soubor, jehož nárok se skrývá uvnitř komprimovaného proudu, už normu, na kterou si dělá nárok, porušil

function PdfAClaim(const FileName: string): string;
var
  Bytes: TBytes;
  S: RawByteString;
  P, Limit: Integer;
begin
  Result := '';                     // prázdné = žádný nárok na PDF/A
  Bytes := TFile.ReadAllBytes(FileName);
  if Length(Bytes) = 0 then
    Exit;
  SetString(S, PAnsiChar(@Bytes[0]), Length(Bytes));
  P := Pos('pdfaid:part', S);       // identifikační schéma XMP
  if P = 0 then
    Exit;
  // Zvládne <pdfaid:part>2</pdfaid:part> i pdfaid:part="2":
  // bere se první číslice za názvem vlastnosti.
  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;

Nález, který z toho vzejde, je záměrně informativní, protože nárok je prohlášení, ne vlastnost souboru. Zápis v XMP je jeden řádek XML, který umí napsat kterýkoli producent, i rozbitý; shoda je to, že soubor skutečně splňuje stovky pravidel o vložených fontech, na zařízení nezávislé barvě a zakázaných prvcích. Detekce nároku vám řekne, které soubory poslat na skutečnou validaci, a nic víc. Vestavěný preflight engine komponenty tuto validaci provádí napříč profily PDF/A, PDF/UA a PDF/X a článek o dávkovém CLI ukazuje, jak jej zapojit do linky s reporty, které si auditor otevře později

Průběh nad problémovým souborem

Ovladač navlékne kontroly za sebe: nejdřív zabezpečení, protože rozhoduje, zda audit vůbec proběhne, pak chování na úrovni dokumentu a nárok na standard a nakonec cyklus přes stránky pro akce a obrázky

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);                        // selhání auditu, ne verdikt
  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;

U brožury, která se vrátila od externí agentury, vypadá výstup takto

> 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ždý řádek je akční sám o sobě, ale skutečný verdikt je jejich kombinace. Tento soubor si dělá nárok na PDF/A-2, a přitom nese šifrovací slovník a živý JavaScript, což PDF/A obojí přímo zakazuje — takže je ten nárok prokazatelně nepravdivý dřív, než se rozeběhne jakýkoli hloubkový validátor. Přesně takový rozpor plochý seznam nálezů ukáže a booleovské prošlo či neprošlo skryje

Co vám tento audit neřekne

Poctivost ohledně rozsahu je to, co drží preflight nástroj důvěryhodný. Všechno výše čte, co o sobě soubor deklaruje: PDFium rozparsuje strukturu a tento audit ji inventarizuje. Neprovádí validaci PDF/A — žádné kontroly pokrytí glyfů proti vloženým fontům, žádnou analýzu barevných prostorů proti výstupním záměrům, nic z pravidel na úrovni jednotlivých článků, jež oddělují nárok od shody; na to potřebujete vyhrazený validátor, například preflight engine komponenty nebo veraPDF. Bity oprávnění jsou prohlášení, která vyhovující čtečky ctí, ne kryptografické zdi, takže SEC-NOPRINT popisuje záměr, ne vynucení. Procházení akcí pokrývá anotace odkazů a skripty na úrovni dokumentu; skripty pohřbené ve slovnících událostí formulářových polí vyžadují navrch formulářové API. A kontrola podpisu, pokud jí audit rozšíříte, hlásí deklarovaný záměr, ne ověřenou kryptografii — validace řetězce certifikátů je samostatná práce. Preflight audit je vstupní pohovor, ne soud: jeho úkolem je udělat rozhodnutí o směrování informovaným, rychlým a opakovatelným

Poznámka: API pro dokument, stránku, anotace a obrázkové objekty použitá v celém tomto auditu spolu s vysokoúrovňovou obálkou pro Delphi a plným preflight enginem pro validaci standardů jsou součástí komponenty PDFium Component