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

การโหลด PDF แบบ Hybrid-Reference จาก Word และ Excel ใน Delphi

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

นี่เป็นหนึ่งในวิธีที่พบบ่อยที่สุดที่ไปป์ไลน์ของ PDF ที่ผ่านการทดสอบภายในทั้งหมดต้องเจอไฟล์ที่มันไม่สามารถทำ round-trip ได้ ไฟล์อินพุตทั้งหมดสร้างขึ้นภายในองค์กร ดังนั้นมันจึงไม่เคยเป็นแบบไฮบริด ไฟล์ไฮบริดไฟล์แรกจะมาถึงในวันที่ลูกค้าส่งต่อใบแจ้งหนี้ที่ส่งออกจากสเปรดชีต

สิ่งที่ Word และ Excel เขียนจริงๆ

ISO 32000-1 อธิบายเค้าโครงแบบ hybrid-reference ไว้ใน §7.5.8.4 แอปพลิเคชันที่ต้องการฟีเจอร์ของ PDF 1.5 เช่น object streams ในขณะที่ยังคงให้โปรแกรมอ่าน PDF 1.4 เปิดไฟล์ได้ จะเขียนข้อมูล cross-reference สองครั้ง มีตาราง cross-reference แบบคลาสสิก ซึ่งเป็นแถว ASCII ที่มีความกว้างคงที่ซึ่งอยู่ท้ายไฟล์ PDF ทุกไฟล์จนถึงเวอร์ชัน 1.4 และมีสตรีม cross-reference ที่จัดทำดัชนีส่วนที่เหลือ ส่วนท้ายของส่วนคลาสสิกจะประกอบด้วย /XRefStm ซึ่งมีค่าเท่ากับไบต์ออฟเซ็ตของสตรีมนั้น

การแบ่งงานกันทำนี้เป็นไปโดยความตั้งใจ ออบเจ็กต์ที่โปรแกรมอ่านรุ่นเก่าต้องเข้าถึง เช่น แคตตาล็อกและแผนผังหน้า (page tree) สามารถระบุตำแหน่งได้จากตารางแบบคลาสสิก ออบเจ็กต์ที่ถูกพับลงใน object streams ที่ถูกบีบอัด จะถูกทำเครื่องหมายว่าว่าง (free) ในตารางแบบคลาสสิก ด้วยประเภท f เพื่อให้โปรแกรมอ่านเวอร์ชัน 1.4 ข้ามมันไปทันที และไม่สะดุดกับโครงสร้างที่มันไม่สามารถแยกวิเคราะห์ได้ ตำแหน่งที่แท้จริงของพวกมันจะอยู่ในสตรีม cross-reference เท่านั้น ลายเซ็นของไฟล์ดังกล่าวคือส่วนท้ายของมัน: ซึ่งจะเป็นส่วนคลาสสิกสั้นๆ ที่มักจะไม่มีอะไรมากไปกว่า xref ตามด้วย 0 0 ของส่วนย่อย (subsection header) ซึ่งมีส่วนท้าย (trailer) ชี้ไปที่ /XRefStm ที่ข้อมูลการกู้คืนจริงตั้งอยู่

เหตุใดการนับจำนวนหน้าที่ถูกต้องจึงพิสูจน์อะไรไม่ได้

เนื่องจากแคตตาล็อกและแผนผังหน้า (page tree) สามารถเข้าถึงได้จากตารางแบบคลาสสิกอย่างตั้งใจ ตัวโหลดที่อ่านเฉพาะตารางนั้นจะพบ /Root เดินตามแผนผังหน้า และรายงานจำนวนหน้าที่ถูกต้อง ทุกสิ่งที่โปรแกรมอ่านรุ่นเก่าต้องการมีอยู่ครบ ไฟล์จึงดูสมบูรณ์ดี ออบเจ็กต์ที่หายไปคือสิ่งที่ถูกบรรจุใน object streams: พจนานุกรมฟิลด์ของ AcroForm องค์ประกอบโครงสร้างแบบ tagged-PDF และพจนานุกรมขนาดเล็กมากมายที่ไม่จำเป็นต้องปรากฏให้โปรแกรมดูเอกสารรุ่นเก่าเห็น

