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

ลบ identity Tm ใน content stream ของ PDF อย่างปลอดภัย

PDF Library for Delphi ลบ operator text matrix identity อย่าง 1 0 0 1 0 0 Tm ระหว่างการ optimize content stream แบบ peephole ตอน save ได้เฉพาะเมื่อ text matrix กับ text line matrix เป็น identity อยู่แล้วเท่านั้น: คือตรงหลัง BT หรือตรงหลัง identity Tm ตัวก่อนหน้า ส่วน identity cm ยังถูกทิ้งตลอดเหมือนเดิม เพราะ cm คูณเข้ากับ CTM ขณะที่ Tm แทนที่ text matrix ทั้งคู่ทั้งก้อน ตั้งแต่ v3.539.28 identity Tm แบบอื่นทุกตัวจึงอยู่ใน stream ครบ

บั๊กที่ถูกแก้คือแบบเงียบ ๆ โปรแกรมสร้างรายงานพ่น BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET ออกมา โดยหวังให้ identity Tm ส่ง string ตัวที่สองกลับไปที่จุดกำเนิดของ text space ก่อนจะใช้ logic จัดตำแหน่งของตัวเอง optimizer รุ่นเก่าเห็นเลขหกตัวที่สะกดออกมาเป็น identity matrix จึงตัดสินใจว่า operator นี้เปลี่ยนอะไรไม่ได้แน่ แล้วลบทิ้ง ไม่มีอะไรล้ม ไม่มีอะไร log เตือน และหน้าที่บันทึกก็วาด "Total" ต่อจาก "Invoice" ทันทีบน baseline เดียวกัน ซึ่งเป็นบั๊กพันธุ์ที่ไม่มีใครสังเกตจนกว่าลูกค้าจะสั่งพิมพ์ PDF ออกมา

ทำไม 1 0 0 1 0 0 Tm ไม่ได้เป็น no-op เสมอไป

