מאמר טכני

השתלת שדות AcroForm בין קובצי PDF ב־Delphi עם PDFiumPas

העברת גוש שדות טופס מתבנית של השנה שעברה אל הפריסה של השנה הנוכחית היא הנקודה שבה סבבי FDF ו־XFDF מפסיקים להספיק: הערכים מגיעים, אבל זרמי המראה, פעולות החישוב והמשאבים שברירת המחדל לא. PDFiumPas עונה על המקרה הזה עם GraftPdfAcroForm, שמשכפל את גרף אובייקטי השדות כולו מתוך PDF אחד וכותב אותו לתוך אחר

הסיבה שייצוא ברמת נתונים אינו יכול לעשות זאת היא מבנית. שדה אינו רשומה, הוא תת־גרף. ISO 32000-1 §12.7 מגדיר את מילון הטופס האינטראקטיבי שמחזיק /Fields, /CO, /DR ו־/DA, §12.7.3 מגדיר את מילוני השדות שתלויים מתחתיו, ו§12.5.6.19 מגדיר את הערות ה־widget שנותנות לשדות האלה תיבה נראית בעמוד. XFDF נושא את העלים של המבנה הזה. השתלה נושאת את המבנה עצמו

למה העתקת מערך ה־/Fields לעולם אינה מספיקה

העתקת /Fields ממסמך אחד למשנהו מניבה טופס ששבור בכל דרך מעניינת, כי המערך מחזיק הפניות עקיפות ולא יותר. ISO 32000-1 §7.3.10 הופך אובייקט עקיף לניתן למיעון באמצעות מספר אובייקט ודור, והמספרים האלה משמעותיים רק בתוך הקובץ שממנו הגיעו. מדביקים את המערך בקובץ אחר, וכל הפניה בו או תלויה באוויר או — גרוע מכך — נפתרת בשקט אל אובייקט לא קשור שבמקרה תופס את המשבצת הזאת ביעד. מתחת לכל הפניה יושב גרף שהוא גם משותף וגם מחזורי. מילון שדה מצביע על הילדים שלו, כל ילד מצביע חזרה אל ה־/Parent שלו, widget מצביע על זרמי המראה שלו ועל העמוד שנושא אותו דרך /P, זרמי מראה מצביעים על גופנים במילון המשאבים שברירת המחדל של הטופס, ומילוני פעולה נוספים תחת /AA מצביעים על עוד אובייקטים. שני widgets בעמודים שונים חולקים בדרך כלל גופן אחד ו־XObject מראה אחד. לכן השתלה נכונה חייבת ללכת בגרף הזה, לשכפל כל אובייקט נגיש בדיוק פעם אחת, להפנות מחדש את ה־/P של כל widget אל עמוד היעד הממופה, ולהוסיף את ה־widget המשוכפל אל מערך ה־/Annots של אותו עמוד — אחרת השדה קיים בטופס אך בלתי נראה בעמוד. אם רדפתם אחרי ההבדל בין שדה, ה־widget שלו והערת העמוד שמציגה אותו, ההערה שלנו על אינדקס widget מול אינדקס annotation עוסקת בדיוק בפיצול הזה

גרף האובייקטים שמאחורי שדה טופס אחד ב־PDF בזמן ש־PDFiumPas משתיל אותו ב־Delphi: מילון הטופס, השדה, ערות ה־widget, מערכי הערות העמוד ביעד, וזרם המראה והגופן ששני ה־widgets חולקים, וכן ההפניה חזרה אל ההורה שסוגרת את המחזור
שדה הוא תת־גרף משותף ומחזורי, ולכן העתקת מערך ה־/Fields בין מסמכים משאירה כל הפניה תלויה באוויר

מה GraftPdfAcroForm דורש מכם?

