Articol tehnic

Verificarea preliminară automată a PDF-urilor și auditarea riscurilor cu PDFium

Un PDF care ajunge la o limită de producție — o coadă de imprimare, o arhivă, un portal de încărcare a clienților — ar trebui auditat înainte ca ceva să îl randeze. Fișierul ar putea conține o acțiune de lansare setată să pornească un program extern, imagini prea neclare pentru a supraviețui imprimării, un dicționar de criptare care interzice chiar sarcina de imprimare pentru care a fost trimis, sau o etichetă PDF/A la standardele căreia nu se ridică. Inspectarea unui document conform unor reguli ca acestea înainte ca acesta să intre într-un flux de lucru se numește verificare preliminară (preflighting), iar API-ul C PDFium oferă limbajului Delphi tot ce este necesar pentru a implementa verificările direct, fără a randa o singură pagină

Acest articol construiește verificările în sine: patru clase de audit, fiecare o mică rutină care adaugă constatările la o listă de rezultate partajată. Elementele interactive, metricile resurselor, starea de securitate și markerii standardelor primesc toate cod de lucru, inclusiv aritmetica. Dacă ceea ce aveți nevoie este mecanismul din jurul verificărilor — bucle de dosare în lot, fișiere de raport JSON și HTML, izolare pe fișier — Componenta PDFium livrează un motor de verificare preliminară gata făcut, iar articolul despre CLI de verificare preliminară în lot acoperă acele detalii. Cele două împart în mod deliberat același vocabular pentru codurile de ieșire, astfel încât un auditor scris aici se integrează direct sub acel driver de lot

Înregistrarea constatărilor și contractul codurilor de ieșire

Fiecare verificare scrie într-un singur tip de înregistrare plată, deoarece alternativa, în care fiecare verificare își imprimă propria proză, nu poate fi numărată, filtrată sau supusă unor limite ulterior. Patru câmpuri sunt suficiente

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;

Uneltele din aval se bazează pe Code, niciodată pe textul Message, care se poate modifica liber. Codul de ieșire al procesului urmează același contract cu trei valori ca în articolul despre verificarea în lot: 0 înseamnă că fișierul nu a produs nicio constatare, 1 înseamnă că există constatări, iar 2 înseamnă că auditul în sine nu a putut rula deoarece fișierul nu a putut fi analizat sau necesită o parolă. Păstrarea codului 2 separat este importantă. Un dosar cu scanări corupte reprezintă un scaner stricat în amonte, nu un colaps brusc de conformitate, iar combinarea celor două trimite pe cineva să urmărească problema greșită

Elemente interactive: scripturi, ținte de lansare, linkuri externe

PDFium clasifică fiecare acțiune pe care o găsește printr-un tip de număr întreg, iar constantele din fpdf_doc.h merită fixate cu precizie, deoarece valorile copiate greșit fac un scaner orb în tăcere. Enumerația reală este PDFACTION_UNSUPPORTED = 0, PDFACTION_GOTO = 1, PDFACTION_REMOTEGOTO = 2, PDFACTION_URI = 3, PDFACTION_LAUNCH = 4 și PDFACTION_EMBEDDEDGOTO = 5. Observați ce lipsește: nu există niciun membru JavaScript. Scripturile la nivel de document nu sunt acțiuni de link și nu apar niciodată prin FPDFAction_GetType; ele sunt enumerate printr-o familie separată de apeluri. Un auditor care testează tipurile de acțiuni față de o constantă JavaScript imaginată compilează, rulează și nu găsește nimic, pentru totdeauna

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;

