fontisan 0.4.34 → 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.
@@ -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,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,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