Techninis straipsnis

Automatizuotas PDF pirminis patikrinimas ir rizikos auditas su PDFium

PDF dokumentas, kuris pasiekia gamybos ribą — spausdinimo eilę, archyvą, klientų įkėlimo portalą — turėtų būti patikrintas prieš jį atvaizduojant. Faile gali būti „Launch“ veiksmas, skirtas paleisti išorinę programą, vaizdai, per daug neryškūs, kad juos būtų galima atspausdinti, šifravimo žodynas, draudžiantis būtent tą spausdinimo užduotį, kuriai jis buvo pateiktas, arba PDF/A žyma, kurios jis neatitinka. Dokumento tikrinimas pagal tokias taisykles prieš jam patenkant į darbo eigą vadinamas pirminiu patikrinimu (preflighting), o PDFium C API suteikia Delphi viską, ko reikia šiems patikrinimams tiesiogiai įgyvendinti, neatvaizduojant nė vieno puslapio

Šiame straipsnyje kuriami patys patikrinimai: keturios audito klasės, iš kurių kiekviena yra nedidelė rutina, pridedanti radinius į bendrą rezultatų sąrašą. Interaktyvūs elementai, išteklių metrikos, saugumo būsena ir standartų žymekliai gauna veikiantį kodą, įskaitant aritmetiką. Jei jums reikia paties patikrinimų mechanizmo — paketinių aplankų ciklų, JSON ir HTML ataskaitų failų, atskirų failų izoliavimo — „PDFium Component“ pateikia jau paruoštą pirminio patikrinimo variklį, o straipsnis apie paketinį pirminio patikrinimo CLI apima šią infrastruktūrą. Abu sąmoningai dalijasi vienu išėjimo kodų (exit-code) žodynu, todėl čia parašytas auditorius tinka tiesiai po tuo paketiniu tvarkyklių (batch driver) varikliu

Radinių įrašas ir išėjimo kodo (exit-code) sutartis

Kiekvienas patikrinimas rašo į vieną plokščią (flat) įrašo tipą, nes alternatyvos — kai kiekvienas patikrinimas spausdina savo tekstą — vėliau negalima suskaičiuoti, filtruoti ar taikyti ribinių verčių. Pakanka keturių laukų

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;

Tolesni įrankiai remiasi Code, o ne Message tekstu, kurį galima laisvai keisti. Proceso išėjimo kodas vadovaujasi ta pačia trijų reikšmių sutartimi kaip ir paketinis (batch) straipsnis: 0 reiškia, kad faile nerasta jokių problemų, 1 reiškia, kad rasta problemų, o 2 reiškia, kad paties audito nepavyko atlikti, nes failo nepavyko apdoroti (parse) arba jam reikia slaptažodžio. Svarbu išlaikyti 2 kodą atskirą. Sugadintų nuskaitytų dokumentų aplankas reiškia sugedusį skaitytuvą (scanner), o ne staigų atitikties (compliance) žlugimą, todėl šių dviejų dalykų suplakimas į vieną priverstų ką nors ieškoti neteisingos problemos

Interaktyvūs elementai: scenarijai, paleidimo tikslai, išorinės nuorodos

PDFium klasifikuoja kiekvieną rastą veiksmą (action) pagal sveikojo skaičiaus (integer) tipą, o konstantas iš fpdf_doc.h verta tiksliai nustatyti, nes neteisingai nukopijuotos reikšmės daro skaitytuvą tyliai aklu. Tikrasis išvardijimas yra PDFACTION_UNSUPPORTED = 0, PDFACTION_GOTO = 1, PDFACTION_REMOTEGOTO = 2, PDFACTION_URI = 3, PDFACTION_LAUNCH = 4 ir PDFACTION_EMBEDDEDGOTO = 5. Atkreipkite dėmesį, ko trūksta: nėra jokio JavaScript nario. Dokumento lygio scenarijai nėra nuorodų veiksmai ir niekada nepasirodo per FPDFAction_GetType; jie išvardijami atskira iškvietimų šeima. Auditorius, testuojantis veiksmų tipus pagal įsivaizduojamą JavaScript konstantą, susikompiliuoja, veikia ir nieko neranda, visam laikui

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;

Sunkumo laipsnio atskyrimas koduoja politiką (policy). „Launch“ veiksmas yra klaida, nes savavališkos programos paleidimas yra pavojingiausias dalykas, kurį gali padaryti paspaudimas PDF faile, ir jokiai sąskaitai faktūrai to nereikia. Išoriniai URI yra įspėjimai: dažni teisėtuose dokumentuose, tačiau peržiūrintis asmuo turėtų matyti tikslą nepaspaudęs, nes matomas nuorodos tekstas ir tikrasis paskirties taškas neturi sutapti. Dokumente esantys „GoTo“ perėjimai yra struktūra, o ne elgsena, ir jie visiškai neįtraukiami į ataskaitą — pirminis patikrinimas (preflight), kuris kelia aliarmą dėl kiekvieno turinio įrašo, išmoko žmones jį ignoruoti. Norint perskaityti scenarijų (script) kūnus už JavaScript skaičiaus, ir parašo MDP lygiams bei XFA aptikimui, saugumo rizikos audito straipsnis nagrinėja tą patį paviršių per komponento objektų apvalkalą (wrapper)

