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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +19 -217
- data/TODO.improvements/README.md +4 -4
- data/lib/fontisan/audit/check.rb +47 -0
- data/lib/fontisan/audit/check_registry.rb +47 -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/glyph_name_check.rb +107 -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.rb +22 -0
- data/lib/fontisan/audit.rb +20 -0
- data/lib/fontisan/cli.rb +78 -0
- data/lib/fontisan/commands/audit_command.rb +291 -0
- data/lib/fontisan/commands.rb +1 -0
- data/lib/fontisan/models/audit_report.rb +148 -0
- data/lib/fontisan/models.rb +2 -0
- data/lib/fontisan/type1/charstrings.rb +34 -1
- data/lib/fontisan/type1/seac_expander.rb +68 -57
- data/lib/fontisan/ufo/compile/feature_compiler.rb +161 -0
- data/lib/fontisan/ufo/compile/gsub.rb +267 -0
- data/lib/fontisan/ufo/compile.rb +3 -1
- data/lib/fontisan/ufo/font.rb +3 -2
- data/lib/fontisan/ufo/image.rb +15 -2
- data/lib/fontisan/ufo/image_set.rb +82 -2
- data/lib/fontisan/ufo/reader.rb +8 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan.rb +1 -0
- metadata +14 -1
|
@@ -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
|