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

โครงสร้างไฟล์ PDF: ส่วนหัว ตัวเครื่อง Xref และ Trailer

โปรแกรมอ่าน PDF ไม่ได้เริ่มทำงานที่จุดเริ่มต้นของไฟล์ แต่มันเริ่มต้นที่จุดสิ้นสุด ไบต์ไม่กี่ไบต์สุดท้ายจะเก็บที่อยู่ของสิ่งอื่นๆ ทั้งหมด และตัววิเคราะห์ (parser) ที่ไม่เข้าใจลำดับนั้นจะอ่านรูปแบบไฟล์ผิดพลาดตั้งแต่บรรทัดแรก ดังนั้นวิธีที่มีประโยชน์ที่สุดในการเรียนรู้ PDF บนดิสก์คือการเรียนรู้ในแบบที่โปรแกรมอ่านทำ: เริ่มจากส่วนท้ายก่อน จากนั้นกระโดดกลับไปที่แผนที่ แล้วจึงประมวลผลออบเจ็กต์ที่แผนที่ชี้ไป

ตัวไบต์นั้นอ่านได้ง่ายๆ ในโปรแกรมแก้ไขข้อความ (text editor) หากไม่มีอะไรถูกบีบอัด เอกสารแบบหน้าเดียวที่เรียบง่ายที่สุดซึ่งวาดคำว่า "Hello, World!" มีขนาดไม่ถึงห้าร้อยไบต์ และองค์ประกอบโครงสร้างของรูปแบบไฟล์แต่ละส่วนก็มองเห็นได้ชัดเจนในนั้น นี่คือไฟล์ทั้งหมด โดยมีการทำเครื่องหมายแบ่งเป็นสี่ส่วน:

%PDF-1.0                          % Header
%âãÏÓ

1 0 obj                           % Body: the object sequence
<<
/Kids [2 0 R]
/Count 1
/Type /Pages
>>
endobj

2 0 obj
<<
/Rotate 0
/Parent 1 0 R
/Resources 3 0 R
/MediaBox [0 0 612 792]
/Contents [4 0 R]
/Type /Page
>>
endobj

3 0 obj
<< /Font << /F0 << /BaseFont /Times-Italic /Subtype /Type1 /Type /Font >> >> >>
endobj

4 0 obj
<< /Length 65 >>
stream
1. 0. 0. 1. 50. 700. cm BT
  /F0 36. Tf
  (Hello, World!) Tj
ET
endstream
endobj

5 0 obj
<< /Pages 1 0 R /Type /Catalog >>
endobj

xref                              % Cross-reference table
0 6
0000000000 65535 f
0000000015 00000 n
0000000074 00000 n
0000000192 00000 n
0000000291 00000 n
0000000409 00000 n

trailer                           % Trailer
<<
/Root 5 0 R
/Size 6
>>
startxref
459
%%EOF

สี่ส่วน เรียงลำดับตามนี้เสมอลงมาในไฟล์: ส่วนหัว (header), ตัวเครื่องของออบเจ็กต์ (body of objects), ตารางอ้างอิงไขว้ (cross-reference table), และส่วนท้าย (trailer) แต่ที่น่าสนใจคือคุณจะอ่านพวกมันในลำดับที่เกือบจะกลับกัน ISO 32000-2 §7.5.1 ได้วางกายวิภาคสี่ส่วนนี้ไว้เหมือนกัน และเหตุผลของการเข้าถึงจากหลังไปหน้านั้นก็เพื่อการใช้งานจริงล้วนๆ: โปรแกรมอ่านที่กระโดดตรงไปยังออบเจ็กต์ที่ต้องการนั้นเร็วกว่าโปรแกรมอ่านที่สแกนทุกไบต์จากด้านบนมาก และการเข้าถึงแบบสุ่ม (random access) นั้นก็คือสิ่งที่ trailer และตาราง cross-reference มีไว้เพื่อการนี้อย่างแท้จริง

ส่วนหัวมีสองบรรทัด และบรรทัดที่สองนั้นสำคัญ

