Teknisk artikkel

Automatisert PDF-preflight og risikorevisjon med PDFium

En PDF som ankommer en produksjonsgrense — en utskriftskø, et arkiv, en opplastingsportal for kunder — bør revideres før noe som helst gjengir den. Filen kan bære en Launch-handling koblet til å starte et eksternt program, bilder for grove til å overleve utskrift, en krypteringsdictionary som forbyr nettopp den utskriftsjobben den ble sendt inn for, eller en PDF/A-etikett den ikke lever opp til. Å inspisere et dokument mot slike regler før det slippes inn i en arbeidsflyt kalles preflighting, og PDFium C-API-et gir Delphi alt som trengs for å implementere kontrollene direkte, uten å gjengi en eneste side

Denne artikkelen bygger selve kontrollene: fire revisjonsklasser, hver en liten rutine som føyer funn til en delt resultatliste. Interaktive elementer, ressursmålinger, sikkerhetstilstand og standardmarkører får alle fungerende kode, aritmetikken inkludert. Trenger du maskineriet rundt kontrollene — mappeløkker for batch, JSON- og HTML-rapportfiler, isolasjon per fil — leveres PDFium Component med en ferdig preflightmotor, og artikkelen om batch-preflight-kommandolinjen dekker den rørleggingen. De to deler bevisst ett vokabular for avslutningskoder, så en revisor skrevet her passer rett inn under den batchdriveren

PDF-rørledningsdiagram: en inndata-PDF vifter ut gjennom fire kontrollklasser hvis funn samles i én TPreflightFinding-record som avbildes på en terskelbasert avslutningskode
Revisjonen vifter en ikke-betrodd fil gjennom fire kontrollklasser — interaktive elementer, ressursmålinger, sikkerhetstilstand og standardmarkører — samler hvert resultat i én tellbar funn-record, og gjør det om til én enkelt avslutningskode

Funn-recorden og kontrakten for avslutningskoder

Hver kontroll skriver inn i én flat recordtype, fordi alternativet, at hver kontroll skriver sin egen prosa, ikke kan telles, filtreres eller terskles i etterkant. Fire felter er nok

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

type
  TFindingSeverity = (fsInfo, fsWarning, fsError);

  TPreflightFinding = record
    Severity: TFindingSeverity;
    Code: string;       // stabil maskinnøkkel, f.eks. 'ACT-LAUNCH'
    Page: Integer;      // 1-basert; 0 betyr dokumentnivå
    Message: string;    // for mennesker; fritt å omformulere mellom utgivelser
  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;

Nedstrømsverktøy nøkler på Code, aldri på Message-teksten, som fritt kan endre seg. Prosessens avslutningskode følger den samme trekontrakten som batchartikkelen: 0 betyr at filen ikke ga noen funn, 1 betyr at det finnes funn, og 2 betyr at selve revisjonen ikke kunne kjøre fordi filen ikke lot seg parse eller krever et passord. Å holde kode 2 atskilt betyr noe. En mappe med ødelagte skanninger er en defekt skanner oppstrøms, ikke et plutselig samsvarssammenbrudd, og å slå de to sammen sender noen ut på jakt etter feil problem

Interaktive elementer: skript, Launch-mål, eksterne lenker

PDFium klassifiserer hver handling den finner, med en heltallstype, og konstantene fra fpdf_doc.h er verdt å fastslå presist, fordi feilkopierte verdier gjør en skanner stille blind. Den virkelige enumerasjonen er PDFACTION_UNSUPPORTED = 0, PDFACTION_GOTO = 1, PDFACTION_REMOTEGOTO = 2, PDFACTION_URI = 3, PDFACTION_LAUNCH = 4 og PDFACTION_EMBEDDEDGOTO = 5. Merk deg hva som mangler: det finnes ikke noe JavaScript-medlem. Skript på dokumentnivå er ikke lenkehandlinger og dukker aldri opp gjennom FPDFAction_GetType; de enumereres av en egen familie av kall. En revisor som tester handlingstyper mot en innbilt JavaScript-konstant, kompilerer, kjører og finner ingenting, for alltid

const
  PDFACTION_GOTO         = 1;   // hopp inne i dokumentet: ufarlig
  PDFACTION_REMOTEGOTO   = 2;   // hopp inn i en annen lokal fil
  PDFACTION_URI          = 3;   // åpner en ekstern URL
  PDFACTION_LAUNCH       = 4;   // starter et eksternt program
  PDFACTION_EMBEDDEDGOTO = 5;   // hopp inn i en innebygd fil

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;                 // lenke med bare destinasjon, ingenting å flagge
    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 tier etter 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;

