Technical Article

Validasi Preflight PDF/A di Delphi dengan PDFium VCL

Sebuah gerbang ingest arsip menolak sekelompok berkas "PDF/A-2b" yang terbuka normal di setiap penampil di meja kerja. Pemasok bersikeras bahwa berkas-berkas itu sudah sesuai standar. Ternyata tidak, karena masing-masing membawa aksi JavaScript yang tersembunyi di catalog, jenis hal yang hampir tidak pernah terlihat oleh mata biasa dan langsung ditandai oleh validator PDF/A penuh seperti veraPDF. Masalahnya, tidak ada yang mau menempelkan toolchain Java ke layanan batch Delphi hanya untuk menjawab satu pertanyaan ya atau tidak per berkas. Celah itulah yang diisi oleh ValidatePdfACompliance di PDFium Component, dan menarik untuk memahami bagaimana fungsi ini sampai pada keputusan tanpa pernah mengurai content stream secara penuh

Mengapa PDFium sendiri tidak bisa menjawab ini

Hal pertama yang perlu jujur diakui: pdfium.dll bawaan sama sekali tidak punya kemampuan PDF/A. Tidak ada ConvertToPDFA, tidak ada penulis OutputIntent, tidak ada API XMP di permukaan publik. Seluruh bagian PDF/A di pustaka ini, baik sisi penulisan maupun sisi pemeriksaan, hidup murni di Pascal dalam FPdfPdfa.pas dan bekerja lewat parsing level byte plus incremental update. Jadi saat Anda memanggil validator, Anda tidak sedang meminta apa pun dari renderer Chromium. Anda sedang menjalankan pemindai token Pascal atas byte struktural file

API publiknya sengaja kecil. Satu fungsi membaca stream dari posisi 0 dan mengembalikan sebuah record:

function ValidatePdfACompliance(Source: TStream): TPdfAValidationResult;

type
  TPdfAValidationResult = record
    Conformance: TPdfAConformance;        // pacUnknown, pacNone, pac1b, pac2u, ...
    Issues: TPdfAValidationIssues;        // a set of TPdfAValidationIssue
    function IsCompliant: Boolean;        // True only when level <> unknown/none
  end;                                    // AND Issues is empty

IsCompliant merangkum aturan yang penting di gerbang ini: sebuah berkas hanya lolos bila level konformansi yang benar terdeteksi dan himpunan isu kosong. Parsing yang berhasil tetapi tidak menemukan penanda pdfaid akan menghasilkan pacNone, dan itu jelas bukan lolos. Ini sama seperti yang ditegaskan oleh CLI laporan preflight batch dari luar: daftar temuan yang kosong pada berkas yang tidak dikenali bukan berarti berkas itu bersih

Menghapus isi stream sebelum pemindaian token apa pun

Ini detail implementasi yang paling penting, dan juga yang paling mudah salah jika Anda menulis pemindai sendiri. Detektor menemukan pelanggaran dengan mencari token nama yang dibatasi, hal-hal seperti /JavaScript, /LZWDecode, /BM. Jika Anda memindai byte mentah file, isi stream biner yang tertanam, gambar terkompresi, profil ICC, program font, akan secara acak berisi urutan byte yang tampak seperti token itu. Anda akan melaporkan /AA atau /3D sebagai "ditemukan" hanya karena tiga byte di dalam JPEG kebetulan membentuknya. Itu pabrik false positive

Solusinya adalah PdfStructureBytes: ia menelusuri file dan mengisi byte di antara setiap kata kunci stream dan endstream dengan spasi, sambil membiarkan struktur dictionary tetap utuh. Pemindaian baru dijalankan setelah itu. Setiap pemeriksaan token nama di validator bekerja atas salinan yang sudah dibersihkan ini. Jika Anda hanya mengambil satu gagasan dari artikel ini, ambil gagasan itu. Disiplin yang sama juga dipakai di validator PDF/UA, yang menyimpan salinan rutin sendiri karena kedua standar itu berkembang secara independen

29 isu dan arti masing-masing

TPdfAValidationIssue adalah kontrak terdokumentasi. Nilai ordinalnya dikunci karena tes DUnitX, demo, dan lapisan laporan semuanya bergantung padanya, jadi temuan baru hanya pernah ditambahkan di akhir. Per v1.63.0 ada 29 anggota. Mereka terbagi ke beberapa keluarga:

  • Metadata dan identitas: pvaiMissingXmpMetadata, pvaiMissingPdfAIdentifier, pvaiMissingTrailerId (ISO 19005-1 6.1.3), pvaiMissingXmpDates
  • Warna dan output: pvaiMissingOutputIntent, pvaiMissingIccProfile, dan pvaiMixedDeviceColorSpaces saat DeviceRGB dan DeviceCMYK sama-sama muncul (6.2.3.3)
  • Larangan keras untuk setiap part: pvaiEncryptionPresent (dictionary /Encrypt dilarang mutlak), pvaiJavaScriptPresent, pvaiForbiddenAction, pvaiAdditionalActions, pvaiLzwUsed, pvaiXfaPresent, pvaiNeedAppearancesTrue, pvaiForbiddenAnnotation
  • Font: pvaiFontNotEmbedded dan yang lebih ketat pvaiUnembeddedFont, plus pvaiUnicodeMappingMissing untuk klaim Level U tanpa /ToUnicode
  • Tagging: pvaiLevelAStructureMissing ketika klaim conformance=A tidak punya struktur bertag