Išteklių metrikos: efektyvusis vaizdo DPI

PDF faile esantis vaizdas neturi savo DPI. Jis turi pikselius, o puslapis tuos pikselius patalpina į stačiakampį, matuojamą taškais (points), kur 72 taškai sudaro vieną colį. Skiriamoji geba egzistuoja tik kaip šių dviejų dydžių santykis, todėl ta pati 600 x 400 nuotrauka yra itin ryški kaip miniatiūra (thumbnail) ir tampa neryškia dėme per visą puslapį (hero vaizdu). Todėl auditui reikalingi abu skaičiai kiekvienam vaizdui: šaltinio pikselių matmenys iš vaizdo metaduomenų ir patalpinto stačiakampio matmenys iš objekto ribų (bounds)

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;

Ribinės vertės (thresholds) yra politika, o ne fizika: 150 DPI yra riba, žemiau kurios biuro spausdinimas akivaizdžiai tampa pikseliuotas, 300 yra įprastas komercinis tikslas, o bet kas, kas viršija 600, nesuteikia jokios matomos kokybės, tik išpučia failo dydį, todėl apie tai pranešama kaip apie informacinį išsipūtimą (bloat), o ne kaip apie defektą. Vienas sąžiningas įspėjimas: FPDFPageObj_GetBounds grąžina ašimis sulygiuotą dėžutę, todėl, jei vaizdas patalpintas su pasukimu (rotation), apskaičiuotas skaičius nepakankamai įvertina tikrąjį tankį. FPDF_IMAGEOBJ_METADATA struktūra taip pat turi horizontal_dpi ir vertical_dpi laukus, kuriuos PDFium gauna iš visos transformacijos matricos, ir šių dviejų rezultatų palyginimas yra pigus būdas pastebėti pasuktas vietas. Ta pati taškų į pikselius aritmetika valdo atvaizdavimą (rendering) priešinga kryptimi, aptartą JPEG eksporto straipsnyje

Saugumo būsena: šifravimas ir leidimų bitai

PDF šifravimas apibrėžia du slaptažodžius, turinčius skirtingas užduotis. Vartotojo slaptažodis valdo iššifravimą: be jo failas apskritai neatsidarys, o FPDF_LoadDocument grąžins nil, kartu su FPDF_GetLastError, pranešančiu apie FPDF_ERR_PASSWORD. Savininko slaptažodis valdo leidimus: failas, apsaugotas tik savininko slaptažodžiu, atidaromas be jokių prisijungimo duomenų, tačiau turi apribojimų bitus, kurių atitinkantis (conforming) skaitytuvas turi laikytis. Todėl pats bandymas įkelti failą yra pirmasis saugumo zondas, o šis skirtumas nulemia išėjimo kodą — vartotojo slaptažodžio failas negali būti audituojamas (kodas 2), o savininko slaptažodžio failas audituojamas įprastai ir tiesiog kaupia radinius

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;

Kaukės (masks) pateikiamos iš ISO 32000-1 standarto 22 lentelės, kurioje bitai numeruojami nuo 1: 3-asis /P reikšmės bitas yra kaukė 4, 5-asis bitas yra 16, 12-asis bitas yra 2048. Tai, ar tam tikras radinys yra svarbus, priklauso nuo maršrutizavimo sprendimo. Spausdinimo biuras turėtų atmesti SEC-NOPRINT failą dar priėmimo etape, kai pateikėjas gauna aiškų pranešimą, o ne RIP procese likus trims valandoms iki termino. Archyvas turėtų traktuoti patį SEC-ENC kaip blokatorių, nes šifravimas ir ilgalaikis išsaugojimas nesiderina — tai faktas, kurį netrukus oficialiai patvirtins standartų patikrinimas

Standartų žymekliai: PDF/A teiginio skaitymas

