Every color emoji OxiText ever rendered was fully transparent — and nothing in the API surface told you that.
Today we released OxiText 0.2.1 — a fix release that turns COLRv0/COLRv1 color-glyph rendering from a silent no-op into a complete paint-graph interpreter, replaces its PNG dependency with a Pure-Rust encoder to close the last banned flate2/miniz_oxide link, and cuts thread-local font-cache overhead by up to 13,000x.
No png crate. No flate2. No miniz_oxide. And COLRv1’s gradients, transforms, clip lists, and all 28 composite modes now paint real pixels instead of the seven distinct outputs the old stub-based interpreter produced from sixty test glyphs. OxiText stays composed entirely from Pure-Rust crates, so it still builds as a single static binary with no system libraries and no C toolchain — this release just makes what it draws correct.
Why OxiText 0.2.1 is a game changer
Color-glyph fonts (Twemoji, Noto Emoji, and every COLR-based emoji set) look simple from the outside — layers, palettes, maybe a gradient — but the previous pipeline had two compounding bugs:
fontdue::Font::rasterize_indexedonly materializes glyphs reachable from the font’scmap, and COLR layer glyphs are deliberately mapped from no codepoint — so every layer rasterized to a0×0bitmap and every color glyph came back 0.0% coverage, silently.push_transform/pop_transform,push_clip/pop_clip, andpush_layer/pop_layerwere empty stubs, so even a font that did rasterize lost everyPaintTransform, everyClipListbox, and collapsed all 28PaintCompositemodes to plain source-over.
OxiText 0.2.1 ends all of that:
- Outlines now rasterize directly from
ttf-parser, not through fontdue’s cmap-gated path —glyf,CFF, andCFF2all work, verified againsttwemoji_smiley-glyf_colr_1.ttf,noto_handwriting-glyf_colr_1.ttf, and the full 4.6 MB Noto COLRv1 emoji build: all previously 0.0% coverage, now the Twemoji smileys render at 88% coverage with ~50 distinct colors. - A real transform stack applies
PaintTransform/Scale/Rotate/Skew/Translateboth to flattened outlines and, through its inverse, to gradient sampling. - A real clip stack rasterizes
PaintGlyphandClipListboxes into a coverage mask instead of dropping them. - All 28 composite modes — thirteen Porter-Duff operators, eleven separable CSS blend modes, four non-separable ones (
Hue,Saturation,Color,Luminosity) — render into premultipliedf32layer targets and combine correctly; the COLRv1 conformance font’s 60 composite-mode glyphs went from 7 distinct bitmaps to 58. - Sweep, linear, and radial gradients are all fixed — sweep angles were off by a factor of two (F2DOT14 counts 180°/unit, not 360°), linear gradients ignored the
p2rotation point, and radial gradients now solve the actual two-point conical quadratic instead of approximating with concentric circles. - The pipeline calls
render_colr_v1for COLRv1 fonts instead of the COLRv0 entry point it used before — the oldColrV0Painterdropped every non-solid paint, so gradient emoji lost all their layers on top of the fontdue problem above. - The thread-local font cache stopped deep-copying parsed faces.
get_or_parse_fontduenow hands back a sharedArc<fontdue::Font>instead of cloning the whole glyph table on every call — measured 302 ms/glyph → 5.0 µs/glyph (~13,000x) for a 30-glyph Japanese caption cue against a 4.5 MB Noto Sans JP face.
Technical Deep Dive: the paint pipeline, layer by layer
- Outline extraction. A new internal anti-aliased scanline rasterizer reads
ttf-parseroutlines directly (glyf/CFF/CFF2), using the same em-scale and baseline placement fontdue used, so single-layer COLRv0 output is unchanged to within a mean alpha difference under 12/255. - Paint-graph interpretation (
colr_paint.rs). Transform and clip stacks are threaded through everyPaintColrLayers/PaintComposite/PaintGlyphrecursion; gradients are sampled through the inverse of the active transform so control points stay in the right coordinate space. - Compositing. Each layer renders into its own premultiplied
f32target; the 28 composite modes combine them, and the result is un-premultiplied once at the end so 8-bit rounding error doesn’t accumulate across intermediate layers. - Memoization (
colr_cache.rs). A thread-local LRU keyed on(Arc<[u8]> font identity, glyph id, size, palette)retains the caller’sArcfor the entry’s lifetime (a bounded content hash isn’t a safe identity here — a collision would hand back the wrong picture, not just a wrong parse). Bounded to 256 entries / 8 MiB total; a single result over 2 MiB is returned uncached. Warm lookups cost 13–15 ns in release — a 2,600–4,800x saving over repainting.
Getting Started
cargo add oxitext-raster
use oxitext_raster::render_colr_glyph_sized;
let font_data = std::fs::read("emoji-font.ttf")?;
let glyph_id: u16 = 42; // resolved via cmap/shaping for the emoji codepoint
let palette = 0; // default CPAL palette
if let Some(img) = render_colr_glyph_sized(&font_data, glyph_id, 64.0, palette) {
// `img.rgba` is straight (non-premultiplied) RGBA, trimmed to ink.
println!(
"{}x{} color glyph, bearing ({}, {})",
img.width, img.height, img.bearing_x, img.bearing_y
);
}
# Ok::<(), Box<dyn std::error::Error>>(())
render_colr_glyph_sized derives the bitmap from the glyph’s own paint box — its ClipList entry, else the base outline’s bbox, else a margin around the em — rather than a fixed preview square, so gradients and transforms that paint outside a naive 1em box aren’t clipped. Drawing the same emoji every frame (a caption renderer, for instance)? Reach for render_colr_glyph_sized_cached instead, which memoizes the paint graph per thread and returns a shared Arc<ColorGlyphImage> on a hit.
What’s New in 0.2.1
- Fixed: COLR color glyphs no longer rasterize to a fully transparent bitmap — the root fontdue-cmap cause is asserted directly by a regression test so it can’t silently return.
- Fixed: COLRv1
PaintTransform/Scale/Rotate/Skew/Translate,PaintGlyph/ClipListclipping, and all 28PaintCompositemodes — previously empty stubs. - Fixed: COLRv1 sweep-gradient angle (off by 2x), linear-gradient
p2rotation point (ignored), and radial-gradient two-circle cone (approximated) — all now spec-correct. - Fixed: an out-of-range CPAL palette index returns
Noneinstead of failing silently into a blank bitmap. - Fixed: the pipeline’s COLR path now calls
render_colr_v1for COLRv1 fonts instead of the COLRv0 entry point. - Fixed:
oxitext-layouttab stops on the second and later lines resolved the wrong source character (a line-relative vs. absolute glyph-index bug);oxitext-sdf’sSdfAtlas::from_bytesno longer risks an overflow-driven out-of-bounds panic on a malformed atlas header. - Removed the last banned dependency:
oxitext-sdf’s unconditionalpngdep (→flate2→miniz_oxide, all banned bydeny.toml) is gone from the default build, replaced by a new in-tree encoder,oxitext-core::png_encode, built onoxiarc-deflate/oxiarc-core. Byte-identical output verified through an independent inflate and accepted by libpng and macOS ImageIO. - Performance: thread-local font-cache lookups now share an
Arc<fontdue::Font>instead of cloning it — up to ~13,000x on large CJK faces;FontdueRasterizer::rasterconsults that cache before its own mutex-guarded LRU (~2,800x on a second rasterizer’s first glyph); COLR paint results are now cached per thread (~2,600–4,800x on warm lookups). - Added:
render_colr_with_palette,render_colr_glyph_sized+ColorGlyphImage, and their cached counterpartsrender_colr_cached/render_colr_glyph_sized_cached. - 772 tests passing (nextest, all-features), zero warnings, Pure Rust default features, MSRV 1.89.
Tips
- Prefer
render_colr_glyph_sizedover the fixed-square entry points (render_colr_v0/render_colr_v1) when you’re placing color glyphs next to shaped text — it won’t clip gradients or transforms that paint outside 1em, which real emoji fonts do routinely. - Reach for the
_cachedvariants in any render loop. A caption or subtitle renderer redraws the same emoji every frame;render_colr_glyph_sized_cached/render_colr_cachedreturn a sharedArcon a hit instead of re-walking the paint graph. - Upgrade if you ever shipped color-emoji output from OxiText. Every prior release had the fontdue-cmap bug — any COLR glyph you rendered before 0.2.1 was fully transparent, not just imperfect.
- The
png-outputfeature needs no code change. It now maps tooxitext-core/png-encodeinstead ofdep:png;RenderResult::to_png’s signature and output format are unchanged, only the error text on an encoder failure differs. ColorGlyphBitmap::rgbais straight, non-premultiplied RGBA — the painter composites internally in premultipliedf32and un-premultiplies once at the end, so this is a documentation clarification, not a behavior change.
This is the foundation
OxiText is part of NoFFI — the COOLJAPAN initiative replacing every C/C++/Fortran/-sys dependency in the Rust ecosystem with a clean, memory-safe, Pure-Rust implementation. It pairs with OxiFont for font parsing and discovery, and this release matters most for everything downstream that draws color emoji: oximedia (subtitles and captions), oxiphoton (text on images), OxiUI (every widget, via the oxitext-sdf GPU glyph atlas), oxigdal-symbology (map labels), and oxigaf (PDF/EPUB reflow).
Repository: https://github.com/cool-japan/oxitext
Star the repo if you want color emoji that actually render before you spend an afternoon debugging why they don’t.
Pure Rust typography — sovereign, safe, and FFI-free.
— KitaSan at COOLJAPAN OÜ July 30, 2026