· guides, reference
Rendering color fonts on Windows

This is the Windows companion to our field log of rendering color fonts on Linux, and the two platforms could not approach the problem more differently. On Linux the work was finding any program that would paint COLR layers at all, and the answer was a debug viewer behind a virtual display. On Windows the color support is built into the operating system’s text stack, all four formats of it, and the work is picking the right API layer and remembering to switch color on. The failure mode is the same, though, and it is the one this whole series exists to warn about: when a Windows app gets color wrong, it does not error. It draws flat black and keeps quiet.
A note on method: the Linux post was a log of commands run on our own box. The Windows material here is built from Microsoft’s documentation (the DirectWrite color font overview is the primary source) and the same format-first discipline: know which tables your font carries before choosing a renderer. The routing table below is shared by both posts.
The Windows stack in one paragraph
Since Windows 10 version 1607, DirectWrite and Direct2D between them support every color technique the OpenType spec defines: COLR/CPAL layers (supported since Windows 8.1), the SVG table (a documented subset of full SVG), and the bitmap tables CBDT and sbix. Frameworks built on that stack inherit the support: XAML text elements render color by default, as do Edge and WebView2. The architecture is worth understanding because it explains every gotcha that follows: layout is always monochrome. Text layout produces ordinary glyph runs, and color appears only at draw time, when the run is translated into color glyph runs. Nothing upstream of the draw call knows or cares that the font is chromatic.
The two silent traps
Trap 1: GDI never got color. Anything rendering through GDI or System.Drawing (classic WinForms text, a great deal of legacy .NET code, more production utilities than anyone admits) has no color glyph API at all. A color font in a GDI text path renders its monochrome outlines forever, with no flag to flip, because the concept does not exist in that layer. It is the exact Windows twin of the PIL behavior in our Linux log: a fine renderer, faithfully drawing the wrong table. If your pipeline is .NET Framework-era System.Drawing, the fix is not a setting; it is a different renderer (the managed shortcut below exists largely for this reason).
Trap 2: Direct2D color is opt-in. Direct2D’s convenient text methods, DrawText and DrawTextLayout, deliberately do not render color glyphs unless you pass the D2D1_DRAW_TEXT_OPTIONS_ENABLE_COLOR_FONT flag. Microsoft chose compatibility over surprise: apps written before color fonts existed should not change behavior because a font changed. Reasonable, and a perfect silent-failure generator for everyone who assumes the high-level API is the full behavior. Win2D, the Windows App SDK’s 2D library, repeats the pattern in managed form: set CanvasDrawTextOptions.EnableColorFont on the text format, or get monochrome.
The native route: translating a glyph run
At the low level, where custom renderers live, the model is explicit and honestly elegant. You hand DirectWrite a normal glyph run plus the set of color formats you are prepared to draw, and it hands back an enumerator of color runs:
// Abridged sketch of the documented procedure (see the DirectWrite
// color font overview on Microsoft Learn for the full reference).
ComPtr<IDWriteColorGlyphRunEnumerator1> runs;
HRESULT hr = factory->TranslateColorGlyphRun(
baselineOrigin, glyphRun, glyphRunDesc,
DWRITE_GLYPH_IMAGE_FORMATS_TRUETYPE |
DWRITE_GLYPH_IMAGE_FORMATS_PNG |
DWRITE_GLYPH_IMAGE_FORMATS_SVG,
measuringMode,
nullptr, // world transform
paletteIndex, // <-- remember this parameter
&runs);
if (hr == DWRITE_E_NOCOLOR) {
// No color here: draw the original run as ordinary monochrome.
dc->DrawGlyphRun(origin, glyphRun, brush);
} else {
// Draw each translated run with the call matching its format:
// layered vector runs with DrawGlyphRun and each run's own
// palette-resolved brush, PNG runs with DrawColorBitmapGlyphRun,
// SVG runs with DrawSvgGlyphRun (ID2D1DeviceContext4 and up).
}
Three things to internalize. First, DWRITE_E_NOCOLOR is a normal outcome, not an error: most runs in most fonts have no color, and your code should treat it as the monochrome fast path. Second, you declare the formats you can handle, and DirectWrite only translates those; claim PNG and SVG without implementing their draw calls and you have built a very efficient way to draw nothing. Third, and this is the detail that made the Linux half of this series wince:
The palette is a parameter. TranslateColorGlyphRun takes a palette index, and the translated runs come back with their colors resolved from that palette. The thing we could not do from the Linux command line at all, select which of a font’s seven palettes to paint with, is one integer in the Windows API. Platform stacks have personalities; DirectWrite’s is “everything is explicit, including the good parts”.
For COLR v1 specifically, current DirectWrite builds (Windows 11 era, and DWriteCore, the downloadable DirectWrite Microsoft ships for the Windows App SDK) extend the same translation model to v1’s richer paint structures. On older Windows 10 machines, DWriteCore is the sanctioned way to get current color behavior without waiting for an OS upgrade, which matters if your renderer ships to other people’s desktops.
The managed shortcut: SkiaSharp from the command line
Native interop is the right answer for a shipping Windows renderer. For the job our Linux post was actually about, command-line specimen rendering, it is more machinery than the task wants. The pragmatic Windows answer is a small .NET console program using SkiaSharp, the managed wrapper over Google’s Skia engine. Skia’s text stack paints COLR v0 and v1, bitmap color, and OpenType-SVG glyphs without being asked, because Skia treats the color representation as the glyph, full stop. There is no flag because there is no monochrome-first architecture to opt out of.
// dotnet new console, then: dotnet add package SkiaSharp
// + SkiaSharp.NativeAssets.Win32
using SkiaSharp;
var typeface = SKTypeface.FromFile(args[0]); // the font file
var text = args[1]; // e.g. "Color!"
using var font = new SKFont(typeface, 160);
using var paint = new SKPaint { IsAntialias = true }; // no color set:
// color glyphs
// bring their own
float width = font.MeasureText(text);
using var bmp = new SKBitmap((int)width + 40, 240);
using var canvas = new SKCanvas(bmp);
canvas.Clear(SKColors.White);
canvas.DrawText(text, 20, 170, font, paint);
using var img = SKImage.FromBitmap(bmp);
using var data = img.Encode(SKEncodedImageFormat.Png, 100);
File.WriteAllBytes(args[2], data.ToArray());
Feed it the Nabla file from our color fonts explainer and it writes the same golden specimen the Linux pipeline produced, gradients and all. C# developers who need DirectWrite itself rather than Skia’s engine should look at Vortice, the maintained .NET bindings for DirectX and DirectWrite, which expose the translation API without C++/WinRT.
Whatever route you choose, keep the verification habit from the Linux log: count the distinct colors in the output PNG and assert on it (thousands for a real color render, a few hundred for a silent monochrome one). It is three lines in any language and it is the only thing standing between your pipeline and a confident, completely black specimen page.
The tools
The short version
- Windows supports all four color formats in DirectWrite and Direct2D, and has for a decade. The capability was never the problem.
- GDI and System.Drawing have no color glyph concept; Direct2D and Win2D have it behind an opt-in flag. Between those two facts lives every “my emoji font prints black” bug report on the platform.
- Layout is monochrome; color is a draw-time translation, with the palette as an explicit parameter. Design your renderer around that and the platform cooperates beautifully.
- For command-line work, a 30-line SkiaSharp console program is the specimen renderer. Assert on color counts before you ship its output.
Between the two posts, the pattern should be familiar by now: the format chooses the renderer, the renderer has exactly one or two silent ways to give you black, and the professionals are the ones who check.