Bài viết kỹ thuật

Tải các tệp PDF Lai tham chiếu từ Word và Excel trong Delphi

Mở một tệp PDF do Microsoft Word hoặc Excel xuất ra, lướt qua các trang và không có gì bất thường cả. Tải tệp đó vào một chương trình Delphi, đọc lại số trang thì con số vẫn đúng. Sau đó thử lưu lại tệp với mã hóa được bật và công việc đó thất bại với một EListError, hoặc tệp được xuất ra mở lên báo cảnh báo bị hỏng bảng cross-reference. Tệp đó chưa bao giờ bị hỏng. Nó là một tệp lai tham chiếu (hybrid-reference), và chính cái cấu trúc cho phép một trình xem PDF 15 năm tuổi có thể mở nó lên lại là cái cấu trúc đánh bại một trình tải dừng đọc quá sớm

Đây là một trong những cách phổ biến nhất mà một quy trình xử lý PDF vượt qua mọi bài kiểm tra nội bộ gặp phải một tệp mà nó không thể lưu lại trọn vẹn (round-trip). Các tệp đầu vào đều được tạo nội bộ, nên chưa bao giờ là tệp lai. Tệp lai đầu tiên xuất hiện vào ngày mà một khách hàng chuyển tiếp một hóa đơn được xuất từ bảng tính

Thực chất Word và Excel ghi những gì

ISO 32000-1 mô tả bố cục tham chiếu lai tại mục §7.5.8.4. Một ứng dụng muốn sử dụng các tính năng của PDF 1.5 như object streams (dòng đối tượng), trong khi vẫn cho phép một trình đọc PDF 1.4 mở được tệp, sẽ ghi thông tin cross-reference hai lần. Có một bảng cross-reference kiểu cổ điển, là các hàng ASCII độ rộng cố định nằm cuối mỗi tệp PDF cho đến phiên bản 1.4, và có một stream cross-reference để đánh chỉ mục (index) cho phần còn lại. Phần trailer của khu vực cổ điển mang một mục /XRefStm có giá trị là byte offset của cái stream đó

Sự phân chia công việc là có chủ ý. Những đối tượng mà một trình đọc cũ cần chạm tới, bao gồm catalog và cây trang, đều có thể truy cập được từ bảng cổ điển. Những đối tượng bị xếp vào trong các object stream nén được đánh dấu là trống (free) trong bảng cổ điển, với một mục có loại f, để một trình đọc 1.4 lướt thẳng qua chúng mà không bao giờ vấp phải một cấu trúc mà nó không thể phân tích cú pháp (parse). Vị trí thực sự của chúng chỉ nằm trong stream cross-reference. Dấu hiệu nhận biết của một tệp như vậy nằm ở phần đuôi: một phần cổ điển ngắn, thường không có gì hơn ngoài chữ xref tiếp theo là một tiêu đề phần phụ 0 0, với phần trailer trỏ tới /XRefStm nơi chứa dữ liệu khôi phục thực sự

Tại sao số trang đúng lại chẳng chứng minh được điều gì

Do catalog và cây trang được cố ý đặt sao cho có thể truy cập từ bảng cổ điển, một trình tải (loader) chỉ đọc cái bảng đó vẫn tìm thấy /Root, duyệt cây trang, và báo cáo số lượng trang chính xác. Mọi thứ một trình đọc cũ cần đều hiện diện, nên tệp có vẻ khỏe mạnh. Các đối tượng bị thiếu là những đối tượng được nhồi nhét vào trong các object stream: từ điển trường AcroForm, các yếu tố cấu trúc tagged-PDF, cái đuôi dài gồm những từ điển nhỏ bé không bao giờ cần phải hiển thị cho một trình xem đời cũ

