Bài viết kỹ thuật

Lỗi Kiểm tra Phạm vi trong Thư viện PDF Delphi: Nguyên nhân Gốc rễ

Lỗi kiểm tra phạm vi (range check errors) trong các thư viện PDF Delphi nổi tiếng là khó xác định vì chúng không tuân theo một mẫu đầu vào nhất quán. Cùng một tài liệu lại tạo ra lỗi trên máy này mà không phải máy khác; cùng một đường dẫn mã kích hoạt ngoại lệ trên một tệp 3 trang nhưng lại chạy trơn tru trên tệp 12 trang. Sự không nhất quán đó hầu như luôn bắt nguồn từ một nguyên nhân gốc rễ duy nhất: các đối tượng trang PDF không được lưu trữ theo thứ tự tệp. Nếu thư viện xây dựng mảng trang nội bộ của nó bằng cách quét các đối tượng một cách tuần tự thay vì đi qua cây trang (page tree) được khai báo bởi danh mục (catalog), nó sẽ xây dựng một chỉ mục có phạm vi hợp lệ không khớp với những gì người gọi mong đợi, và tính năng kiểm tra phạm vi sẽ bắt lỗi sự không khớp đó vào thời điểm tồi tệ nhất có thể

Cách thức hoạt động của kiểm tra phạm vi trong Delphi

Khi chỉ thị biên dịch (compiler directive) {$R+} hoạt động (mặc định trong cấu hình Gỡ lỗi - Debug), Delphi RTL xác thực mọi chỉ số mảng, chỉ số phụ chuỗi và gán bảng liệt kê tại thời điểm chạy. Việc truy cập ngoài giới hạn sẽ gây ra ERangeError thay vì âm thầm đọc bộ nhớ lân cận. Hành vi đó rất có giá trị: nó phát hiện sớm các lỗi tiềm ẩn thay vì để chúng làm hỏng cấu trúc dữ liệu mà chỉ gặp sự cố ở một trăm dòng lệnh sau đó. Phần gây bực bội là ngoại lệ kích hoạt tại trang web truy cập (access site), chứ không phải tại thời điểm chỉ mục được tính toán không chính xác. Khi ngăn xếp lệnh gọi (call stack) hiển thị một phương thức lồng nhau sâu sắc trong một đơn vị PDF, lỗi thực sự thường nằm ở vài khung hình trước đó

Các điều kiện boolean phức hợp làm điều này tồi tệ hơn. Delphi đánh giá các biểu thức and từ trái sang phải với ngữ nghĩa đoản mạch (short-circuit semantics), nhưng phép đoản mạch chỉ bỏ qua việc đánh giá khi vế trái là False. Một biểu thức giống như:

if FDocStarted and (DestIndex < Length(PageArr)) and
   (PageArr[DestIndex].PageObj <> nil) then

trông có vẻ an toàn, nhưng nó chỉ bảo vệ khỏi một chỉ mục nằm ngoài phạm vi nếu FDocStartedTrueDestIndex không âm. Việc kiểm tra DestIndex < Length(PageArr) không có tác dụng gì khi DestIndex là số âm, vì việc so sánh một số nguyên âm với một độ dài không âm sẽ trả về True trong số học có dấu (signed arithmetic) và việc truy cập mảng sau đó vẫn gây ra lỗi kiểm tra phạm vi. Di chuyển việc kiểm tra giới hạn ra vị trí ngoài cùng là cách khắc phục đúng đắn:

if (DestIndex >= 0) and (DestIndex < Length(PageArr)) then
begin
  if FDocStarted and (PageArr[DestIndex].PageObj <> nil) then
    Result := PageArr[DestIndex].PageObj
  else
    Result := nil;
end
else
  raise ERangeError.CreateFmt(
    'Page index %d is out of range (0..%d)',
    [DestIndex, Length(PageArr) - 1]);

Đây là cách khắc phục theo kiểu cơ học. Nó ngăn chặn sự cố. Nó không giải thích được lý do tại sao DestIndex ngay từ đầu lại nhận được giá trị nằm ngoài phạm vi hợp lệ

Nguyên nhân thực sự: thứ tự đối tượng so với thứ tự trang

ISO 32000-1 §7.7.3 định nghĩa cây trang là cây của các nút Pages mà trong đó mảng Kids liệt kê các đối tượng trang theo thứ tự hiển thị. Tệp lưu trữ những đối tượng đó tại bất kỳ phần bù đắp (offsets) nào mà người ghi tình cờ chọn; đối tượng số 20 có thể đứng trước đối tượng số 3 về mặt vật lý trong luồng byte. Một thư viện xây dựng danh sách trang của nó bằng cách lặp lại bảng tham chiếu chéo (cross-reference table) theo thứ tự số đối tượng thay vì đi theo chuỗi Kids sẽ tạo ra một trình tự khác biệt với những gì người dùng mong đợi. Trên các tài liệu mà trình tạo tình cờ viết các trang theo thứ tự, mọi thứ đều hoạt động. Trên các tài liệu không như vậy, sự khác biệt giữa cách đánh số trang của thư viện và cách đánh số trang của người gọi tạo ra các chỉ số nằm ngoài PageArr

Cách tiếp cận đúng là bắt đầu từ danh mục (catalog), giải quyết tham chiếu gián tiếp /Pages và duyệt qua mảng Kids theo cách đệ quy. Đối với một tài liệu phẳng không có các nút Pages trung gian, quá trình duyệt rất đơn giản:

procedure BuildPageIndexFromTree(
  const KidsArray: THPDFArray;
  var PageArr: TPageObjArray);
var
  i, Idx: Integer;
  Child: THPDFObject;
  ChildType: string;
begin
  for i := 0 to KidsArray.Count - 1 do
  begin
    Child := KidsArray.GetIndirectObject(i);
    if Child = nil then
      Continue;
    ChildType := Child.GetNameValue('/Type');
    if ChildType = 'Page' then
    begin
      Idx := Length(PageArr);
      SetLength(PageArr, Idx + 1);
      PageArr[Idx].PageObj := Child;
    end
    else if ChildType = 'Pages' then
    begin
      // intermediate node: recurse into its Kids
      BuildPageIndexFromTree(Child.GetArray('/Kids'), PageArr);
    end;
  end;
end;

Sau khi mã này chạy, PageArr[0] là trang đầu tiên mà trình xem (viewer) sẽ hiển thị, bất kể đối tượng đó nằm ở đâu trong luồng byte. Các chỉ mục do người gọi chuyển sang (giả sử thứ tự hiển thị) giờ đã ánh xạ chính xác và các lỗi kiểm tra phạm vi sẽ dừng lại

Cách khắc phục cứng (hard-coded) làm trầm trọng thêm vấn đề

Trong các cơ sở mã mà nguyên nhân gốc rễ chưa bao giờ được xác định, người ta thường tìm thấy các bản vá theo kinh nghiệm (heuristic patches): hoán đổi trang đầu và trang cuối nếu tổng số bằng 3, xoay chỉ mục đối với các tài liệu từ một trình tạo (generator) cụ thể, áp dụng một phần bù đắp (offset) khi số đối tượng đầu tiên vượt quá ngưỡng. Mỗi bản vá lỗi này phù hợp chính xác với tập hợp các tệp thử nghiệm hiện có khi nó được viết. Thêm một nguồn PDF khác và một trong các bản vá lỗi đó sẽ kích hoạt sai thời điểm, tạo ra một chỉ mục giờ lại sai hai lần: sai vì nó được tính toán từ một mảng không theo thứ tự, và sai lần nữa vì một ánh xạ không thể áp dụng đã được áp dụng chồng lên trên. Trình kiểm tra phạm vi (range checker) sẽ bắt được nó ở đâu đó phía sau và dấu vết ngăn xếp (stack trace) không trỏ đến điểm hữu ích nào cả

Con đường hiệu quả duy nhất là loại bỏ mọi ánh xạ theo kinh nghiệm và thay thế việc xây dựng mảng trang bằng cách duyệt cây (tree walk) thích hợp. Khi các chỉ mục được tạo dựng chính xác, không cần bản vá nào nữa và trình kiểm tra phạm vi sẽ trở thành một lợi thế thay vì là một trở ngại

Nếu bạn đang duy trì một thư viện có mẫu lỗi này, hãy tạm thời bật tính năng kiểm tra phạm vi trong bản dựng Release và chạy nó dựa trên một tập hợp tài liệu PDF đa dạng: tài liệu được tạo bởi Word, bởi LaTeX, bởi chương trình cơ sở máy quét (scanner firmware), bởi các tiện ích chia nhỏ PDF sang PDF. Các tệp gây ra ngoại lệ là những tệp có thứ tự đối tượng trang khác biệt với thứ tự duyệt mà mã của bạn giả định. Mỗi một tệp đó là một điểm dữ liệu, chứ không phải là một lỗi riêng biệt

Đối với mã mới gọi đến thư viện PDF Delphi, lời khuyên thực tế là hãy coi số lượng trang của thư viện là có thẩm quyền (authoritative) và không bao giờ chuyển một chỉ mục có nguồn gốc từ số học trên dữ liệu bên ngoài mà không xác nhận trước rằng nó nằm trong phạm vi 0..PageCount - 1. Thành phần HotPDF đưa ra số trang đã được giải quyết thông qua THotPDF.PageCount sau khi thực hiện BeginDoc hoặc sau khi tải tài liệu; giá trị đó luôn phản ánh quá trình duyệt cây trang và an toàn để sử dụng làm giới hạn trên cho mọi số học chỉ mục