Teknisk artikel

Automatiseret PDF-preflight og risikorevision med PDFium

En PDF, der ankommer til en produktionsgrænse — en printkø, et arkiv, en kunde-uploadportal — bør revideres (audited), før noget som helst render den. Filen bærer muligvis en Launch-handling (handling til start), der er kodet til at starte et eksternt program, billeder, der er for grove til at overleve print, en krypteringsordbog (encryption dictionary), der forbyder selve det printjob, den blev indsendt til, eller et PDF/A-label, den ikke lever op til. At inspicere et dokument mod regler som disse, før det indgår i en arbejdsgang (workflow), kaldes preflighting, og PDFium C API'et giver Delphi alt, hvad der behøves for at implementere tjekkene direkte, uden at rendere en eneste side

Denne artikel bygger selve tjekkene: fire revisionsklasser, hver en lille rutine, der tilføjer fund til en delt resultatliste. Interaktive elementer, ressource-metrikker, sikkerhedstilstand og standardmarkører får alle fungerende kode, inklusive aritmetikken. Hvis det, du har brug for, er maskineriet omkring tjekkene — batch mappe-loops, JSON- og HTML-rapportfiler, isolation pr. fil — leveres PDFium-komponenten med en færdiglavet preflight-motor, og artiklen om batch preflight CLI dækker den infrastruktur (plumbing). De to deler bevidst ét exit-kode-vokabular, så en revisor skrevet her passer direkte ind under den batch-driver

Fund-recorden (finding record) og exit-kode-kontrakten

Hvert tjek skriver i én flad record-type, fordi alternativet, hvor hvert tjek udskriver sin egen prosa, ikke kan tælles, filtreres eller sættes tærskler for bagefter. 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;       // 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;

Downstream-værktøjer afhænger (keys on) af Code, aldrig af Message-teksten, som frit kan ændres. Processens exit-kode følger den samme tre-værdi kontrakt som batch-artiklen: 0 betyder, at filen ikke producerede nogen fund, 1 betyder, at der findes fund, og 2 betyder, at selve revisionen ikke kunne køre, fordi filen mislykkedes med at parse eller kræver en adgangskode. Det er vigtigt at holde kode 2 separat. En mappe med korrupte scanninger er en ødelagt scanner upstream, ikke et pludseligt compliance-kollaps, og at folde de to sammen sender nogen på jagt efter det forkerte problem

Interaktive elementer: scripts, launch-mål, eksterne links

PDFium klassificerer hver handling, den finder, efter en heltalstype, og konstanterne fra fpdf_doc.h er værd at slå fast præcist, fordi forkert kopierede værdier gør en scanner lydløst blind. Den rigtige opregning (enumeration) er PDFACTION_UNSUPPORTED = 0, PDFACTION_GOTO = 1, PDFACTION_REMOTEGOTO = 2, PDFACTION_URI = 3, PDFACTION_LAUNCH = 4 og PDFACTION_EMBEDDEDGOTO = 5. Bemærk hvad der mangler: der er intet JavaScript-medlem. Scripts på dokumentniveau er ikke link-handlinger og dukker aldrig op via FPDFAction_GetType; de opregnes af en separat familie af kald. En revisor, der tester handlingstyper mod en indbildt JavaScript-konstant kompilerer, kører og finder ingenting, for evigt

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;

Alvorlighedsopdelingen koder politikken. En Launch-handling er en fejl, fordi at starte et vilkårligt program er det farligste, et klik i en PDF kan gøre, og ingen faktura har brug for det. Eksterne URI'er er advarsler: almindelige i legitime dokumenter, men en anmelder (reviewer) bør se målet uden at klikke, da den synlige linktekst og den faktiske destination ikke behøver at stemme overens. GoTo-spring i dokumentet er struktur, ikke adfærd, og holdes helt ude af rapporten — et preflight, der råber ulv på hver indholdsfortegnelsespost, træner folk til at ignorere det. For at læse script-kroppene (script bodies) bag JavaScript-tællingen og for signatur-MDP-niveauer og XFA-detektion, gennemgår artiklen om revision af sikkerhedsrisici den samme overflade gennem komponentens objekt-wrapper

Ressource-metrikker: effektiv billed-DPI

Et billede inde i en PDF har ingen egen DPI. Det har pixels, og siden placerer de pixels i et rektangel målt i punkter (points), hvor 72 punkter udgør en tomme (inch). Opløsning eksisterer kun som forholdet mellem de to, hvilket er grunden til, at det samme 600 x 400 foto er knivskarpt som miniaturebillede og et sløret rod som et helsides heltebillede (hero image). Revisionen har derfor brug for begge tal for hvert billede: kildepixeldimensioner fra billedmetadataene og det placerede rektangel fra objektgrænserne

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;

Tærsklerne er politik, ikke fysik: 150 DPI er en bund, under hvilken kontorprint synligt pixelerer, 300 er det sædvanlige kommercielle mål, og alt over 600 køber ingen synlig kvalitet, mens det oppuster filstørrelsen, hvilket er grunden til, at det rapporteres som informativt oppustethed (bloat) snarere end en defekt. Ét ærligt forbehold: FPDFPageObj_GetBounds returnerer den akse-justerede (axis-aligned) boks, så for et billede placeret med rotation undervurderer det beregnede tal den sande tæthed. FPDF_IMAGEOBJ_METADATA-structen bærer også horizontal_dpi og vertical_dpi felter, som PDFium udleder fra den fulde transformationsmatrix, og at sammenligne de to resultater er en billig måde at spotte roterede placeringer. Den samme punkter-til-pixels aritmetik driver rendering i den modsatte retning, dækket i JPEG-eksportartiklen

