Техническая статья

Чтение PDF через memory-mapped окно в Delphi

PDFlibPas может открыть локальный PDF через ограниченное memory-mapped представление только для чтения: LoadFromMappedFile и DAOpenMappedFile удерживают ровно одно скользящее окно над файлом, по необходимости перемапляют его и обслуживают каждый срез объекта через чтение по абсолютному смещению. Библиотека PDF для Delphi никогда не держит весь источник в памяти, поэтому использование адресного пространства остаётся постоянным при росте файла. Этот дизайн предназначен для одной нагрузки: гигабайтных PDF, где парсер уже закончил первоначальную загрузку, но продолжает обращаться к диску — объект за объектом, фрагмент потока за фрагментом потока

Почему разреженные чтения остаются дорогими после загрузки PDF?

Загрузка PDF не означает, что чтение завершено, и в многогигабайтном файле именно на этот разрыв уходит время. Таблица перекрёстных ссылок или поток перекрёстных ссылок (ISO 32000-1 §7.5.4 и §7.5.8) сообщает только, где начинается каждый косвенный объект. Сами байты приходят позже, когда рендерится страница, декодируется программа шрифта или извлекается поток встроенного файла (ISO 32000-1 §7.11.4). Архив размером 2 ГБ с десятками тысяч объектов превращается в десятки тысяч небольших неупорядоченных чтений, и во время загрузки ни одно из них не известно заранее

Раньше эти чтения шли через общий Seek, за которым следовал Read одного позиционного потока, и этот путь сразу давал два класса проблем. Каждый фрагмент оплачивался отдельным чтением файла, даже если страница уже находилась в кэше операционной системы, а курсор был общей изменяемой переменной, поэтому локальный файл и byte-range-источник за прогрессивной загрузкой диапазонов PDF с prefetch не могли использовать один и тот же код парсера, не борясь за позицию. PDFlibPas исправляет оба недостатка, превращая чтение по абсолютному смещению из оптимизации в контракт

Что гарантирует TPDFReadAtStream?

TPDFReadAtStream гарантирует чтение по абсолютному смещению, которое не зависит от логического курсора потока и не изменяет его. Это абстрактный наследник TStream ровно с одним виртуальным методом, и от него происходят оба источника, независимых от курсора: TReadOnlyMappedFileStream для локальных файлов и TByteRangeStream для удалённых источников, обслуживаемых диапазонами. Читатель срезов объектов один раз проверяет, является ли источник TPDFReadAtStream, и возвращается к старой последовательности seek-then-read, если это не так, поэтому обычный файловый поток или поток памяти продолжает работать без изменений

type
  // Потоки только для чтения, абсолютные чтения которых не зависят от общей пары Seek и Read
  TPDFReadAtStream = class(TStream)
  public
    function ReadAt(Offset: Int64; var Buffer;
      Count: LongInt): LongInt; virtual; abstract;
  end;

  // Оконный доступ только для чтения к одному локальному файлу
  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 использует переданное ему смещение и оставляет Position ровно там, где он был, поэтому вложенные уровни парсера могут выполнять чтения без сохранения и восстановления позиции вокруг каждого вызова. TReadOnlyMappedFileStream по-прежнему реализует Read, Seek и Size как любой другой TStream, Seek ограничивает логическую позицию пределами файла, а Write всегда возвращает 0, поскольку источник открыт только для чтения

Открытие PDF через mapped view в Delphi

Для открытия mapped-источника есть две явные точки входа, и ни одна не меняет поведение уже используемых точек входа. LoadFromMappedFile загружает и выбирает документ, а DAOpenMappedFile возвращает Direct Access handle над тем же файлом — именно этот режим нужен при слиянии и разделении гигабайтных PDF через Direct Access. LoadFromFile и DAOpenFile сохраняют прежнюю семантику совместного доступа к файлу, ошибок и совместимости, поэтому для не подключившихся к новой возможности вызывающих мест ничего не меняется. Обе mapped-точки входа принимают запрошенный в байтах WindowSize и битовую маску 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]);

    // Отложенное извлечение теперь проходит по 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 в немедленный и диагностируемый сбой во время открытия. Если Options оставить равным 0, обе точки входа принимают fallback на файловый поток только для чтения: если на платформе нет кода mapping или вызов mapping завершается ошибкой, документ всё равно открывается, а каждое чтение проходит через обычный файловый поток. При установленном флаге PDFlibPas принимает вход только после создания первого view и сообщает об отказе через LastErrorCode 401, вместо того чтобы загрузить документ, который незаметно работает ровно по старому пути

