מאמר טכני

Dynamic XFA ב-PDFium Component: מספר העמודים הוא דלתא

כשטופס dynamic XFA בצופן Delphi מוסיף או מסיר עמודים, PDFium Component מדווח על הסך החדש דרך TPdf.PageCount ו-TPdf.OnXfaPageCountChanged מאז v3.126.1, כי אירוע העמודים הילידי נושא דלתא של נוסף/הוסר ולא סך כולל. ספריות ה-V8 של Windows ב-v3.126.1 מזיזות גם את אזורי הפגיעה של הקלט יחד עם שדות שהועברו, ו-v3.126.2 טוען מחדש מצביעי עמודים מיושנים אחרי שcallback הפריסה חוזר. דיווח הבאג שפתח את זה היה טופס הצהרת הוצאות: לוחצים פעמיים על Add Row, הטופס גדל לשני עמודים, ומחוון העמודים מצהיר בגאווה 1 מתוך 1. מקלידים לשדה שעבר לעמוד 2 והתווים נוחתים איפשהו בלתי נראה. אף אחד מזה לא הופיע בטפסים באורך קבוע שכולם בודקים איתם קודם, והסיבות שוות היכרות לכל מי שמטמיע צופה טפסים

מה קורה כשטופס dynamic XFA מתפרק לעמודים מחדש?

לטופס dynamic XFA אין רשימת עמודים קבועה, ולכן מספר העמודים שלו הוא פלט של פריסה ויכול להשתנות בכל עריכת נתונים של המשתמש. XFA 3.3 מתאר את הטופס בתור עץ של subforms; subform חוזר נשלט על ידי instanceManager, ותסריט כמו _Row.addInstance() משכפל שורה נוספת. מעבד הפריסה אז מזרים את התוכן אל אזורי העמודים מחדש, מה שעשוי להוסיף עמוד, להשמיט עמוד או לדחוף שדות קיימים אל עמוד אחר. ISO 32000-1 §12.7.8 מגדיר רק איך חבילות ה-XFA יושבות בתוך ה-PDF; כל מה שקורה אחר כך שייך למנוע ה-XFA, שב-PDFium Component הוא הפריסה של XFA של PDFium עצמו רצה בתוך תהליך המארח. צופן Delphi מתמודד לכן עם מסמך שמספר העמודים, גדלי העמודים ומיקומי ה-widgets שלו כולם מצב חי. שלושה דברים משתבשים כשהמארח מניח אחרת:

  • מספר העמודים שהמארח שומר במטמון עבור ניווט, טווחי גלילה ומחווני עמודים מתיישן, או גרוע מכך, מתעדכן במספר הלא נכון
  • שדות שעברו מקום מציגים את המסגרת שלהם במיקום החדש בזמן שהעורך ואזור הפגיעה של העכבר נשארים בקואורדינטות הישנות
  • הצופה מחזיק מצביע עמודים שהפריסה החליפה, ולכן קליקים וציור הולכים אל עמוד שכבר לא קיים באותו טופס

שימור עריכות שורות על פני שמירה ופתיחה מחדש הוא בעיה נפרדת עם כללים משלה; המאמר הזה נשאר עם מה שקורה בזמן ריצה בתוך הצופה

איזה רנטיים PDFium צריך עבור dynamic XFA?

Dynamic XFA ב-PDFium Component דורש את build ה-V8/XFA של הספרייה הילידית, שנבחר על ידי המשתנה הגלובלי EnableV8Engine ביחידת PDFium לפני הטעינה של המסמך הראשון. התהליך מתחייב ל-DLL אחת בפעם הראשונה שכל TPdf טוען את הספרייה, ו-build PDFium רגיל לא יכול להריץ את מנוע ה-XFA בכלל. כשמסמך נפתח, TPdf כן צופה בקובץ אחר סמני XFA ועובר אוטומטית אל build ה-V8, אבל רק אם אף ספרייה רגילה עדיין לא נטענה באותו תהליך. כשההתחייבות כבר הלכה לכיוון הלא נכון, TPdf.OnXfaRuntimeMissing נורה פעם אחת כדי שהמארח יוכל לבקש מהמשתמש להפעיל מחדש. הגדרת הדגל במפורש בהפעלה מסירה את הניחושים. מבנה ה-callbacks של FPDF_FORMFILLINFO שנושא את אירועי ה-XFA חייב גם הוא להתאים ל-DLL; הרקע ב-FPDF_FORMFILLINFO גרסה 2 וה-ABI של ה-callbacks של XFA, ו-זיהוי טפסי XFA וקריאת החבילות שלהם מכסה הבחנה בין טיפוסי הטפסים לפני שפותחים צופה

