מאמר טכני

פונטי אימוג׳י צבעוניים ב-PDF: COLR v1, SVG ו-bitmaps בדלפי

HotPDF מצייר אימוג'י צבעוני לתוך PDF דרך THotPDF.DrawRegisteredColorGlyph, שקורא את נתוני הצבע של פונט רשום עם RegisterUnicodeTTF ומשגר אותם כגרפיקת PDF טבעית: שכבות COLR v0 כקווי מתאר של glyph ממולאים, גרפי צביעה של COLR v1 כ-clips, shadings ומצבי blend, glyph-ים של SVG כ-Form XObjects, ו-bitmaps של CBDT או sbix כתמונות. כל מה שלא ניתן למיפוי טבעי הולך לאירוע OnColorGlyphRasterize במקום להפוך בשקט לצורה שחורה

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

למה אימוג'י צבעוני מודפס כצללית שחורה ב-PDF?

תוכנית פונט של PDF אינה מכירה glyph-ים צבעוניים. ISO 32000-1 מתייחס אל glyph כאל צורה שנצבעת בצבע הנוכחי, וטבלאות הצבע ש-OpenType הוסיף מאוחר יותר, דהיינו COLR/CPAL, SVG , CBDT/CBLC ו-sbix, אינן חלק ממודל הדימות של PDF, ולכן אף מציג אינו מחויב לקרוא אותן מפונט מוטמע. את הצבע חייבים לתרגם לתוכן עמוד בזמן הייצור, בזמן שהמפיק עדיין מחזיק את בתי הפונט ויודע איזה glyph הוא רוצה. התרגום הזה שונה לכל פורמט, ופונטי אימוג'י בשוק משתמשים בכולם: וקטורים שכבתיים, גרפי צביעת גרדיאנט, מסמכי SVG מוטמעים ו-strikes של PNG. HotPDF מדווח את התוצאה כ-THPDFOpenTypeColorFormat, עם הערכים otcfNone, otcfCOLRv0, otcfCOLRv1, otcfCBDT, otcfSVG ו-otcfSBIX, ובודק את הפונט בעדיפות קבועה: COLR קודם, ואז SVG, ואז CBDT, ואז sbix. נתוני וקטור מנצחים bitmaps בכל פעם שפונט נושא את שניהם, וזה בדיוק מה שרוצים במסמך שעשוי להיות מוגדל או מודפס

דיאגרמת בדיקת glyph צבעוני של HotPDF: תוכנית פונט של PDF צובעת קווי מתאר של glyph בצבע הנוכחי, ולכן את טבלאות הצבע של OpenType COLR, SVG, CBDT ו-sbix חייבים לתרגם לתוכן עמוד בזמן הייצור, ו-HotPDF בודק פונט רשום בעדיפות הקבועה COLR, ואז SVG, ואז CBDT, ואז sbix, ומדווח THPDFOpenTypeColorFormat מ-otcfCOLRv0 ועד otcfSBIX
נתוני וקטור מנצחים bitmaps בכל פעם שפונט נושא את שניהם, וזה בדיוק מה שרוצים במסמך שעשוי להיות מוגדל או מודפס, ו-glyph בלי נתיב צבע נשאר ל-fallback שלכם

קריאה אחת, חמישה פורמטים: פתרון וציור של glyph צבעוני

THotPDF.GetRegisteredColorGlyphInfo עונה איזה נתיב נקודת קוד תיקח, וה-DrawRegisteredColorGlyph לוקח אותו. שניהם מחפשים את נקודת הקוד במפת התווים של הפונט שהועבר לאחרונה אל RegisterUnicodeTTF, ולכן הפונט הצבעוני חייב להיות פונט ה-Unicode הרשום בעת הקריאה. פונקציית הציור מחזירה False כשל-glyph אין נתוני צבע או שאף נתיב לא יכול לרנדר אותו, ומשאירה את ה-fallback לכם

const
  FormatNames: array[THPDFOpenTypeColorFormat] of string =
    ('none', 'COLR v0', 'COLR v1', 'CBDT', 'SVG', 'sbix');
var
  Pdf: THotPDF;
  Info: THPDFOpenTypeColorGlyphInfo;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'emoji.pdf';
    Pdf.BeginDoc;
    Pdf.RegisterUnicodeTTF('C:\Windows\Fonts\seguiemj.ttf');

    // U+1F600, palette 0 של CPAL, strike של bitmap הקרוב ביותר ל-300 ppem
    if Pdf.GetRegisteredColorGlyphInfo($1F600, 0, 300, Info) then
      Writeln(Format('GID %d via %s',
        [Info.GlyphID, FormatNames[Info.Format]]));

    if not Pdf.DrawRegisteredColorGlyph(Pdf.CurrentPage, $1F600,
      72, 144, 'Segoe UI Emoji', 36, 0, 300) then
    begin
      // אין נתוני צבע: חזרה אל קו המתאר החד-גוני
      Pdf.CurrentPage.SetFont('Segoe UI Emoji', [], 36, DEFAULT_CHARSET);
      Pdf.CurrentPage.TextOut(72, 144, 0, WideString(#$D83D#$DE00));
    end;
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

שני פרמטרים ראויים לתשומת לב. PaletteIndex בוחר palette של CPAL, כך שפונט שמשלח palette לרקע כהה אפשר להחליף בלי לגעת ב-glyph. TargetPixelsPerEm חשוב רק לפונטי bitmap; כשהוא מושאר על אפס ברירת המחדל שלו היא Round(FontSize * 96 / 72), רזולוציית מסך, וזו הסיבה שהדוגמה מבקשת 300 עבור פלט הדפסה. הגבול הכן יושב בחתימה: הקריאה לוקחת נקודת קוד אחת וממפה אותה דרך cmap בלבד. רצפי ZWJ, מודיפיקטורים של גוון עור ודגלי regional-indicator הם ליגטורות GSUB, ולכן הרכבתם היא בעיית shaping מהסוג שהמאמר על חלופות GSUB של OpenType מכסה, ולא משהו שנקודת הכניסה הזאת עושה בשבילכם

COLR v0: שכבות glyph מוערמות עם צבעי palette

COLR v0 הוא המקרה הפשוט ו-HotPDF מרנדר אותו ישירות: כל base glyph מפרט glyph-י שכבה עם רשומת צבע של CPAL, וכל שכבה הופכת לפעולת הצגת-טקסט רגילה אחת עם צבע המילוי שלה עצמה, מוערמת בסדר הטבלה. שכבה עם alpha מתחת ל-255 מקבלת מילון פרמטרי מצב גרפי עם /ca ו-/CA מתאימים (ISO 32000-1 §8.4.5), וכל glyph שכבה מסומן כבשימוש כדי שה-subsetter ישמור את קו המתאר שלו גם כשאף נקודת קוד לא ממפה אליו ישירות. פרט אחד מפתיע אנשים: אינדקס רשומת ה-palette 0xFFFF פירושו "השתמשו בצבע החזית של הטקסט" במפרט ה-OpenType, ו-HotPDF פותר אותו לשחור במקום לצבע המילוי הנוכחי של העמוד. עבור פונטי אימוג'י זה בקושי משנה; עבור פונטי אייקון שמסתמכים על רשומת החזית כדי לצבוע glyph, בדקו את הפלט לפני שמניחים שהוא ילך אחרי צבע הטקסט שלכם

איך HotPDF הופך גרף צביעה של COLR v1 לאופרטורים של PDF?

על ידי פענוח טבלאות הצביעה קודם לגרף שטוח וחסום, ורק אז מיפוי כל צומת למבנה PDF. glyph של COLR v1 אינו רשימת שכבות אלא גרף אציקלי מכוון של רשומות צביעה, שבו צמתים יכולים להיות משותפים דרך PaintColrLayers ו-PaintColrGlyph. ה-parser מגביל אותו ל-4096 צמתי צביעה, 64 רמות עומק ו-1024 עצירות צבע, ועוקב אחר כל צומת כפעיל או כגמור, כך שהפניה חזרה אל צומת פעיל — מעגל שפונט זדוני יכול לבנות משימוש חוזר בשכבות — נדחה במקום להתקיים לתוכו. בסיסי ההיסטים הם המקום שבו מימוש ראשון טועה. היסטים של BaseGlyphPaintRecord הם יחסיים אל תחילת ה-BaseGlyphList, היסטי צביעה של LayerList הם יחסיים אל LayerList, וכל Offset24 בתוך טבלת צביעה הוא יחסי אל אותה טבלת צביעה עצמה. לפתור את שלושתם מול אותו בסיס ו-glyph-ים תקינים לגמרי נכשלים בבדיקת הגבולות, וזה נראה בדיוק כמו פונט פגום. ברגע שהגרף נבנה, המיפוי ישיר:

  • PaintGlyph מגדיר את קו המתאר של ה-glyph כ-clip עם מצב רינדור טקסט 7 (ISO 32000-1 §9.3.6), ואז צובע את הילד שלו בתוכו
  • צביעות אחידות ממלאות מלבן חתוך; גרדיאנטים ליניאריים הופכים ל-shadings אקסיאליים מרובי עצירות וגרדיאנטים רדיאליים הופכים ל-shadings רדיאליים דו-צבעיים (§8.7.4.5)
  • גרדיאנטי sweep אין להם מקבילה ב-PDF, ולכן HotPDF מקרב אותם ב-96 טריזים בצבע אחיד, כל אחד נדגם מקו הצבע
  • טרנספורמציות משוגרות כ-cm, מצומדות סביב מקור קו הבסיס של ה-glyph, עם הזזות שהוכפלו ב-FontSize / UnitsPerEm
  • מצבי PaintComposite 13 עד 27 ממופים אל מצבי ה-blend הנפרדים ושאינם נפרדים של PDF כמו /Multiply, /Screen ו-/Luminosity (§11.3.5), מוגדרים דרך רשומת /BM של ExtGState

הגבול מפורש. מצבי Porter-Duff 5 עד 12 (src_in, xor, plus והשאר) אין להם מקבילת blend mode ב-PDF, מצבי הרחבה repeat ו-reflect על גרדיאנטים ליניאריים ורדיאליים אינם משוגרים, וגרדיאנטים שהעצירות שלהם נושאות ערכי alpha שונים אינם מזויפים עם opacity יחיד. גרדיאנטים רדיאליים עם יותר משתי עצירות שומרים רק את הצבעים הראשון והאחרון שלהם. HotPDF בודק את הגרף כולו מול תת-הקבוצה הנתמכת הזאת לפני כתיבת אופרטור אחד, כך ש-glyph לא נתמך משאיר את העמוד נקי ועובר הלאה אל fallback ה-raster במקום להשאיר מאחור חצי ציור

דיאגרמת המרת COLR v1 של HotPDF: גרף הצביעה מפוענח לגרף חסום שתקרתו 4096 צמתים, 64 רמות עומק ו-1024 עצירות צבע עם דחיית מעגלים, ואז PaintGlyph הופך ל-clip של מצב 7, גרדיאנטים ליניאריים ורדיאליים הופכים ל-shadings אקסיאליים ורדיאליים, גרדיאנטי sweep הופכים ל-96 טריזים, ומצבי PaintComposite 13 עד 27 הופכים למצבי blend של PDF
הגרף כולו נבדק מול תת-הקבוצה הנתמכת לפני כתיבת האופרטור הראשון, כך ש-glyph לא נתמך משאיר את העמוד נקי ועובר הלאה אל fallback ה-raster במקום להשאיר חצי ציור

glyph-ים של SVG ו-strikes של bitmap

glyph-ים של SVG עוברים דרך אותו builder חסום ש-HotPDF משתמש בו עבור קבצי SVG מיובאים, והתוצאה נרשמת כ-Form XObject (§8.10), בדיוק כפי שמתואר בהמאמר על SVG אל Form XObject. המסמך בטבלת SVG עשוי להיות דחוס-gzip; הפריקה רצה בחתיכות של 8 KB ועוצרת ברגע שהגודל המורחב יעבור 32 MB, במקום לנפח קודם ולבדוק אחר כך, והקלט הדחוס עצמו מוגבל ל-8 MB. הפרופיל מגביל בכוונה: סקריפטים, תמונות מוטמעות, URL-ים חיצוניים, URI-ים של data: והפניות לא מקומיות נכשלים בסגירה. הטופס מוקטן כך שצידו הארוך שווה לגודל הפונט ומעוגן על קו הבסיס, מה שממפה את מערכת הקואורדינטות של SVG y-למטה אל זו של PDF y-למעלה. שימו לב שה-builder מקבל את מסמך ה-SVG כולו עבור ה-glyph, בלי בחירה של אלמנט ה-glyphNNN, ולכן פונטים שדוחסים glyph-ים רבים למסמך משותף אחד שווים בדיקה לפני שסומכים עליהם

פונטי bitmap הם שאלה של בחירת strike ומיקום. עבור CBDT, HotPDF בוחר את הגודל של CBLC שה-ppem האנכי שלו הקרוב ביותר אל TargetPixelsPerEm, מקבל פורמטי תמונה 17, 18 ו-19, וקורא מדדי פורמט 19 מתת-הטבלה של אינדקס ה-CBLC כי הפורמט הזה לא מאחסן אף אחד משלו. עבור sbix, היסטי strike הם יחסיים לטבלה והיסטי glyph יחסיים ל-strike, ורשומת dupe עושה שימוש חוזר בגרפיקה של glyph אחר תוך שמירה על היסטי המקור של עצמה; לתת לרקורסיה לדרוך על המקור החיצוני מזיז את התמונה. מטעני PNG ו-JPEG מפוענחים בפנים, מוקטנים לפי FontSize / PixelsPerEmY במקום להימתח אל גודל הפונט, ונכתבים עם soft mask (§11.6.5.3) בכל פעם שפיקסל כלשהו אינו אטום לגמרי. מטעני TIFF של sbix אינם מפוענחים והולכים לאירוע

מה קורה כשglyph לא ניתן לציור טבעי?

HotPDF מעלה את OnColorGlyphRasterize וממקם את bitmap ה-RGBA שה-handler שלכם מחזיר; אם שום דבר לא מוקצה, או שה-handler משאיר את Handled כ-false, ה-DrawRegisteredColorGlyph מחזיר False והעמוד נשאר ללא שינוי. האירוע נורה עבור גרף COLR v1 מחוץ לתת-הקבוצה הנתמכת, מסמך SVG שה-builder הבטוח סירב לו, ומטען bitmap שהמפענחים הפנימיים לא קוראים. ה-handler מקבל את הפורמט, את בתי הפונט הגולמיים, את ה-asset שחולץ (מסמך ה-SVG, אולי עדיין דחוס-gzip, או בתי ה-bitmap; ריק עבור COLR v1), את מזהה ה-glyph, את ה-palette ואת גודל הפיקסל היעד

דיאגרמת fallback raster של HotPDF: OnColorGlyphRasterize נורה עבור גרף COLR v1 מחוץ לתת-הקבוצה הנתמכת, מסמך SVG שה-builder הבטוח סירב לו או מטען bitmap שהמפענחים לא קוראים, מעביר את הפורמט, בתי הפונט, ה-asset, ה-GlyphID, ה-PaletteIndex וה-PixelSize, וחוצץ ה-RGBA שהוחזר מתקבל רק כשאורכו בדיוק Width כפול Height כפול 4
גדלים אפסיים, אורך חוצץ שגוי או מימדים שגולשים נדחים לפני שהעמוד נוגע, ועם אין handler או Handled false הקריאה מחזירה False והעמוד נשאר ללא שינוי
type
  TEmojiFallback = class
  public
    procedure Rasterize(Sender: TObject;
      Format: THPDFOpenTypeColorFormat; const FontBytes: TBytes;
      const AssetData: TBytes; GlyphID: Word;
      PaletteIndex, PixelSize: Integer;
      out Width, Height: Integer; out RGBA: TBytes;
      out Handled: Boolean);
  end;

procedure TEmojiFallback.Rasterize(Sender: TObject;
  Format: THPDFOpenTypeColorFormat; const FontBytes: TBytes;
  const AssetData: TBytes; GlyphID: Word;
  PaletteIndex, PixelSize: Integer;
  out Width, Height: Integer; out RGBA: TBytes;
  out Handled: Boolean);
begin
  Width := 0;
  Height := 0;
  RGBA := nil;
  // RenderWithOwnEngine הוא ה-rasterizer שלכם, לא API של HotPDF.
  // עליו להחזיר בדיוק Width * Height * 4 בתים של RGBA.
  Handled := RenderWithOwnEngine(Format, FontBytes, AssetData,
    GlyphID, PaletteIndex, PixelSize, Width, Height, RGBA);
end;

// חיבור
Pdf.OnColorGlyphRasterize := Fallback.Rasterize;

HotPDF מאמתת את פלט ה-handler לפני שנוגעים בעמוד: גדלים אפסיים, חוצץ שאורכו אינו בדיוק Width * Height * 4, או מימדים גדולים מכדי לגלוש נדחים והקריאה מחזירה False. fallback של raster עדיין raster, ולכן אימוג'י שרונדר כך מאבד את חדות הווקטור שלו; בקשו PixelSize שתואם את רזולוציית הפלט שלכם. שלבו את נתיב הצבע עם בדיקות כיסוי בזמן ציור מהמאמר על מעקב אחר glyph-ים חסרים ו-pipeline שמטפל בטקסט משתמש שרירותי יכול לדווח גם על glyph-ים חסרים וגם על glyph-ים שאיבדו את הצבע שלהם

מרנדר ה-glyph-ים הצבעוניים, מחסנית ה-shaping של OpenType וה-builder הבטוח של SVG מגיעים כולם ברכיב HotPDF ל-Delphi, זמין עבור Delphi ו-C++Builder