מאמר טכני

גופנים וטקסט ב-PDF: מדוע תווים (Glyphs) הופכים לתיבות

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

הבנה מדוע זה קורה, ומדוע כשל נפרד מייצר טקסט שנראה בר-חיפוש המועתק החוצה כג'יבריש, פירושה להסתכל כיצד PDF מאחסן טקסט. הוא אינו מאחסן משפטים. הוא מאחסן קודי גליפים בתוספת תוכנית גופן בתוספת טבלאות הממפות אחד לשני, וכל באג של רינדור או חילוץ חי בפער בין שלושת אלה. להלן סיור במנגנון זה, מבוסס על ISO 32000, עם קריאות ה-Delphi השולטות בו היכן שהן חשובות

תווים (Characters), קודים וגליפים (glyphs) הם שלושה דברים שונים

אוצר המילים מכשיל אנשים מכיוון שדיבור יומיומי מקפל שלושה רעיונות נפרדים לתוך המילה "אות". תו (character) הוא יחידת כתיבה מופשטת, הרעיון של A גדולה, המזוהה ב-Unicode כ-U+0041. גליף הוא צורה מצוירת, קו המתאר של עקומה וגזע שגופן מסוים משתמש בו כדי לתאר את התו הזה. ביניהם יושב הקוד: הבית (byte) או הבתים בזרם התוכן שאומרים למציג איזה גליף בגופן הנוכחי לצבוע

PDF עובד בקודים. כאשר זרם תוכן מראה מחרוזת, בתים אלה הם אינדקסים לתוך הגופן הפעיל, לא Unicode. הקידוד של הגופן מחליט שקוד של 65 פירושו "צייר את הגליף המתויק תחת 65", ושום דבר בפעולה זו אינו יודע שהתוצאה נראית כמו A לאדם. זה מה שגורם ל-PDF להתרנדר באופן זהה בכל מקום בו הוא יכול למצוא את הגליפים, וזו גם הסיבה שחילוץ הוא בעיה נפרדת מתצוגה: ציור זקוק רק לקוד-לגליף (code-to-glyph), קריאה זקוקה לקוד-ל-Unicode, ואלה שתי טבלאות שונות שיכולות לא להסכים או ללכת לאיבוד באופן עצמאי

סוגי הגופנים שבאמת תפגוש

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

Type 1 הוא פורמט קו המתאר המקורי של Adobe PostScript, בנוי מעקומות בזייה קוביות. ארבעה עשר הגופנים התקניים שכל קורא תואם חייב לספק, משפחות Helvetica, Times, Courier, Symbol ו-ZapfDingbats, הם Type 1, ומילון גופנים שנוקב בשם של אחד מהם יכול מבחינה חוקית להשמיט את תוכנית הגופן. זהו המקרה היחיד שבו השארת גופן לא מוטמע (unembedded) היא בטוחה על פי המפרט ולא על פי מזל. עבור כל גופן Type 1 אחר, התוכנית חייבת להיות מוטמעת או שהמציג מחליף משהו, בדרך כלל גופן דומה מבחינה מטרית אך שונה חזותית

TrueType משתמש בעקומות ריבועיות והגיע מעולם ה-Apple וה-Microsoft. זה מה שרוב גופני המערכת הם, ומה שתטמיע לרוב. גופן TrueType פשוט ב-PDF מוגבל לקודים של בית יחיד, כך שגופן אחד כזה יכול להתייחס לכל היותר ל-256 גליפים בו-זמנית. מגבלה זו היא הסיבה המבנית לכך ש-CJK וכתבים גדולים אחרים אינם יכולים לרכוב על גופן פשוט

Type 0, הגופן המורכב (composite) או מקודד ה-CID, הוא התשובה למגבלה זו. הוא משתמש בקודים מרובי-בתים (multi-byte) וב-CMap כדי לנתב אותם דרך CIDFont צאצא, שקווי המתאר שלו בעצמם הם TrueType או CFF/Type 1. זהו סוג הגופן היחיד שיכול לשאת אלפי גליפים, כך שכל PDF המחזיק סינית, יפנית, קוריאנית, או תמהיל רב-לשוני רחב משתמש ב-Type 0 בין אם המחבר חשב על זה או לא. הפשרה היא מורכבות: יותר חלקים נעים, שיותר מהם חייבים להיות נכונים הן לרינדור והן לחילוץ

גופן TrueType אחד מרונדר ב-12, 18, 24 ו-36 נקודות ב-PDF, מראה שקו מתאר מוטמע יחיד משנה קנה מידה לכל גודל

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

הטמעה (Embedding) היא ההבדל בין נייד לשביר

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

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

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'report.pdf';
    Pdf.Compression := cmFlateDecode;
    Pdf.FontEmbedding := True;          // קווי מתאר נוסעים בתוך הקובץ
    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 מבצעת commit למבנה המסמך, כך ש-FontEmbedding חייב להיות true לפני קריאה זו. הקצה אותו לאחר מכן ואין שום שגיאה, שום אזהרה, רק קובץ שיצא בשקט ללא הגופנים שלו. זהו הסוג הגרוע ביותר של באג: הוא עובר כל בדיקה במחשב של המפתח, שבו הגופן במקרה מותקן, וצץ רק אצל לקוח, שם הוא אינו מותקן

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

