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