Render monochrome, grayscale and BGRA bitmap glyphs - #1115
Conversation
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>
|
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' 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. |
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>
|
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. |
|
I'm not sure about this one. Bitmap fonts are extremely rare and this only adds complexity without solving much. |
|
@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 |
|
Ok, this makes a bit more sense. I also do not like Also worth extracting some code from |
`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>
|
Both of the structural points are done, and I have an answer on the third. The bitmap handling now lives in 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. 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 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>
|
Good. The implementation looks much nicer and less invasive now. |
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.
Implements the
// TODO: implement other bitmap formatsinflatten.rs. Until now onlyBitmapData::Pngwas handled andBitmapData::Mask/BitmapData::BgrareturnedNone,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
ImageKindcan only carry encoded image data, so the decoded bitmaps have to be re-encodedas PNG. This adds
pngtousvgbehind thetextfeature. It is already in the dependencytree via
tiny-skia, so it costs no additional transitive crates.Tests
mask_coverageis covered by unit tests across all bit depths and both layouts, since asingle font can only exercise one combination.
For the render tests,
BitmapMono.subset.ttfis added: a 7.6 KiB subset of Terminus (TTF)carrying a single 16ppem 1bpp
EBDTstrike.pyftsubsetdropsEBDT/EBLC, so the strikeis pruned and re-attached by
make-bitmap-mono.py, which is included next to the font anddocumented 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
mainthe test SVG flattens tozero
<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).