Отсканированный архив может достигать нескольких гигабайт в одном PDF-файле. Программа просмотра, открывающая такой файл, обычно хочет показать одну страницу, возможно, оглавление или страницу, на которую пользователь перешел по закладке. Чтение всего файла в память для рендеринга двух страниц расточительно по всем направлениям: это сжигает адресное пространство, задерживает пользователя за долгим первоначальным чтением, а в 32-битном процессе Delphi это может привести к сбою еще до появления первой страницы. PDFium создавался с учетом этого. Он может загружать документ через функцию обратного вызова, которая запрашивает конкретные диапазоны байтов по мере необходимости, и он никогда не требует весь файл целиком. Одну границу стоит обозначить заранее: этот потоковый канал описывает файл 32-битной длиной, поэтому он обслуживает один файл размером до 4 ГиБ, что на практике покрывает почти каждый отсканированный архив. Файл, выходящий за эту границу, не относится к теме данной статьи; его следует либо разделить на тома во время сканирования, либо открыть с помощью стратегии прямого доступа, и защита, обеспечивающая соблюдение этого потолка, честно получает отдельный раздел ниже
Компонент предоставляет этот путь через адаптер потока. Вы передаете ему любой TStream, и PDFium извлекает блоки из этого потока по запросу. Файл может находиться на диске, в поле blob базы данных или в любом другом потомке TStream, и ничто из этого не копируется в память заранее
Как PDFium запрашивает байты
C API PDFium загружает документ из предоставленного вызывающей стороной объекта, описываемого структурой FPDF_FILEACCESS. Эта структура имеет три важные в данном контексте части: поле длины, функцию обратного вызова для чтения и непрозрачный пользовательский параметр. Точкой входа, которая ее использует, является FPDF_LoadCustomDocument. Как только PDFium получает эту структуру, он анализирует трейлер, находит таблицу перекрестных ссылок и с этого момента считывает только то, что требуется для конкретной операции. Открытие документа затрагивает хвост файла и небольшое количество объектов каталога. Отрисовка страницы 400 считывает потоки содержимого и ресурсы для этой страницы и ничего больше
В этом заключается разница между буферизованной загрузкой и потоковой загрузкой. Буферизованная загрузка считывает файл от начала до конца, прежде чем PDFium увидит нулевой байт. Потоковая загрузка переворачивает это отношение: PDFium управляет чтением, и байты, которые никогда не затрагиваются, никогда не читаются. Для многогигабайтного файла, просматриваемого по одной странице за раз, это разрыв между непригодной для использования загрузкой и мгновенной загрузкой
Адаптер потока
Адаптер, который связывает Delphi TStream с FPDF_FILEACCESS, — это TPdfStreamAdapter. Его конструктор принимает поток и флаг владения, один раз фиксирует длину потока, заполняет запись FPDF_FILEACCESS и подключает колбэк чтения. Когда PDFium позже выполняет обратный вызов со смещением и размером, адаптер перемещает указатель потока к этому смещению и копирует ровно этот диапазон в буфер, предоставленный PDFium
// Verbatim from the component: the stream-to-FPDF_FILEACCESS bridge
constructor TPdfStreamAdapter.Create(AStream: TStream; AOwnsStream: Boolean);
begin
inherited Create;
if AStream = nil then
raise EPdfError.Create('TPdfStreamAdapter: AStream is nil');
FStream := AStream;
FOwnsStream := AOwnsStream;
// FPDF_FILEACCESS.m_FileLen is a 32-bit unsigned long. Refuse a stream
// that would silently truncate past 4 GiB.
if AStream.Size > High(FPDF_DWORD) then
raise EPdfError.Create('TPdfStreamAdapter: stream exceeds the 4 GiB limit');
FillChar(FFileAccess, SizeOf(FFileAccess), 0);
FFileAccess.m_FileLen := FPDF_DWORD(AStream.Size);
FFileAccess.m_GetBlock := GetBlockCallback;
FFileAccess.m_Param := Self;
end;
Флаг владения решает, кто освобождает поток. Передайте False, и вызывающая сторона сохранит поток и должна будет поддерживать его жизнь на протяжении всего времени жизни документа. Передайте True, и адаптер берет на себя ответственность, освобождая поток при закрытии документа. В любом случае поток должен пережить каждое чтение, которое выполнит PDFium, потому что PDFium хранит указатель FPDF_FILEACCESS и будет выполнять обратный вызов в любой момент, пока документ открыт, а не только во время начальной загрузки
Почему функция обратного вызова является статической
Функция обратного вызова чтения, которую PDFium хранит в m_GetBlock, — это простой указатель на функцию C с соглашением о вызовах cdecl. Метод Delphi нельзя использовать напрямую, потому что он содержит скрытый аргумент Self, о котором вызывающая сторона C ничего не знает и никогда не предоставит. Поэтому адаптер объявляет колбэк как функцию класса (class function), помеченную cdecl; static, которая компилируется в автономную функцию с компоновкой фрейма C, ожидаемой PDFium, и без неявного Self
Это решает проблему с соглашением о вызовах, но поднимает второй вопрос: как без Self колбэк получает доступ к конкретному потоку, из которого он должен читать? Ответом является непрозрачный пользовательский параметр. Когда адаптер создает запись, он сохраняет указатель на свой собственный экземпляр в m_Param. PDFium возвращает тот же самый указатель в качестве первого аргумента каждого обратного вызова. Статическая функция преобразует его обратно в TPdfStreamAdapter и отправляет чтение в поток этого экземпляра. Это стандартный трамплин для передачи контекста объекта через границу C, которая не имеет понятия об объектах
// Verbatim from the component: the cdecl trampoline back to the instance
class function TPdfStreamAdapter.GetBlockCallback(
param : Pointer;
position: FPDF_DWORD;
pBuf : PByte;
size : FPDF_DWORD): Integer; cdecl;
var
Adapter: TPdfStreamAdapter;
begin
Result := 0;
if (param = nil) or (pBuf = nil) or (size = 0) then
Exit;
Adapter := TPdfStreamAdapter(param); // recover the instance from m_Param
if Adapter.FStream = nil then
Exit;
try
Adapter.FStream.Position := Int64(position);
Adapter.FStream.ReadBuffer(pBuf^, Int64(size));
Result := 1;
except
Result := 0; // report failure by return value, never by raising
end;
end;
Потолок в 4 ГиБ и почему ему нужна защита
Именно отсюда берется граница, указанная в начале. Поле длины m_FileLen в FPDF_FILEACCESS — это 32-битное беззнаковое значение. Его максимальная представимая длина на один байт меньше 4 ГиБ. TStream сообщает свой размер как Int64, поэтому поток может описывать гораздо больше байтов, чем может вместить поле. В тот момент, когда размер потока превышает этот потолок, нет никакого честного способа сказать PDFium, какой длины файл
Неправильный ответ — назначить размер и позволить ему переполниться. Усечение длины 5 ГиБ до 32-битного поля дает небольшое, правдоподобно выглядящее число, и затем PDFium проанализирует файл, полагая, что он заканчивается примерно на первом гигабайте. Трейлер и таблица перекрестных ссылок находятся в реальном конце файла, далеко за усеченной длиной, поэтому анализ завершится ошибкой, которая не имеет ничего общего с истинной причиной. Вы бы отлаживали ошибку перекрестной ссылки в совершенно валидном файле, не подозревая, что целое число переполнилось двумя уровнями выше
Вместо этого адаптер отклоняет ввод. Конструктор сравнивает размер потока с High(FPDF_DWORD) и вызывает EPdfError в тот момент, когда поток слишком велик для описания. Явная, немедленная ошибка называет реальную проблему в точке конструирования. Тихое усечение скрывает ее за вводящим в заблуждение симптомом, с которым вы столкнулись бы намного позже. Ограничение в 4 ГиБ — это подлинное ограничение этого пути загрузки, и честным шагом будет громко заявить об этом, а не маскировать арифметикой, которая случайно скомпилировалась. Когда архив действительно пересекает черту, обещанные в начале средства защиты находятся за пределами этого API: разделите скан на файлы-тома, каждый из которых остается ниже потолка, или оставьте документ на диске и обслуживайте его через дизайн прямого доступа, построенный на 64-битных смещениях, а не через FPDF_FILEACCESS
Сбои не должны пересекать границу
Чтение может завершиться сбоем. Поток может быть поддерживаемым сетью объектом, тайм-аут которого истек, дескриптором большого двоичного объекта (blob), который был закрыт под вами, или файлом, который был усечен после открытия документа. Контракт PDFium для функции обратного вызова чтения — это возвращаемое значение: ненулевое при успехе, нулевое при неудаче. Это фрейм C, и в нем нет механизмов для перехвата или распространения исключения Pascal
Вот почему трамплин оборачивает поиск и чтение в конструкцию try/except, которая проглатывает исключение и возвращает ноль. Если бы исключению Delphi было позволено распространиться за пределы колбэка, оно развернулось бы через кадры стека cdecl PDFium, которые никогда не создавались для развертывания механизмом исключений Pascal. Результатом в лучшем случае является неопределенное поведение, а в худшем — жесткий сбой глубоко внутри парсера PDF без пригодного для использования стека. Возврат нуля удерживает сбой в рамках контракта. PDFium видит неудачное чтение блока, чисто прерывает операцию, и FPDF_LoadCustomDocument сообщает, что документ не удалось загрузить, что компонент выводит на поверхность как EPdfError на стороне Pascal, где ему и место
Открытие документа таким способом
Метод компонента, который управляет потоковым путем — это LoadCustomDocument, объявленный как отдельный метод, а не как очередная перегрузка LoadDocument, чтобы передача TMemoryStream никогда случайно не попала на буферизованный путь. Он создает адаптер, вызывает FPDF_LoadCustomDocument и поддерживает жизнь адаптера на протяжении всего времени жизни загруженного документа
var
Pdf: TPdf;
FileStream: TFileStream;
begin
Pdf := TPdf.Create(nil);
FileStream := TFileStream.Create('Archive_4GB.pdf', fmOpenRead or fmShareDenyWrite);
try
// Hand stream ownership to Pdf: it frees FileStream when the document closes.
Pdf.LoadCustomDocument(FileStream, True);
// PDFium has read only the trailer and catalog so far.
// Rendering a page pulls just that page's bytes through the callback.
// ... render or inspect pages here ...
finally
Pdf.Free; // closes the document, which frees the adapter and the stream
end;
end;
Тот же вызов работает для TMemoryStream, потока большого двоичного объекта (blob) из набора данных базы данных или пользовательского потомка TStream. Загрузка по запросу окупает себя, когда файл большой и будет прочитана только его часть: средство просмотра архивов, генератор эскизов, который производит выборку нескольких страниц, поисковый индекс, который извлекает по одной странице за раз. Когда файл небольшой или вы в любом случае собираетесь прочитать его целиком, буферизованная загрузка проще, и потоковый механизм ничего вам не дает. Решающим фактором является отношение байтов, которые вы фактически затронете, к байтам, которые содержит файл
После того, как страницы загружаются по запросу, следующей заботой становится поддержание отзывчивости визуализированных страниц при масштабировании и прокрутке пользователем, что рассматривается в нашей заметке о кэшировании рендеринга и производительности масштабирования. Когда транслируемый документ является тем, который средство просмотра должно отображать, но не позволять пользователю экспортировать или изменять, методы из руководства по безопасному предварительному просмотру PDF естественным образом сочетаются с этим путем загрузки. И то, и другое строится на описанной здесь потоковой загрузке, которая поставляется как часть PDFium Component для Delphi и C++Builder наряду с API для рендеринга, извлечения текста и аннотаций, описанными в других местах этого блога