[PATCH] avfilter/vf_drawtext: support color bitmap (CBDT) emoji fonts

George via ffmpeg-devel <[email protected]> Tue, 21 Jul 2026 02:11:43 -0400
Newsgroups gmane.comp.video.ffmpeg.devel
Message-ID <CALahHapB6Ha7keq+GXkzy3v3Q2HZAa2S=PfivmziMoRs-vrZGg@mail.gmail.com>
Hi,

drawtext currently cannot render color-bitmap emoji fonts such as Noto
Color Emoji: glyphs are loaded without FT_LOAD_COLOR, so CBDT/CBLC faces
present their glyphs as monochrome bitmaps and rendering fails with
"Monocromatic (1bpp) fonts are not supported".

The attached patch (git format-patch against master c231236) loads
glyphs with FT_LOAD_COLOR when the face has color (falling back to the
regular glyph when the color glyph cannot be rasterized, e.g. OT-SVG),
and blends the premultiplied-BGRA glyphs through a new drawutils
function, ff_blend_color_bitmap(): per destination sample - including
each chroma sample at its subsampled resolution - it accumulates
coverage-weighted contributions of all source pixels mapping to it and
composites in a single blend, so subsampled YUV output is exact and
order-independent; frame-edge truncated blocks are weighted by their
real pixel count. Straight-alpha targets get exact source-over with the
destination alpha accumulated per full-resolution position (correct even
when coverage and destination alpha vary together inside a chroma
block); premultiplied and alphaless targets blend directly. Shadows are
drawn from the glyph alpha channel, the stroker border pass skips color
glyphs, and bitmap-only faces select the fixed strike nearest the
requested fontsize.

Verification highlights (worst-case absolute differences, 8-bit code
values): yuv420p at an odd origin vs a reference from the rgba render
(BT.601 + 2x2 chroma box filter): Y max 1.04, U max 0.93, V max 0.93,
no sample off by more than 2; odd-sized (641x361) frame: Y max 0.78,
U max 0.88, V max 0.87. Straight-alpha rgba over transparent:
destination alpha exact, color max 1.00 above 0.45 coverage; over a
50%-alpha background: alpha max 0.36, color max 1.38. A yuva420p frame
whose alpha checkerboards inside every chroma block vs the per-pixel
source-over reference: Y max 0.86, A max 0.48, U/V max 3.2, with 1 of
4625 glyph-covered blocks above 3 per plane. A premultiplied-tagged
frame receives exactly the premultiplied composite over transparent
and is byte-identical to the straight-tagged render over opaque.
framemd5 of DejaVuSans (border+shadow) and of the COLR faces Nabla and
Noto-COLRv1 are bit-identical before/after; the full test set is clean
under an ASan+UBSan build. Requires FreeType >= 2.5.1; the color path
compiles out on older builds.

Prior art: Yogeshwar Velingker's March 2025 patch addressed monochrome
bitmaps but not color glyphs and was not merged; this patch is
self-contained.

Notes for review: bitmap strikes are not scaled (nearest strike +
warning); no stroker border on color glyphs; the new blend uses plain
rounding division for clarity and can be converted to the fixed-point
style of the neighbouring code if preferred. The patch is attached
rather than inlined; happy to resend inline via git send-email if that
is preferred.

Thanks,
George

_______________________________________________
ffmpeg-devel mailing list -- [email protected]
To unsubscribe send an email to [email protected]
0001-avfilter-vf_drawtext-support-color-bitmap-CBDT-emoji.patch (application/octet-stream, 27.7 KB)
From d623015fe01ce844045090f6536d5a3d7b0eda56 Mon Sep 17 00:00:00 2001
From: George Teifel <[email protected]>
Date: Mon, 20 Jul 2026 17:32:52 +0000
Subject: [PATCH] avfilter/vf_drawtext: support color bitmap (CBDT) emoji fonts

drawtext could not render color-bitmap fonts such as Noto Color Emoji:
glyphs were loaded without FT_LOAD_COLOR, so CBDT/CBLC faces presented
their glyphs as monochrome bitmaps and rendering failed with
"Monocromatic (1bpp) fonts are not supported".

