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
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "parsers/dfont_parser"
4
+ require_relative "error"
5
+
6
+ module Fontisan
7
+ # DfontCollection represents an Apple dfont suitcase containing multiple fonts
8
+ #
9
+ # dfont (Data Fork Font) is an Apple-specific format that stores Mac font
10
+ # suitcase resources in the data fork. It can contain multiple SFNT fonts
11
+ # (TrueType or OpenType).
12
+ #
13
+ # This class provides a collection interface similar to TrueTypeCollection
14
+ # and OpenTypeCollection for consistency.
15
+ #
16
+ # @example Load dfont collection
17
+ # collection = DfontCollection.from_file("family.dfont")
18
+ # puts "Collection has #{collection.num_fonts} fonts"
19
+ #
20
+ # @example Extract fonts from dfont
21
+ # File.open("family.dfont", "rb") do |io|
22
+ # fonts = collection.extract_fonts(io)
23
+ # fonts.each { |font| puts font.class.name }
24
+ # end
25
+ class DfontCollection
26
+ # Path to dfont file
27
+ # @return [String]
28
+ attr_reader :path
29
+
30
+ # Number of fonts in collection
31
+ # @return [Integer]
32
+ attr_reader :num_fonts
33
+ alias font_count num_fonts
34
+
35
+ # Load dfont collection from file
36
+ #
37
+ # @param path [String] Path to dfont file
38
+ # @return [DfontCollection] Collection object
39
+ # @raise [InvalidFontError] if not valid dfont
40
+ def self.from_file(path)
41
+ File.open(path, "rb") do |io|
42
+ unless Parsers::DfontParser.dfont?(io)
43
+ raise InvalidFontError, "Not a valid dfont file: #{path}"
44
+ end
45
+
46
+ num_fonts = Parsers::DfontParser.sfnt_count(io)
47
+ new(path, num_fonts)
48
+ end
49
+ end
50
+
51
+ # Initialize collection
52
+ #
53
+ # @param path [String] Path to dfont file
54
+ # @param num_fonts [Integer] Number of fonts
55
+ # @api private
56
+ def initialize(path, num_fonts)
57
+ @path = path
58
+ @num_fonts = num_fonts
59
+ end
60
+
61
+ # Check if collection is valid
62
+ #
63
+ # @return [Boolean] true if valid
64
+ def valid?
65
+ File.exist?(@path) && @num_fonts.positive?
66
+ end
67
+
68
+ # Extract all fonts from dfont
69
+ #
70
+ # @param io [IO] Open file handle
71
+ # @return [Array<TrueTypeFont, OpenTypeFont>] Array of fonts
72
+ def extract_fonts(io)
73
+ require "stringio"
74
+
75
+ fonts = []
76
+
77
+ @num_fonts.times do |index|
78
+ io.rewind
79
+ sfnt_data = Parsers::DfontParser.extract_sfnt(io, index: index)
80
+
81
+ # Load font from SFNT binary
82
+ sfnt_io = StringIO.new(sfnt_data)
83
+ signature = sfnt_io.read(4)
84
+ sfnt_io.rewind
85
+
86
+ # Create font based on signature
87
+ font = case signature
88
+ when [Constants::SFNT_VERSION_TRUETYPE].pack("N"), "true"
89
+ TrueTypeFont.read(sfnt_io)
90
+ when "OTTO"
91
+ OpenTypeFont.read(sfnt_io)
92
+ else
93
+ raise InvalidFontError,
94
+ "Invalid SFNT signature in dfont at index #{index}: #{signature.inspect}"
95
+ end
96
+
97
+ font.initialize_storage
98
+ font.loading_mode = LoadingModes::FULL
99
+ font.lazy_load_enabled = false
100
+ font.read_table_data(sfnt_io)
101
+
102
+ fonts << font
103
+ end
104
+
105
+ fonts
106
+ end
107
+
108
+ # List fonts in collection (brief info)
109
+ #
110
+ # @param io [IO] Open file handle
111
+ # @return [Models::CollectionListInfo] Collection list info
112
+ def list_fonts(io)
113
+ require_relative "models/collection_list_info"
114
+ require_relative "models/collection_font_summary"
115
+
116
+ fonts = extract_fonts(io)
117
+
118
+ summaries = fonts.map.with_index do |font, index|
119
+ name_table = font.table("name")
120
+ family = name_table.english_name(Models::Tables::Name::FAMILY) || "Unknown"
121
+ subfamily = name_table.english_name(Models::Tables::Name::SUBFAMILY) || "Regular"
122
+
123
+ # Detect font format
124
+ format = if font.has_table?("CFF ") || font.has_table?("CFF2")
125
+ "OpenType"
126
+ else
127
+ "TrueType"
128
+ end
129
+
130
+ Models::CollectionFontSummary.new(
131
+ index: index,
132
+ family_name: family,
133
+ subfamily_name: subfamily,
134
+ font_format: format,
135
+ )
136
+ end
137
+
138
+ Models::CollectionListInfo.new(
139
+ num_fonts: @num_fonts,
140
+ fonts: summaries,
141
+ )
142
+ end
143
+
144
+ # Get specific font from collection
145
+ #
146
+ # @param index [Integer] Font index
147
+ # @param io [IO] Open file handle
148
+ # @param mode [Symbol] Loading mode
149
+ # @return [TrueTypeFont, OpenTypeFont] Font object
150
+ # @raise [InvalidFontError] if index out of range
151
+ def font(index, io, mode: LoadingModes::FULL)
152
+ if index >= @num_fonts
153
+ raise InvalidFontError,
154
+ "Font index #{index} out of range (collection has #{@num_fonts} fonts)"
155
+ end
156
+
157
+ io.rewind
158
+ sfnt_data = Parsers::DfontParser.extract_sfnt(io, index: index)
159
+
160
+ # Load font from SFNT binary
161
+ require "stringio"
162
+ sfnt_io = StringIO.new(sfnt_data)
163
+ signature = sfnt_io.read(4)
164
+ sfnt_io.rewind
165
+
166
+ # Create font based on signature
167
+ font = case signature
168
+ when [Constants::SFNT_VERSION_TRUETYPE].pack("N"), "true"
169
+ TrueTypeFont.read(sfnt_io)
170
+ when "OTTO"
171
+ OpenTypeFont.read(sfnt_io)
172
+ else
173
+ raise InvalidFontError,
174
+ "Invalid SFNT signature: #{signature.inspect}"
175
+ end
176
+
177
+ font.initialize_storage
178
+ font.loading_mode = mode
179
+ font.lazy_load_enabled = false
180
+ font.read_table_data(sfnt_io)
181
+
182
+ font
183
+ end
184
+ end
185
+ end
@@ -63,7 +63,7 @@ module Fontisan
63
63
  when Constants::TTC_TAG
64
64
  load_from_collection(io, path, font_index, mode: resolved_mode,
65
65
  lazy: resolved_lazy)
66
- when pack_uint32(Constants::SFNT_VERSION_TRUETYPE)
66
+ when pack_uint32(Constants::SFNT_VERSION_TRUETYPE), "true"
67
67
  TrueTypeFont.from_file(path, mode: resolved_mode, lazy: resolved_lazy)
68
68
  when "OTTO"
69
69
  OpenTypeFont.from_file(path, mode: resolved_mode, lazy: resolved_lazy)
@@ -71,6 +71,8 @@ module Fontisan
71
71
  WoffFont.from_file(path, mode: resolved_mode, lazy: resolved_lazy)
72
72
  when "wOF2"
73
73
  Woff2Font.from_file(path, mode: resolved_mode, lazy: resolved_lazy)
74
+ when Constants::DFONT_RESOURCE_HEADER
75
+ extract_and_load_dfont(io, path, font_index, resolved_mode, resolved_lazy)
74
76
  else
75
77
  raise InvalidFontError,
76
78
  "Unknown font format. Expected TTF, OTF, TTC, OTC, WOFF, or WOFF2 file."
@@ -92,13 +94,25 @@ module Fontisan
92
94
 
93
95
  File.open(path, "rb") do |io|
