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,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PngConform
|
|
4
|
+
module Services
|
|
5
|
+
# Profile manager for PNG validation profiles
|
|
6
|
+
#
|
|
7
|
+
# Manages validation profiles that define which chunks are required,
|
|
8
|
+
# optional, or prohibited for different PNG use cases.
|
|
9
|
+
#
|
|
10
|
+
# Profiles can be used to validate PNG files against specific standards:
|
|
11
|
+
# - Web: Optimized for web browsers
|
|
12
|
+
# - Print: High quality for printing
|
|
13
|
+
# - Archive: Long-term preservation
|
|
14
|
+
# - Minimal: Minimal valid PNG
|
|
15
|
+
# - Strict: Full PNG specification compliance
|
|
16
|
+
#
|
|
17
|
+
class ProfileManager
|
|
18
|
+
# Built-in validation profiles
|
|
19
|
+
PROFILES = {
|
|
20
|
+
# Minimal valid PNG - only critical chunks
|
|
21
|
+
minimal: {
|
|
22
|
+
name: "Minimal",
|
|
23
|
+
description: "Minimal valid PNG with only critical chunks",
|
|
24
|
+
required_chunks: %w[IHDR IDAT IEND],
|
|
25
|
+
optional_chunks: [],
|
|
26
|
+
prohibited_chunks: [],
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
# Web-optimized PNG
|
|
30
|
+
web: {
|
|
31
|
+
name: "Web",
|
|
32
|
+
description: "Optimized for web browsers",
|
|
33
|
+
required_chunks: %w[IHDR IDAT IEND],
|
|
34
|
+
optional_chunks: %w[
|
|
35
|
+
gAMA sRGB pHYs tEXt zTXt iTXt tIME
|
|
36
|
+
bKGD tRNS PLTE
|
|
37
|
+
],
|
|
38
|
+
prohibited_chunks: %w[iCCP cHRM sBIT],
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
# Print-quality PNG
|
|
42
|
+
print: {
|
|
43
|
+
name: "Print",
|
|
44
|
+
description: "High quality for printing",
|
|
45
|
+
required_chunks: %w[IHDR IDAT IEND pHYs],
|
|
46
|
+
optional_chunks: %w[
|
|
47
|
+
gAMA sRGB cHRM iCCP sBIT
|
|
48
|
+
tEXt zTXt iTXt tIME
|
|
49
|
+
bKGD tRNS PLTE
|
|
50
|
+
],
|
|
51
|
+
prohibited_chunks: [],
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
# Archive PNG - maximum metadata
|
|
55
|
+
archive: {
|
|
56
|
+
name: "Archive",
|
|
57
|
+
description: "Long-term preservation with full metadata",
|
|
58
|
+
required_chunks: %w[IHDR IDAT IEND tIME],
|
|
59
|
+
optional_chunks: %w[
|
|
60
|
+
gAMA sRGB cHRM iCCP sBIT
|
|
61
|
+
pHYs tEXt zTXt iTXt
|
|
62
|
+
bKGD tRNS hIST sPLT PLTE
|
|
63
|
+
oFFs pCAL sCAL sTER
|
|
64
|
+
],
|
|
65
|
+
prohibited_chunks: [],
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
# Strict PNG specification compliance
|
|
69
|
+
strict: {
|
|
70
|
+
name: "Strict",
|
|
71
|
+
description: "Full PNG specification compliance",
|
|
72
|
+
required_chunks: %w[IHDR IDAT IEND],
|
|
73
|
+
optional_chunks: %w[
|
|
74
|
+
PLTE
|
|
75
|
+
gAMA sRGB cHRM iCCP sBIT cICP mDCv
|
|
76
|
+
pHYs tEXt zTXt iTXt tIME
|
|
77
|
+
bKGD tRNS hIST sPLT
|
|
78
|
+
oFFs pCAL sCAL sTER
|
|
79
|
+
],
|
|
80
|
+
prohibited_chunks: [],
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
# Default profile - permissive
|
|
84
|
+
default: {
|
|
85
|
+
name: "Default",
|
|
86
|
+
description: "Permissive validation (all standard chunks allowed)",
|
|
87
|
+
required_chunks: %w[IHDR IDAT IEND],
|
|
88
|
+
optional_chunks: %w[
|
|
89
|
+
PLTE
|
|
90
|
+
gAMA sRGB cHRM iCCP sBIT cICP mDCv
|
|
91
|
+
pHYs tEXt zTXt iTXt tIME
|
|
92
|
+
bKGD tRNS hIST sPLT
|
|
93
|
+
oFFs pCAL sCAL sTER
|
|
94
|
+
],
|
|
95
|
+
prohibited_chunks: [],
|
|
96
|
+
},
|
|
97
|
+
}.freeze
|
|
98
|
+
|
|
99
|
+
class << self
|
|
100
|
+
# Get profile by name
|
|
101
|
+
#
|
|
102
|
+
# @param name [Symbol, String] Profile name
|
|
103
|
+
# @return [Hash, nil] Profile configuration or nil if not found
|
|
104
|
+
def get_profile(name)
|
|
105
|
+
PROFILES[name.to_sym]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Check if a profile exists
|
|
109
|
+
#
|
|
110
|
+
# @param name [Symbol, String] Profile name
|
|
111
|
+
# @return [Boolean] True if profile exists
|
|
112
|
+
def profile_exists?(name)
|
|
113
|
+
PROFILES.key?(name.to_sym)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Get all available profile names
|
|
117
|
+
#
|
|
118
|
+
# @return [Array<Symbol>] List of profile names
|
|
119
|
+
def available_profiles
|
|
120
|
+
PROFILES.keys
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Get profile information
|
|
124
|
+
#
|
|
125
|
+
# @param name [Symbol, String] Profile name
|
|
126
|
+
# @return [Hash] Profile name and description
|
|
127
|
+
def profile_info(name)
|
|
128
|
+
profile = get_profile(name)
|
|
129
|
+
return nil unless profile
|
|
130
|
+
|
|
131
|
+
{
|
|
132
|
+
name: profile[:name],
|
|
133
|
+
description: profile[:description],
|
|
134
|
+
}
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Validate chunk against profile
|
|
138
|
+
#
|
|
139
|
+
# @param chunk_type [String] Chunk type code
|
|
140
|
+
# @param profile_name [Symbol, String] Profile name
|
|
141
|
+
# @return [Hash] Validation result with status and message
|
|
142
|
+
def validate_chunk_against_profile(chunk_type, profile_name)
|
|
143
|
+
profile = get_profile(profile_name)
|
|
144
|
+
return error_result("Unknown profile: #{profile_name}") unless profile
|
|
145
|
+
|
|
146
|
+
if profile[:prohibited_chunks].include?(chunk_type)
|
|
147
|
+
error_result(
|
|
148
|
+
"#{chunk_type} chunk prohibited in #{profile[:name]} profile",
|
|
149
|
+
)
|
|
150
|
+
elsif profile[:required_chunks].include?(chunk_type)
|
|
151
|
+
success_result("#{chunk_type} chunk required and present")
|
|
152
|
+
elsif profile[:optional_chunks].include?(chunk_type)
|
|
153
|
+
success_result("#{chunk_type} chunk optional and present")
|
|
154
|
+
else
|
|
155
|
+
warning_result(
|
|
156
|
+
"#{chunk_type} chunk not defined in #{profile[:name]} profile",
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Check required chunks for profile
|
|
162
|
+
#
|
|
163
|
+
# @param present_chunks [Array<String>] List of chunk types in file
|
|
164
|
+
# @param profile_name [Symbol, String] Profile name
|
|
165
|
+
# @return [Array<String>] List of missing required chunks
|
|
166
|
+
def check_required_chunks(present_chunks, profile_name)
|
|
167
|
+
profile = get_profile(profile_name)
|
|
168
|
+
return [] unless profile
|
|
169
|
+
|
|
170
|
+
profile[:required_chunks] - present_chunks
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Check prohibited chunks for profile
|
|
174
|
+
#
|
|
175
|
+
# @param present_chunks [Array<String>] List of chunk types in file
|
|
176
|
+
# @param profile_name [Symbol, String] Profile name
|
|
177
|
+
# @return [Array<String>] List of prohibited chunks present
|
|
178
|
+
def check_prohibited_chunks(present_chunks, profile_name)
|
|
179
|
+
profile = get_profile(profile_name)
|
|
180
|
+
return [] unless profile
|
|
181
|
+
|
|
182
|
+
present_chunks & profile[:prohibited_chunks]
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Validate file chunks against profile
|
|
186
|
+
#
|
|
187
|
+
# @param chunks [Array<String>] List of chunk types in file
|
|
188
|
+
# @param profile_name [Symbol, String] Profile name
|
|
189
|
+
# @return [Hash] Validation results with errors and warnings
|
|
190
|
+
def validate_file_against_profile(chunks, profile_name)
|
|
191
|
+
results = {
|
|
192
|
+
errors: [],
|
|
193
|
+
warnings: [],
|
|
194
|
+
valid: true,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
# Check for missing required chunks
|
|
198
|
+
missing = check_required_chunks(chunks, profile_name)
|
|
199
|
+
missing.each do |chunk_type|
|
|
200
|
+
results[:errors] << "Missing required chunk: #{chunk_type}"
|
|
201
|
+
results[:valid] = false
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Check for prohibited chunks
|
|
205
|
+
prohibited = check_prohibited_chunks(chunks, profile_name)
|
|
206
|
+
prohibited.each do |chunk_type|
|
|
207
|
+
results[:errors] << "Prohibited chunk present: #{chunk_type}"
|
|
208
|
+
results[:valid] = false
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
results
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
private
|
|
215
|
+
|
|
216
|
+
# Create success result
|
|
217
|
+
#
|
|
218
|
+
# @param message [String] Success message
|
|
219
|
+
# @return [Hash] Result hash
|
|
220
|
+
def success_result(message)
|
|
221
|
+
{ status: :success, message: message }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Create warning result
|
|
225
|
+
#
|
|
226
|
+
# @param message [String] Warning message
|
|
227
|
+
# @return [Hash] Result hash
|
|
228
|
+
def warning_result(message)
|
|
229
|
+
{ status: :warning, message: message }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Create error result
|
|
233
|
+
#
|
|
234
|
+
# @param message [String] Error message
|
|
235
|
+
# @return [Hash] Result hash
|
|
236
|
+
def error_result(message)
|
|
237
|
+
{ status: :error, message: message }
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../validators/chunk_registry"
|
|
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"
|
|
11
|
+
|
|
12
|
+
module PngConform
|
|
13
|
+
module Services
|
|
14
|
+
# Main validation orchestration service
|
|
15
|
+
#
|
|
16
|
+
# This service coordinates the validation of PNG files by:
|
|
17
|
+
# 1. Reading chunks from the file
|
|
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
|
|
24
|
+
#
|
|
25
|
+
class ValidationService
|
|
26
|
+
attr_reader :reader, :context, :results, :chunks
|
|
27
|
+
|
|
28
|
+
# Convenience method to validate a file by path
|
|
29
|
+
#
|
|
30
|
+
# @param filepath [String] Path to PNG file
|
|
31
|
+
# @return [ValidationResult] Validation results
|
|
32
|
+
def self.validate_file(filepath)
|
|
33
|
+
require_relative "../readers/full_load_reader"
|
|
34
|
+
reader = Readers::FullLoadReader.new(filepath)
|
|
35
|
+
service = new(reader, filepath)
|
|
36
|
+
service.validate
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Initialize validation service
|
|
40
|
+
#
|
|
41
|
+
# @param reader [Object] File reader (StreamingReader or FullLoadReader)
|
|
42
|
+
# @param filepath [String, nil] Optional file path (for reporting)
|
|
43
|
+
def initialize(reader, filepath = nil)
|
|
44
|
+
@reader = reader
|
|
45
|
+
@filepath = filepath
|
|
46
|
+
@context = Validators::ValidationContext.new
|
|
47
|
+
@results = []
|
|
48
|
+
@chunks = [] # Store chunks as we read them
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Validate the PNG file
|
|
52
|
+
#
|
|
53
|
+
# This is the main entry point for validation. It processes all chunks
|
|
54
|
+
# in order, validates them, and collects the results.
|
|
55
|
+
#
|
|
56
|
+
# @return [FileAnalysis] Complete file analysis with all data
|
|
57
|
+
def validate
|
|
58
|
+
validate_signature
|
|
59
|
+
validate_chunks
|
|
60
|
+
validate_chunk_sequence
|
|
61
|
+
build_file_analysis
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# 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
|
|
78
|
+
|
|
79
|
+
# 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
|
|
94
|
+
|
|
95
|
+
# Validate a single chunk
|
|
96
|
+
#
|
|
97
|
+
# @param chunk [Object] Chunk to validate
|
|
98
|
+
# @return [void]
|
|
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
|
|
117
|
+
|
|
118
|
+
# Handle unknown chunk types
|
|
119
|
+
#
|
|
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
|
+
# @param chunk [Object] Unknown chunk
|
|
125
|
+
# @return [void]
|
|
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
|
|
140
|
+
|
|
141
|
+
# 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
|
|
154
|
+
|
|
155
|
+
# 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
|
|
163
|
+
|
|
164
|
+
# 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
|
|
172
|
+
|
|
173
|
+
# 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
|
|
181
|
+
|
|
182
|
+
# Build complete FileAnalysis with validation results and analyzer data
|
|
183
|
+
#
|
|
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
|
+
# @return [FileAnalysis] Complete analysis model
|
|
191
|
+
def build_file_analysis
|
|
192
|
+
# First build the ValidationResult (legacy structure)
|
|
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
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Build ValidationResult (original method renamed)
|
|
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
|
|
287
|
+
|
|
288
|
+
private
|
|
289
|
+
|
|
290
|
+
# Add an error to results
|
|
291
|
+
#
|
|
292
|
+
# @param message [String] Error message
|
|
293
|
+
# @return [void]
|
|
294
|
+
def add_error(message)
|
|
295
|
+
@results << { type: :error, message: message }
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Add a warning to results
|
|
299
|
+
#
|
|
300
|
+
# @param message [String] Warning message
|
|
301
|
+
# @return [void]
|
|
302
|
+
def add_warning(message)
|
|
303
|
+
@results << { type: :warning, message: message }
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Add info to results
|
|
307
|
+
#
|
|
308
|
+
# @param message [String] Info message
|
|
309
|
+
# @return [void]
|
|
310
|
+
def add_info(message)
|
|
311
|
+
@results << { type: :info, message: message }
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Merge results from a validator
|
|
315
|
+
#
|
|
316
|
+
# @param errors [Array<String>] Error messages
|
|
317
|
+
# @param warnings [Array<String>] Warning messages
|
|
318
|
+
# @param info [Array<String>] Info messages
|
|
319
|
+
# @return [void]
|
|
320
|
+
def merge_results(errors, warnings, info)
|
|
321
|
+
errors.each { |msg| add_error(msg) }
|
|
322
|
+
warnings.each { |msg| add_warning(msg) }
|
|
323
|
+
info.each { |msg| add_info(msg) }
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Determine file type based on chunks
|
|
327
|
+
#
|
|
328
|
+
# @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
|
|
336
|
+
|
|
337
|
+
# Calculate CRC32 for a chunk
|
|
338
|
+
#
|
|
339
|
+
# @param chunk [Object] BinData chunk
|
|
340
|
+
# @return [Integer] CRC32 value
|
|
341
|
+
def calculate_crc(chunk)
|
|
342
|
+
require "zlib"
|
|
343
|
+
# CRC is calculated over chunk type + chunk data
|
|
344
|
+
Zlib.crc32(chunk.chunk_type.to_s + chunk.data.to_s)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Format integer as hex string
|
|
348
|
+
#
|
|
349
|
+
# @param value [Integer] Value to format
|
|
350
|
+
# @return [String] Hex string (e.g., "0x12345678")
|
|
351
|
+
def format_hex(value)
|
|
352
|
+
format("0x%08x", value)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Calculate compression ratio for PNG
|
|
356
|
+
#
|
|
357
|
+
# @param chunks [Array<Chunk>] All chunks
|
|
358
|
+
# @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
|
|
389
|
+
|
|
390
|
+
# 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
|
|
410
|
+
|
|
411
|
+
# 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
|
|
422
|
+
|
|
423
|
+
# 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
|
|
429
|
+
|
|
430
|
+
# 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
|
|
436
|
+
|
|
437
|
+
# 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
|
|
443
|
+
|
|
444
|
+
# Helper to convert color type code to name
|
|
445
|
+
def color_type_name(code)
|
|
446
|
+
case code
|
|
447
|
+
when 0 then "grayscale"
|
|
448
|
+
when 2 then "truecolor"
|
|
449
|
+
when 3 then "palette"
|
|
450
|
+
when 4 then "grayscale+alpha"
|
|
451
|
+
when 6 then "truecolor+alpha"
|
|
452
|
+
else "unknown"
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
end
|