יצירת תת-קבוצות (Subsetting): הטמע רק את הגליפים שהשתמשת בהם

הטמעה מלאה כותבת את תוכנית הגופן כולה לתוך הקובץ. גופן CJK TrueType גדול יכול להגיע למספר מגה-בתים, והטמעתו בשלמותו כדי להציג תריסר תווים היא בזבזנית באופן שמחמיר במסמך מרובה-עמודים. Subsetting (יצירת תת-קבוצה) פותר זאת על ידי כתיבת רק הגליפים שהמסמך מתייחס אליהם, ואז שינוי שם הגופן עם תגית בת שש אותיות וסימן פלוס, צורת ABCDEF+Calibri בכל רשימת גופנים של PDF שעבר subsetting, כך שקורא אף פעם לא מתבלבל בין הגופן החלקי לגופן מערכת מלא באותו שם

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

גופני Unicode ובעיית התיבה של CJK

כאשר הטקסט אינו לטיני רגיל, נתיב הגופן-הפשוט אוזל, והתיקון הוא לרשום במפורש גופן בעל יכולת Unicode ולתת ל-HotPDF לבנות ממנו גופן Type 0. RegisterUnicodeTTF טוען קובץ TrueType לפי נתיב; לאחר מכן השם הרשום ניתן לשימוש ב-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;

שני דברים גורמים לזה להצליח או להיכשל. הגופן חייב לכסות את הכתבים (scripts) במחרוזת: TrueType לטיני בלבד לא יצמיח גליפים סיניים בגלל שביקשת ממנו, והתוצאה היא שוב תיבות ריקות, הפעם בגלל שהגליף באמת אינו קיים בגופן ההוא. והטמעה חייבת להישאר דולקת, מכיוון שגופן Type 0 המורכב מ-TTF רשום חסר משמעות לקורא שאינו יכול למצוא את קווי המתאר. עבור תוכן מעורב הבחירה העמידה היא גופן עם כיסוי רחב, כאשר משפחות Noto ו-Arial Unicode MS הן התשובות הרגילות, מוטמעות (embedded) ובתת-קבוצות (subsetted)

כתבים מימין לשמאל וכתבים מורכבים מוסיפים שכבת עיצוב צורה (shaping) על גבי הכיסוי. HotPDF חושף את RtLTextOut עבור ערבית ועברית, אשר מטפל בסידור מחדש הכיווני (directional reordering) כך שאתה מעביר סדר לוגי ונותן לספרייה לפרוס אותו. קבלת ערבית נכונה היא כיסוי פלוס עיצוב צורה פלוס כיוון, שלושה דברים נפרדים, ותיבה שם יכולה להיות משמעותה שכל אחד מהם נכשל

טבלת ToUnicode: היכן שהעתק-הדבק חי

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

כאשר הוא נוכח ונכון, טקסט מועתק יוצא כתווים הנכונים. כאשר הוא חסר או שגוי, או שהגופן עבר subsetting עם קודי גליף מותאמים אישית ולא נכתב ToUnicode, הדף נראה מושלם ולוח העריכה (clipboard) מתמלא בזבל: קודי גליף נקראים כאילו היו Unicode, ובעבור תת-קבוצה המקודדת בהתאמה אישית, הם אינם כאלה. זו הסיבה שמסמך סרוק עם שכבת טקסט OCR יכול להיות בר-חיפוש בעוד ש-PDF דיגיטלי-מלידה ממחולל רשלני אינו כזה. רינדור וחילוץ שואבים מטבלאות שונות, כך שקובץ יכול לספק אחת ולהיכשל באחרת. אם חילוץ חשוב לפלט שלך, התייחס למפת ToUnicode נכונה כאל דרישה, ואמת אותה על ידי העתקת טקסט מתוך דוגמה במקום לסמוך על כך שהיא שם

כיצד לאבחן באג גופן במהירות

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

שום דבר מזה אינו אקזוטי ברגע שהפיצול בין תו (character), קוד וגליף ברור. הטמע את הגופנים שאתה מצייר איתם, צור תת-קבוצות לגדולים, הושט יד לגופן Unicode ול-RegisterUnicodeTTF ברגע שהטקסט עוזב את הלטינית, ושמור על מפת ToUnicode נכונה אם מישהו יחלץ את הטקסט. עשה אלה נכון והתיבות מפסיקות להופיע. עבור המכניקה שמסביב, האנטומיה של PDF מינימלי מראה היכן מילון הגופנים יושב בעץ האובייקטים, וההדרכה על מבנה המסמך מכסה כיצד משאבים משותפים על פני דפים

הקריאות SetFont, FontEmbedding, ו-RegisterUnicodeTTF המוצגות כאן הן חלק מ-HotPDF Component עבור Delphi ו-C++Builder