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

ไฟล์แนบระดับหน้าใน PDF 2.0 ด้วย PDFlibPas

PDFlibPas ผูกไฟล์ที่ฝังไว้เข้ากับหน้าใดหน้าหนึ่งโดยเฉพาะ แทนที่จะผูกกับเอกสารทั้งฉบับ ด้วยการเขียนอาร์เรย์ /AF ลงใน page dictionary ขณะที่ตัว payload เองยังถูกลงทะเบียนอยู่ใน name tree ของ EmbeddedFiles ระดับเอกสารเหมือนเดิม การแยกสองส่วนนี้คือสิ่งที่ ISO 32000-2 §14.13 อธิบายไว้ และเป็นสิ่งที่ทำให้ reader ตอบคำถามที่ attachment ระดับเอกสารตอบไม่ได้ คือข้อมูลชุดนี้เป็นของหน้าไหน

กรณีใช้งานจริงเจาะจงกว่า attachment ทั่วไปอยู่แล้ว รายงานสำรวจที่แต่ละหน้าแนบชุดข้อมูลการวัดดิบที่อยู่เบื้องหลังกราฟของหน้านั้น ชุดเอกสารสแกนที่ทุกหน้าเก็บผล OCR ที่ผลิต text layer ของหน้านั้น ชุดแบบวาดที่แต่ละแผ่นแนบไฟล์ CAD ที่ถูก render ออกมาเป็นแผ่นนั้น ทุกกรณีนี้ถ้าใช้รายการ attachment ระดับเอกสารจะได้แค่กองไฟล์ที่ฝังเลขหน้าไว้ในชื่อไฟล์ ซึ่งเป็นแค่ธรรมเนียม ไม่ใช่โครงสร้าง

payload ชุดเดียว แต่ถูกอ้างอิงจากสองที่

จุดสำคัญเชิงโครงสร้างคือการผูกระดับหน้าไม่ได้สร้างสำเนาที่สองของอะไรขึ้นมาเลย ไฟล์ถูกฝังเพียงครั้งเดียวและลงทะเบียนใน name tree ของ EmbeddedFiles ด้วยกลไก file specification เดียวกับ attachment ระดับเอกสารเป๊ะ ๆ สิ่งที่ต่างคือตำแหน่งที่เขียน reference และ relationship key ของมันลงไป คือลงใน page dictionary แทนที่จะเป็น document catalog

ผลตามมาสองข้อ ข้อแรก reader ที่รู้จักแค่ attachment ระดับเอกสารก็ยังหา payload เจอ เพราะมันอยู่ใน name tree ซึ่งเป็นที่ที่ reader แบบนั้นค้นหา ข้อสองการล้าง association ระดับหน้าเอา binding ออก ไม่ได้เอาไฟล์ออก ClearPageAssociatedFiles ปลดหน้าออกจากไฟล์ที่ผูกไว้แล้วปล่อยให้ payload ยังเข้าถึงได้ผ่าน name tree ซึ่งเป็นพฤติกรรมแบบอนุรักษ์นิยม การกระทำที่บอกว่าล้าง association ไม่ควรทำลายข้อมูลที่ส่วนอื่นของเอกสารอาจอ้างอิงอยู่เงียบ ๆ

โครงสร้างของไฟล์ associated ระดับหน้าในเอกสาร PDF 2.0 ที่ PDFlibPas เขียนออกมา: payload ถูกฝังเพียงครั้งเดียวและลงทะเบียนใน name tree ของ EmbeddedFiles ใต้ document catalog ขณะที่ page dictionary พกอาร์เรย์ /AF ที่อ้างไปยัง file specification เดียวกันพร้อม key ของ AFRelationship ทำให้ ClearPageAssociatedFiles ปลด binding โดยไม่ทำลายข้อมูล
การผูกระดับหน้าเพิ่ม reference ที่สอง ไม่ใช่สำเนาที่สอง reader ที่รู้จักแค่ attachment ระดับเอกสารก็ยังหา payload ใน name tree เจอ และการล้าง binding ของหน้าก็ปล่อยให้ embedded stream ยังเข้าถึงได้

