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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c534e6da296ea563119e164f40835bf5e794d0c7d37d4fdcd2c0e31008925410
4
- data.tar.gz: be05402a2e893896e744432950d1f1e8951a59004f76ca9aacd2a9353f8d54cd
3
+ metadata.gz: 615c64e2005d769c01c4b7a5cd153630b42f5230c55a0242f892aaec65618320
4
+ data.tar.gz: deca6b51aba40a45b2150290fe5ef0bd94b935f4fd09c54da54e65bf8cde3194
5
5
  SHA512:
6
- metadata.gz: 2e77ac651176f1d4b42d24ac60d41d3b371ac548438bc373b9d5f1ce44b36f187050ce9e5e5b5385cfc83732e2e40d946cfb9d3ae2ac83ad9c82a20a8cc7fc5b
7
- data.tar.gz: 903c46cd5ab5631f3ae6723bdee6f6df007c0351291e1a60856da0328ddcadbedc9a21ba68717d5db5d77031bd3b5bf69a003d08e33a8654302ae72eb122c7a4
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
- # 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; 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
- structural: %i[table_directory collection_integrity],
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