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

สร้าง PDF ขนาดเล็กด้วยมือ: ออบเจกต์ 5 อย่างที่คุณต้องการ

โดยเนื้อแท้แล้ว PDF คือคอนเทนเนอร์ข้อความธรรมดา (plain-text container) หากเปิดไฟล์ส่วนใหญ่ด้วย hex editor จะพบว่าส่วนบนสุดสามารถอ่านได้: เป็นคอมเมนต์เวอร์ชัน, ตามด้วยชุดของวัตถุที่มีหมายเลขกำกับ, จากนั้นเป็นดัชนีขนาดเล็ก และมีตัวชี้ (pointer) อยู่ด้านล่างสุดซึ่งจะบอกโปรแกรมอ่านว่าจะต้องเริ่มต้นที่ใด หากถอดการบีบอัดออก รูปแบบการจัดเก็บนี้จะดูเข้าใจง่ายพอที่คุณจะสามารถพิมพ์เอกสารที่ใช้งานได้จริงใน text editor และใช้โปรแกรม viewer เปิดขึ้นมาดูได้ การได้ลองทำแบบนี้สักครั้งจะช่วยให้คุณเข้าใจว่า PDF ยึดโยงเข้าด้วยกันได้อย่างไรได้ดีกว่าการอ่านจากข้อกำหนดมาตรฐานใด ๆ เพราะคุณจะต้องเชื่อมต่อวัตถุเข้าด้วยกันด้วยตัวเอง และไฟล์จะปฏิเสธการเปิดจนกว่าคุณจะเชื่อมต่อมันได้ถูกต้อง

คำแนะนำนี้จะพาคุณสร้าง PDF ไฟล์ที่เล็กที่สุดแต่สามารถแสดงผลบางอย่างออกมาได้: ไฟล์ 1 หน้า, ข้อความ "Hello, World!" ด้วยฟอนต์พื้นฐาน, ลงบนกระดาษขนาด US Letter ไฟล์ผลลัพธ์นี้จำเป็นต้องมีวัตถุ 5 อย่างเป๊ะ ๆ และการจัดเก็บบันทึกเพียงไม่กี่บรรทัดรอบตัววัตถุเหล่านั้น เราจะเริ่มจากการเขียนตัววัตถุก่อน จากนั้นค่อยรวบรวมส่วนหัว (header), ตารางการอ้างอิงโยง (cross-reference table), และส่วนท้าย (trailer) ที่จะมัดรวมมันให้กลายเป็นไฟล์ที่โปรแกรมอ่านจะยอมรับได้

วัตถุ 5 อย่างที่โปรแกรมอ่านต้องการ

โปรแกรมอ่านไม่ได้สแกนเอกสาร PDF จากบนลงล่างเพื่อค้นหาเนื้อหา แต่มันเริ่มต้นที่ trailer จากนั้นก็ไล่ตามการอ้างอิง (reference) ไปยัง document catalog และเดินตามสายโซ่ของวัตถุเริ่มจากจุดนั้น ทุกวัตถุในโซ่นี้จำเป็นต้องมีอยู่จริง มิฉะนั้นการเปิดไฟล์จะล้มเหลว สำหรับเอกสารหน้าเดียว สายโซ่นี้จะสั้นมาก และแต่ละลิงก์ก็มีหน้าที่เพียงอย่างเดียว:

  • Catalog คือราก (root) มันคือวัตถุที่ trailer ชี้มาหา และรายการที่จำเป็นต้องมีในที่นี้ก็คือการอ้างอิงไปยังต้นไม้ของหน้า (page tree)
  • Pages คือโหนดของต้นไม้ของหน้า มันจะแสดงรายการหน้าในเอกสารและรายงานว่ามีกี่หน้า
  • Page บรรยายถึงหน้าทางกายภาพหนึ่งหน้า: ขนาด, ทรัพยากรที่ใช้ในการวาด และสตรีมเนื้อหา (content stream) ใดที่เป็นตัววาดหน้านี้
  • Content stream เก็บตัวดำเนินการวาด (drawing operators) ซึ่งเป็นคำสั่ง postfix ที่จัดวางข้อความและกราฟิกลงบนหน้านั้น
  • Font เป็นตัวประกาศรูปแบบตัวอักษรที่ content stream อ้างถึง หากใช้หนึ่งในฟอนต์มาตรฐานทั้ง 14 แบบ คุณก็ไม่จำเป็นต้องฝังฟอนต์ใด ๆ ลงไปเลย

