Bài viết kỹ thuật

Dựng lại bảng Xref PDF hỏng: Quét khôi phục Delphi

Khi bảng cross-reference của PDF không thể dùng được, cách sửa là bỏ qua nó hoàn toàn và dựng lại từ phần thân tệp. PDFlibPas Delphi PDF Library làm điều này bằng một trình quét token một lượt, ghi lại mọi header đối tượng gián tiếp thật sự mà nó thấy, sau đó khôi phục trailer dictionary và giao bảng đã tái tạo cho bộ tải thông thường

Điều gì hỏng trước tiên khi một PDF bị hư hại

Bảng cross-reference là phần dễ vỡ nhất của một PDF, vì nó là phần duy nhất lưu các offset byte tuyệt đối. ISO 32000-1 §7.5.4 định nghĩa các mục đó là offset mười chữ số tính từ đầu tệp, và §7.5.5 đặt từ khóa startxref gần cuối tệp trỏ tới chính bảng đó. Mọi con số như vậy đều bị vô hiệu hóa bởi bất kỳ chỉnh sửa nào làm dịch chuyển byte. Một phiên FTP chạy ở chế độ văn bản và dịch CRLF, một lượt tải xuống bị cắt cụt, một sector hỏng trên ổ đĩa chia sẻ, một công cụ batch nối thêm mà không ghi đúng một incremental update: tất cả đều để lại dữ liệu đối tượng vẫn đọc được hoàn hảo trong khi chỉ mục thì trỏ vào rác

Đó là lý do vì sao "tệp bị hỏng và đang được sửa chữa" là một hộp thoại phổ biến đến vậy. Các byte hầu như luôn còn nguyên. Cái mất đi là bản đồ. Vì vậy việc tái tạo không phải là khôi phục pháp y dữ liệu đã mất, mà là dựng lại một chỉ mục có thể suy ra được từ phần thân, và nó thành công thường xuyên hơn nhiều so với người dùng mong đợi, bởi vì phần nội dung tốn kém — cây trang, font và hình ảnh — vẫn còn nguyên vẹn

Vì sao quét tìm N 0 obj lại tìm ra kết quả sai?

Một cách dựng lại ngây thơ tìm kiếm trong các byte thô mẫu "số nguyên, số nguyên, obj" và ghi lại mọi lần khớp. Nó tìm ra quá nhiều. PDF là một định dạng container, và ba vùng của một tệp mờ đục với văn phạm đối tượng: comment (§7.2), chuỗi (§7.3.4), và dữ liệu stream (§7.3.8). Bất kỳ vùng nào trong số đó cũng có thể chứa các byte đọc y hệt một header đối tượng, mà không vùng nào trong số đó thực sự là một header đối tượng. Một chú thích trong một chuỗi literal, một comment gỡ lỗi còn sót lại, hay hai megabyte dữ liệu Flate hoặc DCT đều có thể vô tình tạo ra thứ trông giống 99 0 obj

const
  Trap: AnsiString =
    '4 0 obj'#10 +
    '(a caption that mentions 88 0 obj)'#10 +   // literal string, not an object
    'endobj'#10 +
    '% 77 0 obj left over from a debug dump'#10 +  // comment, not an object
    '5 0 obj'#10 +
    '<< /Length 2097152 >>'#10 +
    'stream'#10 +
    { two MiB of compressed bytes that contain the byte sequence
      99 0 obj and, further along, a complete endstream }
    'endstream'#10 +
    'endobj'#10;

Mỗi mục sai đều tốn kém gấp đôi. Nó làm ô nhiễm bảng đã dựng lại bằng một số đối tượng không tồn tại, và nó có thể che lấp một đối tượng thật mang cùng số xuất hiện sau đó trong tệp. Vì vậy PDFlibPas hoàn toàn không khớp mẫu theo kiểu đó. Nó tokenize hóa, nghĩa là nó luôn biết liệu các byte dưới con trỏ là mã hay payload, và payload được bỏ qua mà không bao giờ bị diễn giải

Một máy trạng thái một lượt quét trên các khối 64 KiB

PDFlibPas quét toàn bộ tệp đúng một lần, theo các khối 64 KiB, với một máy trạng thái được xây dựng trên các quy tắc token của ISO 32000-1 §7.2 và cú pháp đối tượng gián tiếp của §7.3.10. Một token kết thúc tại khoảng trắng hoặc tại một trong các ký tự phân định, và một header đối tượng chỉ được ghi lại khi đã thấy đầy đủ một chuỗi gồm số đối tượng dương, số thế hệ không âm, và một từ khóa obj đứng riêng. Offset được ghi lại là điểm bắt đầu của token số đối tượng, đó chính là thứ mà một mục cross-reference phải trỏ tới, không phải vị trí của từ khóa obj

