fontisan 0.4.0 → 0.4.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '020862878eef5569b82edb88989afdc3b7c21d3f01d19b605132af70cabe524f'
4
- data.tar.gz: d8d685a687c94f987835e440619aca344a615368ea78d6caf6a882c34c2900d3
3
+ metadata.gz: 14c31b390c5721b221f5c04239baf1d2330b668d142addd3e321b3bd868f7b7a
4
+ data.tar.gz: 726fe81623ef7f410aa09e3b7a7838b8e0df1c2304cf9b36d1ad0727bdcf03e8
5
5
  SHA512:
6
- metadata.gz: a98055b753907dedfd44fd89c61fd3d5d797d405d702e65730bae1e3299665354f385b135529ca6b48708672e37cfa0e10a7b518ce0b624ccb46fae9459cfb22
7
- data.tar.gz: 46af4e780f4a1786501e2b874d4b439614c212b839b0b398316806a2baa9348b67c8e4db3e63b85142d79ae015df3f093db957f055ddbd3f4955268caa2c1dfa
6
+ metadata.gz: f4c11a54cd876590a4e52df790eeadc490da5b3bad9ef2a0ff6186f560ff2b584c29bc2f7fac1881f25771a3a7031b1d1db5d7cf5c0c67c057ec254165d6b2ac
7
+ data.tar.gz: 556737df14831d0bf14a82ed34e27f9438f93fe0d1c51b6324d0e3f938de3b7ce136bcb0b4a3a8a50698e7e603a02227fd87ee74de530df60e81fcbd5d3a4e5b
data/README.adoc CHANGED
@@ -2475,6 +2475,36 @@ fontisan stitch \
2475
2475
  --output stitched.ttf
2476
2476
  ----
2477
2477
 
2478
+ === Color emoji (CBDT/CBLC passthrough)
2479
+
2480
+ Sources with CBDT/CBLC tables (e.g. NotoColorEmoji) can be used as
2481
+ Stitcher sources. The bitmap data is propagated byte-for-byte from the
2482
+ source into the output. The Stitcher automatically detects the source's
2483
+ storage mode via `bitmap_mode`:
2484
+
2485
+ [source,ruby]
2486
+ ----
2487
+ stitcher = Fontisan::Stitcher.new
2488
+ stitcher.add_source(:text, Fontisan::FontLoader.load("NotoSans.ttf"))
2489
+ stitcher.add_source(:emoji, Fontisan::FontLoader.load("NotoColorEmoji.ttf"))
2490
+
2491
+ # All emoji glyphs from the CBDT source are added; the CBLC table
2492
+ # is copied byte-for-byte and references the right GIDs.
2493
+ stitcher.include_range(0x41..0x5A, from: :text)
2494
+ stitcher.include_range(0x1F600..0x1F64F, from: :emoji)
2495
+
2496
+ stitcher.write_to("out.ttf", format: :ttf)
2497
+ # → out.ttf has glyf + CBDT + CBLC, emoji renders correctly
2498
+ ----
2499
+
2500
+ **Constraints:**
2501
+ - Only ONE CBDT source per Stitcher (multiple raise
2502
+ `Fontisan::MultipleCbdtSourcesError`)
2503
+ - The CBDT source is processed first (GIDs 0..N-1) so the CBLC's GID
2504
+ references remain valid in the output
2505
+ - Multi-source CBDT merge is tracked as TODO (REVIEW
2506
+ `TODO.full/ufo/23-cbdt-passthrough.md`)
2507
+
2478
2508
 
2479
2509
  == Testing
2480
2510
 
@@ -6,10 +6,10 @@ module Fontisan
6
6
  # extraction API used by the selectors.
7
7
  #
8
8
  # For UFO sources, glyphs are accessed by name directly. For TTF
9
- # or OTF sources, the source is lazily converted to a UFO::Font
10
- # via Ufo::Convert::FromBinData on first glyph access, then cached.
11
- # This is O(n) in donor glyph count but amortized across all
12
- # codepoint extractions from that donor.
9
+ # or OTF sources, individual glyphs are extracted on demand from
10
+ # the BinData tables (glyf/loca/head for TTF, CFF for OTF). This
11
+ # is O(1) per glyph rather than the previous O(n) full-donor
12
+ # conversion.
13
13
  #
14
14
  # CBDT/CBLC sources (e.g. NotoColorEmoji) are detected via
15
15
  # #bitmap_mode. When a source is :cbdt, the Stitcher propagates
@@ -20,7 +20,7 @@ module Fontisan
20
20
 
21
21
  def initialize(font)
22
22
  @font = font
23
- @ufo_cache = nil
23
+ @bin_data_cache = nil
24
24
  end
25
25
 
26
26
  # @return [Symbol] :ufo, :ttf, :otf
@@ -66,14 +66,19 @@ module Fontisan
66
66
 
67
67
  # Extract a glyph by gid.
68
68
  #
69
+ # For TTF/OTF sources, this is O(1) per glyph: it parses just
70
+ # the requested glyph from glyf/CFF on demand, not the entire
71
+ # donor. The full-donor conversion is avoided entirely.
72
+ #
69
73
  # For CBDT sources, returns a placeholder glyph (no contours)
70
- # since the glyph data lives in the bitmap tables, not outlines.
74
+ # since the glyph data is in the bitmap tables, not outlines.
75
+ #
71
76
  # @param gid [Integer]
72
77
  # @return [Fontisan::Ufo::Glyph, nil]
73
78
  def glyph_for_gid(gid)
74
79
  case @font
75
80
  when Fontisan::Ufo::Font then ufo_glyph_at(gid)
76
- else converted_ufo_glyph_at(gid)
81
+ else extract_single_glyph_from_bindata(gid)
77
82
  end
78
83
  end
79
84
 
@@ -89,6 +94,15 @@ module Fontisan
89
94
  nil
90
95
  end
91
96
 
97
+ # Width of a specific glyph (extracted from hmtx).
98
+ # Falls back to 0 if hmtx is missing.
99
+ # @param gid [Integer]
100
+ # @return [Integer]
101
+ def glyph_width(gid)
102
+ widths = bin_data_widths
103
+ widths[gid] || 0
104
+ end
105
+
92
106
  private
93
107
 
94
108
  # ---------- UFO source ----------
@@ -108,7 +122,7 @@ module Fontisan
108
122
  @font.glyph(name)
109
123
  end
110
124
 
111
- # ---------- TTF/OTF source ----------
125
+ # ---------- TTF/OTF source: O(1) per-glyph extraction ----------
112
126
 
113
127
  def bin_data_gid_for(codepoint)
114
128
  cmap = @font.table("cmap")
@@ -117,22 +131,137 @@ module Fontisan
117
131
  cmap.unicode_mappings[codepoint]
118
132
  end
119
133
 
120
- # Lazily convert the loaded TTF/OTF to a UFO::Font, then
121
- # extract glyphs from the cached UFO model.
122
- def converted_ufo
123
- return @ufo_cache if @ufo_cache
134
+ # Lazily parse the relevant BinData tables. Cached so we only
135
+ # pay the parse cost once per source.
136
+ def bin_data_cache
137
+ @bin_data_cache ||= parse_bin_data_tables
138
+ end
139
+
140
+ def parse_bin_data_tables
141
+ cache = { head: @font.table("head") }
142
+
143
+ if @font.has_table?("glyf")
144
+ cache[:loca] = @font.table("loca")
145
+ cache[:glyf] = @font.table("glyf")
146
+ # loca needs head's index_to_loc_format to size its offsets
147
+ if cache[:loca].respond_to?(:parse_with_context) && cache[:head]
148
+ cache[:loca].parse_with_context(
149
+ cache[:head].index_to_loc_format,
150
+ @font.table("maxp")&.num_glyphs || 0,
151
+ )
152
+ end
153
+ end
154
+
155
+ cache
156
+ end
124
157
 
125
- @ufo_cache = Fontisan::Ufo::Convert::FromBinData.convert(@font)
158
+ # Build {gid → advance_width} from hmtx (cached).
159
+ def bin_data_widths
160
+ @bin_data_widths ||= build_bin_data_widths
126
161
  end
127
162
 