แต่ละวัตถุจะระบุด้วยตัวเลขและสามารถเข้าถึงตามที่อยู่ (addressable) วัตถุทางอ้อม (indirect object) จะถูกเขียนในรูปแบบ N 0 obj ... endobj โดย N คือหมายเลขของวัตถุ และ 0 คือหมายเลขรุ่น (generation number) (มักจะเป็น 0 เสมอในไฟล์ที่คุณเขียนใหม่) ไม่ว่าคุณจะอยู่ที่ใดในไฟล์ คุณสามารถชี้ไปยังวัตถุนั้นได้ด้วยการอ้างอิง: 5 0 R หมายความว่า "วัตถุ 5" การอ้างอิงเหล่านั้นคือการเชื่อมต่อ (wiring) ตัว catalog มีค่า 2 0 R ในหมายเลขของเราเพื่อเข้าถึงต้นไม้ของหน้า ต้นไม้ของหน้ามีการอ้างอิงชี้กลับลงมายังหน้า และเป็นเช่นนี้ไปเรื่อย ๆ หากใช้หมายเลขผิด โปรแกรมอ่านก็จะตามตัวชี้ที่ค้างอยู่ (dangling pointer) ไปสู่ความว่างเปล่า

ชื่อ (Names), พจนานุกรม (dictionaries) และ สตรีม (streams)

ไวยากรณ์สามส่วนทำหน้าที่รับภาระไปเกือบทั้งหมด ชื่อ (name) จะเริ่มต้นด้วยเครื่องหมายทับ: /Type, /Page, /F0 ชื่อเป็นตัวระบุที่รับรู้ตัวพิมพ์เล็ก-ใหญ่ (case-sensitive) ไม่ใช่สตริง และ PDF ใช้สิ่งเหล่านี้เป็นกุญแจของพจนานุกรม (dictionary keys) และสำหรับแท็กว่าวัตถุนั้นคืออะไร พจนานุกรม (dictionary) คือชุดคู่ key-value ที่ห่อหุ้มในวงเล็บมุมคู่ ซึ่งทุก key ก็คือชื่อ: << /Type /Page /MediaBox [0 0 612 792] >> ตัว value ของมันสามารถเป็นตัวเลข, ชื่อ, อาร์เรย์ (array) ในวงเล็บเหลี่ยม, การอ้างอิง หรือพจนานุกรมซ้อนทับกัน วัตถุ PDF ส่วนใหญ่จะเป็นพจนานุกรม

สตรีม (stream) คือพจนานุกรมที่มีกลุ่มไบต์ (bytes) คั่นกลางระหว่างคำสำคัญ (keywords) stream และ endstream นี้นี่เองคือพื้นที่ของตัวดำเนินการวาดหน้า (page-drawing operators), และในไฟล์ใช้งานจริง ก็เป็นที่จัดเก็บภาพบีบอัดและฟอนต์ฝังตัวด้วยเช่นกัน พจนานุกรมของสตรีมจะบรรยายถึงไบต์เหล่านั้น; ในไฟล์แบบโปรดักชันจะต้องมีรายการ /Length เพื่อบอกจำนวนไบต์ที่แน่นอน และมักจะมี /Filter เช่น /FlateDecode เมื่อข้อมูลถูกบีบอัด เราจะพึ่งพาเครื่องมือในการกรอก /Length เพราะการนับจำนวนไบต์ด้วยมือเป็นส่วนของการฝึกฝนนี้ที่ไม่มีประโยชน์ในแง่การเรียนรู้ และมีโอกาสสูงที่จะคลาดเคลื่อนไปหนึ่งหน่วย (off-by-one) จนทำให้ไฟล์เสียหายได้