Delingen i alvorlighetsgrader koder policy. En Launch-handling er en feil fordi det å starte et vilkårlig program er det farligste et klikk i en PDF kan gjøre, og ingen faktura trenger det. Eksterne URI-er er advarsler: vanlige i legitime dokumenter, men en gjennomgåer bør se målet uten å klikke, siden den synlige lenketeksten og den faktiske destinasjonen ikke trenger å stemme overens. GoTo-hopp inne i dokumentet er struktur, ikke atferd, og holdes helt utenfor rapporten — en preflight som roper ulv ved hver innholdsfortegnelsesoppføring, lærer folk opp til å overse den. For å lese skriptkroppene bak JavaScript-antallet, og for MDP-nivåer for signaturer og XFA-oppdaging, går artikkelen om revisjon av sikkerhetsrisiko gjennom den samme flaten via komponentens objektinnpakning

Ressursmålinger: effektiv bilde-DPI

Et bilde inne i en PDF har ingen DPI av seg selv. Det har piksler, og siden plasserer de pikslene inn i et rektangel målt i punkter, der 72 punkter utgjør en tomme. Oppløsning finnes bare som forholdet mellom de to, som er grunnen til at det samme bildet på 600 ganger 400 er knivskarpt som miniatyrbilde og et uskarpt rot som helsidebilde. Revisjonen trenger derfor begge tallene for hvert bilde: kildens pikseldimensjoner fra bildemetadataene, og det plasserte rektangelet fra objektets avgrensning

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;              // plassert størrelse på siden, i punkter
    HeightPt := T - B;
    if (WidthPt <= 0) or (HeightPt <= 0) or
       (Meta.Width = 0) or (Meta.Height = 0) then
      Continue;

    // 72 punkter = 1 tomme, så plasserte tommer = punkter / 72, og
    // effektiv DPI = kildepiksler / plasserte tommer.
    DpiX := Meta.Width  / (WidthPt  / 72.0);
    DpiY := Meta.Height / (HeightPt / 72.0);
    EffDpi := Min(DpiX, DpiY);      // den dårligste aksen avgjør utskriftskvaliteten

    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;

Tersklene er policy, ikke fysikk: 150 DPI er et gulv der kontorutskrift begynner å pikselere synlig, 300 er det vanlige kommersielle målet, og alt over 600 kjøper ingen synlig kvalitet, men blåser opp filstørrelsen, som er grunnen til at det rapporteres som informativ oppblåsthet snarere enn en defekt. Ett ærlig forbehold: FPDFPageObj_GetBounds returnerer den akseparallelle boksen, så for et bilde plassert med rotasjon undervurderer det beregnede tallet den virkelige tettheten. FPDF_IMAGEOBJ_METADATA-strukturen bærer også feltene horizontal_dpi og vertical_dpi, som PDFium utleder fra den fulle transformasjonsmatrisen, og å sammenligne de to resultatene er en billig måte å oppdage roterte plasseringer på. Den samme aritmetikken fra punkter til piksler driver gjengivelsen i motsatt retning, dekket i artikkelen om JPEG-eksport

Sikkerhetstilstand: kryptering og tillatelsesbiter

PDF-kryptering definerer to passord med ulike oppgaver. Brukerpassordet porter dekrypteringen: uten det åpner filen seg ikke i det hele tatt, og FPDF_LoadDocument returnerer nil med FPDF_GetLastError som melder FPDF_ERR_PASSWORD. Eierpassordet porter tillatelsene: en fil beskyttet bare av et eierpassord åpner seg uten legitimasjon, men bærer restriksjonsbiter som en leser som følger spesifikasjonen må respektere. Selve innlastingsforsøket er derfor den første sikkerhetssonden, og skillet avgjør avslutningskoden — en fil med brukerpassord kan ikke revideres (kode 2), mens en fil med eierpassord revideres normalt og bare samler opp funn

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 betyr at filen ikke er kryptert
  begin
    // Åpnet med tomt passord, men likevel kryptert: kun eierpassord.
    // Hvem som helst kan lese den, men tillatelsesbitene begrenser hva en
    // leser som følger spesifikasjonen lar dem gjøre. Ukrypterte filer melder
    // alle biter satt, som er grunnen til at revisjonsporten kommer først.
    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: utskrift
      Add(Findings, fsWarning, 'SEC-NOPRINT', 0,
        'printing is not permitted');
    if (Perms and 16) = 0 then     // bit 5: kopiering / uttrekk av innhold
      Add(Findings, fsInfo, 'SEC-NOCOPY', 0,
        'content extraction is not permitted');
    if (Perms and 2048) = 0 then   // bit 12: høyoppløst utskrift
      Add(Findings, fsWarning, 'SEC-LOWPRINT', 0,
        'only low-resolution printing is permitted');
  end;
end;

Maskene kommer fra tabell 22 i ISO 32000-1, som nummererer biter fra 1: bit 3 av /P-verdien er maske 4, bit 5 er 16, bit 12 er 2048. Hvorvidt et gitt funn betyr noe, er en rutingbeslutning. Et trykkeri bør avvise en SEC-NOPRINT-fil ved inntaket, der innsenderen får en tydelig melding, snarere enn ved RIP-en tre timer før en frist. Et arkiv bør behandle SEC-ENC i seg selv som en blokkering, siden kryptering og langtidsbevaring ikke går sammen — et poeng standardkontrollen er i ferd med å slå fast formelt