הוא דורש שלושה זרמים נפרדים ומיפוי עמודים מפורש. GraftPdfAcroForm מקבל Source, Destination ו־Output כמופעי TStream נפרדים, מערך TPdfGraftPageMappings, רשומת TPdfAcroFormGraftOptions, TPdfCrossDocumentGraftMap אופציונלי ופרמטר out מסוג TPdfAcroFormGraftReport. הפונקציה מחזירה Boolean במקום להרים חריגה, ובכשל הדוח נושא את הסיבה ב־ErrorMessage. מיפוי העמודים הוא מבוסס־אחד בשני הצדדים ואינו נגזר מעצמו: כל עמוד מקור שנושא widget שבכוונתכם להשתיל חייב להופיע בו. העברת nil בתור מפת ההשתלה לגיטימית — הפונקציה יוצרת ומשחררת אז מפה פרטית למשך הקריאה — ו־TPdfAcroFormGraftOptions.Default נותן לכם CollisionPolicy שהוגדר ל־pagcpReject, RenamePrefix שהוגדר ל־Imported_, MaxObjects של 100000, MaxDepth של 128 ו־AllowSignedDestination שהוגדר ל־False. שלושת האחרונים הם תקציבים, והם קיימים כי גרף האובייקטים שבו אתם עומדים ללכת הגיע מקובץ שלא אתם כתבתם

uses
  Classes, SysUtils, FPdfCompress;

var
  Source, Destination, Output: TMemoryStream;
  Options: TPdfAcroFormGraftOptions;
  Mappings: TPdfGraftPageMappings;
  Report: TPdfAcroFormGraftReport;
begin
  Source := TMemoryStream.Create;
  Destination := TMemoryStream.Create;
  Output := TMemoryStream.Create;
  try
    Source.LoadFromFile('claim-template-2025.pdf');
    Destination.LoadFromFile('claim-layout-2026.pdf');
    Source.Position := 0;
    Destination.Position := 0;

    Options := TPdfAcroFormGraftOptions.Default;

    SetLength(Mappings, 2);
    Mappings[0].SourcePageNumber := 1;
    Mappings[0].DestinationPageNumber := 1;
    Mappings[1].SourcePageNumber := 2;
    Mappings[1].DestinationPageNumber := 3;

    if GraftPdfAcroForm(Source, Destination, Output, Mappings,
      Options, nil, Report) then
      Output.SaveToFile('claim-2026-with-fields.pdf')
    else
      raise Exception.Create(Report.ErrorMessage);
  finally
    Output.Free;
    Destination.Free;
    Source.Free;
  end;
end;

איך מפת ההשתלה נמנעת משכפול גופן משותף פעמיים?

TPdfCrossDocumentGraftMap מחזיק טבלת הפניות ממקור ליעד שהמפתחות שלה נושאים גם מספר אובייקט וגם דור, והמשכפל הרקורסיבי מתייעץ איתה לפני שהוא צולל. סדר הפעולות הוא מה שהופך מחזורים לבטוחים: המשכפל מקצה קודם את מספר האובייקט ביעד ורושם את המיפוי לפני שהוא הולך בהפניות הילד של אובייקט המקור. הורה שמגיע אל ילד שמצביע חזרה אל ההורה מוצא את ההורה כבר רשום ומחזיר את ההפניה הקיימת ליעד במקום לרדת לרקורסיה. אותה בדיקה היא שגורמת לגופן, לזרם מראה או לפעולה ששישה widgets חולקים להישכפל פעם אחת ולהיות מופנים שש פעמים. המפה קשורה למסמך המקור באמצעות גיבוב SHA-256 של בתי המקור, שנחשף כ־SourceIdentity. אם תמסרו ל־GraftPdfAcroForm מפה שהזהות שלה אינה תואמת את המקור שהעברתם, היא מסרבת לקריאה במקום לעשות שימוש חוזר בהפניות שמעולם לא היו תקפות לקובץ הזה. מיפויי העמודים נזרעים לאותה מפה לפני שהשכפול מתחיל, וזה בדיוק האופן שבו ה־/P של widget מגיע להצביע על עמוד היעד: אובייקט עמוד המקור כבר נפתר לאובייקט עמוד היעד הממופה, ולכן מעבר שכתוב ההפניות הרגיל מטפל בזה ללא מקרה מיוחד

מפת ההשתלה בין־מסמכית של PDFiumPas ב־Delphi ממפתחת כל הפניית מקור לפי מספר אובייקט ודור, רושמת את מיפוי היעד לפני הצלילה כך שהפניה חזרה אל ההורה נעצרת, ומחזירה את הרשומה הקיימת כך שגופן משותף נשכפל פעם אחת בלבד
רישום המיפוי לפני הליכה בילדים הוא מה שהופך גרף מחזורי לבטוח ואובייקט משותף נשכפל בדיוק פעם אחת
uses
  Classes, SysUtils, FPdfCompress, FPdfSha256;