การเขียนวัตถุ (Writing the objects)

นี่คือวัตถุทั้ง 5 ตัวตามลำดับ รายละเอียดของแกนพิกัดที่ต้องจดจำไว้ก่อนอ่าน content stream คือ: PDF วัดขนาดจากมุมซ้ายล่างของหน้าในหน่วยพอยต์ (points) ซึ่งหนึ่งพอยต์เท่ากับ 1/72 นิ้ว และค่า Y จะเพิ่มขึ้นในทิศทางด้านบน กระดาษขนาด US Letter มีขนาด 612 x 792 พอยต์ ดังนั้นพิกัด 50 700 จึงตกไปอยู่ใกล้ด้านซ้ายของขอบมุมด้านบน ไม่ใช่ด้านล่าง

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]
   /Resources << /Font << /F0 4 0 R >> >>
   /Contents 5 0 R
>>
endobj

4 0 obj
<< /Type /Font
   /Subtype /Type1
   /BaseFont /Helvetica
>>
endobj

5 0 obj
<< /Length 44 >>
stream
BT
/F0 36 Tf
50 700 Td
(Hello, World!) Tj
ET
endstream
endobj

เมื่ออ่านการอ้างอิง โครงสร้างก็จะปรากฏให้เห็น วัตถุ 1 คือ catalog ชี้ /Pages ไปที่วัตถุ 2 วัตถุ 2 คือต้นไม้ของหน้า (page tree) ใส่ชื่อวัตถุ 3 ไว้ในรายการ /Kids และประกาศ /Count 1 วัตถุ 3 คือหน้า (page) ชี้กลับที่ /Parent โยงไปยังวัตถุ 2 (ทั้งต้นไม้และหน้าจะต้องอ้างอิงกลับเข้าหากันและกันเสมอตามข้อกำหนด) กำหนดขนาดให้ตัวเองด้วย /MediaBox ประกาศใช้งานฟอนต์ในชื่อเรียกภายใน /F0 ภายใต้ /Resources และระบุให้วัตถุ 5 เป็นเนื้อหาหลัก (/Contents) วัตถุ 4 คือฟอนต์: เราเลือก /BaseFont /Helvetica ซึ่งเป็น 1 ใน 14 รูปแบบตัวอักษรมาตรฐานที่โปรแกรมอ่านมาตรฐานทุกตัวมีอยู่แล้ว จึงไม่ต้องฝังข้อมูลใด ๆ และวัตถุ 5 คือกระแสเนื้อหา (content stream)

สตรีมเนื้อหา (content stream) บอกอะไรกันแน่

ข้อมูลภายในสตรีมคือชุดโปรแกรมขนาดจิ๋วในภาษากำหนดรายละเอียดหน้าของ PDF ซึ่งเป็นแบบ postfix: ตัวถูกดำเนินการ (operands) จะมาก่อน ตามด้วยตัวดำเนินการ (operator) ที่รับคำสั่งไปทำงาน เพียงห้าบรรทัดก็ทำงานได้แล้ว BT และ ET เริ่มต้นและสิ้นสุดวัตถุข้อความ (text object); ทุกสิ่งที่ใช้เพื่อวางตำแหน่งหรือแสดงข้อความจะต้องอยู่ระหว่างเครื่องหมายเหล่านี้ /F0 36 Tf ทำหน้าที่เซ็ตฟอนต์ปัจจุบันเป็นทรัพยากรที่ชื่อว่า /F0 ที่ขนาด 36 พอยต์ (Tf ก็คือ "set text font and size") 50 700 Td สั่งขยับเลื่อนตำแหน่งข้อความไปยังจุด (50, 700) ในพิกัดของหน้า (Hello, World!) Tj แสดงสตริงซึ่ง PDF เขียนในรูปแบบข้อความตัวอักษรดิบ ๆ ในวงเล็บ และประทับด้วยการวาดลงไปด้วย Tj ที่ตำแหน่งปัจจุบัน หากละทิ้ง BT/ET โปรแกรมอ่านที่เข้มงวดจะปฏิเสธตัวดำเนินการข้อความนั้น; หากลืมเซ็ตฟอนต์ก่อนเรียกใช้ Tj ก็จะไม่มีฟอนต์ปัจจุบันให้ใช้วาด

