Artigo Técnico

Erro de Ciclo for no FPC: Clicar em Cancelar Não Interrompe a Impressão

Clicar em Cancelar durante uma tarefa de impressão num visualizador do PDFium Component por vezes não fazia nada: o ciclo continuava a renderizar todas as páginas e cópias restantes, e a tarefa continuava a chegar à impressora. A causa era o ciclo for do Free Pascal, que fixa o seu limite superior uma única vez à entrada do ciclo, pelo que zerar a variável por trás de CopyCount ou ToPage a meio do ciclo não alterava nada do que já estava em execução. O erro dos limites do ciclo for é o quinto problema a surgir da mesma auditoria de compatibilidade Delphi/FPC do PDFiumPas que produziu outras quatro armadilhas entre compiladores, e, ao contrário dessas quatro, vive inteiramente dentro da rotina de impressão de um visualizador — foi preciso um testador manter o Cancelar premido ao longo de uma tarefa longa para efetivamente reparar que a impressora nunca parava

Porque é que a tarefa de impressão continuava depois de se clicar em Cancelar?

A tarefa de impressão continuava porque a verificação de cancelamento só repunha as variáveis que alimentavam os limites do ciclo, não os próprios ciclos, que o Free Pascal já tinha fixado no momento em que cada ciclo começou. O manipulador SpeedButtonPrintClick por trás do botão Imprimir nas demonstrações PDFViewer e MultiPageViewer constrói cada tarefa a partir de três ciclos aninhados: um ciclo exterior sobre conjuntos de cópias agrupadas, um ciclo intermédio sobre páginas, e um ciclo interior sobre cópias não agrupadas da mesma página. O PrintDialog.Collate decide qual contador, CollateCopyCount ou CopyCount, efetivamente detém o número de cópias pedido, enquanto o outro se mantém em um. Cancelar tem de alcançar os três níveis ao mesmo tempo, e a primeira versão deste código tentava fazê-lo repondo as próprias variáveis de limite no momento em que Cancel se tornava verdadeiro

for CollateCopy:= 1 to CollateCopyCount do
  for Page:= FromPage to ToPage do
    for Copy:= 1 to CopyCount do
    begin
      // ... render the page and send it to the printer ...
      Application.ProcessMessages;
      if Cancel then
      begin
        CollateCopyCount:= 0;
        ToPage:= 0;
        CopyCount:= 0;
      end;
    end;
Printer.EndDoc;  // runs whether or not Cancel fired

A intenção lê-se com clareza suficiente: se os contadores que definem quantas colações, páginas, e cópias restam caírem todos a zero, os ciclos deveriam ficar sem trabalho e terminar por conta própria. O Printer.EndDoc corria depois incondicionalmente a seguir ao ciclo, independentemente de como este terminasse, pelo que mesmo uma tarefa que um utilizador acreditasse ter interrompido continuava a ser submetida ao spooler com todas as páginas renderizadas antes de o clique ser notado

O que o Free Pascal fixa quando um ciclo for começa

O Free Pascal avalia o valor final de um ciclo for exatamente uma vez, no momento em que o ciclo começa, e nunca mais durante a vida desse ciclo. O for Page := FromPage to ToPage doToPage uma única vez para calcular quantas iterações correr, e depois disso o ciclo já não tem qualquer interesse numa variável chamada ToPage, apenas na contagem de iterações que já capturou. Definir ToPage := 0 de dentro do corpo do ciclo altera uma variável que o ciclo em execução já não está a consultar, o que é exatamente a razão pela qual Cancel podia estar verdadeiro enquanto a impressora continuava a receber páginas durante mais várias iterações, por vezes todas elas

Esse padrão é um hábito perfeitamente razoável de transportar de C ou C++, e a demonstração de visualizador em C++Builder do PDFium Component espelha a versão em Pascal de perto o suficiente para tornar a comparação direta. O seu for (Copy = 1; Copy <= CopyCount; Copy++) volta a testar Copy <= CopyCount contra o valor ativo de CopyCount em cada passagem, pelo que zerar o contador ali termina genuinamente o ciclo na verificação seguinte. A demonstração em C++Builder transportava o mesmo código de zerar os contadores e funcionava, o que é exatamente o que fez a mesma ideia parecer segura para reutilizar na compilação em Pascal sentada mesmo ao lado, no mesmo repositório

Porque é que a compilação Delphi não sofreu o mesmo erro?

A demonstração PDFViewer em Delphi nunca dependeu de um ciclo reparar numa alteração de limite, porque o seu caminho de cancelamento se desenrola através de uma exceção em vez de uma comparação. O seu ciclo mais interior chama o procedimento Abort da RTL no momento em que Cancel se torna verdadeiro, o que levanta um EAbort silencioso que se propaga diretamente através dos três ciclos for aninhados até um handler que envolve todo o bloco de impressão

Printer.BeginDoc;
try
  for CollateCopy:= 1 to CollateCopyCount do
    for Page:= FromPage to ToPage do
      for Copy:= 1 to CopyCount do
      begin
        // ... render the page and send it to the printer ...
        Application.ProcessMessages;
        if Cancel then
          Abort;  // raises EAbort, unwinds all three loops at once
      end;
  Printer.EndDoc;