94
96
  signature = io.read(4)
95
- signature == Constants::TTC_TAG
97
+ io.rewind
98
+
99
+ # Check for TTC/OTC signature
100
+ return true if signature == Constants::TTC_TAG
101
+
102
+ # Check for multi-font dfont (suitcase) - only if it's actually a dfont
103
+ if signature == Constants::DFONT_RESOURCE_HEADER
104
+ require_relative "parsers/dfont_parser"
105
+ # Verify it's a valid dfont and has multiple fonts
106
+ return Parsers::DfontParser.dfont?(io) && Parsers::DfontParser.sfnt_count(io) > 1
107
+ end
108
+
109
+ false
96
110
  end
97
111
  end
98
112
 
99
113
  # Load a collection object without extracting fonts
100
114
  #
101
- # Returns the collection object (TrueTypeCollection or OpenTypeCollection)
115
+ # Returns the collection object (TrueTypeCollection, OpenTypeCollection, or DfontCollection)
102
116
  # without extracting individual fonts. Useful for inspecting collection
103
117
  # metadata and structure.
104
118
  #
@@ -112,6 +126,10 @@ module Fontisan
112
126
  # - OTC typically contains OpenType fonts (CFF/CFF2 outlines)
113
127
  # - Mixed collections are possible (both TTF and OTF in same collection)
114
128
  #
129
+ # dfont (Data Fork Font) is an Apple-specific format that contains Mac
130
+ # font suitcase resources. It can contain multiple SFNT fonts (TrueType
131
+ # or OpenType).
132
+ #
115
133
  # Each collection can contain multiple SFNT-format font files, with table
116
134
  # deduplication to save space. Individual fonts within a collection are
117
135
  # stored at different offsets within the file, each with their own table
@@ -128,13 +146,16 @@ module Fontisan
128
146
  # 4. If ANY font is OpenType (CFF), returns OpenTypeCollection
129
147
  # 5. Only returns TrueTypeCollection if ALL fonts are TrueType
130
148
  #
149
+ # For dfont files, returns DfontCollection.
150
+ #
131
151
  # This approach correctly handles:
132
152
  # - Homogeneous collections (all TTF or all OTF)
133
153
  # - Mixed collections (both TTF and OTF fonts) - uses OpenTypeCollection
134
154
  # - Large collections with many fonts (like NotoSerifCJK.ttc with 35 fonts)
155
+ # - dfont suitcases (Apple-specific)
135
156
  #
136
157
  # @param path [String] Path to the collection file
137
- # @return [TrueTypeCollection, OpenTypeCollection] The collection object
158
+ # @return [TrueTypeCollection, OpenTypeCollection, DfontCollection] The collection object
138
159
  # @raise [Errno::ENOENT] if file does not exist
139
160
  # @raise [InvalidFontError] if file is not a collection or type cannot be determined
140
161
  #
@@ -146,10 +167,18 @@ module Fontisan
146
167
 
147
168
  File.open(path, "rb") do |io|
148
169
  signature = io.read(4)
170
+ io.rewind
171
+
172
+ # Check for dfont
173
+ if signature == Constants::DFONT_RESOURCE_HEADER || dfont_signature?(io)
174
+ require_relative "dfont_collection"
175
+ return DfontCollection.from_file(path)
176
+ end
149
177
 
178
+ # Check for TTC/OTC
150
179
  unless signature == Constants::TTC_TAG
151
180
  raise InvalidFontError,
152
- "File is not a collection (TTC/OTC). Use FontLoader.load instead."
181
+ "File is not a collection (TTC/OTC/dfont). Use FontLoader.load instead."
153
182
  end
154
183
 
