Artículo técnico

Fallo del bucle for de FPC: hacer clic en Cancelar no detiene la impresión

Hacer clic en Cancelar durante un trabajo de impresión en un visor del componente PDFium a veces no hacía nada: el bucle seguía renderizando cada página y copia restante, y el trabajo aun así llegaba a la impresora. La causa era el bucle for de Free Pascal, que fija su límite superior una sola vez al entrar en el bucle, así que poner a cero la variable que hay detrás de CopyCount o ToPage a mitad de bucle no cambiaba nada de lo que ya estaba en marcha. El fallo de límites del bucle for es la quinta trampa que ha salido de la misma auditoría de compatibilidad Delphi/FPC de PDFiumPas que produjo otras cuatro trampas entre compiladores, y a diferencia de esas cuatro vive enteramente dentro de la rutina de impresión de un visor: hizo falta que un probador mantuviera pulsado Cancelar durante un trabajo largo para notar de verdad que la impresora nunca se detenía

¿Por qué sigue adelante el trabajo de impresión después de pulsar Cancelar?

El trabajo de impresión seguía adelante porque la comprobación de cancelación solo reiniciaba las variables que alimentaban los límites del bucle, no los propios bucles, que Free Pascal ya había fijado en cuanto empezaba cada bucle. El gestor SpeedButtonPrintClick detrás del botón Imprimir en las demostraciones PDFViewer y MultiPageViewer construye cada trabajo a partir de tres bucles anidados: un bucle exterior sobre juegos de copias intercaladas, un bucle intermedio sobre páginas, y un bucle interior sobre copias no intercaladas de la misma página. PrintDialog.Collate decide qué contador, CollateCopyCount o CopyCount, contiene realmente el número de copias solicitado, mientras el otro se queda en uno. Cancelar tiene que alcanzar los tres niveles a la vez, y la primera versión de este código intentaba hacerlo reiniciando las propias variables límite en el momento en que Cancel se volvía true

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

La intención se lee con bastante claridad: si los contadores que definen cuántas intercalaciones, páginas y copias quedan bajan todos a cero, los bucles deberían quedarse sin trabajo y terminar por sí solos. Printer.EndDoc se ejecutaba entonces incondicionalmente después del bucle sin importar cómo hubiera terminado, así que incluso un trabajo que un usuario creía haber detenido igualmente se enviaba al spooler con cada página ya renderizada antes de que se notara el clic

Qué fija Free Pascal cuando empieza un bucle for

Free Pascal evalúa el valor final de un bucle for exactamente una vez, en el momento en que empieza el bucle, y nunca más durante toda la vida de ese bucle. for Page := FromPage to ToPage do lee ToPage una sola vez para calcular cuántas iteraciones ejecutar, y después de eso el bucle ya no tiene ningún interés en una variable llamada ToPage, solo en el recuento de iteraciones que ya capturó. Poner ToPage := 0 desde dentro del cuerpo del bucle cambia una variable que el bucle en marcha ya no consulta, que es exactamente por qué Cancel podía ser true mientras la impresora seguía recibiendo páginas durante varias iteraciones más, a veces todas ellas

Ese patrón es un hábito completamente razonable de trasladar desde C o C++, y la demostración de visor C++Builder del componente PDFium refleja la Pascal lo bastante de cerca como para hacer la comparación directa. Su for (Copy = 1; Copy <= CopyCount; Copy++) vuelve a comprobar Copy <= CopyCount contra el valor vivo de CopyCount en cada pasada, así que poner a cero el contador ahí sí termina genuinamente el bucle en la siguiente comprobación. La demostración de C++Builder llevaba el código idéntico de poner-a-cero-los-contadores y funcionaba, que es exactamente lo que hizo que la misma idea pareciera segura de reutilizar en la compilación Pascal situada justo al lado en el mismo repositorio

¿Por qué no se topó la compilación Delphi con el mismo fallo?

La demostración PDFViewer de Delphi nunca dependió de que un bucle notara un límite cambiado, porque su vía de cancelación se deshace mediante una excepción en lugar de una comparación. Su bucle más interno llama al procedimiento Abort de la RTL en el momento en que Cancel se vuelve true, lo que lanza un EAbort silencioso que se propaga directamente a través de los tres bucles for anidados hasta un gestor que envuelve todo el bloque de impresión

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;

