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

Скасовуваний прогресивний рендеринг PDF у Delphi (PDFium)

Більшість сторінок PDF растеризується за кілька мілісекунд, і ви ніколи про це не замислюєтесь. Але потім користувач відкриває інженерне креслення формату А1, сторінку з десятками тисяч векторних штрихів або плакат, переповнений групами прозорості та м'якими масками, і єдиний виклик, який малює все це, займає дві або три секунди. Якщо цей виклик виконується в потоці UI, вікно перестає перемальовуватися, рядок заголовка стає сірим, і операційна система пропонує закрити програму. Ця робота є виправданою. Сторінці справді потрібно стільки часу. Недолік полягає в тому, що рендеринг є одним неподільним викликом, який блокує потік, без можливості "перевести подих" і без можливості зупинитися

Ця стаття присвячена саме одній із цих двох проблем: скасуванню тривалого рендерингу однієї сторінки без блокування інтерфейсу користувача (UI). Користувач перейшов на наступну сторінку, змінив масштаб або закрив документ, і поточний рендеринг тепер є марною роботою, яка має завершитися за першої ж нагоди, а не виконуватися до кінця. Згладжування прокручування та масштабування шляхом кешування того, що вже було растеризовано, є окремим питанням зі своєю власною архітектурою, яка розглядається в супутній статті, посилання на яку наведено в кінці. Тут розглядається лише питання про те, як змусити прогресивний рендеринг швидко і чисто відповісти на запит про скасування

API прогресивного рендерингу, який вже постачається з PDFium

PDFium передбачив проблему зависання. Поряд з одноразовим FPDF_RenderPageBitmap, він надає прогресивний варіант, який розбиває сторінку на частини роботи. Ви викликаєте FPDF_RenderPageBitmap_Start один раз, щоб налаштувати рендеринг у цільовий растр, а потім багаторазово викликаєте FPDF_RenderPage_Continue. Кожен Continue растеризує обмежений фрагмент і повертає статус. FPDF_RENDER_TOBECONTINUED означає, що є ще робота, FPDF_RENDER_DONE означає, що сторінка завершена, а FPDF_RENDER_FAILED означає, що робота зупинилася через помилку. Коли цикл завершується, ви викликаєте FPDF_RenderPage_Close для звільнення прогресивного стану сторінки. Оскільки між фрагментами управління повертається до вашого коду, ви можете обробляти повідомлення, оновлювати індикатор прогресу або перевіряти, чи ця робота все ще потрібна

Механізм, який надає PDFium для прийняття рішення про те, коли передати управління, - це структура зворотного виклику під назвою IFSDK_PAUSE. Ви передаєте її в Start і в кожен Continue. Після кожного фрагмента PDFium викликає її вказівник на функцію NeedToPauseNow, і якщо вона повертає ненульове значення, поточний Continue зупиняється достроково і повертає управління з FPDF_RENDER_TOBECONTINUED. Структура також містить поле version, яке має бути встановлено на 1, і вказівник вільного формату user, якого PDFium ніколи не торкається і передає без змін. Цей недоторканий вказівник є стрижнем всієї архітектури, про яку піде мова далі

Перепрофілювання паузи як скасування

Початкова мета NeedToPauseNow - це квантування часу (time-slicing). Поверніть ненульове значення, коли бюджет вашого кадру вичерпано, поверніть нуль, щоб продовжити рендеринг, і PDFium зробить паузу, щоб ви могли зайнятися іншими справами перед відновленням того самого рендерингу. PDFium Component повторно використовує той самий сигнал для іншої дії. Замість того, щоб відповідати "чи повинен я зробити паузу і дозволити вам продовжити", зворотний виклик відповідає "чи була ця робота скасована". Ці дві дії чітко накладаються одна на одну завдяки тому, що робить цикл, коли бачить прапорець. Справжня пауза очікує на подальший Continue; скасування - ні. Щойно цикл, який здійснює виклик, помічає, що маркер скасовано, він закриває контекст рендерингу і більше ніколи не викликає Continue, тому те саме ненульове значення, яке PDFium сприймає як "зупинити цей фрагмент", по суті стає "зупинити назавжди"

Скасування виражається через інтерфейс IPdfCancellationToken, властивість IsCancelled якого перемикається з false на true, коли інша частина програми запитує про зупинку рендерингу. Містком між цим інтерфейсом Pascal і зворотним викликом C PDFium є єдиний вказівник. Посилання на інтерфейс маркера записується у IFSDK_PAUSE.user, а статичний зворотний виклик cdecl зчитує його назад і опитує. Це класична проблема дозволу бібліотеці C робити зворотний виклик у Pascal: зворотний виклик має бути звичайною функцією з угодою про виклик C, а не методом, оскільки PDFium зберігає та викликає чистий вказівник на функцію, який нічого не знає про об'єкти Pascal або Self

type
  TPdfProgressivePause = record
    Pause: IFSDK_PAUSE;            // PDFium reads this; .user holds the token
    Token: IPdfCancellationToken; // strong ref keeps the token alive
  end;