Failas deklaruoja PDF/A atitiktį savo XMP metaduomenų pakete, naudodamas pdfaid:part savybę (nuo 1 iki 4) ir pdfaid:conformance (lygio raidę, pavyzdžiui, b vizualiniam tikslumui arba a visam struktūriniam žymėjimui). PDFium C API nesiūlo jokio XMP prieigos metodo (accessor); FPDF_GetMetaText nuskaito tik Info žodyną, kuriame identifikacija nelaikoma. Išeitis (escape hatch) yra pati standarto taisyklė: ISO 19005 reikalauja, kad XMP metaduomenų srautas būtų saugomas nesuspaustas (uncompressed), būtent tam, kad įrankiai galėtų jį rasti be pilno PDF analizatoriaus (parser). Todėl neapdorotų baitų nuskaitymas (raw byte scan) yra teisėtas teiginio detektorius — o failas, kurio teiginys slepiasi suspaustame sraute, jau pažeidė standartą, kurį jis teigia atitinkąs

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;

Tai sukuriantis radinys yra sąmoningai informacinis, nes teiginys yra deklaracija, o ne failo savybė. XMP įrašas yra viena XML eilutė, kurią gali parašyti bet koks kūrėjas (producer), įskaitant ir sugedusį; atitiktis (conformance) yra tai, kai failas iš tikrųjų atitinka šimtus taisyklių dėl įterptųjų šriftų (embedded fonts), nuo įrenginio nepriklausomų spalvų (device-independent color) ir draudžiamų funkcijų. Teiginio aptikimas tik nurodo, kuriuos failus nukreipti tikram patvirtinimui (validation), ir nieko daugiau. Komponento įtaisytasis pirminio patikrinimo (preflight) variklis atlieka tą patvirtinimą PDF/A, PDF/UA ir PDF/X profiliuose, o paketinis CLI straipsnis parodo, kaip jį sujungti į konvejerį (pipeline) su ataskaitomis, kurias auditorius galės atidaryti vėliau

Probleminio failo paleidimas

Tvarkyklė (driver) sujungia patikrinimus kartu: pirmiausia saugumas, nes jis nusprendžia, ar auditas apskritai vykdomas, tada dokumento lygio elgsenos ir standartų teiginys, o tada puslapių ciklas (page loop) veiksmams ir vaizdams

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;

Patikrinus brošiūrą, grįžusią iš išorinės agentūros, išvestis atrodo taip

> 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

Kiekviena eilutė savaime leidžia imtis veiksmų, tačiau jų kombinacija yra tikrasis nuosprendis. Šis failas teigia esąs PDF/A-2, bet turi šifravimo žodyną ir veikiantį JavaScript, o PDF/A abu šiuos dalykus griežtai draudžia — todėl teiginys yra įrodomai klaidingas dar prieš paleidžiant bet kokį giluminį tvirtinimo įrankį (validator). Tai yra tas prieštaravimas, kurį atskleidžia plokščias (flat) radinių sąrašas ir kurį paslepia loginių verčių (boolean) išlaikymo/neišlaikymo (pass/fail) rezultatas

Ko šis auditas negali jums pasakyti

Sąžiningumas dėl apimties (scope) yra tai, kas išlaiko pasitikėjimą pirminio patikrinimo (preflight) įrankiu. Viskas, kas paminėta aukščiau, perskaito tai, ką failas deklaruoja pats apie save: PDFium analizuoja (parses) struktūrą, o šis auditas ją inventorizuoja. Jis neatlieka PDF/A patvirtinimo — jokių glifų aprėpties (glyph-coverage) patikrinimų įterptiems šriftams, jokios spalvų erdvės (color space) analizės išvesties ketinimams (output intents), jokių taisyklių punktų lygmeniu (clause-level), kurios atskiria teiginį nuo atitikties; tam jums reikalingas specialus tvirtinimo įrankis (validator), pvz., komponento preflight variklis arba veraPDF. Leidimų bitai yra deklaracijos, kurių laikosi (honor) atitinkantys (conforming) skaitytuvai, o ne kriptografinės sienos, todėl SEC-NOPRINT apibūdina ketinimą, o ne vykdymo užtikrinimą. Veiksmų nuskaitymas (action scan) apima nuorodų anotacijas (link annotations) ir dokumento lygio scenarijus; scenarijams, paslėptiems formos laukų (form-field) įvykių žodynuose, papildomai reikia formų API. O parašo patikrinimas (signature check), jei praplėsite auditą, praneš apie deklaruotą ketinimą, bet ne patikrintą kriptografiją — sertifikatų grandinės (certificate chain) patvirtinimas yra atskiras darbas. Pirminis (preflight) auditas yra priėmimo interviu, o ne teismo procesas: jo darbas — padaryti maršrutizavimo (routing) sprendimą pagrįstą, greitą ir pakartojamą

Pastaba: Dokumentų, puslapių, anotacijų ir vaizdų objektų API, naudojamos viso šio audito metu, kartu su aukšto lygio Delphi apvalkalu (wrapper) ir pilnu standartų patvirtinimo (standards-validation) pirminio patikrinimo (preflight) varikliu, pateikiamos kartu su PDFium komponentu