fontisan 0.4.35 → 0.4.36

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c534e6da296ea563119e164f40835bf5e794d0c7d37d4fdcd2c0e31008925410
4
- data.tar.gz: be05402a2e893896e744432950d1f1e8951a59004f76ca9aacd2a9353f8d54cd
3
+ metadata.gz: 3957074ba3c679c22715fa1f1aabf4dbd3cc7334891f729d64988ec231237f3b
4
+ data.tar.gz: 18129617714b672947d8f39a88e7c06a7644e4ff12875c383321635a6e051377
5
5
  SHA512:
6
- metadata.gz: 2e77ac651176f1d4b42d24ac60d41d3b371ac548438bc373b9d5f1ce44b36f187050ce9e5e5b5385cfc83732e2e40d946cfb9d3ae2ac83ad9c82a20a8cc7fc5b
7
- data.tar.gz: 903c46cd5ab5631f3ae6723bdee6f6df007c0351291e1a60856da0328ddcadbedc9a21ba68717d5db5d77031bd3b5bf69a003d08e33a8654302ae72eb122c7a4
6
+ metadata.gz: 3315d7c7f3cd824dadcf4ecddc63319a5661ac13ebf8383dfab8f2eda3fffd2713a1a039df922d58073cc0b99615d0d608de5641f67528e48f2aa70678e5b8f1
7
+ data.tar.gz: 1bd98a99dc9902aeae6e5ceb3c40f16d01c7a61c16ae2c94f1c97f212602de2675602ab736545869640500dc49ac5f4bef6302bf3b43c34593071e94b513c448
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
- # Point#x / Point#y are domain-standard coordinate names; renaming to
120
- # x_coord/y_coord adds noise without aiding readers.
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: ["x", "y", "cp", "gid", "n"]
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 07:11:58 UTC using RuboCop version 1.86.1.
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: 2030
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: 762
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: 379
137
+ # Offense count: 383
138
138
  # Configuration parameters: AllowedMethods, AllowedPatterns, Max.
139
139
  Metrics/CyclomaticComplexity:
140
140
  Enabled: false
141
141
 
142
- # Offense count: 1034
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: 291
153
+ # Offense count: 294
154
154
  # Configuration parameters: AllowedMethods, AllowedPatterns, Max.
155
155
  Metrics/PerceivedComplexity:
156
156
  Enabled: false
157
157
 
158
- # Offense count: 209
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
- Enabled: false
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: 41
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: 1727
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: 2151
330
+ # Offense count: 2153
319
331
  RSpec/MultipleExpectations:
320
332
  Max: 19
321
333
 
@@ -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 validation, glyph name validation, cmap validation, OTS compatibility predictor, collection integrity; CLI `fontisan audit --validate` with profiles: default, structural, ots, layout)
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, OpenType conformance foundation; CLI `fontisan audit --validate` with profiles: default, structural, ots, layout, variable, hinting, web, spec)
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
 
@@ -18,10 +18,15 @@ module Fontisan
18
18
  class CheckRegistry
19
19
  PROFILES = {
20
20
  default: %i[table_directory glyph_names cmap ots_compatibility
21
- collection_integrity],
22
- structural: %i[table_directory collection_integrity],
21
+ collection_integrity variable_font hinting woff2_validation
22
+ format_round_trip opentype_conformance],
23
+ structural: %i[table_directory collection_integrity opentype_conformance],
23
24
  ots: %i[ots_compatibility],
24
25
  layout: %i[glyph_names cmap],
26
+ variable: %i[variable_font],
27
+ hinting: %i[hinting],
28
+ web: %i[ots_compatibility woff2_validation],
29
+ spec: %i[opentype_conformance],
25
30
  }.freeze
26
31
 
27
32
  # @param profile [Symbol]
@@ -40,6 +45,11 @@ module Fontisan
40
45
  when :cmap then Checks::CmapCheck
41
46
  when :ots_compatibility then Checks::OtsCompatibilityCheck
42
47
  when :collection_integrity then Checks::CollectionIntegrityCheck
48
+ when :variable_font then Checks::VariableFontCheck
49
+ when :hinting then Checks::HintingCheck
50
+ when :woff2_validation then Checks::Woff2ValidationCheck
51
+ when :format_round_trip then Checks::FormatRoundTripCheck
52
+ when :opentype_conformance then Checks::OpenTypeConformanceCheck
43
53
  end