155
184
  # Read version and num_fonts
@@ -291,6 +320,50 @@ mode: LoadingModes::FULL, lazy: true)
291
320
  end
292
321
  end
293
322
 
323
+ # Extract and load font from dfont resource fork
324
+ #
325
+ # @param io [IO] Open file handle
326
+ # @param path [String] Path to dfont file
327
+ # @param font_index [Integer] Font index in suitcase
328
+ # @param mode [Symbol] Loading mode
329
+ # @param lazy [Boolean] Lazy loading flag
330
+ # @return [TrueTypeFont, OpenTypeFont] Loaded font
331
+ # @api private
332
+ def self.extract_and_load_dfont(io, path, font_index, mode, lazy)
333
+ require_relative "parsers/dfont_parser"
334
+
335
+ # Extract SFNT data from resource fork
336
+ sfnt_data = Parsers::DfontParser.extract_sfnt(io, index: font_index)
337
+
338
+ # Create StringIO with SFNT data
339
+ sfnt_io = StringIO.new(sfnt_data)
340
+
341
+ # Detect SFNT signature
342
+ signature = sfnt_io.read(4)
343
+ sfnt_io.rewind
344
+
345
+ # Read and setup font based on signature
346
+ case signature
347
+ when pack_uint32(Constants::SFNT_VERSION_TRUETYPE), "true"
348
+ font = TrueTypeFont.read(sfnt_io)
349
+ font.initialize_storage
350
+ font.loading_mode = mode
351
+ font.lazy_load_enabled = lazy
352
+ font.read_table_data(sfnt_io) unless lazy
353
+ font
354
+ when "OTTO"
355
+ font = OpenTypeFont.read(sfnt_io)
356
+ font.initialize_storage
357
+ font.loading_mode = mode
358
+ font.lazy_load_enabled = lazy
359
+ font.read_table_data(sfnt_io) unless lazy
360
+ font
361
+ else
362
+ raise InvalidFontError,
363
+ "Invalid SFNT data in dfont resource (signature: #{signature.inspect})"
364
+ end
365
+ end
366
+
294
367
  # Pack uint32 value to big-endian bytes
295
368
  #
296
369
  # @param value [Integer] The uint32 value
@@ -301,6 +374,18 @@ mode: LoadingModes::FULL, lazy: true)
301
374
  end
302
375
 
303
376
  private_class_method :load_from_collection, :pack_uint32, :env_mode,
304
- :env_lazy
377
+ :env_lazy, :extract_and_load_dfont
378
+
379
+ # Check if file has dfont signature
380
+ #
381
+ # @param io [IO] Open file handle
382
+ # @return [Boolean] true if dfont
383
+ # @api private
384
+ def self.dfont_signature?(io)
385
+ require_relative "parsers/dfont_parser"
386
+ Parsers::DfontParser.dfont?(io)
387
+ end
388
+
389
+ private_class_method :dfont_signature?
305
390
  end
306
391
  end
@@ -46,6 +46,34 @@ module Fontisan
46
46
  end
47
47
  end
48
48
 
49
+ # Individual check result from DSL-based validation
50
+ class CheckResult < Lutaml::Model::Serializable
51
+ attribute :check_id, :string
52
+ attribute :passed, :boolean
53
+ attribute :severity, :string
54
+ attribute :messages, :string, collection: true, default: -> { [] }
55
+ attribute :table, :string
56
+ attribute :field, :string
57
+
58
+ yaml do
59
+ map "check_id", to: :check_id
60
+ map "passed", to: :passed
61
+ map "severity", to: :severity
62
+ map "messages", to: :messages
63
+ map "table", to: :table
64
+ map "field", to: :field
65
+ end
66
+
67
+ json do
68
+ map "check_id", to: :check_id
69
+ map "passed", to: :passed
70
+ map "severity", to: :severity
71
+ map "messages", to: :messages
72
+ map "table", to: :table
73
+ map "field", to: :field
74
+ end
75
+ end
76
+
49
77
  # Validation summary counts
50
78
  class Summary < Lutaml::Model::Serializable
51
79
  attribute :errors, :integer, default: -> { 0 }
@@ -69,12 +97,22 @@ module Fontisan
69
97
  attribute :valid, :boolean
70
98
  attribute :issues, Issue, collection: true, default: -> { [] }
71
99
  attribute :summary, Summary, default: -> { Summary.new }
100
+ attribute :profile, :string
101
+ attribute :status, :string
102
+ attribute :use_case, :string
103
+ attribute :checks_performed, :string, collection: true, default: -> { [] }
104
+ attribute :check_results, CheckResult, collection: true, default: -> { [] }
72
105
 
73
106
  yaml do
74
107
  map "font_path", to: :font_path
75
108
  map "valid", to: :valid
76
109
  map "summary", to: :summary
77
110
  map "issues", to: :issues
111
+ map "profile", to: :profile
112
+ map "status", to: :status
113
+ map "use_case", to: :use_case
114
+ map "checks_performed", to: :checks_performed
115
+ map "check_results", to: :check_results
78
116
  end
79
117
 
80
118
  json do
@@ -82,6 +120,11 @@ module Fontisan
82
120
  map "valid", to: :valid
83
121
  map "summary", to: :summary
84
122
  map "issues", to: :issues
123
+ map "profile", to: :profile
124
+ map "status", to: :status
125
+ map "use_case", to: :use_case
126
+ map "checks_performed", to: :checks_performed
127
+ map "check_results", to: :check_results
85
128
  end
86
129
 
87
130
  # Add an error to the report
@@ -198,6 +241,190 @@ module Fontisan
198
241
 
199
242
  lines.join("\n")
200
243
  end
