บทความเทคนิค

ปลูกถ่ายฟิลด์ AcroForm คร่อม PDF ใน Delphi ด้วย PDFiumPas

ย้ายก้อนฟิลด์ฟอร์มจากเทมเพลตปีก่อนลงเลย์เอาต์ปีนี้ คือจุดที่การ round-trip ด้วย FDF กับ XFDF เริ่มไม่พอ: ค่ามาถึง แต่ appearance stream, action การคำนวณ และ default resource ไม่มา PDFiumPas ตอบกรณีนี้ด้วย GraftPdfAcroForm ซึ่งโคลนกราฟออบเจกต์ของฟิลด์ทั้งชุดออกจาก PDF หนึ่งไฟล์แล้วเขียนเข้าอีกไฟล์

เหตุที่การส่งออกแค่ระดับข้อมูลทำสิ่งนี้ไม่ได้เป็นเรื่องโครงสร้าง ฟิลด์ไม่ใช่เรกคอร์ด แต่เป็นซับกราฟ ISO 32000-1 §12.7 นิยาม interactive form dictionary ที่ถือ /Fields, /CO, /DR กับ /DA §12.7.3 นิยาม field dictionary ที่ห้อยอยู่ใต้มัน และ §12.5.6.19 นิยาม widget annotation ที่ให้ฟิลด์เหล่านั้นมีกล่องมองเห็นบนหน้า XFDF แบกใบของโครงสร้างนั้น การปลูกถ่ายแบกตัวโครงสร้างเอง

ทำไมคัดลอกอาร์เรย์ /Fields อย่างเดียวจึงไม่เคยพอ

การคัดลอก /Fields จากเอกสารหนึ่งไปอีกเอกสารหนึ่งผลิตฟอร์มที่พังทุกแบบที่น่าสนใจ เพราะอาร์เรย์ถือ indirect reference เปล่า ๆ ISO 32000-1 §7.3.10 ทำให้ออบเจกต์ indirect เข้าถึงได้ด้วยหมายเลขออบเจกต์บวก generation และตัวเลขพวกนั้นมีความหมายเฉพาะข้างในไฟล์ที่มันมาจาก แปะอาร์เรย์ข้ามไปแล้วทุก reference ในนั้นจะ dangle หรือแย่กว่านั้น resolve เงียบ ๆ ไปติดออบเจกต์ไม่เกี่ยวข้องที่บังเอิญนั่งอยู่ช่องนั้นของไฟล์ปลายทาง ใต้ reference แต่ละตัวนั่งกราฟที่ทั้งถูกแชร์และวนเป็นวง ฟิลด์ dictionary ชี้ไปลูก ลูกแต่ละตัวชี้กลับไป /Parent ของมัน widget ชี้ไป appearance stream และหน้าที่แบกมันผ่าน /P appearance stream ชี้ไปฟอนต์ใน default resource dictionary ของฟอร์ม และ additional-action dictionary ใต้ /AA ชี้ต่อไปยังออบเจกต์อีกชุด widget สองตัวบนคนละหน้าปกตินั่งแชร์ฟอนต์หนึ่งตัวกับ appearance XObject หนึ่งตัว การปลูกถ่ายที่ถูกต้องจึงต้องเดินกราฟนั้น โคลนออบเจกต์ที่เอื้อมถึงได้แต่ละตัวเป๊ะหนึ่งครั้ง ชี้ /P ของ widget ทุกตัวกลับไปที่หน้าปลายทางที่แมปไว้ และเติม widget โคลนลงอาร์เรย์ /Annots ของหน้านั้น — มิฉะนั้นฟิลด์จะมีอยู่ในฟอร์มแต่มองไม่เห็นบนหน้า ถ้าคุณเคยไล่ความต่างระหว่างฟิลด์ ของ widget มัน และ annotation บนหน้าที่แสดงมัน บันทึกของเราเรื่อง ดัชนี widget เทียบกับดัชนี annotation เล่าการแบ่งนั้นเป๊ะจุดนั้น

กราฟออบเจกต์เบื้องหลังฟิลด์ฟอร์ม PDF หนึ่งฟิลด์ขณะ PDFiumPas ปลูกถ่ายมันใน Delphi: form dictionary, ฟิลด์, widget annotation, อาร์เรย์ annotation หน้าปลายทาง และ appearance stream กับฟอนต์ที่ widget ทั้งสองแชร์กัน บวกการอ้างอิงกลับหาพ่อที่ปิดวงวน
ฟิลด์เป็นซับกราฟที่ถูกแชร์และวนเป็นวง นั่นคือเหตุที่การคัดลอกอาร์เรย์ /Fields ข้ามเอกสารทิ้ง reference ให้ dangle ทุกตัว

