O controlo TPDFlibViewer do PDFlibPas confirma um editor de campo de formulário in-place através do próprio evento OnExit do editor, e essa escolha de desenho esconde uma armadilha clássica da VCL do Delphi: esconder, reatribuir o pai, ou destruir um controlo com foco de dentro do seu próprio manipulador de OnExit pode disparar o OnExit uma segunda vez antes de a primeira chamada regressar, reenviando a lógica de confirmação de volta para si própria
A falha que isto produz resiste a uma reprodução limpa. Um utilizador percorre rapidamente com Tab uma sequência de campos de texto num formulário de candidatura digitalizado, e de vez em quando o visualizador gera uma violação de acesso, ou pior, continua a correr enquanto silenciosamente escreve o valor errado num campo dois tabs atrás. Reproduza-se a pedido e o erro parece óbvio em retrospetiva; persiga-se a partir do relatório de crash de um único cliente e parece um fantasma, porque se o segundo OnExit efetivamente dispara depende de temporização de handle de janela e de foco que muda consoante o tipo de campo, a velocidade de digitação, e o que mais a fila de mensagens esteja a fazer nesse instante
Como o TPDFlibViewer coloca um editor real por cima de uma página renderizada
O TPDFlibViewer renderiza cada página de PDF para um bitmap e não transforma os campos de formulário em controlos VCL ativos por predefinição, pelo que o BeginEditFormField é o método que faz a ponte entre os dois mundos: chamado com um índice de campo, procura o retângulo do campo e converte-o para coordenadas de cliente, e depois coloca um verdadeiro TEdit ou TMemo por cima desse retângulo para um campo de texto, ou uma TComboBox no estilo csDropDownList para um campo de escolha, já com o valor atual do campo carregado. A ISO 32000-2 §12.7 define o que é um campo de formulário de texto ou de escolha dentro de um PDF, mas nada nessa especificação diz como uma aplicação Windows deve deixar alguém escrever num, e essa lacuna é exatamente o que o BeginEditFormField existe para preencher. Tanto o OnKeyDown como o OnExit estão ligados aos mesmos dois métodos do visualizador, InplaceEditorKeyDown e InplaceEditorExit, em cada editor que o TPDFlibViewer cria, um emparelhamento que se distribui sem alterações desde que o preenchimento interativo de formulários chegou pela primeira vez na v3.220.0, e é no OnExit que o problema começa
Porque é que esconder o editor dispara o OnExit uma segunda vez?
O TWinControl na VCL trata uma alteração a Visible ou Parent num controlo com foco como razão para afastar o foco dele de imediato, e afastar o foco de um controlo é exatamente o que dispara o evento OnExit desse controlo, de forma síncrona, antes mesmo de a atribuição de propriedade que o despoletou regressar. O CommitInplaceEditor, o método que o PDFlibPas usa para fechar o editor in-place e escrever de volta o seu valor no campo de formulário, precisa de fazer precisamente essas duas coisas à saída: definir Editor.Visible como False e Editor.Parent como nil, para que o controlo pare de desenhar por cima da página e pare de receber entrada. Ao fazer qualquer uma dessas coisas enquanto o editor ainda tem o foco, o que quase sempre acontece já que o utilizador acabou de o deixar, o OnExit dispara de novo a meio da própria chamada que era suposto ser a última coisa que o OnExit desse editor alguma vez despoletaria
O que corre mal quando o CommitInplaceEditor reentra em si próprio?
Um método de confirmação ingénuo paga por isto de uma de duas formas. Ou escreve o valor do campo duas vezes, uma a partir da chamada original e outra a partir da chamada reentrante que se infiltrou antes de a primeira terminar de tocar no seu próprio estado, ou tenta libertar o controlo de editor enquanto uma frame mais abaixo na pilha de chamadas ainda está dentro do próprio manipulador de eventos desse controlo, o que é território indefinido na VCL e manifesta-se como uma violação de acesso que pode apontar para quase qualquer linha, não necessariamente a que efetivamente a causou. Nenhuma das falhas precisa de um formulário grande para se despoletar; um documento de dois campos basta, desde que o utilizador deixe o segundo campo suficientemente depressa para o SO ainda estar a desenrolar mensagens de foco do primeiro
// 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;
Anule a referência antes de tocar no controlo
A correção que o PDFlibPas distribui é uma simples reordenação: capturar o editor numa variável local, limpar o campo que aponta para ele, e só depois começar a alterar as propriedades do controlo. O CommitInplaceEditor lê FInplaceEditor para uma variável local Editor, define FInplaceEditor como nil de imediato, e só depois atribui Editor.Visible e Editor.Parent. Uma chamada reentrante despoletada por qualquer uma dessas duas atribuições lê o próprio FInplaceEditor, encontra-o já como nil, e sai logo na sua primeira linha, antes de conseguir tocar em Editor ou escrever o valor do campo uma 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;
O SaveEditorValue nessa listagem representa o ramo real, que verifica se Editor é uma TComboBox, uma TMemo, ou uma TEdit e lê o seu valor em conformidade, já que o PDFlibPas cria um controlo diferente consoante o campo seja um campo de texto ou um campo de escolha. A proteção não se importa com qual o ramo que corre, apenas que FInplaceEditor seja nil antes de qualquer coisa capaz de despoletar o OnExit ser executada, que é a única restrição de ordem que torna o resto do método seguro de escrever em qualquer estilo de resto natural
Nunca liberte um controlo de dentro do seu próprio evento
O TPDFlibViewer.CommitInplaceEditor nunca chama Editor.Free diretamente, e isso é deliberado: libertar um controlo é inseguro enquanto uma frame de pilha pertencente ao próprio despacho de eventos desse controlo possa ainda estar a desenrolar-se acima da chamada que o liberta, reentrante ou não. O PDFlibPas entrega antes o editor destacado a um local de estacionamento de uma única posição, FDeadEditor, libertando o que quer que lá estivesse do ciclo de edição anterior através de um pequeno auxiliar, ReapDeadEditor, chamado no início do BeginEditFormField seguinte e mais uma vez a partir de CloseDocument; cada editor que o visualizador cria também é possuído pelo próprio visualizador, TEdit.Create(Self) em vez de TEdit.Create(nil), pelo que mesmo um controlo ainda estacionado em FDeadEditor quando o visualizador é destruído é apanhado pela propriedade de componente VCL comum em vez de gerar uma fuga
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;
Porque é que isto se manifesta com mais força durante a navegação rápida com Tab?
O FocusNextFormField, o método que o PDFlibPas acrescentou na v3.226.0 para conduzir a navegação Tab e Shift+Tab ao longo de um formulário, chama BeginEditFormField para o próximo campo elegível em cada salto individual, e o BeginEditFormField começa chamando CommitInplaceEditor(True) para descarregar qualquer que seja o editor que o campo anterior tenha deixado aberto. Isso significa que cada pressão de Tab que um utilizador faz ao preencher um formulário de vários campos corre exatamente uma vez a sequência de destacar-depois-tocar acima descrita, precisamente o caminho de código com mais probabilidade de ainda ter um controlo genuinamente com foco no momento em que Visible e Parent mudam, porque o Tab é a interação mais garantidamente capaz de deixar o editor de saída a segurar o foco até o novo o pedir
Nada disto torna o erro fiável de demonstrar, e vale a pena dizê-lo claramente em vez de o disfarçar. Se uma dada atribuição de Visible ou Parent efetivamente força um OnExit síncrono depende de estado de foco e de handle de janela que um depurador altera só por estar anexado, que uma repintura ou temporizador não relacionados podem perturbar, e que se comporta de forma diferente consoante qual de TEdit, TMemo, ou TComboBox calhe ser o controlo em jogo. Uma proteção que só às vezes é exercitada é a razão pela qual este tipo de defeito sobrevive tanto à revisão de código como aos testes manuais, e é também a razão pela qual a correção tem de estar correta por construção, anulando a referência antes de qualquer outra coisa acontecer, em vez de correta em função do comportamento que um punhado de passagens de teste manual calhou observar
A forma geral desta correção viaja muito para além de um único controlo de visualizador. Qualquer superfície de edição personalizada construída sobrepondo um controlo VCL ativo a conteúdo renderizado, não apenas um campo de formulário de PDF, herda o mesmo risco no momento em que a sua lógica de fechar-e-confirmar pode ser despoletada tanto por uma ação explícita do utilizador como por uma alteração implícita de foco, e a mesma resposta em duas partes aplica-se: limpar a referência que identifica o controlo ativo antes de fazer qualquer coisa que possa despoletar o seu próprio evento de saída, e nunca chamar Free a partir de um caminho de código que ainda possa estar a correr por baixo do próprio despacho de eventos desse controlo. A superfície mais ampla de preenchimento de formulários e renderização do TPDFlibViewer, incluindo como decide que tipo de controlo mostrar para que campo, é abordada em a visão geral sobre construir um controlo de visualizador de PDF interativo em Delphi VCL com o PDFlibPas, e a cache de bitmap de página que o SetFormFieldValueAndRefresh tem de invalidar em cada edição confirmada é abordada separadamente em a peça sobre a cache de página em disco por DPI de monitor do visualizador
A edição de campo de formulário in-place, a navegação de campo orientada por Tab, e o caminho de confirmação seguro contra reentrância por trás de ambos fazem parte do controlo de visualizador interativo distribuído com o PDFlibPas, a biblioteca de PDF para Delphi e C++Builder, a par do resto da sua superfície de API de renderização de página, anotação, e campo de formulário