Cổng tiếp nhận tài liệu đã từ chối một lô tệp "PDF/A-2b" dù chúng mở bình thường trong mọi trình xem trên bàn làm việc. Nhà cung cấp khẳng định chúng đạt chuẩn. Thực tế thì không: mỗi tệp đều mang một hành động JavaScript bị giấu trong catalog, kiểu thứ mà nhìn lướt bằng mắt thường không bao giờ thấy, còn bộ kiểm tra PDF/A đầy đủ như veraPDF sẽ gắn cờ ngay lập tức. Vấn đề là không ai muốn ghép cả một toolchain Java vào một dịch vụ batch Delphi chỉ để trả lời một câu hỏi có hoặc không cho từng tệp. Khoảng trống đó là thứ ValidatePdfACompliance trong PDFium Component lấp đầy, và đáng để hiểu cách nó đưa ra kết luận mà không bao giờ phải phân tích trọn vẹn một content stream.
Vì sao chính PDFium không thể trả lời câu hỏi này
Điều đầu tiên cần nói rõ là pdfium.dll đi kèm hoàn toàn không có khả năng PDF/A. Không có ConvertToPDFA, không có bộ ghi OutputIntent, không có API XMP trong bề mặt công khai. Mọi phần PDF/A trong thư viện này, cả phía ghi lẫn phía kiểm tra, đều nằm thuần Pascal trong FPdfPdfa.pas và hoạt động bằng phân tích ở mức byte cộng với incremental update. Vì vậy khi gọi bộ kiểm tra, bạn không hỏi gì renderer của Chromium cả. Bạn đang chạy một bộ quét token Pascal trên các byte cấu trúc của tệp.
API công khai được cố tình giữ rất nhỏ. Một hàm đọc stream từ vị trí 0 và trả về một 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 mã hóa quy tắc quan trọng ở cổng kiểm tra: một tệp chỉ đạt khi phát hiện được một mức conformance thực và tập issue rỗng. Một lần parse thành công nhưng không thấy marker pdfaid sẽ trả về pacNone, và đó rõ ràng không phải là đạt. Đây cũng là điều mà CLI báo cáo preflight theo lô nói từ bên ngoài: danh sách kết quả trống trên một tệp không được nhận diện không phải là giấy chứng nhận sạch.
Loại bỏ thân stream trước mọi lần quét token
Đây là chi tiết triển khai quan trọng nhất, và cũng là chỗ dễ làm sai nhất nếu bạn tự viết bộ quét. Bộ phát hiện tìm vi phạm bằng cách tìm các name token có ranh giới rõ ràng, chẳng hạn /JavaScript, /LZWDecode, /BM. Nếu quét trực tiếp byte thô của tệp, phần thân stream nhị phân được nhúng, ảnh đã nén, ICC profile, chương trình phông chữ, sẽ ngẫu nhiên chứa những dãy byte trông giống token đó. Bạn sẽ báo rằng /AA hoặc /3D đã được "tìm thấy" chỉ vì ba byte trong một JPEG tình cờ ghép thành chuỗi đó. Đó là nhà máy tạo false positive.
Cách sửa là PdfStructureBytes: nó đi qua tệp và thay toàn bộ byte nằm giữa mỗi từ khóa stream và endstream bằng khoảng trắng, nhưng vẫn giữ nguyên cấu trúc dictionary. Chỉ sau đó mới chạy quét. Mọi phép kiểm tra name token trong bộ xác thực đều làm việc trên bản sao đã được làm sạch này. Nếu bạn chỉ rút ra một ý từ bài viết này, thì hãy rút ra ý đó. Kỷ luật tương tự cũng xuất hiện trong bộ kiểm tra PDF/UA, vốn giữ một bản sao riêng của cùng thủ tục vì hai tiêu chuẩn tiến hóa độc lập với nhau.
29 lỗi và ý nghĩa của từng lỗi
TPdfAValidationIssue là một hợp đồng đã được tài liệu hóa. Các ordinal được cố định vì test DUnitX, bản demo và tầng báo cáo đều phụ thuộc vào chúng, nên các phát hiện mới chỉ được nối thêm ở cuối. Tính đến v1.63.0 có 29 thành viên. Chúng chia thành vài nhóm:
- Metadata và danh tính:
pvaiMissingXmpMetadata,pvaiMissingPdfAIdentifier,pvaiMissingTrailerId(ISO 19005-1 6.1.3),pvaiMissingXmpDates. - Màu sắc và đầu ra:
pvaiMissingOutputIntent,pvaiMissingIccProfile, vàpvaiMixedDeviceColorSpaceskhi cả DeviceRGB lẫn DeviceCMYK đều xuất hiện (6.2.3.3). - Cấm tuyệt đối ở mọi part:
pvaiEncryptionPresent(dictionary/Encryptbị cấm hoàn toàn),pvaiJavaScriptPresent,pvaiForbiddenAction,pvaiAdditionalActions,pvaiLzwUsed,pvaiXfaPresent,pvaiNeedAppearancesTrue,pvaiForbiddenAnnotation. - Phông chữ:
pvaiFontNotEmbeddedvà biến thể nghiêm hơnpvaiUnembeddedFont, cùngpvaiUnicodeMappingMissingcho một yêu cầu Level U nhưng thiếu/ToUnicode. - Gắn thẻ:
pvaiLevelAStructureMissingkhi một yêu cầu conformance=A không có cấu trúc được gắn thẻ.
Sáu thành viên mới nhất, được thêm ở các ordinal 24 đến 29, bao phủ những trường hợp tinh vi mà reviewer thực sự hay vấp phải: pvaiTrappedTrue (một /Trapped /True trong Info dictionary, một "false friend" vì giá trị phải là False hoặc Unknown), pvaiForbiddenActionSubtype (Sound hoặc Movie được dùng như một action, không chỉ là annotation), pvaiTransparentColorSpace (blend mode không phải Normal hoặc /CA//ca khác 1.0), pvaiAnnotationDictViolation, pvaiUnembeddedFont, và pvaiMixedDeviceColorSpaces.
Cơ chế chặn theo part: A-1 nghiêm ngặt, A-2 và A-3 nới lỏng
PDF/A không phải chỉ có một bộ quy tắc. Ba điều mà PDF/A-1 cấm thì từ PDF/A-2 trở đi lại được phép rõ ràng: transparency (một group /Transparency hoặc /SMask đang hoạt động, 6.4), optional content (/OCProperties, 6.1.13), và embedded file (/EmbeddedFiles hoặc /EF, 6.1.11). Một bộ kiểm tra ngây thơ mà đánh dấu cả ba cho mọi tệp sẽ từ chối hàng loạt những tài liệu PDF/A-2 hoàn toàn hợp lệ.
Vì vậy bộ kiểm tra đọc số part từ marker pdfaid thông qua PdfAPartOf và đưa các kiểm tra đó ra sau điều kiện PartNo = 1. Các kiểm tra blend mode và annotation alpha cho các vấn đề transparency mới cũng chỉ áp dụng cho 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;
Một mặc định thận trọng cũng đáng nhắc tới: khi không có marker pdfaid nào cả, part sẽ được xem là 1, tức mức nghiêm nhất. Lý do là một tệp không xác định nên bị áp dụng bộ quy tắc chặt nhất thay vì được cho qua. JavaScript, các action bị cấm, LZW, XFA, NeedAppearances, annotation bị cấm, và phông chữ chưa nhúng đều vẫn bị cấm ở mọi part, nên các kiểm tra đó không bao giờ nằm sau cổng chặn.
Mở rộng object stream để không gì bị che giấu
PDF 1.5 đã giới thiệu cross-reference stream và object stream (/Type /ObjStm), và chúng tạo ra một điểm mù cho bộ quét byte ngây thơ. Một catalog, một OutputIntent, một action dictionary, hay bất kỳ thứ gì bản thân nó không phải là stream, đều có thể bị nén Flate bên trong một ObjStm. Quét cấu trúc thô và bạn sẽ không thấy gì cả, rồi lại báo rằng một tệp sạch trong khi thực tế không hề sạch.
PdfExpandObjectStreams khép lại khoảng trống đó. Trước khi chạy bất kỳ kiểm tra nào, bộ xác thực thực hiện Data := PdfExpandObjectStreams(Data). Thủ tục này tìm mọi ObjStm, đọc phần header /N và /First để lấy số object và offset được chứa bên trong, giải nén phần thân bằng PdfInflate (RTL zlib, System.ZLib trên Delphi và zstream trên FPC), rồi nối từng object bên trong thành một N 0 obj ... endobj thông thường ở cuối bản sao của byte. Các phép kiểm tra token hiện có sau đó sẽ tìm thấy những object này mà không cần thay đổi logic.
Hai ràng buộc giúp cách này sạch thay vì mong manh. Các object stream không thể chứa stream object, Metadata, ICC profile hay chương trình phông chữ, chỉ các dictionary không phải stream mới có thể nằm ở đó, nên bước mở rộng chỉ xử lý dictionary và các object được nối thêm không mang từ khóa stream để làm nhiễu bước loại bỏ thân stream. Và vì phần nội dung được nối thêm nằm sau %%EOF, phép tìm ngược từ startxref vẫn thấy được trailer gốc. Bản thân trailer của cross-reference stream đã được xử lý từ trước, ở v1.49.3, bằng cách đọc Root, Size và ID trực tiếp từ dictionary xref-stream dạng văn bản thuần, một chủ đề được bàn trong bài đồng hành về xác thực object stream và cross-reference stream; phần việc của object-stream chỉ cần thêm bước inflate, không cần giải mã các mục xref type-2 hay gỡ predictor PNG.
Giới hạn trung thực của bộ kiểm tra ở mức byte
Đây là một công cụ preflight, không phải bộ xác thực được chứng nhận, và các giới hạn đó là có thật. Nhúng phông chữ là một heuristic đếm, và để làm đúng đã cần một chỉnh sửa đáng nói. Phép kiểm tra ban đầu dùng PdfCountName('/FontDescriptor'), nhưng mỗi font lại đóng góp hai token /FontDescriptor, một lần tham chiếu từ font dictionary và một /Type trong chính descriptor object, nên kết quả đếm là 2N trong khi số chương trình được nhúng là N và bài kiểm tra luôn trả về đúng. Bản sửa là PdfCountDescriptorRefs, chỉ đếm dạng tham chiếu /FontDescriptor N G R, một lần cho mỗi font, và chỉ gắn cờ pvaiUnembeddedFont khi số chương trình được nhúng thực sự ít hơn:
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);
Dù đã được sửa, cách này vẫn khá thô: một tài liệu pha trộn mà mọi descriptor tình cờ đều có một FontFile nào đó vẫn có thể để lọt một font riêng lẻ không đạt chuẩn. Việc mở rộng object stream cũng có một tác dụng phụ đã biết là nó làm lộ các tài nguyên mặc định chuẩn 14 mà một AcroForm /DR mang theo, chẳng hạn /Helv, và heuristic sẽ báo chúng là chưa nhúng dù veraPDF vẫn cho qua vì chúng thực ra không bao giờ được dùng để render. Các kiểm tra ở mức operator của content stream (6.2.10) thì hoàn toàn nằm ngoài phạm vi, vì chúng cần phân tích đầy đủ content thay vì quét byte. Hãy xem bộ kiểm tra này như một cổng đầu tiên nhanh, không phụ thuộc gì thêm, có thể bắt những vi phạm mà việc chèn marker không sửa được, và để bộ xác thực đầy đủ cho giai đoạn chứng nhận cuối cùng.
Đó là nửa kiểm tra của câu chuyện. Phía ghi đối ứng, nơi SaveAsPdfA chèn XMP, OutputIntent và profile ICC sRGB, đồng thời hạ trung thực một yêu cầu Level A không có cấu trúc được gắn thẻ, dựa trên cùng một bộ máy mức byte. Cả hai nửa đều được phát hành trong PDFium Component for Delphi, một gói VCL duy nhất trên nền triển khai PDF/A thuần Pascal, không cần cài runtime bên ngoài.