Enam anggota terbaru, ditambahkan pada ordinal 24 sampai 29, menutup kasus-kasus halus yang benar-benar sering menjebak reviewer: pvaiTrappedTrue (sebuah /Trapped /True di dictionary Info, "false friend" karena nilainya harus False atau Unknown), pvaiForbiddenActionSubtype (Sound atau Movie dipakai sebagai aksi, bukan sekadar anotasi), pvaiTransparentColorSpace (mode blend non-Normal atau /CA//ca yang tidak sama dengan 1.0), pvaiAnnotationDictViolation, pvaiUnembeddedFont, dan pvaiMixedDeviceColorSpaces

Pembatasan yang sadar part: A-1 ketat, A-2 dan A-3 lebih longgar

PDF/A bukan satu buku aturan. Tiga hal yang dilarang PDF/A-1 justru diizinkan mulai PDF/A-2: transparansi (group /Transparency atau /SMask yang aktif, 6.4), optional content (/OCProperties, 6.1.13), dan file tertanam (/EmbeddedFiles atau /EF, 6.1.11). Validator naif yang menandai ketiganya untuk setiap berkas akan menolak dokumen PDF/A-2 yang sebenarnya valid secara massal

Karena itu validator membaca nomor part dari penanda pdfaid lewat PdfAPartOf dan menempatkan pemeriksaan itu di balik PartNo = 1. Pemeriksaan blend-mode dan alpha anotasi untuk isu transparansi baru juga hanya berlaku di part 1:

if PartNo = 1 then
begin
  if PdfHasName(Struct, '/BM') then
    if not PdfHasBMNormal(Struct) then          // only /Normal or /Compatible allowed
      Include(Result.Issues, pvaiTransparentColorSpace);
  if PdfHasCaNotOne(Struct, '/CA') or PdfHasCaNotOne(Struct, '/ca') then
    Include(Result.Issues, pvaiTransparentColorSpace);
end;

Satu default konservatif juga perlu disebut: ketika tidak ada penanda pdfaid sama sekali, part diperlakukan sebagai 1, yang paling ketat. Alasannya, berkas yang tidak teridentifikasi harus dikenai aturan paling keras, bukan langsung dilewatkan. JavaScript, action terlarang, LZW, XFA, NeedAppearances, anotasi terlarang, dan font yang tidak tertanam tetap dilarang untuk setiap part, jadi pemeriksaan itu tidak pernah berada di balik gerbang

Mengekspansi object stream agar tidak ada yang tersembunyi

PDF 1.5 memperkenalkan cross-reference stream dan object stream (/Type /ObjStm), dan keduanya menciptakan titik buta bagi pemindai byte naif. Catalog, OutputIntent, dictionary aksi, apa pun yang bukan stream itu sendiri, bisa saja dikompresi Flate di dalam ObjStm. Pindai struktur mentah dan Anda tidak akan melihat apa pun, lalu Anda akan melaporkan berkas yang bersih padahal tidak sama sekali

PdfExpandObjectStreams menutup celah itu. Sebelum pemeriksaan apa pun dijalankan, validator melakukan Data := PdfExpandObjectStreams(Data). Rutin ini menemukan setiap ObjStm, membaca header /N dan /First untuk mengambil nomor dan offset objek di dalamnya, melakukan inflate pada body dengan PdfInflate (zlib RTL, System.ZLib di Delphi dan zstream di FPC), lalu menambahkan setiap objek yang terkandung sebagai N 0 obj ... endobj biasa ke akhir salinan byte. Pemeriksaan token yang sudah ada kemudian menemukan objek-objek itu tanpa perubahan logika

Ada dua batasan yang membuat pendekatan ini tetap bersih, bukan rapuh. Objek stream, Metadata, profil ICC, dan program font tidak bisa hidup di dalam object stream, hanya dictionary non-stream yang bisa, jadi ekspansi hanya menangani dictionary dan objek yang ditambahkan tidak membawa kata kunci stream yang bisa mengganggu proses penghapusan body. Dan karena konten yang ditambahkan jatuh setelah %%EOF, pencarian mundur dari startxref tetap menemukan trailer asli. Trailer cross-reference stream sendiri sudah ditangani lebih awal, di v1.49.3, dengan membaca Root, Size, dan ID langsung dari dictionary xref-stream plaintext, topik yang dibahas di artikel pendamping tentang memvalidasi object dan cross-reference stream; pekerjaan object stream hanya perlu menambahkan langkah inflate, tanpa perlu mendekode entri xref tipe 2 atau membongkar PNG predictor

Batas jujur pemeriksa level byte

Ini adalah alat preflight, bukan validator tersertifikasi, dan batasnya nyata. Embedding font adalah heuristik hitung, dan membetulkannya butuh koreksi yang penting untuk diketahui. Pemeriksaan awal memakai PdfCountName('/FontDescriptor'), tetapi setiap font menyumbang dua token /FontDescriptor, satu referensi dari dictionary font dan satu /Type di objek descriptor itu sendiri, jadi hitungannya menjadi 2N terhadap N program tertanam dan pengujian selalu bernilai true. Perbaikannya adalah PdfCountDescriptorRefs, yang hanya menghitung bentuk referensi /FontDescriptor N G R, satu per font, dan hanya menaikkan pvaiUnembeddedFont ketika program yang tertanam memang lebih sedikit:

K := PdfCountDescriptorRefs(Struct);                 // one per font dict
Emb := PdfCountName(Struct, '/FontFile')
     + PdfCountName(Struct, '/FontFile2')
     + PdfCountName(Struct, '/FontFile3');
if (K > 0) and (Emb < K) then
  Include(Result.Issues, pvaiUnembeddedFont);

Bahkan setelah diperbaiki, pendekatan ini tetap kasar: dokumen campuran yang kebetulan membuat setiap descriptor punya FontFile masih bisa membiarkan satu font yang tidak sesuai lolos. Mengekspansi object stream juga punya efek samping yang sudah diketahui, yaitu memunculkan resource default standar-14 yang dibawa AcroForm /DR, seperti /Helv, dan heuristik akan patuh melaporkannya sebagai tidak tertanam walaupun veraPDF membiarkannya lewat karena resource itu memang tidak pernah dipakai untuk merender. Pemeriksaan level operator pada content stream (6.2.10) sepenuhnya di luar cakupan, karena itu membutuhkan parsing konten penuh, bukan pemindaian byte. Perlakukan validator ini sebagai gerbang pertama yang cepat dan tanpa dependensi, yang menangkap pelanggaran yang tidak bisa diperbaiki dengan injeksi penanda, lalu simpan validator penuh untuk sertifikasi final

Ini adalah sisi pemeriksaan dari cerita ini. Sisi penulisan yang melengkapinya, tempat SaveAsPdfA menyisipkan XMP, OutputIntent, dan profil ICC sRGB lalu dengan jujur menurunkan permintaan Level A yang tidak punya struktur bertag, dibangun di atas mesin level byte yang sama. Kedua sisi dikirimkan dalam PDFium Component untuk Delphi, satu paket VCL di atas implementasi PDF/A murni Pascal tanpa runtime eksternal yang perlu dipasang