fontisan 0.4.33 → 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.
@@ -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