บรรทัดแรกคือ %PDF-1.0 เครื่องหมายเปอร์เซ็นต์ทำให้มันเป็นคอมเมนต์ (comment) ในแง่ของไวยากรณ์ แต่โปรแกรมอ่านจะปฏิบัติต่อมันเหมือนเป็นลายเซ็นของไฟล์ และดึงหมายเลขเวอร์ชันออกมาจากมัน การจัดการเวอร์ชันนั้นหละหลวมในทางปฏิบัติ โปรแกรมอ่านที่สร้างมาสำหรับ PDF 2.0 จะเปิดไฟล์ที่อ้างว่าเป็น 1.0 ได้อย่างยินดี และโปรแกรมอ่านส่วนใหญ่จะพยายามเปิดไฟล์ที่ประกาศเวอร์ชันผิด หรือบรรทัดเวอร์ชันถูกซ่อนไว้ในไฟล์ลึกเข้าไปเล็กน้อยแทนที่จะอยู่ที่ไบต์ศูนย์ ตัวเลขนี้เป็นคำบอกใบ้ว่าควรคาดหวังฟีเจอร์ใดบ้าง ไม่ใช่ประตูปิดกั้น

บรรทัดที่สองคือบรรทัดที่ผู้คนมักลบโดยไม่ตั้งใจ แล้วก็ต้องใช้เวลาทั้งบ่ายมานั่งแก้บั๊ก มันเป็นคอมเมนต์ด้วยเช่นกัน แต่เพย์โหลด (payload) ของมันคือไบต์สี่ตัวที่มีค่าเหนือ ASCII 127 พวกมันมีอยู่เพื่อให้สิ่งใดก็ตามที่ย้ายไฟล์ใน "โหมดข้อความ" รับรู้ว่ามันเป็นไบนารีและหยุดเขียนจุดสิ้นสุดบรรทัดใหม่ (rewriting line endings) PDF มีสตรีมที่ถูกบีบอัดซึ่งไบต์ของมันอาจบังเอิญตรงกับ carriage return หรือ line feed ได้; หากเครื่องมือถ่ายโอน (transfer tool) เขียนสิ่งเหล่านั้นใหม่ ความยาวสตรีมที่บันทึกไว้ในดิกชันนารีจะไม่ตรงกับจำนวนไบต์บนดิสก์อีกต่อไป และไฟล์ก็จะเสียหาย คอมเมนต์ที่มีไบต์สูงนี้คือการป้องกันตัวที่มีอายุสี่สิบปีจาก FTPในโหมด ASCII และมันยังคงอยู่ในทุกไฟล์ที่สร้างโดยเครื่องมือที่จริงจัง เพราะความล้มเหลวที่มันช่วยป้องกันนั้นเกิดขึ้นอย่างเงียบเชียบและพังทลายอย่างสิ้นเชิง

ตัวเครื่องเก็บออบเจ็กต์ ซึ่งแต่ละตัวมีหมายเลขกำกับ

ทุกสิ่งที่ประกอบขึ้นเป็นเอกสารอาศัยอยู่ในตัวเครื่อง (body) ในฐานะลำดับแบนๆ ของ indirect objects แต่ละตัวเปิดด้วยเลขจำนวนเต็มสองตัวและคีย์เวิร์ด obj, เก็บเนื้อหาไว้, และปิดด้วย endobj ตัวออบเจ็กต์หมายเลข 1 ในตัวอย่างด้านบนคือโหนดของ page-tree: 1 0 obj, จากนั้นเป็นดิกชันนารี, ตามด้วย endobj เลขจำนวนเต็มตัวแรกคือหมายเลขออบเจ็กต์ (object number) ตัวที่สองคือหมายเลขรุ่น (generation number) หมายเลขรุ่นมักจะเป็นศูนย์เสมอในไฟล์ที่เพิ่งเขียนใหม่; มันจะเพิ่มขึ้นก็ต่อเมื่อมีการใช้หมายเลขออบเจ็กต์ซ้ำในการแก้ไขข้ามครั้ง ซึ่งหาได้ยากมากจนคุณสามารถถือว่าหมายเลขรุ่นที่ไม่ใช่ศูนย์เป็นสัญญาณว่าไฟล์ผ่านการอัปเดตแบบ incremental มาแล้ว เนื้อหาระหว่างคีย์เวิร์ดในตัวอย่างนี้คือดิกชันนารี ซึ่งเขียนอยู่ระหว่าง << และ >> แต่มันอาจเป็นตัวเลข, สตริง, อาร์เรย์ หรือสตรีมก็ได้

