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.
- checksums.yaml +4 -4
- data/.rubocop.yml +15 -3
- data/.rubocop_todo.yml +23 -18
- data/TODO.improvements/README.md +1 -1
- data/lib/fontisan/audit/check.rb +47 -0
- data/lib/fontisan/audit/check_registry.rb +57 -0
- data/lib/fontisan/audit/checks/cmap_check.rb +249 -0
- data/lib/fontisan/audit/checks/collection_integrity_check.rb +102 -0
- data/lib/fontisan/audit/checks/format_round_trip_check.rb +150 -0
- data/lib/fontisan/audit/checks/glyph_name_check.rb +107 -0
- data/lib/fontisan/audit/checks/hinting_check.rb +163 -0
- data/lib/fontisan/audit/checks/opentype_conformance_check.rb +159 -0
- data/lib/fontisan/audit/checks/ots_compatibility_check.rb +191 -0
- data/lib/fontisan/audit/checks/table_directory_check.rb +157 -0
- data/lib/fontisan/audit/checks/variable_font_check.rb +151 -0
- data/lib/fontisan/audit/checks/woff2_validation_check.rb +140 -0
- data/lib/fontisan/audit/checks.rb +30 -0
- data/lib/fontisan/audit.rb +20 -0
- data/lib/fontisan/cli.rb +13 -0
- data/lib/fontisan/commands/audit_command.rb +36 -2
- data/lib/fontisan/models/audit_report.rb +5 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan.rb +1 -0
- metadata +15 -1
|
@@ -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
|