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.
@@ -0,0 +1,427 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alhena
4
+ # Lazy sfnt/OpenType reader. Does not execute hinting bytecode or shape text.
5
+ class Font
6
+ LOOKUP_CACHE_SIZE = 4096
7
+ private_constant :LOOKUP_CACHE_SIZE
8
+ attr_reader :index, :tables, :axis_values
9
+
10
+ # @param path [String] sfnt, OpenType or TTC filename
11
+ # @param index [Integer] zero-based collection face
12
+ # @param axes [Hash] variation axis tags and user-space coordinates
13
+ # @return [Font]
14
+ def self.open(path, index: 0, axes: {}) = new(File.binread(path), index: index, axes: axes)
15
+
16
+ def initialize(data, index: 0, axes: {})
17
+ raise ArgumentError, "index must be a nonnegative integer" unless index.is_a?(Integer) && index >= 0
18
+ @binary = Binary.new(data.b.freeze)
19
+ @index = index
20
+ @axis_values = axes.transform_keys(&:to_s).freeze
21
+ offset = 0
22
+ if @binary.bytes(0, 4) == "ttcf"
23
+ count = @binary.u32(8)
24
+ @binary.validate_bounds(12, count * 4)
25
+ raise InvalidFont, "collection index out of range" unless index < count
26
+ offset = @binary.u32(12 + index * 4)
27
+ elsif index != 0
28
+ raise InvalidFont, "collection index out of range"
29
+ end
30
+ signature = @binary.bytes(offset, 4)
31
+ raise UnsupportedFont, "expected sfnt, OpenType or TTC" unless ["\x00\x01\x00\x00".b, "OTTO", "true"].include?(signature)
32
+ count = @binary.u16(offset + 4)
33
+ @binary.validate_bounds(offset + 12, count * 16)
34
+ @tables = {}
35
+ count.times do |i|
36
+ record = offset + 12 + i * 16
37
+ tag = @binary.bytes(record, 4)
38
+ location, length = @binary.u32(record + 8), @binary.u32(record + 12)
39
+ @binary.validate_bounds(location, length)
40
+ raise InvalidFont, "duplicate #{tag} table" if @tables.key?(tag)
41
+ @tables[tag] = [location, length].freeze
42
+ end
43
+ @tables.freeze
44
+ @parsed = {}
45
+ normalized_coordinates unless @axis_values.empty?
46
+ end
47
+
48
+ def table(tag)
49
+ @parsed[tag] ||= begin
50
+ location, length = @tables.fetch(tag) { raise InvalidFont, "missing #{tag} table" }
51
+ Binary.new(@binary.bytes(location, length).freeze)
52
+ end
53
+ end
54
+ # Original immutable sfnt/TTC bytes for native rasterizer interoperability.
55
+ def data = @binary.data
56
+
57
+ def units_per_em
58
+ value = table("head").u16(18)
59
+ raise InvalidFont, "invalid unitsPerEm" unless (16..16_384).cover?(value)
60
+ value
61
+ end
62
+
63
+ def glyph_count = table("maxp").u16(4)
64
+ def ascent = table("hhea").i16(4)
65
+ def descent = table("hhea").i16(6)
66
+ def line_gap = table("hhea").i16(8)
67
+
68
+ def names
69
+ @names ||= begin
70
+ data = table("name")
71
+ count, storage = data.u16(2), data.u16(4)
72
+ data.validate_bounds(6, count * 12)
73
+ result = {}
74
+ count.times do |i|
75
+ at = 6 + i * 12
76
+ platform, _encoding, language, name_id, length, offset = 6.times.map { |j| data.u16(at + j * 2) }
77
+ next unless [0, 1, 3].include?(platform)
78
+ text = data.bytes(storage + offset, length)
79
+ text = text.force_encoding(platform == 1 ? "macRoman" : "UTF-16BE").encode("UTF-8", invalid: :replace, undef: :replace)
80
+ priority = platform == 3 && language == 0x409 ? 3 : platform == 0 ? 2 : 1
81
+ result[name_id] = [priority, text.freeze] if !result.key?(name_id) || result[name_id][0] < priority
82
+ end
83
+ result.transform_values(&:last).freeze
84
+ end
85
+ end
86
+
87
+ def family = names[16] || names[1]
88
+
89
+ def os2
90
+ data = table("OS/2")
91
+ {version: data.u16(0), weight: data.u16(4), width: data.u16(6),
92
+ typo_ascent: data.i16(68), typo_descent: data.i16(70), typo_line_gap: data.i16(72),
93
+ win_ascent: data.u16(74), win_descent: data.u16(76)}.freeze
94
+ end
95
+
96
+ def post
97
+ data = table("post")
98
+ {version: data.fixed(0), italic_angle: data.fixed(4), underline_position: data.i16(8),
99
+ underline_thickness: data.i16(10), fixed_pitch: data.u32(12) != 0}.freeze
100
+ end
101
+
102
+ # Accept a Unicode scalar or a string, optionally with an explicit selector.
103
+ def glyph_id(character, variation_selector: nil)
104
+ codepoint = character.is_a?(String) ? character.ord : character
105
+ raise ArgumentError, "invalid Unicode scalar" unless codepoint.is_a?(Integer) && (0..0x10ffff).cover?(codepoint) && !(0xd800..0xdfff).cover?(codepoint)
106
+ if variation_selector
107
+ selector = variation_selector.is_a?(String) ? variation_selector.ord : variation_selector
108
+ variant = variation_glyph_mapping(codepoint, selector)
109
+ return variant unless variant == :default
110
+ end
111
+ cache = (@glyph_ids ||= {})
112
+ cached = cache[codepoint]
113
+ return cached if cached
114
+ maps, index, result = cmaps, 0, 0
115
+ while (data = maps[index])
116
+ result = glyph_id_from_cmap(data, codepoint)
117
+ break if result != 0 && result < glyph_count
118
+ index += 1
119
+ result = 0
120
+ end
121
+ cache.shift if cache.length >= LOOKUP_CACHE_SIZE
122
+ cache[codepoint] = result
123
+ end
124
+
125
+ def advance(glyph, size: units_per_em, vertical: false)
126
+ cached_metric(glyph, vertical: vertical)[0] * scale_factor(size)
127
+ end
128
+
129
+ def bearing(glyph, size: units_per_em, vertical: false)
130
+ cached_metric(glyph, vertical: vertical)[1] * scale_factor(size)
131
+ end
132
+
133
+ # @param glyph [Integer] glyph ID, not a Unicode scalar
134
+ # @return [Outline] unhinted path in font units with Y increasing upwards
135
+ def outline(glyph)
136
+ validate_glyph(glyph)
137
+ if @tables.key?("glyf")
138
+ truetype_glyph(glyph, [])[0]
139
+ elsif @tables.key?("CFF ")
140
+ (@cff ||= CFF.new(table("CFF "), units_per_em: units_per_em)).outline(glyph)
141
+ elsif @tables.key?("CFF2")
142
+ (@cff ||= CFF.new(table("CFF2"), units_per_em: units_per_em, coordinates: normalized_coordinates)).outline(glyph)
143
+ else
144
+ raise UnsupportedFont, "font contains no vector outlines"
145
+ end
146
+ end
147
+
148
+ def rasterize(glyph, size:, subpixel_x: 0, tolerance: 0.25, gamma: 1.0, darkening: 0.0, lcd: nil)
149
+ factor = scale_factor(size)
150
+ raise ArgumentError, "invalid subpixel position" unless subpixel_x.is_a?(Numeric) && subpixel_x.finite?
151
+ path = outline(glyph).transform([factor, 0, 0, -factor, subpixel_x, 0])
152
+ # Match the conventional 26.6 pixel grid used by font rasterizers.
153
+ path.coordinates.map! { |value| (value * 64).round / 64.0 }
154
+ return Bitmap.new(width: 0, height: 0, coverage: "") if path.empty?
155
+ xmin, ymin, xmax, ymax = path.bounds
156
+ left, top, right, bottom = xmin.floor, ymin.floor, xmax.ceil, ymax.ceil
157
+ padding = lcd ? 1 : 0
158
+ left -= padding
159
+ right += padding
160
+ translated = path.transform([1, 0, 0, 1, -left, -top])
161
+ Rasterizer.new(width: right - left, height: bottom - top, tolerance: tolerance).fill(
162
+ translated, left: left, top: -top, gamma: gamma, darkening: darkening, lcd: lcd
163
+ )
164
+ end
165
+
166
+ private
167
+
168
+ def scale_factor(size)
169
+ raise ArgumentError, "size must be positive and finite" unless size.is_a?(Numeric) && size.finite? && size > 0
170
+ size.to_f / units_per_em
171
+ end
172
+
173
+ def validate_glyph(glyph)
174
+ raise ArgumentError, "glyph ID out of range" unless glyph.is_a?(Integer) && glyph >= 0 && glyph < glyph_count
175
+ end
176
+
177
+ # Font bytes and variation coordinates never change. Cache font-unit pairs,
178
+ # not sizes; bound missing-character and large-CJK scans as well as hits.
179
+ def cached_metric(glyph, vertical:)
180
+ validate_glyph(glyph)
181
+ cache = vertical ? (@vertical_metrics ||= {}) : (@horizontal_metrics ||= {})
182
+ cached = cache[glyph]
183
+ return cached if cached
184
+ result = metric(glyph, vertical: vertical).freeze
185
+ cache.shift if cache.length >= LOOKUP_CACHE_SIZE
186
+ cache[glyph] = result
187
+ end
188
+
189
+ def raw_metric(glyph, vertical: false)
190
+ validate_glyph(glyph)
191
+ count = table(vertical ? "vhea" : "hhea").u16(34)
192
+ raise InvalidFont, "invalid metric count" unless count > 0 && count <= glyph_count
193
+ data = table(vertical ? "vmtx" : "hmtx")
194
+ width = data.u16([glyph, count - 1].min * 4)
195
+ bearing_offset = glyph < count ? glyph * 4 + 2 : count * 4 + (glyph - count) * 2
196
+ [width, data.i16(bearing_offset)]
197
+ end
198
+
199
+ def cmaps
200
+ @cmaps ||= begin
201
+ cmap = table("cmap")
202
+ count = cmap.u16(2)
203
+ cmap.validate_bounds(4, count * 8)
204
+ @variation_cmap = nil
205
+ records = count.times.filter_map do |i|
206
+ platform, encoding = cmap.u16(4 + i * 8), cmap.u16(6 + i * 8)
207
+ next unless platform == 0 || (platform == 3 && [0, 1, 10].include?(encoding)) || (platform == 1 && encoding.zero?)
208
+ offset = cmap.u32(8 + i * 8)
209
+ format = cmap.u16(offset)
210
+ next unless [0, 4, 6, 12, 13, 14].include?(format)
211
+ length = format == 14 ? cmap.u32(offset + 2) : format >= 12 ? cmap.u32(offset + 4) : cmap.u16(offset + 2)
212
+ record = Binary.new(cmap.bytes(offset, length))
213
+ if format == 14
214
+ @variation_cmap = record
215
+ next
216
+ end
217
+ [format == 12 ? 4 : format == 4 ? 3 : platform == 0 ? 2 : 1, record]
218
+ end
219
+ records.sort_by { |priority, _| -priority }.map(&:last)
220
+ end
221
+ end
222
+
223
+ def glyph_id_from_cmap(data, code)
224
+ case data.u16(0)
225
+ when 0
226
+ code < 256 ? data.u8(6 + code) : 0
227
+ when 6
228
+ first, count = data.u16(6), data.u16(8)
229
+ code >= first && code < first + count ? data.u16(10 + (code - first) * 2) : 0
230
+ when 4
231
+ return 0 if code > 0xffff
232
+ count = data.u16(6) / 2
233
+ data.validate_bounds(14, count * 8 + 2)
234
+ lo, hi = 0, count
235
+ while lo < hi
236
+ mid = (lo + hi) / 2
237
+ data.u16(14 + mid * 2) < code ? lo = mid + 1 : hi = mid
238
+ end
239
+ return 0 if lo == count
240
+ start = data.u16(16 + count * 2 + lo * 2)
241
+ return 0 if code < start
242
+ delta = data.i16(16 + count * 4 + lo * 2)
243
+ at = 16 + count * 6 + lo * 2
244
+ offset = data.u16(at)
245
+ return (code + delta) & 0xffff if offset.zero?
246
+ glyph = data.u16(at + offset + (code - start) * 2)
247
+ glyph.zero? ? 0 : (glyph + delta) & 0xffff
248
+ when 12, 13
249
+ constant = data.u16(0) == 13
250
+ count = data.u32(12)
251
+ data.validate_bounds(16, count * 12)
252
+ lo, hi = 0, count
253
+ while lo < hi
254
+ mid = (lo + hi) / 2
255
+ data.u32(20 + mid * 12) < code ? lo = mid + 1 : hi = mid
256
+ end
257
+ return 0 if lo == count
258
+ start = data.u32(16 + lo * 12)
259
+ return 0 if code < start
260
+ data.u32(24 + lo * 12) + (constant ? 0 : code - start)
261
+ end
262
+ end
263
+
264
+ def variation_glyph_mapping(code, selector)
265
+ cmaps
266
+ return 0 unless @variation_cmap
267
+ data = @variation_cmap
268
+ count = data.u32(6)
269
+ data.validate_bounds(10, count * 11)
270
+ count.times do |i|
271
+ at = 10 + i * 11
272
+ next unless data.u24(at) == selector
273
+ default, nondefault = data.u32(at + 3), data.u32(at + 7)
274
+ unless nondefault.zero?
275
+ entries = data.u32(nondefault)
276
+ data.validate_bounds(nondefault + 4, entries * 5)
277
+ entries.times do |j|
278
+ entry = nondefault + 4 + j * 5
279
+ return data.u16(entry + 3) if data.u24(entry) == code
280
+ end
281
+ end
282
+ unless default.zero?
283
+ entries = data.u32(default)
284
+ data.validate_bounds(default + 4, entries * 4)
285
+ entries.times do |j|
286
+ entry = default + 4 + j * 4
287
+ start = data.u24(entry)
288
+ return :default if code >= start && code <= start + data.u8(entry + 3)
289
+ end
290
+ end
291
+ end
292
+ 0
293
+ end
294
+
295
+ def truetype_glyph(glyph, ancestors)
296
+ raise InvalidFont, "cyclic or deeply nested composite glyph" if ancestors.include?(glyph) || ancestors.length > 5
297
+ validate_glyph(glyph)
298
+ loca = table("loca")
299
+ format = table("head").i16(50)
300
+ raise InvalidFont, "invalid loca format" unless [0, 1].include?(format)
301
+ first = format.zero? ? loca.u16(glyph * 2) * 2 : loca.u32(glyph * 4)
302
+ last = format.zero? ? loca.u16((glyph + 1) * 2) * 2 : loca.u32((glyph + 1) * 4)
303
+ source = table("glyf")
304
+ data = Binary.new(source.bytes(first, last - first))
305
+ path, points = Outline.new, []
306
+ if data.size.zero?
307
+ vary_points(glyph, [], [], data)
308
+ return [path, points]
309
+ end
310
+ contours = data.i16(0)
311
+ data.validate_bounds(0, 10)
312
+ data.position = 10
313
+ if contours.zero?
314
+ vary_points(glyph, [], [], data)
315
+ return [path, points]
316
+ end
317
+ return simple_glyph(data, contours, glyph) if contours.positive?
318
+ composite_glyph(data, glyph, ancestors)
319
+ end
320
+
321
+ def composite_glyph(data, glyph, ancestors)
322
+ components = read_composite_components(data)
323
+ instructions = data.u16 if components.last[0] & 256 != 0
324
+ data.validate_bounds(data.position, instructions) if instructions
325
+ original_offsets = components.map { |flags, _, args, *_| flags & 2 != 0 ? args : [0, 0] }
326
+ offsets = vary_points(glyph, original_offsets, [], data)
327
+ path, points = Outline.new, []
328
+ components.each_with_index do |(flags, child, args, a, b, c, d), component|
329
+ xy = flags & 2 != 0
330
+ child_path, child_points = truetype_glyph(child, ancestors + [glyph])
331
+ transformed = child_points.map { |x, y| [a * x + c * y, b * x + d * y] }
332
+ if xy
333
+ dx, dy = offsets[component]
334
+ dx, dy = a * dx + c * dy, b * dx + d * dy if flags & 0x800 != 0
335
+ else
336
+ parent_point, child_point = points[args[0]], transformed[args[1]]
337
+ raise InvalidFont, "invalid composite point attachment" unless parent_point && child_point
338
+ dx, dy = parent_point[0] - child_point[0], parent_point[1] - child_point[1]
339
+ end
340
+ path.append(child_path.transform([a, b, c, d, dx, dy]))
341
+ points.concat(transformed.map { |x, y| [x + dx, y + dy] })
342
+ raise InvalidFont, "composite glyph exceeds point limit" if points.length > 65_536
343
+ end
344
+ [path, points]
345
+ end
346
+
347
+ def read_composite_components(data)
348
+ components = []
349
+ loop do
350
+ flags, child = data.u16, data.u16
351
+ raise InvalidFont, "composite child glyph out of range" if child >= glyph_count
352
+ words, xy = flags & 1 != 0, flags & 2 != 0
353
+ args = 2.times.map { xy ? (words ? data.i16 : data.i8) : (words ? data.u16 : data.u8) }
354
+ a, b, c, d = 1.0, 0.0, 0.0, 1.0
355
+ if flags & 8 != 0
356
+ a = d = data.i16 / 16_384.0
357
+ elsif flags & 64 != 0
358
+ a, d = data.i16 / 16_384.0, data.i16 / 16_384.0
359
+ elsif flags & 128 != 0
360
+ a, b, c, d = 4.times.map { data.i16 / 16_384.0 }
361
+ end
362
+ components << [flags, child, args, a, b, c, d]
363
+ break if flags & 32 == 0
364
+ end
365
+ components
366
+ end
367
+
368
+ def simple_glyph(data, count, glyph)
369
+ ends = count.times.map { data.u16 }
370
+ raise InvalidFont, "invalid contour endpoints" unless ends.each_cons(2).all? { |a, b| a < b }
371
+ instructions = data.u16
372
+ data.validate_bounds(data.position, instructions)
373
+ data.position += instructions
374
+ total = ends.last + 1
375
+ flags = []
376
+ while flags.length < total
377
+ flag = data.u8
378
+ repeat = flag & 8 == 0 ? 1 : data.u8 + 1
379
+ raise InvalidFont, "glyph flag repetition overflow" if flags.length + repeat > total
380
+ repeat.times { flags << flag }
381
+ end
382
+ axes = [[2, 16], [4, 32]].map do |short, same|
383
+ value = 0
384
+ flags.map do |flag|
385
+ delta = if flag & short != 0
386
+ data.u8 * (flag & same == 0 ? -1 : 1)
387
+ else
388
+ flag & same == 0 ? data.i16 : 0
389
+ end
390
+ value += delta
391
+ end
392
+ end
393
+ points = axes[0].zip(axes[1])
394
+ points = vary_points(glyph, points, ends, data)
395
+ [simple_glyph_outline(points, flags, ends), points]
396
+ end
397
+
398
+ def simple_glyph_outline(points, flags, ends)
399
+ path, first = Outline.new, 0
400
+ ends.each do |last|
401
+ contour = (first..last).map { |i| [*points[i], flags[i] & 1 != 0] }
402
+ start = if contour.first[2]
403
+ contour.shift
404
+ elsif contour.last[2]
405
+ contour.pop
406
+ else
407
+ [(contour.first[0] + contour.last[0]) / 2.0, (contour.first[1] + contour.last[1]) / 2.0, true]
408
+ end
409
+ path.move_to(*start[0, 2])
410
+ contour << start
411
+ pending = nil
412
+ contour.each do |point|
413
+ if point[2]
414
+ pending ? path.quad_to(*pending[0, 2], *point[0, 2]) : path.line_to(*point[0, 2])
415
+ pending = nil
416
+ else
417
+ path.quad_to(*pending[0, 2], (pending[0] + point[0]) / 2.0, (pending[1] + point[1]) / 2.0) if pending
418
+ pending = point
419
+ end
420
+ end
421
+ path.close
422
+ first = last + 1
423
+ end
424
+ path
425
+ end
426
+ end
427
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alhena
4
+ # A path in font units, or arbitrary coordinates for Rasterizer#fill.
5
+ class Outline
6
+ attr_reader :commands, :coordinates
7
+
8
+ def initialize
9
+ @commands = []
10
+ @coordinates = []
11
+ end
12
+
13
+ def move_to(x, y) = add(:move_to, x, y)
14
+ def line_to(x, y) = add(:line_to, x, y)
15
+ def quad_to(cx, cy, x, y) = add(:quad_to, cx, cy, x, y)
16
+ def cubic_to(c1x, c1y, c2x, c2y, x, y) = add(:cubic_to, c1x, c1y, c2x, c2y, x, y)
17
+ def close = add(:close)
18
+ def empty? = @coordinates.empty?
19
+
20
+ def each
21
+ return enum_for(__method__) unless block_given?
22
+ offset = 0
23
+ @commands.each do |command|
24
+ length = {move_to: 2, line_to: 2, quad_to: 4, cubic_to: 6, close: 0}.fetch(command)
25
+ yield command, *@coordinates.slice(offset, length)
26
+ offset += length
27
+ end
28
+ self
29
+ end
30
+
31
+ # Matrix is [xx, yx, xy, yy, dx, dy], as used by SVG and Canvas.
32
+ def transform(matrix)
33
+ raise ArgumentError, "matrix must have six finite numbers" unless matrix.length == 6 && matrix.all? { |n| n.is_a?(Numeric) && n.finite? }
34
+ a, b, c, d, e, f = matrix
35
+ result = self.class.new
36
+ result.commands.concat(@commands)
37
+ @coordinates.each_slice(2) { |x, y| result.coordinates.push(a * x + c * y + e, b * x + d * y + f) }
38
+ result
39
+ end
40
+
41
+ def append(other)
42
+ @commands.concat(other.commands)
43
+ @coordinates.concat(other.coordinates)
44
+ self
45
+ end
46
+
47
+ def bounds
48
+ return [0, 0, 0, 0] if empty?
49
+ xs, ys = @coordinates.each_slice(2).to_a.transpose
50
+ [xs.min, ys.min, xs.max, ys.max]
51
+ end
52
+
53
+ # Reduce cubics to quadratic segments with a bounded coordinate error.
54
+ def to_quadratic(tolerance: 0.25)
55
+ raise ArgumentError, "tolerance must be positive" unless tolerance.is_a?(Numeric) && tolerance.finite? && tolerance > 0
56
+ result = self.class.new
57
+ x = y = sx = sy = 0.0
58
+ each do |command, *args|
59
+ if command == :cubic_to
60
+ reduce_cubic(result, [x, y, *args], tolerance, 0)
61
+ else
62
+ result.public_send(command, *args)
63
+ end
64
+ case command
65
+ when :move_to
66
+ sx, sy = args
67
+ x, y = args
68
+ when :close then x, y = sx, sy
69
+ else x, y = args[-2, 2]
70
+ end
71
+ end
72
+ result
73
+ end
74
+
75
+ private
76
+
77
+ def reduce_cubic(result, points, tolerance, depth)
78
+ x0, y0, ax, ay, bx, by, x1, y1 = points
79
+ error = Math.hypot(-x0 + 3 * ax - 3 * bx + x1, -y0 + 3 * ay - 3 * by + y1) / (12 * Math.sqrt(3))
80
+ if error <= tolerance || depth >= 16
81
+ result.quad_to((3 * ax + 3 * bx - x0 - x1) / 4.0, (3 * ay + 3 * by - y0 - y1) / 4.0, x1, y1)
82
+ else
83
+ p0, p1, p2, p3 = points.each_slice(2).to_a
84
+ a, b, c = [p0, p1, p2, p3].each_cons(2).map { |u, v| [(u[0] + v[0]) / 2, (u[1] + v[1]) / 2] }
85
+ d, e = [a, b, c].each_cons(2).map { |u, v| [(u[0] + v[0]) / 2, (u[1] + v[1]) / 2] }
86
+ middle = [(d[0] + e[0]) / 2, (d[1] + e[1]) / 2]
87
+ reduce_cubic(result, [*p0, *a, *d, *middle], tolerance, depth + 1)
88
+ reduce_cubic(result, [*middle, *e, *c, *p3], tolerance, depth + 1)
89
+ end
90
+ end
91
+
92
+ def add(command, *values)
93
+ raise ArgumentError, "coordinates must be finite" unless values.all? { |n| n.is_a?(Numeric) && n.finite? }
94
+ raise ArgumentError, "path must start with move_to" if @commands.empty? && command != :move_to
95
+ @commands << command
96
+ @coordinates.concat(values.map(&:to_f))
97
+ self
98
+ end
99
+ end
100
+ end
data/lib/alhena/png.rb ADDED
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+
5
+ module Alhena
6
+ # PNG decoder for embedded bitmap glyphs; Ruby stdlib only, bounded inflate.
7
+ module PNG
8
+ SIGNATURE = "\x89PNG\r\n\x1a\n".b.freeze
9
+ PASSES = [[0, 0, 8, 8], [4, 0, 8, 8], [0, 4, 4, 8], [2, 0, 4, 4], [0, 2, 2, 4], [1, 0, 2, 2], [0, 1, 1, 2]].freeze
10
+
11
+ class << self
12
+ def decode(bytes)
13
+ header, compressed, palette, transparency = read_chunks(Binary.new(bytes))
14
+ width, height, _depth, _color, _compression, _filter, interlace = header
15
+ channels = validate_header(header)
16
+ passes = interlace.zero? ? [[0, 0, 1, 1]] : PASSES
17
+ expected = inflated_size(header, channels, passes)
18
+ raw = inflate(compressed, expected)
19
+ pixels = decode_pixels(raw, header: header, channels: channels, passes: passes, palette: palette, transparency: transparency)
20
+ [width, height, pixels]
21
+ rescue Zlib::Error => error
22
+ raise InvalidFont, "invalid PNG compression: #{error.message}"
23
+ end
24
+
25
+ private
26
+
27
+ def read_chunks(source)
28
+ raise InvalidFont, "invalid PNG signature" unless source.bytes(0, 8) == SIGNATURE
29
+ offset = 8
30
+ compressed = +"".b
31
+ palette = transparency = header = nil
32
+ ended = false
33
+ while offset < source.size
34
+ length = source.u32(offset)
35
+ type = source.bytes(offset + 4, 4)
36
+ payload = source.bytes(offset + 8, length)
37
+ crc = source.u32(offset + 8 + length)
38
+ raise InvalidFont, "PNG checksum mismatch" unless Zlib.crc32(type + payload) == crc
39
+ case type
40
+ when "IHDR"
41
+ raise InvalidFont, "invalid PNG header" unless length == 13 && offset == 8
42
+ header = payload.unpack("N2C5")
43
+ when "PLTE" then palette = payload.bytes.each_slice(3).to_a
44
+ when "tRNS" then transparency = payload
45
+ when "IDAT" then compressed << payload
46
+ when "IEND"
47
+ ended = true
48
+ break
49
+ else
50
+ raise UnsupportedFont, "unsupported critical PNG chunk #{type}" if type.getbyte(0) & 32 == 0
51
+ end
52
+ offset += length + 12
53
+ end
54
+ raise InvalidFont, "incomplete PNG" unless header && ended
55
+ [header, compressed, palette, transparency]
56
+ end
57
+
58
+ def validate_header(header)
59
+ width, height, depth, color, compression, filter, interlace = header
60
+ raise InvalidFont, "PNG dimensions exceed limit" unless width > 0 && height > 0 && width * height <= Rasterizer::MAX_PIXELS
61
+ channels = {0 => 1, 2 => 3, 3 => 1, 4 => 2, 6 => 4}[color]
62
+ valid_depths = {0 => [1, 2, 4, 8, 16], 2 => [8, 16], 3 => [1, 2, 4, 8], 4 => [8, 16], 6 => [8, 16]}
63
+ raise UnsupportedFont, "unsupported PNG format" unless channels && valid_depths[color].include?(depth) && compression.zero? && filter.zero? && [0, 1].include?(interlace)
64
+ channels
65
+ end
66
+
67
+ def inflated_size(header, channels, passes)
68
+ width, height, depth = header
69
+ passes.sum do |x, y, dx, dy|
70
+ w, h = pass_dimensions(width, height, x, y, dx, dy)
71
+ w.zero? || h.zero? ? 0 : ((w * channels * depth + 7) / 8 + 1) * h
72
+ end
73
+ end
74
+
75
+ def pass_dimensions(width, height, x, y, dx, dy)
76
+ [[0, (width - x + dx - 1) / dx].max, [0, (height - y + dy - 1) / dy].max]
77
+ end
78
+
79
+ def inflate(compressed, expected)
80
+ raw = +"".b
81
+ inflater = Zlib::Inflate.new
82
+ begin
83
+ inflater.inflate(compressed) do |chunk|
84
+ raise InvalidFont, "PNG decompression exceeds image size" if raw.bytesize + chunk.bytesize > expected
85
+ raw << chunk
86
+ end
87
+ raise InvalidFont, "incomplete PNG compression stream" unless inflater.finished?
88
+ ensure
89
+ inflater.close
90
+ end
91
+ raise InvalidFont, "PNG scanline length mismatch" unless raw.bytesize == expected
92
+ raw
93
+ end
94
+
95
+ def decode_pixels(raw, header:, channels:, passes:, palette:, transparency:)
96
+ width, height, depth, color = header
97
+ output, at = "\0".b * (width * height * 4), 0
98
+ passes.each do |x0, y0, dx, dy|
99
+ w, h = pass_dimensions(width, height, x0, y0, dx, dy)
100
+ next if w.zero? || h.zero?
101
+ stride = (w * channels * depth + 7) / 8
102
+ pixel_bytes = [(channels * depth + 7) / 8, 1].max
103
+ previous = Array.new(stride, 0)
104
+ h.times do |row|
105
+ current = raw.byteslice(at + 1, stride).bytes
106
+ unfilter(current, previous, raw.getbyte(at), pixel_bytes)
107
+ at += stride + 1
108
+ w.times do |col|
109
+ rgba = rgba_for(pixel_samples(current, col, channels, depth), color, depth, palette, transparency)
110
+ target = ((y0 + row * dy) * width + x0 + col * dx) * 4
111
+ rgba.each_with_index { |value, channel| output.setbyte(target + channel, value) }
112
+ end
113
+ previous = current
114
+ end
115
+ end
116
+ output
117
+ end
118
+
119
+ def unfilter(current, previous, mode, pixel_bytes)
120
+ raise InvalidFont, "invalid PNG filter" unless (0..4).cover?(mode)
121
+ current.each_index do |i|
122
+ a, b, c = i >= pixel_bytes ? current[i - pixel_bytes] : 0, previous[i], i >= pixel_bytes ? previous[i - pixel_bytes] : 0
123
+ predictor = case mode
124
+ when 0 then 0
125
+ when 1 then a
126
+ when 2 then b
127
+ when 3 then (a + b) / 2
128
+ when 4
129
+ p = a + b - c
130
+ pa, pb, pc = (p - a).abs, (p - b).abs, (p - c).abs
131
+ pa <= pb && pa <= pc ? a : pb <= pc ? b : c
132
+ end
133
+ current[i] = (current[i] + predictor) & 255
134
+ end
135
+ end
136
+
137
+ def pixel_samples(bytes, column, channels, depth)
138
+ channels.times.map do |channel|
139
+ index = column * channels + channel
140
+ if depth == 16
141
+ (bytes[index * 2] << 8) | bytes[index * 2 + 1]
142
+ elsif depth == 8
143
+ bytes[index]
144
+ else
145
+ (bytes[index * depth / 8] >> (8 - depth - index * depth % 8)) & ((1 << depth) - 1)
146
+ end
147
+ end
148
+ end
149
+
150
+ def rgba_for(samples, color, depth, palette, transparency)
151
+ case color
152
+ when 0
153
+ value = (samples[0] * 255.0 / ((1 << depth) - 1)).round
154
+ alpha = transparency && samples[0] == transparency.unpack1("n") ? 0 : 255
155
+ [value, value, value, alpha]
156
+ when 2
157
+ alpha = transparency && samples == transparency.unpack("n3") ? 0 : 255
158
+ samples.map { |value| depth == 16 ? value >> 8 : value } + [alpha]
159
+ when 3
160
+ rgb = palette && palette[samples[0]]
161
+ raise InvalidFont, "PNG palette index out of range" unless rgb && rgb.length == 3
162
+ rgb + [transparency&.getbyte(samples[0]) || 255]
163
+ when 4
164
+ gray, alpha = samples.map { |value| depth == 16 ? value >> 8 : value }
165
+ [gray, gray, gray, alpha]
166
+ when 6 then samples.map { |value| depth == 16 ? value >> 8 : value }
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end