คุณรับโฟลเดอร์ PDF ต่อมาจากต้นทางอื่น และงานฟังดูเหมือนเรื่องง่าย บอกมาว่าบุ๊กมาร์กไหนพาไปยัง URL ภายนอก บุ๊กมาร์กไหนรัน JavaScript และรายการภายในแต่ละรายการไปลงที่ไหนจริง ๆ พอเปิดเอกสารอ้างอิง API ก็พบว่าไลบรารีสร้างแอ็กชันเหล่านั้นได้ครบทุกแบบ แต่ไม่มีทางอ่านกลับออกมา ความไม่สมมาตรแบบนี้เจอได้ทั่วไปในเครื่องมือ PDF การเขียนบุ๊กมาร์กที่เปิด https://example.com เป็นแค่บรรทัดเดียว แต่การถามบุ๊กมาร์กที่มีอยู่แล้วว่า "คุณทำอะไร และชี้ไปที่เป้าหมายไหน" มักหมายถึงต้องไล่โครงสร้างอ็อบเจ็กต์ดิบด้วยมือผ่าน /A, /S, /Dest และชุดตัวแปรชนิด fit ที่แทบไม่มีใครทำถูกตั้งแต่ครั้งแรก
PDFlibPas เป็นไลบรารี PDF แบบเนทีฟที่เขียนด้วย Object Pascal สำหรับ Delphi และ C++Builder และเป็นเวลานานมันก็มีช่องว่างแบบเดียวกัน นั่นคือฝั่งเขียนมี setter ครบ แต่ตัวอ่านส่ง TPDFObject เปล่า ๆ กลับมาแล้วให้คุณไปไล่แกะเอาเอง รุ่น v3.77.0 ปิดช่องว่างนี้ไปบางส่วนด้วยชุดฟังก์ชันตรวจสอบแบบมีชนิดข้อมูลที่รายงานชนิดของแอ็กชัน เพย์โหลดของแอ็กชัน และเรขาคณิตของปลายทางออกมาเป็น record ตรง ๆ บทความนี้จะอธิบายว่าฟังก์ชันเหล่านั้นแมปเข้ากับโมเดล action และ destination ของ ISO 32000-1 อย่างไร และกับดักจริงสามข้อที่ทำให้โค้ดเขียนเองเงียบ ๆ แล้วพัง
ทำไมการอ่านแอ็กชันถึงยากกว่าการเขียน
แอ็กชันใน PDF คือพจนานุกรมที่มีคีย์ /S ระบุชนิดย่อย ได้แก่ GoTo, GoToR, URI, Launch, Named, JavaScript และกลุ่มท้าย ๆ อีกยาวที่แทบไม่ค่อยเจอ (ISO 32000-1 §12.6.4) ปัญหาคือเพย์โหลดของแต่ละชนิดย่อยไปอยู่คนละคีย์ และไม่มีช่องกลางแบบสากลที่บอกว่า "ขอเป้าหมายให้หน่อย" URI action เก็บที่อยู่ไว้ใน /URI GoToR หรือ Launch action เก็บข้อมูลระบุไฟล์ไว้ใน /F JavaScript action เก็บสคริปต์ไว้ใน /JS ซึ่งอาจเป็นได้ทั้งสตริงและสตรีม ส่วน GoTo action ไม่มีเพย์โหลดของตัวเองเลย เป้าหมายของมันคือปลายทางที่แขวนอยู่ใน /D แล้วต้องไปคลี่ค่าแยกต่างหาก
ตอนเขียนแอ็กชัน คุณรู้ชนิดของมันอยู่แล้วตั้งแต่ต้น ดังนั้นเรื่องพวกนี้ไม่สำคัญ แต่ตอนอ่านกลับ คุณต้องแยกทางตาม /S ก่อน แล้วค่อยไปดึงคีย์ที่ถูกต้อง จากนั้นยังต้องรับมือกับข้อเท็จจริงที่ว่าแนวคิดเดียวกันทางตรรกะ "สิ่งที่แอ็กชันนี้ชี้ไป" ถูกเข้ารหัสไว้สามแบบที่เข้ากันไม่ได้เลย การแยกสายนี่เองคือสิ่งที่ตัวอ่านแบบมีชนิดข้อมูลช่วยดูดซับไว้ GetOutlineActionInfo และ GetAnnotActionInfo ต่างก็คืนค่าเป็น record TPDFlibActionInfo:
type
TPDFlibActionKind = (akNone, akGoTo, akGoToR, akURI,
akLaunch, akNamed, akJavaScript);
TPDFlibActionInfo = record
Kind: TPDFlibActionKind;
URI: AnsiString; // populated for akURI
JavaScript: WideString; // populated for akJavaScript
FileName: AnsiString; // populated for akGoToR / akLaunch
OpenInNewWindow: Boolean; // akGoToR / akLaunch
end;
record นี้บอกว่าฟิลด์ไหนมีความหมายผ่าน Kind ถ้า Kind กลับมาเป็น akURI ก็ให้อ่าน URI แล้วไม่ต้องสนใจส่วนที่เหลือ ถ้ากลับมาเป็น akGoTo ฟิลด์เพย์โหลดทั้งหมดไม่เกี่ยวข้อง และคุณจะไปต่อที่ปลายทางซึ่งเป็นการเรียกแยกอีกชุดด้านล่าง akNone คือคำตอบตรงไปตรงมาเมื่อบุ๊กมาร์กหรือคำอธิบายประกอบไม่มีแอ็กชันเลย แทนที่จะเป็นศูนย์ที่ต้องเดาเอาเองว่าหมายถึงอะไร
ไล่โครงสร้างลำดับชั้นเพื่อหาบุ๊กมาร์ก
ก่อนจะตรวจสอบบุ๊กมาร์กได้ คุณต้องมีตัวอ้างอิงของมันก่อน PDFlibPas ระบุโหนดของ outline ด้วย ID แบบจำนวนเต็ม และ FindOutlineByTitle จะค้นหาตามข้อความที่มองเห็นได้ พร้อมควบคุมชัดเจนว่าการค้นหาจะลึกแค่ไหน:
type
TPDFlibOutlineSearchDepth =
(osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);
function FindOutlineByTitle(const Title: WideString;
StartOutlineID: Integer;
Depth: TPDFlibOutlineSearchDepth): Integer;
สิ่งที่ควรหยุดคิดคืออาร์กิวเมนต์ Depth osdSiblingsOnly จะสแกนเฉพาะสายพี่น้องในระดับของโหนดเริ่มต้นแล้วหยุด มันจะเจอบุ๊กมาร์กที่อยู่ระดับเดียวกัน แต่จะไม่มีวันลงไปยังลูกของโหนดพี่น้อง osdChildrenOnly จะมองลงไปหนึ่งระดับในลูกโดยตรงของโหนดเริ่มต้น osdFullSubTree จะไล่ลงทั้งกิ่ง การเลือกผิดไม่ใช่ error แต่เป็นการพลาดแบบเงียบ ๆ เช่นค้นหาแบบ sibling-only สำหรับชื่อที่อยู่ลึกลงไปสองระดับ ก็จะได้ศูนย์กลับมา แล้วคุณจะสรุปว่าบุ๊กมาร์กไม่มีอยู่ ทั้งที่มันอยู่ตรงนั้นมาตลอด ใช้ GetFirstOutline เป็น start ID เพื่อค้นหาจากรากของเอกสาร
var
Lib: TPDFlib;
FoundID: Integer;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile('report.pdf', '') = 1 then
begin
// Search the whole tree from the root for a nested bookmark.
FoundID := Lib.FindOutlineByTitle('Appendix B',
Lib.GetFirstOutline, osdFullSubTree);
if FoundID <> 0 then
// FoundID is now a handle you can pass to the action and
// destination getters below.
;
end;
finally
Lib.Free;
end;
end;
การจับคู่ใช้สตริงชื่อเรื่องแบบตรงตัว เปรียบเทียบเป็น WideString ดังนั้นจึงไวต่อการใช้ตัวพิมพ์เล็กใหญ่ และเคารพข้อความ Unicode ตามที่เก็บไว้ทุกตัวอักษร ถ้า PDF ต้นทางของคุณมาจากผู้สร้างหลายแบบที่ไม่สอดคล้องกัน ให้ normalize ชื่อเรื่องที่ค้นหาให้ตรงกับรูปแบบที่เอกสารเก็บไว้ ไม่อย่างนั้นคุณจะไล่ตามการพลาดที่ไม่มีอยู่จริง
การคลี่แอ็กชันและเป้าหมายของบุ๊กมาร์ก
เมื่อมีตัวอ้างอิงอยู่ในมือ GetOutlineActionInfo จะให้มุมมองแบบมีชนิดข้อมูล รูปแบบคือเรียกมันก่อน แล้วสลับตาม Kind จากนั้นอ่านฟิลด์ที่ชนิดนั้นเติมค่าไว้
var
Info: TPDFlibActionInfo;
begin
Info := Lib.GetOutlineActionInfo(FoundID);
case Info.Kind of
akURI:
Writeln('Opens URL: ', Info.URI);
akGoToR, akLaunch:
Writeln('Opens file: ', Info.FileName,
' (new window: ', Info.OpenInNewWindow, ')');
akJavaScript:
Writeln('Runs script: ', string(Info.JavaScript));
akGoTo:
Writeln('Jumps within this document'); // see destination below
akNamed:
Writeln('Named action (NextPage, Print, etc.)');
akNone:
Writeln('Bookmark has no action');
end;
end;
นี่คือกับดักจริงข้อแรก และเป็นข้อที่ข้อเสนอแนะจากการทดสอบช่วยจับได้ระหว่างพัฒนา มีตัวอ่านรุ่นเก่าอย่าง GetActionURL และการยื่นมือไปใช้มันเพื่ออ่าน URI action คือความผิดที่ดูเหมือนน่าจะถูก GetActionURL แก้ไขข้อมูลระบุไฟล์ผ่านคีย์ /F ซึ่งเป็นสิ่งที่ถูกต้องสำหรับ GoToR และ Launch เพราะเป้าหมายของมันคือไฟล์จริง ๆ แต่กลับเป็นคีย์ที่ผิดทั้งหมดสำหรับ URI action ที่อยู่ของ URI action คือสตริงธรรมดาในคีย์ /URI ของแอ็กชันนั้นเอง ไม่ใช่ข้อมูลระบุไฟล์ ถ้าส่ง URI action เข้าเส้นทางข้อมูลระบุไฟล์ก็จะได้ผลลัพธ์ว่างหรือไร้ความหมาย ตัวอ่านแบบมีชนิดข้อมูลจะจัดการเรื่องนี้ภายใน โดยอ่าน /URI โดยตรงสำหรับ akURI และเรียกตัวแปลงข้อมูลระบุไฟล์เฉพาะสำหรับ akGoToR และ akLaunch เท่านั้น ซึ่งก็คือความต่างที่โค้ดเขียนเองมักจะเผลอทำให้ปนกัน
ชนิด Fit ของปลายทางและเรขาคณิตที่อยู่เบื้องหลัง
akGoTo action หมายถึง "นำทางภายในเอกสารนี้" แต่ไม่ได้บอกอะไรเลยว่าอยู่ ตรงไหน หรือ อย่างไร นั่นคือหน้าที่ของปลายทาง และปลายทางก็มีรายละเอียดมากกว่าที่หลายคนคิด ปลายทางของ PDF ไม่ใช่แค่หมายเลขหน้า แต่มันคือหน้าหนึ่งหน้าพร้อมข้อกำหนด "fit" ที่บอกว่าตัวอ่านควรจัดกรอบหน้านั้นอย่างไร (ISO 32000-1 §12.3.2.2) GetOutlineDestinationInfo จะคืนค่ามันกลับมาเป็น record:
type
TPDFlibDestinationKind = (dkNone, dkXYZ, dkFit, dkFitH,
dkFitV, dkFitR, dkFitB, dkFitBH, dkFitBV);
TPDFlibDestinationInfo = record
Kind: TPDFlibDestinationKind;
Page: Integer; // 1-based; 0 when unresolved
Left, Top, Right, Bottom, Zoom: Double;
end;
ชนิด fit ทั้งแปดแบบตอบคำถามเรื่องการจัดกรอบคนละมุม dkXYZ จะวางจุดเฉพาะไว้ที่มุมซ้ายบนด้วยการซูมที่กำหนด จึงใช้ Left, Top และ Zoom dkFit จะพอดีทั้งหน้าในหน้าต่างและไม่สนใจพิกัด dkFitH และ dkFitV จะพอดีกับความกว้างหรือความสูงของหน้าโดยใช้พิกัดที่เกี่ยวข้องเพียงตัวเดียว ไม่ว่าจะเป็นขอบบนหรือขอบซ้าย dkFitR คือชนิดที่น่าสนใจ เพราะมันพอดีกับสี่เหลี่ยมที่ระบุไว้ ดังนั้นขอบทั้งสี่จึงมีความหมาย ตระกูล dkFitB* ก็ทำสิ่งเดียวกันแต่ยึดตามกรอบ bounding box ของเนื้อหาที่มองเห็น แทนที่จะใช้ทั้งหน้า การรู้ว่าฟิลด์ไหนมีชีวิตสำหรับแต่ละชนิดคือความต่างระหว่างการอ่านปลายทางให้ถูกกับการพ่นพิกัดขยะที่บังเอิญเป็นศูนย์

