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,299 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../models/validation_result"
4
+ require_relative "../models/file_analysis"
5
+ require_relative "../models/image_info"
6
+ require_relative "../models/compression_info"
7
+
8
+ module PngConform
9
+ module Services
10
+ # Builds validation results and file analysis
11
+ #
12
+ # The ResultBuilder handles:
13
+ # - Building ValidationResult from validation context
14
+ # - Extracting ImageInfo from IHDR chunk
15
+ # - Extracting CompressionInfo (lazy calculation)
16
+ # - Building complete FileAnalysis model
17
+ #
18
+ # This class extracts result building logic from ValidationService
19
+ # following Single Responsibility Principle.
20
+ #
21
+ class ResultBuilder
22
+ # Initialize result builder
23
+ #
24
+ # @param reader [Object] File reader (StreamingReader or FullLoadReader)
25
+ # @param filepath [String] File path for reporting
26
+ # @param context [ValidationContext] Validation context with results
27
+ # @param chunks [Array] Array of BinData chunks
28
+ # @param options [Hash] CLI options for conditional calculations
29
+ def initialize(reader, filepath, context, chunks, options = {})
30
+ @reader = reader
31
+ @filepath = filepath
32
+ @context = context
33
+ @chunks = chunks
34
+ @options = options
35
+ @idat_data_cache = nil # Cache for streaming IDAT data
36
+ end
37
+
38
+ # Build complete FileAnalysis with all data
39
+ #
40
+ # @return [FileAnalysis] Complete analysis model
41
+ def build
42
+ # First build the ValidationResult
43
+ validation_result = build_validation_result
44
+
45
+ # Extract image info from IHDR
46
+ image_info = extract_image_info(validation_result)
47
+
48
+ # Build complete FileAnalysis
49
+ Models::FileAnalysis.new.tap do |analysis|
50
+ analysis.file_path = @filepath || "unknown"
51
+ analysis.file_size = validation_result.file_size
52
+ analysis.file_type = validation_result.file_type
53
+ analysis.validation_result = validation_result
54
+ analysis.image_info = image_info
55
+ analysis.compression_info = extract_compression_info(validation_result)
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ # Build ValidationResult from validation context and chunks
62
+ #
63
+ # @return [ValidationResult] Validation results with chunks and errors
64
+ def build_validation_result
65
+ Models::ValidationResult.new.tap do |result|
66
+ # Set file metadata
67
+ result.filename = @filepath || "unknown"
68
+ result.file_type = determine_file_type
69
+ result.file_size = calculate_file_size
70
+
71
+ # Add all chunks with CRC validation
72
+ add_chunks_with_crc(result)
73
+
74
+ # Calculate compression ratio for PNG files (lazy calculation)
75
+ result.compression_ratio = if result.file_type == "PNG" &&
76
+ need_compression_ratio?
77
+ calculate_compression_ratio(result.chunks)
78
+ else
79
+ 0.0
80
+ end
81
+
82
+ # Add all errors, warnings, and info from context
83
+ add_context_messages(result)
84
+ end
85
+ end
86
+
87
+ # Add chunks with CRC validation to result
88
+ #
89
+ # Caches IDAT data during initial pass for streaming compression calculation.
90
+ # Uses cached CRC validation from reader if available to avoid recalculation.
91
+ #
92
+ # @param result [ValidationResult] Result to add chunks to
93
+ # @return [void]
94
+ def add_chunks_with_crc(result)
95
+ crc_error_count = 0
96
+
97
+ @chunks.each do |bindata_chunk|
98
+ chunk = Models::Chunk.from_bindata(bindata_chunk,
99
+ bindata_chunk.abs_offset)
100
+
101
+ # Cache IDAT data for streaming compression calculation
102
+ if bindata_chunk.chunk_type.to_s == "IDAT"
103
+ @idat_data_cache ||= ""
104
+ @idat_data_cache += bindata_chunk.data.to_s
105
+ end
106
+
107
+ # Validate CRC using cached result if available
108
+ expected_crc = bindata_chunk.crc
109
+ chunk.crc_expected = format_hex(expected_crc)
110
+
111
+ # Check if reader already validated CRC (cached in @_crc_valid)
112
+ if bindata_chunk.instance_variable_defined?(:@_crc_valid)
113
+ # Use cached validation result
114
+ chunk.valid_crc = bindata_chunk.instance_variable_get(:@_crc_valid)
115
+ # Don't set crc_actual since we didn't recalculate it
116
+ else
117
+ # Calculate CRC if not cached
118
+ actual_crc = calculate_crc(bindata_chunk)
119
+ chunk.crc_actual = format_hex(actual_crc)
120
+ chunk.valid_crc = (expected_crc == actual_crc)
121
+ end
122
+
123
+ crc_error_count += 1 unless chunk.valid_crc
124
+
125
+ result.add_chunk(chunk)
126
+ end
127
+
128
+ result.crc_errors_count = crc_error_count
129
+ end
130
+
131
+ # Add all messages from validation context to result
132
+ #
133
+ # @param result [ValidationResult] Result to add messages to
134
+ # @return [void]
135
+ def add_context_messages(result)
136
+ # Add errors from context
137
+ @context.all_errors.each do |e|
138
+ result.error(e[:message])
139
+ end
140
+
141
+ # Add warnings from context
142
+ @context.all_warnings.each do |w|
143
+ result.warning(w[:message])
144
+ end
145
+
146
+ # Add info from context
147
+ @context.all_info.each do |i|
148
+ result.info(i[:message])
149
+ end
150
+ end
151
+
152
+ # Calculate file size from reader or chunks
153
+ #
154
+ # Performance optimization: Use reader.file_size if available,
155
+ # otherwise calculate from chunks (O(n) operation).
156
+ #
157
+ # @return [Integer] File size in bytes
158
+ def calculate_file_size
159
+ if @reader.respond_to?(:file_size)
160
+ @reader.file_size
161
+ else
162
+ # 8 bytes signature + sum of chunk sizes
163
+ # (8 byte header + data + 4 byte CRC per chunk)
164
+ 8 + @chunks.sum { |c| 12 + c.length }
165
+ end
166
+ end
167
+
168
+ # Determine file type based on chunks
169
+ #
170
+ # @return [String] File type (PNG, MNG, JNG, or UNKNOWN)
171
+ def determine_file_type
172
+ return Models::ValidationResult::FILE_TYPE_MNG if @context.seen?("MHDR")
173
+ return Models::ValidationResult::FILE_TYPE_JNG if @context.seen?("JHDR")
174
+ return Models::ValidationResult::FILE_TYPE_PNG if @context.seen?("IHDR")
175
+
176
+ Models::ValidationResult::FILE_TYPE_UNKNOWN
177
+ end
178
+
179
+ # Calculate CRC32 for a chunk
180
+ #
181
+ # @param chunk [Object] BinData chunk
182
+ # @return [Integer] CRC32 value
183
+ def calculate_crc(chunk)
184
+ require "zlib"
185
+ # CRC is calculated over chunk type + chunk data
186
+ Zlib.crc32(chunk.chunk_type.to_s + chunk.data.to_s)
187
+ end
188
+
189
+ # Format integer as hex string
190
+ #
191
+ # @param value [Integer] Value to format
192
+ # @return [String] Hex string (e.g., "0x12345678")
193
+ def format_hex(value)
194
+ format("0x%08x", value)
195
+ end
196
+
197
+ # Calculate compression ratio for PNG
198
+ #
199
+ # Streaming optimization: Uses cached IDAT data from initial chunk read
200
+ # to avoid re-selecting and concatenating chunks.
201
+ #
202
+ # Lazy calculation - only performed when needed.
203
+ #
204
+ # @param chunks [Array<Chunk>] All chunks
205
+ # @return [Float] Compression ratio as percentage, 0.0 if cannot calculate
206
+ def calculate_compression_ratio(chunks)
207
+ # Use cached IDAT data from streaming read
208
+ idat_chunks = chunks.select { |c| c.type == "IDAT" }
209
+ return 0.0 if idat_chunks.empty?
210
+
211
+ compressed_size = idat_chunks.sum(&:length)
212
+ return 0.0 if compressed_size.zero?
213
+
214
+ # Try to decompress to get original size
215
+ begin
216
+ require "zlib"
217
+
218
+ # Use cached IDAT data from initial read (streaming optimization)
219
+ compressed_data = @idat_data_cache || ""
220
+
221
+ decompressed = Zlib::Inflate.inflate(compressed_data)
222
+ original_size = decompressed.bytesize
223
+
224
+ return 0.0 if original_size.zero?
225
+
226
+ # Calculate percentage: (compressed/original - 1) * 100
227
+ # Negative means compression, positive means expansion
228
+ ((compressed_size.to_f / original_size - 1) * 100).round(1)
229
+ rescue StandardError
230
+ # If decompression fails, we can't calculate ratio
231
+ 0.0
232
+ end
233
+ end
234
+
235
+ # Extract ImageInfo from IHDR chunk
236
+ #
237
+ # @param result [ValidationResult] Validation result with chunks
238
+ # @return [ImageInfo, nil] Image info or nil if IHDR not found
239
+ def extract_image_info(result)
240
+ ihdr = result.ihdr_chunk
241
+ return nil unless ihdr&.data && ihdr.data.bytesize >= 13
242
+
243
+ width = ihdr.data.bytes[0..3].pack("C*").unpack1("N")
244
+ height = ihdr.data.bytes[4..7].pack("C*").unpack1("N")
245
+ bit_depth = ihdr.data.bytes[8]
246
+ color_type = ihdr.data.bytes[9]
247
+ interlace = ihdr.data.bytes[12]
248
+
249
+ Models::ImageInfo.new.tap do |info|
250
+ info.width = width
251
+ info.height = height
252
+ info.bit_depth = bit_depth
253
+ info.color_type = color_type_name(color_type)
254
+ info.interlaced = interlace == 1
255
+ info.animated = false # Could check for APNG chunks
256
+ end
257
+ end
258
+
259
+ # Extract CompressionInfo
260
+ #
261
+ # @param result [ValidationResult] Validation result
262
+ # @return [CompressionInfo, nil] Compression info or nil
263
+ def extract_compression_info(result)
264
+ return nil unless result.compression_ratio
265
+
266
+ Models::CompressionInfo.new.tap do |info|
267
+ info.compression_ratio = result.compression_ratio
268
+ info.compressed_size = result.chunks.select do |c|
269
+ c.type == "IDAT"
270
+ end.sum(&:length)
271
+ end
272
+ end
273
+
274
+ # Helper to convert color type code to name
275
+ #
276
+ # @param code [Integer] Color type code
277
+ # @return [String] Color type name
278
+ def color_type_name(code)
279
+ case code
280
+ when 0 then "grayscale"
281
+ when 2 then "truecolor"
282
+ when 3 then "palette"
283
+ when 4 then "grayscale+alpha"
284
+ when 6 then "truecolor+alpha"
285
+ else "unknown"
286
+ end
287
+ end
288
+
289
+ # Check if compression ratio calculation is needed
290
+ #
291
+ # @return [Boolean] True if compression ratio should be calculated
292
+ def need_compression_ratio?
293
+ return true if ["yaml", "json"].include?(@options[:format])
294
+
295
+ !@options[:quiet]
296
+ end
297
+ end
298
+ end
299
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lru_cache"
4
+ require "digest"
5
+
6
+ module PngConform
7
+ module Services
8
+ # Validation cache for storing and reusing validation results
9
+ #
10
+ # Provides multi-level caching for validation operations to avoid
11
+ # redundant I/O and computation. Supports LRU eviction for memory efficiency.
12
+ #
13
+ class ValidationCache
14
+ attr_reader :file_cache, :max_files, :enabled
15
+
16
+ # Default configuration
17
+ DEFAULT_MAX_FILES = 100
18
+ DEFAULT_ENABLED = true
19
+
20
+ class << self
21
+ # Get global singleton instance
22
+ #
23
+ # @return [ValidationCache] Global cache instance
24
+ def instance
25
+ @instance ||= new
26
+ end
27
+
28
+ # Reset global cache
29
+ #
30
+ # @return [void]
31
+ def reset!
32
+ @instance = new
33
+ end
34
+ end
35
+
36
+ # Initialize validation cache
37
+ #
38
+ # @param options [Hash] Cache configuration options
39
+ def initialize(options = {})
40
+ @max_files = options.fetch(:max_files, DEFAULT_MAX_FILES)
41
+ @file_cache = LRUCache.new(@max_files)
42
+ @enabled = options.fetch(:enabled, DEFAULT_ENABLED)
43
+ @stats = {
44
+ hits: 0,
45
+ misses: 0,
46
+ evictions: 0,
47
+ }
48
+ end
49
+
50
+ # Fetch cached validation result for a file
51
+ #
52
+ # @param file_path [String] Path to PNG file
53
+ # @param options [Hash] Validation options (affects cache key)
54
+ # @return [FileAnalysis, nil] Cached result or nil if not cached
55
+ def fetch(file_path, options = {})
56
+ return nil unless @enabled
57
+
58
+ cache_key = generate_cache_key(file_path, options)
59
+ entry = @file_cache[cache_key]
60
+
61
+ if entry
62
+ @stats[:hits] += 1
63
+ # Return shallow copy - FileAnalysis objects are read-only after creation
64
+ # This is significantly faster than Marshal deep copy
65
+ shallow_copy(entry[:result])
66
+ else
67
+ @stats[:misses] += 1
68
+ nil
69
+ end
70
+ end
71
+
72
+ # Store validation result in cache
73
+ #
74
+ # @param file_path [String] Path to PNG file
75
+ # @param options [Hash] Validation options (affects cache key)
76
+ # @param result [FileAnalysis] Validation result to cache
77
+ # @return [void]
78
+ def store(file_path, options, result)
79
+ return unless @enabled
80
+
81
+ cache_key = generate_cache_key(file_path, options)
82
+ entry = {
83
+ result: result, # Store reference - objects are read-only
84
+ timestamp: Time.now,
85
+ file_size: result.file_size,
86
+ validation_time: result.validation_result&.validation_time,
87
+ }
88
+
89
+ @file_cache[cache_key] = entry
90
+ end
91
+
92
+ # Check if file validation is cached
93
+ #
94
+ # @param file_path [String] Path to PNG file
95
+ # @param options [Hash] Validation options
96
+ # @return [Boolean] True if result is cached
97
+ def cached?(file_path, options = {})
98
+ return false unless @enabled
99
+
100
+ cache_key = generate_cache_key(file_path, options)
101
+ @file_cache.key?(cache_key)
102
+ end
103
+
104
+ # Invalidate cache entry for a file
105
+ #
106
+ # @param file_path [String] Path to PNG file
107
+ # @param options [Hash] Validation options (optional)
108
+ # @return [Boolean] True if entry was found and removed
109
+ def invalidate(file_path, options = {})
110
+ return false unless @enabled
111
+
112
+ cache_key = generate_cache_key(file_path, options)
113
+ deleted = @file_cache.delete(cache_key)
114
+
115
+ if deleted
116
+ @stats[:evictions] += 1
117
+ end
118
+
119
+ !!deleted
120
+ end
121
+
122
+ # Clear all cached entries
123
+ #
124
+ # @return [void]
125
+ def clear
126
+ @file_cache.clear
127
+ reset_stats
128
+ end
129
+
130
+ # Get cache statistics
131
+ #
132
+ # @return [Hash] Cache performance statistics
133
+ def stats
134
+ total_requests = @stats[:hits] + @stats[:misses]
135
+ hit_rate = if total_requests.positive?
136
+ (@stats[:hits].to_f / total_requests * 100).round(1)
137
+ else
138
+ 0.0
139
+ end
140
+
141
+ {
142
+ enabled: @enabled,
143
+ max_files: @max_files,
144
+ current_files: @file_cache.current_size,
145
+ hits: @stats[:hits],
146
+ misses: @stats[:misses],
147
+ evictions: @stats[:evictions],
148
+ hit_rate: hit_rate,
149
+ cache_stats: @file_cache.stats,
150
+ }
151
+ end
152
+
153
+ # Reset statistics counters
154
+ #
155
+ # @return [void]
156
+ def reset_stats
157
+ @stats = {
158
+ hits: 0,
159
+ misses: 0,
160
+ evictions: 0,
161
+ }
162
+ end
163
+
164
+ # Get cache key for file validation
165
+ #
166
+ # The cache key includes file path, mtime, and relevant options
167
+ # to ensure cache validity when files change or options differ.
168
+ #
169
+ # @param file_path [String] Path to PNG file
170
+ # @param options [Hash] Validation options
171
+ # @return [String] Cache key
172
+ def generate_cache_key(file_path, options = {})
173
+ # Include relevant options in cache key
174
+ relevant_opts = options.slice(:profile, :batch_enabled, :fail_fast)
175
+
176
+ # Get file modification time for cache invalidation
177
+ mtime = begin
178
+ File.mtime(file_path).to_i
179
+ rescue StandardError
180
+ 0
181
+ end
182
+
183
+ # Create deterministic cache key
184
+ key_parts = [
185
+ file_path,
186
+ mtime,
187
+ relevant_opts.sort.to_h,
188
+ ]
189
+
190
+ Digest::SHA256.hexdigest(key_parts.flatten.join(":"))
191
+ end
192
+
193
+ private
194
+
195
+ # Create shallow copy of result object
196
+ #
197
+ # Uses .dup for fast shallow copying. FileAnalysis objects are
198
+ # effectively read-only after creation, so deep copying is unnecessary.
199
+ #
200
+ # @param obj [Object] Object to copy
201
+ # @return [Object] Shallow copy of object
202
+ def shallow_copy(obj)
203
+ obj.dup
204
+ rescue TypeError
205
+ # For objects that can't be dup'd, return as-is
206
+ obj
207
+ end
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "chunk_processor"
4
+ require_relative "result_builder"
5
+ require_relative "analysis_manager"
6
+
7
+ module PngConform
8
+ module Services
9
+ # Main validation orchestration service
10
+ #
11
+ # This service coordinates the validation of PNG files by:
12
+ # 1. Validating the PNG signature
13
+ # 2. Processing chunks through ChunkProcessor
14
+ # 3. Validating chunk sequence requirements
15
+ # 4. Building the complete FileAnalysis result
16
+ #
17
+ # The orchestrator follows a pipeline architecture:
18
+ # File → Signature → Chunks → Sequence → Analysis → Result
19
+ #
20
+ class ValidationOrchestrator
21
+ attr_reader :reader, :filepath, :options, :context, :chunks
22
+
23
+ # Convenience method to validate a file by path
24
+ #
25
+ # @param filepath [String] Path to PNG file
26
+ # @param options [Hash] Optional CLI options
27
+ # @return [FileAnalysis] Complete file analysis
28
+ def self.validate_file(filepath, options = {})
29
+ require_relative "../readers/full_load_reader"
30
+ reader = Readers::FullLoadReader.new(filepath)
31
+ new(reader, filepath, options).validate
32
+ end
33
+
34
+ # Initialize validation orchestrator
35
+ #
36
+ # @param reader [Object] File reader (StreamingReader or FullLoadReader)
37
+ # @param filepath [String, nil] Optional file path (for reporting)
38
+ # @param options [Hash] CLI options for controlling behavior
39
+ def initialize(reader, filepath = nil, options = {})
40
+ @reader = reader
41
+ @filepath = filepath
42
+ @options = options
43
+ @context = Validators::ValidationContext.new
44
+ @chunks = []
45
+ end
46
+
47
+ # Validate the PNG file
48
+ #
49
+ # This is the main entry point for validation. It orchestrates
50
+ # all validation stages in the correct order.
51
+ #
52
+ # @return [FileAnalysis] Complete file analysis with all data
53
+ def validate
54
+ validate_signature
55
+ process_chunks
56
+ validate_sequence
57
+ build_result
58
+ end
59
+
60
+ # Get all errors from the validation context
61
+ #
62
+ # @return [Array<Hash>] Array of error hashes
63
+ def errors
64
+ @context.all_errors
65
+ end
66
+
67
+ # Get all warnings from the validation context
68
+ #
69
+ # @return [Array<Hash>] Array of warning hashes
70
+ def warnings
71
+ @context.all_warnings
72
+ end
73
+
74
+ # Get all info messages from the validation context
75
+ #
76
+ # @return [Array<Hash>] Array of info hashes
77
+ def info_messages
78
+ @context.all_info
79
+ end
80
+
81
+ private
82
+
83
+ # Validate PNG signature
84
+ #
85
+ # Checks that the file starts with the PNG signature:
86
+ # 137 80 78 71 13 10 26 10
87
+ #
88
+ # @return [void]
89
+ def validate_signature
90
+ sig = reader.signature
91
+ expected = [137, 80, 78, 71, 13, 10, 26, 10].pack("C*")
92
+
93
+ return if sig == expected
94
+
95
+ @context.add_error(
96
+ chunk_type: "SIGNATURE",
97
+ message: "Invalid PNG signature",
98
+ severity: :error,
99
+ )
100
+ end
101
+
102
+ # Process all chunks using ChunkProcessor
103
+ #
104
+ # Delegates chunk validation to the ChunkProcessor which handles:
105
+ # - Iterating through chunks
106
+ # - Creating validators via ChunkRegistry
107
+ # - Collecting validation results
108
+ #
109
+ # @return [void]
110
+ def process_chunks
111
+ processor = ChunkProcessor.new(@reader, @context, @options)
112
+ processor.process do |chunk|
113
+ @chunks << chunk
114
+ end
115
+ end
116
+
117
+ # Validate chunk sequence requirements
118
+ #
119
+ # Checks high-level sequencing rules:
120
+ # - IHDR must be first chunk
121
+ # - IEND must be last chunk
122
+ # - At least one IDAT chunk required
123
+ #
124
+ # @return [void]
125
+ def validate_sequence
126
+ validate_ihdr_first
127
+ validate_iend_last
128
+ validate_idat_present
129
+ end
130
+
131
+ # Check that IHDR is the first chunk
132
+ #
133
+ # @return [void]
134
+ def validate_ihdr_first
135
+ return if @context.seen?("IHDR")
136
+
137
+ @context.add_error(
138
+ chunk_type: "IHDR",
139
+ message: "Missing IHDR chunk (must be first)",
140
+ severity: :error,
141
+ )
142
+ end
143
+
144
+ # Check that IEND is the last chunk
145
+ #
146
+ # @return [void]
147
+ def validate_iend_last
148
+ return if @context.seen?("IEND")
149
+
150
+ @context.add_error(
151
+ chunk_type: "IEND",
152
+ message: "Missing IEND chunk (must be last)",
153
+ severity: :error,
154
+ )
155
+ end
156
+
157
+ # Check that at least one IDAT chunk exists
158
+ #
159
+ # @return [void]
160
+ def validate_idat_present
161
+ return if @context.seen?("IDAT")
162
+
163
+ @context.add_error(
164
+ chunk_type: "IDAT",
165
+ message: "Missing IDAT chunk (at least one required)",
166
+ severity: :error,
167
+ )
168
+ end
169
+
170
+ # Build complete FileAnalysis with validation results and analyzer data
171
+ #
172
+ # Coordinates ResultBuilder and AnalysisManager to produce the final result
173
+ #
174
+ # @return [FileAnalysis] Complete analysis model
175
+ def build_result
176
+ result_builder = ResultBuilder.new(@reader, @filepath, @context,
177
+ @chunks, @options)
178
+ file_analysis = result_builder.build
179
+
180
+ # Run analyzers through AnalysisManager
181
+ analysis_manager = AnalysisManager.new(@options)
182
+ analysis_manager.enrich(file_analysis)
183
+
184
+ file_analysis
185
+ end
186
+ end
187
+ end
188
+ end