מאמר טכני

למה עריכות שדה XFA נעלמות בשמירה ב-PDFium ל-Delphi

TPdf.SetFocusedFormFieldText ברכיב PDFium כותבת לתוך מאגר-העריכה החי של שדה הטופס הממוקד כרגע, ועבור טופס XFA המאגר ההוא אף פעם לא מגיע לחבילת ה-datasets שמסודרת (serialized) לדיסק — כך שערך שמשתמש מקליד, והקוד שלך מאשר שהתקבל, נעלם בשקט בפעם הבאה שהקובץ נפתח. לשדות AcroForm אין את הבעיה הזו: אותה קריאה מבצעת (commits) לתוך רשומת ה-/V של השדה ברגע שהפוקוס זז ממנו. משתמש שממלא טופס-קליטה XFA, שומר, ופותח מחדש ומוצא את שדה הסכום ריק שוב לא נתקל בתקלת עיבוד — הוא נתקל בקצה של מה שמנוע PDFium עצמו חושף עבור כתיבת נתוני טופס

זו שאלה צרה יותר מזיהוי טופס XFA מלכתחילה, או הרצת ה-JavaScript שלו: לא "האם PDFium תומכת ב-XFA" ולא "איך אני מריץ סקריפטים של AcroForm" אלא ספציפית מה קורה לערך אחרי ש-SetFocusedFormFieldText מדווחת הצלחה. הגרסה הקצרה היא ש-AcroForm ו-XFA אינם שני ניבים של אותו מודל טופס ככל שנוגע לנתיב-הכתיבה של PDFium — הם שני מודלי טופס עם שני יחסים שונים לחלוטין בין מה שמשתמש מקליד לבין מה ששמירה בפועל לוכדת, ובלבול בין השניים הוא מה שהופך קריאת API בת-שורה-אחת לכרטיס תמיכה שלושה שבועות אחרי שפריסת פיילוט של לקוח יוצאת לדרך. מאמר ה-JavaScript של AcroForm מציג את הקריאה בת-השורה-האחת ומצהיר על תוצאת AcroForm-מול-XFA בהערת קוד; זה נשאר על אותו API ועובר על נתיב הכתיבה הפנימי, ההוכחה מחבילת ה-datasets שהכתיבה של XFA אף פעם לא נוחתת, למה הפער יושב ב-PDFium עצמה ולא בקישור ה-Delphi, ופתרון-עקיפה של טלאה-XML-משלך עבור מסמכים שזקוקים שהעריכה תשרוד שמירה

איך SetFocusedFormFieldText כותבת ערך שדה?

TPdf.SetFocusedFormFieldText עובדת על ידי סימולציה של עריכה ברמת-הקשה, לא על ידי דחיפת ערך לתוך מודל המסמך ישירות. פנימית היא קוראת ל-FORM_SelectAllText כדי לבחור את התוכן הנוכחי של השדה הממוקד, ואז FORM_ReplaceSelection כדי לכתוב מעל הבחירה עם המחרוזת החדשה — אותן שתי פעולות שבחר-הכל-והקלד מונע-מקלדת היה מפעיל. משום שהכתיבה עוברת דרך נתיב עריכת-הטקסט האינטראקטיבי של PDFium ולא סביבו, כל סקריפט הקשה, פורמט, או חישוב שקשור לשדה מופעל בדיוק כפי שהיה עבור בן-אדם שמקליד, וזה מה שהופך את ה-API לשימושי עבור מילוי טופס פרוגרמטי במציג ששומר על JavaScript חי. המקביל בצד-הקריאה הוא FocusedFormFieldText, נתמך על ידי FORM_GetFocusedText, והוא משקף את אותו מאגר חי ש-SetFocusedFormFieldText הרגע כתבה

if Pdf.FocusedFormFieldIndex >= 0 then
begin
  if Pdf.SetFocusedFormFieldText('1284.50') then
    Log('Buffer now reads: ' + Pdf.FocusedFormFieldText)
  else
    Log('No field is focused, or it does not accept text');
end
else
  Log('Focus a field first - FocusFormField or a real click');

למה AcroForm שומר את הערך ו-XFA מאבד אותו?