128
- def converted_ufo_glyph_at(gid)
163
+ def build_bin_data_widths
164
+ widths = {}
165
+ hmtx = @font.table("hmtx")
166
+ return widths unless hmtx
167
+
168
+ hhea = @font.table("hhea")
169
+ maxp = @font.table("maxp")
170
+ num_h_metrics = hhea&.number_of_h_metrics || 1
171
+ num_glyphs = maxp&.num_glyphs || 0
172
+
173
+ if hmtx.respond_to?(:parse_with_context)
174
+ hmtx.parse_with_context(num_h_metrics, num_glyphs)
175
+ end
176
+
177
+ num_glyphs.times do |gid|
178
+ metric = hmtx.respond_to?(:metric_for) ? hmtx.metric_for(gid) : nil
179
+ widths[gid] = metric ? metric[:advance_width] : 0
180
+ rescue StandardError
181
+ widths[gid] = 0
182
+ end
183
+ widths
184
+ end
185
+
186
+ # Extract a single glyph by gid, parsing just the relevant bytes.
187
+ # O(1) per call (after the first call's table-parsing overhead).
188
+ #
189
+ # For TTF (glyf) sources: reads one glyph from glyf/loca.
190
+ # For OTF (CFF) sources: falls back to the full-donor conversion
191
+ # BUT uses a proper gid→name map (not array index) to avoid the
192
+ # gid-misalignment bug that dropped Plane 1 codepoints.
193
+ def extract_single_glyph_from_bindata(gid)
194
+ cache = bin_data_cache
195
+
196
+ if cache[:glyf] && cache[:loca] && cache[:head]
197
+ extract_truetype_glyph(gid, cache)
198
+ elsif @font.has_table?("CFF ")
199
+ extract_cff_glyph_safe(gid)
200
+ end
201
+ end
202
+
203
+ # CFF glyph extraction: uses the full UFO conversion. After the
204
+ # fix in FromBinData (no more `next unless simple`), every gid
205
+ # gets a glyph, so array index = gid.
206
+ def extract_cff_glyph_safe(gid)
129
207
  ufo = converted_ufo
130
208
  names = ufo.glyphs.keys
131
- name = names[gid]
132
- return nil unless name
209
+ return nil if gid >= names.size
133
210
 
211
+ name = names[gid]
134
212
  ufo.glyph(name)
135
213
  end
214
+
215
+ # Lazily convert the loaded TTF/OTF to a UFO::Font (for CFF
216
+ # sources and as a fallback).
217
+ def converted_ufo
218
+ @converted_ufo ||= Fontisan::Ufo::Convert::FromBinData.convert(@font)
219
+ end
220
+
221
+ def extract_truetype_glyph(gid, cache)
222
+ simple = cache[:glyf].glyph_for(gid, cache[:loca], cache[:head])
223
+ return nil unless simple
224
+ return nil unless simple.respond_to?(:simple?) && simple.simple?
225
+
226
+ name = gid.zero? ? ".notdef" : "gid#{gid}"
227
+ glyph = Fontisan::Ufo::Glyph.new(name: name)
228
+ glyph.width = glyph_width(gid)
229
+ copy_simple_contours(simple, glyph)
230
+ add_cmap_unicodes(gid, glyph)
231
+ glyph
232
+ rescue StandardError
233
+ nil
234
+ end
235
+
236
+ # Copy a SimpleGlyph's contours + points into a Ufo::Glyph.
237
+ def copy_simple_contours(simple, ufo_glyph)
238
+ num_contours = simple.end_pts_of_contours&.size || 0
239
+ return if num_contours.zero?
240
+
241
+ num_contours.times do |ci|
242
+ points = simple.points_for_contour(ci)
243
+ next unless points && !points.empty?
244
+
245
+ ufo_points = points.map do |pt|
246
+ x = pt[:x] || pt["x"]
247
+ y = pt[:y] || pt["y"]
248
+ on_curve = pt[:on_curve].nil? || pt[:on_curve]
249
+ type = on_curve ? "line" : "offcurve"
250
+ Fontisan::Ufo::Point.new(x: x.to_f, y: y.to_f, type: type)
251
+ end
252
+ ufo_glyph.add_contour(Fontisan::Ufo::Contour.new(ufo_points))
253
+ end
254
+ end
255
+
256
+ # Add Unicode codepoints from the cmap that map to this gid.
257
+ def add_cmap_unicodes(gid, glyph)
258
+ cmap = @font.table("cmap")
259
+ return unless cmap
260
+
261
+ (cmap.unicode_mappings || {}).each do |cp, g|
262
+ glyph.add_unicode(cp) if g == gid
263
+ end
264
+ end
136
265
  end
