fontisan 0.4.45 → 0.4.47
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/Rakefile +1 -2
- data/lib/fontisan/cli.rb +1 -1
- data/lib/fontisan/commands/export_command.rb +3 -3
- data/lib/fontisan/export/exporter.rb +1 -1
- data/lib/fontisan/export/table_serializer.rb +36 -38
- data/lib/fontisan/pipeline/transformation_pipeline.rb +10 -8
- data/lib/fontisan/pipeline.rb +1 -1
- data/lib/fontisan/stitcher/partition_strategy/by_block.rb +1 -2
- data/lib/fontisan/tables/cff/standard_strings_data.rb +1 -2
- data/lib/fontisan/tables/glyf.rb +4 -6
- data/lib/fontisan/tables/gpos/glyph_filter.rb +87 -0
- data/lib/fontisan/tables/gpos/kerning_pairs.rb +215 -0
- data/lib/fontisan/tables/gpos/pair_position.rb +274 -0
- data/lib/fontisan/tables/gpos.rb +23 -0
- data/lib/fontisan/tables/post.rb +2 -4
- data/lib/fontisan/validators/opentype_validator.rb +13 -3
- data/lib/fontisan/variable/instancer.rb +22 -192
- data/lib/fontisan/variation/instance_generator.rb +3 -149
- data/lib/fontisan/variation/instancing/axis_space.rb +136 -0
- data/lib/fontisan/variation/instancing/glyph_outline.rb +204 -0
- data/lib/fontisan/variation/instancing/gvar_reader.rb +146 -0
- data/lib/fontisan/variation/instancing/item_variation_store.rb +92 -0
- data/lib/fontisan/variation/instancing/name_rewriter.rb +90 -0
- data/lib/fontisan/variation/instancing/true_type_instancer.rb +318 -0
- data/lib/fontisan/variation/instancing.rb +20 -0
- data/lib/fontisan/variation/variable_svg_generator.rb +19 -1
- data/lib/fontisan/variation.rb +1 -0
- data/lib/fontisan/version.rb +1 -1
- metadata +12 -3
- data/fontisan.gemspec +0 -48
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fontisan"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Tables
|
|
7
|
+
class Gpos
|
|
8
|
+
# Checked views share physical identity without copying table suffixes.
|
|
9
|
+
class BinaryView
|
|
10
|
+
def initialize(data, offset: 0, cache: {})
|
|
11
|
+
@data = data
|
|
12
|
+
@offset = offset
|
|
13
|
+
@cache = cache
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def bytes(offset, length)
|
|
17
|
+
if offset.negative? || length.negative? || @offset + offset + length > @data.bytesize
|
|
18
|
+
raise ParseError, "Truncated GPOS data at byte #{offset}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
@data.byteslice(@offset + offset, length)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def uint16(offset)
|
|
25
|
+
bytes(offset, 2).unpack1("n")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def uint32(offset)
|
|
29
|
+
bytes(offset, 4).unpack1("N")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def array(offset, count)
|
|
33
|
+
bytes(offset, count * 2).unpack("n*")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def child(offset, minimum:)
|
|
37
|
+
raise ParseError, "Invalid GPOS offset #{offset}" if offset < minimum
|
|
38
|
+
|
|
39
|
+
bytes(offset, 1)
|
|
40
|
+
self.class.new(@data, offset: @offset + offset, cache: @cache)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def record(type, header:, count_offset:, item_size:)
|
|
44
|
+
bytes(0, header + uint16(count_offset) * item_size)
|
|
45
|
+
type.read(@data.byteslice(@offset..))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def cached(context)
|
|
49
|
+
key = [@offset, context]
|
|
50
|
+
@cache.fetch(key) { @cache[key] = yield }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def freeze
|
|
54
|
+
@cache.freeze
|
|
55
|
+
super
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Coverage ranges remain compact, including the full glyph-ID domain.
|
|
60
|
+
class Coverage
|
|
61
|
+
include Enumerable
|
|
62
|
+
|
|
63
|
+
attr_reader :size
|
|
64
|
+
|
|
65
|
+
def initialize(view)
|
|
66
|
+
format = view.uint16(0)
|
|
67
|
+
count = view.uint16(2)
|
|
68
|
+
case format
|
|
69
|
+
when 1
|
|
70
|
+
@glyphs = view.array(4, count).freeze
|
|
71
|
+
unless @glyphs.each_cons(2).all? { |first, second| first < second }
|
|
72
|
+
raise ParseError, "Unsorted or duplicate GPOS glyph IDs"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
@size = count
|
|
76
|
+
when 2
|
|
77
|
+
@ranges = view.array(4, count * 3).each_slice(3).map(&:freeze).freeze
|
|
78
|
+
@size = 0
|
|
79
|
+
previous = -1
|
|
80
|
+
@ranges.each do |first, last, index|
|
|
81
|
+
if first > last || index != @size || first <= previous
|
|
82
|
+
raise ParseError, "Invalid GPOS coverage range"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
@size += last - first + 1
|
|
86
|
+
previous = last
|
|
87
|
+
end
|
|
88
|
+
else
|
|
89
|
+
raise ParseError, "Unsupported Coverage format #{format}"
|
|
90
|
+
end
|
|
91
|
+
freeze
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def include?(glyph)
|
|
95
|
+
return @glyphs.bsearch { |candidate| candidate >= glyph } == glyph if @glyphs
|
|
96
|
+
|
|
97
|
+
record = @ranges.bsearch { |_first, last, _index| last >= glyph }
|
|
98
|
+
record && record[0] <= glyph
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def each(&block)
|
|
102
|
+
return enum_for(:each) unless block
|
|
103
|
+
|
|
104
|
+
if @glyphs
|
|
105
|
+
@glyphs.each(&block)
|
|
106
|
+
else
|
|
107
|
+
@ranges.each do |first, last, _index|
|
|
108
|
+
(first..last).each(&block)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
self
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Class arrays support direct glyph indexing without singleton ranges.
|
|
116
|
+
class ClassDefinition
|
|
117
|
+
attr_reader :maximum
|
|
118
|
+
|
|
119
|
+
def initialize(view)
|
|
120
|
+
@view = view
|
|
121
|
+
case view.uint16(0)
|
|
122
|
+
when 1
|
|
123
|
+
@first = view.uint16(2)
|
|
124
|
+
@count = view.uint16(4)
|
|
125
|
+
raise ParseError, "Invalid GPOS class definition" if @first + @count > 65536
|
|
126
|
+
|
|
127
|
+
@maximum = view.array(6, @count).max || 0
|
|
128
|
+
when 2
|
|
129
|
+
@ranges = view.array(4, view.uint16(2) * 3).each_slice(3).map(&:freeze).freeze
|
|
130
|
+
previous = -1
|
|
131
|
+
@maximum = 0
|
|
132
|
+
@ranges.each do |first, last, value|
|
|
133
|
+
if first <= previous || first > last
|
|
134
|
+
raise ParseError, "Invalid GPOS class definition"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
@maximum = [@maximum, value].max
|
|
138
|
+
previous = last
|
|
139
|
+
end
|
|
140
|
+
else
|
|
141
|
+
raise ParseError, "Unsupported ClassDef format #{view.uint16(0)}"
|
|
142
|
+
end
|
|
143
|
+
freeze
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def [](glyph)
|
|
147
|
+
if @ranges
|
|
148
|
+
record = @ranges.bsearch { |_first, last, _value| last >= glyph }
|
|
149
|
+
record && record[0] <= glyph ? record[2] : 0
|
|
150
|
+
else
|
|
151
|
+
index = glyph - @first
|
|
152
|
+
index.between?(0, @count - 1) ? @view.uint16(6 + index * 2) : 0
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Compact PairPos data; class matrices stay in their binary representation.
|
|
158
|
+
class PairPosition
|
|
159
|
+
attr_reader :left_glyphs
|
|
160
|
+
|
|
161
|
+
def initialize(view)
|
|
162
|
+
@view = view
|
|
163
|
+
@format = view.uint16(0)
|
|
164
|
+
raise ParseError, "Unsupported PairPos format #{@format}" unless [1, 2].include?(@format)
|
|
165
|
+
|
|
166
|
+
@first_format = view.uint16(4)
|
|
167
|
+
@second_format = view.uint16(6)
|
|
168
|
+
@first_size = value_size(@first_format)
|
|
169
|
+
@record_size = @first_size + value_size(@second_format)
|
|
170
|
+
@advance_offset = value_size(@first_format & 3)
|
|
171
|
+
@coverage = coverage
|
|
172
|
+
@left_glyphs = @coverage
|
|
173
|
+
@format == 1 ? parse_pairs : parse_classes
|
|
174
|
+
freeze
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def class_based?
|
|
178
|
+
@format == 2
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def covers?(glyph)
|
|
182
|
+
@coverage.include?(glyph)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def adjustment(left, right)
|
|
186
|
+
return unless covers?(left)
|
|
187
|
+
return @pairs.fetch(left)[right] unless class_based?
|
|
188
|
+
|
|
189
|
+
row = @first_classes[left]
|
|
190
|
+
column = @second_classes[right]
|
|
191
|
+
advance(@matrix, (row * @second_count + column) * @record_size)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def right_glyphs(left)
|
|
195
|
+
@pairs.fetch(left).each_key
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
private
|
|
199
|
+
|
|
200
|
+
def value_size(format)
|
|
201
|
+
raise ParseError, "Reserved GPOS ValueFormat bits" unless (format & 0xff00).zero?
|
|
202
|
+
|
|
203
|
+
format.digits(2).sum * 2
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def advance(view, offset)
|
|
207
|
+
return 0 if (@first_format & 4).zero?
|
|
208
|
+
|
|
209
|
+
value = view.uint16(offset + @advance_offset)
|
|
210
|
+
value >= 0x8000 ? value - 0x10000 : value
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def coverage
|
|
214
|
+
minimum = @format == 1 ? 10 + @view.uint16(8) * 2 : 16 + matrix_size
|
|
215
|
+
view = @view.child(@view.uint16(2), minimum: minimum)
|
|
216
|
+
view.cached(:coverage) { Coverage.new(view) }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def parse_pairs
|
|
220
|
+
count = @view.uint16(8)
|
|
221
|
+
raise ParseError, "GPOS PairSet count differs from coverage" unless count == left_glyphs.size
|
|
222
|
+
|
|
223
|
+
offsets = @view.array(10, count)
|
|
224
|
+
@pairs = {}
|
|
225
|
+
left_glyphs.each_with_index do |left, index|
|
|
226
|
+
view = @view.child(offsets[index], minimum: 10 + count * 2)
|
|
227
|
+
@pairs[left] = view.cached([:pair_set, @first_format, @second_format]) { pair_set(view) }
|
|
228
|
+
end
|
|
229
|
+
@pairs.freeze
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def pair_set(view)
|
|
233
|
+
count = view.uint16(0)
|
|
234
|
+
view.bytes(2, count * (2 + @record_size))
|
|
235
|
+
previous = -1
|
|
236
|
+
pairs = {}
|
|
237
|
+
count.times do |index|
|
|
238
|
+
start = 2 + index * (2 + @record_size)
|
|
239
|
+
glyph = view.uint16(start)
|
|
240
|
+
raise ParseError, "Unsorted or duplicate GPOS glyph IDs" if glyph <= previous
|
|
241
|
+
|
|
242
|
+
pairs[glyph] = advance(view, start + 2)
|
|
243
|
+
previous = glyph
|
|
244
|
+
end
|
|
245
|
+
pairs.freeze
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def matrix_size
|
|
249
|
+
@view.uint16(12) * @view.uint16(14) * @record_size
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def parse_classes
|
|
253
|
+
first_count = @view.uint16(12)
|
|
254
|
+
@second_count = @view.uint16(14)
|
|
255
|
+
raise ParseError, "Empty GPOS class matrix" if first_count.zero? || @second_count.zero?
|
|
256
|
+
|
|
257
|
+
@matrix = BinaryView.new(@view.bytes(16, matrix_size).freeze).freeze
|
|
258
|
+
@first_classes = class_definition(@view.uint16(8), first_count)
|
|
259
|
+
@second_classes = class_definition(@view.uint16(10), @second_count)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def class_definition(offset, class_count)
|
|
263
|
+
view = @view.child(offset, minimum: 16 + matrix_size)
|
|
264
|
+
definition = view.cached(:class_definition) { ClassDefinition.new(view) }
|
|
265
|
+
raise ParseError, "Invalid GPOS class definition" if definition.maximum >= class_count
|
|
266
|
+
|
|
267
|
+
definition
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
private_constant :BinaryView, :Coverage, :ClassDefinition, :PairPosition
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
data/lib/fontisan/tables/gpos.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "fontisan"
|
|
4
|
+
|
|
3
5
|
module Fontisan
|
|
4
6
|
module Tables
|
|
5
7
|
# GPOS (Glyph Positioning) table parser
|
|
@@ -55,6 +57,25 @@ module Fontisan
|
|
|
55
57
|
[]
|
|
56
58
|
end
|
|
57
59
|
|
|
60
|
+
# Read PairPos value1.xAdvance in font units, directly or via extensions.
|
|
61
|
+
# The returned Enumerable supports pairs[[left_glyph_id, right_glyph_id]];
|
|
62
|
+
# unmatched pairs return zero. Use glyph_count: font.table("maxp").num_glyphs.to_i
|
|
63
|
+
# to enumerate class-based pairs, including implicit class zero, with to_h.
|
|
64
|
+
# Selects the script's default language system, with no script fallback.
|
|
65
|
+
# First matching subtables take precedence; distinct lookups are summed.
|
|
66
|
+
# Supports PairPos formats 1 and 2 in lookup types 2 and 9. Pass raw GDEF
|
|
67
|
+
# bytes with gdef: font.table_data["GDEF"] for glyph-filtering lookups.
|
|
68
|
+
# Lazy SFNT fonts can use font.sfnt_table("GDEF").load_data!.data instead.
|
|
69
|
+
# Filters apply to supplied pair endpoints; this does not shape a stream.
|
|
70
|
+
# Other lookup types, device corrections and variations are not applied.
|
|
71
|
+
# @return [Enumerable] nonzero glyph-ID pairs and signed advances
|
|
72
|
+
# @raise [ParseError] malformed selected positioning data
|
|
73
|
+
# @raise [UnsupportedFormatError] selected filtering requires missing GDEF data
|
|
74
|
+
def kern_pairs(script_tag: "latn", feature_tag: "kern", glyph_count: nil, gdef: nil)
|
|
75
|
+
KerningPairs.new(self, script_tag: script_tag, feature_tag: feature_tag,
|
|
76
|
+
glyph_count: glyph_count, gdef: gdef)
|
|
77
|
+
end
|
|
78
|
+
|
|
58
79
|
private
|
|
59
80
|
|
|
60
81
|
# Get feature indices for a given script
|
|
@@ -107,3 +128,5 @@ module Fontisan
|
|
|
107
128
|
end
|
|
108
129
|
end
|
|
109
130
|
end
|
|
131
|
+
|
|
132
|
+
require "fontisan/tables/gpos/kerning_pairs"
|
data/lib/fontisan/tables/post.rb
CHANGED
|
@@ -14,7 +14,7 @@ module Fontisan
|
|
|
14
14
|
|
|
15
15
|
register_tag "post"
|
|
16
16
|
# Standard Mac glyph names for version 1.0 (258 glyphs)
|
|
17
|
-
# rubocop:disable Metrics/CollectionLiteralLength
|
|
17
|
+
# rubocop:disable-next Metrics/CollectionLiteralLength
|
|
18
18
|
STANDARD_NAMES = %w[
|
|
19
19
|
.notdef .null nonmarkingreturn space exclam quotedbl numbersign
|
|
20
20
|
dollar percent ampersand quotesingle parenleft parenright asterisk
|
|
@@ -48,7 +48,6 @@ module Fontisan
|
|
|
48
48
|
Gbreve gbreve Idotaccent Scedilla scedilla Cacute cacute Ccaron
|
|
49
49
|
ccaron dcroat
|
|
50
50
|
].freeze
|
|
51
|
-
# rubocop:enable Metrics/CollectionLiteralLength
|
|
52
51
|
|
|
53
52
|
# Version 2.0 as Fixed 16.16 constant
|
|
54
53
|
VERSION_2_0_RAW = 131_072 # 2.0 * 65536
|
|
@@ -99,7 +98,7 @@ module Fontisan
|
|
|
99
98
|
#
|
|
100
99
|
# Version 2.0 uses a combination of standard Mac names (indices 0-257)
|
|
101
100
|
# and custom names (indices >= 258) stored as Pascal strings.
|
|
102
|
-
# rubocop:disable Metrics/PerceivedComplexity
|
|
101
|
+
# rubocop:disable-next Metrics/PerceivedComplexity
|
|
103
102
|
def parse_version_2_names
|
|
104
103
|
return [] unless version_raw == VERSION_2_0_RAW
|
|
105
104
|
return [] if remaining_data.empty?
|
|
@@ -145,7 +144,6 @@ module Fontisan
|
|
|
145
144
|
end
|
|
146
145
|
end
|
|
147
146
|
end
|
|
148
|
-
# rubocop:enable Metrics/PerceivedComplexity
|
|
149
147
|
|
|
150
148
|
public
|
|
151
149
|
|
|
@@ -44,7 +44,7 @@ module Fontisan
|
|
|
44
44
|
glyf = font.table("glyf")
|
|
45
45
|
next true unless glyf # Skip if CFF font
|
|
46
46
|
|
|
47
|
-
loca = font
|
|
47
|
+
loca = parsed_loca(font)
|
|
48
48
|
head = font.table("head")
|
|
49
49
|
maxp = font.table("maxp")
|
|
50
50
|
glyf.all_glyphs_accessible?(loca, head, maxp.num_glyphs)
|
|
@@ -55,7 +55,7 @@ module Fontisan
|
|
|
55
55
|
glyf = font.table("glyf")
|
|
56
56
|
next true unless glyf
|
|
57
57
|
|
|
58
|
-
loca = font
|
|
58
|
+
loca = parsed_loca(font)
|
|
59
59
|
head = font.table("head")
|
|
60
60
|
maxp = font.table("maxp")
|
|
61
61
|
glyf.no_clipped_glyphs?(loca, head, maxp.num_glyphs)
|
|
@@ -66,7 +66,7 @@ module Fontisan
|
|
|
66
66
|
glyf = font.table("glyf")
|
|
67
67
|
next true unless glyf
|
|
68
68
|
|
|
69
|
-
loca = font
|
|
69
|
+
loca = parsed_loca(font)
|
|
70
70
|
head = font.table("head")
|
|
71
71
|
maxp = font.table("maxp")
|
|
72
72
|
|
|
@@ -101,6 +101,16 @@ module Fontisan
|
|
|
101
101
|
true # Placeholder - actual checksum validation if desired
|
|
102
102
|
end
|
|
103
103
|
end
|
|
104
|
+
|
|
105
|
+
# loca must be parsed with head/maxp context before glyf can read it.
|
|
106
|
+
def parsed_loca(font)
|
|
107
|
+
loca = font.table("loca")
|
|
108
|
+
return loca if loca.parsed?
|
|
109
|
+
|
|
110
|
+
loca.parse_with_context(font.table("head").index_to_loc_format,
|
|
111
|
+
font.table("maxp").num_glyphs)
|
|
112
|
+
loca
|
|
113
|
+
end
|
|
104
114
|
end
|
|
105
115
|
end
|
|
106
116
|
end
|
|
@@ -25,12 +25,6 @@ module Fontisan
|
|
|
25
25
|
# @return [Object] The variable font object
|
|
26
26
|
attr_reader :font
|
|
27
27
|
|
|
28
|
-
# @return [DeltaApplicator] Delta applicator
|
|
29
|
-
attr_reader :delta_applicator
|
|
30
|
-
|
|
31
|
-
# @return [StaticFontBuilder] Static font builder
|
|
32
|
-
attr_reader :static_font_builder
|
|
33
|
-
|
|
34
28
|
# Initialize the instancer
|
|
35
29
|
#
|
|
36
30
|
# @param font [TrueTypeFont, OpenTypeFont] Variable font object
|
|
@@ -38,9 +32,6 @@ module Fontisan
|
|
|
38
32
|
def initialize(font)
|
|
39
33
|
@font = font
|
|
40
34
|
validate_variable_font!
|
|
41
|
-
|
|
42
|
-
@delta_applicator = DeltaApplicator.new(font)
|
|
43
|
-
@static_font_builder = StaticFontBuilder.new(font)
|
|
44
35
|
end
|
|
45
36
|
|
|
46
37
|
# Generate static font instance at specified coordinates
|
|
@@ -53,21 +44,10 @@ module Fontisan
|
|
|
53
44
|
#
|
|
54
45
|
# @example
|
|
55
46
|
# binary = instancer.instance({ "wght" => 700, "wdth" => 100 })
|
|
56
|
-
def instance(user_coords,
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
# Collect varied metrics for all glyphs
|
|
61
|
-
varied_metrics = collect_varied_glyph_metrics(
|
|
62
|
-
delta_result[:normalized_coords],
|
|
63
|
-
delta_result[:region_scalars],
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
# Extract font-level metrics
|
|
67
|
-
font_metrics = delta_result[:font_metrics]
|
|
68
|
-
|
|
69
|
-
# Build static font
|
|
70
|
-
@static_font_builder.build(varied_metrics, font_metrics, options)
|
|
47
|
+
def instance(user_coords, _options = {})
|
|
48
|
+
tables = Variation::Instancing::TrueTypeInstancer
|
|
49
|
+
.new(@font.table_data, user_coords).call
|
|
50
|
+
FontWriter.write_font(tables)
|
|
71
51
|
end
|
|
72
52
|
|
|
73
53
|
# Generate static font instance and write to file
|
|
@@ -117,23 +97,16 @@ module Fontisan
|
|
|
117
97
|
# @return [Array<Hash>] Array of named instance information
|
|
118
98
|
# [{ name: "Bold", coords: { "wght" => 700 } }, ...]
|
|
119
99
|
def named_instances
|
|
120
|
-
fvar =
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
fvar.instances.map do |instance|
|
|
127
|
-
coords = extract_instance_coords(instance, fvar)
|
|
128
|
-
name = if name_table
|
|
129
|
-
get_instance_name(instance[:name_id],
|
|
130
|
-
name_table)
|
|
131
|
-
end
|
|
132
|
-
|
|
100
|
+
fvar = @font.table_data["fvar"]
|
|
101
|
+
names = if @font.table_data["name"]
|
|
102
|
+
Variation::Instancing::NameRewriter.new(@font.table_data["name"])
|
|
103
|
+
end
|
|
104
|
+
tags = axis_tags
|
|
105
|
+
Variation::Instancing::AxisSpace.named_instances(fvar).map do |inst|
|
|
133
106
|
{
|
|
134
|
-
name_id:
|
|
135
|
-
name:
|
|
136
|
-
coordinates:
|
|
107
|
+
name_id: inst[:name_id],
|
|
108
|
+
name: names&.lookup(inst[:name_id]) || "Instance #{inst[:name_id]}",
|
|
109
|
+
coordinates: tags.zip(inst[:values]).to_h,
|
|
137
110
|
}
|
|
138
111
|
end
|
|
139
112
|
end
|
|
@@ -142,21 +115,21 @@ module Fontisan
|
|
|
142
115
|
#
|
|
143
116
|
# @return [Boolean] True if variable font
|
|
144
117
|
def variable_font?
|
|
145
|
-
|
|
118
|
+
true
|
|
146
119
|
end
|
|
147
120
|
|
|
148
121
|
# Get available axis information
|
|
149
122
|
#
|
|
150
|
-
# @return [
|
|
123
|
+
# @return [Array<Variation::Instancing::AxisSpace::Axis>]
|
|
151
124
|
def axes
|
|
152
|
-
|
|
125
|
+
axis_space.axes
|
|
153
126
|
end
|
|
154
127
|
|
|
155
128
|
# Get available axis tags
|
|
156
129
|
#
|
|
157
130
|
# @return [Array<String>] Array of axis tags
|
|
158
131
|
def axis_tags
|
|
159
|
-
|
|
132
|
+
axis_space.tags
|
|
160
133
|
end
|
|
161
134
|
|
|
162
135
|
private
|
|
@@ -165,60 +138,16 @@ module Fontisan
|
|
|
165
138
|
#
|
|
166
139
|
# @raise [ArgumentError] If not a variable font
|
|
167
140
|
def validate_variable_font!
|
|
168
|
-
fvar_data = @font.table_data
|
|
141
|
+
fvar_data = @font.table_data["fvar"]
|
|
169
142
|
return unless fvar_data.nil? || fvar_data.empty?
|
|
170
143
|
|
|
171
144
|
raise ArgumentError, "Font is not a variable font (missing fvar table)"
|
|
172
145
|
end
|
|
173
146
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
# @return [Hash<Integer, Hash>] Varied metrics by glyph ID
|
|
179
|
-
def collect_varied_glyph_metrics(normalized_coords, _region_scalars)
|
|
180
|
-
varied_metrics = {}
|
|
181
|
-
|
|
182
|
-
# Get number of glyphs
|
|
183
|
-
maxp = load_maxp_table
|
|
184
|
-
return varied_metrics unless maxp
|
|
185
|
-
|
|
186
|
-
num_glyphs = maxp.num_glyphs
|
|
187
|
-
|
|
188
|
-
# Get original hmtx metrics
|
|
189
|
-
hmtx = load_hmtx_table
|
|
190
|
-
return varied_metrics unless hmtx
|
|
191
|
-
|
|
192
|
-
# For each glyph, calculate varied metrics
|
|
193
|
-
num_glyphs.times do |glyph_id|
|
|
194
|
-
original_metric = hmtx.metric_for(glyph_id)
|
|
195
|
-
next unless original_metric
|
|
196
|
-
|
|
197
|
-
# Get deltas from delta applicator
|
|
198
|
-
deltas = @delta_applicator.apply_glyph(glyph_id, normalized_coords)
|
|
199
|
-
metric_deltas = deltas[:metric_deltas]
|
|
200
|
-
|
|
201
|
-
# Calculate varied values
|
|
202
|
-
varied_metric = {
|
|
203
|
-
advance_width: original_metric[:advance_width],
|
|
204
|
-
lsb: original_metric[:lsb],
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
# Apply horizontal deltas if present
|
|
208
|
-
if metric_deltas[:horizontal]
|
|
209
|
-
if metric_deltas[:horizontal][:advance_width]
|
|
210
|
-
varied_metric[:advance_width] += metric_deltas[:horizontal][:advance_width]
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
if metric_deltas[:horizontal][:lsb]
|
|
214
|
-
varied_metric[:lsb] += metric_deltas[:horizontal][:lsb]
|
|
215
|
-
end
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
varied_metrics[glyph_id] = varied_metric
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
varied_metrics
|
|
147
|
+
def axis_space
|
|
148
|
+
@axis_space ||= Variation::Instancing::AxisSpace.new(
|
|
149
|
+
@font.table_data["fvar"], @font.table_data["avar"]
|
|
150
|
+
)
|
|
222
151
|
end
|
|
223
152
|
|
|
224
153
|
# Find coordinates for a named instance
|
|
@@ -237,105 +166,6 @@ module Fontisan
|
|
|
237
166
|
|
|
238
167
|
instance[:coordinates]
|
|
239
168
|
end
|
|
240
|
-
|
|
241
|
-
# Extract coordinates from instance record
|
|
242
|
-
#
|
|
243
|
-
# @param instance [Hash] Instance record
|
|
244
|
-
# @param fvar [Fvar] fvar table
|
|
245
|
-
# @return [Hash<String, Float>] Coordinates by axis tag
|
|
246
|
-
def extract_instance_coords(instance, fvar)
|
|
247
|
-
coords = {}
|
|
248
|
-
instance[:coordinates].each_with_index do |value, index|
|
|
249
|
-
axis = fvar.axes[index]
|
|
250
|
-
coords[axis.axis_tag.to_s] = value if axis
|
|
251
|
-
end
|
|
252
|
-
coords
|
|
253
|
-
end
|
|
254
|
-
|
|
255
|
-
# Get instance name from name table
|
|
256
|
-
#
|
|
257
|
-
# @param name_id [Integer] Name table ID
|
|
258
|
-
# @param name_table [Name] name table
|
|
259
|
-
# @return [String, nil] Instance name
|
|
260
|
-
def get_instance_name(name_id, name_table)
|
|
261
|
-
# Try to get English name
|
|
262
|
-
record = name_table.records.find do |r|
|
|
263
|
-
r.name_id == name_id && r.language_id == 0x0409 # English (US)
|
|
264
|
-
end
|
|
265
|
-
|
|
266
|
-
record ||= name_table.records.find { |r| r.name_id == name_id }
|
|
267
|
-
record&.value
|
|
268
|
-
rescue StandardError
|
|
269
|
-
nil
|
|
270
|
-
end
|
|
271
|
-
|
|
272
|
-
# Load fvar table
|
|
273
|
-
#
|
|
274
|
-
# @return [Fvar, nil] fvar table or nil
|
|
275
|
-
def load_fvar_table
|
|
276
|
-
data = @font.table_data("fvar")
|
|
277
|
-
return nil if data.nil? || data.empty?
|
|
278
|
-
|
|
279
|
-
Tables::Fvar.read(data)
|
|
280
|
-
rescue StandardError
|
|
281
|
-
nil
|
|
282
|
-
end
|
|
283
|
-
|
|
284
|
-
# Load name table
|
|
285
|
-
#
|
|
286
|
-
# @return [Name, nil] name table or nil
|
|
287
|
-
def load_name_table
|
|
288
|
-
data = @font.table_data("name")
|
|
289
|
-
return nil if data.nil? || data.empty?
|
|
290
|
-
|
|
291
|
-
Tables::Name.read(data)
|
|
292
|
-
rescue StandardError
|
|
293
|
-
nil
|
|
294
|
-
end
|
|
295
|
-
|
|
296
|
-
# Load maxp table
|
|
297
|
-
#
|
|
298
|
-
# @return [Maxp, nil] maxp table or nil
|
|
299
|
-
def load_maxp_table
|
|
300
|
-
data = @font.table_data("maxp")
|
|
301
|
-
return nil if data.nil? || data.empty?
|
|
302
|
-
|
|
303
|
-
Tables::Maxp.read(data)
|
|
304
|
-
rescue StandardError
|
|
305
|
-
nil
|
|
306
|
-
end
|
|
307
|
-
|
|
308
|
-
# Load hmtx table
|
|
309
|
-
#
|
|
310
|
-
# @return [Hmtx, nil] hmtx table or nil
|
|
311
|
-
def load_hmtx_table
|
|
312
|
-
data = @font.table_data("hmtx")
|
|
313
|
-
return nil if data.nil? || data.empty?
|
|
314
|
-
|
|
315
|
-
hmtx = Tables::Hmtx.read(data)
|
|
316
|
-
|
|
317
|
-
# Parse with context
|
|
318
|
-
hhea = load_hhea_table
|
|
319
|
-
maxp = load_maxp_table
|
|
320
|
-
return nil unless hhea && maxp
|
|
321
|
-
|
|
322
|
-
hmtx.parse_with_context(hhea.number_of_h_metrics, maxp.num_glyphs)
|
|
323
|
-
hmtx
|
|
324
|
-
rescue StandardError
|
|
325
|
-
nil
|
|
326
|
-
end
|
|
327
|
-
|
|
328
|
-
# Load hhea table
|
|
329
|
-
#
|
|
330
|
-
# @return [Hhea, nil] hhea table or nil
|
|
331
|
-
def load_hhea_table
|
|
332
|
-
data = @font.table_data("hhea")
|
|
333
|
-
return nil if data.nil? || data.empty?
|
|
334
|
-
|
|
335
|
-
Tables::Hhea.read(data)
|
|
336
|
-
rescue StandardError
|
|
337
|
-
nil
|
|
338
|
-
end
|
|
339
169
|
end
|
|
340
170
|
end
|
|
341
171
|
end
|