fontisan 0.4.34 → 0.4.35

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: 1c278ba38670bfc65e41805d14d6bc175ae451f0e5400e4b25036bf3ae6a84f6
4
- data.tar.gz: 59ec85b42b6879f589c13429e8e490a41eb32257530207647203c010ee461182
3
+ metadata.gz: c534e6da296ea563119e164f40835bf5e794d0c7d37d4fdcd2c0e31008925410
4
+ data.tar.gz: be05402a2e893896e744432950d1f1e8951a59004f76ca9aacd2a9353f8d54cd
5
5
  SHA512:
6
- metadata.gz: 17b9aeaeeb0589379b2ff65413aabb3db15c4d0dd323bd4891e4863a9dca2fdb7190fd795ebcd62fa16d6d570e7f7bb50c740b9947c0b42a5e673380e0164cd2
7
- data.tar.gz: c9b35dd44d43fc634b1bf07aa84d2bd99527e4695636424cbf46aca757615b09d6dc27f3512e701ab8676d1111f51f980dd97d0d0fd82c47f4177592f9b94c95
6
+ metadata.gz: 2e77ac651176f1d4b42d24ac60d41d3b371ac548438bc373b9d5f1ce44b36f187050ce9e5e5b5385cfc83732e2e40d946cfb9d3ae2ac83ad9c82a20a8cc7fc5b
7
+ data.tar.gz: 903c46cd5ab5631f3ae6723bdee6f6df007c0351291e1a60856da0328ddcadbedc9a21ba68717d5db5d77031bd3b5bf69a003d08e33a8654302ae72eb122c7a4
data/.rubocop_todo.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  # This configuration was generated by
2
2
  # `rubocop --auto-gen-config`
3
- # on 2026-07-11 04:55:54 UTC using RuboCop version 1.86.1.
3
+ # on 2026-07-11 07:11:58 UTC using RuboCop version 1.86.1.
4
4
  # The point is for the user to remove these configuration records
5
5
  # one by one as the offenses are removed from the code base.
6
6
  # Note that changes in the inspected code, or installation of new
@@ -32,20 +32,13 @@ Layout/IndentationWidth:
32
32
  - 'lib/fontisan/commands/audit_command.rb'
33
33
  - 'lib/fontisan/ufo/compile/glyf_loca.rb'
34
34
 
35
- # Offense count: 1969
35
+ # Offense count: 2030
36
36
  # This cop supports safe autocorrection (--autocorrect).
37
37
  # Configuration parameters: Max, AllowHeredoc, AllowURI, AllowQualifiedName, URISchemes, AllowRBSInlineAnnotation, AllowCopDirectives, AllowedPatterns, SplitStrings.
38
38
  # URISchemes: http, https
39
39
  Layout/LineLength:
40
40
  Enabled: false
41
41
 
42
- # Offense count: 2
43
- # This cop supports safe autocorrection (--autocorrect).
44
- # Configuration parameters: AllowInHeredoc.
45
- Layout/TrailingWhitespace:
46
- Exclude:
47
- - 'lib/fontisan/commands/audit_command.rb'
48
-
49
42
  # Offense count: 2
50
43
  Lint/CopDirectiveSyntax:
51
44
  Exclude:
@@ -118,7 +111,7 @@ Lint/UselessConstantScoping:
118
111
  - 'lib/fontisan/conversion_options.rb'
119
112
  - 'lib/fontisan/type1/charstrings.rb'
120
113
 
121
- # Offense count: 752
114
+ # Offense count: 762
122
115
  # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes, Max.
123
116
  Metrics/AbcSize:
124
117
  Enabled: false
@@ -141,12 +134,12 @@ Metrics/CollectionLiteralLength:
141
134
  - 'lib/fontisan/type1/agl.rb'
142
135
  - 'lib/fontisan/type1/encodings.rb'
143
136
 
144
- # Offense count: 375
137
+ # Offense count: 379
145
138
  # Configuration parameters: AllowedMethods, AllowedPatterns, Max.
146
139
  Metrics/CyclomaticComplexity:
147
140
  Enabled: false
148
141
 
149
- # Offense count: 1019
142
+ # Offense count: 1034
150
143
  # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
151
144
  Metrics/MethodLength:
152
145
  Max: 135
@@ -157,7 +150,7 @@ Metrics/ParameterLists:
157
150
  Max: 39
158
151
  MaxOptionalParameters: 4
159
152
 
160
- # Offense count: 289
153
+ # Offense count: 291
161
154
  # Configuration parameters: AllowedMethods, AllowedPatterns, Max.
162
155
  Metrics/PerceivedComplexity:
163
156
  Enabled: false
@@ -228,7 +221,7 @@ RSpec/AnyInstance:
228
221
  RSpec/ContextWording:
229
222
  Enabled: false
230
223
 
231
- # Offense count: 40
224
+ # Offense count: 41
232
225
  # Configuration parameters: IgnoredMetadata.
233
226
  RSpec/DescribeClass:
234
227
  Enabled: false
@@ -244,7 +237,7 @@ RSpec/DescribeMethod:
244
237
  - 'spec/fontisan/type1/seac_expander_spec.rb'
245
238
  - 'spec/fontisan/type1_real_fonts_spec.rb'
246
239
 
247
- # Offense count: 1726
240
+ # Offense count: 1727
248
241
  # Configuration parameters: CountAsOne.
249
242
  RSpec/ExampleLength:
250
243
  Max: 66
@@ -322,7 +315,7 @@ RSpec/MultipleDescribes:
322
315
  - 'spec/fontisan/utils/thread_pool_spec.rb'
323
316
  - 'spec/fontisan/variation/cache_spec.rb'
324
317
 
325
- # Offense count: 2149
318
+ # Offense count: 2151
326
319
  RSpec/MultipleExpectations:
327
320
  Max: 19
328
321
 
@@ -11,7 +11,7 @@ Each file is `NN-short-name.md` where `NN` is the priority order.
11
11
  - [x] ~~[15 — CFF/CFF2 subsetter strategy](15-cff-cff2-subsetter-strategy.md)~~ ✓ Done — CFF via UFO round-trip, CFF2 via standalone INDEX filter with VStore preservation
12
12
 