except
  on E: EAbort do
    Printer.Abort;
else
  begin
    Printer.Abort;
    raise;
  end;
end;

Uma exceção não se importa com quantos ciclos for separam o ponto onde é levantada do handler que a apanha, o que é exatamente a propriedade de que este problema precisa. Essa robustez não era uma defesa deliberada contra o comportamento de fixação de limites descrito acima — o autor da demonstração em Delphi simplesmente recorreu a uma ferramenta diferente. A saída baseada em exceções continua a merecer ser nomeada como o padrão mais robusto: sobrevive a um quarto nível de aninhamento ser acrescentado mais tarde, enquanto uma cadeia de instruções Break colocadas manualmente tem de ser lembrada e reacrescentada sempre que o aninhamento do ciclo muda

A correção: Break em cada nível de aninhamento, protegido por uma flag PrintSucceeded

A correção lançada na v2.27.0 do PDFiumPas mantém a estrutura de três ciclos nas demonstrações de visualizador em Lazarus e C++Builder, mas torna o cancelamento explícito em cada nível, e separa a paragem do ciclo da submissão da tarefa numa flag que só é lida depois de o ciclo ter terminado por completo

Printer.BeginDoc;
try
  Cancel:= False;
  for CollateCopy:= 1 to CollateCopyCount do
  begin
    for Page:= FromPage to ToPage do
    begin
      for Copy:= 1 to CopyCount do
      begin
        // ... render the page and send it to the printer ...
        Application.ProcessMessages;
        if Cancel then
          Break;
      end;
      if Cancel then
        Break;
    end;
    if Cancel then
      Break;
  end;
  PrintSucceeded:= not Cancel;
finally
  if PrintSucceeded then
    Printer.EndDoc
  else
    Printer.Abort;
end;

O PrintSucceeded é deliberadamente calculado uma única vez, imediatamente depois de o ciclo triplamente aninhado terminar, a partir de nada mais do que not Cancel. Nada dentro do corpo do ciclo tem autoridade para decidir por conta própria se a tarefa foi bem-sucedida — o ciclo só pode terminar de duas formas, ficando sem colações, páginas, e cópias, ou atingindo a cadeia de Break que Cancel despoleta, e o PrintSucceeded lê o resultado depois do facto, em vez de o acompanhar à medida que o ciclo avança. Calculá-lo dessa forma é o que torna fiável a escolha do bloco finally entre Printer.EndDoc e Printer.Abort: nunca dispara antes de o ciclo se ter efetivamente decidido

Auditar os próprios ciclos de impressão orientados por cancelamento

Três verificações vão muito além desta única rotina de impressão. Nunca presumir que um ciclo for em Pascal vai reparar numa variável de limite a mudar depois de ter começado; se um ciclo precisa de terminar mais cedo, dizê-lo diretamente com Break, em cada nível de aninhamento que o cancelamento precise de atravessar, não apenas o mais interior. Preferir um mecanismo de saída que se desenrole por construção, como uma exceção, assim que o aninhamento for suficientemente profundo para um Break em falta ser plausível — o par Abort/EAbort da demonstração em Delphi obteve isto de graça. Condicionar qualquer passo de confirmação como EndDoc a uma flag calculada estritamente depois do ciclo, nunca dentro dele, para que uma tarefa que tenha parado cedo nunca possa ser confundida com uma que tenha terminado

A mesma revisão que corrigiu este ciclo também apertou a forma como as nove demonstrações de visualizador tratam o teclado enquanto um botão de cancelar está visível: agora engolem todas as teclas exceto Esc, pelo que um Ctrl+P ou Ctrl+F perdido durante uma impressão ou pesquisa ativa já não consegue iniciar uma segunda por cima dela. Esta correção de reentrância é um erro diferente com um mecanismo diferente, mas surgiu da mesma revisão sobre o que Cancel efetivamente faz a meio de uma operação. O hábito que vale a pena reter de ambas as correções é mais um hábito de teste do que de programação: exercitar o cancelamento em cada compilador para o qual uma rotina de impressão partilhada efetivamente é distribuída, porque uma ideia como limpar os contadores e confiar que o ciclo repare nisso pode sobreviver a testes sob o C++Builder, chegar a uma compilação Free Pascal sem verificação, ler-se de forma idêntica na fonte, e falhar de uma forma que ninguém vê até alguém manter o Cancelar premido tempo suficiente para ver a tarefa terminar de qualquer forma. Para a configuração de impressão sobre a qual esta lógica de cancelamento assenta, o passo a passo sobre imprimir documentos PDF com o componente VCL PDFium cobre o resto

Esta correção de cancelamento de impressão foi lançada como parte do PDFium Component para Delphi, C++Builder, e FPC/Lazarus, que corre o mesmo conjunto de demonstrações nos três compiladores em cada lançamento, de modo a que lacunas como esta sejam apanhadas por uma matriz de compilação em vez de um pedido de suporte