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

Object Stream และ Cross-Reference Stream ของ PDF ใน Delphi

Object stream ของ PDF 1.5 บรรจุ indirect object ขนาดเล็กจำนวนมากไว้ใน container ที่บีบอัดแบบ Flate เพียงตัวเดียว และ losLab PDF Library จะสร้างมันขึ้นตอน full save ผ่าน flag PackObjectStreams ประโยชน์ที่ได้นั้นเกิดขึ้นจริง เพราะ dictionary ของหน้า ฟอนต์ และ annotation หลายร้อยตัวที่แต่ละตัวกิน byte แบบไม่บีบอัดหลายสิบ byte จะยุบรวมเหลือเป็น blob บีบอัดเพียงไม่กี่ก้อน ต้นทุนที่ต้องจ่ายคืออ็อบเจกต์ที่ถูกบรรจุแต่ละตัวตอนนี้ต้องมี cross-reference stream มาบอกที่อยู่ของมัน

ครึ่งหลังนี้เองที่ทำให้ writer ส่วนใหญ่พัง การสร้าง container /ObjStm เป็นแค่เลขคณิต แต่การสอนกลไก cross-reference ให้ชี้เข้าไปในนั้นได้คือการออกแบบใหม่ทั้งหมด writer ที่สร้าง container ที่ถูกต้องสมบูรณ์แบบแล้วอธิบายสมาชิกของมันด้วย type-1 offset ธรรมดา จะได้ไฟล์ที่ Acrobat เปิดได้แค่นานพอจะประกาศว่าเสียหาย ทั้งสองฟีเจอร์นี้จริง ๆ แล้วเป็นฟีเจอร์เดียวกัน และบทความนี้ครอบคลุมฝั่งการเขียนของทั้งคู่ ตามที่กำหนดไว้ใน ISO 32000-1 §7.5.7 และ §7.5.8

Container ของ ObjStm มีอะไรอยู่ข้างในจริง ๆ

Object stream คือ stream ที่ byte ที่ decode แล้วประกอบด้วยสองส่วนต่อกัน และ ISO 32000-1 §7.5.7 กำหนดคีย์ dictionary ไว้พอดีสามตัวที่สำคัญต่อการสร้าง /Type /ObjStm ระบุชนิดของมัน /N บอกจำนวนสมาชิก และ /First บอกความยาวเป็น byte ของส่วน header ซึ่งเทียบเท่ากับ offset ที่ตัว body เริ่มต้น header คือคู่ของ object number กับ offset ที่คั่นด้วยช่องว่าง ส่วน body คือสมาชิกที่ serialize ต่อกันไปเรื่อย ๆ โดยแต่ละ offset วัดจากจุดเริ่มต้นของ body ไม่ใช่จากจุดเริ่มต้นของ payload ที่ decode แล้ว การอ่าน container ที่ decode สมบูรณ์แล้วจะเห็นชัดเจน ในตัวอย่างด้านล่าง /First เท่ากับ 14 เพราะ header สามบรรทัดกิน 14 byte และอ็อบเจกต์ 7 อยู่ที่ตำแหน่ง 55 byte เข้าไปใน body เพราะอ็อบเจกต์ 4 ถูก serialize ออกมาเป็น 54 ตัวอักษรบวกตัวคั่นหนึ่งตัว

// Decoded payload of: 12 0 obj << /Type /ObjStm /N 3 /First 14
//                        /Filter /FlateDecode /Length 118 >> stream
4 0
7 55
9 90
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
<< /Type /ExtGState /CA 1 /ca 1 >>
[ 0 0 595 842 ]

