PDFlibPas ไลบรารี PDF เนทีฟสำหรับ Delphi และ C++Builder ให้เอกสาร PDF มีที่แขวนพฤติกรรมอัตโนมัติแยกกันสองที่ คือ action วงจรชีวิตระดับเอกสาร เช่น WillClose, WillSave, DidSave, WillPrint และ DidPrint เก็บไว้ใน dictionary /AA ของ Catalog และ action วงจรชีวิตระดับหน้า คือ Open และ Close เก็บไว้ใน dictionary /AA ของ Page object เองแทน การสับสนระหว่าง container ทั้งสองเป็นวิธีที่พบบ่อยที่สุดวิธีเดียวที่ทำให้ action วงจรชีวิตไม่ทำอะไรเลยอย่างเงียบๆ
กรณีที่กระตุ้นบทความนี้เป็นกรณีธรรมดา ทีมการเงินต้องการเทมเพลตใบแจ้งยอดที่ประทับ timestamp การพิมพ์และบันทึกว่าใครพิมพ์มัน ทันทีที่การพิมพ์เริ่มขึ้นจริงๆ ไม่ใช่ตอนที่ไฟล์แค่เปิด workflow ที่หนักด้านฟอร์มต้องการค่าฟิลด์ที่ถูกผลักไปยังเซิร์ฟเวอร์โดยอัตโนมัติก่อนที่ PDF client ของ reader จะได้รับอนุญาตให้ปิดหน้าต่าง ดังนั้นแท็บที่ปิดไปจึงไม่มีวันหมายถึงการแก้ไขที่หายไป รายงานหลายหน้าต้องการแบนเนอร์เฉพาะหน้าที่ปรากฏก็ต่อเมื่อหน้านั้นอยู่บนหน้าจอเท่านั้น PDF มีชั้นที่สามจริงๆ ต่ำกว่าเอกสารและหน้าสำหรับพฤติกรรมประเภทนี้ คือ action ที่ผูกกับ entry /A ของฟิลด์ฟอร์มหรือลิงก์แต่ละตัวเอง หัวข้อของบทความคู่กันเรื่อง action ฟอร์มแบบโต้ตอบและ JavaScript แต่บทความนี้อยู่ที่สองชั้นเหนือมันเท่านั้น คือทั้งเอกสารและหน้าเดียว
trigger อะไรที่อยู่บน /AA ของ Catalog เอกสาร
trigger ห้าตัวอยู่บน dictionary /AA ของ Catalog และแต่ละตัวยิงสำหรับ event ที่กระทบทั้งเอกสาร ไม่ใช่หน้าเดียว ISO 32000-1 §12.6.3 (Trigger Events) ระบุ key ระดับเอกสารเป็น WC, WS, DS, WP และ DP ชื่อสองตัวอักษรตรงตัวที่เขียนเข้า dictionary /AA สำหรับ WillClose, WillSave, DidSave, WillPrint และ DidPrint ตามลำดับ และ PDFlibPas สะท้อนชุดนั้นตรงเป๊ะใน enumeration TPDFlibDocumentActionTrigger คือ datWillClose, datWillSave, datDidSave, datWillPrint, datDidPrint SetDocumentAction เป็นจุดเข้าเดียวที่ผูกตัวใดตัวหนึ่งในห้าตัวนี้ และพารามิเตอร์ ActionKind ที่มันรับเป็นหนึ่งในสิบค่าคงที่ PDF_ACTION_BUILDER_* ที่ใช้ร่วมกันข้ามทุกการเรียก action-builder ในไลบรารี ตั้งแต่ URI ธรรมดาไปจนถึง script ไปจนถึงการกระโดดปลายทาง สิ่งที่ action แบบ GoTo, remote-file, embedded-file หรือ Launch ทำจริงๆ เมื่อถูกกระตุ้นเป็นคำถามที่ต่างจากที่ที่มันผูกไว้ และนั่นเป็นหัวข้อของบทความคู่กันเรื่อง action แบบ GoTo, remote, embedded และ launch บทความนี้อยู่ที่คำถามเรื่อง container คือ Catalog หรือ Page แทนที่จะเป็นคำถามเรื่องประเภท action
var
Lib: TPDFlib;
begin
Lib := TPDFlib.Create;
try
Lib.AddStandardFont(4);
Lib.DrawText(40, 700, 'Quarterly statement');
Lib.SetDocumentAction(datWillSave, PDF_ACTION_BUILDER_WEB,
'https://example.com/audit/will-save', '', 0, 0);
Lib.SetDocumentAction(datWillClose, PDF_ACTION_BUILDER_SUBMIT,
'https://example.com/forms/submit', 'CustomerName;OrderTotal', 0, 0);
Lib.SaveToFile('statement.pdf');
finally
Lib.Free;
end;
end;
trigger ระดับหน้าต่างจาก trigger ระดับเอกสารอย่างไร
trigger ระดับหน้ายิงแค่สำหรับ Page object เดียวที่มันผูกไว้เท่านั้น และ PDFlibPas เก็บมันไว้ใน dictionary /AA ของหน้านั้นเอง แทนที่จะเป็นของ Catalog มี trigger ระดับหน้าแค่สองตัวเท่านั้น คือ Open และ Close ตรงกับ key O และ C ที่ ISO 32000-1 นิยามไว้สำหรับ dictionary additional-actions ของหน้า และ PDFlibPas เปิดมันเป็น patOpen และ patClose ผ่าน SetPageAction ซึ่งผูกเข้ากับหน้าใดก็ตามที่ถูกเลือกอยู่ในขณะนั้นผ่าน SelectPage รายละเอียดที่สำคัญในครั้งแรกที่คุณวนลูปข้ามเอกสารโดยคาดหวังว่าการเรียกครั้งเดียวจะใช้ได้ทุกที่ เพราะมันไม่เคยเป็นแบบนั้นเลย การผูก trigger ประเภทใดก็ตามยังเพิ่มเวอร์ชัน PDF ขั้นต่ำของไฟล์ด้วย และ container ทั้งสองขอพื้นที่ที่ต่างกัน PDFlibPas ยกเอกสารเป็นอย่างน้อย PDF 1.4 ในครั้งแรกที่มันเขียน entry Catalog /AA และเป็นอย่างน้อย PDF 1.5 ในครั้งแรกที่มันเขียน entry Page /AA ไม่ว่า action ประเภทไหนจะอยู่ข้างในก็ตาม นั่นเป็นข้อกำหนดระดับ container ที่ซ้อนทับบนสิ่งที่ action เองต้องการอยู่แล้ว ดังนั้น action แบบ URI เปล่าๆ ที่ต้องการแค่ PDF 1.1 เพียงลำพัง ก็ยังคงดึงทั้งไฟล์ขึ้นเป็น PDF 1.5 ทันทีที่มันถูกห่อด้วย trigger page-open
Lib.SelectPage(3);
Lib.SetPageAction(patOpen, PDF_ACTION_BUILDER_JAVASCRIPT,
'app.alert("Section 3: internal review only");', '', 0, 0);
Lib.SetPageAction(patClose, PDF_ACTION_BUILDER_WEB,
'https://example.com/analytics/page-3-closed', '', 0, 0);
การอ่านและลบ action วงจรชีวิต
GetDocumentActionInfo และ GetPageActionInfo ทั้งคู่คืน record TPDFlibActionInfo และฟิลด์ Kind จะกลับมาเป็น akNone ทุกครั้งที่ trigger นั้นไม่มีอะไรผูกอยู่ ดังนั้นตรวจสอบ Kind ก่อนที่จะเชื่อฟิลด์อื่นใดบน record เลย URI, JavaScript, FileName และอื่นๆ มีความหมายก็ต่อเมื่อเป็น action ประเภทเดียวที่ Kind รายงานจริงๆ เท่านั้น เพราะรูปร่าง record เดียวกันถูกใช้ซ้ำข้ามทุกประเภท action ที่ builder สร้างได้ RemoveDocumentAction และ RemovePageAction แต่ละตัวล้าง trigger เดียวและรายงาน 1 เมื่อพบอะไรให้ลบ, 0 เมื่อ trigger ว่างเปล่าอยู่แล้ว เมื่อ entry ที่ลบเป็นตัวสุดท้ายที่เหลืออยู่ใน dictionary /AA PDFlibPas จะลบ /AA ที่ว่างเปล่าแล้วนั้นเองด้วย แทนที่จะทิ้ง container ที่แขวนค้างและไม่มีความหมายไว้บน Catalog หรือหน้า
var
Info: TPDFlibActionInfo;
begin
Info := Lib.GetDocumentActionInfo(datWillSave);
if Info.Kind = akURI then
WriteLn('WillSave calls out to: ', string(Info.URI));
if Lib.RemoveDocumentAction(datWillSave) = 1 then
Lib.SetDocumentAction(datWillSave, PDF_ACTION_BUILDER_WEB,
'https://example.com/audit/will-save-v2', '', 0, 0);
end;
PDF/A อนุญาต action วงจรชีวิตเลยหรือไม่
ไม่ ความสอดคล้องกับ PDF/A ปฏิเสธ container additional-actions ทั้งหมด ไม่ใช่แค่ประเภท action ที่ฟังดูเสี่ยงเท่านั้น เพราะ ISO 19005 จำกัดโมเดล interactive-action ของ PDF บนสมมติฐานที่ว่าไฟล์เก็บถาวรต้อง render แบบเดียวกันในอีกหลายสิบปีข้างหน้า โดยไม่ต้องพึ่งพา scripting engine หรือการเชื่อมต่อเครือข่ายที่อาจไม่มีอยู่ในตอนนั้น SetLifecycleAction ตัว builder ที่ใช้ร่วมกันเบื้องหลังทั้ง SetDocumentAction และ SetPageAction ตรวจสอบ PDFAMode ก่อนที่จะมองที่ ActionKind เลย ดังนั้น action แบบ URI ที่แค่เปิดหน้าเว็บบริษัท หรือ action แบบ Named ที่หมายถึงแค่ไปหน้าถัดไป จะถูกจับในตาข่ายเดียวกับตัวที่อันตราย ไม่มีอะไรที่ผู้ review ด้านความปลอดภัยจะ flag ตามปกติ ถูกบล็อคอยู่ดี เพราะข้อจำกัดนี้เป็นเชิงโครงสร้าง ไม่ใช่กรณีต่อกรณี อันตรายในทางปฏิบัติคือการปฏิเสธนั้นเงียบ SetDocumentAction และ SetPageAction ทั้งคู่คืน 0 โดยไม่ยก exception ดังนั้นจุดเรียกที่ไม่เคยตรวจสอบค่าที่คืนกลับมาจะส่งเอกสารออกไปโดยขาด trigger ที่มันควรพกอย่างเงียบๆ
Lib.SetPDFAMode(2); // PDF/A-1b
if Lib.SetDocumentAction(datWillClose, PDF_ACTION_BUILDER_NAMED,
'', '', 0, 0) = 0 then
// rejected: PDF/A-1b forbids Catalog /AA, even a plain Named action
WriteLn('lifecycle action not attached');
มีความไม่สมมาตรหนึ่งอย่างที่ควรจำไว้ RemoveDocumentAction และ RemovePageAction ไม่เคยตรวจสอบ PDFAMode เลย ดังนั้นการโหลดไฟล์ที่พก action วงจรชีวิตที่ไม่สอดคล้องอยู่แล้ว และลอกมันออกระหว่างทางไปสู่การบันทึกแบบสอดคล้อง PDF/A ทำงานได้ตามที่คาดไว้เป๊ะ มีแค่เส้นทางการเขียน การผูก trigger ใหม่ เท่านั้นที่ถูกคุมด้วยโหมดความสอดคล้อง
print-on-open เข้ากับที่ไหนโดยไม่มี trigger WillOpen
dictionary /AA ของ Catalog ไม่มี entry WillOpen เลยโดยการออกแบบ /AA ระดับเอกสารใน ISO 32000-1 นิยาม key ไว้ห้าตัวเป๊ะ คือ WillClose, WillSave, DidSave, WillPrint และ DidPrint และไม่มีตัวไหนในรายการนั้นยิงเพียงเพราะไฟล์ถูกเปิดเลย hook ตอนเปิดอยู่ใน entry Catalog แยกต่างหาก คือ /OpenAction ซึ่ง PDFlibPas เปิดผ่านตระกูลการเรียกของตัวเอง SetOpenActionJavaScript, SetOpenActionDestination และ SetOpenActionNamedDestination รวมอยู่ในนั้น ไม่มีตัวไหนแตะ dictionary /AA หรือ enumeration TPDFlibDocumentActionTrigger เลย แต่สองกลไกนี้ประกอบกันได้ และนั่นมักเป็นสิ่งที่เทมเพลต print-on-open ต้องการจริงๆ สร้างเทมเพลตให้ /OpenAction ของมันเริ่มงานพิมพ์ โดยทั่วไปเป็น action JavaScript ที่เรียกคำสั่งพิมพ์ของ viewer เอง และการพิมพ์เองก็คือสิ่งที่ให้ WillPrint และ DidPrint มีอะไรให้ทำงานด้วย timestamp ที่ประทับก่อนที่หน้าจะ spool, entry audit ที่เขียนเมื่อมันเสร็จแล้ว
trigger เหล่านี้เชื่อถือได้แค่ไหนข้าม PDF viewer
ไม่ใช่ทุก viewer จะรันมัน แม้แต่นอกเหนือ PDF/A ดังนั้นให้ปฏิบัติต่อ action วงจรชีวิตเป็นคำขอมากกว่าการรับประกัน Acrobat และ desktop reader เต็มรูปแบบส่วนใหญ่รันทั้งชุดอย่างซื่อสัตย์ แต่ส่วนแบ่งขนาดใหญ่ของการใช้งาน PDF จริงในโลกไม่เคยแตะ dictionary additional-actions เลย viewer ที่ฝังในเบราว์เซอร์, reader มือถือส่วนใหญ่ และ pipeline การ render หรือดึงข้อความฝั่งเซิร์ฟเวอร์เกือบทุกตัว ล้วนเพิกเฉยต่อ /AA โดยตรง หรือเคารพแค่เสี้ยวแคบๆ ของมัน โดย WillPrint และ DidPrint มักทำงานได้แย่ที่สุด เพราะการแปลงแบบ headless ไม่มีการดำเนินการพิมพ์ให้มันเกาะเข้าไปเลย ถ้า action submit-form แบบ WillClose เป็นเส้นทางเดียวที่จับข้อมูลฟอร์ม มันไม่ใช่เส้นทางที่เชื่อถือได้เลย จับคู่มันกับปุ่ม submit ที่ชัดเจน และปฏิบัติต่อ trigger อัตโนมัติเป็นความสะดวกสำหรับ reader ที่บังเอิญรองรับมัน
trigger ระดับเอกสาร, หน้า และฟิลด์เป็นสามชั้นของกลไก action-dictionary พื้นฐานเดียวกัน และเมื่อ container ชัดเจนแล้ว ที่เหลือคือการเลือกค่าคงที่ ActionKind ที่ถูกต้องและตรวจสอบ return code trigger วงจรชีวิตเหล่านี้ พร้อมกับ API action-builder ที่กว้างกว่าที่บทความนี้แตะ มาพร้อมกับไลบรารี PDF PDFlibPas สำหรับ Delphiรุ่นมาตรฐาน พร้อมเอกสารอ้างอิง trigger และประเภท action แบบเต็มในเอกสารผลิตภัณฑ์