Artikel Teknis

Associated File PDF/A-3 dan AFRelationship di Delphi

Untuk melampirkan berkas sumber ke dokumen PDF/A-3 dari Delphi, PDFium Component menulis rantai associated-file PDF 2.0: satu embedded file stream dengan MIME /Subtype, satu file specification yang membawa /AFRelationship, dan satu array /AF yang digantungkan di catalog atau sebuah halaman. InjectAssociateFiles dan TPdf.SaveAsWithAssociateFiles membangun rantai itu dalam satu incremental update, dan sejak v3.121.2 tipe MIME diserialisasi sebagai satu nama PDF yang ter-escape dengan benar. Sisa tulisan ini membahas apa yang diperiksa validator, bug satu karakter yang merusak text/plain, dan tempat-tempat di mana rilis lama diam-diam melakukan sesuatu yang lain dari yang Anda minta

Apa yang benar-benar dibutuhkan sebuah associated file PDF/A-3?

Lampiran PDF/A-3 lolos validasi hanya saat tiga objek sepakat satu sama lain: embedded file stream mendeklarasikan /Type /EmbeddedFile plus MIME /Subtype, dictionary file specification (ISO 32000-2 §7.11.3) membawa /F, /UF, /EF, dan /AFRelationship, dan sesuatu di dokumen merujuk file specification itu lewat array /AF (ISO 32000-2 §14.13). Embedding polos lewat pohon /Names /EmbeddedFiles — yang dilakukan TPdf.CreateAttachment — tidak pernah menyetel field asosiasi sama sekali. Fixture validasi PDF/A-3b milik PDFium Component sendiri membuat ketergantungan itu konkret: ganti nama hanya key /AFRelationship dan berkasnya gagal tepat satu rule di klausul 6.8 ISO 19005-3; jatuhkan hanya MIME /Subtype dan rule 6.8 yang berbeda yang gagal; masukkan lampiran yang sama ke kandidat PDF/A-1b dan ia ditolak mentah-mentah, karena PDF/A-1 melarang embedded files betapapun rapi metadata-nya

Rantai tiga objek milik associated file PDF A-3 di PDFium Component: stream EmbeddedFile dengan MIME Subtype seperti application xml, file specification dengan F, UF, EF, dan AFRelationship diset ke Data, serta array AF untuknya dari catalog atau sebuah halaman — tiga objek yang diperiksa validator sebelum klausul 6.8 ISO 19005-3 lolos
Stream, file specification, dan array AF harus sepakat; embedding pohon nama polos milik TPdf.CreateAttachment tidak menyetel satu pun field asosiasi dan tidak akan pernah

Nilai relationship adalah bagian yang cenderung ditebak. TPdfAFRelationship di FPdfAssocFiles memetakan satu anggota enum ke tiap name token yang bisa di-emit injector, dan hanya lima pertama yang termasuk subset yang dikenali ISO 19005-3:

  • afSource → /Source: dokumen asli tempat PDF diproduksi, seperti file pengolah kata atau spreadsheet
  • afData → /Data: data yang terbaca mesin, tempat konten yang tampak diturunkan atau yang diwakilinya
  • afAlternative → /Alternative, afSupplement → /Supplement, afUnspecified → /Unspecified
  • afEncryptedPayload, afFormData, afTemplate: tambahan PDF 2.0 yang berada di luar subset PDF/A-3, jadi jauhkan dari keluaran arsip

Mengapa /Subtype /text/plain merusak validasi?

Bug MIME-nya adalah error tokenisasi, bukan celah kepatuhan: sebelum v3.121.2 injector menyambung string milik caller persis setelah slash, menghasilkan /Subtype /text/plain. Dalam sintaks PDF, slash kedua memulai objek name baru (ISO 32000-1 §7.3.5), jadi dictionary stream tiba-tiba memuat key /Subtype, name /text, dan name ekstra menggantung /plain yang membuat pasangan key-value tak seimbang. Validator PDF/A independen menolak berkasnya saat meng-parse dictionary EmbeddedFile, sebelum sempat mencapai rule PDF/A mana pun, itulah sebabnya kegagalannya tampak seperti kerusakan berkas alih-alih properti lampiran yang hilang