uses
  PDFium;

procedure TClaimForm.FormCreate(Sender: TObject);
begin
  // מכריעים לפני שה-TPdf הראשון טוען את הספרייה הילידית:
  // התהליך לא יכול לעבור מ-pdfium.dll אל pdfium.v8.dll מאוחר יותר
  EnableV8Engine := True;

  FPdf := TPdf.Create(nil);
  FPdf.OnXfaRuntimeMissing := PdfXfaRuntimeMissing;
  FPdf.OnXfaPageCountChanged := PdfXfaPageCountChanged;
  FPdf.FileName := 'C:\Forms\expense-claim.pdf';
  FPdf.Active := True;

  PdfView1.Pdf := FPdf;
  PdfView1.OnPageChange := PdfViewPageChange;
  PdfView1.Active := True;

  UpdatePageRange(FPdf.PageCount);
end;

procedure TClaimForm.PdfXfaRuntimeMissing(Sender: TObject);
begin
  StatusBar1.SimpleText :=
    'This XFA form needs the V8 runtime; restart the application to enable it';
end;

מדוע PageCount דיווח 1 עבור טופס בן שני עמודים?

לפני v3.126.1, PDFium Component שמר את הארגומנט page_count של אירוע העמודים הילידי בתור הסך של המסמך, ואותו ארגומנט הוא בעצם ההפרש המוחלט בין מספרי העמודים החדש והישן. PDFium מעלה FFI_PageEvent אחרי שמעבר פריסה מסתיים עם טיפוס אירוע של עמוד נוסף או עמוד הוסר; פנימית הוא מעדכן קודם את מספר העמודים השמור שלו ואז מעביר abs(new - old). בפריסה הראשונית הספירה הישנה היא אפס, ולכן הדלתא שווה לסך, ודגימה סטטית של שלושה עמודים מדווחת שלושה עמודים כמצופה. זו בדיוק הסיבה שטפסי בדיקה באורך קבוע לעולם לא חשפו את הבאג. בפעם הראשונה שטופס דינמי גדל מעמוד אחד לשניים הדלתא היא 1, וה-wrapper הגדיר גם את TPdf.PageCount וגם את הפרמטר NewCount של OnXfaPageCountChanged ל-1. הסרת שורה מטופס של שלושה עמודים הפיקה את אותו סוג של איוולת בכיוון השני

הצטברות הדלתא על הערך הקודם אינה תיקון בטוח גם כן. סדר ה-callbacks של האתחול והפריסה אומר שה-wrapper לא תמיד יכול לסמוך על הספירה הקודמת שלו בתור בסיס, ולכן סכום נע יכול לסטות. מאז v3.126.1 ה-callback מתעלם מהארגומנט בתור ספירה וקורא ל-FPDF_GetPageCount על המסמך, שקורא את הסך מהפריסה שהושלמה זה עתה. הוא אז מנקה את סצנות העמודים המטמוניות, שומר את הסך הזה בתור עקיפת מספר-העמודים של ה-XFA מאחורי TPdf.PageCount, ורק אחר כך מעלה את OnXfaPageCountChanged. בזמן שה-handler שלכם רץ, NewCount ו-FPdf.PageCount מסכימים

דיאגרמת dynamic XFA של PDFium Component שבה הוספת שורה מפרקת טופס של עמוד אחד לשני עמודים ו-FFI_PageEvent מעביר את abs(חדש פחות ישן) בתור דלתא, ולכן ה-wrapper הישן דיווח TPdf.PageCount 1 בזמן ש-v3.126.1 קורא FPDF_GetPageCount ומדווח את הסך הנכון
אירוע העמודים הילידי מדווח על דלתא של נוסף-או-הוסר, לא על סך, ולכן v3.126.1 מתעלם מהארגומנט וקורא את הפריסה שהושלמה לפני שהוא מעלה את OnXfaPageCountChanged
procedure TClaimForm.PdfXfaPageCountChanged(Sender: TObject; NewCount: Integer);
begin
  // v3.126.1 ומעלה: NewCount הוא הסך הכול של הפריסה שהושלמה, לעולם לא דלתא.
  // זה רץ בתוך callback הפריסה של PDFium: מעדכנים רק מצב UI של ה-host,
  // לא סוגרים את המסמך ולא טוענים עמודים מחדש מכאן
  UpdatePageRange(NewCount);
end;