Împărțirea severității codifică politica. O acțiune de lansare este o eroare deoarece pornirea unui program arbitrar este cel mai periculos lucru pe care îl poate face un clic într-un PDF, și nicio factură nu are nevoie de asta. URI-urile externe sunt avertismente: comune în documente legitime, dar un recenzent ar trebui să vadă ținta fără a da clic, deoarece textul vizibil al linkului și destinația reală nu trebuie să se potrivească. Salturile GoTo în interiorul documentului sunt structură, nu comportament, și rămân în afara raportului în întregime — o verificare preliminară care declanșează alarme false la fiecare intrare din cuprins antrenează oamenii să o ignore. Pentru citirea corpurilor scripturilor din spatele numărătorii JavaScript, și pentru nivelurile MDP de semnătură și detectarea XFA, articolul despre auditarea riscurilor de securitate parcurge aceeași suprafață prin wrapper-ul de obiecte al componentei

Metrici de resurse: DPI efectiv al imaginilor

O imagine din interiorul unui PDF nu are un DPI propriu. Are pixeli, iar pagina plasează acești pixeli într-un dreptunghi măsurat în puncte, unde 72 de puncte reprezintă un inch. Rezoluția există doar ca raportul celor două, motiv pentru care aceeași fotografie de 600 pe 400 este extrem de clară ca miniatură și o mizerie neclară ca imagine principală pe o pagină întreagă. Prin urmare, auditul necesită ambele numere pentru fiecare imagine: dimensiunile pixelilor sursă din metadatele imaginii și dreptunghiul plasat din limitele obiectului

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;

Pragurile sunt politici, nu fizică: 150 DPI este limita de jos sub care imprimarea de birou pixelează vizibil, 300 este ținta comercială obișnuită, și orice este peste 600 nu aduce nicio calitate vizibilă, umflând în același timp dimensiunea fișierului, motiv pentru care se raportează ca balast informațional, nu ca defect. Un avertisment sincer: FPDFPageObj_GetBounds returnează caseta aliniată la axe, astfel încât pentru o imagine plasată cu rotație, cifra calculată subestimează densitatea adevăratată. Structura FPDF_IMAGEOBJ_METADATA poartă, de asemenea, câmpurile horizontal_dpi și vertical_dpi pe care PDFium le derivă din matricea de transformare completă, iar compararea celor două rezultate este o modalitate ieftină de a detecta plasările rotite. Aceeași aritmetică puncte-în-pixeli conduce randarea în direcția opusă, abordată în articolul despre exportul JPEG

Starea de securitate: criptare și biți de permisiune

Criptarea PDF definește două parole cu roluri diferite. Parola utilizatorului condiționează decriptarea: fără ea fișierul nu se va deschide deloc, iar FPDF_LoadDocument returnează nil cu FPDF_GetLastError raportând FPDF_ERR_PASSWORD. Parola proprietarului condiționează permisiunile: un fișier protejat doar de o parolă a proprietarului se deschide fără credențiale, dar poartă biți de restricție pe care un cititor conform trebuie să îi onoreze. Încercarea de încărcare în sine este, prin urmare, prima sondă de securitate, iar distincția decide codul de ieșire — un fișier cu parolă de utilizator este ne-auditabil (cod 2), în timp ce un fișier cu parolă de proprietar este auditat normal și doar acumulează constatări

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;

Măștile provin din Tabelul 22 al ISO 32000-1, care numerotează biții de la 1: bitul 3 din valoarea /P este masca 4, bitul 5 este 16, bitul 12 este 2048. Dacă o anumită constatare contează este o decizie de rutare. Un birou de imprimare ar trebui să respingă un fișier SEC-NOPRINT la primire, unde cel care l-a trimis primește un mesaj clar, în loc să o facă la RIP cu trei ore înainte de termenul limită. O arhivă ar trebui să trateze SEC-ENC în sine ca pe un blocaj, deoarece criptarea și conservarea pe termen lung nu se amestecă — un aspect pe care verificarea standardelor urmează să îl sublinieze oficial

Markeri ai standardelor: citirea unei revendicări PDF/A