שדות טקסט ו-combo של AcroForm נשמרים משום שסביבת מילוי-הטופס של PDFium עצמה מבצעת (commits) את מאגר-העריכה עבורך: ברגע שהשדה מאבד פוקוס, המאגר נכתב לתוך רשומת ה-/V של השדה, אותו מפתח שכל קורא PDF תואם מסתכל עליו כדי לדעת את הערך השמור של שדה. ‏TPdf.ClearFormFieldFocus — שקוראת ל-FORM_ForceToKillFocus מתחת למכסה — כופה את הביצוע הזה לפי דרישה, כך שקוד שמגדיר ערך פרוגרמטית לא צריך לחכות ללחיצת עכבר אמיתית במקום אחר ב-UI. שמור מיד אחר-כך, והטקסט החדש הוא חלק מגרף אובייקטי המסמך לפני ש-TPdf.SaveAs אי-פעם רצה, משום ש-/V היא רשומה אמיתית במילון שדה אמיתי, לא משהו שמוברג בדיעבד

Pdf.FocusFormField(FieldIndex);
Pdf.SetFocusedFormFieldText('1284.50');
Pdf.ClearFormFieldFocus;              // forces the /V commit now
Pdf.SaveAs('invoice-acroform.pdf');

// Reopen and confirm - this is an AcroForm document, so it holds
Pdf.Active := False;
Pdf.FileName := 'invoice-acroform.pdf';
Pdf.Active := True;
Pdf.FocusFormField(FieldIndex);
Assert(Pdf.FocusedFormFieldValue = '1284.50');   // passes

איפה עריכת שדה XFA בעצם חיה?

לשדות XFA אין חיווט כזה. הטקסט שמשתמש מקליד נוחת במאגר CPWL_Edit ששייך לשכבת העיבוד והאינטראקציה של XFA של PDFium, ולשכבה ההיא אין נתיב קוד שמעתיק את המאגר בחזרה לתוך חבילת ה-datasets שמאוחסנת ב-PDF. ‏TPdf.GetXfaDatasets הופכת את הפער לגלוי: קרא לה לפני ואחרי עריכה על שדה XFA והבייטים שהיא מחזירה זהים, משום שהפונקציה קוראת את החבילה המקורית שהמסמך נפתח איתה, אף פעם לא את המצב החי של הווידג'ט שהרגע ערכת. שום דבר מזה אינו באג-מטמון או בעיית-תזמון-רענון — חבילת ה-datasets על הדיסק ומאגר-העריכה בזיכרון הם פשוט שני פיסות מצב שונות ש-API הציבורי של PDFium אף פעם לא מחבר

var
  Before, After: TBytes;
begin
  Before := Pdf.GetXfaDatasets;
  Pdf.FocusFormField(FieldIndex);
  Pdf.SetFocusedFormFieldText('1284.50');
  After := Pdf.GetXfaDatasets;
  // Before and After are byte-for-byte identical on an XFA document -
  // the edit never touched the packet GetXfaDatasets reads from
end;

האם זה באג של רכיב PDFium או מגבלה של PDFium?

החלק החסר יושב ב-PDFium עצמה, לא בקישור ה-Delphi מעליה. ל-API הציבורי של PDFium אין FPDF_SetXFAPacket להזרקת חבילה מעודכנת ואין FPDF_SaveAsXFA לבקש ממנוע ה-XFA לסדר את ה-DOM הנוכחי שלו בחזרה לתוך XML של datasets לפני שמירה. ‏FPDF_SaveAsCopy — הייצוא שמאחורי TPdf.SaveAs — כותב את גרף אובייקטי המסמך ש-PDFium כבר מחזיקה; אין לה hook לבקש ממנוע ה-XFA לשטוף את המצב החי שלו קודם, משום שה-hook ההוא לא קיים במעלה-הזרם. רכיב PDFium לא יכול להוסיף פיוס (reconciliation) ש-PDFium עצמה אף פעם לא יישמה, ושילוח מסדר-DOM-ל-XML ביתי שמנחש את מצב-ה-XFA הפנימי של PDFium היה גרוע יותר מהפער הכן: הוא היה נראה עובד עד שגרסת PDFium הבאה משנה משהו שאף אחד מחוץ לפרויקט לא רואה

הגבול הזה עלה על פני השטח במהלך אותה ביקורת v2.13.2 שבנתה את SetFocusedFormFieldText מלכתחילה. ‏FORM_ReplaceSelection הייתה מקושרת בטבלת הייבוא DLL עבור גרסאות בלי שאי-פעם נקראה מקוד Pascal, והוספת נתיב-הכתיבה שסוף-סוף השתמש בה היא מה שהפכה את פער-ההתמדה לקונקרטי מספיק כדי לתעד ולא תיאורטי. אותו סבב ביקורת מצא פער בלתי-קשור אך קשור-ברוח: JavaScript של AcroForm הושבת בשקט מאז v2.13.0 משום שפלטפורמת ה-JS חוברה רק בתוך ענף אתחול ה-XFA, כך שמסמכי AcroForm רגילים עם app.alert או שדות מחושבים אף פעם לא קיבלו מנוע סקריפט בכלל. ההוא ניתן היה לתיקון — הרחבת פלטפורמת ה-JS לכל מסמך ללא קשר ל-XFA — והוא יצא לדרך באותה גרסה; פער ההתמדה המכוסה כאן לא היה ניתן לתיקון, מהסיבות לעיל. תיקון ה-JavaScript ואירועי ה-veto-מארח סביבו מכוסים בהרצת JavaScript של AcroForm עם רכיב PDFium

