Technical Article

Отменяемый прогрессивный рендеринг PDF в Delphi (PDFium)

Большинство PDF-страниц растрируются за несколько миллисекунд, и никто об этом не задумывается. Затем пользователь открывает инженерный чертёж формата A1, страницу с десятками тысяч векторных штрихов или плакат, насыщенный группами прозрачности и мягкими масками, и единственный вызов, который её отрисовывает, занимает две-три секунды. Если этот вызов выполняется в 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 - нарезка времени. Возвращайте ненулевое значение, когда бюджет кадра исчерпан, возвращайте ноль, чтобы продолжить рендеринг, и 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-структуры или nil-поля user означает, что одну и ту же функцию можно безопасно устанавливать даже для рендеринга, которому не был передан реальный токен

Удержание токена в памяти на протяжении цикла

Приведение указателя на интерфейс через голый Pointer и обратно - это место, где рождаются ошибки времени жизни. IInterface в Delphi использует подсчёт ссылок, и счётчик изменяется только тогда, когда компилятор видит, что переменная типа интерфейс присваивается. Хранение токена исключительно как голого указателя внутри IFSDK_PAUSE.user скрыло бы его от счётчика ссылок полностью. Если единственная другая ссылка на этот токен вышла бы из области видимости, пока ещё выполнялся цикл Continue, объект был бы освобождён из-под обратного вызова, и следующий фрагмент разыменовал бы висячий указатель

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

// 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, поддерживающая обратный вызов, предоставляет вам ровно один канал для передачи состояния в этот обратный вызов - непрозрачный указатель user. Поместите за этот указатель подсчитываемую Pascal-ссылку на интерфейс, сохраняйте вторую реальную ссылку рядом со структурой, чтобы объект не мог быть освобождён в середине вызова, и считывайте интерфейс обратно внутри статической функции cdecl. Оберните весь управляющий цикл в try и освободите нативный контекст в finally. Тот же шаблон применяется к любой прогрессивной или управляемой обратным вызовом операции PDFium, где Pascal-код должен контролировать время жизни, пока C удерживает указатель

Отмена - это лишь половина отзывчивого просмотрщика. Другая половина - не перерисовывать страницы, которые уже были нарисованы, и поддерживать плавность масштабирования и прокрутки за счёт использования кэшированных битмапов, что рассматривается в нашей статье о кэшировании рендеринга и производительности при масштабировании. О том, как отменяемый рендеринг вписывается в полноценный просмотрщик рядом с навигацией, выделением и поиском, см. в статье создание многофункционального просмотрщика PDF с компонентом PDFium VCL. Описанный здесь прогрессивный рендеринг поставляется в составе PDFium Component для Delphi и Lazarus вместе с API загрузки, рендеринга и форм, описанными в других статьях этого блога