var
  GraftMap: TPdfCrossDocumentGraftMap;
  SourceBytes: TBytes;
  EntriesBefore: Integer;
begin
  SetLength(SourceBytes, Source.Size);
  Source.Position := 0;
  if Length(SourceBytes) > 0 then
    Source.ReadBuffer(SourceBytes[0], Length(SourceBytes));

  GraftMap := TPdfCrossDocumentGraftMap.Create(
    AnsiString(SHA256Hex(SHA256Bytes(SourceBytes))));
  try
    EntriesBefore := GraftMap.Count;
    Source.Position := 0;
    if not GraftPdfAcroForm(Source, Destination, Output, Mappings,
      Options, GraftMap, Report) then
    begin
      // הרשומות שנוספו בקריאה הזאת שוחזרו לאחור;
      // כל מה שנרשם לפניה עדיין שלם.
      Assert(GraftMap.Count = EntriesBefore);
      WriteLn('graft refused: ', Report.ErrorMessage);
    end;
  finally
    GraftMap.Free;
  end;
end;

השחזור לאחור הזה הוא הסיבה להחזיק את המפה בעצמכם. PDFiumPas מתייחס למפה שהמתקשר מספק באופן טרנזקציוני: השתלה שנכשלה משליכה את הרשומות שהקריאה הזאת הוסיפה ומשמרת כל מיפוי שהיה קיים קודם, כך שסירוב אחד לעולם לא משאיר אחריו מטמון של הפניות אל אובייקטים שמעולם לא נכתבו. עם זאת, החזיקו מפה אחת לכל מסמך יעד — הצד של היעד בכל רשומה הוא מספר אובייקט בקובץ המסוים ההוא, ואין לו משמעות בקובץ אחר

התנגשויות שמות שדות: דחייה או שינוי שם

שמות שדות מלאים חייבים להישאר ייחודיים בתוך טופס, ו־PDFiumPas לא ינחש מה התכוונתם כשהם מתנגשים. TPdfAcroFormCollisionPolicy מציע בדיוק שתי תשובות. תחת pagcpReject, ברירת המחדל, שדה המקור הראשון שהשם המלא שלו כבר קיים ביעד מבטל את כל ההשתלה בשגיאה ומשאיר את זרם הפלט ריק. תחת pagcpRename, שדה המקור המתנגש משנה את שמו בהוספת RenamePrefix כקידומת וההשתלה נמשכת, כאשר Report.RenamedFieldCount מדווח כמה פעמים זה קרה

Options := TPdfAcroFormGraftOptions.Default;
Options.CollisionPolicy := pagcpRename;
Options.RenamePrefix := 'Y2025_';
Options.MaxObjects := 20000;
Options.MaxDepth := 64;

if GraftPdfAcroForm(Source, Destination, Output, Mappings,
  Options, nil, Report) then
begin
  WriteLn('source fields  : ', Report.SourceFieldCount);
  WriteLn('existing fields: ', Report.DestinationFieldCount);
  WriteLn('grafted fields : ', Report.GraftedFieldCount);
  WriteLn('renamed fields : ', Report.RenamedFieldCount);
  WriteLn('cloned objects : ', Report.GraftedObjectCount);
  WriteLn('reused objects : ', Report.ReusedObjectCount);
  WriteLn('mapped pages   : ', Report.MappedPageCount);
  WriteLn('output bytes   : ', Report.OutputByteCount);
end
else
  WriteLn('graft refused  : ', Report.ErrorMessage);

שינוי שם אינו חינם, וכדאי להחליט עליו בכוונה ולא להיאחז בו רק כדי לגרום לשגיאה להיעלם. שדה ששינה שם הוא שדה אחר: כל JavaScript ביעד שמפנה אליו בשם, כל רשומת חישוב ב־/CO שאדם כתב מול השם הישן, וכל צרכן במורד הזרם שממפתח לפי שם השדה יצטרכו לדעת על הקידומת. אם שני המסמכים באמת מתארים את אותו שדה, התיקון הישר הוא בדרך כלל ליישב את השמות במעלה הזרם, ולא בזמן ההשתלה. אחרי שההשתלה נחתה, הליכה בטופס הממוזג כדי לוודא מה קיבלתם בפועל היא הצעד הטבעי הבא, והמאמר על ניווט בשדות טופס ב־PDFiumPas עוסק במעבר הזה