Standardmarkører: å lese en PDF/A-påstand

En fil erklærer PDF/A-samsvar i XMP-metadatapakken sin, gjennom egenskapen pdfaid:part (1 til 4) og pdfaid:conformance (nivåbokstaven, som b for visuell troskap eller a for full strukturell tagging). PDFiums C-API tilbyr ingen XMP-tilgang; FPDF_GetMetaText leser bare Info-dictionaryen, som ikke er der identifikasjonen bor. Nødutgangen er en regel i selve standarden: ISO 19005 krever at XMP-metadatastrømmen lagres ukomprimert, nettopp slik at verktøy kan finne den uten en full PDF-parser. En rå bytegjennomgang er derfor en legitim påstandsdetektor — og en fil hvis påstand gjemmer seg inne i en komprimert strøm, har allerede brutt standarden den påberoper seg

function PdfAClaim(const FileName: string): string;
var
  Bytes: TBytes;
  S: RawByteString;
  P, Limit: Integer;
begin
  Result := '';                     // tom = ingen PDF/A-påstand til stede
  Bytes := TFile.ReadAllBytes(FileName);
  if Length(Bytes) = 0 then
    Exit;
  SetString(S, PAnsiChar(@Bytes[0]), Length(Bytes));
  P := Pos('pdfaid:part', S);       // XMP-identifikasjonsskjema
  if P = 0 then
    Exit;
  // Håndterer både <pdfaid:part>2</pdfaid:part> og pdfaid:part="2":
  // ta det første sifferet etter egenskapsnavnet.
  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;

Funnet dette gir, er bevisst informativt, fordi en påstand er en erklæring, ikke en egenskap ved filen. XMP-oppføringen er én linje XML som enhver produsent kan skrive, også en defekt en; samsvar er at filen faktisk oppfyller hundrevis av regler om innebygde fonter, enhetsuavhengige farger og forbudte funksjoner. Å oppdage påstanden forteller deg hvilke filer du skal rute til ekte validering, og ikke noe mer. Komponentens innebygde preflightmotor utfører den valideringen på tvers av PDF/A-, PDF/UA- og PDF/X-profiler, og artikkelen om batch-kommandolinjen viser hvordan du kobler den inn i en rørledning med rapporter en revisor kan åpne senere

En kjøring mot en problemfil

Driveren tråder kontrollene sammen: sikkerhet først, fordi den avgjør om revisjonen kjører i det hele tatt, deretter atferd på dokumentnivå og standardpåstanden, så en sideløkke for handlinger og bilder

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);                        // revisjonsfeil, ikke en dom
  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;

Mot en brosjyre som kom tilbake fra et eksternt byrå ser utdataene slik ut

> 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

Hver linje er handlingsrettet i seg selv, men kombinasjonen er den virkelige dommen. Denne filen påberoper seg PDF/A-2 samtidig som den bærer en krypteringsdictionary og levende JavaScript, og PDF/A forbyr begge deler rett ut — så påstanden er beviselig usann før noen dyp validator i det hele tatt kjører. Det er den typen motsigelse en flat funnliste bringer frem i lyset og en boolsk bestått/ikke bestått skjuler

Hva denne revisjonen ikke kan fortelle deg

Ærlighet om virkeområdet er det som holder et preflightverktøy troverdig. Alt det ovenstående leser hva filen erklærer om seg selv: PDFium parser strukturen, og denne revisjonen inventerer den. Den utfører ikke PDF/A-validering — ingen kontroll av glyffdekning mot innebygde fonter, ingen fargeromsanalyse mot utdataintensjoner, ingen av reglene på klausulnivå som skiller en påstand fra samsvar; til det trenger du en dedikert validator som komponentens preflightmotor eller veraPDF. Tillatelsesbiter er erklæringer som lesere som følger spesifikasjonen respekterer, ikke kryptografiske murer, så SEC-NOPRINT beskriver intensjon snarere enn håndhevelse. Handlingsskanningen dekker lenkemerknader og skript på dokumentnivå; skript begravd i hendelsesdictionaryer for skjemafelt krever skjema-API-ene i tillegg. Og en signaturkontroll, om du utvider revisjonen med en, melder erklært intensjon, ikke verifisert kryptografi — validering av sertifikatkjeder er en egen jobb. En preflightrevisjon er inntaksintervjuet, ikke rettssaken: jobben dens er å gjøre rutingbeslutningen informert, rask og repeterbar

Merk: Dokument-, side-, merknads- og bildeobjekt-API-ene som brukes gjennom hele denne revisjonen, sammen med en høynivå Delphi-innpakning og en fullstendig preflightmotor for standardvalidering, følger med PDFium Component