Bài viết kỹ thuật

Tuân thủ PDF/A lưu trữ trong Delphi với PDFium VCL

Bạn phát hành một bộ chuyển đổi gắn PDF/A-1b cho mọi tệp, hệ thống lưu trữ của khách hàng nhận chúng suốt một năm, rồi đến lúc kiểm toán chạy toàn bộ lô qua veraPDF thì một phần ba bị trả về không tuân thủ. Không có gì sập, không có ngoại lệ nào được ném ra, tệp mở bình thường trong mọi trình xem trên máy của bạn. Chúng chỉ đơn giản không phải tiêu chuẩn mà bạn đã gắn nhãn. Đây là kiểu lỗi rất điển hình của PDF lưu trữ, và vì sao câu “chúng tôi đã đặt cờ” không bao giờ đồng nghĩa với “nó xác thực được”

Điều đầu tiên cần hiểu về PDFium và PDF/A là engine không liên quan gì ở đây. PDFium hiển thị, phân tích và ghi PDF, nhưng bề mặt công khai của nó không có ConvertToPDFA, không có bộ ghi OutputIntent, cũng không có API XMP. Mọi phần của tuân thủ lưu trữ, gói XMP, OutputIntent và ICC profile của nó, các cờ ở catalog, và khâu xác thực, đều nằm trong chính PDFiumPas, trong một unit Pascal thuần gần 2.000 dòng (FPdfPdfa.pas) đọc lại byte đã lưu rồi ghi đè chúng bằng một bản cập nhật gia tăng. Biết phần việc diễn ra ở đâu sẽ cho bạn biết lỗi ẩn ở đâu, và chúng không ẩn trong PDFium

PDF/A thực sự đòi hỏi gì, và vướng ở đâu

PDF/A không phải một định dạng duy nhất. ISO 19005 định nghĩa ba phần (PDF/A-1, -2, -3) và, trong mỗi phần, các mức tuân thủ hứa hẹn những điều khác nhau. Mức B (basic) chỉ bảo đảm rằng hình thức hiển thị có thể tái tạo. Mức A (accessible) bổ sung cây cấu trúc có gắn thẻ và ánh xạ Unicode lên trên B. Mức U, chỉ có ở phần 2 và 3, nằm giữa hai mức đó: văn bản Unicode đáng tin cậy nhưng không cần toàn bộ cây cấu trúc. ISO 19005-1 không có Level U, và thư viện mã hóa đúng ràng buộc này

Một số quy tắc của định dạng là những thứ vấp ngã nhiều nhất khi dùng thực tế. Mã hóa bị cấm tuyệt đối (ISO 19005-1 §6.1.3 và các bản sau): tệp PDF/A không được mang từ điển /Encrypt. Tài liệu phải khai báo điều kiện dựng đầu ra thông qua một OutputIntent có đích là một ICC profile hợp lệ (§6.2.3.2). Bản thân lời tuyên bố tuân thủ phải xuất hiện dưới dạng siêu dữ liệu XMP theo lược đồ nhận dạng PDF/A. Mức A còn đòi hỏi cấu trúc logic ở §6.8, tức cây thẻ giúp tài liệu có thể đọc máy. Chỉ cần thiếu một trong các phần này là trình kiểm tra tuân thủ sẽ từ chối tệp, dù nó hiển thị hoàn hảo

Một lời gọi duy nhất tạo ra kho lưu trữ

PDFiumPas phơi bày toàn bộ pipeline phía sau TPdf.SaveAsPdfA. Overload đơn giản lấy một mức tuân thủ đích và mặc định là PDF/A-1b, đây là mặc định đúng cho trường hợp phổ biến “làm cho tài liệu này có thể hiển thị mãi”

