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

Object stream แบบ lazy กับการเขียน PDF ใหม่ทั้งไฟล์บน Delphi

เมื่อ HotPDF Delphi Component โหลดไฟล์ PDF 1.5 ด้วย LoadFromFile มันไม่ parse object ที่อัดอยู่ใน container /Type /ObjStm มันบันทึกว่า member ที่ถูกบีบอัดแต่ละตัวอยู่ตรงไหนและ parse มันเฉพาะเมื่อมีอะไรขอ สภาพ lazy นี้คือสิ่งที่ทำให้เวลาโหลดเป็นสัดส่วนกับสิ่งที่คุณแตะจริง และเป็นเหตุผลที่การเขียนใหม่ทั้งไฟล์ต้องทำงานเพิ่มอีกหนึ่งอย่างก่อนไบต์ใดจะถูกปล่อยออกไป: ขยาย member ทุกตัวที่ยังไม่ถูก parse เพราะการเขียนใหม่กำลังจะทิ้ง container ที่ member เหล่านั้นอาศัยอยู่

อาการที่ทำให้ต้องเขียนบันทึกนี้เล่าได้ง่ายและ debug ไม่สนุก โหลดไฟล์ที่ฟอนต์ color space และ structure tree อยู่ใน object stream รันมันผ่านคู่การสร้างเอกสาร BeginDoc กับ EndDoc แล้ว output เปิดได้ไม่มี complaint จำนวนหน้าถูกต้อง ข้อความมองเห็นบนหน้าที่คุณสุ่มตรวจ แล้วเพื่อนร่วมงานเปิดหน้า 40 และเนื้อความ render ด้วยฟอนต์ที่ถูกแทน หรือคำสั่ง Extract Text คืนขยะตรงที่เดิมมีข้อความแทนจาก ActualText ไม่มีอะไร crash ตัวเขียนแค่ serialize object ที่ไม่เคยถูกโหลด และ object ที่ไม่ถูกโหลดก็ serialize ออกมาเป็นความว่างเปล่า

LoadFromFile เก็บอะไรไว้จริง ๆ สำหรับ object ที่ถูกบีบอัด

สำหรับ cross-reference entry ชนิดที่ 2 ทุกตัว LoadFromFile เก็บ record เล็ก ๆ ไว้ใน FCompactObjects: หมายเลข object, index ของ stream ที่บรรจุมันในตาราง container, ตำแหน่งของ member ใน stream นั้น และ pointer ParsedObject ที่เริ่มต้นเป็น nil ตัว container เองถูกค้นหา ถอดรหัสถ้าเอกสารถูกเข้ารหัส และ inflate แต่ body ของ member ถูกทิ้งไว้เป็นไบต์ ISO 32000-1 §7.5.7 นิยามโครง container ที่ทำให้เรื่องนี้เป็นไปได้: header ของคู่หมายเลข object กับ offset จากนั้น body ของ member ต่อกันหลัง /First member ตัวใดตัวหนึ่งจึงถูกตัดออกมาได้โดยไม่ต้องแตะตัวข้าง ๆ

EnsureCompressedObjectLoaded เป็นเส้นทางเดียวที่เปลี่ยน record ให้เป็น object มันหา record ด้วยหมายเลข object และถ้า ParsedObject ถูกตั้งไว้แล้ว มันคืน object ที่ cache ไว้และนับเป็น cache hit ไม่งั้นมันโหลด container ใหม่ถ้ามันถูก evict ไป คำนวณช่วงไบต์ของ member จากตาราง offset ส่ง view แบบ zero-copy ของช่วงนั้นให้ parser และเก็บผลลัพธ์กลับเข้า record จากนั้น object นั้นเป็น indirect พาหมายเลข object จริงของมัน และถูกลงทะเบียนใน object index ของเอกสารเหมือน object ที่ถูก parse จาก body ของไฟล์ catalog, info dictionary, root ของ page tree และ object ของหน้า ผ่านเส้นทางนี้ตอนโหลดเพราะการนำทางต้องใช้มัน ฟอนต์, color space, dictionary ExtGState และ structure element ไม่ต้อง และมันยังเป็น record อยู่จนกว่าการ render หน้าที่หรือการเขียนใหม่จะแตะมัน

