Skip to content

Add support for (some) colour fonts#30725

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

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

Conversation

@QuLogic
Copy link
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
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
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
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
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
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
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.

@github-project-automation github-project-automation bot moved this to Waiting for other PR in Font and text overhaul Jan 31, 2026
@QuLogic QuLogic moved this from Waiting for other PR to Ready for Review in Font and text overhaul Jan 31, 2026
@QuLogic QuLogic added this to the v3.11.0 milestone Jan 31, 2026
@anntzer
Copy link
Contributor

anntzer commented Jan 31, 2026

Impressive!
Regarding premultiplication conversions: it appears that agg implements demultiplication with plain divisions (in rgba8T::demultiply). I also implemented (manual) conversions for mplcairo (as cairo uses premultiplied throughout), and back then I noticed it was much faster to precompute the (premul, alpha) -> (demul) table once (there's only 65536 entries with single bitdepth, which very reasonable) and use lookups instead. (https://github.com/matplotlib/mplcairo/blob/8bb74e0c25c6ac0248f6d507db09b67a953bbc1f/ext/_mplcairo.cpp#L1860-L1867) Obviously it doesn't have to be done here, but perhaps something to keep in mind (was there any place where we had to do any of this dance with agg previously?).

@QuLogic
Copy link
Member Author

QuLogic commented Feb 5, 2026

was there any place where we had to do any of this dance with agg previously?

Probably not? We usually do plain->plain blending. I guess there's a division here, but not for demultiplying alpha:

static AGG_INLINE void blend_pix(value_type* p,
value_type cr, value_type cg, value_type cb, value_type alpha)
{
if(alpha == 0) return;
calc_type a = p[Order::A];
calc_type r = p[Order::R] * a;
calc_type g = p[Order::G] * a;
calc_type b = p[Order::B] * a;
a = ((alpha + a) << base_shift) - alpha * a;
p[Order::A] = (value_type)(a >> base_shift);
p[Order::R] = (value_type)((((cr << base_shift) - r) * alpha + (r << base_shift)) / a);
p[Order::G] = (value_type)((((cg << base_shift) - g) * alpha + (g << base_shift)) / a);
p[Order::B] = (value_type)((((cb << base_shift) - b) * alpha + (b << base_shift)) / a);
}

I've now added a test using a subset of OpenMoji Color in COLRv0 format. I forgot that Niklaas was just a sampler for a proprietary font, so that was a no-go.

@QuLogic
Copy link
Member Author

QuLogic commented Feb 5, 2026

Rebased on top of #31085 as I think it makes this simpler to review. It also should mean that this is now independent of the font work going in for test image changes (other than requiring the new FreeType, of course).

if buffer.ndim == 3:
self._renderer.draw_text_bgra_image(
gc,
bitmap.left, bitmap.top - buffer.shape[0],
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is the y different here from the monochrome case?

Copy link
Member Author

Choose a reason for hiding this comment

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

Copy link
Contributor

Choose a reason for hiding this comment

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

It doesn't have to be done as part of this PR, but I guess the semantics of draw_text_image could be made consistent with draw_text_rgba_image by adjusting things around

int deltay = y - image.shape(0);

?

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: Waiting for other PR

Development

Successfully merging this pull request may close these issues.

2 participants