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

ตรวจจับ glyph ที่หายไปของ PDF ตอนวาดใน Delphi

glyph ที่หายไปใน PDF ไม่ใช่ error ผู้ผลิตขอตัวอักษรที่ font ที่เลือกไว้ map ไม่ได้ font คืน glyph index ศูนย์ และไฟล์ที่ออกมาถูกต้องเชิงโครงสร้าง เปิดได้ทุกที่ และแสดงกล่องว่างตรงที่ชื่อหรือจำนวนเงินควรจะอยู่ ไม่มีใครใน pipeline ที่ผลิตมันรู้ ผู้รับต่างหากที่รู้ HotPDF ปิดวงวนนี้ด้วย TrackUnresolvedGlyphs: เปิดมันแล้วเส้นทางการวาดข้อความจะบันทึก code point ทุกตัวที่ผลการค้น glyph ไปจบที่ index ศูนย์ ยิง OnUnresolvedGlyph ครั้งเดียวต่อข้อค้นพบที่ไม่ซ้ำ พร้อม code point, font ที่ล้มเหลว, ระบบเขียนที่มันสังกัด และคำแนะนำ font ที่จะครอบคลุมมันได้

การตรวจจับเป็นครึ่งหนึ่งของคำตอบ อีกครึ่งคือ SetFontFallbackChain ซึ่งลงทะเบียนรายการ font ที่เรียงลำดับไว้แยกตามระบบเขียน กรณีทั่วไปจึง resolve ตัวเองได้ และมีเฉพาะช่องว่างจริง ๆ ที่ไปถึง handler ของคุณ ทั้งสองร่วมกันเปลี่ยนข้อบกพร่องชั้นหนึ่งที่เคยถูกรายงานโดยลูกค้าให้กลายเป็นการเช็กตอน build

ทำไม glyph ที่หายไปจึงไม่ raise อะไร

เพราะ ISO 32000 ไม่ได้บังคับให้ผู้ผลิตตรวจความครอบคลุม และ glyph index ศูนย์เป็น glyph ที่ถูกกฎหมาย มันคือ .notdef ซึ่งรูปทรงของมันเป็นดุลพินิจของผู้ออกแบบ font: มักเป็นสี่เหลี่ยมเปล่าหรือกลวง บางทีก็ไม่มีอะไรเลย viewer ที่วาดมันออกมากำลังทำงานถูกต้อง การดึงข้อความยังอาจคืนตัวอักษรที่ถูกต้องด้วย เพราะ mapping /ToUnicode ถูกเขียนจากข้อความต้นฉบับ ไม่ใช่จาก outline การเช็กแบบวนกลับอัตโนมัติจึงยินดีผ่านเอกสารที่ข้อความที่มองเห็นมีรูรั่วอยู่

แผนภาพเหตุที่ glyph ที่หายไปของ PDF ยังเงียบ เมื่อ glyph ศูนย์วาดกล่องว่างและการดึงด้วย ToUnicode ผ่านการเช็กแบบวนกลับ
glyph ศูนย์เป็นคำตอบ .notdef ที่ถูกกฎหมาย และ /ToUnicode เขียนจากข้อความต้นฉบับ ไม่มีอะไรใน pipeline จึงได้รับแจ้งเรื่องช่องว่างนี้

ผลทางปฏิบัติก็คือความครอบคลุมต้องถูกเช็กในขณะวาด ช่วงที่ไลบรารียังรู้ว่า code point ตัวใดถูกขอและ glyph ตัวใดที่ font เสนอจริง หลังจากนั้นข้อมูลนั้นหายไปแล้ว

ตัวตรวจจับต้องมองสถานะ subset ไม่ใช่ device context

นี่คือจุดที่ implementation แรกพลาด และเหตุผลสมควรเข้าใจ เพราะมันใช้กับการเช็กความครอบคลุมแบบคาดเข้าไปกับ pipeline ข้อความใด ๆ HotPDF มีเส้นทางข้อความสองเส้นทาง เส้นทางหนึ่งปล่อยผ่าน font Unicode แบบ TrueType ที่ลงทะเบียนไว้ พร้อม character map ในหน่วยความจำที่สร้างตอนลงทะเบียน อีกเส้นทางเป็นเส้นทาง GDI รุ่นเก่าที่สร้าง device context และ font handle ใหม่ต่อช่วงข้อความหนึ่งช่วง

