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

การตรวจสอบ PDF ที่ถูกบีบอัด: Object และ XRef Streams

คุณเขียนโปรแกรมตรวจสอบ (validator) ขนาดเล็กขึ้นมา มันจะเปิด PDF ค้นหาไปจนสุด มองหา startxref อ่านออฟเซ็ต แล้วคาดหวังว่าจะพบกับคีย์เวิร์ด xref พร้อมกับตาราง cross-reference ความกว้างคงที่อยู่ด้านล่าง จากตารางนั้น มันจะรวบรวมออฟเซ็ตของอ็อบเจ็กต์ แล้วจึงสแกนย้อนกลับไปหาคีย์เวิร์ด trailer เพื่อค้นหา /Root และ /Size มันทำงานได้อย่างสมบูรณ์แบบกับไฟล์ทุกไฟล์ที่คุณสร้างขึ้นเพื่อทดสอบ จากนั้นไฟล์ที่ถูกสร้างขึ้นโดยโปรแกรม Word เวอร์ชันปัจจุบัน หรือโดยไลบรารีที่กำหนดเป้าหมายไปที่รูปแบบ PDF 1.5 ก็มาถึง และโปรแกรมตรวจสอบก็จะประกาศว่าไฟล์นั้นเสีย ไม่มีคีย์เวิร์ด xref ตรงจุดที่ออฟเซ็ตชี้ไป ไม่มีดิกชันนารี (dictionary) trailer อยู่ที่ใดเลย และตารางอ็อบเจ็กต์ที่เครื่องมือตรวจสอบสร้างขึ้นมาก็แทบจะว่างเปล่า ไฟล์นั้นเป็นไฟล์ที่ใช้งานได้ โปรแกรมตรวจสอบกำลังอ่านมันผ่านเลนส์ที่มีอายุสิบห้าปี

นี่คือสาเหตุที่พบบ่อยที่สุดเพียงข้อเดียวที่ทำให้การตรวจสอบ PDF ระดับไบต์ซึ่งเขียนขึ้นตามรูปแบบดั้งเดิม (classic layout) ล้มเหลวกับเอกสารสมัยใหม่ โครงสร้างที่มันพึ่งพานั้น นั่นคือตาราง cross-reference ที่เป็นข้อความธรรมดา (plaintext) และคีย์เวิร์ด trailer ได้ถูกกำหนดให้เป็นส่วนเสริม (optional) ในรูปแบบ PDF 1.5 และมักจะหายไปในไฟล์หลายๆ ไฟล์ ฟีเจอร์สองอย่างที่มาแทนที่คือ: cross-reference stream และ compressed object stream ทั้งสองถูกอธิบายไว้ใน ISO 32000-1 และเครื่องมือตรวจสอบที่ไม่รู้จักพวกมันจะมองเห็นไฟล์ที่สมบูรณ์แข็งแรงเป็นเพียงเศษซากของอ็อบเจ็กต์ที่ขาดหายไป

สิ่งที่ PDF 1.5 เปลี่ยนแปลงเกี่ยวกับส่วนท้ายของไฟล์

ISO 32000-1 §7.5.8 ระบุถึง cross-reference stream และ §7.5.7 ระบุถึง object stream ชนิด /ObjStm เมื่อรวมเข้าด้วยกัน พวกมันจะปล่อยให้ผู้เขียน (writer) สามารถทิ้งโครงสร้างสองส่วนที่เครื่องมือแยกวิเคราะห์รูปแบบดั้งเดิม (classic parser) มักจะใช้ ไฟล์ PDF 1.5 อาจจะลงเอยด้วยการไม่มีตาราง xref เลยก็เป็นได้ อ็อบเจ็กต์ที่ startxref ชี้ไปจะทำหน้าที่แทน ซึ่งเป็นอ็อบเจ็กต์แบบสตรีมธรรมดาที่ดิกชันนารีของมันจะพก /Type /XRef ไปด้วย และสตรีมนั้นจะเก็บข้อมูล cross-reference ไว้ในรูปแบบไบนารีที่กะทัดรัด (compact binary) และไม่มีคีย์เวิร์ด trailer ด้วยเช่นกัน เพราะในตอนนี้ trailer กลายเป็นดิกชันนารีของสตรีมเองแล้ว คีย์ (keys) ที่โปรแกรมแยกวิเคราะห์แบบเก่าตามล่าหา ทั้ง /Root, /Size และ /ID ล้วนอาศัยอยู่ภายในดิกชันนารีนั้น

การเปลี่ยนแปลงครั้งที่สองคือการย้ายตำแหน่งของตัวอ็อบเจ็กต์เอง แทนที่จะเขียนอ็อบเจ็กต์ทางอ้อมแต่ละตัวด้วยไบต์ออฟเซ็ตของมันเอง นักเขียน (writer) จะสามารถจัดเก็บอ็อบเจ็กต์ขนาดเล็กจำนวนมาก ไม่ว่าจะเป็นดิกชันนารีหน้า (page dictionaries), ดิกชันนารีคำอธิบายประกอบ (annotation dictionaries), โครงสร้างต้นไม้ (structure tree), ลงไปใน object stream เพียงสายเดียว แล้วทำการบีบอัดคอนเทนเนอร์ทั้งหมดด้วย Flate อ็อบเจ็กต์เดี่ยวๆ จะไม่มีไบต์ออฟเซ็ตในไฟล์อีกต่อไป พวกมันจะมีตำแหน่งอยู่ภายใน blob ที่ถูกบีบอัดเอาไว้ เครื่องมือตรวจสอบที่ทำการสแกนไบต์ดิบ (raw bytes) เพื่อหา 1 0 obj จะไม่มีวันพบพวกมัน เพราะข้อความนั้นจะมีอยู่ก็ต่อเมื่อขยายไฟล์ (inflation) ออกมาเท่านั้น สำหรับเครื่องมือแยกวิเคราะห์แบบคลาสสิกแล้ว มันจะมองว่าเอกสารหายไปครึ่งหนึ่งเลยทีเดียว

คีย์ท้ายกระดาษ (trailer keys) เป็นข้อความธรรมดา แม้จะอยู่ในไฟล์ที่ถูกบีบอัดก็ตาม

ส่วนที่น่าอุ่นใจก็คือการอ่านส่วนท้าย (trailer) ของ cross-reference stream ไม่จำเป็นต้องขยายไฟล์ (inflating) แต่อย่างใด อ็อบเจ็กต์สตรีมจะถูกเขียนเป็นดิกชันนารีตามด้วยคีย์เวิร์ด stream และตามด้วยไบต์ที่ถูกบีบอัด ดิกชันนารีนั้นจะเป็นข้อความธรรมดา ดังนั้นเมื่อ startxref ชี้ไปที่ cross-reference stream ไบต์ที่อยู่ตามหลังตัวเลขของอ็อบเจ็กต์จะดูเหมือนดิกชันนารีธรรมดาๆ และค่า /Root, /Size และ /ID ก็จะตั้งอยู่ที่นั่นอย่างชัดเจน ก่อนที่คีย์เวิร์ด stream และข้อมูล Flate จะเริ่มทำงาน

นั่นหมายความว่าเครื่องมือตรวจสอบสามารถเรียนรู้ข้อเท็จจริงสามประการที่มันต้องการมากที่สุดได้ นั่นคือ แคตตาล็อกอยู่ที่ใด ไฟล์นั้นมีอ็อบเจ็กต์อยู่กี่ตัว และตัวระบุไฟล์ (file identifier) คืออะไร โดยการแยกวิเคราะห์เฉพาะดิกชันนารีของสตรีมเท่านั้น ไม่จำเป็นต้องแตกไฟล์ (decompress) ข้อมูล cross-reference และไม่ต้องแปลความหมายรายการไบนารีที่อยู่ภายใน งานที่ทำให้เครื่องมือแยกวิเคราะห์ไร้เดียงสาต้องพ่ายแพ้นั้นไม่ใช่การอ่านตัวส่วนท้าย (trailer); แต่เป็นการค้นหาอ็อบเจ็กต์ สิ่งเหล่านี้คือสองปัญหาที่สามารถแยกออกจากกันได้ และการแก้ปัญหาแรกก็คือของถูก

