ข้ามไปยังเนื้อหา

PDF แท้จริงแล้วคืออะไร

Evidence: Standard-backed

PDF ไม่ใช่คำอธิบายหน้ากระดาษที่บังเอิญถูกเก็บอยู่ในไฟล์ แต่เป็นฐานข้อมูลกราฟขนาดเล็กที่มีเครื่องพิมพ์ติดมาด้วย หน้านี้อธิบายสี่ส่วนที่ PDF ทุกไฟล์ต้องมี ได้แก่ header, body, ตาราง cross-reference และ trailer รวมถึงวิธีที่ NextPDF เขียนส่วนเหล่านี้เพื่อให้โปรแกรมอ่านสามารถค้นหาอ็อบเจกต์ทุกตัวได้โดยไม่ต้องเดา

บั๊กของ PDF ส่วนใหญ่ไม่ใช่บั๊กของการเรนเดอร์ แต่เป็นบั๊กของ โครงสร้าง เช่น byte offset ที่ชี้เกินอ็อบเจกต์ที่ควรชี้ไปหนึ่งอักขระ trailer ที่ระบุ root ผิด หรือรายการ cross-reference ที่ขัดแย้งกับตำแหน่งจริงของอ็อบเจกต์ ปัญหาเหล่านี้ไม่มีข้อใดเปลี่ยนหน้าตาของหน้ากระดาษ จนกว่าโปรแกรมอ่านจะไล่ตามโครงสร้างไฟล์ผ่านอีกเส้นทางหนึ่งแล้วอ่านเลยท้ายไฟล์ไป

หากมอง PDF เป็นกล่องทึบ ความล้มเหลวเหล่านั้นจะดูเหมือนเกิดขึ้นแบบสุ่ม หากเข้าใจโมเดลของอ็อบเจกต์ ความล้มเหลวเหล่านั้นจะเผยตัวตามจริงทุกประการ คือตัวเลขที่ไม่ตรงกับตำแหน่ง การอ่านรูปแบบไฟล์ให้เข้าใจคือสิ่งที่แยกระหว่าง “PDF เสียหาย” กับ “offset ของอ็อบเจกต์ 14 ล้าสมัยเพราะตัวเขียนวัดค่าไว้ก่อนสรุปความยาวของสตรีม”

PDF มีสี่ส่วน เรียงตามลำดับในไฟล์ ดังนี้

  1. ส่วน header คือบรรทัดเดียวที่ระบุเวอร์ชัน (%PDF-2.0)
  2. ส่วน body คือลำดับของ indirect object ที่มีหมายเลขกำกับ ได้แก่ dictionary, stream, array, number, string และ name
  3. ส่วน ตาราง cross-reference (หรือใน PDF 2.0 คือ cross-reference stream) คือดัชนีค้นจากหมายเลขอ็อบเจกต์ไปยัง byte offset เพื่อให้เข้าถึงอ็อบเจกต์ใดๆ ได้โดยไม่ต้องสแกนทั้งไฟล์
  4. ส่วน trailer คือ dictionary ขนาดเล็กที่ระบุอ็อบเจกต์ root ของเอกสารและชี้ไปยังตำแหน่งเริ่มต้นของส่วน cross-reference

โปรแกรมอ่านไม่ได้อ่าน PDF จากต้นไปท้าย แต่อ่านบรรทัดสุดท้ายเป็นอันดับแรก หาตำแหน่ง startxref แล้วข้ามไปยังส่วน cross-reference และใช้ส่วนนั้นเป็นดัชนีเพื่อเข้าสู่ body รูปแบบไฟล์นี้ถูกออกแบบมาให้อ่านย้อนกลับ ข้อเท็จจริงเพียงข้อนี้อธิบายการออกแบบส่วนใหญ่ของรูปแบบไฟล์ได้

NextPDF สร้าง PDF ด้วยลำดับเดียวกับวิธีที่รูปแบบไฟล์ถูกอ่าน คือเขียนอ็อบเจกต์ก่อน บันทึก offset ทีหลัง แล้วเขียนตารางเป็นลำดับสุดท้าย