היכן ההשתלה בוחרת בכוונה להיכשל בצורה סגורה

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

  • טופס המקור נושא רשומת /XFA — מנות XFA הן מודל טפסים מקביל ואי אפשר לצמצמן למילוני שדות AcroForm
  • widget שוכן בעמוד מקור שאין לו רשומה במיפוי העמודים — בלי זה השדה היה מושמט בשקט או מוצמד לעמוד הלא נכון
  • מיפויי עמודים מחוץ לטווח, או שני מיפויים שעושים שימוש חוזר באותו עמוד מקור או יעד
  • שני הטפסים מגדירים מילון משאבים שברירת מחדל /DR, כי מיזוג של שני מרחבי שמות משאבים עלול להפנות שם קיים אל גופן אחר
  • גרף האובייקטים חורג מ־MaxObjects או שהרקורסיה חורגת מ־MaxDepth
  • היעד מכיל חתימה ו־AllowSignedDestination הוא False
  • מפת ההשתלה שסופקה שייכת למסמך מקור אחר, או שהפניית מקור תלויה באוויר

נתיב הכתיבה שמרני באותה מידה. PDFiumPas פולט את התוצאה כתיקון מצטבר דליל שנוסף ליעד, ואז מגשים מחדש את הפלט שנכתב וקורא את הטופס שלו שוב: אם מספר השדות בתוצאה אינו שווה למספר השדות המקורי של היעד בתוספת זה של המקור, כל ההשתלה נדחית והפלט מתרוקן. לעולם לא תקבלו קובץ שהושתל חלקית. המחיר של המדיניות הזאת ממשי — התנגשות /DR או יעד חתום עוצרים אתכם לחלוטין, ואתם צריכים לפתור זאת בעצמכם במקום לקבל קירוב ממוזג — אבל החלופה היא טופס שנפתח יפה ומחשב לא נכון

איך GraftPdfAcroForm של PDFiumPas נכשל בצורה סגורה ב־Delphi: התיקון שנכתב נקרא מחדש ומספר השדות שלו נבדק, כל תנאי עמום כמו XFA או עמוד לא ממופה מסרב לקריאה, וסירוב משליך רק את רשומות המפה שהקריאה הזאת הוסיפה
נתיב הכתיבה המאומת והמפה הטרנזקציונית הם הסיבה שהשתלה שנדחתה לעולם לא משאירה אחריה קובץ ממוזג חלקית

מתי השתלה היא הכלי הלא נכון

השתלה מעבירה מבנה, ולכן השתמשו בה כשהמבנה הוא בדיוק מה שחסר לכם. אם שני המסמכים כבר נושאים את אותה קבוצת שדות ואתם צריכים רק להעביר ביניהם ערכים והערות, נתיב הייצוא והייבוא במאמר על נתוני טפסים של XFDF קל יותר, תקני והפיך. פנו אל GraftPdfAcroForm כשליעד אין שדות כלל, או שיש לו קבוצה אחרת, ואתם צריכים שה־widgets, זרמי המראה, הפעולות וסדר החישוב יעברו שלמים. הערה מעשית אחרונה על זהות: כי מפת ההשתלה ממפתחת לפי מספר אובייקט ודור וקשורה לגיבוב SHA-256 של בתי המקור, שמירה מחדש או אופטימיזציה של המקור בין הרצות מייצרות זהות אחרת ומפה שכבר לא רלוונטית. צלמו תמונת מצב של המקור שממנו אתם משתילים והחזיקו אותו יציב לאורך האצווה; התייחסו אליו כאל ארטיפקט קלט, לא כאל משהו שמשימה לילית רשאית לשכתב

GraftPdfAcroForm, TPdfCrossDocumentGraftMap וערכת הכלים ל־PDF ברמת זרם שסביבם מגיעים עם PDFiumPas Delphi PDFium Component עבור Delphi, C++Builder ו־Lazarus, שבה עמוד המוצר נושא את ההפניה המלאה ל־API של אפשרויות ההשתלה, שדות הדוח ושאר משטח עריכת המסמכים