ส่วนค่า /Length 44 ในพจนานุกรมของสตรีมคือจำนวนไบต์ระหว่าง stream และ endstream และค่านี้ต้องถูกต้องแม่นยำ นี่คือเหตุผลว่าควรให้เครื่องมือเป็นตัวคำนวณแทนการนับด้วยมือ โดยเฉพาะอย่างยิ่งเพราะการที่โปรแกรมแก้ไข (editor) ของคุณเขียนบรรทัดสิ้นสุดด้วย LF หรือ CRLF จะเปลี่ยนจำนวนรวมนั้น

ส่วนหัว (Header), xref และ ตัวตามหลัก (trailer)

ตัววัตถุคือตัวเนื้อหา แต่ส่วนโครงสร้างสามชิ้นจะเปลี่ยนมันให้กลายเป็นไฟล์ ส่วนแรกสุดก็คือ header หรือบรรทัดแรกสุด ซึ่งเป็นการตั้งชื่อรูปแบบและเวอร์ชัน:

%PDF-1.7

เครื่องหมาย % เป็นการเริ่มต้นข้อความแสดงความคิดเห็น (comment) ในไวยากรณ์ PDF แต่โปรแกรมอ่านจะปฏิบัติต่อคอมเมนต์เฉพาะนี้ในฐานะที่เป็นสัญลักษณ์แสดงรูปแบบ (format signature) และอ่านเวอร์ชันจากมัน สำหรับตัวเขียนโปรแกรมจริง ๆ จะต่อท้ายด้วยบรรทัดคอมเมนต์ที่มีบิตเลขลำดับสูง ๆ ในทันทีเพื่อเป็นการบอกใบ้แก่เครื่องมือในการส่งถ่ายไฟล์ว่าไฟล์นี้เป็นรูปแบบไบนารีและห้ามแปลงสภาพไปเป็นแบบข้อความ (text)

ที่ส่วนท้ายสุดของไฟล์คือตารางการอ้างอิงโยง (cross-reference table) ซึ่งเป็นดัชนีที่ทำให้การเข้าถึงแบบสุ่ม (random access) เกิดขึ้นได้ มันทำหน้าที่บันทึกไบต์ออฟเซ็ตของวัตถุทุกตัวที่เริ่มต้นจากตัวไฟล์ ดังนั้นโปรแกรมอ่านจะสามารถหาช่องทางเพื่อวิ่งกระโดดไปยังออบเจกต์ 3 ได้เลย โดยที่ไม่ต้องไปแวะคอยอ่านวัตถุที่ 1 กับ 2 เสียก่อน ตารางชุดนี้ถูกฟิกซ์เอาไว้ตายตัว: รายการจะมีความกว้างคงที่ 20 ไบต์ต่อบรรทัดรวมถึงตัวขึ้นบรรทัดใหม่ ประกอบด้วยออฟเซ็ต 10 หลัก, เลขเจเนอเรชัน 5 หลัก, คำค้นหา (n สำหรับใช้งาน หรือ in-use, และ f สำหรับว่าง หรือ free) และจบด้วยตัวยุติ (terminator) สองไบต์ สำหรับรูปแบบตารางที่ถูกต้องตามรายการทั้ง 6 ของเรา (วัตถุ 0 เป็นส่วนหัวของ free-list เสมอ) จะมีหน้าตาแบบนี้:

xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000235 00000 n
0000000308 00000 n
trailer
<< /Size 6
   /Root 1 0 R
>>
startxref
408
%%EOF

