Skip to content

Fix FreeType glyph cache upload for embedded bitmap glyphs#155

Open
arsnyder16 wants to merge 3 commits into
mosra:masterfrom
arsnyder16:arsnyder16/embedded-bitmap-fix
Open

Fix FreeType glyph cache upload for embedded bitmap glyphs#155
arsnyder16 wants to merge 3 commits into
mosra:masterfrom
arsnyder16:arsnyder16/embedded-bitmap-fix

Conversation

@arsnyder16

@arsnyder16 arsnyder16 commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

This fixes glyph cache corruption when FreeType returns embedded bitmap glyphs in a packed bitmap format instead of an 8-bit grayscale bitmap.

Previously FreeTypeFont::fillGlyphCache() assumed that FT_Render_Glyph() always produced bitmap data that could be copied directly into the R8Unorm glyph cache as one byte per pixel. That is not true for embedded bitmap strikes. For example, FreeType can return FT_PIXEL_MODE_MONO or FT_PIXEL_MODE_GRAY4, where the bitmap data is packed and bitmap.pitch does not match bitmap.width. Copying that data as if it were tightly packed 8-bit grayscale causes corrupted glyph cache contents.

The fix uses FT_Bitmap_Convert() if the pixel mode is not FT_PIXEL_MODE_GRAY before uploading the glyph bitmap. This converts the bitmap storage to FT_PIXEL_MODE_GRAY, giving us one byte per pixel and a usable pitch for row-by-row copying.

One subtlety is that FT_Bitmap_Convert() changes the storage format, but it does not always expand the coverage values to the full 0..255 range. The converted bitmap still preserves num_grays, so a monochrome bitmap becomes byte values in 0..1, and a 4bpp grayscale bitmap becomes byte values in 0..15. The upload path now checks bitmap.num_grays:

  • num_grays == 256: copy the converted bytes directly
  • otherwise: scale 0..num_grays - 1 into 0..255 before writing to the glyph cache

Test coverage was added with generated font fixtures containing embedded bitmap strikes:

  • MonochromeBitmap.ttf: exercises FT_PIXEL_MODE_MONO, num_grays = 2
  • Gray4Bitmap.ttf: exercises FT_PIXEL_MODE_GRAY4, num_grays = 16

The fixtures are generated from the existing Oxygen.ttf test font using generate-embedded-bitmaps.py, so the test data can be reproduced.

@codecov

codecov Bot commented Jun 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.15385% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 97.60%. Comparing base (220034c) to head (2cab373).

Files with missing lines Patch % Lines
src/MagnumPlugins/FreeTypeFont/FreeTypeFont.cpp 96.15% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #155      +/-   ##
==========================================
- Coverage   97.61%   97.60%   -0.01%     
==========================================
  Files         159      159              
  Lines       17282    17302      +20     
==========================================
+ Hits        16870    16888      +18     
- Misses        412      414       +2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@mosra mosra left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thank you and sorry for the delay, too many things happening this week.

The code is top-notch (and your previous PRs were as well), I have just a few questions / minor suggestions. If you don't have time to do the followup changes, let me know, I can handle those myself.

By the way, may I ask what are you working on / what's your use case, if it can be shared publicly?

.sliceSize({std::size_t(glyphs[i].offset.y()),
std::size_t(glyphs[i].offset.x())}, glyphSize);
CORRADE_INTERNAL_ASSERT(bitmap->pixel_mode == FT_PIXEL_MODE_GRAY);
CORRADE_INTERNAL_ASSERT(bitmap->num_grays > 1);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks for changing the error into assertions, that would have been my suggestion as well (as it'd be impossible-to-test code, which is as good as not handling that case at all).

const unsigned char* const src = bitmap->buffer + y*bitmap->pitch;
char* const dst = &glyphDst[bitmap->rows - y - 1][0];
for(std::size_t x = 0; x != bitmap->width; ++x) {
dst[x] = char((UnsignedInt(src[x])*255)/(bitmap->num_grays - 1));

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Hmm, I was desperately trying to find some other way to do this, but as I don't have any convenient pixel format conversion APIs in Magnum, what you have here is probably the best possible right now. (FreeType seems to set num_grays to just 2, 4, 16 or 256, but branching on that and then having to test each such case would be excessive, such logic doesn't belong to this plugin.)

Once I have the required APIs in Magnum I'll turn this part into something similar to the copy() above.

Comment on lines +272 to +277
const FT_Bitmap* bitmap = &glyph->bitmap;
if(bitmap->pixel_mode != FT_PIXEL_MODE_GRAY) {
FT_Bitmap_Init(&convertedBitmap);
CORRADE_INTERNAL_ASSERT_OUTPUT(FT_Bitmap_Convert(freeType.library, bitmap, &convertedBitmap, 1) == 0);
bitmap = &convertedBitmap;
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Suggested change
const FT_Bitmap* bitmap = &glyph->bitmap;
if(bitmap->pixel_mode != FT_PIXEL_MODE_GRAY) {
FT_Bitmap_Init(&convertedBitmap);
CORRADE_INTERNAL_ASSERT_OUTPUT(FT_Bitmap_Convert(freeType.library, bitmap, &convertedBitmap, 1) == 0);
bitmap = &convertedBitmap;
}
const FT_Bitmap* bitmap;
if(bitmap->pixel_mode == FT_PIXEL_MODE_GRAY) {
bitmap = &glyph->bitmap;
} else {
FT_Bitmap_Init(&convertedBitmap);
bitmap = &convertedBitmap;
CORRADE_INTERNAL_ASSERT_OUTPUT(FT_Bitmap_Convert(freeType.library, &glyph->bitmap, &convertedBitmap, 1) == 0);
}

would be perhaps slightly more readable? I hope I didn't misunderstand what's going on here.

CORRADE_VERIFY(glyphId);
font->fillGlyphCache(cache, "X");
CORRADE_VERIFY(cache.called);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This is verifying that the image matches exactly the expectation, but maybe it'd be enough to make the test similar to the fillGlyphCache() test, and compare against a PNG image, possibly together with checking that min of all pixel values is 0 and max is 255?

And the Gray4 would be just a second instance of this test case, with different input TTF and different output PNG.

(Just in case, you can save the actual PNG image in current working directory by running the test with -XS$(pwd). I can also do this change myself if you don't want to deal with it.)

"--name-IDs=*",
"--name-legacy",
"--name-languages=*"
], check=True)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Out of curiosity I tried to rasterize the X glyph from MonochromeBitmap.ttf with StbTrueTypeFont to see how / if it fails, and there it produced the same antialiased glyph as it'd do with Oxygen.ttf.

So if I understand correctly, in the script you're keeping the original vector glyph and adding a bitmap one as well, and FreeType prefers the bitmap glyph while stb_truetype the vector one? IOW, if FreeType would get FT_LOAD_NO_BITMAP, it'd behave the same as stb_truetype?

Is it possible for the script to produce a font with just a bitmap outline for X, so I could use the generated files to verify also if / how stb_truetype handles those? Thank you.


const UnsignedInt glyphId = font->glyphId('X');
CORRADE_VERIFY(glyphId);
font->fillGlyphCache(cache, "X");

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Suggested change
font->fillGlyphCache(cache, "X");
font->fillGlyphCache(cache, {glyphId});

will render just the X glyph alone into the output, without the implicitly-added glyph 0, and thus should be less annoying for the comparison after.

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

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

2 participants