คุณจะไม่สังเกตเห็นช่องโหว่จนกว่าจะมีสิ่งใดไปสัมผัสกับออบเจ็กต์เหล่านั้น และการบันทึกซ้ำทั้งหมดจะสัมผัสกับพวกมันทั้งหมด การเดินตามเอกสารเพื่อเข้ารหัสซ้ำหรือเขียนซ้ำ คือการทำงานที่ร้องขอทุกหมายเลขออบเจ็กต์ตามลำดับ ซึ่งเป็นเหตุผลที่อาการปรากฏขึ้นในเวลาที่บันทึกแทนที่จะเป็นเวลาที่โหลด ซึ่งห่างไกลจากสาเหตุของมันมาก

กับดักคือตัวตรวจจับที่เห็น xref แล้วหยุด

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

var
  Pdf: THotPDF;
  PageCount: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    PageCount := Pdf.LoadFromFile('Invoice_XLS.pdf');  // count is correct
    // inspect or edit the loaded document here
    Pdf.SaveLoadedDocument('Invoice_secured.pdf');     // walks every object
  finally
    Pdf.Free;
  end;
end;

ด้วยตัวตรวจจับที่ออกก่อนเวลา (early-exit) ที่มีอยู่ การโหลดจะดูเหมือนปกติ แต่การบันทึกซ้ำคือจุดที่ออบเจ็กต์ที่หายไปจะปรากฏให้เห็น วิธีแก้ปัญหาไม่ใช่การอ่านไบต์เพิ่มเติมในตอนเริ่มต้น แต่มันคือการจดจำส่วนท้ายแบบไฮบริดและทำตาม /XRefStm ก่อนที่จะตัดสินว่าไฟล์เสร็จสมบูรณ์

ลำดับการรวมไม่สามารถต่อรองได้

เมื่ออ่านทั้งสองดัชนีแล้ว พวกมันจะสามารถรวมกันได้ในทิศทางเดียวเท่านั้น ต้องรวมสตรีม cross-reference เข้าด้วยกันก่อน โดยให้รายการคลาสสิกถูกเติมเข้าไปรอบๆ เหตุผลก็คือการหลอกลวงเล็กน้อยที่เป็นหัวใจหลักของรูปแบบนี้ ไฟล์ไฮบริดทำเครื่องหมายออบเจ็กต์ที่ถูกบีบอัดของมันว่าว่างในตารางคลาสสิก เพื่อให้โปรแกรมอ่านรุ่นเก่าข้ามพวกมันไป ตัวโหลดที่ให้เกียรตินโยบาย first-seen-wins และอ่านตารางคลาสสิกก่อน จะบันทึกหมายเลขออบเจ็กต์เหล่านั้นว่าว่าง จากนั้นจะทิ้งรายการสตรีมที่ระบุตำแหน่งที่แท้จริงของพวกมัน เนื่องจากสล็อตเหล่านั้นถูกจองไว้แล้ว ลองกลับลำดับดู แล้วรายการประเภท 2 จากสตรีม ซึ่งแต่ละรายการคือหมายเลข object-stream บวกกับดัชนี จะชนะช่องที่ตั้งใจให้พวกมันเป็นเจ้าของ และรายการแบบคลาสสิกก็จะอยู่รอบๆ พวกมัน

ระเบียบวินัยเดียวกันนี้ยังช่วยป้องกันการแก้ไขรุ่นเก่าจากการรื้อฟื้นออบเจ็กต์ที่ถูกลบไปแล้ว การอัปเดตส่วนเพิ่มจะเรียงย้อนกลับไปผ่าน /Prev และรายการที่ว่างประเภท 0 คือสิ่งที่เตือนว่าส่วนล่าสุดได้ปลดระวางหมายเลขออบเจ็กต์แล้ว ส่วนที่เก่ากว่าและมาทีหลังในห่วงโซ่ จะต้องไม่ได้รับอนุญาตให้เขียนทับสิ่งที่เตือนนี้ด้วยตำแหน่งที่ล้าสมัย ถือว่าข้อมูลที่พบก่อนมีอำนาจเหนือกว่าสำหรับเครื่องหมายฟรี และออบเจ็กต์ที่ถูกลบจะยังคงถูกลบต่อไป ปฏิบัติต่อมันอย่างประมาทและประวัติของไฟล์จะรื้อฟื้นเนื้อหาที่ฉบับแก้ไขล่าสุดได้ลบออกไป

สิ่งนี้หมายถึงอะไรใน HotPDF

