Technical Article

Xác thực các tệp PDF nén: Luồng đối tượng và XRef

Bạn viết một trình xác thực nhỏ. Nó mở một tệp PDF, tìm đến cuối tệp, tìm thấy startxref, đọc khoảng lệch (offset), và mong đợi đáp xuống từ khóa xref với một bảng tham chiếu chéo độ rộng cố định bên dưới nó. Từ bảng đó, nó thu thập các khoảng lệch đối tượng, sau đó quét ngược lại để tìm từ khóa trailer để biết /Root/Size. Nó hoạt động hoàn hảo trên mọi tệp bạn tự tạo ra để kiểm tra. Sau đó, một tệp được sản xuất bởi một phiên bản Word hiện tại, hoặc bởi một thư viện nhắm mục tiêu đến PDF 1.5, được gửi đến, và trình xác thực tuyên bố nó bị hỏng. Không có từ khóa xref nào ở nơi khoảng lệch trỏ đến, không có từ điển trailer nào ở bất kỳ đâu, và bảng đối tượng mà trình xác thực xây dựng gần như trống rỗng. Tệp đó hoàn toàn hợp lệ. Trình xác thực đang đọc nó qua lăng kính mười lăm năm tuổi.

Đây là lý do phổ biến nhất khiến việc kiểm tra cấp độ byte của tệp PDF được viết chống lại bố cục cổ điển thất bại trên các tài liệu hiện đại. Cấu trúc mà nó phụ thuộc vào, bảng tham chiếu chéo văn bản thuần và từ khóa trailer, đã được chuyển thành tùy chọn trong PDF 1.5 và thường vắng mặt. Hai tính năng đã thay thế nó: luồng tham chiếu chéo (cross-reference stream) và luồng đối tượng nén (compressed object stream). Cả hai đều được mô tả trong ISO 32000-1, và một trình xác thực không biết về chúng sẽ nhìn nhận một tệp khỏe mạnh như một đống đối tượng bị thiếu.

PDF 1.5 đã thay đổi điều gì về phần đuôi tệp

ISO 32000-1 §7.5.8 định nghĩa luồng tham chiếu chéo, và §7.5.7 định nghĩa luồng đối tượng loại /ObjStm. Cùng nhau, chúng cho phép một trình viết loại bỏ hai cấu trúc mà một trình phân tích cú pháp cổ điển thường dùng làm khóa để tìm kiếm. Một tệp PDF 1.5 có thể kết thúc mà hoàn toàn không có bảng xref nào cả. Ở vị trí của nó, đối tượng mà startxref trỏ đến là một đối tượng luồng thông thường có từ điển mang /Type /XRef, và luồng đó giữ dữ liệu tham chiếu chéo ở dạng nhị phân nhỏ gọn. Cũng không có từ khóa trailer nào cả, bởi vì trailer hiện chính là từ điển của chính luồng đó. Các khóa mà một trình phân tích cú pháp cổ điển đi săn, bao gồm /Root, /Size/ID, đều nằm bên trong từ điển đó.

Thay đổi thứ hai di chuyển chính các đối tượng. Thay vì viết từng đối tượng gián tiếp tại khoảng lệch byte của riêng nó, một trình viết có thể đóng gói nhiều đối tượng nhỏ, bao gồm từ điển trang, từ điển chú thích, cây cấu trúc, vào một luồng đối tượng duy nhất và nén toàn bộ thùng chứa bằng Flate. Các đối tượng riêng lẻ không còn có khoảng lệch byte trong tệp nữa. Chúng có một vị trí bên trong một khối nén. Một trình xác thực quét các byte thô cho chuỗi 1 0 obj không bao giờ tìm thấy chúng, vì văn bản đó chỉ tồn tại sau khi giải nén (inflation). Đối với một trình phân tích cú pháp cổ điển, một nửa tài liệu đơn giản là đã biến mất.

Các khóa trailer là văn bản thuần, ngay cả trong một tệp nén

Phần trấn an là việc đọc các khóa trailer của một luồng tham chiếu chéo không yêu cầu giải nén bất kỳ thứ gì. Một đối tượng luồng được viết dưới dạng một từ điển theo sau bởi từ khóa stream và sau đó là các byte nén. Từ điển là văn bản thuần. Vì vậy, khi startxref trỏ đến một luồng tham chiếu chéo, các byte ngay sau số đối tượng trông giống như một từ điển thông thường, và /Root, /Size/ID nằm ở đó một cách rõ ràng, trước khi từ khóa stream và dữ liệu Flate bắt đầu.

Điều đó có nghĩa là một trình xác thực có thể tìm hiểu ba thực tế mà nó cần nhất, bao gồm catalog ở đâu, tài liệu khai báo bao nhiêu đối tượng, và số nhận dạng tệp (file identifier), bằng cách chỉ phân tích cú pháp từ điển luồng. Nó không cần giải nén dữ liệu tham chiếu chéo, và không cần giải nghĩa các mục nhập nhị phân bên trong nó. Công việc đánh bại một trình phân tích cú pháp ngây thơ không phải là đọc trailer; đó là tìm kiếm các đối tượng. Đó là hai vấn đề có thể tách biệt, và việc giải quyết vấn đề đầu tiên là rất rẻ.