function RebuildIsWhiteSpace(Value: Byte): Boolean;
begin
  Result := (Value = 0) or (Value = 9) or (Value = 10) or
            (Value = 12) or (Value = 13) or (Value = 32);
end;

function RebuildIsDelimiter(Value: Byte): Boolean;
begin
  Result := (Value = Ord('(')) or (Value = Ord(')')) or
            (Value = Ord('<')) or (Value = Ord('>')) or
            (Value = Ord('[')) or (Value = Ord(']')) or
            (Value = Ord('{')) or (Value = Ord('}')) or
            (Value = Ord('/')) or (Value = Ord('%'));
end;

Chi tiết quan trọng là trạng thái token và trạng thái chuỗi vẫn tồn tại qua ranh giới khối. Một header nằm vắt ngang qua mốc 65536 byte vẫn được nhận diện, vì token dở dang, cặp số nguyên đang chờ, và các cờ trạng thái "đang trong chuỗi" đều được mang sang khối tiếp theo. Các buffer là cố định: 64 KiB cho lượt quét, 32 byte cho token dài nhất có thể có ý nghĩa, và những mảng duy nhất tăng theo kích thước tệp là danh sách số đối tượng, số thế hệ và offset 64-bit, vốn tỷ lệ với số lượng đối tượng thực chứ không phải với kích thước tệp. Trong thực tế, lượt quét chỉ phát ra các lệnh đọc tuần tự và tối đa hai lần seek rõ ràng trên toàn bộ tài liệu, đây chính là điều khiến nó khả thi trên các đầu vào cỡ vài trăm megabyte được bàn tới trong bài viết về gộp và tách bằng truy cập trực tiếp

Vì sao không thể tin rằng một stream kết thúc tại endstream?

Vì dữ liệu stream là các byte tùy ý, và các byte tùy ý có thể vô tình đánh vần ra endstream. Một stream bắt đầu sau từ khóa stream phải được bỏ qua như dữ liệu mờ đục cho tới khi nó thực sự kết thúc, nhưng lần xuất hiện đầu tiên của từ khóa đóng chỉ là một ứng viên. PDFlibPas giải quyết điều này bằng cách yêu cầu xác nhận chéo: một token endstream chỉ được chấp nhận là điểm kết thúc thật sự của stream khi token không phải khoảng trắng tiếp theo là một endobj đứng riêng, đúng như chuỗi mà §7.3.8 yêu cầu quanh một đối tượng stream. Một lần khớp tình cờ bên trong dữ liệu nén hầu như không bao giờ có phần tiếp theo đó, nên trình quét vẫn ở lại bên trong stream và tiếp tục. Hai quy tắc nhỏ hơn cũng quan trọng không kém. Từ khóa stream chỉ đưa vào trạng thái stream khi nó là một từ khóa đứng riêng, nên một tên đối tượng như /stream trong một dictionary không bao giờ kích hoạt nó. Và một token obj hay trailer chỉ được chấp nhận khi token đó không vượt quá giới hạn 32 byte và không bắt đầu bằng dấu gạch chéo. Nếu thiếu hai lớp bảo vệ đó, một resource dictionary với tên khóa sai cũng đủ để làm chệch hướng lượt quét, đây chính xác là loại đầu vào mang tính đối kháng được đề cập trong ghi chú về phân tích PDF không đáng tin cậy một cách an toàn

Tìm điểm kết thúc thật sự của trailer dictionary

Khôi phục các đối tượng chỉ là một nửa công việc, vì bộ tải vẫn cần một trailer để tìm /Root. PDFlibPas ghi nhớ 64 vị trí từ khóa trailer gần nhất tìm được trong lượt quét và xác thực chúng theo chiều ngược lại, mới nhất trước tiên, để trailer khả dụng mới nhất thắng, và một từ khóa lạc lõng không theo sau bởi một dictionary sẽ đơn giản là thất bại xác thực và chuyển sang ứng viên trước đó. Mỗi ứng viên được đọc với giới hạn 1 MiB, và điểm kết thúc của dictionary được xác định bằng cách theo dõi độ sâu lồng nhau của <<>> cùng với các ký tự thoát của chuỗi literal, chuỗi hex và comment