Bạn không để ý đến khoảng trống đó cho đến khi có thứ gì đó đụng vào những đối tượng đó, và một lần lưu lại (resave) toàn bộ sẽ chạm tới tất cả chúng. Duyệt qua tài liệu để mã hóa lại hoặc ghi lại chính là thao tác đòi hỏi phải tìm kiếm từng số đối tượng (object number) một, đó là lý do triệu chứng nổi lên ở thời điểm lưu thay vì thời điểm tải, rất xa khỏi nguyên nhân của nó

Cái bẫy là một bộ phát hiện chỉ nhìn thấy xref rồi dừng lại

Cách rẻ tiền để quyết định tệp được đánh chỉ mục như thế nào là lần theo startxref và kiểm tra những byte đầu tiên nó trỏ tới. Từ khóa xref đồng nghĩa với bảng cổ điển; một đối tượng stream đồng nghĩa với stream cross-reference. Bài kiểm tra đó là chính xác đối với bất kỳ tệp nào cam kết với một kiểu kiến trúc. Nhưng nó sai đối với một tệp lai, nơi startxref nhắm tới một phần cổ điển với mục đích duy nhất là làm hài lòng các trình đọc cũ, trong khi cái /XRefStm trong trailer của phần đó lại là nơi thực sự chỉ mục phần lớn tài liệu. Một bộ phát hiện trả về "classic" ở chữ xref đầu tiên nó gặp không bao giờ đọc /XRefStm, và mọi đối tượng chỉ tồn tại trong stream trở nên tàng hình

var
  Pdf: THotPDF;
  PageCount: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    PageCount := Pdf.LoadFromFile('Invoice_XLS.pdf');  // count is correct
    // inspect or edit the loaded document here
    Pdf.SaveLoadedDocument('Invoice_secured.pdf');     // walks every object
  finally
    Pdf.Free;
  end;
end;

Với bộ phát hiện thoát sớm này được bật, quá trình tải có vẻ ổn thỏa và lần lưu lại là lúc các đối tượng bị vắng mặt lên tiếng. Cách sửa không phải là đọc thêm các byte ngay ở phần đầu; mà là nhận diện trailer lai và lần theo /XRefStm trước khi quyết định tệp đã đọc xong

Thứ tự gộp không thể thương lượng

Một khi cả hai chỉ mục đã được đọc, chúng chỉ có thể được kết hợp theo một chiều duy nhất. Stream cross-reference phải được gộp (merge) đầu tiên, rồi mới lấp các mục của bảng cổ điển xung quanh nó. Lý do nằm ở một chiêu trò nhỏ bé ở trung tâm của định dạng. Một tệp lai đánh dấu các đối tượng bị nén của nó là trống (free) trong bảng cổ điển để những trình đọc cũ làm ngơ chúng. Một trình tải (loader) áp dụng chính sách "kẻ nào nhìn thấy đầu tiên kẻ đó thắng" (first-seen-wins) và đọc bảng cổ điển trước tiên sẽ ghi nhận những số đối tượng đó là trống, rồi sau đó vứt bỏ những mục trong stream thực sự định vị chúng, vì chỗ trống đã bị chiếm rồi. Đảo ngược thứ tự đi và các mục thuộc loại 2 từ trong stream, với mỗi mục là một số của object-stream cộng với một index, sẽ giành lại các chỗ trống chúng sinh ra để sở hữu, và các mục cổ điển sẽ lắng lại xung quanh chúng

Kỷ luật tương tự cũng chống lại một phiên bản cũ hơn dựng dậy một đối tượng đã bị xóa. Các bản cập nhật tăng dần (incremental updates) móc nối ngược qua /Prev, và một mục trống thuộc loại 0 là một lính gác (sentinel) báo hiệu rằng một phần mới hơn đã thu hồi một số đối tượng. Một phần cũ hơn nằm phía sau trong chuỗi không được phép ghi đè lên lính gác đó bằng một vị trí đã thiu. Coi lính gác lần đầu nhìn thấy là kẻ có thẩm quyền đối với các dấu trống thì đối tượng đã xóa vẫn mãi mãi bị xóa; cư xử bất cẩn thì lịch sử của chính tệp đó sẽ hồi sinh lại cái nội dung mà bản sửa đổi mới nhất vừa gỡ đi

