Features · Installation · Quick Start · Usage · Supported Formats · Performance
Alhena reads TrueType, OpenType, and TTC fonts, extracts their outlines, and rasterizes antialiased grayscale, LCD, and color glyph bitmaps. It has no runtime gem dependencies or native extensions.
- TrueType, OpenType CFF1/CFF2, and TTC font parsing
- TrueType and supported CFF1 subset construction and glyph-to-Unicode lookup for PDF embedding
- Analytic grayscale and LCD rasterization with subpixel positioning
- Variable font axes, outlines, and metrics
- Fast text advance measurement without rasterization
- Low-resolution ink distribution rendering for minimaps
- COLR/CPAL, sbix, and CBDT/CBLC color glyphs
- Entry- and byte-bounded LRU glyph cache
- Lazy, bounds-checked table parsing
- RBS type signatures
Add Alhena to your Gemfile:
gem "alhena"Then run:
bundle installOr install it directly:
gem install alhena- Ruby 3.1 or later
require "alhena"
font = Alhena::Font.open("/path/to/font.ttf")
glyph = font.glyph_id("A")
bitmap = font.rasterize(glyph, size: 24)
puts font.family
puts bitmap.to_asciibitmap.coverage is an immutable binary String. Grayscale bitmaps contain one coverage byte per pixel; LCD bitmaps contain three. width, height, left, and top describe the bitmap and its bearing. Advances are separate:
advance = font.advance(glyph, size: 24)Measure a UTF-8 string without rasterizing its glyphs:
metrics = font.measure("Inline hint", size: 14)
width = font.advance_width("Inline hint".codepoints, size: 14)Metrics contains the scaled width, ascent, descent, and line_gap;
descent retains the font's signed value (normally negative).
OpenType shaping is outside Alhena's scope, so non-empty features: values raise
Alhena::UnsupportedFont.
Font.open(path, index: 0) reads a font file. Font.new(bytes, index: 0) accepts font bytes directly. Tables are parsed on demand.
Metadata methods include family, names, units_per_em, ascent, descent, line_gap, glyph_count, os2, and post. advance and bearing accept vertical: true. glyph_id accepts a character or Unicode scalar and an optional variation_selector:; unmapped characters return glyph 0.
outline = font.outline(glyph) # font coordinates, Y up
outline.each { |operation, *coordinates| p [operation, coordinates] }
path = Alhena::Outline.new
path.move_to(0, 0).quad_to(50, 100, 100, 0).close
bitmap = Alhena::Rasterizer.new(width: 100, height: 100).fill(path)Outline supports lines, quadratic and cubic curves, transforms, bounds, appending, and cubic-to-quadratic conversion. Rasterizer#fill uses pixel coordinates with Y down and implicitly closes open subpaths.
For minimaps, combine already-positioned outlines into a low-resolution coverage bitmap without rasterizing each glyph separately:
mini = Alhena::Rasterizer.new(width: 1, height: 1)
.fill_downsampled(outlines, scale: 0.05, width: 120, height: 2)Font#rasterize and Rasterizer#fill accept gamma:, darkening:, and lcd: :rgb or :bgr. Use grayscale when the display subpixel order is unknown.
cache = Alhena::Cache.new(capacity: 4096, max_bytes: 16 * 1024 * 1024)
cache.prewarm(font, "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ", size: 24)
bitmap = cache.rasterize(font, glyph, size: 24, subpixel_x: 0.25)The cache quantizes horizontal positions to quarter pixels and evicts least-recently-used entries. Cache and rasterizer instances are intended for one owner; protect shared instances or use one per thread.
font.axes
bold = font.variation(wght: 700, wdth: 90)You can also pass axes to Font.open(path, axes: {wght: 700}). Unspecified axes use their defaults, and out-of-range values are clamped.
color = font.color_bitmap(glyph, size: 48, palette: 0)
rgba = color&.rgbaColorBitmap#rgba stores straight sRGB RGBA8 pixels, and to_bitmap extracts alpha coverage. Font#rasterize automatically returns alpha coverage for supported color glyphs. embedded_bitmap exposes original sbix/CBDT image data and strike metrics.
- sfnt TrueType, OpenType CFF1/CFF2, and TTC collections
- Unicode
cmapformats 0, 4, 6, 12, 13, and 14 - Simple and composite
glyfoutlines with short or longloca - Type 2 charstrings, CID CFF, variation stores, and CFF2 blends
fvar,gvar,avarv1, HVAR, and VVAR variable font data- COLR/CPAL v0, sbix PNG, and CBDT/CBLC PNG, grayscale, BGRA, and composite bitmaps
Alhena does not provide TrueType hinting, shaping, GSUB/GPOS, kerning, system font discovery, COLR v1 paint graphs, avar v2, JPEG/TIFF decoding, or MVAR global metric variation. Use embedded_bitmap to retrieve unsupported sbix image formats for external decoding.
Alhena::Subset.build(font, glyph_ids) creates a compact TrueType font containing the requested glyphs and any composite-glyph dependencies, or a compact static name-keyed CFF1 font for the supported CFF subset described below. CFF2, CID-keyed CFF, predefined CFF charsets, custom CFF encodings, and variable CFF1 fonts are rejected with Alhena::UnsupportedFont; they are never silently returned unchanged. CFF subsets retain selected outlines, Unicode mappings, horizontal metrics, and the original subroutine indexes, but omit layout, shaping, color, and vertical-metric tables whose glyph references are not rewritten.
For PDF consumers, Alhena::Subset.build_cid(font, glyph_ids) converts a static, name-keyed CFF1 font to a compact CID-keyed CFF1 program. Array order assigns CIDs (the first glyph must be glyph 0), and repeated glyph IDs are allowed. It intentionally rejects CFF2, variable, and already CID-keyed source fonts.
Unknown formats raise Alhena::UnsupportedFont; malformed bounds and structures raise Alhena::InvalidFont. Bitmap allocations are limited to 16,777,216 samples.
Measurements below are medians of five batches on Ruby 4.0.0 with YJIT on arm64-darwin24. Run ruby --yjit bench/bench.rb to reproduce them.
| Operation | Measured | Budget |
|---|---|---|
| Open Noto Sans (569,208 bytes) | 64.73 µs | 30,000 µs |
| A at 14px, uncached | 40.11 µs | 500 µs |
| A at 14px, cache hit | 0.48 µs | 5 µs |
| ASCII 95 glyph prewarm | 4.79 ms | 60 ms |
| 鬱 at 48px, uncached | 0.47 ms | 3 ms |
| 10,000 downsampled rows | 100.37 ms | 250 ms |
Cache glyphs in interactive applications so each glyph is normally rasterized once per size and position. These measurements are local evidence, not universal guarantees.
bundle install
bundle exec rake test
bundle exec rake test:oracle
bundle exec rake test:fuzz
bundle exec rake bench:assertOracle tests compare Alhena with FreeType, ttfunk, and committed PNG references. FreeType is development-only; install libfreetype6 on Linux or freetype with Homebrew on macOS, or set FREETYPE_LIBRARY. Optional oracle dependencies are skipped when unavailable.
Render the bundled examples with:
bundle exec ruby examples/render.rb test/fonts/NotoSans-Regular.ttf "Hello, Ruby!" 48 text.png
bundle exec ruby examples/color.rb test/fonts/NotoColorEmoji.ttf "😀" 64 color.pngSee the changelog for release history. Bug reports and pull requests are welcome on GitHub.
Alhena is available under the MIT License. Test font licenses and upstream sources are recorded in test/fonts/README.md.