Fix-nya mengarahkan nilai MIME lewat EscapePdfName, yang meng-emit /text#2Fplain: satu name yang nilai ter-decode-nya text/plain. Escaping-nya sengaja dibuat lebih luas dari sekadar slash. Setiap byte pada atau di bawah 32 (spasi, tab, CR, LF), setiap byte pada atau di atas 127, delimiter ()<>[]{}/%, dan karakter escape # itu sendiri menjadi #XX. Meng-escape hanya slash akan menyisakan lubang lain: string MIME yang memuat >> atau whitespace bisa menutup dictionary lebih awal atau menyuntikkan key ekstra, jadi tes regresinya memberi nilai permusuhan dengan setiap delimiter plus tab, LF, dan CR, lalu memeriksa keluaran terenkode persisnya

Mengapa MIME subtype text slash plain merusak parsing PDF A-3 di PDFium Component: menyambung nilai setelah slash menghasilkan dua objek name, /text sebagai nilai plus /plain menggantung yang membuat dictionary EmbeddedFile tak seimbang, dan fix v3.121.2 mengarahkan nilai lewat EscapePdfName sehingga /text#2Fplain adalah satu name yang ter-decode ke text/plain
Kegagalannya tampak seperti kerusakan berkas karena terjadi di parser, sebelum rule PDF/A mana pun; name yang ter-escape menjaga pasangan tetap seimbang dan validator tetap membaca
// Yang injector tulis untuk MIMEType = 'text/plain'
//   sebelum v3.121.2:  /Type /EmbeddedFile /Subtype /text/plain     (dua nama)
//   v3.121.2:         /Type /EmbeddedFile /Subtype /text#2Fplain   (satu nama)
//
// Caller selalu mengirim nilai MIME biasa. Meng-escape-nya sendiri di muka
// mengenkode ganda '#', yang mengubah 'text#2Fplain' menjadi 'text#232Fplain'
Options.Files[0].MIMEType := 'text/plain';

Membangun berkas PDF/A-3 dengan InjectAssociateFiles

Untuk keluaran PDF/A-3, buat dokumen dasar yang patuh dengan TPdf.SaveAsPdfAToStream lalu panggil InjectAssociateFiles pada stream itu; pipeline dua langkah itulah persis yang dijalankan fixture validasi sebelum ia lolos PDF/A-3b. TPdf.SaveAsWithAssociateFiles adalah wrapper kenyamanan, tapi ia menyimpan lewat jalur SaveAs biasa dengan saRemoveSecurity alih-alih lewat writer PDF/A, jadi ia tidak menambah identifikasi XMP dan output intent yang disyaratkan PDF/A. Perhatikan bahwa tipe record-nya tinggal di FPdfAssocFiles dan FPdfPdfa, jadi kedua unit harus masuk klausa uses Anda. Sejak v3.121.3, FileName dan Description tidak lagi harus ASCII polos: /UF dan /Desc ditulis sebagai PDF text string, ASCII yang printable secara literal dan sisanya sebagai UTF-16BE dengan byte order mark, sementara nama /F legacy selalu ASCII printable portabel dengan setiap karakter lain diganti _, jadi reader yang meng-decode /F dengan code page-nya sendiri memperlihatkan underscore alih-alih mojibake. Build lebih lama mengonversi ketiganya lewat code page ANSI sistem di Delphi atau menulis byte UTF-8 mentah di Free Pascal, jadi pertahankan nama ASCII saja kalau build lama harus menghasilkan keluaran yang sama

uses
  System.SysUtils, System.Classes, System.IOUtils,
  PDFium, FPdfPdfa, FPdfAssocFiles;

procedure SaveWithSourceData(Pdf: TPdf; const XmlPath, OutPath: string);
var
  PdfAOptions: TPdfASaveOptions;
  Options: TAssocFilesOptions;
  Base: TMemoryStream;
  Output: TFileStream;
