Technisch artikel

Geautomatiseerde PDF Preflight en Risico-auditing met PDFium

Een PDF die aankomt bij een productiegrens (production boundary) — een afdrukwachtrij (print queue), een archief, een klanten-uploadportaal — zou geaudit moeten worden voordat iets het rendert. Het bestand zou een Launch-actie kunnen dragen die is geprogrammeerd om een extern programma te starten, afbeeldingen die te grof (coarse) zijn om te overleven bij het afdrukken, een coderingswoordenboek (encryption dictionary) dat precies de afdruktaak verbiedt waarvoor het is ingediend, of een PDF/A-label waaraan het niet voldoet. Het inspecteren van een document tegen dergelijke regels voordat het een workflow binnengaat, wordt preflighting genoemd, en de PDFium C API geeft Delphi alles wat nodig is om de controles (checks) rechtstreeks te implementeren, zonder ook maar één pagina te renderen

Dit artikel bouwt de controles zelf: vier audit-klassen (audit classes), elk een kleine routine die bevindingen (findings) toevoegt aan een gedeelde resultatenlijst. Interactieve elementen, resourcemetrics, beveiligingsstatus en standaardenmarkeringen krijgen allemaal werkende code, inclusief de wiskunde. Als wat u nodig heeft de machinerie rond de controles is — batch maploops (folder loops), JSON- en HTML-rapportbestanden, isolatie per bestand — dan levert de PDFium-component een kant-en-klare preflight-engine (preflight engine), en het artikel over de batch preflight CLI behandelt die techniek. De twee delen opzettelijk één exit-code-vocabulaire, zodat een auditor die hier wordt geschreven rechtstreeks onder die batch driver past

Het bevindingsrecord (finding record) en het exit-code contract

Elke controle schrijft naar één plat record-type, omdat het alternatief, waarbij elke controle zijn eigen proza afdrukt, achteraf niet geteld, gefilterd of van een drempelwaarde (thresholded) kan worden voorzien. Vier velden zijn voldoende

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 gebruikt Code als sleutel (keys on), nooit de tekst in Message, die vrij kan veranderen. De proces-exitcode volgt hetzelfde dries-waarden contract (three-value contract) als het batch-artikel: 0 betekent dat het bestand geen bevindingen heeft opgeleverd, 1 betekent dat er bevindingen zijn, en 2 betekent dat de audit zelf niet kon draaien omdat het bestand niet kon worden geparseerd (failed to parse) of om een wachtwoord vraagt. Code 2 gescheiden houden is belangrijk. Een map met corrupte scans wijst op een kapotte scanner verderop in de keten (upstream), niet op een plotselinge ineenstorting van naleving (compliance collapse), en door de twee samen te vouwen stuur je iemand achter het verkeerde probleem aan

Interactieve elementen: scripts, startdoelen (launch targets), externe links

PDFium classificeert elke actie die het vindt met een geheel getal type (integer type), en de constanten van fpdf_doc.h zijn het waard om precies vast te leggen, omdat verkeerd gekopieerde waarden een scanner stilzwijgend blind maken. De echte opsomming (enumeration) is PDFACTION_UNSUPPORTED = 0, PDFACTION_GOTO = 1, PDFACTION_REMOTEGOTO = 2, PDFACTION_URI = 3, PDFACTION_LAUNCH = 4 en PDFACTION_EMBEDDEDGOTO = 5. Let op wat ontbreekt: er is geen JavaScript-lid (member). Document-niveau scripts zijn geen linkacties en verschijnen nooit via FPDFAction_GetType; ze worden opgesomd door een afzonderlijke familie van aanroepen (calls). Een auditor die actietypen test tegen een ingebeelde JavaScript constante compileert, draait en vindt niets, voor altijd

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;

De splitsing op basis van ernst (severity split) codeert beleid (policy). Een Launch-actie is een fout, omdat het starten van een willekeurig programma het meest gevaarlijke is wat een klik in een PDF kan doen, en geen enkele factuur heeft dit nodig. Externe URL's zijn waarschuwingen (warnings): ze komen vaak voor in legitieme documenten, maar een beoordelaar moet het doel (target) kunnen zien zonder erop te klikken, aangezien de zichtbare linktekst en de daadwerkelijke bestemming niet hoeven overeen te komen. Interne GoTo sprongen zijn structuur, geen gedrag, en blijven helemaal uit het rapport — een preflight die bij elke inhoudsopgave-invoer alarm slaat, traint mensen om dit te negeren. Voor het lezen van de script-body's achter de JavaScript telling (count), en voor handtekening MDP niveaus en XFA-detectie, doorloopt het artikel over auditing van beveiligingsrisico's hetzelfde oppervlak via de object-wrapper van de component