ฟังก์ชันนี้มีเงื่อนไขความสำเร็จที่แคบโดยตั้งใจหนึ่งข้อที่ควรรู้ไว้ มันรายงานความสำเร็จเมื่อหน้านั้นพก key /AF อยู่จริงเท่านั้น หน้าที่ไม่เคยมี association จะได้ความล้มเหลวกลับมา ไม่ใช่คำยืนยันที่แสนไพเราะ ผู้เรียกจึงสับสนระหว่าง no-op กับการล้างที่สำเร็จไม่ได้

var
  Lib: TPDFlib;
  Idx, I: Integer;
begin
  Lib := TPDFlib.Create(nil);
  try
    Lib.LoadFromFile('survey-report.pdf');

    // แนบชุดการวัดดิบที่อยู่เบื้องหลังกราฟบนหน้า 3
    Idx := Lib.AddPageAssociatedFileFromFile(3,
      'series-03.csv',            // ไฟล์บนดิสก์
      'measurements.csv',         // ชื่อแสดงผลภายใน PDF
      'text/csv',                 // ชนิด MIME
      'Raw measurement series for figure 3',
      'Data');                    // AFRelationship, ISO 32000-2 14.13

    if Idx < 0 then
      raise Exception.Create('page association refused');

    for I := 0 to Lib.GetPageAssociatedFileCount(3) - 1 do
      Writeln('page 3 associated file, embedded index ',
        Lib.GetPageAssociatedFileEmbeddedIndex(3, I));

    Lib.SaveToFile('survey-report-with-data.pdf');
  finally
    Lib.Free;
  end;
end;

สายอักขระความสัมพันธ์ในทางปฏิบัติไม่ใช่ free text ISO 32000-2 กำหนดคำศัพท์เอาไว้ ได้แก่ Source, Data, Alternative, Supplement, EncryptedPayload, FormData, Schema และ Unspecified และผู้บริโภคเอกสารเอาคำศัพท์นี้เป็นตัวตั้ง Data สำหรับตัวเลขที่อยู่หลังกราฟ Source สำหรับเอกสารต้นทางที่หน้านั้นถูกผลิตขึ้นจาก Alternative สำหรับรูปแบบที่เทียบเท่ากัน เลือกจากคำศัพท์ที่กำหนดไว้เสมอ แม้ตอนนี้ไม่มีอะไรใน pipeline ของคุณอ่านมันก็ตาม เพราะเครื่องมือถัดไปในสายพานอาจอ่าน

ทำไม lookup คำถามเดียวกันจึงต้องใช้ FollowRef ทั้งสองทิศทาง

เพราะการไล่ตาม reference ตอบคำถามที่ต่างกันสองข้อ และโค้ดต้องรู้ว่าตัวเองกำลังถามข้อไหน key lookup ที่ไล่ตาม indirect reference จะคืนออบเจกต์ที่ reference ชี้อยู่ ส่วน lookup ที่ไม่ไล่ตามจะคืนตัว reference มาเฉย ๆ ทั้งสองแบบถูกต้อง และการใช้ผิดฝั่งจะได้พฤติกรรมเพี้ยนเงียบ ๆ ไม่ใช่ error

การอ่านไฟล์ associated คือตัวอย่างของทิศทางแรก ถ้าต้องการหมายเลขออบเจกต์ของ embedded stream ที่อยู่หลัง key /EF และ /F ของ file specification lookup ต้อง ไม่ ไล่ตาม reference เพราะการไล่ตามจะ resolve มันเป็น stream object เสียก่อน แล้วหมายเลขออบเจกต์ก็หายไป กฎข้อนี้ขยายได้ทั่วไป โค้ดเส้นทางไหนที่ต้องการตัวตนของออบเจกต์มากกว่าเนื้อหา ก็ต้องเอา reference ดิบ

