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

CompressDocument ของ HotPDF: font subset กะทัดรัดใน Delphi

THotPDF.CompressDocument ของ HotPDF เป็นสวิตช์เดียวที่ทำให้ BeginDoc ผลิต PDF แบบ lossless ที่เล็กที่สุดเท่าที่ component จะเขียนได้: FlateDecode ระดับสูงสุด, cross-reference stream พร้อม object streams, font subsetting และ font subset แบบกะทัดรัดที่เรียกเลข glyph ที่เก็บไว้ใหม่หลัง /CIDToGIDMap ที่ประกาศชัด ๆ EndDoc แล้วคืนค่าตั้งของคุณกลับไปเหมือนเดิม เอกสารทดสอบสามหน้าด้วย Arial กับ SimSun ลดจาก 10.2 MB เหลือ 20 KB โดย render ออกมาเหมือนกันทุกอย่าง

CompressDocument เปิดอะไรจริง ๆ

CompressDocument ข้ามค่าตั้งฝั่งเขียนหกตัว บวกเพดาน object-stream ไว้กับเอกสารเดียว แล้วคืนทั้งหมดหลังจบ ที่ BeginDoc ก่อนเวอร์ชัน PDF จะตกลงตัว HotPDF จดค่าของคุณไว้แล้วตั้ง Compression เป็น cmFlateDecode, CompressionLevel เป็น clMaximum เปิด EnableFontSubsetting กับ CompactFontSubsetting และเปิด UseXRefStream บวก UseObjectStreams (ISO 32000-1 §7.5.7 กับ §7.5.8) object streams ต้องการ PDF 1.5 Version ที่เก่ากว่าจึงถูกยกเป็น 1.5 เมื่อไม่ได้ล็อกไว้ PDF/A-1 ห้ามทั้งสองโครงสร้าง เอกสาร PDF/A-1 จึงคงตาราง cross-reference แบบคลาสสิกและได้แค่งานฝั่ง Flate กับฟอนต์ ส่วนภาพถูกปล่อยไปตามที่คุณฝังเข้ามาเป๊ะ ๆ

แผนภาพ lifecycle ของ CompressDocument ใน HotPDF บน Delphi: BeginDoc จดค่าตั้งฝั่งเขียนของตัวเอง ข้ามค่าตั้งหกตัวรวม Compression กับ UseObjectStreams ไว้กับเอกสารเดียว และ EndDoc คืนทุกค่าที่ยืมไปใน finally ชั้นนอกสุด ขณะที่ property CompressDocument เองยังคง True
ค่าตั้งฝั่งเขียนหกตัวกับเพดาน object-stream ถูกยืมไปเพียงเอกสารเดียวแล้วคืนเมื่อ EndDoc ทำงาน รายงานที่ล้มกลางทางจึงไม่ทิ้ง component ค้างที่การบีบอัดสูงสุด
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'invoice-2026-1042.pdf';
    Pdf.CompressDocument := True;    // BeginDoc นำไปใช้ EndDoc คืนค่ากลับ
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(40, 40, 0, 'Invoice 2026-1042');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

การคืนค่าเกิดขึ้นใน finally ชั้นนอกสุดของ EndDoc ทำให้ exception กลางทางของรายงานไม่ทิ้ง component อายุยาวค้างที่การบีบอัดสูงสุดสำหรับงานถัดไป property CompressDocument เองยังคงเป็น True มีแต่ค่าตั้งหกตัวที่มันยืมไปที่กลับไป เรื่องเวอร์ชันถูกจัดการด้วยความละเอียดกว่านั้น HotPDF ยกเลิกการยกเวอร์ชันของตัวเองเป็น 1.5 เฉพาะเมื่อเอกสารยังจบที่ 1.5 เท่านั้น ถ้าฟีเจอร์อื่นดันไฟล์ไปที่ 1.6 ระหว่างทำงาน (ฟอนต์ OpenType ที่ฝังเข้ามา เช่น) เวอร์ชันที่สูงกว่าก็อยู่ต่อ เหมือนกรณีที่ไม่มีการบีบอัดทุกประการ

ทำไม font subset ยังใหญ่อยู่ถ้าไม่บีบเลข glyph

