png_conform 0.1.2 → 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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +116 -6
  3. data/Gemfile +1 -1
  4. data/config/validation_profiles.yml +105 -0
  5. data/lib/png_conform/analyzers/comparison_analyzer.rb +41 -7
  6. data/lib/png_conform/analyzers/metrics_analyzer.rb +6 -9
  7. data/lib/png_conform/analyzers/optimization_analyzer.rb +30 -24
  8. data/lib/png_conform/analyzers/resolution_analyzer.rb +31 -32
  9. data/lib/png_conform/cli.rb +12 -0
  10. data/lib/png_conform/commands/check_command.rb +118 -53
  11. data/lib/png_conform/configuration.rb +147 -0
  12. data/lib/png_conform/container.rb +113 -0
  13. data/lib/png_conform/models/validation_result.rb +30 -4
  14. data/lib/png_conform/pipelines/pipeline_result.rb +39 -0
  15. data/lib/png_conform/pipelines/stages/analysis_stage.rb +35 -0
  16. data/lib/png_conform/pipelines/stages/base_stage.rb +23 -0
  17. data/lib/png_conform/pipelines/stages/chunk_validation_stage.rb +74 -0
  18. data/lib/png_conform/pipelines/stages/sequence_validation_stage.rb +77 -0
  19. data/lib/png_conform/pipelines/stages/signature_validation_stage.rb +41 -0
  20. data/lib/png_conform/pipelines/validation_pipeline.rb +90 -0
  21. data/lib/png_conform/readers/full_load_reader.rb +13 -4
  22. data/lib/png_conform/readers/streaming_reader.rb +27 -2
  23. data/lib/png_conform/reporters/color_reporter.rb +17 -14
  24. data/lib/png_conform/reporters/visual_elements.rb +22 -16
  25. data/lib/png_conform/services/analysis_manager.rb +120 -0
  26. data/lib/png_conform/services/chunk_processor.rb +195 -0
  27. data/lib/png_conform/services/file_signature.rb +226 -0
  28. data/lib/png_conform/services/file_strategy.rb +78 -0
  29. data/lib/png_conform/services/lru_cache.rb +170 -0
  30. data/lib/png_conform/services/parallel_validator.rb +118 -0
  31. data/lib/png_conform/services/profile_manager.rb +41 -12
  32. data/lib/png_conform/services/result_builder.rb +299 -0
  33. data/lib/png_conform/services/validation_cache.rb +210 -0
  34. data/lib/png_conform/services/validation_orchestrator.rb +188 -0
  35. data/lib/png_conform/services/validation_service.rb +53 -337
  36. data/lib/png_conform/services/validator_pool.rb +142 -0
  37. data/lib/png_conform/utils/colorizer.rb +149 -0
  38. data/lib/png_conform/validators/chunk_registry.rb +12 -0
  39. data/lib/png_conform/validators/streaming_idat_validator.rb +123 -0
  40. data/lib/png_conform/version.rb +1 -1
  41. data/png_conform.gemspec +1 -0
  42. metadata +38 -2
@@ -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
@@ -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