procedure TClaimForm.PdfViewPageChange(Sender: TObject);
begin
  // נורה אחרי כל טעינת עמוד מחדש, כולל רענון ה-XFA הנדחה
  PageSpin.Value := PdfView1.PageNumber;
end;

procedure TClaimForm.UpdatePageRange(Count: Integer);
begin
  PageSpin.MinValue := 1;
  PageSpin.MaxValue := Count;
  PageLabel.Caption := Format('of %d', [Count]);
end;

האירוע נורה רק עבור טפסי Full XFA שהפריסה שלהם משתנה בזמן ריצה. מסמכי Static XFA ו-AcroForm לעולם לא מעלים אותו, ולכן צופה שמטפל בשניהם יכול להשאיר את אותו handler משויך. השארתו לא משויך בטוחה גם כן; העקיפה מאחורי TPdf.PageCount מיושמת בכל מקרה, והאירוע קיים כדי שהמארח ירענן את מה שמטמון

מדוע תיבת הקלט נשארת בעמוד הישן כששדה עובר מקום?

המסגרת זזה והעורך לא כי ה-notifier של ה-XFA הילידי השווה מלבן אל עצמו. כשפריסה משנה את הגיאומטריה של widget שכבר נטען, PDFium אמור לשים לב למלבן החדש ולקרוא ל-PerformLayout על ה-widget, מה שממקם מחדש את עורך הטקסט ואת אזור הפגיעה שלו. הבדיקה השוותה בין GetWidgetRect() ל-RecacheWidgetRect(). שתי הפונקציות מחזירות הפניה const לאותו חבר, וה-recache דורס את החבר הזה במקום, ולכן ההשוואה תמיד ראתה שני ערכים זהים ו-widgets טעונים דילגו על הסידור מחדש שלהם

הסימפטום עלה כשבדיקה שינתה גובה של subform כך ששדות קיימים חצו אל העמוד הבא. בשתי ארכיטקטורות ה-V8, מסגרת השדה צוירה במיקומה החדש בזמן שהטקסט שהוקלד ואזור הפגיעה של העכבר נשארו בקואורדינטת ה-Y הקודמת. סידור מחדש מפורש לא תיקן, וגם טעינת העמוד מחדש לא, כי ה-widget עדיין האמין שהגיאומטריה שלו עדכנית. ספריות ה-V8 של Windows שנשלחו עם v3.126.1 מעתיקות את המלבן הישן לפי ערך לפני ה-recache ומשוות את העותק הזה, ולכן widgets שזזו מסתדרים מחדש והערך הערוך מופיע בדיוק היכן שהמסגרת. זה תיקון ילידי: הוא נוסע עם ה-DLLs, ולכן עדכון יחידות ה-Pascal תוך השארת pdfium.v8.dll ישנה משאיר את אזורי הפגיעה המוזזים במקומם. בדיקת הרגרסיה שהניעה אותו עורכת קודם שורה שורדת לערך שאינו ברירת מחדל ואז דורשת את הערך הזה במיקום החדש של השדה, כי שורה שנבנתה מחדש עם ערכי ברירת מחדל הייתה נראית כמו עמידה

דיאגרמת סידור widgets מחדש ב-PDFium Component שמנגדת את ההשוואה-העצמית הישנה, שבה GetWidgetRect ו-RecacheWidgetRect החזירו חבר משותף אחד ולכן widgets שזזו דילגו על PerformLayout, מול בדיקת ההעתקה-לפי-ערך של ה-V8 ל-Windows ב-v3.126.1 שממקמת מחדש את העורך ואת אזור הפגיעה של העכבר על המסגרת שצוירה מחדש
השוואת מלבן אל עצמו לעולם לא נכשלת, ולכן המסגרת זזה בזמן שהטקסט שהוקלד והקליקים נשארו מאחור, עד שהבדיקה שמרה עותק לפי ערך קודם

איך TPdfView טוען עמודים מחדש בלי לשלוף מצביע מתחת ל-PDFium?

מאז v3.126.2, TPdfView דוחה את טעינת העמודים מחדש שבאה בעקבות שינוי פריסת XFA עד שמחסנית הקריאות הילידית מתפרקת. אירוע העמודים בדרך כלל נורה בזמן ש-PDFium עדיין מעבד קלט: המשתמש לחץ על כפתור Add Row, הלחיצה הריצה תסריט, התסריט שינה את מספר המופעים, והפריסה הסתיימה בתוך אותה קריאה ילידית. סגירה ופתיחה מחדש של מצביע העמודים באותו רגע הייתה משחררת אובייקט שהקורא עדיין משתמש בו. לפני v3.126.2 הצופה רק פסל את עצמו, ולכן מצביע העמודים המוצג יכול היה להמשיך להצביע על מצב שלפני הפריסה, ואם המשתמש היה בעמוד האחרון כשהוא נעלם, מספר העמוד הנבחר היה מחוץ לטווח

