Bài viết kỹ thuật

Đọc PDF memory-mapped trong Delphi: cửa sổ trượt

PDFlibPas có thể mở PDF cục bộ qua một view memory-mapped chỉ đọc có giới hạn: LoadFromMappedFileDAOpenMappedFile duy trì đúng một cửa sổ trượt trên file, remap khi cần và cung cấp mọi lát object bằng các lần đọc theo offset tuyệt đối. Thư viện PDF Delphi không bao giờ giữ toàn bộ source trong memory, nên mức dùng address space vẫn ổn định khi file lớn dần. Thiết kế này phục vụ một workload cụ thể: PDF hàng gigabyte mà parser đã load xong nhưng vẫn phải quay lại disk, lần lượt theo object và từng mảnh stream

Vì sao sparse read vẫn tốn kém sau khi PDF đã được load?

Load PDF không có nghĩa là đã đọc xong, và với file nhiều gigabyte, chính khoảng cách đó là nơi thời gian bị tiêu tốn. Bảng cross-reference hoặc cross-reference stream (ISO 32000-1 §7.5.4 và §7.5.8) chỉ ghi vị trí bắt đầu của từng indirect object. Byte chỉ đến sau, khi page được render, font program được decode hoặc embedded file stream (ISO 32000-1 §7.11.4) được extract. Một archive 2 GB với hàng chục nghìn object trở thành hàng chục nghìn lần đọc nhỏ, không theo thứ tự, và không lần nào được biết trước lúc load

Trước đây những lần đọc đó đi qua một Seek dùng chung rồi Read trên một positional stream, và cách này thất bại theo hai hướng cùng lúc. Mỗi fragment đều phải trả giá cho một lần đọc file ngay cả khi page đã nằm trong cache của hệ điều hành, còn cursor là mutable state dùng chung, nên file cục bộ và byte-range source phía sau progressive PDF range loading có prefetch không thể chạy cùng parser code mà không tranh chấp position. PDFlibPas sửa cả hai bằng cách nâng việc đọc theo offset tuyệt đối từ một optimization thành một contract

TPDFReadAtStream bảo đảm điều gì?

TPDFReadAtStream bảo đảm việc đọc tại một offset tuyệt đối không phụ thuộc vào và cũng không làm thay đổi logical stream cursor. Đây là một descendant abstract của TStream chỉ có đúng một virtual method, và cả hai source không phụ thuộc cursor trong library đều kế thừa nó: TReadOnlyMappedFileStream cho file cục bộ và TByteRangeStream cho source remote phục vụ theo range. Object-slice reader chỉ hỏi một lần xem source có phải TPDFReadAtStream hay không, rồi fallback về chuỗi seek-then-read cũ nếu không phải, vì vậy file stream hoặc memory stream thông thường vẫn hoạt động như cũ

type
  // Stream chỉ đọc có absolute read, tránh Seek cộng với Read dùng chung
  TPDFReadAtStream = class(TStream)
  public
    function ReadAt(Offset: Int64; var Buffer;
      Count: LongInt): LongInt; virtual; abstract;
  end;

  // Truy cập chỉ đọc theo cửa sổ vào một file cục bộ
  TReadOnlyMappedFileStream = class(TPDFReadAtStream)
  private
    FMemoryMapped: Boolean;
  public
    constructor Create(const FileName: WideString; WindowSize: Int64 = 0);
    function GetStats: TPDFMappedFileStats;
    function ReadAt(Offset: Int64; var Buffer;
      Count: LongInt): LongInt; override;
    property MemoryMapped: Boolean read FMemoryMapped;
  end;

Khác biệt này quan trọng hơn chữ ký method gợi ý. ReadAt dùng offset được truyền vào và giữ Position nguyên đúng như cũ, nhờ đó các level parser lồng nhau có thể đọc mà không cần save-and-restore quanh từng lần gọi. TReadOnlyMappedFileStream vẫn triển khai Read, SeekSize như mọi TStream khác, Seek kẹp logical position vào phạm vi file, còn Write luôn trả về 0 vì source được mở chỉ đọc

Mở PDF qua mapped view trong Delphi

