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

Progressive download PDF и отмена в Delphi (FPDFAvail)

PDFium Component открывает PDF, который ещё качается, через TPdfProgressiveDocument — подкласс TPdf, оборачивающий availability API PDFium FPDFAvail_*. BeginProgressiveLoad стартует сессию, CheckDocumentAvailability репортит, какие диапазоны байтов PDFium всё ещё ждёт, OpenProgressiveDocument открывает файл, как только байтов достаточно, а CancelProgressiveLoad бросает прерванную закачку, не утекая нативными хэндлами. Трудна не счастливая дорога. Вьюер на флапающем соединении увидит, как пользователи закрывают вкладку на 25 процентах, передумывают и открывают ту же ссылку снова, и каждая из тех брошенных сессий несёт нативный availability-хэндл, две записи C-callback'ов, stream-адаптер и набор летящих range-запросов, которые надо освободить в строго правильном порядке

Как TPdfProgressiveDocument грузит PDF, который ещё качается?

TPdfProgressiveDocument держит availability-провайдера PDFium живым, пока random-access stream наполняется, и перед каждым шагом разбора спрашивает того провайдера, на месте ли нужные байты. BeginProgressiveLoad(AStream, AFileSize, AOwnsStream, AInitialAvailableByteCount) берёт опорный stream плюс логический размер удалённого файла, заводит callback IsDataAvail и callback AddSegment в две записи и зовёт FPDFAvail_Create. Когда PDFium спрашивает, присутствует ли диапазон, компонент отвечает «да», если диапазон лежит внутри непрерывного префикса, описанного AvailableByteCount, или внутри диапазона, уже доведённого планировщиком RangeRequests до конца, а событие OnDataAvailable может переиграть вердикт для разреженных хранилищ. Каждый вызов CheckDocumentAvailability возвращает одно из трёх значений TPdfDataAvailability (pdaAvailable, pdaNotAvailable, pdaError) и отдаёт запрошенные PDFium диапазоны как отсортированный, слитый массив TPdfDownloadRanges, уже поставленный в очередь планировщика с приоритетом rrpImmediate

// FetchRange — ваш транспорт (HTTP Range GET, сокет, читатель блобов):
// он пишет Size байтов со смещением Offset в Store и возвращает, сколько доехало
function FetchRange(Store: TStream; Offset, Size: UInt64): UInt64; forward;

procedure OpenWhileDownloading(Pdf: TPdfProgressiveDocument; Store: TStream;
  RemoteSize: UInt64);
const
  MaxRounds = 64;
var
  Hints: TPdfDownloadRanges;
  State: TPdfDataAvailability;
  Request: TPdfRangeRequest;
  Round: Integer;
begin
  Pdf.BeginProgressiveLoad(Store, RemoteSize, False);
  State := pdaNotAvailable;
  for Round := 1 to MaxRounds do
  begin
    State := Pdf.CheckDocumentAvailability(Hints);
    if State <> pdaNotAvailable then
      Break;
    // Подсказки уже в очереди; сперва пишем байты, затем завершаем
    while Pdf.RangeRequests.TryDequeue(Request) do
      Pdf.RangeRequests.CompleteRequest(Request,
        FetchRange(Store, Request.Offset, Request.Size));
  end;
  if State <> pdaAvailable then
    raise EPdfError.Create('The document could not be discovered');
  Pdf.OpenProgressiveDocument;
end;

Две детали того цикла несущие. Потолок раундов важен, потому что мёртвая ссылка заставляет CheckDocumentAvailability спрашивать одни и те же диапазоны вечно, а неограниченный цикл превращает сетевой сбой в зависший UI. Порядок важен, потому что планировщик сериализует собственное состояние критической секцией, но ничего не делает для TStream.Position опорного хранилища: транспортный поток обязан записать байты ответа в stream до вызова CompleteRequest, поскольку в момент публикации завершения PDFium может тот диапазон прочитать, а конкурентным писцам нужны позиционированное I/O или собственный лок

Цикл доступности TPdfProgressiveDocument в PDFium Component: BeginProgressiveLoad создаёт провайдера FPDFAvail, CheckDocumentAvailability отдаёт отсортированные слитые подсказки закачки, поставленные в очередь с приоритетом rrpImmediate, транспорт пишет байты в хранилище до того, как CompleteRequest опубликует диапазон PDFium, а цикл ограничен 64 раундами, потому что мёртвая ссылка спрашивает одни и те же диапазоны вечно
Запишите байты, затем завершите запрос: в момент публикации завершения PDFium может тот диапазон прочитать, и позицию stream для вас никто не защитит

Почему AvailableByteCount отказывается ехать назад?

