Bài viết kỹ thuật

Associated file PDF/A-3 và AFRelationship trong Delphi

Để đính kèm một file nguồn vào một tài liệu PDF/A-3 từ Delphi, PDFium Component ghi một chuỗi associated-file kiểu PDF 2.0: một embedded file stream với MIME /Subtype, một file specification mang /AFRelationship, và một mảng /AF treo trên catalog hay một trang. InjectAssociateFiles và TPdf.SaveAsWithAssociateFiles dựng chuỗi đó trong đúng một incremental update, và từ v3.121.2, kiểu MIME được serialize thành một PDF name duy nhất, được escape đúng đắn. Phần còn lại của bài nói về những gì một validator kiểm tra, cái bug một ký tự từng làm gãy text/plain, và những chỗ mà các release cũ lặng lẽ làm một việc khác với những gì bạn yêu cầu

Một associated file PDF/A-3 thực chất cần gì?

Một attachment PDF/A-3 chỉ pass validation khi ba object thống nhất với nhau: embedded file stream khai báo /Type /EmbeddedFile cộng một MIME /Subtype, dictionary file specification (ISO 32000-2 §7.11.3) mang /F, /UF, /EF và /AFRelationship, và một thứ gì đó trong tài liệu tham chiếu file specification đó qua một mảng /AF (ISO 32000-2 §14.13). Nhúng thuần túy qua cây /Names /EmbeddedFiles — điều mà TPdf.CreateAttachment làm — chẳng bao giờ đặt bất kỳ field liên kết nào. Fixture validation PDF/A-3b của chính PDFium Component làm cho sự phụ thuộc trở nên cụ thể: đổi tên đúng mỗi khóa /AFRelationship thì file gãy đúng một luật trong điều 6.8 của ISO 19005-3; bỏ đúng mỗi MIME /Subtype thì một luật 6.8 khác gãy; nhét cùng attachment đó vào một ứng viên PDF/A-1b thì nó bị từ chối thẳng, vì PDF/A-1 cấm embedded file bất kể metadata gọn gàng đến đâu

Chuỗi ba object của một associated file PDF A-3 trong PDFium Component: một stream EmbeddedFile với MIME Subtype như application xml, một file specification với F, UF, EF và AFRelationship đặt là Data, và một mảng AF cho nó từ catalog hay một trang — ba object mà validator kiểm trước khi điều 6.8 của ISO 19005-3 pass
Stream, file specification và mảng AF phải thống nhất; cách nhúng name-tree trơn của TPdf.CreateAttachment chẳng đặt field liên kết nào và sẽ chẳng bao giờ

Giá trị relationship là phần mà người ta hay đoán mò. TPdfAFRelationship trong FPdfAssocFiles map một thành viên enum cho mỗi name token mà injector có thể emit, và chỉ năm cái đầu thuộc tập con mà ISO 19005-3 công nhận:

  • afSource → /Source: bản gốc mà PDF được sinh ra từ đó, như một file soạn thảo văn bản hay một bảng tính
  • afData → /Data: dữ liệu máy đọc được mà nội dung hiển thị được suy ra từ nó hay đại diện cho nó
  • afAlternative → /Alternative, afSupplement → /Supplement, afUnspecified → /Unspecified
  • afEncryptedPayload, afFormData, afTemplate: các bổ sung PDF 2.0 nằm ngoài tập con PDF/A-3, nên để chúng khỏi output lưu trữ

Vì sao /Subtype /text/plain làm gãy validation?

Bug MIME là một lỗi tokenize chứ không phải một khoảng hổng tuân thủ: trước v3.121.2, injector nối chuỗi của caller thẳng sau một dấu gạch chéo, sinh ra /Subtype /text/plain. Trong cú pháp PDF, dấu gạch chéo thứ hai mở đầu một name object mới (ISO 32000-1 §7.3.5), nên dictionary stream bỗng giữ khóa /Subtype, name /text, và một name thừa lơ lửng /plain làm mất cân bằng các cặp khóa-giá trị. Một validator PDF/A độc lập từ chối file ngay khi parse dictionary EmbeddedFile, trước cả khi nó kịp chạm một luật PDF/A nào, đó là lý do cái lỗi trông như file hỏng chứ không như một thuộc tính attachment bị thiếu