เอนจินจะแก้ไขไฟล์ไฮบริดให้คุณ และมันจะทำในทุกเส้นทางที่ต้องแยกวิเคราะห์ข้อมูล cross-reference โหลดเอกสารด้วย LoadFromFile หรือ LoadFromStream ทำการเปลี่ยนแปลงของคุณ และเรียก SaveLoadedDocument; หรือเรียกใช้การทำงานแบบขั้นตอนเดียว (one-shot operation) เช่น EncryptFile ที่อ่านอินพุตและเขียนเอาต์พุต ไม่ว่าทางใด การกู้คืนก็จะอ่าน /XRefStm นำส่วนของสตรีมมารวมกันก่อนรายการคลาสสิก และค้นหาออบเจ็กต์ที่อยู่ในสตรีมก่อนที่การเขียนจะแสดงหมายเลขพวกมัน เส้นทางการเข้ารหัส AES-256 คือจุดที่ปัญหานี้ปรากฏตัวเป็นครั้งแรก เนื่องจากการเข้ารหัสเอกสารจะเป็นการเขียนออบเจ็กต์ทุกชิ้นใหม่ ดังนั้นจึงต้องให้ทุกออบเจ็กต์ถูกค้นพบเรียบร้อยแล้ว

// One-shot: read the hybrid input, write an AES-256 encrypted copy
Pdf.EncryptFile('Letter_DOC.pdf', 'Letter_secured.pdf',
  'owner-secret', '', aes256, [prPrint, prFillAnnotations]);

รายละเอียดที่ควรนำกลับไปพิจารณาคือสิ่งที่อยู่ก่อนการเรียกใช้ API ไฟล์ที่มาจาก Word, Excel, PowerPoint และไปป์ไลน์ "บันทึกเป็น PDF" อีกมากมาย มักจะเป็นไฟล์ไฮบริดเสมอ ดังนั้นตัวโหลดที่คุณทดสอบกับผลลัพธ์ของเจเนอเรเตอร์ของคุณเอง อาจไม่เคยเจอสิ่งนี้เลยในการทดสอบ ควรเพิ่มเอกสารที่ส่งออกจากแอปพลิเคชัน Office จริงๆ ลงในชุดการทดสอบของคุณ ไม่ใช่แค่ใช้ไฟล์ที่โค้ดของคุณสร้างขึ้นเองเท่านั้น

การตรวจสอบไฟล์ที่คุณสงสัย

การตรวจสอบสองอย่างสามารถตอบคำถามนี้ได้อย่างรวดเร็ว เปิดไฟล์ในมุมมองเลขฐานสิบหก (hex view) และอ่านไบต์หลังจาก startxref ตัวสุดท้าย ไฟล์ไฮบริดจะแสดงส่วนคลาสสิกสั้นๆ ซึ่งพจนานุกรมส่วนท้าย (trailer dictionary) จะมี /XRefStm อยู่ หรือเปรียบเทียบจำนวนออบเจ็กต์ที่รายงานจากการแยกวิเคราะห์แบบเต็มรูปแบบ กับหมายเลขออบเจ็กต์สูงสุดที่ /Size ประกาศในส่วนท้าย ช่องว่างขนาดใหญ่หมายความว่ามีออบเจ็กต์ซ่อนอยู่ในสตรีมที่ตัวโหลดยังไม่ได้เปิด ซึ่งเป็นข้อบกพร่องเดียวกันที่จะกลายเป็นความล้มเหลวในเวลาที่บันทึกในภายหลัง

ส่วนท้ายของการส่งออกจาก Excel ทั่วไป ทำให้การตรวจสอบครั้งแรกเป็นรูปธรรม ทุกสิ่งหลังจากคีย์เวิร์ด xref ตัวสุดท้ายจะเป็นเพียง ASCII ธรรมดา ดังนั้นลายเซ็นจึงสามารถอ่านได้จากมุมมองเลขฐานสิบหก (ออฟเซ็ตมีไว้เพื่อเป็นตัวอย่าง และเพิ่มคำอธิบายประกอบ)

xref
0 0                          % empty classic subsection: no rows at all
trailer
<< /Size 216                 % one past the highest object number in use
   /Root 1 0 R
   /Info 15 0 R
   /ID [<5C9A...> <5C9A...>]
   /XRefStm 87325            % byte offset of the cross-reference stream
>>
startxref
88710                        % points at the classic section above
%%EOF

