Műszaki cikk

Automatizált PDF preflight és kockázatellenőrzés a PDFium segítségével

Egy PDF-et, amely egy termelési határhoz érkezik — nyomtatási sorba, archívumba, ügyfélfeltöltési portálra —, ellenőrizni kell, mielőtt bármi is megjelenítené. A fájl hordozhat egy olyan "Launch" (Indítás) műveletet, amely egy külső program indítására van bekötve, túl durva képeket a nyomtatás túléléséhez, egy titkosítási szótárat, amely megtiltja magát a nyomtatási feladatot, amelyre beküldték, vagy egy olyan PDF/A címkét, amelynek nem felel meg. Egy dokumentum ellenőrzését az ilyen szabályok alapján, mielőtt egy munkafolyamatba kerülne, preflightingnak (nyomdai előkészítő ellenőrzésnek) nevezzük, és a PDFium C API megadja a Delphinek mindazt, amire szükség van ezen ellenőrzések közvetlen implementálásához, egyetlen oldal megjelenítése nélkül

Ez a cikk magukat az ellenőrzéseket építi fel: négy ellenőrző osztályt, amelyek mindegyike egy kis rutin, ami megállapításokat fűz egy megosztott eredménylistához. Az interaktív elemek, az erőforrás-metrikák, a biztonsági állapot és a szabványjelölők mind működő kódot kapnak, beleértve a matematikát is. Ha arra a gépezetre van szüksége, amely az ellenőrzések körül van — kötegelt (batch) mappa ciklusok, JSON és HTML jelentésfájlok, fájlonkénti izoláció —, a PDFium komponens szállít egy kész preflight motort, és a kötegelt preflight CLI cikk foglalkozik ezzel az infrastruktúrával. A kettő szándékosan oszt meg egy kilépési kód (exit-code) szókincset, így egy itt írt ellenőrző egyenesen illeszkedik a kötegelt vezérlő alá

A megállapítás rekordja és a kilépési kód szerződés

Minden ellenőrzés egy lapos rekordtípusba ír, mert az alternatíva, miszerint minden ellenőrzés a saját prózáját nyomtatja, később nem számolható, szűrhető vagy küszöbözhető. Négy mező elegendő

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;

A feldolgozási láncban lejjebb lévő eszközök a Code-ra (kódra) támaszkodnak, sosem a Message (üzenet) szövegére, ami szabadon változhat. A folyamat kilépési kódja ugyanazt a háromértékű szerződést követi, mint a kötegelt cikkben: a 0 azt jelenti, hogy a fájl nem hozott megállapításokat, az 1 azt jelenti, hogy vannak megállapítások, a 2 pedig azt, hogy maga az ellenőrzés nem tudott lefutni, mert a fájl elemzése (parse) sikertelen volt, vagy jelszót követel. A 2-es kód külön tartása fontos. Egy mappa korrupt beolvasás (scan) egy hibás szkennert jelent a lánc elején, nem pedig hirtelen megfelelőségi összeomlást, és e kettő összemosása valakit egy rossz probléma üldözésére küld

Interaktív elemek: szkriptek, indítási célok, külső linkek

A PDFium minden általa talált műveletet egy egész típus alapján osztályoz, és az fpdf_doc.h-ból származó konstansokat érdemes pontosan rögzíteni, mert a rosszul másolt értékek némán vakká teszik a scannert. A valódi felsorolás: PDFACTION_UNSUPPORTED = 0, PDFACTION_GOTO = 1, PDFACTION_REMOTEGOTO = 2, PDFACTION_URI = 3, PDFACTION_LAUNCH = 4 és PDFACTION_EMBEDDEDGOTO = 5. Figyelje meg, mi hiányzik: nincs JavaScript tag. A dokumentum szintű szkriptek nem link műveletek, és sosem jelennek meg az FPDFAction_GetType híváson keresztül; ezeket a hívások egy külön családja sorolja fel. Egy olyan ellenőrző, amely a művelettípusokat egy elképzelt JavaScript konstans ellen teszteli, lefordul, lefut, és sosem talál semmit

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;

