PDFlibPas kontroll TPDFlibViewer committar en redigerare av formulärfält på plats genom redigerarens egen OnExit-händelse, och det designvalet döljer en klassisk Delphi VCL-fälla: att dölja, byta förälder på, eller förstöra en fokuserad kontroll inifrån dess egen OnExit-hanterare kan utlösa OnExit en andra gång innan det första anropet returnerar, och skicka committlogiken tillbaka in i sig själv
Felet det här producerar motstår en ren reproduktion. En användare tabbar snabbt genom en rad textfält på ett inskannat ansökningsformulär, och då och då kastar visaren en åtkomstöverträdelse, eller värre, fortsätter köra medan den tyst skriver fel värde in i ett fält två tabbar bakåt. Reproducera det på begäran och buggen ser uppenbar ut i efterhand; jaga den från en enda kunds kraschrapport och den ser ut som ett spöke, eftersom huruvida den andra OnExit faktiskt utlöses beror på fönsterhandtags- och fokustidsförhållanden som skiftar med fälttyp, skrivhastighet, och vad meddelandekön än gör i det ögonblicket
Hur lägger TPDFlibViewer en riktig redigerare ovanpå en rendrerad sida
TPDFlibViewer rendrerar varje PDF-sida till en bitmapp och förvandlar inte formulärfält till levande VCL-kontroller som standard, så BeginEditFormField är metoden som bygger bro mellan de två världarna: anropad med ett fältindex slår den upp fältets rektangel och konverterar den till klientkoordinater, och lägger sedan en riktig TEdit eller TMemo ovanpå den rektangeln för ett textfält, eller en TComboBox i csDropDownList-stil för ett valfält, komplett med fältets aktuella värde redan laddat. ISO 32000-2 §12.7 definierar vad ett text- eller valformulärfält är inuti en PDF, men inget i den specifikationen säger hur en Windows-applikation ska låta någon skriva in i ett, och den luckan är precis vad BeginEditFormField existerar för att fylla. Både OnKeyDown och OnExit är kopplade till samma två visarmetoder, InplaceEditorKeyDown och InplaceEditorExit, på varje redigerare TPDFlibViewer skapar, en parkoppling som levererats oförändrad sedan interaktiv formulärifyllning först landade i v3.220.0, och OnExit är där problemet börjar
Varför utlöser att dölja redigeraren OnExit en andra gång?
TWinControl i VCL behandlar en ändring av Visible eller Parent på en fokuserad kontroll som en anledning att flytta fokus bort från den omedelbart, och att flytta fokus bort från en kontroll är precis vad som utlöser den kontrollens OnExit-händelse, synkront, innan egenskapstilldelningen som utlöste det ens returnerar. CommitInplaceEditor, metoden PDFlibPas använder för att stänga redigeraren på plats och skriva dess värde tillbaka till formulärfältet, behöver göra precis de två sakerna på vägen ut: sätta Editor.Visible till False och sätta Editor.Parent till nil så att kontrollen slutar rita ovanpå sidan och slutar ta emot indata. Gör endera av det medan redigeraren fortfarande har fokus, vilket den nästan alltid har eftersom användaren just lämnade den, och OnExit utlöses igen mitt i just det anropet som skulle vara det sista redigerarens OnExit någonsin utlöste
Vad går fel när CommitInplaceEditor återinträder sig själv?
En naiv commit-metod betalar för det här på ett av två sätt. Antingen skriver den fältets värde två gånger, en gång från det ursprungliga anropet och en gång från det återinträdande anropet som smög sig in innan det första hann röra sitt eget tillstånd klart, eller så försöker den frigöra redigeringskontrollen medan en ram längre ner i anropsstacken fortfarande är inuti den kontrollens egen händelsehanterare, vilket är odefinierat territorium i VCL och visar sig som en åtkomstöverträdelse som kan peka på nästan vilken rad som helst, inte nödvändigtvis den som faktiskt orsakade den. Ingendera felet behöver ett stort formulär för att utlösas; ett tvåfältsdokument räcker, förutsatt att användaren lämnar det andra fältet snabbt nog för att OS ska fortfarande hålla på att avveckla fokusmeddelanden från det första
// 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;
Nolla referensen innan du rör kontrollen
Fixen PDFlibPas levererar är en enda omordning: fånga redigeraren i en lokal variabel, rensa fältet som pekar på den, och först då börja ändra kontrollens egenskaper. CommitInplaceEditor läser FInplaceEditor in i en lokal Editor-variabel, sätter FInplaceEditor till nil omedelbart, och tilldelar först efter det Editor.Visible och Editor.Parent. Ett återinträdande anrop utlöst av endera av de två tilldelningarna läser FInplaceEditor självt, hittar den redan nil, och avslutas på sin allra första rad, innan den kan röra Editor eller skriva fältets värde en andra gång
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 i det listet står in för den riktiga grenen, som kontrollerar om Editor är en TComboBox, en TMemo, eller en TEdit och läser dess värde därefter, eftersom PDFlibPas skapar en annan kontroll beroende på om fältet är ett textfält eller ett valfält. Skyddet bryr sig inte om vilken gren som körs, bara att FInplaceEditor är nil innan något kapabelt till att utlösa OnExit exekverar, vilket är den enda ordningsbegränsningen som gör resten av metoden säker att skriva i vilken stil som annars är naturlig
Frigör aldrig en kontroll inifrån sin egen händelse
TPDFlibViewer.CommitInplaceEditor anropar aldrig Editor.Free direkt, och det är medvetet: att frigöra en kontroll är osäkert medan en stackram som tillhör den samma kontrollens egen händelsedispatch fortfarande kan hålla på att avvecklas ovanför anropet som frigör den, återinträdande OnExit eller inte. PDFlibPas ger istället den frikopplade redigeraren till en enda-plats-parkeringsplats, FDeadEditor, och frigör vad som än satt där från föregående redigeringscykel genom en liten hjälpare, ReapDeadEditor, anropad i början av nästa BeginEditFormField och en gång till från CloseDocument; varje redigerare visaren skapar ägs också av visaren själv, TEdit.Create(Self) snarare än TEdit.Create(nil), så även en kontroll fortfarande parkerad i FDeadEditor när visaren förstörs sopas upp av vanligt VCL-komponentägande istället för att läcka
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;
Varför visar det här sig som starkast under snabb Tab-navigering?
FocusNextFormField, metoden PDFlibPas lade till i v3.226.0 för att driva Tab- och Shift+Tab-navigering över ett formulär, anropar BeginEditFormField för nästa berättigade fält vid varje enda hopp, och BeginEditFormField öppnar med att anropa CommitInplaceEditor(True) för att spola vad för redigerare det föregående fältet lämnade öppen. Det betyder att varje Tab-tryckning en användare gör medan de fyller i ett flerfältsformulär kör exakt frikopplings-sedan-röra-sekvensen som beskrivs ovan en gång, vilket är precis den kodvägen mest sannolik att fortfarande ha en kontroll genuint fokuserad i det ögonblick Visible och Parent ändras, eftersom Tab är den ena interaktionen nästan garanterad att lämna den utgående redigeraren hållande fokus ända fram tills den nya ber om det
Inget av det här gör buggen tillförlitlig att demonstrera, och det är värt att säga rakt ut snarare än att tona ner. Huruvida en given Visible- eller Parent-tilldelning faktiskt tvingar fram en synkron OnExit beror på fokus- och fönsterhandtagstillstånd som en debugger ändrar bara genom att vara ansluten, som en orelaterad omritning eller timer kan störa, och som beter sig olika beroende på vilken av TEdit, TMemo, eller TComboBox som råkar vara kontrollen i spel. Ett skydd som bara ibland utövas är anledningen till att den här typen av defekt överlever både kodgranskning och manuell testning, och det är också anledningen till att fixen måste vara korrekt genom konstruktion, nolla referensen innan något annat händer, snarare än korrekt genom vad för beteende en handfull manuella testpasseringar än råkade observera
Den generella formen av den här fixen reser långt bortom en visarkontroll. Vilken anpassad redigeringsyta som helst byggd genom att lägga en levande VCL-kontroll ovanpå rendrerat innehåll, inte bara ett PDF-formulärfält, ärver samma fara i det ögonblick dess stäng-och-committ-logik kan utlösas av både en explicit användaråtgärd och en implicit fokusändring, och samma tvådelade svar gäller: rensa referensen som identifierar den aktiva kontrollen innan något som kan utlösa dess egen exit-händelse görs, och anropa aldrig Free från en kodväg som fortfarande kan köra under den kontrollens egen händelsedispatch. TPDFlibViewers bredare formulärifyllnings- och rendreringsyta, inklusive hur den bestämmer vilken kontrolltyp som ska visas för vilket fält, täcks i översikten över att bygga en interaktiv PDF-visarkontroll i Delphi VCL med PDFlibPas, och sidbitmapp-cachen som SetFormFieldValueAndRefresh måste invalidera vid varje committad redigering täcks separat i stycket om visarens per-monitor-DPI-diskssidscache
Redigering av formulärfält på plats, Tab-driven fältnavigering, och den återinträdessäkra committvägen bakom båda är del av den interaktiva visarkontrollen levererad med PDFlibPas, PDF-biblioteket för Delphi och C++Builder, tillsammans med resten av dess sidrendrerings-, anteckning-, och formulärfälts-API-yta