13
13
  ### P1 — High-value features
14
- - [x] ~~[03 — `fontisan audit` command (identity+style+features lens)](03-fontisan-audit-command.md)~~ ✓ Done (Phase 1: identity, style, coverage, OpenType layout, provenance; CLI `fontisan audit` with YAML/JSON output)
14
+ - [x] ~~[03 — `fontisan audit` command (identity+style+features lens)](03-fontisan-audit-command.md)~~ ✓ Done (Phase 1: identity, style, coverage, layout, provenance; Phase 2 axes: table directory validation, glyph name validation, cmap validation, OTS compatibility predictor, collection integrity; CLI `fontisan audit --validate` with profiles: default, structural, ots, layout)
15
15
  - [x] ~~[04 — UFO composite glyph encoding](04-ufo-composite-glyph-encoding.md)~~ ✓ Done
16
16
  - [x] ~~[05 — OTF compiler real CFF charstrings](05-otf-compiler-real-cff.md)~~ ✓ Already working (discovered in PR #117 — Cff.build produces real Type 2 charstrings; stale TODO marker removed)
17
17
 
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Fontisan
6
+ module Audit
7
+ # Abstract base for all audit checks. A check is a stateless
8
+ # function that examines a loaded font and returns an Array of
9
+ # {Models::ValidationReport::Issue} records. An empty array means
10
+ # the font passed the check.
11
+ #
12
+ # Subclasses MUST override {.call} and {.code}.
13
+ #
14
+ # Checks are intentionally stateless + side-effect free so they
15
+ # can be composed, run in parallel, and tested in isolation.
16
+ class Check
17
+ # Run the check against +font+.
18
+ #
19
+ # @param font [SfntFont, BaseCollection]
20
+ # @return [Array<Models::ValidationReport::Issue>]
21
+ def self.call(font)
22
+ raise NotImplementedError,
23
+ "#{name} must override .call(font) -> Array<Issue>"
24
+ end
25
+
26
+ # Unique short identifier for this check (e.g. :table_checksum).
27
+ # Used as the issue +category+ for programmatic filtering.
28
+ #
29
+ # @return [Symbol]
30
+ def self.code
31
+ raise NotImplementedError,
32
+ "#{name} must override .code -> Symbol"
33
+ end
34
+
35
+ # Build an issue. Centralizes the category so callers don't repeat it.
36
+ # @return [Models::ValidationReport::Issue]
37
+ def self.issue(severity:, message:, location: nil)
38
+ Models::ValidationReport::Issue.new(
39
+ severity: severity.to_s,
40
+ category: code.to_s,
41
+ message: message,
42
+ location: location,
43
+ )
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ # Central registry of audit checks. A profile is a named subset of
6
+ # checks (e.g. +:ots+, +:structural+). The +:default+ profile runs
7
+ # every registered check.
8
+ #
9
+ # Profiles let callers opt into specific validation axes without
10
+ # running everything — e.g. +fontisan audit font.ttf --validate ots+
11
+ # runs only the OTS compatibility check.
12
+ #
13
+ # @example Run all checks
14
+ # CheckRegistry.for(:default) # => [TableDirectoryCheck, ...]
15
+ #
16
+ # @example Run a specific profile
17
+ # CheckRegistry.for(:ots) # => [OtsCompatibilityCheck]
18
+ class CheckRegistry
19
+ PROFILES = {
20
+ default: %i[table_directory glyph_names cmap ots_compatibility
21
+ collection_integrity],
22
+ structural: %i[table_directory collection_integrity],
23
+ ots: %i[ots_compatibility],
24
+ layout: %i[glyph_names cmap],
25
+ }.freeze
26
+
27
+ # @param profile [Symbol]
28
+ # @return [Array<Class>] check classes for the profile
29
+ def self.for(profile)
30
+ codes = PROFILES[profile] || PROFILES[:default]
31
+ codes.filter_map { |code| check_for(code) }
32
+ end
33
+
34
+ # @param code [Symbol]
35
+ # @return [Class, nil] the check class
36
+ def self.check_for(code)
37
+ case code
38
+ when :table_directory then Checks::TableDirectoryCheck
39
+ when :glyph_names then Checks::GlyphNameCheck
40
+ when :cmap then Checks::CmapCheck
41
+ when :ots_compatibility then Checks::OtsCompatibilityCheck
42
+ when :collection_integrity then Checks::CollectionIntegrityCheck
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Fontisan
6
+ module Audit
7
+ module Checks
8
+ # Validates cmap subtable invariants per the OpenType spec:
9
+ #
10
+ # - At least one Unicode-encoded subtable is present
11
+ # - Format 4: segCountX2 must equal segCount × 2
12
+ # - Format 4: the last segment must have endCode 0xFFFF
13
+ # - Format 4: reservedPad must be 0
14
+ # - Format 12: groups must be sorted and non-overlapping
15
+ # - Format 12: each group startCharCode ≤ endCharCode
16
+ # - Subtable offsets must be within the cmap table bounds
17
+ #
18
+ # Catches the most common real-world cmap bugs that renderers and
19
+ # text shapers reject silently.
20
+ #
21
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/cmap
22
+ class CmapCheck < Check
23
+ # @param font [SfntFont]
24
+ # @return [Array<Models::ValidationReport::Issue>]
25
+ def self.call(font)
26
+ return [] unless font.has_table?("cmap")
27
+
28
+ raw = font.table_data["cmap"]
29
+ return [] unless raw
30
+
31
+ issues = []
32
+ issues.concat(validate_header(raw))
33
+ return issues unless issues.empty?
34
+
35
+ records = read_encoding_records(raw)
36
+ issues.concat(validate_unicode_presence(records))
37
+ issues.concat(validate_subtables(raw, records))
38
+ issues
39
+ end
40
+
41
+ def self.code
42
+ :cmap
43
+ end
44
+
45
+ # ---------- header ----------
46
+
47
+ def self.validate_header(raw)
48
+ issues = []
49
+ version, num_tables = raw.unpack("nn")
50
+ if version != 0
51
+ issues << issue(severity: :error,
52
+ message: "cmap version is #{version} but must be 0",
53
+ location: "cmap.version")
54
+ end
55
+ if num_tables.zero?
56
+ issues << issue(severity: :error,
57
+ message: "cmap has no encoding records",
58
+ location: "cmap.numTables")
59
+ end
60
+ issues
61
+ end
62
+ private_class_method :validate_header
63
+
64
+ # ---------- encoding records ----------
65
+
66
+ def self.read_encoding_records(raw)
67
+ num_tables = raw.unpack1("x2n")
68
+ offset = 4
69
+ Array.new(num_tables) do |i|
70
+ break if offset + 8 > raw.bytesize
71
+
72
+ platform_id, encoding_id, subtable_offset = raw[offset, 8].unpack("nnN")
73
+ offset += 8
74
+ {
75
+ index: i,
76
+ platform_id: platform_id,
77
+ encoding_id: encoding_id,
78
+ subtable_offset: subtable_offset,
79
+ }
80
+ end
81
+ end
82
+ private_class_method :read_encoding_records
83
+
84
+ def self.validate_unicode_presence(records)
85
+ has_unicode = records.any? do |r|
86
+ unicode_platform?(r[:platform_id], r[:encoding_id])
87
+ end
88
+ return [] if has_unicode
89
+
90
+ [issue(severity: :warning,
91
+ message: "cmap has no Unicode-encoded subtable " \
92
+ "(expected platform 0 or 3 with encoding 1 or 10)",
93
+ location: "cmap.encoding_records")]
94
+ end
95
+ private_class_method :validate_unicode_presence
96
+
97
+ def self.unicode_platform?(platform_id, encoding_id)
98
+ platform_id.zero? ||
99
+ (platform_id == 3 && [1, 10].include?(encoding_id))
100
+ end
101
+ private_class_method :unicode_platform?
102
+
103
+ # ---------- per-subtable validation ----------
104
+
105
+ def self.validate_subtables(raw, records)
106
+ records.flat_map { |r| validate_one_subtable(raw, r) }
107
+ end
108
+ private_class_method :validate_subtables
109
+
110
+ def self.validate_one_subtable(raw, record)
111
+ off = record[:subtable_offset]
112
+ if off + 2 > raw.bytesize
113
+ return [issue(severity: :error,
114
+ message: "cmap subtable at record #{record[:index]} " \
115
+ "has offset #{off} beyond the table end",
116
+ location: "cmap.encoding_records.#{record[:index]}")]
117
+ end
118
+
119
+ format = raw[off, 2].unpack1("n")
120
+ case format
121
+ when 0 then validate_format0(raw, off, record)
122
+ when 4 then validate_format4(raw, off, record)
123
+ when 6 then validate_format6(raw, off, record)
124
+ when 12 then validate_format12(raw, off, record)
125
+ when 14 then [] # format 14 (Unicode Variation Sequences) — skip for now
126
+ else
127
+ [issue(severity: :warning,
128
+ message: "cmap subtable at record #{record[:index]} uses " \
129
+ "unknown format #{format}",
130
+ location: "cmap.encoding_records.#{record[:index]}")]
131
+ end
132
+ end
133
+ private_class_method :validate_one_subtable
134
+
135
+ # Format 4 validation: segCountX2 consistency, sentinel, reservedPad.
136
+ def self.validate_format4(raw, off, record)
137
+ issues = []
138
+ return issues unless off + 14 <= raw.bytesize
139
+
140
+ _format, _, _lang, seg_count_x2, _search, _sel, _shift,
141
+ = raw[off, 16].unpack("nnnnnnnn")
142
+ seg_count = seg_count_x2 / 2
143
+
144
+ if seg_count_x2.odd? || seg_count_x2 != seg_count * 2
145
+ issues << issue(severity: :error,
146
+ message: "cmap format 4 (record #{record[:index]}): " \
147
+ "segCountX2=#{seg_count_x2} is inconsistent " \
148
+ "with segCount=#{seg_count}",
149
+ location: "cmap.encoding_records.#{record[:index]}")
150
+ end
151
+
152
+ # Read the last endCode (the sentinel segment)
153
+ end_codes_start = off + 14
154
+ if seg_count.positive? && end_codes_start + (seg_count * 2) <= raw.bytesize
155
+ last_end = raw[end_codes_start + ((seg_count - 1) * 2), 2].unpack1("n")
156
+ if last_end != 0xFFFF
157
+ issues << issue(severity: :warning,
158
+ message: "cmap format 4 (record #{record[:index]}): " \
159
+ "last segment endCode is 0x#{last_end.to_s(16)} " \
160
+ "but should be 0xFFFF (sentinel)",
161
+ location: "cmap.encoding_records.#{record[:index]}")
162
+ end
163
+ end
164
+
165
+ # reservedPad check
166
+ reserved_pad_off = end_codes_start + (seg_count * 2)
167
+ if reserved_pad_off + 2 <= raw.bytesize
168
+ reserved_pad = raw[reserved_pad_off, 2].unpack1("n")
169
+ unless reserved_pad.zero?
170
+ issues << issue(severity: :error,
171
+ message: "cmap format 4 (record #{record[:index]}): " \
172
+ "reservedPad is #{reserved_pad} but must be 0",
173
+ location: "cmap.encoding_records.#{record[:index]}")
174
+ end
175
+ end
176
+
177
+ issues
178
+ end
179
+ private_class_method :validate_format4
180
+
181
+ # Format 12 validation: groups sorted, non-overlapping, ordered.
182
+ def self.validate_format12(raw, off, record)
183
+ issues = []
184
+ return issues unless off + 16 <= raw.bytesize
185
+
186
+ _format, _reserved, _length, _lang, num_groups = raw[off, 16].unpack("nnNnN")
187
+ groups_start = off + 16
188
+ return issues if num_groups.zero?
189
+ return issues unless groups_start + (num_groups * 12) <= raw.bytesize
190
+
191
+ prev_end = nil
192
+ num_groups.times do |g|
193
+ entry_off = groups_start + (g * 12)
194
+ start_cp, end_cp, _gid = raw[entry_off, 12].unpack("NNN")
195
+
196
+ if start_cp > end_cp
197
+ issues << issue(severity: :error,
198
+ message: "cmap format 12 (record #{record[:index]}): " \
199
+ "group #{g} has startCharCode 0x#{start_cp.to_s(16)} " \
200
+ "> endCharCode 0x#{end_cp.to_s(16)}",
201
+ location: "cmap.encoding_records.#{record[:index]}")
202
+ end
203
+
204
+ if prev_end && start_cp <= prev_end
205
+ issues << issue(severity: :error,
206
+ message: "cmap format 12 (record #{record[:index]}): " \
207
+ "group #{g} overlaps or is out of order " \
208
+ "(startCharCode 0x#{start_cp.to_s(16)} ≤ " \
209
+ "previous endCharCode 0x#{prev_end.to_s(16)})",
210
+ location: "cmap.encoding_records.#{record[:index]}")
211
+ end
212
+ prev_end = end_cp
213
+ end
214
+ issues
215
+ end
216
+ private_class_method :validate_format12
217
+
218
+ def self.validate_format0(raw, off, record)
219
+ # Format 0: 262 bytes (header 6 + 256-byte glyphIdArray)
220
+ if off + 262 > raw.bytesize
221
+ [issue(severity: :error,
222
+ message: "cmap format 0 (record #{record[:index]}): " \
223
+ "subtable is truncated (need 262 bytes)",
224
+ location: "cmap.encoding_records.#{record[:index]}")]
225
+ else
226
+ []
227
+ end
228
+ end
229
+ private_class_method :validate_format0
230
+
231
+ def self.validate_format6(raw, off, record)
232
+ return [] unless off + 10 <= raw.bytesize
233
+
234
+ _fmt, _len, _lang, first, count = raw[off, 10].unpack("nnnnn")
235
+ needed = off + 10 + (count * 2)
236
+ if needed > raw.bytesize
237
+ [issue(severity: :error,
238
+ message: "cmap format 6 (record #{record[:index]}): " \
239
+ "subtable truncated (first=#{first}, count=#{count})",
240
+ location: "cmap.encoding_records.#{record[:index]}")]
241
+ else
242
+ []
243
+ end
244
+ end
245
+ private_class_method :validate_format6
246
+ end
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Checks
6
+ # Validates TrueType/OpenType Collection (TTC/OTC) structural
7
+ # integrity: TTC header, per-face SFNT offsets, shared-table
8
+ # deduplication. TTC is undertooled — CJK and Apple workflows
9
+ # ship fonts as collections and no widely-available validator
10
+ # covers the collection-specific invariants.
11
+ #
12
+ # Checks:
13
+ #
14
+ # - TTC tag must be 'ttcf'
15
+ # - header.majorVersion must be 1 or 2
16
+ # - numFonts must be positive and match the offset array length
17
+ # - every face offset must be > 0 and within the file
18
+ # - face offsets must not overlap the TTC header
19
+ # - (v2 only) DSIG tag/length must be null or point inside the file
20
+ #
21
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/otff#ttc-header
22
+ class CollectionIntegrityCheck < Check
23
+ TTC_TAG = "ttcf"
24
+ VALID_MAJOR_VERSIONS = [1, 2].freeze
25
+
26
+ # @param font [BaseCollection, SfntFont] a collection or single font
27
+ # @return [Array<Models::ValidationReport::Issue>]
28
+ def self.call(font)
29
+ return [] unless collection?(font)
30
+
31
+ issues = []
32
+ issues.concat(validate_header(font))
33
+ issues.concat(validate_face_offsets(font))
34
+ issues
35
+ end
36
+
37
+ def self.code
38
+ :collection_integrity
39
+ end
40
+
41
+ def self.collection?(font)
42
+ font.is_a?(BaseCollection)
43
+ end
44
+ private_class_method :collection?
45
+
46
+ def self.validate_header(collection)
47
+ issues = []
48
+ tag = collection.tag.to_s
49
+
50
+ if tag != TTC_TAG
51
+ issues << issue(severity: :error,
52
+ message: "Collection tag is '#{tag}' but must be '#{TTC_TAG}'",
53
+ location: "ttc_header.tag")
54
+ end
55
+
56
+ version = collection.major_version.to_i
57
+ unless VALID_MAJOR_VERSIONS.include?(version)
58
+ issues << issue(severity: :error,
59
+ message: "Collection major version is #{version} " \
60
+ "but must be one of #{VALID_MAJOR_VERSIONS.join(', ')}",
61
+ location: "ttc_header.majorVersion")
62
+ end
63
+
64
+ num_fonts = collection.num_fonts.to_i
65
+ if num_fonts <= 0
66
+ issues << issue(severity: :error,
67
+ message: "Collection numFonts is #{num_fonts} " \
68
+ "but must be positive",
69
+ location: "ttc_header.numFonts")
70
+ end
71
+
72
+ if collection.font_offsets.length != num_fonts
73
+ issues << issue(severity: :error,
74
+ message: "Collection has #{num_fonts} fonts but " \
75
+ "#{collection.font_offsets.length} offsets " \
76
+ "(should match)",
77
+ location: "ttc_header.font_offsets")
78
+ end
79
+
80
+ issues
81
+ end
82
+ private_class_method :validate_header
83
+
84
+ def self.validate_face_offsets(collection)
85
+ header_end = 12 + (collection.num_fonts.to_i * 4) +
86
+ (collection.major_version.to_i == 2 ? 8 : 0)
87
+ collection.font_offsets.each_with_index.with_object([]) do |(off, idx), issues|
88
+ off_val = off.to_i
89
+ if off_val < header_end
90
+ issues << issue(severity: :error,
91
+ message: "Face #{idx} SFNT offset #{off_val} " \
92
+ "overlaps the TTC header " \
93
+ "(header ends at #{header_end})",
94
+ location: "ttc_header.font_offsets.#{idx}")
95
+ end
96
+ end
97
+ end
98
+ private_class_method :validate_face_offsets
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Checks
6
+ # Validates glyph names against the OpenType spec rules:
7
+ #
8
+ # - Length ≤ 63 characters
9
+ # - Allowed characters: printable ASCII excluding special chars
10
+ # (per OT spec: A–Z a–z 0–9, period, hyphen, underscore)
11
+ # - Must not start with a digit
12
+ # - Must not be a reserved name (.notdef is the only exception at GID 0)
13
+ # - Duplicate names across the font (warning)
14
+ # - Empty names (warning — some tools tolerate them but spec discourages)
15
+ #
16
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/post
17
+ class GlyphNameCheck < Check
18
+ MAX_NAME_LENGTH = 63
19
+ # OT spec post table glyph name charset: A–Z a–z 0–9 . - _
20
+ # (leading dot is allowed for .notdef, .null, .nonmarkingreturn, etc.)
21
+ ALLOWED_NAME_PATTERN = /\A[A-Za-z0-9._-]+\z/
22
+
23
+ # @param font [SfntFont]
24
+ # @return [Array<Models::ValidationReport::Issue>]
25
+ def self.call(font)
26
+ names = extract_glyph_names(font)
27
+ return [] if names.empty?
28
+
29
+ issues = []
30
+ names.each_with_index do |name, gid|
31
+ issues.concat(check_name(name, gid))
32
+ end
33
+ issues.concat(check_duplicates(names))
34
+ issues
35
+ end
36
+
37
+ def self.code
38
+ :glyph_names
39
+ end
40
+
41
+ def self.extract_glyph_names(font)
42
+ return [] unless font.has_table?("post")
43
+
44
+ post = font.table("post")
45
+ return [] unless post
46
+
47
+ post.glyph_names || []
48
+ end
49
+ private_class_method :extract_glyph_names
50
+
51
+ def self.check_name(name, gid)
52
+ issues = []
53
+ if name.nil? || name.empty?
54
+ issues << issue(severity: :warning,
55
+ message: "Glyph at GID #{gid} has an empty name",
56
+ location: "glyph.#{gid}.name")
57
+ return issues
58
+ end
59
+
60
+ if name.length > MAX_NAME_LENGTH
61
+ issues << issue(severity: :error,
62
+ message: "Glyph name '#{name[0, 20]}…' (GID #{gid}) " \
63
+ "exceeds #{MAX_NAME_LENGTH} characters " \
64
+ "(#{name.length})",
65
+ location: "glyph.#{gid}.name")
66
+ end
67
+
68
+ unless name.match?(ALLOWED_NAME_PATTERN)
69
+ issues << issue(severity: :warning,
70
+ message: "Glyph name '#{name}' (GID #{gid}) contains " \
71
+ "characters outside the OT spec grammar " \
72
+ "(A–Z a–z 0–9 . - _)",
73
+ location: "glyph.#{gid}.name")
74
+ end
75
+
76
+ if name.match?(/\A\d/)
77
+ issues << issue(severity: :warning,
78
+ message: "Glyph name '#{name}' (GID #{gid}) starts " \
79
+ "with a digit — not portable",
80
+ location: "glyph.#{gid}.name")
81
+ end
82
+ issues
83
+ end
84
+ private_class_method :check_name
85
+
86
+ def self.check_duplicates(names)
87
+ seen = {}
88
+ names.each_with_index do |name, gid|
89
+ next unless name && !name.empty?
90
+
91
+ (seen[name] ||= []) << gid
92
+ end
93
+ seen.each_with_object([]) do |(name, gids), issues|
94
+ next unless gids.size > 1
95
+
96
+ issues << issue(severity: :warning,
97
+ message: "Glyph name '#{name}' is shared by " \
98
+ "#{gids.size} glyphs (GIDs: #{gids.first(5).join(', ')}" \
99
+ "#{'…' if gids.size > 5})",
100
+ location: "glyph_names.#{name}")
101
+ end
102
+ end
103
+ private_class_method :check_duplicates
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Checks
6
+ # Predicts whether Chrome/Android OTS (OpenType Sanitizer) would
7
+ # reject the font. OTS has strict requirements beyond the base
8
+ # OpenType spec — fonts that pass the spec can still be rejected
9
+ # by browsers.
10
+ #
11
+ # Ports the most commonly-hit ots-sanitize rules:
12
+ #
13
+ # - head magicNumber must be 0x5F0F3CF5
14
+ # - head unitsPerEm must be 16–16384
15
+ # - name table must have PostScript + Family names
16
+ # - post table version must be 1.0, 2.0, 2.5, 3.0, or 4.0
17
+ # - numGlyphs must be ≤ 65534 (uint16 minus sentinel)
18
+ # - glyf/CFF (depending on sfnt flavor) must be present
19
+ # - cmap must have at least one Unicode subtable
20
+ # - OS/2 usWeightClass must be 1–1000
21
+ # - OS/2 usWidthClass must be 1–9
22
+ # - Total file size must be under the OTS limit (~30MB)
23
+ #
24
+ # @see https://github.com/khaledhosny/ots/tree/master
25
+ class OtsCompatibilityCheck < Check
26
+ MAX_GLYPHS = 0xFFFD # 65534 — OTS rejects fonts with more
27
+ MAX_FILE_SIZE = 30 * 1024 * 1024 # 30MB — OTS soft limit
28
+ VALID_POST_VERSIONS = [1.0, 2.0, 2.5, 3.0, 4.0].freeze
29
+ HEAD_MAGIC = 0x5F0F3CF5
30
+ MIN_UPM = 16
31
+ MAX_UPM = 16_384
32
+
33
+ # @param font [SfntFont]
34
+ # @return [Array<Models::ValidationReport::Issue>]
35
+ def self.call(font)
36
+ issues = []
37
+ issues.concat(check_head(font))
38
+ issues.concat(check_post(font))
39
+ issues.concat(check_maxp(font))
40
+ issues.concat(check_name(font))
41
+ issues.concat(check_cmap(font))
42
+ issues.concat(check_os2(font))
43
+ issues.concat(check_outlines(font))
44
+ issues
45
+ end
46
+
47
+ def self.code
48
+ :ots_compatibility
49
+ end
50
+
51
+ # ---------- head ----------
52
+
53
+ def self.check_head(font)
54
+ issues = []
55
+ return [missing_table_issue("head", :error)] unless font.has_table?("head")
56
+
57
+ head = font.table("head")
58
+ if head.magic_number != HEAD_MAGIC
59
+ issues << issue(severity: :error,
60
+ message: "OTS: head.magicNumber is 0x#{head.magic_number.to_i.to_s(16)} " \
61
+ "but must be 0x#{HEAD_MAGIC.to_s(16)}",
62
+ location: "head.magic_number")
63
+ end
64
+ upm = head.units_per_em.to_i
65
+ if upm < MIN_UPM || upm > MAX_UPM
66
+ issues << issue(severity: :error,
67
+ message: "OTS: head.unitsPerEm is #{upm} but must " \
68
+ "be between #{MIN_UPM} and #{MAX_UPM}",
69
+ location: "head.units_per_em")
70
+ end
71
+ issues
72
+ end
73
+ private_class_method :check_head
74
+
75
+ # ---------- post ----------
76
+
77
+ def self.check_post(font)
78
+ return [missing_table_issue("post", :error)] unless font.has_table?("post")
79
+
80
+ post = font.table("post")
81
+ version = post.version.to_f
82
+ return [] if VALID_POST_VERSIONS.include?(version)
83
+
84
+ [issue(severity: :error,
85
+ message: "OTS: post.version is #{version} but must be one of " \
86
+ "#{VALID_POST_VERSIONS.join(', ')}",
87
+ location: "post.version")]
88
+ end
89
+ private_class_method :check_post
90
+
91
+ # ---------- maxp ----------
92
+
93
+ def self.check_maxp(font)
94
+ return [missing_table_issue("maxp", :error)] unless font.has_table?("maxp")
95
+
96
+ num_glyphs = font.table("maxp").num_glyphs.to_i
97
+ return [] if num_glyphs <= MAX_GLYPHS
98
+
99
+ [issue(severity: :error,
100
+ message: "OTS: maxp.numGlyphs is #{num_glyphs} but OTS " \
101
+ "rejects fonts with more than #{MAX_GLYPHS} glyphs",
102
+ location: "maxp.num_glyphs")]
103
+ end
104
+ private_class_method :check_maxp
105
+
106
+ # ---------- name ----------
107
+
108
+ def self.check_name(font)
109
+ return [missing_table_issue("name", :error)] unless font.has_table?("name")
110
+
111
+ name = font.table("name")
112
+ issues = []
113
+ if name.english_name(Tables::Name::FAMILY).to_s.empty?
114
+ issues << issue(severity: :error,
115
+ message: "OTS: name table has no English Family name (nameID 1)",
116
+ location: "name.nameID.1")
117
+ end
118
+ if name.english_name(Tables::Name::POSTSCRIPT_NAME).to_s.empty?
119
+ issues << issue(severity: :error,
120
+ message: "OTS: name table has no English PostScript name " \
121
+ "(nameID 6)",
122
+ location: "name.nameID.6")
123
+ end
124
+ issues
125
+ end
126
+ private_class_method :check_name
127
+
128
+ # ---------- cmap ----------
129
+
130
+ def self.check_cmap(font)
131
+ return [missing_table_issue("cmap", :error)] unless font.has_table?("cmap")
132
+
133
+ cmap = font.table("cmap")
134
+ mappings = cmap.unicode_mappings || {}
135
+ return [] if mappings.any?
136
+
137
+ [issue(severity: :error,
138
+ message: "OTS: cmap has no Unicode subtable or no codepoint mappings",
139
+ location: "cmap.unicode_mappings")]
140
+ end
141
+ private_class_method :check_cmap
142
+
143
+ # ---------- OS/2 ----------
144
+
145
+ def self.check_os2(font)
146
+ return [missing_table_issue("OS/2", :error)] unless font.has_table?("OS/2")
147
+
148
+ os2 = font.table("OS/2")
149
+ issues = []
150
+ weight = os2.us_weight_class.to_i
151
+ unless weight.between?(1, 1000)
152
+ issues << issue(severity: :error,
153
+ message: "OTS: OS/2.usWeightClass is #{weight} " \
154
+ "but must be between 1 and 1000",
155
+ location: "os2.us_weight_class")
156
+ end
157
+ width = os2.us_width_class.to_i
158
+ unless width.between?(1, 9)
159
+ issues << issue(severity: :error,
160
+ message: "OTS: OS/2.usWidthClass is #{width} " \
161
+ "but must be between 1 and 9",
162
+ location: "os2.us_width_class")
163
+ end
164
+ issues
165
+ end
166
+ private_class_method :check_os2
167
+
168
+ # ---------- outline presence ----------
169
+
170
+ def self.check_outlines(font)
171
+ has_glyf = font.has_table?("glyf")
172
+ has_cff = font.has_table?("CFF ") || font.has_table?("CFF2")
173
+ return [] if has_glyf || has_cff
174
+
175
+ [issue(severity: :error,
176
+ message: "OTS: font has neither 'glyf' nor 'CFF '/'CFF2' — " \
177
+ "no outline data",
178
+ location: "outline_tables")]
179
+ end
180
+ private_class_method :check_outlines
181
+
182
+ def self.missing_table_issue(tag, severity)
183
+ issue(severity: severity,
184
+ message: "OTS: required table '#{tag}' is missing",
185
+ location: "tables.#{tag}")
186
+ end
187
+ private_class_method :missing_table_issue
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Checks
6
+ # Validates the SFNT table directory: checksums, 4-byte alignment,
7
+ # offset arithmetic, search-range/entry-selector/range-shift
8
+ # consistency.
9
+ #
10
+ # These checks are the foundation of all font validation — a
11
+ # corrupt table directory makes every downstream parse
12
+ # unreliable. Replaces the table-directory portion of MS Font
13
+ # Validator + ots-sanitize.
14
+ #
15
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/otff#table-directory
16
+ class TableDirectoryCheck < Check
17
+ # @param font [SfntFont]
18
+ # @return [Array<Models::ValidationReport::Issue>]
19
+ def self.call(font)
20
+ issues = []
21
+ issues.concat(validate_search_fields(font))
22
+ issues.concat(validate_alignment(font))
23
+ issues.concat(validate_checksums(font))
24
+ issues.concat(validate_offsets(font))
25
+ issues
26
+ end
27
+
28
+ def self.code
29
+ :table_directory
30
+ end
31
+
32
+ # ---------- search range / entry selector / range shift ----------
33
+
34
+ def self.validate_search_fields(font)
35
+ num_tables = font.header.num_tables
36
+ expected_range = largest_power_of_two_le(num_tables) * 16
37
+ expected_selector = Math.log2(expected_range / 16).to_i
38
+ expected_shift = (num_tables * 16) - expected_range
39
+
40
+ issues = []
41
+ if font.header.search_range != expected_range
42
+ issues << issue(severity: :warning,
43
+ message: "searchRange is #{font.header.search_range} " \
44
+ "but should be #{expected_range} " \
45
+ "(numTables=#{num_tables})",
46
+ location: "header.searchRange")
47
+ end
48
+ if font.header.entry_selector != expected_selector
49
+ issues << issue(severity: :warning,
50
+ message: "entrySelector is #{font.header.entry_selector} " \
51
+ "but should be #{expected_selector}",
52
+ location: "header.entrySelector")
53
+ end
54
+ if font.header.range_shift != expected_shift
55
+ issues << issue(severity: :warning,
56
+ message: "rangeShift is #{font.header.range_shift} " \
57
+ "but should be #{expected_shift}",
58
+ location: "header.rangeShift")
59
+ end
60
+ issues
61
+ end
62
+
63
+ def self.largest_power_of_two_le(n)
64
+ return 0 if n <= 0
65
+
66
+ power = 1
67
+ power *= 2 while power * 2 <= n
68
+ power
69
+ end
70
+ private_class_method :largest_power_of_two_le
71
+
72
+ # ---------- 4-byte alignment ----------
73
+
74
+ def self.validate_alignment(font)
75
+ font.tables.each_with_object([]) do |entry, issues|
76
+ next if (entry.offset % 4).zero?
77
+
78
+ issues << issue(severity: :warning,
79
+ message: "Table '#{readable_tag(entry.tag)}' at offset " \
80
+ "#{entry.offset} is not 4-byte aligned",
81
+ location: "tables.#{readable_tag(entry.tag)}.offset")
82
+ end
83
+ end
84
+
85
+ # ---------- checksums ----------
86
+
87
+ # The 'head' table checksum has a special rule: its checkSumAdjustment
88
+ # field (offset 8) is set to make the whole-table checksum match the
89
+ # directory entry. We skip the head table's own checksum verification
90
+ # and instead verify the adjustment is present.
91
+ def self.validate_checksums(font)
92
+ font.tables.each_with_object([]) do |entry, issues|
93
+ tag = readable_tag(entry.tag)
94
+ raw = font.table_data[tag]
95
+ next unless raw
96
+
97
+ next if tag == "head" # checkSumAdjustment complicates this
98
+
99
+ actual = checksum(raw)
100
+ next if actual == entry.checksum
101
+
102
+ issues << issue(severity: :error,
103
+ message: "Table '#{tag}' checksum mismatch: " \
104
+ "directory=0x#{entry.checksum.to_s(16).upcase} " \
105
+ "computed=0x#{actual.to_s(16).upcase}",
106
+ location: "tables.#{tag}.checksum")
107
+ end
108
+ end
109
+
110
+ # OpenType table checksum: sum of uint32 words, padded to 4 bytes
111
+ # with zeros if the table length isn't a multiple of 4.
112
+ def self.checksum(data)
113
+ remainder = data.bytesize % 4
114
+ padded_bytes = remainder.zero? ? data : data + "\x00".b * (4 - remainder)
115
+ padded_bytes.unpack("N*").sum & 0xFFFFFFFF
116
+ rescue StandardError
117
+ 0
118
+ end
119
+ private_class_method :checksum
120
+
121
+ # ---------- offset arithmetic ----------
122
+
123
+ def self.validate_offsets(font)
124
+ dir_end = 12 + (font.header.num_tables * 16)
125
+ font.tables.each_with_object([]) do |entry, issues|
126
+ tag = readable_tag(entry.tag)
127
+
128
+ if entry.offset < dir_end
129
+ issues << issue(severity: :error,
130
+ message: "Table '#{tag}' offset #{entry.offset} " \
131
+ "overlaps the table directory " \
132
+ "(directory ends at #{dir_end})",
133
+ location: "tables.#{tag}.offset")
134
+ end
135
+
136
+ raw = font.table_data[tag]
137
+ if raw && raw.bytesize != entry.table_length
138
+ issues << issue(severity: :error,
139
+ message: "Table '#{tag}' length mismatch: " \
140
+ "directory says #{entry.table_length} " \
141
+ "but actual data is #{raw.bytesize} bytes",
142
+ location: "tables.#{tag}.table_length")
143
+ end
144
+ end
145
+ end
146
+
147
+ # BinData returns ASCII-8BIT tag strings; normalize for display.
148
+ def self.readable_tag(tag)
149
+ tag.dup.force_encoding("UTF-8")
150
+ rescue StandardError
151
+ tag.to_s
152
+ end
153
+ private_class_method :readable_tag
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Fontisan
6
+ module Audit
7
+ # Individual audit check implementations. Each is a standalone class
8
+ # under {Audit::Check} that implements `.call(font)` and `.code`.
9
+ #
10
+ # Adding a new check = one file + one autoload entry here. No edits
11
+ # to {CheckRegistry} or {AuditCommand} required (Open/Closed).
12
+ module Checks
13
+ autoload :TableDirectoryCheck, "fontisan/audit/checks/table_directory_check"
14
+ autoload :GlyphNameCheck, "fontisan/audit/checks/glyph_name_check"
15
+ autoload :CmapCheck, "fontisan/audit/checks/cmap_check"
16
+ autoload :OtsCompatibilityCheck,
17
+ "fontisan/audit/checks/ots_compatibility_check"
18
+ autoload :CollectionIntegrityCheck,
19
+ "fontisan/audit/checks/collection_integrity_check"
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ # Audit-layer validation axes. Each check is a standalone module that
5
+ # produces structured issues when run against a loaded font. Checks
6
+ # are MECE by design: one concern per check (checksums, glyph names,
7
+ # cmap subtables, OTS compatibility, collection integrity, etc.).
8
+ #
9
+ # Checks are independent of the existing `Validators::*` framework
10
+ # (which uses a DSL and produces boolean pass/fail per check). The
11
+ # audit checks produce detailed `Models::ValidationReport::Issue`
12
+ # records so the consumer knows exactly what failed and where.
13
+ #
14
+ # @see Models::ValidationReport::Issue
15
+ module Audit
16
+ autoload :Check, "fontisan/audit/check"
17
+ autoload :CheckRegistry, "fontisan/audit/check_registry"
18
+ autoload :Checks, "fontisan/audit/checks"
19
+ end
20
+ end
data/lib/fontisan/cli.rb CHANGED
@@ -38,10 +38,14 @@ module Fontisan
38
38
  desc: "Omit the codepoint list from the report"
39
39
  option :font_index, type: :numeric, default: nil,
40
40
  desc: "Audit only the given face of a collection"
41
+ option :validate, type: :string, default: nil,
42
+ desc: "Run validation checks. Use 'true' for all, or a " \
43
+ "profile name: default, structural, ots, layout"
41
44
  # Produce a structured font audit report (YAML or JSON).
42
45
  def audit(font_file)
43
46
  cmd_options = options.dup
44
47
  cmd_options[:include_codepoints] = !options[:no_codepoints]
48
+ cmd_options[:validate] = parse_validate_option(options[:validate])
45
49
  command = Commands::AuditCommand.new(font_file, cmd_options)
46
50
  result = command.run
47
51
  write_audit_result(result, options[:output])
@@ -797,6 +801,15 @@ module Fontisan
797
801
  format("%<idx>02d-%<name>s.%<suffix>s", idx: idx, name: safe_name, suffix: suffix)
798
802
  end
799
803
 
804
+ # The --validate option is either nil (no validation), "true" (run all
805
+ # checks), or a profile name (e.g. "ots", "structural").
806
+ def parse_validate_option(raw)
807
+ return nil if raw.nil? || raw.empty?
808
+ return true if raw == "true"
809
+
810
+ raw.to_sym
811
+ end
812
+
800
813
  def serialize_report(report, format)
801
814
  format == :json ? report.to_json : report.to_yaml
802
815
  end
@@ -75,6 +75,7 @@ module Fontisan
75
75
  populate_style(r, face)
76
76
  populate_coverage(r, face)
77
77
  populate_layout(r, face)
78
+ populate_validation(r, face) if validate?
78
79
  end
79
80
  end
80
81
 
@@ -246,11 +247,44 @@ module Fontisan
246
247
  end
247
248
 
248
249
  def features_for_script(layout_table, script)
249
-
250
250
  layout_table.features(script_tag: script)
251
251
  rescue StandardError
252
252
  []
253
-
253
+ end
254
+
255
+ # ------------------------------------------------------------------
256
+ # Validation (audit checks)
257
+ # ------------------------------------------------------------------
258
+
259
+ def populate_validation(report, face)
260
+ report.validation_issues = run_validation_checks(face)
261
+ end
262
+
263
+ def run_validation_checks(face)
264
+ checks = Audit::CheckRegistry.for(validation_profile)
265
+ checks.flat_map { |check| safe_run_check(check, face) }
266
+ end
267
+
268
+ def safe_run_check(check, face)
269
+ check.call(face)
270
+ rescue StandardError => e
271
+ [Models::ValidationReport::Issue.new(
272
+ severity: "fatal",
273
+ category: check.code.to_s,
274
+ message: "Check #{check.code} failed to execute: #{e.class}: #{e.message}",
275
+ location: nil,
276
+ )]
277
+ end
278
+
279
+ def validate?
280
+ @options.fetch(:validate, false)
281
+ end
282
+
283
+ def validation_profile
284
+ profile = @options[:validate]
285
+ return :default if profile == true
286
+
287
+ profile&.to_sym || :default
254
288
  end
255
289
  end
256
290
  end
@@ -73,6 +73,9 @@ module Fontisan
73
73
  attribute :opentype_scripts, :string, collection: true
74
74
  attribute :features, :string, collection: true
75
75
 
76
+ # Validation (populated only with --validate)
77
+ attribute :validation_issues, Models::ValidationReport::Issue, collection: true
78
+
76
79
  json do
77
80
  map "generated_at", to: :generated_at
78
81
  map "fontisan_version", to: :fontisan_version
@@ -104,6 +107,7 @@ module Fontisan
104
107
 
105
108
  map "opentype_scripts", to: :opentype_scripts
106
109
  map "features", to: :features
110
+ map "validation_issues", to: :validation_issues
107
111
  end
108
112
 
109
113
  yaml do
@@ -137,6 +141,7 @@ module Fontisan
137
141
 
138
142
  map "opentype_scripts", to: :opentype_scripts
139
143
  map "features", to: :features
144
+ map "validation_issues", to: :validation_issues
140
145
  end
141
146
  end
142
147
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fontisan
4
- VERSION = "0.4.34"
4
+ VERSION = "0.4.35"
5
5
  end
data/lib/fontisan.rb CHANGED
@@ -77,6 +77,7 @@ module Fontisan
77
77
 
78
78
  # Namespace hubs (each hub declares its own child autoloads)
79
79
  autoload :Binary, "fontisan/binary"
80
+ autoload :Audit, "fontisan/audit"
80
81
  autoload :Collection, "fontisan/collection"
81
82
  autoload :Commands, "fontisan/commands"
82
83
  autoload :Converters, "fontisan/converters"
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.34
4
+ version: 0.4.35
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -293,6 +293,15 @@ files:
293
293
  - exe/fontisan
294
294
  - fontisan.gemspec
295
295
  - lib/fontisan.rb
296
+ - lib/fontisan/audit.rb
297
+ - lib/fontisan/audit/check.rb
298
+ - lib/fontisan/audit/check_registry.rb
299
+ - lib/fontisan/audit/checks.rb
300
+ - lib/fontisan/audit/checks/cmap_check.rb
301
+ - lib/fontisan/audit/checks/collection_integrity_check.rb
302
+ - lib/fontisan/audit/checks/glyph_name_check.rb
303
+ - lib/fontisan/audit/checks/ots_compatibility_check.rb
304
+ - lib/fontisan/audit/checks/table_directory_check.rb
296
305
  - lib/fontisan/base_collection.rb
297
306
  - lib/fontisan/binary.rb
298
307
  - lib/fontisan/binary/base_record.rb