var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.LoadFromFile('invoice.pdf');
    // Default conformance is pac1b (PDF/A-1b)
    if Pdf.SaveAsPdfA('invoice_archive.pdf') then
      // file now carries XMP, sRGB OutputIntent, and catalog markers
    else
      raise Exception.Create('PDF/A save failed');
  finally
    Pdf.Free;
  end;
end;

Bên trong, đây là một thao tác hai giai đoạn. SaveAsPdfA trước tiên yêu cầu PDFium tuần tự hóa tài liệu bằng FPDF_SaveAsCopy, rồi chuyển luồng byte đó cho InjectPdfAMarkers, hàm sẽ nối thêm siêu dữ liệu XMP, OutputIntent sRGB cùng ICC profile nhúng của nó, và một catalog đã được ghi lại dưới dạng cập nhật gia tăng. Nguồn được đọc từ vị trí zero và đích được ghi từ vị trí zero; cây đối tượng gốc được giữ nguyên và các marker đi vào sau %%EOF hiện có. Nếu bạn cần byte thay vì tệp, SaveAsPdfAToStream nhận một TStream và cùng bộ tùy chọn

Chọn mức tuân thủ bằng record tùy chọn

Để nhắm tới một phần và mức cụ thể, hãy truyền record TPdfASaveOptions. Trường Conformance của nó nhận một giá trị TPdfAConformance. Enumeration bao phủ mọi tổ hợp hợp lệ và không có gì khác: pac1b, pac1a cho phần 1; pac2b, pac2u, pac2a cho phần 2; pac3b, pac3u, pac3a cho phần 3, cộng thêm pacUnknownpacNone cho phía xác thực. Không có pac1u, vì mức đó không tồn tại trong tiêu chuẩn

var
  Pdf: TPdf;
  Opts: TPdfASaveOptions;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.LoadFromFile('report.pdf');
    Opts := TPdfASaveOptions.Default;
    Opts.Conformance := pac2u;           // PDF/A-2u: reliable Unicode text
    Opts.Title := 'Quarterly Report 2026';
    Opts.Author := 'Finance';
    // Leave IccProfileData empty to use the built-in sRGB IEC61966-2.1 profile
    if not Pdf.SaveAsPdfA('report_a2u.pdf', Opts) then
      raise Exception.Create('PDF/A-2u save failed');
  finally
    Pdf.Free;
  end;
end;

Phần lớn record có thể để trống. Để trống Title, Author, Subject, Keywords, Creator, và Producer thì SaveAsPdfA sẽ tự điền từ từ điển Info của tài liệu thông qua FPDF_GetMetaText. Để trống CreationDateModDate thì nó dùng thời gian UTC hiện tại cho cả hai ngày XMP. Để trống DocumentIdInstanceId thì thư viện sẽ tự khởi tạo chúng từ FPDF_GetFileIdentifier, và nếu không có thì rơi về một ID xác định được dẫn xuất từ byte nguồn. Trường duy nhất bạn có thể muốn ghi đè có chủ đích là IccProfileData: để trống nghĩa là dùng sẵn profile sRGB IEC61966-2.1 đi kèm, còn quy trình CMYK hoặc grayscale nên cung cấp profile riêng của nó

Vì sao mức A bị hạ xuống, và vì sao đó là lựa chọn trung thực

Đây là một chi tiết nhỏ rất dễ làm người ta vấp nếu nghĩ rằng một lá cờ là lời bảo đảm. Bạn có thể yêu cầu pac1a cho một tài liệu không có cây thẻ, nhưng PDF/A-1a đòi hỏi cấu trúc logic ở §6.8, và thư viện không thể tự dựng cây cấu trúc từ một PDF không gắn thẻ. Thay vì xuất ra một tệp tự nhận là Level A nhưng thực tế không đạt, SaveAsPdfA sẽ kiểm tra xem có cấu trúc gắn thẻ thực sự hay không (/StructTreeRoot cộng /MarkInfo với /Marked true) và, nếu không có, hạ lời tuyên bố xuống: pac1a thành pac1b, pac2a thành pac2b, và tương tự cho cả ba phần. Các trợ giúp nội bộ là PdfAIsLevelAPdfADowngradeToLevelB

Lý do rất đáng nói thẳng: một tệp trung thực khai báo đúng mức nó đạt được hữu ích hơn một tệp nói dối về mức nó không đạt. Mức U được xử lý khác. Việc phát hiện phủ Unicode thật sự sẽ đòi một phép thử ngây thơ kiểu “có /ToUnicode không” và như vậy sẽ hạ cấp quá mức những tài liệu hợp lệ (WinAnsi và các mã hóa tương tự được miễn), nên phía lưu giữ nguyên lời tuyên bố U như người gọi đã khai báo và để phía xác thực báo chênh lệch sau. Nếu bạn cần một kho lưu trữ Level A bảo đảm, hãy gắn thẻ tài liệu trước khi chuyển đổi; bộ chuyển đổi sẽ không tự bịa ra cấu trúc không tồn tại

Cái bẫy ICC mà chỉ trình kiểm tra thực sự mới bắt được

Đây là lỗi đã dạy bài học nặng nhất, vì bộ kiểm tra nội bộ của thư viện đã qua nhưng veraPDF, trình kiểm tra tham chiếu của ISO 19005, thì không. PDF/A yêu cầu profile đích của OutputIntent phải là một luồng ICCBased hợp lệ, và §6.2.3.2 bắt trình xác thực kiểm tra luồng đó như một không gian màu. Một luồng ICCBased phải khai báo /N, tức số thành phần màu. Một phiên bản sớm của bộ chèn đã ghi từ điển luồng ICC chỉ với /Length mà không có /N, và veraPDF từ chối kết quả với thông báo “The N entry (value null)... is missing”

Điểm oái oăm là việc từ chối chỉ xuất hiện với PDF/A-1b và -1a. Mô hình tuân thủ của phần 2 và phần 3 không chạy riêng phép kiểm này trên profile đích, nên cùng một cấu trúc được chèn như nhau vẫn xác thực được dưới pac2b, pac3bpac2u, nhưng lại thất bại dưới pac1b chỉ vì giá trị pdfaid:part. Một unit test sẽ không bao giờ nhìn thấy chuyện đó, vì ValidatePdfACompliance nội bộ chỉ kiểm tra rằng khóa /DestOutputProfile tồn tại, chứ không xem bên trong từ điển luồng có gì. Kiểm thử nội bộ vẫn xanh; xác thực lưu trữ thực sự lại thất bại

Bản sửa là IccComponentCount, hàm đọc signature không gian màu dữ liệu ở offset 16 của ICC header rồi ánh xạ nó thành số thành phần: GRAY là 1, RGB , Lab , và XYZ là 3, CMYK là 4, còn profile không xác định thì mặc định 3. Số đó được ghi vào từ điển luồng dưới dạng /N. Nó được tính ra, không bị hard-code là 3, để người gọi cung cấp profile CMYK hoặc grayscale qua IccProfileData vẫn nhận đúng giá trị. Bài học rộng hơn là về phương pháp: bộ kiểm tra bên trong thư viện và một trình xác thực có thẩm quyền đều có điểm mù riêng, và đầu ra PDF/A phải được kiểm thử end to end bằng một implementation tham chiếu như veraPDF thay vì tin vào các self-check. Cùng kỷ luật cập nhật gia tăng đứng sau các kho lưu trữ sạch sẽ cũng được nói trong kiểm tra các luồng object và xref nén, điều này quan trọng vì PDF hiện đại mà bộ chèn xử lý thường được xây trên cross-reference streams

Mã hóa, xref stream và các góc cạnh khác

Vì ISO 19005 cấm mã hóa, đường lưu sẽ gỡ nó trước khi ghi. SaveAsPdfA áp dụng FPDF_REMOVE_SECURITY khi tuần tự hóa, nên nguồn đã mã hóa (được nạp kèm mật khẩu) sẽ được giải mã trên đường đi vào kho lưu trữ. Với tài liệu không mã hóa, đây là một thao tác không làm gì cả và không thay đổi gì. Hệ quả tương tự là ràng buộc HotPDF cũng áp từ hướng ngược lại: một tệp không thể vừa mã hóa vừa là PDF/A. Khi một quy trình cần cả hai, câu trả lời là hai artefact, một bản mã hóa để phân phối và một bản sạch riêng cho lưu trữ

Một góc nữa chỉ lộ ra khi nó gây lỗi: các tài liệu PDF 1.5+ dùng một cross-reference stream thuần và không có từ khóa trailer. Bộ chèn đọc trailer để tìm /Info nguồn và nối phần cập nhật gia tăng của nó, và nó phải chấp nhận dạng xref-stream, nếu không tài liệu như vậy sẽ bị sao chép qua mà các marker âm thầm bị bỏ rơi. ISO 32000-1 §7.5.6 cho phép rõ ràng một bản cập nhật gia tăng dạng trailer cổ điển đi sau tài liệu xref-stream, với /Prev trỏ tới offset của xref-stream, đúng chính là cấu trúc mà bộ chèn phát ra. Bản FPDF_SaveAsCopy của PDFium luôn ghi một trailer cổ điển, nên trong pipeline bình thường bộ chèn không gặp nguồn xref-stream thuần, nhưng đường đọc vẫn xử lý được các tài liệu đi từ nơi khác tới

Xác minh trước khi tin vào tuyên bố

Thư viện đi kèm một bộ kiểm tra cấp byte, TPdf.ValidatePdfA, trả về TPdfAValidationResult. Trường Conformance của nó báo mức đã phát hiện và Issues là một tập các giá trị TPdfAValidationIssue; phương thức tiện ích IsCompliant chỉ là true khi đã phát hiện ra một mức thật và tập vấn đề rỗng. Hãy chạy nó như một cửa ải đầu tiên nhanh trong một batch

var
  Pdf: TPdf;
  Res: TPdfAValidationResult;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.LoadFromFile('invoice_archive.pdf');
    Res := Pdf.ValidatePdfA;
    if Res.IsCompliant then
      Writeln('Conformant: detected level ', Ord(Res.Conformance))
    else
      Writeln('Issues found: ', SizeOf(Res.Issues), ' flags set');
  finally
    Pdf.Free;
  end;
end;

Hãy trung thực về thứ nó mang lại. Bộ kiểm tra cấp byte bắt được các vấn đề cấu trúc (thiếu OutputIntent, hành động bị cấm, có /Encrypt, transparency ở nơi phần 1 cấm) với độ tin cậy cao, và phần phát hiện font embedding dùng một heuristic đếm chỉ cố báo tín hiệu có độ tin cậy cao thay vì săn theo mức bao phủ từng glyph. Nó không làm phân tích toán tử của content stream, việc đó đòi một content parser đầy đủ và theo thiết kế là ngoài phạm vi. Với một cổng phát hành, hãy ghép bộ kiểm tra trong thư viện với veraPDF: bộ kiểm tra cho kết quả ngay lập tức và chạy ở mọi nơi không cần DLL, veraPDF là thẩm quyền. Cách nối cặp này vào một batch run là chủ đề của CLI báo cáo tiền kiểm theo lô, và đó mới là nơi việc xác thực này thuộc về trong một quy trình lưu trữ thực sự

Các API SaveAsPdfA, InjectPdfAMarkersValidatePdfA được minh họa ở đây đi kèm PDFium Component cho Delphi, C++Builder và Lazarus/FPC. Trang sản phẩm liên kết toàn bộ tài liệu tham chiếu API, bao gồm enumeration tuân thủ đầy đủ và record tùy chọn đứng sau các ví dụ này