แผนภาพวิธีที่ HotPDF Delphi Component เก็บ member ที่ถูกบีบอัดไว้ก่อน parse: record FCompactObjects เก็บหมายเลข object, index ของ container, index ของ member และ pointer ParsedObject ที่เป็น nil ส่วน EnsureCompressedObjectLoaded เปลี่ยน record ให้เป็น object ที่ลงทะเบียนแล้วผ่าน cache hit, การโหลด container ใหม่, การตัดช่วงจากตาราง offset และการ parse แบบ zero-copy
LoadFromFile ทิ้ง body ของสมาชิกใน /ObjStm ไว้เป็นไบต์และ parse เฉพาะเมื่อตัวอ่านขอ เวลาโหลดจึงตามสิ่งที่คุณแตะ catalog กับ page tree มาถึงก่อน ขณะที่ฟอนต์ color space และ structure element ยังเป็น record อยู่

คุณดูเรื่องนี้จากข้างนอกได้ GetLoadedObjectStreamCacheInfo รายงานว่ามี container กี่ตัว มี member ถูก index ไปกี่ตัว และในนั้นถูก parse ไปแล้วกี่ตัว:

var
  Pdf: THotPDF;
  Info: THPDFObjectStreamCacheInfo;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('tagged-report.pdf');
    if Pdf.GetLoadedObjectStreamCacheInfo(Info) then
      Writeln(Format('%d containers, %d members indexed, %d parsed so far',
        [Info.ContainerCount, Info.IndexedObjectCount,
         Info.MaterializedObjectCount]));
  finally
    Pdf.Free;
  end;
end;

บนไฟล์ที่เน้น structure ตัวเลขตัวที่สามเป็นเศษเสี้ยวเล็ก ๆ ของตัวที่สองทันทีหลังโหลด ช่องว่างนั้นคือประเด็นทั้งหมดของการโหลดแบบ lazy และมันก็คือชุด object ที่การเขียนใหม่ทั้งไฟล์ต้องย้อนกลับไปเอาเป๊ะ ๆ

ทำไมการเขียนใหม่ทั้งไฟล์ถึงทำฟอนต์หายขณะที่ incremental save เก็บไว้

การเขียนใหม่ทั้งไฟล์ทิ้ง container /ObjStm กับ /XRef ของไฟล์ต้นทางและ serialize object graph ใหม่ตั้งแต่ต้น member ตัวใดที่ ParsedObject ยังเป็น nil จึงไม่เหลือตัวแทนใน output เลย incremental update ไม่เคยมีปัญหานี้ เพราะมันต่อ object ใหม่หลังไบต์เดิมและทิ้ง container เก่าไว้ให้ cross-reference section ก่อนหน้าอ้างถึง ความต่างไม่ได้อยู่ที่วิธีที่สองโหมดปฏิบัติกับฟอนต์ แต่อยู่ที่ว่า container เดิมรอดไปให้ viewer ตัวถัดไปอ่านหรือไม่

fix อยู่ใน SaveToStream ซึ่งเป็น serializer ที่ EndDoc ขับไม่ว่าคุณจะตั้ง FileName หรือ OutputStream ก่อนที่มันจะ dispatch ไป branch ของตัวเขียนใด มันเดิน FCompactObjects และเรียก EnsureCompressedObjectLoaded กับทุก entry ถ้า member ตัวใดโหลดไม่ได้ การเซฟจะ raise แทนที่จะทำต่อ เพราะการเขียนใหม่ที่ทำ font dictionary หายเงียบ ๆ แย่กว่าการที่มันหยุด expansion ต้องอยู่ที่ระดับนั้น เหนือ branch ของ classic, packed และ linearized และเหนือการตัด structural stream ที่ถูกโหลดซ้ำของเส้นทาง linearized เวอร์ชันก่อนหน้าขยาย member แค่ใน SaveLoadedDocument ซึ่งครอบคลุมคำศัพท์ของ loaded document และพลาดคำศัพท์ของ generation ไปทั้งหมด LoadFromFile ตามด้วย BeginDoc แก้หน้า แล้ว EndDoc ไปถึงตัวเขียนตรง ๆ โดยที่ member ที่ไม่ถูกแตะทุกตัวยังไม่ถูก parse