มีกฎการเป็นสมาชิกอยู่สองข้อที่เด็ดขาด และทั้งคู่มาจาก §7.5.7 โดยตรง stream object ไม่มีวันเป็นสมาชิกได้ เพราะ stream บรรจุ byte ดิบที่ต้องซ้อนอยู่ภายใน stream อีกตัวหนึ่ง และสมาชิกต้องเป็นค่าอ็อบเจกต์ที่สมบูรณ์เท่านั้น ไม่ใช่แค่ indirect reference เปล่า ๆ เพราะอ็อบเจกต์ที่ถูกบีบอัดซึ่งเป็นแค่ 5 0 R จะสร้าง indirection ที่ reader แก้ปัญหาไม่ได้หากยังไม่รู้ว่ามันชี้ไปที่ไหนอยู่ก่อนแล้ว losLab PDF Library กรองทั้งสองกรณีนี้ออกในขั้นตอนรวบรวมผู้สมัคร พร้อมกับ encryption dictionary และอ็อบเจกต์ 0 แล้วบรรจุสิ่งที่เหลือรอดเป็นกลุ่ม กลุ่มละ 200 ต่อ container การกำหนดเพดานนี้เป็นการตัดสินใจเรื่อง random-access ไม่ใช่ข้อจำกัดของสเปก เพราะ reader ที่ต้องการสมาชิกแค่ตัวเดียวก็ต้อง inflate ทั้ง container ดังนั้น container ที่ใหญ่เกินไปจะทำให้การค้นหาตัวเล็ก ๆ มีต้นทุนสูง

ทำไมสมาชิกของ ObjStm ต้องใช้ type-2 cross-reference entry

เพราะอ็อบเจกต์ที่ถูกบรรจุแล้วไม่มี file offset ให้บันทึก ISO 32000-1 §7.5.8 ตอบคำถามนี้ด้วย entry สามชนิดใน binary cross-reference stream คือ type 0 สำหรับอ็อบเจกต์ที่ว่าง type 1 สำหรับอ็อบเจกต์ปกติที่ใช้งานอยู่และเก็บที่ byte offset และ type 2 สำหรับอ็อบเจกต์ที่ถูกบีบอัด ซึ่งฟิลด์ข้อมูลสองตัวของมันเก็บ object number ของ container และ index ของสมาชิกภายในนั้น ไม่มีทางแสดงอ็อบเจกต์ที่ถูกบรรจุในตาราง xref แบบข้อความล้วนดั้งเดิมได้เลย ซึ่งนี่คือเหตุผลที่ PDF 1.5 เปิดตัวทั้งสองฟีเจอร์นี้พร้อมกัน

ลำดับที่ตามมานี้ทำให้แทบทุก implementation แรกสะดุด รวมถึงของเราด้วย อ็อบเจกต์ปกติได้ type-1 entry ส่วน container /ObjStm เองก็ได้ type-1 entry เช่นกัน เพราะ container เป็นแค่ indirect stream object ธรรมดาที่เขียนไว้ที่ offset จริง มีเพียงสมาชิกเท่านั้นที่ได้ type-2 entry และตัว cross-reference stream เองก็เป็น indirect object ในไฟล์ด้วย จึงต้องมี type-1 entry ของตัวเองชี้ไปยัง offset ที่มันเพิ่งถูกเขียนลงไป ซึ่งเป็น offset เดียวกับที่ startxref บันทึกไว้ writer เวอร์ชันแรก ๆ ของเราเคยตัด object number ของ container ออกจากลูปการเขียนแทนที่จะตัดสมาชิกออก ผลลัพธ์คือไฟล์ที่มี cross-reference stream แต่ไม่มี object stream เลยสักตัว โครงสร้างสอดคล้องกันดี แต่ความหมายว่างเปล่า และถูกปฏิเสธที่ปลายทาง ค่า /Size ก็ซ่อน off-by-one ที่เข้าคู่กันไว้ด้วย เพราะมันคือหมายเลขอ็อบเจกต์สูงสุดบวกหนึ่ง และ cross-reference stream เองก็ถูกจัดสรรเป็นหมายเลขอ็อบเจกต์สูงสุด จึงต้องถูกนับรวมด้วย

กำหนดขนาด array /W ทำไมสี่ byte ถึงไม่พอ

