Tekninen artikkeli

Automaattinen PDF-preflight ja riskiauditointi PDFiumilla

PDF, joka saapuu tuotannon rajalle — tulostusjonoon, arkistoon, asiakkaan latausportaaliin — tulisi auditoida ennen kuin mikään piirtää sen. Tiedosto saattaa kantaa Launch-toimintoa, joka on kytketty käynnistämään ulkoinen ohjelma, kuvia jotka ovat liian karkeita selviämään tulostuksesta, salaussanakirjaa joka kieltää juuri sen tulostustyön johon se lähetettiin, tai PDF/A-merkinnän jota se ei lunasta. Dokumentin tarkastamista tällaisia sääntöjä vasten ennen sen pääsyä työnkulkuun kutsutaan preflight-tarkastukseksi, ja PDFiumin C-rajapinta antaa Delphille kaiken tarvittavan tarkistusten toteuttamiseen suoraan, ilman että yhtäkään sivua piirretään

Tämä artikkeli rakentaa itse tarkistukset: neljä auditointiluokkaa, joista kukin on pieni rutiini, joka liittää löydökset jaettuun tuloslistaan. Interaktiiviset elementit, resurssimitat, tietoturvatila ja standardimerkinnät saavat kaikki toimivan koodin, aritmetiikka mukaan lukien. Jos tarvitset tarkistusten ympärillä olevaa koneistoa — eräkansioiden silmukat, JSON- ja HTML-raporttitiedostot, tiedostokohtainen eristys — PDFium Component toimittaa valmiin preflight-moottorin, ja erä-preflightin CLI-artikkeli kattaa tuon putkiston. Nämä kaksi jakavat tarkoituksella yhden paluukoodisanaston, joten tässä kirjoitettu auditoija loksahtaa suoraan tuon eräajurin alle

PDF-liukuhihnakaavio: syöte-PDF haarautuu neljän tarkistusluokan läpi, joiden löydökset kertyvät yhteen TPreflightFinding-tietueeseen, joka kartoittuu kynnyspohjaiseksi paluukoodiksi
Auditointi haarauttaa epäluotettavan tiedoston neljän tarkistusluokan läpi — interaktiiviset elementit, resurssimitat, tietoturvatila ja standardimerkinnät — kerää jokaisen tuloksen yhteen laskettavaan löydöstietueeseen ja muuttaa sen yhdeksi paluukoodiksi

Löydöstietue ja paluukoodisopimus

Jokainen tarkistus kirjoittaa yhteen litteään tietuetyyppiin, koska vaihtoehtoa, jossa kukin tarkistus tulostaa omaa proosaansa, ei voi laskea, suodattaa eikä kynnystää jälkikäteen. Neljä kenttää riittää

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

type
  TFindingSeverity = (fsInfo, fsWarning, fsError);

  TPreflightFinding = record
    Severity: TFindingSeverity;
    Code: string;       // vakaa koneavain, esim. 'ACT-LAUNCH'
    Page: Integer;      // 1-pohjainen; 0 tarkoittaa dokumenttitasoa
    Message: string;    // ihmisille; saa muotoilla uudelleen julkaisujen välillä
  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;

Myöhempi työkalusto avaintaa Code-kenttään, ei koskaan Message-tekstiin, joka saa vapaasti muuttua. Prosessin paluukoodi noudattaa samaa kolmen arvon sopimusta kuin erä-artikkeli: 0 tarkoittaa, ettei tiedosto tuottanut löydöksiä, 1 tarkoittaa, että löydöksiä on, ja 2 tarkoittaa, ettei auditointia itseään voitu ajaa, koska tiedoston jäsennys epäonnistui tai se vaatii salasanan. Koodin 2 pitäminen erillään merkitsee. Kansiollinen turmeltuneita skannauksia on rikkinäinen skanneri ylävirrassa, ei äkillinen vaatimustenmukaisuuden romahdus, ja näiden kahden niputtaminen lähettää jonkun jahtaamaan väärää ongelmaa

Interaktiiviset elementit: skriptit, käynnistyskohteet, ulkoiset linkit

