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

ฟอนต์และข้อความ PDF: ทำไมกลีฟจึงกลายเป็นกล่องสี่เหลี่ยม

PDF ที่ดูสมบูรณ์แบบบนเครื่องของคุณ แต่กลับเรนเดอร์ออกมาเป็นแถวของกล่องสี่เหลี่ยมว่างเปล่าบนเครื่องของคนอื่น คือข้อบกพร่องเรื่องฟอนต์ที่พบบ่อยที่สุดในซอฟต์แวร์เอกสาร และมันแทบไม่เคยหมายความว่าข้อความนั้นผิด ตัวอักขระยังคงสมบูรณ์, การเข้ารหัสก็ถูกต้อง, แค่กลีฟ (glyphs) ไม่มีอยู่ตรงนั้นเท่านั้น สิ่งที่เปลี่ยนไประหว่างเครื่องสองเครื่องคือฟอนต์ที่ระบบปฏิบัติการติดตั้งไว้ และช่องว่างระหว่างไฟล์ที่พกพาได้ (portable) กับไฟล์ที่เปราะบาง (fragile) คือการตัดสินใจเพียงครั้งเดียวตอนที่หน้านั้นถูกเขียนขึ้น: ฟอนต์ได้เดินทางไปพร้อมกับ PDF หรือถูกทึกทักเอาว่ามีอยู่แล้วที่ปลายทาง

การเข้าใจว่าทำไมสิ่งนี้จึงเกิดขึ้น และทำไมข้อบกพร่องอีกแบบหนึ่งถึงสร้างข้อความที่ดูเหมือนจะค้นหาได้ แต่คัดลอกออกมาแล้วกลายเป็นข้อมูลขยะ หมายถึงการมองดูวิธีที่ PDF จัดเก็บข้อความ มันไม่ได้จัดเก็บประโยค แต่มันจัดเก็บรหัสกลีฟ (glyph codes) พร้อมด้วยโปรแกรมฟอนต์ (font program) และตารางที่แมปสองสิ่งนี้เข้าด้วยกัน และข้อบกพร่องในการเรนเดอร์หรือการสกัดข้อความทุกอย่าง ล้วนแฝงตัวอยู่ในช่องว่างระหว่างสามสิ่งนี้ ต่อไปนี้คือการทัวร์ชมกลไกดังกล่าว โดยอิงตาม ISO 32000 พร้อมกับการเรียกใช้ Delphi ที่ควบคุมกลไกเหล่านี้ในส่วนที่สำคัญ

ตัวอักขระ รหัส และกลีฟ คือสามสิ่งที่แตกต่างกัน

คำศัพท์มักทำให้คนสับสนเพราะภาษาพูดทั่วไปได้ยุบรวมแนวคิดที่แตกต่างกันสามอย่างนี้ไว้ในคำว่า "ตัวอักษร" (letter) ตัวอักขระ (character) คือหน่วยนามธรรมของการเขียน อย่างเช่นแนวคิดของตัว A ตัวพิมพ์ใหญ่ ซึ่งระบุใน Unicode ว่า U+0041 กลีฟ (glyph) คือรูปทรงที่วาดขึ้น คือเส้นโครงร่างส่วนโค้งและก้านที่ฟอนต์เฉพาะตัวหนึ่งใช้เพื่อแสดงตัวอักขระนั้น และระหว่างสองสิ่งนี้คือ รหัส (code): ไบต์หรือกลุ่มไบต์ในสตรีมเนื้อหาที่บอกโปรแกรมดูว่าให้วาดกลีฟตัวไหนในฟอนต์ปัจจุบัน