Có hai entry point tường minh để mở mapped source, và không entry point nào thay đổi hành vi của các API bạn đang dùng. LoadFromMappedFile load rồi chọn document; DAOpenMappedFile trả về Direct Access handle trên cùng file, là mode phù hợp khi merge và split PDF hàng gigabyte qua Direct Access. LoadFromFileDAOpenFile giữ nguyên semantics về chia sẻ file, lỗi và compatibility, nên caller không opt in không bị thay đổi. Cả hai mapped entry point nhận WindowSize yêu cầu tính theo byte và một bitmask Options, đồng thời chấp nhận 0 cho cả hai

var
  Pdf: TPDFlib;
  Payload: AnsiString;
  Info: WideString;
begin
  Pdf := TPDFlib.Create;
  try
    // WindowSize 0 chọn mặc định 64 MiB; mapping là bắt buộc ở đây
    if Pdf.LoadFromMappedFile('archive-2026.pdf', '', 0,
      PDF_MAPPED_FILE_REQUIRE_MAPPING) <> 1 then
      raise Exception.CreateFmt('mapped open refused, LastErrorCode=%d',
        [Pdf.LastErrorCode]);

    // Extraction trì hoãn giờ đi qua các cửa sổ mapped thay vì seek
    Payload := Pdf.GetEmbeddedFileContentToString(1);
    if Pdf.GetMappedFileInfo(Info) = 1 then
      Writeln(Info);
  finally
    Pdf.Free;
  end;
end;

PDF_MAPPED_FILE_REQUIRE_MAPPING thực sự ép buộc điều gì?

PDF_MAPPED_FILE_REQUIRE_MAPPING biến fallback im lặng thành lỗi tức thời có thể chẩn đoán ngay lúc open. Khi để Options bằng 0, cả hai entry point chấp nhận fallback sang file stream chỉ đọc: nếu platform không có code mapping hoặc lời gọi mapping thất bại, document vẫn mở và mọi lần đọc đi qua file stream thông thường. Khi bật flag, PDFlibPas chỉ nhận input sau khi view đầu tiên đã được thiết lập, và báo từ chối qua LastErrorCode 401 thay vì load một document âm thầm hoạt động y hệt đường cũ

Trên Windows, mapped stream mở thêm một handle chỉ đọc với FILE_SHARE_READ, FILE_SHARE_WRITEFILE_SHARE_DELETE cùng FILE_FLAG_RANDOM_ACCESS, tạo mapping PAGE_READONLY trên handle đó và map cửa sổ đầu tiên ngay trong constructor. Mapping eager là toàn bộ mục đích: lỗi "mapping required" xuất hiện tại LoadFromMappedFile, không phải ở lần đọc object lazy đầu tiên giữa một job render. Tuy nhiên cần rõ guarantee dừng ở đâu. Code mapping chỉ được compile cho target Windows, và file zero-byte hoàn toàn không thử mapping, nên PDF_MAPPED_FILE_REQUIRE_MAPPING là một yêu cầu có thể thất bại hợp lệ chứ không phải lời hứa portable. WindowSize âm hoặc bất kỳ bit nào trong Options ngoài giá trị được tài liệu hóa đều bị từ chối ngay với cùng lỗi 401

Một cửa sổ, remap theo allocation granularity

Chỉ một view được giữ lại tại mọi thời điểm, nhờ vậy address-space use không phụ thuộc kích thước file. WindowSize bằng 0 chọn 64 MiB; giá trị nhỏ hơn system allocation granularity được nâng lên bằng nó; giá trị lớn hơn 1 GiB bị giới hạn; rồi kết quả được làm tròn lên số nguyên đơn vị granularity, 65536 byte trên Windows trừ khi GetSystemInfo báo dwAllocationGranularity khác. Khi một lần đọc rơi ngoài view hiện tại, PDFlibPas unmap nó, align offset yêu cầu xuống boundary của granularity và map một cửa sổ mới tại đó. Cửa sổ cuối được kẹp theo kích thước file vật lý, nên view không bao giờ vượt quá cuối file

