A maioria das páginas PDF rasteriza em alguns milissegundos e nunca pensa nisso. Depois um utilizador abre um desenho de engenharia A1, uma página repleta de dezenas de milhares de traços vetoriais, ou um cartaz cheio de grupos de transparência e máscaras suaves, e a única chamada que o pinta demora dois ou três segundos. Se essa chamada correr na thread da UI, a janela deixa de se redesenhar, a barra de título fica acinzentada, e o sistema operativo oferece-se para matar a aplicação. O trabalho é legítimo. A página precisa mesmo de tanto tempo. O defeito é que a renderização é uma única chamada bloqueante indivisível, sem forma de respirar e sem forma de parar
Este artigo trata exatamente de um desses dois problemas: cancelar uma renderização longa de uma única página sem congelar a UI. O utilizador clicou na página seguinte, ou fez zoom, ou fechou o documento, e a renderização em curso é agora trabalho desperdiçado que deveria terminar na próxima oportunidade em vez de correr até ao fim. Suavizar o scroll e o zoom guardando em cache o que já foi rasterizado é uma preocupação distinta com o seu próprio design, abordada no artigo complementar ligado no fim. Aqui a única questão é como fazer com que uma renderização progressiva responda a um pedido 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 problema relativa ao congelamento. A par do disparo único FPDF_RenderPageBitmap, expõe uma variante progressiva que divide uma página em pedaços de trabalho. Chama-se FPDF_RenderPageBitmap_Start uma vez para configurar a renderização contra um bitmap de destino, e depois chama-se FPDF_RenderPage_Continue repetidamente. Cada Continue rasteriza uma fatia limitada e devolve um estado. FPDF_RENDER_TOBECONTINUED significa que há mais por fazer, FPDF_RENDER_DONE significa que a página está terminada, e FPDF_RENDER_FAILED significa que parou num erro. Quando o loop termina, chama-se FPDF_RenderPage_Close para libertar o estado progressivo por página. Como o controlo regressa ao seu código entre fatias, pode bombear mensagens, atualizar um indicador de progresso ou verificar se o trabalho ainda é desejado
O mecanismo que o PDFium fornece para decidir quando ceder é uma struct de callback chamada IFSDK_PAUSE. Entrega-se ao Start e a cada Continue. Após cada pedaço, o PDFium chama o seu ponteiro de função NeedToPauseNow, e se este devolver um valor diferente de zero, o Continue atual pára cedo e devolve o controlo com FPDF_RENDER_TOBECONTINUED. A struct transporta também um campo version, que tem de ser definido como 1, e um ponteiro user de forma livre que o PDFium nunca toca e passa adiante intacto. Esse ponteiro intacto é toda a charneira do design que se segue
Reaproveitar a pausa como cancelamento
A intenção original de NeedToPauseNow é o fatiamento do tempo (time-slicing). Devolve-se um valor diferente de zero quando o orçamento de frame se esgota, devolve-se zero para continuar a renderizar, e o PDFium faz uma pausa para que 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 a «devo fazer uma pausa e deixá-lo retomar», o callback responde a «este trabalho foi cancelado». Os dois mapeiam-se um no outro de forma limpa, por causa do que o loop faz quando vê a flag. Uma pausa genuína espera um Continue posterior; um cancelamento não. Assim que o loop chamador observa que o token está cancelado, fecha o contexto de renderização e nunca mais chama Continue, pelo que o mesmo valor não-zero que o PDFium lê como «pára este pedaço» torna-se, na prática, «pára de vez»
O cancelamento é expresso através de uma interface, IPdfCancellationToken, cuja propriedade IsCancelled passa de falso para verdadeiro quando alguma outra parte do programa pede para a renderização parar. 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 cdecl estático lê-a de volta e consulta-a. Este é o problema clássico de deixar uma biblioteca C chamar de volta para Pascal: o callback tem de 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 nu que nada sabe sobre objetos Pascal ou sobre Self
type
TPdfProgressivePause = record
Pause: IFSDK_PAUSE; // o PDFium lê isto; .user contém o token
Token: IPdfCancellationToken; // a ref forte mantém o token vivo
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; // não-zero: o PDFium pára este pedaço
end;
O callback recupera o token convertendo (cast) pThis^.user de volta para o tipo de interface e lê IsCancelled. Nada nele aloca, bloqueia (lock) ou trava, o que importa porque o PDFium chama-o na thread de renderização após cada pedaço, e qualquer trabalho feito aqui é adicionado ao custo da própria renderização. A guarda contra uma struct nil ou um campo user nil significa que a mesma função é segura para instalar mesmo numa renderização à qual nunca foi dado um token real
Manter o token vivo ao longo do loop
Converter um ponteiro de interface através de um Pointer nu e de volta é onde nascem os bugs de tempo de vida. Uma IInterface em Delphi tem contagem de referências, e a contagem só se move quando o compilador consegue ver uma variável de tipo interface a ser atribuída. Armazenar o token apenas como um ponteiro nu dentro de IFSDK_PAUSE.user escondê-lo-ia por completo do contador de referências. Se a única outra referência a esse token saísse de âmbito enquanto o loop Continue ainda estivesse a correr, o objeto seria libertado por baixo do callback, e o pedaço seguinte desreferenciaria um ponteiro pendente
É por isso que o descritor é um registo que contém duas coisas, não uma. O campo Pause é a struct que o PDFium lê. O campo Token é uma referência real de tipo interface que o compilador conta, e existe por nenhuma outra razão senão fixar o token em memória durante o tempo de vida do registo. O registo é uma variável local na stack da rotina de renderização, pelo que permanece válido durante toda a duração do loop e só é desmontado quando a rotina termina. O ponteiro nu em user e a referência contada em Token nomeiam o mesmo objeto; um é o que o PDFium pode ler, o outro é o que impede esse objeto de ser recolhido
var
Pause: TPdfProgressivePause;
EffectiveToken: IPdfCancellationToken;
begin
// ... escolher EffectiveToken ...
// Primeiro a ref forte, depois publica o mesmo objeto ao PDFium via .user.
Pause.Token := EffectiveToken;
Pause.Pause.version := 1;
Pause.Pause.NeedToPauseNow := ProgressivePauseCallback;
Pause.Pause.user := Pointer(EffectiveToken);
Fechar o contexto de renderização, qualquer que seja a forma como o loop termina
Cada chamada a FPDF_RenderPageBitmap_Start aloca estado progressivo que o PDFium associa à página, e esse estado só é libertado por FPDF_RenderPage_Close. Há três saídas do loop de condução. A página termina e o último estado é FPDF_RENDER_DONE. O token dispara e o loop sai cedo a reportar cancelamento. Algo falha e o estado é FPDF_RENDER_FAILED. As três têm de chamar Close, e o caminho de cancelamento é o mais fácil de errar, porque a forma natural de «ver cancelamento, sair» tende a saltar a limpeza no seu caminho para a saída. Deixar Close por alcançar perde (leak) o estado por página, e um visualizador que deixe o utilizador cancelar renderização após renderização acumularia esse leak em cada página abortada
A forma robusta coloca o loop e a classificação do resultado dentro de um try e o FPDF_RenderPage_Close no finally correspondente. O bitmap de destino é destruído no mesmo bloco. O cancelamento pode sair do loop através de um Exit antecipado e o finally continua a correr, pelo que existe exatamente um sítio que liberta o estado progressivo e ele não pode ser contornado
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
// Liberta o estado progressivo que Start alocou; obrigatório em todos os caminhos.
FPDF_RenderPage_Close(FPage);
FPDFBitmap_Destroy(PdfBmp);
end;
O loop verifica o token antes de cada Continue além de confiar no callback dentro dele. O callback encurta o pedaço atual; a verificação do loop impede o seguinte de começar. Juntos, limitam o tempo que um cancelamento demora a fazer efeito a aproximadamente a duração de um pedaço
Três desfechos, e o que o bitmap contém após um cancelamento
O ponto de entrada público é TPdf.RenderPageProgressive, e devolve um TPdfProgressiveStatus que é um de prsDone, prsCancelled ou prsFailed. Os valores espelham as constantes FPDF_RENDER_* do PDFium em idioma Pascal, mas integram o caso de cancelamento como um resultado de primeira classe em vez de um erro
O ponto que apanha as pessoas é o que o bitmap de destino contém após prsCancelled. Não está em branco. O PDFium renderiza progressivamente para o mesmo bitmap pedaço após pedaço, pelo que quando um cancelamento pára o loop, o bitmap contém o que quer que tenha sido pintado até esse momento, que é uma imagem parcial: algumas bandas concluídas, o resto ainda a mostrar a cor de preenchimento. Se esse resultado parcial é útil depende do chamador. Um visualizador que está prestes a deitar fora o bitmap porque o utilizador navegou para outro lado pode simplesmente ignorá-lo. Um visualizador que queira mostrar uma pré-visualização de baixo custo pode mantê-lo. O que não deve fazer é assumir que prsCancelled implica um bitmap vazio ou indefinido; implica um instantâneo verdadeiro de uma renderização inacabada
var
Bmp: TBitmap;
Token: IPdfCancellationToken;
Status: TPdfProgressiveStatus;
begin
Bmp := TBitmap.Create;
try
// O token começa não-cancelado; comute Token.IsCancelled de outro sítio
// (uma ação de UI, um evento de navegação) para abortar a renderização em curso.
Status := Pdf.RenderPageProgressive(Bmp, 0, 0, PageW, PageH, Token);
case Status of
prsDone: Image1.Picture.Assign(Bmp); // totalmente renderizado
prsCancelled: ; // bitmap parcial, normalmente descartado
prsFailed: ShowMessage('Render failed');
end;
finally
Bmp.Free;
end;
end;
O token nil e um caminho de callback sem ramificações
O cancelamento é opcional (opt-in). Um chamador que apenas quer renderização progressiva pelo benefício do bombeamento de mensagens, sem intenção de abortar, deve poder passar nil para o token. A forma ingénua de suportar isso é espalhar verificações de «foi fornecido um token» pelo callback e pelo loop, o que significa uma ramificação em cada pedaço e um callback que tem de lidar tanto com um token real como com a sua ausência
A implementação evita isso substituindo um singleton quando o chamador não passa nada. Um token nil é trocado por PdfNoCancellationToken, uma interface cujo IsCancelled é sempre falso. A partir daí, o callback e o loop têm um token a consultar em todos os casos, pelo que nenhum precisa de uma verificação de nil e nenhum precisa de um caminho especial. O token que nunca cancela responde simplesmente sempre falso, o callback devolve sempre zero, e a renderização corre até ao fim exatamente como uma não cancelável faria. O comportamento opcional é modelado como um token que nunca dispara em vez de como a ausência de um token, o que mantém o caminho quente (hot path) uniforme
// nil -> singleton que nunca cancela, para que o caminho do callback seja idêntico
// quer o chamador tenha ou não optado pelo cancelamento.
if AToken <> nil then
EffectiveToken := AToken
else
EffectiveToken := PdfNoCancellationToken;
A forma que emerge é pequena e vale a pena reafirmar, porque é a parte reutilizável. Uma biblioteca C que suporta um callback dá-lhe exatamente um canal para passar estado para esse callback, o ponteiro user opaco. Coloque uma referência de interface Pascal contada por trás desse ponteiro, mantenha uma segunda referência real viva ao lado da struct para que o objeto não possa ser recolhido a meio da chamada, e leia a interface de volta dentro de uma função cdecl estática. Envolva todo o loop de condução num try e liberte o contexto nativo no finally. O mesmo modelo aplica-se a qualquer operação progressiva ou orientada a callback do PDFium em que o código Pascal tenha de manter o controlo do tempo de vida enquanto o C segura um ponteiro
O cancelamento é apenas metade de um visualizador responsivo. A outra metade é não voltar a renderizar páginas que já desenhou, e manter o zoom e o scroll suaves servindo bitmaps em cache, o que é abordado no nosso artigo sobre a cache de renderização e o desempenho do zoom. Para saber como a renderização cancelável se encaixa num visualizador completo a par de navegação, seleção e pesquisa, veja construir um visualizador de PDF rico em funcionalidades com o componente PDFium Component. A renderização progressiva aqui descrita é entregue como parte do PDFium Component para Delphi e Lazarus, a par das APIs de carregamento, renderização e formulários abordadas noutros artigos deste blog