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

คัดลอกออบเจกต์ PDF ข้ามเอกสารใน Delphi: วงจรที่พัง

ลอง merge PDF สองไฟล์ด้วยมือ ย้ายออบเจกต์หน้าเดียวเข้าเอกสารปลายทาง การคัดลอกจะพุ่งเข้า access violation เสียทันที PDFlibPas แก้จุดนี้ใน CopyForeignObject: มัน deep copy indirect object หนึ่งตัวพร้อม closure ของการอ้างอิงทั้งหมด และเปลี่ยน back reference แบบวงวนอย่าง /Parent ให้เป็น null แทนการ recurse ต่อ

ทำไมการคัดลอกหนึ่งหน้าข้ามเอกสารจึง crash

เพราะ page tree ของ PDF จะเป็นต้นไม้ก็ต่อเมื่อคุณอ่านมันจากบนลงล่างเท่านั้น ลองเดินตามแบบที่ copier แบบ recursive เดิน คือไล่ทุกค่าในทุก dictionary หน้า page dictionary จะยื่น /Parent ให้ ซึ่งชี้กลับไปที่โหนด /Pages ที่คุณเดินมาจาก และโหนดนั้นก็ยื่น /Kids ให้ ซึ่งชี้กลับมาที่หน้า ISO 32000-1 §7.7.3 บังคับให้ /Parent ต้องมีในทุกโหนดของ page tree ยกเว้นราก ไฟล์แบบนี้จึงไม่ใช่ไฟล์เสียที่ปฏิเสธได้ มันคือรูปร่างปกติของเอกสารทุกฉบับที่จะตกไปอยู่ในมือคุณ

อีกครึ่งของปัญหาคือเรื่องเลขออบเจกต์ Indirect object ถูกระบุด้วย object number ที่มีความหมายเฉพาะในไฟล์เดียว (ISO 32000-1 §7.3.10) ออบเจกต์ที่ลากจากเอกสาร A เข้าเอกสาร B จึงต้องถูกเลขใหม่ และการอ้างอิงทุกจุดที่ชี้มันภายใน closure ที่คัดลอกมาก็ต้องเลขใหม่ด้วยวิธีเดียวกัน ไม่เช่นนั้นการอ้างอิงสองจุดที่เคยชี้ฟอนต์ shared ตัวเดียวกันจะกลายเป็นการชี้สองสิ่งที่ไม่เกี่ยวกัน การเลขใหม่แบบนี้คืองานเดียวกับที่ merge แบบเร็วทำในระดับไบต์ และควรอ่านสองเรื่องนี้คู่กัน: การเลื่อน reference ระดับไบต์เพื่อ merge PDF แบบเร็ว แก้ด้วยการแปลงทั้งไฟล์ ส่วนการคัดลอกระดับออบเจกต์ต้องแก้ทีละขอบ

ทำไมการคัดลอก PDF ข้ามเอกสารใน Delphi ต้องระวัง: page dictionary กับโหนด /Pages ของมันปิดวงวนผ่าน /Parent กับ /Kids closure ของฟอนต์วิ่งลงล่างและจบ และ PDFlibPas จับเลข object number ที่เป็น file-local ใหม่ทุกตัว
page tree ปิดลูปผ่าน /Parent กับ /Kids ในขณะที่ closure ของเนื้อหาวิ่งลงล่างแล้วจบ และ object number ที่ถูกคัดลอกทุกตัวต้องถูกจับแมปใหม่ระหว่างทาง

CopyForeignObject ของ PDFlibPas คัดลอกอะไรกันแน่

TPDFlib.CopyForeignObject(SourceDocumentID, ObjectNumber) โคลน indirect object หนึ่งตัวพร้อมทุกสิ่งที่ไปถึงได้จากมัน ทั้ง dictionary ซ้อนกัน, array, string, name, number และ stream ที่ dictionary ครบถ้วน เข้าสู่เอกสารที่ถูกเลือกอยู่ในปัจจุบัน แล้วคืน handle ที่ไม่ใช่ศูนย์ของ indirect reference ใหม่ object number ต้นทางถูกจับแมปใหม่ผ่าน map ที่มีชีวิตตลอดช่วงการเรียก ออบเจกต์ที่ถูกไปถึงสองครั้งใน closure จึงถูกโคลนครั้งเดียวและถูกใช้ร่วมสองทาง มันคืนศูนย์โดยไม่ raise เมื่อ document ID ต้นทางไม่รู้จัก เมื่อต้นทางคือเอกสารที่ถูกเลือกอยู่เอง หรือเมื่อ ObjectNumber ต่ำกว่า 1