Điều này có ý nghĩa gì trong HotPDF

Engine giải quyết các tệp tham chiếu lai hộ bạn, và nó thực hiện điều đó trên mọi đường truyền phải parse dữ liệu cross-reference. Tải một tài liệu với LoadFromFile hoặc LoadFromStream, thực hiện các thay đổi, rồi gọi SaveLoadedDocument; hoặc chạy một thao tác bắn một phát (one-shot) ví dụ như EncryptFile để đọc đầu vào và ghi ra đầu ra. Dù thế nào thì quá trình khôi phục cũng đọc /XRefStm, hợp nhất phần stream lên trước các mục cổ điển, và giải quyết các đối tượng nằm trong stream trước khi thao tác ghi đếm chúng. Đường truyền mã hóa AES-256 là nơi vấn đề lộ diện lần đầu, bởi vì mã hóa một tài liệu nghĩa là ghi lại mọi đối tượng và do đó bắt buộc mọi đối tượng phải được xác định vị trí từ trước đó rồi

// One-shot: read the hybrid input, write an AES-256 encrypted copy
Pdf.EncryptFile('Letter_DOC.pdf', 'Letter_secured.pdf',
  'owner-secret', '', aes256, [prPrint, prFillAnnotations]);

Cái chi tiết đáng mang về nằm ở thượng nguồn của API. Các tệp từ Word, Excel, PowerPoint, và một chuỗi dài dằng dặc các đường ống "Save as PDF" mặc định thường là dạng lai, vậy nên một trình tải mà bạn chỉ dùng để tập duyệt chọi với đầu ra do bạn tự phát tạo thì khó có cơ may va phải dạng đó trong lúc thử nghiệm. Rải hột mấy ca thử nghiệm của bạn bằng các văn bản xuất nạp từ các app Office đồ xịn, chứ chớ chỉ xài các tệp rặn ra từ bộ mã bạn mài viết

Kiểm tra một tệp mà bạn nghi ngờ

Hai bước soi mói dàn xếp êm câu hỏi trong nháy mắt. Mở tệp ở view hex ra và đọc mấy byte ngay dưới startxref chót; tệp dạng lai bày ra một phần cổ điển cụt ngủn mà bộ từ điển trailer của nó chứa cụm /XRefStm. Hay đem kê đếm tổng đối tượng bộ parse đầy đủ xướng lên đem so chọi với cái số định danh đối tượng cao tót mà /Size xưng danh bên trong trailer. Một độ vênh lớn hé lộ lũ đối tượng kia đang giấu mình trong các stream trình tải còn chả buồn mở nắp, thứ này chính là cái khoản thâm hụt sau chót hóa mủ rụng thành pha sấp mặt hồi bóp nút lưu

Cái vệt đuôi từ cục nạp Excel tiêu biểu đúc bài kiểm đầu nặn ra hồn. Mọi thứ bâu bám dưới cái từ khóa xref cuối chót đều là chuẩn ASCII trần trụi, suy ra cụm chữ ký sờn rách kia lật đọc trơn tuột ngay trong view hex (mấy cái offset bôi rọi minh hoạ, dán chú giải bám theo sau)

xref
0 0                          % empty classic subsection: no rows at all
trailer
<< /Size 216                 % one past the highest object number in use
   /Root 1 0 R
   /Info 15 0 R
   /ID [<5C9A...> <5C9A...>]
   /XRefStm 87325            % byte offset of the cross-reference stream
>>
startxref
88710                        % points at the classic section above
%%EOF

Khúc phụ mục 0 0 chính là điềm hở đuôi: một cái bảng cổ điển chứa vỏn vẹn không lính tráng chen chúc đẻ ra rặt chỉ để còng gánh bộ trailer, rồi cái trailer trườn ra cốt chĩa miệng la làng /XRefStm 87325. Đám công cụ dò la đi phanh vấp ổ xref đến khúc này nếm trọn cú lừa đâm đụng bộ chỉ mục mục đích nhét lót không khí. Tới lúc chán mắt tự soi thèm nã luôn mã lệnh bới lông, gờ đánh dấu kia rặt rúc sâu tịt vào khúc đôi ba kilobyte vạt bọc đít file, do đó đâm rà đụng dội dăm dòng ngược về là đủ ép góc nạp tài

