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