Object streams: ส่วนหัว จากนั้นก็เป็น Flate blob

object stream คือคอนเทนเนอร์ชนิดหนึ่ง ดิกชันนารีของมันพกค่า /Type /ObjStm, ค่า /N ที่บอกจำนวนของอ็อบเจ็กต์ที่ถูกบรรจุไว้ภายใน, และค่า /First ที่ให้ข้อมูลไบต์ออฟเซ็ต, ภายในข้อมูลที่ถูกขยาย (inflated data), เพื่อใช้บอกตำแหน่งที่เนื้อหาของอ็อบเจ็กต์ตัวแรกเริ่มต้น เพย์โหลด (payload) ที่ถูกบีบอัด เมื่อขยายออกแล้ว จะเริ่มต้นด้วยส่วนหัวขนาดเล็กที่มีค่าตัวเลข (integer) เป็นคู่ๆ ตามจำนวนของ /N แต่ละคู่คือหมายเลขหน้าของอ็อบเจ็กต์ และออฟเซ็ตเนื้อหาของอ็อบเจ็กต์นั้นที่สัมพันธ์กับ /First หลังจากส่วนหัวก็จะเป็นตัวเนื้อหาของอ็อบเจ็กต์เองที่เรียงต่อกันไป

การขยายโครงสร้างนั้นเป็นเรื่องของกลไกเมื่อไบต์ข้อมูลถูกขยายออก คุณอ่านดิกชันนารีเพื่อให้ได้ค่า /N และ /First, ขยายสตรีมด้วย Flate decoder, วนดูคู่ /N ตัวนำหน้าเพื่อดูว่าหมายเลขหน้าอ็อบเจ็กต์ใดอยู่ที่ออฟเซ็ตใด, แล้วจึงยกเนื้อหาแต่ละอันออกมาเหมือนกับว่ามันเป็นอ็อบเจ็กต์ทางอ้อมธรรมดาๆ ตัวพึ่งพา (dependency) เพียงตัวเดียวจริงๆ คือ Flate decoder และคุณก็มีมันอยู่แล้ว: Delphi จัดส่ง System.ZLib มาให้ และ Free Pascal ก็จัดส่งยูนิต zstream มาให้เช่นกัน ซึ่งทั้งคู่จะห่อหุ้ม zlib และขยาย Flate stream ดิบๆ ออกมาโดยไม่ต้องใช้โค้ดของบุคคลที่สาม รูทีน (routine) ที่ต่อท้ายอ็อบเจ็กต์ที่ถูกดึงออกมาแต่ละตัวเข้ากับตารางอ็อบเจ็กต์ของเครื่องมือตรวจสอบจะทำให้เครื่องมือตรวจสอบในส่วนที่เหลือ ซึ่งก็คือส่วนที่เดินสำรวจ /Root และตรวจสอบต้นไม้หน้า (page tree) ทำงานได้แบบเดียวกับที่มันทำบนไฟล์คลาสสิกทุกประการ

สิ่งที่คุณไม่จำเป็นต้องดำเนินการ

