fontisan 0.2.4 → 0.2.6

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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +168 -32
  3. data/README.adoc +673 -1091
  4. data/lib/fontisan/cli.rb +94 -13
  5. data/lib/fontisan/collection/dfont_builder.rb +315 -0
  6. data/lib/fontisan/commands/convert_command.rb +118 -7
  7. data/lib/fontisan/commands/pack_command.rb +129 -22
  8. data/lib/fontisan/commands/validate_command.rb +107 -151
  9. data/lib/fontisan/config/conversion_matrix.yml +175 -1
  10. data/lib/fontisan/constants.rb +8 -0
  11. data/lib/fontisan/converters/collection_converter.rb +438 -0
  12. data/lib/fontisan/converters/woff2_encoder.rb +7 -29
  13. data/lib/fontisan/dfont_collection.rb +185 -0
  14. data/lib/fontisan/font_loader.rb +91 -6
  15. data/lib/fontisan/models/validation_report.rb +227 -0
  16. data/lib/fontisan/parsers/dfont_parser.rb +192 -0
  17. data/lib/fontisan/pipeline/transformation_pipeline.rb +4 -8
  18. data/lib/fontisan/tables/cmap.rb +82 -2
  19. data/lib/fontisan/tables/glyf.rb +118 -0
  20. data/lib/fontisan/tables/head.rb +60 -0
  21. data/lib/fontisan/tables/hhea.rb +74 -0
  22. data/lib/fontisan/tables/maxp.rb +60 -0
  23. data/lib/fontisan/tables/name.rb +76 -0
  24. data/lib/fontisan/tables/os2.rb +113 -0
  25. data/lib/fontisan/tables/post.rb +57 -0
  26. data/lib/fontisan/true_type_font.rb +8 -46
  27. data/lib/fontisan/validation/collection_validator.rb +265 -0
  28. data/lib/fontisan/validators/basic_validator.rb +85 -0
  29. data/lib/fontisan/validators/font_book_validator.rb +130 -0
  30. data/lib/fontisan/validators/opentype_validator.rb +112 -0
  31. data/lib/fontisan/validators/profile_loader.rb +139 -0
  32. data/lib/fontisan/validators/validator.rb +484 -0
  33. data/lib/fontisan/validators/web_font_validator.rb +102 -0
  34. data/lib/fontisan/version.rb +1 -1
  35. data/lib/fontisan.rb +78 -6
  36. metadata +13 -12
  37. data/lib/fontisan/config/validation_rules.yml +0 -149
  38. data/lib/fontisan/validation/checksum_validator.rb +0 -170
  39. data/lib/fontisan/validation/consistency_validator.rb +0 -197
  40. data/lib/fontisan/validation/structure_validator.rb +0 -198
  41. data/lib/fontisan/validation/table_validator.rb +0 -158
  42. data/lib/fontisan/validation/validator.rb +0 -152
  43. data/lib/fontisan/validation/variable_font_validator.rb +0 -218
  44. data/lib/fontisan/validation/woff2_header_validator.rb +0 -278
  45. data/lib/fontisan/validation/woff2_table_validator.rb +0 -270
  46. data/lib/fontisan/validation/woff2_validator.rb +0 -248
