Artikel Teknis

Preflight PDF Otomatis dan Audit Risiko dengan PDFium

Sebuah PDF yang tiba di batas produksi — antrean cetak, arsip, portal unggahan pelanggan — sebaiknya diaudit sebelum apa pun me-render-nya. File itu bisa saja membawa Launch action yang dirangkai untuk menjalankan program eksternal, gambar yang terlalu kasar untuk bertahan saat dicetak, sebuah dictionary enkripsi yang justru melarang pekerjaan cetak yang menjadi alasan pengirimannya, atau label PDF/A yang tidak dipenuhinya. Memeriksa sebuah dokumen terhadap aturan semacam itu sebelum ia masuk ke dalam workflow disebut preflighting, dan PDFium C API memberi Delphi semua yang diperlukan untuk mengimplementasikan pemeriksaannya secara langsung, tanpa me-render satu halaman pun

Artikel ini membangun pemeriksaannya sendiri: empat kelas audit, masing-masing berupa rutin kecil yang menambahkan temuan ke sebuah daftar hasil bersama. Elemen interaktif, metrik sumber daya, keadaan keamanan, dan penanda standar semuanya memperoleh kode yang bisa dijalankan, termasuk aritmetikanya. Jika yang Anda butuhkan adalah mesin di sekeliling pemeriksaan itu — loop folder batch, file laporan JSON dan HTML, isolasi per file — PDFium Component menyertakan engine preflight siap pakai, dan artikel tentang CLI preflight batch membahas perpipaan tersebut. Keduanya sengaja berbagi satu kosakata exit code, sehingga auditor yang ditulis di sini langsung terpasang di bawah driver batch itu

Diagram pipeline PDF: sebuah PDF masukan menyebar ke empat kelas pemeriksaan yang temuannya terkumpul ke dalam satu record TPreflightFinding yang dipetakan ke exit code berbasis ambang
Audit ini menyebarkan file yang tak tepercaya melalui empat kelas pemeriksaan — elemen interaktif, metrik sumber daya, keadaan keamanan, dan penanda standar — mengumpulkan setiap hasilnya ke dalam satu record temuan yang bisa dihitung, lalu mengubahnya menjadi satu exit code

Record temuan dan kontrak exit code-nya

Setiap pemeriksaan menulis ke dalam satu tipe record datar, karena alternatifnya, yaitu setiap pemeriksaan mencetak prosanya sendiri, tidak bisa dihitung, disaring, atau diambangkan sesudahnya. Empat field sudah cukup

uses
  System.SysUtils, System.Math, System.IOUtils,
  System.Generics.Collections, pdfium_lib;

type
  TFindingSeverity = (fsInfo, fsWarning, fsError);

  TPreflightFinding = record
    Severity: TFindingSeverity;
    Code: string;       // kunci mesin yang stabil, mis. 'ACT-LAUNCH'
    Page: Integer;      // berbasis 1; 0 berarti tingkat dokumen
    Message: string;    // untuk manusia; bebas diubah antar rilis
  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;

Perkakas di hilir berpatokan pada Code, tidak pernah pada teks Message, yang bebas berubah. Exit code prosesnya mengikuti kontrak tiga nilai yang sama dengan artikel batch: 0 berarti file itu tidak menghasilkan temuan, 1 berarti ada temuan, dan 2 berarti auditnya sendiri tidak bisa berjalan karena file-nya gagal di-parsing atau menuntut password. Menjaga kode 2 tetap terpisah itu penting. Satu folder berisi hasil pindaian rusak berarti pemindai yang bermasalah di hulu, bukan keruntuhan kepatuhan yang mendadak, dan menyatukan keduanya akan mengirim orang mengejar masalah yang keliru

Elemen interaktif: skrip, target launch, tautan eksternal