มันง่ายมากที่คุณจะประเมินเนื้องานสูงเกินจริง การอ่านคีย์ (keys) ของส่วนท้าย (trailer) จากไฟล์ที่ถูกบีบอัดไม่จำเป็นต้องถอดรหัสรายการไบนารีของ cross-reference stream รูปแบบ §7.5.8 cross-reference stream ใช้ประเภทรายการ (entry types) สามแบบ และประเภทรายการแบบ 2 ซึ่งเป็นตัวที่บอกว่า อ็อบเจ็กต์นี้อาศัยอยู่ภายใน object stream N ที่ดัชนี i คือสิ่งที่คุณต้องถอดรหัสเพื่อสร้างแผนที่ออฟเซ็ตที่สมบูรณ์ คุณจำเป็นต้องใช้แผนที่นั้นเพื่อแก้ไขอ็อบเจ็กต์ที่ระบุได้ยากจากหมายเลข คุณไม่จำเป็นต้องใช้มันเพื่ออ่าน /Root, /Size และ /ID ซึ่งอยู่ในดิกชันนารีแบบข้อความธรรมดา (plaintext dictionary) และคุณไม่จำเป็นต้องใช้มันเพื่อขยาย object streams เพราะแต่ละ /ObjStm จะประกาศเนื้อหาของตัวเองผ่าน /N และ /First

คุณไม่จำเป็นต้องจัดการกับฟังก์ชันการพยากรณ์ (predictor functions) ของ PNG และ TIFF ที่ cross-reference stream อาจนำมาใช้ผ่าน /DecodeParms เพียงเพื่อให้ได้คีย์ (keys) ของส่วนท้าย (trailer) predictors จะทำการกรองแถวของไบนารี cross-reference เพื่อทำให้สามารถบีบอัดข้อมูลได้ดียิ่งขึ้น; พวกมันไม่เกี่ยวข้องกับดิกชันนารีที่นำหน้าสตรีมเลย การอัปเกรดเพียงเล็กน้อยที่ทำให้โปรแกรมตรวจสอบแบบเก่าสามารถเข้าใจ PDF สมัยใหม่ได้จึงมีเพียงนิดเดียว: เมื่อ startxref ปักหมุดที่สตรีมแทนที่จะเป็นคีย์เวิร์ด xref ให้แยกวิเคราะห์ดิกชันนารีสตรีมเพื่อหาคีย์ส่วนท้าย แล้วขยายอ็อบเจ็กต์ /ObjStm ใดๆ ที่คุณพบเพื่อนำเนื้อหาไปเข้าสู่ตารางอ็อบเจ็กต์ การถอดรหัส (Decoding) รายการประเภท 2 และ predictors ถือเป็นงานที่แยกต่างหากและมีขนาดใหญ่กว่า ซึ่งคุณสามารถชะลอเอาไว้ก่อนได้จนกว่าคุณจะต้องการใช้ความละเอียดของอ็อบเจ็กต์แบบสุ่มอย่างแท้จริง

ทำไมการตรวจสอบความสอดคล้องจึงต้องขยายสตรีม (streams) ก่อน

สิ่งนี้จะหยุดความเป็นวิชาการลงในทันทีที่คุณทำการตรวจสอบความสอดคล้องกับมาตรฐาน (profile check) โปรแกรมตรวจสอบ PDF/A หรือ PDF/X จะทำการตรวจสอบอ็อบเจ็กต์ที่เฉพาะเจาะจง: แคตตาล็อกเอกสารเพื่อค้นหาอาร์เรย์ (array) ของ /OutputIntents, สตรีม /Metadata เพื่อค้นหาแพ็กเกจ XMP ที่มีตัวระบุ (identifier) ที่ถูกต้อง, descriptor ของแต่ละฟอนต์ที่ใช้ค้นหาไฟล์ฟอนต์ที่ถูกฝัง (embedded) เข้ามา, ส่วนท้าย (trailer) สำหรับ /ID ในไฟล์ที่ถูกบีบอัด อ็อบเจ็กต์เหล่านั้นส่วนใหญ่จะอยู่ภายใน object streams เครื่องมือตรวจสอบที่ยังไม่ได้ขยาย (expand) object streams จะไม่สามารถมองเห็นคีย์ (keys) ของแคตตาล็อก ไม่สามารถค้นหา metadata และไม่สามารถจำแนกฟอนต์ต่างๆ ได้ มันจะรายงานเอกสารที่สมบูรณ์แบบว่าขาด output intent ขาด XMP และขาดโครงสร้างไปครึ่งหนึ่ง เพราะหลักฐานที่มันต้องการยังคงนั่งรออยู่ใน Flate blob ที่ไม่เคยถูกขยาย (inflate) ออกมา