// A naive reader that stops at the first '>>' truncates this trailer,
// and a fixed 2048-byte window can cut it in half on a large one
'trailer'#10 +
'<< /Size 5 /Root 1 0 R' +
'   /Custom << /Text (value >> preserved) >> >>'#10

Theo dõi độ sâu không phải là chuyện hàn lâm. Một trailer bị cắt cụt làm mất /Encrypt biến một tài liệu đã mã hóa có thể khôi phục được thành một tài liệu không thể mở, và mất /Info hay một sub-dictionary tùy chỉnh sẽ âm thầm loại bỏ metadata mà một hệ thống phía sau có thể phụ thuộc vào. Nếu tệp bị mã hóa, trailer đã khôi phục chính là thứ cho phép luồng xác thực thông thường chạy được, và ngữ nghĩa thử lại giống hệt như được mô tả trong bài viết về tải tài liệu đã mã hóa

Việc tái tạo không thể trả lại cho bạn những gì

Tái tạo là một nỗ lực tốt nhất có thể, và trung thực về giới hạn của nó là một phần của việc triển khai nó. Ba trường hợp thất bại hoàn toàn. Các đối tượng đã đóng gói bên trong object stream (§7.5.7) không thể nhìn thấy riêng lẻ bằng một lượt quét byte, nên nếu một container còn sống sót nhưng cross-reference stream của nó (§7.5.8) thì không, các đối tượng nó chứa sẽ không được lập chỉ mục bởi việc dựng lại. Một tệp mà phần thân thực sự đã bị hỏng, chứ không chỉ là bị lập chỉ mục sai, sẽ tạo ra các header mà nội dung không còn phân tích được nữa. Và một tệp không có từ khóa trailer nào có thể khôi phục và không có catalog nào đọc được thì không có gì để neo một cây tài liệu vào, bất kể tìm thấy bao nhiêu header đối tượng

Số đối tượng trùng lặp là trường hợp trung gian thú vị. Một tệp được cập nhật tăng dần một cách hợp lệ chứa nhiều thế hệ của cùng một số đối tượng, và chuỗi cross-reference còn sống sót là bản ghi duy nhất cho biết cái nào đang là hiện hành. Một lượt dựng lại không có chuỗi đó, nên nó ghi lại mọi header nó thấy theo thứ tự trong tệp rồi giải quyết theo số đối tượng sau đó. Thường thì phiên bản sau cùng thắng, điều này thường là đúng, nhưng một tài liệu đã được cập nhật rồi sau đó bị hoàn tác một phần có thể trở lại khác biệt tinh vi so với những gì xref gốc đã mô tả. Các tệp đã linearize mang cùng lưu ý đó nhưng từ hướng ngược lại: bố cục trang đầu và các bảng hint trở nên vô nghĩa một khi chỉ mục được tái sinh, nên một tệp đã sửa chữa nên được coi là một tài liệu thông thường, không linearize

var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  try
    if Pdf.LoadFromFile('truncated-invoice.pdf', '') = 1 then
    begin
      if Pdf.GetDocumentRepaired = 1 then
        LogWarning('xref was unusable; the table was reconstructed');
      if Pdf.PageCount > 0 then
        Pdf.SaveToFile('recovered-invoice.pdf');   // writes a clean xref
    end;
  finally
    Pdf.Free;
  end;
end;

Cơ chế dự phòng là tự động: PDFlibPas chạy lượt quét thô bất cứ khi nào chuỗi cross-reference không thể đọc được, và cả khi mọi mục đang dùng đều khai offset bằng không, đây là dấu hiệu của một bảng đã được ghi nhưng chưa từng được điền vào. GetDocumentRepaired trả về 1 khi luồng đó đã chạy, và điều này đáng để ghi log thay vì bỏ qua, vì một tài liệu được tải qua tái tạo nên được lưu lại thành một tệp sạch thay vì bị bỏ lại trong một pipeline như thể không có gì xảy ra. Lưu lại nó sẽ ghi ra một bảng cross-reference mới, nhất quán, đây là cách sửa rẻ nhất có thể cho mọi bên tiêu thụ ở phía sau

Luồng tái tạo, cờ GetDocumentRepaired và bộ tải streaming được trình bày ở đây là một phần của PDFlibPas Delphi PDF Library, cùng với các API phân tích, kết xuất và ký số được đề cập ở nơi khác trên blog này