identity Tm เป็น no-op ก็ต่อเมื่อมันจะแทนที่ matrix สองตัวที่เป็น identity อยู่แล้ว ซึ่งเป็นสมบัติของ operator ก่อนหน้ามัน ไม่ใช่ของ operand ของตัวมันเอง ISO 32000-1 §9.4.1 บอกว่า BT ตั้งทั้ง text matrix (Tm) และ text line matrix (Tlm) ให้เป็น identity และ §9.4.2 นิยาม Tm ว่าตั้งทั้งคู่เป็นค่าที่ให้มา ไม่ใช่คูณต่อท้าย เทียบกับ cm (§8.4.4) ที่คูณเข้าทางขวาของ current transformation matrix การคูณด้วย identity ไม่เปลี่ยน CTM ใด ๆ 1 0 0 1 0 0 cm จึงลบได้ปลอดภัยทุกที่ ข้างใน text object ภาพไม่เหมือนกัน Td, TD, T* และ Tm ที่ไม่ใช่ identity ล้วนขยับ Tlm และ operator แสดงข้อความทุกตัว (Tj, TJ, ', ") เลื่อน Tm ตามความกว้างของ glyph ที่มันวาดไป หลังอย่างใดอย่างหนึ่งพวกนี้ identity Tm คือการรีเซ็ตกลับจุดกำเนิดอย่างแท้จริง ถ้าเคยตามตำแหน่งข้อความด้วยมือผ่านตัวติดตามสถานะ CTM กับ text matrix ของ content stream นี่คือความต่างแบบเดียวกันระหว่างการคูณต่อสถานะกับการแทนที่มัน

PDFlibPas ตีความ 1 0 0 1 0 0 cm กับ 1 0 0 1 0 0 Tm ไม่เหมือนกัน: cm คูณเข้าทางขวาของ CTM จึงเป็น no-op ที่ไหนก็ได้ ขณะที่ Tm แทนที่ Tm กับ Tlm ทั้งก้อน และ Tj ทุกตัวเลื่อน Tm ตามความกว้างที่มันวาด identity Tm หลังข้อความที่แสดงไปแล้วจึงเป็นการรีเซ็ตจริง
โปรแกรมสร้างรายงานหวังพึ่งการรีเซ็ตนั้น: การลบ identity Tm ทำให้ Total ถูกวาดต่อจาก Invoice ทันทีบน baseline เดียวกัน และไม่มีอะไรล้ม ไม่มี log ไม่มีการเตือนตลอดทางไปถึงเครื่องพิมพ์ของลูกค้า

scan ย้อนหลังตัดสินใจอย่างไรว่า identity Tm ตัวไหนทิ้งได้

TPDFContentPeepholeOptimizer.RemoveIdentityMatrices ตอนนี้เดินย้อนหลังจาก identity Tm แต่ละตัวและทิ้งมันเฉพาะเมื่อ scan ไปเจอ BT หรือ identity Tm อีกตัวก่อน identity Tm ตัวก่อนหน้านับว่าผ่านทั้งกรณีถูกเก็บไว้หรือเพิ่งถูกจัดคิวลบ เพราะไม่ว่าทางไหนมันก็ทิ้ง matrix ทั้งคู่ไว้ที่ identity เหมือนที่ BT ทำ กฎนี้จัด operator ทุกตัวที่ scan อาจเจอลงหนึ่งในสองกลุ่ม:

  • หยุดและเก็บ Tm ไว้: Td, TD, T*, Tm ที่ไม่ใช่ identity, Tj, TJ, ', ", ET, operator ใดที่ parser ไม่รู้จัก หรือจุดเริ่มของ stream
  • ก้าวข้ามแล้ว scan ต่อ: operator ที่ไม่เคยแตะ Tm หรือ Tlm เช่น Tf, Tc, ตัวตั้งสี, gs, operator marked-content และ cm
RemoveIdentityMatrices ของ PDFlibPas เดินย้อนหลังจาก identity Tm แต่ละตัว: Tf, Tc, ตัวตั้งสี, gs กับ cm ถูกก้าวข้ามไป ขณะที่ Td, TD, T*, Tm ที่ไม่ใช่ identity, Tj, TJ, operator ที่ไม่รู้จักหรือ ET หยุด scan และเก็บ Tm ไว้ และ BT เป็นผู้รับรองว่าทิ้งได้
identity Tm ตัวก่อนหน้าก็หยุด scan ได้เหมือนกัน เพราะไม่ว่าจะถูกเก็บไว้หรือจัดคิวลบไปแล้วมันก็ทิ้ง matrix ทั้งคู่ไว้ที่ identity — optimizer ไม่มีวันขยับ glyph แม้แต่ตัวเดียวไม่ว่ากรณีใด

กรณีอนุรักษนิยมพวกนี้ตั้งใจไว้แบบนั้น operator ที่ไม่รู้จักอาจเป็นอะไรก็ได้ scan จึงปฏิเสธจะอ้างเหตุผลข้ามมันไป ET ปิด text object Tm ที่อยู่หลังมันจึงไม่มี BT คอยรับรองค่าของ matrix scan ยังทำงานทีละหนึ่ง content stream ด้วย ซึ่งสำคัญกับหน้าที่ /Contents เป็น array: เลเยอร์ที่เริ่มกลาง ๆ text object โดยไม่มี BT ของตัวเอง จะเก็บ identity Tm ของมันไว้แม้เลเยอร์ก่อนหน้าจะทำให้มันซ้ำซ้อนก็ตาม ราคาคือไบต์ไม่กี่ตัวบนไฟล์แปลก ๆ แลกกับการไม่ขยับ glyph เด็ดขาด ถ้าคุณแก้ข้อความหน้าที่ระดับ instruction อย่างในwalkthrough การ map อักขระกับ content byte โมเดล TPDFContentProgram ที่ parse แล้วตัวเดียวกันนี้แหละที่ optimizer เขียนทับ

uses
  PDFlibContentModel, PDFlibContentOptimize;

function OptimizeSnippet(const Source: AnsiString): AnsiString;
var
  Prog: TPDFContentProgram;
  Optimizer: TPDFContentPeepholeOptimizer;
begin
  Result := Source;
  Prog := TPDFContentProgram.Create;
  try
    if not Prog.Parse(Source) then
      Exit; // สตรีมเสียหาย: ปล่อยไบต์ไว้ตามเดิม
    Optimizer := TPDFContentPeepholeOptimizer.Create(Prog);
    try
      Optimizer.Run; // คืนจำนวน instruction ที่ถูกเอาออก
    finally
      Optimizer.Free;
    end;
    Result := Prog.Emit; // หนึ่ง instruction ต่อหนึ่งบรรทัด
  finally
    Prog.Free;
  end;
end;

// ถูกเอาออก: Tm ตรงหลัง BT, ตัวที่สองจากสอง identity Tm ติดกัน
//   OptimizeSnippet('BT /F1 12 Tf 1 0 0 1 0 0 Tm (hello) Tj ET')
// ถูกเก็บไว้: Tm หลัง Td, หลัง Tj, หลัง Tm ที่ไม่ใช่ identity หรืออยู่นอก BT
//   OptimizeSnippet('BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET')

รัน helper กับสตรีมใบแจ้งหนี้จากตอนเปิดเรื่อง identity Tm จะรอด เพราะ scan ย้อนหลังเจอ Tj ก่อนไปถึง BT ใส่ /F1 12 Tf, 2 Tc กับ 0 g คั่นระหว่าง BT กับ identity Tm มันก็ยังถูกทิ้ง เพราะไม่มีตัวไหนในนั้นแตะ text matrix ลำดับอย่าง BT 10 20 Td 1 0 0 1 0 0 Tm 1 0 0 1 0 0 Tm เสีย operator ไปพอดีหนึ่งตัว: identity Tm ตัวแรกรีเซ็ต matrix ที่ Td ขยับไว้ และตัวที่สองเท่านั้นที่ซ้ำซ้อน

peephole optimizer รันตอนไหนกันแน่

optimizer รันเฉพาะระหว่างรอบบีบอัด ภายใน TPDFPageTree.Compress และเฉพาะ content stream ที่ยังไม่ถูกบีบอัดด้วย Flate เท่านั้น TPDFlib.SetOptimizeContentStreams(1) เป็นค่าเริ่มต้น และสวิตช์เดียวกันโผล่มาเป็น field OptimizeContentStreams ของ TPDFlibSaveOptions ทั้ง CompressContent กับ CompressPage ก็ฟังคำนี้ สตรีมที่ /Filter เป็น /FlateDecode อยู่แล้วถูกข้ามทั้งหมด การโหลด PDF ที่บีบอัดแล้วแล้วบันทึกซ้ำจึงไม่แตะ operator ของมันเลย ถ้าสตรีม parse ไม่ผ่าน ไบต์ที่ decode แล้วเดิมจะถูกบีบอัดไปตามที่เป็น TPDFlib.NormalizeContentStreams parse แล้วพ่นเนื้อหาใหม่ด้วยระยะห่างกับตัวเลขแบบ canonical แต่ไม่เคยเรียก optimizer ทำให้มันเป็น baseline ที่ใช้ดูได้ดีว่ากฎ peephole ช่วยเรื่องขนาดได้เท่าไร เคียงข้างกำไรก้อนใหญ่กว่าที่เล่าไว้ในการ optimize ขนาดไฟล์ PDF ด้วย font subsetting

PDFlibPas รัน peephole optimizer เฉพาะข้างในรอบบีบอัดตอน save: TPDFPageTree.Compress ฟัง SetOptimizeContentStreams สตรีมที่ถูก filter ด้วย /FlateDecode อยู่แล้วถูกข้ามทั้งหมด สตรีมที่ parse ไม่ได้ถูกบีบอัดด้วยไบต์เดิมโดยไม่เปลี่ยน และ NormalizeContentStreams ไม่เคยเรียก optimizer เลยแม้แต่ครั้งเดียว
สตรีมที่บีบอัดแล้วและถูกข้ามคือส่วนที่เงียบที่สุด: โหลด PDF ที่มีอยู่บันทึกซ้ำ operator ก็ออกมาเหมือนเดิมทุกอย่าง เพราะ optimizer เขียนทับเฉพาะสตรีมที่มัน decode เองก่อนเท่านั้น
var
  Lib: TPDFlib;
  Options: TPDFlibSaveOptions;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('report.pdf', '') <> 1 then
      Exit;
    // สตรีมที่ยังไม่บีบอัดผ่านกฎ peephole ก่อนแล้วค่อย Flate
    Lib.SetOptimizeContentStreams(1);
    Lib.CompressContent;
    Lib.SaveToFile('report-optimized.pdf');

    // ทางเลือกเดียวกันผ่าน save options ที่แถมมา; False คือไม่ใช้
    Options.CompressContent := True;
    Options.CompressFonts := True;
    Options.CompressImages := True;
    Options.Linearize := False;
    Options.KeepModDate := False;
    Options.OptimizeContentStreams := False;
    Options.GarbageCollect := False;
    Options.PackObjectStreams := True;
    Lib.SaveToFileOptions('report-plain.pdf', Options);
  finally
    Lib.Free;
  end;
end;

ชุดทดสอบ regression รุ่นเก่ารับประกันอะไรกันแน่

ชุดทดสอบ regression รุ่นเก่ารับประกันรูปร่างเดียวเท่านั้น: identity Tm ที่อยู่ตรงหลัง BT ถูกเอาออก Peephole_RemovesIdentityTextMatrix ป้อน BT 1 0 0 1 0 0 Tm (hello) Tj ET ให้ optimizer แล้ว assert ว่าไม่เหลือ Tm สักตัว รีลีสก่อนหน้าเคยระบุไว้แล้วว่าการทิ้ง identity Tm ไม่ปลอดภัยเมื่อ Tlm ไม่ใช่ identity แต่ก็คงพฤติกรรมนั้นไว้ต่อเพราะชุดทดสอบ "ล็อก" มันเอาไว้ อ่านใหม่อย่างตั้งใจ ชุดทดสอบไม่ได้พูดอะไรกับ identity Tm หลัง Td หรือหลังข้อความที่แสดงไปแล้วเลย การเอาความครอบคลุมของตัวอย่างเดียวมาเป็นสัญญาของกฎทั้งกฎต่างหากที่เป็นความผิดพลาดจริง การแก้คงกรณีเดิมให้ผ่านต่อไปและเพิ่มหกกรณีที่ตอกทั้งรูปร่างที่ทิ้งได้และรูปร่างที่ต้องเก็บ รวมถึง Tm ที่อยู่นอก text object ใด ๆ และตัวที่ตามหลัง ET

trade-off นี้ยอมรับง่ายพอได้เขียนลงไป โปรแกรมสร้างเอกสารที่ห่อ text object ทุกอันด้วย BT 1 0 0 1 0 0 Tm ... ก็ยังได้ operator ซ้ำซ้อนตัวนี้ถูกเอาออก ซึ่งเป็นที่มาของกำไรขนาดเกือบทั้งหมด สิ่งที่ optimizer ยอมสละคือ identity Tm แบบมาเป็นครั้งคราวที่อยู่กลาง text object ไบต์ไม่กี่ตัวต่อหนึ่งหน้าก่อน Flate จะได้เห็นพวกมันเสียอีก แลกกับการรับประกันที่หัวโมดูลพูดกันตรง ๆ: transform ทุกตัวให้ output เทียบเท่ากันและไม่เปลี่ยนหน้าที่มองเห็นเด็ดขาด optimizer ที่ช่วยขนาดแต่ขยับข้อความไม่ใช่ optimizer มันคือบั๊ก rendering ที่มีอัตราบีบอัดดีเท่านั้น

parser ของ content stream, peephole optimizer และตัวเลือกบีบอัดตอน save ที่เล่ามาทั้งหมดมาพร้อมPDF Library for Delphi and C++Builder ซึ่งเปิด NormalizeContentStreams, CompressContent กับ TPDFlibSaveOptions ออกมาให้ปรับว่าเอกสารแต่ละฉบับจะถูกเขียนอย่างไร