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
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|