fontisan 0.2.4 → 0.2.5

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +150 -30
  3. data/README.adoc +497 -242
  4. data/lib/fontisan/cli.rb +67 -6
  5. data/lib/fontisan/commands/validate_command.rb +107 -151
  6. data/lib/fontisan/converters/woff2_encoder.rb +7 -29
  7. data/lib/fontisan/models/validation_report.rb +227 -0
  8. data/lib/fontisan/pipeline/transformation_pipeline.rb +4 -8
  9. data/lib/fontisan/tables/cmap.rb +82 -2
  10. data/lib/fontisan/tables/glyf.rb +118 -0
  11. data/lib/fontisan/tables/head.rb +60 -0
  12. data/lib/fontisan/tables/hhea.rb +74 -0
  13. data/lib/fontisan/tables/maxp.rb +60 -0
  14. data/lib/fontisan/tables/name.rb +76 -0
  15. data/lib/fontisan/tables/os2.rb +113 -0
  16. data/lib/fontisan/tables/post.rb +57 -0
  17. data/lib/fontisan/validators/basic_validator.rb +85 -0
  18. data/lib/fontisan/validators/font_book_validator.rb +130 -0
  19. data/lib/fontisan/validators/opentype_validator.rb +112 -0
  20. data/lib/fontisan/validators/profile_loader.rb +139 -0
  21. data/lib/fontisan/validators/validator.rb +484 -0
  22. data/lib/fontisan/validators/web_font_validator.rb +102 -0
  23. data/lib/fontisan/version.rb +1 -1
  24. data/lib/fontisan.rb +78 -6
  25. metadata +7 -11
  26. data/lib/fontisan/config/validation_rules.yml +0 -149
  27. data/lib/fontisan/validation/checksum_validator.rb +0 -170
  28. data/lib/fontisan/validation/consistency_validator.rb +0 -197
  29. data/lib/fontisan/validation/structure_validator.rb +0 -198
  30. data/lib/fontisan/validation/table_validator.rb +0 -158
  31. data/lib/fontisan/validation/validator.rb +0 -152
  32. data/lib/fontisan/validation/variable_font_validator.rb +0 -218
  33. data/lib/fontisan/validation/woff2_header_validator.rb +0 -278
  34. data/lib/fontisan/validation/woff2_table_validator.rb +0 -270
  35. data/lib/fontisan/validation/woff2_validator.rb +0 -248
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "basic_validator"
4
+ require_relative "font_book_validator"
5
+ require_relative "opentype_validator"
6
+ require_relative "web_font_validator"
7
+
8
+ module Fontisan
9
+ module Validators
10
+ # ProfileLoader manages validation profiles and loads appropriate validators
11
+ #
12
+ # This class provides a registry of validation profiles, each configured for
13
+ # specific use cases. Profiles define which validator to use, loading mode,
14
+ # and severity thresholds.
15
+ #
16
+ # Available profiles:
17
+ # - indexability: Fast validation for font discovery (BasicValidator)
18
+ # - usability: Basic usability for installation (FontBookValidator)
19
+ # - production: Comprehensive quality checks (OpenTypeValidator)
20
+ # - web: Web embedding and optimization (WebFontValidator)
21
+ # - spec_compliance: Full OpenType spec compliance (OpenTypeValidator)
22
+ # - default: Alias for production profile
23
+ #
24
+ # @example Loading a profile
25
+ # validator = ProfileLoader.load(:production)
26
+ # report = validator.validate(font)
27
+ #
28
+ # @example Getting profile info
29
+ # info = ProfileLoader.profile_info(:web)
30
+ # puts info[:description]
31
+ class ProfileLoader
32
+ # Profile definitions (hardcoded, no YAML)
33
+ PROFILES = {
34
+ indexability: {
35
+ name: "Font Indexability",
36
+ description: "Fast validation for font discovery and indexing",
37
+ validator: "BasicValidator",
38
+ loading_mode: "metadata",
39
+ severity_threshold: "error",
40
+ },
41
+ usability: {
42
+ name: "Font Usability",
43
+ description: "Basic usability for installation",
44
+ validator: "FontBookValidator",
45
+ loading_mode: "full",
46
+ severity_threshold: "warning",
47
+ },
48
+ production: {
49
+ name: "Production Quality",
50
+ description: "Comprehensive quality checks",
51
+ validator: "OpenTypeValidator",
52
+ loading_mode: "full",
53
+ severity_threshold: "warning",
54
+ },
55
+ web: {
56
+ name: "Web Font Readiness",
57
+ description: "Web embedding and optimization",
58
+ validator: "WebFontValidator",
59
+ loading_mode: "full",
60
+ severity_threshold: "warning",
61
+ },
62
+ spec_compliance: {
63
+ name: "OpenType Specification",
64
+ description: "Full OpenType spec compliance",
65
+ validator: "OpenTypeValidator",
66
+ loading_mode: "full",
67
+ severity_threshold: "info",
68
+ },
69
+ default: {
70
+ name: "Default Profile",
71
+ description: "Default validation profile (alias for production)",
72
+ validator: "OpenTypeValidator",
73
+ loading_mode: "full",
74
+ severity_threshold: "warning",
75
+ },
76
+ }.freeze
77
+
78
+ class << self
79
+ # Load a validator for the specified profile
80
+ #
81
+ # @param profile_name [Symbol, String] Profile name
82
+ # @return [Validator] Validator instance for the profile
83
+ # @raise [ArgumentError] if profile name is unknown
84
+ #
85
+ # @example Load production validator
86
+ # validator = ProfileLoader.load(:production)
87
+ def load(profile_name)
88
+ profile_name = profile_name.to_sym
89
+ profile_config = PROFILES[profile_name]
90
+
91
+ unless profile_config
92
+ raise ArgumentError,
93
+ "Unknown profile: #{profile_name}. " \
94
+ "Available profiles: #{available_profiles.join(', ')}"
95
+ end
96
+
97
+ validator_class_name = profile_config[:validator]
98
+ validator_class = Validators.const_get(validator_class_name)
99
+ validator_class.new
100
+ end
101
+
102
+ # Get list of available profile names
103
+ #
104
+ # @return [Array<Symbol>] Array of profile names
105
+ #
106
+ # @example List available profiles
107
+ # ProfileLoader.available_profiles
108
+ # # => [:indexability, :usability, :production, :web, :spec_compliance, :default]
109
+ def available_profiles
110
+ PROFILES.keys
111
+ end
112
+
113
+ # Get profile configuration
114
+ #
115
+ # @param profile_name [Symbol, String] Profile name
116
+ # @return [Hash, nil] Profile configuration or nil if not found
117
+ #
118
+ # @example Get profile info
119
+ # info = ProfileLoader.profile_info(:web)
120
+ # puts info[:description]
121
+ def profile_info(profile_name)
122
+ PROFILES[profile_name.to_sym]
123
+ end
124
+
125
+ # Get all profiles with their configurations
126
+ #
127
+ # @return [Hash] All profile configurations
128
+ #
129
+ # @example Get all profiles
130
+ # ProfileLoader.all_profiles.each do |name, config|
131
+ # puts "#{name}: #{config[:description]}"
132
+ # end
133
+ def all_profiles
134
+ PROFILES
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,484 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../models/validation_report"
4
+
5
+ module Fontisan
6
+ module Validators
7
+ # Base class for all validators using block-based DSL
8
+ #
9
+ # This class provides a declarative DSL for defining validation checks
10
+ # and an execution engine that runs those checks against font files.
11
+ # Subclasses define their validation logic by implementing define_checks.
12
+ #
13
+ # @example Creating a custom validator
14
+ # class MyValidator < Validator
15
+ # private
16
+ #
17
+ # def define_checks
18
+ # check_table :name_table, 'name' do
19
+ # check_field :family_name, :family_name do |table, value|
20
+ # !value.nil? && !value.empty?
21
+ # end
22
+ # end
23
+ # end
24
+ # end
25
+ #
26
+ # @example Running validation
27
+ # validator = MyValidator.new
28
+ # report = validator.validate(font)
29
+ # puts report.valid?
30
+ class Validator
31
+ # Initialize validator and define checks
32
+ def initialize
33
+ @checks = []
34
+ @current_table_context = nil
35
+ define_checks
36
+ end
37
+
38
+ # Validate a font and return a ValidationReport
39
+ #
40
+ # @param font [TrueTypeFont, OpenTypeFont] Font object to validate
41
+ # @return [ValidationReport] Complete validation report
42
+ def validate(font)
43
+ start_time = Time.now
44
+ all_results = []
45
+
46
+ @checks.each do |check_def|
47
+ result = execute_check(font, check_def)
48
+ all_results << result
49
+ end
50
+
51
+ elapsed = Time.now - start_time
52
+ build_report(font, all_results, elapsed)
53
+ end
54
+
55
+ protected
56
+
57
+ # DSL method: Define a table-level check
58
+ #
59
+ # @param check_id [Symbol] Unique identifier for this check
60
+ # @param table_tag [String] OpenType table tag (e.g., 'name', 'head')
61
+ # @param severity [Symbol] Severity level (:info, :warning, :error, :fatal)
62
+ # @param block [Proc] Check logic that receives table as parameter
63
+ def check_table(check_id, table_tag, severity: :error, &block)
64
+ @checks << {
65
+ type: :table,
66
+ id: check_id,
67
+ table_tag: table_tag,
68
+ severity: severity,
69
+ block: block,
70
+ }
71
+ end
72
+
73
+ # DSL method: Define a field-level check
74
+ #
75
+ # Must be called within a check_table block to establish table context
76
+ #
77
+ # @param check_id [Symbol] Unique identifier for this check
78
+ # @param field_key [Symbol] Field name to check
79
+ # @param severity [Symbol] Severity level (:info, :warning, :error, :fatal)
80
+ # @param block [Proc] Check logic that receives (table, value) as parameters
81
+ def check_field(check_id, field_key, severity: :error, &block)
82
+ unless @current_table_context
83
+ raise ArgumentError, "check_field must be called within check_table block"
84
+ end
85
+
86
+ @checks << {
87
+ type: :field,
88
+ id: check_id,
89
+ table_tag: @current_table_context,
90
+ field: field_key,
91
+ severity: severity,
92
+ block: block,
93
+ }
94
+ end
95
+
96
+ # DSL method: Define a structural validation check
97
+ #
98
+ # Used for checks that validate font structure and relationships
99
+ #
100
+ # @param check_id [Symbol] Unique identifier for this check
101
+ # @param severity [Symbol] Severity level (:info, :warning, :error, :fatal)
102
+ # @param block [Proc] Check logic that receives font as parameter
103
+ def check_structure(check_id, severity: :error, &block)
104
+ @checks << {
105
+ type: :structure,
106
+ id: check_id,
107
+ severity: severity,
108
+ block: block,
109
+ }
110
+ end
111
+
112
+ # DSL method: Define a usability check
113
+ #
114
+ # Used for checks that validate font usability and best practices
115
+ #
116
+ # @param check_id [Symbol] Unique identifier for this check
117
+ # @param severity [Symbol] Severity level (:info, :warning, :error, :fatal)
118
+ # @param block [Proc] Check logic that receives font as parameter
119
+ def check_usability(check_id, severity: :warning, &block)
120
+ @checks << {
121
+ type: :usability,
122
+ id: check_id,
123
+ severity: severity,
124
+ block: block,
125
+ }
126
+ end
127
+
128
+ # DSL method: Define an instruction validation check
129
+ #
130
+ # Used for checks that validate TrueType instructions/hinting
131
+ #
132
+ # @param check_id [Symbol] Unique identifier for this check
133
+ # @param severity [Symbol] Severity level (:info, :warning, :error, :fatal)
134
+ # @param block [Proc] Check logic that receives font as parameter
135
+ def check_instructions(check_id, severity: :warning, &block)
136
+ @checks << {
137
+ type: :instructions,
138
+ id: check_id,
139
+ severity: severity,
140
+ block: block,
141
+ }
142
+ end
143
+
144
+ # DSL method: Define a glyph-level check
145
+ #
146
+ # Used for checks that validate individual glyphs
147
+ #
148
+ # @param check_id [Symbol] Unique identifier for this check
149
+ # @param severity [Symbol] Severity level (:info, :warning, :error, :fatal)
150
+ # @param block [Proc] Check logic that receives font as parameter
151
+ def check_glyphs(check_id, severity: :error, &block)
152
+ @checks << {
153
+ type: :glyphs,
154
+ id: check_id,
155
+ severity: severity,
156
+ block: block,
157
+ }
158
+ end
159
+
160
+ private
161
+
162
+ # Template method: Subclasses implement this to define their checks
163
+ #
164
+ # @return [void]
165
+ def define_checks
166
+ # Subclasses override this method
167
+ end
168
+
169
+ # Execute a single check using strategy pattern
170
+ #
171
+ # @param font [TrueTypeFont, OpenTypeFont] Font to validate
172
+ # @param check_def [Hash] Check definition
173
+ # @return [Hash] Check result with :passed, :severity, :messages, :issues
174
+ def execute_check(font, check_def)
175
+ case check_def[:type]
176
+ when :table
177
+ execute_table_check(font, check_def)
178
+ when :field
179
+ execute_field_check(font, check_def)
180
+ when :structure
181
+ execute_structure_check(font, check_def)
182
+ when :usability
183
+ execute_usability_check(font, check_def)
184
+ when :instructions
185
+ execute_instruction_check(font, check_def)
186
+ when :glyphs
187
+ execute_glyph_check(font, check_def)
188
+ else
189
+ {
190
+ check_id: check_def[:id],
191
+ passed: false,
192
+ severity: :fatal,
193
+ messages: ["Unknown check type: #{check_def[:type]}"],
194
+ issues: [],
195
+ }
196
+ end
197
+ rescue => e
198
+ {
199
+ check_id: check_def[:id],
200
+ passed: false,
201
+ severity: :fatal,
202
+ messages: ["Check execution failed: #{e.message}"],
203
+ issues: [{
204
+ severity: "fatal",
205
+ category: "check_execution",
206
+ message: "Exception during check execution: #{e.class} - #{e.message}",
207
+ }],
208
+ }
209
+ end
210
+
211
+ # Execute a table-level check
212
+ #
213
+ # @param font [TrueTypeFont, OpenTypeFont] Font to validate
214
+ # @param check_def [Hash] Check definition
215
+ # @return [Hash] Check result
216
+ def execute_table_check(font, check_def)
217
+ table_tag = check_def[:table_tag]
218
+ table = font.table(table_tag)
219
+
220
+ unless table
221
+ return {
222
+ check_id: check_def[:id],
223
+ passed: false,
224
+ severity: check_def[:severity].to_s,
225
+ messages: ["Table '#{table_tag}' not found in font"],
226
+ table: table_tag,
227
+ issues: [{
228
+ severity: check_def[:severity].to_s,
229
+ category: "table_presence",
230
+ table: table_tag,
231
+ message: "Required table '#{table_tag}' is missing",
232
+ }],
233
+ }
234
+ end
235
+
236
+ # Set context for nested field checks
237
+ old_context = @current_table_context
238
+ @current_table_context = table_tag
239
+
240
+ begin
241
+ result = check_def[:block].call(table)
242
+ passed = result != false && result != nil
243
+
244
+ {
245
+ check_id: check_def[:id],
246
+ passed: passed,
247
+ severity: check_def[:severity].to_s,
248
+ messages: passed ? [] : ["Table '#{table_tag}' validation failed"],
249
+ table: table_tag,
250
+ issues: passed ? [] : [{
251
+ severity: check_def[:severity].to_s,
252
+ category: "table_validation",
253
+ table: table_tag,
254
+ message: "Table '#{table_tag}' failed validation",
255
+ }],
256
+ }
257
+ ensure
258
+ @current_table_context = old_context
259
+ end
260
+ end
261
+
262
+ # Execute a field-level check
263
+ #
264
+ # @param font [TrueTypeFont, OpenTypeFont] Font to validate
265
+ # @param check_def [Hash] Check definition
266
+ # @return [Hash] Check result
267
+ def execute_field_check(font, check_def)
268
+ table_tag = check_def[:table_tag]
269
+ field_key = check_def[:field]
270
+ table = font.table(table_tag)
271
+
272
+ unless table
273
+ return {
274
+ check_id: check_def[:id],
275
+ passed: false,
276
+ severity: check_def[:severity].to_s,
277
+ messages: ["Table '#{table_tag}' not found"],
278
+ table: table_tag,
279
+ field: field_key.to_s,
280
+ issues: [{
281
+ severity: check_def[:severity].to_s,
282
+ category: "table_presence",
283
+ table: table_tag,
284
+ field: field_key.to_s,
285
+ message: "Cannot validate field '#{field_key}': table '#{table_tag}' missing",
286
+ }],
287
+ }
288
+ end
289
+
290
+ # Get field value
291
+ value = if table.respond_to?(field_key)
292
+ table.public_send(field_key)
293
+ else
294
+ nil
295
+ end
296
+
297
+ result = check_def[:block].call(table, value)
298
+ passed = result != false && result != nil
299
+
300
+ {
301
+ check_id: check_def[:id],
302
+ passed: passed,
303
+ severity: check_def[:severity].to_s,
304
+ messages: passed ? [] : ["Field '#{field_key}' validation failed"],
305
+ table: table_tag,
306
+ field: field_key.to_s,
307
+ issues: passed ? [] : [{
308
+ severity: check_def[:severity].to_s,
309
+ category: "field_validation",
310
+ table: table_tag,
311
+ field: field_key.to_s,
312
+ message: "Field '#{field_key}' in table '#{table_tag}' failed validation",
313
+ }],
314
+ }
315
+ end
316
+
317
+ # Execute a structure check
318
+ #
319
+ # @param font [TrueTypeFont, OpenTypeFont] Font to validate
320
+ # @param check_def [Hash] Check definition
321
+ # @return [Hash] Check result
322
+ def execute_structure_check(font, check_def)
323
+ result = check_def[:block].call(font)
324
+ passed = result != false && result != nil
325
+
326
+ {
327
+ check_id: check_def[:id],
328
+ passed: passed,
329
+ severity: check_def[:severity].to_s,
330
+ messages: passed ? [] : ["Structure validation failed"],
331
+ issues: passed ? [] : [{
332
+ severity: check_def[:severity].to_s,
333
+ category: "structure",
334
+ message: "Font structure validation failed for check '#{check_def[:id]}'",
335
+ }],
336
+ }
337
+ end
338
+
339
+ # Execute a usability check
340
+ #
341
+ # @param font [TrueTypeFont, OpenTypeFont] Font to validate
342
+ # @param check_def [Hash] Check definition
343
+ # @return [Hash] Check result
344
+ def execute_usability_check(font, check_def)
345
+ result = check_def[:block].call(font)
346
+ passed = result != false && result != nil
347
+
348
+ {
349
+ check_id: check_def[:id],
350
+ passed: passed,
351
+ severity: check_def[:severity].to_s,
352
+ messages: passed ? [] : ["Usability check failed"],
353
+ issues: passed ? [] : [{
354
+ severity: check_def[:severity].to_s,
355
+ category: "usability",
356
+ message: "Font usability check failed for '#{check_def[:id]}'",
357
+ }],
358
+ }
359
+ end
360
+
361
+ # Execute an instruction check
362
+ #
363
+ # @param font [TrueTypeFont, OpenTypeFont] Font to validate
364
+ # @param check_def [Hash] Check definition
365
+ # @return [Hash] Check result
366
+ def execute_instruction_check(font, check_def)
367
+ result = check_def[:block].call(font)
368
+ passed = result != false && result != nil
369
+
370
+ {
371
+ check_id: check_def[:id],
372
+ passed: passed,
373
+ severity: check_def[:severity].to_s,
374
+ messages: passed ? [] : ["Instruction validation failed"],
375
+ issues: passed ? [] : [{
376
+ severity: check_def[:severity].to_s,
377
+ category: "instructions",
378
+ message: "TrueType instruction check failed for '#{check_def[:id]}'",
379
+ }],
380
+ }
381
+ end
382
+
383
+ # Execute a glyph check
384
+ #
385
+ # @param font [TrueTypeFont, OpenTypeFont] Font to validate
386
+ # @param check_def [Hash] Check definition
387
+ # @return [Hash] Check result
388
+ def execute_glyph_check(font, check_def)
389
+ result = check_def[:block].call(font)
390
+ passed = result != false && result != nil
391
+
392
+ {
393
+ check_id: check_def[:id],
394
+ passed: passed,
395
+ severity: check_def[:severity].to_s,
396
+ messages: passed ? [] : ["Glyph validation failed"],
397
+ issues: passed ? [] : [{
398
+ severity: check_def[:severity].to_s,
399
+ category: "glyphs",
400
+ message: "Glyph validation failed for check '#{check_def[:id]}'",
401
+ }],
402
+ }
403
+ end
404
+
405
+ # Build ValidationReport from check results
406
+ #
407
+ # @param font [TrueTypeFont, OpenTypeFont] Font that was validated
408
+ # @param all_results [Array<Hash>] All check results
409
+ # @param elapsed [Float] Elapsed time in seconds
410
+ # @return [ValidationReport] Complete report
411
+ def build_report(font, all_results, elapsed)
412
+ # Extract font path from font object
413
+ font_path = if font.respond_to?(:path)
414
+ font.path
415
+ elsif font.respond_to?(:filename)
416
+ font.filename
417
+ elsif font.instance_variable_defined?(:@filename)
418
+ font.instance_variable_get(:@filename)
419
+ else
420
+ "unknown"
421
+ end
422
+
423
+ report = Models::ValidationReport.new(
424
+ font_path: font_path,
425
+ valid: true,
426
+ )
427
+
428
+ # Build CheckResult objects
429
+ all_results.each do |result|
430
+ check_result = Models::ValidationReport::CheckResult.new(
431
+ check_id: result[:check_id].to_s,
432
+ passed: result[:passed],
433
+ severity: result[:severity],
434
+ messages: result[:messages] || [],
435
+ table: result[:table],
436
+ field: result[:field],
437
+ )
438
+ report.check_results << check_result
439
+
440
+ # Add issues to main report
441
+ if result[:issues]
442
+ result[:issues].each do |issue_data|
443
+ case issue_data[:severity]
444
+ when "error", "fatal"
445
+ report.add_error(
446
+ issue_data[:category] || "validation",
447
+ issue_data[:message],
448
+ issue_data[:table] || issue_data[:field],
449
+ )
450
+ when "warning"
451
+ report.add_warning(
452
+ issue_data[:category] || "validation",
453
+ issue_data[:message],
454
+ issue_data[:table] || issue_data[:field],
455
+ )
456
+ when "info"
457
+ report.add_info(
458
+ issue_data[:category] || "validation",
459
+ issue_data[:message],
460
+ issue_data[:table] || issue_data[:field],
461
+ )
462
+ end
463
+ end
464
+ end
465
+ end
466
+
467
+ # Mark checks performed
468
+ report.checks_performed = all_results.map { |r| r[:check_id].to_s }
469
+
470
+ # Set status based on results
471
+ if report.has_errors?
472
+ report.status = "invalid"
473
+ report.valid = false
474
+ elsif report.has_warnings?
475
+ report.status = "valid_with_warnings"
476
+ else
477
+ report.status = "valid"
478
+ end
479
+
480
+ report
481
+ end
482
+ end
483
+ end
484
+ end