Tehnički članak

Automatizirani PDF Preflight (priprema za tisak) i revizija rizika s PDFiumom

PDF koji stigne na proizvodnu granicu (production boundary) — u red za ispis, arhivu, korisnički portal za prijenos (upload) — treba biti revidiran prije nego što ga bilo što iscrta (render). Datoteka bi mogla nositi radnju Launch povezanu za pokretanje vanjskog programa, slike koje su pregrube (coarse) da bi preživjele ispis, šifrirani rječnik koji zabranjuje upravo onaj ispis (print job) za koji je podnesena (submitted), ili PDF/A oznaku kojoj ne udovoljava. Pregledavanje dokumenta prema pravilima poput ovih prije nego što uđe u radni tijek (workflow) naziva se preflight, a PDFium C API daje Delphiju sve što je potrebno da provjere implementira izravno, bez iscrtavanja ijedne stranice

Ovaj članak izgrađuje same provjere: četiri klase za reviziju, svaka mala rutina koja dodaje nalaze u zajednički popis rezultata. Interaktivni elementi, metrika resursa, sigurnosno stanje i markeri standarda dobivaju radni kod, uključujući matematiku. Ako je ono što vam treba mehanizam (machinery) oko provjera — batch petlje po mapama (folders), JSON i HTML datoteke za izvješća, izolacija po datoteci — PDFium Component isporučuje gotov mehanizam (engine) za preflight, a članak o CLI preflightu u batchu (seriji) pokriva to preusmjeravanje (plumbing). To dvoje namjerno dijeli jedan vokabular izlaznih kodova (exit-code vocabulary), pa se revizor (auditor) napisan ovdje savršeno (straight) uklapa ispod tog batch pokretača (driver)

Zapis o nalazu (finding record) i ugovor o izlaznom kodu (exit-code contract)

Svaka provjera piše u jedan jedini plosnati (flat) tip zapisa, jer se alternativa, da svaka provjera ispisuje vlastitu prozu, ne može poslije brojiti (counted), filtrirati niti uspoređivati s pragom (thresholded). Četiri polja su dovoljna

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

type
  TFindingSeverity = (fsInfo, fsWarning, fsError);

  TPreflightFinding = record
    Severity: TFindingSeverity;
    Code: string;       // stabilan strojni ključ (machine key), npr. 'ACT-LAUNCH'
    Page: Integer;      // počinje od 1 (1-based); 0 znači razina dokumenta
    Message: string;    // za ljude; slobodno za preoblikovanje (reword) između izdanja
  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;

Alati nizvodno (downstream) oslanjaju se na Code, nikada na tekst Message, koji se može slobodno mijenjati. Izlazni kod procesa (process exit code) slijedi isti ugovor s tri vrijednosti kao i članak o batchu: 0 znači da datoteka nije proizvela nikakve nalaze (findings), 1 znači da nalazi postoje, a 2 znači da se sama revizija nije mogla pokrenuti jer se datoteka nije uspjela parsirati ili zahtijeva lozinku. Držanje koda 2 odvojenim je bitno (matters). Mapa s oštećenim (corrupt) skeniranim dokumentima je pokvaren skener uzvodno (upstream), a ne iznenadni krah (collapse) u udovoljavanju standardima (compliance), a preklapanje (folding) ta dva zajedno šalje nekoga da juri za pogrešnim problemom

Interaktivni elementi: skripte, mete pokretanja (launch targets), vanjske veze

PDFium klasificira svaku radnju koju pronađe pomoću tipa cijelog broja (integer), a konstante iz fpdf_doc.h vrijedi precizno utvrditi (pin down), jer pogrešno kopirane vrijednosti čine skener tiho slijepim. Prava enumeracija je PDFACTION_UNSUPPORTED = 0, PDFACTION_GOTO = 1, PDFACTION_REMOTEGOTO = 2, PDFACTION_URI = 3, PDFACTION_LAUNCH = 4 i PDFACTION_EMBEDDEDGOTO = 5. Obratite pozornost na ono čega nema: nema člana za JavaScript. Skripte na razini dokumenta nisu radnje nad vezama (link actions) i nikada se ne pojavljuju kroz FPDFAction_GetType; one su enumerirane kroz odvojenu obitelj poziva. Revizor koji testira tipove radnji protiv zamišljene JavaScript konstante se kompilira (compiles), pokreće i ne pronalazi ništa, zauvijek

const
  PDFACTION_GOTO         = 1;   // skok unutar dokumenta: bezopasno
  PDFACTION_REMOTEGOTO   = 2;   // skok u drugu lokalnu datoteku
  PDFACTION_URI          = 3;   // otvara vanjski URL
  PDFACTION_LAUNCH       = 4;   // pokreće vanjski program
  PDFACTION_EMBEDDEDGOTO = 5;   // skok u ugrađenu datoteku

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;                 // veza samo prema odredištu (destination-only), nema se što označiti
    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 ostaje tih prema dizajnu
  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;

Podjela po ozbiljnosti (severity) kodira politiku (policy). Radnja Launch je greška jer je pokretanje proizvoljnog programa najopasnija stvar koju klik u PDF-u može učiniti, a to nije potrebno nijednom računu (invoice). Vanjski URI-ji su upozorenja: uobičajeni u legitimnim dokumentima, ali recenzent bi trebao vidjeti metu bez klikanja, budući da se vidljivi tekst veze i stvarno odredište ne moraju slagati. GoTo skokovi unutar dokumenta su struktura, a ne ponašanje, i u potpunosti ostaju izvan izvješća — preflight koji viče 'vuk' na svaki unos u tablici sadržaja (table-of-contents) uči ljude da to ignoriraju. Za čitanje tijela skripti koje stoje iza broja JavaScriptova, te za MDP razine potpisa i detekciju XFA, članak o reviziji sigurnosnih rizika prolazi kroz istu površinu kroz omotač (wrapper) objekta komponente

Metrika resursa: efektivni DPI slike

Slika unutar PDF-a nema vlastiti DPI. Ona ima piksele, a stranica postavlja te piksele u pravokutnik mjeren u točkama (points), gdje 72 točke čine jedan inč. Razlučivost (resolution) postoji samo kao omjer tih dviju vrijednosti, što je razlog zašto je ista fotografija dimenzija 600 puta 400 oštra kao žilet (razor sharp) kao sličica (thumbnail), te mutna zbrka (blurry mess) kao 'hero' preko cijele stranice. Reviziji su stoga potrebna oba broja za svaku sliku: pikselne dimenzije izvora (source) iz metapodataka slike i postavljeni pravokutnik (placed rectangle) iz granica (bounds) objekta

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;              // postavljena veličina na stranici, u točkama
    HeightPt := T - B;
    if (WidthPt <= 0) or (HeightPt <= 0) or
       (Meta.Width = 0) or (Meta.Height = 0) then
      Continue;

    // 72 točke = 1 inč, pa postavljeni inči = točke / 72, a
    // efektivni DPI = izvorni pikseli / postavljeni inči.
    DpiX := Meta.Width  / (WidthPt  / 72.0);
    DpiY := Meta.Height / (HeightPt / 72.0);
    EffDpi := Min(DpiX, DpiY);      // lošija os (axis) odlučuje o kvaliteti ispisa

    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;

Pragovi su politika, a ne fizika: 150 DPI je donja granica ispod koje se uredski ispis vidljivo pikselizira, 300 je uobičajeni komercijalni cilj (target), a bilo što iznad 600 ne kupuje nikakvu vidljivu kvalitetu, dok napuhuje veličinu datoteke, zbog čega se izvještava (reports) kao informacijsko nadimanje (informational bloat), a ne kao defekt. Jedno iskreno upozorenje: FPDFPageObj_GetBounds vraća okvir usklađen s osi (axis-aligned box), pa za sliku postavljenu s rotacijom izračunata brojka podcjenjuje (underestimates) pravu gustoću. Struktura FPDF_IMAGEOBJ_METADATA također nosi polja horizontal_dpi i vertical_dpi koja PDFium izvodi (derives) iz pune transformacijske matrice (transform matrix), a usporedba tih dvaju rezultata je jeftin način da se primijete rotirani položaji (placements). Ista aritmetika točaka u piksele (points-to-pixels) pokreće renderiranje u suprotnom smjeru, što je pokriveno u članku o izvozu u JPEG

Sigurnosno stanje: šifriranje i bitovi dopuštenja

PDF šifriranje (encryption) definira dvije lozinke s različitim zadacima. Korisnička lozinka postavlja vrata (gates) prema dešifriranju: bez nje se datoteka uopće neće otvoriti, a FPDF_LoadDocument vraća nil dok FPDF_GetLastError izvještava o FPDF_ERR_PASSWORD. Vlasnička lozinka je vrata prema dopuštenjima (permissions): datoteka zaštićena samo vlasničkom lozinkom otvara se bez vjerodajnica (credentials), ali nosi bitove ograničenja (restriction bits) koje čitač koji je u skladu sa standardima (conforming reader) mora poštovati. Sam pokušaj učitavanja je stoga prva sigurnosna sonda (probe), i ta razlika odlučuje o izlaznom kodu — datoteku s korisničkom lozinkom nemoguće je revidirati (kod 2), dok se datoteka s vlasničkom lozinkom revidira normalno i samo gomila (accumulates) nalaze

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 znači da datoteka nije šifrirana
  begin
    // Otvoreno s praznom lozinkom, a ipak šifrirano: samo-vlasnička-lozinka.
    // Svatko je može čitati, ali bitovi dopuštenja ograničavaju ono što im
    // čitač usklađen sa standardima dopušta raditi. Nešifrirane datoteke izvještavaju
    // da su svi bitovi postavljeni, zbog čega vrata (gate) za reviziju dolaze prva.
    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: ispis (print)
      Add(Findings, fsWarning, 'SEC-NOPRINT', 0,
        'printing is not permitted');
    if (Perms and 16) = 0 then     // bit 5: kopiranje / izvlačenje (extract) sadržaja
      Add(Findings, fsInfo, 'SEC-NOCOPY', 0,
        'content extraction is not permitted');
    if (Perms and 2048) = 0 then   // bit 12: ispis visoke razlučivosti (high-resolution)
      Add(Findings, fsWarning, 'SEC-LOWPRINT', 0,
        'only low-resolution printing is permitted');
  end;
end;

Maske dolaze iz Tablice 22 ISO 32000-1, koja obrojčava (numbers) bitove od 1: bit 3 vrijednosti /P je maska 4, bit 5 je 16, bit 12 je 2048. To je li zadani nalaz bitan jest odluka o preusmjeravanju (routing). Ured (bureau) za ispis bi trebao odbiti (bounce) SEC-NOPRINT datoteku na prijamu (intake), gdje onaj tko ju je podnio dobiva jasnu poruku, umjesto na RIP-u tri sata prije isteka roka (deadline). Arhiva bi trebala tretirati sam SEC-ENC kao bloker, budući da se enkripcija i dugoročno čuvanje (preservation) ne miješaju — točka koju će provjera standarda upravo formalno iznijeti

Markeri standarda: čitanje PDF/A tvrdnje (claim)

Datoteka deklarira sukladnost s PDF/A u svom XMP paketu metapodataka, putem svojstva pdfaid:part (od 1 do 4) i pdfaid:conformance (slovo za razinu, kao što je b za vizualnu vjernost (visual fidelity) ili a za potpuno strukturalno označavanje (tagging)). PDFiumov C API ne nudi pristupnik (accessor) za XMP; FPDF_GetMetaText čita samo Info rječnik, što nije mjesto gdje živi identifikacija. Izlaz u slučaju nužde (escape hatch) je pravilo u samom standardu: ISO 19005 zahtijeva da se tok (stream) XMP metapodataka pohranjuje nekomprimiran, upravo tako da ga alati mogu pronaći bez punog PDF parsera. Skeniranje (scan) sirovih bajtova stoga je legitiman detektor tvrdnji — a datoteka čija se tvrdnja skriva unutar komprimiranog toka (stream) već je prekršila standard za koji tvrdi da ga ima

function PdfAClaim(const FileName: string): string;
var
  Bytes: TBytes;
  S: RawByteString;
  P, Limit: Integer;
begin
  Result := '';                     // prazno = nije prisutna tvrdnja o PDF/A
  Bytes := TFile.ReadAllBytes(FileName);
  if Length(Bytes) = 0 then
    Exit;
  SetString(S, PAnsiChar(@Bytes[0]), Length(Bytes));
  P := Pos('pdfaid:part', S);       // XMP identifikacijska shema
  if P = 0 then
    Exit;
  // Rukuje s <pdfaid:part>2</pdfaid:part> i pdfaid:part="2":
  // uzmite prvu znamenku nakon naziva svojstva.
  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;

