Технічна стаття

Memory-mapped читання PDF у Delphi: ковзне вікно

PDFlibPas може відкрити локальний PDF через обмежене memory-mapped view лише для читання: LoadFromMappedFile і DAOpenMappedFile утримують над файлом рівно одне ковзне вікно, перемаплюють його за потреби й віддають кожен фрагмент object через читання за абсолютним offset. Delphi PDF library ніколи не тримає весь source у пам’яті, тому використання address space не зростає разом із файлом. Цю конструкцію створено для одного workload: гігабайтних PDF, де parser уже завершив завантаження, але все ще повертається на диск за object-ами та фрагментами stream-ів

Чому розріджені читання залишаються дорогими після завантаження PDF?

Завантаження PDF не означає, що його читання завершено, і в багатогігабайтному файлі саме на цю різницю йде час. Cross-reference table або cross-reference stream (ISO 32000-1 §7.5.4 і §7.5.8) лише записує, де починається кожен indirect object. Байти надходять пізніше, коли рендериться сторінка, декодується font program або витягується embedded file stream (ISO 32000-1 §7.11.4). Архів розміром 2 GB із десятками тисяч object-ів перетворюється на десятки тисяч малих невпорядкованих read-ів, і під час load жоден із них не відомий

Раніше ці read-и проходили через спільний Seek, за яким ішов Read одного positional stream, і це одразу створювало дві проблеми. Кожен фрагмент оплачував file read, навіть коли сторінка вже була в cache операційної системи, а cursor був спільним mutable state, тому локальний file і byte-range source за прогресивним завантаженням PDF-діапазонів із prefetch не могли виконувати той самий parser code, не конкуруючи за position. PDFlibPas виправляє обидві проблеми, перетворюючи читання за абсолютним offset із оптимізації на контракт

Що гарантує TPDFReadAtStream?

TPDFReadAtStream гарантує читання за абсолютним offset, яке не залежить від logical stream cursor і не змінює його. Це abstract descendant від TStream з рівно одним virtual method, і від нього походять обидва cursor-independent source у library: TReadOnlyMappedFileStream для local files та TByteRangeStream для remote source, що обслуговуються діапазонами. Object-slice reader один раз перевіряє, чи є його source TPDFReadAtStream, і якщо ні, повертається до старої послідовності seek-then-read, тож звичайні file stream або memory stream продовжують працювати без змін

type
  // Read-only streams, чиї absolute reads уникають спільного Seek плюс Read
  TPDFReadAtStream = class(TStream)
  public
    function ReadAt(Offset: Int64; var Buffer;
      Count: LongInt): LongInt; virtual; abstract;
  end;

  // Windowed read-only доступ до одного local file
  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;

Різниця важливіша, ніж може здатися з підпису. ReadAt використовує переданий offset і залишає Position точно там, де він був, тому вкладені parser levels можуть виконувати read-и без save-and-restore навколо кожного виклику. TReadOnlyMappedFileStream все одно реалізує Read, Seek і Size, як будь-який інший TStream, Seek обмежує logical position межами file, а Write завжди повертає 0, бо source відкрито лише для читання

Відкриття PDF через mapped view у Delphi

Два явні entry points відкривають mapped source, і жоден не змінює поведінку entry points, які ви вже використовуєте. LoadFromMappedFile завантажує та вибирає document; DAOpenMappedFile повертає Direct Access handle над тим самим file — саме цей режим потрібен для об’єднання й розділення гігабайтних PDF через Direct Access. LoadFromFile і DAOpenFile зберігають свої file sharing, error та compatibility semantics, тому для caller-ів, які не вмикають новий режим, нічого не змінюється. Обидва mapped entry points приймають запитаний у байтах WindowSize і bitmask Options, причому для кожного можна передати 0

var
  Pdf: TPDFlib;
  Payload: AnsiString;
  Info: WideString;
begin
  Pdf := TPDFlib.Create;
  try
    // WindowSize 0 вибирає типовий розмір 64 MiB; mapping тут обов’язковий
    if Pdf.LoadFromMappedFile('archive-2026.pdf', '', 0,
      PDF_MAPPED_FILE_REQUIRE_MAPPING) <> 1 then
      raise Exception.CreateFmt('mapped open refused, LastErrorCode=%d',
        [Pdf.LastErrorCode]);

    // Deferred extraction тепер проходить mapped windows замість seek
    Payload := Pdf.GetEmbeddedFileContentToString(1);
    if Pdf.GetMappedFileInfo(Info) = 1 then
      Writeln(Info);
  finally
    Pdf.Free;
  end;
end;

Що насправді забезпечує PDF_MAPPED_FILE_REQUIRE_MAPPING?

