A maioria das páginas PDF é rasterizada em alguns milissegundos e você nunca pensa nisso. Então um usuário abre um desenho de engenharia A1, uma página repleta de dezenas de milhares de traçados vetoriais, ou um cartaz com grupos de transparência e máscaras suaves, e a chamada que a renderiza leva dois ou três segundos. Se essa chamada rodar na thread da interface, a janela para de se redesenhar, a barra de título fica cinza e o sistema operacional se oferece para encerrar o aplicativo. O trabalho é legítimo. A página realmente precisa de todo esse tempo. O defeito é que a renderização é uma chamada bloqueante indivisível, sem forma de respirar e sem como parar
Este artigo trata exatamente de um desses dois problemas: cancelar uma renderização longa de página única sem travar a interface. O usuário clicou na próxima página, deu zoom ou fechou o documento, e a renderização em andamento é agora trabalho desperdiçado que deve terminar na primeira oportunidade em vez de ser concluído. Suavizar a rolagem e o zoom com cache do que já foi rasterizado é uma preocupação separada com seu próprio design, abordada no artigo complementar ao final. Aqui a única pergunta é como fazer uma renderização progressiva responder a uma solicitação de cancelamento de forma rápida e limpa
A API de renderização progressiva que o PDFium já fornece
O PDFium antecipou a metade do congelamento do problema. Além do FPDF_RenderPageBitmap de execução única, ele expõe uma variante progressiva que divide uma página em fragmentos de trabalho. Você chama FPDF_RenderPageBitmap_Start uma vez para configurar a renderização em um bitmap de destino, depois chama FPDF_RenderPage_Continue repetidamente. Cada Continue rasteriza uma fatia delimitada e retorna um status. FPDF_RENDER_TOBECONTINUED significa que há mais trabalho, FPDF_RENDER_DONE significa que a página foi concluída, e FPDF_RENDER_FAILED significa que parou com erro. Quando o loop termina, você chama FPDF_RenderPage_Close para liberar o estado progressivo por página. Como o controle retorna ao seu código entre as fatias, você pode processar mensagens, atualizar um indicador de progresso ou verificar se o trabalho ainda é necessário
O mecanismo que o PDFium fornece para decidir quando ceder é uma struct de callback chamada IFSDK_PAUSE. Você a passa para Start e para cada Continue. Após cada fragmento, o PDFium chama seu ponteiro de função NeedToPauseNow, e se este retornar um valor diferente de zero, o Continue atual para cedo e devolve o controle com FPDF_RENDER_TOBECONTINUED. A struct também carrega um campo version, que deve ser definido como 1, e um ponteiro user de forma livre que o PDFium nunca toca e passa sem alterar. Esse ponteiro intocado é todo o eixo do design que se segue
Reaproveitando pause como cancel
A intenção original de NeedToPauseNow é fatiar o tempo. Retorne diferente de zero quando o orçamento do frame estiver esgotado, retorne zero para continuar renderizando, e o PDFium pausa para que você possa fazer outra coisa antes de retomar a mesma renderização. O PDFium Component reutiliza esse mesmo sinal para um verbo diferente. Em vez de responder "devo pausar e deixar você retomar", o callback responde "este trabalho foi cancelado". Os dois se mapeiam um no outro de forma limpa por causa do que o loop faz quando vê o sinalizador. Uma pausa genuína espera um Continue posterior; um cancelamento não. Uma vez que o loop de chamada observa que o token está cancelado, ele fecha o contexto de renderização e nunca chama Continue novamente, então o mesmo retorno diferente de zero que o PDFium lê como "pare este fragmento" se torna, na prática, "pare definitivamente"
O cancelamento é expresso por meio de uma interface, IPdfCancellationToken, cuja propriedade IsCancelled muda de false para true quando outra parte do programa solicita que a renderização pare. A ponte entre essa interface Pascal e o callback C do PDFium é um único ponteiro. A referência de interface do token é escrita em IFSDK_PAUSE.user, e um callback estático cdecl lê de volta e consulta. Este é o problema clássico de permitir que uma biblioteca C faça callback para Pascal: o callback precisa ser uma função simples com convenção de chamada C, não um método, porque o PDFium armazena e invoca um ponteiro de função simples que não sabe nada sobre objetos Pascal ou 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;
O callback recupera o token fazendo cast de pThis^.user de volta ao tipo de interface e lê IsCancelled. Nada nele aloca, bloqueia ou trava, o que importa porque o PDFium o chama na thread de renderização após cada fragmento e qualquer trabalho feito aqui é somado ao custo da própria renderização. A proteção contra uma struct nil ou um campo user nil significa que a mesma função é segura para instalar mesmo em uma renderização que nunca recebeu um token real
Mantendo o token ativo durante o loop
Fazer cast de um ponteiro de interface por um Pointer bruto e de volta é onde nascem os bugs de tempo de vida. Um IInterface no Delphi é contado por referência, e a contagem só muda quando o compilador pode ver uma variável tipada como interface sendo atribuída. Armazenar o token somente como um ponteiro bruto dentro de IFSDK_PAUSE.user o ocultaria completamente do contador de referências. Se a única outra referência a esse token saísse do escopo enquanto o loop Continue ainda estivesse em execução, o objeto seria liberado por baixo do callback, e o próximo fragmento desreferenciaria um ponteiro pendente
É por isso que o descritor é um record contendo dois elementos, não um. O campo Pause é a struct que o PDFium lê. O campo Token é uma referência real tipada como interface que o compilador conta, e existe por nenhuma outra razão senão manter o token na memória enquanto o record viver. O record é uma variável local na pilha da rotina de renderização, então permanece válido durante toda a duração do loop e é destruído apenas quando a rotina sai. O ponteiro bruto em user e a referência contada em Token nomeiam o mesmo objeto; um é o que o PDFium pode ler, o outro é o que mantém esse objeto de ser coletado
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);
Fechando o contexto de renderização independente de como o loop termina
Cada chamada a FPDF_RenderPageBitmap_Start aloca estado progressivo que o PDFium associa à página, e esse estado é liberado apenas por FPDF_RenderPage_Close. Há três formas de sair do loop. A página termina e o último status é FPDF_RENDER_DONE. O token dispara e o loop sai cedo relatando cancelamento. Algo falha e o status é FPDF_RENDER_FAILED. Os três devem chamar Close, e o caminho de cancelamento é o mais fácil de errar, porque a forma natural de "ver cancelamento, sair" tende a pular a limpeza no caminho para a saída. Deixar Close inalcançável vaza o estado por página, e um visualizador que deixa o usuário cancelar renderização após renderização acumularia esse vazamento a cada página abortada
A forma robusta coloca o loop e a classificação de resultado dentro de um try e FPDF_RenderPage_Close no finally correspondente. O bitmap de destino é destruído no mesmo bloco. O cancelamento pode sair do loop por um Exit antecipado e o finally ainda é executado, então há exatamente um lugar que libera o estado progressivo e ele não pode ser ignorado
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;
O loop verifica o token antes de cada Continue, além de depender do callback dentro dele. O callback encurta o fragmento atual; a verificação do loop impede o próximo de começar. Juntos, eles limitam o tempo que um cancelamento leva para ter efeito a aproximadamente a duração de um fragmento
Três resultados e o que o bitmap contém após um cancelamento
O ponto de entrada público é TPdf.RenderPageProgressive, e ele retorna um TPdfProgressiveStatus que é um de prsDone, prsCancelled ou prsFailed. Os valores espelham as constantes FPDF_RENDER_* do PDFium em idioma Pascal, mas incluem o caso de cancelamento como um resultado de primeira classe em vez de um erro
O ponto que pega as pessoas é o que o bitmap de destino contém após prsCancelled. Não está em branco. O PDFium renderiza progressivamente no mesmo bitmap fragmento após fragmento, então quando um cancelamento para o loop, o bitmap contém tudo o que foi pintado até aquele momento, que é uma imagem parcial: algumas bandas concluídas, o resto ainda mostrando a cor de preenchimento. Se esse resultado parcial é útil depende do chamador. Um visualizador que está prestes a descartar o bitmap porque o usuário navegou para outro lugar pode simplesmente ignorá-lo. Um visualizador que quer mostrar uma prévia de baixo custo pode mantê-lo. O que não se deve fazer é assumir que prsCancelled implica um bitmap vazio ou indefinido; implica um instantâneo fiel de uma renderização inacabada
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;
O token nil e um caminho de callback sem ramificações
O cancelamento é opcional. Um chamador que só quer renderização progressiva pelo benefício do processamento de mensagens, sem intenção de abortar, deve conseguir passar nil para o token. A forma ingênua de suportar isso é dispersar verificações de "se um token foi fornecido" pelo callback e pelo loop, o que significa uma ramificação em cada fragmento e um callback que precisa lidar tanto com um token real quanto com sua ausência
A implementação evita isso substituindo por um singleton quando o chamador não passa nada. Um token nil é trocado por PdfNoCancellationToken, uma interface cujo IsCancelled é sempre false. A partir desse ponto, o callback e o loop têm um token para consultar em todos os casos, então nenhum deles precisa de uma verificação nil e nenhum precisa de um caminho especial. O token que nunca cancela simplesmente sempre responde false, o callback sempre retorna zero, e a renderização vai até a conclusão exatamente como uma não cancelável faria. O comportamento opcional é modelado como um token que nunca dispara em vez da ausência de um token, o que mantém o caminho quente uniforme
// 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;
A forma que emerge é pequena e vale a pena reiterar, porque é a parte reutilizável. Uma biblioteca C que suporta callback dá exatamente um canal para passar estado a esse callback, o ponteiro opaco de usuário. Coloque uma referência de interface Pascal contada atrás desse ponteiro, mantenha uma segunda referência real ativa ao lado da struct para que o objeto não possa ser coletado a meio da chamada, e leia a interface de volta dentro de uma função cdecl estática. Envolva todo o loop de direção em um try e libere o contexto nativo no finally. O mesmo modelo se aplica a qualquer operação progressiva ou orientada a callback do PDFium em que o código Pascal precisa manter o controle do tempo de vida enquanto C mantém um ponteiro
O cancelamento é apenas metade de um visualizador responsivo. A outra metade é não rerenderizar páginas que você já desenhou e manter o zoom e a rolagem suaves servindo bitmaps em cache, o que é abordado em nosso artigo sobre cache de renderização e desempenho de zoom. Para saber como a renderização cancelável se encaixa em um visualizador completo junto com navegação, seleção e pesquisa, veja construindo um visualizador de PDF completo com o componente PDFium VCL. A renderização progressiva descrita aqui faz parte do PDFium Component para Delphi e Lazarus, junto com as APIs de carregamento, renderização e formulários abordadas no restante deste blog