Artículo técnico

Reentrada de OnExit en un editor de campos de formulario in situ en Delphi

El control TPDFlibViewer de PDF Library for Delphi confirma un editor de campo de formulario in situ a través del propio evento OnExit del editor, y esa decisión de diseño esconde una trampa clásica de la VCL de Delphi: ocultar, reasignar de padre o destruir un control con foco desde dentro de su propio gestor de OnExit puede disparar OnExit una segunda vez antes de que regrese la primera llamada, enviando la lógica de confirmación de vuelta hacia sí misma

El fallo que esto produce se resiste a una reproducción limpia. Un usuario recorre rápidamente con Tab una serie de campos de texto en un formulario de solicitud escaneado, y de vez en cuando el visor lanza una violación de acceso, o peor, sigue ejecutándose mientras escribe en silencio el valor equivocado en un campo dos tabulaciones atrás. Reproducidlo a voluntad y el fallo parece obvio en retrospectiva; perseguidlo a partir del informe de fallo de un único cliente y parece un fantasma, porque si el segundo OnExit realmente se dispara depende de la sincronización de handle de ventana y foco que cambia con el tipo de campo, la velocidad de tecleo, y cualquier otra cosa que esté haciendo la cola de mensajes en ese instante

Cómo coloca TPDFlibViewer un editor real encima de una página renderizada

TPDFlibViewer renderiza cada página PDF a un mapa de bits y no convierte los campos de formulario en controles VCL vivos por defecto, así que BeginEditFormField es el método que tiende el puente entre los dos mundos: llamado con un índice de campo, busca el rectángulo del campo y lo convierte a coordenadas de cliente, y luego coloca un TEdit o TMemo real encima de ese rectángulo para un campo de texto, o un TComboBox en estilo csDropDownList para un campo de elección, ya con el valor actual del campo cargado. ISO 32000-2 §12.7 define qué es un campo de formulario de texto o de elección dentro de un PDF, pero nada en esa especificación dice cómo debería una aplicación Windows dejar que alguien escriba en uno, y esa brecha es exactamente lo que BeginEditFormField existe para llenar. Tanto OnKeyDown como OnExit están conectados a los mismos dos métodos del visor, InplaceEditorKeyDown e InplaceEditorExit, en cada editor que crea TPDFlibViewer, un emparejamiento que se distribuye sin cambios desde que el relleno de formularios interactivo aterrizó por primera vez en la v3.220.0, y OnExit es donde empieza el problema

Diagrama de TPDFlibViewer colocando un editor VCL en vivo sobre el mapa de bits de la página PDF renderizada y conectando OnKeyDown y OnExit a los manejadores del visor
BeginEditFormField coloca un control real con foco sobre la página, y cada editor de este tipo se distribuye llevando OnExit — la puerta por la que entra la reentrada

¿Por qué ocultar el editor dispara OnExit una segunda vez?

TWinControl en la VCL trata un cambio en Visible o Parent sobre un control con foco como una razón para alejar el foco de él de inmediato, y alejar el foco de un control es exactamente lo que dispara el evento OnExit de ese control, de forma síncrona, antes incluso de que regrese la asignación de propiedad que lo disparó. CommitInplaceEditor, el método que usa PDF Library for Delphi para cerrar el editor in situ y escribir su valor de vuelta al campo de formulario, necesita hacer precisamente esas dos cosas al salir: poner Editor.Visible a False y poner Editor.Parent a nil para que el control deje de dibujarse encima de la página y deje de recibir entrada. Haced cualquiera de las dos mientras el editor todavía tiene el foco, que casi siempre lo tiene ya que el usuario acaba de abandonarlo, y OnExit se dispara de nuevo en medio de la propia llamada que se suponía que iba a ser lo último que jamás disparara el OnExit de ese editor

¿Qué sale mal cuando CommitInplaceEditor se reentra a sí mismo?

Un método de confirmación ingenuo paga esto de una de dos formas. O bien escribe el valor del campo dos veces, una de la llamada original y otra de la llamada reentrante que se coló antes de que la primera terminara de tocar su propio estado, o bien intenta liberar el control editor mientras un marco más abajo en la pila de llamadas todavía está dentro del propio gestor de evento de ese mismo control, territorio indefinido en la VCL que aparece como una violación de acceso que puede señalar a casi cualquier línea, no necesariamente la que realmente lo causó. Ninguno de los dos fallos necesita un formulario grande para dispararse; un documento de dos campos basta, siempre que el usuario abandone el segundo campo lo bastante rápido como para que el sistema operativo todavía esté deshaciendo mensajes de foco del primero

// Versión ingenua: se lee bien en la revisión, falla solo con velocidad de escritura real
procedure TMyPdfViewer.EditorExit(Sender: TObject);
begin
  CommitEditor;               // todavía se ejecuta dentro del propio OnExit de FEditor
end;

procedure TMyPdfViewer.CommitEditor;
begin
  if not Assigned(FEditor) then
    Exit;
  SaveFieldValue(FEditor.Text);
  FEditor.Parent := nil;      // control con foco reasignado a otro padre aquí: OnExit
                               // se dispara de nuevo, reentrando en este mismo método
  FEditor.Free;                // se libera mientras una llamada más abajo en la
  FEditor := nil;              // pila todavía está dentro de su manejador OnExit
end;

Poned la referencia a nil antes de tocar el control

La solución que distribuye PDF Library for Delphi es una simple reordenación: capturar el editor en una variable local, limpiar el campo que apunta a él, y solo entonces empezar a cambiar las propiedades del control. CommitInplaceEditor lee FInplaceEditor en una variable local Editor, pone FInplaceEditor a nil de inmediato, y solo después asigna Editor.Visible y Editor.Parent. Una llamada reentrante disparada por cualquiera de esas dos asignaciones lee FInplaceEditor por sí misma, la encuentra ya en nil, y sale en su primerísima línea, antes de poder tocar Editor o escribir el valor del campo una segunda vez

procedure TPDFlibViewer.CommitInplaceEditor(Save: Boolean);
var
  Editor: TWinControl;
begin
  Editor := FInplaceEditor;
  if not Assigned(Editor) then
    Exit;                      // una llamada reentrante llega aquí y se detiene
  FInplaceEditor := nil;       // se desvincula antes de tocar el control siquiera
  if Save then
    SaveEditorValue(Editor);   // seguro: FInplaceEditor ya es nil
  Editor.Visible := False;
  Editor.Parent := nil;        // puede disparar OnExit de nuevo; la protección de arriba
                                // convierte esa llamada reentrante en una no-operación
  ReapDeadEditor;               // libera lo que quedó aparcado en el ciclo anterior
  FDeadEditor := Editor;        // aparca este en lugar de liberarlo aquí
end;

SaveEditorValue en ese listado representa la rama real, que comprueba si Editor es un TComboBox, un TMemo o un TEdit y lee su valor en consecuencia, ya que PDF Library for Delphi crea un control distinto según si el campo es un campo de texto o un campo de elección. A la protección no le importa qué rama se ejecute, solo que FInplaceEditor sea nil antes de que se ejecute cualquier cosa capaz de disparar OnExit, que es la única restricción de orden que hace seguro escribir el resto del método en cualquiera que sea el estilo que resulte natural

Nunca liberéis un control desde dentro de su propio evento

TPDFlibViewer.CommitInplaceEditor nunca llama a Editor.Free directamente, y eso es deliberado: liberar un control es inseguro mientras un marco de pila perteneciente al despacho de evento de ese mismo control todavía pueda estar deshaciéndose por encima de la llamada que lo libera, sea o no un OnExit reentrante. PDF Library for Delphi en su lugar entrega el editor desconectado a un lugar de aparcamiento de una sola ranura, FDeadEditor, liberando cualquiera que sea lo que estuviera ahí sentado desde el ciclo de edición anterior a través de un pequeño ayudante, ReapDeadEditor, llamado al principio del siguiente BeginEditFormField y una vez más desde CloseDocument; cada editor que crea el visor también es propiedad del propio visor, TEdit.Create(Self) en lugar de TEdit.Create(nil), así que incluso un control todavía aparcado en FDeadEditor cuando se destruye el visor queda recogido por la propiedad ordinaria de componentes de la VCL en lugar de filtrarse

procedure TPDFlibViewer.ReapDeadEditor;
begin
  if Assigned(FDeadEditor) then
  begin
    FDeadEditor.Free;          // ahora es seguro: el propio OnExit de este control
    FDeadEditor := nil;        // terminó al menos un ciclo de edición atrás
  end;
end;

function TPDFlibViewer.BeginEditFormField(FieldIndex: Integer): Integer;
var
  Edit: TEdit;