PDF_MAPPED_FILE_REQUIRE_MAPPING перетворює тихий fallback на негайний і діагностований failure під час open. Коли Options дорівнює 0, обидва entry points приймають fallback на read-only file stream: якщо платформа не має mapping code або виклик mapping завершується невдачею, document усе одно відкривається, а кожен read проходить через звичайний file stream. Коли прапорець встановлено, PDFlibPas приймає input лише після створення першого view і повідомляє про відмову через LastErrorCode 401, замість того щоб завантажити document, який непомітно працює точно як старий шлях

У Windows mapped stream відкриває другий read-only handle з FILE_SHARE_READ, FILE_SHARE_WRITE і FILE_SHARE_DELETE разом із FILE_FLAG_RANDOM_ACCESS, створює над ним mapping PAGE_READONLY і мапить перше window всередині constructor. Eager mapping — уся суть: failure на кшталт "mapping required" виникає в LoadFromMappedFile, а не під час першого lazy object read посеред render job. Але важливо розуміти межу гарантії. Mapping code компілюється лише для Windows targets, а file нульового розміру взагалі не намагається мапити, тому PDF_MAPPED_FILE_REQUIRE_MAPPING — це запит, який може легітимно провалитися, а не portable promise. Від’ємний WindowSize або будь-який bit у Options, крім єдиного документованого значення, відхиляється з тією самою помилкою 401

Одне window, перемаплене за allocation granularity

Утримується лише одне view, і саме це робить використання address space незалежним від розміру file. WindowSize 0 вибирає 64 MiB; значення нижче system allocation granularity піднімається до неї; значення понад 1 GiB обмежується; а результат округлюється вгору до цілого числа granularity units — 65536 bytes у Windows, якщо GetSystemInfo не повідомить інше значення dwAllocationGranularity. Коли read виходить за межі поточного view, PDFlibPas відмаплює його, вирівнює запитаний offset вниз до granularity boundary і мапить нове window там. Останнє window обмежується фізичним розміром file, тому view ніколи не виходить за його кінець

Один read може перетнути будь-яку кількість windows: loop копіює все, що може віддати поточне view, перемаплює його й продовжує, а запит, що виходить за кінець, повертає short count, а не failure. PDFlibPas навмисно не видає вам pointer усередину view, бо наступний cross-window read зробить його недійсним, і caller не зміг би надійно від цього захиститися. Маплені байти копіюються прямо в destination buffers, якими володіє parser, що прибирає зайвий file input buffer і перемикання position, але library не обіцяє zero-copy для фінального parser storage. Windowing на read side також поєднується з write side, оскільки байтове зміщення reference під час швидкого PDF merge виводить object bytes, поки mapped source подає їх на вхід. Компроміс щодо window size очевидний: менше window займає менше address space і частіше перемаплюється, що зазвичай правильно для 32-бітного process

Що захищає lock і що повідомляє GetMappedFileInfo

Одна critical section охоплює mapped view, fallback file cursor, logical position і statistics, а розділення двох read methods прямо випливає з цього. ReadAt бере lock і викликає lock-free internal reader; Read бере той самий lock, викликає internal reader з поточною logical position, а потім пересуває її. Повторне використання internal function замість public ReadAt уникає recursive locking, а утримання lock протягом усього copy loop забезпечує коректний remap одного window під час concurrent calls. Перед porting варто знати одну деталь Free Pascal: його unit Windows оголошує власний record з іменем TCriticalSection, тому field і його construction мають бути записані як SyncObjs.TCriticalSection. Delphi без проблем компілює некваліфіковану форму; FPC розв’язує її до record без Create, Enter або 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 має значення false, коли активний portable file-stream fallback, і це єдине поле, яке доводить, що mapping ніколи не створювався
  • windowSize — це фактичне вирівняне window, а не запитане значення, і mappedBytes менше за нього в tail window
  • mappedOffset — вирівняний за allocation start утримуваного view або -1, коли активного view немає
  • readCalls рахує успішні read requests у межах file, bytesRead — байти, скопійовані caller-ам, а remapCount включає initial view

Цільові regressions охоплюють absolute reads через межі windows, збереження logical cursor, short reads у хвості, invalid offsets, відхилені writes, remapping між розділеними windows, deferred extraction incompressible attachment розміром 220 KB і invalidation statistics після DACloseFile; headless suites Win32 та Win64 кожна виявила 1467 tests і пройшла їх усі без ignored, failed, errored чи leaked results. Якщо ви працюєте з гігабайтними PDF у Delphi або C++Builder і profiler постійно вказує на file reads, а не на parsing, mapped-file entry points варті кількох вимірювань, а GetMappedFileInfo покаже, чи справді ви отримали mapping. Повна API reference і trial build доступні на сторінці PDFlibPas Delphi PDF library