В Windows mapped-поток открывает второй handle только для чтения с FILE_SHARE_READ, FILE_SHARE_WRITE и FILE_SHARE_DELETE вместе с FILE_FLAG_RANDOM_ACCESS, создаёт над ним mapping PAGE_READONLY и отображает первое окно прямо в конструкторе. Раннее создание mapping — весь смысл этой возможности: ошибка «mapping обязателен» проявляется в LoadFromMappedFile, а не при первом ленивом чтении объекта где-то посреди задачи рендеринга. Но важно понимать границу гарантии. Код mapping компилируется только для целей Windows, а файл нулевой длины вообще не пытается создавать mapping, поэтому PDF_MAPPED_FILE_REQUIRE_MAPPING — это запрос, который может законно завершиться отказом, а не переносимое обещание. Отрицательный WindowSize или любой бит в Options, кроме единственного документированного значения, сразу отклоняется с той же ошибкой 401

Одно окно, перемапленное с учётом гранулярности размещения

Удерживается только одно view, и именно это делает использование адресного пространства независимым от размера файла. WindowSize 0 выбирает 64 MiB; значение меньше системной гранулярности размещения поднимается до неё; значение больше 1 GiB ограничивается; затем результат округляется вверх до целого числа единиц гранулярности — 65536 байт в Windows, если только GetSystemInfo не сообщит другое значение dwAllocationGranularity. Когда чтение выходит за пределы текущего view, PDFlibPas снимает его, выравнивает запрошенное смещение вниз по границе гранулярности и отображает новое окно с этой позиции. Последнее окно ограничивается физическим размером файла, поэтому view никогда не выходит за его конец

Одно чтение может пересечь любое число окон: цикл копирует всё, что может предоставить текущее view, перемапляет его и продолжает, а запрос, уходящий за конец файла, возвращает короткий результат, а не ошибку. PDFlibPas намеренно не выдаёт указатель внутрь view, потому что следующее чтение через границу окна сделает его недействительным, и у вызывающего кода не было бы разумного способа от этого защититься. Байты из mapping копируются прямо в буферы назначения, принадлежащие парсеру, что убирает дополнительный входной буфер файла и переключение позиции, но библиотека не заявляет об отсутствии копирования в итоговое хранилище парсера. Оконное чтение хорошо сочетается и со стороной записи, поскольку побайтовое смещение ссылок при быстром слиянии PDF выводит байты объектов, пока mapped-источник подаёт их на вход. Компромисс размера окна очевиден: маленькое окно занимает меньше адресного пространства и чаще перемапляется, что обычно правильно внутри 32-битного процесса

Что защищает блокировка и что сообщает GetMappedFileInfo

Одна критическая секция охватывает mapped view, курсор fallback-файла, логическую позицию и статистику, а разделение между двумя методами чтения напрямую из этого следует. ReadAt захватывает блокировку и вызывает внутренний reader без блокировки; Read захватывает ту же блокировку, вызывает тот же внутренний reader на текущей логической позиции и затем продвигает её. Повторное использование внутренней функции вместо публичного ReadAt предотвращает рекурсивную блокировку, а удержание блокировки на протяжении всего цикла копирования сохраняет корректность перемапления одного окна при конкурентных вызовах. Перед переносом стоит знать одну деталь Free Pascal: модуль FPC Windows объявляет собственную запись с именем TCriticalSection, поэтому поле и его создание нужно записывать как SyncObjs.TCriticalSection. Delphi спокойно компилирует неквалифицированную форму, а FPC разрешает её в запись без 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, когда активен переносимый fallback на файловый поток, и это единственное поле, доказывающее, что mapping не был создан
  • windowSize — фактическое выровненное окно, а не запрошенное значение, а mappedBytes в хвостовом окне меньше него
  • mappedOffset — выровненное по гранулярности размещения начало удерживаемого view или -1, если активного view сейчас нет
  • readCalls считает успешные запросы чтения в пределах диапазона, bytesRead — байты, скопированные вызывающему коду, а remapCount включает первое view

Целевые регрессионные тесты покрывают абсолютные чтения через границы окон, сохранение логического курсора, короткие чтения в хвосте, недопустимые смещения, отклонённые записи, перемапление между разнесёнными окнами, отложенное извлечение несжимаемого вложения размером 220 KB и переход статистики в недействительное состояние после DACloseFile; безголовые наборы Win32 и Win64 обнаружили по 1467 тестов и прошли их все без пропущенных, проваленных, аварийных тестов или утечек. Если вы работаете с гигабайтными PDF в Delphi или C++Builder, а профайлер продолжает указывать на чтение файлов, а не на парсинг, mapped-точки входа заслуживают отдельного измерительного эксперимента, а GetMappedFileInfo покажет, действительно ли mapping был получен. Полная справка API и trial build находятся на странице библиотеки PDFlibPas PDF для Delphi