begin
  PdfAOptions := TPdfASaveOptions.Default;
  PdfAOptions.Conformance := pac3b;

  Options := TAssocFilesOptions.Default;      // TargetPage = 0: /AF level catalog
  SetLength(Options.Files, 1);
  Options.Files[0].FileName := 'invoice-data.xml';
  Options.Files[0].Description := 'Structured invoice data';
  Options.Files[0].Content := TFile.ReadAllBytes(XmlPath);
  Options.Files[0].Relationship := afData;
  Options.Files[0].MIMEType := 'application/xml';  // ditulis sebagai /application#2Fxml

  Base := TMemoryStream.Create;
  try
    if not Pdf.SaveAsPdfAToStream(Base, PdfAOptions) then
      raise Exception.Create('PDF/A-3 base save failed');
    Output := TFileStream.Create(OutPath, fmCreate);
    try
      InjectAssociateFiles(Base, Output, Options);  // meng-rewind Base; melempar EPdfAssocFilesError saat gagal
    finally
      Output.Free;
    end;
  finally
    Base.Free;
  end;
end;

Catalog atau halaman: array /AF mendarat di mana?

TAssocFilesOptions.TargetPage memutuskan pemilik array /AF: 0 menempelkannya ke catalog sebagai asosiasi level dokumen, dan 1..N menempelkannya ke dictionary halaman itu, berbasis 1. Injector menambahkan semuanya sebagai satu incremental update dalam layout tetap (embedded stream dulu, lalu file specification, lalu array /AF, lalu objek catalog atau halaman yang ditulis ulang), jadi objek yang sudah ada menjaga offset-nya dan tak ada yang dikompres ulang. Entry /AF lebih awal di dictionary target digantikan, bukan digabung, yang membuat save berulang bersifat idempotent tapi juga berarti panggilan kedua dengan daftar berkas berbeda yang menang. Dua perilaku dulu pantas dijaga di kode Anda sendiri, dan keduanya sudah berubah. Sebelum v3.122.0, TargetPage di luar jangkauan tidak gagal; ia jatuh kembali ke catalog, jadi satu typo mengubah asosiasi level halaman jadi level dokumen tanpa sinyal apa pun. Sejak v3.122.0, SaveAsWithAssociateFiles dan SaveAsWithAssociateFilesToStream melempar EPdfError saat TargetPage di luar 0..PageCount, dan InjectAssociateFiles melempar EPdfAssocFilesError yang baru untuk TargetPage negatif atau yang tak menyebut halaman yang ada, membiarkan stream tujuan tak tersentuh. Sebelum v3.121.4, pencarian halaman memindai byte tersimpan untuk dictionary /Type /Page dalam urutan berkas, yang bisa menempelkan berkas ke halaman berbeda begitu objek halaman tersimpan dalam urutan lain dari yang ditampilkan, misalnya setelah halaman diurut ulang atau disisipkan; sejak v3.121.4 TargetPage menyebut halaman pada posisi itu dalam urutan halaman dokumen

Ke mana array AF mendarat di PDFium Component: TargetPage nol menempelkannya ke catalog, halaman 1 sampai N menempelkannya ke dictionary halaman, dan nilai di luar jangkauan — yang sebelum v3.122.0 diam-diam jatuh kembali ke catalog — kini melempar exception, sementara injector menambahkan semuanya sebagai satu incremental update dalam layout tetap yang menjaga offset yang ada dan menggantikan entry AF lebih awal
Sebelum v3.122.0 TargetPage di luar jangkauan diam-diam menjadi asosiasi level dokumen; rilis kini melempar exception, dan panggilan kedua dengan daftar berkas berbeda tetap menang
procedure AttachChartSource(Pdf: TPdf; PageNumber: Integer;
  const CsvBytes: TBytes; const OutPath: string);
var
  Options: TAssocFilesOptions;
