fontisan 0.4.34 → 0.4.36

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,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,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Checks
6
+ # Validates variable-font readiness: fvar axis invariants, named
7
+ # instances within ranges, companion-table presence (gvar for
8
+ # TrueType outlines, HVAR for horizontal metrics, STAT for OS
9
+ # style matching). Ports the most commonly-hit fontbakery GF-VF
10
+ # check subset.
11
+ #
12
+ # Only runs when the font HAS an fvar table — static fonts are
13
+ # skipped (returns empty issues array).
14
+ #
15
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/fvar
16
+ class VariableFontCheck < Check
17
+ # @param font [SfntFont]
18
+ # @return [Array<Models::ValidationReport::Issue>]
19
+ def self.call(font)
20
+ return [] unless font.has_table?("fvar")
21
+
22
+ issues = []
23
+ issues.concat(validate_axes(font))
24
+ issues.concat(validate_instances(font))
25
+ issues.concat(validate_companion_tables(font))
26
+ issues
27
+ end
28
+
29
+ def self.code
30
+ :variable_font
31
+ end
32
+
33
+ # ---------- fvar axes ----------
34
+
35
+ def self.validate_axes(font)
36
+ fvar = font.table("fvar")
37
+ axes = fvar.axes
38
+ issues = []
39
+
40
+ issues.concat(check_axis_uniqueness(axes))
41
+ axes.each_with_index { |axis, idx| issues.concat(check_one_axis(axis, idx)) }
42
+ issues
43
+ end
44
+ private_class_method :validate_axes
45
+
46
+ def self.check_axis_uniqueness(axes)
47
+ tags = axes.map(&:axis_tag)
48
+ duplicates = tags.tally.filter_map { |t, c| t if c > 1 }
49
+ duplicates.map do |tag|
50
+ issue(severity: :error,
51
+ message: "Duplicate fvar axis tag '#{tag}' — axis tags " \
52
+ "must be unique within a font",
53
+ location: "fvar.axes.#{tag}")
54
+ end
55
+ end
56
+ private_class_method :check_axis_uniqueness
57
+
58
+ def self.check_one_axis(axis, idx)
59
+ issues = []
60
+ min_v = axis.min_value.to_f
61
+ default_v = axis.default_value.to_f
62
+ max_v = axis.max_value.to_f
63
+
64
+ unless min_v <= default_v && default_v <= max_v
65
+ issues << issue(severity: :error,
66
+ message: "fvar axis '#{axis.axis_tag}' (#{idx}) has " \
67
+ "invalid range: min=#{min_v} " \
68
+ "default=#{default_v} max=#{max_v} " \
69
+ "(must be min ≤ default ≤ max)",
70
+ location: "fvar.axes.#{idx}")
71
+ end
72
+
73
+ if axis.axis_name_id.to_i.zero?
74
+ issues << issue(severity: :warning,
75
+ message: "fvar axis '#{axis.axis_tag}' (#{idx}) has " \
76
+ "no name ID — users cannot identify the axis",
77
+ location: "fvar.axes.#{idx}.axis_name_id")
78
+ end
79
+
80
+ issues
81
+ end
82
+ private_class_method :check_one_axis
83
+
84
+ # ---------- fvar named instances ----------
85
+
86
+ def self.validate_instances(font)
87
+ fvar = font.table("fvar")
88
+ instances = fvar.instances || []
89
+ axes = fvar.axes
90
+
91
+ instances.each_with_index.with_object([]) do |(inst, idx), issues|
92
+ issues.concat(check_instance_ranges(inst, idx, axes))
93
+ end
94
+ end
95
+ private_class_method :validate_instances
96
+
97
+ def self.check_instance_ranges(inst, idx, axes)
98
+ issues = []
99
+ coords = inst[:coordinates] || inst["coordinates"] || []
100
+ coords.each_with_index do |coord, axis_idx|
101
+ next unless axes[axis_idx]
102
+
103
+ axis = axes[axis_idx]
104
+ min_v = axis.min_value.to_f
105
+ max_v = axis.max_value.to_f
106
+ next if coord.to_f.between?(min_v, max_v)
107
+
108
+ issues << issue(severity: :warning,
109
+ message: "fvar instance #{idx} has coordinate " \
110
+ "#{coord} for axis '#{axis.axis_tag}' " \
111
+ "outside its range [#{min_v}, #{max_v}]",
112
+ location: "fvar.instances.#{idx}.#{axis.axis_tag}")
113
+ end
114
+ issues
115
+ end
116
+ private_class_method :check_instance_ranges
117
+
118
+ # ---------- companion tables ----------
119
+
120
+ def self.validate_companion_tables(font)
121
+ issues = []
122
+ is_truetype = font.has_table?("glyf")
123
+ if is_truetype && !font.has_table?("gvar")
124
+ issues << issue(severity: :warning,
125
+ message: "Variable TrueType font has no 'gvar' table — " \
126
+ "glyph outlines won't vary across the design space",
127
+ location: "tables.gvar")
128
+ end
129
+
130
+ unless font.has_table?("HVAR")
131
+ issues << issue(severity: :warning,
132
+ message: "Variable font has no 'HVAR' table — " \
133
+ "advance widths won't vary; renderers must " \
134
+ "fall back to per-glyph hmtx lookups",
135
+ location: "tables.HVAR")
136
+ end
137
+
138
+ unless font.has_table?("STAT")
139
+ issues << issue(severity: :info,
140
+ message: "Variable font has no 'STAT' table — " \
141
+ "OS style-matching won't work; Windows and " \
142
+ "modern font pickers rely on STAT",
143
+ location: "tables.STAT")
144
+ end
145
+ issues
146
+ end
147
+ private_class_method :validate_companion_tables
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Checks
6
+ # Validates WOFF2 (Web Open Font Format 2) wrapper structure per
7
+ # the W3C WOFF2 spec. Only runs when the font IS a Woff2Font —
8
+ # plain TTF/OTF inputs are skipped.
9
+ #
10
+ # Checks:
11
+ #
12
+ # - Signature must be 0x774F4632 ('wOF2')
13
+ # - Flavor must be a valid SFNT version
14
+ # - reserved field must be 0
15
+ # - major_version should be 1
16
+ # - total_compressed_size must be positive
17
+ # - If metadata block is present, lengths must be consistent
18
+ # - num_tables must match the table directory count
19
+ #
20
+ # @see https://www.w3.org/TR/WOFF2/
21
+ class Woff2ValidationCheck < Check
22
+ WOFF2_SIGNATURE = 0x774F4632
23
+ VALID_FLAVORS = [
24
+ 0x00010000, # TrueType
25
+ 0x74727565, # 'true' (Apple TrueType)
26
+ 0x4F54544F, # 'OTTO' (OpenType CFF)
27
+ 0x74746366, # 'ttcf' (collection)
28
+ ].freeze
29
+
30
+ # @param font [Woff2Font, SfntFont]
31
+ # @return [Array<Models::ValidationReport::Issue>]
32
+ def self.call(font)
33
+ return [] unless woff2?(font)
34
+
35
+ header = font.header
36
+ issues = []
37
+ issues.concat(validate_signature(header))
38
+ issues.concat(validate_flavor(header))
39
+ issues.concat(validate_reserved(header))
40
+ issues.concat(validate_version(header))
41
+ issues.concat(validate_sizes(header))
42
+ issues.concat(validate_metadata(header))
43
+ issues
44
+ end
45
+
46
+ def self.code
47
+ :woff2_validation
48
+ end
49
+
50
+ def self.woff2?(font)
51
+ font.is_a?(Woff2Font)
52
+ end
53
+ private_class_method :woff2?
54
+
55
+ def self.validate_signature(header)
56
+ return [] if header.signature == WOFF2_SIGNATURE
57
+
58
+ [issue(severity: :error,
59
+ message: "WOFF2 signature is 0x#{header.signature.to_s(16)} " \
60
+ "but must be 0x#{WOFF2_SIGNATURE.to_s(16)} ('wOF2')",
61
+ location: "woff2_header.signature")]
62
+ end
63
+ private_class_method :validate_signature
64
+
65
+ def self.validate_flavor(header)
66
+ return [] if VALID_FLAVORS.include?(header.flavor)
67
+
68
+ [issue(severity: :error,
69
+ message: "WOFF2 flavor is 0x#{header.flavor.to_s(16)} " \
70
+ "but must be a valid SFNT version " \
71
+ "(0x00010000, 0x74727565, 0x4F54544F, or 0x74746366)",
72
+ location: "woff2_header.flavor")]
73
+ end
74
+ private_class_method :validate_flavor
75
+
76
+ def self.validate_reserved(header)
77
+ return [] if header.reserved.zero?
78
+
79
+ [issue(severity: :warning,
80
+ message: "WOFF2 reserved field is #{header.reserved} " \
81
+ "but must be 0 per the spec",
82
+ location: "woff2_header.reserved")]
83
+ end
84
+ private_class_method :validate_reserved
85
+
86
+ def self.validate_version(header)
87
+ issues = []
88
+ if header.major_version != 1
89
+ issues << issue(severity: :warning,
90
+ message: "WOFF2 major version is #{header.major_version} " \
91
+ "but the spec recommends 1",
92
+ location: "woff2_header.major_version")
93
+ end
94
+ issues
95
+ end
96
+ private_class_method :validate_version
97
+
98
+ def self.validate_sizes(header)
99
+ issues = []
100
+ if header.total_compressed_size.to_i <= 0
101
+ issues << issue(severity: :error,
102
+ message: "WOFF2 total_compressed_size is " \
103
+ "#{header.total_compressed_size} but must be positive",
104
+ location: "woff2_header.total_compressed_size")
105
+ end
106
+ if header.total_sfnt_size.to_i <= 0
107
+ issues << issue(severity: :error,
108
+ message: "WOFF2 total_sfnt_size is " \
109
+ "#{header.total_sfnt_size} but must be positive",
110
+ location: "woff2_header.total_sfnt_size")
111
+ end
112
+ issues
113
+ end
114
+ private_class_method :validate_sizes
115
+
116
+ def self.validate_metadata(header)
117
+ return [] unless header.meta_offset.to_i.positive?
118
+
119
+ issues = []
120
+ if header.meta_length.to_i <= 0
121
+ issues << issue(severity: :error,
122
+ message: "WOFF2 metadata offset is set but " \
123
+ "meta_length is #{header.meta_length} " \
124
+ "(must be positive)",
125
+ location: "woff2_header.meta_length")
126
+ end
127
+ if header.meta_orig_length.to_i <= 0
128
+ issues << issue(severity: :error,
129
+ message: "WOFF2 metadata offset is set but " \
130
+ "meta_orig_length is #{header.meta_orig_length} " \
131
+ "(must be positive)",
132
+ location: "woff2_header.meta_orig_length")
133
+ end
134
+ issues
135
+ end
136
+ private_class_method :validate_metadata
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,30 @@
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
+ autoload :VariableFontCheck, "fontisan/audit/checks/variable_font_check"
21
+ autoload :HintingCheck, "fontisan/audit/checks/hinting_check"
22
+ autoload :Woff2ValidationCheck,
23
+ "fontisan/audit/checks/woff2_validation_check"
24
+ autoload :FormatRoundTripCheck,
25
+ "fontisan/audit/checks/format_round_trip_check"
26
+ autoload :OpenTypeConformanceCheck,
27
+ "fontisan/audit/checks/opentype_conformance_check"
28
+ end
29
+ end
30
+ 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