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

เพิ่มบุ๊กมาร์กใน PDF ที่มีอยู่แล้วด้วย HotPDF ใน Delphi

HotPDF เพิ่มบุ๊กมาร์กไปยังไฟล์ PDF ที่มีอยู่แล้วใน Delphi ผ่านฟังก์ชัน AddLoadedOutline ซึ่งช่วยสร้างรายการเค้าร่าง (outline entries) ในระดับการซ้อนใดๆ บนเอกสารที่โหลดขึ้นมา และยังอ่านกับเขียนข้อมูลปลายทางระบุชื่อ (named destinations) ผ่านทาง ResolveLoadedNamedDestination และ AddLoadedNamedDestination โหลดไฟล์, สร้างแผนผังโครงสร้างต้นไม้, เรียก SaveLoadedDocument จากนั้นแผงแถบด้านข้างของผู้อ่านที่เคยว่างเปล่าก็จะมีสารบัญที่พร้อมใช้งานทันที

สถานการณ์ที่ทำให้เกิดปัญหานี้เป็นเรื่องปกติที่น่าปวดหัวอย่างยิ่ง คุณรวมสัญญาเป็นสิบฉบับเข้าไว้ในแฟ้มตรวจทานฉบับเดียว หรือรวบรวมคู่มือผลิตภัณฑ์ 3 เล่มเป็นไฟล์ที่แจกจ่ายได้ไฟล์เดียว และผลลัพธ์ที่ได้ก็มีโครงสร้างที่ดี: มีครบทุกหน้า, ฟอนต์ครบถ้วนดี แต่เมื่อมีคนเปิดไฟล์ใน Acrobat แผงบุ๊กมาร์กกลับว่างเปล่า เอกสาร 400 หน้าที่ไม่มีระบบนำทางนั้นแม้จะสมบูรณ์ในทางเทคนิคแต่ใช้งานไม่ได้จริงในเชิงปฏิบัติ และจนกระทั่งเวอร์ชัน 2.347.0 HotPDF สามารถอ่าน, แก้ไข และลบบุ๊กมาร์กในเอกสารที่โหลดได้ แต่ไม่สามารถสร้างบุ๊กมาร์กใหม่ได้ ช่องว่างนั้นได้รับการแก้ไขแล้ว และบทความนี้จะพาไปดูส่วนติดต่อโปรแกรม (API surface) ใหม่ รวมถึงเรื่องลับๆ ของ PDF อย่างคีย์ /Count ที่จะทำให้คุณสับสนได้หากมองเป็นเพียงเครื่องมือนับรายการทั่วไป

แท้จริงแล้วแผนผังโครงสร้างเค้าร่างคืออะไร

เค้าร่าง (outline) ของ PDF คือทรีพจนานุกรมที่มีการลิงก์สองทิศทาง (doubly linked tree) ไม่ใช่รายการแบบแบนราบ และโครงสร้างดังกล่าวนี่เองที่อธิบายทุกพารามิเตอร์ใน API ข้อกำหนด ISO 32000-1 §12.3.3 นิยามไว้ว่า: แคตตาล็อกเอกสารจะชี้ไปยังรูท /Outlines, รูทจะชี้ไปยังลูกคนแรกและคนสุดท้ายผ่านทาง /First และ /Last และพี่น้องจะเชื่อมโยงกันผ่าน /Next และ /Prev ขณะที่แต่ละรายการจะชี้กลับขึ้นไปด้านบนผ่าน /Parent แต่ละรายการจะมีสตริงชื่อเรื่อง /Title และมักจะมีค่า /Dest ซึ่งระบุตำแหน่งที่การคลิกจะนำผู้อ่านไปยังจุดนั้น