מה עליך לעשות בנוגע לזה ב-Delphi?

עבור מסמכי AcroForm, התיקון אינו יותר מהרגל טוב: קרא ל-ClearFormFieldFocus (או אחרת הזז פוקוס משם) לפני SaveAs בכל פעם שערך הוגדר פרוגרמטית, במקום להניח שאינטראקציית UI מאוחרת יותר תפעיל את הביצוע עבורך. עבור מסמך שיכול להיות AcroForm או XFA — שזה המקרה הנפוץ במציג כללי-מטרה — בדוק את FormType או הבוליאני XFA לפני שאתה מבטיח לקוד קורא ששמירה תיתפס, וקרא זיהוי טפסי XFA וחילוץ חבילות XFA עבור קבוצת הבדיקות המלאה, כולל מקרה ה-XFAF שבו תוכן XFA משוכב מעל ווידג'טים אחרת-רגילים של AcroForm שכן מכבדים את /V

עבור טופס XFA דינמי אמיתי שבו הערכים הערוכים חייבים לשרוד שמירה, מאגר-העריכה האינטראקטיבי אינו הכלי הנכון בכלל. הנתיב העמיד הוא להתייחס אל GetXfaDatasets כבסיס שלך, לא כתוצאה שלך: קרא אותה פעם אחת כשהמסמך נפתח, שמור רשומה משלך של מה שהמשתמש שינה שדה אחר שדה — בדיוק הערכים שה-UI שלך כבר מחזיק, שכן PDFium לא תמסור אותם לך בחזרה בדיעבד — טלא אותם לתוך ה-XML הבסיסי בעצמך, והנע את הפלט שלך עצמך. כתיבה שעוברת דרך XML שהקוד שלך עצמו שולט בו שורדת שמירה שמאגר CPWL_Edit אף פעם לא היה יכול

function ExportEditedXfaValue(Pdf: TPdf; const FieldPath,
  NewValue: string): TBytes;
var
  DatasetsXml: string;
begin
  // GetXfaDatasets ships with PDFium Component; PatchXmlNode below is
  // your own helper over your own XML library, nothing PDFium provides
  DatasetsXml := TEncoding.UTF8.GetString(Pdf.GetXfaDatasets);
  DatasetsXml := PatchXmlNode(DatasetsXml, FieldPath, NewValue);
  Result := TEncoding.UTF8.GetBytes(DatasetsXml);
end;

תפיסת הפער לפני שלקוח עושה זאת

TPdf.SaveAs מחזירה True בין אם ערך שדה XFA שרד ובין אם לא, משום שמנקודת מבטה של PDFium השמירה בהחלט הצליחה — היא כתבה כל בייט שהתבקשה לכתוב. זה הופך את זה בדיוק לסוג הפגם שחומק על פני בדיקת-עשן ומגיע ללקוח: שום דבר לא זורק חריגה, שום דבר לא רושם ביומן, הקובץ נפתח בסדר, רק הערך הספציפי שגוי. בדיקת הלוך-ושוב שבפועל פותחת מחדש את הקובץ השמור ומשווה את ערך השדה — או משווה GetXfaDatasets לפני ואחרי, לפי הדוגמה הקודמת — שייכת בחבילת הרגרסיה עבור כל מציג שמאפשר למשתמשים לערוך תוכן XFA, לא רק נתיבי AcroForm שבמקרה עובדים כברירת מחדל

שום דבר מזה אינו פגם להגיש נגד רכיב PDFium כל כך הרבה כמו גבול לתכנן סביבו: SetFocusedFormFieldText עושה בדיוק מה שהשם שלה אומר עבור שני מודלי הטופס, וההבדל בתוצאה עוקב בנקיות אל מה ש-AcroForm ו-XFA כל אחד מחווט את המאגר ההוא אליו בצד PDFium. ה-API, פונקציות הפוקוס והשמירה, וקוראי החבילה המוזכרים כאן הם חלק מרכיב PDFium עבור Delphi ו-C++Builder