fontisan 0.4.33 → 0.4.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ # Minimal Adobe FEA (features.fea) parser. Extracts the two most
7
+ # common feature constructs from UFO sources:
8
+ #
9
+ # feature liga { sub f i by fi; } liga; → LigatureSubst
10
+ # feature kern { pos A V -50; } kern; → PairPos
11
+ #
12
+ # Unsupported constructs are collected and surfaced via
13
+ # {ParsedFeatures#unsupported} so the caller can decide whether
14
+ # to warn or raise. This is the "limited fea subset" approach
15
+ # from TODO #10b — expand as real-world fonts demand.
16
+ #
17
+ # The parser is deliberately simple: it tokenizes on whitespace
18
+ # and semicolons, recognizes `feature <tag> { ... } <tag>;` blocks,
19
+ # and dispatches per-statement to registered rule handlers (OCP —
20
+ # new rule kinds are one handler addition).
21
+ #
22
+ # @example
23
+ # parsed = FeatureCompiler.parse(fea_text)
24
+ # parsed.ligatures_for("liga") # => [{sequence: ["f", "i"], result: "fi"}]
25
+ class FeatureCompiler
26
+ # @param text [String] features.fea content
27
+ # @return [ParsedFeatures]
28
+ def self.parse(text)
29
+ new.parse(text)
30
+ end
31
+
32
+ # @param text [String]
33
+ # @return [ParsedFeatures]
34
+ def parse(text)
35
+ @parsed = ParsedFeatures.new
36
+ tokenize_blocks(text || "")
37
+ @parsed
38
+ end
39
+
40
+ private
41
+
42
+ # Split text into `feature <tag> { body } <tag>;` blocks and
43
+ # pass each block body to the statement dispatcher.
44
+ def tokenize_blocks(text)
45
+ # Strip comments (# to end of line)
46
+ cleaned = text.gsub(%r{#[^\n]*}, "")
47
+ # Match feature blocks: feature TAG { ... } TAG;
48
+ cleaned.scan(/feature\s+([A-Za-z0-9]{4})\s*\{([^}]*)\}\s*\1\s*;/m) do |tag, body|
49
+ parse_body(tag, body)
50
+ end
51
+ end
52
+
53
+ # Parse statements within a feature block.
54
+ def parse_body(tag, body)
55
+ statements = body.split(";").map(&:strip).reject(&:empty?)
56
+ statements.each do |stmt|
57
+ tokens = stmt.split(/\s+/)
58
+ dispatch(tag, tokens, stmt)
59
+ end
60
+ end
61
+
62
+ # Dispatch a statement to the appropriate handler based on the
63
+ # leading keyword. Unknown statements are collected as unsupported.
64
+ def dispatch(tag, tokens, raw)
65
+ case tokens.first
66
+ when "sub" then handle_sub(tag, tokens)
67
+ when "pos" then handle_pos(tag, tokens)
68
+ else
69
+ @parsed.add_unsupported(tag, raw)
70
+ end
71
+ end
72
+
73
+ # `sub A B C by ABC;` → ligature substitution
74
+ def handle_sub(tag, tokens)
75
+ # tokens: ["sub", "A", "B", ..., "by", "ABC"]
76
+ by_idx = tokens.index("by")
77
+ unless by_idx && by_idx > 1 && tokens.size > by_idx + 1
78
+ @parsed.add_unsupported(tag, tokens.join(" "))
79
+ return
80
+ end
81
+
82
+ sequence = tokens[1, by_idx - 1]
83
+ result = tokens[by_idx + 1]
84
+ return if sequence.nil? || sequence.empty? || result.nil?
85
+
86
+ if sequence.size == 1
87
+ # Single substitution: `sub A by A.alt;` — unsupported for now
88
+ @parsed.add_unsupported(tag, tokens.join(" "))
89
+ return
90
+ end
91
+
92
+ @parsed.add_ligature(tag, sequence: sequence, result: result)
93
+ end
94
+
95
+ # `pos A B -50;` → pair positioning
96
+ def handle_pos(tag, tokens)
97
+ # tokens: ["pos", "A", "B", "-50"] or ["pos", "A", "B", "<", "0", "-50", "0", ">"]
98
+ return unless tokens.size >= 4
99
+
100
+ left = tokens[1]
101
+ right = tokens[2]
102
+ value = parse_pos_value(tokens[3])
103
+ return unless value
104
+
105
+ @parsed.add_pair(tag, left:, right:, value:)
106
+ end
107
+
108
+ # Parse a positioning value: either a bare integer or the start
109
+ # of a ValueRecord (we only handle the bare integer case for now).
110
+ def parse_pos_value(token)
111
+ Integer(token)
112
+ rescue ArgumentError
113
+ nil
114
+ end
115
+ end
116
+
117
+ # Result of parsing features.fea. Aggregates ligature and pair
118
+ # rules by feature tag, plus any unsupported statements.
119
+ class ParsedFeatures
120
+ attr_reader :ligatures, :pairs, :unsupported
121
+
122
+ def initialize
123
+ @ligatures = Hash.new { |h, k| h[k] = [] }
124
+ @pairs = Hash.new { |h, k| h[k] = [] }
125
+ @unsupported = Hash.new { |h, k| h[k] = [] }
126
+ end
127
+
128
+ # @param tag [String] feature tag
129
+ # @return [Array<Hash>] ligature rules: {sequence: [names], result: name}
130
+ def ligatures_for(tag)
131
+ @ligatures[tag] || []
132
+ end
133
+
134
+ # @param tag [String] feature tag
135
+ # @return [Array<Hash>] pair rules: {left:, right:, value:}
136
+ def pairs_for(tag)
137
+ @pairs[tag] || []
138
+ end
139
+
140
+ # @return [Boolean] true if no features were parsed
141
+ def empty?
142
+ @ligatures.empty? && @pairs.empty?
143
+ end
144
+
145
+ # ---- mutation (used by FeatureCompiler) ----
146
+
147
+ def add_ligature(tag, sequence:, result:)
148
+ @ligatures[tag] << { sequence: sequence, result: result }
149
+ end
150
+
151
+ def add_pair(tag, left:, right:, value:)
152
+ @pairs[tag] << { left: left, right: right, value: value }
153
+ end
154
+
155
+ def add_unsupported(tag, statement)
156
+ @unsupported[tag] << statement
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Fontisan
6
+ module Ufo
7
+ module Compile
8
+ # Builds the OpenType `GSUB` (Glyph Substitution) table from
9
+ # ligature substitution rules extracted from features.fea via
10
+ # {FeatureCompiler}.
11
+ #
12
+ # Emits a minimal but valid GSUB with:
13
+ #
14
+ # - ScriptList: DFLT script, default language system
15
+ # - FeatureList: one feature record per ligature feature tag
16
+ # (e.g. "liga", "dlig", "clig")
17
+ # - LookupList: one LigatureSubst lookup per feature tag
18
+ #
19
+ # Each `sub A B C by ABC;` rule becomes a LigatureSet on the
20
+ # first glyph (A), with a Ligature record pointing at ABC and
21
+ # component count 2 (B, C).
22
+ #
23
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/gsub
24
+ module Gsub
25
+ SCRIPT_DFLT = "DFLT"
26
+ LANGSYS_DEFAULT = 0
27
+
28
+ # Build GSUB table bytes from parsed feature rules.
29
+ #
30
+ # @param parsed [FeatureCompiler::ParsedFeatures]
31
+ # @param name_to_gid [Hash{String=>Integer}]
32
+ # @return [String, nil] GSUB bytes, or nil if no ligature rules
33
+ def self.build(parsed:, name_to_gid:)
34
+ features = ligature_features(parsed, name_to_gid)
35
+ return nil if features.empty?
36
+
37
+ lookups = features.values
38
+ build_gsub_table(features.keys, lookups)
39
+ end
40
+
41
+ # Resolve ligature rules to per-feature lookup data.
42
+ #
43
+ # @param parsed [FeatureCompiler::ParsedFeatures]
44
+ # @param name_to_gid [Hash{String=>Integer}]
45
+ # @return [Hash{String=>Array}] feature_tag → lookup data
46
+ def self.ligature_features(parsed, name_to_gid)
47
+ parsed.ligatures.keys.each_with_object({}) do |tag, h|
48
+ rules = parsed.ligatures_for(tag)
49
+ sets = build_ligature_sets(rules, name_to_gid)
50
+ h[tag] = sets unless sets.empty?
51
+ end
52
+ end
53
+
54
+ # Build ligature sets keyed by the first glyph in each sequence.
55
+ #
56
+ # @param rules [Array<Hash>] {sequence:, result:}
57
+ # @param name_to_gid [Hash]
58
+ # @return [Hash{Integer=>Array}] gid → [{result_gid, components: [gids]}]
59
+ def self.build_ligature_sets(rules, name_to_gid)
60
+ sets = Hash.new { |h, k| h[k] = [] }
61
+ rules.each do |rule|
62
+ seq_gids = rule[:sequence].map { |n| name_to_gid[n] }
63
+ result_gid = name_to_gid[rule[:result]]
64
+ next unless seq_gids.all? && result_gid
65
+
66
+ first = seq_gids.first
67
+ components = seq_gids[1..]
68
+ sets[first] << { result_gid: result_gid, components: components }
69
+ end
70
+ sets
71
+ end
72
+
73
+ # ---------- GSUB binary assembly ----------
74
+
75
+ # Build the complete GSUB table.
76
+ #
77
+ # @param tags [Array<String>] feature tags in lookup order
78
+ # @param lookups [Array<Hash>] one lookup per tag
79
+ # @return [String]
80
+ def self.build_gsub_table(tags, lookups)
81
+ io = StringIO.new(+"")
82
+ # Header: version (uint32 0x00010000), scriptListOffset,
83
+ # featureListOffset, lookupListOffset (each uint16)
84
+ header_size = 2 + 2 + 2 + 4 # version(4) + 3×uint16
85
+
86
+ # Serialize each list independently, then compute offsets.
87
+ script_list = build_script_list(tags.size)
88
+ feature_list = build_feature_list(tags)
89
+ lookup_list = build_lookup_list(lookups)
90
+
91
+ script_list_offset = header_size
92
+ feature_list_offset = script_list_offset + script_list.bytesize
93
+ lookup_list_offset = feature_list_offset + feature_list.bytesize
94
+
95
+ io << [0x00010000].pack("N")
96
+ io << [script_list_offset].pack("n")
97
+ io << [feature_list_offset].pack("n")
98
+ io << [lookup_list_offset].pack("n")
99
+ io << script_list
100
+ io << feature_list
101
+ io << lookup_list
102
+ io.string
103
+ end
104
+
105
+ # ScriptList: one script (DFLT) with a default LangSys that
106
+ # references all features (featureIndex 0..count-1).
107
+ def self.build_script_list(feature_count)
108
+ io = +""
109
+ io << [1].pack("n") # scriptCount = 1
110
+
111
+ # ScriptRecord: tag(4) + scriptOffset(2)
112
+ # Script table starts right after the ScriptList header:
113
+ # scriptCount(2) + ScriptRecord(6) = 8
114
+ script_offset = 8
115
+ io << SCRIPT_DFLT
116
+ io << [script_offset].pack("n")
117
+
118
+ # Script table: defaultLangSysOffset(2) + langSysCount(2) = 4
119
+ # LangSys follows immediately
120
+ default_langsys_offset = script_offset + 4
121
+ io << [default_langsys_offset].pack("n")
122
+ io << [0].pack("n") # langSysCount
123
+
124
+ # LangSys: lookupOrder(2)=0, requiredFeatureIndex(2)=0xFFFF,
125
+ # featureIndexCount(2), featureIndices[]
126
+ io << [0].pack("n")
127
+ io << [0xFFFF].pack("n")
128
+ io << [feature_count].pack("n")
129
+ feature_count.times { |i| io << [i].pack("n") }
130
+
131
+ io
132
+ end
133
+
134
+ # FeatureList: one FeatureRecord per tag. Each points to a
135
+ # Feature table with one lookup index (the lookup for that tag).
136
+ def self.build_feature_list(tags)
137
+ io = +""
138
+ io << [tags.size].pack("n") # featureCount
139
+
140
+ # FeatureRecords: tag(4) + featureOffset(2)
141
+ records_size = tags.size * 6
142
+ header_size = 2 + records_size
143
+
144
+ feature_records = +""
145
+ feature_tables = +""
146
+ tags.each_with_index do |tag, idx|
147
+ feature_records << tag.ljust(4, " ")[0, 4]
148
+ feature_records << [header_size + (idx * 4)].pack("n")
149
+
150
+ feature_tables << [0].pack("n") # featureParams (null)
151
+ feature_tables << [1].pack("n") # lookupIndexCount
152
+ feature_tables << [idx].pack("n") # lookupIndex (1:1 feature→lookup)
153
+ end
154
+
155
+ io << feature_records
156
+ io << feature_tables
157
+ io
158
+ end
159
+
160
+ # LookupList: one lookup per feature tag. Each lookup is a
161
+ # LigatureSubst (type 4) lookup.
162
+ def self.build_lookup_list(lookups)
163
+ io = +""
164
+ count = lookups.size
165
+ io << [count].pack("n") # lookupCount
166
+
167
+ # Serialize each lookup first, then compute offsets relative
168
+ # to the start of the LookupList.
169
+ serialized = lookups.map { |sets| build_ligature_lookup(sets) }
170
+ offset_table_size = 2 + (count * 2)
171
+ pos = offset_table_size
172
+ offsets = serialized.map do |s|
173
+ o = pos
174
+ pos += s.bytesize
175
+ o
176
+ end
177
+
178
+ offsets.each { |o| io << [o].pack("n") }
179
+ serialized.each { |s| io << s }
180
+
181
+ io
182
+ end
183
+
184
+ # Build a single LigatureSubst lookup (type 4, format 1).
185
+ def self.build_ligature_lookup(sets)
186
+ io = +""
187
+ # Lookup table: lookupType(2)=4, lookupFlag(2)=0, subTableCount(2)=1
188
+ io << [4].pack("n")
189
+ io << [0].pack("n")
190
+ io << [1].pack("n")
191
+
192
+ # Subtable offset (relative to start of Lookup table)
193
+ lookup_header_size = 6
194
+ io << [lookup_header_size].pack("n")
195
+
196
+ # LigatureSubstFormat1: format(2)=1, coverageOffset(2),
197
+ # ligatureSetCount(2), ligatureSetOffsets[]
198
+ first_glyphs = sets.keys.sort
199
+ set_count = first_glyphs.size
200
+ subst_header_size = 2 + 2 + 2 + (set_count * 2)
201
+
202
+ # Serialize each LigatureSet to compute offsets
203
+ serialized_sets = first_glyphs.map { |gid| sets[gid] }.map { |s| build_ligature_set(s) }
204
+ coverage = build_coverage(first_glyphs)
205
+
206
+ pos = subst_header_size
207
+ set_offsets = serialized_sets.map do |s|
208
+ o = pos
209
+ pos += s.bytesize
210
+ o
211
+ end
212
+ coverage_offset = pos
213
+
214
+ io << [1].pack("n") # substFormat = 1
215
+ io << [coverage_offset].pack("n")
216
+ io << [set_count].pack("n")
217
+ set_offsets.each { |o| io << [o].pack("n") }
218
+ serialized_sets.each { |s| io << s }
219
+ io << coverage
220
+
221
+ io
222
+ end
223
+
224
+ # Build a LigatureSet for one first glyph.
225
+ # @param ligatures [Array<Hash>] {result_gid:, components:}
226
+ def self.build_ligature_set(ligatures)
227
+ io = +""
228
+ count = ligatures.size
229
+ io << [count].pack("n")
230
+
231
+ # Serialize each Ligature record
232
+ serialized = ligatures.map { |lig| build_ligature(lig) }
233
+ pos = 2 + (count * 2)
234
+ offsets = serialized.map do |s|
235
+ o = pos
236
+ pos += s.bytesize
237
+ o
238
+ end
239
+
240
+ offsets.each { |o| io << [o].pack("n") }
241
+ serialized.each { |s| io << s }
242
+ io
243
+ end
244
+
245
+ # Build a single Ligature record.
246
+ # @param lig [Hash] {result_gid:, components: [gids]}
247
+ def self.build_ligature(lig)
248
+ io = +""
249
+ io << [lig[:result_gid]].pack("n")
250
+ io << [lig[:components].size + 1].pack("n") # componentCount includes first
251
+ lig[:components].each { |gid| io << [gid].pack("n") }
252
+ io
253
+ end
254
+
255
+ # Build a Coverage table (format 1 = glyph list).
256
+ # @param glyphs [Array<Integer>] sorted glyph IDs
257
+ def self.build_coverage(glyphs)
258
+ io = +""
259
+ io << [1].pack("n") # coverageFormat = 1
260
+ io << [glyphs.size].pack("n")
261
+ glyphs.each { |gid| io << [gid].pack("n") }
262
+ io
263
+ end
264
+ end
265
+ end
266
+ end
267
+ end
@@ -34,8 +34,10 @@ module Fontisan
34
34
  autoload :OtfCompiler, "fontisan/ufo/compile/otf_compiler"
35
35
  autoload :Filters, "fontisan/ufo/compile/filters"
36
36
  autoload :FeatureWriters, "fontisan/ufo/compile/feature_writers"
37
- autoload :Fvar, "fontisan/ufo/compile/fvar"
37
+ autoload :Fvar, "fontisan/ufo/compile/fvar"
38
+ autoload :FeatureCompiler, "fontisan/ufo/compile/feature_compiler"
38
39
  autoload :Gpos, "fontisan/ufo/compile/gpos"
40
+ autoload :Gsub, "fontisan/ufo/compile/gsub"
39
41
  autoload :Gvar, "fontisan/ufo/compile/gvar"
40
42
  autoload :Head, "fontisan/ufo/compile/head"
41
43
  autoload :Hhea, "fontisan/ufo/compile/hhea"
@@ -11,8 +11,9 @@ module Fontisan
11
11
  # The class is the read/write API; serialization is handled by
12
12
  # Fontisan::Ufo::Reader and Fontisan::Ufo::Writer.
13
13
  class Font
14
- attr_accessor :path, :info, :features, :kerning, :groups, :lib, :ufo_version
15
- attr_reader :layers, :data, :images
14
+ attr_accessor :path, :info, :features, :kerning, :groups, :lib,
15
+ :ufo_version, :images
16
+ attr_reader :layers, :data
16
17
 
17
18
  def initialize
18
19
  @path = nil
@@ -3,13 +3,26 @@
3
3
  module Fontisan
4
4
  module Ufo
5
5
  # A background image anchored to a glyph (common in color fonts).
6
+ #
7
+ # In UFO 3, images live in `UFO/images/` as binary blobs (PNG in
8
+ # practice). Each image is referenced from a glyph's GLIF via
9
+ # `<image fileName="..."/>`. The image set stores the actual bytes
10
+ # and deduplicates by sha256 content hash.
6
11
  class Image
7
- attr_reader :file_name, :transformation, :color
12
+ attr_reader :file_name, :transformation, :color, :sha, :bytes
8
13
 
9
- def initialize(file_name:, transformation: nil, color: nil)
14
+ # @param file_name [String] filename within the UFO images/ directory
15
+ # @param transformation [Hash, nil] UFO image transformation matrix
16
+ # @param color [String, nil] optional color hint
17
+ # @param sha [String, nil] content sha256 hex digest
18
+ # @param bytes [String, nil] raw image bytes (set by ImageSet)
19
+ def initialize(file_name:, transformation: nil, color: nil, sha: nil,
20
+ bytes: nil)
10
21
  @file_name = file_name.to_s
11
22
  @transformation = transformation
12
23
  @color = color
24
+ @sha = sha
25
+ @bytes = bytes
13
26
  end
14
27
  end
15
28
  end
@@ -1,19 +1,99 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+
3
5
  module Fontisan
4
6
  module Ufo
5
- # Background images from `images/<layer>/...`. MVP stores nothing;
6
- # a real implementation lands with TODO 02 (glyph model + images).
7
+ # UFO 3 background image set. Lives at `UFO/images/` in a UFO
8
+ # source directory. Each image is a PNG file referenced by glyph
9
+ # GLIF `<image fileName="..."/>` elements.
10
+ #
11
+ # Per the UFO 3 spec, image files are arbitrary binary blobs (in
12
+ # practice always PNG). The image set deduplicates by content hash
13
+ # (sha256) so the same image referenced from multiple glyphs takes
14
+ # one slot.
15
+ #
16
+ # @example Read images from a UFO directory
17
+ # imageset = ImageSet.load_from_dir("MyFont.ufo/images")
18
+ # imageset.each { |img| puts img.file_name }
7
19
  class ImageSet
20
+ include Enumerable
21
+
22
+ # @return [Hash{String=>Image}] filename → Image
8
23
  attr_reader :images
9
24
 
10
25
  def initialize
11
26
  @images = {}
12
27
  end
13
28
 
29
+ # Load all PNG files from a UFO `images/` directory.
30
+ #
31
+ # @param dir [String] path to the `images` directory
32
+ # @return [ImageSet]
33
+ def self.load_from_dir(dir)
34
+ is = new
35
+ return is unless File.directory?(dir)
36
+
37
+ Dir.each_child(dir).sort.each do |name|
38
+ path = File.join(dir, name)
39
+ next unless File.file?(path)
40
+
41
+ is.register_file(name, File.binread(path))
42
+ end
43
+ is
44
+ end
45
+
46
+ # Register an image from its filename and raw bytes. Computes the
47
+ # sha256 content hash automatically.
48
+ #
49
+ # @param file_name [String]
50
+ # @param bytes [String] binary image data
51
+ # @return [Image]
52
+ def register_file(file_name, bytes)
53
+ image = Image.new(
54
+ file_name: file_name,
55
+ sha: Digest::SHA256.hexdigest(bytes),
56
+ bytes: bytes,
57
+ )
58
+ @images[file_name.to_s] = image
59
+ image
60
+ end
61
+
62
+ # Look up an image by its filename.
63
+ #
64
+ # @param file_name [String]
65
+ # @return [Image, nil]
66
+ def find(file_name)
67
+ @images[file_name.to_s]
68
+ end
69
+ alias [] find
70
+
71
+ # Iterate over images in filename order.
72
+ def each(&)
73
+ return enum_for(:each) unless block_given?
74
+
75
+ @images.each_value(&)
76
+ end
77
+
78
+ # Number of registered images.
79
+ def count
80
+ @images.size
81
+ end
82
+
14
83
  def empty?
15
84
  @images.empty?
16
85
  end
86
+
87
+ # Write all images to a target `images/` directory. Useful for
88
+ # UFO round-trip serialization.
89
+ #
90
+ # @param dir [String] target `images` directory path
91
+ def write_to_dir(dir)
92
+ FileUtils.mkpath(dir) unless File.directory?(dir)
93
+ @images.each do |name, img|
94
+ File.binwrite(File.join(dir, name), img.bytes)
95
+ end
96
+ end
17
97
  end
18
98
  end
19
99
  end
@@ -35,6 +35,7 @@ module Fontisan
35
35
  read_groups
36
36
  read_features
37
37
  read_lib
38
+ read_images
38
39
  @font
39
40
  end
40
41
 
@@ -147,6 +148,13 @@ module Fontisan
147
148
  @font.lib = Lib.new(data)
148
149
  end
149
150
 
151
+ def read_images
152
+ dir = join(@font.path, "images")
153
+ return unless File.directory?(dir)
154
+
155
+ @font.images = ImageSet.load_from_dir(dir)
156
+ end
157
+
150
158
  def join(*parts)
151
159
  File.join(*parts)
152
160
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fontisan
4
- VERSION = "0.4.33"
4
+ VERSION = "0.4.34"
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.33
4
+ version: 0.4.34
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -309,6 +309,7 @@ files:
309
309
  - lib/fontisan/collection/table_deduplicator.rb
310
310
  - lib/fontisan/collection/writer.rb
311
311
  - lib/fontisan/commands.rb
312
+ - lib/fontisan/commands/audit_command.rb
312
313
  - lib/fontisan/commands/base_command.rb
313
314
  - lib/fontisan/commands/convert_command.rb
314
315
  - lib/fontisan/commands/dump_table_command.rb
@@ -387,6 +388,7 @@ files:
387
388
  - lib/fontisan/metrics_calculator.rb
388
389
  - lib/fontisan/models.rb
389
390
  - lib/fontisan/models/all_scripts_features_info.rb
391
+ - lib/fontisan/models/audit_report.rb
390
392
  - lib/fontisan/models/bitmap_glyph.rb
391
393
  - lib/fontisan/models/bitmap_strike.rb
392
394
  - lib/fontisan/models/collection_brief_info.rb
@@ -649,6 +651,7 @@ files:
649
651
  - lib/fontisan/ufo/compile/cmap.rb
650
652
  - lib/fontisan/ufo/compile/colr.rb
651
653
  - lib/fontisan/ufo/compile/cpal.rb
654
+ - lib/fontisan/ufo/compile/feature_compiler.rb
652
655
  - lib/fontisan/ufo/compile/feature_writers.rb
653
656
  - lib/fontisan/ufo/compile/feature_writers/base.rb
654
657
  - lib/fontisan/ufo/compile/feature_writers/curs.rb
@@ -670,6 +673,7 @@ files:
670
673
  - lib/fontisan/ufo/compile/fvar.rb
671
674
  - lib/fontisan/ufo/compile/glyf_loca.rb
672
675
  - lib/fontisan/ufo/compile/gpos.rb
676
+ - lib/fontisan/ufo/compile/gsub.rb
673
677
  - lib/fontisan/ufo/compile/gvar.rb
674
678
  - lib/fontisan/ufo/compile/head.rb
675
679
  - lib/fontisan/ufo/compile/hhea.rb