A súlyossági felosztás a házirendet kódolja. Egy Launch (Indítás) művelet hiba, mert egy tetszőleges program elindítása a legveszélyesebb dolog, amit egy PDF-ben történő kattintás tehet, és semmilyen számlának nincs szüksége rá. A külső URI-k figyelmeztetések: gyakoriak a legitim dokumentumokban, de egy felülvizsgálónak kattintás nélkül kellene látnia a célt, mivel a látható linkszöveg és a tényleges célpont nem feltétlenül egyezik meg. A dokumentumon belüli GoTo ugrások a struktúrát alkotják, nem a viselkedést, és teljesen kimaradnak a jelentésből — egy preflight, ami minden tartalomjegyzék bejegyzésnél farkast kiált, arra tanítja az embereket, hogy figyelmen kívül hagyják. A JavaScript darabszám mögötti szkripttestek olvasásához, valamint az aláírás MDP szintjeihez és az XFA észleléshez a biztonsági kockázatok ellenőrzéséről szóló cikk ugyanezt a felületet járja végig a komponens objektumcsomagolóján (object wrapper) keresztül

Erőforrás-metrikák: tényleges kép DPI

A PDF-ben lévő képnek nincs saját DPI-je. Képpontjai (pixelek) vannak, és az oldal ezeket a képpontokat egy pontokban mért téglalapba helyezi el, ahol 72 pont tesz ki egy hüvelyket. A felbontás csak e kettő arányaként létezik, ezért van az, hogy ugyanaz a 600 x 400-as fotó borotvaéles bélyegképként, és homályos massza egy teljes oldalas "hero" képként. Az ellenőrzésnek ezért mindkét számra szüksége van minden képnél: a forrás pixelméretekre a kép metaadataiból, és az elhelyezett téglalapra az objektum határaiból

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;

A küszöbértékek házirendi kérdések, nem fizikaiak: a 150 DPI az a határ, amely alatt az irodai nyomtatás láthatóan pixelessé válik, a 300 a szokásos kereskedelmi cél, a 600 feletti értékek pedig nem hoznak látható minőséget, miközben felduzzasztják a fájlméretet, ezért is jelenik meg tájékoztató jellegű pazarolásként (bloat), nem pedig hibaként. Egy őszinte figyelmeztetés: az FPDFPageObj_GetBounds a tengelyhez igazított (axis-aligned) dobozt adja vissza, így egy elforgatva elhelyezett kép esetén a számított érték alulbecsüli a valódi sűrűséget. Az FPDF_IMAGEOBJ_METADATA struktúra hordoz horizontal_dpi és vertical_dpi mezőket is, amelyeket a PDFium a teljes transzformációs mátrixból vezet le, és a két eredmény összehasonlítása egy olcsó módja az elforgatott elhelyezések észlelésének. Ugyanez a pontokból képpontokba történő matematika hajtja a megjelenítést az ellenkező irányba is, amivel a JPEG exportálás cikk foglalkozik

Biztonsági állapot: titkosítási és engedélybitek

A PDF titkosítás két jelszót definiál különböző feladatokkal. A felhasználói (user) jelszó védi a dekódolást: enélkül a fájl egyáltalán nem nyílik meg, és az FPDF_LoadDocument nil-t ad vissza, miközben az FPDF_GetLastError FPDF_ERR_PASSWORD hibát jelez. A tulajdonosi (owner) jelszó védi az engedélyeket: egy csak tulajdonosi jelszóval védett fájl hitelesítő adatok nélkül is megnyílik, de olyan korlátozási biteket hordoz, amelyeket egy szabványkövető olvasónak tiszteletben kell tartania. Maga a betöltési kísérlet tehát az első biztonsági szonda, és a különbségtétel dönti el a kilépési kódot — egy felhasználói jelszóval védett fájl nem ellenőrizhető (2-es kód), míg egy tulajdonosi jelszóval védett fájl normálisan ellenőrizhető, és csupán megállapításokat halmoz fel

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;

A maszkok az ISO 32000-1 22. táblázatából származnak, amely 1-től számozza a biteket: a /P érték 3. bitje a 4-es maszk, az 5. bit a 16-os, a 12. bit pedig a 2048-as. Hogy egy adott megállapítás számít-e, az egy útválasztási (routing) döntés. Egy nyomdaipari irodának már az átvételnél vissza kell utasítania egy SEC-NOPRINT fájlt, ahol a beküldő egyértelmű üzenetet kap, nem pedig a RIP fázisban, három órával a határidő előtt. Egy archívumnak magát a SEC-ENC-et kellene blokkoló tényezőként kezelnie, mivel a titkosítás és a hosszú távú megőrzés nem fér meg egymással — ez az a pont, amit a szabványellenőrzés hamarosan formálisan is megtesz

Szabványjelölők: a PDF/A állítás olvasása

