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.
Files changed (108) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +19 -0
  4. data/.rubocop_todo.yml +197 -0
  5. data/CODE_OF_CONDUCT.md +84 -0
  6. data/CONTRIBUTING.md +323 -0
  7. data/Gemfile +13 -0
  8. data/LICENSE +43 -0
  9. data/README.adoc +859 -0
  10. data/Rakefile +10 -0
  11. data/SECURITY.md +147 -0
  12. data/docs/ARCHITECTURE.adoc +681 -0
  13. data/docs/CHUNK_TYPES.adoc +450 -0
  14. data/docs/CLI_OPTIONS.adoc +913 -0
  15. data/docs/COMPATIBILITY.adoc +616 -0
  16. data/examples/README.adoc +398 -0
  17. data/examples/advanced_usage.rb +304 -0
  18. data/examples/basic_usage.rb +210 -0
  19. data/exe/png_conform +6 -0
  20. data/lib/png_conform/analyzers/comparison_analyzer.rb +230 -0
  21. data/lib/png_conform/analyzers/metrics_analyzer.rb +176 -0
  22. data/lib/png_conform/analyzers/optimization_analyzer.rb +190 -0
  23. data/lib/png_conform/analyzers/resolution_analyzer.rb +274 -0
  24. data/lib/png_conform/bindata/chunk_structure.rb +153 -0
  25. data/lib/png_conform/bindata/jng_file.rb +79 -0
  26. data/lib/png_conform/bindata/mng_file.rb +97 -0
  27. data/lib/png_conform/bindata/png_file.rb +162 -0
  28. data/lib/png_conform/cli.rb +116 -0
  29. data/lib/png_conform/commands/check_command.rb +323 -0
  30. data/lib/png_conform/commands/list_command.rb +67 -0
  31. data/lib/png_conform/models/chunk.rb +84 -0
  32. data/lib/png_conform/models/chunk_info.rb +71 -0
  33. data/lib/png_conform/models/compression_info.rb +49 -0
  34. data/lib/png_conform/models/decoded_chunk_data.rb +143 -0
  35. data/lib/png_conform/models/file_analysis.rb +181 -0
  36. data/lib/png_conform/models/file_info.rb +91 -0
  37. data/lib/png_conform/models/image_info.rb +52 -0
  38. data/lib/png_conform/models/validation_error.rb +89 -0
  39. data/lib/png_conform/models/validation_result.rb +137 -0
  40. data/lib/png_conform/readers/full_load_reader.rb +113 -0
  41. data/lib/png_conform/readers/streaming_reader.rb +180 -0
  42. data/lib/png_conform/reporters/base_reporter.rb +53 -0
  43. data/lib/png_conform/reporters/color_reporter.rb +65 -0
  44. data/lib/png_conform/reporters/json_reporter.rb +18 -0
  45. data/lib/png_conform/reporters/palette_reporter.rb +48 -0
  46. data/lib/png_conform/reporters/quiet_reporter.rb +18 -0
  47. data/lib/png_conform/reporters/reporter_factory.rb +108 -0
  48. data/lib/png_conform/reporters/summary_reporter.rb +65 -0
  49. data/lib/png_conform/reporters/text_reporter.rb +66 -0
  50. data/lib/png_conform/reporters/verbose_reporter.rb +87 -0
  51. data/lib/png_conform/reporters/very_verbose_reporter.rb +33 -0
  52. data/lib/png_conform/reporters/visual_elements.rb +66 -0
  53. data/lib/png_conform/reporters/yaml_reporter.rb +18 -0
  54. data/lib/png_conform/services/profile_manager.rb +242 -0
  55. data/lib/png_conform/services/validation_service.rb +457 -0
  56. data/lib/png_conform/services/zlib_validator.rb +270 -0
  57. data/lib/png_conform/validators/ancillary/bkgd_validator.rb +140 -0
  58. data/lib/png_conform/validators/ancillary/chrm_validator.rb +178 -0
  59. data/lib/png_conform/validators/ancillary/cicp_validator.rb +202 -0
  60. data/lib/png_conform/validators/ancillary/gama_validator.rb +105 -0
  61. data/lib/png_conform/validators/ancillary/hist_validator.rb +147 -0
  62. data/lib/png_conform/validators/ancillary/iccp_validator.rb +243 -0
  63. data/lib/png_conform/validators/ancillary/itxt_validator.rb +280 -0
  64. data/lib/png_conform/validators/ancillary/mdcv_validator.rb +201 -0
  65. data/lib/png_conform/validators/ancillary/offs_validator.rb +132 -0
  66. data/lib/png_conform/validators/ancillary/pcal_validator.rb +289 -0
  67. data/lib/png_conform/validators/ancillary/phys_validator.rb +107 -0
  68. data/lib/png_conform/validators/ancillary/sbit_validator.rb +176 -0
  69. data/lib/png_conform/validators/ancillary/scal_validator.rb +180 -0
  70. data/lib/png_conform/validators/ancillary/splt_validator.rb +223 -0
  71. data/lib/png_conform/validators/ancillary/srgb_validator.rb +117 -0
  72. data/lib/png_conform/validators/ancillary/ster_validator.rb +111 -0
  73. data/lib/png_conform/validators/ancillary/text_validator.rb +129 -0
  74. data/lib/png_conform/validators/ancillary/time_validator.rb +132 -0
  75. data/lib/png_conform/validators/ancillary/trns_validator.rb +154 -0
  76. data/lib/png_conform/validators/ancillary/ztxt_validator.rb +173 -0
  77. data/lib/png_conform/validators/apng/actl_validator.rb +81 -0
  78. data/lib/png_conform/validators/apng/fctl_validator.rb +155 -0
  79. data/lib/png_conform/validators/apng/fdat_validator.rb +117 -0
  80. data/lib/png_conform/validators/base_validator.rb +241 -0
  81. data/lib/png_conform/validators/chunk_registry.rb +219 -0
  82. data/lib/png_conform/validators/critical/idat_validator.rb +77 -0
  83. data/lib/png_conform/validators/critical/iend_validator.rb +68 -0
  84. data/lib/png_conform/validators/critical/ihdr_validator.rb +160 -0
  85. data/lib/png_conform/validators/critical/plte_validator.rb +120 -0
  86. data/lib/png_conform/validators/jng/jdat_validator.rb +66 -0
  87. data/lib/png_conform/validators/jng/jhdr_validator.rb +116 -0
  88. data/lib/png_conform/validators/jng/jsep_validator.rb +66 -0
  89. data/lib/png_conform/validators/mng/back_validator.rb +87 -0
  90. data/lib/png_conform/validators/mng/clip_validator.rb +65 -0
  91. data/lib/png_conform/validators/mng/clon_validator.rb +45 -0
  92. data/lib/png_conform/validators/mng/defi_validator.rb +104 -0
  93. data/lib/png_conform/validators/mng/dhdr_validator.rb +104 -0
  94. data/lib/png_conform/validators/mng/disc_validator.rb +44 -0
  95. data/lib/png_conform/validators/mng/endl_validator.rb +65 -0
  96. data/lib/png_conform/validators/mng/fram_validator.rb +91 -0
  97. data/lib/png_conform/validators/mng/loop_validator.rb +75 -0
  98. data/lib/png_conform/validators/mng/mend_validator.rb +31 -0
  99. data/lib/png_conform/validators/mng/mhdr_validator.rb +69 -0
  100. data/lib/png_conform/validators/mng/move_validator.rb +61 -0
  101. data/lib/png_conform/validators/mng/save_validator.rb +39 -0
  102. data/lib/png_conform/validators/mng/seek_validator.rb +42 -0
  103. data/lib/png_conform/validators/mng/show_validator.rb +52 -0
  104. data/lib/png_conform/validators/mng/term_validator.rb +84 -0
  105. data/lib/png_conform/version.rb +5 -0
  106. data/lib/png_conform.rb +101 -0
  107. data/png_conform.gemspec +43 -0
  108. 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