244
+
245
+ # Check if font passed validation (alias for valid)
246
+ #
247
+ # @return [Boolean] true if font passed validation
248
+ def passed?
249
+ valid
250
+ end
251
+
252
+ # Check if font is valid (alias for valid attribute)
253
+ #
254
+ # @return [Boolean] true if font is valid
255
+ def valid?
256
+ valid
257
+ end
258
+
259
+ # Get result for a specific check by ID
260
+ #
261
+ # @param check_id [Symbol, String] The check identifier
262
+ # @return [CheckResult, nil] The check result or nil if not found
263
+ def result_of(check_id)
264
+ check_results.find { |cr| cr.check_id == check_id.to_s }
265
+ end
266
+
267
+ # Get all passed checks
268
+ #
269
+ # @return [Array<CheckResult>] Array of passed checks
270
+ def passed_checks
271
+ check_results.select(&:passed)
272
+ end
273
+
274
+ # Get all failed checks
275
+ #
276
+ # @return [Array<CheckResult>] Array of failed checks
277
+ def failed_checks
278
+ check_results.reject(&:passed)
279
+ end
280
+
281
+ # Severity filtering methods
282
+
283
+ # Get issues by severity level
284
+ #
285
+ # @param severity [Symbol, String] Severity level
286
+ # @return [Array<Issue>] Array of issues with the specified severity
287
+ def issues_by_severity(severity)
288
+ issues.select { |issue| issue.severity == severity.to_s }
289
+ end
290
+
291
+ # Get fatal error issues
292
+ #
293
+ # @return [Array<Issue>] Array of fatal error issues
294
+ def fatal_errors
295
+ issues_by_severity(:fatal)
296
+ end
297
+
298
+ # Get error issues only
299
+ #
300
+ # @return [Array<Issue>] Array of error issues
301
+ def errors_only
302
+ issues_by_severity(:error)
303
+ end
304
+
305
+ # Get warning issues only
306
+ #
307
+ # @return [Array<Issue>] Array of warning issues
308
+ def warnings_only
309
+ issues_by_severity(:warning)
310
+ end
311
+
312
+ # Get info issues only
313
+ #
314
+ # @return [Array<Issue>] Array of info issues
315
+ def info_only
316
+ issues_by_severity(:info)
317
+ end
318
+
319
+ # Category filtering methods
320
+
321
+ # Get issues by category
322
+ #
323
+ # @param category [String] Category name
324
+ # @return [Array<Issue>] Array of issues in the specified category
325
+ def issues_by_category(category)
326
+ issues.select { |issue| issue.category == category.to_s }
327
+ end
328
+
329
+ # Get check results for a specific table
330
+ #
331
+ # @param table_tag [String] Table tag (e.g., 'name', 'head')
332
+ # @return [Array<CheckResult>] Array of check results for the table
333
+ def table_issues(table_tag)
334
+ check_results.select { |cr| cr.table == table_tag.to_s }
335
+ end
336
+
337
+ # Get check results for a specific field in a table
338
+ #
339
+ # @param table_tag [String] Table tag
340
+ # @param field_name [String, Symbol] Field name
341
+ # @return [Array<CheckResult>] Array of check results for the field
342
+ def field_issues(table_tag, field_name)
343
+ check_results.select { |cr| cr.table == table_tag.to_s && cr.field == field_name.to_s }
344
+ end
345
+
346
+ # Check filtering methods
347
+
348
+ # Get checks by status
349
+ #
350
+ # @param passed [Boolean] true for passed checks, false for failed checks
351
+ # @return [Array<CheckResult>] Array of checks with the specified status
352
+ def checks_by_status(passed:)
353
+ check_results.select { |cr| cr.passed == passed }
354
+ end
355
+
356
+ # Get IDs of failed checks
357
+ #
358
+ # @return [Array<String>] Array of failed check IDs
359
+ def failed_check_ids
360
+ failed_checks.map(&:check_id)
361
+ end
362
+
363
+ # Get IDs of passed checks
364
+ #
365
+ # @return [Array<String>] Array of passed check IDs
366
+ def passed_check_ids
367
+ passed_checks.map(&:check_id)
368
+ end
369
+
370
+ # Statistics methods
371
+
372
+ # Calculate failure rate as percentage
373
+ #
374
+ # @return [Float] Failure rate (0.0 to 1.0)
375
+ def failure_rate
376
+ return 0.0 if check_results.empty?
377
+ failed_checks.length.to_f / check_results.length
378
+ end
379
+
380
+ # Calculate pass rate as percentage
381
+ #
382
+ # @return [Float] Pass rate (0.0 to 1.0)
383
+ def pass_rate
384
+ 1.0 - failure_rate
385
+ end
386
+
387
+ # Get severity distribution
388
+ #
389
+ # @return [Hash] Hash with :errors, :warnings, :info counts
390
+ def severity_distribution
391
+ {
392
+ errors: summary.errors,
393
+ warnings: summary.warnings,
394
+ info: summary.info,
395
+ }
396
+ end
397
+
398
+ # Export format methods
399
+
400
+ # Generate full detailed text report
401
+ #
402
+ # @return [String] Detailed text report
403
+ def to_text_report
404
+ text_summary
405
+ end
406
+
407
+ # Generate brief summary
408
+ #
409
+ # @return [String] Brief summary string
410
+ def to_summary
411
+ "#{summary.errors} errors, #{summary.warnings} warnings, #{summary.info} info"
412
+ end
413
+
414
+ # Generate tabular format for CLI
415
+ #
416
+ # @return [String] Tabular format output
417
+ def to_table_format
418
+ lines = []
419
+ lines << "CHECK_ID | STATUS | SEVERITY | TABLE"
420
+ lines << "-" * 60
421
+ check_results.each do |cr|
422
+ status = cr.passed ? "PASS" : "FAIL"
423
+ table = cr.table || "N/A"
424
+ lines << "#{cr.check_id} | #{status} | #{cr.severity} | #{table}"
425
+ end
426
+ lines.join("\n")
427
+ end
201
428
  end
202
429
  end
203
430
  end