Egy fájl a PDF/A megfelelőséget az XMP metaadat-csomagjában deklarálja, a pdfaid:part tulajdonságon (1-től 4-ig) és a pdfaid:conformance tulajdonságon (a szint betűje, mint például a b a vizuális hűségért vagy az a a teljes strukturális címkézésért) keresztül. A PDFium C API-ja nem kínál XMP hozzáférést; az FPDF_GetMetaText csak az Info szótárat olvassa, ami nem az a hely, ahol az azonosítás található. A kiskapu egy szabály magában a szabványban: az ISO 19005 megköveteli, hogy az XMP metaadat-folyamot tömörítetlenül tárolják, pontosan azért, hogy az eszközök egy teljes PDF elemző (parser) nélkül is megtalálják. Egy nyers bájtellenőrzés (byte scan) tehát egy legitim állítás-érzékelő — és az a fájl, amelynek állítása egy tömörített folyam (stream) belsejében rejtőzik, már megsértette azt a szabványt, amit állít

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;

A megállapítás, amit ez eredményez, szándékosan tájékoztató jellegű, mert az állítás csak egy deklaráció, nem pedig a fájl tulajdonsága. Az XMP bejegyzés egyetlen sornyi XML, amelyet bármelyik előállító megírhat, beleértve egy hibásat is; a megfelelőség az, hogy a fájl ténylegesen kielégíti-e a beágyazott betűtípusokra, az eszközfüggetlen színre és a tiltott funkciókra vonatkozó több száz szabályt. Az állítás észlelése megmondja, mely fájlokat kell a valódi validáláshoz irányítani, és semmi többet. A komponens beépített preflight motorja elvégzi ezt a validálást a PDF/A, PDF/UA és PDF/X profilokon, és a kötegelt CLI cikk bemutatja, hogyan lehet ezt bekötni egy olyan csővezetékbe (pipeline), amelynek jelentéseit egy ellenőr később megnyithatja

Futtatás egy problémás fájllal

A vezérlő (driver) összefűzi az ellenőrzéseket: először a biztonság, mert ez dönti el, hogy az ellenőrzés egyáltalán lefut-e, majd a dokumentum szintű viselkedések és a szabványállítások, ezután pedig egy oldal ciklus a műveletekhez és a képekhez

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;

Egy külső ügynökségtől visszatért prospektus esetén a kimenet így néz ki

> 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

Minden sor önmagában is cselekvésre ösztönöz, de a kombinációjuk a valódi ítélet. Ez a fájl PDF/A-2-t állít magáról, miközben titkosítási szótárat és élő JavaScriptet hordoz, és a PDF/A mindkettőt egyenesen tiltja — tehát az állítás bizonyíthatóan hamis, még mielőtt bármilyen mély validáló lefutna. Ez az a fajta ellentmondás, amit egy lapos megállapításlista felszínre hoz, egy logikai (boolean) siker/kudarc (pass/fail) pedig elrejt

Amit ez az ellenőrzés nem tud elmondani

Az őszinteség a hatókörről az, ami megőrzi a bizalmat egy preflight eszköz iránt. Minden fentebbi azt olvassa el, amit a fájl önmagáról deklarál: a PDFium elemzi a struktúrát, ez az ellenőrzés pedig leltározza. Nem végez PDF/A validálást — nincsenek glifa-lefedettségi ellenőrzések a beágyazott betűtípusokon, nincs színtérelemzés a kimeneti szándékokkal (output intents) szemben, a záradékszintű szabályok egyike sem, amelyek elválasztják az állítást a megfelelőségtől; ehhez egy dedikált validálóra van szükség, mint például a komponens preflight motorja vagy a veraPDF. Az engedélybitek (permission bits) olyan deklarációk, amelyeket a szabványkövető olvasók tiszteletben tartanak, nem pedig kriptográfiai falak, tehát a SEC-NOPRINT inkább a szándékot írja le, mint a kikényszerítést. A műveletek vizsgálata kiterjed a link annotációkra és a dokumentum szintű szkriptekre; az űrlapmezők eseményszótáraiba temetett szkriptekhez szükség van az űrlap (form) API-kra. És egy aláírás-ellenőrzés, ha azzal egészíti ki a vizsgálatot, a deklarált szándékot jelenti, nem az ellenőrzött kriptográfiát — a tanúsítványlánc validálása külön feladat. Egy preflight ellenőrzés a felvételi elbeszélgetés, nem a tárgyalás: a feladata, hogy az útválasztási döntést megalapozottá, gyorssá és megismételhetővé tegye

Megjegyzés: Az ebben az ellenőrzésben végig használt dokumentum, oldal, annotáció és kép objektum API-k, egy magas szintű Delphi burkolóval (wrapper) és egy teljes szabvány-validáló preflight motorral együtt, a PDFium komponenssel érkeznek