AvailableByteCount только растёт, и сеттер поднимает EPdfError с сообщением «Available byte count cannot move backwards», если вы пытаетесь его уменьшить. Раз callback IsDataAvail уже сказал PDFium, что диапазон существует, парсер мог уже прочитать и закэшировать оттуда объекты, поэтому отзыв тех байтов задним числом сделал бы ответы о доступности несогласными с тем, что PDFium уже потребил. Тот же сеттер отвергает значения больше LogicalFileSize и поднимает «No progressive load is active» вне сессии, — оттого байты, которые вы уже держите до старта загрузки, принадлежат аргументу AInitialAvailableByteCount вызова BeginProgressiveLoad, а не присваиванию свойства, сделанному слишком рано. Если ваше закачное хранилище наполняется непоследовательно, не пытайтесь выразить это префиксом вовсе: доводите диапазоны до конца через планировщик или отвечайте через OnDataAvailable

Когда частично скачанный PDF реально открывается?

До приезда всего файла открывается только linearized PDF (Annex F ISO 32000-1, раскладка «Fast Web View»); нелинеаризованному нужны все байты. OpenProgressiveDocument проверяет свойство Linearization (plnUnknown, plnNotLinearized, plnLinearized) и маршрутизирует соответственно: linearized-файл открывается через FPDFAvail_GetDocument, как только на месте секция первой страницы и таблицы подсказок, а нелинеаризованный открывается через FPDF_LoadCustomDocument на той же записи файлового доступа и трактуется как читаемый только целиком. Маршрутизация существует по конкретной причине. Вызов FPDFAvail_GetDocument на нелинеаризованном файле может вернуть не-нулевой хэндл, чьё число страниц равно нулю, — документ, который выглядит открытым и пуст. В собственном тестовом наборе компонента linearized-фикстура на 51 страницу достигает pdaAvailable и открывается со всем своим деревом страниц, пока разреженное закачное хранилище всё ещё не покрывает файл

Как OpenProgressiveDocument маршрутизирует частичную закачку в PDFium Component: linearized-файл открывается через FPDFAvail_GetDocument, как только приезжают секция первой страницы и таблицы подсказок, нелинеаризованному нужны FPDF_LoadCustomDocument и все байты, а LoadAvailablePage проверяет доступность формы через FPDFAvail_IsFormAvail до проверки страницы, обходя ловушку не-нулевого хэндла с нулём страниц
Фору получают только linearized-файлы; на всём прочем FPDFAvail_GetDocument может вернуть похожий на открытый документ с нулём страниц — ровно то, что предотвращает маршрутизация
function WaitForPage(Pdf: TPdfProgressiveDocument; Store: TStream;
  PageNumber: Integer): Boolean;
var
  Hints: TPdfDownloadRanges;
  Request: TPdfRangeRequest;
  Round: Integer;
begin
  Result := False;
  for Round := 1 to 64 do
    case Pdf.LoadAvailablePage(PageNumber, Hints) of
      pdaAvailable:
        Exit(True);   // PageNumber теперь активная страница
      pdaError:
        Exit(False);
      pdaNotAvailable:
        while Pdf.RangeRequests.TryDequeue(Request) do
          Pdf.RangeRequests.CompleteRequest(Request,
            FetchRange(Store, Request.Offset, Request.Size));
    end;
end;

LoadAvailablePage берёт номер страницы с единицы и соблюдает порядок, которого ждёт PDFium: перед первой проверкой страницы он гоняет CheckFormAvailability, оборачивающую FPDFAvail_IsFormAvail, и лишь затем зовёт FPDFAvail_IsPageAvail. Результат pfaNotPresent — нормальный ответ для документа без AcroForm и ничего не блокирует. Когда страница готова, LoadAvailablePage делает её активной, так что вьюер может отрисовать страницу 1 linearized-буклета, пока остальные ещё в пути; FirstAvailablePageNumber говорит, какую страницу словарь линеаризации назначил первой, — уже сконвертировано из нулевого индекса PDFium

Что освобождает CancelProgressiveLoad и в каком порядке?

CancelProgressiveLoad разбирает сессию за четыре шага, которые нельзя переставлять: отменить планировщик диапазонов, закрыть документ, уничтожить availability-хэндл через FPDFAvail_Destroy, затем распорядиться записями callback'ов и освободить stream-адаптер. Отмена планировщика сперва накатывает его счётчик поколений, сбрасывает все ожидающие и летящие запросы и стреляет OnCancelRequest по каждому летящему, поэтому завершение транспорта, приземлившееся позже, несёт старое поколение, и CompleteRequest возвращает False, ни до чего не дотронувшись. Документ обязан закрыться до того, как уйдут availability-хэндл и адаптер, потому что PDFium может звонить в провайдер файлового доступа, пока закрывает документ, и если адаптера уже нет, тот callback читает освобождённую память

