פקד ה-TPDFlibViewer של PDFlibPas מבצע עורך שדה-טופס-במקום דרך אירוע ה-OnExit של העורך עצמו, ובחירת העיצוב הזו מחביאה מלכודת VCL קלאסית של Delphi: הסתרה, שינוי-הורה, או השמדה של פקד ממוקד מתוך ה-handler של ה-OnExit שלו עצמו יכולה להפעיל OnExit פעם שנייה לפני שהקריאה הראשונה חוזרת, שולחת את לוגיקת-הביצוע בחזרה לתוך עצמה
הכשל שזה מייצר עומד בפני שחזור נקי. משתמש עובר במהירות עם Tab על ריצה של שדות טקסט בטופס בקשה סרוק, ומדי פעם המציג זורק הפרת-גישה, או גרוע יותר, ממשיך לרוץ בעוד הוא בשקט כותב את הערך הלא-נכון לתוך שדה שני Tab-ים אחורה. שחזר את זה לפי דרישה והבאג נראה ברור במבט לאחור; רדוף אחריו מדוח קריסה של לקוח בודד והוא נראה כמו רוח-רפאים, משום שאם ה-OnExit השני בפועל מופעל תלוי בתזמון ידית-חלון ופוקוס שמשתנה עם סוג-שדה, מהירות-הקלדה, ומה שתור-ההודעות עושה ברגע ההוא
איך TPDFlibViewer מניחה עורך אמיתי מעל עמוד מעובד
TPDFlibViewer מעבדת כל עמוד PDF לביטמאפ ולא הופכת שדות טופס לפקדי VCL חיים כברירת מחדל, כך ש-BeginEditFormField היא הפונקציה שמגשרת בין שני העולמות: נקראת עם אינדקס שדה, היא מחפשת את המלבן של השדה וממירה אותו לקואורדינטות לקוח, ואז מניחה TEdit או TMemo אמיתי מעל המלבן ההוא עבור שדה טקסט, או TComboBox בסגנון csDropDownList עבור שדה בחירה, שלם עם הערך הנוכחי של השדה כבר טעון. ISO 32000-2 סעיף 12.7 מגדיר מה שדה טופס טקסט או בחירה הוא בתוך PDF, אבל שום דבר במפרט ההוא לא אומר איך אפליקציית Windows צריכה לתת למישהו להקליד לתוך אחד, והפער הזה בדיוק מה ש-BeginEditFormField קיימת כדי למלא. גם OnKeyDown וגם OnExit מחווטים לאותן שתי פונקציות מציג, InplaceEditorKeyDown ו-InplaceEditorExit, על כל עורך ש-TPDFlibViewer יוצרת, צימוד ששולח ללא שינוי מאז שמילוי-טופס אינטראקטיבי נחת לראשונה ב-v3.220.0, ו-OnExit הוא המקום שבו הצרה מתחילה
למה הסתרת העורך מפעילה OnExit פעם שנייה?
TWinControl ב-VCL מתייחס לשינוי ב-Visible או Parent על פקד ממוקד כסיבה להזיז פוקוס ממנו מיד, והזזת פוקוס מפקד היא בדיוק מה שמפעיל את אירוע ה-OnExit של הפקד ההוא, סינכרונית, לפני שהצבת המאפיין שהפעילה את זה אפילו חוזרת. CommitInplaceEditor, הפונקציה ש-PDFlibPas משתמשת בה כדי לסגור את העורך במקום ולכתוב את הערך שלו בחזרה לשדה הטופס, זקוקה לעשות בדיוק את שני הדברים האלה בדרך החוצה: הגדר את Editor.Visible ל-False והגדר את Editor.Parent ל-nil כך שהפקד יפסיק לצייר מעל העמוד ויפסיק לקבל קלט. עשה אחד מהם בעוד העורך עדיין מחזיק פוקוס, מה שהוא כמעט תמיד עושה שכן המשתמש הרגע עזב אותו, ו-OnExit מופעל שוב באמצע אותה קריאה בדיוק שהייתה אמורה להיות הדבר האחרון שה-OnExit של העורך ההוא אי-פעם הפעיל
מה משתבש כאשר CommitInplaceEditor נכנסת-מחדש לעצמה?
פונקציית ביצוע תמימה משלמת על זה באחת משתי דרכים. או שהיא כותבת את ערך השדה פעמיים, פעם אחת מהקריאה המקורית ופעם אחת מהקריאה הנכנסת-מחדש שהתגנבה לפני שהראשונה סיימה לגעת במצב שלה עצמה, או שהיא מנסה לשחרר את פקד העורך בעוד מסגרת-stack עמוקה יותר עדיין בתוך ה-handler של האירוע של אותו פקד עצמו, שזה שטח בלתי-מוגדר ב-VCL ומופיע כהפרת-גישה שיכולה להצביע על כמעט כל שורה, לא בהכרח זו שבפועל גרמה לזה. אף כשל לא זקוק לטופס גדול כדי להיות מופעל; מסמך בן-שני-שדות מספיק, בתנאי שהמשתמש עוזב את השדה השני מהר מספיק כדי שמערכת ההפעלה עדיין תפרום הודעות פוקוס מהראשון
// 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;
אפס את ההפניה לפני שאתה נוגע בפקד
התיקון ש-PDFlibPas שולחת הוא סידור-מחדש בודד: לכוד את העורך במשתנה מקומי, נקה את השדה שמצביע עליו, ורק אז התחל לשנות את מאפייני הפקד. CommitInplaceEditor קוראת את FInplaceEditor לתוך משתנה מקומי Editor, מגדירה את FInplaceEditor ל-nil מיד, ורק אחר-כך מציבה את Editor.Visible ו-Editor.Parent. קריאה נכנסת-מחדש שמופעלת על ידי אחת משתי ההצבות ההן קוראת את FInplaceEditor עצמה, מוצאת אותה כבר nil, ויוצאת בשורה הראשונה שלה בדיוק, לפני שהיא יכולה לגעת ב-Editor או לכתוב את ערך השדה פעם שנייה
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 ברשימה ההיא עומדת במקום הענף האמיתי, שבודק אם Editor הוא TComboBox, TMemo, או TEdit וקורא את הערך שלו בהתאם, שכן PDFlibPas יוצרת פקד שונה בהתאם לשאלה אם השדה הוא שדה-טקסט או שדה-בחירה. השמירה לא אכפת לה איזה ענף רץ, רק ש-FInplaceEditor הוא nil לפני שכל דבר שמסוגל להפעיל OnExit מבוצע, וזה אילוץ הסידור היחיד שהופך את שאר הפונקציה לבטוחה לכתיבה בכל סגנון שאחרת טבעי
לעולם אל תשחרר פקד מתוך האירוע שלו עצמו
TPDFlibViewer.CommitInplaceEditor אף פעם לא קוראת ל-Editor.Free ישירות, וזה מכוון: שחרור פקד לא בטוח בעוד מסגרת-stack ששייכת לאותו פקד עצמו של שיגור-האירוע שלו אולי עדיין נפרמת מעל הקריאה ששחררה אותו, OnExit נכנס-מחדש או לא. במקום זאת PDFlibPas מוסרת את העורך המנותק למקום-חנייה בעל-משבצת-בודדת, FDeadEditor, משחררת כל מה שיושב שם ממחזור העריכה הקודם דרך עוזר קטן, ReapDeadEditor, נקראת בתחילת ה-BeginEditFormField הבא ופעם נוספת מ-CloseDocument; כל עורך שהמציג יוצר גם בבעלות המציג עצמו, TEdit.Create(Self) ולא TEdit.Create(nil), כך שאפילו פקד שעדיין חונה ב-FDeadEditor כשהמציג נהרס נאסף על ידי בעלות רכיב VCL רגילה במקום לדלוף
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;
למה זה מופיע הכי חזק במהלך ניווט Tab מהיר?
FocusNextFormField, הפונקציה ש-PDFlibPas הוסיפה ב-v3.226.0 כדי להניע ניווט Tab ו-Shift+Tab על פני טופס, קוראת ל-BeginEditFormField עבור השדה הכשיר הבא בכל קפיצה בודדת, ו-BeginEditFormField נפתחת בקריאה ל-CommitInplaceEditor(True) כדי לשטוף כל עורך שהשדה הקודם השאיר פתוח. זה אומר שכל לחיצת Tab שמשתמש עושה בעת מילוי טופס רב-שדות מריצה את רצף הניתוק-ואז-נגיעה המדויק שתואר לעיל פעם אחת, שזה בדיוק נתיב הקוד הכי סביר שעדיין יש לו פקד ממוקד באמת ברגע ש-Visible ו-Parent משתנים, משום ש-Tab הוא האינטראקציה האחת שכמעט מובטח שתשאיר את העורך היוצא מחזיק פוקוס ממש עד שהחדש מבקש אותו
שום דבר מזה לא הופך את הבאג לאמין להדגמה, וזה שווה לומר בפירוש ולא לטשטש. אם הצבת Visible או Parent נתונה בפועל כופה OnExit סינכרוני תלוי במצב פוקוס וידית-חלון שדיבאגר משנה סתם על ידי היותו מחובר, שציור-מחדש בלתי-קשור או טיימר יכולים לערער, ושמתנהג אחרת בהתאם לאיזה מ-TEdit, TMemo, או TComboBox במקרה הפקד במשחק. שמירה שרק לפעמים מופעלת היא הסיבה שהסוג הזה של פגם שורד סקירת קוד ובדיקה ידנית כאחד, וזו גם הסיבה שהתיקון חייב להיות נכון מבנייתו, מאפס את ההפניה לפני שכל דבר אחר קורה, ולא נכון לפי איזו התנהגות שכמה מעברי בדיקה ידנית במקרה צפו בה
הצורה הכללית של התיקון הזה נוסעת הרבה מעבר לפקד מציג אחד. כל משטח עריכה מותאם אישית שבנוי על ידי שכיבת פקד VCL חי מעל תוכן מעובד, לא רק שדה טופס PDF, יורש את אותה סכנה ברגע שלוגיקת הסגור-ובצע שלו יכולה להיות מופעלת גם על ידי פעולת משתמש מפורשת וגם על ידי שינוי פוקוס משתמע, ואותה תשובה דו-חלקית חלה: נקה את ההפניה שמזהה את הפקד הפעיל לפני שאתה עושה כל דבר שעלול להפעיל את אירוע-היציאה שלו עצמו, ולעולם אל תקרא ל-Free מנתיב קוד שאולי עדיין רץ מתחת לשיגור-האירוע של אותו פקד עצמו. משטח מילוי-הטפסים והעיבוד הרחב יותר של TPDFlibViewer, כולל איך היא מחליטה איזה סוג פקד להציג עבור איזה שדה, מכוסה בסקירת בניית פקד מציג PDF אינטראקטיבי ב-Delphi VCL עם PDFlibPas, ומטמון ביטמאפ העמוד ש-SetFormFieldValueAndRefresh חייבת לבטל בכל עריכה שמבוצעת מכוסה בנפרד בהחלק על מטמון עמוד-דיסק לפי-DPI-של-מוניטור של המציג
עריכת שדה-טופס במקום, ניווט שדה מונע-Tab, ונתיב-הביצוע הבטוח-לכניסה-חוזרת מאחורי שניהם הם חלק מפקד המציג האינטראקטיבי שנשלח עם PDFlibPas, ספריית ה-PDF עבור Delphi ו-C++Builder, לצד שאר משטח ה-API של עיבוד עמודים, הערות, ושדות-טופס שלה