Skip to content

Add COLR/CPAL and CBDT/CBLC color emoji support#1692

Open
jspears wants to merge 23 commits intofoliojs:masterfrom
jspears:support-color-emoji-google
Open

Add COLR/CPAL and CBDT/CBLC color emoji support#1692
jspears wants to merge 23 commits intofoliojs:masterfrom
jspears:support-color-emoji-google

Conversation

@jspears
Copy link

@jspears jspears commented Feb 24, 2026

Summary

Adds support for two additional color emoji font formats, building on the SBIX support in #1690.

Depends on #1690 — This PR is stacked on the SBIX emoji rendering PR. Once #1690 is merged, the diff here will shrink to only the COLR/CPAL and CBDT/CBLC changes.

COLR/CPAL (Vector Emoji)

  • _renderCOLREmoji() — Renders COLR glyphs by iterating color layers, setting fill colors from the CPAL palette, converting path commands (including quadratic→cubic bezier conversion) to PDF operators.
  • Tested with Twemoji.Mozilla.ttf.

CBDT/CBLC (Bitmap Emoji — Google Noto Color Emoji)

  • _extractCBDTBitmap() — Navigates the CBLC strike index to find the best bitmap size, looks up glyph offsets via IndexSubtable (versions 1-5), and reads raw PNG data from CBDT (formats 17, 18, 19).
  • _calcGlyphOffsetInCBDT() — Computes glyph offsets from fontkit's parsed subtable data.
  • _manualCBLCLookup() — Fallback that manually parses raw CBLC binary data when fontkit's parser doesn't cover the target glyph.
  • _emojiFragmentFallback() — Graceful degradation for fonts where fontkit's layout() fails (e.g., NotoColorEmoji). Uses _cmapProcessor.lookup() per codepoint and extracts CBDT bitmaps directly. Should work with PR fontkit pr 366
  • Tested with NotoColorEmoji.ttf.

Routing

  • _emojiFragment() updated to route based on glyph.type: COLR → vector rendering, else → bitmap (SBIX or CBDT fallback).
  • Try-catch wrapper with fallback to _emojiFragmentFallback() for fonts where layout() crashes.

Bug Fixes

  • Fill color reset after COLR rendering — pdfjs-dist 2.16.105 doesn't fully restore color space after Q (graphics state pop). Explicit fill color reset ensures subsequent text renders correctly.
  • NotoColorEmoji layout crash — fontkit's getGlyph() returns null for CBDT-only fonts. Added try-catch in both _emojiWidthOfString and _emojiFragment with fallback paths.

Tests

  • 34 mock-based unit tests covering _calcGlyphOffsetInCBDT, _extractCBDTBitmap, _manualCBLCLookup, _getEmojiImage, _emojiFragment, and _renderCOLREmoji.
  • Real-font integration tests using Twemoji.Mozilla.ttf (COLR/CPAL) and NotoColorEmoji.ttf (CBDT/CBLC).
  • Visual snapshot tests for COLR rendering (Twemoji), CBDT rendering (NotoColorEmoji), and SBIX rendering (Apple Color Emoji, conditionally on macOS).
  • All 407 tests pass (54 suites, 87 snapshots).

Usage

import PDFDocument from 'pdfkit';

// COLR/CPAL font (e.g., Twemoji)
const doc = new PDFDocument({
  emojiFont: 'path/to/Twemoji.Mozilla.ttf',
  emojiFontFamily: 'TwemojiMozilla',
});

// CBDT/CBLC font (e.g., Noto Color Emoji)
const doc2 = new PDFDocument({
  emojiFont: 'path/to/NotoColorEmoji.ttf',
  emojiFontFamily: 'NotoColorEmoji',
});

doc.font('Helvetica').fontSize(18)
   .text('Hello 😀 World 🎉 from PDFKit');

Limitations

  • ZWJ sequences in CBDT-only fonts (NotoColorEmoji) render as individual component emoji rather than composed glyphs, since fontkit's GSUB shaping doesn't work for these fonts.
  • COLR v1 (gradient layers) is not yet supported — only COLR v0 (solid color layers).

Pull Request opened by Augment Code with guidance from the PR author

jspears and others added 23 commits February 22, 2026 08:23
Co-authored-by: Luiz Américo <camara_luiz@yahoo.com.br>
Agent-Id: agent-c3182b15-f411-4263-9a49-36ee6676ea0a
Agent-Id: agent-b0c62276-e9f4-41e5-abae-9371fa38a6ed
Agent-Id: agent-85aa884b-a6ba-49b0-b980-3b0990124217
… on fontkit 2.x)

Agent-Id: agent-07ae24ff-5c31-44bc-bc98-7d99f3de7a6b
Some glyphs are marked as type 'COLR' by fontkit but have null layers
(e.g. certain emoji sequences where the COLR table doesn't contain an
entry). This caused TypeError: Cannot read properties of null (reading
'length') when iterating glyph.layers.

- Add glyph.layers check at call site so null-layer glyphs fall through
  to the image/bitmap rendering path
- Add defensive null guard inside _renderCOLREmoji itself
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant