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

อ่านแอ็กชันบุ๊กมาร์กและคำอธิบายประกอบ PDF ใน Delphi

คุณรับโฟลเดอร์ 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 ของเนื้อหาที่มองเห็น แทนที่จะใช้ทั้งหน้า การรู้ว่าฟิลด์ไหนมีชีวิตสำหรับแต่ละชนิดคือความต่างระหว่างการอ่านปลายทางให้ถูกกับการพ่นพิกัดขยะที่บังเอิญเป็นศูนย์

PDF reader bookmark navigation panel showing a nested outline tree
บุ๊กมาร์กแต่ละรายการในแผงนำทางนี้จะคลี่ออกเป็นแอ็กชัน และสำหรับการกระโดดภายในเอกสารก็จะมีปลายทางของตัวเองพร้อมชนิด fit และพิกัดเฉพาะ

เบื้องหลัง การทำงานนี้อาศัยการจัดแนวที่ตั้งใจไว้ ซึ่งควรรู้เพราะมันอธิบายได้ว่าทำไมการแมปถึงเชื่อถือได้ 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