PDF ทำงานด้วยรหัส เมื่อสตรีมเนื้อหาแสดงสตริง ไบต์เหล่านั้นคืออินเด็กซ์ (indices) ที่ชี้ไปยังฟอนต์ที่กำลังทำงานอยู่ ไม่ใช่ Unicode การเข้ารหัส (encoding) ของฟอนต์เป็นตัวตัดสินใจว่า รหัส 65 หมายถึง "วาดกลีฟที่เก็บไว้ภายใต้หมายเลข 65" และไม่มีอะไรในการทำงานนั้นที่รู้ว่าผลลัพธ์ที่ได้ดูเหมือนตัว A สำหรับมนุษย์ นั่นคือเหตุผลที่ทำให้ PDF สามารถเรนเดอร์ออกมาเหมือนกันทุกประการในทุกที่ที่มันค้นหากลีฟเจอ และยังเป็นเหตุผลว่าทำไมการสกัดข้อความจึงเป็นปัญหาที่แยกต่างหากจากการแสดงผล: การวาดภาพต้องการเพียงการแมปรหัสไปยังกลีฟ ส่วนการอ่านต้องการการแมปรหัสไปยัง Unicode และพวกมันคือตารางสองตารางที่แตกต่างกัน ซึ่งอาจขัดแย้งกันหรือสูญหายไปแยกจากกันได้อย่างอิสระ

ประเภทของฟอนต์ที่คุณจะได้พบเจอจริงๆ

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

Type 1 คือรูปแบบเส้นโครงร่าง PostScript ดั้งเดิมของ Adobe ซึ่งสร้างจากเส้นโค้ง cubic Bezier ฟอนต์มาตรฐานสิบสี่ตัวที่โปรแกรมอ่านที่ได้มาตรฐานทุกตัวต้องมีให้ คือตระกูล Helvetica, Times, Courier, Symbol, และ ZapfDingbats เป็น Type 1 และดิกชันนารีฟอนต์ที่อ้างอิงชื่อหนึ่งในฟอนต์เหล่านี้สามารถละเว้นโปรแกรมฟอนต์ได้อย่างถูกต้องตามกฎหมาย นั่นคือกรณีเดียวที่การปล่อยฟอนต์ไว้โดยไม่ฝังนั้นปลอดภัยตามข้อกำหนด (specification) ไม่ใช่เพราะโชคช่วย สำหรับฟอนต์ Type 1 อื่นๆ โปรแกรมจะต้องถูกฝังมาด้วย ไม่เช่นนั้นโปรแกรมดูจะหาสิ่งอื่นมาแทนที่ ซึ่งมักจะเป็นฟอนต์ที่มีเมตริก (metric) ใกล้เคียงกันแต่หน้าตาแตกต่างออกไปอย่างเห็นได้ชัด

TrueType ใช้เส้นโค้ง quadratic และมาจากโลกของ Apple และ Microsoft มันคือฟอนต์ระบบส่วนใหญ่ที่เป็นอยู่ และเป็นสิ่งที่คุณจะฝังบ่อยที่สุด ฟอนต์ TrueType แบบง่ายๆ ใน PDF ถูกจำกัดด้วยรหัสแบบไบต์เดียว ดังนั้นฟอนต์หนึ่งตัวจึงอ้างอิงกลีฟได้มากที่สุดเพียง 256 ตัวในแต่ละครั้ง ข้อจำกัดนี้คือเหตุผลเชิงโครงสร้างที่ทำให้สคริปต์ CJK และสคริปต์ขนาดใหญ่อื่นๆ ไม่สามารถเกาะไปกับฟอนต์แบบง่ายๆ ได้

Type 0 ฟอนต์แบบคอมโพสิตหรือ CID-keyed คือคำตอบสำหรับข้อจำกัดนั้น มันใช้รหัสแบบหลายไบต์และ CMap เพื่อกำหนดเส้นทางผ่าน CIDFont ลูกหลาน (descendant CIDFont) ซึ่งเส้นโครงร่างของมันอาจเป็น TrueType หรือ CFF/Type 1 ก็ได้ นี่คือประเภทฟอนต์เดียวที่สามารถบรรจุกลีฟได้หลายพันตัว ดังนั้น PDF ใดๆ ที่มีภาษาจีน ญี่ปุ่น เกาหลี หรือการผสมผสานหลายภาษาที่หลากหลาย ล้วนใช้ Type 0 ไม่ว่าผู้เขียนจะรู้ตัวหรือไม่ก็ตาม สิ่งที่ต้องแลกมาคือความซับซ้อน: มีชิ้นส่วนที่เคลื่อนไหวได้มากขึ้น และส่วนเหล่านั้นก็ต้องถูกต้องมากขึ้นทั้งสำหรับการเรนเดอร์และการสกัดข้อความ

