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

โครงสร้างต้นไม้หน้า PDF: ทำไมลำดับหน้าจึงไม่ใช่ลำดับวัตถุ

หน้า 1 ของ PDF ไม่ใช่วัตถุ 1 ความแตกต่างนี้คือแหล่งที่มาที่พบบ่อยที่สุดของบั๊กการดึงหน้าผิดลำดับในโปรแกรมแยกวิเคราะห์ (parsers) ของ PDF และวิธีแก้ปัญหาคือการอ่านข้อกำหนดมาตรฐาน (spec) แทนที่จะอ่านไบต์ของไฟล์

วัตถุ การอ้างอิง และแค็ตตาล็อก

ไฟล์ PDF คือชุดของวัตถุที่มีหมายเลขกำกับ วัตถุแต่ละชิ้นจะมีหมายเลขวัตถุและหมายเลขรุ่น (generation number) ที่ไม่ซ้ำกัน ซึ่งเขียนในรูปแบบ N G obj โดย G เกือบจะเป็น 0 เสมอในไฟล์ที่ไม่ได้รับการอัปเดตแบบเพิ่มส่วน (incrementally updated) วัตถุจะอ้างอิงถึงกันด้วยรูปแบบ N G R ดังนั้น 3 0 R จึงหมายถึง "เวอร์ชันปัจจุบันของวัตถุ 3" ส่วนท้าย (trailer) จะชี้ไปยังวัตถุแค็ตตาล็อกราก (root catalog object) ซึ่งมีรายการ /Pages ที่นำไปสู่โครงสร้างต้นไม้หน้าเว็บ สิ่งที่สามารถนำทางได้ทั้งหมดใน PDF จะเริ่มต้นจากรากนั้น ไม่ใช่จากไบต์แรกของส่วนเนื้อหาไฟล์

ตารางการอ้างอิงโยง (หรือสตรีมการอ้างอิงโยงใน PDF 1.5+) จะจับคู่หมายเลขวัตถุกับออฟเซ็ตของไฟล์ หน้าที่ของมันคือการเข้าถึงแบบสุ่ม (random access) ไม่ใช่การจัดลำดับ โปรแกรมเขียนที่สร้างเอกสารแบบเพิ่มส่วนสามารถเพิ่มวัตถุใหม่ที่ส่วนท้ายด้วยหมายเลขที่สูงกว่า ในขณะที่ตามตรรกะแล้ววัตถุเหล่านั้นมาก่อนวัตถุที่มีอยู่แล้วในลำดับหน้า นี่ไม่ใช่ข้อบกพร่อง แต่เป็นการออกแบบที่ตั้งใจไว้

โครงสร้างต้นไม้หน้าเว็บ (ISO 32000-1 §7.7.3)

ลำดับหน้าอาศัยอยู่ในโครงสร้างต้นไม้หน้าเว็บ แค็ตตาล็อกรากจะมีการอ้างอิง /Pages ที่ชี้ไปยังโหนดประเภท /Pages อาร์เรย์ /Kids ของโหนดนั้นจะแสดงรายการโหนดลูกตามลำดับการอ่าน โหนดลูกแต่ละโหนดอาจเป็นใบหน้า (leaf node) ประเภท /Page หรือโหนด /Pages ระดับกลางที่มีอาร์เรย์ /Kids เป็นของตัวเอง หน้า 1 คือใบหน้าแรกที่เข้าถึงได้จากการท่องไปในอาร์เรย์ Kids แบบ depth-first จากซ้ายไปขวา รายการ /Count บนโหนดกลางแต่ละโหนดจะแคชจำนวนรวมของใบหน้าที่สืบทอดมา ดังนั้นโปรแกรมอ่านจึงสามารถข้ามไปยังหน้า 500 ได้โดยไม่ต้องเดินดูทั้งต้นไม้

นี่คือลักษณะของโครงสร้างต้นไม้สามหน้าแบบมินิมอลในรูปแบบไวยากรณ์ PDF ดิบ:

16 0 obj
<<
  /Type /Pages
  /Count 3
  /Kids [20 0 R  1 0 R  4 0 R]
  /MediaBox [0 0 612 792]
>>
endobj

20 0 obj
<< /Type /Page  /Parent 16 0 R  /Contents 21 0 R  /Resources 22 0 R >>
endobj

1 0 obj
<< /Type /Page  /Parent 16 0 R  /Contents 2 0 R   /Resources 3 0 R >>
endobj

4 0 obj
<< /Type /Page  /Parent 16 0 R  /Contents 5 0 R   /Resources 6 0 R >>
endobj

อาร์เรย์ Kids คือ [20 0 R, 1 0 R, 4 0 R] หน้าตามตรรกะที่ 1 คือวัตถุ 20, หน้าตามตรรกะที่ 2 คือวัตถุ 1, หน้าตามตรรกะที่ 3 คือวัตถุ 4 โค้ดใดก็ตามที่วนซ้ำหมายเลขวัตถุตั้งแต่ 1 ขึ้นไปจะพบสิ่งเหล่านี้ตามลำดับ 1, 4, 20 และสร้างลำดับ หน้า 2, หน้า 3, หน้า 1 เอกสารผลลัพธ์จะถูกแสดงผลในลำดับที่สลับกันซึ่งอาจดูปกติในโปรแกรมอ่านที่ปฏิบัติตามโครงสร้างต้นไม้ และผิดพลาดอย่างร้ายแรงในโปรแกรมอ่านที่ไม่ปฏิบัติตาม

การสืบทอด (Inheritance)

โหนดระดับกลางสามารถมีคุณสมบัติที่ลูกหลานสืบทอดมา รายการสืบทอดที่พบบ่อยที่สุดคือ /MediaBox (ขนาดหน้า), /CropBox, /Resources (ฟอนต์และรูปภาพ) และ /Rotate ใบหน้าที่ไม่ได้ระบุ /MediaBox ไม่ได้แปลว่าไฟล์พัง แต่มันจะดึงค่าจากโหนดบรรพบุรุษที่ใกล้ที่สุดที่กำหนดค่านั้นไว้ ส่วนหน้าที่ระบุ /MediaBox ไว้เองจะลบล้างค่าใด ๆ ก็ตามที่กำหนดไว้โดยโหนดแม่ สำหรับหน้านั้นโดยเฉพาะ

สิ่งนี้มีความสำคัญต่อการแยกวิเคราะห์ การอ่านวัตถุ /Page โดด ๆ และทึกทักเอาว่าคุณสมบัติของมันครบถ้วนแล้ว จะทำให้รายงานขนาดหน้าผิดพลาดสำหรับหน้าใด ๆ ที่อาศัยการสืบทอด โปรแกรมอ่านที่ถูกต้องจะเดินดูตามเชน /Parent เพื่อรวบรวมคุณสมบัติที่ยังไม่พบ และจะหยุดที่โหนดราก

โครงสร้างต้นไม้ที่ซ้อนกัน (Nested trees)

ไม่มีสิ่งใดในข้อกำหนดที่จำกัดโครงสร้างต้นไม้ให้อยู่แค่ระดับเดียว เอกสารขนาดใหญ่อาจจัดกลุ่มหน้าเว็บไว้ใต้โหนดระดับกลางที่สอดคล้องกับบทความอย่างคร่าว ๆ:

2 0 obj   % root Pages node, Count = 8
<< /Type /Pages  /Count 8  /Kids [3 0 R  4 0 R] >>
endobj

3 0 obj   % first chapter, 5 pages
<< /Type /Pages  /Parent 2 0 R  /Count 5
   /Kids [10 0 R  11 0 R  12 0 R  13 0 R  14 0 R]
   /MediaBox [0 0 612 792] >>
endobj

4 0 obj   % second chapter, 3 pages
<< /Type /Pages  /Parent 2 0 R  /Count 3
   /Kids [20 0 R  21 0 R  22 0 R]
   /MediaBox [0 0 612 792] >>
endobj

อัลกอริทึมในการท่องโครงสร้างเหมือนกัน: ไปยัง Kids ตามลำดับ เข้าไปในโหนด /Pages ใด ๆ และรวบรวมโหนดใบหน้า /Page ค่า /Count ช่วยให้โปรแกรมอ่านสามารถข้ามโครงสร้างย่อยทั้งหมดได้เมื่อต้องการกระโดดไปยังหน้าที่อยู่เลยออกไป ซึ่งเป็นเหตุผลว่าทำไมจำนวนเหล่านี้จึงต้องถูกต้อง โปรแกรมแก้ไข PDF บางตัวจากปลายทศวรรษ 1990 และต้นทศวรรษ 2000 ไม่ได้คำนวณจำนวนหน้าใหม่หลังจากแก้ไขแทนที่ ดังนั้นโปรแกรมแยกวิเคราะห์ที่ปลอดภัยจึงควรตรวจสอบ /Count กับจำนวนใบหน้าที่แท้จริงแทนที่จะเชื่อเพื่อการจัดสรรอาร์เรย์

สิ่งที่เกิดขึ้นในทางปฏิบัติ

บั๊กลำดับหน้ามักจะปรากฏในสองสถานการณ์ สถานการณ์แรกคือโปรแกรมแยกวิเคราะห์แบบกำหนดเองที่สแกนหาวัตถุประเภท /Page แทนที่จะตามโครงสร้างต้นไม้ โปรแกรมจะพบทุกหน้า แต่จะอยู่ในลำดับหมายเลขวัตถุ ไม่ใช่ลำดับการอ่าน วิธีแก้ปัญหาก็เหมือนกันเสมอ: เริ่มต้นจาก trailer ค้นหาแค็ตตาล็อกราก ตาม /Pages ไป และท่องไปในอาร์เรย์ Kids

สถานการณ์ที่สองคือไฟล์ที่มีการอัปเดตแบบเพิ่มส่วน (incremental-update file) เมื่อโปรแกรมแก้ไข PDF เพิ่มการเปลี่ยนแปลงโดยไม่ได้เขียนใหม่ทั้งไฟล์ วัตถุหน้าเว็บใหม่จะได้หมายเลขวัตถุที่สูงขึ้น ในขณะที่อาร์เรย์ Kids ในโครงสร้างต้นไม้เดิมยังคงควบคุมตำแหน่งตรรกะของมัน หน้าที่เดิมเป็นวัตถุ 5 จะถูกแทนที่ด้วยวัตถุใหม่ 143 แต่อาร์เรย์ Kids ในตอนนี้อ้างอิง 143 แทน 5 เดิม ดังนั้นลำดับทางตรรกะจึงยังคงอยู่ การท่องไปตามหมายเลขวัตถุจะทำให้หน้านั้นไปอยู่ในตำแหน่งที่ผิดในลำดับ

PDF แบบ Linearized (ปรับให้เหมาะกับเว็บ) เป็นรูปแบบที่สาม: ไฟล์จะถูกจัดเรียงใหม่ทางกายภาพเพื่อให้เนื้อหาของหน้าแรกปรากฏใกล้ส่วนต้นของไฟล์สำหรับการแสดงผลอย่างรวดเร็วผ่านการเชื่อมต่อที่ช้า โครงสร้างต้นไม้ของหน้าเว็บยังคงเป็นผู้กำหนดลำดับ แต่ตารางการอ้างอิงโยงจะจับคู่กับออฟเซ็ตที่จัดเรียงใหม่ โปรแกรมแยกวิเคราะห์ที่อาศัยตำแหน่งในไฟล์แทนที่จะเป็นตาราง xref จะอ่านผิดพลาดแม้กระทั่งหน้าแรกของไฟล์ Linearized

HotPDF Delphi PDF Component จัดการเรื่องการท่องโครงสร้างต้นไม้หน้าเว็บ การแก้ปัญหาการสืบทอด และการรวม xref สำหรับอัปเดตแบบเพิ่มส่วนไว้เป็นการภายใน การทำงานกับวัตถุหน้าโดยตรงหมายความว่าลำดับอาร์เรย์ Kids ได้ถูกนำไปใช้เรียบร้อยแล้ว ดัชนีหน้าจะจับคู่กับหน้าทางตรรกะ ไม่ใช่หมายเลขวัตถุ

Fan-out และความถูกต้องของ /Count

เอกสารขนาดใหญ่ควรใช้โครงสร้าง /Pages แบบสมดุลที่มีลูกในแต่ละโหนดจำนวนพอเหมาะ แทนการใส่หน้า 10,000 หน้าไว้ในอาร์เรย์ /Kids เดียว การกระจายกิ่งช่วยให้โปรแกรมอ่านเดินลงต้นไม้เพื่อค้นหาหน้าได้โดยไม่ต้องแยกวิเคราะห์อาร์เรย์ขนาดใหญ่ทั้งหมด และค่า /Count ของทุกโหนดต้องเท่ากับจำนวนหน้าใบไม้ในโหนดย่อยของมัน

การทำให้ต้นไม้แบนลงยังถูกต้องตามมาตรฐานได้ แต่ต้องคัดลอกค่า /Resources, /MediaBox, /CropBox และ /Rotate ที่เคยสืบทอดลงไปยังหน้าใบไม้หรือโหนดรากใหม่ หากการอัปเดตแบบ incremental แก้ /Kids เพียงสาขาเดียวแต่ไม่แก้ /Count ของบรรพบุรุษ โปรแกรมอ่านอาจรายงานจำนวนหน้าหรือคำนวณดัชนีกระโดดหน้าไม่ถูกต้อง

ตัวเลข /Count จึงควรถือเป็นคำใบ้ ไม่ใช่ข้อมูลที่เชื่อถือได้สำหรับการจัดสรรหน่วยความจำ โปรแกรมตรวจสอบที่เข้มงวดควรเทียบค่ากับการเดินถึงใบหน้า /Page จริง ขณะที่ parser แบบโต้ตอบควรใช้ผลการเดินต้นไม้เป็นแหล่งความจริงเมื่อพบค่าที่ล้าหรือผิดปกติ