Teknisk artikel

Automatisk PDF-preflight och riskrevision med PDFium

En PDF som anländer till en produktionsgräns – en utskriftskö, ett arkiv, en kunduppladdningsportal – bör revideras innan något renderar den. Filen kan innehålla en Launch-åtgärd som är kopplad till att starta ett externt program, bilder som är för grova för att överleva utskrift, en krypteringsordbok som förbjuder just det utskriftsjobb den skickades in för, eller en PDF/A-etikett den inte lever upp till. Att inspektera ett dokument mot regler som dessa innan det går in i ett arbetsflöde kallas preflighting, och PDFium C API:et ger Delphi allt som behövs för att implementera kontrollerna direkt, utan att rendera en enda sida

Den här artikeln bygger själva kontrollerna: fyra revisionsklasser, var och en en liten rutin som lägger till fynd i en gemensam resultatlista. Interaktiva element, resursmått, säkerhetsstatus och standardmarkörer får alla fungerande kod, inklusive aritmetiken. Om det du behöver är maskineriet kring kontrollerna – batchmappsloopar, JSON- och HTML-rapportfiler, isolering per fil – levereras PDFium Component med en färdig preflight-motor, och artikeln om batch-preflight CLI täcker den infrastrukturen. De två delar avsiktligt ett ordförråd för avslutningskoder, så en granskare som skrivs här passar rakt in under den batchdrivrutinen

Fyndposten och kontraktet för avslutningskoden

Varje kontroll skriver till en platt posttyp (record), eftersom alternativet, där varje kontroll skriver ut sin egen text, inte kan räknas, filtreras eller tröskelsättas i efterhand. Fyra fält räcker

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;

Verktyg i senare led förlitar sig på Code, aldrig på Message-texten, som kan ändras fritt. Processens avslutningskod följer samma trevärdeskontrakt som batchartikeln: 0 betyder att filen inte producerade några fynd, 1 betyder att fynd existerar och 2 betyder att själva granskningen inte kunde köras eftersom filen inte kunde tolkas eller kräver ett lösenord. Att hålla kod 2 separat spelar roll. En mapp med korrupta skanningar är en trasig skanner i tidigare led, inte en plötslig kollaps i efterlevnaden, och att slå ihop de två får någon att jaga fel problem

Interaktiva element: skript, startmål, externa länkar

PDFium klassificerar varje åtgärd den hittar med en heltalstyp, och konstanterna från fpdf_doc.h är värda att fastställa exakt, eftersom felkopierade värden gör en skanner tyst blind. Den riktiga uppräkningen är PDFACTION_UNSUPPORTED = 0, PDFACTION_GOTO = 1, PDFACTION_REMOTEGOTO = 2, PDFACTION_URI = 3, PDFACTION_LAUNCH = 4 och PDFACTION_EMBEDDEDGOTO = 5. Notera vad som saknas: det finns ingen JavaScript-medlem. Skript på dokumentnivå är inte länkåtgärder och dyker aldrig upp via FPDFAction_GetType; de räknas upp av en separat familj av anrop. En granskare som testar åtgärdstyper mot en påhittad JavaScript-konstant kompilerar, körs och hittar ingenting, någonsin

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;

Allvarlighetsgraden kodar policy. En Launch-åtgärd är ett fel eftersom att starta ett godtyckligt program är det farligaste ett klick i en PDF kan göra, och ingen faktura behöver det. Externa URI:er är varningar: vanliga i legitima dokument, men en granskare bör se målet utan att klicka, eftersom den synliga länktexten och den faktiska destinationen inte behöver stämma överens. GoTo-hopp i dokumentet är struktur, inte beteende, och hålls helt utanför rapporten – en preflight som ropar varg på varje innehållsförteckningspost tränar människor att ignorera den. För att läsa skriptkropparna bakom JavaScript-antalet, och för signatur-MDP-nivåer och XFA-detektering, går artikeln om säkerhetsriskgranskning igenom samma yta genom komponentens objektomslag

Resursmått: effektiv bild-DPI

En bild inuti en PDF har ingen egen DPI. Den har pixlar, och sidan placerar de pixlarna i en rektangel mätt i punkter, där 72 punkter utgör en tum. Upplösning existerar bara som förhållandet mellan de två, vilket är anledningen till att samma foto på 600 x 400 är knivskarpt som en miniatyrbild och en suddig röra som en helsideshjälte. Granskningen behöver därför båda siffrorna för varje bild: källpixeldimensioner från bildens metadata och den placerade rektangeln från objektets gränser

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;

Tröskelvärdena är policy, inte fysik: 150 DPI är ett golv under vilket kontorsutskrifter synligt pixlas, 300 är det vanliga kommersiella målet, och allt över 600 köper ingen synlig kvalitet samtidigt som det blåser upp filstorleken, vilket är anledningen till att det rapporteras som informativ uppblåsthet snarare än en defekt. Ett ärligt förbehåll: FPDFPageObj_GetBounds returnerar den axeljusterade rutan, så för en bild som placerats med rotation underskattar den beräknade siffran den verkliga densiteten. FPDF_IMAGEOBJ_METADATA-strukturen bär också horizontal_dpi och vertical_dpi-fält som PDFium härleder från den fullständiga transformeringsmatrisen, och att jämföra de två resultaten är ett billigt sätt att upptäcka roterade placeringar. Samma punkt-till-pixel-aritmetik driver rendering i motsatt riktning, vilket behandlas i artikeln om JPEG-export