ฟอนต์ TrueType หนึ่งตัวถูกเรนเดอร์ที่ขนาด 12, 18, 24, และ 36 พอยต์ใน PDF แสดงให้เห็นว่าเส้นโครงร่างที่ฝังไว้เพียงเส้นเดียวสามารถย่อขยายเป็นขนาดใดก็ได้

รายละเอียดหนึ่งที่อยู่เบื้องหลังภาพนั้นคือตัวขับเคลื่อนขนาดไฟล์ ฟอนต์คือไลบรารีของเส้นโครงร่าง ไม่ใช่บิตแมปขนาดคงที่ ดังนั้นโปรแกรมที่ฝังตัวเดียวกันจึงให้บริการกับทุกๆ ขนาดพอยต์บนหน้ากระดาษ การปรับขนาด (Scaling) คือการแปลงพิกัด (transform) ที่ประยุกต์ใช้ในเวลาที่วาด ซึ่งนั่นคือเหตุผลว่าทำไมหัวเรื่องและข้อความเนื้อหาจึงใช้แบบอักษรที่ฝังร่วมกันเพียงตัวเดียว และทำไมต้นทุนของการฝังจึงเป็นแบบต่อฟอนต์ ไม่ใช่ต่อขนาด

การฝังคือความแตกต่างระหว่างความพกพาง่ายกับความเปราะบาง

การฝัง (Embedding) หมายความว่าโปรแกรมฟอนต์ ซึ่งก็คือข้อมูลเส้นโครงร่างที่แท้จริง จะถูกเขียนลงไปใน PDF เป็นสตรีม โปรแกรมอ่านบนเครื่องที่ไม่เคยรู้จักฟอนต์ของคุณมาก่อน จะอ่านเส้นโครงร่างเหล่านั้นโดยตรงจากไฟล์และวาดกลีฟได้อย่างแม่นยำ หากข้ามการฝังไป คุณกำลังเดิมพันว่าปลายทางมีฟอนต์ชื่อเดียวกันอยู่; เมื่อไม่มี โปรแกรมดูจะกลับไปใช้ตัวแทน สำหรับฟอนต์มาตรฐานสิบสี่ตัว การแทนที่นั้นมีการกำหนดไว้และไม่เป็นอันตราย สำหรับฟอนต์อื่นๆ การแทนที่อาจมีตั้งแต่คล้ายคลึงกันในฟอนต์ที่แตกต่าง ไปจนถึงผลลัพธ์ที่เป็นกล่องสี่เหลี่ยมว่างเปล่าเมื่อไม่มีตัวแทนใดครอบคลุมสคริปต์นั้นเลย

ด้วย HotPDF ตัวควบคุมคือ property เดียว ซึ่งตั้งค่าก่อนที่เอกสารจะเปิดขึ้น FontEmbedding บอกให้ไลบรารีแพ็คแบบอักษรที่ใช้วาดลงในไฟล์:

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'report.pdf';
    Pdf.Compression := cmFlateDecode;
    Pdf.FontEmbedding := True;          // outlines travel inside the file
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Calibri', [], 11);
    Pdf.CurrentPage.TextOut(72, 760, 0, 'This renders the same on a machine without Calibri.');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