PDFium luokittelee jokaisen löytämänsä toiminnon kokonaislukutyypin mukaan, ja tiedoston fpdf_doc.h vakiot kannattaa naulata täsmällisesti, koska väärin kopioidut arvot tekevät skannerista hiljaa sokean. Todellinen luettelo on PDFACTION_UNSUPPORTED = 0, PDFACTION_GOTO = 1, PDFACTION_REMOTEGOTO = 2, PDFACTION_URI = 3, PDFACTION_LAUNCH = 4 ja PDFACTION_EMBEDDEDGOTO = 5. Huomaa mikä puuttuu: JavaScript-jäsentä ei ole. Dokumenttitason skriptit eivät ole linkkitoimintoja eivätkä koskaan näy FPDFAction_GetType-kutsun kautta; ne luetellaan erillisellä kutsuperheellä. Auditoija, joka testaa toimintotyyppejä kuviteltua JavaScript-vakiota vasten, kääntyy, ajautuu ja löytää tyhjää, ikuisesti

const
  PDFACTION_GOTO         = 1;   // dokumentin sisäinen hyppy: vaaraton
  PDFACTION_REMOTEGOTO   = 2;   // hyppy toiseen paikalliseen tiedostoon
  PDFACTION_URI          = 3;   // avaa ulkoisen URL-osoitteen
  PDFACTION_LAUNCH       = 4;   // käynnistää ulkoisen ohjelman
  PDFACTION_EMBEDDEDGOTO = 5;   // hyppy upotettuun tiedostoon

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;                 // pelkkä kohdelinkki, ei liputettavaa
    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 vaikenee tarkoituksella
  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;

Vakavuusjako koodaa politiikan. Launch-toiminto on virhe, koska mielivaltaisen ohjelman käynnistäminen on vaarallisin asia, minkä klikkaus PDF:ssä voi tehdä, eikä yksikään lasku tarvitse sitä. Ulkoiset URI-osoitteet ovat varoituksia: yleisiä laillisissa dokumenteissa, mutta katselmoijan pitäisi nähdä kohde klikkaamatta, koska näkyvän linkkitekstin ja todellisen kohteen ei tarvitse olla samaa mieltä. Dokumentin sisäiset GoTo-hypyt ovat rakennetta, eivät käyttäytymistä, ja jäävät kokonaan raportin ulkopuolelle — preflight, joka huutaa sutta jokaisesta sisällysluettelon rivistä, opettaa ihmiset sivuuttamaan sen. Skriptirunkojen lukemiseen JavaScript-laskurin takaa sekä allekirjoitusten MDP-tasoihin ja XFA-tunnistukseen tietoturvariskien auditointiartikkeli kävelee saman pinnan läpi komponentin objektikääreen kautta

Resurssimitat: kuvan tehollinen DPI

PDF:n sisällä olevalla kuvalla ei ole omaa DPI-arvoa. Sillä on pikseleitä, ja sivu asettaa nuo pikselit suorakulmioon, joka mitataan pisteinä, joita menee 72 tuumaan. Resoluutio on olemassa vain näiden kahden suhteena, minkä vuoksi sama 600 kertaa 400 -valokuva on partaveitsenterävä pikkukuvana ja sumea sotku koko sivun kokoisena kuvituksena. Auditointi tarvitsee siis molemmat luvut jokaisesta kuvasta: lähteen pikselimitat kuvan metadatasta ja sijoitetun suorakulmion objektin rajoista

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;              // sijoitettu koko sivulla, pisteinä
    HeightPt := T - B;
    if (WidthPt <= 0) or (HeightPt <= 0) or
       (Meta.Width = 0) or (Meta.Height = 0) then
      Continue;

    // 72 pistettä = 1 tuuma, joten sijoitetut tuumat = pisteet / 72, ja
    // tehollinen DPI = lähdepikselit / sijoitetut tuumat.
    DpiX := Meta.Width  / (WidthPt  / 72.0);
    DpiY := Meta.Height / (HeightPt / 72.0);
    EffDpi := Min(DpiX, DpiY);      // huonompi akseli ratkaisee tulostuslaadun

    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;

Kynnykset ovat politiikkaa, eivät fysiikkaa: 150 DPI on lattia, jonka alapuolella toimistotulostus pikselöityy näkyvästi, 300 on tavanomainen kaupallinen tavoite, ja mikä tahansa yli 600 ei osta näkyvää laatua vaan paisuttaa tiedostokokoa, minkä vuoksi se raportoidaan informatiivisena turvotuksena eikä virheenä. Yksi rehellinen varauma: FPDFPageObj_GetBounds palauttaa akselin suuntaisen laatikon, joten kierrettynä sijoitetun kuvan kohdalla laskettu luku aliarvioi todellisen tiheyden. FPDF_IMAGEOBJ_METADATA-rakenne kantaa myös horizontal_dpi- ja vertical_dpi-kenttiä, jotka PDFium johtaa täydestä muunnosmatriisista, ja näiden kahden tuloksen vertaaminen on halpa tapa huomata kierretyt sijoitukset. Sama pisteistä pikseleiksi -aritmetiikka ajaa piirtoa vastakkaiseen suuntaan, mitä käsitellään JPEG-vientiartikkelissa