function ProgressivePauseCallback(pThis: PIFSDK_PAUSE): FPDF_BOOL; cdecl;
var
  Token: IPdfCancellationToken;
begin
  Result := 0;
  if (pThis = nil) or (pThis^.user = nil) then
    Exit;
  Token := IPdfCancellationToken(pThis^.user);
  if Token.IsCancelled then
    Result := 1; // non-zero: PDFium stops this chunk
end;

Зворотний виклик відновлює маркер, перетворюючи pThis^.user назад до типу інтерфейсу, і зчитує IsCancelled. У ньому нічого не виділяється (allocates), не блокується (locks) і не зупиняється (blocks), що має значення, оскільки PDFium викликає його в потоці рендерингу після кожного фрагмента, і будь-яка виконана тут робота додається до вартості самого рендерингу. Захист від нульової (nil) структури або нульового поля user означає, що ту саму функцію безпечно встановлювати навіть для рендерингу, якому ніколи не надавали справжнього маркера

Збереження маркера активним протягом усього циклу

Приведення вказівника на інтерфейс через сирий Pointer і назад - це те місце, де народжуються помилки часу життя (lifetime bugs). IInterface у Delphi рахує посилання, і цей лічильник змінюється лише тоді, коли компілятор бачить, що змінній типу інтерфейсу присвоюється значення. Зберігання маркера виключно як чистого вказівника всередині IFSDK_PAUSE.user повністю приховало б його від лічильника посилань. Якби єдине інше посилання на цей маркер вийшло з області видимості, поки цикл Continue все ще виконувався б, об'єкт був би звільнений просто під час зворотного виклику, і наступний фрагмент здійснив би розіменування завислого вказівника (dangling pointer)

Ось чому дескриптор - це запис, який містить дві речі, а не одну. Поле Pause - це структура, яку читає PDFium. Поле Token - це справжнє посилання на тип інтерфейсу, яке рахує компілятор, і воно існує виключно для того, щоб закріпити маркер у пам'яті на той час, поки живе запис. Запис є локальною змінною в стеку процедури рендерингу, тому він залишається дійсним протягом усієї тривалості циклу і знищується лише тоді, коли процедура завершується. Чистий вказівник у user і пораховане посилання у Token вказують на той самий об'єкт; один - це те, що може прочитати PDFium, інший - те, що утримує об'єкт від збирання сміття

var
  Pause: TPdfProgressivePause;
  EffectiveToken: IPdfCancellationToken;
begin
  // ... choose EffectiveToken ...

  // Strong ref first, then publish the same object to PDFium via .user.
  Pause.Token := EffectiveToken;
  Pause.Pause.version := 1;
  Pause.Pause.NeedToPauseNow := ProgressivePauseCallback;
  Pause.Pause.user := Pointer(EffectiveToken);

Закриття контексту рендерингу незалежно від того, як завершується цикл

Кожен виклик FPDF_RenderPageBitmap_Start виділяє прогресивний стан, який PDFium асоціює зі сторінкою, і цей стан звільняється лише викликом FPDF_RenderPage_Close. Існує три варіанти виходу з керуючого циклу. Сторінка закінчується, і останнім статусом є FPDF_RENDER_DONE. Спрацьовує маркер, і цикл завершується достроково, повідомляючи про скасування. Щось дає збій, і статус стає FPDF_RENDER_FAILED. Усі три випадки мають викликати Close, і шлях скасування - це той, де найлегше помилитися, оскільки природна форма "побачити скасування, вирватися" має тенденцію пропускати очищення на шляху до виходу. Якщо Close не буде викликано, це призведе до витоку стану сторінки, і засіб перегляду, який дозволяє користувачеві скасовувати рендеринг один за одним, накопичуватиме цей витік на кожній перерваній сторінці

Надійна архітектура поміщає цикл і класифікацію результатів всередину блоку try, а FPDF_RenderPage_Close - у відповідний finally. Цільовий растр знищується в тому самому блоці. Скасування може вийти з циклу через ранній виклик Exit, і блок finally все одно виконається, тому існує лише одне місце, яке звільняє прогресивний стан, і його неможливо оминути

Status := FPDF_RenderPageBitmap_Start(PdfBmp, FPage, Left, Top,
  Width, Height, Ord(Rotation), EncodeRenderOptions(Options), Pause.Pause);
try
  while Status = FPDF_RENDER_TOBECONTINUED do
  begin
    if EffectiveToken.IsCancelled then
    begin
      Result := prsCancelled;
      Exit;
    end;
    Status := FPDF_RenderPage_Continue(FPage, Pause.Pause);
  end;

  if EffectiveToken.IsCancelled then
    Result := prsCancelled
  else if Status = FPDF_RENDER_DONE then
    Result := prsDone
  else
    Result := prsFailed;
finally
  // Frees the progressive state Start allocated; mandatory on every path.
  FPDF_RenderPage_Close(FPage);
  FPDFBitmap_Destroy(PdfBmp);
end;

Цикл перевіряє маркер перед кожним викликом Continue, а також покладається на зворотний виклик усередині нього. Зворотний виклик скорочує поточний фрагмент; перевірка циклу зупиняє запуск наступного. Разом вони обмежують час, необхідний для того, щоб скасування набуло чинності, приблизно тривалістю одного фрагмента