begin
  // Sejak v3.122.0 TargetPage di luar jangkauan melempar EPdfError (build lama
  // diam-diam jatuh ke /AF level catalog); memeriksa dulu menamai halamannya
  if (PageNumber < 1) or (PageNumber > Pdf.PageCount) then
    raise EArgumentOutOfRangeException.CreateFmt('No page %d', [PageNumber]);

  Options := TAssocFilesOptions.Default;
  Options.TargetPage := PageNumber;
  SetLength(Options.Files, 1);
  Options.Files[0].FileName := 'chart-data.csv';
  Options.Files[0].Content := CsvBytes;
  Options.Files[0].Relationship := afSource;
  Options.Files[0].MIMEType := 'text/csv';

  if not Pdf.SaveAsWithAssociateFiles(OutPath, Options) then
    raise Exception.Create('Associated-file save failed');
end;

Bagaimana membaca AFRelationship kembali secara andal?

TPdf.AttachmentRelationship[Index] mengembalikan nama /AFRelationship milik sebuah lampiran lewat export native FPDFAttachment_GetAFRelationship, tapi string kosong punya dua makna yang mungkin, jadi panggil AttachmentRelationshipFeaturesAvailable dulu. Binding-nya dimuat secara toleran: saat DLL PDFium tak memiliki export itu, semua relationship terbaca kosong, yang tak terbedakan dari file specification yang memang tak punya /AFRelationship. Properti ini juga berbagi indeksnya dengan AttachmentCount, yang menghitung entry di pohon /Names /EmbeddedFiles. Injector hanya menulis rantai /AF dan tidak menambah entry pohon nama, jadi berkas yang dilampirkan lewat InjectAssociateFiles berada di luar indeks itu; untuk memastikan rantai hasil injeksi, inspeksi byte tersimpannya atau jalankan validator PDF/A. Anatomi pohon nama itu dibahas di bekerja dengan lampiran PDF di Delphi memakai PDFium Component

procedure ReportRelationships(const FileName: string);
var
  Pdf: TPdf;
  I: Integer;
  Rel: string;
begin
  if not AttachmentRelationshipFeaturesAvailable then
  begin
    Writeln('This PDFium build cannot report /AFRelationship');
    Exit;  // jawaban kosong akan ambigu, jadi jangan tanya
  end;

  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;
    for I := 0 to Pdf.AttachmentCount - 1 do
    begin
      Rel := Pdf.AttachmentRelationship[I];
      if Rel = '' then
        Rel := '(no /AFRelationship)';
      Writeln(Pdf.AttachmentName[I], ': ', Rel);
    end;
  finally
    Pdf.Free;
  end;
end;

Apa yang tidak dijamin SaveAsWithAssociateFiles?

TPdf.SaveAsWithAssociateFiles menjamin amplop format berkas dan bahwa berkas yang diminta sudah terinjeksi, bukan kepatuhan. Bagian injeksinya itu yang baru: sebelum v3.122.0, saat byte tersimpan tak punya trailer yang terbaca atau dictionary catalog tak bisa ditemukan, InjectAssociateFiles menyalin input apa adanya dan metode itu tetap mengembalikan True. Sejak v3.122.0 InjectAssociateFiles melempar EPdfAssocFilesError di kasus-kasus itu sebelum menulis apa pun, SaveAsWithAssociateFiles mengembalikan False, dan karena kini ia membangun keluaran lengkap di save store sebelum membuka target, save yang ditolak atau gagal tak lagi memotong berkas yang sudah ada. Array Files kosong tetap menyalin dokumen apa adanya, memang disengaja. Isi payload-nya juga tanggung jawab Anda: injector tidak memeriksa bahwa file XML well formed, bahwa tipe MIME cocok dengan byte-nya, atau bahwa dokumen dasarnya PDF/A. Perlakukan berkas akhir sebagai tak terverifikasi sampai validator melihatnya, disiplin yang sama yang dijelaskan di PDFium Component dan kepatuhan arsip PDF/A. Kalau Anda juga meng-parse dictionary yang masuk sendiri, aturan nama #XX yang sama berlaku terbalik, topik yang dibahas di jebakan name token saat meng-parse dictionary PDF

Associated files, keluaran PDF/A, metadata lampiran, dan validasi semuanya dikirim di komponen yang sama, jadi pipeline di atas berjalan tanpa library PDF kedua di build. Referensi API, unduhan trial, dan opsi lisensi ada di halaman produk PDFium Component