Array /W ประกาศความกว้างเป็น byte ของแต่ละฟิลด์ในสามฟิลด์ และ losLab PDF Library เขียนมันเป็น /W [1 Field2 Field3] โดยฟิลด์ 1 ตายตัวที่หนึ่ง byte สำหรับ type code และฟิลด์ 3 ตายตัวที่สอง byte ซึ่งครอบคลุมทั้งหมายเลข generation สูงสุดถึง 65535 และ index ของสมาชิกเช่นกัน ฟิลด์ 2 คือตัวที่ตายตัวไม่ได้ เพราะมันบรรทุกปริมาณสองอย่างที่ไม่เกี่ยวข้องกัน ใน type-1 entry มันคือ byte offset ที่ถูกจำกัดแค่โดยขนาดไฟล์ ส่วนใน type-2 entry มันคือ object number ของ container และใน type-0 entry มันคืออ็อบเจกต์ว่างตัวถัดไปในโซ่ ฟิลด์ 2 ที่ตายตัวสี่ byte ทำงานได้ดีจนกว่าไฟล์จะเกิน 4 GB ซึ่งตอนนั้น offset ทุกตัวที่เกินขอบเขตจะถูกตัดทอนอย่างเงียบ ๆ และทั้งตารางกลายเป็นขยะ writer จึงสแกนตารางที่ประกอบเสร็จแล้วเพื่อหาค่าที่ใหญ่ที่สุดที่ slot ฟิลด์ 2 ใด ๆ จะมี รวมถึง offset ของ cross-reference stream เองด้วย แล้วขยายฟิลด์ให้กว้างขึ้นได้ถึงแปด byte

// Field 2 must hold the largest byte offset AND the largest
// ObjStm container number AND the largest free-chain target.
MaxField2Value := XRefStart;
for X := 0 to MaxObj do
begin
  if XRefTable[X].InUse and (XRefTable[X].ObjStrNum > 0) then
    Field2Value := XRefTable[X].ObjStrNum   // type-2: container number
  else
    Field2Value := XRefTable[X].ObjPos;     // type-1 offset / type-0 next-free
  if Field2Value > MaxField2Value then
    MaxField2Value := Field2Value;
end;

Field2 := 4;
while (Field2 < 8) and
      (MaxField2Value > ((Int64(1) shl (Field2 * 8)) - 1)) do
  Inc(Field2);
Field3 := 2;   // generation numbers and member indices both fit

เมื่อรู้ความกว้างแล้ว ขนาด payload ก็รู้แน่นอน writer จึงจองหน่วยความจำ buffer ทั้งหมดล่วงหน้าแล้วเติมตาม index การต่อ entry เข้า AnsiString ทีละ byte จะทำให้การสร้างตารางกลายเป็น quadratic ซึ่งไม่มีใครสังเกตเห็นในใบแจ้งหนี้สิบหน้า แต่ทุกคนสังเกตเห็นในเอกสารที่มีสองแสนอ็อบเจกต์ ยังมีอีกสองรายละเอียดที่ทำให้ reader เข้มงวดพอใจ /Index ประกาศว่าตารางครอบคลุมช่วงหมายเลขอ็อบเจกต์ใดบ้าง และสำหรับการเขียนใหม่ทั้งหมดก็แค่ [0 N] โดยไม่มีช่องว่าง และทุก slot ที่ writer ไม่ได้เขียนออกมาจริง ๆ ต้องตั้งค่าเริ่มต้นเป็นว่างแทนที่จะเป็นใช้งานอยู่ อ็อบเจกต์ 0 อยู่หัวโซ่ว่าง แต่ละ slot ว่างเชื่อมไปยังตัวถัดไป และ slot ที่เคยเก็บอ็อบเจกต์ที่ถูกลบไว้ก่อนหน้าจะยังคงเพิ่มหมายเลข generation ขึ้นอีกหนึ่งเสมอ บทความคู่กันเรื่อง ความปลอดภัยของหน่วยความจำเมื่อ parse PDF ที่ไม่น่าเชื่อถือ อธิบายข้อโต้แย้งเรื่อง bounds แบบเดียวกันนี้จากฝั่งการอ่าน

ทำไม cross-reference stream ต้องไม่ถูกเข้ารหัสเด็ดขาด

เพราะ reader ต้อง parse มันก่อนถึงจะรู้ได้ว่าจะถอดรหัสอะไรยังไง cross-reference stream คือสิ่งที่บอก reader ว่า dictionary /Encrypt อยู่ตรงไหน ถ้า byte ของมันถูกเข้ารหัสไปด้วย reader ก็จะต้องใช้ file key เพื่อค้นหาอ็อบเจกต์ที่อธิบาย file key นั่นเอง losLab PDF Library บังคับใช้กฎนี้ด้วย predicate ตัวเดียว ShouldCryptStreamData คืนค่า False ทุกครั้งที่ stream dictionary มี /Type /XRef ดังนั้นข้อยกเว้นนี้จึงคงอยู่ไม่ว่าจะผ่าน path ไหนไปถึง serializer

Container /ObjStm ได้รับการปฏิบัติตรงข้ามกัน และความไม่สมมาตรนี้ตั้งใจให้เป็นแบบนั้น container ถูกเข้ารหัสทั้งก้อน โดยใช้ object number ของมันเองเป็นกุญแจ เหมือน stream อื่น ๆ ทั่วไป สมาชิกของมันไม่ได้ถูกเข้ารหัสทีละตัว แต่ถูกบรรจุในรูปแบบ plaintext ที่ยังไม่ถอดรหัส และการเข้ารหัสรอบเดียวที่ครอบคลุม container ที่ประกอบเสร็จแล้วก็ครอบคลุมสมาชิกเหล่านั้นด้วย รวมถึง string ด้วย การเข้ารหัสสมาชิกซ้ำสองครั้งจะสร้างไฟล์ที่ถอดรหัสออกมาเป็น ciphertext และเพราะ layer ชั้นนอกสำเร็จ ความล้มเหลวจึงปรากฏเป็น parse error ลึกเข้าไปใน object graph แทนที่จะเป็น authentication error มีอ็อบเจกต์หนึ่งที่อยู่นอกกลไกนี้ทั้งหมด นั่นคือในเอกสารที่เข้ารหัส Catalog จะถูกเก็บเป็น direct type-1 object เสมอและไม่ถูกบรรจุ เพราะการบรรจุมันจะบังคับให้ loader ต้อง inflate และถอดรหัส object stream เพื่อไปถึง document root ก่อนที่ decryption context ซึ่ง root ช่วยสร้างขึ้นจะถูกสร้างเสร็จสมบูรณ์เสียอีก

เปิดใช้การบรรจุจาก Delphi

สวิตช์สาธารณะคือ PackObjectStreams ซึ่งเปิดให้ใช้เป็นฟิลด์บน TPDFlibSaveOptions เป็น setter แยกต่างหาก SetPackObjectStreams และเป็น property บน document object ค่าเริ่มต้นคือเปิดใช้งาน และถูกล็อกอัตโนมัติตามเวอร์ชัน writer จะบรรจุก็ต่อเมื่อเอกสารเป็น PDF 1.5 ขึ้นไปอยู่แล้วเท่านั้น และมันจะเรียก internal minimum-version guard เพื่อให้เอกสารที่บรรจุแล้วถูกยกระดับเป็น 1.5 แทนที่จะติดป้ายผิด หลังการเซฟ GetLastSaveUsedObjectStreams จะรายงานว่าประตูนั้นเปิดจริงหรือไม่ ซึ่งเป็นสิ่งที่คุณควร assert ใน regression test แทนที่จะเทียบขนาด byte

var
  Doc: TPDFlib;
  Options: TPDFlibSaveOptions;
begin
  Doc := TPDFlib.Create;
  try
    if Doc.LoadFromFile('report.pdf', '') <= 0 then
      Exit;

    Doc.SetInformation(0, '1.5');        // packing is gated on PDF 1.5+

    FillChar(Options, SizeOf(Options), 0);
    Options.CompressContent    := True;
    Options.GarbageCollect     := True;  // drop orphans before packing
    Options.PackObjectStreams  := True;

    if Doc.SaveToFileOptions('report-packed.pdf', Options) = 1 then
      if Doc.GetLastSaveUsedObjectStreams = 1 then
        Writeln('Saved with ObjStm containers and an xref stream');
  finally
    Doc.Free;
  end;
end;

ลำดับระหว่างการบรรจุกับ garbage collection มีความสำคัญ การวิเคราะห์ reachability ต้องรันก่อน เพราะสมาชิกที่รอดเข้าไปอยู่ใน container จะลาก container นั้นไปด้วย ถ้าอ็อบเจกต์ที่มีชีวิตอยู่ถูกบรรจุ หมายเลข container ของมันก็ถูกเข้าถึงได้โดยนิยาม และการ sweep container ทิ้งจะทำให้สมาชิกนั้นค้างอยู่โดยไม่มีทางระบุตำแหน่งได้ การรัน collector ก่อนยังหมายความว่าอ็อบเจกต์ที่ตายแล้วจะไม่มีวันเข้าไปใน container เลย ซึ่งเป็นที่มาของการประหยัดขนาดที่ทบต้นกัน การบรรจุเสริมกับตัวช่วยลดขนาดตัวอื่น ไม่ได้มาแทนที่ บทความ การเพิ่มประสิทธิภาพขนาดไฟล์ PDF และ font subsetting ครอบคลุมตัวช่วยที่ทำงานกับ stream payload ในขณะที่ object stream ทำงานกับโครงสร้าง

ขอบเขตที่ควรรู้ก่อนเปิดใช้งาน

Incremental save ไม่มีวันบรรจุเลย incremental update จะ append อ็อบเจกต์ใหม่และ cross-reference section ใหม่ ในขณะที่ปล่อยให้ revision ก่อนหน้าคงสภาพทาง physical ไว้ไม่แตะต้อง ดังนั้นการบรรจุอ็อบเจกต์ที่มีอยู่แล้วใหม่เข้า container ใหม่จะทำให้ type-1 entry ที่ revision ก่อนหน้ายังอ้างถึงกลายเป็นกำพร้า losLab PDF Library จึงปิดการบรรจุทุกครั้งที่ append mode ทำงานอยู่ และบทความเรื่อง incremental update และ append-mode streaming ครอบคลุม path นั้นแบบเต็ม เอกสารที่ต่ำกว่า PDF 1.5 จะคงตาราง cross-reference แบบ plaintext ไว้เสมอโดยไม่มีเงื่อนไข เพราะ consumer เวอร์ชัน 1.4 ไม่มีทางรู้ว่า /ObjStm คืออะไร และการเลื่อนระดับเอกสารขึ้นอย่างเงียบ ๆ เพียงเพราะ writer อยากได้ไฟล์ที่เล็กกว่าจะเป็นการแลกที่ผิดในนามของผู้เรียก มีคีย์ทางเลือกหนึ่งที่เราตั้งใจไม่ปล่อยออกมาคือ /Extends ซึ่ง ISO 32000-1 §7.5.7 กำหนดไว้ให้ container สามารถระบุชื่อ predecessor ได้ เพื่อให้ reader ปฏิบัติต่อโซ่ของ container เป็นกลุ่มเดียวกันในทาง logic มันเป็นตัวเลือกจริง ๆ container ทุกตัวที่เราเขียนนั้นสมบูรณ์ในตัวเองและ decode ได้อย่างอิสระ และการข้ามมันไปช่วยตัดปัญหา cycle และ dangling-reference ประเภทหนึ่งออกจาก writer ไปเลย แม้ว่า reader จะยังคงต้องเคารพ /Extends เมื่อพบมันในไฟล์จาก producer รายอื่นก็ตาม

Object-stream packing และ cross-reference stream output มาพร้อมกับ losLab PDF Library สำหรับ Delphi และ C++Builder ควบคู่ไปกับ garbage collector และ content-stream optimizer ที่มันประกอบกันด้วย หน้าผลิตภัณฑ์มีเอกสารอ้างอิงของ save-options แบบเต็ม