Sikkerhedstilstand: kryptering og tilladelsesbits

PDF-kryptering definerer to adgangskoder med forskellige job. Brugeradgangskoden styrer dekryptering: uden den åbner filen slet ikke, og FPDF_LoadDocument returnerer nil med FPDF_GetLastError, der rapporterer FPDF_ERR_PASSWORD. Ejeradgangskoden styrer tilladelser: en fil, der kun er beskyttet af en ejeradgangskode, åbner uden legitimationsoplysninger, men bærer restriktionsbits, som en overensstemmende læser skal overholde. Selve indlæsningsforsøget er derfor den første sikkerheds-sondering, og sondringen bestemmer exit-koden — en brugeradgangskodefil kan ikke revideres (kode 2), mens en ejeradgangskodefil reviderer normalt og blot akkumulerer fund

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;

Maskerne kommer fra tabel 22 i ISO 32000-1, som nummererer bits fra 1: bit 3 af /P-værdien er maske 4, bit 5 er 16, bit 12 er 2048. Om et givet fund betyder noget, er en routing-beslutning. Et printbureau bør afvise (bounce) en SEC-NOPRINT-fil ved indtagelsen, hvor indsenderen får en klar besked, snarere end ved RIP'en tre timer før en deadline. Et arkiv bør behandle SEC-ENC i sig selv som en blokering, da kryptering og langsigtet bevarelse ikke passer sammen — en pointe, standardtjekket er ved at fremføre formelt

Standardmarkører: læsning af et PDF/A-krav

En fil erklærer PDF/A-overensstemmelse i sin XMP-metadatapakke gennem pdfaid:part-egenskaben (1 til 4) og pdfaid:conformance (niveau-bogstavet, såsom b for visuel troskab eller a for fuld strukturel tagging). PDFiums C API tilbyder ingen XMP-accessor; FPDF_GetMetaText læser kun Info-ordbogen, hvilket ikke er der, identifikationen lever. Nødudgangen er en regel i selve standarden: ISO 19005 kræver, at XMP-metadatastrømmen gemmes ukomprimeret, netop så værktøjer kan finde den uden en fuld PDF-parser. En rå byte-scanning er derfor en legitim detektor af krav (claim detector) — og en fil, hvis krav (claim) gemmer sig i en komprimeret strøm, har allerede overtrådt den standard, den hævder (claims)

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;

Fundet, dette producerer, er bevidst informativt, fordi et krav (claim) er en erklæring, ikke en egenskab ved filen. XMP-posten er én linje XML, som enhver producent kan skrive, inklusive en defekt én; overensstemmelse er, at filen faktisk opfylder hundreder af regler om indlejrede skrifttyper, uafhængig farve fra enheden og forbudte funktioner. At opdage kravet fortæller dig, hvilke filer der skal dirigeres til ægte validering, og intet mere. Komponentens indbyggede preflight-motor udfører den validering på tværs af PDF/A-, PDF/UA- og PDF/X-profiler, og batch CLI-artiklen viser, hvordan man forbinder den til en pipeline med rapporter, som en revisor kan åbne senere

En kørsel mod en problemfil

Driveren binder (strings) tjekkene sammen: sikkerhed først, fordi det afgør, om revisionen overhovedet kører, derefter adfærd på dokumentniveau og standardkravet, og derefter en sideløkke (page loop) for handlinger og billeder

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;

Mod en brochure, der kom tilbage fra et eksternt bureau, ser outputtet således ud

> 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 kan handles på alene (actionable), men kombinationen er den rigtige dom. Denne fil hævder PDF/A-2, mens den bærer en krypteringsordbog og live JavaScript, og PDF/A forbyder begge dele fuldstændigt — så påstanden er bevisligt falsk, før nogen dyb validering kører. Det er den slags modsigelse, en flad fundliste (findings list) bringer op til overfladen, og som et boolsk bestået/ikke-bestået skjuler

Hvad denne revision ikke kan fortælle dig

Ærlighed omkring omfang (scope) er det, der holder et preflight-værktøj betroet. Alt ovenstående læser, hvad filen erklærer om sig selv: PDFium parser struktur, og denne revision opgør (inventories) den. Den udfører ikke PDF/A-validering — ingen tjek af glyf-dækning mod indlejrede skrifttyper, ingen farverumsanalyse mod output-intents (output intents), ingen af reglerne på klausul-niveau, der adskiller et krav (claim) fra overensstemmelse; til det har du brug for en dedikeret validator såsom komponentens preflight-motor eller veraPDF. Tilladelsesbits (permission bits) er erklæringer, som overensstemmende læsere respekterer, ikke kryptografiske mure, så SEC-NOPRINT beskriver hensigt snarere end håndhævelse. Handlings-scanningen (action scan) dækker link-annotationer og scripts på dokumentniveau; scripts begravet i formularfelt-hændelsesordbøger har brug for form-API'erne ovenpå. Og et signatur-tjek, hvis du udvider revisionen med et, rapporterer erklæret hensigt, ikke verificeret kryptografi — validering af certifikatkæden er et særskilt job. En preflight-revision er indtagelsessamtalen (intake interview), ikke retssagen: dens opgave er at gøre routingbeslutningen informeret, hurtig og repeterbar

Bemærk: De dokument-, side-, annotations- og billedobjekt-API'er, der bruges gennem hele denne revision, sammen med en højniveau Delphi-wrapper og en fuld standard-validerings preflight-motor, leveres med PDFium-komponenten