ลำดับการสั่งไม่ได้เป็นเพียงแค่เรื่องความสวยงาม BeginDoc คือจุดที่ HotPDF ยืนยันโครงสร้างเอกสาร ดังนั้น FontEmbedding จึงต้องเป็นจริง (True) ก่อนการเรียกใช้นั้น หากกำหนดค่าให้มันทีหลังจะไม่มีข้อผิดพลาด, ไม่มีคำเตือน, มีเพียงไฟล์ที่ออกไปอย่างเงียบๆ โดยไม่มีฟอนต์ นั่นคือข้อบกพร่องประเภทที่แย่ที่สุด: มันผ่านการทดสอบทุกอย่างบนเครื่องของนักพัฒนา ซึ่งบังเอิญมีการติดตั้งฟอนต์ไว้ และไปโผล่ขึ้นมาบนเครื่องของลูกค้า ซึ่งไม่มีฟอนต์นั้น

การฝังยังเป็นจุดที่เรื่องลิขสิทธิ์มาพบกับวิศวกรรม โปรแกรมฟอนต์จะมีแฟล็กอธิบายว่าฟอนต์นั้นสามารถฝังได้อย่างอิสระ, สำหรับการพรีวิวเท่านั้น, หรือไม่สามารถฝังได้เลย การให้เกียรติแฟล็กเหล่านั้นคือความรับผิดชอบของคุณ ไม่ใช่ของเครื่องมือเรนเดอร์ และ "มันใช้งานได้" ไม่ได้หมายความเหมือนกับ "มันได้รับอนุญาต"

การสร้างซับเซต: ฝังเฉพาะกลีฟที่คุณใช้

การฝังแบบเต็ม (Full embedding) คือการเขียนโปรแกรมฟอนต์ทั้งหมดลงในไฟล์ ฟอนต์ CJK TrueType ขนาดใหญ่ใบหน้าหนึ่งอาจมีขนาดถึงหลายเมกะไบต์ และการฝังมันทั้งหมดเพื่อแสดงตัวอักขระเพียงสิบกว่าตัวนั้นสิ้นเปลืองในลักษณะที่ทบต้นไปทั่วทั้งเอกสารหลายหน้า การสร้างซับเซต (Subsetting) แก้ปัญหานี้โดยการเขียนเฉพาะกลีฟที่เอกสารอ้างอิงเท่านั้น จากนั้นจึงเปลี่ยนชื่อฟอนต์ด้วยแท็กอักษรหกตัวและเครื่องหมายบวก ในรูปแบบ ABCDEF+Calibri ซึ่งพบในรายชื่อฟอนต์ของ PDF ใดๆ ที่ถูกซับเซต เพื่อให้โปรแกรมอ่านไม่สับสนระหว่างแบบอักษรบางส่วนกับระบบฟอนต์แบบเต็มที่มีชื่อเดียวกัน

สำหรับเอกสารส่วนใหญ่ที่สร้างขึ้น การสร้างซับเซตคือค่าเริ่มต้นที่ถูกต้อง มันทำให้ขนาดไฟล์แปรผันตามเนื้อหาแทนที่จะเป็นฟอนต์ต้นฉบับ ซึ่งสำคัญมากสำหรับฟอนต์หลายภาษาขนาดใหญ่ที่มิฉะนั้นจะครอบงำขนาดไฟล์ ข้อควรระวังเพียงอย่างเดียวคือ ซับเซตจะประกอบด้วยสิ่งที่ถูกใช้ในเวลาที่สร้างเท่านั้น หากกระบวนการปลายน้ำพยายามเพิ่มข้อความลงในฟอนต์ซับเซตในภายหลัง กลีฟที่จำเป็นอาจไม่อยู่ในไฟล์ ซึ่งเป็นข้อจำกัดอย่างแท้จริงต่อการแก้ไข PDF ของคนอื่นแบบ incremental (ค่อยเป็นค่อยไป)

ฟอนต์ Unicode และปัญหา CJK ที่เป็นกล่องสี่เหลี่ยม

เมื่อข้อความไม่ใช่ภาษาละตินแบบธรรมดา เส้นทางของฟอนต์แบบง่ายๆ ก็จะหมดลง และวิธีแก้คือการลงทะเบียนฟอนต์ที่รองรับ Unicode อย่างชัดเจน และให้ HotPDF สร้างฟอนต์ Type 0 จากฟอนต์นั้น RegisterUnicodeTTF จะโหลดไฟล์ TrueType ตาม path; หลังจากนั้นชื่อที่ลงทะเบียนก็สามารถใช้ใน SetFont ได้เหมือนฟอนต์อื่นๆ:

Pdf.FontEmbedding := True;
Pdf.RegisterUnicodeTTF('C:\Fonts\NotoSansCJKsc-Regular.ttf');
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('NotoSansCJKsc-Regular', [], 14);
Pdf.CurrentPage.TextOut(72, 720, 0, '你好,世界 こんにちは 안녕하세요');
Pdf.EndDoc;

สองสิ่งที่จะทำให้เรื่องนี้สำเร็จหรือล้มเหลว ฟอนต์จะต้องครอบคลุมสคริปต์ที่อยู่ในสตริง: TrueType ที่มีเฉพาะภาษาละตินจะไม่งอกกลีฟภาษาจีนขึ้นมาเพียงเพราะคุณร้องขอ และผลลัพธ์ก็จะออกมาเป็นกล่องสี่เหลี่ยมว่างเปล่าอีกครั้ง ครั้งนี้เป็นเพราะกลีฟนั้นไม่มีอยู่จริงในแบบอักษรนั้น และการฝังจะต้องเปิดใช้งานอยู่เสมอ เพราะฟอนต์ Type 0 ที่ประกอบขึ้นจาก TTF ที่ลงทะเบียนไว้จะไร้ความหมายสำหรับโปรแกรมอ่านที่ไม่สามารถค้นหาเส้นโครงร่างเจอ สำหรับเนื้อหาแบบผสมผสาน ทางเลือกที่ทนทานคือแบบอักษรที่ครอบคลุมกว้างขวาง ตระกูล Noto และ Arial Unicode MS คือคำตอบที่มักจะใช้กัน โดยฝังและสร้างซับเซต

การเขียนจากขวาไปซ้าย (Right-to-left) และสคริปต์ที่ซับซ้อน จะเพิ่มเลเยอร์การจัดรูปทรง (shaping) ไว้ด้านบนของการครอบคลุม (coverage) HotPDF เตรียม RtLTextOut ไว้สำหรับภาษาอาหรับและฮีบรู ซึ่งจัดการการจัดลำดับทิศทางใหม่ ดังนั้นคุณจึงส่งผ่านลำดับลอจิคัลและให้ไลบรารีจัดการเลย์เอาต์ การจัดการภาษาอาหรับให้ถูกต้องคือความครอบคลุม (coverage) บวกกับการจัดรูปทรง (shaping) บวกกับทิศทาง (direction) ซึ่งเป็นสามสิ่งที่แยกจากกัน และถ้าผลลัพธ์เป็นกล่องสี่เหลี่ยมในส่วนนั้น อาจหมายถึงอย่างใดอย่างหนึ่งในสามสิ่งนี้เกิดล้มเหลว

ตาราง ToUnicode: ที่พำนักของการคัดลอก-วาง

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

เมื่อมันมีอยู่และถูกต้อง ข้อความที่คัดลอกออกมาจะเป็นตัวอักขระที่ถูกต้อง เมื่อมันหายไปหรือผิดพลาด หรือฟอนต์ถูกซับเซตด้วยรหัสกลีฟที่กำหนดเองและไม่มีการเขียน ToUnicode ไว้ หน้าเอกสารจะดูสมบูรณ์แบบแต่คลิปบอร์ดจะเต็มไปด้วยข้อมูลขยะ: รหัสกลีฟจะถูกอ่านราวกับว่ามันเป็น Unicode ซึ่งสำหรับซับเซตที่เข้ารหัสแบบกำหนดเองนั้นไม่ใช่เลย นี่คือเหตุผลที่เอกสารที่ถูกสแกนพร้อมชั้นข้อความ OCR อาจสามารถค้นหาได้ ในขณะที่ PDF ที่เกิดมาเป็นดิจิทัลแท้ๆ (born-digital) จากเครื่องกำเนิดไฟล์ที่ไม่ระมัดระวังกลับค้นหาไม่ได้ การเรนเดอร์และการสกัดข้อความดึงข้อมูลจากตารางที่แตกต่างกัน ดังนั้นไฟล์หนึ่งจึงสามารถตอบสนองเงื่อนไขหนึ่งได้ แต่ล้มเหลวในอีกเงื่อนไขหนึ่ง หากการสกัดข้อความมีความสำคัญต่อผลลัพธ์ของคุณ จงปฏิบัติต่อแมป ToUnicode ที่ถูกต้องเสมือนข้อกำหนด (requirement) และตรวจสอบมันด้วยการคัดลอกข้อความออกจากตัวอย่างทดสอบ แทนที่จะเชื่อใจว่ามันมีอยู่ตรงนั้น