Un fișier declară conformitatea PDF/A în pachetul său de metadate XMP, prin proprietatea pdfaid:part (1 până la 4) și pdfaid:conformance (litera nivelului, cum ar fi b pentru fidelitate vizuală sau a pentru etichetare structurală completă). API-ul C al PDFium nu oferă un accesor XMP; FPDF_GetMetaText citește doar dicționarul Info, care nu este locul unde se află identificarea. Soluția de ieșire este o regulă chiar în standard: ISO 19005 cere ca fluxul de metadate XMP să fie stocat necomprimat, tocmai pentru ca uneltele să îl poată găsi fără un analizor PDF complet. O scanare a octeților bruți este, prin urmare, un detector legitim al revendicării — și un fișier a cărui revendicare se ascunde într-un flux comprimat a încălcat deja standardul pe care îl revendică

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;

Constatarea produsă este deliberat informațională, deoarece o revendicare este o declarație, nu o proprietate a fișierului. Intrarea XMP este o singură linie de XML pe care orice producător o poate scrie, inclusiv unul stricat; conformitatea înseamnă ca fișierul să satisfacă efectiv sute de reguli despre fonturi încorporate, culori independente de dispozitiv și funcționalități interzise. Detectarea revendicării vă spune care fișiere trebuie direcționate spre validarea reală, și nimic mai mult. Motorul de verificare preliminară încorporat al componentei efectuează acea validare pe profilurile PDF/A, PDF/UA și PDF/X, iar articolul despre CLI în lot arată cum să-l conectați într-o conductă cu rapoarte pe care un auditor le poate deschide mai târziu

O rulare pe un fișier problemă

Driver-ul leagă verificările: mai întâi securitatea, deoarece decide dacă auditul rulează sau nu deloc, apoi comportamentele la nivel de document și revendicarea standardelor, urmate de o buclă de pagină pentru acțiuni și imagini

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;

Pe o broșură venită de la o agenție externă, rezultatul arată astfel

> 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

Fiecare linie este acționabilă în sine, dar combinația reprezintă adevăratul verdict. Acest fișier revendică PDF/A-2 în timp ce poartă un dicționar de criptare și JavaScript activ, iar PDF/A le interzice pe ambele direct — deci revendicarea este demonstrabil falsă înainte să ruleze orice validator profund. Acesta este genul de contradicție pe care o listă plată de constatări o aduce la suprafață, în timp ce un rezultat boolean trece/pică o ascunde

Ce nu vă poate spune acest audit

Sinceritatea cu privire la scop este ceea ce menține încrederea într-o unealtă de verificare preliminară. Tot ce este mai sus citește ceea ce declară fișierul despre sine: PDFium analizează structura, iar acest audit o inventariază. Nu efectuează validare PDF/A — nu există verificări ale acoperirii glifelor pentru fonturile încorporate, nicio analiză a spațiului de culori pentru intențiile de ieșire, niciuna dintre regulile la nivel de clauză care separă o revendicare de conformitate; pentru asta aveți nevoie de un validator dedicat, cum ar fi motorul de verificare preliminară al componentei sau veraPDF. Biții de permisiune sunt declarații pe care cititorii conformi le onorează, nu ziduri criptografice, deci SEC-NOPRINT descrie o intenție mai degrabă decât o aplicare. Scanarea acțiunilor acoperă adnotările de tip link și scripturile la nivel de document; scripturile îngropate în dicționarele de evenimente ale câmpurilor din formulare necesită API-urile de formular în plus. Și o verificare a semnăturii, dacă extindeți auditul cu una, raportează intenția declarată, nu criptografia verificată — validarea lanțului de certificate este o sarcină separată. Un audit de verificare preliminară este interviul de la preluare, nu procesul în sine: treaba lui este să facă decizia de rutare informată, rapidă și repetabilă

Notă: API-urile obiectelor de document, pagină, adnotare și imagine utilizate pe tot parcursul acestui audit, împreună cu un wrapper Delphi de nivel înalt și un motor complet de verificare preliminară pentru validarea standardelor, sunt livrate cu Componenta PDFium