הרענון הנדחה עובד בכמה צעדים קטנים, והם מסבירים את ההתנהגות שרואים מהמארח:

  1. ה-callback של אירוע העמודים מסמן את התצוגה בתור מחכה לרענון פריסת XFA ושולח הודעת חלון פרטית; אירועים חוזרים לפני שההודעה מגיעה מתמזגים לרענון אחד
  2. תצוגה שעדיין אין לה מצביע חלון שומרת על הדגל הממתין ושולחת את ההודעה מ-CreateWnd, בזמן שהחלפת מסמכים, כיבוי התצוגה או השמדתה מנקים את הדגל
  3. כשההודעה מגיעה, התצוגה מנקה את בחירת הטקסט, את הדגשת החיפוש ואת אינדקס השדה הממוקד, כי שלושתם התייחסו לפריסה הישנה
  4. העמוד הנבחר נחתך אל ה-PageCount החדש; מספר עמוד שהשתנה עובר את המעבר הרגיל בין עמודים, אחרת העמוד הנוכחי נטען מחדש, ומצב ההתאמה מוחל שוב
  5. אם הפריסה משאירה בלי עמודים בכלל, התצוגה מפרקת את מצביע העמודים הישן שלה במקום לצייר עמוד שכבר לא קיים
דיאגרמת הרענון הנדחה של XFA ב-TPdfView של PDFium Component שבה אירוע עמודים בתוך מחסנית הקריאות הילידית רק מסמן רענון ממתין ושולח הודעת חלון, שמנקה מאוחר יותר מצב בחירה מיושן, חותך את העמוד אל ה-PageCount החדש וטוען מחדש או מפרק את מצביע העמודים
הטעינה מחדש מחכה עד שמחסנית הקריאות הילידית מתפרקת: הודעה שנשלחה ממזגת אירועים חוזרים, ואז התצוגה חותכת את העמוד, טוענת אותו מחדש ומעלה OnPageChange

אותה מגבלה חלה על הקוד שלכם. OnXfaPageCountChanged רץ בתוך callback הפריסה הילידי הזה, ולכן מתייחסים אליו בתור הודעה: מעדכנים שם תוויות, טווחי spinner ומצב סרגל כלים, וכל דבר כבד יותר, כמו סגירת המסמך או פתיחת אחר, נכנס לתור עם הודעה שנשלחת כדי שירוץ אחרי שה-callback חוזר. TPdfView.OnPageChange אז אומר לכם מתי התצוגה באמת טענה מחדש את העמוד, וקריאת PdfView1.PageNumber באותה נקודה נותנת לכם את הערך החתוך. מעבר טאב ובדיקות ה-FormType שצופה טפסים מריץ בפתיחה מכוסים ב-ניווט שדות טפסים ב-PDF עם PDFium Component

מדוע לחיצה על שדה Full XFA מעלה "Cannot open text page"?

לעמודי Full XFA אין עמוד טקסט PDF, ולפני v3.126.2 בחירת הטקסט וזיהוי הקישורים של ברירת המחדל של הצופה ניסו לטעון אחד בכל זאת. עם TPdfView.AllowUserTextSelection בברירת המחדל True, ריחוף שאל את שכבת הטקסט על תו תחת העכבר, ולחיצת עכבר-למעלה הריצה probe אוטומטי של URL על טקסט העמוד. בעמוד Full XFA אי אפשר לפתוח את עמוד הטקסט, ולכן קליק רגיל לתוך שדה יכל להסתיים בחריגה Cannot open text page. מאז v3.126.2 שני הנתיבים הפנימיים מחזירים אין תוצאה כש-TPdf.FormType הוא ftXfaFull והרנטיים של ה-XFA זמין, ולכן ההגדרות שמוגדרות כברירת מחדל עובדות וקלט השדות נשאר זמין

כיבוי AllowUserTextSelection עבור מסמכי Full XFA נשאר בחירת UI סבירה, כי אין טקסט עמוד לבחור ומחוות גרירה לא צריכות להתחיל מצב בחירה. זה לא תחליף לשדרוג, לעומת זאת: בגרסאות קודמות ה-probe של ה-URL בלחיצה לא היה תלוי בתכונה הזאת, ולכן צופה יכל להיתקל באותה חריגה גם כשהבחירה כבויה

procedure TClaimForm.ConfigureViewerForForm;
begin
  // FormType קורא את המסמך הפתוח, ולכן קוראים לזה אחרי FPdf.Active := True
  if FPdf.XFA and (FPdf.FormType = ftXfaFull) and FPdf.XfaRuntimeAvailable then
  begin
    // אין שכבת טקסט PDF בעמודי Full XFA; השדות נשארים ניתנים לעריכה
    PdfView1.AllowUserTextSelection := False;
    StatusBar1.SimpleText := Format('Dynamic XFA form, %d page(s)',
      [FPdf.PageCount]);
  end
  else
    PdfView1.AllowUserTextSelection := True;
end;

להקלדה היה תיקון משלה ב-v3.126.2. עורך הטקסט של ה-XFA הילידי לא מחליף בחירה כשהוא מקבל תו: FORM_OnChar מכניס בסמן, ו-Backspace מוחק תו אחד, ולכן בחירת ערך והקלדה עליו הפיקה טקסט ישן וחדש זה לצד זה. PDFium Component זוכר עכשיו שהלחיצה נחתה על שדה טקסט של XFA ומנתב תווים שהוקלדו, Backspace ו-Delete דרך FORM_ReplaceSelection בכל פעם שיש בחירה והמסמך מעניק הרשאת fill-forms או modify. האם שדה XFA לקריאה בלבד רשאי להשתנות נקבע עדיין על ידי העורך הילידי, ולכן שדה שסומן לקריאה בלבד בטופס שומר על ערכו גם במסמך שמתיר מילוי באופן אחר. הגדרת TPdfView.AllowFormEvents ל-False גם עוצרת את הניתוב הזה של המקלדת, מה ששומר צופה לקריאה בלבד לקריאה בלבד

עזר זריז: dynamic XFA בצופן Delphi

סימפטוםסיבהתוקן ב
מספר העמודים מציג 1 אחרי שהטופס גדל לשני עמודיםאירוע העמודים הילידי מעביר דלתא של נוסף/הוסר, לא סךv3.126.1 (wrapper)
מסגרת השדה זזה, הטקסט שהוקלד ואזור הפגיעה נשארים מאחורwidget טעון דילג על סידור מחדש אחרי השוואה-עצמיתv3.126.1 (ספריות ה-V8 של Windows)
הצופה מצייר או מנתב קלט אל מצב עמוד שלפני הפריסהמצביע העמודים לא נטען מחדש אחרי רפאגינציהv3.126.2 (רענון נדחה)
לחיצה לתוך שדה מעלה Cannot open text pageבחירת טקסט ו-probe של URL על עמודים בלי שכבת טקסטv3.126.2
הקלדה על ערך נבחר מוסיפה במקום להחליףעורך ה-XFA הילידי מכניס בסמןv3.126.2
  • מגדירים EnableV8Engine ל-True לפני טעינת מסמך כלשהו, ומטפלים ב-OnXfaRuntimeMissing עבור המקרה שהספרייה הרגילה נטענה קודם
  • קוראים את הסך מ-TPdf.PageCount או מהפרמטר NewCount של OnXfaPageCountChanged; לעולם לא מחברים או מחסירים מספרי עמודים בעצמכם
  • משאירים את ה-handler של OnXfaPageCountChanged קל משקל, כי הוא רץ בתוך callback הפריסה הילידי
  • מסנכרנים את מחוון העמוד הנוכחי ב-TPdfView.OnPageChange, שנורה אחרי שהטעינה מחדש הנדחה חותכת את מספר העמוד
  • מפיצים את ה-DLLs של ה-V8 ל-Windows מ-v3.126.1 ומעלה יחד עם היחידות; תיקון סידור ה-widgets מחדש חי בקוד ילידי
  • בודקים עם טופס שבאמת משנה את מספר העמודים שלו ומזיז שדה ערוך מעבר לשבירת עמוד, כי דגימות באורך קבוע מסתירות כל באג ברשימה הזאת

Dynamic XFA הופך את מספר העמודים ואת הגיאומטריה של השדות לערכים חיים, וצופה נשאר נכון רק כשהוא לוקח אותם מהפריסה שהושלמה וטוען עמודים מחדש ברגע בטוח. PDFium Component מטפל בשניהם בתוך TPdf ו-TPdfView, ולכן למארח נותר רק להקשיב. פרטים והורדות ב-דף המוצר של PDFium Component ל-Delphi