การตัดสินความครอบคลุมจากเส้นทาง GDI เป็นสิ่งที่สิ้นหวัง mapping ของมันไม่ใช่ mapping ที่ลงไปอยู่ใน content stream ที่ถูกปล่อยออกมา และสองตัวไม่ได้ sync กัน ตัวตรวจที่อ่านผล GDI จึงรายงานช่วง ASCII ที่พิมพ์ได้ทั้งช่วงเป็น unresolved คำตอบที่เป็นตัวจริงอยู่ที่ font ที่ลงทะเบียน: character map ที่ RegisterUnicodeTTF parse มา สอบถามผ่าน GetUnicodeGlyphForCodepoint ตัวตรวจจึงถูก gate ที่สถานะพร้อม subset ไม่ใช่ที่เงื่อนไข GDI ใด ๆ และมันไม่รันเลยกับเอกสารที่ไม่เคยลงทะเบียน font Unicode ซึ่งถูกต้องแล้ว เพราะเอกสารพวกนั้นถูกจำกัดไว้กับ standard encoding อยู่แล้ว

มีกับดักที่สองนั่งอยู่ข้าง ๆ ชื่อตระกูล GDI ของ font กับชื่อ PostScript ที่ดึงจากตัว font binary ตอนลงทะเบียนเป็นสตริงต่างกัน และต่างในแบบที่ normalize ไม่ได้: ตระกูลที่ชื่อ Arial Unicode MS แบกชื่อ PostScript ว่า ArialMT gate ใดที่เขียนว่า "font ที่เลือกอยู่ตอนนี้ใช่ตัวที่เราลงทะเบียนหรือเปล่า" โดยเทียบชื่อ คือ dead code ที่ไม่เคยยิง ให้ gate ที่สถานะ ไม่ใช่ที่ชื่อ font

ขั้นตอนการตรวจจับ glyph ที่ไม่ถูกแก้ของ HotPDF แสดง gate ที่สถานะ subset, การค้นด้วย GetUnicodeGlyphForCodepoint และการต่อสายเหตุการณ์ OnUnresolvedGlyph
ความครอบคลุมถูกตัดสินจาก character map ของ font Unicode ที่ลงทะเบียน ไม่ใช่ GDI และ code point ที่ไม่ซ้ำแต่ละตัวยิงเหตุการณ์หนึ่งครั้งพร้อมคำแนะนำ font

อย่าทดสอบตัวตรวจ glyph ด้วย emoji

กรณีทดสอบที่ชัดที่สุดคือหน้ายิ้ม และมันจะทำให้คุณเชื่อว่าตัวตรวจพัง code point emoji ที่พบบ่อยใน astral plane resolve ผ่านเส้นทางการสังเคราะห์แบบ private-use ที่ map เข้า glyph index โดยตรง มันจึงไม่เคยไปถึง branch ตรวจความครอบคลุมทั่วไป ตัวตรวจทำงานถูกต้อง และการทดสอบกำลังวัดเส้นทางที่ผิด

ให้ใช้ code point ที่ยังไม่ถูกจัดสรรแทน U+0378 ไม่ถูกจัดสรรอย่างถาวรใน Unicode ไม่มี font ใด map มันได้อย่างชอบธรรม และมันออกกำลังกับ branch ที่คุณต้องการพิสูจน์พอดี ความต่างระหว่าง "ฟีเจอร์พัง" กับ "การทดสอบหยิบอินพุตที่บายพาสฟีเจอร์" กินเวลางานจริงหลายชั่วโมง และ code point ที่ไม่ถูกจัดสรรคือวิธีหลีกเลี่ยงมันที่ถูกที่สุด

type
  TCoverageAudit = class
  private
    FFindings: TStringList;
  public
    procedure Handle(Sender: TObject;
      const Info: THPDFUnresolvedGlyphInfo);
    property Findings: TStringList read FFindings;
  end;

procedure TCoverageAudit.Handle(Sender: TObject;
  const Info: THPDFUnresolvedGlyphInfo);
begin
  // ยิงครั้งเดียวต่อ code point ที่ไม่ซ้ำ ไม่ใช่ครั้งเดียวต่อการเกิด
  FFindings.Add(Format('U+%.4X missing in %s (script %d), try: %s',
    [Info.CodePoint, String(Info.FontName), Ord(Info.Script),
     String(Info.SuggestedFonts)]));
end;

// ต่อเข้ากับงานที่กำลังผลิตเอกสาร
Pdf := THotPDF.Create(nil);
try
  Pdf.TrackUnresolvedGlyphs := True;
  Pdf.OnUnresolvedGlyph := Audit.Handle;
  Pdf.RegisterUnicodeTTF('C:\Windows\Fonts\arial.ttf');
  Pdf.BeginDoc;
  Pdf.CurrentPage.SetFont('Arial', [], 11);
  Pdf.CurrentPage.TextOut(50, 720, 0, CustomerName);
  Pdf.EndDoc;
  if Audit.Findings.Count > 0 then
    // ให้งานล้มเหลว แทนที่จะส่งหน้าที่มีกล่องว่างออกไป
    raise Exception.Create(Audit.Findings.Text);
finally
  Pdf.Free;
end;