44
54
  end
45
55
  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,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
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Checks
6
+ # Foundational OpenType spec conformance checks. Covers the
7
+ # most commonly-violated "MUST" and "SHOULD" rules from the OT
8
+ # spec that other check domains don't already cover.
9
+ #
10
+ # This check is intentionally a growing foundation — new OT spec
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:
16
+ #
17
+ # - 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
22
+ # - 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
+ #
27
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/otff
28
+ class OpenTypeConformanceCheck < Check
29
+ REQUIRED_NAME_IDS = {
30
+ 1 => "Family",
31
+ 2 => "Subfamily",
32
+ 4 => "Full",
33
+ 6 => "PostScript",
34
+ }.freeze
35
+
36
+ # @param font [SfntFont]
37
+ # @return [Array<Models::ValidationReport::Issue>]
38
+ def self.call(font)
39
+ issues = []
40
+ issues.concat(validate_required_tables(font))
41
+ issues.concat(validate_loca_format(font))
42
+ issues.concat(validate_name_coverage(font))
43
+ issues.concat(validate_os2_fsselection(font))
44
+ issues.concat(validate_hhea_metrics(font))
45
+ issues
46
+ end
47
+
48
+ def self.code
49
+ :opentype_conformance
50
+ end
51
+
52
+ # ---------- required tables ----------
53
+
54
+ def self.validate_required_tables(font)
55
+ required = required_tables_for_flavor(font)
56
+ missing = required.reject { |tag| font.has_table?(tag) }
57
+ missing.map do |tag|
58
+ issue(severity: :error,
59
+ message: "Required table '#{tag}' is missing for this " \
60
+ "sfnt flavor",
61
+ location: "tables.#{tag}")
62
+ end
63
+ end
64
+ private_class_method :validate_required_tables
65
+
66
+ def self.required_tables_for_flavor(font)
67
+ base = %w[head hhea maxp hmtx name post cmap OS/2]
68
+ if font.has_table?("glyf")
69
+ base + %w[glyf loca]
70
+ elsif font.has_table?("CFF2")
71
+ base + ["CFF2"]
72
+ elsif font.has_table?("CFF ")
73
+ base + ["CFF "]
74
+ else
75
+ base
76
+ end
77
+ end
78
+ private_class_method :required_tables_for_flavor
79
+
80
+ # ---------- loca format ----------
81
+
82
+ def self.validate_loca_format(font)
83
+ return [] unless font.has_table?("head") && font.has_table?("loca")
84
+
85
+ head = font.table("head")
86
+ loca = font.table("loca")
87
+ return [] unless head && loca
88
+
89
+ format = head.index_to_loc_format
90
+ maxp = font.table("maxp")
91
+ num_glyphs = maxp&.num_glyphs.to_i
92
+ return [] if num_glyphs.zero?
93
+
94
+ expected_size = format.zero? ? (num_glyphs + 1) * 2 : (num_glyphs + 1) * 4
95
+ actual_size = loca.to_binary_s.bytesize
96
+ return [] if actual_size == expected_size
97
+
98
+ [issue(severity: :warning,
99
+ message: "loca table size (#{actual_size} bytes) doesn't match " \
100
+ "head.indexToLocFormat=#{format} + numGlyphs=#{num_glyphs} " \
101
+ "(expected #{expected_size} bytes)",
102
+ location: "head.index_to_loc_format")]
103
+ end
104
+ private_class_method :validate_loca_format
105
+
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
+ # ---------- hhea.numberOfHMetrics ----------
142
+
143
+ def self.validate_hhea_metrics(font)
144
+ return [] unless font.has_table?("hhea") && font.has_table?("maxp")
145
+
146
+ num_metrics = font.table("hhea").number_of_h_metrics.to_i
147
+ num_glyphs = font.table("maxp").num_glyphs.to_i
148
+ return [] if num_metrics.positive? && num_metrics <= num_glyphs
149
+
150
+ [issue(severity: :error,
151
+ message: "hhea.numberOfHMetrics (#{num_metrics}) must be " \
152
+ "between 1 and maxp.numGlyphs (#{num_glyphs})",
153
+ location: "hhea.number_of_h_metrics")]
154
+ end
155
+ private_class_method :validate_hhea_metrics
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Checks
6
+ # Validates variable-font readiness: fvar axis invariants, named
7
+ # instances within ranges, companion-table presence (gvar for
8
+ # TrueType outlines, HVAR for horizontal metrics, STAT for OS
9
+ # style matching). Ports the most commonly-hit fontbakery GF-VF
10
+ # check subset.
11
+ #
12
+ # Only runs when the font HAS an fvar table — static fonts are
13
+ # skipped (returns empty issues array).
14
+ #
15
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/fvar
16
+ class VariableFontCheck < Check
17
+ # @param font [SfntFont]
18
+ # @return [Array<Models::ValidationReport::Issue>]
19
+ def self.call(font)
20
+ return [] unless font.has_table?("fvar")
21
+
22
+ issues = []
23
+ issues.concat(validate_axes(font))
24
+ issues.concat(validate_instances(font))
25
+ issues.concat(validate_companion_tables(font))
26
+ issues
27
+ end
28
+
29
+ def self.code
30
+ :variable_font
31
+ end
32
+
33
+ # ---------- fvar axes ----------
34
+
35
+ def self.validate_axes(font)
36
+ fvar = font.table("fvar")
37
+ axes = fvar.axes
38
+ issues = []
39
+
40
+ issues.concat(check_axis_uniqueness(axes))
41
+ axes.each_with_index { |axis, idx| issues.concat(check_one_axis(axis, idx)) }
42
+ issues
43
+ end
44
+ private_class_method :validate_axes
45
+
46
+ def self.check_axis_uniqueness(axes)
47
+ tags = axes.map(&:axis_tag)
48
+ duplicates = tags.tally.filter_map { |t, c| t if c > 1 }
49
+ duplicates.map do |tag|
50
+ issue(severity: :error,
51
+ message: "Duplicate fvar axis tag '#{tag}' — axis tags " \
52
+ "must be unique within a font",
53
+ location: "fvar.axes.#{tag}")
54
+ end
55
+ end
56
+ private_class_method :check_axis_uniqueness
57
+
58
+ def self.check_one_axis(axis, idx)
59
+ issues = []
60
+ min_v = axis.min_value.to_f
61
+ default_v = axis.default_value.to_f
62
+ max_v = axis.max_value.to_f
63
+
64
+ unless min_v <= default_v && default_v <= max_v
65
+ issues << issue(severity: :error,
66
+ message: "fvar axis '#{axis.axis_tag}' (#{idx}) has " \
67
+ "invalid range: min=#{min_v} " \
68
+ "default=#{default_v} max=#{max_v} " \
69
+ "(must be min ≤ default ≤ max)",
70
+ location: "fvar.axes.#{idx}")
71
+ end
72
+
73
+ if axis.axis_name_id.to_i.zero?
74
+ issues << issue(severity: :warning,
75
+ message: "fvar axis '#{axis.axis_tag}' (#{idx}) has " \
76
+ "no name ID — users cannot identify the axis",
77
+ location: "fvar.axes.#{idx}.axis_name_id")
78
+ end
79
+
80
+ issues
81
+ end
82
+ private_class_method :check_one_axis
83
+
84
+ # ---------- fvar named instances ----------
85
+
86
+ def self.validate_instances(font)
87
+ fvar = font.table("fvar")
88
+ instances = fvar.instances || []
89
+ axes = fvar.axes
90
+
91
+ instances.each_with_index.with_object([]) do |(inst, idx), issues|
92
+ issues.concat(check_instance_ranges(inst, idx, axes))
93
+ end
94
+ end
95
+ private_class_method :validate_instances
96
+
97
+ def self.check_instance_ranges(inst, idx, axes)
98
+ issues = []
99
+ coords = inst[:coordinates] || inst["coordinates"] || []
100
+ coords.each_with_index do |coord, axis_idx|
101
+ next unless axes[axis_idx]
102
+
103
+ axis = axes[axis_idx]
104
+ min_v = axis.min_value.to_f
105
+ max_v = axis.max_value.to_f
106
+ next if coord.to_f.between?(min_v, max_v)
107
+
108
+ issues << issue(severity: :warning,
109
+ message: "fvar instance #{idx} has coordinate " \
110
+ "#{coord} for axis '#{axis.axis_tag}' " \
111
+ "outside its range [#{min_v}, #{max_v}]",
112
+ location: "fvar.instances.#{idx}.#{axis.axis_tag}")
113
+ end
114
+ issues
115
+ end
116
+ private_class_method :check_instance_ranges
117
+
118
+ # ---------- companion tables ----------
119
+
120
+ def self.validate_companion_tables(font)
121
+ issues = []
122
+ is_truetype = font.has_table?("glyf")
123
+ if is_truetype && !font.has_table?("gvar")
124
+ issues << issue(severity: :warning,
125
+ message: "Variable TrueType font has no 'gvar' table — " \
126
+ "glyph outlines won't vary across the design space",
127
+ location: "tables.gvar")
128
+ end
129
+
130
+ unless font.has_table?("HVAR")
131
+ issues << issue(severity: :warning,
132
+ message: "Variable font has no 'HVAR' table — " \
133
+ "advance widths won't vary; renderers must " \
134
+ "fall back to per-glyph hmtx lookups",
135
+ location: "tables.HVAR")
136
+ end
137
+
138
+ unless font.has_table?("STAT")
139
+ issues << issue(severity: :info,
140
+ message: "Variable font has no 'STAT' table — " \
141
+ "OS style-matching won't work; Windows and " \
142
+ "modern font pickers rely on STAT",
143
+ location: "tables.STAT")
144
+ end
145
+ issues
146
+ end
147
+ private_class_method :validate_companion_tables
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Checks
6
+ # Validates WOFF2 (Web Open Font Format 2) wrapper structure per
7
+ # the W3C WOFF2 spec. Only runs when the font IS a Woff2Font —
8
+ # plain TTF/OTF inputs are skipped.
9
+ #
10
+ # Checks:
11
+ #
12
+ # - Signature must be 0x774F4632 ('wOF2')
13
+ # - Flavor must be a valid SFNT version
14
+ # - reserved field must be 0
15
+ # - major_version should be 1
16
+ # - total_compressed_size must be positive
17
+ # - If metadata block is present, lengths must be consistent
18
+ # - num_tables must match the table directory count
19
+ #
20
+ # @see https://www.w3.org/TR/WOFF2/
21
+ class Woff2ValidationCheck < Check
22
+ WOFF2_SIGNATURE = 0x774F4632
23
+ VALID_FLAVORS = [
24
+ 0x00010000, # TrueType
25
+ 0x74727565, # 'true' (Apple TrueType)
26
+ 0x4F54544F, # 'OTTO' (OpenType CFF)
27
+ 0x74746366, # 'ttcf' (collection)
28
+ ].freeze
29
+
30
+ # @param font [Woff2Font, SfntFont]
31
+ # @return [Array<Models::ValidationReport::Issue>]
32
+ def self.call(font)
33
+ return [] unless woff2?(font)
34
+
35
+ header = font.header
36
+ issues = []
37
+ issues.concat(validate_signature(header))
38
+ issues.concat(validate_flavor(header))
39
+ issues.concat(validate_reserved(header))
40
+ issues.concat(validate_version(header))
41
+ issues.concat(validate_sizes(header))
42
+ issues.concat(validate_metadata(header))
43
+ issues
44
+ end
45
+
46
+ def self.code
47
+ :woff2_validation
48
+ end
49
+
50
+ def self.woff2?(font)
51
+ font.is_a?(Woff2Font)
52
+ end
53
+ private_class_method :woff2?
54
+
55
+ def self.validate_signature(header)
56
+ return [] if header.signature == WOFF2_SIGNATURE
57
+
58
+ [issue(severity: :error,
59
+ message: "WOFF2 signature is 0x#{header.signature.to_s(16)} " \
60
+ "but must be 0x#{WOFF2_SIGNATURE.to_s(16)} ('wOF2')",
61
+ location: "woff2_header.signature")]
62
+ end
63
+ private_class_method :validate_signature
64
+
65
+ def self.validate_flavor(header)
66
+ return [] if VALID_FLAVORS.include?(header.flavor)
67
+
68
+ [issue(severity: :error,
69
+ message: "WOFF2 flavor is 0x#{header.flavor.to_s(16)} " \
70
+ "but must be a valid SFNT version " \
71
+ "(0x00010000, 0x74727565, 0x4F54544F, or 0x74746366)",
72
+ location: "woff2_header.flavor")]
73
+ end
74
+ private_class_method :validate_flavor
75
+
76
+ def self.validate_reserved(header)
77
+ return [] if header.reserved.zero?
78
+
79
+ [issue(severity: :warning,
80
+ message: "WOFF2 reserved field is #{header.reserved} " \
81
+ "but must be 0 per the spec",
82
+ location: "woff2_header.reserved")]
83
+ end
84
+ private_class_method :validate_reserved
85
+
86
+ def self.validate_version(header)
87
+ issues = []
88
+ if header.major_version != 1
89
+ issues << issue(severity: :warning,
90
+ message: "WOFF2 major version is #{header.major_version} " \
91
+ "but the spec recommends 1",
92
+ location: "woff2_header.major_version")
93
+ end
94
+ issues
95
+ end
96
+ private_class_method :validate_version
97
+
98
+ def self.validate_sizes(header)
99
+ issues = []
100
+ if header.total_compressed_size.to_i <= 0
101
+ issues << issue(severity: :error,
102
+ message: "WOFF2 total_compressed_size is " \
103
+ "#{header.total_compressed_size} but must be positive",
104
+ location: "woff2_header.total_compressed_size")
105
+ end
106
+ if header.total_sfnt_size.to_i <= 0
107
+ issues << issue(severity: :error,
108
+ message: "WOFF2 total_sfnt_size is " \
109
+ "#{header.total_sfnt_size} but must be positive",
110
+ location: "woff2_header.total_sfnt_size")
111
+ end
112
+ issues
113
+ end
114
+ private_class_method :validate_sizes
115
+
116
+ def self.validate_metadata(header)
117
+ return [] unless header.meta_offset.to_i.positive?
118
+
119
+ issues = []
120
+ if header.meta_length.to_i <= 0
121
+ issues << issue(severity: :error,
122
+ message: "WOFF2 metadata offset is set but " \
123
+ "meta_length is #{header.meta_length} " \
124
+ "(must be positive)",
125
+ location: "woff2_header.meta_length")
126
+ end
127
+ if header.meta_orig_length.to_i <= 0
128
+ issues << issue(severity: :error,
129
+ message: "WOFF2 metadata offset is set but " \
130
+ "meta_orig_length is #{header.meta_orig_length} " \
131
+ "(must be positive)",
132
+ location: "woff2_header.meta_orig_length")
133
+ end
134
+ issues
135
+ end
136
+ private_class_method :validate_metadata
137
+ end
138
+ end
139
+ end
140
+ end
@@ -17,6 +17,14 @@ module Fontisan
17
17
  "fontisan/audit/checks/ots_compatibility_check"
18
18
  autoload :CollectionIntegrityCheck,
19
19
  "fontisan/audit/checks/collection_integrity_check"
20
+ autoload :VariableFontCheck, "fontisan/audit/checks/variable_font_check"
21
+ autoload :HintingCheck, "fontisan/audit/checks/hinting_check"
22
+ autoload :Woff2ValidationCheck,
23
+ "fontisan/audit/checks/woff2_validation_check"
24
+ autoload :FormatRoundTripCheck,
25
+ "fontisan/audit/checks/format_round_trip_check"
26
+ autoload :OpenTypeConformanceCheck,
27
+ "fontisan/audit/checks/opentype_conformance_check"
20
28
  end
21
29
  end
22
30
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fontisan
4
- VERSION = "0.4.35"
4
+ VERSION = "0.4.36"
5
5
  end
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.35
4
+ version: 0.4.36
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -299,9 +299,14 @@ files:
299
299
  - lib/fontisan/audit/checks.rb
300
300
  - lib/fontisan/audit/checks/cmap_check.rb
301
301
  - lib/fontisan/audit/checks/collection_integrity_check.rb
302
+ - lib/fontisan/audit/checks/format_round_trip_check.rb
302
303
  - lib/fontisan/audit/checks/glyph_name_check.rb
304
+ - lib/fontisan/audit/checks/hinting_check.rb
305
+ - lib/fontisan/audit/checks/opentype_conformance_check.rb
303
306
  - lib/fontisan/audit/checks/ots_compatibility_check.rb
304
307
  - lib/fontisan/audit/checks/table_directory_check.rb
308
+ - lib/fontisan/audit/checks/variable_font_check.rb
309
+ - lib/fontisan/audit/checks/woff2_validation_check.rb
305
310
  - lib/fontisan/base_collection.rb
306
311
  - lib/fontisan/binary.rb
307
312
  - lib/fontisan/binary/base_record.rb