Skip to content

Render monochrome, grayscale and BGRA bitmap glyphs - #1115

Open
oetiker wants to merge 10 commits into
linebender:mainfrom
oetiker:feat/bitmap-mask-glyphs
Open

Render monochrome, grayscale and BGRA bitmap glyphs#1115
oetiker wants to merge 10 commits into
linebender:mainfrom
oetiker:feat/bitmap-mask-glyphs

Conversation

@oetiker

@oetiker oetiker commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Implements the // TODO: implement other bitmap formats in flatten.rs. Until now only
BitmapData::Png was handled and BitmapData::Mask / BitmapData::Bgra returned None,
so glyphs from monochrome bitmap fonts silently fell back to the outline, or rendered as
nothing at all in bitmap-only fonts.

Masks

A mask stores coverage rather than color, so it is painted with the fill of the text span,
just like an outline glyph would be. All four bit depths the spec allows (1, 2, 4 and 8 bpp)
are supported, in both the row-packed and the byte-aligned layout.

Because the result now depends on the fill, the raster cache is keyed by the color and
opacity as well. Non-solid paints (gradients, patterns) cannot be expressed by an image and
fall back to black.

BGRA

BGRA data is premultiplied while PNG stores straight alpha, so the color channels are
unpremultiplied during the conversion.

Encoding

ImageKind can only carry encoded image data, so the decoded bitmaps have to be re-encoded
as PNG. This adds png to usvg behind the text feature. It is already in the dependency
tree via tiny-skia, so it costs no additional transitive crates.

Tests

mask_coverage is covered by unit tests across all bit depths and both layouts, since a
single font can only exercise one combination.

For the render tests, BitmapMono.subset.ttf is added: a 7.6 KiB subset of Terminus (TTF)
carrying a single 16ppem 1bpp EBDT strike. pyftsubset drops EBDT/EBLC, so the strike
is pruned and re-attached by make-bitmap-mono.py, which is included next to the font and
documented in the fonts README. Terminus is OFL with a Reserved Font Name, so the script
also renames the font, as required for a Modified Version.

Verified that these tests actually cover the new path: on main the test SVG flattens to
zero <image> nodes (outline fallback), with this change it flattens to one per glyph.

Full suite passes (1734 render tests, no reference image changes to existing tests).

oetiker and others added 4 commits August 4, 2026 00:14
Only PNG bitmap glyphs were rendered so far, the other two `CBDT`/`EBDT`
image formats were left as a TODO and fell back to the outline of the
glyph, or to nothing at all for bitmap-only fonts.

A mask stores coverage rather than color, so it is painted with the fill
of the text span, the same way an outline glyph is. That makes the glyph
depend on the fill, so the raster cache is keyed by it as well.
Non-solid paints cannot be expressed by an image and fall back to black.

BGRA data is premultiplied while PNG stores straight alpha, so the color
channels are unpremultiplied during the conversion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A mask comes from a pixel font, which is drawn for one specific size.
Smoothing one blurs the very grid it was drawn on, and bleeds into the
transparent border of the glyph box wherever a stem touches the edge, so
draw them without interpolation.

The render tests now cover the strike at its own size as well as scaled
up, where the larger line is a pixel for pixel doubling of the smaller
one, and the fill test gained a gradient to cover the fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@oetiker

oetiker commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Pushed two follow-ups after looking more closely at the rendered output.

Mask glyphs are now drawn without interpolation. A mask comes from a pixel font, drawn for one specific size, so smoothing blurs the very grid it was drawn on. It also bleeds into the transparent border of the glyph box wherever a stem touches the edge, which was visible on Terminus' m, whose left stem sits on column 0 of its 8px cell.

The render tests now cover the strike at its own 16px size as well as scaled to 32px, where the larger line is a pixel for pixel doubling of the smaller one. The fill test also gained a gradient fill, covering the fallback to black for paints an image cannot express.

@oetiker
oetiker marked this pull request as draft August 4, 2026 05:15
A pixel font carries a strike per size it was drawn for, and ships
outlines for everything in between. Picking the largest strike and
scaling it, as an unscaled request does, throws away that intent and
produces a worse glyph than the outline would.

Monochrome strikes are therefore only used when one matches the font
size, unless the glyph has no outline to fall back to. Color bitmaps
keep taking the largest strike, since an emoji font tends to carry a
single large one for every size and no outline behind it.

The test font grew a second strike so the sample can show both: at 16px
and 24px the glyphs come from a strike, with two alpha levels and no
antialiasing, while 20px and 28px are antialiased outlines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@oetiker

oetiker commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

One more, prompted by a good point about how pixel fonts are meant to work: a font carries a strike per size it was drawn for, and ships outlines for everything in between. Asking for an unscaled size, as the code did, always picks the largest strike and scales it, which throws that intent away and produces a worse glyph than the outline would.

Monochrome strikes are now only used when one matches the font size, unless the glyph has no outline to fall back to. Color bitmaps still take the largest strike, since an emoji font tends to carry a single large one for every size and no outline behind it, so nothing changes for those.

The test font grew a second strike so the sample shows both cases. At 16px and 24px the glyphs come straight from a strike, with two alpha levels and no antialiasing at all; at 20px and 28px they are antialiased outlines with 20+ levels.

@RazrFalcon

Copy link
Copy Markdown
Collaborator

I'm not sure about this one. Bitmap fonts are extremely rare and this only adds complexity without solving much.

@oetiker

oetiker commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

@RazrFalcon the reason I wrote these patches is that I use resvg to render eink panel content ... there bitmap fonts are very helpful. https://github.com/oetiker/byonk

@oetiker
oetiker marked this pull request as ready for review August 4, 2026 14:05
@RazrFalcon

Copy link
Copy Markdown
Collaborator