var
  Lib: TPDFlib;
  SourceDoc, TargetDoc, Handle: Integer;
begin
  Lib := TPDFlib.Create;
  try
    TargetDoc := Lib.NewDocument;
    if Lib.LoadFromFile('source.pdf', '') <> 1 then
      Exit;                              // LoadFromFile คืน 1 เมื่อสำเร็จ
    SourceDoc := Lib.SelectedDocument;   // การโหลดเลือกสิ่งที่มันโหลดไว้
    Lib.SelectDocument(TargetDoc);       // การคัดลอกเขียนลงเอกสารที่ถูกเลือก
    Handle := Lib.CopyForeignObject(SourceDoc, 12);
    if Handle = 0 then
      raise Exception.Create('cross-document copy rejected');
  finally
    Lib.Free;
  end;
end;

รายละเอียดสองจุดนี้กัดคนที่รันครั้งแรก LoadFromFile ตอบ 1 หรือ 0 ไม่ใช่ document ID คุณจึงต้องเอา handle ที่ต้องการจาก SelectedDocument ทันทีหลังโหลด และการคัดลอกเขียนลงเอกสารที่ SelectDocument ตั้งเป็น current ครั้งล่าสุดเสมอ ไม่เคยเขียนลงเอกสารที่คุณโหลดมา ภายใน recursion ยังมีขีดความลึกแบบตายตัวที่ 64 ซึ่งเป็นเบาะรองรับกับ nesting ที่ผิดปกติ ไม่ใช่กลไกจัดการวงวน การจัดการวงวนเป็นเรื่องแยกและตั้งใจไว้ชัดเจน

ทำไมการจอง mapping ด้วย Nil จึงไม่ตัดวงวนได้

เพราะ Nil ในตาราง mapping หมายถึงสองสิ่งต่างกันพร้อมกัน และโค้ดแยกมันออกไม่ได้ การป้องกันที่เห็นชัดต่อวงวนคือเพิ่ม entry ใน map ก่อนจะ recurse เข้าออบเจกต์ เพื่อให้อะไรก็ตามที่วนกลับมาเจอ entry แล้วหยุด แต่ entry ยังเก็บ target จริงไม่ได้ target ยังไม่มีอยู่จนกว่า closure ข้างใต้จะถูกเขียนเสร็จ มันจึงเก็บ Nil เอาไว้ แล้ว lookup ที่ควรจับ back-edge กลับอ่านเจอ Nil และสรุปว่าออบเจกต์นี้ไม่เคยถูกแมปเลย

// เสีย: target Nil ที่ถูกจองไว้แยกไม่ออกจาก "ยังไม่ถูกแมป"
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // จองไว้ ยังเป็น Nil
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // เติมกลับเฉพาะตอนกำลังออกจาก recursion
end;

ไล่มันตามลูปหน้าดู โคลนของหน้าไปถึง /Parent ก็ recurse เข้าโหนด /Pages ซึ่งไปถึง /Kids แล้ว recurse กลับเข้าหน้าอีก ทั้งที่ entry ที่จองไว้ยังอ่านเป็น Nil มันจึงถูกโคลนเป็นครั้งที่สอง และครั้งที่สาม ทุกระดับดัน frame ใหม่กับออบเจกต์ครึ่ง ๆ กลาง ๆ ใหม่เข้าไป สิ่งที่คุณเห็นก็ไม่ใช่ stack overflow อันสวยงามอะไร frame ชั้นนอกทั้งหลายนั่งกอด reference ที่ target ไม่เคยถูก assign การเขียนผ่าน slot พวกนั้นครั้งแรกจึงเป็น access violation ในที่ที่หน้าตาไม่เหมือนการคัดลอกหน้าที่เป็นต้นเหตุเลย