Bản sửa cho giá trị MIME đi qua EscapePdfName, thứ emit /text#2Fplain: một name duy nhất mà giá trị giải mã là text/plain. Phép escape được cố ý mở rộng hơn dấu gạch chéo. Mọi byte từ 32 trở xuống (space, tab, CR, LF), mọi byte từ 127 trở lên, các delimiter ()<>[]{}/% và chính ký tự escape # đều trở thành #XX. Chỉ escape dấu gạch chéo sẽ để lại một lỗ hổng khác: một chuỗi MIME chứa >> hay khoảng trắng có thể đóng dictionary sớm hay tiêm các khóa thừa, nên test regression nhét một giá trị thù địch với đủ mọi delimiter cộng tab, LF và CR rồi kiểm tra output mã hóa chính xác từng byte

Vì sao MIME subtype text slash plain làm gãy việc parse PDF A-3 trong PDFium Component: nối giá trị sau một dấu gạch chéo sinh ra hai name object, /text làm giá trị cộng một /plain lơ lửng làm mất cân bằng dictionary EmbeddedFile, và bản sửa v3.121.2 cho giá trị đi qua EscapePdfName nên /text#2Fplain là một name duy nhất giải mã thành text/plain
Cái lỗi trông như file hỏng vì nó xảy ra ngay tại parser, trước mọi luật PDF/A; name đã escape giữ các cặp cân bằng và validator tiếp tục đọc
// Thứ injector ghi cho MIMEType = 'text/plain'
//   trước v3.121.2:  /Type /EmbeddedFile /Subtype /text/plain     (hai name)
//   v3.121.2:         /Type /EmbeddedFile /Subtype /text#2Fplain   (một name)
//
// Caller luôn truyền giá trị MIME thường. Tự escape trước thì
// mã hóa kép '#', biến 'text#2Fplain' thành 'text#232Fplain'
Options.Files[0].MIMEType := 'text/plain';

Dựng một file PDF/A-3 với InjectAssociateFiles

Với output PDF/A-3, hãy sinh tài liệu gốc tuân thủ bằng TPdf.SaveAsPdfAToStream rồi gọi InjectAssociateFiles trên stream đó; pipeline hai bước đó chính xác là những gì fixture validation chạy trước khi nó pass PDF/A-3b. TPdf.SaveAsWithAssociateFiles là wrapper tiện lợi, nhưng nó lưu qua đường SaveAs thường với saRemoveSecurity chứ không qua PDF/A writer, nên nó không thêm XMP identification và output intent mà PDF/A đòi hỏi. Chú ý các kiểu record nằm trong FPdfAssocFiles và FPdfPdfa, nên cả hai unit đều phải nằm trong uses clause của bạn. Từ v3.121.3, FileName và Description không còn buộc phải là ASCII trơn: /UF và /Desc được ghi thành PDF text string, ASCII in được giữ nguyên và bất cứ thứ gì khác thành UTF-16BE kèm byte order mark, trong khi name /F legacy luôn là ASCII in được di động với mọi ký tự khác thay bằng _, nên các reader decode /F bằng code page của riêng họ thấy một dấu gạch dưới thay vì mojibake. Các build trước chuyển cả ba qua system ANSI code page trên Delphi hay ghi các byte UTF-8 thô trên Free Pascal, nên chỉ giữ tên ASCII khi các build cũ buộc phải sinh ra cùng output

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 cấp tài liệu
  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';  // ghi thành /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);  // rewinds Base; raise EPdfAssocFilesError khi gãy
    finally
      Output.Free;
    end;
  finally
    Base.Free;
  end;
end;

Catalog hay trang: mảng /AF đáp xuống đâu?

TAssocFilesOptions.TargetPage quyết định chủ nhân của mảng /AF: 0 gắn nó vào catalog như một liên kết cấp tài liệu, còn 1..N gắn nó vào dictionary trang đó, đánh số từ 1. Injector append tất cả như một incremental update duy nhất trong một bố cục cố định (các embedded stream, rồi các file specification, rồi mảng /AF, rồi một catalog hay page object được viết lại), nên các object hiện có giữ nguyên offset và chẳng gì bị nén lại. Bất kỳ entry /AF nào có trước trên dictionary đích đều bị thay thế chứ không bị gộp, điều khiến một lần save lặp lại trở nên idempotent nhưng cũng có nghĩa là một lần gọi thứ hai với một danh sách file khác sẽ thắng. Hai hành vi từng xứng đáng có một phép canh trong code của bạn, và cả hai đã đổi. Trước v3.122.0, một TargetPage ngoài miền không gãy; nó rơi về catalog, nên một lỗi gõ phím biến một liên kết cấp trang thành cấp tài liệu mà chẳng có tín hiệu nào. Từ v3.122.0, SaveAsWithAssociateFiles và SaveAsWithAssociateFilesToStream raise EPdfError khi TargetPage nằm ngoài 0..PageCount, và InjectAssociateFiles raise EPdfAssocFilesError mới cho một TargetPage âm hay một giá trị chẳng gọi tên trang hiện có nào, để nguyên stream đích. Trước v3.121.4, việc tra trang quét các byte đã lưu tìm các dictionary /Type /Page theo thứ tự file, thứ có thể gắn file vào một trang khác một khi các page object được lưu theo thứ tự khác với thứ tự hiển thị, ví dụ sau khi các trang bị sắp lại hay được chèn thêm; từ v3.121.4, TargetPage gọi tên trang tại vị trí đó trong thứ tự trang của tài liệu

