El control TPDFlibViewer de PDFlibPas confirma un editor de campo de formulario en el mismo lugar mediante el propio evento OnExit del editor, y esa decisión de diseño esconde una trampa clásica de VCL de Delphi: ocultar, reasignar el padre, o destruir un control enfocado desde dentro de su propio manejador OnExit puede disparar OnExit una segunda vez antes de que retorne la primera llamada, enviando la lógica de confirmación de vuelta a sí misma
El fallo que esto produce se resiste a una reproducción limpia. Un usuario tabula rápidamente a través de 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 silenciosamente escribe el valor equivocado en un campo dos tabulaciones atrás. Reprodúzcalo a demanda y el bug se ve obvio en retrospectiva; persígalo desde el reporte de fallo de un solo cliente y se ve como un fantasma, porque si el segundo OnExit realmente se dispara depende de la temporización de handle de ventana y foco que cambia según el tipo de campo, la velocidad de escritura, y cualquier otra cosa que esté haciendo la cola de mensajes en ese instante
Cómo pone TPDFlibViewer un editor real encima de una página renderizada
TPDFlibViewer renderiza cada página de 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 conecta 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, completo con el valor actual del campo ya cargado. ISO 32000-2 §12.7 define qué es un campo de formulario de texto o elección dentro de un PDF, pero nada en esa especificación dice cómo debería una aplicación de 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 ha incluido sin cambios desde que el llenado de formularios interactivo aterrizó por primera vez en v3.220.0, y OnExit es donde empieza el problema
¿Por qué ocultar el editor dispara OnExit una segunda vez?
TWinControl en el VCL trata un cambio a Visible o Parent en un control enfocado como una razón para mover el foco fuera de él de inmediato, y mover el foco fuera de un control es exactamente lo que dispara el evento OnExit de ese control, sincrónicamente, antes de que la asignación de propiedad que lo disparó siquiera retorne. CommitInplaceEditor, el método que usa PDFlibPas para cerrar el editor en el mismo lugar y escribir su valor de vuelta al campo de formulario, necesita hacer precisamente esas dos cosas al salir: establecer Editor.Visible en Falso y establecer Editor.Parent en nil para que el control deje de dibujarse encima de la página y deje de recibir entrada. Haga cualquiera de las dos mientras el editor todavía tiene el foco, que casi siempre lo tiene ya que el usuario acaba de salir de él, y OnExit se dispara de nuevo en medio de la propia llamada que se suponía era lo último que ese OnExit de ese editor jamás dispararía
¿Qué sale mal cuando CommitInplaceEditor se reentra a sí mismo?
Un método de confirmación ingenuo paga por esto de una de dos maneras. O bien escribe el valor del campo dos veces, una desde la llamada original y otra desde la llamada reentrante que se coló antes de que la primera terminara de tocar su propio estado, o intenta liberar el control editor mientras un marco más abajo en la pila de llamadas todavía está dentro del propio manejador de evento de ese mismo control, que es territorio indefinido en el VCL y aparece como una violación de acceso que puede apuntar a casi cualquier línea, no necesariamente la que realmente la causó. Ningún fallo necesita un formulario grande para dispararse; un documento de dos campos es suficiente, siempre que el usuario salga del segundo campo lo bastante rápido como para que el SO todavía esté desenrollando 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;
Ponga la referencia en nil antes de tocar el control
La corrección que incorpora 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, establece FInplaceEditor en nil de inmediato, y solo después asigna Editor.Visible y Editor.Parent. Una llamada reentrante disparada por cualquiera de esas dos asignaciones lee ella misma FInplaceEditor, la encuentra ya en nil, y sale en su misma primera 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 cualquier estilo que de otro modo sea natural
Nunca libere 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 desenrollándose por encima de la llamada que lo libera, sea o no reentrante OnExit. PDFlibPas en cambio entrega el editor desconectado a un lugar de estacionamiento de una sola ranura, FDeadEditor, liberando cualquiera que fuera lo que estuviera ahí sentado del ciclo de edición anterior mediante un pequeño helper, ReapDeadEditor, llamado al inicio 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 estacionado en FDeadEditor cuando se destruye el visor es recogido por la propiedad ordinaria de componente 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 agregó PDFlibPas en v3.226.0 para controlar la navegación con Tab y Shift+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 dejó abierto el campo anterior. Eso significa que cada pulsación de Tab que hace un usuario mientras llena un formulario de varios campos ejecuta exactamente una vez la secuencia de desconectar-y-luego-tocar descrita arriba, que es precisamente la ruta de código con más probabilidades de todavía tener un control genuinamente enfocado en el momento en que cambian Visible y Parent, porque Tab es la interacción casi garantizada de dejar al editor saliente reteniendo el foco justo hasta que el nuevo lo pide
Nada de esto hace que el bug sea confiable de demostrar, y vale la pena decirlo claramente en lugar de suavizarlo. Si una asignación dada de Visible o Parent realmente fuerza un OnExit sincrónico depende del estado de foco y handle de ventana que un depurador cambia con solo estar conectado, que un repintado o temporizador no relacionado puede perturbar, y que se comporta de manera distinta según cuál de TEdit, TMemo, o TComboBox resulte ser el control en juego. Una protección que solo a veces se ejercita es la razón por la que este tipo de defecto sobrevive tanto la revisión de código como las pruebas manuales, y también es la razón por la que la corrección tiene que ser correcta por construcción, poniendo en nil la referencia antes de que ocurra cualquier otra cosa, en lugar de correcta según cualquiera que sea el comportamiento que hayan observado por casualidad un puñado de pasadas de prueba manual
La forma general de esta correcció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 ser disparada tanto por una acción explícita del usuario como por un cambio de foco implícito, y aplica la misma respuesta en dos partes: limpie la referencia que identifica al control activo antes de hacer cualquier cosa que pudiera disparar su propio evento de salida, y nunca llame a Free desde una ruta 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 llenado 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 construir 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 en el mismo lugar, la navegación de campo controlada por Tab, y la ruta de confirmación segura ante reentrancia detrás de ambas son parte del control de visor interactivo incluido con PDFlibPas, la biblioteca PDF para Delphi y C++Builder, junto con el resto de su superficie de API de renderizado de página, anotación, y campo de formulario