ห่วงโซ่ fallback แยกตามระบบเขียน ไม่ใช่ตาม font

เหตุที่ fallback ถูกกำหนดขอบเขตตามระบบเขียน แทนที่จะตาม font ต้นทาง คือช่องว่างความครอบคลุมจับกลุ่มกันตามระบบการเขียน font ข้อความละตินขาด Devanagari, ไทย, ฮันและ emoji ไปพร้อมกันทั้งชุด และสิ่งทดแทนสำหรับแต่ละอย่างคือคนละ font การประกาศหนึ่งห่วงโซ่ต่อหนึ่งระบบเขียนจึงอธิบาย deployment ที่แท้จริงได้: หนึ่ง font ละตินสำหรับเนื้อความ หนึ่ง font CJK หนึ่ง font emoji และหนึ่ง catch-all

แผนภาพ font fallback แยกตามระบบเขียน จับคู่ hfsCJK, hfsArabic, hfsEmoji และ hfsOther เข้ากับห่วงโซ่ font ทดแทนที่เรียงลำดับไว้ใน HotPDF
ระบบเขียนแต่ละอันมีห่วงโซ่เรียงลำดับของตัวเอง font เนื้อความละตินที่ขาดฮัน อาหรับ หรือ emoji จึงไหลไปหา font ที่ครอบคลุมมัน
// THPDFFontScript ครอบคลุม hfsCommon, hfsLatin, hfsGreek, hfsCyrillic,
// hfsHebrew, hfsArabic, hfsIndic, hfsSoutheastAsian, hfsCJK, hfsKana,
// hfsHangul, hfsEmoji และ hfsOther
Pdf.SetFontFallbackChain(hfsCJK,
  ['Microsoft YaHei', 'SimSun', 'Yu Gothic']);
Pdf.SetFontFallbackChain(hfsArabic, ['Segoe UI', 'Arial']);
Pdf.SetFontFallbackChain(hfsEmoji, ['Segoe UI Emoji']);
Pdf.SetFontFallbackChain(hfsOther, ['Arial Unicode MS']);

fallback กับการตรวจจับเป็นสิ่งเสริมกัน ไม่ใช่ทางเลือกคู่กัน ห่วงโซ่จัดการความครอบคลุมที่คุณคาดไว้ ตัวตรวจรายงานความครอบคลุมที่คุณไม่ได้คาด ซึ่งบนระบบที่ประมวลผลข้อมูลลูกค้าแบบใดก็ได้คือครึ่งที่น่าสนใจ ให้สังเกตว่าการเปลี่ยน font ทดแทนเปลี่ยน metrics ย่อหน้าที่ต้อง fallback อาจจัดไหม้ใหม่ ถ้า layout สำคัญ พฤติกรรม closure และ subsetting ของ font ทดแทนสมควรไปอ่านเพิ่มในบทความ font subset closure และระบบเขียนที่ต้องจัดลำดับใหม่หรือเชื่อมตัวอักษรถูกจัดการโดยขั้นตอน shaping ตามที่อธิบายไว้ในการ shape ข้อความ complex script

วิธีเติมพฤติกรรมทีหลังโดยไม่เสี่ยงเส้นทางเดิม

release เดียวกันเพิ่ม fallback ตาราง kern รุ่นเก่าสำหรับระยะคู่ตัวอักษร และวิธีที่มันถูกกำหนดขอบเขตเป็นรูปแบบที่คุ้มค่าลอก แทนที่จะเพิ่มจุดตัดสินใจใหม่เข้าตรรกะ kerning fallback ห้อยอยู่กับ branch ออกก่อนเวลาที่มีอยู่แล้วสำหรับ font ที่ไม่มีตาราง GPOS font สมัยใหม่ที่มี GPOS ไม่เคยไปถึงมัน พฤติกรรมจึงไม่เปลี่ยนโดยโครงสร้าง ไม่ใช่โดยการทดสอบ เส้นทางที่ไม่ลงทะเบียน font Unicode ให้ offset ศูนย์สองตัว จึงไม่เปลี่ยนเช่นกัน

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

ทำมันเป็น gate ไม่ใช่ log

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

ใน production handler เดิมใช้เป็น telemetry ดีกว่า บันทึก code point และ font ยังให้บริการเอกสารต่อไป และให้ค่ารวมบอกคุณว่าควรเพิ่มระบบเขียนใดเข้าชุด font ของ deployment เป็นตัวถัดไป พฤติกรรมการ render สำหรับ font ที่ฝังและที่ถูกแทนที่อธิบายเพิ่มเติมในการ render glyph ของ font ที่ฝังมา และรายการ property ฉบับเต็มรวมถึง TrackUnresolvedGlyphs ถูกจัดทำเป็นเอกสารไว้บนหน้าผลิตภัณฑ์HotPDF Delphi PDF component