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,289 @@
|
|
|
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 pCAL (Pixel Calibration) chunk
|
|
9
|
+
#
|
|
10
|
+
# pCAL maps pixel values to physical values:
|
|
11
|
+
# - Calibration name (1-79 bytes, Latin-1)
|
|
12
|
+
# - Null separator (1 byte)
|
|
13
|
+
# - Original zero (4 bytes, signed)
|
|
14
|
+
# - Original max (4 bytes, signed)
|
|
15
|
+
# - Equation type (1 byte, 0-3)
|
|
16
|
+
# - Number of parameters (1 byte)
|
|
17
|
+
# - Unit name (null-terminated Latin-1)
|
|
18
|
+
# - Parameters (null-terminated ASCII floating point strings)
|
|
19
|
+
#
|
|
20
|
+
# Validation rules from PNG spec:
|
|
21
|
+
# - Calibration name must be 1-79 characters, Latin-1 printable
|
|
22
|
+
# - Calibration name must not have leading/trailing/consecutive spaces
|
|
23
|
+
# - Equation type must be 0-3
|
|
24
|
+
# - Number of parameters must match equation type requirements
|
|
25
|
+
# - Parameters must be valid ASCII floating point numbers
|
|
26
|
+
# - Must appear before IDAT chunk
|
|
27
|
+
# - Only one pCAL chunk allowed
|
|
28
|
+
class PcalValidator < BaseValidator
|
|
29
|
+
# Maximum calibration name length
|
|
30
|
+
MAX_CALIBRATION_NAME_LENGTH = 79
|
|
31
|
+
|
|
32
|
+
# Latin-1 printable characters (space to tilde + high ASCII)
|
|
33
|
+
PRINTABLE_LATIN1 = (32..126).to_a + (161..255).to_a
|
|
34
|
+
|
|
35
|
+
# Valid equation types and required parameter counts
|
|
36
|
+
EQUATION_LINEAR = 0 # p0 + p1*x (2 params)
|
|
37
|
+
EQUATION_BASE_E = 1 # p0 + p1*e^(p2*x) (3 params)
|
|
38
|
+
EQUATION_BASE_10 = 2 # p0 + p1*10^(p2*x) (3 params)
|
|
39
|
+
EQUATION_ARBITRARY = 3 # p0 + p1*n^(p2*x) (4 params)
|
|
40
|
+
|
|
41
|
+
EQUATION_PARAM_COUNTS = {
|
|
42
|
+
EQUATION_LINEAR => 2,
|
|
43
|
+
EQUATION_BASE_E => 3,
|
|
44
|
+
EQUATION_BASE_10 => 3,
|
|
45
|
+
EQUATION_ARBITRARY => 4,
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
# ASCII floating point regex
|
|
49
|
+
FLOAT_REGEX = /\A[+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\z/
|
|
50
|
+
|
|
51
|
+
# Validate pCAL chunk
|
|
52
|
+
#
|
|
53
|
+
# @return [Boolean] True if validation passed
|
|
54
|
+
def validate
|
|
55
|
+
return false unless check_crc
|
|
56
|
+
return false unless check_uniqueness
|
|
57
|
+
return false unless check_position
|
|
58
|
+
return false unless check_structure
|
|
59
|
+
return false unless check_calibration_name
|
|
60
|
+
return false unless check_equation_type
|
|
61
|
+
return false unless check_parameters
|
|
62
|
+
|
|
63
|
+
store_calibration_info
|
|
64
|
+
true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# Check that only one pCAL chunk exists
|
|
70
|
+
def check_uniqueness
|
|
71
|
+
if context.retrieve(:has_calibration)
|
|
72
|
+
add_error("duplicate pCAL chunk (only one allowed)")
|
|
73
|
+
return false
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
true
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Check that pCAL appears before IDAT
|
|
80
|
+
def check_position
|
|
81
|
+
if context.seen?("IDAT")
|
|
82
|
+
add_error("pCAL chunk after IDAT chunk")
|
|
83
|
+
return false
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
true
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Check pCAL chunk structure
|
|
90
|
+
def check_structure
|
|
91
|
+
data = chunk.chunk_data
|
|
92
|
+
|
|
93
|
+
# Must contain at least name + nulls + int fields + equation type + param count
|
|
94
|
+
if data.length < 12
|
|
95
|
+
add_error("pCAL chunk too short (minimum 12 bytes)")
|
|
96
|
+
return false
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Must contain null separator after name
|
|
100
|
+
null_pos = data.index("\0")
|
|
101
|
+
unless null_pos
|
|
102
|
+
add_error("pCAL chunk missing null separator")
|
|
103
|
+
return false
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
true
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Check calibration name validity
|
|
110
|
+
def check_calibration_name
|
|
111
|
+
data = chunk.chunk_data
|
|
112
|
+
null_pos = data.index("\0")
|
|
113
|
+
cal_name = data[0, null_pos]
|
|
114
|
+
|
|
115
|
+
# Check calibration name length
|
|
116
|
+
if cal_name.empty?
|
|
117
|
+
add_error("pCAL chunk has empty calibration name")
|
|
118
|
+
return false
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
if cal_name.length > MAX_CALIBRATION_NAME_LENGTH
|
|
122
|
+
add_error("pCAL calibration name too long " \
|
|
123
|
+
"(#{cal_name.length}, max #{MAX_CALIBRATION_NAME_LENGTH})")
|
|
124
|
+
return false
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Check for Latin-1 printable characters
|
|
128
|
+
cal_name.bytes.each do |byte|
|
|
129
|
+
next if PRINTABLE_LATIN1.include?(byte)
|
|
130
|
+
|
|
131
|
+
add_error("pCAL calibration name contains non-printable " \
|
|
132
|
+
"character (0x#{byte.to_s(16)})")
|
|
133
|
+
return false
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Check for leading/trailing spaces
|
|
137
|
+
if cal_name.start_with?(" ")
|
|
138
|
+
add_error("pCAL calibration name has leading space")
|
|
139
|
+
return false
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
if cal_name.end_with?(" ")
|
|
143
|
+
add_error("pCAL calibration name has trailing space")
|
|
144
|
+
return false
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Check for consecutive spaces
|
|
148
|
+
if cal_name.include?(" ")
|
|
149
|
+
add_error("pCAL calibration name has consecutive spaces")
|
|
150
|
+
return false
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
true
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Check equation type
|
|
157
|
+
def check_equation_type
|
|
158
|
+
data = chunk.chunk_data
|
|
159
|
+
null_pos = data.index("\0")
|
|
160
|
+
equation_type = data[null_pos + 9].ord
|
|
161
|
+
|
|
162
|
+
unless EQUATION_PARAM_COUNTS.key?(equation_type)
|
|
163
|
+
add_error("pCAL invalid equation type (#{equation_type}, " \
|
|
164
|
+
"must be 0-3)")
|
|
165
|
+
return false
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
true
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Check parameters
|
|
172
|
+
def check_parameters
|
|
173
|
+
data = chunk.chunk_data
|
|
174
|
+
null_pos = data.index("\0")
|
|
175
|
+
equation_type = data[null_pos + 9].ord
|
|
176
|
+
num_params = data[null_pos + 10].ord
|
|
177
|
+
|
|
178
|
+
expected_params = EQUATION_PARAM_COUNTS[equation_type]
|
|
179
|
+
unless num_params == expected_params
|
|
180
|
+
add_error("pCAL parameter count mismatch (#{num_params}, " \
|
|
181
|
+
"expected #{expected_params} for equation type #{equation_type})")
|
|
182
|
+
return false
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Find unit name and parameters
|
|
186
|
+
unit_start = null_pos + 11
|
|
187
|
+
unit_null = data.index("\0", unit_start)
|
|
188
|
+
unless unit_null
|
|
189
|
+
add_error("pCAL missing unit name null terminator")
|
|
190
|
+
return false
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Parse parameters
|
|
194
|
+
param_start = unit_null + 1
|
|
195
|
+
params_found = 0
|
|
196
|
+
pos = param_start
|
|
197
|
+
|
|
198
|
+
while params_found < num_params && pos < data.length
|
|
199
|
+
param_end = data.index("\0", pos)
|
|
200
|
+
unless param_end
|
|
201
|
+
add_error("pCAL missing parameter #{params_found + 1} " \
|
|
202
|
+
"null terminator")
|
|
203
|
+
return false
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
param_str = data[pos, param_end - pos]
|
|
207
|
+
unless valid_float_string?(param_str)
|
|
208
|
+
add_error("pCAL invalid parameter #{params_found + 1} " \
|
|
209
|
+
"format: '#{param_str}'")
|
|
210
|
+
return false
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
params_found += 1
|
|
214
|
+
pos = param_end + 1
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
unless params_found == num_params
|
|
218
|
+
add_error("pCAL found #{params_found} parameters, " \
|
|
219
|
+
"expected #{num_params}")
|
|
220
|
+
return false
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
true
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Validate floating point string format
|
|
227
|
+
def valid_float_string?(str)
|
|
228
|
+
return false if str.empty?
|
|
229
|
+
|
|
230
|
+
str.match?(FLOAT_REGEX)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Store calibration information in context
|
|
234
|
+
def store_calibration_info
|
|
235
|
+
data = chunk.chunk_data
|
|
236
|
+
null_pos = data.index("\0")
|
|
237
|
+
cal_name = data[0, null_pos]
|
|
238
|
+
|
|
239
|
+
# Parse original zero and max (signed 32-bit big-endian)
|
|
240
|
+
orig_zero_bytes = data[null_pos + 1, 4].bytes
|
|
241
|
+
orig_zero = (orig_zero_bytes[0] << 24) |
|
|
242
|
+
(orig_zero_bytes[1] << 16) |
|
|
243
|
+
(orig_zero_bytes[2] << 8) |
|
|
244
|
+
orig_zero_bytes[3]
|
|
245
|
+
orig_zero -= (1 << 32) if orig_zero >= (1 << 31)
|
|
246
|
+
|
|
247
|
+
orig_max_bytes = data[null_pos + 5, 4].bytes
|
|
248
|
+
orig_max = (orig_max_bytes[0] << 24) |
|
|
249
|
+
(orig_max_bytes[1] << 16) |
|
|
250
|
+
(orig_max_bytes[2] << 8) |
|
|
251
|
+
orig_max_bytes[3]
|
|
252
|
+
orig_max -= (1 << 32) if orig_max >= (1 << 31)
|
|
253
|
+
|
|
254
|
+
equation_type = data[null_pos + 9].ord
|
|
255
|
+
num_params = data[null_pos + 10].ord
|
|
256
|
+
|
|
257
|
+
# Parse unit name
|
|
258
|
+
unit_start = null_pos + 11
|
|
259
|
+
unit_null = data.index("\0", unit_start)
|
|
260
|
+
unit_name = data[unit_start, unit_null - unit_start]
|
|
261
|
+
|
|
262
|
+
# Parse parameters
|
|
263
|
+
params = []
|
|
264
|
+
pos = unit_null + 1
|
|
265
|
+
num_params.times do
|
|
266
|
+
param_end = data.index("\0", pos)
|
|
267
|
+
param_str = data[pos, param_end - pos]
|
|
268
|
+
params << param_str.to_f
|
|
269
|
+
pos = param_end + 1
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Store in context
|
|
273
|
+
context.store(:has_calibration, true)
|
|
274
|
+
context.store(:calibration_name, cal_name)
|
|
275
|
+
context.store(:calibration_equation_type, equation_type)
|
|
276
|
+
context.store(:calibration_parameters, params)
|
|
277
|
+
|
|
278
|
+
# Add info about the calibration
|
|
279
|
+
eq_type_name = %w[linear base-e base-10
|
|
280
|
+
arbitrary][equation_type]
|
|
281
|
+
add_info("pCAL: \"#{cal_name}\" #{eq_type_name} equation, " \
|
|
282
|
+
"range #{orig_zero}..#{orig_max}, " \
|
|
283
|
+
"unit \"#{unit_name}\", " \
|
|
284
|
+
"params: #{params.join(', ')}")
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
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 pHYs (Physical Pixel Dimensions) chunk
|
|
9
|
+
#
|
|
10
|
+
# pHYs specifies the intended pixel size or aspect ratio:
|
|
11
|
+
# - Pixels per unit, X axis (4 bytes)
|
|
12
|
+
# - Pixels per unit, Y axis (4 bytes)
|
|
13
|
+
# - Unit specifier (1 byte): 0 = unknown, 1 = meters
|
|
14
|
+
#
|
|
15
|
+
# Validation rules from PNG spec:
|
|
16
|
+
# - Must be exactly 9 bytes
|
|
17
|
+
# - Must appear before IDAT
|
|
18
|
+
# - Only one pHYs chunk allowed
|
|
19
|
+
# - Unit specifier must be 0 or 1
|
|
20
|
+
class PhysValidator < BaseValidator
|
|
21
|
+
# Unit specifiers
|
|
22
|
+
UNIT_UNKNOWN = 0
|
|
23
|
+
UNIT_METER = 1
|
|
24
|
+
|
|
25
|
+
# Common DPI values (pixels per inch)
|
|
26
|
+
DPI_72 = 2835 # 72 DPI in pixels per meter
|
|
27
|
+
DPI_96 = 3780 # 96 DPI in pixels per meter
|
|
28
|
+
DPI_150 = 5906 # 150 DPI in pixels per meter
|
|
29
|
+
DPI_300 = 11_811 # 300 DPI in pixels per meter
|
|
30
|
+
|
|
31
|
+
# Validate pHYs chunk
|
|
32
|
+
#
|
|
33
|
+
# @return [Boolean] True if validation passed
|
|
34
|
+
def validate
|
|
35
|
+
return false unless check_crc
|
|
36
|
+
return false unless check_length(9)
|
|
37
|
+
return false unless check_position
|
|
38
|
+
return false unless check_uniqueness
|
|
39
|
+
return false unless check_unit
|
|
40
|
+
|
|
41
|
+
store_phys_info
|
|
42
|
+
true
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Check pHYs position relative to other chunks
|
|
48
|
+
def check_position
|
|
49
|
+
# pHYs should appear before IDAT
|
|
50
|
+
if context.seen?("IDAT")
|
|
51
|
+
add_error("pHYs chunk after IDAT (must be before)")
|
|
52
|
+
return false
|
|
53
|
+
end
|
|
54
|
+
true
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Check that only one pHYs chunk is present
|
|
58
|
+
def check_uniqueness
|
|
59
|
+
if context.seen?("pHYs")
|
|
60
|
+
add_error("duplicate pHYs chunk")
|
|
61
|
+
return false
|
|
62
|
+
end
|
|
63
|
+
true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Check unit specifier
|
|
67
|
+
def check_unit
|
|
68
|
+
data = chunk.chunk_data
|
|
69
|
+
unit = data[8].unpack1("C")
|
|
70
|
+
|
|
71
|
+
check_enum(unit, [UNIT_UNKNOWN, UNIT_METER], "unit specifier")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Store physical dimensions in context
|
|
75
|
+
def store_phys_info
|
|
76
|
+
data = chunk.chunk_data
|
|
77
|
+
pixels_per_unit_x = data[0, 4].unpack1("N")
|
|
78
|
+
pixels_per_unit_y = data[4, 4].unpack1("N")
|
|
79
|
+
unit = data[8].unpack1("C")
|
|
80
|
+
|
|
81
|
+
context.store(:phys_x, pixels_per_unit_x)
|
|
82
|
+
context.store(:phys_y, pixels_per_unit_y)
|
|
83
|
+
context.store(:phys_unit, unit)
|
|
84
|
+
|
|
85
|
+
# Calculate aspect ratio
|
|
86
|
+
if pixels_per_unit_x.positive? && pixels_per_unit_y.positive?
|
|
87
|
+
aspect = pixels_per_unit_x.to_f / pixels_per_unit_y
|
|
88
|
+
context.store(:pixel_aspect_ratio, aspect)
|
|
89
|
+
|
|
90
|
+
if (aspect - 1.0).abs > 0.01
|
|
91
|
+
add_info("pHYs: non-square pixels " \
|
|
92
|
+
"(aspect ratio #{format('%.3f', aspect)})")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Provide DPI information if unit is meters
|
|
97
|
+
unless unit == UNIT_METER && pixels_per_unit_x == pixels_per_unit_y
|
|
98
|
+
return
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
dpi = (pixels_per_unit_x * 0.0254).round
|
|
102
|
+
add_info("pHYs: #{dpi} DPI")
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
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 sBIT (Significant Bits) chunk
|
|
9
|
+
#
|
|
10
|
+
# sBIT indicates the number of significant bits in the original image:
|
|
11
|
+
# - For grayscale: 1 byte (significant grayscale bits)
|
|
12
|
+
# - For truecolor: 3 bytes (R, G, B significant bits)
|
|
13
|
+
# - For indexed-color: 3 bytes (R, G, B significant bits)
|
|
14
|
+
# - For grayscale+alpha: 2 bytes (gray, alpha significant bits)
|
|
15
|
+
# - For truecolor+alpha: 4 bytes (R, G, B, alpha significant bits)
|
|
16
|
+
#
|
|
17
|
+
# Validation rules from PNG spec:
|
|
18
|
+
# - Length depends on color type
|
|
19
|
+
# - Must appear before PLTE and IDAT
|
|
20
|
+
# - Only one sBIT chunk allowed
|
|
21
|
+
# - Each value must be > 0 and <= sample depth
|
|
22
|
+
class SbitValidator < BaseValidator
|
|
23
|
+
# Validate sBIT chunk
|
|
24
|
+
#
|
|
25
|
+
# @return [Boolean] True if validation passed
|
|
26
|
+
def validate
|
|
27
|
+
return false unless check_crc
|
|
28
|
+
return false unless check_position
|
|
29
|
+
return false unless check_uniqueness
|
|
30
|
+
return false unless check_length_for_color_type
|
|
31
|
+
return false unless check_values
|
|
32
|
+
|
|
33
|
+
store_sbit_info
|
|
34
|
+
true
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# Check sBIT position relative to other chunks
|
|
40
|
+
def check_position
|
|
41
|
+
valid = true
|
|
42
|
+
|
|
43
|
+
# sBIT should appear before PLTE and IDAT
|
|
44
|
+
if context.seen?("PLTE")
|
|
45
|
+
add_warning("sBIT chunk after PLTE (should be before)")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if context.seen?("IDAT")
|
|
49
|
+
add_error("sBIT chunk after IDAT (must be before)")
|
|
50
|
+
valid = false
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
valid
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Check that only one sBIT chunk is present
|
|
57
|
+
def check_uniqueness
|
|
58
|
+
if context.seen?("sBIT")
|
|
59
|
+
add_error("duplicate sBIT chunk")
|
|
60
|
+
return false
|
|
61
|
+
end
|
|
62
|
+
true
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Check sBIT length for color type
|
|
66
|
+
def check_length_for_color_type
|
|
67
|
+
color_type = context.retrieve(:color_type)
|
|
68
|
+
return true unless color_type
|
|
69
|
+
|
|
70
|
+
expected_length = case color_type
|
|
71
|
+
when 0 then 1 # Grayscale
|
|
72
|
+
when 2 then 3 # Truecolor
|
|
73
|
+
when 3 then 3 # Indexed-color
|
|
74
|
+
when 4 then 2 # Grayscale + alpha
|
|
75
|
+
when 6 then 4 # Truecolor + alpha
|
|
76
|
+
else
|
|
77
|
+
return true
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
check_length(expected_length)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Check significant bit values
|
|
84
|
+
def check_values
|
|
85
|
+
color_type = context.retrieve(:color_type)
|
|
86
|
+
bit_depth = context.retrieve(:bit_depth)
|
|
87
|
+
return true unless color_type && bit_depth
|
|
88
|
+
|
|
89
|
+
data = chunk.chunk_data
|
|
90
|
+
valid = true
|
|
91
|
+
|
|
92
|
+
case color_type
|
|
93
|
+
when 0
|
|
94
|
+
# Grayscale
|
|
95
|
+
gray_bits = data[0].unpack1("C")
|
|
96
|
+
valid &= check_sbit_value(gray_bits, bit_depth, "gray")
|
|
97
|
+
when 2
|
|
98
|
+
# Truecolor
|
|
99
|
+
red, green, blue = data.unpack("CCC")
|
|
100
|
+
valid &= check_sbit_value(red, bit_depth, "red")
|
|
101
|
+
valid &= check_sbit_value(green, bit_depth, "green")
|
|
102
|
+
valid &= check_sbit_value(blue, bit_depth, "blue")
|
|
103
|
+
when 3
|
|
104
|
+
# Indexed-color (refers to palette sample depth, which is always 8)
|
|
105
|
+
red, green, blue = data.unpack("CCC")
|
|
106
|
+
valid &= check_sbit_value(red, 8, "red")
|
|
107
|
+
valid &= check_sbit_value(green, 8, "green")
|
|
108
|
+
valid &= check_sbit_value(blue, 8, "blue")
|
|
109
|
+
when 4
|
|
110
|
+
# Grayscale + alpha
|
|
111
|
+
gray, alpha = data.unpack("CC")
|
|
112
|
+
valid &= check_sbit_value(gray, bit_depth, "gray")
|
|
113
|
+
valid &= check_sbit_value(alpha, bit_depth, "alpha")
|
|
114
|
+
when 6
|
|
115
|
+
# Truecolor + alpha
|
|
116
|
+
red, green, blue, alpha = data.unpack("CCCC")
|
|
117
|
+
valid &= check_sbit_value(red, bit_depth, "red")
|
|
118
|
+
valid &= check_sbit_value(green, bit_depth, "green")
|
|
119
|
+
valid &= check_sbit_value(blue, bit_depth, "blue")
|
|
120
|
+
valid &= check_sbit_value(alpha, bit_depth, "alpha")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
valid
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Check individual significant bit value
|
|
127
|
+
def check_sbit_value(value, max_depth, name)
|
|
128
|
+
if value.zero?
|
|
129
|
+
add_error("#{name} significant bits cannot be 0")
|
|
130
|
+
return false
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
if value > max_depth
|
|
134
|
+
add_error("#{name} significant bits (#{value}) exceeds " \
|
|
135
|
+
"sample depth (#{max_depth})")
|
|
136
|
+
return false
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
true
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Store significant bit information in context
|
|
143
|
+
def store_sbit_info
|
|
144
|
+
color_type = context.retrieve(:color_type)
|
|
145
|
+
data = chunk.chunk_data
|
|
146
|
+
|
|
147
|
+
case color_type
|
|
148
|
+
when 0
|
|
149
|
+
gray = data[0].unpack1("C")
|
|
150
|
+
context.store(:significant_bits, { gray: gray })
|
|
151
|
+
add_info("sBIT: gray=#{gray}")
|
|
152
|
+
when 2
|
|
153
|
+
red, green, blue = data.unpack("CCC")
|
|
154
|
+
context.store(:significant_bits,
|
|
155
|
+
{ red: red, green: green, blue: blue })
|
|
156
|
+
add_info("sBIT: R=#{red}, G=#{green}, B=#{blue}")
|
|
157
|
+
when 3
|
|
158
|
+
red, green, blue = data.unpack("CCC")
|
|
159
|
+
context.store(:significant_bits,
|
|
160
|
+
{ red: red, green: green, blue: blue })
|
|
161
|
+
add_info("sBIT: R=#{red}, G=#{green}, B=#{blue}")
|
|
162
|
+
when 4
|
|
163
|
+
gray, alpha = data.unpack("CC")
|
|
164
|
+
context.store(:significant_bits, { gray: gray, alpha: alpha })
|
|
165
|
+
add_info("sBIT: gray=#{gray}, alpha=#{alpha}")
|
|
166
|
+
when 6
|
|
167
|
+
red, green, blue, alpha = data.unpack("CCCC")
|
|
168
|
+
context.store(:significant_bits,
|
|
169
|
+
{ red: red, green: green, blue: blue, alpha: alpha })
|
|
170
|
+
add_info("sBIT: R=#{red}, G=#{green}, B=#{blue}, A=#{alpha}")
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|