fontisan 0.4.35 → 0.4.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +15 -3
- data/.rubocop_todo.yml +23 -11
- data/TODO.improvements/README.md +1 -1
- data/lib/fontisan/audit/check_registry.rb +26 -2
- data/lib/fontisan/audit/checks/format_round_trip_check.rb +150 -0
- data/lib/fontisan/audit/checks/head_check.rb +96 -0
- data/lib/fontisan/audit/checks/hhea_check.rb +68 -0
- data/lib/fontisan/audit/checks/hinting_check.rb +163 -0
- data/lib/fontisan/audit/checks/kern_check.rb +47 -0
- data/lib/fontisan/audit/checks/maxp_check.rb +85 -0
- data/lib/fontisan/audit/checks/name_table_check.rb +78 -0
- data/lib/fontisan/audit/checks/opentype_conformance_check.rb +105 -0
- data/lib/fontisan/audit/checks/os2_check.rb +117 -0
- data/lib/fontisan/audit/checks/post_check.rb +75 -0
- data/lib/fontisan/audit/checks/variable_font_check.rb +151 -0
- data/lib/fontisan/audit/checks/woff2_validation_check.rb +140 -0
- data/lib/fontisan/audit/checks.rb +15 -0
- data/lib/fontisan/version.rb +1 -1
- metadata +13 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 615c64e2005d769c01c4b7a5cd153630b42f5230c55a0242f892aaec65618320
|
|
4
|
+
data.tar.gz: deca6b51aba40a45b2150290fe5ef0bd94b935f4fd09c54da54e65bf8cde3194
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dc66734c722fd5fc959d80c95f41b701ecb7679926e85d6bbf09f9aab84c5ea96eb94f558ee241f40b88c999f21a811d9d45a306f35ec7b5cc61b188c053851e
|
|
7
|
+
data.tar.gz: 6ceebcf686badd81a1d934f8f2d03b607f29522bef598e85cd363c33d16a94f9df6d3fd048ebce38dfce103e5dc55efb7df6cbd55016220e564e4ca833490dd8
|
data/.rubocop.yml
CHANGED
|
@@ -116,7 +116,19 @@ Style/BitwisePredicate:
|
|
|
116
116
|
Enabled: false
|
|
117
117
|
|
|
118
118
|
# Allow short math-conventional parameter names in font geometry code.
|
|
119
|
-
#
|
|
120
|
-
#
|
|
119
|
+
# Font geometry / binary parsing uses single-letter variables by domain
|
|
120
|
+
# convention: affine matrix (a,b,c,d,e,f), loop indices (i,j,k,m,n),
|
|
121
|
+
# coordinates (x,y,z), deltas (dx,dy). Renaming to 3+ chars adds noise
|
|
122
|
+
# without aiding readers familiar with the math.
|
|
121
123
|
Naming/MethodParameterName:
|
|
122
|
-
AllowedNames: ["
|
|
124
|
+
AllowedNames: ["a", "b", "c", "d", "e", "f", "i", "j", "k", "m", "n",
|
|
125
|
+
"p", "r", "s", "t", "u", "v", "w", "x", "y", "z",
|
|
126
|
+
"cp", "gid", "dx", "dy", "fd", "io", "id"]
|
|
127
|
+
|
|
128
|
+
# Allow spec-format identifiers that match OpenType spec terminology:
|
|
129
|
+
# cmap format4, format6, format12, format14 are the official names.
|
|
130
|
+
# Renaming to format_4 diverges from the spec and makes docs harder
|
|
131
|
+
# to cross-reference.
|
|
132
|
+
Naming/VariableNumber:
|
|
133
|
+
AllowedIdentifiers: ["format0", "format4", "format6", "format12", "format14",
|
|
134
|
+
"version1", "version2", "table1", "table2"]
|
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 08:52:22 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,7 +32,7 @@ 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: 2053
|
|
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
|
|
@@ -111,7 +111,7 @@ Lint/UselessConstantScoping:
|
|
|
111
111
|
- 'lib/fontisan/conversion_options.rb'
|
|
112
112
|
- 'lib/fontisan/type1/charstrings.rb'
|
|
113
113
|
|
|
114
|
-
# Offense count:
|
|
114
|
+
# Offense count: 766
|
|
115
115
|
# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes, Max.
|
|
116
116
|
Metrics/AbcSize:
|
|
117
117
|
Enabled: false
|
|
@@ -134,12 +134,12 @@ Metrics/CollectionLiteralLength:
|
|
|
134
134
|
- 'lib/fontisan/type1/agl.rb'
|
|
135
135
|
- 'lib/fontisan/type1/encodings.rb'
|
|
136
136
|
|
|
137
|
-
# Offense count:
|
|
137
|
+
# Offense count: 383
|
|
138
138
|
# Configuration parameters: AllowedMethods, AllowedPatterns, Max.
|
|
139
139
|
Metrics/CyclomaticComplexity:
|
|
140
140
|
Enabled: false
|
|
141
141
|
|
|
142
|
-
# Offense count:
|
|
142
|
+
# Offense count: 1045
|
|
143
143
|
# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
|
|
144
144
|
Metrics/MethodLength:
|
|
145
145
|
Max: 135
|
|
@@ -150,16 +150,28 @@ Metrics/ParameterLists:
|
|
|
150
150
|
Max: 39
|
|
151
151
|
MaxOptionalParameters: 4
|
|
152
152
|
|
|
153
|
-
# Offense count:
|
|
153
|
+
# Offense count: 294
|
|
154
154
|
# Configuration parameters: AllowedMethods, AllowedPatterns, Max.
|
|
155
155
|
Metrics/PerceivedComplexity:
|
|
156
156
|
Enabled: false
|
|
157
157
|
|
|
158
|
-
# Offense count:
|
|
158
|
+
# Offense count: 34
|
|
159
159
|
# Configuration parameters: MinNameLength, AllowNamesEndingInNumbers, AllowedNames, ForbiddenNames.
|
|
160
160
|
# AllowedNames: as, at, by, cc, db, id, if, in, io, ip, of, on, os, pp, to
|
|
161
161
|
Naming/MethodParameterName:
|
|
162
|
-
|
|
162
|
+
Exclude:
|
|
163
|
+
- 'lib/fontisan.rb'
|
|
164
|
+
- 'lib/fontisan/conversion_options.rb'
|
|
165
|
+
- 'lib/fontisan/optimizers/subroutine_optimizer.rb'
|
|
166
|
+
- 'lib/fontisan/svg_to_glyf/geometry/affine_transform.rb'
|
|
167
|
+
- 'lib/fontisan/svg_to_glyf/geometry/transform_parser.rb'
|
|
168
|
+
- 'lib/fontisan/type1/ttf_to_type1_converter.rb'
|
|
169
|
+
- 'lib/fontisan/ufo/compile/filters/cubic_to_quadratic.rb'
|
|
170
|
+
- 'lib/fontisan/ufo/convert.rb'
|
|
171
|
+
- 'lib/fontisan/variation/optimizer.rb'
|
|
172
|
+
- 'lib/fontisan/woff2/glyf_loca_reconstruct.rb'
|
|
173
|
+
- 'lib/fontisan/woff2/glyf_loca_transform.rb'
|
|
174
|
+
- 'spec/fontisan/subset/table_strategy/cff2_spec.rb'
|
|
163
175
|
|
|
164
176
|
# Offense count: 13
|
|
165
177
|
# Configuration parameters: Mode, AllowedMethods, AllowedPatterns, AllowBangMethods, WaywardPredicates.
|
|
@@ -221,7 +233,7 @@ RSpec/AnyInstance:
|
|
|
221
233
|
RSpec/ContextWording:
|
|
222
234
|
Enabled: false
|
|
223
235
|
|
|
224
|
-
# Offense count:
|
|
236
|
+
# Offense count: 42
|
|
225
237
|
# Configuration parameters: IgnoredMetadata.
|
|
226
238
|
RSpec/DescribeClass:
|
|
227
239
|
Enabled: false
|
|
@@ -237,7 +249,7 @@ RSpec/DescribeMethod:
|
|
|
237
249
|
- 'spec/fontisan/type1/seac_expander_spec.rb'
|
|
238
250
|
- 'spec/fontisan/type1_real_fonts_spec.rb'
|
|
239
251
|
|
|
240
|
-
# Offense count:
|
|
252
|
+
# Offense count: 1729
|
|
241
253
|
# Configuration parameters: CountAsOne.
|
|
242
254
|
RSpec/ExampleLength:
|
|
243
255
|
Max: 66
|
|
@@ -315,7 +327,7 @@ RSpec/MultipleDescribes:
|
|
|
315
327
|
- 'spec/fontisan/utils/thread_pool_spec.rb'
|
|
316
328
|
- 'spec/fontisan/variation/cache_spec.rb'
|
|
317
329
|
|
|
318
|
-
# Offense count:
|
|
330
|
+
# Offense count: 2153
|
|
319
331
|
RSpec/MultipleExpectations:
|
|
320
332
|
Max: 19
|
|
321
333
|
|
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
|
|
14
|
+
- [x] ~~[03 — `fontisan audit` command (identity+style+features lens)](03-fontisan-audit-command.md)~~ ✓ Done (Phase 1: identity, style, coverage, layout, provenance; Phase 2 axes: table directory, glyph names, cmap, OTS, collection integrity; Phase 3 axes: variable-font, hinting, WOFF2, format-round-trip; Phase 3 axis 14 (full OT conformance): cross-table conformance + per-table checks for head, hhea, maxp, OS/2, name, post, kern; CLI `fontisan audit --validate` with profiles: default (17 checks), structural, ots, layout, variable, hinting, web, spec, per_table)
|
|
15
15
|
- [x] ~~[04 — UFO composite glyph encoding](04-ufo-composite-glyph-encoding.md)~~ ✓ Done
|
|
16
16
|
- [x] ~~[05 — OTF compiler real CFF charstrings](05-otf-compiler-real-cff.md)~~ ✓ Already working (discovered in PR #117 — Cff.build produces real Type 2 charstrings; stale TODO marker removed)
|
|
17
17
|
|
|
@@ -16,12 +16,24 @@ module Fontisan
|
|
|
16
16
|
# @example Run a specific profile
|
|
17
17
|
# CheckRegistry.for(:ots) # => [OtsCompatibilityCheck]
|
|
18
18
|
class CheckRegistry
|
|
19
|
+
# Per-table OT conformance checks (axis 14 — full OT spec coverage).
|
|
20
|
+
OT_TABLE_CHECKS = %i[ot_head ot_hhea ot_maxp ot_os2 ot_name ot_post
|
|
21
|
+
ot_kern].freeze
|
|
22
|
+
|
|
19
23
|
PROFILES = {
|
|
20
24
|
default: %i[table_directory glyph_names cmap ots_compatibility
|
|
21
|
-
collection_integrity
|
|
22
|
-
|
|
25
|
+
collection_integrity variable_font hinting woff2_validation
|
|
26
|
+
format_round_trip opentype_conformance
|
|
27
|
+
ot_head ot_hhea ot_maxp ot_os2 ot_name ot_post ot_kern],
|
|
28
|
+
structural: %i[table_directory collection_integrity opentype_conformance],
|
|
23
29
|
ots: %i[ots_compatibility],
|
|
24
30
|
layout: %i[glyph_names cmap],
|
|
31
|
+
variable: %i[variable_font],
|
|
32
|
+
hinting: %i[hinting],
|
|
33
|
+
web: %i[ots_compatibility woff2_validation],
|
|
34
|
+
spec: %i[opentype_conformance ot_head ot_hhea ot_maxp ot_os2
|
|
35
|
+
ot_name ot_post ot_kern],
|
|
36
|
+
per_table: OT_TABLE_CHECKS,
|
|
25
37
|
}.freeze
|
|
26
38
|
|
|
27
39
|
# @param profile [Symbol]
|
|
@@ -40,6 +52,18 @@ module Fontisan
|
|
|
40
52
|
when :cmap then Checks::CmapCheck
|
|
41
53
|
when :ots_compatibility then Checks::OtsCompatibilityCheck
|
|
42
54
|
when :collection_integrity then Checks::CollectionIntegrityCheck
|
|
55
|
+
when :variable_font then Checks::VariableFontCheck
|
|
56
|
+
when :hinting then Checks::HintingCheck
|
|
57
|
+
when :woff2_validation then Checks::Woff2ValidationCheck
|
|
58
|
+
when :format_round_trip then Checks::FormatRoundTripCheck
|
|
59
|
+
when :opentype_conformance then Checks::OpenTypeConformanceCheck
|
|
60
|
+
when :ot_head then Checks::HeadCheck
|
|
61
|
+
when :ot_hhea then Checks::HheaCheck
|
|
62
|
+
when :ot_maxp then Checks::MaxpCheck
|
|
63
|
+
when :ot_os2 then Checks::Os2Check
|
|
64
|
+
when :ot_name then Checks::NameTableCheck
|
|
65
|
+
when :ot_post then Checks::PostCheck
|
|
66
|
+
when :ot_kern then Checks::KernCheck
|
|
43
67
|
end
|
|
44
68
|
end
|
|
45
69
|
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Audit
|
|
7
|
+
module Checks
|
|
8
|
+
# Reports fidelity discrepancies when converting between TTF and
|
|
9
|
+
# OTF outline formats. A high-fidelity conversion preserves:
|
|
10
|
+
#
|
|
11
|
+
# - Glyph count (maxp.numGlyphs)
|
|
12
|
+
# - Codepoint coverage (cmap Unicode mappings)
|
|
13
|
+
# - Font identity (name table family + PostScript name)
|
|
14
|
+
#
|
|
15
|
+
# Any mismatch signals a bug in the outline converter or a data
|
|
16
|
+
# loss path. This check is the pure-Ruby replacement for `ttx`
|
|
17
|
+
# diff comparisons.
|
|
18
|
+
#
|
|
19
|
+
# Only converts in one direction (TTF→OTF for TrueType sources,
|
|
20
|
+
# OTF→TTF for CFF sources) — full round-trip would double the
|
|
21
|
+
# conversion surface without adding signal.
|
|
22
|
+
#
|
|
23
|
+
# @see Converters::OutlineConverter
|
|
24
|
+
class FormatRoundTripCheck < Check
|
|
25
|
+
# @param font [SfntFont]
|
|
26
|
+
# @return [Array<Models::ValidationReport::Issue>]
|
|
27
|
+
def self.call(font)
|
|
28
|
+
target = round_trip_target(font)
|
|
29
|
+
return [] unless target
|
|
30
|
+
|
|
31
|
+
converted_path = convert_to_temp(font, target)
|
|
32
|
+
return [] unless converted_path
|
|
33
|
+
|
|
34
|
+
converted = FontLoader.load(converted_path)
|
|
35
|
+
compare_fonts(font, converted, target)
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
[issue(severity: :warning,
|
|
38
|
+
message: "Round-trip conversion to #{target.upcase} failed: " \
|
|
39
|
+
"#{e.class}: #{e.message}",
|
|
40
|
+
location: "round_trip.#{target}")]
|
|
41
|
+
ensure
|
|
42
|
+
cleanup_temp(converted_path) if converted_path
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.code
|
|
46
|
+
:format_round_trip
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Decide the conversion target based on the source outline type.
|
|
50
|
+
# @return [Symbol, nil] :otf, :ttf, or nil if not convertible
|
|
51
|
+
def self.round_trip_target(font)
|
|
52
|
+
return :otf if font.has_table?("glyf")
|
|
53
|
+
return :ttf if font.has_table?("CFF ") || font.has_table?("CFF2")
|
|
54
|
+
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
private_class_method :round_trip_target
|
|
58
|
+
|
|
59
|
+
def self.convert_to_temp(font, target)
|
|
60
|
+
converter = Converters::FormatConverter.new
|
|
61
|
+
tables = converter.convert(font, target)
|
|
62
|
+
return nil unless tables && !tables.empty?
|
|
63
|
+
|
|
64
|
+
dir = Dir.mktmpdir("fontisan-roundtrip-")
|
|
65
|
+
path = File.join(dir, "converted.#{target}")
|
|
66
|
+
sfnt = target == :otf ? 0x4F54544F : 0x00010000
|
|
67
|
+
FontWriter.write_to_file(
|
|
68
|
+
tables.transform_values { |t| t.is_a?(String) ? t : t.to_binary_s },
|
|
69
|
+
path,
|
|
70
|
+
sfnt_version: sfnt,
|
|
71
|
+
)
|
|
72
|
+
File.exist?(path) ? path : nil
|
|
73
|
+
rescue StandardError
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
private_class_method :convert_to_temp
|
|
77
|
+
|
|
78
|
+
def self.compare_fonts(source, converted, target)
|
|
79
|
+
issues = []
|
|
80
|
+
issues.concat(compare_glyph_count(source, converted, target))
|
|
81
|
+
issues.concat(compare_codepoints(source, converted, target))
|
|
82
|
+
issues
|
|
83
|
+
end
|
|
84
|
+
private_class_method :compare_fonts
|
|
85
|
+
|
|
86
|
+
def self.compare_glyph_count(source, converted, target)
|
|
87
|
+
src_count = glyph_count(source)
|
|
88
|
+
dst_count = glyph_count(converted)
|
|
89
|
+
return [] if src_count == dst_count
|
|
90
|
+
|
|
91
|
+
[issue(severity: :warning,
|
|
92
|
+
message: "Round-trip to #{target.upcase}: glyph count changed " \
|
|
93
|
+
"from #{src_count} to #{dst_count}",
|
|
94
|
+
location: "round_trip.#{target}.glyph_count")]
|
|
95
|
+
end
|
|
96
|
+
private_class_method :compare_glyph_count
|
|
97
|
+
|
|
98
|
+
def self.compare_codepoints(source, converted, target)
|
|
99
|
+
src_cps = codepoint_set(source)
|
|
100
|
+
dst_cps = codepoint_set(converted)
|
|
101
|
+
missing = src_cps - dst_cps
|
|
102
|
+
extra = dst_cps - src_cps
|
|
103
|
+
issues = []
|
|
104
|
+
unless missing.empty?
|
|
105
|
+
issues << issue(severity: :warning,
|
|
106
|
+
message: "Round-trip to #{target.upcase}: #{missing.size} " \
|
|
107
|
+
"codepoints lost (first: " \
|
|
108
|
+
"#{missing.first(5).map { |c| format('U+%04X', c) }.join(', ')})",
|
|
109
|
+
location: "round_trip.#{target}.codepoints.missing")
|
|
110
|
+
end
|
|
111
|
+
unless extra.empty?
|
|
112
|
+
issues << issue(severity: :info,
|
|
113
|
+
message: "Round-trip to #{target.upcase}: #{extra.size} " \
|
|
114
|
+
"codepoints gained (first: " \
|
|
115
|
+
"#{extra.first(5).map { |c| format('U+%04X', c) }.join(', ')})",
|
|
116
|
+
location: "round_trip.#{target}.codepoints.extra")
|
|
117
|
+
end
|
|
118
|
+
issues
|
|
119
|
+
end
|
|
120
|
+
private_class_method :compare_codepoints
|
|
121
|
+
|
|
122
|
+
def self.glyph_count(font)
|
|
123
|
+
return 0 unless font.has_table?("maxp")
|
|
124
|
+
|
|
125
|
+
font.table("maxp").num_glyphs.to_i
|
|
126
|
+
rescue StandardError
|
|
127
|
+
0
|
|
128
|
+
end
|
|
129
|
+
private_class_method :glyph_count
|
|
130
|
+
|
|
131
|
+
def self.codepoint_set(font)
|
|
132
|
+
return Set.new unless font.has_table?("cmap")
|
|
133
|
+
|
|
134
|
+
Set.new(font.table("cmap").unicode_mappings&.keys || [])
|
|
135
|
+
rescue StandardError
|
|
136
|
+
Set.new
|
|
137
|
+
end
|
|
138
|
+
private_class_method :codepoint_set
|
|
139
|
+
|
|
140
|
+
def self.cleanup_temp(path)
|
|
141
|
+
dir = File.dirname(path)
|
|
142
|
+
FileUtils.rm_rf(dir) if Dir.exist?(dir)
|
|
143
|
+
rescue StandardError
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
private_class_method :cleanup_temp
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,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,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Audit
|
|
5
|
+
module Checks
|
|
6
|
+
# Audits font hinting configuration. Hinting quality directly
|
|
7
|
+
# affects small-size screen rendering (Windows, Android, embedded
|
|
8
|
+
# systems).
|
|
9
|
+
#
|
|
10
|
+
# TrueType (glyf outlines):
|
|
11
|
+
# - fpgm (font program) presence recommended for hinted fonts
|
|
12
|
+
# - prep (control value program) presence recommended
|
|
13
|
+
# - cvt (control value table) presence recommended
|
|
14
|
+
# - maxp.maxStackElements and maxp.maxZones must be positive
|
|
15
|
+
# if any hinting instructions exist
|
|
16
|
+
# - gasp table recommended for controlling grid-fitting per ppem
|
|
17
|
+
#
|
|
18
|
+
# CFF (PostScript outlines):
|
|
19
|
+
# - Private DICT should have BlueValues or StdHW/StdVW for hinting
|
|
20
|
+
# - Subroutine usage (Local/Global subrs) recommended for size
|
|
21
|
+
#
|
|
22
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/ttinst#hinting-instructions
|
|
23
|
+
class HintingCheck < Check
|
|
24
|
+
# @param font [SfntFont]
|
|
25
|
+
# @return [Array<Models::ValidationReport::Issue>]
|
|
26
|
+
def self.call(font)
|
|
27
|
+
if font.has_table?("glyf")
|
|
28
|
+
validate_truetype_hinting(font)
|
|
29
|
+
elsif font.has_table?("CFF ") || font.has_table?("CFF2")
|
|
30
|
+
validate_cff_hinting(font)
|
|
31
|
+
else
|
|
32
|
+
[]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.code
|
|
37
|
+
:hinting
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# ---------- TrueType hinting ----------
|
|
41
|
+
|
|
42
|
+
def self.validate_truetype_hinting(font)
|
|
43
|
+
issues = []
|
|
44
|
+
has_fpgm = font.has_table?("fpgm")
|
|
45
|
+
has_prep = font.has_table?("prep")
|
|
46
|
+
has_cvt = font.has_table?("cvt")
|
|
47
|
+
has_glyf_data = table_has_data?(font, "glyf")
|
|
48
|
+
|
|
49
|
+
if has_fpgm && has_glyf_data
|
|
50
|
+
issues.concat(validate_maxp_hint_capacity(font))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
issues << no_program_warning("prep") if !has_prep && has_fpgm
|
|
54
|
+
issues << no_program_warning("cvt") if !has_cvt && has_fpgm
|
|
55
|
+
|
|
56
|
+
unless font.has_table?("gasp")
|
|
57
|
+
issues << issue(severity: :info,
|
|
58
|
+
message: "TrueType font has no 'gasp' table — " \
|
|
59
|
+
"grid-fitting behavior per ppem is " \
|
|
60
|
+
"implementation-defined",
|
|
61
|
+
location: "tables.gasp")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
issues
|
|
65
|
+
end
|
|
66
|
+
private_class_method :validate_truetype_hinting
|
|
67
|
+
|
|
68
|
+
def self.validate_maxp_hint_capacity(font)
|
|
69
|
+
issues = []
|
|
70
|
+
maxp = font.table("maxp")
|
|
71
|
+
return issues unless maxp.version_1_0?
|
|
72
|
+
|
|
73
|
+
stack = maxp.max_stack_elements.to_i
|
|
74
|
+
zones = maxp.max_zones.to_i
|
|
75
|
+
if has_fpgm_instructions?(font) && stack.zero?
|
|
76
|
+
issues << issue(severity: :warning,
|
|
77
|
+
message: "maxp.maxStackElements is 0 but the font " \
|
|
78
|
+
"has hinting instructions — rendering " \
|
|
79
|
+
"may fail",
|
|
80
|
+
location: "maxp.max_stack_elements")
|
|
81
|
+
end
|
|
82
|
+
unless zones.between?(1, 2)
|
|
83
|
+
issues << issue(severity: :warning,
|
|
84
|
+
message: "maxp.maxZones is #{zones} but must be 1 or 2 " \
|
|
85
|
+
"per the OpenType spec",
|
|
86
|
+
location: "maxp.max_zones")
|
|
87
|
+
end
|
|
88
|
+
issues
|
|
89
|
+
end
|
|
90
|
+
private_class_method :validate_maxp_hint_capacity
|
|
91
|
+
|
|
92
|
+
def self.has_fpgm_instructions?(font)
|
|
93
|
+
table_has_data?(font, "fpgm")
|
|
94
|
+
end
|
|
95
|
+
private_class_method :has_fpgm_instructions?
|
|
96
|
+
|
|
97
|
+
def self.no_program_warning(table)
|
|
98
|
+
issue(severity: :info,
|
|
99
|
+
message: "Font has 'fpgm' but no '#{table}' table — " \
|
|
100
|
+
"#{table == 'cvt' ? 'control values' : 'pre-program'} " \
|
|
101
|
+
"are recommended for consistent hinting",
|
|
102
|
+
location: "tables.#{table}")
|
|
103
|
+
end
|
|
104
|
+
private_class_method :no_program_warning
|
|
105
|
+
|
|
106
|
+
# ---------- CFF hinting ----------
|
|
107
|
+
|
|
108
|
+
def self.validate_cff_hinting(font)
|
|
109
|
+
issues = []
|
|
110
|
+
tag = font.has_table?("CFF2") ? "CFF2" : "CFF "
|
|
111
|
+
cff = font.table(tag)
|
|
112
|
+
return issues unless cff
|
|
113
|
+
|
|
114
|
+
priv = begin
|
|
115
|
+
cff.private_dict(0)
|
|
116
|
+
rescue StandardError
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
return issues unless priv
|
|
120
|
+
|
|
121
|
+
has_blues = priv_values?(priv, :blue_values) || priv_values?(priv, :other_blues)
|
|
122
|
+
has_stems = priv_value?(priv, :std_hw) || priv_value?(priv, :std_vw)
|
|
123
|
+
|
|
124
|
+
if !has_blues && !has_stems
|
|
125
|
+
issues << issue(severity: :info,
|
|
126
|
+
message: "CFF Private DICT has no BlueValues or " \
|
|
127
|
+
"StdHW/StdVW — alignment zones and stem " \
|
|
128
|
+
"widths are the primary hinting mechanism " \
|
|
129
|
+
"for CFF outlines",
|
|
130
|
+
location: "cff.private_dict")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
issues
|
|
134
|
+
end
|
|
135
|
+
private_class_method :validate_cff_hinting
|
|
136
|
+
|
|
137
|
+
def self.priv_values?(priv, key)
|
|
138
|
+
val = priv[key] || priv[key.to_s]
|
|
139
|
+
val.is_a?(Array) ? val.any? : false
|
|
140
|
+
rescue StandardError
|
|
141
|
+
false
|
|
142
|
+
end
|
|
143
|
+
private_class_method :priv_values?
|
|
144
|
+
|
|
145
|
+
def self.priv_value?(priv, key)
|
|
146
|
+
val = priv[key] || priv[key.to_s]
|
|
147
|
+
val && !val.zero?
|
|
148
|
+
rescue StandardError
|
|
149
|
+
false
|
|
150
|
+
end
|
|
151
|
+
private_class_method :priv_value?
|
|
152
|
+
|
|
153
|
+
# ---------- helpers ----------
|
|
154
|
+
|
|
155
|
+
def self.table_has_data?(font, tag)
|
|
156
|
+
raw = font.table_data[tag]
|
|
157
|
+
raw && !raw.empty?
|
|
158
|
+
end
|
|
159
|
+
private_class_method :table_has_data?
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|