subset ของ TrueType แบบคลาสสิกทิ้ง outline ที่คุณไม่เคยวาด แต่เก็บ glyph ID ทุกตัวไว้ที่เดิม และการเรียงเลขนี่เองคือสิ่งที่ทำให้มันหนัก content stream แสดง CID ที่เท่ากับ GID เดิม subset จึงต้องเก็บ offset ของ loca กับ entry ของ hmtx ไว้ทุกช่องจนถึง glyph สูงสุดที่มันเก็บไว้ ว่างหรือไม่ว่างก็ตาม สำหรับฟอนต์ลาติน overhead พวกนี้คือฝุ่น แต่สำหรับฟอนต์ CJK อย่าง SimSun ที่อักษรจีนนั่งลึกอยู่ในตาราง glyph ใหญ่โต อักษรจีนสองตัวก็ลากตารางขนาดทั้งฟอนต์มาด้วย กฎ closure ของ font subset สำหรับ glyph ที่ผ่านการจัดรูปเป็นตัวตัดสินว่า glyph ไหนรอด compaction เป็นเรื่องว่าผู้รอดต้องจ่ายเท่าไร

CompactFontSubsetting เรียงเลข glyph ที่เก็บไว้ใหม่เป็นช่วงแน่นเริ่มที่ศูนย์ แล้วเขียน stream ของ /CIDToGIDMap ลงบน CIDFont ซึ่ง ISO 32000-1 §9.7.4.2 นิยามว่าเป็นตาราง GID สองไบต์ที่ชี้ด้วย CID ตารางนี้แหละคือทั้งมายากล content stream, array ความกว้าง /W และ ToUnicode CMap ล้วนคง CID เดิม สิ่งที่เขียนไปแล้วจึงไม่ต้องแตะเลย มีแค่การ lookup จาก CID ไป glyph ที่ย้ายเข้าไปอยู่ใน map ในชุดทดสอบที่จุดประกอบฟีเจอร์นี้ SimSun สองอักษรลดจากข้อมูลฟอนต์ 24.8 KB เหลือ 3.1 KB

เทียบ font subset แบบห่างของ HotPDF ที่เก็บ entry ของ loca กับ hmtx ไว้ทุก glyph ID เดิมจนถึง GID สูงสุดที่เก็บ กับ output ของ CompactFontSubsetting ที่เรียงเลข glyph ที่เก็บไว้แน่นจากศูนย์แล้ว map CID ผ่าน stream ของ CIDToGIDMap โดย content stream, /W กับ ToUnicode คงเดิม
การเรียงเลขใหม่ย้ายต้นทุนออกจาก font program ไปอยู่ใน map stream เล็ก ๆ หนึ่งก้อน — อักษร SimSun สองตัวลดจาก 24.8 KB เหลือ 3.1 KB โดยไม่แตะเนื้อหาที่เขียนไปแล้วแม้แต่ไบต์เดียว

compaction มีขอบเขตแน่นหนา และมันถอยลงเงียบ ๆ แทนการล้ม HotPDF สร้าง compact subset เฉพาะฟอนต์ TrueType แบบ Type 0 ทั้งที่ตั้งผ่าน SetFont โดยเปิด subsetting และฟอนต์ที่ลงทะเบียนผ่าน RegisterUnicodeTTF ฟอนต์ TrueType ธรรมดาหา glyph ผ่าน cmap ข้างใน font program ซึ่งการเรียงเลขใหม่จะทำพัง มันจึงคง subset แบบห่างไว้ ฟอนต์ OpenType-CFF ก็ไม่มีทาง compact เช่นกัน compact build ที่ล้มจะ fallback ไปใช้ subset แบบห่างแทนการยก exception property นี้ปิดเป็นค่าเริ่มต้น output เดิมจึงออกมาเหมือนเดิมทุกไบต์ ขณะที่ใต้ PDF/A ฟอนต์ Unicode ที่ลงทะเบียนไว้จะได้ compact subset เสมอ