Luồng đối tượng: một tiêu đề, sau đó là một khối Flate

Một luồng đối tượng là một thùng chứa. Từ điển của nó mang /Type /ObjStm, một mục nhập /N đưa ra số lượng đối tượng được đóng gói bên trong, và một mục nhập /First đưa ra khoảng lệch byte, trong dữ liệu được giải nén, nơi phần thân của đối tượng đầu tiên bắt đầu. Phần tải nén, một khi được giải nén, bắt đầu bằng một tiêu đề nhỏ gồm các cặp số nguyên /N. Mỗi cặp là một số đối tượng và khoảng lệch của thân đối tượng đó tương đối so với /First. Sau tiêu đề là chính phần thân của các đối tượng, được liên kết chuỗi.

Mở rộng một luồng là mang tính cơ học một khi các byte được giải nén. Bạn đọc từ điển để lấy /N/First, giải nén luồng bằng bộ giải nén Flate, đi qua các cặp /N dẫn đầu để biết số đối tượng nào nằm ở khoảng lệch nào, và sau đó nhấc từng phần thân ra như thể nó là một đối tượng gián tiếp thông thường. Phụ thuộc thực sự duy nhất là bộ giải nén Flate, và bạn đã có sẵn một cái: Delphi đi kèm System.ZLib, và Free Pascal đi kèm đơn vị zstream, cả hai đều bọc zlib và giải nén một luồng Flate thô mà không cần mã của bên thứ ba. Một quy trình nối thêm mỗi đối tượng được trích xuất vào bảng đối tượng của trình xác thực sẽ giúp phần còn lại của trình xác thực, phần duyệt qua /Root và kiểm tra cây trang, hoạt động chính xác như trên một tệp cổ điển.

Những gì bạn không phải triển khai

Rất dễ đánh giá cao quá mức công việc. Đọc các khóa trailer từ một tệp nén không yêu cầu giải mã các mục nhập nhị phân của luồng tham chiếu chéo. Luồng tham chiếu chéo mục §7.5.8 sử dụng ba loại mục nhập, và mục nhập loại 2, loại nói rằng đối tượng này sống bên trong luồng đối tượng N tại chỉ mục i, là thứ bạn sẽ giải mã để xây dựng một bản đồ khoảng lệch đầy đủ. Bạn cần bản đồ đó để phân giải các đối tượng tùy ý theo số hiệu. Bạn không cần nó để đọc /Root, /Size/ID, vốn nằm trong từ điển văn bản thuần, và bạn không cần nó để mở rộng các luồng đối tượng, vì mỗi /ObjStm tự thông báo nội dung của nó thông qua /N/First.

Bạn cũng không phải xử lý các hàm dự đoán (predictor functions) PNG và TIFF mà một luồng tham chiếu chéo có thể áp dụng thông qua /DecodeParms của nó chỉ để lấy các khóa trailer. Các hàm dự đoán lọc các hàng tham chiếu chéo nhị phân để giúp chúng nén tốt hơn; chúng không liên quan gì đến từ điển đứng trước luồng. Do đó, việc nâng cấp tối thiểu để giúp một trình xác thực cổ điển nhận biết được PDF hiện đại là rất nhỏ: khi startxref hạ cánh xuống một luồng thay vì từ khóa xref, hãy phân tích cú pháp từ điển luồng cho các khóa trailer, và mở rộng bất kỳ đối tượng /ObjStm nào bạn gặp phải để nội dung của chúng đi vào bảng đối tượng. Giải mã các mục nhập loại 2 và các hàm dự đoán là một nhiệm vụ riêng biệt, lớn hơn mà bạn có thể hoãn lại cho đến khi thực sự cần phân giải đối tượng ngẫu nhiên.

Tại sao kiểm tra tuân thủ phải mở rộng luồng trước

Điều này không còn mang tính học thuật khi bạn chạy một kiểm tra cấu hình (profile check). Một trình xác thực PDF/A hoặc PDF/X kiểm tra các đối tượng cụ thể: catalog tài liệu cho một mảng /OutputIntents, luồng /Metadata cho một gói XMP có số nhận dạng chính xác, mọi bộ mô tả phông chữ cho một tệp phông chữ được nhúng, trailer cho một /ID. Trong một tệp nén, hầu hết các đối tượng đó đều nằm bên trong các luồng đối tượng. Một trình xác thực không mở rộng các luồng đối tượng không thể nhìn thấy các khóa của catalog, không thể tìm thấy siêu dữ liệu, và không thể liệt kê các phông chữ. Nó sẽ báo cáo một tài liệu hoàn toàn tuân thủ là thiếu ý định đầu ra của nó, thiếu XMP của nó, và thiếu một nửa cấu trúc của nó, vì bằng chứng nó cần vẫn đang nằm trong một khối Flate mà nó chưa bao giờ giải nén.

