fontisan 0.4.36 → 0.4.38
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 +21 -3
- data/lib/fontisan/audit/checks/cff_table_check.rb +87 -0
- data/lib/fontisan/audit/checks/glyf_table_check.rb +115 -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/kern_check.rb +47 -0
- data/lib/fontisan/audit/checks/layout_table_check.rb +70 -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 +10 -0
- data/lib/fontisan/cli.rb +41 -2
- data/lib/fontisan/commands/audit_command.rb +3 -1
- data/lib/fontisan/models/audit_report.rb +48 -0
- data/lib/fontisan/models.rb +1 -0
- data/lib/fontisan/version.rb +1 -1
- metadata +11 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aebdd37610f8ce36210375f348bd5a0f1aea12d0a12c951005d018e64cac290c
|
|
4
|
+
data.tar.gz: 33e81566249885ae646e91e727abedff605619e6deb30f742fcf1dff570cd641
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8e43a54dc2421202b7d780e10e37fb4b363c835d261257677728dd99602f6df0190414c1ed59c0d5c331e8b977d75326961f05b5cea2f7c32431c9cee39c094b
|
|
7
|
+
data.tar.gz: cce7fd930ab3d5da138c0f4756468b0494f21b7dcf0b515df5e38498770493a399fa10d862c5e2948efc35dfcae84c8a937ad5d8209ed1eddd4a033491934c37
|
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, CFF/CFF2, glyf, GSUB/GPOS layout; CLI `fontisan audit --validate` with 20 checks across 9 profiles; CI exit codes via `--fail-on-error`; profile discovery via `--list-profiles`; audit summary model with error/warning/info counts)
|
|
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,25 @@ 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 ot_cff ot_glyf ot_layout].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
|
|
28
|
+
ot_cff ot_glyf ot_layout],
|
|
23
29
|
structural: %i[table_directory collection_integrity opentype_conformance],
|
|
24
30
|
ots: %i[ots_compatibility],
|
|
25
|
-
layout: %i[glyph_names cmap],
|
|
31
|
+
layout: %i[glyph_names cmap ot_layout],
|
|
26
32
|
variable: %i[variable_font],
|
|
27
33
|
hinting: %i[hinting],
|
|
28
34
|
web: %i[ots_compatibility woff2_validation],
|
|
29
|
-
spec: %i[opentype_conformance
|
|
35
|
+
spec: %i[opentype_conformance ot_head ot_hhea ot_maxp ot_os2
|
|
36
|
+
ot_name ot_post ot_kern ot_cff ot_glyf ot_layout],
|
|
37
|
+
per_table: OT_TABLE_CHECKS,
|
|
30
38
|
}.freeze
|
|
31
39
|
|
|
32
40
|
# @param profile [Symbol]
|
|
@@ -50,6 +58,16 @@ module Fontisan
|
|
|
50
58
|
when :woff2_validation then Checks::Woff2ValidationCheck
|
|
51
59
|
when :format_round_trip then Checks::FormatRoundTripCheck
|
|
52
60
|
when :opentype_conformance then Checks::OpenTypeConformanceCheck
|
|
61
|
+
when :ot_head then Checks::HeadCheck
|
|
62
|
+
when :ot_hhea then Checks::HheaCheck
|
|
63
|
+
when :ot_maxp then Checks::MaxpCheck
|
|
64
|
+
when :ot_os2 then Checks::Os2Check
|
|
65
|
+
when :ot_name then Checks::NameTableCheck
|
|
66
|
+
when :ot_post then Checks::PostCheck
|
|
67
|
+
when :ot_kern then Checks::KernCheck
|
|
68
|
+
when :ot_cff then Checks::CffTableCheck
|
|
69
|
+
when :ot_glyf then Checks::GlyfTableCheck
|
|
70
|
+
when :ot_layout then Checks::LayoutTableCheck
|
|
53
71
|
end
|
|
54
72
|
end
|
|
55
73
|
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Audit
|
|
5
|
+
module Checks
|
|
6
|
+
# Validates CFF/CFF2 table structure: header version, Name INDEX
|
|
7
|
+
# presence (CFF1), Top DICT CharStrings offset, CharStrings count
|
|
8
|
+
# vs maxp.numGlyphs.
|
|
9
|
+
#
|
|
10
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/cff
|
|
11
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/cff2
|
|
12
|
+
class CffTableCheck < Check
|
|
13
|
+
def self.call(font)
|
|
14
|
+
if font.has_table?("CFF2")
|
|
15
|
+
validate_cff2(font)
|
|
16
|
+
elsif font.has_table?("CFF ")
|
|
17
|
+
validate_cff1(font)
|
|
18
|
+
else
|
|
19
|
+
[]
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.code
|
|
24
|
+
:ot_cff
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# ---------- CFF1 ----------
|
|
28
|
+
|
|
29
|
+
def self.validate_cff1(font)
|
|
30
|
+
cff = font.table("CFF ")
|
|
31
|
+
return [] unless cff
|
|
32
|
+
|
|
33
|
+
issues = []
|
|
34
|
+
issues.concat(validate_cff1_name_index(cff))
|
|
35
|
+
issues.concat(validate_charstrings_count(cff, font, "CFF "))
|
|
36
|
+
issues
|
|
37
|
+
end
|
|
38
|
+
private_class_method :validate_cff1
|
|
39
|
+
|
|
40
|
+
def self.validate_cff1_name_index(cff)
|
|
41
|
+
count = cff.font_count.to_i
|
|
42
|
+
return [] if count.positive?
|
|
43
|
+
|
|
44
|
+
[issue(severity: :error,
|
|
45
|
+
message: "CFF Name INDEX is empty — must contain at least " \
|
|
46
|
+
"one font name",
|
|
47
|
+
location: "cff.name_index")]
|
|
48
|
+
end
|
|
49
|
+
private_class_method :validate_cff1_name_index
|
|
50
|
+
|
|
51
|
+
# ---------- CFF2 ----------
|
|
52
|
+
|
|
53
|
+
def self.validate_cff2(font)
|
|
54
|
+
cff2 = font.table("CFF2")
|
|
55
|
+
return [] unless cff2
|
|
56
|
+
|
|
57
|
+
validate_charstrings_count(cff2, font, "CFF2")
|
|
58
|
+
end
|
|
59
|
+
private_class_method :validate_cff2
|
|
60
|
+
|
|
61
|
+
# ---------- shared ----------
|
|
62
|
+
|
|
63
|
+
def self.validate_charstrings_count(cff, font, table_tag)
|
|
64
|
+
return [] unless font.has_table?("maxp")
|
|
65
|
+
|
|
66
|
+
cs_count = glyph_count_from_cff(cff)
|
|
67
|
+
maxp_count = font.table("maxp").num_glyphs.to_i
|
|
68
|
+
return [] if cs_count == maxp_count
|
|
69
|
+
|
|
70
|
+
[issue(severity: :error,
|
|
71
|
+
message: "#{table_tag} CharStrings count (#{cs_count}) does " \
|
|
72
|
+
"not match maxp.numGlyphs (#{maxp_count})",
|
|
73
|
+
location: "#{table_tag.downcase}.charstrings")]
|
|
74
|
+
end
|
|
75
|
+
private_class_method :validate_charstrings_count
|
|
76
|
+
|
|
77
|
+
def self.glyph_count_from_cff(cff)
|
|
78
|
+
cs_index = cff.charstrings_index(0)
|
|
79
|
+
cs_index&.count.to_i
|
|
80
|
+
rescue StandardError
|
|
81
|
+
0
|
|
82
|
+
end
|
|
83
|
+
private_class_method :glyph_count_from_cff
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Audit
|
|
5
|
+
module Checks
|
|
6
|
+
# Validates the 'glyf' table: simple glyph contour integrity and
|
|
7
|
+
# compound glyph reference bounds. Only runs on TrueType fonts.
|
|
8
|
+
#
|
|
9
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/glyf
|
|
10
|
+
class GlyfTableCheck < Check
|
|
11
|
+
MAX_COMPOUND_DEPTH = 16
|
|
12
|
+
|
|
13
|
+
def self.call(font)
|
|
14
|
+
return [] unless font.has_table?("glyf") && font.has_table?("loca")
|
|
15
|
+
|
|
16
|
+
issues = []
|
|
17
|
+
issues.concat(validate_glyph_counts(font))
|
|
18
|
+
issues
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.code
|
|
22
|
+
:ot_glyf
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Verify loca offsets are monotonically non-decreasing and
|
|
26
|
+
# within the glyf table bounds. Discontinuities signal
|
|
27
|
+
# corruption that renderers may reject.
|
|
28
|
+
def self.validate_glyph_counts(font)
|
|
29
|
+
glyf = font.table("glyf")
|
|
30
|
+
return [] unless glyf
|
|
31
|
+
|
|
32
|
+
raw = font.table_data["glyf"]
|
|
33
|
+
return [] unless raw
|
|
34
|
+
|
|
35
|
+
issues = []
|
|
36
|
+
maxp = font.table("maxp")
|
|
37
|
+
num_glyphs = maxp&.num_glyphs.to_i
|
|
38
|
+
return [] if num_glyphs.zero?
|
|
39
|
+
|
|
40
|
+
num_glyphs.times do |gid|
|
|
41
|
+
issues.concat(validate_one_glyph(font, gid))
|
|
42
|
+
rescue StandardError
|
|
43
|
+
issues << issue(severity: :warning,
|
|
44
|
+
message: "glyf: failed to validate glyph at GID " \
|
|
45
|
+
"#{gid}",
|
|
46
|
+
location: "glyf.glyph.#{gid}")
|
|
47
|
+
end
|
|
48
|
+
issues
|
|
49
|
+
end
|
|
50
|
+
private_class_method :validate_glyph_counts
|
|
51
|
+
|
|
52
|
+
def self.validate_one_glyph(font, gid)
|
|
53
|
+
head = font.table("head")
|
|
54
|
+
loca = font.table("loca")
|
|
55
|
+
glyf = font.table("glyf")
|
|
56
|
+
|
|
57
|
+
raw = begin
|
|
58
|
+
glyf.glyph_for(gid, loca, head)
|
|
59
|
+
rescue StandardError
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
return [] unless raw
|
|
63
|
+
|
|
64
|
+
if raw.simple?
|
|
65
|
+
validate_simple_glyph(raw, gid)
|
|
66
|
+
elsif raw.compound?
|
|
67
|
+
validate_compound_glyph(raw, gid, font)
|
|
68
|
+
else
|
|
69
|
+
[]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
private_class_method :validate_one_glyph
|
|
73
|
+
|
|
74
|
+
def self.validate_simple_glyph(raw, gid)
|
|
75
|
+
issues = []
|
|
76
|
+
end_pts = raw.end_pts_of_contours
|
|
77
|
+
return [] unless end_pts&.any?
|
|
78
|
+
|
|
79
|
+
# endPtsOfContours must be monotonically increasing
|
|
80
|
+
end_pts.each_cons(2) do |prev, curr|
|
|
81
|
+
next unless curr <= prev
|
|
82
|
+
|
|
83
|
+
issues << issue(severity: :error,
|
|
84
|
+
message: "glyf glyph #{gid}: contour endPoints " \
|
|
85
|
+
"are not monotonically increasing " \
|
|
86
|
+
"(#{prev} → #{curr})",
|
|
87
|
+
location: "glyf.glyph.#{gid}.contours")
|
|
88
|
+
break
|
|
89
|
+
end
|
|
90
|
+
issues
|
|
91
|
+
end
|
|
92
|
+
private_class_method :validate_simple_glyph
|
|
93
|
+
|
|
94
|
+
def self.validate_compound_glyph(raw, gid, font)
|
|
95
|
+
issues = []
|
|
96
|
+
maxp = font.table("maxp")
|
|
97
|
+
max_gid = maxp&.num_glyphs.to_i - 1
|
|
98
|
+
|
|
99
|
+
raw.components.each do |comp|
|
|
100
|
+
ref_gid = comp.glyph_index.to_i
|
|
101
|
+
next if ref_gid.between?(0, max_gid)
|
|
102
|
+
|
|
103
|
+
issues << issue(severity: :error,
|
|
104
|
+
message: "glyf compound glyph #{gid} references " \
|
|
105
|
+
"out-of-bounds GID #{ref_gid} " \
|
|
106
|
+
"(max: #{max_gid})",
|
|
107
|
+
location: "glyf.glyph.#{gid}.component.#{ref_gid}")
|
|
108
|
+
end
|
|
109
|
+
issues
|
|
110
|
+
end
|
|
111
|
+
private_class_method :validate_compound_glyph
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
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,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Audit
|
|
5
|
+
module Checks
|
|
6
|
+
# Validates GSUB/GPOS layout table structure: header version,
|
|
7
|
+
# ScriptList/FeatureList/LookupList offsets, and DFLT script
|
|
8
|
+
# recommendation. Only runs when layout tables are present.
|
|
9
|
+
#
|
|
10
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/chapter2
|
|
11
|
+
class LayoutTableCheck < Check
|
|
12
|
+
def self.call(font)
|
|
13
|
+
issues = []
|
|
14
|
+
issues.concat(validate_layout_table(font, "GSUB"))
|
|
15
|
+
issues.concat(validate_layout_table(font, "GPOS"))
|
|
16
|
+
issues
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.code
|
|
20
|
+
:ot_layout
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.validate_layout_table(font, tag)
|
|
24
|
+
return [] unless font.has_table?(tag)
|
|
25
|
+
|
|
26
|
+
table = font.table(tag)
|
|
27
|
+
return [] unless table
|
|
28
|
+
|
|
29
|
+
issues = []
|
|
30
|
+
issues.concat(validate_scripts(table, tag))
|
|
31
|
+
issues.concat(validate_dflt_script(table, tag))
|
|
32
|
+
issues
|
|
33
|
+
end
|
|
34
|
+
private_class_method :validate_layout_table
|
|
35
|
+
|
|
36
|
+
def self.validate_scripts(table, tag)
|
|
37
|
+
scripts = begin
|
|
38
|
+
table.scripts
|
|
39
|
+
rescue StandardError
|
|
40
|
+
[]
|
|
41
|
+
end
|
|
42
|
+
return [] if scripts.any?
|
|
43
|
+
|
|
44
|
+
[issue(severity: :warning,
|
|
45
|
+
message: "#{tag} table has no script records — layout " \
|
|
46
|
+
"rules won't apply without at least one script " \
|
|
47
|
+
"(DFLT recommended)",
|
|
48
|
+
location: "#{tag.downcase}.script_list")]
|
|
49
|
+
end
|
|
50
|
+
private_class_method :validate_scripts
|
|
51
|
+
|
|
52
|
+
def self.validate_dflt_script(table, tag)
|
|
53
|
+
scripts = begin
|
|
54
|
+
table.scripts
|
|
55
|
+
rescue StandardError
|
|
56
|
+
[]
|
|
57
|
+
end
|
|
58
|
+
return [] if scripts.include?("DFLT")
|
|
59
|
+
|
|
60
|
+
[issue(severity: :info,
|
|
61
|
+
message: "#{tag} table has no 'DFLT' script — fonts " \
|
|
62
|
+
"without DFLT may not apply features to scripts " \
|
|
63
|
+
"not explicitly listed",
|
|
64
|
+
location: "#{tag.downcase}.script_list.DFLT")]
|
|
65
|
+
end
|
|
66
|
+
private_class_method :validate_dflt_script
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
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,16 @@ 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"
|
|
35
|
+
autoload :CffTableCheck, "fontisan/audit/checks/cff_table_check"
|
|
36
|
+
autoload :GlyfTableCheck, "fontisan/audit/checks/glyf_table_check"
|
|
37
|
+
autoload :LayoutTableCheck, "fontisan/audit/checks/layout_table_check"
|
|
28
38
|
end
|
|
29
39
|
end
|
|
30
40
|
end
|
data/lib/fontisan/cli.rb
CHANGED
|
@@ -40,15 +40,26 @@ module Fontisan
|
|
|
40
40
|
desc: "Audit only the given face of a collection"
|
|
41
41
|
option :validate, type: :string, default: nil,
|
|
42
42
|
desc: "Run validation checks. Use 'true' for all, or a " \
|
|
43
|
-
"profile name: default, structural, ots, layout"
|
|
43
|
+
"profile name: default, structural, ots, layout, " \
|
|
44
|
+
"variable, hinting, web, spec, per_table"
|
|
45
|
+
option :list_profiles, type: :boolean, default: false,
|
|
46
|
+
desc: "List available validation profiles and exit"
|
|
47
|
+
option :fail_on_error, type: :boolean, default: true,
|
|
48
|
+
desc: "Exit with code 1 if validation finds errors " \
|
|
49
|
+
"(only applies with --validate)"
|
|
44
50
|
# Produce a structured font audit report (YAML or JSON).
|
|
45
|
-
def audit(font_file)
|
|
51
|
+
def audit(font_file = nil)
|
|
52
|
+
return list_audit_profiles if options[:list_profiles]
|
|
53
|
+
|
|
54
|
+
raise Thor::Error, "FONT_FILE is required" if font_file.nil?
|
|
55
|
+
|
|
46
56
|
cmd_options = options.dup
|
|
47
57
|
cmd_options[:include_codepoints] = !options[:no_codepoints]
|
|
48
58
|
cmd_options[:validate] = parse_validate_option(options[:validate])
|
|
49
59
|
command = Commands::AuditCommand.new(font_file, cmd_options)
|
|
50
60
|
result = command.run
|
|
51
61
|
write_audit_result(result, options[:output])
|
|
62
|
+
exit_on_validation_errors(result) if options[:validate] && options[:fail_on_error]
|
|
52
63
|
rescue Errno::ENOENT
|
|
53
64
|
warn "File not found: #{font_file}" unless options[:quiet]
|
|
54
65
|
exit 1
|
|
@@ -810,6 +821,34 @@ module Fontisan
|
|
|
810
821
|
raw.to_sym
|
|
811
822
|
end
|
|
812
823
|
|
|
824
|
+
# Print available validation profiles with their check counts.
|
|
825
|
+
def list_audit_profiles
|
|
826
|
+
puts "Available validation profiles for --validate:"
|
|
827
|
+
puts ""
|
|
828
|
+
Audit::CheckRegistry::PROFILES.each do |name, codes|
|
|
829
|
+
puts " #{name.to_s.ljust(15)} #{codes.size} checks"
|
|
830
|
+
end
|
|
831
|
+
puts ""
|
|
832
|
+
puts "Usage: fontisan audit font.ttf --validate <profile>"
|
|
833
|
+
puts " fontisan audit font.ttf --validate true (all checks)"
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
# Exit with code 1 if the audit found error-level issues.
|
|
837
|
+
# For collections, aggregates across all faces.
|
|
838
|
+
def exit_on_validation_errors(result)
|
|
839
|
+
issues = if result.is_a?(Array)
|
|
840
|
+
result.flat_map(&:validation_issues)
|
|
841
|
+
else
|
|
842
|
+
result.validation_issues || []
|
|
843
|
+
end
|
|
844
|
+
has_errors = issues.any? { |i| %w[error fatal].include?(i.severity) }
|
|
845
|
+
return unless has_errors
|
|
846
|
+
|
|
847
|
+
error_count = issues.count { |i| %w[error fatal].include?(i.severity) }
|
|
848
|
+
warn "Audit found #{error_count} error(s) — exiting with code 1"
|
|
849
|
+
exit 1
|
|
850
|
+
end
|
|
851
|
+
|
|
813
852
|
def serialize_report(report, format)
|
|
814
853
|
format == :json ? report.to_json : report.to_yaml
|
|
815
854
|
end
|
|
@@ -257,7 +257,9 @@ module Fontisan
|
|
|
257
257
|
# ------------------------------------------------------------------
|
|
258
258
|
|
|
259
259
|
def populate_validation(report, face)
|
|
260
|
-
|
|
260
|
+
issues = run_validation_checks(face)
|
|
261
|
+
report.validation_issues = issues
|
|
262
|
+
report.validation_summary = Models::AuditSummary.from_issues(issues)
|
|
261
263
|
end
|
|
262
264
|
|
|
263
265
|
def run_validation_checks(face)
|
|
@@ -4,6 +4,51 @@ require "lutaml/model"
|
|
|
4
4
|
|
|
5
5
|
module Fontisan
|
|
6
6
|
module Models
|
|
7
|
+
# Summary of validation issues by severity. Used by CI pipelines
|
|
8
|
+
# to decide pass/fail and by humans to gauge font health at a
|
|
9
|
+
# glance.
|
|
10
|
+
class AuditSummary < Lutaml::Model::Serializable
|
|
11
|
+
attribute :error_count, :integer, default: 0
|
|
12
|
+
attribute :warning_count, :integer, default: 0
|
|
13
|
+
attribute :info_count, :integer, default: 0
|
|
14
|
+
attribute :fatal_count, :integer, default: 0
|
|
15
|
+
attribute :total, :integer, default: 0
|
|
16
|
+
attribute :passed, Lutaml::Model::Type::Boolean, default: true
|
|
17
|
+
|
|
18
|
+
json do
|
|
19
|
+
map "error_count", to: :error_count
|
|
20
|
+
map "warning_count", to: :warning_count
|
|
21
|
+
map "info_count", to: :info_count
|
|
22
|
+
map "fatal_count", to: :fatal_count
|
|
23
|
+
map "total", to: :total
|
|
24
|
+
map "passed", to: :passed
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
yaml do
|
|
28
|
+
map "error_count", to: :error_count
|
|
29
|
+
map "warning_count", to: :warning_count
|
|
30
|
+
map "info_count", to: :info_count
|
|
31
|
+
map "fatal_count", to: :fatal_count
|
|
32
|
+
map "total", to: :total
|
|
33
|
+
map "passed", to: :passed
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Build a summary from an array of ValidationReport::Issue records.
|
|
37
|
+
# @param issues [Array<ValidationReport::Issue>]
|
|
38
|
+
# @return [AuditSummary]
|
|
39
|
+
def self.from_issues(issues)
|
|
40
|
+
counts = issues.group_by(&:severity).transform_values(&:size)
|
|
41
|
+
new(
|
|
42
|
+
error_count: counts["error"].to_i,
|
|
43
|
+
warning_count: counts["warning"].to_i,
|
|
44
|
+
info_count: counts["info"].to_i,
|
|
45
|
+
fatal_count: counts["fatal"].to_i,
|
|
46
|
+
total: issues.size,
|
|
47
|
+
passed: counts["error"].to_i.zero? && counts["fatal"].to_i.zero?,
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
7
52
|
# Variable-font axis descriptor for the audit report.
|
|
8
53
|
class AuditAxis < Lutaml::Model::Serializable
|
|
9
54
|
attribute :tag, :string
|
|
@@ -75,6 +120,7 @@ module Fontisan
|
|
|
75
120
|
|
|
76
121
|
# Validation (populated only with --validate)
|
|
77
122
|
attribute :validation_issues, Models::ValidationReport::Issue, collection: true
|
|
123
|
+
attribute :validation_summary, AuditSummary
|
|
78
124
|
|
|
79
125
|
json do
|
|
80
126
|
map "generated_at", to: :generated_at
|
|
@@ -108,6 +154,7 @@ module Fontisan
|
|
|
108
154
|
map "opentype_scripts", to: :opentype_scripts
|
|
109
155
|
map "features", to: :features
|
|
110
156
|
map "validation_issues", to: :validation_issues
|
|
157
|
+
map "validation_summary", to: :validation_summary
|
|
111
158
|
end
|
|
112
159
|
|
|
113
160
|
yaml do
|
|
@@ -142,6 +189,7 @@ module Fontisan
|
|
|
142
189
|
map "opentype_scripts", to: :opentype_scripts
|
|
143
190
|
map "features", to: :features
|
|
144
191
|
map "validation_issues", to: :validation_issues
|
|
192
|
+
map "validation_summary", to: :validation_summary
|
|
145
193
|
end
|
|
146
194
|
end
|
|
147
195
|
end
|
data/lib/fontisan/models.rb
CHANGED
|
@@ -8,6 +8,7 @@ module Fontisan
|
|
|
8
8
|
"fontisan/models/all_scripts_features_info"
|
|
9
9
|
autoload :AuditAxis, "fontisan/models/audit_report"
|
|
10
10
|
autoload :AuditReport, "fontisan/models/audit_report"
|
|
11
|
+
autoload :AuditSummary, "fontisan/models/audit_report"
|
|
11
12
|
autoload :AxisInfo, "fontisan/models/variable_font_info"
|
|
12
13
|
autoload :BitmapGlyph, "fontisan/models/bitmap_glyph"
|
|
13
14
|
autoload :BitmapStrike, "fontisan/models/bitmap_strike"
|
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.38
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
@@ -297,13 +297,23 @@ files:
|
|
|
297
297
|
- lib/fontisan/audit/check.rb
|
|
298
298
|
- lib/fontisan/audit/check_registry.rb
|
|
299
299
|
- lib/fontisan/audit/checks.rb
|
|
300
|
+
- lib/fontisan/audit/checks/cff_table_check.rb
|
|
300
301
|
- lib/fontisan/audit/checks/cmap_check.rb
|
|
301
302
|
- lib/fontisan/audit/checks/collection_integrity_check.rb
|
|
302
303
|
- lib/fontisan/audit/checks/format_round_trip_check.rb
|
|
304
|
+
- lib/fontisan/audit/checks/glyf_table_check.rb
|
|
303
305
|
- lib/fontisan/audit/checks/glyph_name_check.rb
|
|
306
|
+
- lib/fontisan/audit/checks/head_check.rb
|
|
307
|
+
- lib/fontisan/audit/checks/hhea_check.rb
|
|
304
308
|
- lib/fontisan/audit/checks/hinting_check.rb
|
|
309
|
+
- lib/fontisan/audit/checks/kern_check.rb
|
|
310
|
+
- lib/fontisan/audit/checks/layout_table_check.rb
|
|
311
|
+
- lib/fontisan/audit/checks/maxp_check.rb
|
|
312
|
+
- lib/fontisan/audit/checks/name_table_check.rb
|
|
305
313
|
- lib/fontisan/audit/checks/opentype_conformance_check.rb
|
|
314
|
+
- lib/fontisan/audit/checks/os2_check.rb
|
|
306
315
|
- lib/fontisan/audit/checks/ots_compatibility_check.rb
|
|
316
|
+
- lib/fontisan/audit/checks/post_check.rb
|
|
307
317
|
- lib/fontisan/audit/checks/table_directory_check.rb
|
|
308
318
|
- lib/fontisan/audit/checks/variable_font_check.rb
|
|
309
319
|
- lib/fontisan/audit/checks/woff2_validation_check.rb
|