Säkerhetsstatus: kryptering och behörighetsbitar

PDF-kryptering definierar två lösenord med olika jobb. Användarlösenordet vaktar dekrypteringen: utan det öppnas inte filen alls, och FPDF_LoadDocument returnerar nil med FPDF_GetLastError som rapporterar FPDF_ERR_PASSWORD. Ägarlösenordet vaktar behörigheter: en fil som skyddas enbart av ett ägarlösenord öppnas utan inloggningsuppgifter men bär restriktionsbitar som en överensstämmande läsare måste respektera. Själva inläsningsförsöket är därför den första säkerhetskontrollen, och skillnaden avgör avslutningskoden – en användarlösenordsfil kan inte granskas (kod 2), medan en ägarlösenordsfil granskas normalt och enbart ackumulerar fynd

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;

Maskerna kommer från tabell 22 i ISO 32000-1, som numrerar bitar från 1: bit 3 i /P-värdet är mask 4, bit 5 är 16, bit 12 är 2048. Huruvida ett givet fynd spelar någon roll är ett dirigeringsbeslut. En utskriftsbyrå bör avvisa en SEC-NOPRINT-fil vid intaget, där inlämnaren får ett tydligt meddelande, snarare än vid RIP:en tre timmar före en deadline. Ett arkiv bör behandla själva SEC-ENC som ett hinder, eftersom kryptering och långtidsbevarande inte hör ihop – en poäng som standardkontrollen är på väg att göra formellt

Standardmarkörer: läsa ett PDF/A-anspråk

En fil deklarerar PDF/A-överensstämmelse i sitt XMP-metadatapaket, genom egenskapen pdfaid:part (1 till 4) och pdfaid:conformance (nivåbokstaven, såsom b för visuell trohet eller a för fullständig strukturell taggning). PDFiums C API erbjuder ingen XMP-åtkomst; FPDF_GetMetaText läser bara Info-ordboken, vilket inte är där identifikationen bor. Nödutgången är en regel i själva standarden: ISO 19005 kräver att XMP-metadataströmmen lagras okomprimerad, just för att verktyg ska kunna hitta den utan en fullständig PDF-tolkare. En rå byte-skanning är därför en legitim anspråksdetektor – och en fil vars anspråk gömmer sig inuti en komprimerad ström har redan brutit mot den standard den gör anspråk på

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;

Fyndet detta producerar är avsiktligt informativt, eftersom ett anspråk är en deklaration, inte en egenskap hos filen. XMP-posten är en rad XML som vilken producent som helst kan skriva, inklusive en trasig sådan; överensstämmelse är att filen faktiskt uppfyller hundratals regler om inbäddade teckensnitt, enhetsoberoende färg och förbjudna funktioner. Att upptäcka anspråket talar om för dig vilka filer du ska dirigera till verklig validering, och inget mer. Komponentens inbyggda preflight-motor utför den valideringen över PDF/A-, PDF/UA- och PDF/X-profiler, och artikeln om batch CLI visar hur man kopplar in den i en pipeline med rapporter som en granskare kan öppna senare

En körning mot en problemfil

Drivrutinen binder samman kontrollerna: säkerhet först, eftersom det avgör om granskningen körs alls, sedan beteenden på dokumentnivå och standardanspråket, sedan en sidloop för åtgärder och 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);                        // 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;

Mot en broschyr som kom tillbaka från en extern byrå ser utdata ut så här

> 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

Varje rad är handlingsbar i sig, men kombinationen är den verkliga domen. Den här filen gör anspråk på PDF/A-2 samtidigt som den bär en krypteringsordbok och live JavaScript, och PDF/A förbjuder båda rakt av – så anspråket är bevisligen falskt innan någon djup validerare ens körs. Det är den typen av motsägelse en platt fyndlista för fram i ljuset och ett booleskt godkänd/underkänd döljer

Vad den här granskningen inte kan berätta för dig

Ärlighet om omfattning är det som håller ett preflight-verktyg pålitligt. Allt ovan läser vad filen deklarerar om sig själv: PDFium tolkar struktur, och den här granskningen inventerar den. Den utför ingen PDF/A-validering – inga glyftäckningskontroller mot inbäddade teckensnitt, ingen färgrymdsanalys mot utgångsavsikter (output intents), inga av reglerna på klausulnivå som skiljer ett anspråk från överensstämmelse; för det behöver du en dedikerad validerare som komponentens preflight-motor eller veraPDF. Behörighetsbitar är deklarationer som överensstämmande läsare respekterar, inte kryptografiska murar, så SEC-NOPRINT beskriver avsikt snarare än tillämpning. Åtgärdsskanningen täcker länkkommentarer och skript på dokumentnivå; skript begravda i formulärfälts händelseordböcker (event dictionaries) behöver formulär-API:erna ovanpå. Och en signaturkontroll, om du utökar granskningen med en sådan, rapporterar deklarerad avsikt, inte verifierad kryptografi – validering av certifikatkedjor är ett separat jobb. En preflight-granskning är intagningsintervjun, inte rättegången: dess jobb är att göra dirigeringsbeslutet välinformerat, snabbt och repeterbart

Obs: API:erna för dokument-, sid-, kommentars- och bildobjekt som används genomgående i den här granskningen, tillsammans med ett Delphi-omslag på hög nivå och en fullständig preflight-motor för standardvalidering, levereras med PDFium Component