גליפים מעוצבים מוצגים כתיבות .notdef כאשר subsetter של גופן שומר רק את הגליפים הנגישים מנקודות הקוד שנפלטו. HotPDF, רכיב ה-VCL הילידי ל-PDF עבור Delphi ו-C++Builder, נשא בדיוק את הפגם הזה עד גרסה 2.435.0: פלט GSUB של OpenType נרשם ב-bitmap שימוש פנימי שה-subsetter הכריז שיכבד ואז מעולם לא באמת קרא
זהו כשל שונה מזה המתואר בבאג ה-EndDoc ששיתק בשקט את subsetting הגופנים. הבאג ההוא היה על מתי ה-subsetting רץ ביחס ל-serialization, והוא ביטל subsetting לגמרי. זה כאן על מה ה-subset מכיל כשה-subsetting רץ בול בזמן. הצינור יורה ברגע הנכון, קידומת ה-subset בת שש האותיות מופיעה על /BaseFont בדיוק כפי ש-ISO 32000-1 §9.6.4 דורש, הקובץ קטן יותר, כל עמוד לטיני עובר הגהה נקייה, ועמוד ערבי יוצא כשורה של מלבנים ריקים. באגי סדר רועשים ברגע שמסתכלים. באגי סגירה נשארים שקטים לנצח, כי ה-subset תקין מבנית ורק שגוי לגבי רשימת החברות שלו
מדוע גליפים מעוצבים מוצגים כ-.notdef?
כי קבוצת נקודות הקוד שמסמך פולט אינה קבוצת הגליפים שהמסמך מצייר, ו-subsetter שמבלבל בין השניים מפיל כל גליף שהופק על ידי עיצוב (shaping). עיצוב טקסט הופך רצף תווים לוגי לרצף גליפים ממוקם, וכל מטרתו להפיק גליפים שאף תו קלט בודד לא ממופה אליהם: heh ערבי במיקום אמצעי, ligature של fi, conjunct בדוונגרי, חלופה הקשרית (contextual alternate) שנבחרה על ידי התכונה rclt. כל אחד מהם הוא מזהה גליף שחיפוש GSUB ייצר, לא כזה שטבלת cmap נותנת לכם עבור כל תו במחרוזת שלכם. subsetter המונחה אך ורק על ידי ה-cmap לכן עובר על האינדקס הלא נכון. הוא שומר בנאמנות כל גליף שהטקסט יכול היה להשתמש בו לפני העיצוב ומשמיט בדיוק את הגליפים שהטקסט משתמש בהם אחרי העיצוב. הרנדרר אז שואל את הגופן המוטמע עבור GID 1847, ה-subset אפס את הרשומה הזו ב-loca, ואינדקס גליף 0 חוזר במקום. אינדקס גליף 0 הוא .notdef לפי הגדרת OpenType, וזו הסיבה שחתימת הכשל היא תיבה ריקה ולא אות שגויה או קריסה. שום דבר ב-PDF אינו פגום; הגופן פשוט לא מכיל את הגליף שזרם התוכן ביקש
נקודות קוד אינן גליפים: שלושת מקורות ה-subset
סגירת subset נכונה חייבת לאחד שלושה מקורות עצמאיים, כל אחד עם צובר (accumulator) משלו. הראשון הוא הקבוצה הנגזרת מנקודות קוד: HotPDF צובר את FUnicodeUsedCps כשתווי BMP נפלטים ו-FUnicodeSmpUsed עבור תווי מישור-משלים המושגים דרך זוגות surrogate, ואז ממפה כל אחד דרך FUnicodeCpToGid למזהה גליף. השני הוא הקבוצה הנגזרת מעיצוב, מזהי הגליף שהחלפת GSUB הפיקה, נרשמים דרך MarkUnicodeGlyphUsed ו-EnableShapingFeatureForSubset לתוך FUnicodeExtraUsedGlyphs. השלישי הוא סגירה מורכבת (composite closure): גליף שה-numberOfContours שלו הוא -1 ב-glyf מורכב ממזהי גליף רכיב, ושמירת המורכב תוך השמטת רכיביו מפיקה מתאר ריק במקום .notdef, וזה כנראה גרוע יותר כי זה נקרא כבאג ריווח
HotPDF תמיד טיפל בראשון ובשלישי. BuildAndApplyUnicodeFontSubset, נקודת הכניסה ל-subsetting ש-EndDoc קורא לפני ה-serialization, מזרעת את מערך הגליפים-בשימוש עם GID 0, עוברת על נקודות קוד BMP, עוברת על רשימת שימוש SMP, ומעבירה את המערך לבונה subset שפותר רכיבי מורכב באופן פנימי. המקור השני נכתב אך מעולם לא נצרך, ומכיוון ששלושת המקורות נכשלים בתוכן שונה, הפער יכול להסתתר שנים בבסיס קוד שקורפוס הרגרסיה שלו הוא בעיקר לטיני
המערך שנכתב ומעולם לא נקרא
החוזה תועד בשלושה מקומות וכובד באף אחד מהם. ההצהרה של FUnicodeExtraUsedGlyphs קבעה שה-subsetter של EndDoc מאחד אותו עם השימוש הנגזר מנקודות קוד; הערת הכותרת על ApplyArabicGSUBRefinement הבטיחה שכל GID תחליף שנפלט מועבר דרך MarkUnicodeGlyphUsed כך שה-subsetter מושך את הגליף לתוך הגופן המוטמע; אותה הבטחה מופיעה מילה במילה על ApplyArabicGSUBContextualRefinement עבור נתיב ה-rclt. שני הקוראים קיימו את חלקם. חיפוש grep על פני כל הפניה לשדה הכריע את החצי השני תוך כתשעים שניות: הצהרה אחת, הקצאת SetLength אחת בתוך RegisterUnicodeTTF, וכתיבות בשתי שגרות הסימון. אף קריאה אחת. זו האבחנה ששווה להפנים, כי היא מוכללת הרבה מעבר לגופנים. כששדה נכתב על ידי כמה נקודות קריאה ונקרא על ידי אף אחת, התכונה שהוא מייצג לא קיימת, לא משנה כמה יסודי הוא מתועד. שלב 1 של ה-subsetter קטן מספיק לקריאה במסך אחד, והפער ברור ברגע שיודעים לחפש אותו
// 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
תיקון הלולאה האחת, וסימון גליפים בעצמכם
התיקון הוא איחוד (union), וטיעון הבטיחות שלו מגיע מכיוון הפעולה: הוא רק מגדיר ביטים, לעולם לא מנקה אותם, כך שאף גליף ששרד בעבר את ה-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;
שלוש תכונות הופכות זאת לשינוי בסיכון נמוך ולא לכתיבה מחדש של מנוע גופנים. הוא מונוטוני, כמו למעלה. הוא no-op על גופנים שמעולם לא עיצבו דבר, מכיוון ש-FUnicodeExtraUsedGlyphs נשאר כולו-False ופלט הבייטים עבור מסמך לטיני-בלבד לא משתנה. והוא נוחת לפני שלב 2, כך ששני בוני ה-subset יורשים אותו: הבונה הדליל ששומר על מספור GID מקורי, והבונה הקומפקטי _BuildCompactSubsetTTF ש-HotPDF בוחר תחת PDF/A כדי למספר מחדש את הגליפים שנשמרו לתוך טווח צפוף, לכווץ את maxp.numGlyphs, ולפלוט את המיפוי ישן-לחדש כזרם /CIDToGIDMap הנדרש על ידי ISO 32000-1 §9.7.4.2. שניהם קוראים ל-_TTFWalkCompositeClosure באופן פנימי, כך שגליף מעוצב שקורה להיות מורכב עכשיו גם גורר את רכיביו. סגירה מורכבת מעולם לא הייתה שבורה; היא פשוט מעולם לא הושגה עבור מזהי הגליף הללו, כי מזהי הגליף לא היו בקבוצה שהיא עוברת עליה. אם מפעילים את מנוע ה-GSUB ישירות במקום להסתמך על מעברי הזיקוק המובנים, סגירה הופכת לאחריותכם, וכל מזהה גליף תחליף שפולטים חייב להיות מסומן לפני ש-EndDoc מקפיא את קבוצת הגליפים-בשימוש
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 הוא המקבילה האצווה לקריאת ה-GID היחיד, והוא שמרני בכוונה. הוא עובר על רשימת חיפוש ה-GSUB בחיפוש אחר החיפושים המחווטים לתגית תכונה אחת בת ארבעה בייטים תחת נתיב הכתב והשפה שנבחרו כעת, ומסמן את מזהי הגליף התחליפיים שאותם חיפושים יכולים להפיק. הוא no-op הגנתי כשהגופן לא נושא טבלת GSUB או כשהתכונה נעדרת מהנתיב ההוא, כך שקריאה לו ללא תנאי בטוחה. הוא גם over-approximation בכוונה: הוא עשוי לשמר גליפים שמסמך נתון לעולם לא מצייר. עבור subsetting, הכללת-יתר עולה בייטים והכללת-חסר עולה נכונות, מה שהופך את זו לעסקה קלה. המבנה של החיפושים הללו, וטבלאות הכיסוי שמחליטות אילו גליפים משתתפים, מכוסה בהסקירה של חלופות סגנוניות GSUB ב-Delphi טהור
כיצד מוכיחים שהגליף אכן נמצא ב-subset?
על ידי קריאת הגופן שנפלט, לא על ידי מבט בעמוד בצופה שאולי מחליף גופן מערכת מאחורי הגב שלכם. הבדיקה שתופסת את כל הקטגוריה הזו של באג היא מכנית: מחלצים את זרם ה-/FontFile2 מה-PDF הפלט, מנתחים loca, ומאשרים שמזהה הגליף שאתם מצפים לו נושא רשומה לא-ריקה, כלומר ה-offsets ההתחלה והסיום שלו שונים. רשומה ריקה היא ה-subsetter שהחליט שהגליף אינו בשימוש. שני הרגלים אז הופכים את זה להרבה יותר קשה לשגר את הכשל שוב. שומרים עמוד כתב-מעוצב בקורפוס העשן האוטומטי ולא רק בסט ההגהה הידני, כי ערבית, דוונגרי, וח'מר מפעילים נתיבי סגירה שאף כמות של כיסוי לטיני לא תיגע בהם. וכל אימת שקיים צובר, מאשרים ש-assert שמשהו צורך אותו, כי שדה כתיבה-בלבד הוא תכונה שמתקמפלת, עוברת בדיקות ירוקות על הקורפוס הלא נכון, ולא עושה דבר
איפה התיקון נעצר
סגירת subset הכרחית כדי שגליף מעוצב יוצג, והיא אינה מספיקה. הגליף גם חייב להיות בר-כתובת מזרם התוכן, שזו בעיה נפרדת עם גבול משלה. מעברי הזיקוק הערבי המובנים של HotPDF מבצעים החלפה רק כשכל מזהה גליף תחליף בר-הגעה דרך נקודת קוד צורת-הצגה יוניקוד באמצעות סריקת cmap הפוכה על פני כ-690 נקודות קוד ב-U+FB50 עד U+FDFF וב-U+FE70 עד U+FEFF. כשתחליף נוחת על מזהה גליף מחוץ לטווח הזה, חלון הקלט עובר ללא שינוי במקום לפלוט משהו שהקורא לא יכול להפנות אליו; חלופות ספציפיות-לגופן במזהי גליף שרירותיים זקוקות לנקודת קוד שימוש-פרטי סינתטית המוקצית ב-U+E000 עד U+F8FF כדי לשאת אותן דרך נתיב הפליטה. אז הסיכום הכן הוא שהתיקון של 2.435.0 הסיר חוסם קשיח במקום להשלים את הסיפור. לפניו, גליף יכול היה להיות מעוצב נכון, נפלט נכון, ועדיין להיעלם בזמן ה-subset, מה שאומר שמנוע העיצוב לא היה ניתן לסמוך עליו מקצה לקצה לא משנה כמה טובים חיפושיו היו. מה שנשאר הוא בר-כתובתיות, ואילוץ זה לפחות נכשל בגלוי בנקודת הפליטה במקום בשקט בשלב build שרץ אחרי כל מה שהייתם צופים בו. עבור צד הפליטה של אותו צינור, ראו המדריך לעיצוב טקסט ערבי ו-RTL ב-Delphi PDFs
ה-subsetting של הגופנים, מנוע ה-GSUB, ועיצוב הכתבים המורכבים המתוארים כאן מגיעים בגרסה הסטנדרטית של HotPDF Component עבור Delphi ו-C++Builder; דף המוצר נושא את הפניית ה-API המלאה עבור קריאות הגופנים והעיצוב היוניקוד הנקובות למעלה