Nalaz koji se ovim proizvodi namjerno je informativan, jer tvrdnja (claim) je deklaracija, a ne svojstvo datoteke. XMP unos je jedna linija XML-a koju bilo koji proizvođač (producer) može napisati, uključujući i onog pokvarenog; sukladnost (conformance) podrazumijeva da datoteka zapravo zadovoljava stotine pravila o ugrađenim fontovima, o boji neovisnoj o uređaju, i zabranjenim značajkama. Otkrivanje tvrdnje govori vam samo koje datoteke treba preusmjeriti na stvarnu validaciju, i ništa više. Ugrađeni preflight mehanizam komponente (component's built-in engine) provodi (performs) tu validaciju preko PDF/A, PDF/UA i PDF/X profila, a članak o batch CLI-ju pokazuje kako to povezati u cjevovod (pipeline) s izvješćima koja revizor (auditor) može kasnije otvoriti

Provođenje na problematičnoj datoteci

Pokretač (driver) niže provjere (checks) jednu za drugom: sigurnost prva, jer ona odlučuje hoće li se revizija uopće pokrenuti, zatim ponašanja na razini dokumenta i tvrdnja (claim) o standardu, te onda petlja (loop) po stranici za radnje (actions) i slike

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);                        // neuspjeh revizije, a ne presuda (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;

Protiv brošure koja je stigla natrag od vanjske agencije, izlaz (output) izgleda ovako

> 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

Svaki redak može se sam za sebe obraditi (actionable), ali prava presuda je u kombinaciji. Ova datoteka tvrdi da ima PDF/A-2, a istovremeno nosi šifrirani (encryption) rječnik i živi JavaScript, a PDF/A izričito (outright) zabranjuje oboje — dakle, tvrdnja se dokazivo (provably) pokazuje lažnom prije nego što se pokrene bilo koji duboki validator (deep validator). To je vrsta kontradikcije koju ravan popis nalaza iznosi na površinu, a koju logičko stanje (boolean pass/fail) prolazi/pada (pass/fail) skriva

Ono što vam ova revizija ne može reći

Iskrenost o opsegu (scope) je ono što preflight alat održava pouzdanim. Sve gore navedeno čita ono što datoteka deklarira o sebi: PDFium parsira strukturu, a ova revizija to popisuje (inventories). Ona ne izvodi PDF/A validaciju — nema provjera pokrivenosti glifovima (glyph-coverage checks) naspram ugrađenih fontova, nema analize prostora boja (color space analysis) prema izlaznim namjerama (output intents), nema niti jednog od pravila na razini klauzule (clause-level rules) koja odvajaju tvrdnju od sukladnosti; za to vam je potreban namjenski (dedicated) validator kao što je preflight mehanizam (engine) komponente (component's preflight engine) ili veraPDF. Bitovi dopuštenja su deklaracije koje čitači (readers) u skladu sa standardima poštuju, a ne kriptografski zidovi (walls), pa SEC-NOPRINT opisuje namjeru umjesto same provedbe (enforcement). Skeniranje radnji (action scan) pokriva zabilješke veza (link annotations) i skripte na razini dokumenta; za skripte zakopane u rječnicima događaja polja obrazaca (form-field event dictionaries) potrebni su API-ji za obrasce povrh toga. A provjera potpisa (signature check), ako reviziju (audit) proširite jednim takvim, prijavljuje (reports) deklariranu namjeru, a ne verificiranu kriptografiju — validacija lanca certifikata zaseban je posao. Preflight revizija (audit) je prijamni intervju (intake interview), a ne suđenje (trial): njen je zadatak (job) učiniti odluku o preusmjeravanju informiranom, brzom i ponovljivom

Napomena: Dokumenti, stranice, napomene (annotations), i API-ji slikovnih objekata korišteni u ovoj reviziji, zajedno s Delphi omotačem (wrapper) visoke razine i punim preflight mehanizmom za standardnu (standards-validation) validaciju, isporučuju se uz PDFium komponentu