137
266
  end
138
267
  end
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Fontisan
6
+ module Ufo
7
+ module Compile
8
+ # Builds the OpenType `GPOS` (Glyph Positioning) table from UFO
9
+ # kerning data. Emits a minimal but valid GPOS with:
10
+ #
11
+ # - ScriptList: DFLT script, default language system
12
+ # - FeatureList: `kern` feature (feature tag "kern")
13
+ # - LookupList: one PairPos lookup (format 1, individual pairs)
14
+ #
15
+ # Each kerning pair from the UFO source becomes a PairPosRecord
16
+ # with an x-advance adjustment on the first glyph.
17
+ #
18
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/gpos
19
+ module Gpos
20
+ FEATURE_KERN = "kern"
21
+ SCRIPT_DFLT = "DFLT"
22
+ LANGSYS_DEFAULT = 0
23
+
24
+ # ValueFormat flags (which fields are present in a ValueRecord)
25
+ VALUE_X_PLACEMENT = 0x0001
26
+ VALUE_Y_PLACEMENT = 0x0002
27
+ VALUE_X_ADVANCE = 0x0004
28
+ VALUE_Y_ADVANCE = 0x0008
29
+
30
+ # @param font [Fontisan::Ufo::Font]
31
+ # @param glyphs [Array<Fontisan::Ufo::Glyph>] in gid order
32
+ # @return [String, nil] GPOS table bytes, or nil if no kerning
33
+ def self.build(font, glyphs:)
34
+ pairs = collect_kerning_pairs(font, glyphs)
35
+ return nil if pairs.empty?
36
+
37
+ build_gpos_table(pairs)
38
+ end
39
+
40
+ # ---------- pair collection ----------
41
+
42
+ # Collect kerning pairs from the UFO model. Each pair is
43
+ # (gid1, gid2, x_advance_delta).
44
+ # @return [Array<[Integer, Integer, Integer]>]
45
+ def self.collect_kerning_pairs(font, glyphs)
46
+ name_to_gid = {}
47
+ glyphs.each_with_index { |g, gid| name_to_gid[g.name] = gid }
48
+
49
+ pairs = []
50
+ font.kerning.each_pair do |key, value|
51
+ # UFO kerning key is "glyph1 glyph2" or a class name.
52
+ # We only handle individual glyph pairs (not classes).
53
+ names = key.split
54
+ next unless names.size == 2
55
+
56
+ gid1 = name_to_gid[names[0]]
57
+ gid2 = name_to_gid[names[1]]
58
+ next unless gid1 && gid2
59
+
60
+ pairs << [gid1, gid2, value.to_i]
61
+ end
62
+
63
+ pairs.sort_by { |a| [a[0], a[1]] }
64
+ end
65
+
66
+ # ---------- GPOS binary assembly ----------
67
+
68
+ def self.build_gpos_table(pairs)
69
+ # Group pairs by first glyph (gid1)
70
+ by_first = {}
71
+ pairs.each do |gid1, gid2, delta|
72
+ by_first[gid1] ||= []
73
+ by_first[gid1] << [gid2, delta]
74
+ end
75
+
76
+ first_gids = by_first.keys.sort
77
+
78
+ # --- Build subtables bottom-up ---
79
+
80
+ # 1. PairSets (one per first glyph)
81
+ pair_sets_data = {}
82
+ pair_set_offsets = {}
83
+ pair_sets_blob = +""
84
+
85
+ first_gids.each do |gid1|
86
+ pair_set_offsets[gid1] = pair_sets_blob.bytesize
87
+
88
+ second_pairs = by_first[gid1].sort_by { |gid2, _| gid2 }
89
+ data = [second_pairs.size].pack("n") # pairValueCount
90
+ second_pairs.each do |gid2, delta|
91
+ data << [gid2, delta, 0].pack("nnn") # secondGlyph + valRec1(xAdvance) + valRec2(0)
92
+ end
93
+
94
+ pair_sets_data[gid1] = data
95
+ pair_sets_blob << data
96
+ end
97
+
98
+ # 2. Coverage table (Format 1: individual glyphs)
99
+ coverage = build_coverage_format1(first_gids)
100
+
101
+ # 3. PairPosFormat1 subtable
102
+ value_format1 = VALUE_X_ADVANCE
103
+ value_format2 = 0
104
+
105
+ pairpos_header_size = 10 # format(2) + coverageOffset(2) + valueFormat1(2) + valueFormat2(2) + pairSetCount(2)
106
+ pairset_array_size = first_gids.size * 2 # one uint16 offset per first glyph
107
+
108
+ coverage_offset = pairpos_header_size + pairset_array_size
109
+ pairset_base = coverage_offset + coverage.bytesize
110
+
111
+ pairpos = +""
112
+ pairpos << [1].pack("n") # posFormat = 1
113
+ pairpos << [coverage_offset].pack("n") # coverageOffset
114
+ pairpos << [value_format1].pack("n") # valueFormat1
115
+ pairpos << [value_format2].pack("n") # valueFormat2
116
+ pairpos << [first_gids.size].pack("n") # pairSetCount
117
+
118
+ # PairSet offsets (relative to start of PairPos subtable)
119
+ first_gids.each do |gid1|
120
+ pairpos << [pairset_base + pair_set_offsets[gid1]].pack("n")
121
+ end
122
+
123
+ pairpos << coverage
124
+ pairpos << pair_sets_blob
125
+
126
+ # 4. Lookup table (type 2 = PairPos, flag 0)
127
+ lookup_header = [
128
+ 2, # lookupType = PairPos
129
+ 0, # lookupFlag
130
+ 1, # subTableCount
131
+ ].pack("nnn")
132
+
133
+ subtable_offset = lookup_header.bytesize + 2 # +2 for the offset array
134
+ lookup = lookup_header + [subtable_offset].pack("n") + pairpos
135
+
136
+ # 5. Assemble GPOS header + ScriptList + FeatureList + LookupList
137
+
138
+ # ScriptList (minimal: DFLT script, default LangSys)
139
+ script_list = build_script_list
140
+
141
+ # FeatureList (minimal: kern feature)
142
+ feature_list = build_feature_list
143
+
144
+ # LookupList (minimal: one lookup)
145
+ lookup_list_header = [1].pack("n") # lookupCount
146
+ lookup_offset_in_list = lookup_list_header.bytesize + 2 # +2 for the offset
147
+ lookup_list = lookup_list_header + [lookup_offset_in_list].pack("n") + lookup
148
+
149
+ # GPOS header (version 1.0)
150
+ header_size = 10 # version(4) + scriptListOffset(2) + featureListOffset(2) + lookupListOffset(2)
151
+ script_list_offset = header_size
152
+ feature_list_offset = script_list_offset + script_list.bytesize
153
+ lookup_list_offset = feature_list_offset + feature_list.bytesize
154
+
155
+ header = [
156
+ 0x00010000, # version 1.0
157
+ script_list_offset,
158
+ feature_list_offset,
159
+ lookup_list_offset,
160
+ ].pack("Nnnn")
161
+
162
+ header + script_list + feature_list + lookup_list
163
+ end
164
+
165
+ # Coverage Format 1: list of individual glyph IDs.
166
+ def self.build_coverage_format1(gids)
167
+ [1, gids.size].pack("nn") + gids.pack("n*")
168
+ end
169
+
170
+ # ScriptList: DFLT script with a single default LangSys.
171
+ # The LangSys references feature index 0 (kern).
172
+ def self.build_script_list
173
+ # ScriptList header: scriptCount(2)
174
+ # ScriptRecord: scriptTag(4) + scriptOffset(2)
175
+ # Script table: defaultLangSysOffset(2) + langSysCount(2) = 0
176
+ # LangSys: lookupOrder(2)=0 + reqFeatureIndex(2)=0xFFFF + featureIndexCount(2)=1 + featureIndex(2)=0
177
+
178
+ script_list_header_size = 2 + (4 + 2) # scriptCount + 1 ScriptRecord
179
+ script_offset = script_list_header_size
180
+ langsys_offset = script_offset + 4 # script table size (defaultLangSysOffset + langSysCount)
181
+
182
+ script_list = +""
183
+ script_list << [1].pack("n") # scriptCount
184
+ script_list << SCRIPT_DFLT # scriptTag
185
+ script_list << [script_offset].pack("n") # scriptOffset
186
+
187
+ # Script table
188
+ script_list << [langsys_offset - script_offset].pack("n") # defaultLangSysOffset (relative)
189
+ script_list << [0].pack("n") # langSysCount
190
+
191
+ # Default LangSys
192
+ script_list << [0].pack("n") # lookupOrder (reserved)
193
+ script_list << [0xFFFF].pack("n") # reqFeatureIndex (none)
194
+ script_list << [1].pack("n") # featureIndexCount
195
+ script_list << [0].pack("n") # featureIndex[0] = kern
196
+
197
+ script_list
198
+ end
199
+
200
+ # FeatureList: one feature record (kern) referencing lookup 0.
201
+ def self.build_feature_list
202
+ # FeatureList header: featureCount(2)
203
+ # FeatureRecord: featureTag(4) + featureOffset(2)
204
+ # Feature table: featureParams(2)=0 + lookupIndexCount(2)=1 + lookupIndex(2)=0
205
+
206
+ feature_list_header_size = 2 + (4 + 2) # featureCount + 1 FeatureRecord
207
+ feature_offset = feature_list_header_size
208
+
209
+ feature_list = +""
210
+ feature_list << [1].pack("n") # featureCount
211
+ feature_list << FEATURE_KERN # featureTag
212
+ feature_list << [feature_offset].pack("n") # featureOffset
213
+
214
+ # Feature table
215
+ feature_list << [0].pack("n") # featureParams (null)
216
+ feature_list << [1].pack("n") # lookupIndexCount
217
+ feature_list << [0].pack("n") # lookupIndex[0]
218
+
219
+ feature_list
220
+ end
221
+
222
+ private_class_method :build_gpos_table, :build_coverage_format1,
223
+ :build_script_list, :build_feature_list
224
+ end
225
+ end
226
+ end
227
+ end
@@ -33,6 +33,7 @@ module Fontisan
33
33
  autoload :TtfCompiler, "fontisan/ufo/compile/ttf_compiler"