สิ่งที่ทำให้สิ่งนี้เป็นกราฟแทนที่จะเป็นรายการคือโทเค็นอ้างอิง 2 0 R สิ่งนั้นหมายถึง "ออบเจ็กต์ 2, รุ่น 0, ไม่ว่ามันจะอยู่ที่ไหนในไฟล์" โหนดของ page-tree ด้านบนไม่ได้บรรจุตัวหน้ากระดาษไว้ในตัวเอง; แต่มันชี้ไปที่ออบเจ็กต์ 2 ซึ่งชี้ไปที่ทรัพยากร (resources) และสตรีมเนื้อหาด้วยกลไกเดียวกัน ตัวเครื่องถูกจัดวางในลำดับใดก็ตามที่ผู้เขียนพบว่าสะดวก และการอ้างอิงก็เย็บมันเข้าด้วยกันเป็นโครงสร้างต้นไม้ที่มีรากอยู่ที่ catalog ตำแหน่งในไฟล์ไม่มีความหมาย ตัวตน (Identity) มาจากหมายเลขออบเจ็กต์ และตำแหน่งที่ตั้งมาจากตาราง cross-reference

ตารางอ้างอิงไขว้คือดัชนีของออฟเซตไบต์

ตาราง xref คือสิ่งที่เปลี่ยนหมายเลขออบเจ็กต์ให้กลายเป็นตำแหน่งในไฟล์ มันคือเหตุผลที่โปรแกรมอ่านสามารถเปิดเอกสารพันหน้าและเรนเดอร์หน้า 850 ได้โดยไม่ต้องวิเคราะห์ 849 หน้าก่อนหน้านั้น แต่ละรายการ (entry) บันทึกอย่างแม่นยำว่าออบเจ็กต์เริ่มต้นที่ใด โดยนับเป็นไบต์จากจุดเริ่มต้นของไฟล์:

xref
0 6                  % 6 entries, starting at object 0
0000000000 65535 f   % entry 0: head of the free list
0000000015 00000 n   % object 1 begins at byte 15
0000000074 00000 n   % object 2 begins at byte 74
0000000192 00000 n   % object 3 begins at byte 192
0000000291 00000 n   % object 4 begins at byte 291
0000000409 00000 n   % object 5 begins at byte 409

ความกว้างคงที่เป็นเรื่องตั้งใจ แต่ละรายการมีความยาว 20 ไบต์พอดี: ออฟเซต 10 หลัก, ช่องว่าง 1 ตัว, รุ่น 5 หลัก, ช่องว่าง 1 ตัว, ประเภทความยาว 1 อักขระ, และอักขระจุดสิ้นสุดบรรทัด 2 ไบต์ เนื่องจากทุกแถวมีรูปแบบที่สม่ำเสมอ โปรแกรมอ่านจึงสามารถทำดัชนี (index) ตรงไปยังรายการสำหรับออบเจ็กต์ที่ n ได้ด้วยคณิตศาสตร์แทนที่จะสแกน ดังนั้นตารางที่ให้การเข้าถึงแบบสุ่มแก่ตัวเครื่อง จึงสามารถเข้าถึงได้แบบสุ่มด้วยตัวมันเอง บรรทัด 0 6 คือส่วนหัวของส่วนย่อย (subsection header): มันบอกว่ารายการถัดไปจะอธิบายออบเจ็กต์ 6 ตัวโดยเริ่มจากหมายเลข 0

