Technical Article

באג ה-EndDoc שביטל בחשאי יצירת תתי-קבוצות של גופנים

צור דוח, הטמע גופן TrueType, והפלט נפתח בצורה תקינה בכל מציג שתנסה. הגליפים נכונים, הטקסט ניתן לבחירה, הקובץ תקף. הדבר היחיד שאינו תקין הוא הגודל. מסמך שהשתמש בכמה עשרות תווים לטיניים נושא את כל הגופן בגודל 350 KB. מסמך שהדפיס פסקה בסינית נושא גופן CJK בגודל 14 MB במקום חלק של חצי מגה-בייט שהוא באמת צריך. לא נזרקה שום חריגה, לא נרשמה אזהרה, והקובץ עבר אימות. כך נראה שלב סיום לא מסודר מבחוץ: שום דבר לא נכשל, והראיה היחידה היא מספר גדול מדי.

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

מה יצירת תת-קבוצת גופנים אמורה לעשות

גופן תת-קבוצה הוא החלק של קובץ TrueType שמסמך באמת משתמש בו. תקן ISO 32000-1 §9.9 מתאר כיצד תוכנית גופן מובנית נמצאת בתוך זרם המיוחס על ידי תיאור הגופן, ועבור תוכנית TrueType זרם זה הוא /FontFile2 עם /Length1 המציין את מספר הבתים הלא דחוסים. יצירת תת-קבוצה משכתבת את הטבלאות glyf ו-loca כך שיכילו רק את הגליפים שאליהם המסמך מתייחס, ממספרת מחדש את מזהי הגליפים, ומוסיפה קידומת לשם ה-/BaseFont עם תג בן שש אותיות כגון ABCDEF+ כדי לסמן את הגופן כתת-קבוצה, בדיוק כפי שהמפרט דורש. גופן לטיני המצומצם לתת-קבוצה של עשרה או חמישה עשר קילובייט הוא ההבדל בין קובץ PDF יעיל לבין קובץ ששולח גופן שלם עבור כותרת אחת.

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

הסימפטום, ומדוע שום דבר לא התלונן

ההתנהגות שדווחה הייתה גופנים מלאים בפלט ללא כל אבחון. משתמש שרשם גופן Unicode TrueType והפיק מסמך רגיל מצא שאובייקט הגופן המובנה היה באותו אורך של קובץ ה-.ttf המקורי, וששם ה-/BaseFont לא נשא שום קידומת תת-קבוצה בת שש אותיות. הפלט מעולם לא התכווץ בין הרצות שהשתמשו בעשרה גליפים להרצות שהשתמשו בעשרת אלפים.

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

עילת השורש הייתה סדר הסיום

ב-HotPDF עבודת הסגירה מתרחשת בתוך EndDoc. שלב יצירת תת-קבוצה הוא שגרה פנימית בשם BuildAndApplyUnicodeFontSubset. היא קוראת את קבוצת נקודות הקוד המשומשות לכל מסמך, שנשמרת במפת סיביות (bitmap) שנתיב הפלט של הטקסט ממלא כאשר מוצגים גליפים, ממפה כל נקודת קוד בשימוש דרך טבלת נקודת-קוד-לגליף המאוחסנת במטמון למזהה גליף אמיתי, ומשכתבת את תוכנית הגופן סביב סגירה זו. כאשר גופן Unicode TrueType רשום, נתיב הפלט מגדיר סיבית בקבוצת נקודות הקוד המשומשות עבור כל תו שהוא מצייר, כך שעד שהמסמך נסגר המנוע יודע בדיוק באילו גליפים תת-הקבוצה חייבת להחזיק.