optional content แสดงทิศทางตรงข้าม และมันแพงกว่าในการค้นพบ dictionary ของ optional content properties ถูกเขียนลง catalog เป็น indirect object โค้ดที่อ่านกลับมาโดยไม่ไล่ตาม reference จึงได้ reference มาแทน dictionary การเช็ก type บนค่านั้นก็ล้มเหลว และสาย fallback ที่ดูเป็นธรรมชาติ คือถ้าไม่มี configuration ก็สร้างใหม่ จะทำงานแล้วเขียนทับ configuration ที่มีอยู่แล้วเดิม ไม่มีอะไร raise เลยสักนิด layers ที่พูดถึงใน optional content groups และ layers ก็แค่เสียสถานะ visibility เริ่มต้นไปเงียบ ๆ

บทเรียนขยายได้เกินสองกรณีนี้ เมื่อ lookup คืนได้ทั้ง reference และตัวออบเจกต์ การเช็ก type อย่างเดียวไม่ใช่การจัดการ error แต่มันคือสายแยกที่สักวันจะถูกเลือกด้วยเหตุผลที่ผิด กำหนดให้ชัดว่าจุดเรียกแต่ละจุดต้องการอะไร แล้วเลือกใช้ public API ที่ตอบคำถามตรง ๆ เช่น property นับจำนวน optional content มากกว่าการล้วนเข้าไปแตะ protected accessor เพื่อคว้า catalog dictionary

แผนที่การตัดสินใจเรื่องการไล่ตาม reference ใน lookup ของ PDF ตามที่ PDFlibPas ทำ: การอ่าน /EF และ /F ใต้ file specification ต้องไม่ไล่ตาม reference เพราะหมายเลขออบเจกต์ของ embedded stream คือคำตอบ ขณะที่ dictionary /OCProperties แบบ indirect ใน catalog ต้องไล่ตาม ไม่เช่นนั้นการเช็ก type ที่ล้มเหลวจะเขียนทับ optional content configuration ที่มีอยู่แบบเงียบ ๆ
lookup ตัวเดียวกันตอบคำถามต่างกันสองข้อ ความเป็นตัวตนต้องการ reference ดิบ เนื้อหาต้องการออบเจกต์ที่ resolve แล้ว และการเช็ก type ลอย ๆ แทนการตัดสินใจตรงนี้สักวันจะรันสายที่ผิดโดยไม่ raise เลย
// ไฟล์แนบระดับเอกสารและ association ระดับหน้าอยู่ร่วมกันได้
// ไฟล์ที่ฝังไว้จะถูกทำเครื่องหมาย associated ระดับเอกสารก็ได้
if Lib.IsEmbeddedFileAssociated(0) = 0 then
  Lib.SetEmbeddedFileAssociated(0, 1, 'Supplement');

Writeln('document associated files: ', Lib.GetAssociatedFileCount);
Writeln('page 3 associated files  : ',
        Lib.GetPageAssociatedFileCount(3));

// การ clear ปลด binding ของหน้าออก ส่วน payload ยังอยู่ใน name tree
if Lib.ClearPageAssociatedFiles(3) > 0 then
  Writeln('page 3 associations removed, payloads still reachable');

conformance mode ทำอะไรกับไฟล์แนบ

โปรไฟล์สำหรับการเก็บถาวรจำกัดว่าอะไรฝังได้ และข้อจำกัดนี้ถูกบังคับที่จุดเรียก ไม่ใช่ตอนเซฟ PDF/A-1 ห้ามฝังไฟล์เด็ดขาด PDF/A-2 อนุญาตเฉพาะเอกสาร PDF/A ที่ถูกฝังเข้ามา และ PDF/A-3 คือโปรไฟล์ที่เปิดประตูให้ฝังไฟล์ทุกชนิดได้ ซึ่งก็คือเหตุผลเป๊ะ ๆ ที่ฟอร์แมตใบแจ้งหนี้แบบ hybrid ถูกสร้างบนมัน