- load glyphs with FT_LOAD_COLOR when the face has color (FT_HAS_COLOR);
  FreeType then returns premultiplied BGRA bitmaps for color glyphs.
  For outline glyphs of color-capable faces the flag is a no-op and
  rendering is unchanged; when the color glyph cannot be rasterized
  here (unimplemented color format, or a glyph format other than
  outline and bitmap - e.g. OT-SVG glyphs, or COLRv1 paints depending
  on the FreeType build), the glyph is loaded without FT_LOAD_COLOR,
  preserving the previous rendering, while other load errors are
  reported as real errors. The color path compiles out on FreeType
  builds without FT_LOAD_COLOR/FT_HAS_COLOR
- blend FT_PIXEL_MODE_BGRA glyph bitmaps with per-pixel colors through
  a new drawutils function, ff_blend_color_bitmap(): each glyph pixel
  is un-premultiplied and its straight color converted once to the
  target pixel format with ff_draw_color() (cached per glyph); at blend
  time, all source pixels mapping to the same destination sample -
  including each subsampled chroma sample - are accumulated with
  coverage weighting and composited in a single blend per destination
  sample, so the result is order-independent. The per-pixel alpha is
  applied exactly once, as the coverage weight; the destination alpha
  component is blended towards opacity by coverage. On straight-alpha
  targets the color components are composited with full source-over,
  accumulating the destination contribution per full-resolution
  position ((255 - a_i) ad_i, with the never-subsampled alpha plane
  read per pixel) and renormalizing by the output alpha, so straight
  color values stay straight even where the source coverage and the
  destination alpha vary together inside a subsampling block; on
  premultiplied and alphaless targets the color components are blended
  directly, which the coverage weighting premultiplies as needed.
  Subsampling blocks truncated by the frame edges are weighted by
  their real in-frame pixel count. The fontcolor option does not apply
  to color glyphs, which carry their own colors
- draw shadows of color glyphs by using the glyph alpha channel as the
  blend mask for shadowcolor; the border pass skips color glyphs since
  FT_Glyph_StrokeBorder() only works on outlines (previously borderw
  with a bitmap glyph made load_glyph fail outright)
- bitmap-only faces cannot be scaled: select the fixed strike whose
  y_ppem is nearest to the requested fontsize with FT_Select_Size()
  and warn when it differs; glyphs render at the strike native size
- do not error out on empty monochrome bitmaps (e.g. the .notdef glyph
  of an emoji-only font); non-empty 1bpp glyphs are still rejected

Verified against computed references (Noto Color Emoji, 109px CBDT
strike; figures are worst-case absolute differences in 8-bit code
values): yuv420p output at an odd origin vs a reference derived from
the rgba render (per-pixel BT.601 conversion + 2x2 chroma box filter):
Y max 1.04, U max 0.93, V max 0.93, no sample off by more than 2; on
an odd-sized (641x361) frame whose terminal chroma column/row is
backed by a single luma position: Y max 0.78, U max 0.88, V max 0.87.
Straight-alpha rgba over a transparent background: destination alpha
exact (max 0.00), color max 1.00 at coverage above 0.45; over a
50%-alpha background: alpha max 0.36, color max 1.38. yuva420p with
the destination alpha checkerboarding 255/64 inside every chroma
block, vs the per-pixel source-over reference: Y max 0.86, A max
0.48, U max 3.20 and V max 3.18 with 1 of 4625 glyph-covered chroma
blocks above 3 per plane (reference-recovery quantization). A
premultiplied-tagged rgba frame (setparams=alpha_mode=premultiplied)
receives exactly the premultiplied composite over a transparent
background (max 0.00) and is byte-identical to the straight-tagged
render over an opaque background.
Rendering of outline fonts is unchanged: framemd5 of a
DejaVuSans render with border and shadow, and of renders with the
color-capable COLR faces Nabla and Noto-COLRv1, are bit-identical
before/after this change on yuv420p and rgba. Also tested: partially
off-frame placements, per-frame varying fontsize, and the full set
under an ASan/UBSan build.

Signed-off-by: George Teifel <[email protected]>
---
 doc/filters.texi          |   8 ++
 libavfilter/drawutils.c   | 124 ++++++++++++++++++++++++
 libavfilter/drawutils.h   |  42 ++++++++
 libavfilter/vf_drawtext.c | 195 +++++++++++++++++++++++++++++++++++---
 4 files changed, 358 insertions(+), 11 deletions(-)