เลขออฟเซ็ตเหล่านี้นี่แหละที่เป็นความยุ่งยากของการเขียน PDF ด้วยมือ แต่ละตัวคือตำแหน่งจุดเริ่มต้นของไบต์ที่ตรงกับ N 0 obj ขึ้นมา และค่าออฟเซ็ตทุกตัวจะเปลี่ยนทันทีถ้าหากคุณเพิ่มอักขระเข้าไปในจุดใดก็ตามที่อยู่ด้านบน ส่วน trailer จะเป็นจุดเริ่มต้นที่โปรแกรมอ่านใช้เป็นส่วนสุดท้ายและส่วนแรก: /Root 1 0 R ตั้งชื่อ catalog, /Size 6 ระบุจำนวนวัตถุ และ startxref 408 ให้ไบต์ออฟเซ็ตของคำว่า xref เอง โปรแกรมอ่านจะเปิดไฟล์, กระโดดไปที่ส่วนท้าย, อ่าน startxref, ค้นหาตารางการอ้างอิงโยง, และจากตรงนั้นไปถึง catalog และทุกอย่างข้างใต้มัน %%EOF เป็นการทำเครื่องหมายไบต์สุดท้าย

ปล่อยให้เครื่องมือแก้ไขจำนวนไบต์

เลขออฟเซ็ตด้านบนเป็นเพียงตัวอย่างให้เห็นภาพ ในทางปฏิบัติ เลขออฟเซ็ตจะผิดเมื่อคุณพิมพ์เสร็จ เพราะมันขึ้นอยู่กับโครงสร้างไบต์ที่แน่นอนของไฟล์ของคุณ แทนที่จะคำนวณใหม่ ให้เขียนโครงสร้างพร้อมค่าตัวยึด (placeholder) และใช้ยูทิลิตี (utility) สร้างตารางการอ้างอิงโยงและความยาวสตรีมใหม่ pdftk ซึ่งเป็นโปรแกรมข้ามแพลตฟอร์มที่ฟรี จะทำสิ่งนี้ได้ในการทำงานรอบเดียว (one pass):

pdftk hello-draft.pdf output hello.pdf

มันจะวิเคราะห์วัตถุของคุณ คำนวณไบต์ออฟเซ็ตแต่ละอันให้ใหม่ เติมค่า /Length ที่ถูกต้อง เขียนตาราง xref และ trailer ที่ใช้งานได้ และแสดง hello.pdf ออกมา เปิดไฟล์นั้นด้วยโปรแกรม viewer ตัวไหนก็ได้ แล้วคุณจะได้หน้ากระดาษหนึ่งหน้าพร้อมข้อความ "Hello, World!" ด้วยฟอนต์ Helvetica ขนาด 36 พอยต์ อยู่ใกล้ด้านบน Qpdf ก็ทำหน้าที่แบบเดียวกัน และโปรแกรม viewer หลายตัวก็จะแก้ไขไฟล์ที่ผิดรูปเล็กน้อยให้โดยอัตโนมัติขณะเปิด เหตุผลที่ต้องพึ่งพาเครื่องมือตรงนี้ไม่ใช่เพราะความขี้เกียจ แต่เป็นเพราะการคำนวณออฟเซ็ตเป็นเพียงส่วนเดียวของรูปแบบที่ไม่มีเนื้อหาเชิงแนวคิดเลยและมีอัตราการผิดพลาดสูงที่สุด ดังนั้นการทำให้อัตโนมัติจึงช่วยให้คุณจดจ่อกับการเรียนรู้โครงสร้างได้

เหตุใดจึงสามารถขยายเป็นเอกสารในชีวิตจริงได้