A una excepción no le importa cuántos bucles for separan el punto donde se lanza del gestor que la captura, que es exactamente la propiedad que necesita este problema. Esa robustez no fue una defensa deliberada contra el comportamiento de bloqueo de límites descrito arriba, el autor de la demostración Delphi simplemente recurrió a una herramienta distinta. La salida basada en excepciones sigue mereciendo la pena nombrarla como el patrón más robusto: sobrevive a que se añada más tarde un cuarto nivel de anidamiento, mientras que una cadena de instrucciones Break colocadas a mano hay que recordarla y volver a añadirla cada vez que cambia el anidamiento del bucle

La solución: Break en cada nivel de anidamiento, condicionado por un indicador PrintSucceeded

La solución distribuida en PDFiumPas v2.27.0 mantiene la estructura de tres bucles en las demostraciones de visor de Lazarus y C++Builder pero hace explícita la cancelación en cada nivel, y separa la detención del bucle del envío del trabajo mediante un indicador que solo se lee después de que el bucle haya 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;

PrintSucceeded se calcula deliberadamente una sola vez, inmediatamente después de que salga el bucle de triple anidamiento, a partir de nada más que not Cancel. Nada dentro del cuerpo del bucle llega a decidir por sí mismo si el trabajo tuvo éxito, el bucle solo puede terminar de dos formas, quedándose sin intercalaciones, páginas y copias, o topándose con la cadena de Break que dispara Cancel, y PrintSucceeded lee el resultado después del hecho en lugar de rastrearlo a medida que avanza el bucle. Calcularlo de ese modo es lo que hace fiable la elección del bloque finally entre Printer.EndDoc y Printer.Abort: nunca se dispara antes de que el bucle se haya asentado realmente

Auditar vuestros propios bucles de impresión gobernados por cancelación

Tres comprobaciones viajan mucho más allá de esta única rutina de impresión. Nunca asumáis que un bucle for de Pascal notará que una variable límite cambia después de haber empezado; si un bucle necesita terminar antes de tiempo, decidlo directamente con Break, en cada nivel de anidamiento que la cancelación necesite cruzar, no solo en el más interno. Preferid un mecanismo de salida que se deshaga por construcción, como una excepción, en cuanto el anidamiento sea lo bastante profundo como para que un Break olvidado sea plausible; el par Abort/EAbort de la demostración Delphi obtuvo esto gratis. Condicionad cualquier paso de confirmación como EndDoc detrás de un indicador calculado estrictamente después del bucle, nunca dentro de él, para que un trabajo que se detuvo antes de tiempo nunca pueda confundirse con uno que terminó

La misma revisión que arregló este bucle también ajustó cómo gestionan el teclado las nueve demostraciones de visor mientras hay visible un botón de cancelar: ahora tragan cada tecla salvo Esc, así que un Ctrl+P o Ctrl+F fortuito durante una impresión o búsqueda activa ya no puede iniciar una segunda encima de ella. Esta solución de reentrada es un fallo distinto con un mecanismo distinto, pero salió de la misma revisión de qué hace realmente Cancel a mitad de operación. El hábito que merece la pena llevarse de ambas soluciones es un hábito de pruebas más que de codificación: ejercitad la cancelación en cada compilador al que realmente se distribuya una rutina de impresión compartida, porque una idea como poner a cero los contadores y confiar en que el bucle lo note puede sobrevivir a las pruebas bajo C++Builder, llegar a una compilación Free Pascal sin verificar, leerse de forma idéntica en el código fuente, y fallar de un modo que nadie ve hasta que alguien mantiene pulsado Cancelar el tiempo suficiente para ver que el trabajo termina de todos modos. Para la configuración de impresión sobre la que se apoya esta lógica de cancelación, el recorrido sobre la impresión de documentos PDF con el componente VCL de PDFium cubre el resto

Esta solución de cancelación de impresión se distribuyó como parte del componente PDFium para Delphi, C++Builder y FPC/Lazarus, que ejecuta la misma batería de demostraciones en los tres compiladores en cada versión para que huecos como este los atrape una matriz de compilación en lugar de un ticket de soporte