diff --git a/doc/filters.texi b/doc/filters.texi
index 002979b..e5c88ee 100644
--- a/doc/filters.texi
+++ b/doc/filters.texi
@@ -12638,6 +12638,14 @@ configure FFmpeg with @code{--enable-libfontconfig}.
 To enable the @var{text_shaping} option, you need to configure FFmpeg with
 @code{--enable-libfribidi}.
 
+Fonts with color bitmap glyphs (for example CBDT/CBLC based color emoji
+fonts such as Noto Color Emoji) are supported: such glyphs are drawn with
+their intrinsic colors; the @var{fontcolor} option does not apply to them
+and @var{borderw} does not draw a border around them (though it still
+takes part in the text layout). Bitmap fonts cannot be scaled: the fixed strike
+nearest to the requested @var{fontsize} is selected and the glyphs are
+drawn at the strike's native size.
+
 @subsection Syntax
 
 It accepts the following parameters:
diff --git a/libavfilter/drawutils.c b/libavfilter/drawutils.c
index 75bf8f4..10d9a1a 100644
--- a/libavfilter/drawutils.c
+++ b/libavfilter/drawutils.c
@@ -655,6 +655,130 @@ void ff_blend_mask(FFDrawContext *draw, FFDrawColor *color,
     }
 }
 
