png_conform 0.1.0
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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +19 -0
- data/.rubocop_todo.yml +197 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/CONTRIBUTING.md +323 -0
- data/Gemfile +13 -0
- data/LICENSE +43 -0
- data/README.adoc +859 -0
- data/Rakefile +10 -0
- data/SECURITY.md +147 -0
- data/docs/ARCHITECTURE.adoc +681 -0
- data/docs/CHUNK_TYPES.adoc +450 -0
- data/docs/CLI_OPTIONS.adoc +913 -0
- data/docs/COMPATIBILITY.adoc +616 -0
- data/examples/README.adoc +398 -0
- data/examples/advanced_usage.rb +304 -0
- data/examples/basic_usage.rb +210 -0
- data/exe/png_conform +6 -0
- data/lib/png_conform/analyzers/comparison_analyzer.rb +230 -0
- data/lib/png_conform/analyzers/metrics_analyzer.rb +176 -0
- data/lib/png_conform/analyzers/optimization_analyzer.rb +190 -0
- data/lib/png_conform/analyzers/resolution_analyzer.rb +274 -0
- data/lib/png_conform/bindata/chunk_structure.rb +153 -0
- data/lib/png_conform/bindata/jng_file.rb +79 -0
- data/lib/png_conform/bindata/mng_file.rb +97 -0
- data/lib/png_conform/bindata/png_file.rb +162 -0
- data/lib/png_conform/cli.rb +116 -0
- data/lib/png_conform/commands/check_command.rb +323 -0
- data/lib/png_conform/commands/list_command.rb +67 -0
- data/lib/png_conform/models/chunk.rb +84 -0
- data/lib/png_conform/models/chunk_info.rb +71 -0
- data/lib/png_conform/models/compression_info.rb +49 -0
- data/lib/png_conform/models/decoded_chunk_data.rb +143 -0
- data/lib/png_conform/models/file_analysis.rb +181 -0
- data/lib/png_conform/models/file_info.rb +91 -0
- data/lib/png_conform/models/image_info.rb +52 -0
- data/lib/png_conform/models/validation_error.rb +89 -0
- data/lib/png_conform/models/validation_result.rb +137 -0
- data/lib/png_conform/readers/full_load_reader.rb +113 -0
- data/lib/png_conform/readers/streaming_reader.rb +180 -0
- data/lib/png_conform/reporters/base_reporter.rb +53 -0
- data/lib/png_conform/reporters/color_reporter.rb +65 -0
- data/lib/png_conform/reporters/json_reporter.rb +18 -0
- data/lib/png_conform/reporters/palette_reporter.rb +48 -0
- data/lib/png_conform/reporters/quiet_reporter.rb +18 -0
- data/lib/png_conform/reporters/reporter_factory.rb +108 -0
- data/lib/png_conform/reporters/summary_reporter.rb +65 -0
- data/lib/png_conform/reporters/text_reporter.rb +66 -0
- data/lib/png_conform/reporters/verbose_reporter.rb +87 -0
- data/lib/png_conform/reporters/very_verbose_reporter.rb +33 -0
- data/lib/png_conform/reporters/visual_elements.rb +66 -0
- data/lib/png_conform/reporters/yaml_reporter.rb +18 -0
- data/lib/png_conform/services/profile_manager.rb +242 -0
- data/lib/png_conform/services/validation_service.rb +457 -0
- data/lib/png_conform/services/zlib_validator.rb +270 -0
- data/lib/png_conform/validators/ancillary/bkgd_validator.rb +140 -0
- data/lib/png_conform/validators/ancillary/chrm_validator.rb +178 -0
- data/lib/png_conform/validators/ancillary/cicp_validator.rb +202 -0
- data/lib/png_conform/validators/ancillary/gama_validator.rb +105 -0
- data/lib/png_conform/validators/ancillary/hist_validator.rb +147 -0
- data/lib/png_conform/validators/ancillary/iccp_validator.rb +243 -0
- data/lib/png_conform/validators/ancillary/itxt_validator.rb +280 -0
- data/lib/png_conform/validators/ancillary/mdcv_validator.rb +201 -0
- data/lib/png_conform/validators/ancillary/offs_validator.rb +132 -0
- data/lib/png_conform/validators/ancillary/pcal_validator.rb +289 -0
- data/lib/png_conform/validators/ancillary/phys_validator.rb +107 -0
- data/lib/png_conform/validators/ancillary/sbit_validator.rb +176 -0
- data/lib/png_conform/validators/ancillary/scal_validator.rb +180 -0
- data/lib/png_conform/validators/ancillary/splt_validator.rb +223 -0
- data/lib/png_conform/validators/ancillary/srgb_validator.rb +117 -0
- data/lib/png_conform/validators/ancillary/ster_validator.rb +111 -0
- data/lib/png_conform/validators/ancillary/text_validator.rb +129 -0
- data/lib/png_conform/validators/ancillary/time_validator.rb +132 -0
- data/lib/png_conform/validators/ancillary/trns_validator.rb +154 -0
- data/lib/png_conform/validators/ancillary/ztxt_validator.rb +173 -0
- data/lib/png_conform/validators/apng/actl_validator.rb +81 -0
- data/lib/png_conform/validators/apng/fctl_validator.rb +155 -0
- data/lib/png_conform/validators/apng/fdat_validator.rb +117 -0
- data/lib/png_conform/validators/base_validator.rb +241 -0
- data/lib/png_conform/validators/chunk_registry.rb +219 -0
- data/lib/png_conform/validators/critical/idat_validator.rb +77 -0
- data/lib/png_conform/validators/critical/iend_validator.rb +68 -0
- data/lib/png_conform/validators/critical/ihdr_validator.rb +160 -0
- data/lib/png_conform/validators/critical/plte_validator.rb +120 -0
- data/lib/png_conform/validators/jng/jdat_validator.rb +66 -0
- data/lib/png_conform/validators/jng/jhdr_validator.rb +116 -0
- data/lib/png_conform/validators/jng/jsep_validator.rb +66 -0
- data/lib/png_conform/validators/mng/back_validator.rb +87 -0
- data/lib/png_conform/validators/mng/clip_validator.rb +65 -0
- data/lib/png_conform/validators/mng/clon_validator.rb +45 -0
- data/lib/png_conform/validators/mng/defi_validator.rb +104 -0
- data/lib/png_conform/validators/mng/dhdr_validator.rb +104 -0
- data/lib/png_conform/validators/mng/disc_validator.rb +44 -0
- data/lib/png_conform/validators/mng/endl_validator.rb +65 -0
- data/lib/png_conform/validators/mng/fram_validator.rb +91 -0
- data/lib/png_conform/validators/mng/loop_validator.rb +75 -0
- data/lib/png_conform/validators/mng/mend_validator.rb +31 -0
- data/lib/png_conform/validators/mng/mhdr_validator.rb +69 -0
- data/lib/png_conform/validators/mng/move_validator.rb +61 -0
- data/lib/png_conform/validators/mng/save_validator.rb +39 -0
- data/lib/png_conform/validators/mng/seek_validator.rb +42 -0
- data/lib/png_conform/validators/mng/show_validator.rb +52 -0
- data/lib/png_conform/validators/mng/term_validator.rb +84 -0
- data/lib/png_conform/version.rb +5 -0
- data/lib/png_conform.rb +101 -0
- data/png_conform.gemspec +43 -0
- metadata +201 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
#
|
|
5
|
+
# Basic Usage Examples for PngConform
|
|
6
|
+
#
|
|
7
|
+
# This file demonstrates the most common ways to use PngConform
|
|
8
|
+
# for validating PNG files programmatically.
|
|
9
|
+
#
|
|
10
|
+
|
|
11
|
+
require "png_conform"
|
|
12
|
+
|
|
13
|
+
# Example 1: Basic validation
|
|
14
|
+
def basic_validation(file_path)
|
|
15
|
+
puts "=" * 60
|
|
16
|
+
puts "Example 1: Basic Validation"
|
|
17
|
+
puts "=" * 60
|
|
18
|
+
|
|
19
|
+
result = PngConform::Services::ValidationService.validate_file(file_path)
|
|
20
|
+
|
|
21
|
+
if result.valid?
|
|
22
|
+
puts "✓ File is valid!"
|
|
23
|
+
puts " Dimensions: #{result.image_info.width}x#{result.image_info.height}"
|
|
24
|
+
puts " Color type: #{result.image_info.color_type}"
|
|
25
|
+
puts " Bit depth: #{result.image_info.bit_depth}"
|
|
26
|
+
puts " Chunks: #{result.chunks.count}"
|
|
27
|
+
else
|
|
28
|
+
puts "✗ File has errors:"
|
|
29
|
+
result.errors.each do |error|
|
|
30
|
+
puts " #{error.severity.upcase}: #{error.message}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
puts
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Example 2: Validation with specific profile
|
|
37
|
+
def profile_validation(file_path, profile_name)
|
|
38
|
+
puts "=" * 60
|
|
39
|
+
puts "Example 2: Profile-Based Validation (#{profile_name})"
|
|
40
|
+
puts "=" * 60
|
|
41
|
+
|
|
42
|
+
profile = PngConform::Services::ProfileManager.get_profile(profile_name)
|
|
43
|
+
|
|
44
|
+
if profile
|
|
45
|
+
puts "Profile: #{profile[:name]} - #{profile[:description]}"
|
|
46
|
+
puts "Required chunks: #{profile[:required_chunks].join(', ')}"
|
|
47
|
+
puts
|
|
48
|
+
|
|
49
|
+
result = PngConform::Services::ValidationService.validate_file(file_path)
|
|
50
|
+
chunk_types = result.chunks.map(&:type)
|
|
51
|
+
|
|
52
|
+
# Validate against profile
|
|
53
|
+
profile_result = PngConform::Services::ProfileManager.validate_file_against_profile(
|
|
54
|
+
chunk_types, profile_name
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if profile_result[:valid]
|
|
58
|
+
puts "✓ File conforms to #{profile_name} profile"
|
|
59
|
+
else
|
|
60
|
+
puts "✗ Profile violations:"
|
|
61
|
+
profile_result[:errors].each do |error|
|
|
62
|
+
puts " #{error}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
else
|
|
66
|
+
puts "✗ Profile not found: #{profile_name}"
|
|
67
|
+
end
|
|
68
|
+
puts
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Example 3: Detailed chunk inspection
|
|
72
|
+
def inspect_chunks(file_path)
|
|
73
|
+
puts "=" * 60
|
|
74
|
+
puts "Example 3: Detailed Chunk Inspection"
|
|
75
|
+
puts "=" * 60
|
|
76
|
+
|
|
77
|
+
result = PngConform::Services::ValidationService.validate_file(file_path)
|
|
78
|
+
|
|
79
|
+
puts "File: #{file_path}"
|
|
80
|
+
puts "Chunks found: #{result.chunks.count}"
|
|
81
|
+
puts
|
|
82
|
+
|
|
83
|
+
result.chunks.each do |chunk|
|
|
84
|
+
puts "Chunk: #{chunk.type}"
|
|
85
|
+
puts " Offset: 0x#{chunk.offset.to_s(16).rjust(8, '0')}"
|
|
86
|
+
puts " Length: #{chunk.length} bytes"
|
|
87
|
+
puts " CRC: #{chunk.crc_expected}"
|
|
88
|
+
puts " CRC Valid: #{chunk.valid_crc ? '✓' : '✗'}"
|
|
89
|
+
|
|
90
|
+
# Display decoded data for specific chunks
|
|
91
|
+
if chunk.data
|
|
92
|
+
case chunk.type
|
|
93
|
+
when "IHDR"
|
|
94
|
+
# Extract IHDR data manually
|
|
95
|
+
width = chunk.data.bytes[0..3].pack("C*").unpack1("N")
|
|
96
|
+
height = chunk.data.bytes[4..7].pack("C*").unpack1("N")
|
|
97
|
+
bit_depth = chunk.data.bytes[8]
|
|
98
|
+
color_type = chunk.data.bytes[9]
|
|
99
|
+
puts " Width: #{width}"
|
|
100
|
+
puts " Height: #{height}"
|
|
101
|
+
puts " Bit Depth: #{bit_depth}"
|
|
102
|
+
puts " Color Type: #{color_type}"
|
|
103
|
+
when "gAMA"
|
|
104
|
+
gamma_value = chunk.data.bytes[0..3].pack("C*").unpack1("N")
|
|
105
|
+
puts " Gamma: #{gamma_value / 100_000.0}"
|
|
106
|
+
when "tEXt"
|
|
107
|
+
text = chunk.data.to_s
|
|
108
|
+
null_pos = text.index("\x00")
|
|
109
|
+
if null_pos
|
|
110
|
+
keyword = text[0...null_pos]
|
|
111
|
+
content = text[(null_pos + 1)..]
|
|
112
|
+
puts " Keyword: #{keyword}"
|
|
113
|
+
puts " Text: #{content[0..50]}..."
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
puts
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Example 4: Batch validation
|
|
122
|
+
def batch_validation(directory)
|
|
123
|
+
puts "=" * 60
|
|
124
|
+
puts "Example 4: Batch Validation"
|
|
125
|
+
puts "=" * 60
|
|
126
|
+
|
|
127
|
+
results = {
|
|
128
|
+
valid: [],
|
|
129
|
+
invalid: [],
|
|
130
|
+
errors: [],
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
Dir.glob(File.join(directory, "*.png")).each do |file|
|
|
134
|
+
result = PngConform::Services::ValidationService.validate_file(file)
|
|
135
|
+
if result.valid?
|
|
136
|
+
results[:valid] << file
|
|
137
|
+
else
|
|
138
|
+
results[:invalid] << { file: file, errors: result.errors }
|
|
139
|
+
end
|
|
140
|
+
rescue StandardError => e
|
|
141
|
+
results[:errors] << { file: file, error: e.message }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
puts "Processed #{results[:valid].count + results[:invalid].count + results[:errors].count} files"
|
|
145
|
+
puts " Valid: #{results[:valid].count}"
|
|
146
|
+
puts " Invalid: #{results[:invalid].count}"
|
|
147
|
+
puts " Errors: #{results[:errors].count}"
|
|
148
|
+
puts
|
|
149
|
+
|
|
150
|
+
unless results[:invalid].empty?
|
|
151
|
+
puts "Invalid files:"
|
|
152
|
+
results[:invalid].each do |item|
|
|
153
|
+
puts " #{File.basename(item[:file])}: #{item[:errors].count} errors"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
puts
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Example 5: Export validation results
|
|
160
|
+
def export_results(file_path, format = :yaml)
|
|
161
|
+
puts "=" * 60
|
|
162
|
+
puts "Example 5: Export Results (#{format.upcase})"
|
|
163
|
+
puts "=" * 60
|
|
164
|
+
|
|
165
|
+
result = PngConform::Services::ValidationService.validate_file(file_path)
|
|
166
|
+
|
|
167
|
+
case format
|
|
168
|
+
when :yaml
|
|
169
|
+
require "yaml"
|
|
170
|
+
puts result.to_yaml
|
|
171
|
+
when :json
|
|
172
|
+
require "json"
|
|
173
|
+
puts JSON.pretty_generate(result.to_h)
|
|
174
|
+
else
|
|
175
|
+
puts "Unknown format: #{format}"
|
|
176
|
+
end
|
|
177
|
+
puts
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Main execution
|
|
181
|
+
if __FILE__ == $PROGRAM_NAME
|
|
182
|
+
# Check if a file path was provided
|
|
183
|
+
if ARGV.empty?
|
|
184
|
+
puts "Usage: ruby #{$PROGRAM_NAME} PATH_TO_PNG_FILE [DIRECTORY_FOR_BATCH]"
|
|
185
|
+
puts
|
|
186
|
+
puts "Examples:"
|
|
187
|
+
puts " ruby #{$PROGRAM_NAME} image.png"
|
|
188
|
+
puts " ruby #{$PROGRAM_NAME} image.png ./png_directory"
|
|
189
|
+
exit 1
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
file_path = ARGV[0]
|
|
193
|
+
directory = ARGV[1]
|
|
194
|
+
|
|
195
|
+
unless File.exist?(file_path)
|
|
196
|
+
puts "Error: File not found: #{file_path}"
|
|
197
|
+
exit 1
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Run examples
|
|
201
|
+
basic_validation(file_path)
|
|
202
|
+
profile_validation(file_path, "web")
|
|
203
|
+
inspect_chunks(file_path)
|
|
204
|
+
export_results(file_path, :yaml)
|
|
205
|
+
|
|
206
|
+
# Run batch validation if directory provided
|
|
207
|
+
if directory && Dir.exist?(directory)
|
|
208
|
+
batch_validation(directory)
|
|
209
|
+
end
|
|
210
|
+
end
|
data/exe/png_conform
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PngConform
|
|
4
|
+
module Analyzers
|
|
5
|
+
# Compares two PNG files and reports differences
|
|
6
|
+
class ComparisonAnalyzer
|
|
7
|
+
# Metadata chunk types
|
|
8
|
+
METADATA_CHUNKS = %w[tEXt zTXt iTXt tIME].freeze
|
|
9
|
+
|
|
10
|
+
def initialize(result1, result2)
|
|
11
|
+
@result1 = result1
|
|
12
|
+
@result2 = result2
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def analyze
|
|
16
|
+
{
|
|
17
|
+
files: file_comparison,
|
|
18
|
+
image: image_comparison,
|
|
19
|
+
chunks: chunk_comparison,
|
|
20
|
+
validation: validation_comparison,
|
|
21
|
+
quality: quality_comparison,
|
|
22
|
+
summary: generate_summary,
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def file_comparison
|
|
29
|
+
size1 = @result1.file_size
|
|
30
|
+
size2 = @result2.file_size
|
|
31
|
+
diff = size2 - size1
|
|
32
|
+
percent = size1.zero? ? 0 : ((diff.to_f / size1) * 100).round(2)
|
|
33
|
+
|
|
34
|
+
{
|
|
35
|
+
file1: @result1.filename,
|
|
36
|
+
file2: @result2.filename,
|
|
37
|
+
size_bytes: {
|
|
38
|
+
file1: size1,
|
|
39
|
+
file2: size2,
|
|
40
|
+
difference: diff,
|
|
41
|
+
change_percent: percent,
|
|
42
|
+
},
|
|
43
|
+
size_change: format_size_change(diff, percent),
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def image_comparison
|
|
48
|
+
ihdr1 = @result1.ihdr_chunk
|
|
49
|
+
ihdr2 = @result2.ihdr_chunk
|
|
50
|
+
|
|
51
|
+
w1 = ihdr1 ? get_width(ihdr1) : 0
|
|
52
|
+
h1 = ihdr1 ? get_height(ihdr1) : 0
|
|
53
|
+
w2 = ihdr2 ? get_width(ihdr2) : 0
|
|
54
|
+
h2 = ihdr2 ? get_height(ihdr2) : 0
|
|
55
|
+
|
|
56
|
+
{
|
|
57
|
+
dimensions: {
|
|
58
|
+
file1: "#{w1}x#{h1}",
|
|
59
|
+
file2: "#{w2}x#{h2}",
|
|
60
|
+
same: w1 == w2 && h1 == h2,
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def chunk_comparison
|
|
66
|
+
chunks1 = @result1.chunks.map(&:type)
|
|
67
|
+
chunks2 = @result2.chunks.map(&:type)
|
|
68
|
+
|
|
69
|
+
{
|
|
70
|
+
count: {
|
|
71
|
+
file1: chunks1.count,
|
|
72
|
+
file2: chunks2.count,
|
|
73
|
+
difference: chunks2.count - chunks1.count,
|
|
74
|
+
},
|
|
75
|
+
added: chunks2 - chunks1,
|
|
76
|
+
removed: chunks1 - chunks2,
|
|
77
|
+
common: chunks1 & chunks2,
|
|
78
|
+
changed: detect_chunk_changes,
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def validation_comparison
|
|
83
|
+
{
|
|
84
|
+
validity: {
|
|
85
|
+
file1: @result1.valid?,
|
|
86
|
+
file2: @result2.valid?,
|
|
87
|
+
same: @result1.valid? == @result2.valid?,
|
|
88
|
+
},
|
|
89
|
+
errors: {
|
|
90
|
+
file1: @result1.error_count,
|
|
91
|
+
file2: @result2.error_count,
|
|
92
|
+
difference: @result2.error_count - @result1.error_count,
|
|
93
|
+
},
|
|
94
|
+
warnings: {
|
|
95
|
+
file1: @result1.warning_count,
|
|
96
|
+
file2: @result2.warning_count,
|
|
97
|
+
difference: @result2.warning_count - @result1.warning_count,
|
|
98
|
+
},
|
|
99
|
+
new_errors: new_errors,
|
|
100
|
+
resolved_errors: resolved_errors,
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def quality_comparison
|
|
105
|
+
{
|
|
106
|
+
compression_ratio: {
|
|
107
|
+
file1: @result1.compression_ratio,
|
|
108
|
+
file2: @result2.compression_ratio,
|
|
109
|
+
improved: compression_improved?,
|
|
110
|
+
},
|
|
111
|
+
has_color_profile: {
|
|
112
|
+
file1: has_color_profile?(@result1),
|
|
113
|
+
file2: has_color_profile?(@result2),
|
|
114
|
+
same: has_color_profile?(@result1) == has_color_profile?(@result2),
|
|
115
|
+
},
|
|
116
|
+
metadata_count: {
|
|
117
|
+
file1: metadata_count(@result1),
|
|
118
|
+
file2: metadata_count(@result2),
|
|
119
|
+
difference: metadata_count(@result2) - metadata_count(@result1),
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def detect_chunk_changes
|
|
125
|
+
common_types = @result1.chunks.map(&:type) & @result2.chunks.map(&:type)
|
|
126
|
+
changed = []
|
|
127
|
+
|
|
128
|
+
common_types.each do |type|
|
|
129
|
+
chunk1 = @result1.chunks.find { |c| c.type == type }
|
|
130
|
+
chunk2 = @result2.chunks.find { |c| c.type == type }
|
|
131
|
+
|
|
132
|
+
next unless chunk1 && chunk2
|
|
133
|
+
|
|
134
|
+
if chunk1.length != chunk2.length || chunk1.crc != chunk2.crc
|
|
135
|
+
changed << {
|
|
136
|
+
type: type,
|
|
137
|
+
size_changed: chunk1.length != chunk2.length,
|
|
138
|
+
data_changed: chunk1.crc != chunk2.crc,
|
|
139
|
+
}
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
changed
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def new_errors
|
|
147
|
+
errors1 = @result1.errors.map(&:message).to_set
|
|
148
|
+
errors2 = @result2.errors.map(&:message).to_set
|
|
149
|
+
(errors2 - errors1).to_a
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def resolved_errors
|
|
153
|
+
errors1 = @result1.errors.map(&:message).to_set
|
|
154
|
+
errors2 = @result2.errors.map(&:message).to_set
|
|
155
|
+
(errors1 - errors2).to_a
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def compression_improved?
|
|
159
|
+
return nil unless @result1.compression_ratio && @result2.compression_ratio
|
|
160
|
+
|
|
161
|
+
@result2.compression_ratio > @result1.compression_ratio
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def has_color_profile?(result)
|
|
165
|
+
result.has_chunk?("gAMA") || result.has_chunk?("sRGB") || result.has_chunk?("iCCP")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def metadata_count(result)
|
|
169
|
+
result.chunks.count { |c| METADATA_CHUNKS.include?(c.type) }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def format_size_change(diff, percent)
|
|
173
|
+
if diff.positive?
|
|
174
|
+
"Larger by #{diff} bytes (+#{percent}%)"
|
|
175
|
+
elsif diff.negative?
|
|
176
|
+
"Smaller by #{diff.abs} bytes (#{percent}%)"
|
|
177
|
+
else
|
|
178
|
+
"Same size"
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def generate_summary
|
|
183
|
+
summary = []
|
|
184
|
+
|
|
185
|
+
size_diff = @result2.file_size - @result1.file_size
|
|
186
|
+
summary << format_size_summary(size_diff) if size_diff.abs > 1024
|
|
187
|
+
|
|
188
|
+
added = chunk_comparison[:added]
|
|
189
|
+
removed = chunk_comparison[:removed]
|
|
190
|
+
summary << "Chunks added: #{added.join(', ')}" if added.any?
|
|
191
|
+
summary << "Chunks removed: #{removed.join(', ')}" if removed.any?
|
|
192
|
+
|
|
193
|
+
if @result1.valid? && !@result2.valid?
|
|
194
|
+
summary << "⚠️ File became invalid"
|
|
195
|
+
elsif !@result1.valid? && @result2.valid?
|
|
196
|
+
summary << "✓ File became valid"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
summary
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def format_size_summary(diff)
|
|
203
|
+
diff.positive? ? "File grew by #{format_bytes(diff)}" : "File reduced by #{format_bytes(diff.abs)} ✓"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def format_bytes(bytes)
|
|
207
|
+
if bytes < 1024
|
|
208
|
+
"#{bytes} bytes"
|
|
209
|
+
elsif bytes < 1024 * 1024
|
|
210
|
+
"#{(bytes / 1024.0).round(2)} KB"
|
|
211
|
+
else
|
|
212
|
+
"#{(bytes / 1024.0 / 1024.0).round(2)} MB"
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Helper methods to extract IHDR data
|
|
217
|
+
def get_width(ihdr_chunk)
|
|
218
|
+
return 0 unless ihdr_chunk.data && ihdr_chunk.data.bytesize >= 4
|
|
219
|
+
|
|
220
|
+
ihdr_chunk.data.bytes[0..3].pack("C*").unpack1("N")
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def get_height(ihdr_chunk)
|
|
224
|
+
return 0 unless ihdr_chunk.data && ihdr_chunk.data.bytesize >= 8
|
|
225
|
+
|
|
226
|
+
ihdr_chunk.data.bytes[4..7].pack("C*").unpack1("N")
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PngConform
|
|
4
|
+
module Analyzers
|
|
5
|
+
# Generates comprehensive metrics for CI/CD and automation
|
|
6
|
+
class MetricsAnalyzer
|
|
7
|
+
# Text chunk types
|
|
8
|
+
TEXT_CHUNKS = %w[tEXt zTXt iTXt].freeze
|
|
9
|
+
|
|
10
|
+
# Metadata chunk types including time
|
|
11
|
+
METADATA_CHUNKS = %w[tEXt zTXt iTXt tIME].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(result)
|
|
14
|
+
@result = result
|
|
15
|
+
ihdr = result.ihdr_chunk
|
|
16
|
+
@width = ihdr ? get_width(ihdr) : 0
|
|
17
|
+
@height = ihdr ? get_height(ihdr) : 0
|
|
18
|
+
@bit_depth = ihdr ? get_bit_depth(ihdr) : 0
|
|
19
|
+
@color_type = ihdr ? get_color_type(ihdr) : 0
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def analyze
|
|
23
|
+
{
|
|
24
|
+
file: file_metrics,
|
|
25
|
+
image: image_metrics,
|
|
26
|
+
chunks: chunk_metrics,
|
|
27
|
+
validation: validation_metrics,
|
|
28
|
+
compression: compression_metrics,
|
|
29
|
+
quality: quality_metrics,
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_json(*_args)
|
|
34
|
+
require "json"
|
|
35
|
+
JSON.pretty_generate(analyze)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_yaml
|
|
39
|
+
require "yaml"
|
|
40
|
+
analyze.to_yaml
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# Extract data from IHDR chunk
|
|
46
|
+
def get_width(ihdr_chunk)
|
|
47
|
+
return 0 unless ihdr_chunk.data && ihdr_chunk.data.bytesize >= 4
|
|
48
|
+
|
|
49
|
+
ihdr_chunk.data.bytes[0..3].pack("C*").unpack1("N")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def get_height(ihdr_chunk)
|
|
53
|
+
return 0 unless ihdr_chunk.data && ihdr_chunk.data.bytesize >= 8
|
|
54
|
+
|
|
55
|
+
ihdr_chunk.data.bytes[4..7].pack("C*").unpack1("N")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def get_bit_depth(ihdr_chunk)
|
|
59
|
+
return 0 unless ihdr_chunk.data && ihdr_chunk.data.bytesize >= 9
|
|
60
|
+
|
|
61
|
+
ihdr_chunk.data.bytes[8]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def get_color_type(ihdr_chunk)
|
|
65
|
+
return 0 unless ihdr_chunk.data && ihdr_chunk.data.bytesize >= 10
|
|
66
|
+
|
|
67
|
+
ihdr_chunk.data.bytes[9]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def color_type_name
|
|
71
|
+
case @color_type
|
|
72
|
+
when 0 then "Grayscale"
|
|
73
|
+
when 2 then "RGB"
|
|
74
|
+
when 3 then "Indexed"
|
|
75
|
+
when 4 then "Grayscale+Alpha"
|
|
76
|
+
when 6 then "RGBA"
|
|
77
|
+
else "Unknown"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def file_metrics
|
|
82
|
+
{
|
|
83
|
+
filename: @result.filename,
|
|
84
|
+
size_bytes: @result.file_size,
|
|
85
|
+
size_kb: (@result.file_size / 1024.0).round(2),
|
|
86
|
+
size_mb: (@result.file_size / 1024.0 / 1024.0).round(4),
|
|
87
|
+
file_type: @result.file_type,
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def image_metrics
|
|
92
|
+
{
|
|
93
|
+
width: @width,
|
|
94
|
+
height: @height,
|
|
95
|
+
dimensions: "#{@width}x#{@height}",
|
|
96
|
+
total_pixels: @width * @height,
|
|
97
|
+
megapixels: (@width * @height / 1_000_000.0).round(2),
|
|
98
|
+
bit_depth: @bit_depth,
|
|
99
|
+
color_type: @color_type,
|
|
100
|
+
color_type_name: color_type_name,
|
|
101
|
+
has_alpha: [4, 6].include?(@color_type),
|
|
102
|
+
has_palette: @color_type == 3,
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def chunk_metrics
|
|
107
|
+
{
|
|
108
|
+
total_count: @result.chunks.count,
|
|
109
|
+
types: @result.chunks.map(&:type).uniq.sort,
|
|
110
|
+
type_counts: @result.chunks.group_by(&:type).transform_values(&:count),
|
|
111
|
+
critical_chunks: @result.chunks.select(&:critical?).map(&:type),
|
|
112
|
+
ancillary_chunks: @result.chunks.reject(&:critical?).map(&:type),
|
|
113
|
+
total_chunk_data_bytes: @result.chunks.sum(&:length),
|
|
114
|
+
total_chunk_overhead_bytes: @result.chunks.count * 12,
|
|
115
|
+
chunk_data_percentage: calculate_chunk_data_percentage,
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def validation_metrics
|
|
120
|
+
{
|
|
121
|
+
valid: @result.valid?,
|
|
122
|
+
error_count: @result.error_count,
|
|
123
|
+
warning_count: @result.warning_count,
|
|
124
|
+
info_count: @result.info_count,
|
|
125
|
+
errors_by_severity: {
|
|
126
|
+
error: @result.error_count,
|
|
127
|
+
warning: @result.warning_count,
|
|
128
|
+
info: @result.info_count,
|
|
129
|
+
},
|
|
130
|
+
crc_errors: @result.crc_errors_count,
|
|
131
|
+
has_errors: @result.error_count.positive?,
|
|
132
|
+
has_warnings: @result.warning_count.positive?,
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def compression_metrics
|
|
137
|
+
{
|
|
138
|
+
compression_ratio: @result.compression_ratio,
|
|
139
|
+
}
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def quality_metrics
|
|
143
|
+
{
|
|
144
|
+
has_color_profile: @result.has_chunk?("gAMA") ||
|
|
145
|
+
@result.has_chunk?("sRGB") ||
|
|
146
|
+
@result.has_chunk?("iCCP"),
|
|
147
|
+
has_gamma: @result.has_chunk?("gAMA"),
|
|
148
|
+
has_srgb: @result.has_chunk?("sRGB"),
|
|
149
|
+
has_iccp: @result.has_chunk?("iCCP"),
|
|
150
|
+
has_transparency: @result.has_chunk?("tRNS"),
|
|
151
|
+
has_metadata: @result.chunks.any? do |c|
|
|
152
|
+
TEXT_CHUNKS.include?(c.type)
|
|
153
|
+
end,
|
|
154
|
+
metadata_chunks_count: @result.chunks.count do |c|
|
|
155
|
+
METADATA_CHUNKS.include?(c.type)
|
|
156
|
+
end,
|
|
157
|
+
bytes_per_pixel: calculate_bytes_per_pixel,
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def calculate_chunk_data_percentage
|
|
162
|
+
return 0 if @result.file_size.zero?
|
|
163
|
+
|
|
164
|
+
total_chunk_bytes = @result.chunks.sum(&:length) + (@result.chunks.count * 12)
|
|
165
|
+
(total_chunk_bytes.to_f / @result.file_size * 100).round(2)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def calculate_bytes_per_pixel
|
|
169
|
+
total_pixels = @width * @height
|
|
170
|
+
return 0 if total_pixels.zero?
|
|
171
|
+
|
|
172
|
+
(@result.file_size.to_f / total_pixels).round(3)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|