alhena 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +178 -0
- data/examples/color.rb +11 -0
- data/examples/render.rb +21 -0
- data/lib/alhena/binary.rb +49 -0
- data/lib/alhena/bitmap.rb +27 -0
- data/lib/alhena/cache.rb +46 -0
- data/lib/alhena/cff.rb +423 -0
- data/lib/alhena/color.rb +342 -0
- data/lib/alhena/data_compat.rb +25 -0
- data/lib/alhena/font.rb +427 -0
- data/lib/alhena/outline.rb +100 -0
- data/lib/alhena/png.rb +171 -0
- data/lib/alhena/rasterizer.rb +172 -0
- data/lib/alhena/variation.rb +314 -0
- data/lib/alhena/version.rb +5 -0
- data/lib/alhena.rb +21 -0
- data/sig/alhena.rbs +117 -0
- metadata +64 -0
data/lib/alhena/font.rb
ADDED
|
@@ -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
|