begin
  Result := 0;
  CommitInplaceEditor(True);   // vacía cualquier editor que siga abierto
  // ... búsqueda del campo y conversión de rectángulo omitidas ...
  ReapDeadEditor;              // ahora es seguro liberar el editor aparcado del ciclo anterior
  Edit := TEdit.Create(Self);
  Edit.Parent := Self;
  Edit.OnExit := InplaceEditorExit;
  FInplaceEditor := Edit;
  FInplaceEditor.SetFocus;
  Result := 1;
end;

¿Por qué aparece esto con más fuerza durante la navegación rápida con Tab?

FocusNextFormField, el método que PDF Library for Delphi añadió en la v3.226.0 para gobernar la navegación con Tab y Mayús+Tab a través de un formulario, llama a BeginEditFormField para el siguiente campo elegible en cada salto individual, y BeginEditFormField empieza llamando a CommitInplaceEditor(True) para volcar cualquiera que sea el editor que el campo anterior haya dejado abierto. Eso significa que cada pulsación de Tab que hace un usuario al rellenar un formulario de varios campos ejecuta exactamente una vez la secuencia de desconectar-y-luego-tocar descrita arriba, que es precisamente la vía de código con más probabilidades de tener todavía un control genuinamente con foco en el momento en que cambian Visible y Parent, porque Tab es la interacción con más garantías de dejar al editor saliente reteniendo el foco hasta el mismo instante en que el nuevo lo solicita

Diagrama de flujo de PDF Library for Delphi que muestra cómo asignar Editor.Parent a nil en un editor con foco hace que OnExit se dispare de nuevo, reentrando en CommitInplaceEditor antes de que la primera llamada retorne
Una única asignación de reparentalización devuelve la confirmación hacia sí misma, abriendo a la vez los modos de fallo de doble escritura y de Free prematuro

Nada de esto hace que el fallo sea fiable de demostrar, y eso merece la pena decirlo con claridad en lugar de pasarlo por alto. Que una asignación dada de Visible o Parent realmente fuerce un OnExit síncrono depende de un estado de foco y de handle de ventana que un depurador cambia solo por estar conectado, que un repintado o temporizador sin relación puede perturbar, y que se comporta de forma distinta según cuál de TEdit, TMemo o TComboBox resulte ser el control en juego. Una protección que solo a veces se llega a ejercitar es la razón por la que este tipo de defecto sobrevive tanto a la revisión de código como a las pruebas manuales, y también es la razón por la que la solución tiene que ser correcta por construcción, poniendo la referencia a nil antes de que ocurra cualquier otra cosa, en lugar de correcta según cualquiera que sea el comportamiento que resultara observar un puñado de pasadas de prueba manual

La forma general de esta solución viaja mucho más allá de un solo control de visor. Cualquier superficie de edición personalizada construida superponiendo un control VCL vivo sobre contenido renderizado, no solo un campo de formulario PDF, hereda el mismo peligro en el momento en que su lógica de cerrar-y-confirmar puede dispararse tanto por una acción de usuario explícita como por un cambio de foco implícito, y se aplica la misma respuesta de dos partes: limpiad la referencia que identifica al control activo antes de hacer cualquier cosa que pueda disparar su propio evento de salida, y nunca llaméis a Free desde una vía de código que todavía pudiera estar ejecutándose por debajo del propio despacho de evento de ese control. La superficie más amplia de relleno de formularios y renderizado de TPDFlibViewer, incluido cómo decide qué tipo de control mostrar para qué campo, se cubre en la visión general de la construcción de un control de visor de PDF interactivo en Delphi VCL con PDF Library for Delphi, y la caché de mapa de bits de página que SetFormFieldValueAndRefresh tiene que invalidar en cada edición confirmada se cubre por separado en la pieza sobre la caché de página en disco por DPI de monitor del visor

PDF Library for Delphi: Secuencia de confirmación ordenada de seis pasos que limpia FInplaceEditor antes de tocar el control, trata el OnExit reentrante como no-op y aplaza el Free mediante la ranura FDeadEditor
Anular primero la referencia hace que cada llamada reentrante salga de inmediato, y la ranura del editor aparcado traslada cada Free a un ciclo cuyas pilas están en silencio

La edición de campo de formulario in situ, la navegación de campo gobernada por Tab, y la vía de confirmación segura frente a la reentrada que hay detrás de ambas forman parte del control de visor interactivo distribuido con PDF Library for Delphi, la biblioteca PDF para Delphi y C++Builder, junto con el resto de su superficie de API de renderizado de página, anotaciones y campos de formulario