PDFium mengklasifikasikan setiap action yang ditemukannya dengan sebuah tipe bilangan bulat, dan konstanta dari fpdf_doc.h layak dipaku dengan tepat, karena nilai yang salah salin membuat pemindainya buta diam-diam. Enumerasi yang sebenarnya adalah PDFACTION_UNSUPPORTED = 0, PDFACTION_GOTO = 1, PDFACTION_REMOTEGOTO = 2, PDFACTION_URI = 3, PDFACTION_LAUNCH = 4, dan PDFACTION_EMBEDDEDGOTO = 5. Perhatikan apa yang tidak ada: tidak ada anggota JavaScript. Skrip tingkat dokumen bukanlah link action dan tidak pernah muncul lewat FPDFAction_GetType; keduanya dienumerasi oleh keluarga panggilan yang terpisah. Sebuah auditor yang menguji tipe action terhadap konstanta JavaScript khayalan akan tetap ter-compile, tetap berjalan, dan tidak menemukan apa pun, selamanya

const
  PDFACTION_GOTO         = 1;   // lompatan dalam dokumen: tidak berbahaya
  PDFACTION_REMOTEGOTO   = 2;   // lompatan ke file lokal lain
  PDFACTION_URI          = 3;   // membuka URL eksternal
  PDFACTION_LAUNCH       = 4;   // menjalankan program eksternal
  PDFACTION_EMBEDDEDGOTO = 5;   // lompatan ke file tertanam

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;                 // tautan hanya tujuan, tak ada yang ditandai
    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 sengaja dibiarkan diam
  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;

Pembagian tingkat keparahannya mengodekan kebijakan. Sebuah Launch action adalah error karena menjalankan program sembarang adalah hal paling berbahaya yang bisa dilakukan sebuah klik di dalam PDF, dan tidak ada faktur yang membutuhkannya. URI eksternal berstatus peringatan: lazim ada di dokumen yang sah, namun seorang pemeriksa sebaiknya melihat targetnya tanpa mengklik, karena teks tautan yang terlihat dan tujuan sebenarnya tidak harus sama. Lompatan GoTo di dalam dokumen adalah struktur, bukan perilaku, dan sepenuhnya tidak masuk laporan — sebuah preflight yang berteriak serigala pada setiap entri daftar isi hanya melatih orang untuk mengabaikannya. Untuk membaca isi skrip di balik hitungan JavaScript itu, serta untuk level MDP signature dan deteksi XFA, artikel tentang audit risiko keamanan menyusuri permukaan yang sama lewat pembungkus objek milik component-nya

Metrik sumber daya: DPI efektif gambar

Sebuah gambar di dalam PDF tidak punya DPI-nya sendiri. Ia punya piksel, dan halaman menempatkan piksel itu ke dalam sebuah persegi panjang yang diukur dalam poin, di mana 72 poin sama dengan satu inci. Resolusi hanya ada sebagai rasio keduanya, itulah sebabnya foto 600 kali 400 yang sama bisa setajam silet sebagai thumbnail dan menjadi kekacauan buram sebagai hero satu halaman penuh. Karena itu auditnya membutuhkan kedua angka untuk setiap gambar: dimensi piksel sumber dari metadata gambarnya, dan persegi panjang penempatannya dari batas objeknya

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;              // ukuran penempatan di halaman, dalam poin
    HeightPt := T - B;
    if (WidthPt <= 0) or (HeightPt <= 0) or
       (Meta.Width = 0) or (Meta.Height = 0) then
      Continue;

    // 72 poin = 1 inci, jadi inci penempatan = poin / 72, dan
    // DPI efektif = piksel sumber / inci penempatan.
    DpiX := Meta.Width  / (WidthPt  / 72.0);
    DpiY := Meta.Height / (HeightPt / 72.0);
    EffDpi := Min(DpiX, DpiY);      // sumbu terburuk menentukan kualitas cetak

    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;

Ambangnya adalah kebijakan, bukan fisika: 150 DPI adalah lantai yang di bawahnya cetakan kantoran terlihat berpiksel, 300 adalah sasaran komersial yang biasa, dan apa pun di atas 600 tidak membeli kualitas yang kasatmata sambil menggelembungkan ukuran file, itulah sebabnya ia dilaporkan sebagai kegemukan yang bersifat informatif alih-alih sebagai cacat. Satu peringatan yang jujur: FPDFPageObj_GetBounds mengembalikan kotak sejajar sumbu, sehingga untuk gambar yang ditempatkan dengan rotasi angka hitungannya meremehkan kerapatan yang sebenarnya. Struct FPDF_IMAGEOBJ_METADATA juga membawa field horizontal_dpi dan vertical_dpi yang diturunkan PDFium dari matriks transformasi lengkapnya, dan membandingkan kedua hasil itu adalah cara murah untuk mengendus penempatan yang terotasi. Aritmetika poin-ke-piksel yang sama menggerakkan rendering ke arah sebaliknya, yang dibahas di artikel tentang ekspor JPEG