indirect object ทุกตัวจะได้รับการจัดสรรหมายเลขจาก registry เพียงตัวเดียว (src/Core/ObjectRegistry.php) registry แจกจ่ายหมายเลขตามลำดับผ่าน allocate() และ หลังจาก ที่ไบต์ของอ็อบเจกต์ถูกเขียนลงบัฟเฟอร์เอาต์พุตแล้ว จึงบันทึก byte offset ผ่าน register() offset ไม่เคยถูกคาดเดาล่วงหน้า แต่ถูกอ่านค่าจาก BinaryBuffer::getOffset() ณ ขณะที่ header ของอ็อบเจกต์ถูกเขียนออกมา นี่คือเหตุผลที่รายการ cross-reference ของ NextPDF ไม่อาจคลาดเคลื่อนจากอ็อบเจกต์ที่อธิบายได้ เพราะ offset คือค่าตำแหน่งจริงของบัฟเฟอร์ ณ ขณะนั้น

เมื่อ body เสร็จสมบูรณ์ serialization strategy เฉพาะเวอร์ชัน (src/Writer/PdfSerializationStrategy.php) จะเขียนส่วน cross-reference และ trailer ดังนี้

  • Pdf20StreamStrategy เขียน cross-reference stream แบบบีบอัด (/Type /XRef) ซึ่งเป็นค่าเริ่มต้นของ PDF 2.0
  • Pdf17TableStrategy และ Pdf14TableStrategy เขียน cross-reference table แบบดั้งเดิมขนาด 20 ไบต์ พร้อม dictionary ของ trailer แยกต่างหาก ซึ่งเป็นข้อกำหนดของโปรไฟล์ PDF/A ที่บังคับให้ใช้โครงสร้างไฟล์แบบเก่า

NextPDF เลือก strategy จากโปรไฟล์เอาต์พุต ไม่ใช่จากการอนุมาน ไม่ว่าจะเป็นแบบใด ไบต์สุดท้ายก็มีรูปแบบเดียวกัน คือส่วน cross-reference ตามด้วย startxref ตามด้วย byte offset และ %%EOF ส่วนท้ายนั้นคือสิ่งที่โปรแกรมอ่านพบเป็นอันดับแรก

  1. Step 1 of 4: ISO 32000-2 §7.5.5 — %%EOF and startxref at the file end
  2. Step 2 of 4: ISO 32000-2 §7.5.4 / §7.5.8 — the cross-reference section maps object number to offset
  3. Step 3 of 4: ISO 32000-2 §7.5.5 — the trailer names /Root, the document catalog
  4. Step 4 of 4: ISO 32000-2 §7.3.10 — each indirect object is reached at its recorded offset
วิธีที่โปรแกรมอ่านแก้หาอ็อบเจกต์ในไฟล์ NextPDF และข้อกำหนด ISO 32000-2 ที่นิยามแต่ละขั้นตอน โดยเริ่มจากท้ายไฟล์แล้วทำงานเข้าด้านใน

โครงสร้างสี่ส่วนนี้ไม่ใช่ธรรมเนียมของ NextPDF แต่เป็นข้อกำหนดเรื่องโครงสร้างไฟล์ของ Spec: ISO 32000-2, §7.5 มาตรฐานนิยาม PDF ว่าประกอบด้วย header, body ของอ็อบเจกต์, ตาราง cross-reference และ trailer และระบุว่าโปรแกรมอ่านควรแยกวิเคราะห์ไฟล์จากท้ายไฟล์ บรรทัดสุดท้ายคือ %%EOF และสองบรรทัดก่อนหน้าคือคีย์เวิร์ด startxref และ byte offset ที่ชี้ไปยังส่วน cross-reference

Evidence: Standard-backed

คำว่า indirect object ถูกนิยามว่าเป็นหมายเลขอ็อบเจกต์และหมายเลข generation คั่นด้วยช่องว่าง ตามด้วยค่าของอ็อบเจกต์ที่อยู่ระหว่างคีย์เวิร์ด obj และ endobj หมายเลขอ็อบเจกต์รวมกับหมายเลข generation ระบุอ็อบเจกต์ได้อย่างไม่ซ้ำกัน reference แบบอ้อมไปยังอ็อบเจกต์นั้นเขียนเป็นหมายเลขอ็อบเจกต์ หมายเลข generation และคีย์เวิร์ด R ObjectRegistry ของ NextPDF สะท้อนรูปแบบนี้โดยตรง คือหมายเลขตามลำดับ generation 0 สำหรับอ็อบเจกต์ที่เพิ่งเขียน และ offset ที่บันทึกไว้

ตั้งแต่ PDF 1.5 เป็นต้นมา ยังอนุญาตให้อ็อบเจกต์อยู่ภายใน object stream ได้ โดยจัดเก็บโดยไม่มีคีย์เวิร์ด obj/endobj และต้องมี generation เป็นศูนย์ cross-reference stream (/Type /XRef, Spec: ISO 32000-2, §7.5.8 ) คือกลไกของ PDF 2.0 สำหรับทำดัชนีให้ทั้งอ็อบเจกต์ปกติและอ็อบเจกต์ที่ถูกบีบอัดเหล่านี้ CrossReferenceStream ของ NextPDF สร้างสิ่งนี้ด้วยอาร์เรย์ความกว้างของฟิลด์ /W และ การบีบอัดแบบ FlateDecode

นี่คือรูปแบบของ body ของ PDF ขั้นต่ำและ trailer ของ PDF นั้น ตัวเลขในส่วน cross-reference คือ byte offset ตัวเลขเหล่านี้ต้องถูกต้องอย่างแม่นยำ นี่คือเหตุผลที่ NextPDF บันทึกค่าจากบัฟเฟอร์แทนที่จะคำนวณ

%PDF-2.0
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>
endobj
xref
0 4
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000122 00000 n
trailer
<< /Size 4 /Root 1 0 R >>
startxref
196
%%EOF

โปรแกรมอ่านเปิดไฟล์นี้จากท้ายไฟล์ คือ %%EOF ตามด้วย startxref 196 จากนั้นไปยังไบต์ที่ 196 ซึ่งเป็นจุดเริ่มต้นของ xref อ่านได้ว่าอ็อบเจกต์ 1 อยู่ที่ไบต์ที่ 9 ตาม /Root 1 0 R ไปยัง catalog แล้วเดินสำรวจ page tree จากตรงนั้น อ็อบเจกต์ 0 เป็นหัวของ free-list เสมอ โดยมี generation 65535 ซึ่งเป็นลักษณะเฉพาะที่สืบทอดมาจากการออกแบบยุคแรกสุดของรูปแบบไฟล์ และถูกทำซ้ำอย่างซื่อตรงเพราะโปรแกรมอ่านต่างๆ คาดหวังให้เป็นเช่นนั้น

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

หน้านี้อธิบายโครงสร้างไฟล์ ไม่ใช่เนื้อหาของหน้ากระดาษ วิธีที่รอยหมึกปรากฏบนหน้ากระดาษ ไม่ว่าจะเป็น content stream, graphics operator หรือการแสดงข้อความ เป็นหัวข้อแยกต่างหาก อีกทั้งยังไม่ครอบคลุมถึงสิ่งที่เกิดขึ้นเมื่อไฟล์ถูก เปลี่ยนแปลง หลังจากเขียนเสร็จแล้ว นั่นเป็นหน้าที่ของ incremental update ซึ่งตัวเขียนจะต่อท้ายส่วน cross-reference ส่วนที่สอง และ trailer จะเชื่อมโยงย้อนกลับ

NextPDF เป็น ตัวเขียน พฤติกรรมที่อธิบายไว้ในที่นี้คือวิธีที่ NextPDF serialize เอกสารที่ตนเองสร้างขึ้น NextPDF ไม่ใช่ parser สำหรับ PDF หรือเครื่องมือซ่อมแซมแบบอเนกประสงค์ NextPDF ไม่รับประกันว่าจะอ่าน สร้างใหม่ หรือกู้คืนไฟล์จากบุคคลที่สามใดๆ ที่มีตาราง cross-reference เสียหายได้ การรับประกันนี้แคบและจงใจให้เป็นเช่นนั้น ไฟล์ที่ NextPDF เขียน มี offset ที่ตรงกัน เพราะค่าเหล่านั้นได้จากการวัด ไม่ใช่การคาดการณ์

ทำไมต้องมีหมายเลข generation ในเมื่อไฟล์ใหม่ใช้ 0 เสมอ? หมายเลข generation มีไว้สำหรับการนำอ็อบเจกต์กลับมาใช้ซ้ำข้ามการอัปเดต ไฟล์ที่เพิ่งเขียนใหม่จะมีทุกอ็อบเจกต์อยู่ที่ generation 0 ส่วน generation ที่ไม่เป็นศูนย์จะปรากฏก็ต่อเมื่อไฟล์ถูกอัปเดตแบบ incremental และหมายเลขอ็อบเจกต์ถูกนำกลับมาใช้ใหม่เท่านั้น

อ็อบเจกต์สองตัวมีหมายเลขเดียวกันได้หรือไม่? ภายในส่วน cross-reference เดียวกัน ไม่ได้ เมื่อมีการอัปเดตแบบ incremental ไฟล์อาจมีสำเนาของหมายเลขอ็อบเจกต์เดียวกันหลายชุดในเชิงกายภาพ รายการ cross-reference ล่าสุดเป็นฝ่ายชนะ นั่นคือหัวข้อของหน้าถัดไป

ลำดับของอ็อบเจกต์ในไฟล์มีผลต่อเอาต์พุตหรือไม่? ไม่มี NextPDF เขียนอ็อบเจกต์ในลำดับที่กำหนดได้แน่นอนเพื่อให้การ build ทำซ้ำได้ แต่โปรแกรมอ่านค้นหาทุกสิ่งผ่านส่วน cross-reference ดังนั้นลำดับเชิงกายภาพจึงไม่มีความหมายในเชิงความหมาย

  • Incremental updates and why they matter สิ่งที่เกิดขึ้นเมื่อ PDF ที่เขียนแล้วถูกเปลี่ยนแปลง ได้แก่ ส่วนที่ต่อท้ายและ trailer ที่เชื่อมโยงเป็นลูกโซ่
  • Streams and filters วิธีที่อ็อบเจกต์สตรีมใน body ถูกบีบอัดและเข้ารหัส
  • PDF 2.0: what changed ความแตกต่างของโครงสร้างไฟล์ระหว่าง 1.7 กับ baseline 2.0 ที่ NextPDF มุ่งหมาย
  • Indirect object อ็อบเจกต์ที่มีหมายเลขกำกับใน body เขียนเป็น N G obj … endobj โดย N คือหมายเลขอ็อบเจกต์ และ G คือหมายเลข generation
  • Indirect reference ตัวชี้ไปยัง indirect object เขียนเป็น N G R ตามลำดับ
  • Cross-reference table (xref) ดัชนีจากหมายเลขอ็อบเจกต์ไปยัง byte offset ใน PDF 2.0 สิ่งนี้มักเป็น cross-reference stream (/Type /XRef) แทนที่ตารางข้อความแบบดั้งเดิมที่ใช้ 20 ไบต์ต่อรายการ
  • Trailer dictionary ที่อยู่ท้ายส่วน cross-reference ซึ่งระบุ /Root (document catalog) และ /Size และพบได้ผ่าน offset ของ startxref
  • Object stream อ็อบเจกต์สตรีมที่มีอ็อบเจกต์อ้อม (indirect object) อื่นๆ อยู่ภายใน (บีบอัดรวมกัน) สมาชิกไม่มี obj/endobj และมี generation เป็นศูนย์
  • Document catalog อ็อบเจกต์ที่ /Root ระบุถึง เป็นจุดเริ่มต้นเข้าสู่ page tree และส่วนอื่นๆ ทั้งหมดในเอกสาร