แผนภาพว่ารอบการขยายตอนเขียนใหม่ทั้งไฟล์ของ HotPDF อยู่ตรงไหน: SaveToStream เดินทุก entry ใน FCompactObjects ผ่าน EnsureCompressedObjectLoaded ก่อน dispatch ไปตัวเขียนแบบ classic, packed หรือ linearized คำศัพท์ทั้งของ SaveLoadedDocument และของ LoadFromFile บวก BeginDoc บวก EndDoc จึง serialize object ที่ parse ครบแล้วแทน record ที่เป็น nil
incremental update ต่อท้ายไบต์เดิมและทิ้ง container เก่าให้อ่านได้ แต่การเขียนใหม่ทั้งไฟล์ทิ้งมัน การขยายรอบเดียวเหนือทุก branch ของตัวเขียนคือสิ่งที่กันไม่ให้ฟอนต์หรือ structure element ที่ไม่ถูกโหลด serialize ออกมาเป็นความว่างเปล่า
// คำศัพท์การเขียนใหม่ทั้งสองแบบตอนนี้ขยาย member แบบ compact ก่อนตัวเขียนใดจะรัน
// เส้นทาง loaded document:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.SaveLoadedDocument('quarterly-rewritten.pdf');

// เส้นทาง generation บนไฟล์ที่โหลดมา:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.FileName := 'quarterly-stamped.pdf';
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 9);
Pdf.CurrentPage.TextOut(40, 20, 0, 'Reviewed 2026-09-11');
Pdf.EndDoc;   // SaveToStream materialize ทุก entry ใน FCompactObjects ก่อน

member ที่ cache ไว้เก็บทุกอย่างที่คุณทำกับมันไว้ object ที่ถูก parse แก้ และทำเครื่องหมาย dirty ก่อนเซฟจะถูกคืนจาก cache พร้อมการแก้ของมัน และ member ที่คุณลบจะคงสถานะการลบไว้ข้ามการเซฟหลายรอบ รอบการขยายเป็น idempotent ตั้งแต่โครงสร้าง: มันเติมแค่ช่องที่เป็น nil เท่านั้น

ทำไมการตรวจพิกเซลสามหน้าถึงพลาดเคส ActualText

structure element คือที่ที่ bug นี้ซ่อนได้นานที่สุด entry ActualText บน marked-content sequence ซึ่งนิยามใน ISO 32000-1 §14.9.4 แทนที่ glyph สำหรับการ extract และ accessibility แต่ไม่กระทบการ render ถ้า structure element นั้นอยู่ใน object stream และการเขียนใหม่ทำมันหาย หน้าก็ยังวาดถูก หน้าแรก ตรงกลาง และหน้าสุดท้ายเทียบพิกเซลกับต้นฉบับได้ทุกจุด และ regression จะโผล่ก็ต่อเมื่อมีคนรัน text extraction หรือ screen reader test การเขียนใหม่ที่ render หน้าเท่านั้นไม่ใช่ test การเขียนใหม่สำหรับ PDF ที่มี tag ต้อง diff ข้อความที่ extract ได้กับ structure tree ด้วย

รหัสผ่านผู้ใช้ที่เป็นสตริงว่างเปลี่ยนการโหลดอย่างไร