วิธีวินิจฉัยข้อบกพร่องเรื่องฟอนต์อย่างรวดเร็ว

โหมดความล้มเหลวจะบอกคุณว่าควรดูที่ไหน กล่องสี่เหลี่ยมว่างเปล่าบนเครื่องอื่นเกือบจะหมายความว่าฟอนต์ไม่ได้ถูกฝัง ดังนั้นให้ตรวจสอบการฝังก่อน และตรวจสอบความครอบคลุมของกลีฟเป็นอันดับสอง กล่องสี่เหลี่ยมที่ปรากฏแม้กระทั่งบนเครื่องของคุณเอง จะชี้ไปที่ความครอบคลุม: ฟอนต์ไม่มีสคริปต์นั้น ไม่ว่าจะฝังหรือไม่ก็ตาม ข้อความที่เรนเดอร์ได้ถูกต้องแต่คัดลอกออกมาเป็นเรื่องไร้สาระคือปัญหา ToUnicode ไม่ใช่ปัญหาการเรนเดอร์ และการปรับแต่งฟอนต์หรือการฝังก็จะไม่สามารถแก้ไขได้ เนื่องจากการวาดภาพไม่เคยเสียตั้งแต่แรก ในการอ่านไฟล์ที่เสร็จแล้ว ให้เปิดมันใน Acrobat และไปที่ Document Properties, Fonts: รายการที่สมบูรณ์จะแสดงประเภท, ระบุว่า Embedded หรือ Embedded Subset, และบอกชื่อของการเข้ารหัส ฟอนต์ที่ควรจะฝังแต่ไม่ได้ฝัง จะประกาศตัวเองที่นั่นก่อนที่ลูกค้าจะทำ

จะไม่มีอะไรแปลกประหลาดเลยเมื่อความแตกต่างระหว่างตัวอักขระ รหัส และกลีฟ ชัดเจนขึ้น ฝังฟอนต์ที่คุณใช้วาด, สร้างซับเซตสำหรับฟอนต์ขนาดใหญ่, หันไปใช้แบบอักษร Unicode และ RegisterUnicodeTTF ทันทีที่ข้อความออกจากขอบเขตภาษาละติน, และรักษาแมป ToUnicode ที่ถูกต้องหากจะมีใครมาสกัดข้อความนั้น ทำให้สิ่งเหล่านี้ถูกต้องแล้วกล่องสี่เหลี่ยมจะหยุดปรากฏ สำหรับกลไกที่อยู่รอบๆ กายวิภาคของเอกสาร PDF ธรรมดา จะแสดงให้เห็นว่าดิกชันนารีฟอนต์ตั้งอยู่ตรงไหนในโครงสร้างต้นไม้ของออบเจ็กต์ และการอธิบายโครงสร้างเอกสารอย่างเจาะลึก จะครอบคลุมถึงวิธีการแบ่งปันทรัพยากรข้ามหน้า

การเรียกใช้ SetFont, FontEmbedding, และ RegisterUnicodeTTF ที่แสดงที่นี่เป็นส่วนหนึ่งของ HotPDF Component สำหรับ Delphi และ C++Builder