ส่วนย่อย 0 0 คือสิ่งที่บ่งบอก: ตารางคลาสสิกที่ไม่มีรายการข้อมูลใดๆ มีอยู่เพียงเพื่อถือส่วนท้าย (trailer) และส่วนท้ายมีอยู่เพื่อที่จะบอกว่า /XRefStm 87325 ตัวตรวจจับที่หยุดที่คีย์เวิร์ด xref ในจุดนี้ จะเห็นว่าไม่มีดัชนีอะไรเลย หากคุณต้องการเขียนสคริปต์ตรวจสอบแทนที่จะดูด้วยตาเปล่า เครื่องหมายนี้มักจะอยู่ในไฟล์ช่วงกิโลไบต์สุดท้ายเสมอ ดังนั้นการอ่านแบบย้อนกลับอย่างจำกัดขอบเขตจึงเพียงพอแล้ว

// Returns the /XRefStm offset from the file's tail, or -1 if the
// marker is absent (the file is not hybrid, or not a PDF at all)
function FindXRefStm(const FileName: string): Int64;
var
  FS: TFileStream;
  Tail: AnsiString;
  Len, P: Integer;
begin
  Result := -1;
  FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    Len := 2048;                        // the trailer lives in the tail
    if FS.Size < Len then
      Len := Integer(FS.Size);
    FS.Position := FS.Size - Len;       // bounded backward read: 2 KB max
    SetLength(Tail, Len);
    FS.ReadBuffer(Tail[1], Len);
  finally
    FS.Free;
  end;
  P := Pos(AnsiString('/XRefStm'), Tail);
  if P = 0 then
    Exit;                               // no hybrid marker in the tail
  Inc(P, Length('/XRefStm'));
  while (P <= Len) and (Tail[P] in [' ', #9, #13, #10]) do
    Inc(P);                             // skip whitespace after the key
  Result := 0;
  while (P <= Len) and (Tail[P] in ['0'..'9']) do
  begin
    Result := Result * 10 + Ord(Tail[P]) - Ord('0');
    Inc(P);
  end;
end;

// Usage: a non-negative result names the byte where the stream starts
if FindXRefStm('Invoice_XLS.pdf') >= 0 then
  Writeln('hybrid-reference file: resave will need the /XRefStm section');

ปฏิบัติต่อการตรวจสอบนี้เสมือนการคัดกรองเบื้องต้น ไม่ใช่เป็นตัวแยกวิเคราะห์: มันจะบอกคุณว่าไฟล์ใดในกลุ่มสมควรได้รับความสนใจก่อนที่งานบันทึกซ้ำจะเริ่มทำงาน และไม่มีอะไรมากไปกว่านั้น สิ่งที่ตัวโหลดต้องทำต่อไปกับออฟเซ็ตที่พบคือ ติดตามสายโซ่ของแต่ละส่วน (section chain) นำรายการของสตรีมมารวมกันก่อนรายการคลาสสิก ให้ความสำคัญกับคำเตือนจาก free-entry สิ่งเหล่านี้ได้ถูกอธิบายทีละขั้นตอนไว้ใน บทความประกอบของเราเกี่ยวกับการจัดการไฟล์ PDF แบบ hybrid-reference จากแอปพลิเคชัน Office

ด้านผู้เขียนของเรื่องราวนี้ วิธีที่ object streams และ cross-references ที่ถูกบีบอัดถูกสร้างขึ้นตั้งแต่แรก ได้ครอบคลุมอยู่ใน บทความของเราเกี่ยวกับ object streams และการอัปเดตส่วนเพิ่ม เมื่อไฟล์ไฮบริดที่มีปัญหาเป็นไฟล์ขนาดใหญ่มาก เทคนิคการโหลดใน คำแนะนำเกี่ยวกับการใช้ Direct File API สำหรับเวิร์กโฟลว์ของไฟล์ PDF ขนาดใหญ่ จะช่วยให้คุณตรวจสอบไฟล์ได้โดยไม่ต้องอ่านทั้งหมดลงในหน่วยความจำ ทั้งสองสิ่งนี้สามารถใช้ร่วมกับการกู้คืนที่อธิบายไว้ในที่นี้ได้อย่างลงตัว ซึ่งมันถูกรวมมาเป็นส่วนหนึ่งของ คอมโพเนนต์ HotPDF สำหรับ Delphi และ C++Builder ควบคู่ไปกับ API ด้านการโหลด การแก้ไข การเข้ารหัส และการเซ็นชื่อ ซึ่งครอบคลุมอยู่ในที่อื่นๆ ในบล็อกนี้