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