ออบเจ็กต์ 0 นั้นพิเศษและมีอยู่เสมอ ประเภทของมันคือ f สำหรับว่าง (free), รุ่นของมันคือ 65535, และมันทำหน้าที่เป็นส่วนหัวของรายการเชื่อมโยง (linked list) สำหรับหมายเลขออบเจ็กต์ที่ว่าง ในไฟล์ที่ไม่เคยถูกแก้ไข รายการที่ว่างมีแค่รายการนี้เพียงรายการเดียว ถือเป็นเรื่องที่เป็นพิธีการ มันจะทำหน้าที่ของมันในระหว่างการอัปเดตแบบ incremental เมื่อการลบออบเจ็กต์ใดๆ จะเพิ่มหมายเลขของออบเจ็กต์นั้นลงในรายการดังกล่าว เพื่อให้การแก้ไขในภายหลังสามารถนำมันกลับมาใช้ใหม่ได้ รายการอื่นๆ จะมีประเภท n สำหรับถูกใช้งานอยู่ (in-use) และหมายเลขสิบหลักของพวกมันคือออฟเซตที่คุณจะต้องค้นหาเพื่ออ่านคำนิยามของออบเจ็กต์นั้น

Trailer คือจุดเริ่มต้น และมันอยู่ที่จุดสิ้นสุด

ส่วนท้าย (trailer) คือสิ่งแรกที่โปรแกรมอ่านบริโภคเข้าไปจริงๆ แม้ว่ามันจะถูกเขียนเป็นสิ่งสุดท้าย ตัววิเคราะห์ (parser) จะเปิดไฟล์ ค้นหาไปยังจุดสิ้นสุด และเดินย้อนกลับไปหา %%EOF เหนือมันขึ้นไปนิดเดียวจะมี startxref ตามด้วยตัวเลขเดี่ยวๆ หนึ่งตัว และตัวเลขนั้นคือออฟเซตไบต์ของคีย์เวิร์ด xref ด้วยสิ่งนี้ โปรแกรมอ่านจะกระโดดตรงไปยังตาราง cross-reference ได้โดยไม่ต้องสแกนออบเจ็กต์ใดๆ เลยแม้แต่ตัวเดียว:

trailer
<<
/Root 5 0 R          % the document catalog
/Size 6              % one more than the highest object number
>>
startxref
459                  % byte offset of the xref table
%%EOF

ดิกชันนารีของ trailer จะบรรจุสองค่าที่โปรแกรมอ่านจำเป็นต้องรู้ก่อนที่จะสามารถทำสิ่งอื่นได้ /Root ชี้ไปที่ document catalog ซึ่งก็คือออบเจ็กต์ 5 ในที่นี้ ซึ่งเป็นจุดสูงสุดของกราฟออบเจ็กต์ และเป็นเส้นทางไปสู่ page tree /Size คือจำนวนรายการที่ตาราง cross-reference ควรจะบรรจุอยู่ ซึ่งจะมีค่ามากกว่าหมายเลขออบเจ็กต์ที่สูงที่สุดอยู่ 1 เนื่องจากรายการว่าง (free entry) ที่ช่องหมายเลขศูนย์ จาก %%EOF ลำดับการอ่านทั้งหมดก็จะไหลออกมา: ค้นหาเครื่องหมาย, อ่าน startxref เพื่อหาตำแหน่งตาราง, โหลดตารางเพื่อเรียนรู้ว่าออบเจ็กต์แต่ละตัวอยู่ที่ใด, อ่าน /Root เพื่อค้นหา catalog, และดำเนินการ (resolve) ออบเจ็กต์ตามต้องการจากตรงนั้น ส่วนหัวที่อยู่ด้านบนสุดแทบจะไม่ถูกเรียกใช้จนกว่าจะถึงช่วงหลัง แผนที่ที่อยู่ด้านล่างสุดต่างหากคือสิ่งที่โปรแกรมอ่านต้องการเป็นอันดับแรก

การอัปเดตแบบ Incremental จะต่อท้ายแผนที่อันที่สองแทนการเขียนใหม่