Фиксированный порядок разборки CancelProgressiveLoad в PDFium Component: сперва отмените планировщик диапазонов, чтобы поздние завершения упёрлись в накатанный счётчик поколений и вернули False, закройте документ до исчезновения адаптера файлового доступа, уничтожьте availability-хэндл через FPDFAvail_Destroy и лишь затем распорядитесь записями callback'ов и освободите stream-адаптер
Один идемпотентный метод вычищает и неудавшийся старт, и пользовательскую отмену, и деструктор; при рабочем потоке, пишущем в хранилище, владение stream остаётся за вами
procedure TDownloadForm.FormCreate(Sender: TObject);
begin
  FPdf := TPdfProgressiveDocument.Create(nil);
  // Планировщик живёт столько же, сколько FPdf, поэтому вешаем один раз
  FPdf.RangeRequests.OnCancelRequest := RangeCancelled;
end;

procedure TDownloadForm.RangeCancelled(Sender: TObject; RequestId: UInt64;
  Attempt: Cardinal);
begin
  FTransport.Abort(RequestId);   // ваш код: закройте тот сокет или запрос
end;

procedure TDownloadForm.CancelButtonClick(Sender: TObject);
begin
  FPdf.CancelProgressiveLoad;
  // ProgressiveLoading = False, Active = False, AvailableByteCount = 0
end;

Метод идемпотентен и является единственным путём уборки для трёх ситуаций: BeginProgressiveLoad, упавший на середине строительства, явная пользовательская отмена и деструктор. BeginProgressiveLoad зовёт его и перед стартом, так что перезапуск того же объекта на новом URL безопасен без явной отмены. Одно решение о владении вы должны принять правильно: если рабочий поток пишет в опорный stream, передавайте AOwnsStream = False и освобождайте stream сами после остановки рабочего, потому что при переданном владении отмена освободит stream, пока поздняя запись ещё может быть в пути. Исключения, поднятые внутри OnCancelRequest, глотаются по-запросно, чтобы один сбоящий транспорт не заблокировал остальные отмены

Как lifecycle-набор доказывает, что путь отмены не течёт?

Стресс-набор lifecycle PDFium Component на каждом смешанном цикле упражняет прерванную сетевую закачку. Каждый цикл стартует progressive load, чьё хранилище держит лишь четверть байтов фикстуры, требует pdaNotAvailable с непустым списком подсказок, зовёт CancelProgressiveLoad и ассертит, что объект рапортует ни ProgressiveLoading, ни Active; затем тот же streaming-путь гоняется до конца с полной доступностью, OpenProgressiveDocument, рендером и закрытием. Смешанный прогон по умолчанию покрывает 100 измеренных циклов с 600 открытиями, 2300 рендерами и 100 progressive-отменами, а выборочно снятая приватная память выросла на 8,21 МиБ против бюджета 32 МиБ. Набор считает progressive-отмены отдельно от отмен render-callback'ов, потому что брошенная закачка и рендерный цикл, остановившийся раньше, — разные события с разными критериями приёмки

Где progressive-путь перестаёт помогать

Несколько пределов стоит знать, прежде чем строить на этом вьюер. Фичи, которым нужны оригинальные байты файла, отказываются от неполного progressive-источника, а не угадывают: ReadXmpPacket падает явно, а валидация подписей репортит Indeterminate, пока файл не приехал целиком. Тест доступности по умолчанию предполагает непрерывный префикс, поэтому транспорт, таскающий диапазоны не по порядку, обязан доводить их через RangeRequests или отвечать через OnDataAvailable, иначе PDFium будет спрашивать байты, которые вы уже держите. Нелинеаризованный файл ничего не выигрывает во времени до первой страницы, так что если быстрая первая отрисовка важна, линеаризуйте файл на стороне сервера. И CancelProgressiveLoad сам ваши сокеты не закрывает; OnCancelRequest — тот крюк, где это происходит

Про простой путь stream-адаптера, грузящий полный локальный файл по требованию, смотрите стриминг больших PDF по требованию с PDFium; про открытие PDF, сидящего внутри большего буфера, — загрузку byte range для встроенных PDF. Отмена медленного рендера уже загруженной страницы — отдельный механизм, разобранный в статье о отменяемом прогрессивном рендеринге страниц. TPdfProgressiveDocument и его планировщик диапазонов едут вместе с PDFium Component для Delphi и C++Builder