fontisan 0.4.36 → 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/TODO.improvements/README.md +1 -1
- data/lib/fontisan/audit/check_registry.rb +16 -2
- 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/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 +6 -60
- 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.rb +7 -0
- data/lib/fontisan/version.rb +1 -1
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 615c64e2005d769c01c4b7a5cd153630b42f5230c55a0242f892aaec65618320
|
|
4
|
+
data.tar.gz: deca6b51aba40a45b2150290fe5ef0bd94b935f4fd09c54da54e65bf8cde3194
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dc66734c722fd5fc959d80c95f41b701ecb7679926e85d6bbf09f9aab84c5ea96eb94f558ee241f40b88c999f21a811d9d45a306f35ec7b5cc61b188c053851e
|
|
7
|
+
data.tar.gz: 6ceebcf686badd81a1d934f8f2d03b607f29522bef598e85cd363c33d16a94f9df6d3fd048ebce38dfce103e5dc55efb7df6cbd55016220e564e4ca833490dd8
|
data/TODO.improvements/README.md
CHANGED
|
@@ -11,7 +11,7 @@ Each file is `NN-short-name.md` where `NN` is the priority order.
|
|
|
11
11
|
- [x] ~~[15 — CFF/CFF2 subsetter strategy](15-cff-cff2-subsetter-strategy.md)~~ ✓ Done — CFF via UFO round-trip, CFF2 via standalone INDEX filter with VStore preservation
|
|
12
12
|
|
|
13
13
|
### P1 — High-value features
|
|
14
|
-
- [x] ~~[03 — `fontisan audit` command (identity+style+features lens)](03-fontisan-audit-command.md)~~ ✓ Done (Phase 1: identity, style, coverage, layout, provenance; Phase 2 axes: table directory, glyph names, cmap, OTS, collection integrity; Phase 3 axes: variable-font, hinting, WOFF2, format-round-trip
|
|
14
|
+
- [x] ~~[03 — `fontisan audit` command (identity+style+features lens)](03-fontisan-audit-command.md)~~ ✓ Done (Phase 1: identity, style, coverage, layout, provenance; Phase 2 axes: table directory, glyph names, cmap, OTS, collection integrity; Phase 3 axes: variable-font, hinting, WOFF2, format-round-trip; Phase 3 axis 14 (full OT conformance): cross-table conformance + per-table checks for head, hhea, maxp, OS/2, name, post, kern; CLI `fontisan audit --validate` with profiles: default (17 checks), structural, ots, layout, variable, hinting, web, spec, per_table)
|
|
15
15
|
- [x] ~~[04 — UFO composite glyph encoding](04-ufo-composite-glyph-encoding.md)~~ ✓ Done
|
|
16
16
|
- [x] ~~[05 — OTF compiler real CFF charstrings](05-otf-compiler-real-cff.md)~~ ✓ Already working (discovered in PR #117 — Cff.build produces real Type 2 charstrings; stale TODO marker removed)
|
|
17
17
|
|
|
@@ -16,17 +16,24 @@ module Fontisan
|
|
|
16
16
|
# @example Run a specific profile
|
|
17
17
|
# CheckRegistry.for(:ots) # => [OtsCompatibilityCheck]
|
|
18
18
|
class CheckRegistry
|
|
19
|
+
# Per-table OT conformance checks (axis 14 — full OT spec coverage).
|
|
20
|
+
OT_TABLE_CHECKS = %i[ot_head ot_hhea ot_maxp ot_os2 ot_name ot_post
|
|
21
|
+
ot_kern].freeze
|
|
22
|
+
|
|
19
23
|
PROFILES = {
|
|
20
24
|
default: %i[table_directory glyph_names cmap ots_compatibility
|
|
21
25
|
collection_integrity variable_font hinting woff2_validation
|
|
22
|
-
format_round_trip opentype_conformance
|
|
26
|
+
format_round_trip opentype_conformance
|
|
27
|
+
ot_head ot_hhea ot_maxp ot_os2 ot_name ot_post ot_kern],
|
|
23
28
|
structural: %i[table_directory collection_integrity opentype_conformance],
|
|
24
29
|
ots: %i[ots_compatibility],
|
|
25
30
|
layout: %i[glyph_names cmap],
|
|
26
31
|
variable: %i[variable_font],
|
|
27
32
|
hinting: %i[hinting],
|
|
28
33
|
web: %i[ots_compatibility woff2_validation],
|
|
29
|
-
spec: %i[opentype_conformance
|
|
34
|
+
spec: %i[opentype_conformance ot_head ot_hhea ot_maxp ot_os2
|
|
35
|
+
ot_name ot_post ot_kern],
|
|
36
|
+
per_table: OT_TABLE_CHECKS,
|
|
30
37
|
}.freeze
|
|
31
38
|
|
|
32
39
|
# @param profile [Symbol]
|
|
@@ -50,6 +57,13 @@ module Fontisan
|
|
|
50
57
|
when :woff2_validation then Checks::Woff2ValidationCheck
|
|
51
58
|
when :format_round_trip then Checks::FormatRoundTripCheck
|
|
52
59
|
when :opentype_conformance then Checks::OpenTypeConformanceCheck
|
|
60
|
+
when :ot_head then Checks::HeadCheck
|
|
61
|
+
when :ot_hhea then Checks::HheaCheck
|
|
62
|
+
when :ot_maxp then Checks::MaxpCheck
|
|
63
|
+
when :ot_os2 then Checks::Os2Check
|
|
64
|
+
when :ot_name then Checks::NameTableCheck
|
|
65
|
+
when :ot_post then Checks::PostCheck
|
|
66
|
+
when :ot_kern then Checks::KernCheck
|
|
53
67
|
end
|
|
54
68
|
end
|
|
55
69
|
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Audit
|
|
5
|
+
module Checks
|
|
6
|
+
# Validates the 'head' table per the OpenType spec.
|
|
7
|
+
#
|
|
8
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/head
|
|
9
|
+
class HeadCheck < Check
|
|
10
|
+
HEAD_MAGIC = 0x5F0F3CF5
|
|
11
|
+
MIN_UPM = 16
|
|
12
|
+
MAX_UPM = 16_384
|
|
13
|
+
MAC_STYLE_DEFINED_BITS = 0x00FF
|
|
14
|
+
MAC_STYLE_RESERVED_BITS = 0xFF00
|
|
15
|
+
|
|
16
|
+
def self.call(font)
|
|
17
|
+
return [] unless font.has_table?("head")
|
|
18
|
+
|
|
19
|
+
head = font.table("head")
|
|
20
|
+
issues = []
|
|
21
|
+
issues.concat(validate_magic(head))
|
|
22
|
+
issues.concat(validate_upm(head))
|
|
23
|
+
issues.concat(validate_loc_format(head))
|
|
24
|
+
issues.concat(validate_dates(head))
|
|
25
|
+
issues.concat(validate_mac_style(head))
|
|
26
|
+
issues
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.code
|
|
30
|
+
:ot_head
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.validate_magic(head)
|
|
34
|
+
return [] if head.magic_number == HEAD_MAGIC
|
|
35
|
+
|
|
36
|
+
[issue(severity: :error,
|
|
37
|
+
message: "head.magicNumber is 0x#{head.magic_number.to_i.to_s(16)} " \
|
|
38
|
+
"but must be 0x#{HEAD_MAGIC.to_s(16)}",
|
|
39
|
+
location: "head.magic_number")]
|
|
40
|
+
end
|
|
41
|
+
private_class_method :validate_magic
|
|
42
|
+
|
|
43
|
+
def self.validate_upm(head)
|
|
44
|
+
upm = head.units_per_em.to_i
|
|
45
|
+
return [] if upm.between?(MIN_UPM, MAX_UPM)
|
|
46
|
+
|
|
47
|
+
[issue(severity: :error,
|
|
48
|
+
message: "head.unitsPerEm is #{upm} but must be between " \
|
|
49
|
+
"#{MIN_UPM} and #{MAX_UPM}",
|
|
50
|
+
location: "head.units_per_em")]
|
|
51
|
+
end
|
|
52
|
+
private_class_method :validate_upm
|
|
53
|
+
|
|
54
|
+
def self.validate_loc_format(head)
|
|
55
|
+
fmt = head.index_to_loc_format.to_i
|
|
56
|
+
return [] if [0, 1].include?(fmt)
|
|
57
|
+
|
|
58
|
+
[issue(severity: :error,
|
|
59
|
+
message: "head.indexToLocFormat is #{fmt} but must be 0 " \
|
|
60
|
+
"(short) or 1 (long)",
|
|
61
|
+
location: "head.index_to_loc_format")]
|
|
62
|
+
end
|
|
63
|
+
private_class_method :validate_loc_format
|
|
64
|
+
|
|
65
|
+
def self.validate_dates(head)
|
|
66
|
+
issues = []
|
|
67
|
+
if head.created_raw.to_i.zero?
|
|
68
|
+
issues << issue(severity: :info,
|
|
69
|
+
message: "head.created is 0 — creation date not set",
|
|
70
|
+
location: "head.created")
|
|
71
|
+
end
|
|
72
|
+
if head.modified_raw.to_i.zero?
|
|
73
|
+
issues << issue(severity: :info,
|
|
74
|
+
message: "head.modified is 0 — modification date not set",
|
|
75
|
+
location: "head.modified")
|
|
76
|
+
end
|
|
77
|
+
issues
|
|
78
|
+
end
|
|
79
|
+
private_class_method :validate_dates
|
|
80
|
+
|
|
81
|
+
def self.validate_mac_style(head)
|
|
82
|
+
style = head.mac_style.to_i
|
|
83
|
+
issues = []
|
|
84
|
+
unless (style & MAC_STYLE_RESERVED_BITS).zero?
|
|
85
|
+
issues << issue(severity: :warning,
|
|
86
|
+
message: "head.macStyle uses reserved bits " \
|
|
87
|
+
"(0x#{style.to_s(16)}, bits 8-15 must be 0)",
|
|
88
|
+
location: "head.mac_style")
|
|
89
|
+
end
|
|
90
|
+
issues
|
|
91
|
+
end
|
|
92
|
+
private_class_method :validate_mac_style
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Audit
|
|
5
|
+
module Checks
|
|
6
|
+
# Validates the 'hhea' table per the OpenType spec.
|
|
7
|
+
#
|
|
8
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/hhea
|
|
9
|
+
class HheaCheck < Check
|
|
10
|
+
def self.call(font)
|
|
11
|
+
return [] unless font.has_table?("hhea")
|
|
12
|
+
|
|
13
|
+
hhea = font.table("hhea")
|
|
14
|
+
issues = []
|
|
15
|
+
issues.concat(validate_ascent(hhea))
|
|
16
|
+
issues.concat(validate_descent(hhea))
|
|
17
|
+
issues.concat(validate_line_gap(hhea))
|
|
18
|
+
issues.concat(validate_advance_width_max(hhea))
|
|
19
|
+
issues
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.code
|
|
23
|
+
:ot_hhea
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.validate_ascent(hhea)
|
|
27
|
+
return [] if hhea.ascent.to_i.positive?
|
|
28
|
+
|
|
29
|
+
[issue(severity: :warning,
|
|
30
|
+
message: "hhea.ascent is #{hhea.ascent} but should be positive " \
|
|
31
|
+
"(ascender typically rises above the baseline)",
|
|
32
|
+
location: "hhea.ascent")]
|
|
33
|
+
end
|
|
34
|
+
private_class_method :validate_ascent
|
|
35
|
+
|
|
36
|
+
def self.validate_descent(hhea)
|
|
37
|
+
return [] if hhea.descent.to_i <= 0
|
|
38
|
+
|
|
39
|
+
[issue(severity: :warning,
|
|
40
|
+
message: "hhea.descent is #{hhea.descent} but should be ≤ 0 " \
|
|
41
|
+
"(descender typically falls below the baseline)",
|
|
42
|
+
location: "hhea.descent")]
|
|
43
|
+
end
|
|
44
|
+
private_class_method :validate_descent
|
|
45
|
+
|
|
46
|
+
def self.validate_line_gap(hhea)
|
|
47
|
+
return [] if hhea.line_gap.to_i >= 0
|
|
48
|
+
|
|
49
|
+
[issue(severity: :info,
|
|
50
|
+
message: "hhea.lineGap is #{hhea.line_gap} — negative line " \
|
|
51
|
+
"gap is unusual but not spec-prohibited",
|
|
52
|
+
location: "hhea.line_gap")]
|
|
53
|
+
end
|
|
54
|
+
private_class_method :validate_line_gap
|
|
55
|
+
|
|
56
|
+
def self.validate_advance_width_max(hhea)
|
|
57
|
+
return [] unless hhea.advance_width_max.to_i <= 0
|
|
58
|
+
|
|
59
|
+
[issue(severity: :warning,
|
|
60
|
+
message: "hhea.advanceWidthMax is #{hhea.advance_width_max} " \
|
|
61
|
+
"but should be positive (the widest glyph advance)",
|
|
62
|
+
location: "hhea.advance_width_max")]
|
|
63
|
+
end
|
|
64
|
+
private_class_method :validate_advance_width_max
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -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
|
|
@@ -3,44 +3,25 @@
|
|
|
3
3
|
module Fontisan
|
|
4
4
|
module Audit
|
|
5
5
|
module Checks
|
|
6
|
-
# Foundational OpenType spec conformance checks.
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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.
|
|
9
10
|
#
|
|
10
|
-
# This check
|
|
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:
|
|
11
|
+
# This check owns the rules that span multiple tables:
|
|
16
12
|
#
|
|
17
13
|
# - 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
|
|
14
|
+
# - head.indexToLocFormat consistency with loca table byte size
|
|
22
15
|
# - 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
16
|
#
|
|
27
17
|
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/otff
|
|
28
18
|
class OpenTypeConformanceCheck < Check
|
|
29
|
-
REQUIRED_NAME_IDS = {
|
|
30
|
-
1 => "Family",
|
|
31
|
-
2 => "Subfamily",
|
|
32
|
-
4 => "Full",
|
|
33
|
-
6 => "PostScript",
|
|
34
|
-
}.freeze
|
|
35
|
-
|
|
36
19
|
# @param font [SfntFont]
|
|
37
20
|
# @return [Array<Models::ValidationReport::Issue>]
|
|
38
21
|
def self.call(font)
|
|
39
22
|
issues = []
|
|
40
23
|
issues.concat(validate_required_tables(font))
|
|
41
24
|
issues.concat(validate_loca_format(font))
|
|
42
|
-
issues.concat(validate_name_coverage(font))
|
|
43
|
-
issues.concat(validate_os2_fsselection(font))
|
|
44
25
|
issues.concat(validate_hhea_metrics(font))
|
|
45
26
|
issues
|
|
46
27
|
end
|
|
@@ -103,41 +84,6 @@ module Fontisan
|
|
|
103
84
|
end
|
|
104
85
|
private_class_method :validate_loca_format
|
|
105
86
|
|
|
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
87
|
# ---------- hhea.numberOfHMetrics ----------
|
|
142
88
|
|
|
143
89
|
def self.validate_hhea_metrics(font)
|
|
@@ -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
|
|
@@ -25,6 +25,13 @@ module Fontisan
|
|
|
25
25
|
"fontisan/audit/checks/format_round_trip_check"
|
|
26
26
|
autoload :OpenTypeConformanceCheck,
|
|
27
27
|
"fontisan/audit/checks/opentype_conformance_check"
|
|
28
|
+
autoload :HeadCheck, "fontisan/audit/checks/head_check"
|
|
29
|
+
autoload :HheaCheck, "fontisan/audit/checks/hhea_check"
|
|
30
|
+
autoload :MaxpCheck, "fontisan/audit/checks/maxp_check"
|
|
31
|
+
autoload :Os2Check, "fontisan/audit/checks/os2_check"
|
|
32
|
+
autoload :NameTableCheck, "fontisan/audit/checks/name_table_check"
|
|
33
|
+
autoload :PostCheck, "fontisan/audit/checks/post_check"
|
|
34
|
+
autoload :KernCheck, "fontisan/audit/checks/kern_check"
|
|
28
35
|
end
|
|
29
36
|
end
|
|
30
37
|
end
|
data/lib/fontisan/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: fontisan
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.4.
|
|
4
|
+
version: 0.4.37
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
@@ -301,9 +301,16 @@ files:
|
|
|
301
301
|
- lib/fontisan/audit/checks/collection_integrity_check.rb
|
|
302
302
|
- lib/fontisan/audit/checks/format_round_trip_check.rb
|
|
303
303
|
- lib/fontisan/audit/checks/glyph_name_check.rb
|
|
304
|
+
- lib/fontisan/audit/checks/head_check.rb
|
|
305
|
+
- lib/fontisan/audit/checks/hhea_check.rb
|
|
304
306
|
- lib/fontisan/audit/checks/hinting_check.rb
|
|
307
|
+
- lib/fontisan/audit/checks/kern_check.rb
|
|
308
|
+
- lib/fontisan/audit/checks/maxp_check.rb
|
|
309
|
+
- lib/fontisan/audit/checks/name_table_check.rb
|
|
305
310
|
- lib/fontisan/audit/checks/opentype_conformance_check.rb
|
|
311
|
+
- lib/fontisan/audit/checks/os2_check.rb
|
|
306
312
|
- lib/fontisan/audit/checks/ots_compatibility_check.rb
|
|
313
|
+
- lib/fontisan/audit/checks/post_check.rb
|
|
307
314
|
- lib/fontisan/audit/checks/table_directory_check.rb
|
|
308
315
|
- lib/fontisan/audit/checks/variable_font_check.rb
|
|
309
316
|
- lib/fontisan/audit/checks/woff2_validation_check.rb
|