รหัสผ่านผู้ใช้ที่เป็นสตริงว่างยังหมายความว่าไฟล์ถูกเข้ารหัส และ object stream ในไฟล์แบบนั้นเป็น ciphertext จนกว่าจะกู้ file key ได้ ISO 32000-1 §7.6.3.4 Algorithm 2 สร้างคีย์นั้นจากรหัสผ่าน, entry /O, /P และ document identifier ตัวแรก และ HotPDF ต้องรันมันกับสตริงว่างก่อนที่รอบ type-2 จะ inflate container ได้แม้แต่ตัวเดียว นั่นคือเหตุผลที่ BeginDoc บนเอกสารที่โหลดมาและถูกเข้ารหัสเรียก DecryptLoadedDocument ด้วยรหัสผ่านว่างก่อนอย่างอื่น: object graph ต้องผ่านการยืนยันตัวตนและถอดรหัสก่อนการเขียนใหม่จะเริ่มได้ ไม่ว่า caller จะตั้งใจป้องกัน output หรือไม่ การเข้ารหัส output เป็นการตัดสินใจคนละเรื่อง ขับด้วยค่าตั้งการป้องกันของ caller และ BeginDoc คืนค่าตั้งเหล่านั้นหลังรอบถอดรหัส input ที่เข้ารหัสจึงไม่กลายเป็น output ที่เข้ารหัสเงียบ ๆ

นโยบายของ container ถูกอ่านจาก dictionary /Encrypt ก่อนลองรหัสผ่านใด สำหรับ /V 1 และ 2 ทุก stream ถูกเข้ารหัสด้วย file key สำหรับ crypt filter HotPDF resolve /StmF ผ่าน /CF: filter Identity หรือ /CFM เป็น None หมายถึง container เป็น plaintext ขณะที่ V2 กับ AESV2 หมายถึง container ถูกเข้ารหัส คำตอบไปลงที่ FReloadObjectStreamsEncrypted และมันสำคัญกับเคสหนึ่งโดยเฉพาะ เมื่อ container เป็น plaintext แต่สตริงไม่ใช่ member จะพาสตริงที่ถูกเข้ารหัสซึ่งต้องถอดรหัสทีละตัว MaterializeMembersOfPlaintextObjectStreams จึงขยาย member แบบ compact ทุกตัวก่อนรอบถอดรหัสต่อ object มันไม่ทำอะไรเมื่อยังไม่รู้นโยบาย และไม่ทำอะไรเมื่อ container เองถูกเข้ารหัส เพราะ member ของ container ที่เข้ารหัสถูกถอดรหัสไปพร้อมมันแล้วและห้ามถอดรหัสสองครั้งเด็ดขาด

เกิดอะไรขึ้นเมื่อ container ถูกถอดรหัสไม่ได้

container ที่ถอดรหัสไม่ผ่านจะถูกกักกัน ไม่ใช่เรื่องถึงตาย รอบ type-2 บันทึก entry THPDFObjStmQuarantineInfo ลงใน FObjStmQuarantine พร้อมหมายเลข object ของ container, THPDFObjStmQuarantineReason, สตริง diagnostic และรายการหมายเลข object ของ member ที่ cross-reference ส่งเข้ามาในนั้น osqrDecryptFailed ถูก raise ในสี่สถานการณ์ที่ต่างกัน: resolve crypt filter ไม่ได้เลย, การถอดรหัส AES-256 หรือ AES-GCM โยน exception, การถอดรหัส RC4 หรือ AES-128 แบบเก่าโยน exception หรือไม่มี file key ที่ใช้ได้เลย container อิสระตัวอื่นยังโหลดต่อ เอกสารที่มี container เสียหนึ่งตัวจึงยังเปิดได้และยัง render ทุกหน้าที่ไม่ขึ้นกับมัน

แผนภาพการกักกันการถอดรหัสของ HotPDF บน PDF ที่โหลดมา: container ที่ถอดรหัสแล้วโยน exception ถูกบันทึกเป็น THPDFObjStmQuarantineInfo พร้อมเหตุผล osqrDecryptFailed และหมายเลข object ของ member container อิสระตัวอื่นยังโหลดต่อ และ BeginDoc raise ที่ entry ที่ล้มเหลวตัวแรกก่อนการเขียนใหม่จะรายงานว่าสำเร็จ
record การกักกันรอดผ่าน parser fallback และ BeginDoc ตรวจมันตามชื่อแทนที่จะดูธง encrypted เอกสารที่มี container เสียหนึ่งตัวจึงยังเปิดได้ ขณะที่เส้นทางการเขียนใหม่หยุดแทนที่จะเขียน object ว่าง

