freetype-ruby 1.0.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +175 -0
- data/Rakefile +8 -0
- data/examples/rugl_text.rb +127 -0
- data/examples/stagecraft_text.rb +71 -0
- data/lib/freetype/atlas.rb +127 -0
- data/lib/freetype/bitmap.rb +46 -0
- data/lib/freetype/charset.rb +13 -0
- data/lib/freetype/data/joyo_kanji.txt +1 -0
- data/lib/freetype/errors.rb +20 -0
- data/lib/freetype/face/rasterization.rb +78 -0
- data/lib/freetype/face.rb +186 -0
- data/lib/freetype/glyph.rb +20 -0
- data/lib/freetype/harfbuzz.rb +10 -0
- data/lib/freetype/image.rb +29 -0
- data/lib/freetype/library.rb +95 -0
- data/lib/freetype/metrics.rb +17 -0
- data/lib/freetype/native/structs.rb +73 -0
- data/lib/freetype/native.rb +102 -0
- data/lib/freetype/pool.rb +79 -0
- data/lib/freetype/text_run.rb +120 -0
- data/lib/freetype/version.rb +5 -0
- data/lib/freetype.rb +25 -0
- metadata +89 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: adc5b7ad1ba57d87445fae976c5efb291089b19029c1d2cb274adef20c38abc7
|
|
4
|
+
data.tar.gz: e95fad7662fee87f88d2eac4942ee7b6f92ffa0bf347abfc6f64132dfaa33f5a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f6670e92527f4a077838d143aa1b7fb9a7799a7e42082a271e8a89639844691452a737d305ad80effbc1d385daf035cf684bd3a56f6dd097ab441ba7c79a74f8
|
|
7
|
+
data.tar.gz: f0df4c355e61f1da405ac07423e0c1da67a15199b458c701a77e24ba87b2d898ef29c79705a8c847c7c87f98cac2677ca829c86f8607ffc28fe6b01c0dec4627
|
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,175 @@
|
|
|
1
|
+
# freetype
|
|
2
|
+
|
|
3
|
+
Ruby FFI bindings for FreeType 2.11 and newer. The public API converts all
|
|
4
|
+
FreeType 26.6 and 16.16 fixed-point values to pixel `Float`s and provides the
|
|
5
|
+
rasterization half of a shape → rasterize → atlas → GPU text pipeline.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Ruby 3.2 or newer
|
|
10
|
+
- A system FreeType library (2.11 or newer for SDF rendering)
|
|
11
|
+
- The `ffi` gem
|
|
12
|
+
- Optional: `texel` for `Texel::Image` bitmap values
|
|
13
|
+
- Optional: `harfbuzz-ruby` for shaped text meshes
|
|
14
|
+
- Optional: `rugl` plus GLFW/OpenGL for the GPU example
|
|
15
|
+
|
|
16
|
+
Install FreeType with the platform package manager, then add the gem:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
bundle add freetype
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The native loader searches `freetype`, `libfreetype.so.6`, and
|
|
23
|
+
`libfreetype.6.dylib`. Set `FREETYPE_LIBRARY` to an explicit shared-library
|
|
24
|
+
path when the system loader cannot find it.
|
|
25
|
+
|
|
26
|
+
## Faces and glyphs
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
require "freetype"
|
|
30
|
+
|
|
31
|
+
FreeType.open do |library|
|
|
32
|
+
library.face("NotoSansJP-Regular.ttf") do |face|
|
|
33
|
+
face.set_pixel_size(32)
|
|
34
|
+
|
|
35
|
+
p face.family_name
|
|
36
|
+
p face.style_name
|
|
37
|
+
p face.metrics # ascender, descender, line_height, max_advance in pixels
|
|
38
|
+
|
|
39
|
+
glyph = face.glyph("あ")
|
|
40
|
+
glyph = face.glyph_by_id(1234) # use this for HarfBuzz output
|
|
41
|
+
p [glyph.width, glyph.height, glyph.bearing_x, glyph.bearing_y, glyph.advance]
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`Library#face` also accepts font bytes. A memory face retains its own frozen
|
|
47
|
+
copy for the full face lifetime because FreeType does not copy the source:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
font_bytes = File.binread("font.ttf")
|
|
51
|
+
face = library.face(font_bytes)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Use `set_char_size(pt: 12, dpi: 96)` for point sizes. `Face#kerning` accepts
|
|
55
|
+
two glyph IDs. `Face#line_advance` is a small unshaped helper for simple text;
|
|
56
|
+
use HarfBuzz for script shaping, bidi text, and production layout.
|
|
57
|
+
|
|
58
|
+
The normal bitmap is an immutable `Texel::Image` with one `:u8` channel and
|
|
59
|
+
`:linear` color space when Texel is installed. Otherwise it is an immutable
|
|
60
|
+
`{ width:, height:, data: }` hash. Mono bitmaps are expanded to byte values
|
|
61
|
+
0 and 255, and all row pitch padding and bottom-up storage are normalized.
|
|
62
|
+
|
|
63
|
+
## SDF and atlases
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
glyph = face.glyph("A", mode: :sdf, spread: 8)
|
|
67
|
+
|
|
68
|
+
atlas = FreeType::Atlas.build(
|
|
69
|
+
face,
|
|
70
|
+
chars: FreeType::Charset::ASCII + "こんにちは世界".chars,
|
|
71
|
+
size: 48,
|
|
72
|
+
mode: :sdf,
|
|
73
|
+
padding: 2,
|
|
74
|
+
max_width: 1024
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
atlas.image # one-channel u8 image; both dimensions are powers of two
|
|
78
|
+
atlas.entries # glyph ID => normalized UV and pixel metrics
|
|
79
|
+
atlas.metrics
|
|
80
|
+
atlas.missing
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Pass `glyph_ids:` instead of `chars:` when shaping has already produced glyph
|
|
84
|
+
IDs. `Charset` includes `ASCII`, `LATIN1`, `KANA`, and the 2,136-character
|
|
85
|
+
`JOYO_KANJI` set. SDF rendering raises `FreeType::UnsupportedError` on
|
|
86
|
+
FreeType older than 2.11.
|
|
87
|
+
|
|
88
|
+
## HarfBuzz text meshes
|
|
89
|
+
|
|
90
|
+
Load the optional integration and configure the HarfBuzz font at 26.6 pixel
|
|
91
|
+
scale. This makes its advances and offsets use the same coordinate system as
|
|
92
|
+
the FreeType atlas:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
require "freetype/harfbuzz"
|
|
96
|
+
|
|
97
|
+
size = 48
|
|
98
|
+
hb_font.scale = [size * 64, size * 64]
|
|
99
|
+
hb_font.ppem = [size, size]
|
|
100
|
+
|
|
101
|
+
buffer = HarfBuzz::Buffer.new
|
|
102
|
+
buffer.add_utf8("グラフィックス強化中")
|
|
103
|
+
buffer.guess_segment_properties
|
|
104
|
+
HarfBuzz.shape(hb_font, buffer)
|
|
105
|
+
|
|
106
|
+
atlas = FreeType::Atlas.build(
|
|
107
|
+
face,
|
|
108
|
+
glyph_ids: buffer.glyph_infos.map(&:glyph_id),
|
|
109
|
+
size: size,
|
|
110
|
+
mode: :sdf
|
|
111
|
+
)
|
|
112
|
+
run = FreeType::TextRun.layout(buffer, atlas)
|
|
113
|
+
packed = run.to_packed
|
|
114
|
+
|
|
115
|
+
packed[:vertices] # little-endian f32 (x, y, u, v), four vertices per quad
|
|
116
|
+
packed[:indices] # little-endian u16, six indices per quad
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Quads use a baseline origin with y increasing downward. Missing atlas entries
|
|
120
|
+
do not emit a quad, but their HarfBuzz advances still move the cursor. See
|
|
121
|
+
[`examples/rugl_text.rb`](examples/rugl_text.rb) and
|
|
122
|
+
[`examples/stagecraft_text.rb`](examples/stagecraft_text.rb) for GPU handoff.
|
|
123
|
+
|
|
124
|
+
The Rugl example opens a real GLFW/OpenGL window. Its one-frame smoke mode
|
|
125
|
+
renders the SDF text, reads back the physical framebuffer (including HiDPI
|
|
126
|
+
framebuffers), and fails if no text pixels were produced:
|
|
127
|
+
|
|
128
|
+
```sh
|
|
129
|
+
FREETYPE_RUGL_SMOKE=1 bundle exec ruby \
|
|
130
|
+
examples/rugl_text.rb spec/fixtures/ABeeZee-Regular.ttf "GPU smoke"
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The Stagecraft example remains an adapter contract because no public
|
|
134
|
+
Stagecraft Ruby package/API is available. Its syntax and adapter boundary are
|
|
135
|
+
tested; the example exits with an explicit message instead of claiming an
|
|
136
|
+
unverified integration. It must be validated and adjusted when the real API is
|
|
137
|
+
published.
|
|
138
|
+
|
|
139
|
+
## Threading and lifetime
|
|
140
|
+
|
|
141
|
+
A FreeType library must not be shared by concurrent threads. Use one
|
|
142
|
+
`FreeType::Library` per thread, or use `FreeType::Pool`, which keeps a
|
|
143
|
+
thread-local library and face cache:
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
pool = FreeType::Pool.new
|
|
147
|
+
face = pool.face("font.ttf") # cached only in the current thread
|
|
148
|
+
# use pool independently from worker threads
|
|
149
|
+
pool.close
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Closing a library first closes every live face. Closing is idempotent. A
|
|
153
|
+
glyph bitmap is copied immediately because the native slot is overwritten by
|
|
154
|
+
the next glyph load. `Glyph#raw` intentionally exposes that transient native
|
|
155
|
+
slot for callers that need native fields.
|
|
156
|
+
|
|
157
|
+
## Development
|
|
158
|
+
|
|
159
|
+
```sh
|
|
160
|
+
bundle install
|
|
161
|
+
bundle exec rake spec
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The test suite includes an OFL font, bitmap goldens, pitch and fixed-point
|
|
165
|
+
tests, SDF coverage, atlas geometry checks, and a compiled ABI probe that
|
|
166
|
+
compares every field offset and total size of the public FreeType structs. CI
|
|
167
|
+
tests Ruby 3.2, 3.3, 3.4, 4.0, and head; builds FreeType 2.11.1 and 2.14.3 from
|
|
168
|
+
checksum-verified official sources; exercises real HarfBuzz and Texel gems;
|
|
169
|
+
and runs the Rugl readback test with Mesa under Xvfb. Ruby head is an
|
|
170
|
+
experimental compatibility signal and does not block stable releases.
|
|
171
|
+
|
|
172
|
+
## License
|
|
173
|
+
|
|
174
|
+
The gem is available under the MIT License. The bundled ABeeZee test font is
|
|
175
|
+
distributed under the SIL Open Font License; see `spec/fixtures/OFL.txt`.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Usage: bundle exec ruby examples/rugl_text.rb FONT_PATH [TEXT]
|
|
4
|
+
# Set FREETYPE_RUGL_SMOKE=1 to render one frame, verify it by framebuffer
|
|
5
|
+
# readback, and exit. The default remains an interactive window.
|
|
6
|
+
|
|
7
|
+
require "freetype/harfbuzz"
|
|
8
|
+
require "rugl"
|
|
9
|
+
|
|
10
|
+
font_path = ARGV[0] || abort("usage: #{$PROGRAM_NAME} FONT_PATH [TEXT]")
|
|
11
|
+
text = ARGV[1] || "FreeType + HarfBuzz + Rugl"
|
|
12
|
+
font_size = 64
|
|
13
|
+
smoke = ENV["FREETYPE_RUGL_SMOKE"] == "1"
|
|
14
|
+
|
|
15
|
+
blob = HarfBuzz::Blob.from_file!(font_path)
|
|
16
|
+
hb_face = HarfBuzz::Face.new(blob, 0)
|
|
17
|
+
hb_font = HarfBuzz::Font.new(hb_face)
|
|
18
|
+
hb_font.scale = [font_size * 64, font_size * 64]
|
|
19
|
+
hb_font.ppem = [font_size, font_size]
|
|
20
|
+
buffer = HarfBuzz::Buffer.new
|
|
21
|
+
buffer.add_utf8(text)
|
|
22
|
+
buffer.guess_segment_properties
|
|
23
|
+
HarfBuzz.shape(hb_font, buffer)
|
|
24
|
+
|
|
25
|
+
FreeType.open do |library|
|
|
26
|
+
library.face(font_path) do |face|
|
|
27
|
+
ids = buffer.glyph_infos.map(&:glyph_id)
|
|
28
|
+
atlas = FreeType::Atlas.build(
|
|
29
|
+
face, glyph_ids: ids, size: font_size, mode: :sdf,
|
|
30
|
+
padding: 2, max_width: 1024
|
|
31
|
+
)
|
|
32
|
+
packed = FreeType::TextRun.layout(buffer, atlas).to_packed
|
|
33
|
+
|
|
34
|
+
atlas_data = FreeType::Image.data(atlas.image)
|
|
35
|
+
rgba = atlas_data.each_byte.each_with_object(String.new(capacity: atlas_data.bytesize * 4).b) do |alpha, bytes|
|
|
36
|
+
bytes << 255 << 255 << 255 << alpha
|
|
37
|
+
end
|
|
38
|
+
image_width = atlas.image.respond_to?(:width) ? atlas.image.width : atlas.image.fetch(:width)
|
|
39
|
+
image_height = atlas.image.respond_to?(:height) ? atlas.image.height : atlas.image.fetch(:height)
|
|
40
|
+
|
|
41
|
+
rugl = Rugl.create(width: 960, height: 320, title: "FreeType SDF text")
|
|
42
|
+
vertices = indices = texture = draw_text = nil
|
|
43
|
+
begin
|
|
44
|
+
vertices = rugl.buffer(data: packed.fetch(:vertices), type: :float)
|
|
45
|
+
indices = rugl.elements(data: packed.fetch(:indices).unpack("S<*"), type: :uint16)
|
|
46
|
+
texture = rugl.texture(
|
|
47
|
+
width: image_width, height: image_height, format: :rgba,
|
|
48
|
+
type: :uint8, data: rgba, min_filter: :linear, mag_filter: :linear
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
draw_text = rugl.command(
|
|
52
|
+
vert: <<~GLSL,
|
|
53
|
+
#version 410 core
|
|
54
|
+
layout(location = 0) in vec2 position;
|
|
55
|
+
layout(location = 1) in vec2 uv;
|
|
56
|
+
uniform vec2 viewport;
|
|
57
|
+
uniform vec2 origin;
|
|
58
|
+
out vec2 text_uv;
|
|
59
|
+
void main() {
|
|
60
|
+
vec2 pixel = position + origin;
|
|
61
|
+
vec2 clip = vec2(pixel.x / viewport.x * 2.0 - 1.0,
|
|
62
|
+
1.0 - pixel.y / viewport.y * 2.0);
|
|
63
|
+
gl_Position = vec4(clip, 0.0, 1.0);
|
|
64
|
+
text_uv = uv;
|
|
65
|
+
}
|
|
66
|
+
GLSL
|
|
67
|
+
frag: <<~GLSL,
|
|
68
|
+
#version 410 core
|
|
69
|
+
in vec2 text_uv;
|
|
70
|
+
uniform sampler2D atlas_texture;
|
|
71
|
+
out vec4 frag_color;
|
|
72
|
+
void main() {
|
|
73
|
+
float distance_value = texture(atlas_texture, text_uv).a;
|
|
74
|
+
float alpha = smoothstep(0.45, 0.55, distance_value);
|
|
75
|
+
frag_color = vec4(0.92, 0.95, 1.0, alpha);
|
|
76
|
+
}
|
|
77
|
+
GLSL
|
|
78
|
+
attributes: {
|
|
79
|
+
position: { buffer: vertices, size: 2, stride: 16, offset: 0 },
|
|
80
|
+
uv: { buffer: vertices, size: 2, stride: 16, offset: 8 }
|
|
81
|
+
},
|
|
82
|
+
uniforms: {
|
|
83
|
+
viewport: [960.0, 320.0],
|
|
84
|
+
origin: [48.0, 150.0],
|
|
85
|
+
atlas_texture: texture
|
|
86
|
+
},
|
|
87
|
+
elements: indices,
|
|
88
|
+
viewport: {
|
|
89
|
+
width: Rugl.context(:framebuffer_width),
|
|
90
|
+
height: Rugl.context(:framebuffer_height)
|
|
91
|
+
},
|
|
92
|
+
blend: { enable: true, func: { src: :src_alpha, dst: :one_minus_src_alpha } }
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
rugl.frame(max_frames: smoke ? 1 : nil) do |context|
|
|
96
|
+
rugl.clear(color: [0.025, 0.035, 0.06, 1.0])
|
|
97
|
+
draw_text.call
|
|
98
|
+
|
|
99
|
+
next unless smoke
|
|
100
|
+
|
|
101
|
+
pixels = rugl.read(
|
|
102
|
+
width: context.fetch(:framebuffer_width),
|
|
103
|
+
height: context.fetch(:framebuffer_height)
|
|
104
|
+
)
|
|
105
|
+
maximum = [0, 0, 0, 0]
|
|
106
|
+
rendered = pixels.each_byte.each_slice(4).any? do |red, green, blue, alpha|
|
|
107
|
+
maximum[0] = [maximum[0], red].max
|
|
108
|
+
maximum[1] = [maximum[1], green].max
|
|
109
|
+
maximum[2] = [maximum[2], blue].max
|
|
110
|
+
maximum[3] = [maximum[3], alpha].max
|
|
111
|
+
red > 32 || green > 32 || blue > 32
|
|
112
|
+
end
|
|
113
|
+
unless rendered
|
|
114
|
+
abort("Rugl GPU smoke failed: no rendered pixels (maximum RGBA #{maximum.join(", ")})")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
puts "Rugl GPU smoke passed (OpenGL #{rugl.limits.fetch(:version)})"
|
|
118
|
+
end
|
|
119
|
+
ensure
|
|
120
|
+
draw_text&.destroy
|
|
121
|
+
texture&.destroy
|
|
122
|
+
indices&.destroy
|
|
123
|
+
vertices&.destroy
|
|
124
|
+
rugl.destroy
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Usage: bundle exec ruby examples/stagecraft_text.rb FONT_PATH [TEXT]
|
|
4
|
+
#
|
|
5
|
+
# Stagecraft's GPU backend is intentionally isolated behind the four-method
|
|
6
|
+
# adapter below. Map these calls to the backend version used by the host app;
|
|
7
|
+
# the FreeType/HarfBuzz side remains unchanged.
|
|
8
|
+
|
|
9
|
+
require "freetype/harfbuzz"
|
|
10
|
+
|
|
11
|
+
begin
|
|
12
|
+
require "stagecraft"
|
|
13
|
+
rescue LoadError
|
|
14
|
+
abort <<~MESSAGE
|
|
15
|
+
The Stagecraft Ruby package/API is not publicly available yet.
|
|
16
|
+
This example currently documents the StagecraftTextRenderer adapter contract;
|
|
17
|
+
validate and update it against the real API once that package is released.
|
|
18
|
+
MESSAGE
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class StagecraftTextRenderer
|
|
22
|
+
def initialize(stage, atlas, packed)
|
|
23
|
+
width = atlas.image.respond_to?(:width) ? atlas.image.width : atlas.image.fetch(:width)
|
|
24
|
+
height = atlas.image.respond_to?(:height) ? atlas.image.height : atlas.image.fetch(:height)
|
|
25
|
+
@stage = stage
|
|
26
|
+
@texture = stage.texture_2d(
|
|
27
|
+
width: width, height: height, format: :r8_unorm,
|
|
28
|
+
data: FreeType::Image.data(atlas.image)
|
|
29
|
+
)
|
|
30
|
+
@mesh = stage.indexed_mesh(
|
|
31
|
+
vertices: packed.fetch(:vertices), indices: packed.fetch(:indices),
|
|
32
|
+
vertex_stride: 16,
|
|
33
|
+
attributes: { position: [:float32x2, 0], uv: [:float32x2, 8] },
|
|
34
|
+
index_format: :uint16
|
|
35
|
+
)
|
|
36
|
+
@material = stage.sdf_text_material(texture: @texture, edge: 0.5, softness: 0.1)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def draw
|
|
40
|
+
@stage.draw_indexed(mesh: @mesh, material: @material)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
font_path = ARGV[0] || abort("usage: #{$PROGRAM_NAME} FONT_PATH [TEXT]")
|
|
45
|
+
text = ARGV[1] || "FreeType + HarfBuzz + Stagecraft"
|
|
46
|
+
font_size = 64
|
|
47
|
+
|
|
48
|
+
blob = HarfBuzz::Blob.from_file!(font_path)
|
|
49
|
+
hb_face = HarfBuzz::Face.new(blob, 0)
|
|
50
|
+
hb_font = HarfBuzz::Font.new(hb_face)
|
|
51
|
+
hb_font.scale = [font_size * 64, font_size * 64]
|
|
52
|
+
hb_font.ppem = [font_size, font_size]
|
|
53
|
+
buffer = HarfBuzz::Buffer.new
|
|
54
|
+
buffer.add_utf8(text)
|
|
55
|
+
buffer.guess_segment_properties
|
|
56
|
+
HarfBuzz.shape(hb_font, buffer)
|
|
57
|
+
|
|
58
|
+
FreeType.open do |library|
|
|
59
|
+
library.face(font_path) do |face|
|
|
60
|
+
atlas = FreeType::Atlas.build(
|
|
61
|
+
face, glyph_ids: buffer.glyph_infos.map(&:glyph_id),
|
|
62
|
+
size: font_size, mode: :sdf, padding: 2, max_width: 1024
|
|
63
|
+
)
|
|
64
|
+
packed = FreeType::TextRun.layout(buffer, atlas).to_packed
|
|
65
|
+
|
|
66
|
+
Stagecraft.run(title: "FreeType SDF text", width: 960, height: 320) do |stage|
|
|
67
|
+
renderer = StagecraftTextRenderer.new(stage, atlas, packed)
|
|
68
|
+
stage.frame { renderer.draw }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FreeType
|
|
4
|
+
class Atlas
|
|
5
|
+
Entry = Data.define(
|
|
6
|
+
:u0, :v0, :u1, :v1, :bearing_x, :bearing_y, :width, :height, :advance
|
|
7
|
+
)
|
|
8
|
+
Placement = Data.define(:glyph, :x, :y)
|
|
9
|
+
|
|
10
|
+
attr_reader :image, :entries, :metrics, :missing
|
|
11
|
+
|
|
12
|
+
def self.build(face, chars: nil, glyph_ids: nil, size:, mode: :gray, padding: 2, max_width: 1024, spread: 8)
|
|
13
|
+
new(face, chars: chars, glyph_ids: glyph_ids, size: size, mode: mode,
|
|
14
|
+
padding: padding, max_width: max_width, spread: spread)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(face, chars:, glyph_ids:, size:, mode:, padding:, max_width:, spread:)
|
|
18
|
+
validate_options!(chars, glyph_ids, size, padding, max_width)
|
|
19
|
+
face.set_pixel_size(size)
|
|
20
|
+
glyphs, @missing = rasterize(face, chars, glyph_ids, mode, spread)
|
|
21
|
+
placements, width, height = pack(glyphs, padding, max_width)
|
|
22
|
+
pixels = compose(placements, width, height, padding)
|
|
23
|
+
@image = Image.build(width: width, height: height, data: pixels)
|
|
24
|
+
@entries = build_entries(placements, width, height, padding).freeze
|
|
25
|
+
@metrics = face.metrics
|
|
26
|
+
@missing = @missing.freeze
|
|
27
|
+
freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def validate_options!(chars, glyph_ids, size, padding, max_width)
|
|
33
|
+
if chars.nil? == glyph_ids.nil?
|
|
34
|
+
raise ArgumentError, "provide exactly one of chars or glyph_ids"
|
|
35
|
+
end
|
|
36
|
+
raise ArgumentError, "size must be a positive integer" unless size.is_a?(Integer) && size.positive?
|
|
37
|
+
raise ArgumentError, "padding must be a non-negative integer" unless padding.is_a?(Integer) && padding >= 0
|
|
38
|
+
unless max_width.is_a?(Integer) && max_width.positive?
|
|
39
|
+
raise ArgumentError, "max_width must be a positive integer"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def rasterize(face, chars, glyph_ids, mode, spread)
|
|
44
|
+
ids = if glyph_ids
|
|
45
|
+
glyph_ids.to_a.map { |id| Integer(id) }
|
|
46
|
+
else
|
|
47
|
+
characters(chars).map { |character| face.char_index(character) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
missing = []
|
|
51
|
+
glyphs = ids.uniq.filter_map do |id|
|
|
52
|
+
face.glyph_by_id(id, mode: mode, spread: spread)
|
|
53
|
+
rescue NativeError, RangeError
|
|
54
|
+
missing << id
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
[glyphs, missing]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def characters(chars)
|
|
61
|
+
chars.is_a?(String) ? chars.each_char.to_a : chars.to_a
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def pack(glyphs, padding, max_width)
|
|
65
|
+
shelf_width = 1 << (max_width.bit_length - 1)
|
|
66
|
+
x = 0
|
|
67
|
+
y = 0
|
|
68
|
+
shelf_height = 0
|
|
69
|
+
used_width = 0
|
|
70
|
+
placements = []
|
|
71
|
+
|
|
72
|
+
glyphs.sort_by { |glyph| [-glyph.height, -glyph.width, glyph.id] }.each do |glyph|
|
|
73
|
+
packed_width = glyph.width + (padding * 2)
|
|
74
|
+
packed_height = glyph.height + (padding * 2)
|
|
75
|
+
if packed_width > shelf_width
|
|
76
|
+
raise AtlasError, "glyph #{glyph.id} is wider than max_width #{max_width} after power-of-two rounding"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
if x.positive? && x + packed_width > shelf_width
|
|
80
|
+
y += shelf_height
|
|
81
|
+
x = 0
|
|
82
|
+
shelf_height = 0
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
placements << Placement.new(glyph: glyph, x: x, y: y)
|
|
86
|
+
x += packed_width
|
|
87
|
+
shelf_height = [shelf_height, packed_height].max
|
|
88
|
+
used_width = [used_width, x].max
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
used_height = y + shelf_height
|
|
92
|
+
[placements.freeze, power_of_two([used_width, 1].max), power_of_two([used_height, 1].max)]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def compose(placements, width, height, padding)
|
|
96
|
+
pixels = "\0".b * (width * height)
|
|
97
|
+
placements.each do |placement|
|
|
98
|
+
glyph = placement.glyph
|
|
99
|
+
source = Image.data(glyph.bitmap)
|
|
100
|
+
glyph.height.times do |row|
|
|
101
|
+
destination_offset = ((placement.y + padding + row) * width) + placement.x + padding
|
|
102
|
+
pixels[destination_offset, glyph.width] = source.byteslice(row * glyph.width, glyph.width)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
pixels.freeze
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def build_entries(placements, width, height, padding)
|
|
109
|
+
placements.to_h do |placement|
|
|
110
|
+
glyph = placement.glyph
|
|
111
|
+
x = placement.x + padding
|
|
112
|
+
y = placement.y + padding
|
|
113
|
+
entry = Entry.new(
|
|
114
|
+
u0: x.fdiv(width), v0: y.fdiv(height),
|
|
115
|
+
u1: (x + glyph.width).fdiv(width), v1: (y + glyph.height).fdiv(height),
|
|
116
|
+
bearing_x: glyph.bearing_x, bearing_y: glyph.bearing_y,
|
|
117
|
+
width: glyph.width, height: glyph.height, advance: glyph.advance
|
|
118
|
+
)
|
|
119
|
+
[glyph.id, entry]
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def power_of_two(value)
|
|
124
|
+
1 << (value - 1).bit_length
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FreeType
|
|
4
|
+
module BitmapConverter
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def copy(bitmap)
|
|
8
|
+
width = bitmap[:width]
|
|
9
|
+
rows = bitmap[:rows]
|
|
10
|
+
return "".b.freeze if width.zero? || rows.zero?
|
|
11
|
+
|
|
12
|
+
buffer = bitmap[:buffer]
|
|
13
|
+
raise StateError, "FreeType returned a null bitmap buffer" if buffer.null?
|
|
14
|
+
|
|
15
|
+
case bitmap[:pixel_mode]
|
|
16
|
+
when Native::FT_PIXEL_MODE_GRAY
|
|
17
|
+
copy_gray(buffer, width, rows, bitmap[:pitch])
|
|
18
|
+
when Native::FT_PIXEL_MODE_MONO
|
|
19
|
+
copy_mono(buffer, width, rows, bitmap[:pitch])
|
|
20
|
+
else
|
|
21
|
+
raise UnsupportedError, "unsupported FreeType pixel mode #{bitmap[:pixel_mode]}"
|
|
22
|
+
end.freeze
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def copy_gray(buffer, width, rows, pitch)
|
|
26
|
+
rows.times.each_with_object(String.new(capacity: width * rows, encoding: Encoding::BINARY)) do |row, data|
|
|
27
|
+
data << row_pointer(buffer, row, pitch).get_bytes(0, width)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def copy_mono(buffer, width, rows, pitch)
|
|
32
|
+
packed_width = (width + 7) / 8
|
|
33
|
+
rows.times.each_with_object(String.new(capacity: width * rows, encoding: Encoding::BINARY)) do |row, data|
|
|
34
|
+
packed = row_pointer(buffer, row, pitch).get_bytes(0, packed_width)
|
|
35
|
+
width.times do |column|
|
|
36
|
+
byte = packed.getbyte(column / 8)
|
|
37
|
+
data << ((byte & (0x80 >> (column % 8))).zero? ? 0 : 255)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def row_pointer(buffer, row, pitch)
|
|
43
|
+
FFI::Pointer.new(:uchar, buffer.address + (row * pitch))
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FreeType
|
|
4
|
+
module Charset
|
|
5
|
+
ASCII = (0x20..0x7E).map { |codepoint| [codepoint].pack("U") }.freeze
|
|
6
|
+
LATIN1 = (0x20..0xFF).map { |codepoint| [codepoint].pack("U") }.freeze
|
|
7
|
+
KANA = [*(0x3040..0x309F), *(0x30A0..0x30FF)].map { |codepoint| [codepoint].pack("U") }.freeze
|
|
8
|
+
|
|
9
|
+
joyo_path = File.expand_path("data/joyo_kanji.txt", __dir__)
|
|
10
|
+
JOYO_KANJI = File.read(joyo_path, encoding: Encoding::UTF_8)
|
|
11
|
+
.each_char.reject { |character| character.match?(/\s/) }.freeze
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
亜哀挨愛悪握圧扱安暗案以衣位囲医依委威為胃尉異移偉意違維慰遺域育一壱逸茨芋引印因姻員院淫陰飲隠韻右宇羽雨唄鬱畝浦運雲永泳英映栄営詠影鋭衛易疫益液駅悦越謁閲円延沿炎怨宴媛援園煙猿遠鉛塩演縁艶汚王凹央応往押旺欧殴桜翁奥横岡屋億憶臆虞乙俺卸音恩温穏下化火加可仮何花佳価果河苛科架夏家荷華菓貨渦過嫁暇禍靴寡歌箇稼課蚊牙瓦我画芽賀雅餓介回灰会快戒改怪拐悔海界皆械絵開階塊楷解潰壊懐諧貝外劾害崖涯慨蓋該概骸垣柿各角拡革格核殻郭覚較隔閣確獲嚇穫学岳楽額顎掛潟括活喝渇割葛滑褐轄且株釜鎌刈干刊甘汗缶完肝官冠巻看陥乾勘患貫寒喚堪換敢棺款間閑勧寛幹感漢慣管関歓監緩憾還館環簡観韓艦鑑丸含岸岩玩眼頑顔願企伎危机気岐希忌汽奇祈季紀軌既記起飢鬼帰基寄規亀喜幾揮期棋貴棄毀旗器畿輝機騎技宜偽欺義疑儀戯擬犠議菊吉喫詰却客脚逆虐九久及弓丘旧休吸朽臼求究泣急級糾宮救球給嗅窮牛去巨居拒拠挙虚許距魚御漁凶共叫狂京享供協況峡挟狭恐恭胸脅強教郷境橋矯鏡競響驚仰暁業凝曲局極玉巾斤均近金菌勤琴筋僅禁緊錦謹襟吟銀区句苦駆具惧愚空偶遇隅串屈掘窟熊繰君訓勲薫軍郡群兄刑形系径茎係型契計恵啓掲渓経蛍敬景軽傾携継詣慶憬稽憩警鶏芸迎鯨隙劇撃激桁欠穴血決結傑潔月犬件見券肩建研県倹兼剣拳軒健険圏堅検嫌献絹遣権憲賢謙鍵繭顕験懸元幻玄言弦限原現舷減源厳己戸古呼固股虎孤弧故枯個庫湖雇誇鼓錮顧五互午呉後娯悟碁語誤護口工公勾孔功巧広甲交光向后好江考行坑孝抗攻更効幸拘肯侯厚恒洪皇紅荒郊香候校耕航貢降高康控梗黄喉慌港硬絞項溝鉱構綱酵稿興衡鋼講購乞号合拷剛傲豪克告谷刻国黒穀酷獄骨駒込頃今困昆恨根婚混痕紺魂墾懇左佐沙査砂唆差詐鎖座挫才再災妻采砕宰栽彩採済祭斎細菜最裁債催塞歳載際埼在材剤財罪崎作削昨柵索策酢搾錯咲冊札刷刹拶殺察撮擦雑皿三山参桟蚕惨産傘散算酸賛残斬暫士子支止氏仕史司四市矢旨死糸至伺志私使刺始姉枝祉肢姿思指施師恣紙脂視紫詞歯嗣試詩資飼誌雌摯賜諮示字寺次耳自似児事侍治持時滋慈辞磁餌璽鹿式識軸七叱失室疾執湿嫉漆質実芝写社車舎者射捨赦斜煮遮謝邪蛇尺借酌釈爵若弱寂手主守朱取狩首殊珠酒腫種趣寿受呪授需儒樹収囚州舟秀周宗拾秋臭修袖終羞習週就衆集愁酬醜蹴襲十汁充住柔重従渋銃獣縦叔祝宿淑粛縮塾熟出述術俊春瞬旬巡盾准殉純循順準潤遵処初所書庶暑署緒諸女如助序叙徐除小升少召匠床抄肖尚招承昇松沼昭宵将消症祥称笑唱商渉章紹訟勝掌晶焼焦硝粧詔証象傷奨照詳彰障憧衝賞償礁鐘上丈冗条状乗城浄剰常情場畳蒸縄壌嬢錠譲醸色拭食植殖飾触嘱織職辱尻心申伸臣芯身辛侵信津神唇娠振浸真針深紳進森診寝慎新審震薪親人刃仁尽迅甚陣尋腎須図水吹垂炊帥粋衰推酔遂睡穂随髄枢崇数据杉裾寸瀬是井世正生成西声制姓征性青斉政星牲省凄逝清盛婿晴勢聖誠精製誓静請整醒税夕斥石赤昔析席脊隻惜戚責跡積績籍切折拙窃接設雪摂節説舌絶千川仙占先宣専泉浅洗染扇栓旋船戦煎羨腺詮践箋銭潜線遷選薦繊鮮全前善然禅漸膳繕狙阻祖租素措粗組疎訴塑遡礎双壮早争走奏相荘草送倉捜挿桑巣掃曹曽爽窓創喪痩葬装僧想層総遭槽踪操燥霜騒藻造像増憎蔵贈臓即束足促則息捉速側測俗族属賊続卒率存村孫尊損遜他多汰打妥唾堕惰駄太対体耐待怠胎退帯泰堆袋逮替貸隊滞態戴大代台第題滝宅択沢卓拓託濯諾濁但達脱奪棚誰丹旦担単炭胆探淡短嘆端綻誕鍛団男段断弾暖談壇地池知値恥致遅痴稚置緻竹畜逐蓄築秩窒茶着嫡中仲虫沖宙忠抽注昼柱衷酎鋳駐著貯丁弔庁兆町長挑帳張彫眺釣頂鳥朝超腸跳徴嘲潮澄調聴懲直勅捗沈珍朕陳賃鎮追椎墜通痛塚漬坪爪鶴低呈廷弟定底抵邸亭貞帝訂庭逓停偵堤提程艇締諦泥的笛摘滴適敵溺哲鉄徹撤天典店点展添転塡田伝殿電斗吐妬途都渡塗賭土奴努度怒刀冬灯当投豆東到逃倒凍唐島桃討透党悼盗陶塔搭棟湯痘登答等筒統稲踏糖頭謄藤闘騰同洞胴動堂童道働銅導瞳峠匿特得督徳篤毒独読栃凸突届屯豚頓貪鈍曇丼那奈内梨謎鍋南軟難二尼弐肉虹日入乳尿任妊忍認寧熱年念捻粘燃悩納能脳農濃把波派破覇馬婆罵拝杯背肺俳配排敗廃輩売倍梅培陪媒買賠白伯拍泊迫剝舶博薄麦漠縛爆箱箸畑肌八鉢発髪伐抜罰閥反半氾犯帆汎伴判坂阪板版班畔般販斑飯搬煩頒範繁藩晩番蛮盤比皮妃否批彼披肥非卑飛疲秘被悲扉費碑罷避尾眉美備微鼻膝肘匹必泌筆姫百氷表俵票評漂標苗秒病描猫品浜貧賓頻敏瓶不夫父付布扶府怖阜附訃負赴浮婦符富普腐敷膚賦譜侮武部舞封風伏服副幅復福腹複覆払沸仏物粉紛雰噴墳憤奮分文聞丙平兵併並柄陛閉塀幣弊蔽餅米壁璧癖別蔑片辺返変偏遍編弁便勉歩保哺捕補舗母募墓慕暮簿方包芳邦奉宝抱放法泡胞俸倣峰砲崩訪報蜂豊飽褒縫亡乏忙坊妨忘防房肪某冒剖紡望傍帽棒貿貌暴膨謀頰北木朴牧睦僕墨撲没勃堀本奔翻凡盆麻摩磨魔毎妹枚昧埋幕膜枕又末抹万満慢漫未味魅岬密蜜脈妙民眠矛務無夢霧娘名命明迷冥盟銘鳴滅免面綿麺茂模毛妄盲耗猛網目黙門紋問冶夜野弥厄役約訳薬躍闇由油喩愉諭輸癒唯友有勇幽悠郵湧猶裕遊雄誘憂融優与予余誉預幼用羊妖洋要容庸揚揺葉陽溶腰様瘍踊窯養擁謡曜抑沃浴欲翌翼拉裸羅来雷頼絡落酪辣乱卵覧濫藍欄吏利里理痢裏履璃離陸立律慄略柳流留竜粒隆硫侶旅虜了両良料涼猟陵量僚領寮療瞭糧力緑林厘倫輪隣臨瑠涙累塁類令礼冷励戻例鈴零霊隷齢麗暦歴列劣烈裂恋連廉練錬呂炉賂路露老労弄郎朗浪廊楼漏籠六録麓論和話賄脇惑枠湾腕匂咽宛嵐彙徒慮曖椅畏緯萎街貼迭
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FreeType
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
class ClosedError < Error; end
|
|
6
|
+
class StateError < Error; end
|
|
7
|
+
class UnsupportedError < Error; end
|
|
8
|
+
class AtlasError < Error; end
|
|
9
|
+
|
|
10
|
+
class NativeError < Error
|
|
11
|
+
attr_reader :code, :name
|
|
12
|
+
|
|
13
|
+
def initialize(code, operation: nil)
|
|
14
|
+
@code = Integer(code)
|
|
15
|
+
@name = Native::ERROR_NAMES.fetch(@code, :Unknown_Error)
|
|
16
|
+
prefix = operation ? "#{operation} failed: " : ""
|
|
17
|
+
super(format("%sFreeType error 0x%02X (%s)", prefix, @code, @name))
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|