สำหรับเอกสารขนาดร้อยหน้า รูปแบบที่คุณเพิ่งสร้างไปไม่มีอะไรเปลี่ยนแปลง catalog ยังคงอยู่ที่ราก (root), ต้นไม้ของหน้ายังคงรวบรวมหน้ากระดาษ, และแต่ละหน้าก็ยังคงชี้ไปยังทรัพยากรและ content stream ของมัน สิ่งที่เติบโตคือความกว้าง ไม่ใช่แกนหลัก: ต้นไม้ของหน้าจะแตกกิ่งก้านเพื่อให้โปรแกรมอ่านสามารถข้ามต้นไม้ย่อย (subtrees) ทั้งหมดได้, content streams นำพากลุ่มตัวดำเนินการหลายร้อยตัวแทนที่จะเป็นแค่ห้าตัว, ฟอนต์จะถูกฝังเป็นวัตถุแบบสตรีมของมันเองพร้อมตารางความกว้างและการเข้ารหัส, และภาพก็จะเข้ามาในรูปสตรีมด้วยตัวกรอง (filters) สำหรับภาพโดยเฉพาะ ไฟล์สมัยใหม่ยังมักจะบรรจุวัตถุหลายตัวรวมไว้ในสตรีมวัตถุที่ถูกบีบอัด และแทนที่ตาราง xref ธรรมดาด้วยสตรีมของการอ้างอิงโยง (cross-reference stream) นี่คือเหตุผลว่าทำไมการเปิด PDF แบบจริงจังในโปรแกรม text editor มักจะแสดงตัวอักษรแปลกประหลาดเรียงกันเป็นกำแพงไบนารี แต่แบบจำลองหลักด้านล่างก็ยังคงเป็นแบบจำลองเดียวกันกับตัวไฟล์ที่คุณทำด้วยมือนั้น สำหรับกราฟวัตถุที่กว้างขึ้นและความสัมพันธ์ระหว่าง catalog, page tree และพจนานุกรมทรัพยากร (resource dictionaries) ในเอกสารที่มีขนาดใหญ่ขึ้น สามารถศึกษาเพิ่มเติมได้ที่ ทัวร์เจาะลึกโครงสร้างเอกสาร PDF และบทความ ภาพรวมโครงสร้างไฟล์ ที่ครอบคลุมเรื่องการอัปเดตแบบเพิ่มส่วน (incremental updates) และการผูกสายโซ่ trailer ข้ามการแก้ไขเวอร์ชัน

จากการเขียนด้วยมือสู่ไลบรารี

การพิมพ์สร้างวัตถุด้วยมือเป็นแบบฝึกหัดการเรียนรู้ ไม่ใช่เทคนิคสำหรับการทำงานจริง ทันทีที่คุณต้องการใช้ฟอนต์จริง, การตัดคำข้อความ (wrapped text), รูปภาพ, หรือหน้าที่มากกว่าแบบง่าย ๆ ภาระการบันทึกระดับไบต์ (byte bookkeeping) ที่ pdftk ได้ช่วยจัดการแก้ให้ จะกลายมาเป็นเนื้องานทั้งหมด และคุณจะต้องการไลบรารีที่จัดการหน้าที่นี้ให้ วัตถุทั้ง 5 ยังคงถูกเขียนออกมาเหมือนเดิม แต่ตัวไลบรารีจะคำนวณออฟเซ็ตทั้งหมด, จัดการพจนานุกรมฟอนต์และทรัพยากร และบีบอัด content streams โดยที่คุณไม่ต้องติดตามแม้แต่ไบต์เดียว ใน Delphi และ C++Builder คุณสามารถใช้ HotPDF Component ซึ่งลดขนาดไฟล์ทั้งหมดนี้ลงให้เหลือเพียงการเรียก (calls) ไม่กี่ครั้ง: ตั้งค่าเริ่มต้นเอกสาร, เรียกใช้งาน BeginDoc, ใช้ SetFont และ TextOut เพื่อวางข้อความคำทักทายเดิม, แล้วปิดด้วย EndDoc เพื่อเขียน catalog, page tree, xref, และ trailer ที่ถูกต้อง การเข้าใจโครงสร้างตัววัตถุที่ซ่อนอยู่ข้างใต้คือสิ่งที่จะทำให้คุณเข้าใจและสามารถอธิบายได้เกี่ยวกับผลลัพธ์ในเวลาที่เอกสารไม่เรนเดอร์ในแบบที่คุณตั้งใจไว้