รายการกักกันรอดผ่าน parser fallback ถ้าการโหลด cross-reference หลักล้มเหลวและ HotPDF สร้างตาราง object ขึ้นใหม่ด้วยการสแกนไฟล์ ธง encrypted จากการพยายามครั้งแรกอาจไม่รอดผ่านการสร้างใหม่นั้น แต่ record การกักกันรอด นั่นคือเหตุผลที่ BeginDoc ตรวจรายการกักกันแทนธง encrypted: บนเอกสารที่โหลดมา มันเดิน FObjStmQuarantine และ raise ที่ entry osqrDecryptFailed ตัวแรก ระบุชื่อ container และขอให้โหลดใหม่ด้วยรหัสผ่านที่ถูกต้อง การเขียนใหม่ที่ทำต่อเลยจุดนั้นจะเขียน member ที่ container นั้นควรถืออยู่เป็น object ว่างและรายงานว่าสำเร็จ คุณรันการตรวจเดียวกันเองได้ เร็วขึ้นและด้วยนโยบายของคุณเอง ผ่าน accessor สาธารณะ:

var
  Info: THPDFObjStmQuarantineInfo;
  I: Integer;
begin
  Pdf.LoadFromFile('vendor-form.pdf');   // รหัสผ่านผู้ใช้เป็นสตริงว่าง
  for I := 0 to Pdf.GetLoadedQuarantinedObjStmCount - 1 do
    if Pdf.GetLoadedQuarantinedObjStmInfo(I, Info) and
       (Info.Reason = osqrDecryptFailed) then
      raise Exception.CreateFmt(
        'Object stream %d is unreadable (%s); %d members unresolved',
        [Info.ContainerObjNum, String(Info.Diagnostic),
         Length(Info.MemberObjNums)]);
  // ปลอดภัยที่จะเขียนใหม่จากตรงนี้
end;

เหตุผลการกักกันอื่นครอบคลุมความล้มเหลวที่ไม่เกี่ยวกับการเข้ารหัส: container ที่ไม่ใช่ stream, dictionary ที่หายไป, /N หรือ /First ที่ผิด, ขนาด stream ที่นอกช่วงที่รับได้, การ decompress ที่ล้มเหลว, /First ที่ชี้เลยข้อมูล หรือ body ของ member ที่ decode ได้แต่ parse ไม่ผ่าน ทั้งหมดนี้ควรถูก log ตอนนำเข้า เพราะแต่ละอันระบุ member ที่คุณจะขาดไปในปลายน้ำได้เป๊ะ ๆ

ทำไมการเขียนใหม่ถึงต้องใช้ token ตัวเลขเดิม

HotPDF เก็บ numeric object ทุกตัวเป็น Single และ Single ไม่สามารถ reproduce ข้อความต้นทางของจำนวนจริงได้ ISO 32000-1 §7.3.3 ยอมให้ตัวเขียนปล่อย 0.750000, .75 หรือ 0.75 สำหรับค่าเดียวกัน และไม่มีตัวไหนรอดการไปกลับผ่าน binary 24 บิตกับตัวจัดรูปแบบทั่วไปโดยไม่เปลี่ยน ที่แย่กว่านั้น ค่าอย่าง 0.7 ไม่สามารถแทนได้ด้วย Single เลย มัน parse ไปเป็น float ที่ใกล้ที่สุด และการจัดรูปแบบ float นั้นใหม่สามารถให้ 0.69999999 หรือค่าใกล้เคียงที่ถูกปัด ขึ้นกับ loop การแปลงหลัก บนสี fill หรือค่าคงที่ความโปร่งใส /CA นั่นคือความต่างหนึ่งขั้นในช่อง 8 บิต ซึ่งพอจะตกการเทียบพิกเซลกับต้นฉบับ และพอมองเห็นได้ตรงขอบ gradient

