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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +116 -6
- data/Gemfile +1 -1
- data/config/validation_profiles.yml +105 -0
- data/lib/png_conform/analyzers/comparison_analyzer.rb +41 -7
- data/lib/png_conform/analyzers/metrics_analyzer.rb +6 -9
- data/lib/png_conform/analyzers/optimization_analyzer.rb +30 -24
- data/lib/png_conform/analyzers/resolution_analyzer.rb +31 -32
- data/lib/png_conform/cli.rb +12 -0
- data/lib/png_conform/commands/check_command.rb +118 -53
- data/lib/png_conform/configuration.rb +147 -0
- data/lib/png_conform/container.rb +113 -0
- data/lib/png_conform/models/validation_result.rb +30 -4
- data/lib/png_conform/pipelines/pipeline_result.rb +39 -0
- data/lib/png_conform/pipelines/stages/analysis_stage.rb +35 -0
- data/lib/png_conform/pipelines/stages/base_stage.rb +23 -0
- data/lib/png_conform/pipelines/stages/chunk_validation_stage.rb +74 -0
- data/lib/png_conform/pipelines/stages/sequence_validation_stage.rb +77 -0
- data/lib/png_conform/pipelines/stages/signature_validation_stage.rb +41 -0
- data/lib/png_conform/pipelines/validation_pipeline.rb +90 -0
- data/lib/png_conform/readers/full_load_reader.rb +13 -4
- data/lib/png_conform/readers/streaming_reader.rb +27 -2
- data/lib/png_conform/reporters/color_reporter.rb +17 -14
- data/lib/png_conform/reporters/visual_elements.rb +22 -16
- data/lib/png_conform/services/analysis_manager.rb +120 -0
- data/lib/png_conform/services/chunk_processor.rb +195 -0
- data/lib/png_conform/services/file_signature.rb +226 -0
- data/lib/png_conform/services/file_strategy.rb +78 -0
- data/lib/png_conform/services/lru_cache.rb +170 -0
- data/lib/png_conform/services/parallel_validator.rb +118 -0
- data/lib/png_conform/services/profile_manager.rb +41 -12
- data/lib/png_conform/services/result_builder.rb +299 -0
- data/lib/png_conform/services/validation_cache.rb +210 -0
- data/lib/png_conform/services/validation_orchestrator.rb +188 -0
- data/lib/png_conform/services/validation_service.rb +53 -337
- data/lib/png_conform/services/validator_pool.rb +142 -0
- data/lib/png_conform/utils/colorizer.rb +149 -0
- data/lib/png_conform/validators/chunk_registry.rb +12 -0
- data/lib/png_conform/validators/streaming_idat_validator.rb +123 -0
- data/lib/png_conform/version.rb +1 -1
- data/png_conform.gemspec +1 -0
- metadata +38 -2
|
@@ -1,43 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "
|
|
4
|
-
require_relative "../models/validation_result"
|
|
5
|
-
require_relative "../models/file_analysis"
|
|
6
|
-
require_relative "../models/image_info"
|
|
7
|
-
require_relative "../models/compression_info"
|
|
8
|
-
# Lazy load analyzers - only require when needed (Phase 2: Lazy Loading)
|
|
9
|
-
# require_relative "../analyzers/resolution_analyzer"
|
|
10
|
-
# require_relative "../analyzers/optimization_analyzer"
|
|
11
|
-
# require_relative "../analyzers/metrics_analyzer"
|
|
3
|
+
require_relative "validation_orchestrator"
|
|
12
4
|
|
|
13
5
|
module PngConform
|
|
14
6
|
module Services
|
|
15
7
|
# Main validation orchestration service
|
|
16
8
|
#
|
|
17
|
-
# This service coordinates the validation of PNG files by
|
|
18
|
-
#
|
|
19
|
-
# 2. Creating appropriate validators for each chunk
|
|
20
|
-
# 3. Executing validation in the correct order
|
|
21
|
-
# 4. Collecting and aggregating results
|
|
22
|
-
#
|
|
23
|
-
# The service follows a pipeline architecture:
|
|
24
|
-
# File → Chunks → Validators → Results
|
|
9
|
+
# This service coordinates the validation of PNG files by delegating
|
|
10
|
+
# all operations to the ValidationOrchestrator class.
|
|
25
11
|
#
|
|
26
12
|
class ValidationService
|
|
27
|
-
attr_reader :reader, :context, :results, :chunks
|
|
28
|
-
|
|
29
13
|
# Convenience method to validate a file by path
|
|
30
14
|
#
|
|
31
15
|
# @param filepath [String] Path to PNG file
|
|
32
16
|
# @param options [Hash] Optional CLI options
|
|
33
|
-
# @return [
|
|
17
|
+
# @return [FileAnalysis] Validation results
|
|
34
18
|
def self.validate_file(filepath, options = {})
|
|
35
|
-
|
|
36
|
-
reader = Readers::FullLoadReader.new(filepath)
|
|
37
|
-
service = new(reader, filepath, options)
|
|
38
|
-
service.validate
|
|
19
|
+
ValidationOrchestrator.validate_file(filepath, options)
|
|
39
20
|
end
|
|
40
21
|
|
|
22
|
+
attr_reader :reader, :context, :results, :chunks
|
|
23
|
+
|
|
41
24
|
# Initialize validation service
|
|
42
25
|
#
|
|
43
26
|
# @param reader [Object] File reader (StreamingReader or FullLoadReader)
|
|
@@ -47,282 +30,91 @@ module PngConform
|
|
|
47
30
|
@reader = reader
|
|
48
31
|
@filepath = filepath
|
|
49
32
|
@options = options
|
|
50
|
-
@
|
|
51
|
-
@
|
|
52
|
-
@chunks = []
|
|
33
|
+
@orchestrator = ValidationOrchestrator.new(reader, filepath, options)
|
|
34
|
+
@context = @orchestrator.context
|
|
35
|
+
@chunks = []
|
|
53
36
|
end
|
|
54
37
|
|
|
55
38
|
# Validate the PNG file
|
|
56
39
|
#
|
|
57
|
-
# This is the main entry point for validation. It processes all chunks
|
|
58
|
-
# in order, validates them, and collects the results.
|
|
59
|
-
#
|
|
60
40
|
# @return [FileAnalysis] Complete file analysis with all data
|
|
61
41
|
def validate
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
build_file_analysis
|
|
42
|
+
file_analysis = @orchestrator.validate
|
|
43
|
+
@chunks = @orchestrator.chunks
|
|
44
|
+
file_analysis
|
|
66
45
|
end
|
|
67
46
|
|
|
68
47
|
# Validate PNG signature
|
|
69
|
-
|
|
70
|
-
# Checks that the file starts with the PNG signature:
|
|
71
|
-
# 137 80 78 71 13 10 26 10
|
|
72
|
-
#
|
|
73
|
-
# @return [void]
|
|
74
|
-
def validate_signature
|
|
75
|
-
sig = reader.signature
|
|
76
|
-
expected = [137, 80, 78, 71, 13, 10, 26, 10].pack("C*")
|
|
77
|
-
|
|
78
|
-
return if sig == expected
|
|
79
|
-
|
|
80
|
-
add_error("Invalid PNG signature")
|
|
81
|
-
end
|
|
48
|
+
def validate_signature; end
|
|
82
49
|
|
|
83
50
|
# Validate all chunks in the file
|
|
84
|
-
|
|
85
|
-
# Processes each chunk in order:
|
|
86
|
-
# 1. Check for validator
|
|
87
|
-
# 2. Create validator instance
|
|
88
|
-
# 3. Execute validation
|
|
89
|
-
# 4. Collect results
|
|
90
|
-
#
|
|
91
|
-
# @return [void]
|
|
92
|
-
def validate_chunks
|
|
93
|
-
reader.each_chunk do |chunk|
|
|
94
|
-
@chunks << chunk # Store chunk for later use
|
|
95
|
-
validate_chunk(chunk)
|
|
96
|
-
end
|
|
97
|
-
end
|
|
51
|
+
def validate_chunks; end
|
|
98
52
|
|
|
99
53
|
# Validate a single chunk
|
|
100
54
|
#
|
|
101
55
|
# @param chunk [Object] Chunk to validate
|
|
102
|
-
|
|
103
|
-
def validate_chunk(chunk)
|
|
104
|
-
# Get validator for this chunk type
|
|
105
|
-
validator = Validators::ChunkRegistry.create_validator(chunk, context)
|
|
106
|
-
|
|
107
|
-
if validator
|
|
108
|
-
# Validate chunk with registered validator
|
|
109
|
-
validator.validate
|
|
110
|
-
# Errors are stored in context, not validator
|
|
111
|
-
else
|
|
112
|
-
# Unknown chunk - check if it's safe to ignore
|
|
113
|
-
handle_unknown_chunk(chunk)
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
# Mark chunk as seen AFTER validation
|
|
117
|
-
# This allows validators to check for duplicates before marking
|
|
118
|
-
# Convert BinData::String to regular String for hash key consistency
|
|
119
|
-
context.mark_chunk_seen(chunk.chunk_type.to_s, chunk)
|
|
120
|
-
end
|
|
56
|
+
def validate_chunk(chunk); end
|
|
121
57
|
|
|
122
58
|
# Handle unknown chunk types
|
|
123
59
|
#
|
|
124
|
-
# Unknown chunks are checked for safety:
|
|
125
|
-
# - If ancillary (bit 5 of first byte = 1), it's safe to ignore
|
|
126
|
-
# - If critical (bit 5 = 0), it's an error
|
|
127
|
-
#
|
|
128
60
|
# @param chunk [Object] Unknown chunk
|
|
129
|
-
|
|
130
|
-
def handle_unknown_chunk(chunk)
|
|
131
|
-
# Convert BinData::String to regular String
|
|
132
|
-
chunk_type = chunk.chunk_type.to_s
|
|
133
|
-
first_byte = chunk_type.bytes[0]
|
|
134
|
-
|
|
135
|
-
# Bit 5 (0x20) of first byte indicates ancillary vs critical
|
|
136
|
-
if (first_byte & 0x20).zero?
|
|
137
|
-
# Critical chunk - must be recognized
|
|
138
|
-
add_error("Unknown critical chunk type: #{chunk_type}")
|
|
139
|
-
else
|
|
140
|
-
# Ancillary chunk - safe to ignore
|
|
141
|
-
add_info("Unknown ancillary chunk type: #{chunk_type} (ignored)")
|
|
142
|
-
end
|
|
143
|
-
end
|
|
61
|
+
def handle_unknown_chunk(chunk); end
|
|
144
62
|
|
|
145
63
|
# Validate chunk sequence requirements
|
|
146
|
-
|
|
147
|
-
# Checks high-level sequencing rules:
|
|
148
|
-
# - IHDR must be first chunk
|
|
149
|
-
# - IEND must be last chunk
|
|
150
|
-
# - At least one IDAT chunk required
|
|
151
|
-
#
|
|
152
|
-
# @return [void]
|
|
153
|
-
def validate_chunk_sequence
|
|
154
|
-
validate_ihdr_first
|
|
155
|
-
validate_iend_last
|
|
156
|
-
validate_idat_present
|
|
157
|
-
end
|
|
64
|
+
def validate_chunk_sequence; end
|
|
158
65
|
|
|
159
66
|
# Check that IHDR is the first chunk
|
|
160
|
-
|
|
161
|
-
# @return [void]
|
|
162
|
-
def validate_ihdr_first
|
|
163
|
-
return if context.seen?("IHDR")
|
|
164
|
-
|
|
165
|
-
add_error("Missing IHDR chunk (must be first)")
|
|
166
|
-
end
|
|
67
|
+
def validate_ihdr_first; end
|
|
167
68
|
|
|
168
69
|
# Check that IEND is the last chunk
|
|
169
|
-
|
|
170
|
-
# @return [void]
|
|
171
|
-
def validate_iend_last
|
|
172
|
-
return if context.seen?("IEND")
|
|
173
|
-
|
|
174
|
-
add_error("Missing IEND chunk (must be last)")
|
|
175
|
-
end
|
|
70
|
+
def validate_iend_last; end
|
|
176
71
|
|
|
177
72
|
# Check that at least one IDAT chunk exists
|
|
178
|
-
|
|
179
|
-
# @return [void]
|
|
180
|
-
def validate_idat_present
|
|
181
|
-
return if context.seen?("IDAT")
|
|
182
|
-
|
|
183
|
-
add_error("Missing IDAT chunk (at least one required)")
|
|
184
|
-
end
|
|
73
|
+
def validate_idat_present; end
|
|
185
74
|
|
|
186
75
|
# Build complete FileAnalysis with validation results and analyzer data
|
|
187
76
|
#
|
|
188
|
-
# Proper Model → Formatter pattern
|
|
189
|
-
# - Builds ValidationResult (legacy)
|
|
190
|
-
# - Extracts ImageInfo and CompressionInfo
|
|
191
|
-
# - Runs analyzers conditionally (Phase 1.1 optimization)
|
|
192
|
-
# - Returns complete FileAnalysis model
|
|
193
|
-
#
|
|
194
77
|
# @return [FileAnalysis] Complete analysis model
|
|
195
78
|
def build_file_analysis
|
|
196
|
-
|
|
197
|
-
validation_result = build_validation_result
|
|
198
|
-
|
|
199
|
-
# Extract image info from IHDR
|
|
200
|
-
image_info = extract_image_info(validation_result)
|
|
201
|
-
|
|
202
|
-
# Build complete FileAnalysis
|
|
203
|
-
Models::FileAnalysis.new.tap do |analysis|
|
|
204
|
-
analysis.file_path = @filepath || "unknown"
|
|
205
|
-
analysis.file_size = validation_result.file_size
|
|
206
|
-
analysis.file_type = validation_result.file_type
|
|
207
|
-
analysis.validation_result = validation_result
|
|
208
|
-
# chunks delegated to validation_result (no need to set)
|
|
209
|
-
analysis.image_info = image_info
|
|
210
|
-
analysis.compression_info = extract_compression_info(validation_result)
|
|
211
|
-
|
|
212
|
-
# Run analyzers conditionally (Phase 1.1: saves ~80ms)
|
|
213
|
-
if need_resolution_analysis?
|
|
214
|
-
analysis.resolution_analysis = run_resolution_analysis(validation_result)
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
if need_optimization_analysis?
|
|
218
|
-
analysis.optimization_analysis = run_optimization_analysis(validation_result)
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
if need_metrics_analysis?
|
|
222
|
-
analysis.metrics = run_metrics_analysis(validation_result)
|
|
223
|
-
end
|
|
224
|
-
end
|
|
79
|
+
@orchestrator.validate
|
|
225
80
|
end
|
|
226
81
|
|
|
227
|
-
# Build ValidationResult
|
|
228
|
-
def build_validation_result
|
|
229
|
-
Models::ValidationResult.new.tap do |result|
|
|
230
|
-
# Set file metadata
|
|
231
|
-
result.filename = @filepath || "unknown"
|
|
232
|
-
|
|
233
|
-
result.file_type = determine_file_type
|
|
234
|
-
|
|
235
|
-
# Calculate file size from chunks if reader doesn't provide it
|
|
236
|
-
result.file_size = if reader.respond_to?(:file_size)
|
|
237
|
-
reader.file_size
|
|
238
|
-
else
|
|
239
|
-
# 8 bytes signature + sum of chunk sizes (8 byte header + data + 4 byte CRC per chunk)
|
|
240
|
-
8 + @chunks.sum { |c| 12 + c.length }
|
|
241
|
-
end
|
|
242
|
-
|
|
243
|
-
# Add all chunks with CRC validation
|
|
244
|
-
crc_error_count = 0
|
|
245
|
-
@chunks.each do |bindata_chunk|
|
|
246
|
-
chunk = Models::Chunk.from_bindata(bindata_chunk,
|
|
247
|
-
bindata_chunk.abs_offset)
|
|
248
|
-
|
|
249
|
-
# Validate CRC
|
|
250
|
-
expected_crc = bindata_chunk.crc
|
|
251
|
-
actual_crc = calculate_crc(bindata_chunk)
|
|
252
|
-
chunk.crc_expected = format_hex(expected_crc)
|
|
253
|
-
chunk.crc_actual = format_hex(actual_crc)
|
|
254
|
-
chunk.valid_crc = (expected_crc == actual_crc)
|
|
255
|
-
|
|
256
|
-
crc_error_count += 1 unless chunk.valid_crc
|
|
257
|
-
|
|
258
|
-
result.add_chunk(chunk)
|
|
259
|
-
end
|
|
260
|
-
|
|
261
|
-
result.crc_errors_count = crc_error_count
|
|
262
|
-
|
|
263
|
-
# Calculate compression ratio for PNG files (Phase 1.2: lazy calculation)
|
|
264
|
-
result.compression_ratio = if result.file_type == "PNG" && need_compression_ratio?
|
|
265
|
-
calculate_compression_ratio(result.chunks)
|
|
266
|
-
else
|
|
267
|
-
0.0
|
|
268
|
-
end
|
|
269
|
-
|
|
270
|
-
# Add errors from service (@results)
|
|
271
|
-
@results.select { |r| r[:type] == :error }.each do |r|
|
|
272
|
-
result.error(r[:message])
|
|
273
|
-
end
|
|
274
|
-
|
|
275
|
-
# Add errors from validators (context)
|
|
276
|
-
context.all_errors.each do |e|
|
|
277
|
-
result.error(e[:message])
|
|
278
|
-
end
|
|
279
|
-
|
|
280
|
-
# Add warnings from service (@results)
|
|
281
|
-
@results.select { |r| r[:type] == :warning }.each do |r|
|
|
282
|
-
result.warning(r[:message])
|
|
283
|
-
end
|
|
284
|
-
|
|
285
|
-
# Add warnings from validators (context)
|
|
286
|
-
context.all_warnings.each do |w|
|
|
287
|
-
result.warning(w[:message])
|
|
288
|
-
end
|
|
289
|
-
|
|
290
|
-
# Add info from service (@results)
|
|
291
|
-
@results.select { |r| r[:type] == :info }.each do |r|
|
|
292
|
-
result.info(r[:message])
|
|
293
|
-
end
|
|
294
|
-
|
|
295
|
-
# Add info from validators (context)
|
|
296
|
-
context.all_info.each do |i|
|
|
297
|
-
result.info(i[:message])
|
|
298
|
-
end
|
|
299
|
-
end
|
|
300
|
-
end
|
|
82
|
+
# Build ValidationResult
|
|
83
|
+
def build_validation_result; end
|
|
301
84
|
|
|
302
85
|
private
|
|
303
86
|
|
|
304
87
|
# Add an error to results
|
|
305
88
|
#
|
|
306
89
|
# @param message [String] Error message
|
|
307
|
-
# @return [void]
|
|
308
90
|
def add_error(message)
|
|
309
|
-
@
|
|
91
|
+
@context.add_error(
|
|
92
|
+
chunk_type: nil,
|
|
93
|
+
message: message,
|
|
94
|
+
severity: :error,
|
|
95
|
+
)
|
|
310
96
|
end
|
|
311
97
|
|
|
312
98
|
# Add a warning to results
|
|
313
99
|
#
|
|
314
100
|
# @param message [String] Warning message
|
|
315
|
-
# @return [void]
|
|
316
101
|
def add_warning(message)
|
|
317
|
-
@
|
|
102
|
+
@context.add_error(
|
|
103
|
+
chunk_type: nil,
|
|
104
|
+
message: message,
|
|
105
|
+
severity: :warning,
|
|
106
|
+
)
|
|
318
107
|
end
|
|
319
108
|
|
|
320
109
|
# Add info to results
|
|
321
110
|
#
|
|
322
111
|
# @param message [String] Info message
|
|
323
|
-
# @return [void]
|
|
324
112
|
def add_info(message)
|
|
325
|
-
@
|
|
113
|
+
@context.add_error(
|
|
114
|
+
chunk_type: nil,
|
|
115
|
+
message: message,
|
|
116
|
+
severity: :info,
|
|
117
|
+
)
|
|
326
118
|
end
|
|
327
119
|
|
|
328
120
|
# Merge results from a validator
|
|
@@ -330,7 +122,6 @@ module PngConform
|
|
|
330
122
|
# @param errors [Array<String>] Error messages
|
|
331
123
|
# @param warnings [Array<String>] Warning messages
|
|
332
124
|
# @param info [Array<String>] Info messages
|
|
333
|
-
# @return [void]
|
|
334
125
|
def merge_results(errors, warnings, info)
|
|
335
126
|
errors.each { |msg| add_error(msg) }
|
|
336
127
|
warnings.each { |msg| add_warning(msg) }
|
|
@@ -340,13 +131,7 @@ module PngConform
|
|
|
340
131
|
# Determine file type based on chunks
|
|
341
132
|
#
|
|
342
133
|
# @return [String] File type (PNG, MNG, JNG, or UNKNOWN)
|
|
343
|
-
def determine_file_type
|
|
344
|
-
return Models::ValidationResult::FILE_TYPE_MNG if context.seen?("MHDR")
|
|
345
|
-
return Models::ValidationResult::FILE_TYPE_JNG if context.seen?("JHDR")
|
|
346
|
-
return Models::ValidationResult::FILE_TYPE_PNG if context.seen?("IHDR")
|
|
347
|
-
|
|
348
|
-
Models::ValidationResult::FILE_TYPE_UNKNOWN
|
|
349
|
-
end
|
|
134
|
+
def determine_file_type; end
|
|
350
135
|
|
|
351
136
|
# Calculate CRC32 for a chunk
|
|
352
137
|
#
|
|
@@ -354,7 +139,6 @@ module PngConform
|
|
|
354
139
|
# @return [Integer] CRC32 value
|
|
355
140
|
def calculate_crc(chunk)
|
|
356
141
|
require "zlib"
|
|
357
|
-
# CRC is calculated over chunk type + chunk data
|
|
358
142
|
Zlib.crc32(chunk.chunk_type.to_s + chunk.data.to_s)
|
|
359
143
|
end
|
|
360
144
|
|
|
@@ -370,95 +154,27 @@ module PngConform
|
|
|
370
154
|
#
|
|
371
155
|
# @param chunks [Array<Chunk>] All chunks
|
|
372
156
|
# @return [Float] Compression ratio as percentage, 0.0 if cannot calculate
|
|
373
|
-
def calculate_compression_ratio(chunks)
|
|
374
|
-
idat_chunks = chunks.select { |c| c.type == "IDAT" }
|
|
375
|
-
return 0.0 if idat_chunks.empty?
|
|
376
|
-
|
|
377
|
-
compressed_size = idat_chunks.sum(&:length)
|
|
378
|
-
return 0.0 if compressed_size.zero?
|
|
379
|
-
|
|
380
|
-
# Try to decompress to get original size
|
|
381
|
-
# Need to get actual binary data from BinData chunks, not Model chunks
|
|
382
|
-
begin
|
|
383
|
-
require "zlib"
|
|
384
|
-
|
|
385
|
-
# Get IDAT chunks from the original BinData chunks
|
|
386
|
-
idat_bindata = @chunks.select { |c| c.chunk_type.to_s == "IDAT" }
|
|
387
|
-
compressed_data = idat_bindata.map { |c| c.data.to_s }.join
|
|
388
|
-
|
|
389
|
-
decompressed = Zlib::Inflate.inflate(compressed_data)
|
|
390
|
-
original_size = decompressed.bytesize
|
|
391
|
-
|
|
392
|
-
return 0.0 if original_size.zero?
|
|
393
|
-
|
|
394
|
-
# Calculate percentage: (compressed/original - 1) * 100
|
|
395
|
-
# Negative means compression, positive means expansion
|
|
396
|
-
((compressed_size.to_f / original_size - 1) * 100).round(1)
|
|
397
|
-
rescue StandardError
|
|
398
|
-
# If decompression fails, we can't calculate ratio
|
|
399
|
-
# Return 0.0 instead of nil so it appears in YAML/JSON
|
|
400
|
-
0.0
|
|
401
|
-
end
|
|
402
|
-
end
|
|
157
|
+
def calculate_compression_ratio(chunks); end
|
|
403
158
|
|
|
404
159
|
# Extract ImageInfo from IHDR chunk
|
|
405
|
-
def extract_image_info(result)
|
|
406
|
-
ihdr = result.ihdr_chunk
|
|
407
|
-
return nil unless ihdr&.data && ihdr.data.bytesize >= 13
|
|
408
|
-
|
|
409
|
-
width = ihdr.data.bytes[0..3].pack("C*").unpack1("N")
|
|
410
|
-
height = ihdr.data.bytes[4..7].pack("C*").unpack1("N")
|
|
411
|
-
bit_depth = ihdr.data.bytes[8]
|
|
412
|
-
color_type = ihdr.data.bytes[9]
|
|
413
|
-
interlace = ihdr.data.bytes[12]
|
|
414
|
-
|
|
415
|
-
Models::ImageInfo.new.tap do |info|
|
|
416
|
-
info.width = width
|
|
417
|
-
info.height = height
|
|
418
|
-
info.bit_depth = bit_depth
|
|
419
|
-
info.color_type = color_type_name(color_type)
|
|
420
|
-
info.interlaced = interlace == 1
|
|
421
|
-
info.animated = false # Could check for APNG chunks
|
|
422
|
-
end
|
|
423
|
-
end
|
|
160
|
+
def extract_image_info(result); end
|
|
424
161
|
|
|
425
162
|
# Extract CompressionInfo
|
|
426
|
-
def extract_compression_info(result)
|
|
427
|
-
return nil unless result.compression_ratio
|
|
428
|
-
|
|
429
|
-
Models::CompressionInfo.new.tap do |info|
|
|
430
|
-
info.compression_ratio = result.compression_ratio
|
|
431
|
-
info.compressed_size = result.chunks.select do |c|
|
|
432
|
-
c.type == "IDAT"
|
|
433
|
-
end.sum(&:length)
|
|
434
|
-
end
|
|
435
|
-
end
|
|
163
|
+
def extract_compression_info(result); end
|
|
436
164
|
|
|
437
165
|
# Run resolution analyzer
|
|
438
|
-
def run_resolution_analysis(result)
|
|
439
|
-
require_relative "../analyzers/resolution_analyzer" unless defined?(Analyzers::ResolutionAnalyzer)
|
|
440
|
-
Analyzers::ResolutionAnalyzer.new(result).analyze
|
|
441
|
-
rescue StandardError => e
|
|
442
|
-
{ error: "Resolution analysis failed: #{e.message}" }
|
|
443
|
-
end
|
|
166
|
+
def run_resolution_analysis(result); end
|
|
444
167
|
|
|
445
168
|
# Run optimization analyzer
|
|
446
|
-
def run_optimization_analysis(result)
|
|
447
|
-
require_relative "../analyzers/optimization_analyzer" unless defined?(Analyzers::OptimizationAnalyzer)
|
|
448
|
-
Analyzers::OptimizationAnalyzer.new(result).analyze
|
|
449
|
-
rescue StandardError => e
|
|
450
|
-
{ error: "Optimization analysis failed: #{e.message}" }
|
|
451
|
-
end
|
|
169
|
+
def run_optimization_analysis(result); end
|
|
452
170
|
|
|
453
171
|
# Run metrics analyzer
|
|
454
|
-
def run_metrics_analysis(result)
|
|
455
|
-
require_relative "../analyzers/metrics_analyzer" unless defined?(Analyzers::MetricsAnalyzer)
|
|
456
|
-
Analyzers::MetricsAnalyzer.new(result).analyze
|
|
457
|
-
rescue StandardError => e
|
|
458
|
-
{ error: "Metrics analysis failed: #{e.message}" }
|
|
459
|
-
end
|
|
172
|
+
def run_metrics_analysis(result); end
|
|
460
173
|
|
|
461
174
|
# Helper to convert color type code to name
|
|
175
|
+
#
|
|
176
|
+
# @param code [Integer] Color type code
|
|
177
|
+
# @return [String] Color type name
|
|
462
178
|
def color_type_name(code)
|
|
463
179
|
case code
|
|
464
180
|
when 0 then "grayscale"
|
|
@@ -470,28 +186,28 @@ module PngConform
|
|
|
470
186
|
end
|
|
471
187
|
end
|
|
472
188
|
|
|
473
|
-
# Check if resolution analysis is needed
|
|
189
|
+
# Check if resolution analysis is needed
|
|
474
190
|
def need_resolution_analysis?
|
|
475
191
|
return true unless @options[:quiet]
|
|
476
192
|
|
|
477
193
|
@options[:resolution] || @options[:mobile_ready]
|
|
478
194
|
end
|
|
479
195
|
|
|
480
|
-
# Check if optimization analysis is needed
|
|
196
|
+
# Check if optimization analysis is needed
|
|
481
197
|
def need_optimization_analysis?
|
|
482
198
|
return true unless @options[:quiet]
|
|
483
199
|
|
|
484
200
|
@options[:optimize]
|
|
485
201
|
end
|
|
486
202
|
|
|
487
|
-
# Check if metrics analysis is needed
|
|
203
|
+
# Check if metrics analysis is needed
|
|
488
204
|
def need_metrics_analysis?
|
|
489
205
|
return true if ["yaml", "json"].include?(@options[:format])
|
|
490
206
|
|
|
491
207
|
@options[:metrics]
|
|
492
208
|
end
|
|
493
209
|
|
|
494
|
-
# Check if compression ratio calculation is needed
|
|
210
|
+
# Check if compression ratio calculation is needed
|
|
495
211
|
def need_compression_ratio?
|
|
496
212
|
return true if ["yaml", "json"].include?(@options[:format])
|
|
497
213
|
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PngConform
|
|
4
|
+
module Services
|
|
5
|
+
# Validator pool for reusing validator instances
|
|
6
|
+
#
|
|
7
|
+
# Reduces object allocation overhead by pooling validator instances
|
|
8
|
+
# across chunk validations. Validators are reset between uses to ensure
|
|
9
|
+
# clean state for each validation.
|
|
10
|
+
#
|
|
11
|
+
# This pool is scoped to a single validation session and should be
|
|
12
|
+
# discarded after validation completes.
|
|
13
|
+
#
|
|
14
|
+
class ValidatorPool
|
|
15
|
+
# Initialize validator pool
|
|
16
|
+
#
|
|
17
|
+
# @param options [Hash] Pool options
|
|
18
|
+
# @option options [Integer] :max_per_type Maximum validators to cache per type
|
|
19
|
+
def initialize(options = {})
|
|
20
|
+
@max_per_type = options.fetch(:max_per_type, 5)
|
|
21
|
+
@pools = {} # chunk_type => [validator instances]
|
|
22
|
+
@stats = {
|
|
23
|
+
created: 0,
|
|
24
|
+
reused: 0,
|
|
25
|
+
resets: 0,
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Acquire a validator for the given chunk type
|
|
30
|
+
#
|
|
31
|
+
# Returns a validator from the pool if available, or creates a new one.
|
|
32
|
+
# The validator is reset before being returned.
|
|
33
|
+
#
|
|
34
|
+
# @param chunk_type [String] Chunk type code
|
|
35
|
+
# @param validator_class [Class] Validator class to instantiate
|
|
36
|
+
# @param chunk [Object] Chunk object
|
|
37
|
+
# @param context [ValidationContext] Validation context
|
|
38
|
+
# @return [Object] Validator instance
|
|
39
|
+
def acquire(chunk_type, validator_class, chunk, context)
|
|
40
|
+
pool = pool_for(chunk_type)
|
|
41
|
+
|
|
42
|
+
# Try to get a validator from the pool
|
|
43
|
+
validator = pool.pop
|
|
44
|
+
|
|
45
|
+
if validator
|
|
46
|
+
@stats[:reused] += 1
|
|
47
|
+
reset_validator(validator, chunk, context)
|
|
48
|
+
else
|
|
49
|
+
@stats[:created] += 1
|
|
50
|
+
validator = validator_class.new(chunk, context)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
validator
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Return a validator to the pool
|
|
57
|
+
#
|
|
58
|
+
# @param chunk_type [String] Chunk type code
|
|
59
|
+
# @param validator [Object] Validator instance to return
|
|
60
|
+
# @return [void]
|
|
61
|
+
def release(chunk_type, validator)
|
|
62
|
+
pool = pool_for(chunk_type)
|
|
63
|
+
|
|
64
|
+
# Only pool if under limit
|
|
65
|
+
if pool.size < @max_per_type
|
|
66
|
+
# Clear validator state before returning to pool
|
|
67
|
+
clear_validator(validator)
|
|
68
|
+
pool << validator
|
|
69
|
+
end
|
|
70
|
+
# If pool is full, just let validator be garbage collected
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Clear all pools
|
|
74
|
+
#
|
|
75
|
+
# @return [void]
|
|
76
|
+
def clear
|
|
77
|
+
@pools.clear
|
|
78
|
+
reset_stats
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Get pool statistics
|
|
82
|
+
#
|
|
83
|
+
# @return [Hash] Statistics about pool usage
|
|
84
|
+
def stats
|
|
85
|
+
pool_sizes = @pools.transform_values(&:size)
|
|
86
|
+
@stats.merge(pool_sizes: pool_sizes,
|
|
87
|
+
total_pooled: pool_sizes.values.sum)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Reset statistics
|
|
91
|
+
#
|
|
92
|
+
# @return [void]
|
|
93
|
+
def reset_stats
|
|
94
|
+
@stats = {
|
|
95
|
+
created: 0,
|
|
96
|
+
reused: 0,
|
|
97
|
+
resets: 0,
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
# Get or create pool for a chunk type
|
|
104
|
+
#
|
|
105
|
+
# @param chunk_type [String] Chunk type code
|
|
106
|
+
# @return [Array] Pool array for this chunk type
|
|
107
|
+
def pool_for(chunk_type)
|
|
108
|
+
@pools[chunk_type] ||= []
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Reset validator for reuse with new chunk and context
|
|
112
|
+
#
|
|
113
|
+
# @param validator [Object] Validator instance
|
|
114
|
+
# @param chunk [Object] New chunk object
|
|
115
|
+
# @param context [ValidationContext] New validation context
|
|
116
|
+
# @return [void]
|
|
117
|
+
def reset_validator(validator, chunk, context)
|
|
118
|
+
@stats[:resets] += 1
|
|
119
|
+
|
|
120
|
+
# Reset the validator's chunk and context
|
|
121
|
+
validator.instance_variable_set(:@chunk, chunk)
|
|
122
|
+
validator.instance_variable_set(:@context, context)
|
|
123
|
+
|
|
124
|
+
# Clear any cached results/errors
|
|
125
|
+
clear_validator(validator)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Clear validator state
|
|
129
|
+
#
|
|
130
|
+
# @param validator [Object] Validator instance
|
|
131
|
+
# @return [void]
|
|
132
|
+
def clear_validator(validator)
|
|
133
|
+
# Clear common instance variables that validators might cache
|
|
134
|
+
validator.remove_instance_variable(:@errors) if validator.instance_variable_defined?(:@errors)
|
|
135
|
+
validator.remove_instance_variable(:@warnings) if validator.instance_variable_defined?(:@warnings)
|
|
136
|
+
validator.remove_instance_variable(:@info) if validator.instance_variable_defined?(:@info)
|
|
137
|
+
rescue NameError
|
|
138
|
+
# Variable doesn't exist, that's fine
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|