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,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Audit
|
|
7
|
+
module Checks
|
|
8
|
+
# Reports fidelity discrepancies when converting between TTF and
|
|
9
|
+
# OTF outline formats. A high-fidelity conversion preserves:
|
|
10
|
+
#
|
|
11
|
+
# - Glyph count (maxp.numGlyphs)
|
|
12
|
+
# - Codepoint coverage (cmap Unicode mappings)
|
|
13
|
+
# - Font identity (name table family + PostScript name)
|
|
14
|
+
#
|
|
15
|
+
# Any mismatch signals a bug in the outline converter or a data
|
|
16
|
+
# loss path. This check is the pure-Ruby replacement for `ttx`
|
|
17
|
+
# diff comparisons.
|
|
18
|
+
#
|
|
19
|
+
# Only converts in one direction (TTF→OTF for TrueType sources,
|
|
20
|
+
# OTF→TTF for CFF sources) — full round-trip would double the
|
|
21
|
+
# conversion surface without adding signal.
|
|
22
|
+
#
|
|
23
|
+
# @see Converters::OutlineConverter
|
|
24
|
+
class FormatRoundTripCheck < Check
|
|
25
|
+
# @param font [SfntFont]
|
|
26
|
+
# @return [Array<Models::ValidationReport::Issue>]
|
|
27
|
+
def self.call(font)
|
|
28
|
+
target = round_trip_target(font)
|
|
29
|
+
return [] unless target
|
|
30
|
+
|
|
31
|
+
converted_path = convert_to_temp(font, target)
|
|
32
|
+
return [] unless converted_path
|
|
33
|
+
|
|
34
|
+
converted = FontLoader.load(converted_path)
|
|
35
|
+
compare_fonts(font, converted, target)
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
[issue(severity: :warning,
|
|
38
|
+
message: "Round-trip conversion to #{target.upcase} failed: " \
|
|
39
|
+
"#{e.class}: #{e.message}",
|
|
40
|
+
location: "round_trip.#{target}")]
|
|
41
|
+
ensure
|
|
42
|
+
cleanup_temp(converted_path) if converted_path
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.code
|
|
46
|
+
:format_round_trip
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Decide the conversion target based on the source outline type.
|
|
50
|
+
# @return [Symbol, nil] :otf, :ttf, or nil if not convertible
|
|
51
|
+
def self.round_trip_target(font)
|
|
52
|
+
return :otf if font.has_table?("glyf")
|
|
53
|
+
return :ttf if font.has_table?("CFF ") || font.has_table?("CFF2")
|
|
54
|
+
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
private_class_method :round_trip_target
|
|
58
|
+
|
|
59
|
+
def self.convert_to_temp(font, target)
|
|
60
|
+
converter = Converters::FormatConverter.new
|
|
61
|
+
tables = converter.convert(font, target)
|
|
62
|
+
return nil unless tables && !tables.empty?
|
|
63
|
+
|
|
64
|
+
dir = Dir.mktmpdir("fontisan-roundtrip-")
|
|
65
|
+
path = File.join(dir, "converted.#{target}")
|
|
66
|
+
sfnt = target == :otf ? 0x4F54544F : 0x00010000
|
|
67
|
+
FontWriter.write_to_file(
|
|
68
|
+
tables.transform_values { |t| t.is_a?(String) ? t : t.to_binary_s },
|
|
69
|
+
path,
|
|
70
|
+
sfnt_version: sfnt,
|
|
71
|
+
)
|
|
72
|
+
File.exist?(path) ? path : nil
|
|
73
|
+
rescue StandardError
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
private_class_method :convert_to_temp
|
|
77
|
+
|
|
78
|
+
def self.compare_fonts(source, converted, target)
|
|
79
|
+
issues = []
|
|
80
|
+
issues.concat(compare_glyph_count(source, converted, target))
|
|
81
|
+
issues.concat(compare_codepoints(source, converted, target))
|
|
82
|
+
issues
|
|
83
|
+
end
|
|
84
|
+
private_class_method :compare_fonts
|
|
85
|
+
|
|
86
|
+
def self.compare_glyph_count(source, converted, target)
|
|
87
|
+
src_count = glyph_count(source)
|
|
88
|
+
dst_count = glyph_count(converted)
|
|
89
|
+
return [] if src_count == dst_count
|
|
90
|
+
|
|
91
|
+
[issue(severity: :warning,
|
|
92
|
+
message: "Round-trip to #{target.upcase}: glyph count changed " \
|
|
93
|
+
"from #{src_count} to #{dst_count}",
|
|
94
|
+
location: "round_trip.#{target}.glyph_count")]
|
|
95
|
+
end
|
|
96
|
+
private_class_method :compare_glyph_count
|
|
97
|
+
|
|
98
|
+
def self.compare_codepoints(source, converted, target)
|
|
99
|
+
src_cps = codepoint_set(source)
|
|
100
|
+
dst_cps = codepoint_set(converted)
|
|
101
|
+
missing = src_cps - dst_cps
|
|
102
|
+
extra = dst_cps - src_cps
|
|
103
|
+
issues = []
|
|
104
|
+
unless missing.empty?
|
|
105
|
+
issues << issue(severity: :warning,
|
|
106
|
+
message: "Round-trip to #{target.upcase}: #{missing.size} " \
|
|
107
|
+
"codepoints lost (first: " \
|
|
108
|
+
"#{missing.first(5).map { |c| format('U+%04X', c) }.join(', ')})",
|
|
109
|
+
location: "round_trip.#{target}.codepoints.missing")
|
|
110
|
+
end
|
|
111
|
+
unless extra.empty?
|
|
112
|
+
issues << issue(severity: :info,
|
|
113
|
+
message: "Round-trip to #{target.upcase}: #{extra.size} " \
|
|
114
|
+
"codepoints gained (first: " \
|
|
115
|
+
"#{extra.first(5).map { |c| format('U+%04X', c) }.join(', ')})",
|
|
116
|
+
location: "round_trip.#{target}.codepoints.extra")
|
|
117
|
+
end
|
|
118
|
+
issues
|
|
119
|
+
end
|
|
120
|
+
private_class_method :compare_codepoints
|
|
121
|
+
|
|
122
|
+
def self.glyph_count(font)
|
|
123
|
+
return 0 unless font.has_table?("maxp")
|
|
124
|
+
|
|
125
|
+
font.table("maxp").num_glyphs.to_i
|
|
126
|
+
rescue StandardError
|
|
127
|
+
0
|
|
128
|
+
end
|
|
129
|
+
private_class_method :glyph_count
|
|
130
|
+
|
|
131
|
+
def self.codepoint_set(font)
|
|
132
|
+
return Set.new unless font.has_table?("cmap")
|
|
133
|
+
|
|
134
|
+
Set.new(font.table("cmap").unicode_mappings&.keys || [])
|
|
135
|
+
rescue StandardError
|
|
136
|
+
Set.new
|
|
137
|
+
end
|
|
138
|
+
private_class_method :codepoint_set
|
|
139
|
+
|
|
140
|
+
def self.cleanup_temp(path)
|
|
141
|
+
dir = File.dirname(path)
|
|
142
|
+
FileUtils.rm_rf(dir) if Dir.exist?(dir)
|
|
143
|
+
rescue StandardError
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
private_class_method :cleanup_temp
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
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,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Audit
|
|
5
|
+
module Checks
|
|
6
|
+
# Audits font hinting configuration. Hinting quality directly
|
|
7
|
+
# affects small-size screen rendering (Windows, Android, embedded
|
|
8
|
+
# systems).
|
|
9
|
+
#
|
|
10
|
+
# TrueType (glyf outlines):
|
|
11
|
+
# - fpgm (font program) presence recommended for hinted fonts
|
|
12
|
+
# - prep (control value program) presence recommended
|
|
13
|
+
# - cvt (control value table) presence recommended
|
|
14
|
+
# - maxp.maxStackElements and maxp.maxZones must be positive
|
|
15
|
+
# if any hinting instructions exist
|
|
16
|
+
# - gasp table recommended for controlling grid-fitting per ppem
|
|
17
|
+
#
|
|
18
|
+
# CFF (PostScript outlines):
|
|
19
|
+
# - Private DICT should have BlueValues or StdHW/StdVW for hinting
|
|
20
|
+
# - Subroutine usage (Local/Global subrs) recommended for size
|
|
21
|
+
#
|
|
22
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/ttinst#hinting-instructions
|
|
23
|
+
class HintingCheck < Check
|
|
24
|
+
# @param font [SfntFont]
|
|
25
|
+
# @return [Array<Models::ValidationReport::Issue>]
|
|
26
|
+
def self.call(font)
|
|
27
|
+
if font.has_table?("glyf")
|
|
28
|
+
validate_truetype_hinting(font)
|
|
29
|
+
elsif font.has_table?("CFF ") || font.has_table?("CFF2")
|
|
30
|
+
validate_cff_hinting(font)
|
|
31
|
+
else
|
|
32
|
+
[]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.code
|
|
37
|
+
:hinting
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# ---------- TrueType hinting ----------
|
|
41
|
+
|
|
42
|
+
def self.validate_truetype_hinting(font)
|
|
43
|
+
issues = []
|
|
44
|
+
has_fpgm = font.has_table?("fpgm")
|
|
45
|
+
has_prep = font.has_table?("prep")
|
|
46
|
+
has_cvt = font.has_table?("cvt")
|
|
47
|
+
has_glyf_data = table_has_data?(font, "glyf")
|
|
48
|
+
|
|
49
|
+
if has_fpgm && has_glyf_data
|
|
50
|
+
issues.concat(validate_maxp_hint_capacity(font))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
issues << no_program_warning("prep") if !has_prep && has_fpgm
|
|
54
|
+
issues << no_program_warning("cvt") if !has_cvt && has_fpgm
|
|
55
|
+
|
|
56
|
+
unless font.has_table?("gasp")
|
|
57
|
+
issues << issue(severity: :info,
|
|
58
|
+
message: "TrueType font has no 'gasp' table — " \
|
|
59
|
+
"grid-fitting behavior per ppem is " \
|
|
60
|
+
"implementation-defined",
|
|
61
|
+
location: "tables.gasp")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
issues
|
|
65
|
+
end
|
|
66
|
+
private_class_method :validate_truetype_hinting
|
|
67
|
+
|
|
68
|
+
def self.validate_maxp_hint_capacity(font)
|
|
69
|
+
issues = []
|
|
70
|
+
maxp = font.table("maxp")
|
|
71
|
+
return issues unless maxp.version_1_0?
|
|
72
|
+
|
|
73
|
+
stack = maxp.max_stack_elements.to_i
|
|
74
|
+
zones = maxp.max_zones.to_i
|
|
75
|
+
if has_fpgm_instructions?(font) && stack.zero?
|
|
76
|
+
issues << issue(severity: :warning,
|
|
77
|
+
message: "maxp.maxStackElements is 0 but the font " \
|
|
78
|
+
"has hinting instructions — rendering " \
|
|
79
|
+
"may fail",
|
|
80
|
+
location: "maxp.max_stack_elements")
|
|
81
|
+
end
|
|
82
|
+
unless zones.between?(1, 2)
|
|
83
|
+
issues << issue(severity: :warning,
|
|
84
|
+
message: "maxp.maxZones is #{zones} but must be 1 or 2 " \
|
|
85
|
+
"per the OpenType spec",
|
|
86
|
+
location: "maxp.max_zones")
|
|
87
|
+
end
|
|
88
|
+
issues
|
|
89
|
+
end
|
|
90
|
+
private_class_method :validate_maxp_hint_capacity
|
|
91
|
+
|
|
92
|
+
def self.has_fpgm_instructions?(font)
|
|
93
|
+
table_has_data?(font, "fpgm")
|
|
94
|
+
end
|
|
95
|
+
private_class_method :has_fpgm_instructions?
|
|
96
|
+
|
|
97
|
+
def self.no_program_warning(table)
|
|
98
|
+
issue(severity: :info,
|
|
99
|
+
message: "Font has 'fpgm' but no '#{table}' table — " \
|
|
100
|
+
"#{table == 'cvt' ? 'control values' : 'pre-program'} " \
|
|
101
|
+
"are recommended for consistent hinting",
|
|
102
|
+
location: "tables.#{table}")
|
|
103
|
+
end
|
|
104
|
+
private_class_method :no_program_warning
|
|
105
|
+
|
|
106
|
+
# ---------- CFF hinting ----------
|
|
107
|
+
|
|
108
|
+
def self.validate_cff_hinting(font)
|
|
109
|
+
issues = []
|
|
110
|
+
tag = font.has_table?("CFF2") ? "CFF2" : "CFF "
|
|
111
|
+
cff = font.table(tag)
|
|
112
|
+
return issues unless cff
|
|
113
|
+
|
|
114
|
+
priv = begin
|
|
115
|
+
cff.private_dict(0)
|
|
116
|
+
rescue StandardError
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
return issues unless priv
|
|
120
|
+
|
|
121
|
+
has_blues = priv_values?(priv, :blue_values) || priv_values?(priv, :other_blues)
|
|
122
|
+
has_stems = priv_value?(priv, :std_hw) || priv_value?(priv, :std_vw)
|
|
123
|
+
|
|
124
|
+
if !has_blues && !has_stems
|
|
125
|
+
issues << issue(severity: :info,
|
|
126
|
+
message: "CFF Private DICT has no BlueValues or " \
|
|
127
|
+
"StdHW/StdVW — alignment zones and stem " \
|
|
128
|
+
"widths are the primary hinting mechanism " \
|
|
129
|
+
"for CFF outlines",
|
|
130
|
+
location: "cff.private_dict")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
issues
|
|
134
|
+
end
|
|
135
|
+
private_class_method :validate_cff_hinting
|
|
136
|
+
|
|
137
|
+
def self.priv_values?(priv, key)
|
|
138
|
+
val = priv[key] || priv[key.to_s]
|
|
139
|
+
val.is_a?(Array) ? val.any? : false
|
|
140
|
+
rescue StandardError
|
|
141
|
+
false
|
|
142
|
+
end
|
|
143
|
+
private_class_method :priv_values?
|
|
144
|
+
|
|
145
|
+
def self.priv_value?(priv, key)
|
|
146
|
+
val = priv[key] || priv[key.to_s]
|
|
147
|
+
val && !val.zero?
|
|
148
|
+
rescue StandardError
|
|
149
|
+
false
|
|
150
|
+
end
|
|
151
|
+
private_class_method :priv_value?
|
|
152
|
+
|
|
153
|
+
# ---------- helpers ----------
|
|
154
|
+
|
|
155
|
+
def self.table_has_data?(font, tag)
|
|
156
|
+
raw = font.table_data[tag]
|
|
157
|
+
raw && !raw.empty?
|
|
158
|
+
end
|
|
159
|
+
private_class_method :table_has_data?
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Audit
|
|
5
|
+
module Checks
|
|
6
|
+
# Foundational OpenType spec conformance checks. Covers the
|
|
7
|
+
# most commonly-violated "MUST" and "SHOULD" rules from the OT
|
|
8
|
+
# spec that other check domains don't already cover.
|
|
9
|
+
#
|
|
10
|
+
# This check is intentionally a growing foundation — new OT spec
|
|
11
|
+
# rules are added incrementally as real-world fonts surface them.
|
|
12
|
+
# Full OT conformance is a long-running effort that tracks the
|
|
13
|
+
# spec's evolution (axis 14 per TODO #03).
|
|
14
|
+
#
|
|
15
|
+
# Current checks:
|
|
16
|
+
#
|
|
17
|
+
# - Required tables for each sfnt flavor (TTF vs CFF vs variable)
|
|
18
|
+
# - head.indexToLocFormat consistency with loca table size
|
|
19
|
+
# - name table must have at least family (1), subfamily (2),
|
|
20
|
+
# full (4), PostScript (6) name IDs
|
|
21
|
+
# - OS/2 fsSelection must not use reserved bits
|
|
22
|
+
# - hhea.numberOfHMetrics must be ≤ maxp.numGlyphs
|
|
23
|
+
# - post table version must be valid
|
|
24
|
+
# - cmap must have at least one Unicode subtable (already
|
|
25
|
+
# covered by CmapCheck — skipped here to avoid duplicate issues)
|
|
26
|
+
#
|
|
27
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/otff
|
|
28
|
+
class OpenTypeConformanceCheck < Check
|
|
29
|
+
REQUIRED_NAME_IDS = {
|
|
30
|
+
1 => "Family",
|
|
31
|
+
2 => "Subfamily",
|
|
32
|
+
4 => "Full",
|
|
33
|
+
6 => "PostScript",
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
# @param font [SfntFont]
|
|
37
|
+
# @return [Array<Models::ValidationReport::Issue>]
|
|
38
|
+
def self.call(font)
|
|
39
|
+
issues = []
|
|
40
|
+
issues.concat(validate_required_tables(font))
|
|
41
|
+
issues.concat(validate_loca_format(font))
|
|
42
|
+
issues.concat(validate_name_coverage(font))
|
|
43
|
+
issues.concat(validate_os2_fsselection(font))
|
|
44
|
+
issues.concat(validate_hhea_metrics(font))
|
|
45
|
+
issues
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.code
|
|
49
|
+
:opentype_conformance
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# ---------- required tables ----------
|
|
53
|
+
|
|
54
|
+
def self.validate_required_tables(font)
|
|
55
|
+
required = required_tables_for_flavor(font)
|
|
56
|
+
missing = required.reject { |tag| font.has_table?(tag) }
|
|
57
|
+
missing.map do |tag|
|
|
58
|
+
issue(severity: :error,
|
|
59
|
+
message: "Required table '#{tag}' is missing for this " \
|
|
60
|
+
"sfnt flavor",
|
|
61
|
+
location: "tables.#{tag}")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
private_class_method :validate_required_tables
|
|
65
|
+
|
|
66
|
+
def self.required_tables_for_flavor(font)
|
|
67
|
+
base = %w[head hhea maxp hmtx name post cmap OS/2]
|
|
68
|
+
if font.has_table?("glyf")
|
|
69
|
+
base + %w[glyf loca]
|
|
70
|
+
elsif font.has_table?("CFF2")
|
|
71
|
+
base + ["CFF2"]
|
|
72
|
+
elsif font.has_table?("CFF ")
|
|
73
|
+
base + ["CFF "]
|
|
74
|
+
else
|
|
75
|
+
base
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
private_class_method :required_tables_for_flavor
|
|
79
|
+
|
|
80
|
+
# ---------- loca format ----------
|
|
81
|
+
|
|
82
|
+
def self.validate_loca_format(font)
|
|
83
|
+
return [] unless font.has_table?("head") && font.has_table?("loca")
|
|
84
|
+
|
|
85
|
+
head = font.table("head")
|
|
86
|
+
loca = font.table("loca")
|
|
87
|
+
return [] unless head && loca
|
|
88
|
+
|
|
89
|
+
format = head.index_to_loc_format
|
|
90
|
+
maxp = font.table("maxp")
|
|
91
|
+
num_glyphs = maxp&.num_glyphs.to_i
|
|
92
|
+
return [] if num_glyphs.zero?
|
|
93
|
+
|
|
94
|
+
expected_size = format.zero? ? (num_glyphs + 1) * 2 : (num_glyphs + 1) * 4
|
|
95
|
+
actual_size = loca.to_binary_s.bytesize
|
|
96
|
+
return [] if actual_size == expected_size
|
|
97
|
+
|
|
98
|
+
[issue(severity: :warning,
|
|
99
|
+
message: "loca table size (#{actual_size} bytes) doesn't match " \
|
|
100
|
+
"head.indexToLocFormat=#{format} + numGlyphs=#{num_glyphs} " \
|
|
101
|
+
"(expected #{expected_size} bytes)",
|
|
102
|
+
location: "head.index_to_loc_format")]
|
|
103
|
+
end
|
|
104
|
+
private_class_method :validate_loca_format
|
|
105
|
+
|
|
106
|
+
# ---------- name table coverage ----------
|
|
107
|
+
|
|
108
|
+
def self.validate_name_coverage(font)
|
|
109
|
+
return [] unless font.has_table?("name")
|
|
110
|
+
|
|
111
|
+
name = font.table("name")
|
|
112
|
+
REQUIRED_NAME_IDS.each_with_object([]) do |(id, label), issues|
|
|
113
|
+
val = name.english_name(id).to_s
|
|
114
|
+
next unless val.empty?
|
|
115
|
+
|
|
116
|
+
issues << issue(severity: :error,
|
|
117
|
+
message: "Required name ID #{id} (#{label}) is " \
|
|
118
|
+
"missing from the name table",
|
|
119
|
+
location: "name.nameID.#{id}")
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
private_class_method :validate_name_coverage
|
|
123
|
+
|
|
124
|
+
# ---------- OS/2 fsSelection reserved bits ----------
|
|
125
|
+
|
|
126
|
+
def self.validate_os2_fsselection(font)
|
|
127
|
+
return [] unless font.has_table?("OS/2")
|
|
128
|
+
|
|
129
|
+
os2 = font.table("OS/2")
|
|
130
|
+
fs = os2.fs_selection.to_i
|
|
131
|
+
reserved_mask = 0xFC00 # bits 10-15 are reserved
|
|
132
|
+
return [] if (fs & reserved_mask).zero?
|
|
133
|
+
|
|
134
|
+
[issue(severity: :warning,
|
|
135
|
+
message: "OS/2 fsSelection uses reserved bits " \
|
|
136
|
+
"(value 0x#{fs.to_s(16)}, bits 10-15 must be 0)",
|
|
137
|
+
location: "os2.fs_selection")]
|
|
138
|
+
end
|
|
139
|
+
private_class_method :validate_os2_fsselection
|
|
140
|
+
|
|
141
|
+
# ---------- hhea.numberOfHMetrics ----------
|
|
142
|
+
|
|
143
|
+
def self.validate_hhea_metrics(font)
|
|
144
|
+
return [] unless font.has_table?("hhea") && font.has_table?("maxp")
|
|
145
|
+
|
|
146
|
+
num_metrics = font.table("hhea").number_of_h_metrics.to_i
|
|
147
|
+
num_glyphs = font.table("maxp").num_glyphs.to_i
|
|
148
|
+
return [] if num_metrics.positive? && num_metrics <= num_glyphs
|
|
149
|
+
|
|
150
|
+
[issue(severity: :error,
|
|
151
|
+
message: "hhea.numberOfHMetrics (#{num_metrics}) must be " \
|
|
152
|
+
"between 1 and maxp.numGlyphs (#{num_glyphs})",
|
|
153
|
+
location: "hhea.number_of_h_metrics")]
|
|
154
|
+
end
|
|
155
|
+
private_class_method :validate_hhea_metrics
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|