ปลายทาง (Destinations) มี 2 รูปแบบ และข้อกำหนด ISO 32000-1 §12.3.2 ได้แยกพวกมันออกจากกันโดยเจตนา ปลายทางที่ระบุชัดเจน (explicit destination) คืออาร์เรย์แบบอินไลน์ เช่น [page /XYZ x y zoom] ซึ่งอ้างอิงโดยตรงไปยังวัตถุของหน้าและการระบุรูปแบบมุมมอง ปลายทางระบุชื่อ (named destination) จะจัดเก็บเป็นสตริงชื่อแทน และเป้าหมายที่แท้จริงจะอยู่ในแคตตาล็อกเอกสารภายใต้โครงสร้างทรีชื่อ /Names /Dests ซึ่งเป็นระดับการอ้างอิงทางอ้อม (indirection) ที่ช่วยให้ลิงก์จำนวนมากสามารถแชร์เป้าหมายร่วมกันได้ และช่วยให้เปลี่ยนเป้าหมายได้โดยไม่ต้องแตะต้องลิงก์เหล่านั้น HotPDF จะเขียนปลายทาง /XYZ ที่ระบุชัดเจนเมื่อสร้างบุ๊กมาร์ก และเตรียมฟังก์ชันแยกเฉพาะสำหรับการอ่านและการขยายโครงสร้างทรีชื่อ

จะเพิ่มบุ๊กมาร์กใน PDF ที่มีอยู่แล้วด้วย Delphi ได้อย่างไร?

เรียกใช้ AddLoadedOutline(ParentIndex, DestPageIndex, Title, X, Y, Zoom) หลังการเรียก LoadFromFile: โดยส่งค่า ParentIndex = -1 สำหรับรายการระดับบนสุด หรือดัชนีฐาน 0 ของรายการระดับบนสุดที่มีอยู่เพื่อซ้อนรายการย่อยไว้ใต้รายการนั้น ฟังก์ชันจะสร้างรูท /Outlines หากเอกสารไม่เคยมีมาก่อน, สร้างพจนานุกรมรายการ, เขียนอาร์เรย์ /Dest [page /XYZ x y zoom] ชี้ไปยังดัชนีหน้าปลายทาง DestPageIndex แบบอิง 0 และลิงก์รายการใหม่เข้ากับสายสัมพันธ์พี่น้อง ฟังก์ชันจะส่งกลับ 0 เมื่อสำเร็จ และส่งกลับ -1 เมื่อล้มเหลว เช่น เมื่อ DestPageIndex อยู่นอกช่วงข้อมูลที่ถูกต้อง