// Returns the /XRefStm offset from the file's tail, or -1 if the
// marker is absent (the file is not hybrid, or not a PDF at all)
function FindXRefStm(const FileName: string): Int64;
var
  FS: TFileStream;
  Tail: AnsiString;
  Len, P: Integer;
begin
  Result := -1;
  FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    Len := 2048;                        // the trailer lives in the tail
    if FS.Size < Len then
      Len := Integer(FS.Size);
    FS.Position := FS.Size - Len;       // bounded backward read: 2 KB max
    SetLength(Tail, Len);
    FS.ReadBuffer(Tail[1], Len);
  finally
    FS.Free;
  end;
  P := Pos(AnsiString('/XRefStm'), Tail);
  if P = 0 then
    Exit;                               // no hybrid marker in the tail
  Inc(P, Length('/XRefStm'));
  while (P <= Len) and (Tail[P] in [' ', #9, #13, #10]) do
    Inc(P);                             // skip whitespace after the key
  Result := 0;
  while (P <= Len) and (Tail[P] in ['0'..'9']) do
  begin
    Result := Result * 10 + Ord(Tail[P]) - Ord('0');
    Inc(P);
  end;
end;

// Usage: a non-negative result names the byte where the stream starts
if FindXRefStm('Invoice_XLS.pdf') >= 0 then
  Writeln('hybrid-reference file: resave will need the /XRefStm section');

Hãy xem cái bộ soi chiếu này như y lệnh sơ cứu chẩn bệnh, chẳng phải đồ nghề parse mổ nạc: nó chỉ trỏ mặt điểm tên tệp nào kẹp trong đống rác hầm bà lằng nọ cần vỗ về kỹ lưỡng đôi chút trước lúc máy ủi lưu đè sấn lên, chả rặn thêm đồng nào khác. Nhồi nặn tiếp bộ loader sau khi túm chỏm được cái mốc kia xong phải luồn qua đống xích nhánh, dồn đống mớ mục lục stream lên vượt đầu hội cổ điển, bái chào cung cách kiêng né lính gác đánh dấu trống ra sao, thì toàn bộ chu trình vạch cặn kẽ nằm lọt vách bên bài viết bạn đồng hành mổ xẻ xử vụ PDF tham chiếu lai sinh từ hàng tá app Office

Câu chuyện kể theo mép khía cạnh sinh tạo nội dung, chuyện về object streams với cross-references bị vắt ép nén được nhào nặn ra sao bưng đầu, được khui bóc bao trọn lọt ở chuyên đề viết về object streams cùng cập nhật bồi đắp của chúng tôi. Lỡ lúc va trúng file lai vừa bị điểm huyệt cõng chung thể trạng kềnh càng to nạc, chuỗi kĩ năng nạp tài cắm mốc ở lộ trình bóc trần Direct File API đẽo gọt luồng PDF cỡ đại thả lỏng cửa cho phép bạn mổ xẻ dòm nhó khỏi ôm cả tảng nốc thẳng vô ngốn rỗng ruột bộ nhớ ram. Hai mảng gắp xoắn xích đồng pha ăn khớp hoàn mĩ rợp mui vào cái quy trình cấp cứu bóc tách vạch lá tìm sâu nằm ngay trên trang bạn đương đọc, rải cắm tích hợp nhét ruột làm một trong Thành phần (Component) HotPDF cho Delphi và C++Builder cõng ké cạnh sườn mớ tải nạp, đẽo gọt, đánh khóa mã, cùng mâm APIs niêm ấn chữ ký ghim đầy đủ chật vách các bài bên lề trạm blog này