GraftPdfAcroForm ต้องการอะไรจากคุณ?

มันต้องการสตรีมแยกสามสายและการแมปหน้าที่ชัดเจน GraftPdfAcroForm รับ Source, Destination กับ Output เป็น TStream ต่างตัว อาร์เรย์ TPdfGraftPageMappings, เรกคอร์ด TPdfAcroFormGraftOptions, TPdfCrossDocumentGraftMap แบบเลือกได้ และพารามิเตอร์ out TPdfAcroFormGraftReport มันคืน Boolean แทนการ raise และเมื่อล้มเหลวรายงานจะแบกเหตุผลไว้ใน ErrorMessage การแมปหน้าเป็น one-based ทั้งสองฝั่งและไม่ถูกอนุมาน: หน้าต้นทางทุกหน้าที่แบก widget ที่คุณตั้งใจปลูกถ่ายต้องปรากฏอยู่ในนั้น การส่ง nil ให้ graft map เป็นเรื่องชอบธรรม — ฟังก์ชันจะสร้างและปล่อยตัวส่วนตัวของมันเองตลอดการเรียก — และ 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;

graft map หลีกเลี่ยงการโคลนฟอนต์ที่แชร์กันสองครั้งอย่างไร?

TPdfCrossDocumentGraftMap ถือตารางอ้างอิงต้นทางไปปลายทางที่กุญแจแบกทั้งหมายเลขออบเจกต์และ generation และตัวโคลนแบบเรียกซ้ำจะแอบดูมันก่อนจะลงลึก ลำดับการกระทำคือสิ่งที่ทำให้วงวนปลอดภัย: ตัวโคลนจัดสรรหมายเลขออบเจกต์ปลายทางและลงทะเบียน mapping ก่อน แล้วจึงเดิน reference ลูกของออบเจกต์ต้นทาง พ่อที่เอื้อมไปถึงลูกซึ่งชี้กลับมาหาพ่อจะเจอพ่อถูกลงทะเบียนไว้แล้วจึงคืน reference ปลายทางที่มีอยู่ แทนที่จะเรียกซ้ำต่อ การค้นแบบเดียวกันนี้คือสิ่งที่ทำให้ฟอนต์, appearance stream หรือ action ที่ widget หกตัวแชร์กันถูกโคลนหนึ่งครั้งและถูกอ้างอิงหกครั้ง map ถูกผูกกับเอกสารต้นทางด้วยแฮช SHA-256 ของไบต์ต้นทาง เปิดให้เห็นเป็น SourceIdentity ถ้าคุณส่ง GraftPdfAcroForm ให้ map ที่ตัวตนไม่ตรงกับต้นทางที่ส่งไป มันจะปฏิเสธการเรียกแทนการใช้ reference ที่ไม่เคยใช้ได้กับไฟล์นี้ page mapping ถูกหว่านลง map เดียวกันก่อนการโคลนเริ่ม ซึ่งเป๊ะกับวิธีที่ /P ของ widget ลงเอยชี้ไปที่หน้าปลายทาง: ออบเจกต์หน้าต้นทาง resolve ไปติดออบเจกต์หน้าปลายทางที่แมปไว้แล้ว รอบการเขียน reference ใหม่ตามปกติจัดการมันจึงไม่ต้องมีกรณีพิเศษ

graft map คร่อมเอกสารของ PDFiumPas ใน Delphi กุญแจทุก reference ต้นทางด้วยหมายเลขออบเจกต์บวก generation ลงทะเบียน mapping ปลายทางก่อนลงลึกเพื่อให้การอ้างอิงกลับหาพ่อจบลง และคืนรายการที่มีอยู่เพื่อให้ฟอนต์ที่แชร์กันถูกโคลนเพียงครั้งเดียว
การลงทะเบียน mapping ก่อนเดินลูกคือสิ่งที่ทำให้กราฟแบบวนเป็นวงปลอดภัย และออบเจกต์ที่แชร์กันถูกโคลนเป๊ะหนึ่งครั้ง
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
      // รายการที่การเรียกนี้เพิ่มถูก rollback ไปแล้ว
      // ส่วนที่ลงทะเบียนไว้ก่อนหน้ายังครบถ้วน
      Assert(GraftMap.Count = EntriesBefore);
      WriteLn('graft refused: ', Report.ErrorMessage);
    end;
  finally
    GraftMap.Free;
  end;