הפגם היה ש-BuildAndApplyUnicodeFontSubset נקראה לאחר ש-SaveToStream או SaveToFile כבר בצעו סיראליזציה למסמך. העריכות של יוצר תת-הקבוצה ב-/FontFile2, ה-/Length1 המתוקן שלו, וקידומת ה-/BaseFont בת שש האותיות חושבו כולם מול גרף אובייקטים שכבר הפך לבתים. התיקון היה שינוי סדר של שורה אחת: העברת קריאת תת-הקבוצה לפני הסיראליזציה, כך שהכותב יפלוט את הגופן המצומצם לתת-קבוצה במקום את המקורי. הרצף המתוקן מריץ תחילה את יוצר תת-הקבוצה ומבצע סיראליזציה לאחר מכן.

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.RegisterUnicodeTTF('C:\Fonts\NotoSansSC-Regular.ttf');
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Noto Sans SC', [], 12);
    Pdf.CurrentPage.TextOut(72, 760, 0, '报表标题 Report Heading');
    Pdf.EndDoc;                 // subsetting runs here, before the write
    Pdf.SaveToFile('Report.pdf');
  finally
    Pdf.Free;
  end;
end;

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

מדוע שלב אחד שלא במקומו הוא קטגוריה שלמה

הסיבה לכך שזה שווה שיעור ולא הערת שוליים היא ש-EndDoc פולט רשימה של שלבי סגירה, וכל אחד מהם רגיש למיקומו ביחס לכתיבה. יצירת תת-קבוצת גופנים היא אחת מהם. פלט PDF/A דורש זרם /CIDSet המונה בדיוק את מזהי הגליפים הקיימים בתת-הקבוצה, מגבלה שתקן ISO 19005 מטיל כדי שמאמת יוכל לאשר שתוכנית המובנית תואמת למה שמתאר הגופן טוען; זרם זה נפלט באותו חלון סיום ותלוי בכך שתת-הקבוצה נבנתה תחילה. תקן PDF/UA-1 דורש, על פי ISO 14289-1 §7.18.3, שכל עמוד הנושא הערה יצהיר על /Tabs עם הערך /S, ושגרה פנימית בשם EnsurePDFUATabsOnAnnotatedPages חותמת מפתח זה במהלך אותו שלב. בדיקות כוונת פלט רצות שם גם כן.

אותה שגיאת סדר שהשביתה את יצירת תת-הקבוצה השמיטה גם את מפתח סדר הכרטיסיות (tab-order) של PDF/UA בעמודים עם הערות, מכיוון ששלב זה ישב באותו צד לא נכון של הכתיבה. הכלים veraPDF ו-PAC מדווחים על /Tabs /S חסר כהפרה של נקודת הבדיקה 21-001 בפרוטוקול Matterhorn. לכן קריאה בודדת שלא במקומה לא רק ניפחה את גודל הקובץ; היא שברה בחשאי דרישת תאימות נגישות באותו זמן, עם אותו חוסר בשגיאה כלשהי. זהו הסיכון בשלב סיום: שלביו חולקים תנאי מוקדם, וטעות סדר בודדת יכולה להוציא כמה מהם מכלל פעולה בבת אחת בזמן שכל קריאה עדיין מחזירה הצלחה.

כיצד נתפס בפועל כשל פליטה שקט

באג שאינו מעורר חריגה לא נתפס על ידי הרצת התוכנית. הוא נתפס על ידי בדיקת הפלט והשוואתו למה שהקלט היה אמור להפיק. עבור יצירת תת-קבוצת גופנים הבדיקות הן קונקרטיות. השווה את גודל קובץ הפלט מול ציפייה גסה: מסמך שנגע בקומץ גליפים לא אמור להיות בגודל של גופן שלם. פתח את אובייקט הגופן המובנה וקרא את אורך הבתים שלו; זרם /FontFile2 מצומצם עבור גופן לטיני הוא שבר קטן מקובץ המקור. קרא את שם ה-/BaseFont וודא שהקידומת בת שש האותיות קיימת, מכיוון שהיעדרה הוא סימן ישיר לכך שלא הוחלה שום תת-קבוצה.

var
  Pdf: THotPDF;
  Output: TMemoryStream;
