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

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

Большинство страниц PDF растеризуются за несколько миллисекунд, и вы никогда об этом не задумываетесь. Затем пользователь открывает инженерный чертеж формата A1, страницу, заполненную десятками тысяч векторных штрихов, или плакат, переполненный группами прозрачности и мягкими масками, и единственный вызов, который его рисует, занимает две или три секунды. Если этот вызов выполняется в потоке пользовательского интерфейса, окно перестает перерисовываться, заголовок становится серым, а операционная система предлагает завершить работу приложения. Работа легитимна. Странице действительно нужно так много времени. Недостаток заключается в том, что рендеринг — это один неделимый блокирующий вызов, без возможности перевести дух и без возможности остановиться

Эта статья посвящена ровно одной из этих двух проблем: отмене длительного рендеринга одной страницы без замораживания пользовательского интерфейса. Пользователь щелкнул следующую страницу, или масштабировал, или закрыл документ, и рендеринг в полете теперь является потраченной впустую работой, которая должна завершиться при первой возможности, а не выполняться до конца. Сглаживание прокрутки и масштабирования путем кэширования того, что уже было растеризовано, — это отдельная задача с собственным дизайном, описанная в сопутствующей статье, ссылка на которую приведена в конце. Здесь единственный вопрос заключается в том, как заставить один прогрессивный рендеринг быстро и чисто ответить на запрос отмены

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 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. В нем ничего не выделяется, не блокируется и не останавливается, что важно, потому что PDFium вызывает его в потоке рендеринга после каждого фрагмента, и любая выполненная здесь работа добавляется к стоимости самого рендеринга. Защита от нулевой структуры или нулевого поля 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. Значения отражают константы PDFium FPDF_RENDER_* в идиоме 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 меняется на PdfNoCancellationToken, интерфейс, чей IsCancelled всегда false. С fixed этого момента обратный вызов и цикл имеют токен для запроса в каждом случае, поэтому ни одному из них не нужна проверка на 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. Прогрессивный рендеринг, описанный здесь, поставляется как часть компонента PDFium для Delphi и Lazarus наряду с API загрузки, рендеринга и форм, описанными в других статьях этого блога