+void ff_blend_color_bitmap(FFDrawContext *draw,
+                           uint8_t *dst[], int dst_linesize[],
+                           int dst_w, int dst_h,
+                           const uint16_t *comps[4], const uint8_t *alpha,
+                           int src_linesize, int src_w, int src_h,
+                           unsigned global_alpha, int x0, int y0)
+{
+    const AVPixFmtDescriptor *desc = draw->desc;
+    unsigned nb_comp = desc->nb_components -
+        !!(desc->flags & AV_PIX_FMT_FLAG_ALPHA &&
+           !(draw->flags & FF_DRAW_PROCESS_ALPHA));
+    /* Straight-alpha targets need full source-over compositing: the
+     * destination color is weighted by the destination alpha and the
+     * result renormalized by the output alpha. Premultiplied and
+     * alphaless targets blend the color components directly. */
+    int straight = desc->flags & AV_PIX_FMT_FLAG_ALPHA &&
+                   draw->alpha != AVALPHA_MODE_PREMULTIPLIED;
+    const AVComponentDescriptor *ac = &desc->comp[desc->nb_components - 1];
+    unsigned amax = (1u << (ac->depth + ac->shift)) - 1;
+    int xs0 = 0, ys0 = 0;
+
+    clip_interval(dst_w, &x0, &src_w, &xs0);
+    clip_interval(dst_h, &y0, &src_h, &ys0);
+    if (src_w <= 0 || src_h <= 0 || !global_alpha)
+        return;
+
+    for (unsigned comp = 0; comp < nb_comp; comp++) {
+        const AVComponentDescriptor *c = &desc->comp[comp];
+        /* The alpha component is last, so color components read the
+         * destination alpha before this call updates it. */
+        int sover = straight && comp != desc->nb_components - 1;
+        int plane = c->plane;
+        unsigned hsub = draw->hsub[plane], vsub = draw->vsub[plane];
+        int xd0 = x0 >> hsub, xd1 = (x0 + src_w - 1) >> hsub;
+        int yd0 = y0 >> vsub, yd1 = (y0 + src_h - 1) >> vsub;
+
+        for (int yd = yd0; yd <= yd1; yd++) {
+            int sy0 = FFMAX(yd << vsub, y0) - y0;
+            int sy1 = FFMIN((yd + 1) << vsub, y0 + src_h) - y0;
+            /* Subsampling blocks truncated by the image edges are backed
+             * by fewer pixels and weighted accordingly. */
+            int bh = FFMIN((yd + 1) << vsub, dst_h) - (yd << vsub);
+            uint8_t *p = pointer_at(draw, dst, dst_linesize, plane,
+                                    xd0 << hsub, yd << vsub) + c->offset;
+
+            for (int xd = xd0; xd <= xd1; xd++) {
+                int sx0 = FFMAX(xd << hsub, x0) - x0;
+                int sx1 = FFMIN((xd + 1) << hsub, x0 + src_w) - x0;
+                int bw = FFMIN((xd + 1) << hsub, dst_w) - (xd << hsub);
+                /* weight sum of a fully covered, fully opaque block */
+                unsigned denom = 255u * bw * bh;
+                uint32_t sum_a = 0, sum_c = 0;
+
+                for (int sy = sy0; sy < sy1; sy++) {
+                    const uint8_t  *arow = alpha +
+                        (ys0 + sy) * src_linesize + xs0;
+                    const uint16_t *crow = comps[comp] +
+                        (ys0 + sy) * src_linesize + xs0;
+
+                    for (int sx = sx0; sx < sx1; sx++) {
+                        unsigned a = arow[sx];
+
+                        if (global_alpha != 255)
+                            a = (a * global_alpha + 127) / 255;
+                        sum_a += a;
+                        sum_c += a * crow[sx];
+                    }
+                }
+                if (sum_a) {
+                    unsigned vd = c->depth <= 8 ? *p : AV_RL16(p);
+                    uint64_t out;
+
+                    if (sover) {
+                        /* Per full-res position i of the block, the blend
+                         * retains (255 - a_i) ad_i of the destination
+                         * (a_i = 0 where the source does not cover); the
+                         * alpha plane is never subsampled.
+                         * out = sum(a_i cs_i + (255 - a_i) ad_i cd) /
+                         *       sum(a_i amax + (255 - a_i) ad_i);
+                         * sum_a > 0 keeps the divisor positive. */
+                        uint64_t sum_r = 0;
+
+                        for (int yb = 0; yb < bh; yb++) {
+                            int sy = (yd << vsub) + yb - y0;
+                            const uint8_t *ap = pointer_at(draw, dst,
+                                dst_linesize, ac->plane, xd << hsub,
+                                (yd << vsub) + yb) + ac->offset;
+
+                            for (int xb = 0; xb < bw; xb++) {
+                                int sx = (xd << hsub) + xb - x0;
+                                unsigned a = 0;
+
+                                if (sx >= 0 && sx < src_w &&
+                                    sy >= 0 && sy < src_h) {
+                                    a = alpha[(ys0 + sy) * src_linesize +
+                                              xs0 + sx];
+                                    if (global_alpha != 255)
+                                        a = (a * global_alpha + 127) / 255;
+                                }
+                                sum_r += (255 - a) *
+                                    (uint64_t)(ac->depth <= 8 ?
+                                               ap[0] : AV_RL16(ap));
+                                ap += draw->pixelstep[ac->plane];
+                            }
+                        }
+                        uint64_t num = (uint64_t)sum_c * amax + sum_r * vd;
+                        uint64_t den = (uint64_t)sum_a * amax + sum_r;
+
+                        out = (num + den / 2) / den;
+                    } else {
+                        out = ((uint64_t)vd * (denom - sum_a) + sum_c +
+                               denom / 2) / denom;
+                    }
+                    if (c->depth <= 8)
+                        *p = out;
+                    else
+                        AV_WL16(p, out);
+                }
+                p += draw->pixelstep[plane];
+            }
+        }
+    }
+}
+
 int ff_draw_round_to_sub(FFDrawContext *draw, int sub_dir, int round_dir,
                          int value)
 {
diff --git a/libavfilter/drawutils.h b/libavfilter/drawutils.h
index a7fc967..63de58c 100644
--- a/libavfilter/drawutils.h
+++ b/libavfilter/drawutils.h
@@ -153,6 +153,48 @@ void ff_blend_mask(FFDrawContext *draw, FFDrawColor *color,
                    const uint8_t *mask, int mask_linesize, int mask_w, int mask_h,
                    int l2depth, unsigned endianness, int x0, int y0);
 
+/**
+ * Blend a bitmap with per-pixel colors and per-pixel alpha.
+ *
+ * On subsampled targets, all the source pixels mapping to the same
+ * destination sample are accumulated with coverage weighting and blended
+ * in a single operation per destination sample, so the result does not
+ * depend on any per-pixel compositing order. Subsampling blocks truncated
+ * by the image edges are weighted by their real in-image pixel count.
+ *
+ * The per-pixel alpha is applied exactly once, as the coverage weight of
+ * the blend; the source component values must therefore be straight
+ * (non-premultiplied) colors converted with an opaque alpha, which also
+ * makes the destination alpha component blend towards opacity by
+ * coverage. On straight-alpha targets the color components are
+ * composited with full source-over (the destination color weighted by
+ * the destination alpha, renormalized by the output alpha); premultiplied
+ * and alphaless targets blend the color components directly.
+ *
+ * @param draw           draw context
+ * @param dst            destination image
+ * @param dst_linesize   line stride of the destination
+ * @param dst_w          width of the destination image
+ * @param dst_h          height of the destination image
+ * @param comps          source component values, as produced by
+ *                       ff_draw_color() from straight colors with alpha
+ *                       255, stored component-major:
+ *                       comps[comp][y * src_linesize + x]
+ * @param alpha          per-pixel blend weights (0-255)
+ * @param src_linesize   line stride of comps and alpha, in pixels
+ * @param src_w          width of the source rectangle
+ * @param src_h          height of the source rectangle
+ * @param global_alpha   extra alpha multiplier (0-255, 255 for opaque)
+ * @param x0             horizontal position of the overlay
+ * @param y0             vertical position of the overlay
+ */
+void ff_blend_color_bitmap(FFDrawContext *draw,
+                           uint8_t *dst[], int dst_linesize[],
+                           int dst_w, int dst_h,
+                           const uint16_t *comps[4], const uint8_t *alpha,
+                           int src_linesize, int src_w, int src_h,
+                           unsigned global_alpha, int x0, int y0);
+
 /**
  * Round a dimension according to subsampling.
  *
diff --git a/libavfilter/vf_drawtext.c b/libavfilter/vf_drawtext.c
index 2ccb036..bd555b1 100644
--- a/libavfilter/vf_drawtext.c
+++ b/libavfilter/vf_drawtext.c
@@ -52,6 +52,7 @@
 #include "libavutil/opt.h"
 #include "libavutil/random_seed.h"
 #include "libavutil/parseutils.h"
+#include "libavutil/pixdesc.h"
 #include "libavutil/time.h"
 #include "libavutil/timecode.h"
 #include "libavutil/time_internal.h"
@@ -207,6 +208,13 @@ typedef struct Glyph {
     /** Outlined glyph bitmaps with 1/4 pixel precision in both directions */
     FT_BitmapGlyph border_bglyph[16];
     FT_BBox bbox;
+    /** Straight-alpha component values of a color (BGRA) glyph converted
+     *  to the target pixel format with ff_draw_color(), component-major;
+     *  lazily built on the first draw */
+    uint16_t *comp_cache;
+    /** Alpha channel of a color (BGRA) glyph; blend weights for the glyph
+     *  itself and blend mask for its shadow */
+    uint8_t *alpha_cache;
 } Glyph;
 
 /** Global text metrics */