Keadaan keamanan: enkripsi dan bit izin

Enkripsi PDF mendefinisikan dua password dengan tugas berbeda. User password menjaga gerbang dekripsi: tanpanya file itu sama sekali tidak akan terbuka, dan FPDF_LoadDocument mengembalikan nil dengan FPDF_GetLastError melaporkan FPDF_ERR_PASSWORD. Owner password menjaga gerbang izin: sebuah file yang hanya dilindungi owner password akan terbuka tanpa kredensial namun membawa bit pembatasan yang wajib dihormati pembaca yang patuh. Karena itu upaya pemuatannya sendiri adalah probe keamanan yang pertama, dan pembedaan tersebut menentukan exit code-nya — file dengan user password tidak dapat diaudit (kode 2), sementara file dengan owner password diaudit secara normal dan sekadar mengumpulkan temuan

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 berarti file-nya tidak terenkripsi
  begin
    // Terbuka dengan password kosong namun terenkripsi: hanya owner password.
    // Siapa pun boleh membacanya, tetapi bit izinnya membatasi apa yang
    // diperbolehkan pembaca yang patuh. File tak terenkripsi melaporkan
    // semua bit menyala, itulah sebabnya gerbang revisi datang lebih dulu.
    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: cetak
      Add(Findings, fsWarning, 'SEC-NOPRINT', 0,
        'printing is not permitted');
    if (Perms and 16) = 0 then     // bit 5: salin / ekstrak konten
      Add(Findings, fsInfo, 'SEC-NOCOPY', 0,
        'content extraction is not permitted');
    if (Perms and 2048) = 0 then   // bit 12: cetak resolusi tinggi
      Add(Findings, fsWarning, 'SEC-LOWPRINT', 0,
        'only low-resolution printing is permitted');
  end;
end;

Mask-nya berasal dari Tabel 22 ISO 32000-1, yang menomori bit mulai dari 1: bit 3 pada nilai /P adalah mask 4, bit 5 adalah 16, bit 12 adalah 2048. Apakah sebuah temuan tertentu penting atau tidak adalah keputusan perutean. Sebuah biro cetak sebaiknya menolak file SEC-NOPRINT pada saat penerimaan, ketika pengirimnya masih memperoleh pesan yang jelas, alih-alih di RIP tiga jam sebelum tenggat. Sebuah arsip sebaiknya memperlakukan SEC-ENC itu sendiri sebagai penghalang, karena enkripsi dan pelestarian jangka panjang tidak berbaur — sebuah poin yang sebentar lagi dinyatakan resmi oleh pemeriksaan standarnya

Penanda standar: membaca sebuah klaim PDF/A

Sebuah file menyatakan kesesuaian PDF/A di dalam paket metadata XMP-nya, melalui properti pdfaid:part (1 sampai 4) dan pdfaid:conformance (huruf levelnya, seperti b untuk kesetiaan visual atau a untuk penandaan struktural penuh). C API milik PDFium tidak menawarkan akses ke XMP; FPDF_GetMetaText hanya membaca dictionary Info, yang bukan tempat identifikasi itu berdiam. Jalan keluarnya adalah sebuah aturan di dalam standarnya sendiri: ISO 19005 mewajibkan stream metadata XMP disimpan tanpa kompresi, justru supaya perkakas bisa menemukannya tanpa parser PDF yang lengkap. Karena itu pemindaian byte mentah adalah pendeteksi klaim yang sah — dan sebuah file yang klaimnya bersembunyi di dalam stream terkompresi sudah melanggar standar yang diklaimnya

function PdfAClaim(const FileName: string): string;
var
  Bytes: TBytes;
  S: RawByteString;
  P, Limit: Integer;
begin
  Result := '';                     // kosong = tidak ada klaim PDF/A
  Bytes := TFile.ReadAllBytes(FileName);
  if Length(Bytes) = 0 then
    Exit;
  SetString(S, PAnsiChar(@Bytes[0]), Length(Bytes));
  P := Pos('pdfaid:part', S);       // skema identifikasi XMP
  if P = 0 then
    Exit;
  // Menangani <pdfaid:part>2</pdfaid:part> maupun pdfaid:part="2":
  // ambil digit pertama setelah nama propertinya.
  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;

Temuan yang dihasilkannya sengaja bersifat informatif, karena sebuah klaim adalah pernyataan, bukan sifat file-nya. Entry XMP itu hanyalah satu baris XML yang bisa ditulis produser mana pun, termasuk yang rusak; kesesuaian berarti file itu benar-benar memenuhi ratusan aturan tentang font tertanam, warna yang bebas perangkat, dan fitur yang terlarang. Mendeteksi klaimnya memberi tahu Anda file mana yang perlu dirutekan ke validasi yang sesungguhnya, dan tidak lebih dari itu. Engine preflight bawaan component-nya melakukan validasi itu untuk profil PDF/A, PDF/UA, dan PDF/X, dan artikel tentang CLI batch menunjukkan cara merangkainya ke dalam sebuah pipeline dengan laporan yang bisa dibuka auditor belakangan

Satu kali jalan terhadap file bermasalah

Driver-nya merangkai pemeriksaan itu: keamanan lebih dulu, karena ia menentukan apakah auditnya berjalan sama sekali, lalu perilaku tingkat dokumen dan klaim standarnya, kemudian sebuah loop halaman untuk action dan gambar

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);                        // kegagalan audit, bukan sebuah putusan
  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;

Terhadap sebuah brosur yang kembali dari agensi luar, keluarannya tampak seperti ini

> 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

Setiap barisnya bisa ditindaklanjuti sendiri-sendiri, namun kombinasinyalah putusan yang sesungguhnya. File ini mengklaim PDF/A-2 sambil membawa dictionary enkripsi dan JavaScript yang hidup, sementara PDF/A melarang keduanya secara mutlak — sehingga klaimnya terbukti palsu sebelum validator mendalam mana pun dijalankan. Itulah jenis kontradiksi yang dimunculkan sebuah daftar temuan yang datar dan disembunyikan oleh lulus/gagal yang bersifat boolean

Apa yang tidak bisa diberitahukan audit ini kepada Anda

Kejujuran soal cakupan itulah yang menjaga sebuah tool preflight tetap dipercaya. Semua yang di atas membaca apa yang dinyatakan file itu tentang dirinya sendiri: PDFium mem-parsing strukturnya, dan audit ini menginventarisasinya. Ia tidak melakukan validasi PDF/A — tidak ada pemeriksaan cakupan glyph terhadap font tertanam, tidak ada analisis color space terhadap output intent, dan tidak ada satu pun aturan setingkat klausul yang memisahkan klaim dari kesesuaian; untuk itu Anda butuh validator khusus seperti engine preflight milik component-nya atau veraPDF. Bit izin adalah pernyataan yang dihormati pembaca yang patuh, bukan tembok kriptografis, sehingga SEC-NOPRINT menggambarkan niat alih-alih penegakan. Pemindaian action-nya mencakup anotasi tautan dan skrip tingkat dokumen; skrip yang terkubur di dalam dictionary event pada form field membutuhkan API form di atasnya. Dan sebuah pemeriksaan signature, jika Anda memperluas auditnya dengan itu, melaporkan niat yang dinyatakan, bukan kriptografi yang terverifikasi — validasi rantai sertifikat adalah pekerjaan tersendiri. Audit preflight adalah wawancara penerimaan, bukan persidangan: tugasnya adalah membuat keputusan perutean menjadi terinformasi, cepat, dan bisa diulang

Catatan: API dokumen, halaman, anotasi, dan objek gambar yang dipakai di sepanjang audit ini, bersama sebuah pembungkus Delphi tingkat tinggi dan engine preflight validasi standar yang lengkap, disertakan bersama PDFium Component