Tietoturvatila: salaus ja oikeusbitit

PDF-salaus määrittelee kaksi salasanaa eri tehtävillä. Käyttäjän salasana portittaa salauksen purun: ilman sitä tiedosto ei aukea lainkaan, ja FPDF_LoadDocument palauttaa nil-arvon ja FPDF_GetLastError raportoi arvon FPDF_ERR_PASSWORD. Omistajan salasana portittaa oikeudet: tiedosto, jota suojaa vain omistajan salasana, aukeaa ilman tunnuksia mutta kantaa rajoitusbittejä, joita standardinmukaisen lukijan on kunnioitettava. Latausyritys itse on siis ensimmäinen tietoturvaluotain, ja ero ratkaisee paluukoodin — käyttäjäsalasanalla suojattu tiedosto ei ole auditoitavissa (koodi 2), kun taas omistajasalasanalla suojattu tiedosto auditoituu normaalisti ja vain kerryttää löydöksiä

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 tarkoittaa ettei tiedosto ole salattu
  begin
    // Aukesi tyhjällä salasanalla mutta on salattu: vain omistajan salasana.
    // Kuka tahansa saa lukea sen, mutta oikeusbitit rajoittavat sitä, mitä
    // standardinmukainen lukija sallii. Salaamattomat tiedostot raportoivat
    // kaikki bitit asetetuiksi, minkä vuoksi revisioportti tulee ensin.
    Perms := FPDF_GetDocPermissions(Result);
    Add(Findings, fsInfo, 'SEC-ENC', 0,
      Format('encrypted, security handler revision %d', [Revision]));
    if (Perms and 4) = 0 then      // bitti 3: tulostus
      Add(Findings, fsWarning, 'SEC-NOPRINT', 0,
        'printing is not permitted');
    if (Perms and 16) = 0 then     // bitti 5: kopiointi / sisällön poiminta
      Add(Findings, fsInfo, 'SEC-NOCOPY', 0,
        'content extraction is not permitted');
    if (Perms and 2048) = 0 then   // bitti 12: korkean resoluution tulostus
      Add(Findings, fsWarning, 'SEC-LOWPRINT', 0,
        'only low-resolution printing is permitted');
  end;
end;

Maskit tulevat ISO 32000-1:n taulukosta 22, joka numeroi bitit ykkösestä alkaen: /P-arvon bitti 3 on maski 4, bitti 5 on 16 ja bitti 12 on 2048. Se, merkitseekö tietty löydös, on reitityspäätös. Painotalon tulisi kimmottaa SEC-NOPRINT-tiedosto takaisin heti vastaanotossa, jolloin lähettäjä saa selkeän viestin, eikä vasta RIPissä kolme tuntia ennen määräaikaa. Arkiston tulisi kohdella SEC-ENC-löydöstä itsessään esteenä, koska salaus ja pitkäaikaissäilytys eivät sovi yhteen — seikka, jonka standarditarkistus on kohta esittämässä muodollisesti

Standardimerkinnät: PDF/A-väitteen lukeminen

Tiedosto julistaa PDF/A-yhdenmukaisuutensa XMP-metadatapaketissaan pdfaid:part-ominaisuudella (1-4) ja pdfaid:conformance-ominaisuudella (tasokirjain, kuten b visuaaliselle uskollisuudelle tai a täydelle rakenteelliselle tagitukselle). PDFiumin C-rajapinta ei tarjoa XMP-lukijaa; FPDF_GetMetaText lukee vain Info-sanakirjan, joka ei ole se paikka missä tunniste asuu. Pakoluukku on sääntö itse standardissa: ISO 19005 vaatii, että XMP-metadatavirta tallennetaan pakkaamattomana, juuri siksi että työkalut löytäisivät sen ilman täyttä PDF-jäsennintä. Raaka tavuselaus on siis pätevä väitteen tunnistin — ja tiedosto, jonka väite piileskelee pakatun virran sisällä, on jo rikkonut sen standardin jota se väittää noudattavansa

