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

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

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

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

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

PDFium передбачив ту частину проблеми, що стосується зависання. Поряд з одноразовим викликом FPDF_RenderPageBitmap, він пропонує прогресивний варіант, який розбиває сторінку на частини роботи. Ви викликаєте FPDF_RenderPageBitmap_Start один раз, щоб налаштувати рендеринг у цільове растрове зображення (bitmap), а потім неодноразово викликаєте 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 — це розподіл часу. Поверніть ненульове значення, коли ваш бюджет кадру вичерпано, або нуль, щоб продовжити рендеринг, і PDFium зробить паузу, щоб ви могли зробити щось інше перед відновленням того самого рендерингу. Компонент PDFium повторно використовує цей самий сигнал для іншої дії. Замість того, щоб відповідати «чи варто мені зробити паузу і дозволити вам відновити роботу», зворотний виклик відповідає «чи була ця робота скасована». Ці два поняття чисто накладаються одне на одне завдяки тому, що робить цикл, коли бачить цей прапорець. Справжня пауза очікує пізнішого 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. Нічого в ньому не виділяє пам'ять, не блокує і не ставить замки, що має значення, оскільки PDFium викликає його в потоці рендерингу після кожної частини, і будь-яка виконана тут робота додається до вартості самого рендерингу. Захист від нульової (nil) структури або нульового поля user означає, що цю ж функцію безпечно встановлювати навіть для рендерингу, якому ніколи не давали справжнього маркера

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

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

Ось чому дескриптор — це запис (record), що містить дві речі, а не одну. Поле 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) та шлях зворотного виклику без розгалужень

Скасування є додатковою опцією. Викликаюча сторона, яка просто хоче отримати прогресивний рендеринг для переваги обробки повідомлень, без наміру його переривати, повинна мати можливість передати nil як маркер. Наївний спосіб підтримати це — розкидати перевірки «чи був наданий маркер» по зворотному виклику та циклу, що означає розгалуження на кожній порції та зворотний виклик, який має обробляти як реальний маркер, так і його відсутність

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

// 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. Описаний тут прогресивний рендеринг постачається як частина PDFium Component для Delphi та Lazarus разом з API завантаження, рендерингу та форм, які висвітлені в інших публікаціях цього блогу