ลำดับนั้นมีความสำคัญ การขยายข้อมูล (Expansion) จะต้องเกิดขึ้นก่อนที่การตรวจสอบ (checks) จะเริ่มต้นขึ้น ไม่ใช่ทำควบคู่กันไป เพราะการตรวจสอบทุกๆ ด่านล้วนสันนิษฐานว่าสามารถเข้าถึงอ็อบเจ็กต์ได้ด้วยตัวเลข หากคุณผูกเอาการตรวจสอบความสอดคล้องกับมาตรฐานเข้าไปปะทะกับการสแกนไบต์ดิบ (raw byte scan) โดยตรง มันก็จะสืบทอดความตาบอดของเครื่องมือแยกวิเคราะห์รูปแบบดั้งเดิม (classic parser) มาด้วย และสร้างรายงานข้อผิดพลาดที่เป็นเท็จบนไฟล์สมัยใหม่ที่มักจะมีโครงสร้างที่สมบูรณ์อย่างแน่นอน เนื่องจากไฟล์เหล่านี้ถูกสร้างขึ้นมาจากชุดเครื่องมือ (toolchains) ที่มีความใหม่เพียงพอที่จะเขียน cross-reference streams ได้ตั้งแต่ต้น

ปล่อยให้ PDFium ทำการแยกวิเคราะห์ (parsing) แทนคุณ

PDFium Component จะทำหน้าที่แยกวิเคราะห์ (parse) cross-reference streams และ object streams ในระหว่างขั้นตอนของการโหลดเอกสาร ซึ่งเป็นวิธีปฏิบัติเพื่อหลีกเลี่ยงการดำเนินการกับขั้นตอนการขยาย (inflate) และคลาย (expand) ด้วยตัวเอง เมื่อคุณโหลดไฟล์ด้วยคอมโพเนนต์ TPdf อ็อบเจ็กต์ที่ถูกบรรจุในคอนเทนเนอร์ /ObjStm จะถูกแก้ไขเรียบร้อยแล้ว และจุดเข้าสู่การตรวจสอบ (validation entry points) ก็จะมองเห็นเอกสารที่ถูกขยาย (expanded) อย่างเต็มรูปแบบ ValidatePdfA จะส่งคืนค่าตัวแปร (record) ของ TPdfAValidationResult ซึ่งฟิลด์ Conformance เป็นค่า TPdfAConformance อย่างเช่น pac1b หรือ pacNone, ฟิลด์ Issues เป็นชุดของปัญหาเฉพาะที่ค้นพบ และเมธอด IsCompliant จะเป็นจริง (true) ก็ต่อเมื่อตรวจพบระดับความสอดคล้องกับมาตรฐาน (conformance level) และชุดข้อมูลปัญหาว่างเปล่า (empty) เนื่องจากอ็อบเจ็กต์ต่างๆ ได้ถูกขยายในช่วงระหว่างการโหลด ดังนั้นอาร์เรย์ (array) /OutputIntents หรือฟอนต์ที่ถูกฝังไว้ (embedded font) ซึ่งอาศัยอยู่ภายใน object stream จึงถูกค้นพบ ไม่ใช่ถูกรายงานว่าขาดหายไป

uses
  PDFium, FPdfPdfa;

function CheckPdfA(const FileName: string): TPdfAValidationResult;
var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;            // parses xref/object streams on load
    Result := Pdf.ValidatePdfA;    // sees the expanded object table
  finally
    Pdf.Free;
  end;
end;

