Glyph ที่ผ่านการ shape แล้วจะเรนเดอร์เป็นกล่อง .notdef เมื่อตัวทำ subset ของฟอนต์เก็บไว้แค่ glyph ที่เข้าถึงได้จาก code point ที่ปล่อยออกมาเท่านั้น HotPDF ซึ่งเป็น native VCL PDF component สำหรับ Delphi และ C++Builder มีข้อบกพร่องแบบนี้เป๊ะ ๆ จนถึงเวอร์ชัน 2.435.0 นั่นคือ OpenType GSUB output ถูกบันทึกไว้ใน usage bitmap ภายในที่ตัวทำ subset ประกาศว่าจะเคารพมัน แต่แล้วก็ไม่เคยอ่านมันจริง ๆ เลย
นี่เป็นความล้มเหลวคนละแบบกับที่อธิบายไว้ใน บั๊ก EndDoc ที่ปิดการทำ font subsetting ไปเงียบ ๆ บั๊กนั้นเกี่ยวกับเมื่อไรที่ subsetting ทำงานเทียบกับ serialization และมันปิดการทำ subsetting ไปทั้งหมด ส่วนบั๊กนี้เกี่ยวกับอะไรที่อยู่ใน subset เมื่อ subsetting ทำงานตรงตามกำหนดเวลาสมบูรณ์แบบ pipeline ทำงานถูกจังหวะ prefix หกตัวอักษรของ subset ปรากฏบน /BaseFont ตรงตามที่ ISO 32000-1 §9.6.4 กำหนดเป๊ะ ไฟล์เล็กลง หน้าภาษาละตินทุกหน้าผ่านการตรวจพิสูจน์อักษรสะอาด และหน้าภาษาอาหรับกลับออกมาเป็นแถวของสี่เหลี่ยมว่างเปล่า บั๊กเรื่องลำดับจะดังทันทีที่คุณมอง แต่บั๊กเรื่อง closure จะเงียบตลอดไป เพราะ subset นั้นถูกต้องในเชิงโครงสร้าง และผิดแค่เรื่องรายชื่อสมาชิกของตัวมันเองเท่านั้น
ทำไม glyph ที่ shape แล้วถึงเรนเดอร์เป็น .notdef
เพราะชุดของ code point ที่เอกสารปล่อยออกมาไม่ใช่ชุดของ glyph ที่เอกสารวาด และตัวทำ subset ที่รวมสองอย่างนี้เข้าด้วยกันจะทิ้ง glyph ทุกตัวที่เกิดจาก shaping ไป Text shaping เปลี่ยนลำดับตัวอักษรทาง logical ให้กลายเป็นลำดับ glyph ที่มีตำแหน่งกำกับ และจุดประสงค์ทั้งหมดของมันคือการสร้าง glyph ที่ไม่มีตัวอักษร input ตัวใดตัวหนึ่งแมปไปถึงได้เลย เช่น heh ตำแหน่งกลางคำในภาษาอาหรับ ลิเกเจอร์ fi conjunct ของเทวนาครี หรือ contextual alternate ที่ถูกเลือกโดยฟีเจอร์ rclt แต่ละอันเป็น glyph ID ที่ GSUB lookup สร้างขึ้นมา ไม่ใช่ตัวที่ตาราง cmap ส่งให้คุณสำหรับตัวอักษรใดในสตริงของคุณเลย ตัวทำ subset ที่ขับเคลื่อนด้วย cmap ล้วน ๆ จึงกำลังไล่ index ผิดตัว มันเก็บ glyph ทุกตัวที่ข้อความอาจใช้ก่อนการ shape ไว้อย่างซื่อสัตย์ แต่ทิ้ง glyph ตัวที่ข้อความใช้จริงหลัง shape ไปพอดี renderer จึงขอ GID 1847 จากฟอนต์ที่ฝังไว้ subset ได้ทำให้ entry นั้นเป็นศูนย์ใน loca ไปแล้ว และ glyph index 0 ก็ถูกส่งกลับมาแทน glyph index 0 คือ .notdef ตามนิยามของ OpenType นั่นคือเหตุผลที่ลายเซ็นความล้มเหลวเป็นกล่องว่างเปล่าแทนที่จะเป็นตัวอักษรผิดหรือ crash ไม่มีอะไรใน PDF ผิดรูปแบบเลย ฟอนต์แค่ไม่มี glyph ที่ content stream ร้องขอ
Code point ไม่ใช่ glyph: สามแหล่งที่มาของ subset
Subset closure ที่ถูกต้องต้องรวมสามแหล่งที่มาอิสระเข้าด้วยกัน แต่ละแหล่งมี accumulator ของตัวเอง แหล่งแรกคือชุดที่มาจาก code point HotPDF สะสม FUnicodeUsedCps ขณะที่ตัวอักษร BMP ถูกปล่อยออกมา และ FUnicodeSmpUsed สำหรับตัวอักษรใน supplementary plane ที่เข้าถึงผ่าน surrogate pair จากนั้นแมปแต่ละตัวผ่าน FUnicodeCpToGid ไปเป็น glyph ID แหล่งที่สองคือชุดที่มาจาก shaping คือ glyph ID ที่การแทนที่ GSUB สร้างขึ้นมา บันทึกผ่าน MarkUnicodeGlyphUsed และ EnableShapingFeatureForSubset เข้าไปใน FUnicodeExtraUsedGlyphs แหล่งที่สามคือ composite closure คือ glyph ที่ numberOfContours เป็น -1 ใน glyf ถูกประกอบขึ้นจาก glyph ID ของส่วนประกอบ และการเก็บ composite ไว้ในขณะที่ทิ้งส่วนประกอบของมันจะได้ outline ว่างเปล่าแทนที่จะเป็น .notdef ซึ่งอาจแย่กว่าด้วยซ้ำเพราะมันอ่านเหมือนเป็นบั๊กเรื่อง spacing
HotPDF จัดการแหล่งที่หนึ่งและสามมาโดยตลอด BuildAndApplyUnicodeFontSubset ซึ่งเป็นจุดเริ่มต้นของการทำ subsetting ที่ EndDoc เรียกก่อน serialization จะเริ่มต้น used-glyph array ด้วย GID 0 ไล่ผ่าน code point ของ BMP ไล่ผ่านรายการการใช้งาน SMP แล้วส่ง array นั้นให้ตัวสร้าง subset ที่ resolve ส่วนประกอบของ composite ภายในตัวเอง แหล่งที่สองถูกเขียนไว้แต่ไม่เคยถูกใช้เลย และเพราะทั้งสามแหล่งล้มเหลวบนเนื้อหาที่ต่างกัน ช่องโหว่นี้จึงซ่อนตัวได้นานหลายปีใน codebase ที่ corpus สำหรับ regression ส่วนใหญ่เป็นภาษาละติน
Array ที่ถูกเขียนแต่ไม่เคยถูกอ่านเลย
สัญญานี้ถูกบันทึกไว้ในสามที่แต่ไม่ถูกทำตามในที่ไหนเลยแม้แต่ที่เดียว การประกาศ FUnicodeExtraUsedGlyphs ระบุว่าตัวทำ subset ของ EndDoc จะรวมมันเข้ากับการใช้งานที่มาจาก code point comment header บน ApplyArabicGSUBRefinement สัญญาไว้ว่า substitute GID ทุกตัวที่ปล่อยออกมาจะถูกส่งผ่าน MarkUnicodeGlyphUsed เพื่อให้ตัวทำ subset ดึง glyph นั้นเข้าไปในฟอนต์ที่ฝังไว้ สัญญาเดียวกันนี้ปรากฏคำต่อคำบน ApplyArabicGSUBContextualRefinement สำหรับ path ของ rclt ผู้เรียกทั้งสองรายทำหน้าที่ของตัวเองครบถ้วน การ grep ผ่านทุก reference ของฟิลด์นี้ไขปริศนาอีกครึ่งหนึ่งได้ในเวลาประมาณเก้าสิบวินาที คือมีการประกาศหนึ่งครั้ง การจัดสรรด้วย SetLength หนึ่งครั้งภายใน RegisterUnicodeTTF และการเขียนในสอง routine ที่ทำเครื่องหมาย ไม่มีการอ่านแม้แต่ครั้งเดียว นั่นคือการวินิจฉัยที่ควรจดจำไว้ในใจ เพราะมันเป็นจริงกว้างกว่าแค่เรื่องฟอนต์ เมื่อฟิลด์หนึ่งถูกเขียนโดยหลาย call site แต่ไม่ถูกอ่านโดยที่ไหนเลย ฟีเจอร์ที่มันเป็นตัวแทนนั้นไม่มีอยู่จริง ไม่ว่าจะถูก comment ไว้ละเอียดแค่ไหนก็ตาม ขั้นตอนที่ 1 ของตัวทำ subset สั้นพอที่จะอ่านจบในหน้าจอเดียว และช่องโหว่นี้ก็ชัดเจนทันทีที่คุณรู้ว่าต้องมองหามัน
// Step 1: derive the used-glyph set (as it stood before 2.435.0)
SetLength(UsedGlyphs, FUnicodeNumGlyphs);
for I := 0 to FUnicodeNumGlyphs - 1 do
UsedGlyphs[I] := False;
UsedGlyphs[0] := True; // .notdef is always present
for Cp := 0 to $FFFF do // source 1a: BMP code points
if (Cp < Length(FUnicodeUsedCps)) and FUnicodeUsedCps[Cp]
and (Cp < Length(FUnicodeCpToGid)) then
begin
GID := FUnicodeCpToGid[Cp];
if (GID > 0) and (GID < FUnicodeNumGlyphs) then
UsedGlyphs[GID] := True;
end;
for I := 0 to High(FUnicodeSmpUsed) do // source 1b: SMP code points
begin
GID := FUnicodeSmpUsed[I].GID;
if (GID > 0) and (GID < FUnicodeNumGlyphs) then
UsedGlyphs[GID] := True;
end;
// source 2 was missing here: nothing ever consulted FUnicodeExtraUsedGlyphs
การแก้ไขด้วยลูปเดียว และการทำเครื่องหมาย glyph ด้วยตัวเอง
การซ่อมแซมคือการรวมชุด และข้อโต้แย้งเรื่องความปลอดภัยของมันมาจากทิศทางของการดำเนินการ มันแค่ตั้งค่า bit เท่านั้น ไม่เคยล้างมันเลย ดังนั้นจึงไม่มี glyph ตัวใดที่เคยรอดใน subset จะเริ่มถูกทิ้งไป
// v2.435.0: pull GSUB-derived extra glyphs into the subset.
// MarkUnicodeGlyphUsed / EnableShapingFeatureForSubset record GIDs that
// shaping produced but that no emitted code point maps to directly.
for I := 0 to FUnicodeNumGlyphs - 1 do
if (I < Length(FUnicodeExtraUsedGlyphs)) and FUnicodeExtraUsedGlyphs[I] then
UsedGlyphs[I] := True;
มีสามคุณสมบัติที่ทำให้นี่เป็นการเปลี่ยนแปลงความเสี่ยงต่ำ แทนที่จะเป็นการเขียนเอนจินฟอนต์ใหม่ทั้งหมด มันเป็นแบบ monotone ตามที่กล่าวไว้ข้างต้น มันเป็น no-op บนฟอนต์ที่ไม่เคย shape อะไรเลย เพราะ FUnicodeExtraUsedGlyphs ยังคงเป็น False ทั้งหมด และ byte output สำหรับเอกสารภาษาละตินล้วนไม่เปลี่ยนแปลง และมันเกิดขึ้นก่อนขั้นตอนที่ 2 ดังนั้นตัวสร้าง subset ทั้งสองแบบจึงได้รับผลนี้ไปด้วย คือตัวสร้างแบบ sparse ที่คงหมายเลข GID เดิมไว้ และตัวสร้างแบบ compact _BuildCompactSubsetTTF ที่ HotPDF เลือกใช้ภายใต้ PDF/A เพื่อเปลี่ยนหมายเลข glyph ที่เก็บไว้ให้อยู่ในช่วงที่หนาแน่น ย่อ maxp.numGlyphs และปล่อย mapping ระหว่างเก่ากับใหม่ออกมาเป็น stream /CIDToGIDMap ตามที่ ISO 32000-1 §9.7.4.2 กำหนด ทั้งสองเรียก _TTFWalkCompositeClosure ภายในตัวเอง ดังนั้น glyph ที่ผ่าน shape แล้วบังเอิญเป็น composite ก็จะลากส่วนประกอบของมันเข้ามาด้วยตอนนี้ Composite closure ไม่เคยพังเลย มันแค่ไม่เคยถูกเข้าถึงสำหรับ glyph ID เหล่านี้ เพราะ glyph ID เหล่านี้ไม่ได้อยู่ในชุดที่มันไล่ผ่าน ถ้าคุณขับเคลื่อน GSUB engine โดยตรงแทนที่จะพึ่งพา refinement pass ในตัว closure ก็จะกลายเป็นความรับผิดชอบของคุณ และ substitute glyph ID ทุกตัวที่คุณปล่อยออกมาต้องถูกทำเครื่องหมายก่อนที่ EndDoc จะแช่แข็งชุด used-glyph ไว้
var
Pdf: THotPDF;
GIDs: array[0..1] of Word;
LigGID: Word;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.FileName := 'shaped.pdf';
Pdf.BeginDoc;
Pdf.RegisterUnicodeTTF('C:\Fonts\NotoNaskhArabic-Regular.ttf');
Pdf.ShapingFeatures := [sfArabicGSUB, sfStandardLigatures,
sfContextualAlternates];
GIDs[0] := Pdf.GetUnicodeGlyphForCodepoint($0644); // lam
GIDs[1] := Pdf.GetUnicodeGlyphForCodepoint($0627); // alef
if Pdf.ApplyLigatureSubstitution(GIDs, 0, 'liga', LigGID) then
Pdf.MarkUnicodeGlyphUsed(LigGID); // omit this and you get .notdef
Pdf.EnableShapingFeatureForSubset('rclt');
Pdf.CurrentPage.RtLTextOut(50, 700, 0, WideString(ArabicText));
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
EnableShapingFeatureForSubset เป็นคู่หูแบบ batch ของการเรียกทีละ GID และมันจงใจออกแบบให้ระมัดระวังไว้ก่อน มันไล่ผ่านรายการ GSUB lookup เพื่อหา lookup ที่ผูกกับ feature tag สี่ byte ตัวหนึ่งภายใต้ path ของ script และ language ที่เลือกอยู่ในขณะนั้น แล้วทำเครื่องหมาย glyph ID ที่เป็นตัวแทนที่ lookup เหล่านั้นสามารถสร้างได้ มันเป็น no-op เชิงป้องกันเมื่อฟอนต์ไม่มีตาราง GSUB หรือเมื่อฟีเจอร์นั้นไม่มีอยู่ใน path นั้น ดังนั้นการเรียกมันโดยไม่มีเงื่อนไขจึงปลอดภัย มันยังเป็นการประมาณค่าเกินโดยการออกแบบด้วย คืออาจเก็บ glyph ที่เอกสารหนึ่ง ๆ ไม่เคยวาดเลยไว้ สำหรับการทำ subsetting การรวมเกินไปเสียแค่ byte แต่การรวมน้อยเกินไปเสียความถูกต้อง ทำให้การแลกนี้เป็นทางเลือกที่ง่าย โครงสร้างของ lookup เหล่านี้ และตาราง coverage ที่ตัดสินว่า glyph ตัวไหนมีส่วนร่วม ถูกครอบคลุมไว้ใน คำอธิบายเรื่อง GSUB stylistic alternates แบบ Delphi ล้วน
คุณพิสูจน์ได้อย่างไรว่า glyph นั้นอยู่ใน subset จริง
ด้วยการอ่านฟอนต์ที่ปล่อยออกมา ไม่ใช่ด้วยการมองด้วยตาบนหน้าใน viewer ที่อาจแอบแทนที่ด้วยฟอนต์ของระบบลับหลังคุณ การตรวจสอบที่จับบั๊กประเภทนี้ทั้งหมดได้คือแบบเชิงกล คือแตกไฟล์ PDF ผลลัพธ์เพื่อดึง stream /FontFile2 ออกมา parse loca แล้วยืนยันว่า glyph ID ที่คุณคาดหวังมี entry ที่ไม่ว่างเปล่า หมายถึง offset เริ่มต้นและสิ้นสุดของมันต่างกัน entry ที่ว่างเปล่าคือตัวทำ subset ตัดสินใจแล้วว่า glyph นั้นไม่ได้ใช้งาน จากนั้นมีอีกสองนิสัยที่ทำให้ยากขึ้นมากที่จะปล่อยความล้มเหลวนี้ออกไปอีก เก็บหน้าภาษาที่ต้อง shape ไว้ใน smoke corpus แบบอัตโนมัติแทนที่จะอยู่แค่ใน manual proofing set เพราะภาษาอาหรับ เทวนาครี และเขมรใช้ path ของ closure ที่ Latin coverage มากแค่ไหนก็ไปไม่ถึง และเมื่อใดก็ตามที่มี accumulator อยู่ ให้ assert ว่ามีบางอย่างใช้มันจริง เพราะฟิลด์ที่เขียนได้อย่างเดียวคือฟีเจอร์ที่ compile ผ่าน ทดสอบผ่านสีเขียวบน corpus ที่ผิด และไม่ทำอะไรเลย
การแก้ไขนี้หยุดอยู่ตรงไหน
Subset closure จำเป็นสำหรับให้ glyph ที่ shape แล้วเรนเดอร์ได้ แต่มันไม่เพียงพอ glyph ยังต้องระบุตำแหน่งได้จาก content stream ด้วย ซึ่งเป็นปัญหาแยกต่างหากที่มีขอบเขตของตัวเอง refinement pass สำหรับภาษาอาหรับที่มีอยู่ในตัว HotPDF จะยืนยันการแทนที่ก็ต่อเมื่อ substitute glyph ID ทุกตัวสามารถเข้าถึงได้ผ่าน Unicode presentation-form code point ผ่านการสแกน cmap ย้อนกลับ ครอบคลุมประมาณ 690 code point ตั้งแต่ U+FB50 ถึง U+FDFF และ U+FE70 ถึง U+FEFF เมื่อ substitute ลงเอยที่ glyph ID นอกช่วงนั้น หน้าต่าง input จะผ่านไปโดยไม่เปลี่ยนแปลง แทนที่จะปล่อยสิ่งที่ reader ระบุตำแหน่งไม่ได้ alternate เฉพาะฟอนต์ที่อยู่ใน glyph ID ใด ๆ ก็ตามต้องการ code point private-use สังเคราะห์ที่จัดสรรไว้ใน U+E000 ถึง U+F8FF เพื่อนำมันผ่าน emit path ดังนั้นสรุปอย่างซื่อตรงคือการแก้ไขใน 2.435.0 ลบตัวขวางที่แข็งแกร่งตัวหนึ่งออกไป มากกว่าจะทำให้เรื่องราวสมบูรณ์ ก่อนหน้านั้น glyph ตัวหนึ่งอาจถูก shape ถูกต้อง ปล่อยออกมาถูกต้อง แล้วก็ยังหายไปตอนทำ subset ซึ่งหมายความว่า shaping engine ไม่สามารถเชื่อถือได้ตลอดทั้งกระบวนการไม่ว่า lookup ของมันจะดีแค่ไหนก็ตาม สิ่งที่เหลืออยู่คือการระบุตำแหน่งได้ และข้อจำกัดนั้นอย่างน้อยก็ล้มเหลวอย่างเห็นได้ชัดตรงจุดที่ปล่อยออกมา แทนที่จะเงียบอยู่ในขั้นตอน build ที่รันหลังจากทุกอย่างที่คุณกำลังจับตาดูอยู่ สำหรับฝั่งการปล่อยออกมาของ pipeline เดียวกันนี้ ดูที่ คู่มือเรื่องการ shape ข้อความภาษาอาหรับและ RTL ใน Delphi PDF
การทำ font subsetting, GSUB engine และการ shape สคริปต์ที่ซับซ้อนที่อธิบายไว้ที่นี่มาพร้อมกับ HotPDF Component มาตรฐานสำหรับ Delphi และ C++Builder หน้าผลิตภัณฑ์มีเอกสารอ้างอิง API แบบเต็มสำหรับการเรียกใช้เรื่องฟอนต์ Unicode และการ shape ที่กล่าวถึงข้างต้น