fontisan 0.4.35 → 0.4.37
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 -11
- data/TODO.improvements/README.md +1 -1
- data/lib/fontisan/audit/check_registry.rb +26 -2
- data/lib/fontisan/audit/checks/format_round_trip_check.rb +150 -0
- data/lib/fontisan/audit/checks/head_check.rb +96 -0
- data/lib/fontisan/audit/checks/hhea_check.rb +68 -0
- data/lib/fontisan/audit/checks/hinting_check.rb +163 -0
- data/lib/fontisan/audit/checks/kern_check.rb +47 -0
- data/lib/fontisan/audit/checks/maxp_check.rb +85 -0
- data/lib/fontisan/audit/checks/name_table_check.rb +78 -0
- data/lib/fontisan/audit/checks/opentype_conformance_check.rb +105 -0
- data/lib/fontisan/audit/checks/os2_check.rb +117 -0
- data/lib/fontisan/audit/checks/post_check.rb +75 -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 +15 -0
- data/lib/fontisan/version.rb +1 -1
- metadata +13 -1
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Audit
|
|
5
|
+
module Checks
|
|
6
|
+
# Validates the 'kern' table if present. The kern table is
|
|
7
|
+
# deprecated by the OpenType spec — GPOS is the replacement.
|
|
8
|
+
# Many legacy fonts still ship kern; this check warns about
|
|
9
|
+
# deprecation and validates basic structure.
|
|
10
|
+
#
|
|
11
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/kern
|
|
12
|
+
class KernCheck < Check
|
|
13
|
+
def self.call(font)
|
|
14
|
+
return [] unless font.has_table?("kern")
|
|
15
|
+
|
|
16
|
+
issues = [deprecation_warning]
|
|
17
|
+
issues.concat(validate_gpos_presence(font))
|
|
18
|
+
issues
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.code
|
|
22
|
+
:ot_kern
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.deprecation_warning
|
|
26
|
+
issue(severity: :warning,
|
|
27
|
+
message: "kern table is present but deprecated by the " \
|
|
28
|
+
"OpenType spec — use GPOS instead. Some modern " \
|
|
29
|
+
"renderers (HarfBuzz, Core Text) ignore kern",
|
|
30
|
+
location: "tables.kern")
|
|
31
|
+
end
|
|
32
|
+
private_class_method :deprecation_warning
|
|
33
|
+
|
|
34
|
+
def self.validate_gpos_presence(font)
|
|
35
|
+
return [] if font.has_table?("GPOS")
|
|
36
|
+
|
|
37
|
+
[issue(severity: :warning,
|
|
38
|
+
message: "kern table present but no GPOS table — " \
|
|
39
|
+
"kerning data should be migrated to a GPOS " \
|
|
40
|
+
"'kern' feature for full renderer support",
|
|
41
|
+
location: "tables.GPOS")]
|
|
42
|
+
end
|
|
43
|
+
private_class_method :validate_gpos_presence
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Audit
|
|
5
|
+
module Checks
|
|
6
|
+
# Validates the 'maxp' table per the OpenType spec.
|
|
7
|
+
#
|
|
8
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/maxp
|
|
9
|
+
class MaxpCheck < Check
|
|
10
|
+
MAX_GLYPHS = 0xFFFF
|
|
11
|
+
MAX_STACK_ELEMENTS = 1000
|
|
12
|
+
|
|
13
|
+
def self.call(font)
|
|
14
|
+
return [] unless font.has_table?("maxp")
|
|
15
|
+
|
|
16
|
+
maxp = font.table("maxp")
|
|
17
|
+
issues = []
|
|
18
|
+
issues.concat(validate_version(maxp, font))
|
|
19
|
+
issues.concat(validate_num_glyphs(maxp))
|
|
20
|
+
issues.concat(validate_hint_capacity(maxp))
|
|
21
|
+
issues
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.code
|
|
25
|
+
:ot_maxp
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.validate_version(maxp, font)
|
|
29
|
+
version_raw = maxp.version_raw
|
|
30
|
+
is_truetype = font.has_table?("glyf")
|
|
31
|
+
tt_version = 0x00010000 # 1.0 as Fixed 16.16
|
|
32
|
+
cff_version = 0x00005000 # 0.5 as Fixed 16.16
|
|
33
|
+
if is_truetype && version_raw != tt_version
|
|
34
|
+
[issue(severity: :error,
|
|
35
|
+
message: "maxp.version is 0x#{version_raw.to_s(16)} but must " \
|
|
36
|
+
"be 1.0 (0x#{tt_version.to_s(16)}) for TrueType fonts",
|
|
37
|
+
location: "maxp.version")]
|
|
38
|
+
elsif !is_truetype && version_raw != cff_version
|
|
39
|
+
[issue(severity: :warning,
|
|
40
|
+
message: "maxp.version is 0x#{version_raw.to_s(16)} but " \
|
|
41
|
+
"should be 0.5 (0x#{cff_version.to_s(16)}) for " \
|
|
42
|
+
"CFF-based fonts",
|
|
43
|
+
location: "maxp.version")]
|
|
44
|
+
else
|
|
45
|
+
[]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
private_class_method :validate_version
|
|
49
|
+
|
|
50
|
+
def self.validate_num_glyphs(maxp)
|
|
51
|
+
n = maxp.num_glyphs.to_i
|
|
52
|
+
return [] if n.between?(1, MAX_GLYPHS)
|
|
53
|
+
|
|
54
|
+
[issue(severity: :error,
|
|
55
|
+
message: "maxp.numGlyphs is #{n} but must be between 1 and " \
|
|
56
|
+
"#{MAX_GLYPHS}",
|
|
57
|
+
location: "maxp.num_glyphs")]
|
|
58
|
+
end
|
|
59
|
+
private_class_method :validate_num_glyphs
|
|
60
|
+
|
|
61
|
+
def self.validate_hint_capacity(maxp)
|
|
62
|
+
return [] unless maxp.version_1_0?
|
|
63
|
+
|
|
64
|
+
issues = []
|
|
65
|
+
zones = maxp.max_zones.to_i
|
|
66
|
+
unless zones.between?(1, 2)
|
|
67
|
+
issues << issue(severity: :warning,
|
|
68
|
+
message: "maxp.maxZones is #{zones} but must be 1 or 2 " \
|
|
69
|
+
"per the OpenType spec",
|
|
70
|
+
location: "maxp.max_zones")
|
|
71
|
+
end
|
|
72
|
+
stack = maxp.max_stack_elements.to_i
|
|
73
|
+
if stack > MAX_STACK_ELEMENTS
|
|
74
|
+
issues << issue(severity: :warning,
|
|
75
|
+
message: "maxp.maxStackElements is #{stack} — " \
|
|
76
|
+
"excessively large values waste interpreter memory",
|
|
77
|
+
location: "maxp.max_stack_elements")
|
|
78
|
+
end
|
|
79
|
+
issues
|
|
80
|
+
end
|
|
81
|
+
private_class_method :validate_hint_capacity
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Audit
|
|
5
|
+
module Checks
|
|
6
|
+
# Validates the 'name' table per the OpenType spec.
|
|
7
|
+
#
|
|
8
|
+
# Covers: required nameIDs, PostScript name character validity,
|
|
9
|
+
# version string format. Glyph-name rules are in GlyphNameCheck.
|
|
10
|
+
#
|
|
11
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/name
|
|
12
|
+
class NameTableCheck < Check
|
|
13
|
+
REQUIRED_NAME_IDS = {
|
|
14
|
+
1 => "Family",
|
|
15
|
+
2 => "Subfamily",
|
|
16
|
+
4 => "Full",
|
|
17
|
+
6 => "PostScript",
|
|
18
|
+
}.freeze
|
|
19
|
+
PS_NAME_PATTERN = /\A[A-Za-z][A-Za-z0-9._-]*\z/
|
|
20
|
+
|
|
21
|
+
def self.call(font)
|
|
22
|
+
return [] unless font.has_table?("name")
|
|
23
|
+
|
|
24
|
+
name = font.table("name")
|
|
25
|
+
issues = []
|
|
26
|
+
issues.concat(validate_required_ids(name))
|
|
27
|
+
issues.concat(validate_ps_name(name))
|
|
28
|
+
issues.concat(validate_version_string(name))
|
|
29
|
+
issues
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.code
|
|
33
|
+
:ot_name
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.validate_required_ids(name)
|
|
37
|
+
REQUIRED_NAME_IDS.each_with_object([]) do |(id, label), issues|
|
|
38
|
+
val = name.english_name(id).to_s
|
|
39
|
+
next unless val.empty?
|
|
40
|
+
|
|
41
|
+
issues << issue(severity: :error,
|
|
42
|
+
message: "Required name ID #{id} (#{label}) is " \
|
|
43
|
+
"missing from the name table",
|
|
44
|
+
location: "name.nameID.#{id}")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
private_class_method :validate_required_ids
|
|
48
|
+
|
|
49
|
+
def self.validate_ps_name(name)
|
|
50
|
+
ps = name.english_name(6).to_s
|
|
51
|
+
return [] if ps.empty? || ps.match?(PS_NAME_PATTERN)
|
|
52
|
+
|
|
53
|
+
[issue(severity: :warning,
|
|
54
|
+
message: "PostScript name '#{ps}' contains characters outside " \
|
|
55
|
+
"the OT spec grammar (must start with a letter, then " \
|
|
56
|
+
"A–Z a–z 0–9 . - _)",
|
|
57
|
+
location: "name.nameID.6")]
|
|
58
|
+
end
|
|
59
|
+
private_class_method :validate_ps_name
|
|
60
|
+
|
|
61
|
+
def self.validate_version_string(name)
|
|
62
|
+
ver = name.english_name(5).to_s
|
|
63
|
+
return [] if ver.empty?
|
|
64
|
+
|
|
65
|
+
unless ver.match?(/\AVersion\s/i)
|
|
66
|
+
return [issue(severity: :info,
|
|
67
|
+
message: "name ID 5 (version) is '#{ver}' but should " \
|
|
68
|
+
"start with 'Version ' per OT spec convention",
|
|
69
|
+
location: "name.nameID.5")]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
[]
|
|
73
|
+
end
|
|
74
|
+
private_class_method :validate_version_string
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Audit
|
|
5
|
+
module Checks
|
|
6
|
+
# Foundational cross-table OpenType spec conformance checks.
|
|
7
|
+
# Per-table rules live in their own check modules
|
|
8
|
+
# ({HeadCheck}, {HheaCheck}, {Os2Check}, {NameTableCheck},
|
|
9
|
+
# {PostCheck}, {KernCheck}) to keep each concern MECE.
|
|
10
|
+
#
|
|
11
|
+
# This check owns the rules that span multiple tables:
|
|
12
|
+
#
|
|
13
|
+
# - Required tables for each sfnt flavor (TTF vs CFF vs variable)
|
|
14
|
+
# - head.indexToLocFormat consistency with loca table byte size
|
|
15
|
+
# - hhea.numberOfHMetrics must be ≤ maxp.numGlyphs
|
|
16
|
+
#
|
|
17
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/otff
|
|
18
|
+
class OpenTypeConformanceCheck < Check
|
|
19
|
+
# @param font [SfntFont]
|
|
20
|
+
# @return [Array<Models::ValidationReport::Issue>]
|
|
21
|
+
def self.call(font)
|
|
22
|
+
issues = []
|
|
23
|
+
issues.concat(validate_required_tables(font))
|
|
24
|
+
issues.concat(validate_loca_format(font))
|
|
25
|
+
issues.concat(validate_hhea_metrics(font))
|
|
26
|
+
issues
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.code
|
|
30
|
+
:opentype_conformance
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# ---------- required tables ----------
|
|
34
|
+
|
|
35
|
+
def self.validate_required_tables(font)
|
|
36
|
+
required = required_tables_for_flavor(font)
|
|
37
|
+
missing = required.reject { |tag| font.has_table?(tag) }
|
|
38
|
+
missing.map do |tag|
|
|
39
|
+
issue(severity: :error,
|
|
40
|
+
message: "Required table '#{tag}' is missing for this " \
|
|
41
|
+
"sfnt flavor",
|
|
42
|
+
location: "tables.#{tag}")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
private_class_method :validate_required_tables
|
|
46
|
+
|
|
47
|
+
def self.required_tables_for_flavor(font)
|
|
48
|
+
base = %w[head hhea maxp hmtx name post cmap OS/2]
|
|
49
|
+
if font.has_table?("glyf")
|
|
50
|
+
base + %w[glyf loca]
|
|
51
|
+
elsif font.has_table?("CFF2")
|
|
52
|
+
base + ["CFF2"]
|
|
53
|
+
elsif font.has_table?("CFF ")
|
|
54
|
+
base + ["CFF "]
|
|
55
|
+
else
|
|
56
|
+
base
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
private_class_method :required_tables_for_flavor
|
|
60
|
+
|
|
61
|
+
# ---------- loca format ----------
|
|
62
|
+
|
|
63
|
+
def self.validate_loca_format(font)
|
|
64
|
+
return [] unless font.has_table?("head") && font.has_table?("loca")
|
|
65
|
+
|
|
66
|
+
head = font.table("head")
|
|
67
|
+
loca = font.table("loca")
|
|
68
|
+
return [] unless head && loca
|
|
69
|
+
|
|
70
|
+
format = head.index_to_loc_format
|
|
71
|
+
maxp = font.table("maxp")
|
|
72
|
+
num_glyphs = maxp&.num_glyphs.to_i
|
|
73
|
+
return [] if num_glyphs.zero?
|
|
74
|
+
|
|
75
|
+
expected_size = format.zero? ? (num_glyphs + 1) * 2 : (num_glyphs + 1) * 4
|
|
76
|
+
actual_size = loca.to_binary_s.bytesize
|
|
77
|
+
return [] if actual_size == expected_size
|
|
78
|
+
|
|
79
|
+
[issue(severity: :warning,
|
|
80
|
+
message: "loca table size (#{actual_size} bytes) doesn't match " \
|
|
81
|
+
"head.indexToLocFormat=#{format} + numGlyphs=#{num_glyphs} " \
|
|
82
|
+
"(expected #{expected_size} bytes)",
|
|
83
|
+
location: "head.index_to_loc_format")]
|
|
84
|
+
end
|
|
85
|
+
private_class_method :validate_loca_format
|
|
86
|
+
|
|
87
|
+
# ---------- hhea.numberOfHMetrics ----------
|
|
88
|
+
|
|
89
|
+
def self.validate_hhea_metrics(font)
|
|
90
|
+
return [] unless font.has_table?("hhea") && font.has_table?("maxp")
|
|
91
|
+
|
|
92
|
+
num_metrics = font.table("hhea").number_of_h_metrics.to_i
|
|
93
|
+
num_glyphs = font.table("maxp").num_glyphs.to_i
|
|
94
|
+
return [] if num_metrics.positive? && num_metrics <= num_glyphs
|
|
95
|
+
|
|
96
|
+
[issue(severity: :error,
|
|
97
|
+
message: "hhea.numberOfHMetrics (#{num_metrics}) must be " \
|
|
98
|
+
"between 1 and maxp.numGlyphs (#{num_glyphs})",
|
|
99
|
+
location: "hhea.number_of_h_metrics")]
|
|
100
|
+
end
|
|
101
|
+
private_class_method :validate_hhea_metrics
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Audit
|
|
5
|
+
module Checks
|
|
6
|
+
# Validates the 'OS/2' table per the OpenType spec.
|
|
7
|
+
#
|
|
8
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/os2
|
|
9
|
+
class Os2Check < Check
|
|
10
|
+
FS_SELECTION_RESERVED = 0xFC00
|
|
11
|
+
|
|
12
|
+
def self.call(font)
|
|
13
|
+
return [] unless font.has_table?("OS/2")
|
|
14
|
+
|
|
15
|
+
os2 = font.table("OS/2")
|
|
16
|
+
issues = []
|
|
17
|
+
issues.concat(validate_weight_class(os2))
|
|
18
|
+
issues.concat(validate_width_class(os2))
|
|
19
|
+
issues.concat(validate_fs_selection(os2))
|
|
20
|
+
issues.concat(validate_typo_metrics(os2))
|
|
21
|
+
issues.concat(validate_win_metrics(os2))
|
|
22
|
+
issues.concat(validate_panose(os2))
|
|
23
|
+
issues
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.code
|
|
27
|
+
:ot_os2
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.validate_weight_class(os2)
|
|
31
|
+
w = os2.us_weight_class.to_i
|
|
32
|
+
return [] if w.between?(1, 1000)
|
|
33
|
+
|
|
34
|
+
[issue(severity: :error,
|
|
35
|
+
message: "OS/2.usWeightClass is #{w} but must be between " \
|
|
36
|
+
"1 and 1000",
|
|
37
|
+
location: "os2.us_weight_class")]
|
|
38
|
+
end
|
|
39
|
+
private_class_method :validate_weight_class
|
|
40
|
+
|
|
41
|
+
def self.validate_width_class(os2)
|
|
42
|
+
w = os2.us_width_class.to_i
|
|
43
|
+
return [] if w.between?(1, 9)
|
|
44
|
+
|
|
45
|
+
[issue(severity: :error,
|
|
46
|
+
message: "OS/2.usWidthClass is #{w} but must be between 1 " \
|
|
47
|
+
"and 9",
|
|
48
|
+
location: "os2.us_width_class")]
|
|
49
|
+
end
|
|
50
|
+
private_class_method :validate_width_class
|
|
51
|
+
|
|
52
|
+
def self.validate_fs_selection(os2)
|
|
53
|
+
fs = os2.fs_selection.to_i
|
|
54
|
+
return [] if (fs & FS_SELECTION_RESERVED).zero?
|
|
55
|
+
|
|
56
|
+
[issue(severity: :warning,
|
|
57
|
+
message: "OS/2.fsSelection uses reserved bits " \
|
|
58
|
+
"(0x#{fs.to_s(16)}, bits 10-15 must be 0)",
|
|
59
|
+
location: "os2.fs_selection")]
|
|
60
|
+
end
|
|
61
|
+
private_class_method :validate_fs_selection
|
|
62
|
+
|
|
63
|
+
def self.validate_typo_metrics(os2)
|
|
64
|
+
issues = []
|
|
65
|
+
asc = os2.s_typo_ascender.to_i
|
|
66
|
+
desc = os2.s_typo_descender.to_i
|
|
67
|
+
if asc <= 0
|
|
68
|
+
issues << issue(severity: :warning,
|
|
69
|
+
message: "OS/2.sTypoAscender is #{asc} but should " \
|
|
70
|
+
"be positive",
|
|
71
|
+
location: "os2.s_typo_ascender")
|
|
72
|
+
end
|
|
73
|
+
if desc.positive?
|
|
74
|
+
issues << issue(severity: :warning,
|
|
75
|
+
message: "OS/2.sTypoDescender is #{desc} but should " \
|
|
76
|
+
"be ≤ 0",
|
|
77
|
+
location: "os2.s_typo_descender")
|
|
78
|
+
end
|
|
79
|
+
issues
|
|
80
|
+
end
|
|
81
|
+
private_class_method :validate_typo_metrics
|
|
82
|
+
|
|
83
|
+
def self.validate_win_metrics(os2)
|
|
84
|
+
issues = []
|
|
85
|
+
if os2.us_win_ascent.to_i <= 0
|
|
86
|
+
issues << issue(severity: :warning,
|
|
87
|
+
message: "OS/2.usWinAscent is #{os2.us_win_ascent} " \
|
|
88
|
+
"but should be positive",
|
|
89
|
+
location: "os2.us_win_ascent")
|
|
90
|
+
end
|
|
91
|
+
if os2.us_win_descent.to_i <= 0
|
|
92
|
+
issues << issue(severity: :warning,
|
|
93
|
+
message: "OS/2.usWinDescent is #{os2.us_win_descent} " \
|
|
94
|
+
"but should be positive",
|
|
95
|
+
location: "os2.us_win_descent")
|
|
96
|
+
end
|
|
97
|
+
issues
|
|
98
|
+
end
|
|
99
|
+
private_class_method :validate_win_metrics
|
|
100
|
+
|
|
101
|
+
def self.validate_panose(os2)
|
|
102
|
+
panose = os2.panose
|
|
103
|
+
return [] unless panose
|
|
104
|
+
|
|
105
|
+
all_zero = panose.to_a.all?(&:zero?)
|
|
106
|
+
return [] unless all_zero
|
|
107
|
+
|
|
108
|
+
[issue(severity: :info,
|
|
109
|
+
message: "OS/2.panose is all zeros — PANOSE classification " \
|
|
110
|
+
"helps font matching in some applications",
|
|
111
|
+
location: "os2.panose")]
|
|
112
|
+
end
|
|
113
|
+
private_class_method :validate_panose
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Audit
|
|
5
|
+
module Checks
|
|
6
|
+
# Validates the 'post' table per the OpenType spec.
|
|
7
|
+
#
|
|
8
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/post
|
|
9
|
+
class PostCheck < Check
|
|
10
|
+
VALID_VERSIONS = [1.0, 2.0, 2.5, 3.0, 4.0].freeze
|
|
11
|
+
|
|
12
|
+
def self.call(font)
|
|
13
|
+
return [] unless font.has_table?("post")
|
|
14
|
+
|
|
15
|
+
post = font.table("post")
|
|
16
|
+
head = font.has_table?("head") ? font.table("head") : nil
|
|
17
|
+
issues = []
|
|
18
|
+
issues.concat(validate_version(post))
|
|
19
|
+
issues.concat(validate_italic_angle(post, head))
|
|
20
|
+
issues.concat(validate_is_fixed_pitch(post))
|
|
21
|
+
issues
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.code
|
|
25
|
+
:ot_post
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.validate_version(post)
|
|
29
|
+
version = post.version.to_f
|
|
30
|
+
return [] if VALID_VERSIONS.include?(version)
|
|
31
|
+
|
|
32
|
+
[issue(severity: :error,
|
|
33
|
+
message: "post.version is #{version} but must be one of " \
|
|
34
|
+
"#{VALID_VERSIONS.join(', ')}",
|
|
35
|
+
location: "post.version")]
|
|
36
|
+
end
|
|
37
|
+
private_class_method :validate_version
|
|
38
|
+
|
|
39
|
+
def self.validate_italic_angle(post, head)
|
|
40
|
+
angle = post.italic_angle.to_f
|
|
41
|
+
issues = []
|
|
42
|
+
if head&.mac_style
|
|
43
|
+
is_italic = (head.mac_style.to_i & 0x02).positive?
|
|
44
|
+
if is_italic && angle >= 0
|
|
45
|
+
issues << issue(severity: :warning,
|
|
46
|
+
message: "post.italicAngle is #{angle} but " \
|
|
47
|
+
"head.macStyle indicates italic " \
|
|
48
|
+
"(angle should be negative)",
|
|
49
|
+
location: "post.italic_angle")
|
|
50
|
+
elsif !is_italic && angle != 0
|
|
51
|
+
issues << issue(severity: :info,
|
|
52
|
+
message: "post.italicAngle is #{angle} but " \
|
|
53
|
+
"head.macStyle does not indicate italic " \
|
|
54
|
+
"(angle should be 0)",
|
|
55
|
+
location: "post.italic_angle")
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
issues
|
|
59
|
+
end
|
|
60
|
+
private_class_method :validate_italic_angle
|
|
61
|
+
|
|
62
|
+
def self.validate_is_fixed_pitch(post)
|
|
63
|
+
val = post.is_fixed_pitch.to_i
|
|
64
|
+
return [] if [0, 1].include?(val)
|
|
65
|
+
|
|
66
|
+
[issue(severity: :warning,
|
|
67
|
+
message: "post.isFixedPitch is #{val} but must be 0 " \
|
|
68
|
+
"(proportional) or 1 (monospace)",
|
|
69
|
+
location: "post.is_fixed_pitch")]
|
|
70
|
+
end
|
|
71
|
+
private_class_method :validate_is_fixed_pitch
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
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
|