Một lần đọc có thể băng qua bất kỳ số cửa sổ nào: loop copy phần mà view hiện tại cung cấp, remap rồi tiếp tục, còn request chạy quá cuối sẽ trả về short count thay vì thất bại. Điều PDFlibPas cố ý không làm là đưa cho bạn pointer vào view, vì lần đọc cross-window tiếp theo sẽ invalidate nó và không caller nào có thể phòng thủ hợp lý. Byte mapped được copy thẳng vào buffer đích do parser sở hữu, loại bỏ file input buffer phụ và việc đổi position, nhưng library không tuyên bố zero-copy cho storage cuối của parser. Windowing phía đọc cũng phối hợp được với phía ghi, vì byte-level reference shifting trong fast PDF merge stream object byte ra trong khi mapped source stream chúng vào. Trade-off về kích thước cửa sổ khá rõ: cửa sổ nhỏ giữ ít address space hơn và remap thường xuyên hơn, thường là lựa chọn đúng trong process 32-bit

Lock bảo vệ gì, và GetMappedFileInfo báo cáo gì

Một critical section bao phủ mapped view, fallback file cursor, logical position và statistics, còn sự phân chia giữa hai read method xuất phát trực tiếp từ đó. ReadAt lấy lock rồi gọi internal reader không lock; Read lấy cùng lock, gọi internal reader tại logical position hiện tại rồi tăng nó. Tái sử dụng internal function thay vì public ReadAt giúp tránh recursive locking, còn giữ lock trong toàn bộ copy loop giúp remap single-window vẫn đúng khi có call đồng thời. Có một chi tiết Free Pascal đáng biết trước khi port: unit Windows của FPC tự khai báo record tên TCriticalSection, nên field và phần khởi tạo phải viết là SyncObjs.TCriticalSection. Delphi vui vẻ compile dạng không qualify, còn FPC resolve nó thành record không có Create, Enter hay Leave

var
  Pdf: TPDFlib;
  Handle, PageRef: Integer;
  Info: WideString;
begin
  Pdf := TPDFlib.Create;
  try
    Handle := Pdf.DAOpenMappedFile('archive-2026.pdf', '',
      16 * 1024 * 1024, PDF_MAPPED_FILE_REQUIRE_MAPPING);
    if Handle = 0 then
      Exit;
    try
      PageRef := Pdf.DAFindPage(Handle, 1);
      Writeln(Pdf.DAExtractPageText(Handle, PageRef, 0));

      // {"memoryMapped":true,"fileSize":...,"remapCount":...}
      if Pdf.DAGetMappedFileInfo(Handle, Info) = 1 then
        Writeln(Info);
    finally
      Pdf.DACloseFile(Handle);
    end;
  finally
    Pdf.Free;
  end;
end;
  • memoryMapped là false khi fallback file-stream portable đang hoạt động, và đây là field duy nhất chứng minh mapping chưa từng được thiết lập
  • windowSize là cửa sổ hiệu dụng đã align chứ không phải giá trị bạn yêu cầu, còn mappedBytes nhỏ hơn nó ở cửa sổ cuối
  • mappedOffset là điểm bắt đầu đã align theo allocation của view được giữ lại, hoặc -1 khi hiện không có view hoạt động
  • readCalls đếm các request đọc trong phạm vi đã thành công, bytesRead đếm byte copy cho caller, còn remapCount bao gồm cả view ban đầu

Regression mục tiêu bao phủ absolute read xuyên cửa sổ, bảo toàn logical cursor, short read ở cuối, offset không hợp lệ, write bị từ chối, remap giữa các cửa sổ cách xa nhau, extraction trì hoãn một attachment không nén 220 KB và statistics chuyển thành invalid sau DACloseFile; các headless suite Win32 và Win64 đều phát hiện 1467 test và pass toàn bộ, không có kết quả ignored, failed, errored hay leaked. Nếu bạn làm việc với PDF hàng gigabyte trong Delphi hoặc C++Builder và profiler liên tục trỏ vào file read thay vì parsing, mapped-file entry point đáng để đo thử một buổi, còn GetMappedFileInfo sẽ cho biết bạn có thực sự nhận được mapping hay không. API reference đầy đủ và bản trial nằm trên trang thư viện PDF PDFlibPas cho Delphi