Pdf.EnableFontSubsetting := True;
Pdf.CompactFontSubsetting := True;   // ใช้ได้โดยไม่ต้องเปิด CompressDocument
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('SimSun', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, WideString('Total: '#$4E2D#$6587));
Pdf.EndDoc;

ตัวเขียนแบบ packed บีบโครงสร้างไฟล์อย่างไร

พอฟอนต์กับ stream เล็กลง dictionary กับข้อมูล cross-reference กลายเป็นต้นทุนที่เหลือใหญ่ที่สุด ตัวเขียน object-stream เบื้องหลัง CompressDocument จึงตัดส่วนนั้นด้วย คู่มือ object streams กับ incremental updatesเล่ารูปแบบ container เองไว้แล้ว เส้นทางการบีบอัดเติมการปรับสี่จุดบนนั้นต่อ:

  • syntax กะทัดรัดตาม ISO 32000-1 §7.2.2: เขียนช่องว่างเฉพาะระหว่าง token คู่ที่ไม่งั้นจะติดกันเป็นอักขระธรรมดา /Type /Page จึงกลายเป็น /Type/Page
  • ฟิลด์ของ cross-reference stream ใช้ความกว้างอะไรก็ได้ที่ §7.5.8.2 อนุญาต ไฟล์ต่ำกว่า 16 MB เก็บแต่ละ offset ลง 3 ไบต์แทน 4
  • object ได้ถึง 250 ตัวต่อหนึ่ง object stream แทนที่จะเป็น 100 ตามปกติ เว้นแต่คุณจะตั้งเพดานเองผ่าน ConfigureAdaptiveObjectStreamPacking
  • เมื่อไฟล์ไม่ถูกเข้ารหัส Catalog กับ dictionary ของ Info ถูกแพ็กลง object streams ด้วย output ที่เข้ารหัสแล้วคงพวกมันไว้ที่ระดับบนสุด

syntax แบบกะทัดรัดมาพร้อมกับดักที่สมควรรู้ถ้าคุณต่อยอดฝั่งเขียน การลงนามเติมลายเซ็นหลังไฟล์ถูกเขียนโดยค้นไบต์หา placeholder แบบ literal /ByteRange ( กับ /Contents < และการสะกดแบบกะทัดรัดจะเปลี่ยนพวกมันเป็น /ByteRange( กับ /Contents< ที่การค้นหาไม่มีวันเจอ dictionary ของลายเซ็น (Type Sig หรือ DocTimeStamp, FT Sig) และ dictionary ของการเข้ารหัสจึงคงเลย์เอาต์มีช่องว่างไว้ บั๊กที่เกี่ยวข้องอีกตัวกระทบ build ก่อน v2.766.41: การ save แบบมี object stream ทุกครั้ง CompressDocument รวมอยู่ด้วย เริ่มด้วยบรรทัด header %PDF- สองบรรทัด จึงควรอัปเกรดถ้า validator ที่เข้มงวดติง output ของคุณ

บีบอัด PDF ที่โหลดไว้แล้วได้ไหม

ได้ ผ่าน overload แบบ options CompressLoadedDocument(Options, Info) ที่รันขั้นตอน lossless ชุดเดียวกันกับไฟล์ที่มีอยู่ ด้วย THPDFLoadedDocumentCompressionOptions.Default มันลบ page resource ที่ไม่ถูกใช้ รวมฟอนต์กับฟอร์มที่เหมือนกัน subset ฟอนต์ที่ฝังมาโดยเปิด compact subset บีบ stream ที่ยังไม่ถูกบีบอัดแบบ Flate, LZW, ASCII กับ RunLength ใหม่ด้วย Flate เมื่อผลลัพธ์เล็กกว่า และบังคับให้การ save ถัดไปใช้ object streams HighRatioFlate ปิดเป็นค่าเริ่มต้น และ object streams ถูกข้ามในกรณี PDF/A-1 กับการ save แบบ incremental overload ไร้พารามิเตอร์ของ CompressLoadedDocument เป็นการเรียกรุ่นเก่าที่แคบกว่า ทำได้แค่บีบ stream ที่ยังไม่ถูกบีบอัดด้วย Flate

ขั้นตอนของ CompressLoadedDocument ใน HotPDF บน Delphi: การเรียกลบ page resource ที่ไม่ถูกใช้ รวมฟอนต์กับฟอร์มที่เหมือนกัน subset ฟอนต์ที่ฝังมาด้วย compact subset บีบ stream ใหม่ด้วย Flate เฉพาะเมื่อผลลัพธ์เล็กกว่า และเปิด object streams ให้การ save ถัดไป ขณะที่ฟิลด์ลายเซ็นทำให้เจอ RefusedBySignaturePolicy และไฟล์ถูกปล่อยตามเดิม
ทุกขั้นตอนเขียนทับไบต์ที่ลายเซ็นครอบอยู่ เอกสารทั้งฉบับจึงถูกปฏิเสธเว้นแต่คุณจะอนุญาตให้ลายเซ็นเสียความหมายอย่างชัดเจน — Info.BytesSaved จากนั้นรวมแค่งานฝั่ง resource, ฟอนต์ กับ stream เท่านั้น
var
  Doc: THotPDF;
  Options: THPDFLoadedDocumentCompressionOptions;
  Info: THPDFLoadedDocumentCompressionInfo;
begin
  Doc := THotPDF.Create(nil);
  try
    Doc.AutoLaunch := False;
    Doc.LoadFromFile('quarterly-report.pdf');
    Options := THPDFLoadedDocumentCompressionOptions.Default;
    Doc.CompressLoadedDocument(Options, Info);
    if Info.RefusedBySignaturePolicy then
      Writeln(Format('Left untouched: %d signature fields', [Info.SignatureCount]))
    else
    begin
      Writeln(Format('Compact fonts: %d, stream bytes saved: %d',
        [Info.Fonts.CompactSubsetFontCount, Info.BytesSaved]));
      Doc.SaveLoadedDocument('quarterly-report-compact.pdf');
    end;
  finally
    Doc.Free;
  end;
end;

สองเส้นแบ่งสำคัญบนเส้นทาง loaded ทุกขั้นตอนเขียนทับไบต์ที่ลายเซ็นครอบอยู่ เอกสารที่มีฟิลด์ลายเซ็นจึงถูกปฏิเสธทั้งฉบับ: การเรียกคืน 0 ตั้ง RefusedBySignaturePolicy และไม่เปลี่ยนอะไรเลย เว้นแต่คุณจะตั้ง AllowSignatureInvalidation หลังจากนั้น Info.SignaturesInvalidated จะบอกว่าคุณเสียอะไรไป compaction ที่นี่ยังอนุรักษนิยมกว่าเส้นทางสร้างเอกสารอีก HotPDF บีบเฉพาะ font program ที่ถูกใช้โดยฟอนต์ CIDFontType2 โดยเด็ดขาด พร้อม /CIDToGIDMap แบบ Identity ที่ CID เท่ากับ GID และข้าม program ที่มี map stream อยู่แล้ว มี /CIDSet หรือตาราง glyph สีอย่าง COLR, sbix, CBDT หรือ SVG เพราะการสร้างใหม่แบบกะทัดรัดจะทิ้งเลเยอร์สีไป จดไว้ด้วยว่า Info.BytesSaved รวมเฉพาะขั้นตอน resource, ฟอนต์ กับ stream กำไรฝั่ง object-stream จะโผล่ตอนไฟล์ถูกเขียน

ผลลัพธ์ที่ควรคาดหวังในทางปฏิบัติ

กำไรตามสัดส่วนของไฟล์ที่เป็นโครงสร้างยังไม่บีบอัดกับข้อมูลฟอนต์ที่ใหญ่เกินงาม ไม่ใช่ตามจำนวนหน้า ตัวอย่างสามหน้า Arial กับ SimSun หดจาก 10.2 MB เหลือ 20 KB เมื่อสร้างโดยเปิด CompressDocument และจาก 10.2 MB เหลือ 19.8 KB เมื่อเอาต้นฉบับที่ยังไม่บีบอัดมาโหลดแล้วรันผ่าน CompressLoadedDocument โดย render ทั้งสองวิธีเหมือนกันทุกอย่าง PDF ที่กะทัดรัดอยู่แล้วแทบไม่ขยับ: ในชุด regression ไฟล์พวกนี้ประหยัดได้ในช่วง -0.07% ถึง +0.06% ของขนาดเดิม ไฟล์หนักภาพถ่ายได้น้อย เพราะไม่มีเส้นทางไหนแตะข้อมูลภาพเลย

ถ้าคุณผลิตรายงาน CJK ชุดเดิมทุกคืน จับ font subset แบบกะทัดรัดคู่กับแคช font subset ถาวรบนดิสก์เพื่อไม่ต้องคำนวณการ subset ซ้ำในแต่ละรอบ และ diff ของ compressed output ด้วยเนื้อหา object แทนไบต์ เพราะฟิลด์ที่เปลี่ยนไปหนึ่งตัวจะบีบ object stream ทั้งก้อนใหม่ ข้อมูล property กับ record ครบถ้วนอยู่ที่หน้าผลิตภัณฑ์ HotPDF Delphi PDF component