Три результати та що містить растр після скасування

Публічною точкою входу є TPdf.RenderPageProgressive, і вона повертає TPdfProgressiveStatus, що може набувати значень prsDone, prsCancelled або prsFailed. Ці значення відображають константи FPDF_RENDER_* бібліотеки PDFium в ідіомі Pascal, але включають випадок скасування як повноцінний результат першого класу, а не як помилку

Момент, який дивує людей, полягає в тому, що саме містить цільовий растр після prsCancelled. Він не порожній. PDFium виконує прогресивний рендеринг у той самий растр фрагмент за фрагментом, тому коли скасування зупиняє цикл, растр містить усе, що було намальовано до цього моменту, а це часткове зображення: деякі смуги готові, а решта все ще відображає колір заливки. Чи корисний цей частковий результат, залежить від того, хто його викликає. Засіб перегляду, який збирається викинути растр, оскільки користувач перейшов кудись інакше, може просто ігнорувати його. Засіб перегляду, який хоче показати попередній перегляд із низькими витратами ресурсів, може його залишити. Чого ви не повинні робити, так це вважати, що prsCancelled означає порожній або невизначений растр; це означає правдивий знімок незавершеного рендерингу

var
  Bmp: TBitmap;
  Token: IPdfCancellationToken;
  Status: TPdfProgressiveStatus;
begin
  Bmp := TBitmap.Create;
  try
    // Token starts un-cancelled; flip Token.IsCancelled from elsewhere
    // (a UI action, a navigation event) to abort the render in flight.
    Status := Pdf.RenderPageProgressive(Bmp, 0, 0, PageW, PageH, Token);
    case Status of
      prsDone:      Image1.Picture.Assign(Bmp);  // fully rendered
      prsCancelled: ;                            // partial bitmap, usually discarded
      prsFailed:    ShowMessage('Render failed');
    end;
  finally
    Bmp.Free;
  end;
end;

Нульовий маркер (nil) і шлях зворотного виклику без розгалужень

Скасування є опціональним (opt-in). Той, хто викликає і просто хоче отримати прогресивний рендеринг для переваг обробки повідомлень, не маючи наміру переривати його, повинен мати можливість передати nil для маркера. Наївний спосіб підтримати це - розкидати перевірки "чи був наданий маркер" у зворотному виклику та циклі, що означає розгалуження на кожному фрагменті та зворотний виклик, який має обробляти як реальний маркер, так і його відсутність

Реалізація уникає цього шляхом підстановки одинака (singleton), коли викликаючий нічого не передає. Маркер nil замінюється на PdfNoCancellationToken - інтерфейс, властивість IsCancelled якого завжди має значення false. З цього моменту зворотний виклик і цикл у будь-якому випадку мають маркер для опитування, тому жоден із них не потребує перевірки на nil і жоден не потребує спеціального шляху. Маркер, що ніколи не скасовується, просто завжди відповідає false, зворотний виклик завжди повертає нуль, і рендеринг виконується до кінця точно так само, як і рендеринг, що не підлягає скасуванню. Необов'язкова поведінка моделюється як маркер, який ніколи не спрацьовує, а не як відсутність маркера, що зберігає гарячий шлях одноманітним

// nil -> never-cancel singleton, so the callback path is identical
// whether or not the caller opted into cancellation.
if AToken <> nil then
  EffectiveToken := AToken
else
  EffectiveToken := PdfNoCancellationToken;

Архітектура, яка вимальовується, є невеликою, і її варто повторити, оскільки вона є частиною для багаторазового використання. Бібліотека C, яка підтримує зворотний виклик, дає вам рівно один канал для передачі стану в цей зворотний виклик - непрозорий вказівник користувача (opaque user pointer). Помістіть пораховане посилання на інтерфейс Pascal за цим вказівником, збережіть друге реальне посилання поруч зі структурою, щоб об'єкт не міг бути зібраний як сміття посеред виклику, і прочитайте інтерфейс назад всередині статичної функції cdecl. Загорніть весь керуючий цикл у блок try і звільніть нативний контекст у finally. Цей самий шаблон переноситься на будь-яку прогресивну або керовану зворотним викликом операцію PDFium, де код Pascal повинен зберігати контроль над часом життя, тоді як C тримає вказівник

Скасування - це лише одна половина адаптивного засобу перегляду. Інша половина - це не растеризувати повторно сторінки, які ви вже намалювали, і підтримувати плавне масштабування та прокручування шляхом обслуговування кешованих растрів, про що йдеться в нашій статті про кешування рендерингу та продуктивність масштабування. Про те, як скасовуваний рендеринг вписується в повноцінний засіб перегляду поряд із навігацією, виділенням і пошуком, читайте в статті створення багатофункціонального засобу перегляду PDF із компонентом PDFium VCL у Delphi. Описаний тут прогресивний рендеринг постачається як частина PDFium Component для Delphi та Lazarus поряд із API для завантаження, рендерингу та форм, які розглядаються в інших статтях цього блогу