Skip to content

Add support for (some) colour fonts#30725

Open
QuLogic wants to merge 3 commits intomatplotlib:mainfrom
QuLogic:colour-font
Open

Add support for (some) colour fonts#30725
QuLogic wants to merge 3 commits intomatplotlib:mainfrom
QuLogic:colour-font

Conversation

@QuLogic
Copy link
Copy Markdown
Member

@QuLogic QuLogic commented Nov 4, 2025

PR summary

This adds support for fonts with colour glyphs supported by FreeType. Specifically, this should mean COLRv0 fonts. There also exist some other colour font types, which are not supported:

  • CBDT is a non-scalable bitmap format and we don't support those, but it may be possible if we do a scaling ourselves.
  • SVG requires a parser (though it's some font-specific subset of the whole SVG spec)
  • COLRv1 essentially requires a full renderer setup.

We of course do have a full renderer available, but that would require much more interfacing to get working. HarfBuzz (which we use indirectly through libraqm) also has some API for these COLRv1 fonts, but I don't know if it's any nicer to use than FreeType's. Unfortunately, this does exclude one of the most popular emoji fonts, Noto Color Emoji, as that has moved to COLRv1+SVG.

This PR is based on all open font work, because a) #30059 makes it much easier to place the colour data if we don't have to use an intermediate buffer, and b) #30607 due to FT_Glyph_To_Bitmap losing colour information and so we need to move to an implementation that uses FT_Render_Glyph directly. I also merged #30334 though it's probably not strictly required.

For example, we can now render Niklaas in COLRv0, some fonts with simpler decorative effects like Cairo Play, and the older OpenMoji Color that was COLRv0. Fonts that use SVG (like Nabla and Gilbert here) are reduced to their greyscale variant.
colour

PR checklist

self._renderer.draw_image(
gc,
bitmap.left, bitmap.top - buffer.shape[0],
buffer[::-1, :, [2, 1, 0, 3]])
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if we really support big-endian archs, but at least backend_tkcairo uses (2, 1, 0, 3) if sys.byteorder == "little" else (1, 2, 3, 0).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Endianness shouldn't matter as we aren't treating these as 32-bit integers, but I'll confirm when we build on Fedora for the release candidate.

return {{self.bitmap.rows, self.bitmap.width},
if (self.bitmap.pixel_mode == FT_PIXEL_MODE_BGRA) {
return {
py::array::ShapeContainer({self.bitmap.rows, self.bitmap.width, 4}),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not that it really matters but I think the explicit ShapeContainer and StridesContainer are optional?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was because of mixed-signedness with the 4; changing it to 4u now makes them unnecessary.

@QuLogic
Copy link
Copy Markdown
Member Author

QuLogic commented Jan 30, 2026

So there is a small issue with transparent edges; you can see that they slightly darker than they should be. This is because FreeType produces colours with premultiplied alpha. By default, we use the "plain" pixel format, which does non-premultiplied colours into a non-premultiplied buffer. Agg does have multiple blender options, but those are: 1) plain colours to plain buffer, 2) premultiplied colours to premultiplied buffer, and 3) plain colours to premultiplied buffer. Unfortunately, that means it doesn't have the blender we need, premultiplied colours to non-premultiplied buffer.

The simplest fix is to drop the buffer into Pillow and have it un-premultiply and then draw with our normal blender, but this may be a bit inefficient.

@QuLogic
Copy link
Copy Markdown
Member Author

QuLogic commented Jan 31, 2026

OK, I have now moved all that calculation to the Agg side by modifying RendererAgg::draw_image to be a bit more generic. Annoyingly, the way a format is described in Agg is a bit messy. A pixel format is a blender + rendering buffer, and a blender is a class that does mixing over a colour format (i.e., 8-bit bytes) + an order.

The original code has a pixel format that is 8-bit-per-component RGBA with plain (non-premultiplied) colours blended to a plain buffer. For the image, we attach the same pixel format since everything is intended to be the same.

For colour fonts, FreeType generates a premultiplied BGRA image. We can't just attach an order directly to a buffer, so we need a new pixel format for the source image that specified BGRA. Confusingly, the blender there is superfluous because we aren't blending anything onto the image. Instead, a blender for the destination needs to be used with the destination order. The one that we have is plain->plain, so we have to attach a new one that does premultiplied->plain (with a new implementation since Agg doesn't have that one.)

I was hoping to just pass in the source pixel format and somehow decompose that internally, but couldn't figure out how to do that in C++, so I resorted to passing in two pixel formats to get the correct order+blender. I've also not actually checked the implementation with a complex clip path, as that is generally not working with text anyway.

Now, the antialiased edges are correct on an opaque background:
colour-opaque
as well as a transparent background:
colour-transparent

Two remaining questions:

  1. For plain->plain, we apparently have a fixed_blender_rgba_plain to increase precision; the blender_rgba_pre_plain that I wrote is based on Agg's premultiplied->premultiplied routine instead. I am uncertain whether I also need to apply a similar fix for precision purposes.
  2. I don't know how best to test this; maybe Niklaus would be reasonable here? It's about 25k for the COLRv0 font though 67K for the SVG font (but we don't support the latter).

On a side note, if you output PDF, then all of the fonts in the test image above work, so at least the embedding is correct.

@tacaswell tacaswell added this to the v3.12.0 milestone Apr 2, 2026
QuLogic added 3 commits April 10, 2026 21:22
This adds support for fonts with colour glyphs supported by FreeType.
Specifically, this should mean COLRv0 fonts. There also exist some other
colour font types, which are not supported:

* CBDT is a non-scalable bitmap format and we don't support those, but
  it may be possible if we do a scaling ourselves.
  matplotlib#31207
* COLRv1 essentially requires a full renderer setup.
  matplotlib#31210
* SBIX is another non-scalable bitmap format likee CBDT.
  matplotlib#31208
* SVG requires a parser (though it's some font-specific subset of the
  whole SVG spec).
  matplotlib#31211

Fixes matplotlib#31209
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

5 participants