Technical Article

Automated PDF Preflight and Risk Auditing with PDFium

A PDF that arrives at a production boundary — a print queue, an archive, a customer upload portal — should be audited before anything renders it. The file might carry a Launch action wired to start an external program, images too coarse to survive printing, an encryption dictionary that forbids the very print job it was submitted for, or a PDF/A label it does not live up to. Inspecting a document against rules like these before it enters a workflow is called preflighting, and the PDFium C API gives Delphi everything needed to implement the checks directly, without rendering a single page

This article builds the checks themselves: four audit classes, each a small routine that appends findings to a shared result list. Interactive elements, resource metrics, security state, and standards markers all get working code, including the arithmetic. If what you need is the machinery around the checks — batch folder loops, JSON and HTML report files, per-file isolation — the PDFium Component ships a ready-made preflight engine, and the batch preflight CLI article covers that plumbing. The two deliberately share one exit-code vocabulary, so an auditor written here slots straight under that batch driver

The finding record and the exit-code contract

Every check writes into one flat record type, because the alternative, each check printing its own prose, cannot be counted, filtered, or thresholded afterward. Four fields are enough

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 tooling keys on Code, never on Message text, which is free to change. The process exit code follows the same three-value contract as the batch article: 0 means the file produced no findings, 1 means findings exist, and 2 means the audit itself could not run because the file failed to parse or demands a password. Keeping code 2 separate matters. A folder of corrupt scans is a broken scanner upstream, not a sudden compliance collapse, and folding the two together sends someone chasing the wrong problem

Interactive elements: scripts, launch targets, external links

PDFium classifies every action it finds by an integer type, and the constants from fpdf_doc.h are worth pinning down precisely, because miscopied values make a scanner silently blind. The real enumeration is PDFACTION_UNSUPPORTED = 0, PDFACTION_GOTO = 1, PDFACTION_REMOTEGOTO = 2, PDFACTION_URI = 3, PDFACTION_LAUNCH = 4, and PDFACTION_EMBEDDEDGOTO = 5. Note what is absent: there is no JavaScript member. Document-level scripts are not link actions and never show up through FPDFAction_GetType; they are enumerated by a separate family of calls. An auditor that tests action types against an imagined JavaScript constant compiles, runs, and finds nothing, forever

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;

The severity split encodes policy. A Launch action is an error because starting an arbitrary program is the most dangerous thing a click in a PDF can do, and no invoice needs it. External URIs are warnings: common in legitimate documents, but a reviewer should see the target without clicking, since the visible link text and the actual destination need not agree. In-document GoTo jumps are structure, not behavior, and stay out of the report entirely — a preflight that cries wolf on every table-of-contents entry trains people to ignore it. For reading the script bodies behind the JavaScript count, and for signature MDP levels and XFA detection, the security-risk auditing article walks the same surface through the component's object wrapper

Resource metrics: effective image DPI

An image inside a PDF has no DPI of its own. It has pixels, and the page places those pixels into a rectangle measured in points, where 72 points make an inch. Resolution only exists as the ratio of the two, which is why the same 600 by 400 photo is razor sharp as a thumbnail and a blurry mess as a full-page hero. The audit therefore needs both numbers for every image: source pixel dimensions from the image metadata, and the placed rectangle from the object 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;

The thresholds are policy, not physics: 150 DPI is a floor below which office printing visibly pixelates, 300 is the usual commercial target, and anything above 600 buys no visible quality while inflating file size, which is why it reports as informational bloat rather than a defect. One honest caveat: FPDFPageObj_GetBounds returns the axis-aligned box, so for an image placed with rotation the computed figure underestimates the true density. The FPDF_IMAGEOBJ_METADATA struct also carries horizontal_dpi and vertical_dpi fields that PDFium derives from the full transform matrix, and comparing the two results is a cheap way to spot rotated placements. The same points-to-pixels arithmetic drives rendering in the opposite direction, covered in the JPEG export article

Security state: encryption and permission bits

PDF encryption defines two passwords with different jobs. The user password gates decryption: without it the file will not open at all, and FPDF_LoadDocument returns nil with FPDF_GetLastError reporting FPDF_ERR_PASSWORD. The owner password gates permissions: a file protected only by an owner password opens with no credentials but carries restriction bits that a conforming reader must honor. The load attempt itself is therefore the first security probe, and the distinction decides the exit code — a user-password file is unauditable (code 2), while an owner-password file audits normally and merely accumulates findings

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;

The masks come from Table 22 of ISO 32000-1, which numbers bits from 1: bit 3 of the /P value is mask 4, bit 5 is 16, bit 12 is 2048. Whether a given finding matters is a routing decision. A print bureau should bounce a SEC-NOPRINT file at intake, where the submitter gets a clear message, rather than at the RIP three hours before a deadline. An archive should treat SEC-ENC itself as a blocker, since encryption and long-term preservation do not mix — a point the standards check is about to make formally

Standards markers: reading a PDF/A claim

A file declares PDF/A conformance in its XMP metadata packet, through the pdfaid:part property (1 through 4) and pdfaid:conformance (the level letter, such as b for visual fidelity or a for full structural tagging). PDFium's C API offers no XMP accessor; FPDF_GetMetaText reads only the Info dictionary, which is not where the identification lives. The escape hatch is a rule in the standard itself: ISO 19005 requires the XMP metadata stream to be stored uncompressed, precisely so tools can find it without a full PDF parser. A raw byte scan is therefore a legitimate claim detector — and a file whose claim hides inside a compressed stream has already violated the standard it 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;

The finding this produces is deliberately informational, because a claim is a declaration, not a property of the file. The XMP entry is one line of XML that any producer can write, including a broken one; conformance is the file actually satisfying hundreds of rules about embedded fonts, device-independent color, and forbidden features. Detecting the claim tells you which files to route to real validation, and nothing more. The component's built-in preflight engine performs that validation across PDF/A, PDF/UA, and PDF/X profiles, and the batch CLI article shows how to wire it into a pipeline with reports an auditor can open later

A run against a problem file

The driver strings the checks together: security first, because it decides whether the audit runs at all, then document-level behaviors and the standards claim, then a page loop for actions and images

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;

Against a brochure that came back from an outside agency, the output looks like this

> 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

Each line is actionable on its own, but the combination is the real verdict. This file claims PDF/A-2 while carrying an encryption dictionary and live JavaScript, and PDF/A forbids both outright — so the claim is provably false before any deep validator runs. That is the kind of contradiction a flat findings list surfaces and a boolean pass/fail hides

What this audit cannot tell you

Honesty about scope is what keeps a preflight tool trusted. Everything above reads what the file declares about itself: PDFium parses structure, and this audit inventories it. It does not perform PDF/A validation — no glyph-coverage checks against embedded fonts, no color space analysis against output intents, none of the clause-level rules that separate a claim from conformance; for that you need a dedicated validator such as the component's preflight engine or veraPDF. Permission bits are declarations that conforming readers honor, not cryptographic walls, so SEC-NOPRINT describes intent rather than enforcement. The action scan covers link annotations and document-level scripts; scripts buried in form-field event dictionaries need the form APIs on top. And a signature check, if you extend the audit with one, reports declared intent, not verified cryptography — certificate chain validation is a separate job. A preflight audit is the intake interview, not the trial: its job is to make the routing decision informed, fast, and repeatable

Note: The document, page, annotation, and image object APIs used throughout this audit, together with a high-level Delphi wrapper and a full standards-validation preflight engine, ship with the PDFium Component