fontisan 0.4.34 → 0.4.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +9 -16
- data/TODO.improvements/README.md +1 -1
- data/lib/fontisan/audit/check.rb +47 -0
- data/lib/fontisan/audit/check_registry.rb +47 -0
- data/lib/fontisan/audit/checks/cmap_check.rb +249 -0
- data/lib/fontisan/audit/checks/collection_integrity_check.rb +102 -0
- data/lib/fontisan/audit/checks/glyph_name_check.rb +107 -0
- data/lib/fontisan/audit/checks/ots_compatibility_check.rb +191 -0
- data/lib/fontisan/audit/checks/table_directory_check.rb +157 -0
- data/lib/fontisan/audit/checks.rb +22 -0
- data/lib/fontisan/audit.rb +20 -0
- data/lib/fontisan/cli.rb +13 -0
- data/lib/fontisan/commands/audit_command.rb +36 -2
- data/lib/fontisan/models/audit_report.rb +5 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan.rb +1 -0
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c534e6da296ea563119e164f40835bf5e794d0c7d37d4fdcd2c0e31008925410
|
|
4
|
+
data.tar.gz: be05402a2e893896e744432950d1f1e8951a59004f76ca9aacd2a9353f8d54cd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2e77ac651176f1d4b42d24ac60d41d3b371ac548438bc373b9d5f1ce44b36f187050ce9e5e5b5385cfc83732e2e40d946cfb9d3ae2ac83ad9c82a20a8cc7fc5b
|
|
7
|
+
data.tar.gz: 903c46cd5ab5631f3ae6723bdee6f6df007c0351291e1a60856da0328ddcadbedc9a21ba68717d5db5d77031bd3b5bf69a003d08e33a8654302ae72eb122c7a4
|
data/.rubocop_todo.yml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# This configuration was generated by
|
|
2
2
|
# `rubocop --auto-gen-config`
|
|
3
|
-
# on 2026-07-11
|
|
3
|
+
# on 2026-07-11 07:11:58 UTC using RuboCop version 1.86.1.
|
|
4
4
|
# The point is for the user to remove these configuration records
|
|
5
5
|
# one by one as the offenses are removed from the code base.
|
|
6
6
|
# Note that changes in the inspected code, or installation of new
|
|
@@ -32,20 +32,13 @@ Layout/IndentationWidth:
|
|
|
32
32
|
- 'lib/fontisan/commands/audit_command.rb'
|
|
33
33
|
- 'lib/fontisan/ufo/compile/glyf_loca.rb'
|
|
34
34
|
|
|
35
|
-
# Offense count:
|
|
35
|
+
# Offense count: 2030
|
|
36
36
|
# This cop supports safe autocorrection (--autocorrect).
|
|
37
37
|
# Configuration parameters: Max, AllowHeredoc, AllowURI, AllowQualifiedName, URISchemes, AllowRBSInlineAnnotation, AllowCopDirectives, AllowedPatterns, SplitStrings.
|
|
38
38
|
# URISchemes: http, https
|
|
39
39
|
Layout/LineLength:
|
|
40
40
|
Enabled: false
|
|
41
41
|
|
|
42
|
-
# Offense count: 2
|
|
43
|
-
# This cop supports safe autocorrection (--autocorrect).
|
|
44
|
-
# Configuration parameters: AllowInHeredoc.
|
|
45
|
-
Layout/TrailingWhitespace:
|
|
46
|
-
Exclude:
|
|
47
|
-
- 'lib/fontisan/commands/audit_command.rb'
|
|
48
|
-
|
|
49
42
|
# Offense count: 2
|
|
50
43
|
Lint/CopDirectiveSyntax:
|
|
51
44
|
Exclude:
|
|
@@ -118,7 +111,7 @@ Lint/UselessConstantScoping:
|
|
|
118
111
|
- 'lib/fontisan/conversion_options.rb'
|
|
119
112
|
- 'lib/fontisan/type1/charstrings.rb'
|
|
120
113
|
|
|
121
|
-
# Offense count:
|
|
114
|
+
# Offense count: 762
|
|
122
115
|
# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes, Max.
|
|
123
116
|
Metrics/AbcSize:
|
|
124
117
|
Enabled: false
|
|
@@ -141,12 +134,12 @@ Metrics/CollectionLiteralLength:
|
|
|
141
134
|
- 'lib/fontisan/type1/agl.rb'
|
|
142
135
|
- 'lib/fontisan/type1/encodings.rb'
|
|
143
136
|
|
|
144
|
-
# Offense count:
|
|
137
|
+
# Offense count: 379
|
|
145
138
|
# Configuration parameters: AllowedMethods, AllowedPatterns, Max.
|
|
146
139
|
Metrics/CyclomaticComplexity:
|
|
147
140
|
Enabled: false
|
|
148
141
|
|
|
149
|
-
# Offense count:
|
|
142
|
+
# Offense count: 1034
|
|
150
143
|
# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
|
|
151
144
|
Metrics/MethodLength:
|
|
152
145
|
Max: 135
|
|
@@ -157,7 +150,7 @@ Metrics/ParameterLists:
|
|
|
157
150
|
Max: 39
|
|
158
151
|
MaxOptionalParameters: 4
|
|
159
152
|
|
|
160
|
-
# Offense count:
|
|
153
|
+
# Offense count: 291
|
|
161
154
|
# Configuration parameters: AllowedMethods, AllowedPatterns, Max.
|
|
162
155
|
Metrics/PerceivedComplexity:
|
|
163
156
|
Enabled: false
|
|
@@ -228,7 +221,7 @@ RSpec/AnyInstance:
|
|
|
228
221
|
RSpec/ContextWording:
|
|
229
222
|
Enabled: false
|
|
230
223
|
|
|
231
|
-
# Offense count:
|
|
224
|
+
# Offense count: 41
|
|
232
225
|
# Configuration parameters: IgnoredMetadata.
|
|
233
226
|
RSpec/DescribeClass:
|
|
234
227
|
Enabled: false
|
|
@@ -244,7 +237,7 @@ RSpec/DescribeMethod:
|
|
|
244
237
|
- 'spec/fontisan/type1/seac_expander_spec.rb'
|
|
245
238
|
- 'spec/fontisan/type1_real_fonts_spec.rb'
|
|
246
239
|
|
|
247
|
-
# Offense count:
|
|
240
|
+
# Offense count: 1727
|
|
248
241
|
# Configuration parameters: CountAsOne.
|
|
249
242
|
RSpec/ExampleLength:
|
|
250
243
|
Max: 66
|
|
@@ -322,7 +315,7 @@ RSpec/MultipleDescribes:
|
|
|
322
315
|
- 'spec/fontisan/utils/thread_pool_spec.rb'
|
|
323
316
|
- 'spec/fontisan/variation/cache_spec.rb'
|
|
324
317
|
|
|
325
|
-
# Offense count:
|
|
318
|
+
# Offense count: 2151
|
|
326
319
|
RSpec/MultipleExpectations:
|
|
327
320
|
Max: 19
|
|
328
321
|
|
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,
|
|
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 validation, glyph name validation, cmap validation, OTS compatibility predictor, collection integrity; CLI `fontisan audit --validate` with profiles: default, structural, ots, layout)
|
|
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
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "lutaml/model"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Audit
|
|
7
|
+
# Abstract base for all audit checks. A check is a stateless
|
|
8
|
+
# function that examines a loaded font and returns an Array of
|
|
9
|
+
# {Models::ValidationReport::Issue} records. An empty array means
|
|
10
|
+
# the font passed the check.
|
|
11
|
+
#
|
|
12
|
+
# Subclasses MUST override {.call} and {.code}.
|
|
13
|
+
#
|
|
14
|
+
# Checks are intentionally stateless + side-effect free so they
|
|
15
|
+
# can be composed, run in parallel, and tested in isolation.
|
|
16
|
+
class Check
|
|
17
|
+
# Run the check against +font+.
|
|
18
|
+
#
|
|
19
|
+
# @param font [SfntFont, BaseCollection]
|
|
20
|
+
# @return [Array<Models::ValidationReport::Issue>]
|
|
21
|
+
def self.call(font)
|
|
22
|
+
raise NotImplementedError,
|
|
23
|
+
"#{name} must override .call(font) -> Array<Issue>"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Unique short identifier for this check (e.g. :table_checksum).
|
|
27
|
+
# Used as the issue +category+ for programmatic filtering.
|
|
28
|
+
#
|
|
29
|
+
# @return [Symbol]
|
|
30
|
+
def self.code
|
|
31
|
+
raise NotImplementedError,
|
|
32
|
+
"#{name} must override .code -> Symbol"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Build an issue. Centralizes the category so callers don't repeat it.
|
|
36
|
+
# @return [Models::ValidationReport::Issue]
|
|
37
|
+
def self.issue(severity:, message:, location: nil)
|
|
38
|
+
Models::ValidationReport::Issue.new(
|
|
39
|
+
severity: severity.to_s,
|
|
40
|
+
category: code.to_s,
|
|
41
|
+
message: message,
|
|
42
|
+
location: location,
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Audit
|
|
5
|
+
# Central registry of audit checks. A profile is a named subset of
|
|
6
|
+
# checks (e.g. +:ots+, +:structural+). The +:default+ profile runs
|
|
7
|
+
# every registered check.
|
|
8
|
+
#
|
|
9
|
+
# Profiles let callers opt into specific validation axes without
|
|
10
|
+
# running everything — e.g. +fontisan audit font.ttf --validate ots+
|
|
11
|
+
# runs only the OTS compatibility check.
|
|
12
|
+
#
|
|
13
|
+
# @example Run all checks
|
|
14
|
+
# CheckRegistry.for(:default) # => [TableDirectoryCheck, ...]
|
|
15
|
+
#
|
|
16
|
+
# @example Run a specific profile
|
|
17
|
+
# CheckRegistry.for(:ots) # => [OtsCompatibilityCheck]
|
|
18
|
+
class CheckRegistry
|
|
19
|
+
PROFILES = {
|
|
20
|
+
default: %i[table_directory glyph_names cmap ots_compatibility
|
|
21
|
+
collection_integrity],
|
|
22
|
+
structural: %i[table_directory collection_integrity],
|
|
23
|
+
ots: %i[ots_compatibility],
|
|
24
|
+
layout: %i[glyph_names cmap],
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
# @param profile [Symbol]
|
|
28
|
+
# @return [Array<Class>] check classes for the profile
|
|
29
|
+
def self.for(profile)
|
|
30
|
+
codes = PROFILES[profile] || PROFILES[:default]
|
|
31
|
+
codes.filter_map { |code| check_for(code) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param code [Symbol]
|
|
35
|
+
# @return [Class, nil] the check class
|
|
36
|
+
def self.check_for(code)
|
|
37
|
+
case code
|
|
38
|
+
when :table_directory then Checks::TableDirectoryCheck
|
|
39
|
+
when :glyph_names then Checks::GlyphNameCheck
|
|
40
|
+
when :cmap then Checks::CmapCheck
|
|
41
|
+
when :ots_compatibility then Checks::OtsCompatibilityCheck
|
|
42
|
+
when :collection_integrity then Checks::CollectionIntegrityCheck
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Audit
|
|
7
|
+
module Checks
|
|
8
|
+
# Validates cmap subtable invariants per the OpenType spec:
|
|
9
|
+
#
|
|
10
|
+
# - At least one Unicode-encoded subtable is present
|
|
11
|
+
# - Format 4: segCountX2 must equal segCount × 2
|
|
12
|
+
# - Format 4: the last segment must have endCode 0xFFFF
|
|
13
|
+
# - Format 4: reservedPad must be 0
|
|
14
|
+
# - Format 12: groups must be sorted and non-overlapping
|
|
15
|
+
# - Format 12: each group startCharCode ≤ endCharCode
|
|
16
|
+
# - Subtable offsets must be within the cmap table bounds
|
|
17
|
+
#
|
|
18
|
+
# Catches the most common real-world cmap bugs that renderers and
|
|
19
|
+
# text shapers reject silently.
|
|
20
|
+
#
|
|
21
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/cmap
|
|
22
|
+
class CmapCheck < Check
|
|
23
|
+
# @param font [SfntFont]
|
|
24
|
+
# @return [Array<Models::ValidationReport::Issue>]
|
|
25
|
+
def self.call(font)
|
|
26
|
+
return [] unless font.has_table?("cmap")
|
|
27
|
+
|
|
28
|
+
raw = font.table_data["cmap"]
|
|
29
|
+
return [] unless raw
|
|
30
|
+
|
|
31
|
+
issues = []
|
|
32
|
+
issues.concat(validate_header(raw))
|
|
33
|
+
return issues unless issues.empty?
|
|
34
|
+
|
|
35
|
+
records = read_encoding_records(raw)
|
|
36
|
+
issues.concat(validate_unicode_presence(records))
|
|
37
|
+
issues.concat(validate_subtables(raw, records))
|
|
38
|
+
issues
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.code
|
|
42
|
+
:cmap
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# ---------- header ----------
|
|
46
|
+
|
|
47
|
+
def self.validate_header(raw)
|
|
48
|
+
issues = []
|
|
49
|
+
version, num_tables = raw.unpack("nn")
|
|
50
|
+
if version != 0
|
|
51
|
+
issues << issue(severity: :error,
|
|
52
|
+
message: "cmap version is #{version} but must be 0",
|
|
53
|
+
location: "cmap.version")
|
|
54
|
+
end
|
|
55
|
+
if num_tables.zero?
|
|
56
|
+
issues << issue(severity: :error,
|
|
57
|
+
message: "cmap has no encoding records",
|
|
58
|
+
location: "cmap.numTables")
|
|
59
|
+
end
|
|
60
|
+
issues
|
|
61
|
+
end
|
|
62
|
+
private_class_method :validate_header
|
|
63
|
+
|
|
64
|
+
# ---------- encoding records ----------
|
|
65
|
+
|
|
66
|
+
def self.read_encoding_records(raw)
|
|
67
|
+
num_tables = raw.unpack1("x2n")
|
|
68
|
+
offset = 4
|
|
69
|
+
Array.new(num_tables) do |i|
|
|
70
|
+
break if offset + 8 > raw.bytesize
|
|
71
|
+
|
|
72
|
+
platform_id, encoding_id, subtable_offset = raw[offset, 8].unpack("nnN")
|
|
73
|
+
offset += 8
|
|
74
|
+
{
|
|
75
|
+
index: i,
|
|
76
|
+
platform_id: platform_id,
|
|
77
|
+
encoding_id: encoding_id,
|
|
78
|
+
subtable_offset: subtable_offset,
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
private_class_method :read_encoding_records
|
|
83
|
+
|
|
84
|
+
def self.validate_unicode_presence(records)
|
|
85
|
+
has_unicode = records.any? do |r|
|
|
86
|
+
unicode_platform?(r[:platform_id], r[:encoding_id])
|
|
87
|
+
end
|
|
88
|
+
return [] if has_unicode
|
|
89
|
+
|
|
90
|
+
[issue(severity: :warning,
|
|
91
|
+
message: "cmap has no Unicode-encoded subtable " \
|
|
92
|
+
"(expected platform 0 or 3 with encoding 1 or 10)",
|
|
93
|
+
location: "cmap.encoding_records")]
|
|
94
|
+
end
|
|
95
|
+
private_class_method :validate_unicode_presence
|
|
96
|
+
|
|
97
|
+
def self.unicode_platform?(platform_id, encoding_id)
|
|
98
|
+
platform_id.zero? ||
|
|
99
|
+
(platform_id == 3 && [1, 10].include?(encoding_id))
|
|
100
|
+
end
|
|
101
|
+
private_class_method :unicode_platform?
|
|
102
|
+
|
|
103
|
+
# ---------- per-subtable validation ----------
|
|
104
|
+
|
|
105
|
+
def self.validate_subtables(raw, records)
|
|
106
|
+
records.flat_map { |r| validate_one_subtable(raw, r) }
|
|
107
|
+
end
|
|
108
|
+
private_class_method :validate_subtables
|
|
109
|
+
|
|
110
|
+
def self.validate_one_subtable(raw, record)
|
|
111
|
+
off = record[:subtable_offset]
|
|
112
|
+
if off + 2 > raw.bytesize
|
|
113
|
+
return [issue(severity: :error,
|
|
114
|
+
message: "cmap subtable at record #{record[:index]} " \
|
|
115
|
+
"has offset #{off} beyond the table end",
|
|
116
|
+
location: "cmap.encoding_records.#{record[:index]}")]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
format = raw[off, 2].unpack1("n")
|
|
120
|
+
case format
|
|
121
|
+
when 0 then validate_format0(raw, off, record)
|
|
122
|
+
when 4 then validate_format4(raw, off, record)
|
|
123
|
+
when 6 then validate_format6(raw, off, record)
|
|
124
|
+
when 12 then validate_format12(raw, off, record)
|
|
125
|
+
when 14 then [] # format 14 (Unicode Variation Sequences) — skip for now
|
|
126
|
+
else
|
|
127
|
+
[issue(severity: :warning,
|
|
128
|
+
message: "cmap subtable at record #{record[:index]} uses " \
|
|
129
|
+
"unknown format #{format}",
|
|
130
|
+
location: "cmap.encoding_records.#{record[:index]}")]
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
private_class_method :validate_one_subtable
|
|
134
|
+
|
|
135
|
+
# Format 4 validation: segCountX2 consistency, sentinel, reservedPad.
|
|
136
|
+
def self.validate_format4(raw, off, record)
|
|
137
|
+
issues = []
|
|
138
|
+
return issues unless off + 14 <= raw.bytesize
|
|
139
|
+
|
|
140
|
+
_format, _, _lang, seg_count_x2, _search, _sel, _shift,
|
|
141
|
+
= raw[off, 16].unpack("nnnnnnnn")
|
|
142
|
+
seg_count = seg_count_x2 / 2
|
|
143
|
+
|
|
144
|
+
if seg_count_x2.odd? || seg_count_x2 != seg_count * 2
|
|
145
|
+
issues << issue(severity: :error,
|
|
146
|
+
message: "cmap format 4 (record #{record[:index]}): " \
|
|
147
|
+
"segCountX2=#{seg_count_x2} is inconsistent " \
|
|
148
|
+
"with segCount=#{seg_count}",
|
|
149
|
+
location: "cmap.encoding_records.#{record[:index]}")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Read the last endCode (the sentinel segment)
|
|
153
|
+
end_codes_start = off + 14
|
|
154
|
+
if seg_count.positive? && end_codes_start + (seg_count * 2) <= raw.bytesize
|
|
155
|
+
last_end = raw[end_codes_start + ((seg_count - 1) * 2), 2].unpack1("n")
|
|
156
|
+
if last_end != 0xFFFF
|
|
157
|
+
issues << issue(severity: :warning,
|
|
158
|
+
message: "cmap format 4 (record #{record[:index]}): " \
|
|
159
|
+
"last segment endCode is 0x#{last_end.to_s(16)} " \
|
|
160
|
+
"but should be 0xFFFF (sentinel)",
|
|
161
|
+
location: "cmap.encoding_records.#{record[:index]}")
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# reservedPad check
|
|
166
|
+
reserved_pad_off = end_codes_start + (seg_count * 2)
|
|
167
|
+
if reserved_pad_off + 2 <= raw.bytesize
|
|
168
|
+
reserved_pad = raw[reserved_pad_off, 2].unpack1("n")
|
|
169
|
+
unless reserved_pad.zero?
|
|
170
|
+
issues << issue(severity: :error,
|
|
171
|
+
message: "cmap format 4 (record #{record[:index]}): " \
|
|
172
|
+
"reservedPad is #{reserved_pad} but must be 0",
|
|
173
|
+
location: "cmap.encoding_records.#{record[:index]}")
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
issues
|
|
178
|
+
end
|
|
179
|
+
private_class_method :validate_format4
|
|
180
|
+
|
|
181
|
+
# Format 12 validation: groups sorted, non-overlapping, ordered.
|
|
182
|
+
def self.validate_format12(raw, off, record)
|
|
183
|
+
issues = []
|
|
184
|
+
return issues unless off + 16 <= raw.bytesize
|
|
185
|
+
|
|
186
|
+
_format, _reserved, _length, _lang, num_groups = raw[off, 16].unpack("nnNnN")
|
|
187
|
+
groups_start = off + 16
|
|
188
|
+
return issues if num_groups.zero?
|
|
189
|
+
return issues unless groups_start + (num_groups * 12) <= raw.bytesize
|
|
190
|
+
|
|
191
|
+
prev_end = nil
|
|
192
|
+
num_groups.times do |g|
|
|
193
|
+
entry_off = groups_start + (g * 12)
|
|
194
|
+
start_cp, end_cp, _gid = raw[entry_off, 12].unpack("NNN")
|
|
195
|
+
|
|
196
|
+
if start_cp > end_cp
|
|
197
|
+
issues << issue(severity: :error,
|
|
198
|
+
message: "cmap format 12 (record #{record[:index]}): " \
|
|
199
|
+
"group #{g} has startCharCode 0x#{start_cp.to_s(16)} " \
|
|
200
|
+
"> endCharCode 0x#{end_cp.to_s(16)}",
|
|
201
|
+
location: "cmap.encoding_records.#{record[:index]}")
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
if prev_end && start_cp <= prev_end
|
|
205
|
+
issues << issue(severity: :error,
|
|
206
|
+
message: "cmap format 12 (record #{record[:index]}): " \
|
|
207
|
+
"group #{g} overlaps or is out of order " \
|
|
208
|
+
"(startCharCode 0x#{start_cp.to_s(16)} ≤ " \
|
|
209
|
+
"previous endCharCode 0x#{prev_end.to_s(16)})",
|
|
210
|
+
location: "cmap.encoding_records.#{record[:index]}")
|
|
211
|
+
end
|
|
212
|
+
prev_end = end_cp
|
|
213
|
+
end
|
|
214
|
+
issues
|
|
215
|
+
end
|
|
216
|
+
private_class_method :validate_format12
|
|
217
|
+
|
|
218
|
+
def self.validate_format0(raw, off, record)
|
|
219
|
+
# Format 0: 262 bytes (header 6 + 256-byte glyphIdArray)
|
|
220
|
+
if off + 262 > raw.bytesize
|
|
221
|
+
[issue(severity: :error,
|
|
222
|
+
message: "cmap format 0 (record #{record[:index]}): " \
|
|
223
|
+
"subtable is truncated (need 262 bytes)",
|
|
224
|
+
location: "cmap.encoding_records.#{record[:index]}")]
|
|
225
|
+
else
|
|
226
|
+
[]
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
private_class_method :validate_format0
|
|
230
|
+
|
|
231
|
+
def self.validate_format6(raw, off, record)
|
|
232
|
+
return [] unless off + 10 <= raw.bytesize
|
|
233
|
+
|
|
234
|
+
_fmt, _len, _lang, first, count = raw[off, 10].unpack("nnnnn")
|
|
235
|
+
needed = off + 10 + (count * 2)
|
|
236
|
+
if needed > raw.bytesize
|
|
237
|
+
[issue(severity: :error,
|
|
238
|
+
message: "cmap format 6 (record #{record[:index]}): " \
|
|
239
|
+
"subtable truncated (first=#{first}, count=#{count})",
|
|
240
|
+
location: "cmap.encoding_records.#{record[:index]}")]
|
|
241
|
+
else
|
|
242
|
+
[]
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
private_class_method :validate_format6
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Audit
|
|
5
|
+
module Checks
|
|
6
|
+
# Validates TrueType/OpenType Collection (TTC/OTC) structural
|
|
7
|
+
# integrity: TTC header, per-face SFNT offsets, shared-table
|
|
8
|
+
# deduplication. TTC is undertooled — CJK and Apple workflows
|
|
9
|
+
# ship fonts as collections and no widely-available validator
|
|
10
|
+
# covers the collection-specific invariants.
|
|
11
|
+
#
|
|
12
|
+
# Checks:
|
|
13
|
+
#
|
|
14
|
+
# - TTC tag must be 'ttcf'
|
|
15
|
+
# - header.majorVersion must be 1 or 2
|
|
16
|
+
# - numFonts must be positive and match the offset array length
|
|
17
|
+
# - every face offset must be > 0 and within the file
|
|
18
|
+
# - face offsets must not overlap the TTC header
|
|
19
|
+
# - (v2 only) DSIG tag/length must be null or point inside the file
|
|
20
|
+
#
|
|
21
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/otff#ttc-header
|
|
22
|
+
class CollectionIntegrityCheck < Check
|
|
23
|
+
TTC_TAG = "ttcf"
|
|
24
|
+
VALID_MAJOR_VERSIONS = [1, 2].freeze
|
|
25
|
+
|
|
26
|
+
# @param font [BaseCollection, SfntFont] a collection or single font
|
|
27
|
+
# @return [Array<Models::ValidationReport::Issue>]
|
|
28
|
+
def self.call(font)
|
|
29
|
+
return [] unless collection?(font)
|
|
30
|
+
|
|
31
|
+
issues = []
|
|
32
|
+
issues.concat(validate_header(font))
|
|
33
|
+
issues.concat(validate_face_offsets(font))
|
|
34
|
+
issues
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.code
|
|
38
|
+
:collection_integrity
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.collection?(font)
|
|
42
|
+
font.is_a?(BaseCollection)
|
|
43
|
+
end
|
|
44
|
+
private_class_method :collection?
|
|
45
|
+
|
|
46
|
+
def self.validate_header(collection)
|
|
47
|
+
issues = []
|
|
48
|
+
tag = collection.tag.to_s
|
|
49
|
+
|
|
50
|
+
if tag != TTC_TAG
|
|
51
|
+
issues << issue(severity: :error,
|
|
52
|
+
message: "Collection tag is '#{tag}' but must be '#{TTC_TAG}'",
|
|
53
|
+
location: "ttc_header.tag")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
version = collection.major_version.to_i
|
|
57
|
+
unless VALID_MAJOR_VERSIONS.include?(version)
|
|
58
|
+
issues << issue(severity: :error,
|
|
59
|
+
message: "Collection major version is #{version} " \
|
|
60
|
+
"but must be one of #{VALID_MAJOR_VERSIONS.join(', ')}",
|
|
61
|
+
location: "ttc_header.majorVersion")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
num_fonts = collection.num_fonts.to_i
|
|
65
|
+
if num_fonts <= 0
|
|
66
|
+
issues << issue(severity: :error,
|
|
67
|
+
message: "Collection numFonts is #{num_fonts} " \
|
|
68
|
+
"but must be positive",
|
|
69
|
+
location: "ttc_header.numFonts")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
if collection.font_offsets.length != num_fonts
|
|
73
|
+
issues << issue(severity: :error,
|
|
74
|
+
message: "Collection has #{num_fonts} fonts but " \
|
|
75
|
+
"#{collection.font_offsets.length} offsets " \
|
|
76
|
+
"(should match)",
|
|
77
|
+
location: "ttc_header.font_offsets")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
issues
|
|
81
|
+
end
|
|
82
|
+
private_class_method :validate_header
|
|
83
|
+
|
|
84
|
+
def self.validate_face_offsets(collection)
|
|
85
|
+
header_end = 12 + (collection.num_fonts.to_i * 4) +
|
|
86
|
+
(collection.major_version.to_i == 2 ? 8 : 0)
|
|
87
|
+
collection.font_offsets.each_with_index.with_object([]) do |(off, idx), issues|
|
|
88
|
+
off_val = off.to_i
|
|
89
|
+
if off_val < header_end
|
|
90
|
+
issues << issue(severity: :error,
|
|
91
|
+
message: "Face #{idx} SFNT offset #{off_val} " \
|
|
92
|
+
"overlaps the TTC header " \
|
|
93
|
+
"(header ends at #{header_end})",
|
|
94
|
+
location: "ttc_header.font_offsets.#{idx}")
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
private_class_method :validate_face_offsets
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Audit
|
|
5
|
+
module Checks
|
|
6
|
+
# Validates glyph names against the OpenType spec rules:
|
|
7
|
+
#
|
|
8
|
+
# - Length ≤ 63 characters
|
|
9
|
+
# - Allowed characters: printable ASCII excluding special chars
|
|
10
|
+
# (per OT spec: A–Z a–z 0–9, period, hyphen, underscore)
|
|
11
|
+
# - Must not start with a digit
|
|
12
|
+
# - Must not be a reserved name (.notdef is the only exception at GID 0)
|
|
13
|
+
# - Duplicate names across the font (warning)
|
|
14
|
+
# - Empty names (warning — some tools tolerate them but spec discourages)
|
|
15
|
+
#
|
|
16
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/post
|
|
17
|
+
class GlyphNameCheck < Check
|
|
18
|
+
MAX_NAME_LENGTH = 63
|
|
19
|
+
# OT spec post table glyph name charset: A–Z a–z 0–9 . - _
|
|
20
|
+
# (leading dot is allowed for .notdef, .null, .nonmarkingreturn, etc.)
|
|
21
|
+
ALLOWED_NAME_PATTERN = /\A[A-Za-z0-9._-]+\z/
|
|
22
|
+
|
|
23
|
+
# @param font [SfntFont]
|
|
24
|
+
# @return [Array<Models::ValidationReport::Issue>]
|
|
25
|
+
def self.call(font)
|
|
26
|
+
names = extract_glyph_names(font)
|
|
27
|
+
return [] if names.empty?
|
|
28
|
+
|
|
29
|
+
issues = []
|
|
30
|
+
names.each_with_index do |name, gid|
|
|
31
|
+
issues.concat(check_name(name, gid))
|
|
32
|
+
end
|
|
33
|
+
issues.concat(check_duplicates(names))
|
|
34
|
+
issues
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.code
|
|
38
|
+
:glyph_names
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.extract_glyph_names(font)
|
|
42
|
+
return [] unless font.has_table?("post")
|
|
43
|
+
|
|
44
|
+
post = font.table("post")
|
|
45
|
+
return [] unless post
|
|
46
|
+
|
|
47
|
+
post.glyph_names || []
|
|
48
|
+
end
|
|
49
|
+
private_class_method :extract_glyph_names
|
|
50
|
+
|
|
51
|
+
def self.check_name(name, gid)
|
|
52
|
+
issues = []
|
|
53
|
+
if name.nil? || name.empty?
|
|
54
|
+
issues << issue(severity: :warning,
|
|
55
|
+
message: "Glyph at GID #{gid} has an empty name",
|
|
56
|
+
location: "glyph.#{gid}.name")
|
|
57
|
+
return issues
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
if name.length > MAX_NAME_LENGTH
|
|
61
|
+
issues << issue(severity: :error,
|
|
62
|
+
message: "Glyph name '#{name[0, 20]}…' (GID #{gid}) " \
|
|
63
|
+
"exceeds #{MAX_NAME_LENGTH} characters " \
|
|
64
|
+
"(#{name.length})",
|
|
65
|
+
location: "glyph.#{gid}.name")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
unless name.match?(ALLOWED_NAME_PATTERN)
|
|
69
|
+
issues << issue(severity: :warning,
|
|
70
|
+
message: "Glyph name '#{name}' (GID #{gid}) contains " \
|
|
71
|
+
"characters outside the OT spec grammar " \
|
|
72
|
+
"(A–Z a–z 0–9 . - _)",
|
|
73
|
+
location: "glyph.#{gid}.name")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
if name.match?(/\A\d/)
|
|
77
|
+
issues << issue(severity: :warning,
|
|
78
|
+
message: "Glyph name '#{name}' (GID #{gid}) starts " \
|
|
79
|
+
"with a digit — not portable",
|
|
80
|
+
location: "glyph.#{gid}.name")
|
|
81
|
+
end
|
|
82
|
+
issues
|
|
83
|
+
end
|
|
84
|
+
private_class_method :check_name
|
|
85
|
+
|
|
86
|
+
def self.check_duplicates(names)
|
|
87
|
+
seen = {}
|
|
88
|
+
names.each_with_index do |name, gid|
|
|
89
|
+
next unless name && !name.empty?
|
|
90
|
+
|
|
91
|
+
(seen[name] ||= []) << gid
|
|
92
|
+
end
|
|
93
|
+
seen.each_with_object([]) do |(name, gids), issues|
|
|
94
|
+
next unless gids.size > 1
|
|
95
|
+
|
|
96
|
+
issues << issue(severity: :warning,
|
|
97
|
+
message: "Glyph name '#{name}' is shared by " \
|
|
98
|
+
"#{gids.size} glyphs (GIDs: #{gids.first(5).join(', ')}" \
|
|
99
|
+
"#{'…' if gids.size > 5})",
|
|
100
|
+
location: "glyph_names.#{name}")
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
private_class_method :check_duplicates
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Audit
|
|
5
|
+
module Checks
|
|
6
|
+
# Predicts whether Chrome/Android OTS (OpenType Sanitizer) would
|
|
7
|
+
# reject the font. OTS has strict requirements beyond the base
|
|
8
|
+
# OpenType spec — fonts that pass the spec can still be rejected
|
|
9
|
+
# by browsers.
|
|
10
|
+
#
|
|
11
|
+
# Ports the most commonly-hit ots-sanitize rules:
|
|
12
|
+
#
|
|
13
|
+
# - head magicNumber must be 0x5F0F3CF5
|
|
14
|
+
# - head unitsPerEm must be 16–16384
|
|
15
|
+
# - name table must have PostScript + Family names
|
|
16
|
+
# - post table version must be 1.0, 2.0, 2.5, 3.0, or 4.0
|
|
17
|
+
# - numGlyphs must be ≤ 65534 (uint16 minus sentinel)
|
|
18
|
+
# - glyf/CFF (depending on sfnt flavor) must be present
|
|
19
|
+
# - cmap must have at least one Unicode subtable
|
|
20
|
+
# - OS/2 usWeightClass must be 1–1000
|
|
21
|
+
# - OS/2 usWidthClass must be 1–9
|
|
22
|
+
# - Total file size must be under the OTS limit (~30MB)
|
|
23
|
+
#
|
|
24
|
+
# @see https://github.com/khaledhosny/ots/tree/master
|
|
25
|
+
class OtsCompatibilityCheck < Check
|
|
26
|
+
MAX_GLYPHS = 0xFFFD # 65534 — OTS rejects fonts with more
|
|
27
|
+
MAX_FILE_SIZE = 30 * 1024 * 1024 # 30MB — OTS soft limit
|
|
28
|
+
VALID_POST_VERSIONS = [1.0, 2.0, 2.5, 3.0, 4.0].freeze
|
|
29
|
+
HEAD_MAGIC = 0x5F0F3CF5
|
|
30
|
+
MIN_UPM = 16
|
|
31
|
+
MAX_UPM = 16_384
|
|
32
|
+
|
|
33
|
+
# @param font [SfntFont]
|
|
34
|
+
# @return [Array<Models::ValidationReport::Issue>]
|
|
35
|
+
def self.call(font)
|
|
36
|
+
issues = []
|
|
37
|
+
issues.concat(check_head(font))
|
|
38
|
+
issues.concat(check_post(font))
|
|
39
|
+
issues.concat(check_maxp(font))
|
|
40
|
+
issues.concat(check_name(font))
|
|
41
|
+
issues.concat(check_cmap(font))
|
|
42
|
+
issues.concat(check_os2(font))
|
|
43
|
+
issues.concat(check_outlines(font))
|
|
44
|
+
issues
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.code
|
|
48
|
+
:ots_compatibility
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# ---------- head ----------
|
|
52
|
+
|
|
53
|
+
def self.check_head(font)
|
|
54
|
+
issues = []
|
|
55
|
+
return [missing_table_issue("head", :error)] unless font.has_table?("head")
|
|
56
|
+
|
|
57
|
+
head = font.table("head")
|
|
58
|
+
if head.magic_number != HEAD_MAGIC
|
|
59
|
+
issues << issue(severity: :error,
|
|
60
|
+
message: "OTS: head.magicNumber is 0x#{head.magic_number.to_i.to_s(16)} " \
|
|
61
|
+
"but must be 0x#{HEAD_MAGIC.to_s(16)}",
|
|
62
|
+
location: "head.magic_number")
|
|
63
|
+
end
|
|
64
|
+
upm = head.units_per_em.to_i
|
|
65
|
+
if upm < MIN_UPM || upm > MAX_UPM
|
|
66
|
+
issues << issue(severity: :error,
|
|
67
|
+
message: "OTS: head.unitsPerEm is #{upm} but must " \
|
|
68
|
+
"be between #{MIN_UPM} and #{MAX_UPM}",
|
|
69
|
+
location: "head.units_per_em")
|
|
70
|
+
end
|
|
71
|
+
issues
|
|
72
|
+
end
|
|
73
|
+
private_class_method :check_head
|
|
74
|
+
|
|
75
|
+
# ---------- post ----------
|
|
76
|
+
|
|
77
|
+
def self.check_post(font)
|
|
78
|
+
return [missing_table_issue("post", :error)] unless font.has_table?("post")
|
|
79
|
+
|
|
80
|
+
post = font.table("post")
|
|
81
|
+
version = post.version.to_f
|
|
82
|
+
return [] if VALID_POST_VERSIONS.include?(version)
|
|
83
|
+
|
|
84
|
+
[issue(severity: :error,
|
|
85
|
+
message: "OTS: post.version is #{version} but must be one of " \
|
|
86
|
+
"#{VALID_POST_VERSIONS.join(', ')}",
|
|
87
|
+
location: "post.version")]
|
|
88
|
+
end
|
|
89
|
+
private_class_method :check_post
|
|
90
|
+
|
|
91
|
+
# ---------- maxp ----------
|
|
92
|
+
|
|
93
|
+
def self.check_maxp(font)
|
|
94
|
+
return [missing_table_issue("maxp", :error)] unless font.has_table?("maxp")
|
|
95
|
+
|
|
96
|
+
num_glyphs = font.table("maxp").num_glyphs.to_i
|
|
97
|
+
return [] if num_glyphs <= MAX_GLYPHS
|
|
98
|
+
|
|
99
|
+
[issue(severity: :error,
|
|
100
|
+
message: "OTS: maxp.numGlyphs is #{num_glyphs} but OTS " \
|
|
101
|
+
"rejects fonts with more than #{MAX_GLYPHS} glyphs",
|
|
102
|
+
location: "maxp.num_glyphs")]
|
|
103
|
+
end
|
|
104
|
+
private_class_method :check_maxp
|
|
105
|
+
|
|
106
|
+
# ---------- name ----------
|
|
107
|
+
|
|
108
|
+
def self.check_name(font)
|
|
109
|
+
return [missing_table_issue("name", :error)] unless font.has_table?("name")
|
|
110
|
+
|
|
111
|
+
name = font.table("name")
|
|
112
|
+
issues = []
|
|
113
|
+
if name.english_name(Tables::Name::FAMILY).to_s.empty?
|
|
114
|
+
issues << issue(severity: :error,
|
|
115
|
+
message: "OTS: name table has no English Family name (nameID 1)",
|
|
116
|
+
location: "name.nameID.1")
|
|
117
|
+
end
|
|
118
|
+
if name.english_name(Tables::Name::POSTSCRIPT_NAME).to_s.empty?
|
|
119
|
+
issues << issue(severity: :error,
|
|
120
|
+
message: "OTS: name table has no English PostScript name " \
|
|
121
|
+
"(nameID 6)",
|
|
122
|
+
location: "name.nameID.6")
|
|
123
|
+
end
|
|
124
|
+
issues
|
|
125
|
+
end
|
|
126
|
+
private_class_method :check_name
|
|
127
|
+
|
|
128
|
+
# ---------- cmap ----------
|
|
129
|
+
|
|
130
|
+
def self.check_cmap(font)
|
|
131
|
+
return [missing_table_issue("cmap", :error)] unless font.has_table?("cmap")
|
|
132
|
+
|
|
133
|
+
cmap = font.table("cmap")
|
|
134
|
+
mappings = cmap.unicode_mappings || {}
|
|
135
|
+
return [] if mappings.any?
|
|
136
|
+
|
|
137
|
+
[issue(severity: :error,
|
|
138
|
+
message: "OTS: cmap has no Unicode subtable or no codepoint mappings",
|
|
139
|
+
location: "cmap.unicode_mappings")]
|
|
140
|
+
end
|
|
141
|
+
private_class_method :check_cmap
|
|
142
|
+
|
|
143
|
+
# ---------- OS/2 ----------
|
|
144
|
+
|
|
145
|
+
def self.check_os2(font)
|
|
146
|
+
return [missing_table_issue("OS/2", :error)] unless font.has_table?("OS/2")
|
|
147
|
+
|
|
148
|
+
os2 = font.table("OS/2")
|
|
149
|
+
issues = []
|
|
150
|
+
weight = os2.us_weight_class.to_i
|
|
151
|
+
unless weight.between?(1, 1000)
|
|
152
|
+
issues << issue(severity: :error,
|
|
153
|
+
message: "OTS: OS/2.usWeightClass is #{weight} " \
|
|
154
|
+
"but must be between 1 and 1000",
|
|
155
|
+
location: "os2.us_weight_class")
|
|
156
|
+
end
|
|
157
|
+
width = os2.us_width_class.to_i
|
|
158
|
+
unless width.between?(1, 9)
|
|
159
|
+
issues << issue(severity: :error,
|
|
160
|
+
message: "OTS: OS/2.usWidthClass is #{width} " \
|
|
161
|
+
"but must be between 1 and 9",
|
|
162
|
+
location: "os2.us_width_class")
|
|
163
|
+
end
|
|
164
|
+
issues
|
|
165
|
+
end
|
|
166
|
+
private_class_method :check_os2
|
|
167
|
+
|
|
168
|
+
# ---------- outline presence ----------
|
|
169
|
+
|
|
170
|
+
def self.check_outlines(font)
|
|
171
|
+
has_glyf = font.has_table?("glyf")
|
|
172
|
+
has_cff = font.has_table?("CFF ") || font.has_table?("CFF2")
|
|
173
|
+
return [] if has_glyf || has_cff
|
|
174
|
+
|
|
175
|
+
[issue(severity: :error,
|
|
176
|
+
message: "OTS: font has neither 'glyf' nor 'CFF '/'CFF2' — " \
|
|
177
|
+
"no outline data",
|
|
178
|
+
location: "outline_tables")]
|
|
179
|
+
end
|
|
180
|
+
private_class_method :check_outlines
|
|
181
|
+
|
|
182
|
+
def self.missing_table_issue(tag, severity)
|
|
183
|
+
issue(severity: severity,
|
|
184
|
+
message: "OTS: required table '#{tag}' is missing",
|
|
185
|
+
location: "tables.#{tag}")
|
|
186
|
+
end
|
|
187
|
+
private_class_method :missing_table_issue
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Audit
|
|
5
|
+
module Checks
|
|
6
|
+
# Validates the SFNT table directory: checksums, 4-byte alignment,
|
|
7
|
+
# offset arithmetic, search-range/entry-selector/range-shift
|
|
8
|
+
# consistency.
|
|
9
|
+
#
|
|
10
|
+
# These checks are the foundation of all font validation — a
|
|
11
|
+
# corrupt table directory makes every downstream parse
|
|
12
|
+
# unreliable. Replaces the table-directory portion of MS Font
|
|
13
|
+
# Validator + ots-sanitize.
|
|
14
|
+
#
|
|
15
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/otff#table-directory
|
|
16
|
+
class TableDirectoryCheck < Check
|
|
17
|
+
# @param font [SfntFont]
|
|
18
|
+
# @return [Array<Models::ValidationReport::Issue>]
|
|
19
|
+
def self.call(font)
|
|
20
|
+
issues = []
|
|
21
|
+
issues.concat(validate_search_fields(font))
|
|
22
|
+
issues.concat(validate_alignment(font))
|
|
23
|
+
issues.concat(validate_checksums(font))
|
|
24
|
+
issues.concat(validate_offsets(font))
|
|
25
|
+
issues
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.code
|
|
29
|
+
:table_directory
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# ---------- search range / entry selector / range shift ----------
|
|
33
|
+
|
|
34
|
+
def self.validate_search_fields(font)
|
|
35
|
+
num_tables = font.header.num_tables
|
|
36
|
+
expected_range = largest_power_of_two_le(num_tables) * 16
|
|
37
|
+
expected_selector = Math.log2(expected_range / 16).to_i
|
|
38
|
+
expected_shift = (num_tables * 16) - expected_range
|
|
39
|
+
|
|
40
|
+
issues = []
|
|
41
|
+
if font.header.search_range != expected_range
|
|
42
|
+
issues << issue(severity: :warning,
|
|
43
|
+
message: "searchRange is #{font.header.search_range} " \
|
|
44
|
+
"but should be #{expected_range} " \
|
|
45
|
+
"(numTables=#{num_tables})",
|
|
46
|
+
location: "header.searchRange")
|
|
47
|
+
end
|
|
48
|
+
if font.header.entry_selector != expected_selector
|
|
49
|
+
issues << issue(severity: :warning,
|
|
50
|
+
message: "entrySelector is #{font.header.entry_selector} " \
|
|
51
|
+
"but should be #{expected_selector}",
|
|
52
|
+
location: "header.entrySelector")
|
|
53
|
+
end
|
|
54
|
+
if font.header.range_shift != expected_shift
|
|
55
|
+
issues << issue(severity: :warning,
|
|
56
|
+
message: "rangeShift is #{font.header.range_shift} " \
|
|
57
|
+
"but should be #{expected_shift}",
|
|
58
|
+
location: "header.rangeShift")
|
|
59
|
+
end
|
|
60
|
+
issues
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.largest_power_of_two_le(n)
|
|
64
|
+
return 0 if n <= 0
|
|
65
|
+
|
|
66
|
+
power = 1
|
|
67
|
+
power *= 2 while power * 2 <= n
|
|
68
|
+
power
|
|
69
|
+
end
|
|
70
|
+
private_class_method :largest_power_of_two_le
|
|
71
|
+
|
|
72
|
+
# ---------- 4-byte alignment ----------
|
|
73
|
+
|
|
74
|
+
def self.validate_alignment(font)
|
|
75
|
+
font.tables.each_with_object([]) do |entry, issues|
|
|
76
|
+
next if (entry.offset % 4).zero?
|
|
77
|
+
|
|
78
|
+
issues << issue(severity: :warning,
|
|
79
|
+
message: "Table '#{readable_tag(entry.tag)}' at offset " \
|
|
80
|
+
"#{entry.offset} is not 4-byte aligned",
|
|
81
|
+
location: "tables.#{readable_tag(entry.tag)}.offset")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# ---------- checksums ----------
|
|
86
|
+
|
|
87
|
+
# The 'head' table checksum has a special rule: its checkSumAdjustment
|
|
88
|
+
# field (offset 8) is set to make the whole-table checksum match the
|
|
89
|
+
# directory entry. We skip the head table's own checksum verification
|
|
90
|
+
# and instead verify the adjustment is present.
|
|
91
|
+
def self.validate_checksums(font)
|
|
92
|
+
font.tables.each_with_object([]) do |entry, issues|
|
|
93
|
+
tag = readable_tag(entry.tag)
|
|
94
|
+
raw = font.table_data[tag]
|
|
95
|
+
next unless raw
|
|
96
|
+
|
|
97
|
+
next if tag == "head" # checkSumAdjustment complicates this
|
|
98
|
+
|
|
99
|
+
actual = checksum(raw)
|
|
100
|
+
next if actual == entry.checksum
|
|
101
|
+
|
|
102
|
+
issues << issue(severity: :error,
|
|
103
|
+
message: "Table '#{tag}' checksum mismatch: " \
|
|
104
|
+
"directory=0x#{entry.checksum.to_s(16).upcase} " \
|
|
105
|
+
"computed=0x#{actual.to_s(16).upcase}",
|
|
106
|
+
location: "tables.#{tag}.checksum")
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# OpenType table checksum: sum of uint32 words, padded to 4 bytes
|
|
111
|
+
# with zeros if the table length isn't a multiple of 4.
|
|
112
|
+
def self.checksum(data)
|
|
113
|
+
remainder = data.bytesize % 4
|
|
114
|
+
padded_bytes = remainder.zero? ? data : data + "\x00".b * (4 - remainder)
|
|
115
|
+
padded_bytes.unpack("N*").sum & 0xFFFFFFFF
|
|
116
|
+
rescue StandardError
|
|
117
|
+
0
|
|
118
|
+
end
|
|
119
|
+
private_class_method :checksum
|
|
120
|
+
|
|
121
|
+
# ---------- offset arithmetic ----------
|
|
122
|
+
|
|
123
|
+
def self.validate_offsets(font)
|
|
124
|
+
dir_end = 12 + (font.header.num_tables * 16)
|
|
125
|
+
font.tables.each_with_object([]) do |entry, issues|
|
|
126
|
+
tag = readable_tag(entry.tag)
|
|
127
|
+
|
|
128
|
+
if entry.offset < dir_end
|
|
129
|
+
issues << issue(severity: :error,
|
|
130
|
+
message: "Table '#{tag}' offset #{entry.offset} " \
|
|
131
|
+
"overlaps the table directory " \
|
|
132
|
+
"(directory ends at #{dir_end})",
|
|
133
|
+
location: "tables.#{tag}.offset")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
raw = font.table_data[tag]
|
|
137
|
+
if raw && raw.bytesize != entry.table_length
|
|
138
|
+
issues << issue(severity: :error,
|
|
139
|
+
message: "Table '#{tag}' length mismatch: " \
|
|
140
|
+
"directory says #{entry.table_length} " \
|
|
141
|
+
"but actual data is #{raw.bytesize} bytes",
|
|
142
|
+
location: "tables.#{tag}.table_length")
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# BinData returns ASCII-8BIT tag strings; normalize for display.
|
|
148
|
+
def self.readable_tag(tag)
|
|
149
|
+
tag.dup.force_encoding("UTF-8")
|
|
150
|
+
rescue StandardError
|
|
151
|
+
tag.to_s
|
|
152
|
+
end
|
|
153
|
+
private_class_method :readable_tag
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "lutaml/model"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Audit
|
|
7
|
+
# Individual audit check implementations. Each is a standalone class
|
|
8
|
+
# under {Audit::Check} that implements `.call(font)` and `.code`.
|
|
9
|
+
#
|
|
10
|
+
# Adding a new check = one file + one autoload entry here. No edits
|
|
11
|
+
# to {CheckRegistry} or {AuditCommand} required (Open/Closed).
|
|
12
|
+
module Checks
|
|
13
|
+
autoload :TableDirectoryCheck, "fontisan/audit/checks/table_directory_check"
|
|
14
|
+
autoload :GlyphNameCheck, "fontisan/audit/checks/glyph_name_check"
|
|
15
|
+
autoload :CmapCheck, "fontisan/audit/checks/cmap_check"
|
|
16
|
+
autoload :OtsCompatibilityCheck,
|
|
17
|
+
"fontisan/audit/checks/ots_compatibility_check"
|
|
18
|
+
autoload :CollectionIntegrityCheck,
|
|
19
|
+
"fontisan/audit/checks/collection_integrity_check"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
# Audit-layer validation axes. Each check is a standalone module that
|
|
5
|
+
# produces structured issues when run against a loaded font. Checks
|
|
6
|
+
# are MECE by design: one concern per check (checksums, glyph names,
|
|
7
|
+
# cmap subtables, OTS compatibility, collection integrity, etc.).
|
|
8
|
+
#
|
|
9
|
+
# Checks are independent of the existing `Validators::*` framework
|
|
10
|
+
# (which uses a DSL and produces boolean pass/fail per check). The
|
|
11
|
+
# audit checks produce detailed `Models::ValidationReport::Issue`
|
|
12
|
+
# records so the consumer knows exactly what failed and where.
|
|
13
|
+
#
|
|
14
|
+
# @see Models::ValidationReport::Issue
|
|
15
|
+
module Audit
|
|
16
|
+
autoload :Check, "fontisan/audit/check"
|
|
17
|
+
autoload :CheckRegistry, "fontisan/audit/check_registry"
|
|
18
|
+
autoload :Checks, "fontisan/audit/checks"
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/fontisan/cli.rb
CHANGED
|
@@ -38,10 +38,14 @@ module Fontisan
|
|
|
38
38
|
desc: "Omit the codepoint list from the report"
|
|
39
39
|
option :font_index, type: :numeric, default: nil,
|
|
40
40
|
desc: "Audit only the given face of a collection"
|
|
41
|
+
option :validate, type: :string, default: nil,
|
|
42
|
+
desc: "Run validation checks. Use 'true' for all, or a " \
|
|
43
|
+
"profile name: default, structural, ots, layout"
|
|
41
44
|
# Produce a structured font audit report (YAML or JSON).
|
|
42
45
|
def audit(font_file)
|
|
43
46
|
cmd_options = options.dup
|
|
44
47
|
cmd_options[:include_codepoints] = !options[:no_codepoints]
|
|
48
|
+
cmd_options[:validate] = parse_validate_option(options[:validate])
|
|
45
49
|
command = Commands::AuditCommand.new(font_file, cmd_options)
|
|
46
50
|
result = command.run
|
|
47
51
|
write_audit_result(result, options[:output])
|
|
@@ -797,6 +801,15 @@ module Fontisan
|
|
|
797
801
|
format("%<idx>02d-%<name>s.%<suffix>s", idx: idx, name: safe_name, suffix: suffix)
|
|
798
802
|
end
|
|
799
803
|
|
|
804
|
+
# The --validate option is either nil (no validation), "true" (run all
|
|
805
|
+
# checks), or a profile name (e.g. "ots", "structural").
|
|
806
|
+
def parse_validate_option(raw)
|
|
807
|
+
return nil if raw.nil? || raw.empty?
|
|
808
|
+
return true if raw == "true"
|
|
809
|
+
|
|
810
|
+
raw.to_sym
|
|
811
|
+
end
|
|
812
|
+
|
|
800
813
|
def serialize_report(report, format)
|
|
801
814
|
format == :json ? report.to_json : report.to_yaml
|
|
802
815
|
end
|
|
@@ -75,6 +75,7 @@ module Fontisan
|
|
|
75
75
|
populate_style(r, face)
|
|
76
76
|
populate_coverage(r, face)
|
|
77
77
|
populate_layout(r, face)
|
|
78
|
+
populate_validation(r, face) if validate?
|
|
78
79
|
end
|
|
79
80
|
end
|
|
80
81
|
|
|
@@ -246,11 +247,44 @@ module Fontisan
|
|
|
246
247
|
end
|
|
247
248
|
|
|
248
249
|
def features_for_script(layout_table, script)
|
|
249
|
-
|
|
250
250
|
layout_table.features(script_tag: script)
|
|
251
251
|
rescue StandardError
|
|
252
252
|
[]
|
|
253
|
-
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# ------------------------------------------------------------------
|
|
256
|
+
# Validation (audit checks)
|
|
257
|
+
# ------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
def populate_validation(report, face)
|
|
260
|
+
report.validation_issues = run_validation_checks(face)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def run_validation_checks(face)
|
|
264
|
+
checks = Audit::CheckRegistry.for(validation_profile)
|
|
265
|
+
checks.flat_map { |check| safe_run_check(check, face) }
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def safe_run_check(check, face)
|
|
269
|
+
check.call(face)
|
|
270
|
+
rescue StandardError => e
|
|
271
|
+
[Models::ValidationReport::Issue.new(
|
|
272
|
+
severity: "fatal",
|
|
273
|
+
category: check.code.to_s,
|
|
274
|
+
message: "Check #{check.code} failed to execute: #{e.class}: #{e.message}",
|
|
275
|
+
location: nil,
|
|
276
|
+
)]
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def validate?
|
|
280
|
+
@options.fetch(:validate, false)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def validation_profile
|
|
284
|
+
profile = @options[:validate]
|
|
285
|
+
return :default if profile == true
|
|
286
|
+
|
|
287
|
+
profile&.to_sym || :default
|
|
254
288
|
end
|
|
255
289
|
end
|
|
256
290
|
end
|
|
@@ -73,6 +73,9 @@ module Fontisan
|
|
|
73
73
|
attribute :opentype_scripts, :string, collection: true
|
|
74
74
|
attribute :features, :string, collection: true
|
|
75
75
|
|
|
76
|
+
# Validation (populated only with --validate)
|
|
77
|
+
attribute :validation_issues, Models::ValidationReport::Issue, collection: true
|
|
78
|
+
|
|
76
79
|
json do
|
|
77
80
|
map "generated_at", to: :generated_at
|
|
78
81
|
map "fontisan_version", to: :fontisan_version
|
|
@@ -104,6 +107,7 @@ module Fontisan
|
|
|
104
107
|
|
|
105
108
|
map "opentype_scripts", to: :opentype_scripts
|
|
106
109
|
map "features", to: :features
|
|
110
|
+
map "validation_issues", to: :validation_issues
|
|
107
111
|
end
|
|
108
112
|
|
|
109
113
|
yaml do
|
|
@@ -137,6 +141,7 @@ module Fontisan
|
|
|
137
141
|
|
|
138
142
|
map "opentype_scripts", to: :opentype_scripts
|
|
139
143
|
map "features", to: :features
|
|
144
|
+
map "validation_issues", to: :validation_issues
|
|
140
145
|
end
|
|
141
146
|
end
|
|
142
147
|
end
|
data/lib/fontisan/version.rb
CHANGED
data/lib/fontisan.rb
CHANGED
|
@@ -77,6 +77,7 @@ module Fontisan
|
|
|
77
77
|
|
|
78
78
|
# Namespace hubs (each hub declares its own child autoloads)
|
|
79
79
|
autoload :Binary, "fontisan/binary"
|
|
80
|
+
autoload :Audit, "fontisan/audit"
|
|
80
81
|
autoload :Collection, "fontisan/collection"
|
|
81
82
|
autoload :Commands, "fontisan/commands"
|
|
82
83
|
autoload :Converters, "fontisan/converters"
|
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.35
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
@@ -293,6 +293,15 @@ files:
|
|
|
293
293
|
- exe/fontisan
|
|
294
294
|
- fontisan.gemspec
|
|
295
295
|
- lib/fontisan.rb
|
|
296
|
+
- lib/fontisan/audit.rb
|
|
297
|
+
- lib/fontisan/audit/check.rb
|
|
298
|
+
- lib/fontisan/audit/check_registry.rb
|
|
299
|
+
- lib/fontisan/audit/checks.rb
|
|
300
|
+
- lib/fontisan/audit/checks/cmap_check.rb
|
|
301
|
+
- lib/fontisan/audit/checks/collection_integrity_check.rb
|
|
302
|
+
- lib/fontisan/audit/checks/glyph_name_check.rb
|
|
303
|
+
- lib/fontisan/audit/checks/ots_compatibility_check.rb
|
|
304
|
+
- lib/fontisan/audit/checks/table_directory_check.rb
|
|
296
305
|
- lib/fontisan/base_collection.rb
|
|
297
306
|
- lib/fontisan/binary.rb
|
|
298
307
|
- lib/fontisan/binary/base_record.rb
|