@@ -1,218 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Fontisan
4
- module Validation
5
- # VariableFontValidator validates variable font structure
6
- #
7
- # Validates:
8
- # - fvar table structure
9
- # - Axis definitions and ranges
10
- # - Instance definitions
11
- # - Variation table consistency
12
- # - Metrics variation tables
13
- #
14
- # @example Validate a variable font
15
- # validator = VariableFontValidator.new(font)
16
- # errors = validator.validate
17
- # puts "Found #{errors.length} errors" if errors.any?
18
- class VariableFontValidator
19
- # Initialize validator with font
20
- #
21
- # @param font [TrueTypeFont, OpenTypeFont] Font to validate
22
- def initialize(font)
23
- @font = font
24
- @errors = []
25
- end
26
-
27
- # Validate variable font
28
- #
29
- # @return [Array<String>] Array of error messages
30
- def validate
31
- return [] unless @font.has_table?("fvar")
32
-
33
- validate_fvar_structure
34
- validate_axes
35
- validate_instances
36
- validate_variation_tables
37
- validate_metrics_variation
38
-
39
- @errors
40
- end
41
-
42
- private
43
-
44
- # Validate fvar table structure
45
- #
46
- # @return [void]
47
- def validate_fvar_structure
48
- fvar = @font.table("fvar")
49
- return unless fvar
50
-
51
- if !fvar.respond_to?(:axes) || fvar.axes.nil? || fvar.axes.empty?
52
- @errors << "fvar: No axes defined"
53
- return
54
- end
55
-
56
- if fvar.respond_to?(:axis_count) && fvar.axis_count != fvar.axes.length
57
- @errors << "fvar: Axis count mismatch (expected #{fvar.axis_count}, got #{fvar.axes.length})"
58
- end
59
- end
60
-
61
- # Validate all axes
62
- #
63
- # @return [void]
64
- def validate_axes
65
- fvar = @font.table("fvar")
66
- return unless fvar.respond_to?(:axes)
67
-
68
- fvar.axes.each_with_index do |axis, index|
69
- validate_axis_range(axis, index)
70
- validate_axis_tag(axis, index)
71
- end
72
- end
73
-
74
- # Validate axis range values
75
- #
76
- # @param axis [Object] Axis object
77
- # @param index [Integer] Axis index
78
- # @return [void]
79
- def validate_axis_range(axis, index)
80
- return unless axis.respond_to?(:min_value) && axis.respond_to?(:max_value)
81
-
82
- if axis.min_value > axis.max_value
83
- tag = axis.respond_to?(:axis_tag) ? axis.axis_tag : "axis #{index}"
84
- @errors << "Axis #{tag}: min_value (#{axis.min_value}) > max_value (#{axis.max_value})"
85
- end
86
-
87
- if axis.respond_to?(:default_value) && (axis.default_value < axis.min_value || axis.default_value > axis.max_value)
88
- tag = axis.respond_to?(:axis_tag) ? axis.axis_tag : "axis #{index}"
89
- @errors << "Axis #{tag}: default_value (#{axis.default_value}) out of range [#{axis.min_value}, #{axis.max_value}]"
90
- end
91
- end
92
-
93
- # Validate axis tag format
94
- #
95
- # @param axis [Object] Axis object
96
- # @param index [Integer] Axis index
97
- # @return [void]
98
- def validate_axis_tag(axis, index)
99
- return unless axis.respond_to?(:axis_tag)
100
-
101
- tag = axis.axis_tag
102
- unless tag.is_a?(String) && tag.length == 4 && tag =~ /^[a-zA-Z]{4}$/
103
- @errors << "Axis #{index}: invalid tag '#{tag}' (must be 4 ASCII letters)"
104
- end
105
- end
106
-
107
- # Validate named instances
108
- #
109
- # @return [void]
110
- def validate_instances
111
- fvar = @font.table("fvar")
112
- return unless fvar.respond_to?(:instances)
113
- return unless fvar.instances
114
-
115
- fvar.instances.each_with_index do |instance, idx|
116
- validate_instance_coordinates(instance, idx, fvar)
117
- end
118
- end
119
-
120
- # Validate instance coordinates
121
- #
122
- # @param instance [Object] Instance object
123
- # @param idx [Integer] Instance index
124
- # @param fvar [Object] fvar table
125
- # @return [void]
126
- def validate_instance_coordinates(instance, idx, fvar)
127
- return unless instance.is_a?(Hash) && instance[:coordinates]
128
-
129
- coords = instance[:coordinates]
130
- axis_count = fvar.respond_to?(:axis_count) ? fvar.axis_count : fvar.axes.length
131
-
132
- if coords.length != axis_count
133
- @errors << "Instance #{idx}: coordinate count mismatch (expected #{axis_count}, got #{coords.length})"
134
- end
135
-
136
- coords.each_with_index do |value, axis_idx|
137
- next if axis_idx >= fvar.axes.length
138
-
139
- axis = fvar.axes[axis_idx]
140
- next unless axis.respond_to?(:min_value) && axis.respond_to?(:max_value)
141
-
142
- if value < axis.min_value || value > axis.max_value
143
- tag = axis.respond_to?(:axis_tag) ? axis.axis_tag : "axis #{axis_idx}"
144
- @errors << "Instance #{idx}: coordinate for #{tag} (#{value}) out of range [#{axis.min_value}, #{axis.max_value}]"
145
- end
146
- end
147
- end
148
-
149
- # Validate variation tables
150
- #
151
- # @return [void]
152
- def validate_variation_tables
153
- has_gvar = @font.has_table?("gvar")
154
- has_cff2 = @font.has_table?("CFF2")
155
- has_glyf = @font.has_table?("glyf")
156
- has_cff = @font.has_table?("CFF ")
157
-
158
- # TrueType variable fonts should have gvar
159
- if has_glyf && !has_gvar
160
- @errors << "TrueType variable font missing gvar table"
161
- end
162
-
163
- # CFF variable fonts should have CFF2
164
- if has_cff && !has_cff2
165
- @errors << "CFF variable font missing CFF2 table"
166
- end
167
-
168
- # Can't have both gvar and CFF2
169
- if has_gvar && has_cff2
170
- @errors << "Font has both gvar and CFF2 tables (incompatible)"
171
- end
172
- end
173
-
174
- # Validate metrics variation tables
175
- #
176
- # @return [void]
177
- def validate_metrics_variation
178
- validate_hvar if @font.has_table?("HVAR")
179
- validate_vvar if @font.has_table?("VVAR")
180
- validate_mvar if @font.has_table?("MVAR")
181
- end
182
-
183
- # Validate HVAR table
184
- #
185
- # @return [void]
186
- def validate_hvar
187
- # HVAR validation would go here
188
- # For now, just check it exists
189
- hvar = @font.table_data["HVAR"]
190
- if hvar.nil? || hvar.empty?
191
- @errors << "HVAR table is empty"
192
- end
193
- end
194
-
195
- # Validate VVAR table
196
- #
197
- # @return [void]
198
- def validate_vvar
199
- # VVAR validation would go here
200
- vvar = @font.table_data["VVAR"]
201
- if vvar.nil? || vvar.empty?
202
- @errors << "VVAR table is empty"
203
- end
204
- end
205
-
206
- # Validate MVAR table
207
- #
208
- # @return [void]
209
- def validate_mvar
210
- # MVAR validation would go here
211
- mvar = @font.table_data["MVAR"]
212
- if mvar.nil? || mvar.empty?
213
- @errors << "MVAR table is empty"
214
- end
215
- end
216
- end
217
- end
218
- end
@@ -1,278 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Fontisan
4
- module Validation
5
- # Woff2HeaderValidator validates the WOFF2 header structure
6
- #
7
- # This validator checks the WOFF2 header for:
8
- # - Valid signature (0x774F4632 'wOF2')
9
- # - Valid flavor (TrueType or CFF)
10
- # - Reserved field is zero
11
- # - File length consistency
12
- # - Valid table count
13
- # - Valid compressed size
14
- # - Metadata offset/length consistency
15
- # - Private data offset/length consistency
16
- #
17
- # Single Responsibility: WOFF2 header validation
18
- #
19
- # @example Validating a WOFF2 header
20
- # validator = Woff2HeaderValidator.new(rules)
21
- # issues = validator.validate(woff2_font)
22
- class Woff2HeaderValidator
23
- # Valid WOFF2 flavors
24
- VALID_FLAVORS = [
25
- 0x00010000, # TrueType
26
- 0x74727565, # 'true' (TrueType)
27
- 0x4F54544F, # 'OTTO' (CFF/OpenType)
28
- ].freeze
29
-
30
- # Initialize WOFF2 header validator
31
- #
32
- # @param rules [Hash] Validation rules configuration
33
- def initialize(rules)
34
- @rules = rules
35
- @woff2_config = rules["woff2_validation"] || {}
36
- end
37
-
38
- # Validate WOFF2 header
39
- #
40
- # @param woff2_font [Woff2Font] The WOFF2 font to validate
41
- # @return [Array<Hash>] Array of validation issues
42
- def validate(woff2_font)
43
- issues = []
44
-
45
- header = woff2_font.header
46
- return issues unless header
47
-
48
- # Check signature
49
- issues.concat(check_signature(header))
50
-
51
- # Check flavor
52
- issues.concat(check_flavor(header))
53
-
54
- # Check reserved field
55
- issues.concat(check_reserved_field(header))
56
-
57
- # Check table count
58
- issues.concat(check_table_count(header, woff2_font))
59
-
60
- # Check compressed size
61
- issues.concat(check_compressed_size(header))
62
-
63
- # Check metadata consistency
64
- issues.concat(check_metadata(header))
65
-
66
- # Check private data consistency
67
- issues.concat(check_private_data(header))
68
-
69
- issues
70
- end
71
-
72
- private
73
-
74
- # Check signature validity
75
- #
76
- # @param header [Woff2::Woff2Header] The header
77
- # @return [Array<Hash>] Array of signature issues
78
- def check_signature(header)
79
- issues = []
80
-
81
- unless header.signature == Woff2::Woff2Header::SIGNATURE
82
- issues << {
83
- severity: "error",
84
- category: "woff2_header",
85
- message: "Invalid WOFF2 signature: expected 0x#{Woff2::Woff2Header::SIGNATURE.to_s(16)}, " \
86
- "got 0x#{header.signature.to_s(16)}",
87
- location: "header",
88
- }
89
- end
90
-
91
- issues
92
- end
93
-
94
- # Check flavor validity
95
- #
96
- # @param header [Woff2::Woff2Header] The header
97
- # @return [Array<Hash>] Array of flavor issues
98
- def check_flavor(header)
99
- issues = []
100
-
101
- unless VALID_FLAVORS.include?(header.flavor)
102
- issues << {
103
- severity: "error",
104
- category: "woff2_header",
105
- message: "Invalid WOFF2 flavor: 0x#{header.flavor.to_s(16)} " \
106
- "(expected TrueType 0x00010000 or CFF 0x4F54544F)",
107
- location: "header",
108
- }
109
- end
110
-
111
- issues
112
- end
113
-
114
- # Check reserved field
115
- #
116
- # @param header [Woff2::Woff2Header] The header
117
- # @return [Array<Hash>] Array of reserved field issues
118
- def check_reserved_field(header)
119
- issues = []
120
-
121
- if header.reserved != 0
122
- issues << {
123
- severity: "warning",
124
- category: "woff2_header",
125
- message: "Reserved field should be 0, got #{header.reserved}",
126
- location: "header",
127
- }
128
- end
129
-
130
- issues
131
- end
132
-
133
- # Check table count validity
134
- #
135
- # @param header [Woff2::Woff2Header] The header
136
- # @param woff2_font [Woff2Font] The WOFF2 font
137
- # @return [Array<Hash>] Array of table count issues
138
- def check_table_count(header, woff2_font)
139
- issues = []
140
-
141
- if header.num_tables.zero?
142
- issues << {
143
- severity: "error",
144
- category: "woff2_header",
145
- message: "Number of tables cannot be zero",
146
- location: "header",
147
- }
148
- end
149
-
150
- # Check consistency with actual table entries
151
- actual_count = woff2_font.table_entries.length
152
- if header.num_tables != actual_count
153
- issues << {
154
- severity: "error",
155
- category: "woff2_header",
156
- message: "Table count mismatch: header=#{header.num_tables}, actual=#{actual_count}",
157
- location: "header",
158
- }
159
- end
160
-
161
- issues
162
- end
163
-
164
- # Check compressed size validity
165
- #
166
- # @param header [Woff2::Woff2Header] The header
167
- # @return [Array<Hash>] Array of compressed size issues
168
- def check_compressed_size(header)
169
- issues = []
170
-
171
- if header.total_compressed_size.zero?
172
- issues << {
173
- severity: "error",
174
- category: "woff2_header",
175
- message: "Total compressed size cannot be zero",
176
- location: "header",
177
- }
178
- end
179
-
180
- # Check compression ratio
181
- if header.total_sfnt_size.positive? && header.total_compressed_size.positive?
182
- ratio = header.total_compressed_size.to_f / header.total_sfnt_size
183
- min_ratio = @woff2_config["min_compression_ratio"] || 0.2
184
- max_ratio = @woff2_config["max_compression_ratio"] || 0.95
185
-
186
- if ratio < min_ratio
187
- issues << {
188
- severity: "warning",
189
- category: "woff2_header",
190
- message: "Compression ratio (#{(ratio * 100).round(2)}%) is unusually low",
191
- location: "header",
192
- }
193
- elsif ratio > max_ratio
194
- issues << {
195
- severity: "warning",
196
- category: "woff2_header",
197
- message: "Compression ratio (#{(ratio * 100).round(2)}%) is unusually high",
198
- location: "header",
199
- }
200
- end
201
- end
202
-
203
- issues
204
- end
205
-
206
- # Check metadata consistency
207
- #
208
- # @param header [Woff2::Woff2Header] The header
209
- # @return [Array<Hash>] Array of metadata issues
210
- def check_metadata(header)
211
- issues = []
212
-
213
- # If metadata offset is set, length must be positive
214
- if header.meta_offset.positive? && header.meta_length.zero?
215
- issues << {
216
- severity: "warning",
217
- category: "woff2_header",
218
- message: "Metadata offset is set but length is zero",
219
- location: "header",
220
- }
221
- end
222
-
223
- # If metadata length is set, offset must be positive
224
- if header.meta_length.positive? && header.meta_offset.zero?
225
- issues << {
226
- severity: "warning",
227
- category: "woff2_header",
228
- message: "Metadata length is set but offset is zero",
229
- location: "header",
230
- }
231
- end
232
-
233
- # Original length should be >= compressed length
234
- if header.meta_orig_length.positive? && header.meta_length.positive? && (header.meta_orig_length < header.meta_length)
235
- issues << {
236
- severity: "error",
237
- category: "woff2_header",
238
- message: "Metadata original length (#{header.meta_orig_length}) " \
239
- "is less than compressed length (#{header.meta_length})",
240
- location: "header",
241
- }
242
- end
243
-
244
- issues
245
- end
246
-
247
- # Check private data consistency
248
- #
249
- # @param header [Woff2::Woff2Header] The header
250
- # @return [Array<Hash>] Array of private data issues
251
- def check_private_data(header)
252
- issues = []
253
-
254
- # If private offset is set, length must be positive
255
- if header.priv_offset.positive? && header.priv_length.zero?
256
- issues << {
257
- severity: "warning",
258
- category: "woff2_header",
259
- message: "Private data offset is set but length is zero",
260
- location: "header",
261
- }
262
- end
263
-
264
- # If private length is set, offset must be positive
265
- if header.priv_length.positive? && header.priv_offset.zero?
266
- issues << {
267
- severity: "warning",
268
- category: "woff2_header",
269
- message: "Private data length is set but offset is zero",
270
- location: "header",
271
- }
272
- end
273
-
274
- issues
275
- end
276
- end
277
- end
278
- end