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,342 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alhena
4
+ # Straight (not premultiplied) sRGB RGBA8 pixels.
5
+ class ColorBitmap
6
+ attr_reader :width, :height, :left, :top, :rgba
7
+
8
+ def initialize(width:, height:, rgba:, left: 0, top: 0)
9
+ unless [width, height].all? { |n| n.is_a?(Integer) && n >= 0 && n <= Rasterizer::MAX_PIXELS } && width * height <= Rasterizer::MAX_PIXELS && rgba.bytesize == width * height * 4
10
+ raise ArgumentError, "invalid color bitmap dimensions"
11
+ end
12
+ @width, @height, @left, @top, @rgba = width, height, left, top, rgba.b.freeze
13
+ freeze
14
+ end
15
+
16
+ def to_bitmap
17
+ alpha = String.new(capacity: width * height, encoding: Encoding::BINARY)
18
+ (width * height).times { |i| alpha << rgba.getbyte(i * 4 + 3) }
19
+ Bitmap.new(width: width, height: height, left: left, top: top, coverage: alpha)
20
+ end
21
+
22
+ # Bilinear resampling in premultiplied-alpha space prevents dark fringes.
23
+ def resize(factor)
24
+ return self if factor == 1
25
+ raise ArgumentError, "scale must be positive and finite" unless factor.is_a?(Numeric) && factor.finite? && factor > 0
26
+ target_width, target_height = (width * factor).round, (height * factor).round
27
+ raise ArgumentError, "color bitmap exceeds size limit" if target_width * target_height > Rasterizer::MAX_PIXELS
28
+ pixels = +"".b
29
+ target_height.times do |y|
30
+ sy = [[(y + 0.5) / factor - 0.5, 0].max, height - 1].min
31
+ y0, y1, fy = sy.floor, [sy.floor + 1, height - 1].min, sy % 1
32
+ target_width.times do |x|
33
+ sx = [[(x + 0.5) / factor - 0.5, 0].max, width - 1].min
34
+ x0, x1, fx = sx.floor, [sx.floor + 1, width - 1].min, sx % 1
35
+ values = [0.0, 0.0, 0.0, 0.0]
36
+ [[x0, y0, (1 - fx) * (1 - fy)], [x1, y0, fx * (1 - fy)], [x0, y1, (1 - fx) * fy], [x1, y1, fx * fy]].each do |px, py, weight|
37
+ index = (py * width + px) * 4
38
+ alpha = rgba.getbyte(index + 3)
39
+ values[3] += alpha * weight
40
+ 3.times { |c| values[c] += rgba.getbyte(index + c) * alpha * weight }
41
+ end
42
+ 3.times { |c| pixels << (values[3].zero? ? 0 : values[c] / values[3]).round.clamp(0, 255) }
43
+ pixels << values[3].round.clamp(0, 255)
44
+ end
45
+ end
46
+ self.class.new(width: target_width, height: target_height, left: (left * factor).round, top: (top * factor).round, rgba: pixels)
47
+ end
48
+ end
49
+
50
+ EmbeddedBitmap = Data.define(:format, :data, :ppem, :left, :top, :width, :height)
51
+
52
+ class Font
53
+ alias rasterize_outline rasterize
54
+
55
+ def rasterize(glyph, size:, **options)
56
+ if @tables.key?("COLR") || @tables.key?("sbix") || @tables.key?("CBDT")
57
+ bitmap = color_bitmap(glyph, size: size, subpixel_x: options.fetch(:subpixel_x, 0))
58
+ return bitmap.to_bitmap if bitmap
59
+ end
60
+ rasterize_outline(glyph, size: size, **options)
61
+ end
62
+
63
+ # @param glyph [Integer] glyph ID, not a Unicode scalar
64
+ # @param size [Numeric] requested pixels per em
65
+ # @return [ColorBitmap, nil] straight RGBA8 or nil without color data
66
+ def color_bitmap(glyph, size:, palette: 0, foreground: [0, 0, 0, 255], subpixel_x: 0)
67
+ validate_glyph(glyph)
68
+ scale_factor(size)
69
+ raise ArgumentError, "palette must be a nonnegative integer" unless palette.is_a?(Integer) && palette >= 0
70
+ raise ArgumentError, "foreground must be RGBA8" unless foreground.is_a?(Array) && foreground.length == 4 && foreground.all? { |n| n.is_a?(Integer) && (0..255).cover?(n) }
71
+ if (layers = color_layers(glyph))
72
+ colors = palettes.fetch(palette) { raise ArgumentError, "palette index out of range" }
73
+ rendered = layers.map do |layer, color|
74
+ rgba = color == 0xffff ? foreground : colors.fetch(color) { raise InvalidFont, "COLR palette index out of range" }
75
+ [rasterize_outline(layer, size: size, subpixel_x: subpixel_x), rgba]
76
+ end
77
+ return compose_layers(rendered)
78
+ end
79
+ embedded = embedded_bitmap(glyph, size: size)
80
+ return nil unless embedded
81
+ render_embedded_bitmap(embedded, size)
82
+ end
83
+
84
+ def palettes
85
+ return [] unless @tables.key?("CPAL")
86
+ @palettes ||= begin
87
+ data = table("CPAL")
88
+ raise UnsupportedFont, "unsupported CPAL version" unless [0, 1].include?(data.u16(0))
89
+ entries, count, records, offset = data.u16(2), data.u16(4), data.u16(6), data.u32(8)
90
+ data.validate_bounds(12, count * 2)
91
+ data.validate_bounds(offset, records * 4)
92
+ count.times.map do |i|
93
+ first = data.u16(12 + i * 2)
94
+ raise InvalidFont, "palette exceeds color records" if first + entries > records
95
+ entries.times.map do |j|
96
+ blue, green, red, alpha = data.bytes(offset + (first + j) * 4, 4).bytes
97
+ [red, green, blue, alpha].freeze
98
+ end.freeze
99
+ end.freeze
100
+ end
101
+ end
102
+
103
+ def color_layers(glyph)
104
+ validate_glyph(glyph)
105
+ return nil unless @tables.key?("COLR")
106
+ data = table("COLR")
107
+ version = data.u16(0)
108
+ raise UnsupportedFont, "unsupported COLR version" unless [0, 1].include?(version)
109
+ count, base, layers, total = data.u16(2), data.u32(4), data.u32(8), data.u16(12)
110
+ data.validate_bounds(base, count * 6)
111
+ data.validate_bounds(layers, total * 4)
112
+ lo, hi = 0, count
113
+ while lo < hi
114
+ mid = (lo + hi) / 2
115
+ data.u16(base + mid * 6) < glyph ? lo = mid + 1 : hi = mid
116
+ end
117
+ return nil if lo == count || data.u16(base + lo * 6) != glyph
118
+ first, length = data.u16(base + lo * 6 + 2), data.u16(base + lo * 6 + 4)
119
+ raise InvalidFont, "COLR layer range exceeds table" if first + length > total
120
+ length.times.map { |i| [data.u16(layers + (first + i) * 4), data.u16(layers + (first + i) * 4 + 2)] }
121
+ end
122
+
123
+ def embedded_bitmap(glyph, size:)
124
+ validate_glyph(glyph)
125
+ scale_factor(size)
126
+ return sbix_bitmap(glyph, size) if @tables.key?("sbix")
127
+ return cbdt_bitmap(glyph, size) if @tables.key?("CBDT") && @tables.key?("CBLC")
128
+ nil
129
+ end
130
+
131
+ private
132
+
133
+ def render_embedded_bitmap(embedded, size)
134
+ raise UnsupportedFont, "embedded #{embedded.format.inspect} requires an image decoder; use embedded_bitmap to obtain the original bytes" unless embedded.format == :png || embedded.format == :rgba
135
+ width, height, pixels = embedded.format == :png ? PNG.decode(embedded.data) : [embedded.width, embedded.height, embedded.data]
136
+ if embedded.width && (embedded.width != width || embedded.height != height)
137
+ raise InvalidFont, "embedded PNG and glyph metrics dimensions differ"
138
+ end
139
+ top = embedded.top || height
140
+ bitmap = ColorBitmap.new(width: width, height: height, left: embedded.left, top: top, rgba: pixels)
141
+ bitmap.resize(size.to_f / embedded.ppem)
142
+ end
143
+
144
+ def compose_layers(layers)
145
+ layers = layers.reject { |bitmap, _| bitmap.width.zero? || bitmap.height.zero? }
146
+ return ColorBitmap.new(width: 0, height: 0, rgba: "") if layers.empty?
147
+ left = layers.map { |bitmap, _| bitmap.left }.min
148
+ top = layers.map { |bitmap, _| bitmap.top }.max
149
+ width = layers.map { |bitmap, _| bitmap.left + bitmap.width }.max - left
150
+ height = top - layers.map { |bitmap, _| bitmap.top - bitmap.height }.min
151
+ raise InvalidFont, "color layers exceed bitmap limit" if width * height > Rasterizer::MAX_PIXELS
152
+ pixels = "\0".b * (width * height * 4)
153
+ layers.each do |bitmap, color|
154
+ bitmap.coverage.each_byte.with_index do |coverage, i|
155
+ next if coverage.zero? || color[3].zero?
156
+ index = ((top - bitmap.top + i / bitmap.width) * width + bitmap.left - left + i % bitmap.width) * 4
157
+ source_alpha = coverage * color[3] / (255.0 * 255)
158
+ destination_alpha = pixels.getbyte(index + 3) / 255.0
159
+ alpha = source_alpha + destination_alpha * (1 - source_alpha)
160
+ 3.times do |channel|
161
+ value = (color[channel] * source_alpha + pixels.getbyte(index + channel) * destination_alpha * (1 - source_alpha)) / alpha
162
+ pixels.setbyte(index + channel, value.round.clamp(0, 255))
163
+ end
164
+ pixels.setbyte(index + 3, (alpha * 255).round.clamp(0, 255))
165
+ end
166
+ end
167
+ ColorBitmap.new(width: width, height: height, left: left, top: top, rgba: pixels)
168
+ end
169
+
170
+ def sbix_bitmap(glyph, size)
171
+ data = table("sbix")
172
+ raise InvalidFont, "invalid sbix version" unless data.u16(0) == 1
173
+ count = data.u32(4)
174
+ data.validate_bounds(8, count * 4)
175
+ strikes = count.times.map { |i| at = data.u32(8 + i * 4); [data.u16(at), at] }
176
+ strikes.sort_by! { |ppem, _| [ppem >= size ? 0 : 1, (ppem - size).abs] }
177
+ strikes.each do |ppem, strike|
178
+ raise InvalidFont, "invalid bitmap strike size" if ppem.zero?
179
+ target, visited = glyph, []
180
+ loop do
181
+ raise InvalidFont, "cyclic sbix duplicate" if visited.include?(target)
182
+ visited << target
183
+ raise InvalidFont, "sbix duplicate glyph out of range" unless target >= 0 && target < glyph_count
184
+ first, last = data.u32(strike + 4 + target * 4), data.u32(strike + 8 + target * 4)
185
+ break if first == last
186
+ entry = Binary.new(data.bytes(strike + first, last - first))
187
+ left, bottom, format = entry.i16(0), entry.i16(2), entry.bytes(4, 4)
188
+ if format == "dupe"
189
+ target = entry.u16(8)
190
+ next
191
+ end
192
+ payload = entry.bytes(8, entry.size - 8).freeze
193
+ width = height = nil
194
+ if format == "png "
195
+ png = Binary.new(payload)
196
+ raise InvalidFont, "invalid embedded PNG" unless png.bytes(0, 8) == PNG::SIGNATURE
197
+ width, height = png.u32(16), png.u32(20)
198
+ end
199
+ # sbix origin is relative to glyf's lower-left box when contours exist.
200
+ if @tables.key?("glyf")
201
+ path = outline(glyph)
202
+ unless path.empty?
203
+ bounds = path.bounds
204
+ left += (bounds[0] * ppem.to_f / units_per_em).round
205
+ bottom += (bounds[1] * ppem.to_f / units_per_em).round
206
+ end
207
+ end
208
+ return EmbeddedBitmap.new(format: format.strip.to_sym, data: payload, ppem: ppem, left: left, top: height && bottom + height, width: width, height: height)
209
+ end
210
+ end
211
+ nil
212
+ end
213
+
214
+ def cbdt_bitmap(glyph, size, ancestors = [])
215
+ raise InvalidFont, "cyclic or deeply nested CBDT composite" if ancestors.include?(glyph) || ancestors.length > 5
216
+ locations, bitmaps = table("CBLC"), table("CBDT")
217
+ count = locations.u32(4)
218
+ locations.validate_bounds(8, count * 48)
219
+ strikes = count.times.map { |i| at = 8 + i * 48; [locations.u8(at + 45), at] }
220
+ strikes.sort_by! { |ppem, _| [ppem >= size ? 0 : 1, (ppem - size).abs] }
221
+ strikes.each do |ppem, strike|
222
+ raise InvalidFont, "invalid CBDT strike size" if ppem.zero?
223
+ next unless glyph >= locations.u16(strike + 40) && glyph <= locations.u16(strike + 42)
224
+ list, count = locations.u32(strike), locations.u32(strike + 8)
225
+ locations.validate_bounds(list, count * 8)
226
+ count.times do |i|
227
+ record = list + i * 8
228
+ first, last = locations.u16(record), locations.u16(record + 2)
229
+ next unless glyph >= first && glyph <= last
230
+ at = list + locations.u32(record + 4)
231
+ index_format, image_format, image_base = locations.u16(at), locations.u16(at + 2), locations.u32(at + 4)
232
+ offset, length, metrics = cbdt_bitmap_location(locations, at, index_format, glyph, first, last)
233
+ next unless offset && length.positive?
234
+ image = Binary.new(bitmaps.bytes(image_base + offset, length))
235
+ if [1, 2, 8, 17].include?(image_format)
236
+ metrics = image.bytes(0, 5)
237
+ image.position = 5
238
+ elsif [6, 7, 9, 18].include?(image_format)
239
+ metrics = image.bytes(0, 8)
240
+ image.position = 8
241
+ end
242
+ raise InvalidFont, "CBDT bitmap has no metrics" unless metrics
243
+ height, width, left, top = metrics.unpack("CCcc")
244
+ if [17, 18, 19].include?(image_format)
245
+ length = image.u32
246
+ payload = image.bytes(image.position, length).freeze
247
+ format = :png
248
+ elsif [1, 2, 5, 6, 7].include?(image_format)
249
+ payload = unpack_cbdt_bitmap(image, width, height, locations.u8(strike + 46), [1, 6].include?(image_format)).freeze
250
+ format = :rgba
251
+ elsif [8, 9].include?(image_format)
252
+ image.u8 if image_format == 8 # padding after small metrics
253
+ payload = compose_cbdt_pixels(image, width: width, height: height, ppem: ppem, glyph: glyph, ancestors: ancestors)
254
+ format = :rgba
255
+ else
256
+ raise UnsupportedFont, "unsupported CBDT image format #{image_format}"
257
+ end
258
+ return EmbeddedBitmap.new(format: format, data: payload, ppem: ppem, left: left, top: top, width: width, height: height)
259
+ end
260
+ end
261
+ nil
262
+ end
263
+
264
+ def compose_cbdt_pixels(image, width:, height:, ppem:, glyph:, ancestors:)
265
+ components = image.u16
266
+ image.validate_bounds(image.position, components * 4)
267
+ payload = "\0".b * (width * height * 4)
268
+ components.times do
269
+ component, dx, dy = image.u16, image.i8, image.i8
270
+ raise InvalidFont, "CBDT composite glyph out of range" if component >= glyph_count
271
+ child = cbdt_bitmap(component, ppem, ancestors + [glyph])
272
+ raise InvalidFont, "missing CBDT component bitmap" unless child
273
+ cw, ch, pixels = child.format == :png ? PNG.decode(child.data) : [child.width, child.height, child.data]
274
+ ch.times do |row|
275
+ next unless row + dy >= 0 && row + dy < height
276
+ cw.times do |col|
277
+ next unless col + dx >= 0 && col + dx < width
278
+ source = (row * cw + col) * 4
279
+ target = ((row + dy) * width + col + dx) * 4
280
+ sa, da = pixels.getbyte(source + 3) / 255.0, payload.getbyte(target + 3) / 255.0
281
+ alpha = sa + da * (1 - sa)
282
+ next if alpha.zero?
283
+ 3.times { |channel| payload.setbyte(target + channel, ((pixels.getbyte(source + channel) * sa + payload.getbyte(target + channel) * da * (1 - sa)) / alpha).round.clamp(0, 255)) }
284
+ payload.setbyte(target + 3, (alpha * 255).round.clamp(0, 255))
285
+ end
286
+ end
287
+ end
288
+ payload.freeze
289
+ end
290
+
291
+ def cbdt_bitmap_location(data, at, format, glyph, first, last)
292
+ metrics = nil
293
+ case format
294
+ when 1, 3
295
+ width = format == 1 ? 4 : 2
296
+ data.validate_bounds(at + 8, (last - first + 2) * width)
297
+ offset = at + 8 + (glyph - first) * width
298
+ a, b = format == 1 ? [data.u32(offset), data.u32(offset + 4)] : [data.u16(offset), data.u16(offset + 2)]
299
+ when 2, 5
300
+ size = data.u32(at + 8)
301
+ metrics = data.bytes(at + 12, 8)
302
+ index = glyph - first
303
+ if format == 5
304
+ count = data.u32(at + 20)
305
+ data.validate_bounds(at + 24, count * 2)
306
+ index = count.times.find { |i| data.u16(at + 24 + i * 2) == glyph }
307
+ return [nil, nil, nil] unless index
308
+ end
309
+ a, b = index * size, (index + 1) * size
310
+ when 4
311
+ count = data.u32(at + 8)
312
+ data.validate_bounds(at + 12, (count + 1) * 4)
313
+ index = count.times.find { |i| data.u16(at + 12 + i * 4) == glyph }
314
+ return [nil, nil, nil] unless index
315
+ a, b = data.u16(at + 14 + index * 4), data.u16(at + 18 + index * 4)
316
+ else
317
+ raise UnsupportedFont, "unsupported CBLC index format #{format}"
318
+ end
319
+ raise InvalidFont, "unordered CBDT offsets" if b < a
320
+ [a, b - a, metrics]
321
+ end
322
+
323
+ def unpack_cbdt_bitmap(data, width, height, depth, row_aligned)
324
+ raise UnsupportedFont, "unsupported CBDT bit depth" unless [1, 2, 4, 8, 32].include?(depth)
325
+ stride = row_aligned ? ((width * depth + 7) / 8) * 8 : width * depth
326
+ bytes = data.bytes(data.position, (stride * height + 7) / 8)
327
+ output = +"".b
328
+ (width * height).times do |i|
329
+ bit = (i / width) * stride + (i % width) * depth
330
+ if depth == 32
331
+ blue, green, red, alpha = bytes.byteslice(bit / 8, 4).bytes
332
+ [red, green, blue].each { |c| output << (alpha.zero? ? 0 : (c * 255.0 / alpha).round.clamp(0, 255)) }
333
+ output << alpha
334
+ else
335
+ value = (bytes.getbyte(bit / 8) >> (8 - depth - bit % 8)) & ((1 << depth) - 1)
336
+ output << 0 << 0 << 0 << (value * 255.0 / ((1 << depth) - 1)).round
337
+ end
338
+ end
339
+ output
340
+ end
341
+ end
342
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Ruby 3.2 added Data; Struct provides the same record operations used here.
4
+ unless defined?(Data)
5
+ Data = Struct
6
+ def Data.define(*members, &block)
7
+ Struct.new(*members) do
8
+ members.each { |member| undef_method :"#{member}=" }
9
+ define_method(:initialize) do |*values, **keywords|
10
+ if keywords.any?
11
+ raise ArgumentError, "expected either positional or keyword members" unless values.empty? && keywords.keys.sort == members.sort
12
+ values = members.map { |member| keywords.fetch(member) }
13
+ end
14
+ raise ArgumentError, "wrong number of members" unless values.length == members.length
15
+ super(*values)
16
+ freeze
17
+ end
18
+ define_method(:with) do |**changes|
19
+ changes.empty? ? self : self.class.new(**to_h.merge(changes))
20
+ end
21
+ class_eval(&block) if block
22
+ end
23
+ end
24
+ end
25
+