glyphic 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: 9ed1aac5cdc8652bcd5f5bc782e909e140f0ce5e97fe2225327066a59901a093
4
+ data.tar.gz: 8d113109ea36b66cd55356f487fac8496a24424240cf918ebf3d71c69ab7a9d3
5
+ SHA512:
6
+ metadata.gz: 28995db31a3cebb4e6ddb3958bb1b6d4459598e221e2726de79917d47ec3b6c8b711863b75764b1a25ef5943556ca17f7e510dab9aa0ab20af4e8bad1e8a4a9a
7
+ data.tar.gz: 26e4db1c8d9d0f4231256eeefbd053ad8aa1d715c5845878a656135d2a85e8a1aa129837ed2751545e3a2442d873bef08c697e25700ada05682683707e0b21ad
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.1.0] - 2026-09-23
6
+
7
+ - Initial release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 ydah
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,69 @@
1
+ # Glyphic
2
+
3
+ [![Gem version](https://badge.fury.io/rb/glyphic.svg)](https://rubygems.org/gems/glyphic)
4
+ [![Downloads](https://img.shields.io/gem/dt/glyphic?label=downloads)](https://rubygems.org/gems/glyphic)
5
+ [![CI](https://github.com/rbgfx/glyphic/actions/workflows/ci.yml/badge.svg)](https://github.com/rbgfx/glyphic/actions/workflows/ci.yml)
6
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D3.1-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org/)
7
+ [![License](https://img.shields.io/badge/license-MIT-750014.svg)](LICENSE.txt)
8
+
9
+ > Bitmap and outline font rendering for Ruby graphics.
10
+
11
+ Glyphic loads BDF bitmap fonts and TrueType outlines, measures UTF-8 text, and
12
+ draws it onto Tessel RGBA8 images.
13
+
14
+ **[Features](#features) · [Installation](#installation) · [Quick start](#quick-start) · [Development](#development)**
15
+
16
+ ## Features
17
+
18
+ - BDF bitmap font loading.
19
+ - TrueType (.ttf) outline loading and rasterization.
20
+ - UTF-8 text measurement, wrapping, and text boxes.
21
+ - Fallback chains for missing glyphs.
22
+ - Direct rendering into Tessel images.
23
+ - A built-in font for examples and tools.
24
+
25
+ ## Installation
26
+
27
+ Add Glyphic to your Gemfile:
28
+
29
+ ~~~ruby
30
+ gem "glyphic"
31
+ ~~~
32
+
33
+ Then run:
34
+
35
+ ~~~sh
36
+ bundle install
37
+ ~~~
38
+
39
+ Or install the released gem:
40
+
41
+ ~~~sh
42
+ gem install glyphic
43
+ ~~~
44
+
45
+ ## Quick start
46
+
47
+ ~~~ruby
48
+ require "glyphic"
49
+ require "tessel"
50
+
51
+ font = Glyphic.default
52
+ image = Tessel::Image.new(240, 40, fill: "#101827")
53
+ font.draw(image, 4, 4, "Hello, rbgfx", color: "#ffffff")
54
+ image.write("hello.png")
55
+ ~~~
56
+
57
+ Load an external font with <code>Glyphic.load(path)</code>. BDF and TrueType
58
+ fonts are supported; CFF/OpenType outlines are currently unsupported.
59
+
60
+ ## Development
61
+
62
+ ~~~sh
63
+ bundle install
64
+ bundle exec rake verify
65
+ ~~~
66
+
67
+ ## License
68
+
69
+ [MIT](LICENSE.txt)
data/Rakefile ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec) { |task| task.ruby_opts = ["-I../tessel/lib"] }
7
+
8
+ task default: :spec
9
+ task verify: :spec
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glyphic
4
+ VERSION = "0.1.0"
5
+ end
data/lib/glyphic.rb ADDED
@@ -0,0 +1,786 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tessel"
4
+
5
+ require_relative "glyphic/version"
6
+
7
+ module Glyphic
8
+ class Error < StandardError; end
9
+ class UnsupportedError < ArgumentError; end
10
+
11
+ Glyph = Struct.new(:advance, :bearing_x, :bearing_y, :width, :height, :alpha, :runs, keyword_init: true) do
12
+ def initialize(**attributes)
13
+ super
14
+ self.alpha = String(alpha || "").b.freeze
15
+ self.runs ||= build_runs
16
+ freeze
17
+ end
18
+
19
+ def bytes = alpha
20
+
21
+ private
22
+
23
+ def build_runs
24
+ rows = []
25
+ (0...height).each do |y|
26
+ x = 0
27
+ while x < width
28
+ x += 1 while x < width && alpha.getbyte(y * width + x).zero?
29
+ start = x
30
+ x += 1 while x < width && alpha.getbyte(y * width + x).positive?
31
+ rows << [start, x - 1, y] if x > start
32
+ end
33
+ end
34
+ rows.freeze
35
+ end
36
+ end
37
+
38
+ class Font
39
+ attr_reader :line_height, :ascent, :descent
40
+
41
+ def initialize(glyphs, line_height:, ascent:, descent: 0, fallback: nil, skipped_glyphs: 0)
42
+ @glyphs = glyphs
43
+ @line_height = line_height
44
+ @ascent = ascent
45
+ @descent = descent
46
+ @fallback = fallback
47
+ @skipped_glyphs = skipped_glyphs
48
+ end
49
+
50
+ attr_reader :skipped_glyphs
51
+
52
+ def glyph?(character)
53
+ @glyphs.key?(character.to_s.codepoints.first)
54
+ end
55
+
56
+ def glyph(character)
57
+ @glyphs[character.to_s.codepoints.first] || @fallback || @glyphs[63] || blank_glyph
58
+ end
59
+
60
+ def measure(text)
61
+ lines = Layout.lines(text, self)
62
+ [lines.map { |line| Layout.width(line, self) }.max.to_i, lines.length * line_height]
63
+ end
64
+
65
+ def text_width(text)
66
+ measure(text)[0]
67
+ end
68
+
69
+ def kerning(_left, _right)
70
+ 0
71
+ end
72
+
73
+ def draw(target, x, y, text, color: [255, 255, 255, 255], baseline: false, **options)
74
+ Renderer.draw(self, target, x, y, text, color: color, baseline: baseline, **options)
75
+ end
76
+
77
+ def draw_text_box(target, x:, y:, width:, text:, color: [255, 255, 255, 255], align: :left, wrap: :word, line_spacing: 1.0, **options)
78
+ Renderer.draw_box(self, target, x: x, y: y, width: width, text: text, color: color, align: align, wrap: wrap, line_spacing: line_spacing, **options)
79
+ end
80
+
81
+ def render(text, color: [255, 255, 255, 255], padding: 0)
82
+ width, height = measure(text)
83
+ image = Tessel::Image.new(width + padding * 2, height + padding * 2)
84
+ draw(image, padding, padding, text, color: color)
85
+ image
86
+ end
87
+
88
+ private
89
+
90
+ def blank_glyph
91
+ @blank_glyph ||= Glyph.new(advance: line_height / 2, bearing_x: 0, bearing_y: ascent, width: 0, height: 0, alpha: "".b)
92
+ end
93
+ end
94
+
95
+ module BDF
96
+ module_function
97
+
98
+ def load(source)
99
+ text = if source.respond_to?(:read)
100
+ source.read
101
+ elsif source.is_a?(String) && source.lstrip.start_with?("STARTFONT")
102
+ source
103
+ else
104
+ File.read(source)
105
+ end
106
+ lines = text.lines.map(&:strip)
107
+ raise ArgumentError, "not a BDF font" unless lines.first == "STARTFONT 2.1" || lines.first&.start_with?("STARTFONT")
108
+
109
+ ascent = lines.find { |line| line.start_with?("FONT_ASCENT") }&.split&.last.to_i
110
+ descent = lines.find { |line| line.start_with?("FONT_DESCENT") }&.split&.last.to_i
111
+ registry = lines.find { |line| line.start_with?("CHARSET_REGISTRY") }&.split(" ", 2)&.last.to_s.delete('"')
112
+ glyphs = {}
113
+ skipped = 0
114
+ index = 0
115
+ while index < lines.length
116
+ unless lines[index].start_with?("STARTCHAR")
117
+ index += 1
118
+ next
119
+ end
120
+ block = []
121
+ index += 1
122
+ while index < lines.length && lines[index] != "ENDCHAR"
123
+ block << lines[index]
124
+ index += 1
125
+ end
126
+ encoded = block.find { |line| line.start_with?("ENCODING") }&.split&.last
127
+ code = encoded&.to_i
128
+ if code && code >= 0
129
+ code = jis_to_unicode(code) if registry.start_with?("JISX0208")
130
+ if code
131
+ glyph = parse_glyph(block, advance: block.find { |line| line.start_with?("DWIDTH") }&.split&.last.to_i)
132
+ glyphs[code] = glyph
133
+ else
134
+ skipped += 1
135
+ end
136
+ else
137
+ skipped += 1
138
+ end
139
+ index += 1
140
+ end
141
+ Font.new(glyphs, line_height: ascent + descent, ascent: ascent, descent: descent, skipped_glyphs: skipped)
142
+ end
143
+
144
+ def parse_glyph(block, advance:)
145
+ bbx = block.find { |line| line.start_with?("BBX") }.split.drop(1).map(&:to_i)
146
+ width, height, offset_x, offset_y = bbx
147
+ bitmap_start = block.index("BITMAP")
148
+ rows = block[(bitmap_start + 1), height].to_a
149
+ alpha = rows.map do |line|
150
+ value = line.to_i(16)
151
+ (0...width).map { |x| (value & (1 << ([line.length * 4, width].max - x - 1))).positive? ? 255 : 0 }
152
+ end.flatten.pack("C*")
153
+ Glyph.new(advance: advance, bearing_x: offset_x, bearing_y: offset_y + height, width: width, height: height, alpha: alpha)
154
+ end
155
+ private_class_method :parse_glyph
156
+
157
+ def jis_to_unicode(code)
158
+ bytes = [(code >> 8) + 0x80, (code & 0xff) + 0x80].pack("C2")
159
+ bytes.force_encoding("EUC-JP").encode("UTF-8").codepoints.first
160
+ rescue EncodingError
161
+ nil
162
+ end
163
+ private_class_method :jis_to_unicode
164
+ end
165
+
166
+ module TrueType
167
+ module_function
168
+
169
+ class Reader
170
+ attr_reader :bytes, :tables
171
+
172
+ def initialize(bytes)
173
+ @bytes = bytes.b
174
+ @tables = {}
175
+ count = @bytes.byteslice(4, 2).unpack1("n")
176
+ count.times do |index|
177
+ tag, _checksum, offset, length = @bytes.byteslice(12 + index * 16, 16).unpack("a4N3")
178
+ @tables[tag] = @bytes.byteslice(offset, length)
179
+ end
180
+ end
181
+
182
+ def u16(data, offset) = data.byteslice(offset, 2).unpack1("n")
183
+ def i16(data, offset) = data.byteslice(offset, 2).unpack1("s>")
184
+ def u32(data, offset) = data.byteslice(offset, 4).unpack1("N")
185
+ end
186
+
187
+ class Font < Glyphic::Font
188
+ def initialize(reader, size)
189
+ @reader = reader
190
+ @size = size.to_f
191
+ @head = reader.tables.fetch("head")
192
+ @hhea = reader.tables.fetch("hhea")
193
+ @maxp = reader.tables.fetch("maxp")
194
+ @hmtx = reader.tables.fetch("hmtx")
195
+ @loca = reader.tables.fetch("loca")
196
+ @glyf = reader.tables.fetch("glyf")
197
+ @units_per_em = reader.u16(@head, 18)
198
+ @loca_format = reader.i16(@head, 50)
199
+ @glyph_count = reader.u16(@maxp, 4)
200
+ @metric_count = reader.u16(@hhea, 34)
201
+ @cmap = build_cmap(reader.tables.fetch("cmap"))
202
+ @kern = build_kern(reader.tables["kern"])
203
+ ascent = reader.i16(@hhea, 4) * @size / @units_per_em
204
+ descent = -reader.i16(@hhea, 6) * @size / @units_per_em
205
+ super({}, line_height: (ascent + descent).ceil, ascent: ascent.ceil, descent: descent.ceil)
206
+ end
207
+
208
+ def glyph?(character)
209
+ @cmap.key?(character.to_s.codepoints.first)
210
+ end
211
+
212
+ def glyph(character)
213
+ codepoint = character.to_s.codepoints.first
214
+ index = @cmap[codepoint]
215
+ return super unless index
216
+ @glyph_cache ||= {}
217
+ @glyph_cache_order ||= []
218
+ if @glyph_cache.key?(codepoint)
219
+ @glyph_cache_order.delete(codepoint)
220
+ @glyph_cache_order << codepoint
221
+ return @glyph_cache[codepoint]
222
+ end
223
+
224
+ @glyph_cache[codepoint] = build_glyph(index)
225
+ @glyph_cache_order << codepoint
226
+ @glyph_cache.delete(@glyph_cache_order.shift) if @glyph_cache_order.length > 2048
227
+ @glyph_cache[codepoint]
228
+ end
229
+
230
+ def kerning(left, right)
231
+ return 0 unless @kern
232
+ left_index = @cmap[left.to_s.codepoints.first]
233
+ right_index = @cmap[right.to_s.codepoints.first]
234
+ (@kern[[left_index, right_index]] || 0) * @size / @units_per_em
235
+ end
236
+
237
+ private
238
+
239
+ def build_cmap(data)
240
+ records = @reader.u16(data, 2).times.map do |index|
241
+ platform, encoding, offset = data.byteslice(4 + index * 8, 8).unpack("n2N")
242
+ [platform, encoding, offset]
243
+ end
244
+ selected = records.sort_by { |platform, encoding, _| platform == 3 && encoding == 10 ? 0 : platform == 3 && encoding == 1 ? 1 : platform.zero? ? 2 : 3 }.find { |_, _, offset| [4, 12].include?(@reader.u16(data, offset)) }
245
+ return {} unless selected
246
+ format = @reader.u16(data, selected[2])
247
+ format == 12 ? cmap12(data.byteslice(selected[2]..)) : cmap4(data.byteslice(selected[2]..))
248
+ end
249
+
250
+ def build_kern(data)
251
+ return if data.nil? || @reader.u16(data, 0) != 0
252
+
253
+ pairs = {}
254
+ offset = 4
255
+ @reader.u16(data, 2).times do
256
+ length = @reader.u16(data, offset + 2)
257
+ coverage = @reader.u16(data, offset + 4)
258
+ if coverage & 0xff == 0
259
+ base = offset + 6
260
+ @reader.u16(data, base).times do |index|
261
+ left, right, value = data.byteslice(base + 8 + index * 6, 6).unpack("n2s>")
262
+ pairs[[left, right]] = value
263
+ end
264
+ end
265
+ offset += length
266
+ end
267
+ pairs
268
+ end
269
+
270
+ def cmap12(data)
271
+ result = {}
272
+ @reader.u32(data, 12).times do |index|
273
+ start_code, end_code, start_glyph = data.byteslice(16 + index * 12, 12).unpack("N3")
274
+ (start_code..end_code).each { |code| result[code] = start_glyph + code - start_code }
275
+ end
276
+ result
277
+ end
278
+
279
+ def cmap4(data)
280
+ segments = @reader.u16(data, 6) / 2
281
+ end_codes = data.byteslice(14, segments * 2).unpack("n*")
282
+ start_offset = 16 + segments * 2
283
+ start_codes = data.byteslice(start_offset, segments * 2).unpack("n*")
284
+ delta_offset = start_offset + segments * 2
285
+ deltas = data.byteslice(delta_offset, segments * 2).unpack("s>*")
286
+ range_offset = delta_offset + segments * 2
287
+ offsets = data.byteslice(range_offset, segments * 2).unpack("n*")
288
+ result = {}
289
+ end_codes.each_index do |segment|
290
+ (start_codes[segment]..end_codes[segment]).each do |code|
291
+ next if code == 0xffff
292
+ glyph = if offsets[segment].zero?
293
+ (code + deltas[segment]) & 0xffff
294
+ else
295
+ address = range_offset + segment * 2 + offsets[segment] + (code - start_codes[segment]) * 2
296
+ value = @reader.u16(data, address)
297
+ value.zero? ? 0 : (value + deltas[segment]) & 0xffff
298
+ end
299
+ result[code] = glyph if glyph.positive?
300
+ end
301
+ end
302
+ result
303
+ end
304
+
305
+ def build_glyph(index)
306
+ advance = if index < @metric_count
307
+ @reader.u16(@hmtx, index * 4)
308
+ else
309
+ @reader.u16(@hmtx, (@metric_count - 1) * 4)
310
+ end
311
+ scale = @size / @units_per_em
312
+ points, end_points, x_min, y_min, x_max, y_max, = outline(index, [])
313
+ return Glyph.new(advance: (advance * scale).round, bearing_x: 0, bearing_y: 0, width: 0, height: 0, alpha: "".b) if points.empty?
314
+ width = [(x_max - x_min) * scale, 1].max.ceil
315
+ height = [(y_max - y_min) * scale, 1].max.ceil
316
+ alpha = rasterize(points, end_points, x_min, y_min, x_max, y_max, width, height, scale)
317
+ Glyph.new(advance: (advance * scale).round, bearing_x: (x_min * scale).floor, bearing_y: (y_max * scale).ceil, width: width, height: height, alpha: alpha)
318
+ end
319
+
320
+ def outline(index, stack)
321
+ raise UnsupportedError, "cyclic TrueType composite glyph" if stack.include?(index)
322
+
323
+ offset = glyph_offset(index)
324
+ finish = glyph_offset(index + 1)
325
+ return [[], [], 0, 0, 0, 0, []] if finish <= offset
326
+
327
+ glyph = @glyf.byteslice(offset, finish - offset)
328
+ contours = @reader.i16(glyph, 0)
329
+ return composite_outline(index, glyph, stack) if contours.negative?
330
+
331
+ end_points = glyph.byteslice(10, contours * 2).unpack("n*")
332
+ point_count = end_points.last.to_i + 1
333
+ cursor = 10 + contours * 2
334
+ instruction_length = @reader.u16(glyph, cursor)
335
+ cursor += 2 + instruction_length
336
+ flags = []
337
+ while flags.length < point_count
338
+ flag = glyph.getbyte(cursor)
339
+ cursor += 1
340
+ repeat = flag.anybits?(8) ? glyph.getbyte(cursor).tap { cursor += 1 } : 0
341
+ flags.concat([flag] * (repeat + 1))
342
+ end
343
+ xs = read_coordinates(glyph, flags, cursor, point_count, horizontal: true)
344
+ ys = read_coordinates(glyph, flags, xs[1], point_count, horizontal: false)
345
+ raw_points = xs[0].zip(ys[0]).map { |x, y| [x, y] }
346
+ x_min, y_min, x_max, y_max = glyph.byteslice(2, 8).unpack("s>4")
347
+ points, flattened_end_points = flatten_contours(raw_points, flags, end_points)
348
+ [points, flattened_end_points, x_min, y_min, x_max, y_max, raw_points]
349
+ end
350
+
351
+ def glyph_offset(index)
352
+ if @loca_format.zero?
353
+ @reader.u16(@loca, index * 2) * 2
354
+ else
355
+ @reader.u32(@loca, index * 4)
356
+ end
357
+ end
358
+
359
+ def composite_outline(index, glyph, stack)
360
+ flags = 0
361
+ cursor = 10
362
+ points = []
363
+ reference_points = []
364
+ end_points = []
365
+ loop do
366
+ flags = @reader.u16(glyph, cursor)
367
+ component_index = @reader.u16(glyph, cursor + 2)
368
+ cursor += 4
369
+ word_args = flags.anybits?(1)
370
+ arg1, arg2 = if word_args
371
+ values = glyph.byteslice(cursor, 4).unpack("s>2")
372
+ cursor += 4
373
+ values
374
+ else
375
+ values = glyph.byteslice(cursor, 2).unpack("c2")
376
+ cursor += 2
377
+ values
378
+ end
379
+ dx, dy = flags.anybits?(2) ? [arg1, arg2] : [0, 0]
380
+ a, b, c, d = component_transform(glyph, flags, cursor)
381
+ cursor += transform_size(flags)
382
+ component = outline(component_index, stack + [index])
383
+ component_points = component[0]
384
+ transformed = component_points.map { |x, y| [x * a + y * c, x * b + y * d] }
385
+ transformed_reference = component[6].map { |x, y| [x * a + y * c, x * b + y * d] }
386
+ if flags.anybits?(2)
387
+ transformed.map! { |x, y| [x + dx, y + dy] }
388
+ transformed_reference.map! { |x, y| [x + dx, y + dy] }
389
+ else
390
+ parent = reference_points[arg2]
391
+ child = transformed_reference[arg1]
392
+ raise UnsupportedError, "invalid TrueType composite point index" unless parent && child
393
+ offset_x = parent[0] - child[0]
394
+ offset_y = parent[1] - child[1]
395
+ transformed.map! { |x, y| [x + offset_x, y + offset_y] }
396
+ transformed_reference.map! { |x, y| [x + offset_x, y + offset_y] }
397
+ end
398
+ point_offset = points.length
399
+ points.concat(transformed)
400
+ reference_points.concat(transformed_reference)
401
+ end_points.concat(component[1].map { |endpoint| endpoint + point_offset })
402
+ break unless flags.anybits?(32)
403
+ end
404
+ x_values = points.map(&:first)
405
+ y_values = points.map(&:last)
406
+ [points, end_points, x_values.min.to_i, y_values.min.to_i, x_values.max.to_i, y_values.max.to_i, reference_points]
407
+ end
408
+
409
+ def flatten_contours(points, flags, end_points)
410
+ flattened = []
411
+ flattened_end_points = []
412
+ start = 0
413
+ end_points.each do |finish|
414
+ contour = points[start..finish]
415
+ contour_flags = flags[start..finish]
416
+ count = contour.length
417
+ if contour_flags.first.anybits?(1)
418
+ current = contour.first
419
+ index = 1 % count
420
+ processed = 1
421
+ elsif contour_flags.last.anybits?(1)
422
+ current = contour.last
423
+ index = 0
424
+ processed = 1
425
+ else
426
+ current = midpoint(contour.last, contour.first)
427
+ index = 0
428
+ processed = 0
429
+ end
430
+ outline = [current]
431
+ loop do
432
+ point = contour[index]
433
+ if contour_flags[index].anybits?(1)
434
+ outline << point unless point == outline.last
435
+ current = point
436
+ index = (index + 1) % count
437
+ processed += 1
438
+ else
439
+ following_index = (index + 1) % count
440
+ following = contour[following_index]
441
+ if contour_flags[following_index].anybits?(1)
442
+ append_quadratic(outline, current, point, following)
443
+ current = following
444
+ index = (index + 2) % count
445
+ processed += 2
446
+ else
447
+ implied = midpoint(point, following)
448
+ append_quadratic(outline, current, point, implied)
449
+ current = implied
450
+ index = following_index
451
+ processed += 1
452
+ end
453
+ end
454
+ break if processed >= count
455
+ end
456
+ flattened.concat(outline)
457
+ flattened_end_points << flattened.length - 1
458
+ start = finish + 1
459
+ end
460
+ [flattened, flattened_end_points]
461
+ end
462
+
463
+ def append_quadratic(outline, first, control, last)
464
+ length = Math.hypot(control[0] - first[0], control[1] - first[1]) + Math.hypot(last[0] - control[0], last[1] - control[1])
465
+ steps = [(length * @size / @units_per_em / 0.25).ceil, 1].max
466
+ (1..steps).each do |step|
467
+ amount = step.to_f / steps
468
+ inverse = 1 - amount
469
+ outline << [inverse * inverse * first[0] + 2 * inverse * amount * control[0] + amount * amount * last[0], inverse * inverse * first[1] + 2 * inverse * amount * control[1] + amount * amount * last[1]]
470
+ end
471
+ end
472
+
473
+ def midpoint(first, second)
474
+ [(first[0] + second[0]) / 2.0, (first[1] + second[1]) / 2.0]
475
+ end
476
+
477
+ def component_transform(glyph, flags, cursor)
478
+ if flags.anybits?(128)
479
+ glyph.byteslice(cursor, 8).unpack("s>4").map { |value| value / 16_384.0 }
480
+ elsif flags.anybits?(64)
481
+ x, y = glyph.byteslice(cursor, 4).unpack("s>2").map { |value| value / 16_384.0 }
482
+ [x, 0, 0, y]
483
+ elsif flags.anybits?(8)
484
+ scale = @reader.i16(glyph, cursor) / 16_384.0
485
+ [scale, 0, 0, scale]
486
+ else
487
+ [1, 0, 0, 1]
488
+ end
489
+ end
490
+
491
+ def transform_size(flags)
492
+ return 8 if flags.anybits?(128)
493
+ return 4 if flags.anybits?(64)
494
+ flags.anybits?(8) ? 2 : 0
495
+ end
496
+
497
+ def read_coordinates(glyph, flags, cursor, count, horizontal:)
498
+ values = []
499
+ previous = 0
500
+ count.times do |index|
501
+ flag = flags[index]
502
+ short = horizontal ? flag.anybits?(2) : flag.anybits?(4)
503
+ same = horizontal ? flag.anybits?(16) : flag.anybits?(32)
504
+ if short
505
+ delta = glyph.getbyte(cursor)
506
+ cursor += 1
507
+ delta = -delta if !same
508
+ elsif same
509
+ delta = 0
510
+ else
511
+ delta = glyph.byteslice(cursor, 2).unpack1("s>")
512
+ cursor += 2
513
+ end
514
+ previous += delta
515
+ values << previous
516
+ end
517
+ [values, cursor]
518
+ end
519
+
520
+ def rasterize(points, end_points, x_min, y_min, x_max, y_max, width, height, scale)
521
+ alpha = Array.new(width * height, 0)
522
+ (0...height).each do |row|
523
+ (0...width).each do |column|
524
+ x = x_min + (column + 0.5) / scale
525
+ y = y_max - (row + 0.5) / scale
526
+ winding = 0
527
+ start = 0
528
+ end_points.each do |finish|
529
+ contour = points[start..finish]
530
+ contour.each_with_index do |first, index|
531
+ second = contour[(index + 1) % contour.length]
532
+ next unless (first[1] > y) != (second[1] > y)
533
+ crossing = (second[0] - first[0]) * (y - first[1]) / (second[1] - first[1]) + first[0]
534
+ winding += second[1] > first[1] ? 1 : -1 if crossing > x
535
+ end
536
+ start = finish + 1
537
+ end
538
+ alpha[row * width + column] = 255 if winding != 0
539
+ end
540
+ end
541
+ alpha.pack("C*")
542
+ end
543
+ end
544
+
545
+ def load(path, size: 16)
546
+ Font.new(Reader.new(File.binread(path)), size)
547
+ rescue KeyError => error
548
+ raise UnsupportedError, "TrueType font is missing required table: #{error.message}"
549
+ end
550
+ end
551
+
552
+ module Layout
553
+ module_function
554
+
555
+ def width(text, font)
556
+ previous = nil
557
+ text.each_char.sum do |character|
558
+ advance = font.glyph(character).advance + (previous ? font.kerning(previous, character) : 0)
559
+ previous = character
560
+ advance
561
+ end
562
+ end
563
+
564
+ def lines(text, font, width: nil, wrap: nil, tab_width: 4)
565
+ result = []
566
+ text.to_s.split("\n", -1).each do |raw|
567
+ expanded = raw.gsub("\t", " " * tab_width)
568
+ if width && wrap.to_sym == :word
569
+ current = +""
570
+ expanded.split(/(?<=\s)|(?=\s)/).each do |chunk|
571
+ if !current.empty? && Layout.width(current, font) + Layout.width(chunk, font) > width
572
+ result << current.rstrip
573
+ current = chunk.lstrip
574
+ else
575
+ current << chunk
576
+ end
577
+ end
578
+ result << current
579
+ elsif width && wrap
580
+ current = +""
581
+ expanded.each_char do |character|
582
+ if !current.empty? && Layout.width(current, font) + Layout.width(character, font) > width
583
+ result << current
584
+ current = ""
585
+ end
586
+ current << character
587
+ end
588
+ result << current
589
+ else
590
+ result << expanded
591
+ end
592
+ end
593
+ result
594
+ end
595
+ end
596
+
597
+ module Renderer
598
+ module_function
599
+
600
+ def draw(font, target, x, y, text, color:, baseline: false, line_spacing: 1.0, **_options)
601
+ x = Integer(x)
602
+ origin_y = baseline ? Integer(y) - font.ascent : Integer(y)
603
+ Layout.lines(text, font).each_with_index do |line, line_index|
604
+ cursor = x
605
+ line_y = origin_y + (font.line_height * line_spacing * line_index).round
606
+ previous = nil
607
+ line.each_char do |character|
608
+ glyph = font.glyph(character)
609
+ cursor += font.kerning(previous, character) if previous
610
+ draw_glyph(target, glyph, cursor + glyph.bearing_x, line_y + font.ascent - glyph.bearing_y, color)
611
+ cursor += glyph.advance
612
+ previous = character
613
+ end
614
+ end
615
+ target
616
+ end
617
+
618
+ def draw_box(font, target, x:, y:, width:, text:, color:, align:, wrap:, line_spacing:, **options)
619
+ lines = Layout.lines(text, font, width: width, wrap: wrap)
620
+ lines.each_with_index do |line, index|
621
+ line_width = Layout.width(line, font)
622
+ offset = case align
623
+ when :center then (width - line_width) / 2
624
+ when :right then width - line_width
625
+ else 0
626
+ end
627
+ draw(font, target, x + offset, y + (font.line_height * line_spacing * index).round, line, color: color, **options)
628
+ end
629
+ target
630
+ end
631
+
632
+ def draw_glyph(target, glyph, x, y, color)
633
+ if target.is_a?(Tessel::Image)
634
+ target.blit_mask(glyph, x, y, color)
635
+ elsif target.respond_to?(:get_pixel) && target.respond_to?(:set_pixel)
636
+ packed = Tessel::Color.pack(color).unpack("C4")
637
+ glyph.height.times do |row|
638
+ glyph.width.times do |column|
639
+ alpha = glyph.alpha.getbyte(row * glyph.width + column)
640
+ next if alpha.zero?
641
+ target.set_pixel(x + column, y + row, packed)
642
+ end
643
+ end
644
+ else
645
+ raise TypeError, "target must be a Tessel::Image or pixel surface"
646
+ end
647
+ end
648
+ private_class_method :draw_glyph
649
+ end
650
+
651
+ class Chain
652
+ def initialize(*fonts)
653
+ raise ArgumentError, "at least one font is required" if fonts.empty?
654
+
655
+ @fonts = fonts
656
+ @line_height = fonts.map(&:line_height).max
657
+ @ascent = fonts.map(&:ascent).max
658
+ end
659
+
660
+ attr_reader :line_height, :ascent
661
+
662
+ def glyph?(character)
663
+ @fonts.any? { |font| font.glyph?(character) }
664
+ end
665
+
666
+ def glyph(character)
667
+ @fonts.find { |font| font.glyph?(character) }&.glyph(character) || @fonts.first.glyph(character)
668
+ end
669
+
670
+ def kerning(_left, _right)
671
+ 0
672
+ end
673
+
674
+ def draw(target, x, y, text, **options)
675
+ Renderer.draw(self, target, x, y, text, **options)
676
+ end
677
+
678
+ def measure(text)
679
+ lines = Layout.lines(text, self)
680
+ [lines.map { |line| Layout.width(line, self) }.max.to_i, lines.length * line_height]
681
+ end
682
+
683
+ def draw_text_box(target, x:, y:, width:, text:, color: [255, 255, 255, 255], align: :left, wrap: :word, line_spacing: 1.0, **options)
684
+ Renderer.draw_box(self, target, x: x, y: y, width: width, text: text, color: color, align: align, wrap: wrap, line_spacing: line_spacing, **options)
685
+ end
686
+
687
+ def render(text, color: [255, 255, 255, 255], padding: 0)
688
+ width, height = measure(text)
689
+ image = Tessel::Image.new(width + padding * 2, height + padding * 2)
690
+ draw(image, padding, padding, text, color: color)
691
+ image
692
+ end
693
+ end
694
+
695
+ module_function
696
+
697
+ def load(path, size: nil, **_options)
698
+ bytes = File.binread(path)
699
+ if bytes.start_with?("STARTFONT")
700
+ BDF.load(path)
701
+ elsif bytes.start_with?("\x00\x01\x00\x00")
702
+ TrueType.load(path, size: size || 16)
703
+ elsif bytes.start_with?("OTTO")
704
+ raise UnsupportedError, "CFF fonts are not supported"
705
+ else
706
+ raise UnsupportedError, "unknown font format"
707
+ end
708
+ end
709
+
710
+ def default
711
+ @default ||= Builtin.font
712
+ end
713
+
714
+ module Builtin
715
+ module_function
716
+
717
+ def font
718
+ patterns = {
719
+ " " => ["00000", "00000", "00000", "00000", "00000", "00000", "00000"],
720
+ "?" => ["11110", "00001", "00010", "00100", "00100", "00000", "00100"],
721
+ "!" => ["00100", "00100", "00100", "00100", "00100", "00000", "00100"],
722
+ "." => ["00000", "00000", "00000", "00000", "00000", "00110", "00110"],
723
+ ":" => ["00000", "00110", "00110", "00000", "00110", "00110", "00000"],
724
+ "-" => ["00000", "00000", "00000", "11111", "00000", "00000", "00000"],
725
+ "_" => ["00000", "00000", "00000", "00000", "00000", "00000", "11111"]
726
+ }
727
+ patterns.merge!(
728
+ "A" => %w[01110 10001 10001 11111 10001 10001 10001],
729
+ "B" => %w[11110 10001 10001 11110 10001 10001 11110],
730
+ "C" => %w[01111 10000 10000 10000 10000 10000 01111],
731
+ "D" => %w[11110 10001 10001 10001 10001 10001 11110],
732
+ "E" => %w[11111 10000 10000 11110 10000 10000 11111],
733
+ "F" => %w[11111 10000 10000 11110 10000 10000 10000],
734
+ "G" => %w[01111 10000 10000 10111 10001 10001 01111],
735
+ "H" => %w[10001 10001 10001 11111 10001 10001 10001],
736
+ "I" => %w[11111 00100 00100 00100 00100 00100 11111],
737
+ "J" => %w[00111 00010 00010 00010 00010 10010 01100],
738
+ "K" => %w[10001 10010 10100 11000 10100 10010 10001],
739
+ "L" => %w[10000 10000 10000 10000 10000 10000 11111],
740
+ "M" => %w[10001 11011 10101 10101 10001 10001 10001],
741
+ "N" => %w[10001 11001 10101 10011 10001 10001 10001],
742
+ "O" => %w[01110 10001 10001 10001 10001 10001 01110],
743
+ "P" => %w[11110 10001 10001 11110 10000 10000 10000],
744
+ "Q" => %w[01110 10001 10001 10001 10101 10010 01101],
745
+ "R" => %w[11110 10001 10001 11110 10100 10010 10001],
746
+ "S" => %w[01111 10000 10000 01110 00001 00001 11110],
747
+ "T" => %w[11111 00100 00100 00100 00100 00100 00100],
748
+ "U" => %w[10001 10001 10001 10001 10001 10001 01110],
749
+ "V" => %w[10001 10001 10001 10001 10001 01010 00100],
750
+ "W" => %w[10001 10001 10001 10101 10101 10101 01010],
751
+ "X" => %w[10001 10001 01010 00100 01010 10001 10001],
752
+ "Y" => %w[10001 10001 01010 00100 00100 00100 00100],
753
+ "Z" => %w[11111 00001 00010 00100 01000 10000 11111]
754
+ )
755
+ ("a".."z").each { |letter| patterns[letter] = patterns[letter.upcase] }
756
+ patterns.merge!(
757
+ "0" => %w[01110 10001 10011 10101 11001 10001 01110],
758
+ "1" => %w[00100 01100 00100 00100 00100 00100 01110],
759
+ "2" => %w[01110 10001 00001 00010 00100 01000 11111],
760
+ "3" => %w[11110 00001 00001 01110 00001 00001 11110],
761
+ "4" => %w[00010 00110 01010 10010 11111 00010 00010],
762
+ "5" => %w[11111 10000 10000 11110 00001 00001 11110],
763
+ "6" => %w[01110 10000 10000 11110 10001 10001 01110],
764
+ "7" => %w[11111 00001 00010 00100 01000 01000 01000],
765
+ "8" => %w[01110 10001 10001 01110 10001 10001 01110],
766
+ "9" => %w[01110 10001 10001 01111 00001 00001 01110]
767
+ )
768
+ glyphs = patterns.transform_keys(&:ord).transform_values do |rows|
769
+ alpha = rows.join.chars.map { |bit| bit == "1" ? 255 : 0 }.pack("C*")
770
+ Glyph.new(advance: 6, bearing_x: 0, bearing_y: 7, width: 5, height: 7, alpha: alpha)
771
+ end
772
+ Font.new(glyphs, line_height: 9, ascent: 7)
773
+ end
774
+
775
+ def letter_pattern(letter)
776
+ seed = letter.ord
777
+ Array.new(7) { |row| (0...5).map { |column| ((seed + row * 3 + column * 5) % 7).between?(0, 2) ? "1" : "0" }.join }
778
+ end
779
+ private_class_method :letter_pattern
780
+
781
+ def digit_pattern(digit)
782
+ ["01110", "10001", "10011", "10101", "11001", "10001", "01110"].rotate(digit.to_i % 7)
783
+ end
784
+ private_class_method :digit_pattern
785
+ end
786
+ end
data/sig/glyphic.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Glyphic
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,63 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: glyphic
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - ydah
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: tessel
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 0.1.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 0.1.0
26
+ description: BDF text rendering and layout on Tessel RGBA8 images.
27
+ email:
28
+ - t.yudai92@gmail.com
29
+ executables: []
30
+ extensions: []
31
+ extra_rdoc_files: []
32
+ files:
33
+ - CHANGELOG.md
34
+ - LICENSE.txt
35
+ - README.md
36
+ - Rakefile
37
+ - lib/glyphic.rb
38
+ - lib/glyphic/version.rb
39
+ - sig/glyphic.rbs
40
+ homepage: https://github.com/rbgfx/glyphic
41
+ licenses:
42
+ - MIT
43
+ metadata:
44
+ homepage_uri: https://github.com/rbgfx/glyphic
45
+ source_code_uri: https://github.com/rbgfx/glyphic/tree/main
46
+ rdoc_options: []
47
+ require_paths:
48
+ - lib
49
+ required_ruby_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: 3.1.0
54
+ required_rubygems_version: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '0'
59
+ requirements: []
60
+ rubygems_version: 4.0.16
61
+ specification_version: 4
62
+ summary: Bitmap and outline font rendering for Ruby graphics
63
+ test_files: []