สิ่งเดียวกันนี้สามารถนำไปใช้กับ ValidatePdfX ซึ่งจะส่งกลับค่า TPdfXValidationResult ในรูปแบบเดียวกัน ประเด็นของการกำหนดเส้นทางผ่าน PDFium ก็คือการคลายการบีบอัดทางโครงสร้างตามที่อธิบายไว้ข้างต้นจะเกิดขึ้นเพียงครั้งเดียวอย่างถูกต้อง ภายในตัวโหลด (loader) ดังนั้นโค้ดสำหรับการตรวจสอบของคุณจะไม่มีวันเห็นความแตกต่างระหว่างไฟล์แบบเก่า (classic) กับไฟล์ที่มีการบีบอัดอย่างสมบูรณ์แบบ ทั้งสองสิ่งจะมาถึงโปรแกรมตรวจสอบ (validator) ในฐานะของชุดอ็อบเจ็กต์ที่ได้รับการแก้ไขแล้ว (resolved set of objects)

function PdfXConformanceName(C: TPdfXConformance): string;
begin
  case C of
    pxc1a: Result := 'PDF/X-1a';
    pxc3 : Result := 'PDF/X-3';
    pxc4 : Result := 'PDF/X-4';
  else
    Result := 'none';
  end;
end;

var
  Pdf: TPdf;
  R  : TPdfXValidationResult;
  Issue: TPdfXValidationIssue;
  IssueCount: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'Press_Ready.pdf';
    Pdf.Active := True;
    R := Pdf.ValidatePdfX;
    if R.IsCompliant then
      Writeln('PDF/X conformance: ', PdfXConformanceName(R.Conformance))
    else
    begin
      IssueCount := 0;
      for Issue in R.Issues do   // Issues is a set: count its members
        Inc(IssueCount);
      Writeln('Not conformant; issue count = ', IssueCount);
    end;
  finally
    Pdf.Free;
  end;
end;

หากไบต์ข้อมูลอยู่ในหน่วยความจำแล้ว ไม่ใช่บนดิสก์ ลำดับการโหลด-แล้วจึง-ตรวจสอบ ก็สามารถทำงานแบบเดียวกันผ่าน LoadDocument(const Data: TBytes) โอเวอร์โหลด ซึ่งจะนำเอาเนื้อหาไฟล์ดิบมาทำการแยกวิเคราะห์ (parse) cross-reference และ object streams ของมันในวิธีเดียวกันกับการใช้พาธของไฟล์ (file path) สิ่งที่โปรแกรมตรวจสอบ (validator) ที่เขียนขึ้นเองจะได้รับกลับไปคือกฎเชิงโครงสร้าง (structural rule) ไม่ใช่ API: จงอ่านคีย์ (keys) ของส่วนท้าย (trailer) จากดิกชันนารีสตรีมในรูปแบบข้อความธรรมดา (plaintext), ขยายทุกๆ /ObjStm ด้วย Flate decoder ก่อนที่คุณจะเดินสำรวจไปในเอกสาร และปฏิบัติต่อการถอดรหัส (decoding) รายการ (entries) ของไบนารี cross-reference เหมือนกับงานขนาดใหญ่ที่เป็นเพียงแค่ทางเลือก (optional) เท่านั้น

เมื่อโครงสร้างถูกขยายออกมาแล้ว เครื่องมือตรวจสอบก็จะสามารถขับเคลื่อนเวิร์กโฟลว์ที่เหลือให้ทำงานไปบนมันได้ สำหรับระบบ command-line preflight ที่คอยรายงานความถูกต้องตรงตามมาตรฐานของอินพุตในแฟ้มข้อมูล ให้ดู บทสรุปของเราเกี่ยวกับการสร้าง CLI สำหรับรายงาน preflight แบบชุด เมื่อการตรวจสอบ (validation) เป็นด่านที่ต้องผ่านก่อนที่จะทำการแยกเอกสารขนาดใหญ่ออกเป็นส่วนๆ เทคนิคใน คำแนะนำเกี่ยวกับการแยกเอกสาร PDF เป็นหลายไฟล์ ก็จะจับคู่กับรูปแบบของการ โหลด-และ-ตรวจสอบ ที่แสดงไว้ที่นี่ได้อย่างเป็นธรรมชาติ ทั้งสองส่วนสร้างขึ้นบนพื้นผิวของการโหลดและการตรวจสอบของ PDFium Component สำหรับ Delphi และ C++Builder