מאמר טכני

אינדקס Widget מול אינדקס הערה בטפסי PDFium ב-Delphi

ב-PDFium Component, רכיב ה-VCL/LCL המבוסס-PDFium עבור Delphi, C++Builder, ו-Lazarus, אינדקס שדה טופס אינו אינדקס הערה. עמוד נושא הערות Link, Text, ו-Ink לצד ה-widgets שלו, כך שספירת שדות חייבת לסנן לפי FPDFAnnot_GetSubtype ולחשוף אינדקס לוגי מבוסס-אפס, ממופה בחזרה למיקום הערה אמיתי רק בקריאה הילידית

הבאג שחושף את זה חד-משמעי ברגע שראיתם אותו. בודק לוחץ Tab בטופס חשבונית מלא והסמן נעלם, כי הפוקוס הלך להיפר-קישור בכותרת התחתונה. או גרוע מזה, שום דבר לא קורה בכלל: הקוד שלכם רושם שדה 3 כממוקד, פאנל ה-UI מתעדכן, ו-FORM_SetFocusedAnnot החזיר בשקט false כל הזמן. שני התסמינים מגיעים מאותה טעות עיצוב, ואחד מהם יש לו סיבת-שורש שנייה מתחבאת מתחתיו

שני מרחבי האינדקס ש-PDFium נותן לכם

PDFium חושף שתי סכימות מספור על אותו עמוד, והן חופפות רק על מסמכים שקורה שהם לא מכילים כלום מלבד widgets של טופס. הראשונה היא אינדקס ההערה: מיקום במערך /Annots של העמוד, שזה מה ש-FPDFPage_GetAnnotCount סופר ומה ש-FPDFPage_GetAnnot לוקח (ISO 32000-1 §12.5.2). השנייה היא אינדקס השדה הלוגי שAPI ברמת-אפליקציה אמור להציע, רץ מאפס על פני השדות האינטראקטיביים שמשתמש באמת יכול להגיע אליהם. ISO 32000-1 §12.5.6.19 מגדיר הערות widget כייצוג החזותי של שדות טופס אינטראקטיביים, ו-§12.7 מגדיר את הטופס עצמו. כל דבר אחר בעמוד הוא תת-סוג שונה עם סמנטיקה שונה: להערת Link יש יעד, להערת Ink יש רשימת strokes, הערת Text היא פתק דביק. אף אחד מהם לא שייך לספירת שדות, ואף אחד מהם לא יכול לקבל פוקוס טופס. ובכל זאת במערך /Annots הם יושבים משוזרים עם ה-widgets בכל סדר שהאפליקציה המפיקה כתבה אותם, שלעיתים קרובות אינו הסדר שכל דבר אחר במסמך מציע

מדוע Tab נוחת על היפר-קישור במקום השדה הבא?

כי ספירת השדות הייתה בעצם ספירת הערות. המימוש המקורי החזיר FPDFPage_GetAnnotCount ישירות מ-FormFieldCount, בעוד מבאי מידע השדה, helper סדר-ה-tab, ו-helper הפוקוס כולם התייחסו לאותו מספר שלם כמיקום widget. בעמוד AcroForm נקי עם שישה widgets ולא כלום מעבר, שישה שווה שישה וכל בדיקה עוברת. מוסיפים היפר-קישור בכותרת התחתונה והערת סוקר בשוליים, והספירה מדווחת שמונה שדות, אינדקסים 6 ו-7 נפתרים לאובייקטים לא-של-טופס, ו-Tab הולך ישר לתוכם

התיקון בקצה הספירה הוא לספור תת-סוגים במקום הערות. פותחים כל הערה, שואלים לתת-הסוג שלה, שומרים את ה-widgets, וסוגרים את ה-handle בבלוק finally, כי FPDFPage_GetAnnot מחזירה handle בבעלות שחייב לחזור דרך FPDFPage_CloseAnnot

function WidgetCountForPage(Page: FPDF_PAGE): Integer;
var
  Count, I: Integer;
  Annot: FPDF_ANNOTATION;
begin
  Result := 0;
  if Page = nil then
    Exit;
  Count := FPDFPage_GetAnnotCount(Page);   // every annotation, not just fields
  for I := 0 to Count - 1 do
  begin
    Annot := FPDFPage_GetAnnot(Page, I);
    if Annot = nil then
      Continue;
    try
      if FPDFAnnot_GetSubtype(Annot) = FPDF_ANNOT_WIDGET then
        Inc(Result);
    finally
      FPDFPage_CloseAnnot(Annot);
    end;
  end;
end;

שימו לב למה שזה בכוונה לא עושה. זה לא שואל את סביבת מילוי-הטופס שום דבר, ולא זקוק ל-form handle, כי תת-הסוג חי במילון ההערה וקריא מהעמוד לבדו. זה חשוב לסדר: הספירה זמינה לפני שהחלטתם אם המסמך בכלל ראוי לסביבת מילוי-טופס, מה שהמאמר על AcroForm JavaScript ואירועי host מכסה כהחלטת אבטחה ולא כזו של נוחות

מיפוי האינדקס הלוגי בחזרה בגבול הילידי

הכלל ששומר על שני המרחבים מלדלוף זה לתוך זה פשוט: האינדקס הלוגי הוא המספר היחיד שחוצה את ה-API הציבורי שלכם, והוא מומר לאינדקס הערה בפונקציה האחרונה לפני הקריאה הילידית. helper מיפוי אחד, שמשמש מידע שדה, פוקוס, קובעי דגלים, וסדר tab כאחד, הוא מה שהופך את הכלל הזה לאכיף

function AnnotationIndexForField(Page: FPDF_PAGE;
  FieldIndex: Integer): Integer;
var
  Count, I, Current: Integer;
  Annot: FPDF_ANNOTATION;
begin
  Result := -1;
  if (Page = nil) or (FieldIndex < 0) then
    Exit;
  Count := FPDFPage_GetAnnotCount(Page);
  Current := 0;
  for I := 0 to Count - 1 do
  begin
    Annot := FPDFPage_GetAnnot(Page, I);
    if Annot = nil then
      Continue;
    try
      if FPDFAnnot_GetSubtype(Annot) = FPDF_ANNOT_WIDGET then
      begin
        if Current = FieldIndex then
          Exit(I);        // real /Annots position: native calls only
        Inc(Current);
      end;
    finally
      FPDFPage_CloseAnnot(Annot);
    end;
  end;
end;

שתי תכונות של ה-helper הזה שוות ציון ברור. זה סריקה ליניארית, כך שלולאה נאיבית על פני כל שדה עולה מספר ריבועי של פתיחות הערה על עמוד עם מאות widgets; אם אתם סופרים את כל העמוד, עוברים על ההערות פעם אחת ואוספים את ה-handles של ה-widgets תוך כדי במקום לקרוא ל-mapper לכל שדה. וזה מחזיר -1 במקום להעלות חריגה, מה שנותן לקורא להחליט אם אינדקס מיושן הוא שגיאת תכנות ששווה חריגה או מירוץ ששווה להתעלם ממנו, לדוגמה אחרי שעריכה הסירה הערה שרשימת UI במטמון עדיין מפנה אליה

מדוע FORM_SetFocusedAnnot נכשל על עמוד ללא-ראש?

כי PDFium מסרב למקד widget שתצוגת העמוד שלו מעולם לא סומנה כתקפה. FORM_SetFocusedAnnot פותר את ההערה לתצוגת עמוד בתוך סביבת מילוי-הטופס, ואם תצוגת העמוד ההיא לא קיימת הוא מחזיר false ללא אבחון כלשהו. תיקון מיפוי האינדקס בלבד לכן מתקן את Tab נוחת על היפר-קישור אך משאיר את התסמין השני ללא נגיעה: רשומת הפוקוס הלוגי שלכם אומרת שדה 3, ה-widget הממוקד הילידי עדיין כלום, וכל accessor שנבנה על הפוקוס הילידי, טקסט ממוקד, ערך ממוקד, מצב בחירה של רשימה, ממשיך להחזיר ריק. תצוגת העמוד נוצרת על ידי FORM_OnAfterLoadPage ונהרסת על ידי FORM_OnBeforeClosePage. בצופה שבנוי סביב פקד חזותי הקריאות הללו קורות כחלק מהצגת עמוד, וזו הסיבה שהכשל כל כך לעיתים קרובות נראה כבאג-ללא-ראש-בלבד: אותו קוד שעובד בהדגמת ה-GUI נכשל בכלי האצווה. מחזור החיים שייך לאובייקט המסמך, לא לצופה, כך ש-PDFium Component עכשיו מנפיק את שתי הקריאות בכל פעם שעמוד נטען או נפרק עם form handle נוכח. חתימת ה-C לוקחת את העמוד ראשון ואת ה-form handle שני, מה שקל להפוך כשכותבים את ה-binding ביד

procedure ReportFirstField(const FileName: string);
var
  Pdf: TPdf;
  Idx: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FormFill := True;      // form-fill environment, before Active
    Pdf.FileName := FileName;
    Pdf.Active := True;
    Pdf.PageNumber := 1;       // page load also runs FORM_OnAfterLoadPage

    Idx := Pdf.FocusNextFormField;   // logical index, 0-based over widgets
    if Idx < 0 then
      Exit;                    // page holds no widget annotations

    Writeln(string(Pdf.FormFieldInfo[Idx].Name), ' = ',
      string(Pdf.FocusedFormFieldValue));   // reads the native focused widget
  finally
    Pdf.Free;                  // page unload runs FORM_OnBeforeClosePage
  end;
end;

הבדיקה שמוכיחה את התיקון היא זו שמשווה בין שני הצדדים. קוראים ל-FocusFormField עם אינדקס לוגי, ואז קוראים ערך דרך accessor שעובר דרך ה-widget הממוקד הילידי במקום דרך הרשומה שלכם עצמכם, כמו FocusedFormFieldValue או FocusedFormOptionSelected. אם האינדקס הלוגי חוזר בסבב אך ה-accessor הילידי חוזר ריק, תצוגת העמוד חסרה, לא המיפוי

מה אינדקס השדה הלוגי לא מבטיח

אינדקס שדה מבוסס-אפס הוא נוחות, לא זהות סמנטית, וארבעה גבולות נובעים מכך. הוא לכל-עמוד, לא לכל-מסמך, כך שאינדקס 0 בעמוד 2 הוא widget שונה מאינדקס 0 בעמוד 1 והשוואה ביניהם חסרת משמעות. הוא מיקומי, כך שהוספה או מחיקה של הערה פוסלת כל אינדקס במטמון מעל השינוי; מתייחסים לאינדקס שמור כתקף רק כל עוד העמוד נשאר טעון ובלתי-ערוך

הגבול השלישי הוא זה שמפתיע אנשים שסוקרים רשימת שדות. האינדקס סופר widgets, לא שדות. קבוצת רדיו היא שדה אחד עם כמה widget kids, כך שקבוצה בת שלושה כפתורים תורמת שלושה אינדקסים עוקבים שכולם מדווחים את אותו Name. הרשומה TPdfFormFieldInfo נושאת GroupCount ו-GroupIndex בדיוק עבור המקרה הזה, וממשק רשימה שמתעלם מהם מראה את אותו שדה שלוש פעמים. הגבול הרביעי נוגע לסדר מעבר: סדר ה-tab החשוף כאן הוא סדר ספירת ה-widget, שעוקב אחר מערך /Annots, לא אחר רשומת /Tabs של העמוד (ISO 32000-1 §7.7.3.3) ולא אחר עץ שדות ה-AcroForm. עבור רוב היצרנים אלה מסכימים; עבור טופס שפרוס בשתי עמודות על ידי מחולל שפלט את העמודה הימנית קודם, הם לא, ונתיב המקלדת המתואר במאמר ניווט שדות טופס ירגיש לא נכון למרות שכל אינדקס נכון. כשקובץ לקוח מתנהג מוזר, שופכים את שני מרחבי האינדקס זה לצד זה לפני שמתאוריטזים: תצוגת ההערה ותצוגת השדה של אותו עמוד, מודפסות יחד, בדרך כלל הופכות את הסיבה לברורה במבט אחד

procedure DumpIndexSpaces(Pdf: TPdf);
var
  I: Integer;
  Info: TPdfFormFieldInfo;
begin
  for I := 0 to Pdf.AnnotationCount - 1 do
    Writeln('annot ', I, ': subtype ', Ord(Pdf.Annotation[I].Subtype));

  for I := 0 to Pdf.FormFieldCount - 1 do
  begin
    Info := Pdf.FormFieldInfo[I];
    Writeln('field ', I, ': ', string(Info.Name),
      ' widget ', Info.GroupIndex, ' of ', Info.GroupCount);
  end;
end;

ספירת הערות הרבה מעל ספירת השדות משמעה שהעמוד מערבב תת-סוגים, שזה נורמלי במסמכים שנסקרו וזה בדיוק המצב שהמיפוי קיים עבורו; מאמר זרימת עבודה של סקירת הערות מסתכל על אותו עמוד מצד הסימון. ספירות שוות בכל קובץ בדיקה, לעומת זאת, משמען שה-fixtures שלכם לא יכולים לזהות את מחלקת הבאג הזו כלל, והתגובה הכנה היא להוסיף fixture טופס שנושא קישור ופתק דביק

ספירת השדות, הפוקוס, וה-API-ים של ההערות המתוארים כאן מגיעים עם PDFium Component עבור Delphi, C++Builder, ו-Lazarus, שדף המוצר שלו נושא את הפניית שדה-הטופס המלאה כולל רשומת מידע השדה ומבאי הפוקוס