34
34
  autoload :OtfCompiler, "fontisan/ufo/compile/otf_compiler"
35
35
  autoload :Filters, "fontisan/ufo/compile/filters"
36
+ autoload :Gpos, "fontisan/ufo/compile/gpos"
36
37
  autoload :Head, "fontisan/ufo/compile/head"
37
38
  autoload :Hhea, "fontisan/ufo/compile/hhea"
38
39
  autoload :Maxp, "fontisan/ufo/compile/maxp"
@@ -179,12 +179,15 @@ module Fontisan
179
179
  rescue StandardError
180
180
  nil
181
181
  end
182
- next unless simple
183
182
 
184
183
  if simple.is_a?(Fontisan::Tables::SimpleGlyph)
185
184
  extract_simple_contours(simple, ufo_glyph)
186
185
  end
187
186
 
187
+ # Always add the glyph, even if it has no contours. This
188
+ # ensures gid → array index alignment (no gaps from skipped
189
+ # glyphs). Without this, high-gid Plane 1 codepoints are
190
+ # silently dropped because the array is shorter than maxp.
188
191
  ufo.layers.default_layer.add(ufo_glyph)
189
192
  end
190
193
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fontisan
4
- VERSION = "0.4.0"
4
+ VERSION = "0.4.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fontisan
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -564,6 +564,7 @@ files:
564
564
  - lib/fontisan/ufo/compile/filters/flatten_components.rb
565
565
  - lib/fontisan/ufo/compile/filters/reverse_contour_direction.rb
566
566
  - lib/fontisan/ufo/compile/glyf_loca.rb
567
+ - lib/fontisan/ufo/compile/gpos.rb
567
568
  - lib/fontisan/ufo/compile/head.rb
568
569
  - lib/fontisan/ufo/compile/hhea.rb
569
570
  - lib/fontisan/ufo/compile/hmtx.rb