Thứ tự là quan trọng. Việc mở rộng phải xảy ra trước khi kiểm tra chạy, chứ không phải song song với chúng, bởi vì mọi kiểm tra đều giả định nó có thể tiếp cận một đối tượng theo số hiệu. Nếu bạn kết nối một kiểm tra cấu hình trực tiếp lên một quét byte thô, nó sẽ kế thừa sự mù quáng của trình phân tích cú pháp cổ điển và tạo ra các vi phạm giả trên chính các tệp hiện đại có nhiều khả năng được định hình tốt nhất, vì chúng đến từ các chuỗi công cụ đủ mới để viết các luồng tham chiếu chéo ngay từ đầu.

Để PDFium thực hiện việc phân tích cú pháp cho bạn

Thành phần PDFium phân tích cú pháp các luồng tham chiếu chéo và luồng đối tượng như một phần của việc tải một tài liệu, đây là cách thực tế để tránh phải tự viết bước giải nén và mở rộng thủ công. Khi bạn tải một tệp bằng thành phần TPdf, các đối tượng được đóng gói vào các thùng chứa /ObjStm đã được phân giải, và các điểm truy cập xác thực sẽ nhìn thấy tài liệu được mở rộng hoàn toàn. ValidatePdfA trả về một bản ghi TPdfAValidationResult có trường Conformance là một giá trị TPdfAConformance chẳng hạn như pac1b hoặc pacNone, có trường Issues là một tập hợp các vấn đề cụ thể được tìm thấy, và phương thức IsCompliant của nó chỉ đúng khi cấp độ tuân thủ được phát hiện và tập hợp vấn đề là trống. Bởi vì các đối tượng đã được mở rộng trong quá trình tải, một mảng /OutputIntents hoặc một phông chữ nhúng sống bên trong một luồng đối tượng sẽ được tìm thấy, chứ không bị báo cáo là thiếu.

uses
  PDFium, FPdfPdfa;

function CheckPdfA(const FileName: string): TPdfAValidationResult;
var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;            // parses xref/object streams on load
    Result := Pdf.ValidatePdfA;    // sees the expanded object table
  finally
    Pdf.Free;
  end;
end;

Điều tương tự cũng áp dụng cho ValidatePdfX, trả về một TPdfXValidationResult có cùng hình dáng. Mục đích của việc định tuyến qua PDFium là quá trình giải nén cấu trúc được mô tả ở trên xảy ra một lần, chính xác, bên trong bộ tải, do đó mã xác thực của bạn không bao giờ nhìn thấy sự khác biệt giữa một tệp cổ điển và một tệp nén hoàn toàn. Cả hai đều đến trình xác thực dưới dạng một tập hợp các đối tượng đã được phân giải.

var
  Pdf: TPdf;
  R  : TPdfXValidationResult;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'Press_Ready.pdf';
    Pdf.Active := True;
    R := Pdf.ValidatePdfX;
    if R.IsCompliant then
      Writeln('PDF/X conformance: ', Ord(R.Conformance))
    else
      Writeln('Not conformant; issue count = ', SizeOf(R.Issues));
  finally
    Pdf.Free;
  end;
end;

Nếu các byte đã có trong bộ nhớ chứ không phải trên đĩa, cùng một trình tự tải-rồi-xác-thực sẽ hoạt động thông qua việc quá tải LoadDocument(const Data: TBytes), nhận nội dung tệp thô và phân tích cú pháp luồng tham chiếu chéo và luồng đối tượng của nó cùng một cách như đường dẫn tệp thực hiện. Điểm cần lưu ý cho một trình xác thực tự viết là quy tắc cấu trúc, chứ không phải API: đọc các khóa trailer từ từ điển luồng bằng văn bản thuần, mở rộng mọi đối tượng /ObjStm bằng bộ giải nén Flate trước khi bạn đi qua tài liệu, và coi việc giải mã các mục nhập tham chiếu chéo nhị phân là công việc lớn hơn, tùy chọn.

Một khi cấu trúc được mở rộng, một trình xác thực có thể điều khiển phần còn lại của quy trình làm việc trên đó. Đối với một khai thác dòng lệnh preflight báo cáo tính tuân thủ trên một thư mục đầu vào, hãy xem hướng dẫn xây dựng CLI báo cáo preflight hàng loạt của chúng tôi. Khi xác thực là một cổng chặn trước khi chia nhỏ một tài liệu lớn, các kỹ thuật trong hướng dẫn chia nhỏ tài liệu PDF thành nhiều tệp sẽ kết hợp tự nhiên với mô hình tải và kiểm tra được hiển thị ở đây. Cả hai đều được xây dựng trên bề mặt tải và xác thực của PDFium Component dành cho Delphi và C++Builder.