PDFlibPas ปฏิเสธ attachment เมื่อ conformance mode ที่เปิดอยู่ไม่อนุญาต โดยปฏิเสธตั้งแต่ที่คำสั่ง ไม่ใช่รอไปหลายร้อยการดำเนินการก่อนตอนเขียนไฟล์ออก นั่นคือทางเลือกที่ตั้งใจเรื่องจุดที่ error ถูกจัดการได้ถูกที่สุด การปฏิเสธที่จุดเรียกจะชี้ชื่อไฟล์ที่คุณกำลังเพิ่มให้เห็น ส่วนการปฏิเสธตอนเซฟจะชี้แค่ชื่อเอกสาร แล้วปล่อยให้คุณไล่เองว่าในสี่สิบ attachment มันคืออันไหน

นี่ก็คือเหตุผลที่ไฟล์ associated โผล่มาบ่อยมากในใบแจ้งหนี้อิเล็กทรอนิกส์ ใบแจ้งหนี้แบบ hybrid คือ PDF ที่คนอ่านได้พร้อม payload XML ที่เครื่องอ่านได้แนบมาด้วยและถูกทำเครื่องหมาย relationship ที่ถูกต้อง โดยทั้งโปรไฟล์ของคอนเทนเนอร์และ relationship key ล้วนเป็นส่วนหนึ่งของข้อกำหนด ไม่ใช่แค่ธรรมเนียม การประกอบมันขึ้นมามีอยู่ใน การสร้างใบแจ้งหนี้ hybrid แบบ Factur-X และ ZUGFeRD ส่วนด้าน metadata อยู่ใน XMP extension schema ของ PDF/A-3

เมื่อไรที่ association ควรเป็นรายหน้า ไม่ใช่รายเอกสาร

ตอนที่ผู้บริโภคเอกสารจำเป็นต้องรู้ว่าข้อมูลเป็นของหน้าไหน และแค่ตอนนั้นเท่านั้น attachment ระดับเอกสารง่ายกว่า รองรับกว้างกว่าในหลาย viewer และเพียงพอเสมอเมื่อ payload บรรยายเอกสารทั้งฉบับ เช่น XML ของใบแจ้งหนี้ signature manifest หรือ archive ของซอร์ส เอื้อมไปใช้ association ระดับหน้าเมื่อ payload เป็นของเฉพาะหน้าอย่างแท้จริง และตัวตนของหน้าเป็นส่วนหนึ่งของความหมายมัน

การรองรับคือข้อจำกัดเชิงปฏิบัติ ไฟล์ associated ระดับหน้าเป็น construct ของ PDF 2.0 และฝั่ง viewer รองรับบางกว่า attachment ระดับเอกสาร เพราะ payload อยู่ใน name tree เหมือนกันไม่ว่าทางไหน viewer ที่มองข้าม /AF บนหน้าก็ยังแสดงไฟล์ในรายการ attachment ได้ การเสื่อมสภาพจึงค่อยเป็นค่อยไป แต่ถ้า binding ระดับหน้าจำเป็นต่อผู้บริโภคของคุณ ไม่ใช่แค่ metadata ที่มีก็ดี จงทดสอบ reader ที่คุณกำลังจะรองรับจริง อย่าเดาเอา

ไฟล์ associated ระดับหน้า attachment ระดับเอกสาร และด่านโปรไฟล์ถาวรที่กำกับทั้งสองมาพร้อมกับ PDFlibPas Delphi PDF library ถ้าคุณยังต้องซ่อมไฟล์เก่าระหว่างทางที่นำเข้าด้วย งานด้าน metadata และ conformance ใน การแปลงเป็น PDF/A พร้อมซ่อม metadata คือสิ่งที่ตัดสินตั้งแต่แรกว่าเส้นทาง attachment แบบไหนพร้อมให้คุณใช้บ้าง