ทำไมการจอง target Nil ใน map จึงไม่หยุดวงวนในการคัดลอกข้ามเอกสารของ PDFlibPas: lookup แยก entry ที่ถูกจองออกจากที่ยังไม่ถูกแมปไม่ได้ copier จึงลงลึกผ่าน frame ครึ่ง ๆ กลาง ๆ ที่ลึกขึ้นเรื่อย ๆ จนการเขียน crash
เพราะ target Nil ตอบสองคำถามต่างกันพร้อมกัน back-edge จึงไม่เคยถูกรู้จัก และหน้าถูกโคลนซ้ำในทุกรอบ

ทางแก้: สถานะ in-progress ที่ประกาศชัด

การซ่อมคือเลิกให้ Nil แบกความหมายซ้ำซ้อน แล้วถามคำถามตรง ๆ entry ใน map ที่ target ยังไม่ถูก assign หมายความว่าออบเจกต์นี้กำลังถูกโคลนอยู่ predicate InProgress ทดสอบสิ่งนั้นเป๊ะ ๆ ก่อน lookup ปกติจะทำงาน เมื่อเป็นจริง ขอบนั้นคือวงวนที่วนกลับเข้าบรรพบุรุษของโคลนปัจจุบัน และ PDFlibPas จะปล่อย null object ให้แทนที่จะไล่ตาม

// entry ใน map ที่ target เป็น Nil หมายถึงโคลนที่กำลังทำอยู่
function InProgress(Num: Integer): Boolean;
var
  I: Integer;
begin
  Result := False;
  for I := 0 to High(Map) do
    if (Map[I].SourceObjNum = Num) and (not Assigned(Map[I].Target)) then
      Exit(True);
end;

// ... ภายใน CloneObject สำหรับ indirect reference:
if InProgress(SrcRef.ObjNum) then
  Exit(FStructure.NewNull);              // back-edge แบบวงวน ห้าม recurse ต่อ
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
  SrcInd := SourceDoc.FindObj(SrcRef.ObjNum, SrcRef.GenNum);
  if (not Assigned(SrcInd)) or (not Assigned(SrcInd.Obj)) then
    Exit(FStructure.NewNull);            // reference ต้นทางที่ขาดขาด
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // จองก่อน แล้วค่อย recurse
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // เติมกลับ
end;
Exit(NewRef);

การขยายกฎนี้ให้ทั่วไปได้อย่างปลอดภัย เพราะข้อเท็จจริงเชิงโครงสร้างหนึ่งของ PDF: วงวนใน object graph ปรากฏบน back-link ไม่ใช่บนขอบเนื้อหา /Parent ใน page tree และ /Prev ในลูกโซ่ outline ชี้ขึ้นบนหรือย้อนหลังไปที่สิ่งที่ไปถึงแล้ว closure ของฟอนต์, image XObject หรือ form XObject วิ่งลงล่างและจบสวย การคัดลอก font descriptor, colour space หรือ shading dictionary จึงไม่กระทบจากการแทนด้วย null ไม่มีอะไรใน closure เหล่านั้นแตะ InProgress เลย ราคาที่จ่ายพูดตรง ๆ คือขอบแบบวงวนไม่รอดจากการคัดลอก page dictionary ที่ถูกโคลนแบบนี้จะมาถึงพร้อม /Parent เป็น null object ซึ่ง ISO 32000-1 §7.3.9 ถือว่าเทียบเท่า entry ที่ขาดหายไป หน้าที่ถูกคัดลอกจึงเป็นออบเจกต์ที่ถูกต้องแต่ไม่สังกัด page tree ใด จนกว่าคุณจะเชื่อมมันเข้าโหนด /Pages ปลายทางและแก้ /Count ด้วยมือเอง ของ outline ที่ถูกคัดลอกก็เสีย /Prev แบบเดียวกันและต้องสร้างลูกโซ่พี่น้องใหม่ นั่นคือการแลกที่ตรงไปตรงมา: CopyForeignObject มอบ closure ที่ถูกต้องและทิ้งงานเชื่อมโครงสร้างใหม่ให้ผู้เรียก ซึ่งเป็นเส้นแบ่งเดียวกับที่ การแทนหน้าโดยคง object number เดิมไว้ ทำงานอยู่