Ok, this makes a bit more sense.

I also do not like cache_raster: HashMap<(ID, GlyphId, u32, [u8; 4]). We should turn key into a struct.
Do we even need those two new fields? I saw the comment, but I would prefer to share the implementation with current bitmap fonts.

Also worth extracting some code from text/flatten.rs to a dedicated bitmap fonts handling file.

`text/flatten.rs` is about walking the positioned glyphs of a span, and
it had grown a bitmap decoder, a PNG encoder and the strike lookup on
top of that. Those move to `text/bitmap.rs`, together with their tests.

The raster cache key becomes a struct with a documented field per thing
a bitmap glyph depends on, rather than a tuple, and doubles as the
argument of the lookup so the two cannot disagree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@oetiker

oetiker commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Both of the structural points are done, and I have an answer on the third.

The bitmap handling now lives in text/bitmap.rs together with its tests, leaving text/flatten.rs to walking the positioned glyphs of a span. And the cache key is a struct with a documented field per thing a bitmap glyph depends on, which doubles as the argument of the lookup itself, so the key and the parameters cannot drift apart.

On whether the two extra fields are needed: they are, and both fall out of features rather than being incidental. The size picks the strike, so the same glyph genuinely differs between sizes. The fill is there because a mask carries coverage only, with no color of its own, so it has to be painted in the fill of the span the way an outline glyph is; without it, colored text in a bitmap font comes out black. ImageKind can only carry encoded image data, so the color has to be baked in at that point rather than applied later.

On sharing the implementation with the existing bitmap path, I tried it. A single rule of "prefer the strike that matches the size, else the outline, else the best strike" leaves text_color_font_cbdt passing but breaks text_color_font_sbix, because an sbix font ships outlines behind its bitmaps as a fallback for renderers without sbix support, so preferring the outline inverts what the font intends.

That is what the split is really about, and I have reworded the comment to say so: a color bitmap is the intended appearance of the glyph, while a mask is one size specific rendering of the same design the outline already describes. That also makes the conservative behaviour the right one for a color font that does carry outlines, where scaling the color bitmap still beats falling back to a monochrome outline. Happy to reshape it if you would rather draw the line elsewhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@RazrFalcon

Copy link
Copy Markdown
Collaborator

Good. The implementation looks much nicer and less invasive now.

oetiker and others added 3 commits August 16, 2026 00:20
An embedded strike carries its own metrics — bearings and advance, in
whole pixels, drawn for that one pixel size. They are not the outline's
metrics scaled: a pixel font is drawn per size, so the two agree only at
the size the outline was fitted to, if any. skrifa hands us the strike's
advance in `BitmapGlyph::advance` and we were throwing it away, laying
out every bitmap glyph on the outline's `hmtx` advance instead.

That is wrong twice over. The glyphs sit at the wrong distance from each
other — X11Helv's `H` at 12 px advances 9 px in its strike and 8.82 px
in its outline — and, because a scaled advance is almost never a whole
number of pixels, every glyph after the first lands between pixels,
where a picture of a pixel grid cannot be reproduced at all.

The substitution happens where font units become user units, in
`form_glyph_clusters`, so everything downstream — text-anchor,
textLength, letter-spacing, decoration spans, the text bbox — sees the
corrected advance and keeps working.

Only a mask strike that will actually be drawn contributes an advance.
A color bitmap, and a glyph with no outline at all, keep the advance
they have today, so nothing changes for the emoji fonts this path was
built for. Shaping resolves the strike source once per run and dismisses
a face without strikes outright, so a font that has none pays a single
check for the whole run.

Layout now depends on which glyphs will be drawn from a strike, so the
predicate that decides it lives in one place, `bitmap::matching_mask`,
which both layout and flattening go through.
A monochrome strike is a picture of a glyph drawn for one exact pixel
size, so it only reproduces what the designer drew when it lands on the
pixel grid it was drawn on. Spacing the glyphs by their strikes' own
advances puts a run on that grid and keeps it there, since those
advances are whole numbers of pixels — but only for as long as the run
starts on the grid. Nothing makes it: `x="20.5"`, a fractional
`letter-spacing`, and a `text-anchor="middle"` over an odd width each
shift the whole run by a fraction of a pixel.

The image is then painted over a rectangle with fractional edges, and
even though it is sampled nearest-neighbour, the rectangle itself is
anti-aliased: the glyph's outermost row and column come out partially
covered. On a monochrome target that is the difference between a stem
and no stem, and a dithered 1-bit panel turns it into broken stems and
dropped counters.

Snap the placement of a mask bitmap to whole pixels. Rounding is only
meaningful where a user-space unit is a device pixel, so it is skipped
unless the glyph lands unrotated and unscaled. Color bitmaps are left
alone: they are images in their own right rather than a rendering of
the outline.
`mask_advance` refused to answer for a glyph with no outline, on the
grounds that such a glyph keeps its strike at any size and so has its
image scaled. But `matching_mask` — which it calls straight afterwards —
already narrows to the strike whose ppem equals the requested size, and
that strike is drawn unscaled. `glyph` agrees: an exact match is drawn as
it stands, outline or no outline.

So the guard only ever suppressed the advance of a strike that was in
fact being drawn at its own size, and it did so in the one case that can
least afford it. A font with no outlines has nothing but `hmtx` to fall
back on, and `hmtx` holds a single value per glyph: it can be right at
one size at most. Terminus is 8x14 at 14px and 8x16 at 16px, so a font
carrying both strikes and no outline was spaced 7px at 14px, one pixel
short on every glyph.

The new test font is `BitmapMono` with its outline tables removed, and
14px is the size where the two records disagree. Reinstating the guard
fails that test alone, which is also why the five tests already here
could not have caught this: every one of them uses a font with outlines.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants