מאמר טכני

זיהוי glyphs חסרים ב-PDF בשעת הציור ב-Delphi

glyph חסר ב-PDF אינו שגיאה. המייצר מבקש תו שהגופן הנבחר אינו מסוגל למפות, הגופן מחזיר אינדקס glyph אפס, והקובץ שיוצא תקין מבחינה מבנית, נפתח בכל מקום, ומציג תיבה ריקה במקום שבו שם או סכום צריכים להיות. אף אחד בצינורית הייצור לא מגלה זאת. הנמען כן. HotPDF סוגר את הלולאה הזאת עם TrackUnresolvedGlyphs: מפעילים אותו ונתיב ציור הטקסט רושם כל נקודת קוד שחיפוש ה-glyph שלה נפתר לאינדקס אפס, מנפיק OnUnresolvedGlyph פעם אחת לכל ממצא ייחודי עם נקודת הקוד, הגופן שנכשל בו, הסקריפט שהיא שייכת לו, והצעה של גופנים שיכסו אותה

זיהוי הוא חצי מהתשובה. החצי השני הוא SetFontFallbackChain, שרושם רשימה מסודרת של גופנים לכל סקריפט, כך שהמקרים הנפוצים נפתרים מעצמם ורק פערים אמיתיים מגיעים אל המטפל שלכם. יחד הם הופכים מחלקת פגמים שהייתה מדווחת על ידי לקוחות לבדיקה של זמן בנייה

למה glyph חסר לא מעלה דבר?

כי ISO 32000 אינו מטיל על מייצר שום חובה לאמת כיסוי, ואינדקס glyph אפס הוא glyph לגיטימי. הוא .notdef, שאת הקווים המתארים שלו בוחר מעצב הגופן: בדרך כלל מלבן ריק או חלול, לפעמים כלול לגמרי. מציג שמצייר אותו מתנהג נכון. חילוץ טקסט אפילו יכול להחזיר את התווים הנכונים, כי מיפוי ה-/ToUnicode נכתב מהטקסט המקורי ולא מהקווים המתארים, ולכן בדיקת round-trip אוטומטית תעביר בשמחה מסמך שהטקסט הנראה שלו מחורר

תרשים מדוע glyph חסר ב-PDF נשאר שקט כש-glyph אפס מצייר תיבה ריקה בזמן שחילוץ ToUnicode עובר בדיקות round-trip
glyph אפס הוא תשובת .notdef לגיטימית ו-/ToUnicode נכתב מהטקסט המקורי, ולכן שום דבר בצינורית לא נודע על הפער

ההשלכה המעשית היא שאת הכיסוי צריך לבדוק ברגע הציור, כשהספרייה עדיין יודעת איזו נקודת קוד ביקשו ואיזה glyph הגופן באמת הציע. אחר כך המידע הולך לאיבוד

הגלאי צריך לצפות במצב ה-subset, לא בהקשר המכשיר

כאן המימוש הראשון שגה, והסיבה שווה הבנה כי היא חלה על כל בדיקת כיסוי שמוברגת על צינורית טקסט. ל-HotPDF שני נתיבי טקסט. האחד פולט דרך גופן TrueType של Unicode רשום עם מפת תווים בזיכרון שנבנתה בעת הרישום. השני הוא נתיב GDI ישן שיוצר הקשר מכשיר ו-handle של גופן חדשים לכל ריצת תווים

לשפוט כיסוי מהנתיב של GDI הוא מיושן. המיפוי שלו אינו המיפוי שמסתיים ב-stream התוכן הנפלט, והשניים אינם מסונכרנים, ולכן גלאי שקורא תוצאות GDI מדווח על כל טווח ה-ASCII הניתן להדפסה כבלתי פתור. התשובה הסמכותית חיה בגופן הרשום: מפת התווים ש-RegisterUnicodeTTF מנתח, שנשאלת דרך GetUnicodeGlyphForCodepoint. הגלאי לכן שוער על מצב subset-ready ולא על תנאי GDI כלשהו, והוא פשוט לא רץ על מסמכים שמעולם לא רשמו גופן Unicode, מה שנכון כי אותם מסמכים מוגבלים לקידודים הסטנדרטיים בכל מקרה

מלכודת שנייה יושבת לידה. שם המשפחה של GDI של גופן ושם ה-PostScript שחולץ מהבינארי של הגופן בעת הרישום הם מחרוזות שונות, ולא באופן שאפשר לנרמל: משפחה בשם Arial Unicode MS נושאת את שם ה-PostScript ArialMT. כל שער שנכתב כ"האם הגופן הנבחר כרגע הוא זה שרשמנו", שנשווה לפי שם, הוא קוד מת שלעולם לא מנפיק. שערו על מצב, לעולם לא על שמות גופנים

זרימת זיהוי glyph בלתי פתור של HotPDF שמראה את שער מצב ה-subset, את החיפוש ב-GetUnicodeGlyphForCodepoint ואת חיבור אירוע OnUnresolvedGlyph
את הכיסוי שופטים ממפת הגופן הרשום של Unicode ולא מ-GDI, וכל נקודת קוד ייחודית מנפיקה אירוע אחד עם הצעת גופן

אל תבחנו גלאי glyph עם emoji

