Artículo técnico

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

El control TPDFlibViewer de PDFlibPas 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

¿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 PDFlibPas 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

// Naive version: reads fine in review, fails only under real typing speed
procedure TMyPdfViewer.EditorExit(Sender: TObject);
begin
  CommitEditor;               // still running inside FEditor's own OnExit
end;

procedure TMyPdfViewer.CommitEditor;
begin
  if not Assigned(FEditor) then
    Exit;
  SaveFieldValue(FEditor.Text);
  FEditor.Parent := nil;      // focused control reparented here: OnExit
                               // fires again, re-entering this same method
  FEditor.Free;                // freed while a caller further down the
  FEditor := nil;              // stack is still inside its OnExit handler
end;

Poned la referencia a nil antes de tocar el control

La solución que distribuye PDFlibPas 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;                      // a reentrant call lands here and stops
  FInplaceEditor := nil;       // detach before the control is touched at all
  if Save then
    SaveEditorValue(Editor);   // safe: FInplaceEditor is already nil
  Editor.Visible := False;
  Editor.Parent := nil;        // may fire OnExit again; the guard above
                                // turns that reentrant call into a no-op
  ReapDeadEditor;               // free whatever was parked last cycle
  FDeadEditor := Editor;        // park this one instead of freeing it here
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 PDFlibPas 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. PDFlibPas 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;          // safe now: this control's own OnExit
    FDeadEditor := nil;        // finished at least one edit cycle ago
  end;
end;

function TPDFlibViewer.BeginEditFormField(FieldIndex: Integer): Integer;
var
  Edit: TEdit;
begin
  Result := 0;
  CommitInplaceEditor(True);   // flush whatever editor is still open
  // ... field lookup and rectangle conversion omitted ...
  ReapDeadEditor;              // now safe to free last cycle's parked editor
  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 PDFlibPas 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

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 PDFlibPas, 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

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 PDFlibPas, 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