Bài viết kỹ thuật

Xác thự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 startxref, đọc phần chênh lệch (offset) và mong đợi dừng lại ở từ khóa xref với một bảng tham chiếu chéo có chiều rộng cố định nằm bên dưới nó. Từ bảng đó, nó thu thập các offset của đối tượng, sau đó quét ngược lại để tìm từ khóa trailer nhằm lấy /Root/Size. Nó hoạt động hoàn hảo trên mọi tệp bạn đã tạo để kiểm thử. Sau đó, một tệp được tạo bởi phiên bản Word hiện tại, hoặc bởi một thư viện nhắm đến mục tiêu PDF 1.5, được đưa vào và trình xác thực khai báo tệp đó bị hỏng. Không có từ khóa xref nào ở vị trí mà offset trỏ tới, không có từ điển trailer ở 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 này hợp lệ. Trình xác thực đang đọc nó qua một lăng kính từ mười lăm năm trước

Đây là lý do phổ biến nhất khiến việc kiểm tra PDF ở cấp độ byte được viết dựa trên bố cục cổ điển bị 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 túy và từ khóa trailer, đã được chuyển thành tùy chọn trong PDF 1.5 và thường xuyên 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 tiêu chuẩn ISO 32000-1, và một trình xác thực không biết về chúng sẽ coi một tệp hoàn toàn bình thường thành một đống các đố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 có loại /ObjStm. Cùng với nhau, chúng cho phép một trình ghi (writer) loại bỏ hai cấu trúc mà một trình phân tích cú pháp cổ điển tập trung vào. Một tệp PDF 1.5 có thể kết thúc mà không có bảng xref nào cả. Thay vào đó, đối tượng mà startxref trỏ tới là một đối tượng luồng (stream object) thông thường, từ điển của đối tượng này mang /Type /XRef, và luồng đó chứa 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, vì trailer hiện tại chính là từ điển riêng của luồng đó. Các khóa mà trình phân tích cú pháp cổ điển săn lùng, như /Root, /Size/ID, đều nằm bên trong từ điển đó

Sự thay đổi thứ hai làm di chuyển chính các đối tượng. Thay vì viết mỗi đối tượng gián tiếp (indirect object) ở độ lệch byte của riêng nó, một trình ghi có thể đóng gói nhiều đối tượng nhỏ, 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 (container) bằng thuật toán Flate. Các đối tượng riêng lẻ không còn độ lệch byte trong tệp nữa. Chúng có một vị trí bên trong một khối nén (compressed blob). Một trình xác thực quét các byte thô để tìm 1 0 obj sẽ 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. Đố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 túy, ngay cả trong một tệp nén

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

Điều đó có nghĩa là trình xác thực có thể biết được ba thông tin mà nó cần nhất, danh mục (catalog) ở đâu, tệp khai báo có bao nhiêu đối tượng, và định danh của tệp, bằng cách chỉ phân tích cú pháp từ điển của luồng. Nó không phải giải nén dữ liệu tham chiếu chéo, và nó không phải diễn giải các mục 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; mà là việc tìm kiếm các đối tượng. Đó là hai vấn đề có thể tách rời và việc giải quyết vấn đề đầu tiên là rất nhẹ nhàng

Các luồng đối tượng: một phần 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 /N cho biết số lượng đối tượng được đóng gói bên trong, và một mục /First cho biết phần chênh lệch byte (byte offset), nằm trong dữ liệu đã giải nén, nơi bắt đầu thân (body) của đối tượng đầu tiên. Dữ liệu nén, khi được giải nén, bắt đầu với một phần tiêu đề nhỏ gồm /N cặp số nguyên. Mỗi cặp là một số lượng đối tượng và độ lệch của thân đối tượng đó tương đối so với /First. Sau phần tiêu đề là chính thân các đối tượng, được nối lại với nhau

Việc mở rộng một luồng có tính cơ học sau 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 mã Flate, lướt qua /N cặp số dẫn đầu để biết số lượng đối tượng nào nằm ở độ 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 mã Flate, và bạn đã có sẵn nó: Delphi cung cấp System.ZLib, và Free Pascal cung cấp đơn vị (unit) zstream, cả hai đều bao bọc zlib và giải nén một luồng Flate thô mà không cần bất kỳ mã của bên thứ ba nào. Một chu trình nối từng đối tượng được trích xuất vào bảng đối tượng của trình xác thực sẽ làm cho phần còn lại của trình xác thực, phần lướt qua /Root và kiểm tra cây trang, hoạt động chính xác như nó sẽ làm 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á 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ị phân của luồng tham chiếu chéo. Luồng tham chiếu chéo §7.5.8 sử dụng ba loại mục (entry types), và loại mục thứ 2, là loại nói rằng đối tượng này nằm 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 đồ độ lệch hoàn chỉnh. Bạn cần bản đồ đó để giải quyết các đối tượng tùy ý theo số lượng. Bạn không cần nó để đọc /Root, /Size/ID, chúng nằm trong từ điển văn bản thuần túy, và bạn không cần nó để mở rộng các luồng đối tượng, bởi vì mỗi /ObjStm đều công bố nội dung riêng của nó thông qua /N/First

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

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

Điều này không còn là lý thuyết ngay thời điểm bạn chạy kiểm tra cấu hình. Một trình xác thực PDF/A hoặc PDF/X sẽ kiểm tra các đối tượng cụ thể: danh mục tài liệu đối với mảng /OutputIntents, luồng /Metadata cho một gói XMP có định danh phù hợp, mọi bộ mô tả phông chữ cho một tệp phông chữ được nhúng, trailer đối với /ID. Trong một tệp nén, hầu hết các đối tượng đó đều nằm trong các luồng đối tượng. Một trình xác thực chưa mở rộng các luồng đối tượng thì không thể thấy các khóa của danh mục, không thể tìm thấy siêu dữ liệu (metadata), 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ủ thành việc thiếu output intent, thiếu XMP của nó, và thiếu đi một nửa cấu trúc, vì bằng chứng mà nó cần vẫn nằm trong một khối Flate mà nó chưa bao giờ giải nén

Thứ tự đóng vai trò quan trọng. Việc mở rộng phải diễn ra trước khi chạy quá trình kiểm tra, chứ không phải diễn ra song song với chúng, vì mọi kiểm tra đều giả định nó có thể tiếp cận đối tượng bằng số lượng. Nếu bạn nối trực tiếp quá trình kiểm tra cấu hình vào việc 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 lỗi vi phạm sai sót đối với chính những tệp hiện đại có khả năng được định dạng tốt nhất, vì chúng vốn xuất phát từ các chuỗi công cụ đủ mới để có thể viết các luồng tham chiếu chéo ngay từ đầu

Hãy để PDFium phân tích cú pháp cho bạn

Thành phần PDFium (PDFium Component) phân tích cú pháp các luồng tham chiếu chéo và các 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 việc phải thực hiện thủ công các bước giải nén và mở rộ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 trong các thùng chứa /ObjStm đã được giải quyết sẵn, và các điểm đầu vào xác thực sẽ 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ỉ trả về true khi phát hiện mức độ tuân thủ và tập hợp các vấn đề trống rỗng. Vì các đối tượng đã được mở rộng trong quá trình tải, nên mảng /OutputIntents hoặc phông chữ nhúng nằm bên trong một luồng đối tượng sẽ được tìm thấy, 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, nó trả về TPdfXValidationResult có cùng hình dáng. Điểm mấu chốt của việc định tuyến thông qua PDFium là quá trình giải nén cấu trúc được mô tả ở trên diễn ra một lần, một cách chính xác, bên trong trình 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 tệp cổ điển và tệp được 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 giải quyết

function PdfXConformanceName(C: TPdfXConformance): string;
begin
  case C of
    pxc1a: Result := 'PDF/X-1a';
    pxc3 : Result := 'PDF/X-3';
    pxc4 : Result := 'PDF/X-4';
  else
    Result := 'none';
  end;
end;

var
  Pdf: TPdf;
  R  : TPdfXValidationResult;
  Issue: TPdfXValidationIssue;
  IssueCount: Integer;
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: ', PdfXConformanceName(R.Conformance))
    else
    begin
      IssueCount := 0;
      for Issue in R.Issues do   // Issues is a set: count its members
        Inc(IssueCount);
      Writeln('Not conformant; issue count = ', IssueCount);
    end;
  finally
    Pdf.Free;
  end;
end;

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

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