function PdfAClaim(const FileName: string): string;
var
  Bytes: TBytes;
  S: RawByteString;
  P, Limit: Integer;
begin
  Result := '';                     // tyhjä = ei PDF/A-väitettä
  Bytes := TFile.ReadAllBytes(FileName);
  if Length(Bytes) = 0 then
    Exit;
  SetString(S, PAnsiChar(@Bytes[0]), Length(Bytes));
  P := Pos('pdfaid:part', S);       // XMP-tunnistusskeema
  if P = 0 then
    Exit;
  // Käsittelee sekä muodon <pdfaid:part>2</pdfaid:part> että pdfaid:part="2":
  // ota ensimmäinen numero ominaisuuden nimen jälkeen.
  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;

Tästä syntyvä löydös on tarkoituksella informatiivinen, koska väite on julistus, ei tiedoston ominaisuus. XMP-merkintä on yksi rivi XML:ää, jonka mikä tahansa tuottaja voi kirjoittaa, rikkinäinen mukaan lukien; yhdenmukaisuus on sitä, että tiedosto oikeasti täyttää sadat säännöt upotetuista fonteista, laiteriippumattomasta väristä ja kielletyistä ominaisuuksista. Väitteen havaitseminen kertoo, mitkä tiedostot pitää reitittää oikeaan validointiin, eikä mitään muuta. Komponentin sisäänrakennettu preflight-moottori suorittaa tuon validoinnin PDF/A-, PDF/UA- ja PDF/X-profiileille, ja erä-CLI-artikkeli näyttää, miten sen kytkee liukuhihnaan raporteilla, jotka auditoija voi avata myöhemmin

Ajo ongelmatiedostoa vastaan

Ajuri ketjuttaa tarkistukset yhteen: ensin tietoturva, koska se ratkaisee ajetaanko auditointia lainkaan, sitten dokumenttitason käyttäytyminen ja standardiväite, sitten sivusilmukka toiminnoille ja kuville

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);                        // auditoinnin epäonnistuminen, ei tuomio
  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;

Ulkopuoliselta toimistolta takaisin tulleen esitteen kohdalla tuloste näyttää tältä

> 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

Jokainen rivi on toimintakelpoinen yksinään, mutta yhdistelmä on todellinen tuomio. Tämä tiedosto väittää olevansa PDF/A-2 samalla kun se kantaa salaussanakirjaa ja elävää JavaScriptiä, ja PDF/A kieltää molemmat suoraan — joten väite on todistettavasti epätosi ennen kuin yksikään syvävalidaattori ajetaan. Se on juuri sellainen ristiriita, jonka litteä löydöslista tuo esiin ja jonka totuusarvoinen hyväksytty tai hylätty piilottaa

Mitä tämä auditointi ei voi kertoa sinulle

Rehellisyys laajuudesta on se, mikä pitää preflight-työkalun luotettuna. Kaikki yllä oleva lukee sitä, mitä tiedosto julistaa itsestään: PDFium jäsentää rakenteen, ja tämä auditointi inventoi sen. Se ei suorita PDF/A-validointia — ei glyfikattavuustarkistuksia upotettuja fontteja vasten, ei väriavaruusanalyysia tulostetarkoituksia vasten, ei mitään niistä lausetason säännöistä, jotka erottavat väitteen yhdenmukaisuudesta; siihen tarvitset omistetun validaattorin, kuten komponentin preflight-moottorin tai veraPDF:n. Oikeusbitit ovat julistuksia, joita standardinmukaiset lukijat kunnioittavat, eivät kryptografisia muureja, joten SEC-NOPRINT kuvaa aikomusta eikä pakotusta. Toimintoselaus kattaa linkkihuomautukset ja dokumenttitason skriptit; lomakekenttien tapahtumasanakirjoihin haudatut skriptit vaativat lomakerajapinnat päälle. Ja allekirjoitustarkistus, jos laajennat auditointia sellaisella, raportoi julistetun aikomuksen, ei todennettua kryptografiaa — varmenneketjun validointi on erillinen työ. Preflight-auditointi on vastaanottohaastattelu, ei oikeudenkäynti: sen tehtävä on tehdä reitityspäätöksestä perusteltu, nopea ja toistettava

Huomio: Tässä auditoinnissa käytetyt dokumentti-, sivu-, huomautus- ja kuvaobjektirajapinnat sekä korkean tason Delphi-kääre ja täysi standardivalidoinnin preflight-moottori toimitetaan PDFium Component -komponentin mukana