מקרה הבדיקה המובן הוא פרצוף מחייך, והוא ישכנע אתכם שהגלאי שבור. נקודות קוד emoji נפוצות במישורי ה-astral נפתרות דרך נתיב סינתזה של private-use שממפה אותן ישירות לאינדקס glyph, ולכן הן לעולם לא מגיעות אל ענף הכיסוי הכללי. הגלאי מתנהג נכון והבדיקה מודדת את הנתיב השגוי

השתמשו במקום זאת בנקודת קוד לא מוקצית. U+0378 אינו מוקצה לצמיתות ב-Unicode, כך שאף גופן לא יכול למפות אותה באופן לגיטימי, והיא מפעילה בדיוק את הענף שאתם רוצים לאמת. ההבחנה הזאת בין "התכונה שבורה" ו"הבדיקה בחרה קלט שעוקף את התכונה" עולה שעות אמיתיות, ונקודות קוד לא מוקצות הן הדרך הזולה ביותר להימנע מכך

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
  // מנפיק פעם אחת לכל נקודת קוד ייחודית, לא פעם לכל מופע
  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 הן לפי סקריפט, לא לפי גופן

הסיבה ש-fallback מסוגר לפי סקריפט ולא לפי גופן המקור היא שפערי כיסוי מתקבצים לפי מערכת כתיבה. גופן טקסט לטיני חסר דוונגארי, תאילנדית, Han ו-emoji, כולם בבת אחת, והתחליף לכל אחד הוא גופן אחר. הצהרת שרשרת אחת לכל סקריפט לכן מתארת את הפריסה האמיתית: גופן לטיני אחד לטקסט גוף, גופן CJK אחד, גופן emoji אחד, גופן לוכד-הכול

תרשים fallback של גופנים לפי סקריפט שממפה את הסקריפטים hfsCJK, hfsArabic, hfsEmoji ו-hfsOther אל שרשראות גופנים תחליפיים מסודרות ב-HotPDF
לכל סקריפט שרשרת מסודרת משלו, כך שגופן גוף לטיני שחסרים לו Han, ערבית או emoji נופל אל גופן שמכסה אותם
// THPDFFontScript covers hfsCommon, hfsLatin, hfsGreek, hfsCyrillic,
// hfsHebrew, hfsArabic, hfsIndic, hfsSoutheastAsian, hfsCJK, hfsKana,
// hfsHangul, hfsEmoji and 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 וזיהוי משלימים זה את זה ולא חלופות. שרשראות מטפלות בכיסוי שציפיתם לו; הגלאי מדווח על הכיסוי שלא ציפיתם לו, שבמערכת שמעבדת נתוני לקוח שרירותיים הוא החצי המעניין. שימו לב שהחלפת גופן משנה מדדים, ולכן פסקה שנופלת ל-fallback עשויה להסתדר מחדש; אם הפריסה חשובה, התנהגות הסגירה וה-subsetting של הגופן התחליפי שווה לקרוא עליה במאמר סגירת subset של גופן, וסקריפטים שזקוקים לסידור מחדש או חיבור מטופלים על ידי שלב ה-shaping המתואר בעיצוב טקסט סקריפט מורכב

איך להוסיף התנהגות בדיעבד בלי לסכן את הנתיב הקיים

אותה גרסה הוסיפה fallback לטבלת kern ישנה עבור ריווח זוגות, והאופן שבו הוא סוגר תחום הוא תבנית ששווה להעתיק. במקום להוסיף נקודת החלטה חדשה ללוגיקת ה-kerning, ה-fallback תלוי על ענף היציאה המוקדמת שכבר היה קיים עבור גופנים בלי טבלת GPOS. גופן מודרני עם GPOS לעולם לא מגיע אליו, ולכן ההתנהגות שלו ללא שינוי מהבנייה ולא מהבדיקה. נתיבים שאינם רושמים גופן Unicode מפיקים שתי היסת אפס, ולכן הם גם ללא שינוי

זו הצורה הכללית של שדרוג בדיעבד בסיכון נמוך בספריית רינדור בוגרת: מצאו את הענף שכרגע אינו מפיק דבר ושימו שם את ההתנהגות החדשה. זה ממיר "אנחנו מאמינים שזה לא נסוג בדבר" ל"זה לא יכול היה להיסוג בדבר", וזה דבר הרבה יותר טוב לומר על מנוע טקסט שחשבוניות של אחרים עוברות דרכו

הפכו את זה לשער, לא ליומן

ממצאי כיסוי שימושיים רק אם משהו נכשל עליהם. בשירות מחולל מסמכים הסידור הפרודוקטיבי הוא להשאיר את המעקב דלוק במשימת הרגרסיה הלילית מול corpus של שמות לקוחות, כתובות ותיאורי מוצרים אמיתיים, ולהכשיל את המשימה על כל ממצא. כי האירוע מנפיק פעם אחת לכל נקודת קוד ייחודית ולא פעם לכל מופע, הפלט נשאר קטן מספיק לקריאה גם כשסקריפט שלם חסר

בייצור אותו מטפל עדיף להשתמש בו כ-telemetry: רשמו את נקודת הקוד והגופן, המשיכו לשרת את המסמך, ותנו למצטבר לומר לכם איזה סקריפט להוסיף הבא לקבוצת הגופנים של הפריסה. התנהגות הרינדור עבור גופנים מוטמעים ותחליפיים מכוסה עוד ברינדור glyphs של גופן מוטמע, ורשימת התכונות המלאה כולל TrackUnresolvedGlyphs מתועדת בדף המוצר של HotPDF Delphi PDF component