alhena 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 35e4670f6299d744074cb8bc0c64eba91b2f0691d07f1034b9a5ae7423c9dffa
4
+ data.tar.gz: 0df90b79072d3f967af91362b80b4cd7ba3c78dcf72a8e3e61d7227c1933ec91
5
+ SHA512:
6
+ metadata.gz: 4fd434b589dc0feb1c2e71ab0553b3b8059e11613ffa6cbeae33550456c10a2547e811b61fd94a6310e5f53248459967ff2ecc634fc9eda389e2876d62554bd3
7
+ data.tar.gz: 2b80e1d2e093842035364cecc9ad4a5be2389dcdfbd4589ebc776c99f95be3149451fd9977c2455bba5c5da824d4601cc0d1059ff68357487ba7a28eb66bb991
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-09-11
4
+
5
+ - Initial release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,178 @@
1
+ <div align="center">
2
+ <h1>Alhena</h1>
3
+ <p><strong>Pure Ruby TrueType/OpenType/TTC font rasterization with antialiased glyph bitmaps</strong></p>
4
+ <p>
5
+ <a href="https://rubygems.org/gems/alhena"><img src="https://img.shields.io/gem/v/alhena.svg?colorB=319e8c" alt="Gem Version"></a>
6
+ <a href="https://rubygems.org/gems/alhena"><img src="https://img.shields.io/gem/dt/alhena.svg" alt="Downloads"></a>
7
+ <a href="https://github.com/noxdea/alhena/actions/workflows/main.yml"><img src="https://github.com/noxdea/alhena/actions/workflows/main.yml/badge.svg" alt="CI"></a>
8
+ <img src="https://img.shields.io/badge/ruby-%3E%3D%203.1-ruby.svg" alt="Ruby Version">
9
+ <a href="LICENSE.txt"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License"></a>
10
+ </p>
11
+ </div>
12
+
13
+ <p align="center">
14
+ <a href="#features">Features</a> ·
15
+ <a href="#installation">Installation</a> ·
16
+ <a href="#quick-start">Quick Start</a> ·
17
+ <a href="#usage">Usage</a> ·
18
+ <a href="#supported-formats">Supported Formats</a> ·
19
+ <a href="#development">Development</a>
20
+ </p>
21
+
22
+ ---
23
+
24
+ Alhena reads TrueType, OpenType, and TTC fonts, extracts their outlines, and rasterizes antialiased grayscale, LCD, and color glyph bitmaps. It requires Ruby 3.1+ and has no runtime gem dependencies or native extensions.
25
+
26
+ ## Features
27
+
28
+ - TrueType, OpenType CFF1/CFF2, and TTC font parsing
29
+ - Analytic grayscale and LCD rasterization with subpixel positioning
30
+ - Variable font axes, outlines, and metrics
31
+ - COLR/CPAL, sbix, and CBDT/CBLC color glyphs
32
+ - Entry- and byte-bounded LRU glyph cache
33
+ - Lazy, bounds-checked table parsing
34
+ - RBS type signatures
35
+
36
+ ## Installation
37
+
38
+ Add Alhena to your Gemfile:
39
+
40
+ ```ruby
41
+ gem "alhena"
42
+ ```
43
+
44
+ Then run:
45
+
46
+ ```sh
47
+ bundle install
48
+ ```
49
+
50
+ Or install it directly:
51
+
52
+ ```sh
53
+ gem install alhena
54
+ ```
55
+
56
+ ## Quick Start
57
+
58
+ ```ruby
59
+ require "alhena"
60
+
61
+ font = Alhena::Font.open("/path/to/font.ttf")
62
+ glyph = font.glyph_id("A")
63
+ bitmap = font.rasterize(glyph, size: 24)
64
+
65
+ puts font.family
66
+ puts bitmap.to_ascii
67
+ ```
68
+
69
+ `bitmap.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:
70
+
71
+ ```ruby
72
+ advance = font.advance(glyph, size: 24)
73
+ ```
74
+
75
+ ## Usage
76
+
77
+ ### Fonts and metrics
78
+
79
+ `Font.open(path, index: 0)` reads a font file. `Font.new(bytes, index: 0)` accepts font bytes directly. Tables are parsed on demand.
80
+
81
+ 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.
82
+
83
+ ### Outlines and rasterization
84
+
85
+ ```ruby
86
+ outline = font.outline(glyph) # font coordinates, Y up
87
+ outline.each { |operation, *coordinates| p [operation, coordinates] }
88
+
89
+ path = Alhena::Outline.new
90
+ path.move_to(0, 0).quad_to(50, 100, 100, 0).close
91
+ bitmap = Alhena::Rasterizer.new(width: 100, height: 100).fill(path)
92
+ ```
93
+
94
+ `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.
95
+
96
+ `Font#rasterize` and `Rasterizer#fill` accept `gamma:`, `darkening:`, and `lcd: :rgb` or `:bgr`. Use grayscale when the display subpixel order is unknown.
97
+
98
+ ### Glyph cache
99
+
100
+ ```ruby
101
+ cache = Alhena::Cache.new(capacity: 4096, max_bytes: 16 * 1024 * 1024)
102
+ cache.prewarm(font, "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ", size: 24)
103
+ bitmap = cache.rasterize(font, glyph, size: 24, subpixel_x: 0.25)
104
+ ```
105
+
106
+ 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.
107
+
108
+ ### Variable fonts
109
+
110
+ ```ruby
111
+ font.axes
112
+ bold = font.variation(wght: 700, wdth: 90)
113
+ ```
114
+
115
+ You can also pass axes to `Font.open(path, axes: {wght: 700})`. Unspecified axes use their defaults, and out-of-range values are clamped.
116
+
117
+ ### Color glyphs
118
+
119
+ ```ruby
120
+ color = font.color_bitmap(glyph, size: 48, palette: 0)
121
+ rgba = color&.rgba
122
+ ```
123
+
124
+ `ColorBitmap#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.
125
+
126
+ ## Supported Formats
127
+
128
+ - sfnt TrueType, OpenType CFF1/CFF2, and TTC collections
129
+ - Unicode `cmap` formats 0, 4, 6, 12, 13, and 14
130
+ - Simple and composite `glyf` outlines with short or long `loca`
131
+ - Type 2 charstrings, CID CFF, variation stores, and CFF2 blends
132
+ - `fvar`, `gvar`, `avar` v1, HVAR, and VVAR variable font data
133
+ - COLR/CPAL v0, sbix PNG, and CBDT/CBLC PNG, grayscale, BGRA, and composite bitmaps
134
+
135
+ ### Limits
136
+
137
+ 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.
138
+
139
+ Unknown formats raise `Alhena::UnsupportedFont`; malformed bounds and structures raise `Alhena::InvalidFont`. Bitmap allocations are limited to 16,777,216 samples.
140
+
141
+ ## Performance
142
+
143
+ 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.
144
+
145
+ | Operation | Measured | Budget |
146
+ |---|---:|---:|
147
+ | Open Noto Sans (569,208 bytes) | 64.73 µs | 30,000 µs |
148
+ | A at 14px, uncached | 40.11 µs | 500 µs |
149
+ | A at 14px, cache hit | 0.48 µs | 5 µs |
150
+ | ASCII 95 glyph prewarm | 4.79 ms | 60 ms |
151
+ | 鬱 at 48px, uncached | 0.47 ms | 3 ms |
152
+
153
+ Cache glyphs in interactive applications so each glyph is normally rasterized once per size and position. These measurements are local evidence, not universal guarantees.
154
+
155
+ ## Development
156
+
157
+ ```sh
158
+ bundle install
159
+ bundle exec rake test
160
+ bundle exec rake test:oracle
161
+ bundle exec rake test:fuzz
162
+ bundle exec rake bench:assert
163
+ ```
164
+
165
+ Oracle 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.
166
+
167
+ Render the bundled examples with:
168
+
169
+ ```sh
170
+ bundle exec ruby examples/render.rb test/fonts/NotoSans-Regular.ttf "Hello, Ruby!" 48 text.png
171
+ bundle exec ruby examples/color.rb test/fonts/NotoColorEmoji.ttf "😀" 64 color.png
172
+ ```
173
+
174
+ See the [changelog](CHANGELOG.md) for release history. Bug reports and pull requests are welcome on [GitHub](https://github.com/noxdea/alhena).
175
+
176
+ ## License
177
+
178
+ Alhena is available under the [MIT License](LICENSE.txt). Test font licenses and upstream sources are recorded in [test/fonts/README.md](test/fonts/README.md).
data/examples/color.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "alhena"
4
+ require "chunky_png"
5
+
6
+ font = Alhena::Font.open(ARGV.fetch(0))
7
+ glyph = font.glyph_id(ARGV.fetch(1, "😀"))
8
+ bitmap = font.color_bitmap(glyph, size: Integer(ARGV.fetch(2, "64")))
9
+ abort "The glyph has no supported color image" unless bitmap
10
+ image = ChunkyPNG::Image.new(bitmap.width, bitmap.height, bitmap.rgba.unpack("N*"))
11
+ image.save(ARGV.fetch(3, "color.png"))
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+ require "alhena"
3
+ require "chunky_png"
4
+
5
+ font = Alhena::Font.open(ARGV.fetch(0))
6
+ text, size = ARGV.fetch(1, "Hello, Ruby!"), Integer(ARGV.fetch(2, "48"))
7
+ glyphs = text.codepoints.map { |code| font.glyph_id(code) }
8
+ width = glyphs.sum { |glyph| font.advance(glyph, size: size) }.ceil + 16
9
+ height = ((font.ascent - font.descent) * size.to_f / font.units_per_em).ceil + 16
10
+ baseline = (font.ascent * size.to_f / font.units_per_em).ceil + 8
11
+ image = ChunkyPNG::Image.new(width, height, ChunkyPNG::Color::WHITE)
12
+ x = 8.0
13
+ glyphs.each do |glyph|
14
+ bitmap = font.rasterize(glyph, size: size, subpixel_x: x % 1)
15
+ bitmap.coverage.each_byte.with_index do |value, i|
16
+ col, row = x.floor + bitmap.left + i % bitmap.width, baseline - bitmap.top + i / bitmap.width
17
+ image[col, row] = ChunkyPNG::Color.grayscale(255 - value) if col >= 0 && col < width && row >= 0 && row < height
18
+ end
19
+ x += font.advance(glyph, size: size)
20
+ end
21
+ image.save(ARGV.fetch(3, "text.png"))
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alhena
4
+ # Bounded big-endian reads shared by sfnt, glyf and CFF.
5
+ class Binary
6
+ attr_reader :data
7
+ attr_accessor :position
8
+
9
+ def initialize(data)
10
+ @data, @position = data, 0
11
+ end
12
+
13
+ def size = @data.bytesize
14
+
15
+ def bytes(offset, length)
16
+ validate_bounds(offset, length)
17
+ @data.byteslice(offset, length)
18
+ end
19
+
20
+ def validate_bounds(offset, length)
21
+ raise InvalidFont, "font data out of bounds at #{offset} (#{length} bytes)" unless offset.is_a?(Integer) && length.is_a?(Integer) && offset >= 0 && length >= 0 && offset <= size - length
22
+ end
23
+ alias check validate_bounds
24
+
25
+ def u8(offset = nil) = number(offset, 1, "C")
26
+ def i8(offset = nil) = number(offset, 1, "c")
27
+ def u16(offset = nil) = number(offset, 2, "n")
28
+ def i16(offset = nil) = number(offset, 2, "s>")
29
+ def u32(offset = nil) = number(offset, 4, "N")
30
+ def i32(offset = nil) = number(offset, 4, "l>")
31
+ def fixed(offset = nil) = i32(offset) / 65_536.0
32
+
33
+ def u24(offset = nil)
34
+ offset ||= @position
35
+ validate_bounds(offset, 3)
36
+ @position = offset + 3
37
+ (@data.getbyte(offset) << 16) | (@data.getbyte(offset + 1) << 8) | @data.getbyte(offset + 2)
38
+ end
39
+
40
+ private
41
+
42
+ def number(offset, length, format)
43
+ offset ||= @position
44
+ validate_bounds(offset, length)
45
+ @position = offset + length
46
+ @data.unpack1(format, offset: offset)
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alhena
4
+ # Immutable, tightly packed R8 or RGB8 coverage. Y increases downwards.
5
+ class Bitmap
6
+ attr_reader :width, :height, :left, :top, :coverage, :channels
7
+
8
+ def initialize(width:, height:, coverage:, left: 0, top: 0, channels: 1)
9
+ unless [width, height].all? { |n| n.is_a?(Integer) && n >= 0 } && [1, 3].include?(channels) && coverage.bytesize == width * height * channels
10
+ raise ArgumentError, "invalid bitmap dimensions or coverage length"
11
+ end
12
+ @width, @height, @left, @top, @channels = width, height, left, top, channels
13
+ @coverage = coverage.b.freeze
14
+ freeze
15
+ end
16
+
17
+ def to_ascii(ramp: " .:-=+*#%@")
18
+ raise ArgumentError, "ramp is empty" if ramp.empty?
19
+ (0...height).map do |y|
20
+ (0...width).map do |x|
21
+ value = (0...channels).sum { |c| coverage.getbyte((y * width + x) * channels + c) } / channels
22
+ ramp[(value * (ramp.length - 1) / 255.0).round]
23
+ end.join
24
+ end.join("\n")
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alhena
4
+ # Per-instance LRU, bounded by entry count and optionally coverage bytes.
5
+ class Cache
6
+ attr_reader :capacity, :bytesize
7
+
8
+ def initialize(capacity: 4096, max_bytes: nil)
9
+ raise ArgumentError, "capacity must be positive" unless capacity.is_a?(Integer) && capacity > 0
10
+ raise ArgumentError, "max_bytes must be positive" if max_bytes && !(max_bytes.is_a?(Integer) && max_bytes > 0)
11
+ @capacity, @max_bytes, @bytesize, @entries = capacity, max_bytes, 0, {}
12
+ end
13
+
14
+ def size = @entries.size
15
+
16
+ # @return [Bitmap] immutable coverage, reused on an LRU cache hit
17
+ def rasterize(font, glyph, size:, subpixel_x: 0, **options)
18
+ raise ArgumentError, "invalid subpixel position" unless subpixel_x.is_a?(Numeric) && subpixel_x.finite?
19
+ bucket = (subpixel_x * 4).round
20
+ key = [font, glyph, size, bucket, options]
21
+ if (hit = @entries.delete(key))
22
+ return @entries[key] = hit
23
+ end
24
+ bitmap = font.rasterize(glyph, size: size, subpixel_x: bucket / 4.0, **options)
25
+ return bitmap if @max_bytes && bitmap.coverage.bytesize > @max_bytes
26
+ @entries[key] = bitmap
27
+ @bytesize += bitmap.coverage.bytesize
28
+ while @entries.size > @capacity || (@max_bytes && @bytesize > @max_bytes)
29
+ @bytesize -= @entries.shift.last.coverage.bytesize
30
+ end
31
+ bitmap
32
+ end
33
+
34
+ def prewarm(font, text, size = nil, **options)
35
+ size ||= options.delete(:size)
36
+ text.each_codepoint { |code| rasterize(font, font.glyph_id(code), size: size, **options) }
37
+ self
38
+ end
39
+
40
+ def clear
41
+ @entries.clear
42
+ @bytesize = 0
43
+ self
44
+ end
45
+ end
46
+ end