png_conform 0.1.1 → 0.1.3

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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +82 -42
  3. data/Gemfile +2 -0
  4. data/README.adoc +3 -2
  5. data/benchmarks/README.adoc +570 -0
  6. data/benchmarks/config/default.yml +35 -0
  7. data/benchmarks/config/full.yml +32 -0
  8. data/benchmarks/config/quick.yml +32 -0
  9. data/benchmarks/direct_validation.rb +18 -0
  10. data/benchmarks/lib/benchmark_runner.rb +204 -0
  11. data/benchmarks/lib/metrics_collector.rb +193 -0
  12. data/benchmarks/lib/png_conform_runner.rb +68 -0
  13. data/benchmarks/lib/pngcheck_runner.rb +67 -0
  14. data/benchmarks/lib/report_generator.rb +301 -0
  15. data/benchmarks/lib/tool_runner.rb +104 -0
  16. data/benchmarks/profile_loading.rb +12 -0
  17. data/benchmarks/profile_validation.rb +18 -0
  18. data/benchmarks/results/.gitkeep +0 -0
  19. data/benchmarks/run_benchmark.rb +159 -0
  20. data/config/validation_profiles.yml +105 -0
  21. data/docs/CHUNK_TYPES.adoc +42 -0
  22. data/examples/README.md +282 -0
  23. data/lib/png_conform/analyzers/comparison_analyzer.rb +41 -7
  24. data/lib/png_conform/analyzers/metrics_analyzer.rb +6 -9
  25. data/lib/png_conform/analyzers/optimization_analyzer.rb +30 -24
  26. data/lib/png_conform/analyzers/resolution_analyzer.rb +31 -32
  27. data/lib/png_conform/cli.rb +12 -0
  28. data/lib/png_conform/commands/check_command.rb +118 -52
  29. data/lib/png_conform/configuration.rb +147 -0
  30. data/lib/png_conform/container.rb +113 -0
  31. data/lib/png_conform/models/decoded_chunk_data.rb +33 -0
  32. data/lib/png_conform/models/validation_result.rb +30 -4
  33. data/lib/png_conform/pipelines/pipeline_result.rb +39 -0
  34. data/lib/png_conform/pipelines/stages/analysis_stage.rb +35 -0
  35. data/lib/png_conform/pipelines/stages/base_stage.rb +23 -0
  36. data/lib/png_conform/pipelines/stages/chunk_validation_stage.rb +74 -0
  37. data/lib/png_conform/pipelines/stages/sequence_validation_stage.rb +77 -0
  38. data/lib/png_conform/pipelines/stages/signature_validation_stage.rb +41 -0
  39. data/lib/png_conform/pipelines/validation_pipeline.rb +90 -0
  40. data/lib/png_conform/readers/full_load_reader.rb +13 -4
  41. data/lib/png_conform/readers/streaming_reader.rb +27 -2
  42. data/lib/png_conform/reporters/color_reporter.rb +17 -14
  43. data/lib/png_conform/reporters/reporter_factory.rb +18 -11
  44. data/lib/png_conform/reporters/visual_elements.rb +22 -16
  45. data/lib/png_conform/services/analysis_manager.rb +120 -0
  46. data/lib/png_conform/services/chunk_processor.rb +195 -0
  47. data/lib/png_conform/services/file_signature.rb +226 -0
  48. data/lib/png_conform/services/file_strategy.rb +78 -0
  49. data/lib/png_conform/services/lru_cache.rb +170 -0
  50. data/lib/png_conform/services/parallel_validator.rb +118 -0
  51. data/lib/png_conform/services/profile_manager.rb +41 -12
  52. data/lib/png_conform/services/result_builder.rb +299 -0
  53. data/lib/png_conform/services/validation_cache.rb +210 -0
  54. data/lib/png_conform/services/validation_orchestrator.rb +188 -0
  55. data/lib/png_conform/services/validation_service.rb +82 -321
  56. data/lib/png_conform/services/validator_pool.rb +142 -0
  57. data/lib/png_conform/utils/colorizer.rb +149 -0
  58. data/lib/png_conform/validators/ancillary/idot_validator.rb +102 -0
  59. data/lib/png_conform/validators/chunk_registry.rb +143 -128
  60. data/lib/png_conform/validators/streaming_idat_validator.rb +123 -0
  61. data/lib/png_conform/version.rb +1 -1
  62. data/lib/png_conform.rb +7 -46
  63. data/png_conform.gemspec +1 -0
  64. metadata +55 -2
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module PngConform
6
+ module Services
7
+ # File signature service for fast comparison
8
+ #
9
+ # Creates cryptographic signatures of PNG files to enable fast
10
+ # equality checking without full validation. This is particularly
11
+ # useful for comparison operations and caching.
12
+ #
13
+ # The signature is based on key file characteristics that are
14
+ # quick to compute but provide strong uniqueness guarantees.
15
+ #
16
+ class FileSignature
17
+ attr_reader :result, :signature, :metadata
18
+
19
+ class << self
20
+ # Create signature from ValidationResult
21
+ #
22
+ # @param result [ValidationResult] Validation result to signature
23
+ # @return [FileSignature] FileSignature instance
24
+ def from_result(result)
25
+ new(result).compute_signature
26
+ end
27
+
28
+ # Create signature directly from file metadata
29
+ #
30
+ # @param file_path [String] Path to PNG file
31
+ # @param options [Hash] Options for signature creation
32
+ # @return [FileSignature] FileSignature instance
33
+ def from_file(file_path, _options = {})
34
+ unless File.exist?(file_path)
35
+ raise ArgumentError, "File not found: #{file_path}"
36
+ end
37
+
38
+ # Quick signature from file metadata without full validation
39
+ metadata = extract_quick_metadata(file_path)
40
+ new(nil, metadata).compute_signature
41
+ end
42
+ end
43
+
44
+ # Initialize with validation result or metadata hash
45
+ #
46
+ # @param result [ValidationResult, nil] Validation result
47
+ # @param metadata [Hash, nil] File metadata hash
48
+ def initialize(result, metadata = nil)
49
+ @result = result
50
+ @metadata = metadata || extract_metadata_from_result
51
+ @signature = nil
52
+ end
53
+
54
+ # Compute the signature
55
+ #
56
+ # @return [FileSignature] Self for chaining
57
+ def compute_signature
58
+ @signature = generate_signature
59
+ self
60
+ end
61
+
62
+ # Check if two signatures are equal
63
+ #
64
+ # @param other [FileSignature, String] Another signature or signature string
65
+ # @return [Boolean] True if signatures match
66
+ def ==(other)
67
+ return false unless other.is_a?(self.class)
68
+
69
+ @signature == other.signature
70
+ end
71
+
72
+ # Get signature as hex string
73
+ #
74
+ # @return [String] Hex signature
75
+ def to_hex
76
+ @signature
77
+ end
78
+
79
+ # Get signature as bytes
80
+ #
81
+ # @return [String] Signature bytes
82
+ def to_bytes
83
+ [@signature].pack("H*")
84
+ end
85
+
86
+ # Get short signature (first 8 bytes of hex)
87
+ #
88
+ # @return [String] Short signature for quick comparison
89
+ def short_signature
90
+ @signature[0..15]
91
+ end
92
+
93
+ private
94
+
95
+ # Extract metadata from validation result
96
+ #
97
+ # @return [Hash] Metadata hash
98
+ def extract_metadata_from_result
99
+ return {} unless @result
100
+
101
+ {
102
+ file_size: @result.file_size,
103
+ chunk_count: @result.chunks.count,
104
+ chunk_types: @result.chunks.map(&:type).sort,
105
+ chunk_sizes: @result.chunks.map(&:length).sort,
106
+ crcs: @result.chunks.map do |c|
107
+ c.crc_actual || c.crc_expected
108
+ end.compact,
109
+ }
110
+ end
111
+
112
+ # Generate signature from metadata
113
+ #
114
+ # Uses SHA-256 on concatenated metadata for cryptographic strength.
115
+ #
116
+ # @return [String] Hex signature
117
+ def generate_signature
118
+ signature_data = signature_string
119
+ Digest::SHA256.hexdigest(signature_data)
120
+ end
121
+
122
+ # Build signature string from metadata
123
+ #
124
+ # Creates a deterministic string representation of key file attributes.
125
+ #
126
+ # @return [String] Signature string
127
+ def signature_string
128
+ StringIO.new.tap do |io|
129
+ # File size (8 bytes)
130
+ io << [@metadata[:file_size]].pack("N")
131
+
132
+ # Chunk count (4 bytes)
133
+ io << [@metadata[:chunk_count]].pack("N")
134
+
135
+ # Chunk types (sorted for consistency)
136
+ @metadata[:chunk_types].each do |type|
137
+ io << type.ljust(4, "\0")[0..3] # Fixed 4-char chunk types
138
+ end
139
+
140
+ # Chunk sizes (sorted for consistency)
141
+ @metadata[:chunk_sizes].each do |size|
142
+ io << [size].pack("N")
143
+ end
144
+
145
+ # CRC checksums (for integrity verification)
146
+ @metadata[:crcs].each do |crc|
147
+ io << [crc].pack("N")
148
+ end
149
+ end.string
150
+ end
151
+
152
+ # Extract quick metadata from file without full validation
153
+ #
154
+ # Reads just the PNG signature and chunk headers to create
155
+ # a signature without loading entire file into memory.
156
+ #
157
+ # @param file_path [String] Path to PNG file
158
+ # @return [Hash] Quick metadata hash
159
+ def extract_quick_metadata(file_path)
160
+ File.open(file_path, "rb") do |file|
161
+ # Verify PNG signature first
162
+ sig = file.read(8)
163
+ expected_sig = [137, 80, 78, 71, 13, 10, 26, 10].pack("C*")
164
+ unless sig == expected_sig
165
+ raise ArgumentError, "Not a valid PNG file: #{file_path}"
166
+ end
167
+
168
+ # Quick scan to count chunks and collect metadata
169
+ chunk_count = 0
170
+ chunk_types = []
171
+ chunk_sizes = []
172
+ total_data_size = 0
173
+
174
+ # Read entire file - no arbitrary limits for complete signatures
175
+ while file.pos < File.size(file_path)
176
+ # Read chunk length (4 bytes)
177
+ length_bytes = file.read(4)
178
+ break if length_bytes.nil? || length_bytes.length < 4
179
+
180
+ chunk_length = length_bytes.unpack1("N")
181
+ break if chunk_length > 10 * 1024 * 1024 # Sanity check
182
+
183
+ # Read chunk type (4 bytes)
184
+ type_bytes = file.read(4)
185
+ break if type_bytes.nil? || type_bytes.length < 4
186
+
187
+ chunk_type = type_bytes
188
+
189
+ # Skip data
190
+ file.seek(chunk_length, IO::SEEK_CUR)
191
+
192
+ # Read CRC (4 bytes)
193
+ crc_bytes = file.read(4)
194
+ break if crc_bytes.nil? || crc_bytes.length < 4
195
+
196
+ # Record metadata
197
+ chunk_count += 1
198
+ chunk_types << chunk_type
199
+ chunk_sizes << chunk_length
200
+ total_data_size += chunk_length
201
+
202
+ # Stop if we found IEND
203
+ break if chunk_type == "IEND"
204
+ end
205
+
206
+ {
207
+ file_size: File.size(file_path),
208
+ chunk_count: chunk_count,
209
+ chunk_types: chunk_types.sort,
210
+ chunk_sizes: chunk_sizes.sort,
211
+ crcs: [], # Not easily available without validation
212
+ }
213
+ end
214
+ rescue StandardError
215
+ # Fallback to basic metadata
216
+ {
217
+ file_size: File.size(file_path),
218
+ chunk_count: 0,
219
+ chunk_types: [],
220
+ chunk_sizes: [],
221
+ crcs: [],
222
+ }
223
+ end
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PngConform
4
+ module Services
5
+ # File strategy service for reader selection
6
+ #
7
+ # Determines the optimal reader type based on file characteristics
8
+ # and validation options. This enables automatic reader selection for
9
+ # better performance and memory efficiency.
10
+ #
11
+ class FileStrategy
12
+ # Large file threshold (10MB) - files larger than this use streaming
13
+ LARGE_FILE_THRESHOLD = 10 * 1024 * 1024
14
+
15
+ class << self
16
+ # Determine the appropriate reader type for a file
17
+ #
18
+ # @param file_path [String] Path to the PNG file
19
+ # @param options [Hash] Validation options
20
+ # @return [Symbol] Reader type (:streaming or :full_load)
21
+ def reader_type_for(file_path, options = {})
22
+ file_size = File.size(file_path)
23
+
24
+ # Use streaming for large files unless explicitly forced
25
+ if file_size > LARGE_FILE_THRESHOLD && !options[:force_full]
26
+ :streaming
27
+ else
28
+ :full_load
29
+ end
30
+ end
31
+
32
+ # Check if a file is considered large
33
+ #
34
+ # @param file_path [String] Path to the PNG file
35
+ # @return [Boolean] True if file is large
36
+ def large_file?(file_path)
37
+ File.size(file_path) > LARGE_FILE_THRESHOLD
38
+ end
39
+
40
+ # Get the threshold for large file detection
41
+ #
42
+ # @return [Integer] Threshold in bytes
43
+ def large_file_threshold
44
+ LARGE_FILE_THRESHOLD
45
+ end
46
+
47
+ # Calculate recommended chunk size for processing
48
+ #
49
+ # For large files, smaller chunks are better for memory management
50
+ #
51
+ # @param file_path [String] Path to the PNG file
52
+ # @return [Integer] Recommended chunk size in bytes
53
+ def recommended_chunk_size(file_path)
54
+ file_size = File.size(file_path)
55
+
56
+ if file_size > 100 * 1024 * 1024 # > 100MB
57
+ 8192 # 8KB chunks for very large files
58
+ elsif file_size > 10 * 1024 * 1024 # > 10MB
59
+ 16384 # 16KB chunks for large files
60
+ else
61
+ 65536 # 64KB chunks for normal files
62
+ end
63
+ end
64
+
65
+ # Estimate memory usage for full-load reader
66
+ #
67
+ # @param file_path [String] Path to the PNG file
68
+ # @return [Integer] Estimated memory in bytes
69
+ def estimate_memory_usage(file_path)
70
+ file_size = File.size(file_path)
71
+ # Estimate: file_size + overhead for chunks (rough estimate)
72
+ # Each chunk adds overhead for BinData structures
73
+ file_size * 1.2
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PngConform
4
+ module Services
5
+ # LRU (Least Recently Used) Cache implementation
6
+ #
7
+ # Provides efficient caching with automatic eviction of least recently
8
+ # used items when capacity is reached. Thread-safe for basic operations.
9
+ #
10
+ class LRUCache
11
+ attr_reader :max_size, :current_size
12
+
13
+ # Initialize LRU cache
14
+ #
15
+ # @param max_size [Integer] Maximum number of items to store
16
+ # @param options [Hash] Additional options
17
+ def initialize(max_size, options = {})
18
+ @max_size = max_size
19
+ @cache = {}
20
+ @order = [] # Tracks access order (most recent at end)
21
+ @current_size = 0
22
+ @thread_safe = options[:thread_safe] || false
23
+ @mutex = @thread_safe ? Mutex.new : nil
24
+ end
25
+
26
+ # Get value for key
27
+ #
28
+ # @param key [Object] Cache key
29
+ # @return [Object, nil] Cached value or nil if not found
30
+ def [](key)
31
+ return @cache[key] if @thread_safe && !@mutex.synchronize do
32
+ @cache.key?(key)
33
+ end
34
+
35
+ with_synchronization do
36
+ return nil unless @cache.key?(key)
37
+
38
+ # Move to end (most recently used)
39
+ @order.delete(key)
40
+ @order.push(key)
41
+
42
+ @cache[key]
43
+ end
44
+ end
45
+
46
+ # Set value for key
47
+ #
48
+ # @param key [Object] Cache key
49
+ # @param value [Object] Value to cache
50
+ # @return [Object] The cached value
51
+ def []=(key, value)
52
+ with_synchronization do
53
+ # Remove existing key if updating (to re-insert at end)
54
+ @order.delete(key) if @cache.key?(key)
55
+
56
+ @cache[key] = value
57
+ @order.push(key)
58
+
59
+ # Evict oldest if over capacity
60
+ if @order.size > @max_size
61
+ oldest = @order.shift
62
+ @cache.delete(oldest)
63
+ end
64
+
65
+ @current_size = @cache.size
66
+ end
67
+
68
+ value
69
+ end
70
+
71
+ # Check if key exists
72
+ #
73
+ # @param key [Object] Cache key
74
+ # @return [Boolean] True if key exists in cache
75
+ def key?(key)
76
+ @cache.key?(key)
77
+ end
78
+
79
+ # Check if cache is empty
80
+ #
81
+ # @return [Boolean] True if cache has no items
82
+ def empty?
83
+ @cache.empty?
84
+ end
85
+
86
+ # Clear all cached items
87
+ #
88
+ # @return [void]
89
+ def clear
90
+ with_synchronization do
91
+ @cache.clear
92
+ @order.clear
93
+ @current_size = 0
94
+ end
95
+ end
96
+
97
+ # Get all keys
98
+ #
99
+ # @return [Array<Object>] All cached keys (in LRU order)
100
+ def keys
101
+ @order.dup # Return copy to avoid external modification
102
+ end
103
+
104
+ # Get cache statistics
105
+ #
106
+ # @return [Hash] Cache statistics
107
+ def stats
108
+ {
109
+ size: @cache.size,
110
+ max_size: @max_size,
111
+ usage_percent: ((@cache.size.to_f / @max_size) * 100).round(1),
112
+ }
113
+ end
114
+
115
+ # Delete a specific key
116
+ #
117
+ # @param key [Object] Cache key to delete
118
+ # @return [Object, nil] Deleted value or nil if not found
119
+ def delete(key)
120
+ with_synchronization do
121
+ @order.delete(key)
122
+ @cache.delete(key)
123
+ @current_size = @cache.size
124
+ end
125
+ end
126
+
127
+ # Peek at value without affecting LRU order
128
+ #
129
+ # @param key [Object] Cache key
130
+ # @return [Object, nil] Cached value or nil if not found
131
+ def peek(key)
132
+ @cache[key]
133
+ end
134
+
135
+ # Get most recently used item
136
+ #
137
+ # @return [Array] [key, value] or nil if empty
138
+ def mru
139
+ return nil if @order.empty?
140
+
141
+ key = @order.last
142
+ [key, @cache[key]]
143
+ end
144
+
145
+ # Get least recently used item
146
+ #
147
+ # @return [Array] [key, value] or nil if empty
148
+ def lru
149
+ return nil if @order.empty?
150
+
151
+ key = @order.first
152
+ [key, @cache[key]]
153
+ end
154
+
155
+ private
156
+
157
+ # Execute block with optional synchronization
158
+ #
159
+ # @yield Block to execute
160
+ # @return [Object] Block result
161
+ def with_synchronization(&block)
162
+ if @thread_safe && @mutex
163
+ @mutex.synchronize(&block)
164
+ else
165
+ yield
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../container"
4
+ require_relative "validation_orchestrator"
5
+ require "etc"
6
+
7
+ module PngConform
8
+ module Services
9
+ # Parallel validator for processing multiple files simultaneously
10
+ #
11
+ # Uses multi-threading to validate multiple PNG files in parallel,
12
+ # significantly reducing total validation time on multi-core systems.
13
+ #
14
+ # This service is MECE with ValidationOrchestrator - it handles
15
+ # the coordination of multiple file validations, while
16
+ # ValidationOrchestrator handles single file validation.
17
+ #
18
+ class ParallelValidator
19
+ # Default number of threads based on CPU count
20
+ DEFAULT_THREADS = [Etc.nprocessors, 4].max
21
+
22
+ attr_reader :files, :options, :threads
23
+
24
+ # Initialize parallel validator
25
+ #
26
+ # @param files [Array<String>] List of file paths to validate
27
+ # @param options [Hash] CLI options for validation behavior
28
+ def initialize(files, options = {})
29
+ @files = files
30
+ @options = options
31
+ @threads = options.fetch(:threads, DEFAULT_THREADS)
32
+ end
33
+
34
+ # Validate all files in parallel
35
+ #
36
+ # Uses Ruby's Parallel gem to distribute work across threads.
37
+ # Each file is validated independently using ValidationOrchestrator.
38
+ #
39
+ # @return [Array<FileAnalysis>] Array of validation results
40
+ def validate_all
41
+ require "parallel"
42
+
43
+ Parallel.map(@files, in_threads: @threads) do |file_path|
44
+ validate_single_file(file_path)
45
+ end
46
+ end
47
+
48
+ # Validate all files with progress callback
49
+ #
50
+ # @param progress_callback [Proc] Callback for progress updates
51
+ # @return [Array<FileAnalysis>] Array of validation results
52
+ def validate_all_with_progress(progress_callback = nil)
53
+ require "parallel"
54
+
55
+ completed = 0
56
+ total = @files.size
57
+
58
+ Parallel.map(@files, in_threads: @threads) do |file_path|
59
+ result = validate_single_file(file_path)
60
+
61
+ # Report progress if callback provided
62
+ if progress_callback
63
+ completed += 1
64
+ progress_callback.call(completed, total, file_path)
65
+ end
66
+
67
+ result
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ # Validate a single file
74
+ #
75
+ # Uses StreamingReader and ValidationOrchestrator for validation.
76
+ # Returns error hash if validation fails.
77
+ #
78
+ # @param file_path [String] Path to PNG file
79
+ # @return [FileAnalysis, Hash] Validation result or error hash
80
+ def validate_single_file(file_path)
81
+ # Check file existence first (avoid thread overhead for missing files)
82
+ unless File.exist?(file_path)
83
+ return {
84
+ error: "File not found: #{file_path}",
85
+ file: file_path,
86
+ valid: false,
87
+ }
88
+ end
89
+
90
+ unless File.file?(file_path)
91
+ return {
92
+ error: "Not a file: #{file_path}",
93
+ file: file_path,
94
+ valid: false,
95
+ }
96
+ end
97
+
98
+ # Use container to create reader and orchestrator
99
+ Container.open_reader(:streaming, file_path) do |reader|
100
+ orchestrator = Container.validation_orchestrator(
101
+ reader,
102
+ file_path,
103
+ @options.merge(filepath: file_path),
104
+ )
105
+ orchestrator.validate
106
+ end
107
+ rescue StandardError => e
108
+ # Return error hash instead of raising (keeps parallel processing going)
109
+ {
110
+ error: e.message,
111
+ file: file_path,
112
+ valid: false,
113
+ backtrace: @options[:verbose] ? e.backtrace.first(10) : nil,
114
+ }
115
+ end
116
+ end
117
+ end
118
+ end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "yaml"
4
+ require_relative "../configuration"
5
+
3
6
  module PngConform
4
7
  module Services
5
8
  # Profile manager for PNG validation profiles
@@ -7,16 +10,11 @@ module PngConform
7
10
  # Manages validation profiles that define which chunks are required,
8
11
  # optional, or prohibited for different PNG use cases.
9
12
  #
10
- # Profiles can be used to validate PNG files against specific standards:
11
- # - Web: Optimized for web browsers
12
- # - Print: High quality for printing
13
- # - Archive: Long-term preservation
14
- # - Minimal: Minimal valid PNG
15
- # - Strict: Full PNG specification compliance
13
+ # Profiles can be loaded from YAML configuration files or use built-in defaults.
16
14
  #
17
15
  class ProfileManager
18
- # Built-in validation profiles
19
- PROFILES = {
16
+ # Built-in validation profiles (fallback if YAML not available)
17
+ BUILTIN_PROFILES = {
20
18
  # Minimal valid PNG - only critical chunks
21
19
  minimal: {
22
20
  name: "Minimal",
@@ -99,10 +97,12 @@ module PngConform
99
97
  class << self
100
98
  # Get profile by name
101
99
  #
100
+ # Loads from YAML if available, otherwise uses built-in profiles
101
+ #
102
102
  # @param name [Symbol, String] Profile name
103
103
  # @return [Hash, nil] Profile configuration or nil if not found
104
104
  def get_profile(name)
105
- PROFILES[name.to_sym]
105
+ profiles_from_yaml[name.to_sym] || BUILTIN_PROFILES[name.to_sym]
106
106
  end
107
107
 
108
108
  # Check if a profile exists
@@ -110,14 +110,15 @@ module PngConform
110
110
  # @param name [Symbol, String] Profile name
111
111
  # @return [Boolean] True if profile exists
112
112
  def profile_exists?(name)
113
- PROFILES.key?(name.to_sym)
113
+ sym_name = name.to_sym
114
+ profiles_from_yaml.key?(sym_name) || BUILTIN_PROFILES.key?(sym_name)
114
115
  end
115
116
 
116
117
  # Get all available profile names
117
118
  #
118
119
  # @return [Array<Symbol>] List of profile names
119
120
  def available_profiles
120
- PROFILES.keys
121
+ (profiles_from_yaml.keys | BUILTIN_PROFILES.keys).uniq.sort
121
122
  end
122
123
 
123
124
  # Get profile information
@@ -149,7 +150,7 @@ module PngConform
149
150
  )
150
151
  elsif profile[:required_chunks].include?(chunk_type)
151
152
  success_result("#{chunk_type} chunk required and present")
152
- elsif profile[:optional_chunks].include?(chunk_type)
153
+ elsif profile[:optional_chunks].include?(chunk_type) || profile[:optional_chunks] == ["*"]
153
154
  success_result("#{chunk_type} chunk optional and present")
154
155
  else
155
156
  warning_result(
@@ -211,8 +212,36 @@ module PngConform
211
212
  results
212
213
  end
213
214
 
215
+ # Reload profiles from YAML
216
+ #
217
+ # @return [void]
218
+ def reload_yaml_profiles!
219
+ @profiles_from_yaml = nil
220
+ end
221
+
214
222
  private
215
223
 
224
+ # Load profiles from YAML configuration file
225
+ #
226
+ # @return [Hash] Profiles loaded from YAML
227
+ def profiles_from_yaml
228
+ @profiles_from_yaml ||= load_yaml_profiles
229
+ end
230
+
231
+ # Load profiles from YAML file
232
+ #
233
+ # @return [Hash] Loaded profiles or empty hash if file not found
234
+ def load_yaml_profiles
235
+ config_path = File.join(File.dirname(__FILE__),
236
+ "../../config/validation_profiles.yml")
237
+ return {} unless File.exist?(config_path)
238
+
239
+ YAML.load_file(config_path).transform_keys(&:to_sym)
240
+ rescue StandardError => e
241
+ warn "Failed to load profiles from YAML: #{e.message}"
242
+ {}
243
+ end
244
+
216
245
  # Create success result
217
246
  #
218
247
  # @param message [String] Success message