end;

การ rollback นั่นแหละคือประเด็นของการเป็นเจ้าของ map เอง PDFiumPas ถือ map ที่ผู้เรียกส่งมาแบบธุรกรรม: การปลูกถ่ายที่ล้มจะทิ้งรายการที่การเรียกนั้นเพิ่มและเก็บ mapping ทุกตัวที่มีอยู่ก่อนแล้ว การถูกปฏิเสธหนึ่งครั้งจึงไม่มีวันทิ้งแคชของ reference ชี้ไปยังออบเจกต์ที่ไม่เคยถูกเขียน เก็บ map หนึ่งตัวต่อเอกสารปลายทางหนึ่งไฟล์นะ — ฝั่งปลายทางของรายการแต่ละตัวเป็นหมายเลขออบเจกต์ในไฟล์เป๊ะตัวนั้น ไปไฟล์อื่นคือความหมายเป็นศูนย์

ชื่อฟิลด์ชนกัน: ปฏิเสธหรือเปลี่ยนชื่อ

ชื่อฟิลด์แบบเต็มต้องเป็นเอกลักษณ์ข้างในฟอร์มเดียวกัน และ PDFiumPas จะไม่เดาว่าคุณหมายถึงอะไรเมื่อมันชน TPdfAcroFormCollisionPolicy มีคำตอบเป๊ะสองแบบ ใต้ pagcpReject ซึ่งเป็นค่าเริ่มต้น ฟิลด์ต้นทางตัวแรกที่ชื่อมีอยู่แล้วในปลายทางจะยกเลิกการปลูกถ่ายทั้งชุดพร้อม error และทิ้งสตรีมผลลัพธ์ไว้ว่าง ใต้ 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);

การเปลี่ยนชื่อไม่ฟรี และคุณควรตัดสินใจมันอย่างเต็มตื่น แทนที่จะเอื้อมไปหยิบมันเพื่อให้ error หายไป ฟิลด์ที่ถูกเปลี่ยนชื่อคือฟิลด์ต่างตัว: JavaScript ในปลายทางใด ๆ ที่เรียกมันด้วยชื่อ, รายการคำนวณใด ๆ ใน /CO ที่คนเขียนไว้กับชื่อเดิม และผู้บริโภคปลายทางใด ๆ ที่กุญแจด้วยชื่อฟิลด์ ล้วนต้องรู้เรื่องคำนำหน้านั้น ถ้าเอกสารสองไฟล์พูดถึงฟิลด์เดียวกันจริง ๆ ทางแก้ที่ตรงไปตรงมามักคือการกระทบชื่อให้เข้ากันต้นน้ำ ไม่ใช่ตอนปลูกถ่าย พอการปลูกถ่ายลงแล้ว การเดินฟอร์มที่รวมแล้วเพื่อยืนยันสิ่งที่คุณได้จริงคือก้าวถัดไปที่เป็นธรรมชาติ การนำทางฟิลด์ฟอร์มใน PDFiumPas เล่าการเดินแบบนั้น

จุดที่การปลูกถ่ายตั้งใจล้มแบบ fail closed

เงื่อนไขกำกวมทุกข้อเป็น error ไม่ใช่ผลลัพธ์แบบสุดความสามารถ ซึ่งเป็นการตัดสินใจเชิงดีไซน์ที่ควรเข้าใจก่อนมันจะทำให้คุณตกใจใน production GraftPdfAcroForm คืน False รีเซ็ตสตรีมผลลัพธ์ และรายงานเหตุผลเมื่อเจอเงื่อนไขใดข้อนี้

  • ฟอร์มต้นทางแบกรายการ /XFA — แพ็กเกต XFA เป็นโมเดลฟอร์มคู่ขนานที่ยุบลงเป็น field dictionary แบบ AcroForm ไม่ได้
  • มี widget อาศัยอยู่บนหน้าต้นทางที่ไม่มีรายการใน page mapping ซึ่งมิฉะนั้นฟิลด์จะหายไปเงียบ ๆ หรือติดไปผิดหน้า
  • page mapping หลุดช่วง หรือสอง mapping ใช้หน้าต้นทางหรือหน้าปลายทางเดียวกันซ้ำ
  • ทั้งสองฟอร์มนิยาม default resource dictionary /DR เพราะการรวม namespace ของ resource สองชุดเสี่ยงชี้ชื่อที่มีอยู่ไปติดฟอนต์ต่างตัว
  • กราฟออบเจกต์เกิน MaxObjects หรือ recursion เกิน MaxDepth
  • ปลายทางมีลายเซ็นอยู่ และ AllowSignedDestination เป็น False
  • graft map ที่ส่งมาเป็นของเอกสารต้นทางอื่น หรือมี reference ต้นทางที่ dangle

