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

Прогресивне завантаження PDF і cancel у Delphi (FPDFAvail)

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

Як TPdfProgressiveDocument завантажує PDF, який ще качається?

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

// FetchRange — ваш транспорт (HTTP Range GET, сокет, читач blob-ів):
// він пише 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 на потоці-оснуванні: транспортний потік мусить записати байти відповіді в потік перед викликом CompleteRequest, бо в мить, щойно завершення опубліковано, PDFium може читати той діапазон, а конкурентні писальники потребують positioned I/O чи власного замка

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

Чому AvailableByteCount відмовляється рухатися назад?

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

Коли частково завантажений PDF справді може відкритися?

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

Як OpenProgressiveDocument маршрутизує часткове завантаження в PDFium Component: лінеаризований файл відкривається через FPDFAvail_GetDocument, щойно прибувають секція першої сторінки і hint-таблиці, нелінеаризований потребує FPDF_LoadCustomDocument і кожного байта, а LoadAvailablePage перевіряє доступність форми через FPDFAvail_IsFormAvail перед перевіркою сторінки, уникаючи пастки ненульового хендла з нульовою кількістю сторінок
Фальстарт отримують лише лінеаризовані файли; на всьому іншому 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 бере номер сторінки від 1 і забезпечує порядок, якого чекає PDFium: перед першою перевіркою сторінки він виконує CheckFormAvailability, що обгортає FPDFAvail_IsFormAvail, і лише після того викликає FPDFAvail_IsPageAvail. Результат pfaNotPresent — нормальна відповідь для документа без AcroForm і нічого не блокує. Коли сторінка готова, LoadAvailablePage робить її активною, тож переглядач може рендерити сторінку 1 лінеаризованої брошури, поки решта сторінок ще в дорозі; FirstAvailablePageNumber каже, яку сторінку словник лінеаризації призначає першою, — уже конвертовано з 0-based індексу PDFium

Що вивільняє CancelProgressiveLoad, і в якому порядку?

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

Фіксований порядок розбирання CancelProgressiveLoad у PDFium Component: скасуйте планувальник діапазонів першим, щоб пізні завершення влучали в піднятий лічильник поколінь і повертали False, закрийте документ до зникнення адаптера файлового доступу, знищте availability-хендл через FPDFAvail_Destroy і лише потім утилізуйте записи callback і звільніть адаптер потоку
Один ідемпотентний метод прибирає за проваленим стартом, скасуванням користувача і деструктором однаково; з робочим потоком, що пише в сховище, володіння потоком лишається за вами
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 безпечний без явного скасування. Одне рішення про володіння — ваше, і його треба зробити правильно: якщо робочий потік пише в потік-оснування, передайте AOwnsStream = False і звільніть потік самі після зупинки робочого потоку, бо з переданим володінням cancel звільнить потік, поки пізній запис може бути ще в дорозі. Винятки, підняті всередині OnCancelRequest, ковтаються по одному на запит, тож один транспорт, що провалився, не може заблокувати решту скасувань

Як набір lifecycle-тестів доводить, що шлях cancel не тікає?

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

Де progressive-шлях перестає помагати

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

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