begin
  Output := TMemoryStream.Create;
  try
    Pdf := THotPDF.Create(nil);
    try
      Pdf.RegisterUnicodeTTF('C:\Fonts\DejaVuSans.ttf');
      Pdf.BeginDoc;
      Pdf.CurrentPage.SetFont('DejaVu Sans', [], 11);
      Pdf.CurrentPage.TextOut(72, 760, 0, 'Subset me');
      Pdf.EndDoc;
      Pdf.SaveToStream(Output);
    finally
      Pdf.Free;
    end;
    // A few glyphs from a ~700 KB face must not yield a multi-hundred-KB stream.
    if Output.Size > 100 * 1024 then
      raise Exception.Create('Font subset did not shrink the output');
  finally
    Output.Free;
  end;
end;

עבור פלט PDF/A הבדיקה חדה עוד יותר, מכיוון שמאמת עושה את העבודה בשבילך. הגדר את רמת התאימות והרם את התוצאה דרך veraPDF: זרם /CIDSet חסר, או תת-קבוצה שאינה תואמת לתיאור, מדווחים כסעיף שנכשל במקום להשאיר זאת להבחנתך בעין. מתגי התאימות שמניעים עבודת סיום זו הם מאפיינים במסמך. המאפיין PDFACompliance מקבל מחרוזת כגון '2B' עבור PDF/A-2 Level B, ו-PDFUACompliance הוא ערך בוליאני שמפעיל את דרישות ה-tagged-PDF וסדר הכרטיסיות.

Pdf := THotPDF.Create(nil);
try
  Pdf.PDFACompliance := '2B';     // PDF/A-2 Level B, drives /CIDSet emission
  Pdf.PDFUACompliance := True;    // stamps /Tabs /S on annotated pages
  Pdf.RegisterUnicodeTTF('C:\Fonts\NotoSansSC-Regular.ttf');
  Pdf.BeginDoc;
  Pdf.CurrentPage.SetFont('Noto Sans SC', [], 12);
  Pdf.CurrentPage.TextOut(72, 760, 0, '合规报告');
  Pdf.EndDoc;
  Pdf.SaveToFile('Report_PDFA.pdf');
finally
  Pdf.Free;
end;

השיעור ההנדסי

שני כללים נובעים מכך. הראשון הוא שכל שלב סיום שמשנה אובייקטים חייב לרוץ לפני שאובייקטים אלה עוברים סיראליזציה, ויש לקרוא לשלב הסגירה של מנוע מסמכים כצינור מסודר שבו סיראליזציה היא הפעולה האחרונה, ולא פעולה אחת מתוך כמה. השני הוא זה שעלה בהכי הרבה זמן כאן: עבור שלב פליטה, היעדר שגיאה אינו עדות להצלחה. שגרה שבונה את תת-הקבוצה הנכונה ומחילה אותה על הגרף הלא נכון שכבר נכתב אינה מדווחת על שום תקלה, מכיוון שמנקודת המבט שלה שום דבר לא היה לא תקין. האימות חייב להסתכל על התוצר, לא על קוד החזרה. בדוק את גודל הפלט, קרא את אורך הבתים של הגופן המובנה ואת קידומת ה-/BaseFont שלו, ותן ל-veraPDF לשפוט את פלט ה-PDF/A שבו /CIDSet חסר הופך גרעון שקט לכשל מוגדר.

צד היצרן של הטיפול בגופנים, כיצד גופנים נרשמים ומובנים עבור פלט דוחות, מכוסה ב-מאמר שלנו על גופנים ותמונות בפלט דוחות. צד האימות, שבו שלבי סיום אלו נבדקים מול התקנים, מכוסה ב-מדריך על אימות PDF/A ו-PDF/UA. שניהם משתלבים עם עבודת יצירת תת-הקבוצות והתאימות המתוארת כאן, אשר נשלחת כחלק מ- HotPDF Component עבור Delphi ו-C++Builder לצד ממשקי ה-API לטעינה, עריכה, הצפנה וחתימה המכוסים במקומות אחרים בבלוג זה.