Mảng AF đáp xuống đâu trong PDFium Component: TargetPage 0 gắn nó vào catalog, các trang 1 tới N gắn nó vào dictionary trang, và một giá trị ngoài miền — thứ trước v3.122.0 lặng lẽ rơi về catalog — giờ raise exception, trong khi injector append tất cả như một incremental update duy nhất trong bố cục cố định giữ nguyên các offset hiện có và thay mọi entry AF có trước
Trước v3.122.0, một TargetPage ngoài miền lặng lẽ thành một liên kết cấp tài liệu; các release hiện tại raise thay vào đó, và một lần gọi thứ hai với danh sách file khác vẫn thắng
procedure AttachChartSource(Pdf: TPdf; PageNumber: Integer;
  const CsvBytes: TBytes; const OutPath: string);
var
  Options: TAssocFilesOptions;
begin
  // Từ v3.122.0, một TargetPage ngoài miền raise EPdfError (các build cũ
  // lặng lẽ rơi về /AF cấp tài liệu); kiểm trước để gọi đúng tên trang
  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;

Đọc AFRelationship ngược lại một cách đáng tin thế nào?

TPdf.AttachmentRelationship[Index] trả về name /AFRelationship của một attachment qua export native FPDFAttachment_GetAFRelationship, nhưng một chuỗi rỗng có hai nghĩa khả dĩ, nên hãy gọi AttachmentRelationshipFeaturesAvailable trước. Binding được nạp một cách khoan dung: khi DLL PDFium thiếu export đó, mọi relationship đọc về rỗng, điều không thể phân biệt với một file specification đơn giản là không có /AFRelationship. Property còn dùng chung chỉ mục với AttachmentCount, thứ đếm các entry trong cây /Names /EmbeddedFiles. Injector chỉ ghi chuỗi /AF và không thêm entry name-tree nào, nên một file gắn qua InjectAssociateFiles nằm ngoài chỉ mục đó; để xác nhận chuỗi đã inject, hãy soát các byte đã lưu hay chạy một validator PDF/A. Nội thất của cây name đó được trình bày trong làm việc với PDF attachment trong Delphi bằng 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;  // một câu trả lời rỗng sẽ mơ hồ, nên đừng hỏi
  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;

SaveAsWithAssociateFiles không đảm bảo điều gì?

TPdf.SaveAsWithAssociateFiles đảm bảo cái vỏ định dạng file và rằng các file được yêu cầu đã được inject, chứ không đảm bảo tuân thủ. Phần inject là cái mới: trước v3.122.0, khi các byte đã lưu chẳng có trailer đọc được hay không định vị được dictionary catalog, InjectAssociateFiles copy đầu vào đi qua nguyên trạng và method vẫn trả True. Từ v3.122.0, InjectAssociateFiles raise EPdfAssocFilesError trong các ca đó trước khi ghi bất cứ thứ gì, SaveAsWithAssociateFiles trả False, và vì giờ nó dựng output hoàn chỉnh trong một save store trước khi mở đích, một lần save bị từ chối hay gãy không còn cắt cụt một file hiện có. Một mảng Files rỗng vẫn copy tài liệu đi qua nguyên trạng một cách chủ ý. Nội dung của payload cũng là trách nhiệm của bạn: injector chẳng kiểm tra một file XML có well-formed hay không, kiểu MIME có khớp các byte hay không, hay tài liệu gốc có đúng là PDF/A hay không. Hãy coi file cuối là chưa được xác minh cho tới khi một validator kịp nhìn nó, đúng kỷ luật đã mô tả trong PDFium Component và tuân thủ lưu trữ PDF/A. Nếu bạn còn tự parse các dictionary đi vào, cùng các luật name #XX áp dụng theo chiều ngược lại, chủ đề đã trình bày trong các bẫy name token khi parse PDF dictionary

Associated file, output PDF/A, metadata attachment và validation đều nằm trong cùng một component, nên pipeline ở trên chạy mà chẳng cần một thư viện PDF thứ hai trong bản build. Tài liệu tham khảo API, tải bản dùng thử và các tùy chọn licensing nằm trên trang sản phẩm PDFium Component