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,270 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zlib"
|
|
4
|
+
|
|
5
|
+
module PngConform
|
|
6
|
+
module Services
|
|
7
|
+
# Service for validating zlib compressed data streams
|
|
8
|
+
#
|
|
9
|
+
# This service handles:
|
|
10
|
+
# - zlib stream decompression
|
|
11
|
+
# - Filter type validation
|
|
12
|
+
# - Scanline reconstruction
|
|
13
|
+
# - Interlacing support (Adam7)
|
|
14
|
+
# - Data integrity verification
|
|
15
|
+
#
|
|
16
|
+
# PNG uses zlib compression (RFC 1950) with deflate algorithm (RFC 1951)
|
|
17
|
+
# for IDAT chunks. The decompressed data consists of filtered scanlines
|
|
18
|
+
# that must be unfiltered to reconstruct the original image data.
|
|
19
|
+
#
|
|
20
|
+
class ZlibValidator
|
|
21
|
+
# Filter types defined in PNG specification
|
|
22
|
+
FILTER_NONE = 0
|
|
23
|
+
FILTER_SUB = 1
|
|
24
|
+
FILTER_UP = 2
|
|
25
|
+
FILTER_AVERAGE = 3
|
|
26
|
+
FILTER_PAETH = 4
|
|
27
|
+
|
|
28
|
+
VALID_FILTERS = [FILTER_NONE, FILTER_SUB, FILTER_UP,
|
|
29
|
+
FILTER_AVERAGE, FILTER_PAETH].freeze
|
|
30
|
+
|
|
31
|
+
attr_reader :errors, :warnings, :decompressed_data
|
|
32
|
+
|
|
33
|
+
def initialize
|
|
34
|
+
@errors = []
|
|
35
|
+
@warnings = []
|
|
36
|
+
@decompressed_data = nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Decompress and validate zlib compressed data
|
|
40
|
+
#
|
|
41
|
+
# @param compressed_data [String] Binary compressed data
|
|
42
|
+
# @param expected_size [Integer, nil] Expected decompressed size
|
|
43
|
+
# @return [Boolean] True if validation passed
|
|
44
|
+
def decompress_and_validate(compressed_data, expected_size: nil)
|
|
45
|
+
return false if compressed_data.nil? || compressed_data.empty?
|
|
46
|
+
|
|
47
|
+
begin
|
|
48
|
+
# Decompress using zlib
|
|
49
|
+
@decompressed_data = Zlib::Inflate.inflate(compressed_data)
|
|
50
|
+
|
|
51
|
+
# Validate decompressed size if expected size provided
|
|
52
|
+
if expected_size && @decompressed_data.bytesize != expected_size
|
|
53
|
+
add_error("Decompressed size mismatch " \
|
|
54
|
+
"(expected #{expected_size}, got #{@decompressed_data.bytesize})")
|
|
55
|
+
return false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
true
|
|
59
|
+
rescue Zlib::Error => e
|
|
60
|
+
add_error("zlib decompression error: #{e.message}")
|
|
61
|
+
false
|
|
62
|
+
rescue StandardError => e
|
|
63
|
+
add_error("Unexpected error during decompression: #{e.message}")
|
|
64
|
+
false
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Validate filter bytes in scanlines
|
|
69
|
+
#
|
|
70
|
+
# @param width [Integer] Image width in pixels
|
|
71
|
+
# @param height [Integer] Image height in pixels
|
|
72
|
+
# @param bit_depth [Integer] Bits per sample
|
|
73
|
+
# @param color_type [Integer] PNG color type
|
|
74
|
+
# @param interlace_method [Integer] Interlace method (0 or 1)
|
|
75
|
+
# @return [Boolean] True if all filters are valid
|
|
76
|
+
def validate_filters(width, height, bit_depth, color_type,
|
|
77
|
+
interlace_method)
|
|
78
|
+
return false unless @decompressed_data
|
|
79
|
+
|
|
80
|
+
bytes_per_pixel = calculate_bytes_per_pixel(bit_depth, color_type)
|
|
81
|
+
|
|
82
|
+
if interlace_method.zero?
|
|
83
|
+
validate_non_interlaced_filters(width, height, bytes_per_pixel)
|
|
84
|
+
elsif interlace_method == 1
|
|
85
|
+
validate_adam7_filters(width, height, bytes_per_pixel)
|
|
86
|
+
else
|
|
87
|
+
add_error("Invalid interlace method: #{interlace_method}")
|
|
88
|
+
false
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Check if decompressed data size matches expected size
|
|
93
|
+
#
|
|
94
|
+
# @param width [Integer] Image width
|
|
95
|
+
# @param height [Integer] Image height
|
|
96
|
+
# @param bit_depth [Integer] Bits per sample
|
|
97
|
+
# @param color_type [Integer] PNG color type
|
|
98
|
+
# @param interlace_method [Integer] Interlace method
|
|
99
|
+
# @return [Boolean] True if size matches
|
|
100
|
+
def validate_data_size(width, height, bit_depth, color_type,
|
|
101
|
+
interlace_method)
|
|
102
|
+
return false unless @decompressed_data
|
|
103
|
+
|
|
104
|
+
expected = calculate_expected_size(width, height, bit_depth,
|
|
105
|
+
color_type, interlace_method)
|
|
106
|
+
actual = @decompressed_data.bytesize
|
|
107
|
+
|
|
108
|
+
if actual != expected
|
|
109
|
+
add_error("Decompressed data size mismatch " \
|
|
110
|
+
"(expected #{expected} bytes, got #{actual} bytes)")
|
|
111
|
+
return false
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
true
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Get validation result
|
|
118
|
+
#
|
|
119
|
+
# @return [Hash] Validation result with status and messages
|
|
120
|
+
def result
|
|
121
|
+
{
|
|
122
|
+
valid: @errors.empty?,
|
|
123
|
+
errors: @errors,
|
|
124
|
+
warnings: @warnings,
|
|
125
|
+
decompressed_size: @decompressed_data&.bytesize,
|
|
126
|
+
}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def add_error(message)
|
|
132
|
+
@errors << message
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def add_warning(message)
|
|
136
|
+
@warnings << message
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Calculate bytes per pixel
|
|
140
|
+
def calculate_bytes_per_pixel(bit_depth, color_type)
|
|
141
|
+
samples_per_pixel = case color_type
|
|
142
|
+
when 0 then 1 # Grayscale
|
|
143
|
+
when 2 then 3 # RGB
|
|
144
|
+
when 3 then 1 # Indexed
|
|
145
|
+
when 4 then 2 # Grayscale + Alpha
|
|
146
|
+
when 6 then 4 # RGB + Alpha
|
|
147
|
+
else 0
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Bytes per pixel (rounded up)
|
|
151
|
+
((samples_per_pixel * bit_depth + 7) / 8.0).ceil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Calculate expected decompressed data size
|
|
155
|
+
def calculate_expected_size(width, height, bit_depth, color_type,
|
|
156
|
+
interlace_method)
|
|
157
|
+
bytes_per_pixel = calculate_bytes_per_pixel(bit_depth, color_type)
|
|
158
|
+
|
|
159
|
+
if interlace_method.zero?
|
|
160
|
+
# Non-interlaced: (scanline_width + 1 filter byte) * height
|
|
161
|
+
scanline_width = ((width * bit_depth * samples_for_color_type(color_type) + 7) / 8.0).ceil
|
|
162
|
+
(scanline_width + 1) * height
|
|
163
|
+
else
|
|
164
|
+
# Adam7 interlaced: calculate for all 7 passes
|
|
165
|
+
calculate_adam7_size(width, height, bytes_per_pixel)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def samples_for_color_type(color_type)
|
|
170
|
+
case color_type
|
|
171
|
+
when 0 then 1 # Grayscale
|
|
172
|
+
when 2 then 3 # RGB
|
|
173
|
+
when 3 then 1 # Indexed
|
|
174
|
+
when 4 then 2 # Grayscale + Alpha
|
|
175
|
+
when 6 then 4 # RGB + Alpha
|
|
176
|
+
else 0
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Validate filters for non-interlaced image
|
|
181
|
+
def validate_non_interlaced_filters(width, height, bytes_per_pixel)
|
|
182
|
+
offset = 0
|
|
183
|
+
scanline_width = width * bytes_per_pixel
|
|
184
|
+
|
|
185
|
+
height.times do |row|
|
|
186
|
+
break if offset >= @decompressed_data.bytesize
|
|
187
|
+
|
|
188
|
+
filter_type = @decompressed_data[offset].ord
|
|
189
|
+
|
|
190
|
+
unless VALID_FILTERS.include?(filter_type)
|
|
191
|
+
add_error("Invalid filter type #{filter_type} at scanline #{row}")
|
|
192
|
+
return false
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
offset += scanline_width + 1 # +1 for filter byte
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
true
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Validate filters for Adam7 interlaced image
|
|
202
|
+
def validate_adam7_filters(width, height, bytes_per_pixel)
|
|
203
|
+
# Adam7 interlacing uses 7 passes with different starting positions and steps
|
|
204
|
+
adam7_passes = [
|
|
205
|
+
{ x_start: 0, y_start: 0, x_step: 8, y_step: 8 },
|
|
206
|
+
{ x_start: 4, y_start: 0, x_step: 8, y_step: 8 },
|
|
207
|
+
{ x_start: 0, y_start: 4, x_step: 4, y_step: 8 },
|
|
208
|
+
{ x_start: 2, y_start: 0, x_step: 4, y_step: 4 },
|
|
209
|
+
{ x_start: 0, y_start: 2, x_step: 2, y_step: 4 },
|
|
210
|
+
{ x_start: 1, y_start: 0, x_step: 2, y_step: 2 },
|
|
211
|
+
{ x_start: 0, y_start: 1, x_step: 1, y_step: 2 },
|
|
212
|
+
]
|
|
213
|
+
|
|
214
|
+
offset = 0
|
|
215
|
+
|
|
216
|
+
adam7_passes.each_with_index do |pass, pass_num|
|
|
217
|
+
pass_width = ((width - pass[:x_start] + pass[:x_step] - 1) / pass[:x_step]).floor
|
|
218
|
+
pass_height = ((height - pass[:y_start] + pass[:y_step] - 1) / pass[:y_step]).floor
|
|
219
|
+
|
|
220
|
+
next if pass_width.zero? || pass_height.zero?
|
|
221
|
+
|
|
222
|
+
scanline_width = pass_width * bytes_per_pixel
|
|
223
|
+
|
|
224
|
+
pass_height.times do |row|
|
|
225
|
+
break if offset >= @decompressed_data.bytesize
|
|
226
|
+
|
|
227
|
+
filter_type = @decompressed_data[offset].ord
|
|
228
|
+
|
|
229
|
+
unless VALID_FILTERS.include?(filter_type)
|
|
230
|
+
add_error("Invalid filter type #{filter_type} " \
|
|
231
|
+
"at pass #{pass_num}, scanline #{row}")
|
|
232
|
+
return false
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
offset += scanline_width + 1
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
true
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Calculate expected size for Adam7 interlaced image
|
|
243
|
+
def calculate_adam7_size(width, height, bytes_per_pixel)
|
|
244
|
+
adam7_passes = [
|
|
245
|
+
{ x_start: 0, y_start: 0, x_step: 8, y_step: 8 },
|
|
246
|
+
{ x_start: 4, y_start: 0, x_step: 8, y_step: 8 },
|
|
247
|
+
{ x_start: 0, y_start: 4, x_step: 4, y_step: 8 },
|
|
248
|
+
{ x_start: 2, y_start: 0, x_step: 4, y_step: 4 },
|
|
249
|
+
{ x_start: 0, y_start: 2, x_step: 2, y_step: 4 },
|
|
250
|
+
{ x_start: 1, y_start: 0, x_step: 2, y_step: 2 },
|
|
251
|
+
{ x_start: 0, y_start: 1, x_step: 1, y_step: 2 },
|
|
252
|
+
]
|
|
253
|
+
|
|
254
|
+
total_size = 0
|
|
255
|
+
|
|
256
|
+
adam7_passes.each do |pass|
|
|
257
|
+
pass_width = ((width - pass[:x_start] + pass[:x_step] - 1) / pass[:x_step]).floor
|
|
258
|
+
pass_height = ((height - pass[:y_start] + pass[:y_step] - 1) / pass[:y_step]).floor
|
|
259
|
+
|
|
260
|
+
next if pass_width.zero? || pass_height.zero?
|
|
261
|
+
|
|
262
|
+
scanline_width = pass_width * bytes_per_pixel
|
|
263
|
+
total_size += (scanline_width + 1) * pass_height
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
total_size
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../base_validator"
|
|
4
|
+
|
|
5
|
+
module PngConform
|
|
6
|
+
module Validators
|
|
7
|
+
module Ancillary
|
|
8
|
+
# Validator for PNG bKGD (Background Color) chunk
|
|
9
|
+
#
|
|
10
|
+
# bKGD specifies a default background color for displaying the image:
|
|
11
|
+
# - For indexed-color: 1 byte (palette index)
|
|
12
|
+
# - For grayscale: 2 bytes (gray value)
|
|
13
|
+
# - For truecolor: 6 bytes (RGB values)
|
|
14
|
+
#
|
|
15
|
+
# Validation rules from PNG spec:
|
|
16
|
+
# - Length depends on color type
|
|
17
|
+
# - Must appear before IDAT
|
|
18
|
+
# - For indexed-color, must appear after PLTE
|
|
19
|
+
# - Only one bKGD chunk allowed
|
|
20
|
+
# - Palette index must be valid
|
|
21
|
+
class BkgdValidator < BaseValidator
|
|
22
|
+
# Validate bKGD chunk
|
|
23
|
+
#
|
|
24
|
+
# @return [Boolean] True if validation passed
|
|
25
|
+
def validate
|
|
26
|
+
return false unless check_crc
|
|
27
|
+
return false unless check_position
|
|
28
|
+
return false unless check_uniqueness
|
|
29
|
+
return false unless check_length_for_color_type
|
|
30
|
+
return false unless check_value
|
|
31
|
+
|
|
32
|
+
store_background_info
|
|
33
|
+
true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# Check bKGD position relative to other chunks
|
|
39
|
+
def check_position
|
|
40
|
+
valid = true
|
|
41
|
+
|
|
42
|
+
# bKGD must appear before IDAT
|
|
43
|
+
if context.seen?("IDAT")
|
|
44
|
+
add_error("bKGD chunk after IDAT (must be before)")
|
|
45
|
+
valid = false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# For indexed-color, bKGD must appear after PLTE
|
|
49
|
+
color_type = context.retrieve(:color_type)
|
|
50
|
+
if color_type == 3 && !context.seen?("PLTE")
|
|
51
|
+
add_error("bKGD chunk before PLTE for indexed-color image")
|
|
52
|
+
valid = false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
valid
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Check that only one bKGD chunk is present
|
|
59
|
+
def check_uniqueness
|
|
60
|
+
if context.seen?("bKGD")
|
|
61
|
+
add_error("duplicate bKGD chunk")
|
|
62
|
+
return false
|
|
63
|
+
end
|
|
64
|
+
true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Check bKGD length for color type
|
|
68
|
+
def check_length_for_color_type
|
|
69
|
+
color_type = context.retrieve(:color_type)
|
|
70
|
+
return true unless color_type
|
|
71
|
+
|
|
72
|
+
chunk.chunk_data.bytesize
|
|
73
|
+
|
|
74
|
+
case color_type
|
|
75
|
+
when 0, 4
|
|
76
|
+
# Grayscale or grayscale+alpha: 2 bytes
|
|
77
|
+
check_length(2)
|
|
78
|
+
when 2, 6
|
|
79
|
+
# Truecolor or truecolor+alpha: 6 bytes
|
|
80
|
+
check_length(6)
|
|
81
|
+
when 3
|
|
82
|
+
# Indexed-color: 1 byte
|
|
83
|
+
check_length(1)
|
|
84
|
+
else
|
|
85
|
+
true
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Check background value validity
|
|
90
|
+
def check_value
|
|
91
|
+
color_type = context.retrieve(:color_type)
|
|
92
|
+
return true unless color_type
|
|
93
|
+
|
|
94
|
+
data = chunk.chunk_data
|
|
95
|
+
|
|
96
|
+
case color_type
|
|
97
|
+
when 3
|
|
98
|
+
# Indexed-color: check palette index
|
|
99
|
+
index = data.unpack1("C")
|
|
100
|
+
palette_entries = context.retrieve(:palette_entries)
|
|
101
|
+
|
|
102
|
+
if palette_entries && index >= palette_entries
|
|
103
|
+
add_error("bKGD palette index (#{index}) exceeds " \
|
|
104
|
+
"palette size (#{palette_entries})")
|
|
105
|
+
return false
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
true
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Store background information in context
|
|
113
|
+
def store_background_info
|
|
114
|
+
color_type = context.retrieve(:color_type)
|
|
115
|
+
data = chunk.chunk_data
|
|
116
|
+
|
|
117
|
+
case color_type
|
|
118
|
+
when 0, 4
|
|
119
|
+
# Grayscale: store gray value
|
|
120
|
+
gray = data.unpack1("n")
|
|
121
|
+
context.store(:background_gray, gray)
|
|
122
|
+
add_info("bKGD: gray = #{gray}")
|
|
123
|
+
when 2, 6
|
|
124
|
+
# Truecolor: store RGB values
|
|
125
|
+
r, g, b = data.unpack("nnn")
|
|
126
|
+
context.store(:background_color, { r: r, g: g, b: b })
|
|
127
|
+
add_info("bKGD: RGB = (#{r}, #{g}, #{b})")
|
|
128
|
+
when 3
|
|
129
|
+
# Indexed-color: store palette index
|
|
130
|
+
index = data.unpack1("C")
|
|
131
|
+
context.store(:background_index, index)
|
|
132
|
+
add_info("bKGD: palette index = #{index}")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
context.store(:has_background, true)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../base_validator"
|
|
4
|
+
|
|
5
|
+
module PngConform
|
|
6
|
+
module Validators
|
|
7
|
+
module Ancillary
|
|
8
|
+
# Validator for PNG cHRM (Primary Chromaticities and White Point) chunk
|
|
9
|
+
#
|
|
10
|
+
# cHRM specifies the chromaticity coordinates of the display primaries
|
|
11
|
+
# and white point:
|
|
12
|
+
# - White Point x (4 bytes)
|
|
13
|
+
# - White Point y (4 bytes)
|
|
14
|
+
# - Red x (4 bytes)
|
|
15
|
+
# - Red y (4 bytes)
|
|
16
|
+
# - Green x (4 bytes)
|
|
17
|
+
# - Green y (4 bytes)
|
|
18
|
+
# - Blue x (4 bytes)
|
|
19
|
+
# - Blue y (4 bytes)
|
|
20
|
+
#
|
|
21
|
+
# All values are encoded as integers representing the value * 100,000.
|
|
22
|
+
#
|
|
23
|
+
# Validation rules from PNG spec:
|
|
24
|
+
# - Must be exactly 32 bytes
|
|
25
|
+
# - Must appear before PLTE and IDAT
|
|
26
|
+
# - Only one cHRM chunk allowed
|
|
27
|
+
# - Values should be in range 0.0-0.8 (0-80000)
|
|
28
|
+
# - If sRGB present, should match sRGB values
|
|
29
|
+
class ChrmValidator < BaseValidator
|
|
30
|
+
# Standard sRGB chromaticity values (* 100000)
|
|
31
|
+
SRGB_VALUES = {
|
|
32
|
+
white_x: 31_270,
|
|
33
|
+
white_y: 32_900,
|
|
34
|
+
red_x: 64_000,
|
|
35
|
+
red_y: 33_000,
|
|
36
|
+
green_x: 30_000,
|
|
37
|
+
green_y: 60_000,
|
|
38
|
+
blue_x: 15_000,
|
|
39
|
+
blue_y: 6_000,
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
# Tolerance for sRGB comparison
|
|
43
|
+
SRGB_TOLERANCE = 5
|
|
44
|
+
|
|
45
|
+
# Validate cHRM chunk
|
|
46
|
+
#
|
|
47
|
+
# @return [Boolean] True if validation passed
|
|
48
|
+
def validate
|
|
49
|
+
return false unless check_crc
|
|
50
|
+
return false unless check_length(32)
|
|
51
|
+
return false unless check_position
|
|
52
|
+
return false unless check_uniqueness
|
|
53
|
+
return false unless check_values
|
|
54
|
+
|
|
55
|
+
check_srgb_consistency
|
|
56
|
+
|
|
57
|
+
store_chrm_info
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# Check cHRM position relative to other chunks
|
|
64
|
+
def check_position
|
|
65
|
+
valid = true
|
|
66
|
+
|
|
67
|
+
# cHRM should appear before PLTE and IDAT
|
|
68
|
+
if context.seen?("PLTE")
|
|
69
|
+
add_warning("cHRM chunk after PLTE (should be before)")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
if context.seen?("IDAT")
|
|
73
|
+
add_error("cHRM chunk after IDAT (must be before)")
|
|
74
|
+
valid = false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
valid
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Check that only one cHRM chunk is present
|
|
81
|
+
def check_uniqueness
|
|
82
|
+
if context.seen?("cHRM")
|
|
83
|
+
add_error("duplicate cHRM chunk")
|
|
84
|
+
return false
|
|
85
|
+
end
|
|
86
|
+
true
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Check chromaticity values
|
|
90
|
+
def check_values
|
|
91
|
+
data = chunk.chunk_data
|
|
92
|
+
values = data.unpack("N8")
|
|
93
|
+
|
|
94
|
+
white_x, white_y, red_x, red_y, green_x, green_y, blue_x, blue_y = values
|
|
95
|
+
|
|
96
|
+
valid = true
|
|
97
|
+
|
|
98
|
+
# Check ranges (typically 0.0-0.8, or 0-80000)
|
|
99
|
+
valid &= check_chromaticity(white_x, "white point x")
|
|
100
|
+
valid &= check_chromaticity(white_y, "white point y")
|
|
101
|
+
valid &= check_chromaticity(red_x, "red x")
|
|
102
|
+
valid &= check_chromaticity(red_y, "red y")
|
|
103
|
+
valid &= check_chromaticity(green_x, "green x")
|
|
104
|
+
valid &= check_chromaticity(green_y, "green y")
|
|
105
|
+
valid &= check_chromaticity(blue_x, "blue x")
|
|
106
|
+
valid &= check_chromaticity(blue_y, "blue y")
|
|
107
|
+
|
|
108
|
+
valid
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Check individual chromaticity value
|
|
112
|
+
def check_chromaticity(value, name)
|
|
113
|
+
# Values are typically 0.0-0.8 (0-80000)
|
|
114
|
+
if value > 100_000
|
|
115
|
+
add_warning("#{name} chromaticity (#{value / 100_000.0}) " \
|
|
116
|
+
"exceeds typical range (0.0-1.0)")
|
|
117
|
+
end
|
|
118
|
+
true
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Check consistency with sRGB chunk if present
|
|
122
|
+
def check_srgb_consistency
|
|
123
|
+
return unless context.retrieve(:uses_srgb)
|
|
124
|
+
|
|
125
|
+
data = chunk.chunk_data
|
|
126
|
+
values = data.unpack("N8")
|
|
127
|
+
|
|
128
|
+
white_x, white_y, red_x, red_y, green_x, green_y, blue_x, blue_y = values
|
|
129
|
+
|
|
130
|
+
mismatches = []
|
|
131
|
+
mismatches << "white point x" unless close_to?(white_x,
|
|
132
|
+
SRGB_VALUES[:white_x])
|
|
133
|
+
mismatches << "white point y" unless close_to?(white_y,
|
|
134
|
+
SRGB_VALUES[:white_y])
|
|
135
|
+
mismatches << "red x" unless close_to?(red_x, SRGB_VALUES[:red_x])
|
|
136
|
+
mismatches << "red y" unless close_to?(red_y, SRGB_VALUES[:red_y])
|
|
137
|
+
mismatches << "green x" unless close_to?(green_x,
|
|
138
|
+
SRGB_VALUES[:green_x])
|
|
139
|
+
mismatches << "green y" unless close_to?(green_y,
|
|
140
|
+
SRGB_VALUES[:green_y])
|
|
141
|
+
mismatches << "blue x" unless close_to?(blue_x, SRGB_VALUES[:blue_x])
|
|
142
|
+
mismatches << "blue y" unless close_to?(blue_y, SRGB_VALUES[:blue_y])
|
|
143
|
+
|
|
144
|
+
return unless mismatches.any?
|
|
145
|
+
|
|
146
|
+
add_warning("cHRM values don't match sRGB: #{mismatches.join(', ')}")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Check if value is close to expected sRGB value
|
|
150
|
+
def close_to?(actual, expected)
|
|
151
|
+
(actual - expected).abs <= SRGB_TOLERANCE
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Store chromaticity information in context
|
|
155
|
+
def store_chrm_info
|
|
156
|
+
data = chunk.chunk_data
|
|
157
|
+
values = data.unpack("N8")
|
|
158
|
+
|
|
159
|
+
white_x, white_y, red_x, red_y, green_x, green_y, blue_x, blue_y = values
|
|
160
|
+
|
|
161
|
+
context.store(:chromaticity, {
|
|
162
|
+
white_x: white_x / 100_000.0,
|
|
163
|
+
white_y: white_y / 100_000.0,
|
|
164
|
+
red_x: red_x / 100_000.0,
|
|
165
|
+
red_y: red_y / 100_000.0,
|
|
166
|
+
green_x: green_x / 100_000.0,
|
|
167
|
+
green_y: green_y / 100_000.0,
|
|
168
|
+
blue_x: blue_x / 100_000.0,
|
|
169
|
+
blue_y: blue_y / 100_000.0,
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
add_info("cHRM: white=(#{format('%.4f', white_x / 100_000.0)}, " \
|
|
173
|
+
"#{format('%.4f', white_y / 100_000.0)})")
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|