alhena 0.2.1 → 0.3.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 +4 -4
- data/CHANGELOG.md +7 -0
- data/README.md +5 -0
- data/lib/alhena/cff.rb +45 -3
- data/lib/alhena/cff_subset.rb +224 -0
- data/lib/alhena/font.rb +76 -2
- data/lib/alhena/subset.rb +185 -0
- data/lib/alhena/version.rb +1 -1
- data/lib/alhena.rb +2 -0
- data/sig/alhena.rbs +12 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 387d1d13d4023f565425ad659f94aaf572e7110e07eb029fcb7e17a8e20a7aee
|
|
4
|
+
data.tar.gz: 7828025fb60b1a932e7d2517705ba8d5c5b98563aba4d6fc8ca958d976d7bd31
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3a50ec152fed8ae539ee365514ff7759f74675f45fb446412f7d2c9b268f50a4ee0bbf06fed77700b135b23447152438a489c126b94521da70e28af1ed51aed8
|
|
7
|
+
data.tar.gz: 345d93a28236e523e1860db99d779a557676b15ccca644a8794d0713ab447cc591b604d60ad9d2dab06fb489c470e9ab19fa5037ff685692290729cfbf44fac0
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 — 2026-09-23
|
|
4
|
+
|
|
5
|
+
- Expose font bounds, glyph IDs, advances, and Unicode mappings for font consumers.
|
|
6
|
+
- Build self-contained TrueType subsets with composite glyph dependencies and valid checksums.
|
|
7
|
+
- Preserve complete CFF fonts when building PDF-compatible font data.
|
|
8
|
+
- Convert supported static name-keyed CFF1 fonts to CID-keyed subsets for PDF embedding.
|
|
9
|
+
|
|
3
10
|
## 0.2.1 — 2026-09-21
|
|
4
11
|
|
|
5
12
|
- Add a deterministic rasterized-glyph demo image and regeneration task.
|
data/README.md
CHANGED
|
@@ -28,6 +28,7 @@ Alhena reads TrueType, OpenType, and TTC fonts, extracts their outlines, and ras
|
|
|
28
28
|
## Features
|
|
29
29
|
|
|
30
30
|
- TrueType, OpenType CFF1/CFF2, and TTC font parsing
|
|
31
|
+
- TrueType and supported CFF1 subset construction and glyph-to-Unicode lookup for PDF embedding
|
|
31
32
|
- Analytic grayscale and LCD rasterization with subpixel positioning
|
|
32
33
|
- Variable font axes, outlines, and metrics
|
|
33
34
|
- Fast text advance measurement without rasterization
|
|
@@ -164,6 +165,10 @@ rgba = color&.rgba
|
|
|
164
165
|
|
|
165
166
|
Alhena does not provide TrueType hinting, shaping, GSUB/GPOS, kerning, system font discovery, COLR v1 paint graphs, avar v2, JPEG/TIFF decoding, or MVAR global metric variation. Use `embedded_bitmap` to retrieve unsupported sbix image formats for external decoding.
|
|
166
167
|
|
|
168
|
+
`Alhena::Subset.build(font, glyph_ids)` creates a compact TrueType font containing the requested glyphs and any composite-glyph dependencies, or a compact static name-keyed CFF1 font for the supported CFF subset described below. CFF2, CID-keyed CFF, predefined CFF charsets, custom CFF encodings, and variable CFF1 fonts are rejected with `Alhena::UnsupportedFont`; they are never silently returned unchanged. CFF subsets retain selected outlines, Unicode mappings, horizontal metrics, and the original subroutine indexes, but omit layout, shaping, color, and vertical-metric tables whose glyph references are not rewritten.
|
|
169
|
+
|
|
170
|
+
For PDF consumers, `Alhena::Subset.build_cid(font, glyph_ids)` converts a static, name-keyed CFF1 font to a compact CID-keyed CFF1 program. Array order assigns CIDs (the first glyph must be glyph 0), and repeated glyph IDs are allowed. It intentionally rejects CFF2, variable, and already CID-keyed source fonts.
|
|
171
|
+
|
|
167
172
|
Unknown formats raise `Alhena::UnsupportedFont`; malformed bounds and structures raise `Alhena::InvalidFont`. Bitmap allocations are limited to 16,777,216 samples.
|
|
168
173
|
|
|
169
174
|
## Performance
|
data/lib/alhena/cff.rb
CHANGED
|
@@ -17,9 +17,11 @@ module Alhena
|
|
|
17
17
|
else
|
|
18
18
|
names, at = read_index(header_size)
|
|
19
19
|
tops, at = read_index(at)
|
|
20
|
-
|
|
20
|
+
@strings, at = read_index(at)
|
|
21
21
|
@global_subrs, = read_index(at)
|
|
22
22
|
raise InvalidFont, "OpenType CFF must contain one font" unless names.size == 1 && tops.size == 1
|
|
23
|
+
@name_objects, @top_dictionary = names, tops.first
|
|
24
|
+
@top_dictionary_entries = dictionary_entries(@top_dictionary)
|
|
23
25
|
@top = parse_dictionary(tops.first)
|
|
24
26
|
end
|
|
25
27
|
raise UnsupportedFont, "only Type 2 charstrings are supported" unless @top.fetch(1206, [2]).first == 2
|
|
@@ -28,9 +30,11 @@ module Alhena
|
|
|
28
30
|
raise InvalidFont, "invalid CFF FontMatrix" unless @matrix.size == 6
|
|
29
31
|
if @top.key?(1230) || @version == 2
|
|
30
32
|
dictionaries, = read_index(@top.fetch(1236).first)
|
|
33
|
+
@private_records = []
|
|
31
34
|
@privates = dictionaries.map { |entry| read_private_dict(parse_dictionary(entry)) }
|
|
32
35
|
@fd_select = @top[1237]&.first
|
|
33
36
|
else
|
|
37
|
+
@private_records = []
|
|
34
38
|
@privates = [read_private_dict(@top)]
|
|
35
39
|
@privates.first[3] = nil
|
|
36
40
|
end
|
|
@@ -49,6 +53,22 @@ module Alhena
|
|
|
49
53
|
path.transform(@matrix.map { |n| n * @units_per_em })
|
|
50
54
|
end
|
|
51
55
|
|
|
56
|
+
# Rebuild a name-keyed CFF1 table with only the requested charstrings.
|
|
57
|
+
def subset(glyphs)
|
|
58
|
+
Subsetter.new(glyphs, data: @data, version: @version, top: @top,
|
|
59
|
+
top_dictionary_entries: @top_dictionary_entries, name_objects: @name_objects,
|
|
60
|
+
strings: @strings, global_subrs: @global_subrs, charstrings: @charstrings,
|
|
61
|
+
private_records: @private_records).build
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Rebuild a single-FD CID-keyed CFF1 program in the requested CID order.
|
|
65
|
+
def subset_cid(glyphs)
|
|
66
|
+
Subsetter.new(glyphs, data: @data, version: @version, top: @top,
|
|
67
|
+
top_dictionary_entries: @top_dictionary_entries, name_objects: @name_objects,
|
|
68
|
+
strings: @strings, global_subrs: @global_subrs, charstrings: @charstrings,
|
|
69
|
+
private_records: @private_records).build_cid
|
|
70
|
+
end
|
|
71
|
+
|
|
52
72
|
private
|
|
53
73
|
|
|
54
74
|
def read_index(at)
|
|
@@ -73,7 +93,7 @@ module Alhena
|
|
|
73
93
|
data, stack, result = Binary.new(bytes), [], {}
|
|
74
94
|
while data.position < data.size
|
|
75
95
|
byte = data.u8
|
|
76
|
-
if byte >= 32 || [28, 29, 30].include?(byte)
|
|
96
|
+
if byte >= 32 || [28, 29, 30, 255].include?(byte)
|
|
77
97
|
stack << read_number(data, byte)
|
|
78
98
|
raise InvalidFont, "CFF DICT operand overflow" if stack.length > (@version == 2 ? 513 : 48)
|
|
79
99
|
else
|
|
@@ -105,6 +125,7 @@ module Alhena
|
|
|
105
125
|
raise InvalidFont, "oversized CFF real" if value.size > 64
|
|
106
126
|
end
|
|
107
127
|
end
|
|
128
|
+
when 255 then data.fixed
|
|
108
129
|
when 32..246 then byte - 139
|
|
109
130
|
when 247..250 then (byte - 247) * 256 + data.u8 + 108
|
|
110
131
|
when 251..254 then -(byte - 251) * 256 - data.u8 - 108
|
|
@@ -116,11 +137,32 @@ module Alhena
|
|
|
116
137
|
|
|
117
138
|
def read_private_dict(dict)
|
|
118
139
|
length, offset = dict.fetch(18, [0, 0])
|
|
119
|
-
|
|
140
|
+
raw = @data.bytes(offset, length)
|
|
141
|
+
private_data = parse_dictionary(raw)
|
|
120
142
|
local = private_data.key?(19) ? read_index(offset + private_data[19].first).first : []
|
|
143
|
+
@private_records << { entries: dictionary_entries(raw), subrs: local } if @version == 1
|
|
121
144
|
[local, private_data.fetch(20, [0]).first, private_data.fetch(21, [0]).first, dict[1207], private_data.fetch(22, [0]).first]
|
|
122
145
|
end
|
|
123
146
|
|
|
147
|
+
def dictionary_entries(bytes)
|
|
148
|
+
data, operands, entries = Binary.new(bytes), [], []
|
|
149
|
+
while data.position < data.size
|
|
150
|
+
start = data.position
|
|
151
|
+
byte = data.u8
|
|
152
|
+
if byte >= 32 || [28, 29, 30, 255].include?(byte)
|
|
153
|
+
value = read_number(data, byte)
|
|
154
|
+
operands << [bytes.byteslice(start...data.position), value]
|
|
155
|
+
else
|
|
156
|
+
operator = byte == 12 ? 1200 + data.u8 : byte
|
|
157
|
+
entries << [operator, operands]
|
|
158
|
+
operands = []
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
raise InvalidFont, "unterminated CFF DICT operands" unless operands.empty?
|
|
162
|
+
|
|
163
|
+
entries
|
|
164
|
+
end
|
|
165
|
+
|
|
124
166
|
def font_dict_index(glyph)
|
|
125
167
|
return 0 unless @fd_select
|
|
126
168
|
case @data.u8(@fd_select)
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Alhena
|
|
4
|
+
class CFF
|
|
5
|
+
class Subsetter
|
|
6
|
+
def initialize(glyphs, data:, version:, top:, top_dictionary_entries:, name_objects:, strings:,
|
|
7
|
+
global_subrs:, charstrings:, private_records:)
|
|
8
|
+
@glyphs, @data, @version, @top = glyphs, data, version, top
|
|
9
|
+
@top_dictionary_entries, @name_objects, @strings = top_dictionary_entries, name_objects, strings
|
|
10
|
+
@global_subrs, @charstrings, @private_records = global_subrs, charstrings, private_records
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def build
|
|
14
|
+
raise UnsupportedFont, "CFF subsetting requires CFF1" unless @version == 1
|
|
15
|
+
raise UnsupportedFont, "CID-keyed CFF subsetting is unsupported" if @top.key?(1230) || @top.key?(1236) || @top.key?(1237)
|
|
16
|
+
raise UnsupportedFont, "CFF predefined charsets cannot be subset" if @top.fetch(15, [0]).first < 3
|
|
17
|
+
raise UnsupportedFont, "custom CFF encodings cannot be subset" if @top.fetch(16, [0]).first > 1
|
|
18
|
+
raise ArgumentError, "glyphs must be a nonempty Array" unless @glyphs.is_a?(Array) && !@glyphs.empty?
|
|
19
|
+
raise ArgumentError, "CFF glyph out of range" unless @glyphs.all? { |glyph| glyph.is_a?(Integer) && glyph.between?(0, @charstrings.length - 1) }
|
|
20
|
+
|
|
21
|
+
glyphs = @glyphs.uniq.sort
|
|
22
|
+
raise ArgumentError, "CFF subset must include glyph 0" unless glyphs.first.zero?
|
|
23
|
+
private_record = @private_records.first
|
|
24
|
+
name_index, string_index = encode_index(@name_objects), encode_index(@strings)
|
|
25
|
+
global_subrs_index = encode_index(@global_subrs)
|
|
26
|
+
charstrings_index = encode_index(glyphs.map { |glyph| @charstrings.fetch(glyph) })
|
|
27
|
+
charset = subset_charset(glyphs)
|
|
28
|
+
local_subrs = private_record ? private_record[:subrs] : []
|
|
29
|
+
has_local_subrs = private_record && private_record[:entries].any? { |operator, _| operator == 19 }
|
|
30
|
+
local_subrs_index = has_local_subrs ? encode_index(local_subrs) : "".b
|
|
31
|
+
private_dictionary = encode_private_dictionary(private_record, has_local_subrs)
|
|
32
|
+
header = @data.bytes(0, @data.u8(2))
|
|
33
|
+
base_offset = header.bytesize + name_index.bytesize + string_index.bytesize + global_subrs_index.bytesize
|
|
34
|
+
charset_offset = charstrings_offset = private_offset = 0
|
|
35
|
+
|
|
36
|
+
12.times do
|
|
37
|
+
top_index = encode_index([encode_top_dictionary(charset_offset, charstrings_offset, private_offset, private_dictionary.bytesize)])
|
|
38
|
+
offsets = [base_offset + top_index.bytesize]
|
|
39
|
+
offsets << offsets[0] + charset.bytesize
|
|
40
|
+
offsets << offsets[1] + charstrings_index.bytesize
|
|
41
|
+
break if offsets == [charset_offset, charstrings_offset, private_offset]
|
|
42
|
+
|
|
43
|
+
charset_offset, charstrings_offset, private_offset = offsets
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
top_index = encode_index([encode_top_dictionary(charset_offset, charstrings_offset, private_offset, private_dictionary.bytesize)])
|
|
47
|
+
offsets = [base_offset + top_index.bytesize]
|
|
48
|
+
offsets << offsets[0] + charset.bytesize
|
|
49
|
+
offsets << offsets[1] + charstrings_index.bytesize
|
|
50
|
+
raise InvalidFont, "CFF subset offsets did not converge" unless offsets == [charset_offset, charstrings_offset, private_offset]
|
|
51
|
+
|
|
52
|
+
header + name_index + top_index + string_index + global_subrs_index + charset +
|
|
53
|
+
charstrings_index + private_dictionary + local_subrs_index
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def build_cid
|
|
57
|
+
raise UnsupportedFont, "CFF CID conversion requires name-keyed CFF1" unless @version == 1 && !@top.key?(1230) && !@top.key?(1236) && !@top.key?(1237)
|
|
58
|
+
raise ArgumentError, "glyphs must be a nonempty Array" unless @glyphs.is_a?(Array) && !@glyphs.empty?
|
|
59
|
+
raise ArgumentError, "CFF glyph out of range" unless @glyphs.all? { |glyph| glyph.is_a?(Integer) && glyph.between?(0, @charstrings.length - 1) }
|
|
60
|
+
raise ArgumentError, "CID 0 must map to glyph 0" unless @glyphs.first.zero?
|
|
61
|
+
raise ArgumentError, "CID font cannot exceed 65,536 glyphs" if @glyphs.length > 65_536
|
|
62
|
+
|
|
63
|
+
private_record = @private_records.first
|
|
64
|
+
has_local_subrs = private_record && private_record[:entries].any? { |operator, _| operator == 19 }
|
|
65
|
+
private_dictionary = private_record ? encode_private_dictionary(private_record, has_local_subrs) : "".b
|
|
66
|
+
local_subrs_index = has_local_subrs ? encode_index(private_record[:subrs]) : "".b
|
|
67
|
+
charset = [0].pack("C") + (1...@glyphs.length).to_a.pack("n*")
|
|
68
|
+
charstrings_index = encode_index(@glyphs.map { |glyph| @charstrings.fetch(glyph) })
|
|
69
|
+
fd_select = "\0".b * (@glyphs.length + 1)
|
|
70
|
+
header = @data.bytes(0, @data.u8(2))
|
|
71
|
+
name_index = encode_index(@name_objects)
|
|
72
|
+
string_index = encode_index(@strings + ["Adobe", "Identity"])
|
|
73
|
+
global_subrs_index = encode_index(@global_subrs)
|
|
74
|
+
base_offset = header.bytesize + name_index.bytesize + string_index.bytesize + global_subrs_index.bytesize
|
|
75
|
+
offsets = Array.new(5, 0)
|
|
76
|
+
|
|
77
|
+
16.times do
|
|
78
|
+
top_index = encode_index([encode_cid_top_dictionary(offsets)])
|
|
79
|
+
cursor = base_offset + top_index.bytesize
|
|
80
|
+
charset_offset = cursor
|
|
81
|
+
cursor += charset.bytesize
|
|
82
|
+
charstrings_offset = cursor
|
|
83
|
+
cursor += charstrings_index.bytesize
|
|
84
|
+
fd_select_offset = cursor
|
|
85
|
+
cursor += fd_select.bytesize
|
|
86
|
+
fd_array_offset = cursor
|
|
87
|
+
fd_dictionary = encode_dictionary([], private_record ? {18 => [private_dictionary.bytesize, offsets[4]]} : {})
|
|
88
|
+
fd_array = encode_index([fd_dictionary])
|
|
89
|
+
private_offset = fd_array_offset + fd_array.bytesize
|
|
90
|
+
current = [charset_offset, charstrings_offset, fd_select_offset, fd_array_offset, private_offset]
|
|
91
|
+
break if current == offsets
|
|
92
|
+
|
|
93
|
+
offsets = current
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
top_index = encode_index([encode_cid_top_dictionary(offsets)])
|
|
97
|
+
fd_dictionary = encode_dictionary([], private_record ? {18 => [private_dictionary.bytesize, offsets[4]]} : {})
|
|
98
|
+
fd_array = encode_index([fd_dictionary])
|
|
99
|
+
charset_offset, charstrings_offset, fd_select_offset, fd_array_offset, private_offset = offsets
|
|
100
|
+
cursor = base_offset + top_index.bytesize
|
|
101
|
+
expected = [cursor, cursor + charset.bytesize,
|
|
102
|
+
cursor + charset.bytesize + charstrings_index.bytesize,
|
|
103
|
+
cursor + charset.bytesize + charstrings_index.bytesize + fd_select.bytesize]
|
|
104
|
+
raise InvalidFont, "CFF CID subset offsets did not converge" unless expected == offsets.take(4) && fd_array_offset + fd_array.bytesize == private_offset
|
|
105
|
+
|
|
106
|
+
header + name_index + top_index + string_index + global_subrs_index + charset +
|
|
107
|
+
charstrings_index + fd_select + fd_array + private_dictionary + local_subrs_index
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def encode_private_dictionary(record, has_subrs)
|
|
113
|
+
return "".b unless record
|
|
114
|
+
return encode_dictionary(record[:entries], {}) unless has_subrs
|
|
115
|
+
|
|
116
|
+
relative_offset = 0
|
|
117
|
+
8.times do
|
|
118
|
+
bytes = encode_dictionary(record[:entries], {19 => [relative_offset]})
|
|
119
|
+
return bytes if bytes.bytesize == relative_offset
|
|
120
|
+
|
|
121
|
+
relative_offset = bytes.bytesize
|
|
122
|
+
end
|
|
123
|
+
raise InvalidFont, "CFF private subroutine offset did not converge"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def encode_dictionary(entries, replacements)
|
|
127
|
+
present = {}
|
|
128
|
+
output = +"".b
|
|
129
|
+
entries.each do |operator, operands|
|
|
130
|
+
if replacements.key?(operator)
|
|
131
|
+
output << replacements.fetch(operator).map { |value| encode_number(value) }.join.b
|
|
132
|
+
present[operator] = true
|
|
133
|
+
else
|
|
134
|
+
operands.each { |raw, _value| output << raw }
|
|
135
|
+
end
|
|
136
|
+
output << encode_operator(operator)
|
|
137
|
+
end
|
|
138
|
+
replacements.each do |operator, values|
|
|
139
|
+
next if present[operator]
|
|
140
|
+
|
|
141
|
+
output << values.map { |value| encode_number(value) }.join.b
|
|
142
|
+
output << encode_operator(operator)
|
|
143
|
+
end
|
|
144
|
+
output
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def encode_operator(operator)
|
|
148
|
+
operator >= 1200 ? [12, operator - 1200].pack("C2") : [operator].pack("C")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def encode_number(value)
|
|
152
|
+
raise InvalidFont, "invalid CFF offset" unless value.is_a?(Integer) && value >= 0
|
|
153
|
+
case value
|
|
154
|
+
when 0..107 then [value + 139].pack("C")
|
|
155
|
+
when 108..1131 then [247 + (value - 108) / 256, (value - 108) % 256].pack("C2")
|
|
156
|
+
when 1132..32767 then [28, value].pack("Cn")
|
|
157
|
+
when 32768..0x7fff_ffff then [29, value].pack("CN")
|
|
158
|
+
else raise InvalidFont, "CFF offset exceeds DICT integer range"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def encode_index(objects)
|
|
163
|
+
raise UnsupportedFont, "too many CFF INDEX entries" if objects.length > 0xffff
|
|
164
|
+
return [0].pack("n") if objects.empty?
|
|
165
|
+
|
|
166
|
+
offsets = [1]
|
|
167
|
+
objects.each { |object| offsets << offsets.last + object.bytesize }
|
|
168
|
+
raise UnsupportedFont, "CFF INDEX exceeds 32-bit offsets" if offsets.last > 0xffff_ffff
|
|
169
|
+
width = offsets.last <= 0xff ? 1 : offsets.last <= 0xffff ? 2 : offsets.last <= 0xff_ffff ? 3 : 4
|
|
170
|
+
encoded_offsets = offsets.map do |offset|
|
|
171
|
+
width.times.map { |index| (offset >> ((width - index - 1) * 8)) & 0xff }.pack("C*")
|
|
172
|
+
end.join.b
|
|
173
|
+
[objects.length].pack("n") + [width].pack("C") + encoded_offsets + objects.join.b
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def subset_charset(glyphs)
|
|
177
|
+
offset = @top.fetch(15).first
|
|
178
|
+
format = @data.u8(offset)
|
|
179
|
+
source_sids = [0]
|
|
180
|
+
glyph = 1
|
|
181
|
+
case format
|
|
182
|
+
when 0
|
|
183
|
+
@data.validate_bounds(offset + 1, (@charstrings.length - 1) * 2)
|
|
184
|
+
source_sids.concat((@charstrings.length - 1).times.map { |index| @data.u16(offset + 1 + index * 2) })
|
|
185
|
+
when 1, 2
|
|
186
|
+
cursor = offset + 1
|
|
187
|
+
while glyph < @charstrings.length
|
|
188
|
+
first = @data.u16(cursor)
|
|
189
|
+
cursor += 2
|
|
190
|
+
left = format == 1 ? @data.u8(cursor) : @data.u16(cursor)
|
|
191
|
+
cursor += format == 1 ? 1 : 2
|
|
192
|
+
(left + 1).times { |index| source_sids[glyph] = first + index; glyph += 1 }
|
|
193
|
+
raise InvalidFont, "CFF charset range exceeds glyph count" if glyph > @charstrings.length
|
|
194
|
+
end
|
|
195
|
+
else
|
|
196
|
+
raise UnsupportedFont, "unsupported CFF charset format"
|
|
197
|
+
end
|
|
198
|
+
raise InvalidFont, "CFF charset does not cover glyphs" unless source_sids.length == @charstrings.length
|
|
199
|
+
|
|
200
|
+
selected_sids = glyphs.drop(1).map { |glyph_id| source_sids.fetch(glyph_id) }
|
|
201
|
+
unless selected_sids.all? { |sid| sid.between?(0, 390) || (sid >= 391 && sid - 391 < @strings.length) }
|
|
202
|
+
raise InvalidFont, "CFF charset references an invalid SID"
|
|
203
|
+
end
|
|
204
|
+
[0].pack("C") + selected_sids.pack("n*")
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def encode_top_dictionary(charset_offset, charstrings_offset, private_offset, private_size)
|
|
208
|
+
replacements = {15 => [charset_offset], 17 => [charstrings_offset]}
|
|
209
|
+
replacements[18] = [private_size, private_offset] if @top.key?(18)
|
|
210
|
+
replacements[16] = [@top.fetch(16, [0]).first] if @top.key?(16)
|
|
211
|
+
encode_dictionary(@top_dictionary_entries, replacements)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def encode_cid_top_dictionary(offsets)
|
|
215
|
+
charset_offset, charstrings_offset, fd_select_offset, fd_array_offset = offsets
|
|
216
|
+
entries = @top_dictionary_entries.reject { |operator, _| [15, 16, 17, 18, 1230, 1236, 1237].include?(operator) }
|
|
217
|
+
ros_registry = 391 + @strings.length
|
|
218
|
+
replacements = {15 => [charset_offset], 17 => [charstrings_offset],
|
|
219
|
+
1230 => [ros_registry, ros_registry + 1, 0], 1236 => [fd_array_offset], 1237 => [fd_select_offset]}
|
|
220
|
+
encode_dictionary(entries, replacements)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
data/lib/alhena/font.rb
CHANGED
|
@@ -5,7 +5,7 @@ module Alhena
|
|
|
5
5
|
class Font
|
|
6
6
|
LOOKUP_CACHE_SIZE = 4096
|
|
7
7
|
private_constant :LOOKUP_CACHE_SIZE
|
|
8
|
-
attr_reader :index, :tables, :axis_values
|
|
8
|
+
attr_reader :index, :tables, :axis_values, :sfnt_signature
|
|
9
9
|
|
|
10
10
|
# @param path [String] sfnt, OpenType or TTC filename
|
|
11
11
|
# @param index [Integer] zero-based collection face
|
|
@@ -29,6 +29,7 @@ module Alhena
|
|
|
29
29
|
end
|
|
30
30
|
signature = @binary.bytes(offset, 4)
|
|
31
31
|
raise UnsupportedFont, "expected sfnt, OpenType or TTC" unless ["\x00\x01\x00\x00".b, "OTTO", "true"].include?(signature)
|
|
32
|
+
@sfnt_signature = signature.freeze
|
|
32
33
|
count = @binary.u16(offset + 4)
|
|
33
34
|
@binary.validate_bounds(offset + 12, count * 16)
|
|
34
35
|
@tables = {}
|
|
@@ -64,6 +65,36 @@ module Alhena
|
|
|
64
65
|
def ascent = table("hhea").i16(4)
|
|
65
66
|
def descent = table("hhea").i16(6)
|
|
66
67
|
def line_gap = table("hhea").i16(8)
|
|
68
|
+
def bbox = 4.times.map { |index| table("head").i16(36 + index * 2) }.freeze
|
|
69
|
+
def cff? = @tables.key?("CFF ") || @tables.key?("CFF2")
|
|
70
|
+
|
|
71
|
+
def glyph_ids(text)
|
|
72
|
+
raise ArgumentError, "text must be a String" unless text.is_a?(String)
|
|
73
|
+
|
|
74
|
+
codepoints = text.codepoints
|
|
75
|
+
codepoints.each_with_index.filter_map do |codepoint, index|
|
|
76
|
+
next if variation_selector?(codepoint)
|
|
77
|
+
|
|
78
|
+
selector = codepoints[index + 1]
|
|
79
|
+
glyph_id(codepoint, variation_selector: variation_selector?(selector) ? selector : nil)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Unicode codepoint => glyph ID. `glyph_id` remains the bounded lookup API;
|
|
84
|
+
# this map is useful when a consumer needs a stable reverse mapping.
|
|
85
|
+
def cmap
|
|
86
|
+
@cmap ||= begin
|
|
87
|
+
result = {}
|
|
88
|
+
cmaps.each { |data| add_cmap_entries(result, data) }
|
|
89
|
+
result.freeze
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def unicode_for_glyph(glyph)
|
|
94
|
+
validate_glyph(glyph)
|
|
95
|
+
@unicode_cmap ||= cmap.each_with_object({}) { |(codepoint, id), reverse| reverse[id] ||= codepoint }.freeze
|
|
96
|
+
@unicode_cmap[glyph]
|
|
97
|
+
end
|
|
67
98
|
|
|
68
99
|
def names
|
|
69
100
|
@names ||= begin
|
|
@@ -126,7 +157,11 @@ module Alhena
|
|
|
126
157
|
cached_metric(glyph, vertical: vertical)[0] * scale_factor(size)
|
|
127
158
|
end
|
|
128
159
|
|
|
129
|
-
def advance_width(codepoints, size
|
|
160
|
+
def advance_width(codepoints, size: nil, features: [])
|
|
161
|
+
if codepoints.is_a?(Integer)
|
|
162
|
+
return cached_metric(codepoints, vertical: false)[0] * scale_factor(size || units_per_em)
|
|
163
|
+
end
|
|
164
|
+
raise ArgumentError, "size is required" unless size
|
|
130
165
|
validate_features(features)
|
|
131
166
|
raise ArgumentError, "codepoints must be an Array" unless codepoints.is_a?(Array)
|
|
132
167
|
|
|
@@ -190,6 +225,45 @@ module Alhena
|
|
|
190
225
|
|
|
191
226
|
private
|
|
192
227
|
|
|
228
|
+
def add_cmap_entries(result, data)
|
|
229
|
+
format = data.u16(0)
|
|
230
|
+
case format
|
|
231
|
+
when 0
|
|
232
|
+
256.times { |codepoint| add_cmap_entry(result, codepoint, glyph_id_from_cmap(data, codepoint)) }
|
|
233
|
+
when 4
|
|
234
|
+
count = data.u16(6) / 2
|
|
235
|
+
count.times do |index|
|
|
236
|
+
first = data.u16(16 + count * 2 + index * 2)
|
|
237
|
+
last = data.u16(14 + index * 2)
|
|
238
|
+
next if first == 0xffff || last == 0xffff
|
|
239
|
+
|
|
240
|
+
first.upto(last) { |codepoint| add_cmap_entry(result, codepoint, glyph_id_from_cmap(data, codepoint)) }
|
|
241
|
+
end
|
|
242
|
+
when 6
|
|
243
|
+
first, count = data.u16(6), data.u16(8)
|
|
244
|
+
count.times { |index| add_cmap_entry(result, first + index, glyph_id_from_cmap(data, first + index)) }
|
|
245
|
+
when 12, 13
|
|
246
|
+
count = data.u32(12)
|
|
247
|
+
data.validate_bounds(16, count * 12)
|
|
248
|
+
count.times do |index|
|
|
249
|
+
at = 16 + index * 12
|
|
250
|
+
first, last, start_glyph = data.u32(at), data.u32(at + 4), data.u32(at + 8)
|
|
251
|
+
if format == 13
|
|
252
|
+
add_cmap_entry(result, first, start_glyph)
|
|
253
|
+
next
|
|
254
|
+
end
|
|
255
|
+
[last, first + glyph_count - start_glyph - 1, 0x10ffff].min.downto(first) do |codepoint|
|
|
256
|
+
glyph = start_glyph + codepoint - first
|
|
257
|
+
add_cmap_entry(result, codepoint, glyph)
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def add_cmap_entry(result, codepoint, glyph)
|
|
264
|
+
result[codepoint] ||= glyph if glyph.positive? && glyph < glyph_count && !(0xd800..0xdfff).cover?(codepoint)
|
|
265
|
+
end
|
|
266
|
+
|
|
193
267
|
def scale_factor(size)
|
|
194
268
|
valid = size.is_a?(Numeric) && size.real? && size.finite? && size > 0
|
|
195
269
|
raise ArgumentError, "size must be positive and finite" unless valid
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Alhena
|
|
4
|
+
module Subset
|
|
5
|
+
CHECKSUM = 0xb1b0afba
|
|
6
|
+
private_constant :CHECKSUM
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def build(font, glyph_ids)
|
|
11
|
+
raise ArgumentError, "font must be an Alhena::Font" unless font.is_a?(Font)
|
|
12
|
+
raise ArgumentError, "glyph_ids must be an Array" unless glyph_ids.is_a?(Array)
|
|
13
|
+
glyph_ids.each do |glyph|
|
|
14
|
+
raise ArgumentError, "glyph ID out of range" unless glyph.is_a?(Integer) && glyph.between?(0, font.glyph_count - 1)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
return cff(font, glyph_ids) if font.cff?
|
|
18
|
+
|
|
19
|
+
true_type(font, glyph_ids)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Returns a CID-keyed CFF1 program with glyph order matching the requested CIDs.
|
|
23
|
+
# Unlike #build, glyph_ids may repeat; entry zero must be glyph 0 (.notdef).
|
|
24
|
+
def build_cid(font, glyph_ids)
|
|
25
|
+
raise ArgumentError, "font must be an Alhena::Font" unless font.is_a?(Font)
|
|
26
|
+
raise ArgumentError, "glyph_ids must be an Array" unless glyph_ids.is_a?(Array)
|
|
27
|
+
glyph_ids.each do |glyph|
|
|
28
|
+
raise ArgumentError, "glyph ID out of range" unless glyph.is_a?(Integer) && glyph.between?(0, font.glyph_count - 1)
|
|
29
|
+
end
|
|
30
|
+
raise UnsupportedFont, "CID conversion requires a non-variable CFF1 font" unless font.tables.key?("CFF ") && !font.tables.key?("CFF2") && !font.tables.key?("fvar") && !font.tables.key?("HVAR")
|
|
31
|
+
|
|
32
|
+
CFF.new(font.table("CFF "), units_per_em: font.units_per_em).subset_cid(glyph_ids)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def cff(font, glyph_ids)
|
|
36
|
+
raise UnsupportedFont, "CFF2 subsetting is unsupported" unless font.tables.key?("CFF ") && !font.tables.key?("CFF2")
|
|
37
|
+
raise UnsupportedFont, "variable CFF1 subsetting is unsupported" if font.tables.key?("fvar") || font.tables.key?("HVAR")
|
|
38
|
+
|
|
39
|
+
glyphs = [0, *glyph_ids].uniq.sort
|
|
40
|
+
remap = glyphs.each_with_index.to_h
|
|
41
|
+
header = font.table("hhea").data.dup
|
|
42
|
+
header[34, 2] = [glyphs.length].pack("n")
|
|
43
|
+
maximum = font.table("maxp").data.dup
|
|
44
|
+
maximum[4, 2] = [glyphs.length].pack("n")
|
|
45
|
+
tables = {
|
|
46
|
+
"CFF " => CFF.new(font.table("CFF "), units_per_em: font.units_per_em).subset(glyphs),
|
|
47
|
+
"head" => font.table("head").data,
|
|
48
|
+
"hhea" => header,
|
|
49
|
+
"hmtx" => metrics(font, glyphs),
|
|
50
|
+
"maxp" => maximum,
|
|
51
|
+
"cmap" => unicode_cmap(font, remap)
|
|
52
|
+
}
|
|
53
|
+
%w[OS/2 name].each { |tag| tables[tag] = font.table(tag).data if font.tables.key?(tag) }
|
|
54
|
+
if font.tables.key?("post")
|
|
55
|
+
post = font.table("post").bytes(0, 32).dup
|
|
56
|
+
post[0, 4] = [0x0003_0000].pack("N")
|
|
57
|
+
tables["post"] = post
|
|
58
|
+
end
|
|
59
|
+
sfnt(font, tables)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def true_type(font, glyph_ids)
|
|
63
|
+
glyphs = closure(font, [0, *glyph_ids].uniq)
|
|
64
|
+
remap = glyphs.each_with_index.to_h
|
|
65
|
+
glyf, locations = +"".b, [0]
|
|
66
|
+
glyphs.each do |glyph|
|
|
67
|
+
data = remap_components(glyph_bytes(font, glyph), remap)
|
|
68
|
+
glyf << data
|
|
69
|
+
glyf << "\0" if glyf.bytesize.odd?
|
|
70
|
+
locations << glyf.bytesize
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
head = font.table("head").data.dup
|
|
74
|
+
head[8, 4] = "\0" * 4
|
|
75
|
+
head[50, 2] = [1].pack("s>")
|
|
76
|
+
hhea = font.table("hhea").data.dup
|
|
77
|
+
hhea[34, 2] = [glyphs.length].pack("n")
|
|
78
|
+
maxp = font.table("maxp").data.dup
|
|
79
|
+
maxp[4, 2] = [glyphs.length].pack("n")
|
|
80
|
+
tables = {"head" => head, "hhea" => hhea, "maxp" => maxp, "glyf" => glyf,
|
|
81
|
+
"loca" => locations.pack("N*"), "hmtx" => metrics(font, glyphs),
|
|
82
|
+
"cmap" => unicode_cmap(font, remap)}
|
|
83
|
+
%w[OS/2 name post].each { |tag| tables[tag] = font.table(tag).data if font.tables.key?(tag) }
|
|
84
|
+
sfnt(font, tables)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def closure(font, roots)
|
|
88
|
+
found, pending = {}, roots.dup
|
|
89
|
+
until pending.empty?
|
|
90
|
+
glyph = pending.pop
|
|
91
|
+
next if found[glyph]
|
|
92
|
+
found[glyph] = true
|
|
93
|
+
composite_children(glyph_bytes(font, glyph)).each { |child| pending << child unless found[child] }
|
|
94
|
+
end
|
|
95
|
+
found.keys.sort
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def glyph_bytes(font, glyph)
|
|
99
|
+
loca = font.table("loca")
|
|
100
|
+
format = font.table("head").i16(50)
|
|
101
|
+
first = format.zero? ? loca.u16(glyph * 2) * 2 : loca.u32(glyph * 4)
|
|
102
|
+
last = format.zero? ? loca.u16((glyph + 1) * 2) * 2 : loca.u32((glyph + 1) * 4)
|
|
103
|
+
raise InvalidFont, "invalid glyph location" if last < first
|
|
104
|
+
|
|
105
|
+
font.table("glyf").bytes(first, last - first)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def composite_children(data)
|
|
109
|
+
composite_offsets(data).map { |offset| data.unpack1("n", offset: offset) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def remap_components(data, remap)
|
|
113
|
+
composite_offsets(data).each do |offset|
|
|
114
|
+
old = data.unpack1("n", offset: offset)
|
|
115
|
+
data[offset, 2] = [remap.fetch(old) { raise InvalidFont, "missing composite glyph #{old}" }].pack("n")
|
|
116
|
+
end
|
|
117
|
+
data
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def composite_offsets(data)
|
|
121
|
+
return [] if data.empty? || data.unpack1("s>") >= 0
|
|
122
|
+
|
|
123
|
+
offsets = []
|
|
124
|
+
at = 10
|
|
125
|
+
loop do
|
|
126
|
+
raise InvalidFont, "truncated composite glyph" if at + 4 > data.bytesize
|
|
127
|
+
flags = data.unpack1("n", offset: at)
|
|
128
|
+
offsets << at + 2
|
|
129
|
+
at += 4 + ((flags & 1).zero? ? 2 : 4)
|
|
130
|
+
at += if (flags & 8) != 0 then 2 elsif (flags & 64) != 0 then 4 elsif (flags & 128) != 0 then 8 else 0 end
|
|
131
|
+
break if (flags & 32).zero?
|
|
132
|
+
end
|
|
133
|
+
offsets
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def metrics(font, glyphs)
|
|
137
|
+
glyphs.map { |glyph| [font.advance(glyph).round, font.bearing(glyph).round].pack("ns>") }.join.b
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def unicode_cmap(font, remap)
|
|
141
|
+
entries = font.cmap.filter_map { |codepoint, glyph| [codepoint, remap[glyph]] if remap.key?(glyph) }
|
|
142
|
+
groups = []
|
|
143
|
+
entries.sort.each do |codepoint, glyph|
|
|
144
|
+
previous = groups.last
|
|
145
|
+
if previous && codepoint == previous[1] + 1 && glyph == previous[2] + codepoint - previous[0]
|
|
146
|
+
previous[1] = codepoint
|
|
147
|
+
else
|
|
148
|
+
groups << [codepoint, codepoint, glyph]
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
subtable = [12, 0, 16 + groups.length * 12, 0, groups.length].pack("nnNNN") + groups.flatten.pack("N*")
|
|
152
|
+
[0, 1, 0, 4, 12].pack("n4N") + subtable
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def sfnt(font, tables)
|
|
156
|
+
tables = tables.transform_values(&:b)
|
|
157
|
+
head = tables.fetch("head").dup
|
|
158
|
+
head[8, 4] = "\0" * 4
|
|
159
|
+
tables["head"] = head
|
|
160
|
+
count = tables.length
|
|
161
|
+
power = 1 << (Math.log2(count).floor)
|
|
162
|
+
search_range = power * 16
|
|
163
|
+
directory = [font.sfnt_signature, count, search_range, Math.log2(power).to_i, count * 16 - search_range].pack("a4n4")
|
|
164
|
+
payload, records, head_offset = +"".b, +"".b, nil
|
|
165
|
+
tables.sort.each do |tag, bytes|
|
|
166
|
+
offset = 12 + count * 16 + payload.bytesize
|
|
167
|
+
checksum = table_checksum(bytes)
|
|
168
|
+
records << tag << [checksum, offset, bytes.bytesize].pack("N3")
|
|
169
|
+
head_offset = offset if tag == "head"
|
|
170
|
+
payload << bytes
|
|
171
|
+
payload << "\0" until payload.bytesize % 4 == 0
|
|
172
|
+
end
|
|
173
|
+
output = directory + records + payload
|
|
174
|
+
adjustment = (CHECKSUM - table_checksum(output)) & 0xffffffff
|
|
175
|
+
output[head_offset + 8, 4] = [adjustment].pack("N")
|
|
176
|
+
output
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def table_checksum(bytes)
|
|
180
|
+
padded = bytes.dup
|
|
181
|
+
padded << "\0" until padded.bytesize % 4 == 0
|
|
182
|
+
padded.unpack("N*").sum & 0xffffffff
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
data/lib/alhena/version.rb
CHANGED
data/lib/alhena.rb
CHANGED
|
@@ -15,7 +15,9 @@ require_relative "alhena/outline"
|
|
|
15
15
|
require_relative "alhena/binary"
|
|
16
16
|
require_relative "alhena/rasterizer"
|
|
17
17
|
require_relative "alhena/cff"
|
|
18
|
+
require_relative "alhena/cff_subset"
|
|
18
19
|
require_relative "alhena/font"
|
|
20
|
+
require_relative "alhena/subset"
|
|
19
21
|
require_relative "alhena/variation"
|
|
20
22
|
require_relative "alhena/png"
|
|
21
23
|
require_relative "alhena/color"
|
data/sig/alhena.rbs
CHANGED
|
@@ -62,12 +62,18 @@ module Alhena
|
|
|
62
62
|
attr_reader index: Integer
|
|
63
63
|
attr_reader tables: Hash[String, [Integer, Integer]]
|
|
64
64
|
attr_reader axis_values: Hash[String, Numeric]
|
|
65
|
+
attr_reader sfnt_signature: String
|
|
65
66
|
def data: () -> String
|
|
66
67
|
def self.open: (String path, ?index: Integer, ?axes: Hash[String | Symbol, Numeric]) -> Font
|
|
67
68
|
def initialize: (String data, ?index: Integer, ?axes: Hash[String | Symbol, Numeric]) -> void
|
|
68
69
|
def table: (String tag) -> Binary
|
|
69
70
|
def units_per_em: () -> Integer
|
|
70
71
|
def glyph_count: () -> Integer
|
|
72
|
+
def bbox: () -> [Integer, Integer, Integer, Integer]
|
|
73
|
+
def cff?: () -> bool
|
|
74
|
+
def glyph_ids: (String) -> Array[Integer]
|
|
75
|
+
def cmap: () -> Hash[Integer, Integer]
|
|
76
|
+
def unicode_for_glyph: (Integer) -> Integer?
|
|
71
77
|
def ascent: () -> Integer
|
|
72
78
|
def descent: () -> Integer
|
|
73
79
|
def line_gap: () -> Integer
|
|
@@ -77,7 +83,8 @@ module Alhena
|
|
|
77
83
|
def post: () -> Hash[Symbol, Numeric | bool]
|
|
78
84
|
def glyph_id: (String | Integer character, ?variation_selector: String | Integer | nil) -> Integer
|
|
79
85
|
def advance: (Integer glyph, ?size: Numeric, ?vertical: bool) -> Float
|
|
80
|
-
def advance_width: (
|
|
86
|
+
def advance_width: (Integer glyph_id, ?size: Numeric) -> Float
|
|
87
|
+
| (Array[Integer] codepoints, size: Numeric, ?features: Array[String | Symbol]) -> Float
|
|
81
88
|
def measure: (String text, size: Numeric, ?features: Array[String | Symbol]) -> Metrics
|
|
82
89
|
def bearing: (Integer glyph, ?size: Numeric, ?vertical: bool) -> Float
|
|
83
90
|
def outline: (Integer glyph) -> Outline
|
|
@@ -124,4 +131,8 @@ module Alhena
|
|
|
124
131
|
def prewarm: (Font font, String text, ?Numeric? size, **untyped options) -> self
|
|
125
132
|
def clear: () -> self
|
|
126
133
|
end
|
|
134
|
+
module Subset
|
|
135
|
+
def self.build: (Font, Array[Integer]) -> String
|
|
136
|
+
def self.build_cid: (Font, Array[Integer]) -> String
|
|
137
|
+
end
|
|
127
138
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: alhena
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yudai Takada
|
|
@@ -27,12 +27,14 @@ files:
|
|
|
27
27
|
- lib/alhena/bitmap.rb
|
|
28
28
|
- lib/alhena/cache.rb
|
|
29
29
|
- lib/alhena/cff.rb
|
|
30
|
+
- lib/alhena/cff_subset.rb
|
|
30
31
|
- lib/alhena/color.rb
|
|
31
32
|
- lib/alhena/data_compat.rb
|
|
32
33
|
- lib/alhena/font.rb
|
|
33
34
|
- lib/alhena/outline.rb
|
|
34
35
|
- lib/alhena/png.rb
|
|
35
36
|
- lib/alhena/rasterizer.rb
|
|
37
|
+
- lib/alhena/subset.rb
|
|
36
38
|
- lib/alhena/variation.rb
|
|
37
39
|
- lib/alhena/version.rb
|
|
38
40
|
- sig/alhena.rbs
|