เส้นทางเขียนอนุรักษ์นิยมไม่แพ้กัน PDFiumPas ปล่อยผลลัพธ์เป็น revision แบบ sparse เพิ่มท้ายไปบนปลายทาง แล้วจับรูปธรรมผลลัพธ์ที่เขียนขึ้นมาและอ่านฟอร์มของมันซ้ำ: ถ้าจำนวนฟิลด์ของผลลัพธ์ไม่เท่ากับจำนวนฟิลด์เดิมของปลายทางบวกของต้นทาง การปลูกถ่ายทั้งชุดถูกปฏิเสธและผลลัพธ์ถูกล้าง คุณไม่มีวันได้ไฟล์ที่ถูกปลูกถ่ายไปครึ่ง ๆ กลาง ๆ ต้นทุนของนโยบายนี้เป็นของจริง — การชนกันของ /DR หรือปลายทางที่มีลายเซ็นหยุดคุณอย่างเด็ดขาด และคุณต้องไปกระทบมันเอง แทนที่จะยอมรับการรวมแบบประมาณ — แต่ทางเลือกอีกฝั่งคือฟอร์มที่เปิดได้สบายแต่คำนวณผิด

PDFiumPas GraftPdfAcroForm ล้มแบบ fail closed ใน Delphi อย่างไร: revision ที่เขียนถูกอ่านซ้ำและตรวจจำนวนฟิลด์ เงื่อนไขกำกวมใด ๆ เช่น XFA หรือหน้าที่ไม่ถูกแมปปฏิเสธการเรียก และการปฏิเสธทิ้งเฉพาะรายการ map ที่การเรียกนั้นเพิ่ม
เส้นทางเขียนที่ตรวจแล้วกับ map แบบธุรกรรมคือเหตุผลที่การปลูกถ่ายที่ถูกปฏิเสธไม่มีวันทิ้งไฟล์ที่ถูกรวมไปครึ่ง ๆ ไว้เบื้องหลัง

เมื่อการปลูกถ่ายเป็นเครื่องมือที่ผิด

การปลูกถ่ายย้ายโครงสร้าง จึงใช้เมื่อโครงสร้างคือสิ่งที่คุณขาด ถ้าเอกสารทั้งสองมีชุดฟิลด์เดียวกันอยู่แล้วและคุณแค่ต้องย้ายค่ากับ annotation ระหว่างกัน เส้นทางส่งออกและนำเข้าใน บทความข้อมูลฟอร์ม XFDF เบากว่า เป็นมาตรฐานกว่า และย้อนกลับได้ เอื้อมไปหยิบ GraftPdfAcroForm เมื่อปลายทางไม่มีฟิลด์เลย หรือมีชุดต่างกัน และคุณต้องการ widget, appearance stream, action และลำดับการคำนวณวิ่งข้ามไปแบบครบถ้วน หมายเหตุเชิงปฏิบัติสุดท้ายเรื่องตัวตน: เพราะ graft map กุญแจด้วยหมายเลขออบเจกต์บวก generation และผูกกับ SHA-256 ของไบต์ต้นทาง การบันทึกซ้ำหรือปรับปรุงต้นทางระหว่างรอบผลิตตัวตนที่ต่างออกไปและ map ที่ไม่ใช้ได้อีก สแนปช็อตต้นทางที่คุณปลูกถ่ายจากแล้วรักษามันให้นิ่งตลอดชุดงาน ถือมันเป็นอินพุต artifact ไม่ใช่สิ่งที่งานกลางคืนมีสิทธิ์เขียนทับได้ตามใจ

GraftPdfAcroForm, TPdfCrossDocumentGraftMap และชุดเครื่องมือ PDF ระดับสตรีมโดยรอบ มาพร้อมกับ PDFiumPas Delphi PDFium Component สำหรับ Delphi, C++Builder และ Lazarus หน้าผลิตภัณฑ์เก็บเอกสาร API ครบของออปชันการปลูกถ่าย ฟิลด์รายงาน และพื้นที่แก้ไขเอกสารส่วนที่เหลือ