ทางแก้ใน CopyForeignObject ของ PDFlibPas สำหรับ Delphi: การทดสอบ InProgress แบบชัดเจนทำงานก่อน lookup ของ map ขอบวงวนกลายเป็น null object และผู้เรียกเชื่อมหน้าที่คัดลอกกลับเข้า page tree ปลายทางภายหลัง
สถานะ in-progress แบบชัดเจนเข้ามาแทน Nil ที่แบกหลายความหมาย back-edge จึง resolve เป็น null และผู้เรียกเหลืองานซ่อมโครงสร้างแค่ชิ้นเดียว

ทำไม entry ใน map ต้องถูกจองก่อน NewObjRef

ทางเลือกที่เห็นได้ชัดจะเลี่ยงทั้งพิธี in-progress ไปเลย: จอง shell object เปล่าก่อน ลงทะเบียนเลขจริงใน map แล้วค่อยเติมเนื้อเมื่อลูกถูกโคลนครบ ทว่าวิธีนี้ใช้ที่นี่ไม่ได้ เพราะ TPDFIndObj.Obj เป็น read-only และเนื้อหาถูกเปลี่ยนหลังสร้างไม่ได้ ไม่มี shell ให้เติมเลย เลขกับเนื้อหาถูกตัดสินพร้อมกันโดย NewObjRef entry ใน map จึงต้องถูกสร้างก่อนการเรียก recursive และเสร็จหลังจากนั้น ช่วงระหว่างสองช่วงเวลานั้นคือสิ่งที่ InProgress ต้องครอบคลุมพอดี ผลพวงหนึ่งที่ควรรู้ก่อนจะ diff เอาต์พุต: เพราะ NewObjRef ทำงานหลัง closure ของลูกถูกเขียน เลขในเป้าหมายจึงออกมาจากล่างขึ้นบน และ object number จะไม่สะท้อนลำดับต้นทาง ตัวฟอร์แมตไฟล์ไม่แคร์ แต่การเทียบไบต์กับคาดหมายที่สร้างด้วยมือจะแคร์ ถ้าการรันหนึ่งครั้งทิ้งออบเจกต์ที่คุณตัดสินใจไม่เชื่อมเข้ากับอะไรเอาไว้ พวกมันคือสิ่งที่ไม่ถูกอ้างอิง ไม่ใช่ข้อมูลเสีย และ การเก็บกวาดแบบ mark-and-sweep ของ PDF object ที่ไม่ถูกอ้างอิง คือเครื่องมือที่จัดการพวกมันก่อนบันทึก

regression ที่ครอบคลุมเคสนี้ต้องใช้รายละเอียดหนึ่งที่ทำให้คนเขียนเทสต์กับ TPDFlib แปลกใจ: constructor เก็บเอกสาร default เอาไว้แล้ว DocumentCount จึงเริ่มที่ 1 และ fixture สองเอกสารต้อง assert >= 2 ไม่ใช่ = 2 เทียบกับการคัดลอกที่สำเร็จ เทสต์ยังหมุดการปฏิเสธสามเคส ทั้ง source ID ที่ไม่รู้จัก, เอกสารที่ถูกเลือกเป็นแหล่งของตัวเอง และ object number เป็นศูนย์ ทุกเคสคืน 0 แทนการ raise เพราะลูป merge เป็นที่แย่มากสำหรับการไปค้นพบว่า guard clause โยน exception

ตำแหน่งของมันในไปป์ไลน์ merge

การคัดลอกระดับออบเจกต์คือ primitive ที่คุณเอื้อมหาเมื่อการ merge ทั้งไฟล์หยาบเกินไป: งัด font programme หนึ่งตัวออกจาก template ดึง form XObject เดี่ยวเข้าเอกสารสำหรับประทับตรา หรือย้าย annotation พร้อม appearance stream ข้ามไฟล์โดยไม่ลากส่วนที่เหลือของหน้าตามไปด้วย PDFlibPas เปิดให้ใช้เป็นการเรียกครั้งเดียวกับเอกสารที่โหลดไว้ และคุณดูได้ว่ามันวางตัวกับ API ระดับต่ำอื่นอย่างไรใน เอกสารอ้างอิง PDFlibPas Delphi PDF Library