THPDFNumericObject.RememberSourceToken แก้เรื่องนี้สำหรับเคสที่ไม่ถูกแก้ parser เรียกมันด้วย token ดิบทันทีหลัง assign Value เมธอดยอมรับเฉพาะ token ที่ประกอบด้วยตัวเลข จุดทศนิยมไม่เกินหนึ่งจุด และเครื่องหมายนำหน้าถ้ามี และเก็บ token นั้นพร้อมค่าที่มันตรงกันไว้ใน FSourceValue property SourceToken คืนข้อความที่เก็บไว้เฉพาะเมื่อ Value ยังเท่ากับ FSourceValue อยู่ แก้ตัวเลขแล้ว token ก็หายไป ค่าที่ถูกแก้จึงผ่านเส้นทางการจัดรูปแบบเดิมเสมอและไม่เคยปล่อยข้อความค้างเก่า SaveNumericObject ตรวจ SourceToken ก่อนและเขียนมันคำต่อคำถ้ามี จากนั้นจึงถอยไป branch ของจำนวนเต็ม, การอ้าง color space และเศษส่วนสำหรับตัวเลขที่ถูกสร้างหรือแก้ในหน่วยความจำเท่านั้น

หลักประกันข้อนี้เล็กและควรพูดให้ชัด: ตัวเลขที่คุณไม่ได้แตะจะถูกเขียนด้วยไบต์ที่มันถูกอ่านมา และตัวเลขที่คุณแตะจะถูกเขียนด้วยตัวจัดรูปแบบของ HotPDF เอง member แบบ compact ได้ประโยชน์แบบเดียวกับ object ใน body เพราะ EnsureCompressedObjectLoaded รัน parser ชุดเดียวกันบนช่วงของ member การจัดรูปแบบตัวเลขเองและความเป็นอิสระจาก locale ของโปรเซสอยู่ในบทความเรื่องการจัดรูปแบบตัวเลข PDF ที่ไม่ขึ้นกับ locale ใน HotPDF

ทดสอบเส้นทางเขียนใหม่กับ object stream

การตรวจสามข้อจับความล้มเหลวทุกแบบที่เล่ามาข้างบน และไม่มีข้อไหนต้องใช้ Acrobat ข้อแรก เทียบ IndexedObjectCount กับ MaterializedObjectCount หลังการเซฟ ในการเขียนใหม่ทั้งไฟล์ทั้งคู่ต้องเท่ากัน และช่องว่างใด ๆ คือ member ที่ถูกทิ้ง ข้อที่สอง extract ข้อความและไล่ดู structure tree ของทั้งสองไฟล์ ไม่ใช่แค่ render เพื่อให้ ActualText ที่หายไปหรือ structure element ที่หายไปโผล่เป็น diff ข้อที่สาม โหลด output ด้วย instance ใหม่และ assert ว่า GetLoadedQuarantinedObjStmCount เป็นศูนย์ ซึ่งพิสูจน์ด้วยว่าตัวเขียนไม่ได้ผลิต container ที่ตัวอ่านเปิดไม่ได้ ชุดค่าผสมของ crypt filter ที่ตัดสิน FReloadObjectStreamsEncrypted อยู่ในบทความเรื่องนโยบาย StmF, StrF และ EFF ส่วนฝั่งตัวเขียนของเรื่องนี้ คือวิธีปล่อย object stream และเมื่อไรควรเลือก incremental update แทนการเขียนใหม่ อยู่ในคู่มือ object stream และ incremental update

การโหลด member แบบ lazy, รอบการขยายก่อนตัวเขียน, การกักกันการถอดรหัส และการเก็บ token ต้นทาง ทั้งหมด ship อยู่ใน HotPDF Delphi Component สำหรับ Delphi และ C++Builder หน้ารวมลิงก์เอกสารอ้างอิง API ไว้ด้วยถ้าคุณอยากไล่ดู GetLoadedObjectStreamCacheInfo กับ accessor ของการกักกันเทียบกับ pipeline การนำเข้าของคุณเอง