Resourcemetrics: effectieve afbeeldings-DPI

Een afbeelding binnen een PDF heeft geen eigen DPI. Het heeft pixels, en de pagina plaatst die pixels in een rechthoek gemeten in punten (points), waarbij 72 punten een inch (2,54 cm) vormen. Resolutie bestaat alleen als de verhouding (ratio) van de twee, en dat is waarom dezelfde 600 bij 400 foto haarscherp is als miniatuur (thumbnail) en een wazige boel als hero-afbeelding over de hele pagina (full-page hero). De audit heeft daarom beide getallen nodig voor elke afbeelding: bron-pixelafmetingen uit de metadata van de afbeelding, en de geplaatste rechthoek uit de objectgrenzen (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;

De drempels (thresholds) zijn beleid (policy), geen natuurkunde: 150 DPI is een ondergrens (floor) waaronder kantoorafdrukken zichtbaar korrelig worden (pixelates), 300 is het gebruikelijke commerciële doel, en alles daarboven levert geen zichtbare kwaliteit op terwijl de bestandsgrootte opblaast, en dat is waarom het wordt gerapporteerd als informatieve ballast (bloat) in plaats van een defect. Een eerlijk voorbehoud (caveat): FPDFPageObj_GetBounds retourneert de as-uitgelijnde doos (axis-aligned box), dus voor een afbeelding die met rotatie is geplaatst, onderschat het berekende cijfer de werkelijke dichtheid (density). De FPDF_IMAGEOBJ_METADATA structuur bevat ook horizontal_dpi en vertical_dpi velden die PDFium afleidt van de volledige transformatie-matrix (transform matrix), en het vergelijken van de twee resultaten is een goedkope manier om geroteerde plaatsingen (rotated placements) op te sporen. Dezelfde punten-naar-pixels rekenkunde stuurt renderen in de tegenovergestelde richting, wat wordt behandeld in het artikel over JPEG-export

Beveiligingsstatus (Security state): codering en machtigingsbits (permission bits)

PDF-codering definieert twee wachtwoorden met verschillende taken. Het gebruikerswachtwoord (user password) fungeert als poortwachter (gates) voor decodering: zonder dit zal het bestand helemaal niet openen, en FPDF_LoadDocument retourneert nil waarbij FPDF_GetLastError FPDF_ERR_PASSWORD rapporteert. Het eigenaarswachtwoord (owner password) fungeert als poortwachter voor machtigingen (permissions): een bestand dat alleen door een eigenaarswachtwoord wordt beschermd, opent zonder inloggegevens (credentials) maar draagt beperkingsbits (restriction bits) die een conforme lezer (conforming reader) in acht moet nemen (honor). De poging tot laden (load attempt) zelf is daarom de eerste beveiligingssonde (security probe), en het onderscheid bepaalt de exit-code — een bestand met een gebruikerswachtwoord kan niet worden ge-audit (code 2), terwijl een bestand met een eigenaarswachtwoord normaal wordt ge-audit en alleen maar bevindingen (findings) verzamelt

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;

De maskers (masks) komen uit Tabel 22 van ISO 32000-1, die bits nummert vanaf 1: bit 3 van de /P waarde is masker 4, bit 5 is 16, bit 12 is 2048. Of een bepaalde bevinding er toe doet, is een routeringsbeslissing (routing decision). Een afdrukbureau zou een SEC-NOPRINT bestand direct bij inname (intake) moeten weigeren (bounce), waarbij de indiener een duidelijke boodschap krijgt, in plaats van op de RIP (Raster Image Processor) drie uur voor een deadline. Een archief zou SEC-ENC zelf als een blokkade (blocker) moeten behandelen, aangezien encryptie en langetermijnbewaring niet samengaan — een punt dat de standaardencontrole zometeen formeel zal maken

Standaardenmarkeringen (Standards markers): een PDF/A-claim lezen

Een bestand verklaart PDF/A-conformiteit (conformance) in het XMP metadata-pakket, via de pdfaid:part eigenschap (1 tot en met 4) en pdfaid:conformance (de niveau-letter (level letter), zoals b voor visuele getrouwheid of a voor volledige structurele tagging). De PDFium C API biedt geen XMP-toegang (accessor); FPDF_GetMetaText leest alleen het Info woordenboek, dat niet de plaats is waar de identificatie leeft. De uitweg (escape hatch) is een regel in de standaard zelf: ISO 19005 vereist dat de XMP metadata stroom ongecomprimeerd wordt opgeslagen, precies zodat tools het kunnen vinden zonder een volledige PDF-parser. Een scan van ruwe bytes (raw byte scan) is daarom een legitieme claim detector — en een bestand waarvan de claim zich verbergt in een gecomprimeerde stroom (compressed stream), heeft de standaard waarop het aanspraak maakt (claims) al geschonden (violated)

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;

De bevinding (finding) die dit oplevert is bewust informatief, want een claim is een verklaring (declaration), geen eigenschap van het bestand. De XMP invoer is één regel XML die elke producent (producer) kan schrijven, inclusief een kapotte; conformiteit is het daadwerkelijk voldoen aan honderden regels door het bestand over ingebedde lettertypen, apparaatonafhankelijke kleur (device-independent color), en verboden functies. Het detecteren van de claim vertelt u alleen welke bestanden u moet doorsturen (route) naar echte validatie, en niets meer. De ingebouwde preflight engine van de component voert die validatie uit over PDF/A-, PDF/UA-, en PDF/X-profielen, en het artikel over batch CLI laat zien hoe dit in een pijplijn (pipeline) moet worden aangesloten met rapporten die een auditor later kan openen

Een run op een probleembestand

De driver rijgt de controles (checks) aaneen: veiligheid (security) eerst, omdat het beslist of de audit überhaupt draait, vervolgens de document-niveau gedragingen (behaviors) en de standaardenclaim (standards claim), daarna een pagina-loop voor acties en afbeeldingen

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;

Tegen een brochure die terugkwam van een extern bureau ziet de uitvoer er zo uit

> 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

Elke regel is op zichzelf actie-waardig (actionable), maar de combinatie is het echte oordeel (verdict). Dit bestand claimt PDF/A-2 terwijl het een encryptiewoordenboek (encryption dictionary) en actieve (live) JavaScript bevat, en PDF/A verbiedt beide botweg (outright) — dus de claim is aantoonbaar onwaar voordat enige diepgaande validator draait. Dat is het soort tegenstrijdigheid dat een platte lijst van bevindingen (flat findings list) aan het licht brengt (surfaces) en een boolean geslaagd/gezakt (pass/fail) verbergt

Wat deze audit u niet kan vertellen

Eerlijkheid over de reikwijdte (scope) is wat een preflight tool betrouwbaar (trusted) houdt. Alles hierboven leest wat het bestand over zichzelf verklaart (declares): PDFium ontleedt (parses) structuur, en deze audit inventariseert het. Het voert geen PDF/A-validatie uit — geen glyph-dekking controles tegen ingebedde lettertypen, geen kleurruimte analyse (color space analysis) tegen uitvoerintenties (output intents), geen van de clausule-niveau regels die een claim scheiden van conformiteit (conformance); daarvoor heeft u een toegewijde (dedicated) validator nodig, zoals de preflight engine van de component of veraPDF. Machtigingsbits (Permission bits) zijn verklaringen die conforme lezers (conforming readers) respecteren (honor), geen cryptografische muren, dus SEC-NOPRINT beschrijft de bedoeling (intent) in plaats van handhaving (enforcement). De actiescan (action scan) dekt linkannotaties en document-niveau scripts; scripts begraven in gebeurtenissenwoordenboeken (event dictionaries) van formuliervelden hebben de form-API's daarbovenop (on top) nodig. En een handtekeningcontrole (signature check), als u de audit daarmee uitbreidt, rapporteert een verklaarde bedoeling, geen geverifieerde cryptografie — de validatie van de certificaatketen is een aparte taak. Een preflight audit is het intakegesprek, niet het proces (trial): de taak ervan is de routeringsbeslissing geïnformeerd, snel, en herhaalbaar (repeatable) te maken

Let op: De API's voor document, pagina, annotatie, en afbeeldingsobjecten die in deze audit worden gebruikt, worden samen met een high-level Delphi wrapper en een volledige standaarden-validatie (standards-validation) preflight engine meegeleverd met de PDFium Component