รายละเอียดการลิงก์จุดหนึ่งที่มีความสำคัญต่อการเรียงลำดับ: รายการใหม่จะถูกแทรกไว้ที่ ส่วนหัว ของรายการย่อยของโหนดแม่ ในฐานะโหนด /First ใหม่ ดังนั้นหากคุณเพิ่ม "Chapter 1" แล้วเพิ่ม "Chapter 2" เป็นรายการระดับบนสุด Chapter 2 จะแสดงขึ้นมาอยู่เหนือ Chapter 1 ในแถบข้าง และ Chapter 1 จะเลื่อนไปอยู่ที่ดัชนีระดับบนสุดที่ 1 รูปแบบการปฏิบัติงานจริงคือควรเพิ่มรายการในทิศทางย้อนกลับของลำดับการอ่าน หรือเพิ่มโหนดแม่แต่ละอันแล้วกรอกรายการลูกทันทีในขณะที่โหนดยังคงอยู่ที่ดัชนี 0

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('merged-manual.pdf', '') > 0 then
    begin
      // Add chapters in reverse reading order: each new
      // top-level entry becomes /First (index 0).
      Pdf.AddLoadedOutline(-1, 40, 'Chapter 2: Configuration', 0, 792, 0);
      Pdf.AddLoadedOutline(-1, 0, 'Chapter 1: Installation', 0, 792, 0);
      // Chapter 1 is now top-level index 0; nest sections
      // under it (children also prepend, so reverse order).
      Pdf.AddLoadedOutline(0, 12, 'License activation', 0, 792, 0);
      Pdf.AddLoadedOutline(0, 3, 'System requirements', 0, 792, 0);
      Pdf.SaveLoadedDocument('merged-manual-toc.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

พารามิเตอร์ X, Y และ Zoom จะจับคู่โดยตรงไปยังปลายทาง /XYZ และทั้งหมดมีค่าเริ่มต้นเป็น 0 PDF กำหนดพิกัดเริ่มต้นไว้ที่มุมล่างซ้าย ดังนั้น Y = 792 จะตรงกับขอบบนสุด of หน้าขนาด US Letter และตามข้อกำหนด ISO 32000-1 ค่าศูนย์ในช่อง /XYZ จะบอกให้โปรแกรมอ่านเก็บค่ามุมมองปัจจุบันไว้ ซึ่งเป็นสิ่งที่คุณต้องการสำหรับการซูมในเกือบทุกกรณี ทุกกระบวนการจะดำเนินการในโครงสร้างวัตถุในหน่วยความจำ โดยไม่มีการบันทึกลงดิสก์จนกว่าจะรันฟังก์ชัน SaveLoadedDocument ซึ่งเป็นรูปแบบแก้ไขแล้วเซฟแบบเดียวกับที่ใช้ใน API เขียนชื่อเรื่อง, ผู้เขียน และข้อมูลเมตา XMP ของ PDF ที่โหลดขึ้นมาใหม่

การกำหนดเป้าหมายบุ๊กมาร์กใหม่: การข้ามหน้าเทียบกับการทำงานของ URI (URI Actions)

บุ๊กมาร์กที่มีอยู่สามารถสลับปลายทางใหม่ได้โดยไม่ต้องสร้างใหม่ และ HotPDF ได้เตรียมฟังก์ชันการเรียก 2 แบบแยกจากกันสำหรับกรณีดังกล่าว เนื่องจากโครงสร้าง PDF เบื้องหลังมีความแตกต่างกันอย่างสิ้นเชิง SetLoadedOutlineDestination(Index, DestPageIndex, X, Y, Zoom) จะแทนที่ /Dest ของรายการระดับบนสุดที่ Index ด้วยอาร์เรย์ /XYZ ใหม่ ซึ่งเป็นเครื่องมือสำหรับแก้ไขปัญหาการรวมไฟล์ล้มเหลวแบบคลาสสิกที่บุ๊กมาร์กทุกอันยังคงชี้ไปที่เลขหน้าของไฟล์ต้นฉบับก่อนการรวม SetLoadedOutlineURI(Index, URI) จะทำงานในโครงสร้างที่ต่างออกไป: โดยจะแนบพจนานุกรมการทำงาน /A ร่วมกับ /S /URI เพื่อเปลี่ยนบุ๊กมาร์กให้เป็นลิงก์เว็บแทนการข้ามหน้าภายในเอกสาร

// The merge shifted everything by 10 pages: repoint the
// third top-level bookmark at page 12 (zero-based 11).
Pdf.SetLoadedOutlineDestination(2, 11, 0, 792, 0);

// Turn the last entry into an external link.
Pdf.SetLoadedOutlineURI(3, 'https://www.loslab.com/');

Pdf.SaveLoadedDocument('retargeted.pdf');

ควรทำความเข้าใจกลไกทั้งสองแบบให้ชัดเจนเมื่อคุณออกแบบแผนผังโครงสร้าง /Dest คือการนำทางภายในเอกสารอย่างแท้จริง ส่วนการทำงานของ URI จะเป็นการออกจากเอกสารอย่างสมบูรณ์ และข้อกำหนด ISO 32000-1 กำหนดให้รายการเค้าร่างนำทางผ่านกลไกใดกลไกหนึ่งเท่านั้น ไม่ใช้ทั้งสองอย่าง ดังนั้นให้สงวนฟังก์ชัน SetLoadedOutlineURI ไว้สำหรับรายการที่ไม่มีปลายทางหน้าเอกสารอยู่แล้ว เช่น โหนดปลายทาง "หน้าแรกของผลิตภัณฑ์" หรือ "รายงานปัญหา" ที่ตอนท้ายของทรี แทนที่จะนำไปใช้กับหัวข้อบทที่ต้องการแปลง การทำงานของ URI ที่เขียนที่นี่มีโครงสร้าง /S /URI แบบเดียวกับที่ใช้ในคำอธิบายประกอบลิงก์บนเนื้อหาหน้า ซึ่งครอบคลุมรายละเอียดไว้แล้วในคู่มือการสร้างไฮเปอร์ลิงก์ในเอกสาร PDF ด้วย HotPDF

ปลายทางระบุชื่อทำงานอย่างไรบน PDF ที่โหลดมา?

ปลายทางระบุชื่อ (Named destinations) คือกลไกยึดตำแหน่งที่มั่นคงตามข้อกำหนด ISO 32000-1 §12.3.2.3: แทนที่จะฝังการอ้างอิงหน้าไว้ในทุกลิงก์ เอกสารจะจัดเก็บแผนผังคู่ชื่อต่อปลายทางไว้ในโครงสร้างทรีชื่อ /Names /Dests ของแคตตาล็อก และลิงก์ต่างๆ จะอ้างอิงรายการด้วยชื่อ ResolveLoadedNamedDestination(Name) จะค้นหาชื่อในโครงสร้างทรีนั้นและส่งคืนค่าดัชนีหน้าแบบอิง 0 ที่มันชี้ไป หรือส่งคืน -1 หากไม่พบชื่อนั้น ตัวคลี่คลาย (resolver) จะจัดการรูปแบบการจัดเก็บทั้ง 2 แบบที่ข้อกำหนดอนุญาตสำหรับค่าแผนผัง: อาร์เรย์ปลายทางเปล่าอย่าง [page /XYZ x y zoom] และรูปแบบพจนานุกรมที่ห่อหุ้มอาร์เรย์ไว้ภายใต้คีย์ /D

AddLoadedNamedDestination(Name, DestPageIndex, X, Y) จะทำงานในทางตรงกันข้าม: โดยจะลงทะเบียนจุดยึด (anchor) ใหม่ในทรี /Names /Dests พร้อมสร้างโครงสร้างทรีขึ้นมาเมื่อเอกสารไม่มีอยู่ และส่งคืนค่า True เมื่อสำเร็จ การทำงานร่วมกันของทั้งคู่จะครอบคลุมกระบวนการทำงานที่เอกสารควบรวมต้องยอมรับจุดยึดที่เครื่องมือต้นทางเขียนไว้ และเผยแพร่จุดยึดใหม่ให้ลิงก์ปลายทางสามารถกำหนดเป้าหมายมาได้

var
  PageIdx: Integer;
begin
  // Honor an anchor another tool wrote into /Names /Dests.
  PageIdx := Pdf.ResolveLoadedNamedDestination('chapter.3.figures');
  if PageIdx >= 0 then
    Pdf.AddLoadedOutline(-1, PageIdx, 'Chapter 3: Figures', 0, 792, 0);

  // Publish a new anchor for other links to target.
  if Pdf.AddLoadedNamedDestination('appendix.glossary', 42, 0, 792) then
    Pdf.SaveLoadedDocument('anchored.pdf');
end;

ข้อจำกัดทางตรง: ปลายทางที่ HotPDF เขียนขึ้น ทั้งสำหรับบุ๊กมาร์กและจุดยึดระบุชื่อ จะเป็นปลายทางประเภท /XYZ ส่วนประเภทเฉพาะเจาะจงอื่นๆ ที่ข้อกำหนดกำหนดไว้ เช่น /Fit, /FitH, /FitB และประเภทอื่นๆ จะไม่ถูกนำออกมาจากการเรียกฟังก์ชันเหล่านี้ แม้ว่า /XYZ ที่มีค่าการซูมเป็นศูนย์จะครอบคลุมการใช้งานหลักของ "ไปที่จุดนี้และรักษาระดับการซูมของฉัน" ได้แล้วก็ตาม และฟังก์ชัน GetLoadedBookmarkPageIndex(Title) ซึ่งช่วยค้นหาที่อำนวยความสะดวกโดยระบุชื่อเรื่องบุ๊กมาร์กและรับค่าหน้าปลายทางกลับมา จะสแกนเฉพาะระดับบนสุดของทรีเค้าร่างเท่านั้น ดังนั้นให้ใช้เพื่อตรวจสอบรายการระดับบทที่คุณเพิ่งสร้างขึ้น แทนที่จะใช้กับโหนดย่อยที่ซ้อนอยู่ลึกๆ

กับดัก /Count: มันนับโหนดลูกหลาน ไม่ใช่นับรายการ

คีย์ /Count บนรูทของเค้าร่างไม่ได้หมายถึงสิ่งที่คุณคิด และสมมติฐานที่ผิดจะส่งผลให้การทดสอบล้มเหลวซึ่งดูเหมือนการทุจริตของข้อมูล ข้อกำหนด ISO 32000-1 §12.3.3 นิยาม /Count ว่าเป็นผลรวมของ โหนดลูกหลานที่มองเห็นได้ (visible descendants) ในทุกระดับชั้น ไม่ใช่จำนวนรายการระดับบนสุด ในแต่ละรายการตัวเครื่องหมายยังบอกความหมายด้วย: ค่าบวกหมายถึงรายการนั้นกำลังเปิดอยู่และจะแสดงจำนวนโหนดลูกในแถบข้าง ขณะที่ค่าลบหมายถึงรายการนั้นถูกย่อเก็บไว้ และมีโหนดลูก |N| รายการถูกซ่อนอยู่ภายใน

สิ่งนี้ถูกพบจากชุดทดสอบการถดถอย (regression suite) ของ HotPDF ในขณะที่สร้าง APIs บุ๊กมาร์กสำหรับเอกสารที่โหลดขึ้นมา เอกสารทดสอบมีบุ๊กมาร์กเดี่ยวระดับบนสุด 3 รายการ และมีรายการย่อย 1 รายการ; หลังจากลบรายการระดับบนสุดออก 1 รายการ ฟังก์ชัน GetLoadedOutlineCount ซึ่งอ่านค่า /Count จากรูท ได้รายงานค่าเป็น 1 แทนที่จะเป็น 2 ตามที่คาดหมาย ไม่มีข้อมูลใดเสียหาย: ค่า 3 เริ่มแรกไม่เคยเป็น "รายการระดับบนสุด 3 รายการ" มาก่อน และการคำนวณที่ถูกต้องจะต้องสำรวจสายสัมพันธ์ระดับบนสุดเพื่อสะสมค่าแต่ละโหนดบวกกับค่า /Count ที่เป็นบวก และข้ามโหนดลูกของโหนดที่ย่อเก็บไว้ บทเรียนสำหรับโค้ดของคุณคือ: อย่าเพิ่งยืนยัน (assert) ค่าการเปลี่ยนแปลง /Count หลังการแก้ไขโครงสร้าง เนื่องจากค่าจะเคลื่อนไหวในลักษณะที่ไม่เป็นเชิงเส้นตามโหนดลูกหลานที่มองเห็นได้ ให้ตรวจสอบโครงสร้างโดยดูที่เนื้อหาแทน

// After edits, verify by title lookup, not by /Count deltas.
if Pdf.GetLoadedBookmarkPageIndex('Chapter 1: Installation') = 0 then
  ShowMessage('Outline verified');

สิ่งนี้เหมาะสมกับเครื่องมือจัดชุดเอกสารที่โหลดมาตรงไหน

การสร้างบุ๊กมาร์กและปลายทางระบุชื่อจะช่วยเติมเต็มระบบการทำงานที่โหลดเอกสาร-แก้ไข-เซฟ ซึ่ง API ได้พยายามพัฒนามาโดยตลอด: ซึ่งวงจรรูปแบบนี้รองรับการเขียนข้อมูลเมตาของเอกสารใหม่, การเพิ่มคำอธิบายประกอบลิงก์ และการส่งผ่านคำอธิบายประกอบแบบไปกลับด้วยการนำเข้าและส่งออก XFDF รูปแบบของระบบจะมีความเป็นแบบเดียวกัน ได้แก่ LoadFromFile, ลำดับการเรียกใช้คำสั่ง *Loaded* เทียบกับกราฟวัตถุในหน่วยความจำ จากนั้นเรียก SaveLoadedDocument ซึ่งช่วยให้สามารถรวมขั้นตอนการสร้างเค้าร่างเข้ากับกระบวนการควบรวมหรือกระบวนการปั๊มตราเดิมที่มีอยู่แล้วได้อย่างสะดวกสบายเป็นอีกหนึ่งขั้นตอนก่อนการเซฟ

APIs ที่เรานำมาคุยกันที่นี่ ได้แก่ AddLoadedOutline, SetLoadedOutlineDestination, SetLoadedOutlineURI, ResolveLoadedNamedDestination, AddLoadedNamedDestination และ GetLoadedBookmarkPageIndex มีจัดส่งมาพร้อมกับ HotPDF Component มาตรฐานสำหรับ Delphi และ C++Builder โดยไม่มีความเกี่ยวข้องกับบริการภายนอกใดๆ และไม่ต้องรันโปรแกรมอ่านแยกต่างหาก