เบื้องหลัง การทำงานนี้อาศัยการจัดแนวที่ตั้งใจไว้ ซึ่งควรรู้เพราะมันอธิบายได้ว่าทำไมการแมปถึงเชื่อถือได้ GetDestType ภายในจะคืนค่าเป็นจำนวนเต็ม 1..8 สำหรับ fit ทั้งแปดแบบตามลำดับ XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV พอดี TPDFlibDestinationKind ถูกประกาศให้ค่า ordinal สอดคล้องแบบหนึ่งต่อหนึ่งเช่นกัน คือ dkXYZ มี ordinal เป็น 1 และ dkFitBV มี ordinal เป็น 8 ส่วน dkNone อยู่ที่ศูนย์ ดังนั้นการแปลงจึงเป็นการ cast ordinal ตรง ๆ พร้อม guard ตรวจช่วง ไม่ใช่ตาราง lookup ที่อาจเคลื่อนไปไม่ทันเมื่อ enum โตขึ้น นี่เป็นรายละเอียดเล็ก ๆ แต่ก็เป็นชนิดของเรื่องที่ ถ้าทำแบบเผลอ ๆ จะกลายเป็นบั๊ก off-by-one ทันทีที่มีใครจัดลำดับ enumeration ใหม่
var
Dest: TPDFlibDestinationInfo;
begin
Dest := Lib.GetOutlineDestinationInfo(FoundID);
if Dest.Page = 0 then
Exit; // destination did not resolve
case Dest.Kind of
dkXYZ:
Writeln(Format('Page %d at (%.0f, %.0f), zoom %.2f',
[Dest.Page, Dest.Left, Dest.Top, Dest.Zoom]));
dkFitR:
Writeln(Format('Page %d, rect L%.0f T%.0f R%.0f B%.0f',
[Dest.Page, Dest.Left, Dest.Top, Dest.Right, Dest.Bottom]));
dkFit, dkFitB:
Writeln(Format('Page %d, fit whole page', [Dest.Page]));
else
Writeln(Format('Page %d, fit kind %d',
[Dest.Page, Ord(Dest.Kind)]));
end;
end;
ค่า Page ที่เป็นศูนย์คือสัญญาณว่าปลายทางยังคลี่ค่าไม่ได้ มักเพราะแอ็กชันไม่มีปลายทางหรือหา named destination ไม่เจอ ตรวจตรงนี้ก่อนจะเชื่อพิกัดใด ๆ นอกจากนี้ GetOutlineDestinationInfo ยังมองทั้งสองตำแหน่งที่ปลายทางอาจอยู่ได้ คืออยู่ตรง /Dest ของบุ๊กมาร์กโดยตรง และอยู่ใน /D ของ action GoTo ที่ฝังอยู่ คุณไม่จำเป็นต้องรู้ว่าผู้สร้างใช้รูปแบบไหน
แอ็กชันของคำอธิบายประกอบและกับดัก SelectPage
คำอธิบายประกอบแบบลิงก์ถือแอ็กชันไว้เหมือนกับบุ๊กมาร์ก และ GetAnnotActionInfo ก็คืนค่า record TPDFlibActionInfo เดียวกันพร้อมรูปแบบชนิดแล้วเพย์โหลดเหมือนกัน แต่ตรงนี้มีกับดักที่ผูกกับสถานะซึ่งไม่ได้เกิดกับ outline และนี่คือกับดักข้อที่สาม
คำอธิบายประกอบเป็นของแต่ละหน้า และ PDFlibPas เปิดเผยคำอธิบายประกอบของหน้าปัจจุบันผ่านสถานะที่เพิ่งใช้งานได้หลังจากคุณเลือกหน้านั้นแล้วเท่านั้น ถ้าเรียก GetAnnotActionInfo โดยไม่เรียก SelectPage(N) ก่อน ตัวอ้างอิงของคำอธิบายประกอบจะเป็นศูนย์ การเรียกก็จะคืน akNone แล้วคุณจะสรุปผิดว่าหน้านั้นไม่มีคำอธิบายประกอบที่มีแอ็กชัน วิธีแก้มีแค่บรรทัดเดียว แต่ลืมง่ายมากเวลาคุณกำลังวนลูปทุกหน้า:
var
P: Integer;
Info: TPDFlibActionInfo;
begin
for P := 1 to Lib.PageCount do
begin
Lib.SelectPage(P); // mandatory before touching annotations
// GetAnnotActionID(1) <> 0 is the reliable "has an action"
// test. CheckPageAnnots returns a boolean-style flag, not a
// count, so it is the weaker signal here.
if Lib.GetAnnotActionID(1) <> 0 then
begin
Info := Lib.GetAnnotActionInfo(1);
if Info.Kind = akURI then
Writeln(Format('Page %d link -> %s', [P, Info.URI]));
end;
end;
end;
สองสิ่งในลูปนี้ทำไว้ตั้งใจ ข้อแรก SelectPage(P) ต้องมาก่อนการแตะคำอธิบายประกอบทุกครั้งในแต่ละรอบ เพราะสถานะคำอธิบายประกอบแยกตามหน้าไม่ได้ติดข้ามไป ข้อสอง การตรวจว่ามีอยู่หรือไม่ใช้ GetAnnotActionID(1) <> 0 แทน CheckPageAnnots ตัวหลังรายงานการมีอยู่เป็นสถานะลักษณะบูลีน ไม่ใช่จำนวน ดังนั้น action ID ที่ไม่เป็นศูนย์จึงเป็นวิธีถามที่แม่นกว่า ว่ามีคำอธิบายประกอบตัวแรกอยู่หรือไม่ และมันมีแอ็กชันให้อ่านได้หรือเปล่า รายละเอียดเล็กอีกข้อที่ควรระบุไว้คือ สำหรับคำอธิบายประกอบ สคริปต์ของ JavaScript action จะอ่านจาก /JS โดยตรง ถ้าสคริปต์ถูกเก็บเป็นสตรีมก็จะ decode สตรีมนั้น ถ้าเก็บเป็นสตริงก็อ่านสตริงตามปกติ จึงรองรับทั้งสองรูปแบบที่พบบ่อย
ตำแหน่งที่การตรวจสอบฝั่งอ่านเข้ามาใช้งาน
ตัวอ่านเหล่านี้ถูกตั้งใจให้แคบ พวกมันเป็นการอ่านล้วนที่วางอยู่บนชั้นแอ็กชันและปลายทางแบบตัวจัดการจำนวนเต็มที่มีอยู่แล้วของไลบรารี จึงไม่แตะเส้นทางฝั่งเขียน และไม่เพิ่มความเสี่ยงให้กับเอกสารที่คุณกำลังแก้ไขอยู่ด้วย มันรายงานสิ่งที่อยู่ในไฟล์ ไม่ได้ตรวจสอบตามนโยบายหรือเขียนอะไรทับ หากเป้าหมายของคุณคือฝั่งกลับกัน คือการสร้างบุ๊กมาร์กและคำอธิบายประกอบลิงก์ที่พกแอ็กชันเหล่านี้ไว้ตั้งแต่แรก งานนั้นอยู่ฝั่งเขียน และบทความประกอบเรื่อง แอ็กชันฟอร์มเชิงโต้ตอบและ JavaScript ใน Delphi จะพาไล่การสร้างมัน ส่วนถ้าต้องการดึงเนื้อหาที่มองเห็นและโครงสร้างออกจาก PDF แทนกราฟการนำทางของมัน ให้ดู การดึงข้อความ รูปภาพ และฟอนต์ด้วย PDFlibPas
ขอบเขตที่ตรงไปตรงมาที่ควรจำไว้คือ การตรวจสอบฝั่งอ่านเห็นได้แค่สิ่งที่ผู้สร้างเขียนลงมาจริง ๆ เท่านั้น บุ๊กมาร์กที่แอ็กชันถูกสร้างมาอย่างผิดรูปแบบ หรือปลายทางที่ชี้ไปยัง named target ซึ่งไม่เคยถูกกำหนดไว้ จะปรากฏเป็น akNone หรือหน้าเป็นศูนย์ แทนที่จะโยน exception นั่นคือพฤติกรรมที่ถูกต้องสำหรับ API ฝั่งอ่านที่ใช้ตรวจสอบไฟล์ที่ไม่น่าเชื่อถือ แต่ก็หมายความว่าโค้ดของคุณควรตีความผลลัพธ์ศูนย์เหล่านี้ว่า "ไม่มีอยู่หรือยังคลี่ค่าไม่ได้" ไม่ใช่หลักประกันว่าอินพุตถูกสร้างมาถูกต้อง การตรวจสอบแอ็กชันและปลายทางแบบมีชนิดข้อมูลที่แสดงที่นี่เป็นส่วนหนึ่งของ PDFlibPas ไลบรารี PDF แบบ native สำหรับ Delphi และ C++Builder