การออกแบบแบบเริ่มจากหางนั้นส่งผลดีเมื่อไฟล์มีการเปลี่ยนแปลง PDF สามารถถูกแก้ไขได้โดยไม่ต้องเขียนไบต์ใดๆ ที่มีอยู่บนดิสก์แล้วขึ้นมาใหม่ ออบเจ็กต์ใหม่และออบเจ็กต์ที่ถูกปรับเปลี่ยนจะถูกต่อท้ายไปที่ด้านล่างสุด ตามด้วยส่วน cross-reference ใหม่ และ trailer ใหม่ ส่วนไฟล์ดั้งเดิมที่อยู่ด้านล่างจะไม่ถูกแตะต้อง การจดบันทึกชิ้นใหม่เพียงชิ้นเดียวคือรายการ /Prev ใน trailer ใหม่ ซึ่งจะเก็บออฟเซตไบต์ของตาราง cross-reference ก่อนหน้านี้:

% ... original file, unchanged, ends here ...

6 0 obj                          % an object added by this edit
<< /Type /Annot /Subtype /Text /Rect [100 700 120 720] >>
endobj

xref                             % a second xref section, for the new object only
6 1
0000000612 00000 n

trailer
<<
/Root 5 0 R
/Size 7
/Prev 459                        % byte offset of the earlier xref table
>>
startxref
680                              % offset of this new xref section
%%EOF

โปรแกรมอ่านจะยังคงเริ่มต้นที่ %%EOF สุดท้าย ยังคงตาม startxref ไปยังตารางล่าสุด แต่ตอนนี้มันจะตามโซ่ /Prev ย้อนกลับไปยังตารางเก่าๆ โดยผสานมันเข้าด้วยกันเพื่อที่รายการล่าสุดสำหรับออบเจ็กต์หมายเลขใดๆ จะเป็นฝ่ายชนะ ส่วน cross-reference ก่อตัวเป็นรายการเชื่อมโยง (linked list) ไล่ลงไปตามไฟล์ โดยแต่ละส่วนจะลบล้างส่วนที่อยู่ก่อนหน้าสำหรับออบเจ็กต์ที่มันเข้าไปแตะต้อง ออบเจ็กต์ที่ถูกแก้ไขแทนที่ยังคงมีอยู่จริงที่ออฟเซตเก่าของมัน; เพียงแต่มันไม่สามารถเข้าถึงได้อีกต่อไป เนื่องจากรายการ xref ในภายหลังชี้ไปยังที่ที่ใหม่กว่า

นี่คือกลไกที่ทำให้ PDF ที่มีการลงนาม (signed) สามารถตรวจสอบได้ ลายเซ็นดิจิทัลจะครอบคลุมช่วงไบต์ช่วงหนึ่งของไฟล์ และเนื่องจากการอัปเดตแบบ incremental จะทำเพียงแค่ต่อท้ายเสมอ ไบต์ที่ถูกลงนามแล้วจะไม่มีวันเคลื่อนย้าย ลายเซ็นจะยังคงตรวจสอบได้เมื่อเทียบกับช่วงเดิม ในขณะที่ฉบับแก้ไขใหม่ในภายหลังจะอยู่นอกเหนือช่วงนั้น โดยแต่ละฉบับมี xref และ trailer เป็นของตัวเอง นี่เป็นเหตุผลว่าทำไม PDF จึงสามารถเก็บประวัติที่กู้คืนได้: ทุกออบเจ็กต์ที่ถูกแทนที่ยังคงอยู่บนดิสก์ภายใต้ส่วน cross-reference ก่อนหน้า ซึ่งนั่นเป็นฟีเจอร์สำหรับการติดตามเวอร์ชัน และเป็นภาระผูกพันสำหรับใครก็ตามที่คิดว่าการ "ลบ" หมายความว่าไบต์เหล่านั้นหายไปแล้ว

สิ่งที่ต้องแลกมาคือการเติบโตขึ้น การแก้ไขแต่ละครั้งจะเป็นการต่อท้าย; ไม่มีอะไรที่ถูกนำกลับมาใช้ใหม่ ณ จุดนั้น (reclaimed in place) ดังนั้นไฟล์ที่ถูกปรับปรุงหลายครั้งจะสะสมออบเจ็กต์ที่ตายแล้วและโซ่ความยาวของส่วน xref จำนวนมาก วิธีแก้ไขคือการเขียนใหม่ทั้งหมด (full rewrite): โหลดเอกสารและบันทึกมันใหม่ (save fresh) ซึ่งจะให้หมายเลขใหม่แก่ออบเจ็กต์ที่ยังรอดชีวิตอยู่ ละทิ้งออบเจ็กต์ที่ไม่สามารถเข้าถึงได้ และสร้างตาราง cross-reference ที่สะอาดเอี่ยมเพียงตารางเดียว กลยุทธ์สองอย่างนี้แลกเปลี่ยนกันโดยตรง การต่อท้าย (Appending) นั้นรวดเร็ว และรักษารอยเซ็นรวมถึงประวัติไว้; การเขียนใหม่ (rewriting) นั้นช้ากว่า และทิ้งทั้งสองอย่าง เพื่อแลกกับไฟล์ที่กะทัดรัด

การอ่านทั้งสี่ส่วนในทางปฏิบัติ

การรู้เลย์เอาต์นั้นเพียงพอที่จะแก้ปัญหา "ไฟล์นี้เปิดไม่ได้" ส่วนใหญ่ได้ด้วยมือ หากโปรแกรมอ่านปฏิเสธ PDF ผู้กระทำผิดมักจะอยู่ที่ปลายทั้งสองด้าน ไม่ใช่ตรงกลาง การดาวน์โหลดที่ไม่สมบูรณ์ (truncated) จะทำให้เสีย trailer ดังนั้น startxref หรือ %%EOF จะหายไป และโปรแกรมอ่านจะไม่มีจุดเริ่มต้น; โปรแกรมอ่านที่ยืดหยุ่นจะกลับไปใช้วิธีสแกนทั้งไฟล์เพื่อสร้าง xref ขึ้นมาใหม่ ซึ่งนี่คือเส้นทางที่ช้าซึ่งตารางนั้นถูกตั้งใจสร้างมาเพื่อหลีกเลี่ยง การถ่ายโอนในโหมดข้อความที่ผิดพลาด จะทำให้ไบต์ของสตรีมเสียหาย หรือออฟเซตหยุดตรงกับความเป็นจริง และออบเจ็กต์จะโหลดจากตำแหน่งที่ผิด เมื่อออฟเซตในตารางไม่ชี้ไปที่คีย์เวิร์ด obj ที่มีอยู่จริงอีกต่อไป ไฟล์ก็ถือว่าพังทลายในเชิงโครงสร้าง แม้ว่าออบเจ็กต์แต่ละตัวจะปกติดีก็ตาม

สำหรับโค้ดใหม่ บทเรียนของเลย์เอาต์นี้คือการปล่อยให้ไลบรารีเป็นเจ้าของการจดบันทึกไบต์ ออฟเซตในตาราง cross-reference จะต้องตรงกับตำแหน่งที่แท้จริงของทุกออบเจ็กต์แบบไบต์ต่อไบต์, trailer จะต้องชี้ไปที่ตารางที่ถูกต้อง, และการอัปเดตแบบ incremental จะต้องร้อยเรียงผ่าน /Prev ได้อย่างถูกต้อง คอมโพเนนต์แบบเนทีฟอย่าง HotPDF Component สำหรับ Delphi และ C++Builder จัดการสิ่งเหล่านั้นทั้งหมดเมื่อมันเขียนไฟล์ รวมถึงการเลือกได้ว่าจะต่อท้ายฉบับปรับปรุงแบบ incremental หรือเขียนใหม่เพื่อให้กระชับ หากคุณต้องการเห็นโครงสร้างเดียวกันนี้ถูกสร้างขึ้นมาจากศูนย์แทนที่จะเป็นการถูกชำแหละ บทความประกอบเรื่อง การสร้างเอกสาร PDF จากศูนย์ จะนำทางคุณตลอดการสร้างส่วนหัว ออบเจ็กต์ xref และ trailer ตามลำดับ