@@ -435,7 +443,28 @@ static av_cold int set_fontsize(AVFilterContext *ctx, unsigned int fontsize)
     int err;
     DrawTextContext *s = ctx->priv;
 
-    if ((err = FT_Set_Pixel_Sizes(s->face, 0, fontsize))) {
+    if (!FT_IS_SCALABLE(s->face) && s->face->num_fixed_sizes > 0) {
+        /* Bitmap fonts (e.g. CBDT/CBLC color emoji fonts) cannot be scaled;
+         * use the fixed strike closest to the requested size. */
+        int64_t req64 = (int64_t)fontsize << 6;
+        int best = 0;
+
+        for (int i = 1; i < s->face->num_fixed_sizes; i++) {
+            if (FFABS((int64_t)s->face->available_sizes[i].y_ppem - req64) <
+                FFABS((int64_t)s->face->available_sizes[best].y_ppem - req64))
+                best = i;
+        }
+        err = FT_Select_Size(s->face, best);
+        if (!err && s->face->available_sizes[best].y_ppem != req64)
+            av_log(ctx, AV_LOG_WARNING,
+                   "Font does not scale, using the nearest fixed strike "
+                   "(%d pixels) for the requested size %u\n",
+                   (int)((s->face->available_sizes[best].y_ppem + 32) >> 6),
+                   fontsize);
+    } else {
+        err = FT_Set_Pixel_Sizes(s->face, 0, fontsize);
+    }
+    if (err) {
         av_log(ctx, AV_LOG_ERROR, "Could not set font size to %d pixels: %s\n",
                fontsize, FT_ERRMSG(err));
         return AVERROR(EINVAL);
@@ -743,8 +772,40 @@ static int load_glyph(AVFilterContext *ctx, Glyph **glyph_ptr, uint32_t code, in
     glyph = av_tree_find(s->glyphs, &dummy, glyph_cmp, NULL);
     cached = !!glyph;
     if (!glyph) {
-        if (FT_Load_Glyph(s->face, code, s->ft_load_flags)) {
-            return AVERROR(EINVAL);
+        int loaded = 0;
+
+#if defined(FT_LOAD_COLOR) && defined(FT_HAS_COLOR)
+        /* Load the color version of a glyph when the face has one (color
+         * fonts expose them as BGRA bitmaps, e.g. CBDT/CBLC emoji fonts).
+         * Fall back on the regular glyph when the color glyph cannot be
+         * rasterized here: an unimplemented color format, or a loadable
+         * glyph format other than outline and bitmap (e.g. OT-SVG glyphs,
+         * or COLRv1 paints depending on the FreeType build). Any other
+         * load error is a real error and is reported as such. */
+        if (FT_HAS_COLOR(s->face)) {
+            FT_Error ft_err = FT_Load_Glyph(s->face, code,
+                                            s->ft_load_flags | FT_LOAD_COLOR);
+
+            if (!ft_err) {
+                loaded = s->face->glyph->format == FT_GLYPH_FORMAT_OUTLINE ||
+                         s->face->glyph->format == FT_GLYPH_FORMAT_BITMAP;
+            } else if (ft_err != FT_Err_Unimplemented_Feature) {
+                av_log(ctx, AV_LOG_ERROR,
+                       "Could not load glyph %u: %s\n", code,
+                       FT_ERRMSG(ft_err));
+                return AVERROR(EINVAL);
+            }
+        }
+#endif
+        if (!loaded) {
+            FT_Error ft_err = FT_Load_Glyph(s->face, code, s->ft_load_flags);
+
+            if (ft_err) {
+                av_log(ctx, AV_LOG_ERROR,
+                       "Could not load glyph %u: %s\n", code,
+                       FT_ERRMSG(ft_err));
+                return AVERROR(EINVAL);
+            }
         }
         glyph = av_mallocz(sizeof(*glyph));
         if (!glyph) {
@@ -757,7 +818,9 @@ static int load_glyph(AVFilterContext *ctx, Glyph **glyph_ptr, uint32_t code, in
             ret = AVERROR(EINVAL);
             goto error;
         }
-        if (s->borderw) {
+        /* A stroked border can only be derived from outline glyphs; bitmap
+         * glyphs (color emojis) are drawn without a border. */
+        if (s->borderw && glyph->glyph->format == FT_GLYPH_FORMAT_OUTLINE) {
             FT_Glyph tmp = glyph->glyph;
             if (FT_Glyph_StrokeBorder(&tmp, s->stroker, 0, 0)) {
                 ret = AVERROR_EXTERNAL;
@@ -776,7 +839,8 @@ static int load_glyph(AVFilterContext *ctx, Glyph **glyph_ptr, uint32_t code, in
         av_tree_insert(&s->glyphs, glyph, glyph_cmp, &node);
         cached = 1;
     } else {
-        if (s->borderw && !glyph->border_glyph) {
+        if (s->borderw && !glyph->border_glyph &&
+            glyph->glyph->format == FT_GLYPH_FORMAT_OUTLINE) {
             FT_Glyph tmp = glyph->glyph;
             if (FT_Glyph_StrokeBorder(&tmp, s->stroker, 0, 0)) {
                 ret = AVERROR_EXTERNAL;
@@ -801,12 +865,14 @@ static int load_glyph(AVFilterContext *ctx, Glyph **glyph_ptr, uint32_t code, in
             }
             glyph->bglyph[idx] = (FT_BitmapGlyph)tmp_glyph;
         }
-        if (glyph->bglyph[idx]->bitmap.pixel_mode == FT_PIXEL_MODE_MONO) {
+        if (glyph->bglyph[idx]->bitmap.pixel_mode == FT_PIXEL_MODE_MONO &&
+            glyph->bglyph[idx]->bitmap.width &&
+            glyph->bglyph[idx]->bitmap.rows) {
             av_log(ctx, AV_LOG_ERROR, "Monocromatic (1bpp) fonts are not supported.\n");
             ret = AVERROR(EINVAL);
             goto error;
         }
-        if (s->borderw && !glyph->border_bglyph[idx]) {
+        if (s->borderw && glyph->border_glyph && !glyph->border_bglyph[idx]) {
             FT_Glyph tmp_glyph = glyph->border_glyph;
             if (FT_Glyph_To_Bitmap(&tmp_glyph, FT_RENDER_MODE_NORMAL, &shift, 0)) {
                 ret = AVERROR_EXTERNAL;
@@ -1119,6 +1185,8 @@ static int glyph_enu_free(void *opaque, void *elem)
     if (glyph->border_glyph && glyph->border_glyph != glyph->glyph)
         FT_Done_Glyph(glyph->border_glyph);
     FT_Done_Glyph(glyph->glyph);
+    av_freep(&glyph->comp_cache);
+    av_freep(&glyph->alpha_cache);
     av_free(elem);
     return 0;
 }
@@ -1278,10 +1346,73 @@ static void update_alpha(DrawTextContext *s)
         s->alpha = 256 * alpha;
 }
 
+#if defined(FT_LOAD_COLOR) && defined(FT_HAS_COLOR)
+/* Builds the drawing caches of a color (BGRA) glyph: the glyph's pixel
+ * colors converted to the target pixel format, and its alpha channel */
+static int build_color_glyph_caches(DrawTextContext *s, Glyph *glyph,
+                                    const FT_Bitmap *bitmap)
+{
+    const int w = bitmap->width, h = bitmap->rows;
+    const AVPixFmtDescriptor *desc = s->dc.desc;
+    size_t nb_px, comp_size;
+
+    if (glyph->comp_cache)
+        return 0;
+
+    if (av_size_mult(w, h, &nb_px) < 0 ||
+        av_size_mult(nb_px, desc->nb_components * sizeof(*glyph->comp_cache),
+                     &comp_size) < 0)
+        return AVERROR(EINVAL);
+
+    glyph->comp_cache  = av_malloc(comp_size);
+    glyph->alpha_cache = av_malloc(nb_px);
+    if (!glyph->comp_cache || !glyph->alpha_cache) {
+        av_freep(&glyph->comp_cache);
+        av_freep(&glyph->alpha_cache);
+        return AVERROR(ENOMEM);
+    }
+
+    for (int y = 0; y < h; y++) {
+        const uint8_t *p = bitmap->buffer + y * bitmap->pitch;
+
+        for (int x = 0; x < w; x++) {
+            /* Color glyphs are stored as premultiplied BGRA */
+            const uint8_t *px = p + 4 * x;
+            unsigned a = px[3];
+            uint8_t rgba[4];
+            FFDrawColor color;
+
+            rgba[0] = a ? (px[2] * 255u + a / 2) / a : 0;
+            rgba[1] = a ? (px[1] * 255u + a / 2) / a : 0;
+            rgba[2] = a ? (px[0] * 255u + a / 2) / a : 0;
+            /* Store straight component values with a full alpha: the pixel
+             * alpha is applied exactly once, as the coverage weight of the
+             * blend, which also premultiplies where the target needs it,
+             * and the destination alpha component must be blended towards
+             * opacity by coverage. */
+            rgba[3] = 255;
+            ff_draw_color(&s->dc, &color, rgba);
+            glyph->alpha_cache[y * w + x] = a;
+            for (int i = 0; i < desc->nb_components; i++) {
+                int plane  = desc->comp[i].plane;
+                int offset = desc->comp[i].offset;
+                uint16_t v = desc->comp[i].depth > 8 ?
+                    color.comp[plane].u16[offset / 2] :
+                    color.comp[plane].u8[offset];
+
+                glyph->comp_cache[i * nb_px + y * w + x] = v;
+            }
+        }
+    }
+
+    return 0;
+}
+#endif /* FT_LOAD_COLOR && FT_HAS_COLOR */
+
 static int draw_glyphs(AVFilterContext *ctx, AVFrame *frame,
                        FFDrawColor *color,
                        TextMetrics *metrics,
-                       int x, int y, int borderw)
+                       int x, int y, int borderw, int shadow)
 {
     DrawTextContext *s = ctx->priv;
     int g, l, x1, y1, w1, h1, idx;
@@ -1327,6 +1458,14 @@ static int draw_glyphs(AVFilterContext *ctx, AVFrame *frame,
 
             idx = get_subpixel_idx(info->shift_x64, info->shift_y64);
             b_glyph = borderw ? glyph->border_bglyph[idx] : glyph->bglyph[idx];
+            if (!b_glyph) {
+                /* Color (BGRA) glyphs have no stroked border variant: draw
+                 * their shadow from the normal glyph, skip the border pass. */
+                if (shadow)
+                    b_glyph = glyph->bglyph[idx];
+                else
+                    continue;
+            }
             bitmap = b_glyph->bitmap;
             x1 = x + info->x + b_glyph->left;
             y1 = y + info->y - b_glyph->top + offset_y;
@@ -1359,6 +1498,40 @@ static int draw_glyphs(AVFilterContext *ctx, AVFrame *frame,
             w1 = FFMIN(clip_x - x1, w1 - dx);
             h1 = FFMIN(clip_y - y1, h1 - dy);
 
+#if defined(FT_LOAD_COLOR) && defined(FT_HAS_COLOR)
+            if (bitmap.pixel_mode == FT_PIXEL_MODE_BGRA) {
+                int err = build_color_glyph_caches(s, glyph, &bitmap);
+                if (err < 0)
+                    return err;
+                if (shadow) {
+                    /* Draw the shadow using the glyph's alpha channel as
+                     * the blend mask for the shadow color */
+                    ff_blend_mask(&s->dc, color, frame->data,
+                        frame->linesize, clip_x, clip_y,
+                        glyph->alpha_cache + dx + dy * bitmap.width,
+                        bitmap.width, w1, h1, 3, 0, x1, y1);
+                } else {
+                    /* Draw the glyph with its own per-pixel colors; the
+                     * fontcolor option does not apply to color glyphs.
+                     * The source rectangle is already clipped against the
+                     * drawing region, so pass the frame dimensions to let
+                     * frame-edge subsampling blocks be weighted by their
+                     * real in-frame pixel count. */
+                    const uint16_t *comps[4] = { NULL };
+                    size_t nb_px = (size_t)bitmap.width * bitmap.rows;
+
+                    for (int i = 0; i < s->dc.desc->nb_components; i++)
+                        comps[i] = glyph->comp_cache + i * nb_px +
+                            dy * bitmap.width + dx;
+                    ff_blend_color_bitmap(&s->dc, frame->data,
+                        frame->linesize, frame->width, frame->height,
+                        comps, glyph->alpha_cache + dy * bitmap.width + dx,
+                        bitmap.width, w1, h1, s->alpha, x1, y1);
+                }
+                continue;
+            }
+#endif
+
             ff_blend_mask(&s->dc, color, frame->data, frame->linesize, clip_x, clip_y,
                 bitmap.buffer + pdx, bitmap.pitch, w1, h1, 3, 0, x1, y1);
         }
@@ -1843,20 +2016,20 @@ static int draw_text(AVFilterContext *ctx, AVFrame *frame)
 
         if (s->shadowx || s->shadowy) {
             if ((ret = draw_glyphs(ctx, frame, &shadowcolor, &metrics,
-                    s->shadowx, s->shadowy, s->borderw)) < 0) {
+                    s->shadowx, s->shadowy, s->borderw, 1)) < 0) {
                 goto fail;
             }
         }
 
         if (s->borderw) {
             if ((ret = draw_glyphs(ctx, frame, &bordercolor, &metrics,
-                    0, 0, s->borderw)) < 0) {
+                    0, 0, s->borderw, 0)) < 0) {
                 goto fail;
             }
         }
 
         if ((ret = draw_glyphs(ctx, frame, &fontcolor, &metrics, 0,
-                0, 0)) < 0) {
+                0, 0, 0)) < 0) {
             goto fail;
         }
     }
-- 
2.43.0