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,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PngConform
|
|
4
|
+
module Validators
|
|
5
|
+
module Ancillary
|
|
6
|
+
# Validator for cICP (Coding-Independent Code Points) chunk
|
|
7
|
+
#
|
|
8
|
+
# The cICP chunk specifies color space information using MPEG/ITU-T standards.
|
|
9
|
+
# Introduced in PNG 3rd edition (ISO/IEC 15948-1:2023).
|
|
10
|
+
#
|
|
11
|
+
# Structure:
|
|
12
|
+
# - Color primaries (1 byte): ITU-T H.273 / ISO/IEC 23091-2
|
|
13
|
+
# - Transfer function (1 byte): ITU-T H.273 / ISO/IEC 23091-2
|
|
14
|
+
# - Matrix coefficients (1 byte): ITU-T H.273 / ISO/IEC 23091-2
|
|
15
|
+
# - Video full range flag (1 byte): 0 or 1
|
|
16
|
+
#
|
|
17
|
+
# Constraints:
|
|
18
|
+
# - Must contain exactly 4 bytes
|
|
19
|
+
# - Must appear before PLTE and IDAT
|
|
20
|
+
# - At most one cICP chunk allowed
|
|
21
|
+
# - Should not coexist with iCCP or sRGB chunks
|
|
22
|
+
#
|
|
23
|
+
class CicpValidator < BaseValidator
|
|
24
|
+
CHUNK_TYPE = "cICP"
|
|
25
|
+
|
|
26
|
+
# Valid color primaries codes (ITU-T H.273 / ISO/IEC 23091-2)
|
|
27
|
+
VALID_COLOR_PRIMARIES = [
|
|
28
|
+
0, # Reserved
|
|
29
|
+
1, # BT.709
|
|
30
|
+
2, # Unspecified
|
|
31
|
+
4, # BT.470M
|
|
32
|
+
5, # BT.470BG
|
|
33
|
+
6, # BT.601
|
|
34
|
+
7, # SMPTE 240M
|
|
35
|
+
8, # Generic film
|
|
36
|
+
9, # BT.2020
|
|
37
|
+
10, # XYZ
|
|
38
|
+
11, # SMPTE 431
|
|
39
|
+
12, # SMPTE 432
|
|
40
|
+
22, # EBU Tech 3213
|
|
41
|
+
].freeze
|
|
42
|
+
|
|
43
|
+
# Valid transfer function codes (ITU-T H.273 / ISO/IEC 23091-2)
|
|
44
|
+
VALID_TRANSFER_FUNCTIONS = [
|
|
45
|
+
0, # Reserved
|
|
46
|
+
1, # BT.709
|
|
47
|
+
2, # Unspecified
|
|
48
|
+
4, # Gamma 2.2
|
|
49
|
+
5, # Gamma 2.8
|
|
50
|
+
6, # BT.601
|
|
51
|
+
7, # SMPTE 240M
|
|
52
|
+
8, # Linear
|
|
53
|
+
9, # Logarithmic (100:1 range)
|
|
54
|
+
10, # Logarithmic (100*sqrt(10):1 range)
|
|
55
|
+
11, # IEC 61966-2-4
|
|
56
|
+
12, # BT.1361
|
|
57
|
+
13, # sRGB/sYCC
|
|
58
|
+
14, # BT.2020 (10-bit)
|
|
59
|
+
15, # BT.2020 (12-bit)
|
|
60
|
+
16, # SMPTE ST 2084 (PQ)
|
|
61
|
+
17, # SMPTE ST 428
|
|
62
|
+
18, # HLG
|
|
63
|
+
].freeze
|
|
64
|
+
|
|
65
|
+
# Valid matrix coefficients codes (ITU-T H.273 / ISO/IEC 23091-2)
|
|
66
|
+
VALID_MATRIX_COEFFICIENTS = [
|
|
67
|
+
0, # Identity
|
|
68
|
+
1, # BT.709
|
|
69
|
+
2, # Unspecified
|
|
70
|
+
4, # FCC
|
|
71
|
+
5, # BT.470BG
|
|
72
|
+
6, # BT.601
|
|
73
|
+
7, # SMPTE 240M
|
|
74
|
+
8, # YCgCo
|
|
75
|
+
9, # BT.2020 non-constant luminance
|
|
76
|
+
10, # BT.2020 constant luminance
|
|
77
|
+
11, # SMPTE 2085
|
|
78
|
+
12, # Chromaticity-derived non-constant luminance
|
|
79
|
+
13, # Chromaticity-derived constant luminance
|
|
80
|
+
14, # ICtCp
|
|
81
|
+
].freeze
|
|
82
|
+
|
|
83
|
+
def validate
|
|
84
|
+
check_chunk_length
|
|
85
|
+
check_uniqueness
|
|
86
|
+
check_position
|
|
87
|
+
validate_fields if chunk.chunk_data.bytesize == 4
|
|
88
|
+
check_conflicts
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def check_chunk_length
|
|
94
|
+
return if check_length(4)
|
|
95
|
+
|
|
96
|
+
add_error("invalid chunk length: #{chunk.chunk_data.bytesize} bytes")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def check_uniqueness
|
|
100
|
+
return unless context.seen?(CHUNK_TYPE)
|
|
101
|
+
|
|
102
|
+
add_error("multiple cICP chunks not allowed")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def check_position
|
|
106
|
+
add_error("cICP must appear before PLTE") if context.seen?("PLTE")
|
|
107
|
+
|
|
108
|
+
return unless context.seen?("IDAT")
|
|
109
|
+
|
|
110
|
+
add_error("cICP must appear before IDAT")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def validate_fields
|
|
114
|
+
data = chunk.chunk_data.bytes
|
|
115
|
+
|
|
116
|
+
validate_color_primaries(data[0])
|
|
117
|
+
validate_transfer_function(data[1])
|
|
118
|
+
validate_matrix_coefficients(data[2])
|
|
119
|
+
validate_video_full_range_flag(data[3])
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def validate_color_primaries(value)
|
|
123
|
+
unless VALID_COLOR_PRIMARIES.include?(value)
|
|
124
|
+
add_error(
|
|
125
|
+
"invalid color primaries: #{value} " \
|
|
126
|
+
"(valid: #{VALID_COLOR_PRIMARIES.join(', ')})",
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
return unless value.zero?
|
|
131
|
+
|
|
132
|
+
add_warning("color primaries = 0 (reserved)")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def validate_transfer_function(value)
|
|
136
|
+
unless VALID_TRANSFER_FUNCTIONS.include?(value)
|
|
137
|
+
add_error(
|
|
138
|
+
"invalid transfer function: #{value} " \
|
|
139
|
+
"(valid: #{VALID_TRANSFER_FUNCTIONS.join(', ')})",
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
return unless value.zero?
|
|
144
|
+
|
|
145
|
+
add_warning("transfer function = 0 (reserved)")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def validate_matrix_coefficients(value)
|
|
149
|
+
unless VALID_MATRIX_COEFFICIENTS.include?(value)
|
|
150
|
+
add_error(
|
|
151
|
+
"invalid matrix coefficients: #{value} " \
|
|
152
|
+
"(valid: #{VALID_MATRIX_COEFFICIENTS.join(', ')})",
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
return unless value.zero? && rgb_color?
|
|
157
|
+
|
|
158
|
+
add_info(
|
|
159
|
+
"matrix coefficients = 0 (identity) is recommended for RGB",
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def validate_video_full_range_flag(value)
|
|
164
|
+
return if check_enum(value, [0, 1], "video full range flag")
|
|
165
|
+
|
|
166
|
+
add_error("invalid video full range flag: #{value} (must be 0 or 1)")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def check_conflicts
|
|
170
|
+
check_iccp_conflict
|
|
171
|
+
check_srgb_conflict
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def check_iccp_conflict
|
|
175
|
+
return unless context.seen?("iCCP")
|
|
176
|
+
|
|
177
|
+
add_warning(
|
|
178
|
+
"cICP should not coexist with iCCP " \
|
|
179
|
+
"(both specify color space information)",
|
|
180
|
+
)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def check_srgb_conflict
|
|
184
|
+
return unless context.seen?("sRGB")
|
|
185
|
+
|
|
186
|
+
add_warning(
|
|
187
|
+
"cICP should not coexist with sRGB " \
|
|
188
|
+
"(both specify color space information)",
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def rgb_color?
|
|
193
|
+
color_type = context.retrieve(:color_type)
|
|
194
|
+
return false unless color_type
|
|
195
|
+
|
|
196
|
+
# RGB (2) or RGB with alpha (6)
|
|
197
|
+
[2, 6].include?(color_type)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
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 gAMA (Image Gamma) chunk
|
|
9
|
+
#
|
|
10
|
+
# gAMA specifies the gamma of the image for proper display.
|
|
11
|
+
# The value is encoded as a 4-byte unsigned integer representing
|
|
12
|
+
# gamma times 100,000.
|
|
13
|
+
#
|
|
14
|
+
# Validation rules from PNG spec:
|
|
15
|
+
# - Must be exactly 4 bytes
|
|
16
|
+
# - Must appear before PLTE and IDAT
|
|
17
|
+
# - Only one gAMA chunk allowed
|
|
18
|
+
# - Value of 0 is invalid
|
|
19
|
+
# - Typical values: 45455 (gamma 2.2), 100000 (gamma 1.0)
|
|
20
|
+
class GamaValidator < BaseValidator
|
|
21
|
+
# Common gamma values (gamma * 100000)
|
|
22
|
+
GAMMA_2_2 = 45_455 # Standard display gamma
|
|
23
|
+
GAMMA_1_0 = 100_000 # Linear gamma
|
|
24
|
+
|
|
25
|
+
# Validate gAMA chunk
|
|
26
|
+
#
|
|
27
|
+
# @return [Boolean] True if validation passed
|
|
28
|
+
def validate
|
|
29
|
+
return false unless check_crc
|
|
30
|
+
return false unless check_length(4)
|
|
31
|
+
return false unless check_position
|
|
32
|
+
return false unless check_uniqueness
|
|
33
|
+
return false unless check_value
|
|
34
|
+
|
|
35
|
+
store_gamma_info
|
|
36
|
+
true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# Check gAMA position relative to other chunks
|
|
42
|
+
def check_position
|
|
43
|
+
valid = true
|
|
44
|
+
|
|
45
|
+
# gAMA should appear before PLTE and IDAT
|
|
46
|
+
if context.seen?("PLTE")
|
|
47
|
+
add_error("gAMA chunk after PLTE (should be before)")
|
|
48
|
+
valid = false
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
if context.seen?("IDAT")
|
|
52
|
+
add_error("gAMA chunk after IDAT (must be before)")
|
|
53
|
+
valid = false
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
valid
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Check that only one gAMA chunk is present
|
|
60
|
+
def check_uniqueness
|
|
61
|
+
if context.seen?("gAMA")
|
|
62
|
+
add_error("duplicate gAMA chunk")
|
|
63
|
+
return false
|
|
64
|
+
end
|
|
65
|
+
true
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Check gamma value
|
|
69
|
+
def check_value
|
|
70
|
+
gamma = chunk.chunk_data.unpack1("N")
|
|
71
|
+
|
|
72
|
+
if gamma.zero?
|
|
73
|
+
add_error("invalid gAMA value (0)")
|
|
74
|
+
return false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Check sRGB interaction
|
|
78
|
+
if context.seen?("sRGB") && gamma != GAMMA_2_2
|
|
79
|
+
add_warning("gAMA value #{gamma} with sRGB present (should be #{GAMMA_2_2} for gamma 2.2)")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Info about common values
|
|
83
|
+
case gamma
|
|
84
|
+
when GAMMA_2_2
|
|
85
|
+
add_info("gAMA: 2.2 (standard display gamma)")
|
|
86
|
+
when GAMMA_1_0
|
|
87
|
+
add_info("gAMA: 1.0 (linear)")
|
|
88
|
+
else
|
|
89
|
+
actual_gamma = gamma / 100_000.0
|
|
90
|
+
add_info("gAMA: #{format('%.5f', actual_gamma)}")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
true
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Store gamma information in context
|
|
97
|
+
def store_gamma_info
|
|
98
|
+
gamma = chunk.chunk_data.unpack1("N")
|
|
99
|
+
context.store(:gamma, gamma)
|
|
100
|
+
context.store(:gamma_value, gamma / 100_000.0)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
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 hIST (Palette Histogram) chunk
|
|
9
|
+
#
|
|
10
|
+
# hIST provides the approximate usage frequency of each palette entry:
|
|
11
|
+
# - One 2-byte value per palette entry
|
|
12
|
+
# - Values are proportional, not absolute
|
|
13
|
+
#
|
|
14
|
+
# Validation rules from PNG spec:
|
|
15
|
+
# - Must appear after PLTE chunk
|
|
16
|
+
# - Must appear before first IDAT chunk
|
|
17
|
+
# - Must have exactly 2 bytes per palette entry
|
|
18
|
+
# - Only valid for color type 3 (indexed color)
|
|
19
|
+
# - Only one hIST chunk allowed
|
|
20
|
+
class HistValidator < BaseValidator
|
|
21
|
+
# Validate hIST chunk
|
|
22
|
+
#
|
|
23
|
+
# @return [Boolean] True if validation passed
|
|
24
|
+
def validate
|
|
25
|
+
return false unless check_crc
|
|
26
|
+
return false unless check_uniqueness
|
|
27
|
+
return false unless check_color_type
|
|
28
|
+
return false unless check_palette_exists
|
|
29
|
+
return false unless check_position
|
|
30
|
+
return false unless check_length
|
|
31
|
+
|
|
32
|
+
store_histogram_info
|
|
33
|
+
true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# Check that only one hIST chunk exists
|
|
39
|
+
def check_uniqueness
|
|
40
|
+
if context.retrieve(:has_histogram)
|
|
41
|
+
add_error("Multiple hIST chunks (only one allowed)")
|
|
42
|
+
return false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
true
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Check that color type is indexed (3)
|
|
49
|
+
def check_color_type
|
|
50
|
+
color_type = context.retrieve(:color_type)
|
|
51
|
+
|
|
52
|
+
unless color_type
|
|
53
|
+
add_error("hIST chunk before IHDR")
|
|
54
|
+
return false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
unless color_type == 3
|
|
58
|
+
add_error("hIST chunk invalid for color type #{color_type} " \
|
|
59
|
+
"(only valid for indexed color)")
|
|
60
|
+
return false
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Check that PLTE chunk exists
|
|
67
|
+
def check_palette_exists
|
|
68
|
+
has_palette = context.retrieve(:has_palette)
|
|
69
|
+
|
|
70
|
+
unless has_palette
|
|
71
|
+
add_error("hIST chunk without preceding PLTE chunk")
|
|
72
|
+
return false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
true
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Check that hIST appears after PLTE but before IDAT
|
|
79
|
+
def check_position
|
|
80
|
+
# Must come after PLTE
|
|
81
|
+
unless context.seen?("PLTE")
|
|
82
|
+
add_error("hIST chunk before PLTE chunk")
|
|
83
|
+
return false
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Must come before IDAT
|
|
87
|
+
if context.seen?("IDAT")
|
|
88
|
+
add_error("hIST chunk after IDAT chunk")
|
|
89
|
+
return false
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
true
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Check that length matches palette size
|
|
96
|
+
def check_length
|
|
97
|
+
palette_entries = context.retrieve(:palette_entries)
|
|
98
|
+
expected_length = palette_entries * 2
|
|
99
|
+
actual_length = chunk.chunk_data.length
|
|
100
|
+
|
|
101
|
+
unless actual_length == expected_length
|
|
102
|
+
add_error("hIST chunk wrong length (#{actual_length} bytes, " \
|
|
103
|
+
"expected #{expected_length} for #{palette_entries} " \
|
|
104
|
+
"palette entries)")
|
|
105
|
+
return false
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
true
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Store histogram information in context
|
|
112
|
+
def store_histogram_info
|
|
113
|
+
data = chunk.chunk_data
|
|
114
|
+
palette_entries = context.retrieve(:palette_entries)
|
|
115
|
+
|
|
116
|
+
# Read frequency values (2 bytes each, big-endian)
|
|
117
|
+
frequencies = []
|
|
118
|
+
(0...palette_entries).each do |i|
|
|
119
|
+
offset = i * 2
|
|
120
|
+
freq = (data[offset].ord << 8) | data[offset + 1].ord
|
|
121
|
+
frequencies << freq
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Store in context
|
|
125
|
+
context.store(:has_histogram, true)
|
|
126
|
+
context.store(:histogram_frequencies, frequencies)
|
|
127
|
+
|
|
128
|
+
# Add info about histogram
|
|
129
|
+
total = frequencies.sum
|
|
130
|
+
if total.zero?
|
|
131
|
+
add_info("hIST: #{palette_entries} entries (all zero)")
|
|
132
|
+
else
|
|
133
|
+
# Find most and least used colors
|
|
134
|
+
max_freq = frequencies.max
|
|
135
|
+
max_idx = frequencies.index(max_freq)
|
|
136
|
+
min_freq = frequencies.reject(&:zero?).min || 0
|
|
137
|
+
min_idx = frequencies.index(min_freq)
|
|
138
|
+
|
|
139
|
+
add_info("hIST: #{palette_entries} palette entries, " \
|
|
140
|
+
"most used: index #{max_idx} (#{max_freq}), " \
|
|
141
|
+
"least used: index #{min_idx} (#{min_freq})")
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../base_validator"
|
|
4
|
+
require "zlib"
|
|
5
|
+
|
|
6
|
+
module PngConform
|
|
7
|
+
module Validators
|
|
8
|
+
module Ancillary
|
|
9
|
+
# Validator for PNG iCCP (Embedded ICC Profile) chunk
|
|
10
|
+
#
|
|
11
|
+
# iCCP contains an ICC color profile:
|
|
12
|
+
# - Profile name (1-79 bytes, Latin-1)
|
|
13
|
+
# - Null separator (1 byte)
|
|
14
|
+
# - Compression method (1 byte, must be 0)
|
|
15
|
+
# - Compressed profile (deflate compressed ICC profile)
|
|
16
|
+
#
|
|
17
|
+
# Validation rules from PNG spec:
|
|
18
|
+
# - Profile name must be 1-79 characters, Latin-1 printable
|
|
19
|
+
# - Profile name must not have leading/trailing/consecutive spaces
|
|
20
|
+
# - Compression method must be 0 (deflate)
|
|
21
|
+
# - Profile must be successfully decompressible
|
|
22
|
+
# - Must appear before PLTE and IDAT chunks
|
|
23
|
+
# - Only one iCCP chunk allowed
|
|
24
|
+
# - Should not appear with sRGB chunk (warns if both present)
|
|
25
|
+
class IccpValidator < BaseValidator
|
|
26
|
+
# Maximum profile name length
|
|
27
|
+
MAX_PROFILE_NAME_LENGTH = 79
|
|
28
|
+
|
|
29
|
+
# Latin-1 printable characters (space to tilde + high ASCII)
|
|
30
|
+
PRINTABLE_LATIN1 = (32..126).to_a + (161..255).to_a
|
|
31
|
+
|
|
32
|
+
# Valid compression method
|
|
33
|
+
COMPRESSION_DEFLATE = 0
|
|
34
|
+
|
|
35
|
+
# ICC profile signature (first 4 bytes of decompressed data)
|
|
36
|
+
ICC_SIGNATURE_OFFSET = 36
|
|
37
|
+
ICC_SIGNATURE = "acsp"
|
|
38
|
+
|
|
39
|
+
# Validate iCCP chunk
|
|
40
|
+
#
|
|
41
|
+
# @return [Boolean] True if validation passed
|
|
42
|
+
def validate
|
|
43
|
+
return false unless check_crc
|
|
44
|
+
return false unless check_uniqueness
|
|
45
|
+
return false unless check_position
|
|
46
|
+
return false unless check_structure
|
|
47
|
+
return false unless check_profile_name
|
|
48
|
+
return false unless check_compression_method
|
|
49
|
+
return false unless check_decompression
|
|
50
|
+
|
|
51
|
+
check_srgb_conflict
|
|
52
|
+
|
|
53
|
+
store_profile_info
|
|
54
|
+
true
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# Check that only one iCCP chunk exists
|
|
60
|
+
def check_uniqueness
|
|
61
|
+
if context.retrieve(:has_icc_profile)
|
|
62
|
+
add_error("Multiple iCCP chunks (only one allowed)")
|
|
63
|
+
return false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
true
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Check that iCCP appears before PLTE and IDAT
|
|
70
|
+
def check_position
|
|
71
|
+
# Must come before PLTE
|
|
72
|
+
if context.seen?("PLTE")
|
|
73
|
+
add_error("iCCP chunk after PLTE chunk")
|
|
74
|
+
return false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Must come before IDAT
|
|
78
|
+
if context.seen?("IDAT")
|
|
79
|
+
add_error("iCCP chunk after IDAT chunk")
|
|
80
|
+
return false
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
true
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Check iCCP chunk structure
|
|
87
|
+
def check_structure
|
|
88
|
+
data = chunk.chunk_data
|
|
89
|
+
|
|
90
|
+
# Must contain at least name + null + compression method
|
|
91
|
+
if data.length < 3
|
|
92
|
+
add_error("iCCP chunk too short (minimum 3 bytes)")
|
|
93
|
+
return false
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Must contain null separator
|
|
97
|
+
null_pos = data.index("\0")
|
|
98
|
+
unless null_pos
|
|
99
|
+
add_error("iCCP chunk missing null separator")
|
|
100
|
+
return false
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
true
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Check profile name validity
|
|
107
|
+
def check_profile_name
|
|
108
|
+
data = chunk.chunk_data
|
|
109
|
+
null_pos = data.index("\0")
|
|
110
|
+
profile_name = data[0, null_pos]
|
|
111
|
+
|
|
112
|
+
# Check profile name length
|
|
113
|
+
if profile_name.empty?
|
|
114
|
+
add_error("iCCP chunk has empty profile name")
|
|
115
|
+
return false
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
if profile_name.length > MAX_PROFILE_NAME_LENGTH
|
|
119
|
+
add_error("iCCP profile name too long (#{profile_name.length}, " \
|
|
120
|
+
"max #{MAX_PROFILE_NAME_LENGTH})")
|
|
121
|
+
return false
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Check for Latin-1 printable characters
|
|
125
|
+
profile_name.bytes.each do |byte|
|
|
126
|
+
next if PRINTABLE_LATIN1.include?(byte)
|
|
127
|
+
|
|
128
|
+
add_error("iCCP profile name contains non-printable " \
|
|
129
|
+
"character (0x#{byte.to_s(16)})")
|
|
130
|
+
return false
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Check for leading/trailing spaces
|
|
134
|
+
if profile_name.start_with?(" ")
|
|
135
|
+
add_error("iCCP profile name has leading space")
|
|
136
|
+
return false
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
if profile_name.end_with?(" ")
|
|
140
|
+
add_error("iCCP profile name has trailing space")
|
|
141
|
+
return false
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Check for consecutive spaces
|
|
145
|
+
if profile_name.include?(" ")
|
|
146
|
+
add_error("iCCP profile name has consecutive spaces")
|
|
147
|
+
return false
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
true
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Check compression method
|
|
154
|
+
def check_compression_method
|
|
155
|
+
data = chunk.chunk_data
|
|
156
|
+
null_pos = data.index("\0")
|
|
157
|
+
compression_method = data[null_pos + 1].ord
|
|
158
|
+
|
|
159
|
+
unless compression_method == COMPRESSION_DEFLATE
|
|
160
|
+
add_error("iCCP invalid compression method " \
|
|
161
|
+
"(#{compression_method}, must be 0)")
|
|
162
|
+
return false
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
true
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Check that profile data can be decompressed and validate ICC
|
|
169
|
+
# signature
|
|
170
|
+
def check_decompression
|
|
171
|
+
data = chunk.chunk_data
|
|
172
|
+
null_pos = data.index("\0")
|
|
173
|
+
compressed_data = data[(null_pos + 2)..] || ""
|
|
174
|
+
|
|
175
|
+
# Try to decompress
|
|
176
|
+
begin
|
|
177
|
+
decompressed = Zlib::Inflate.inflate(compressed_data)
|
|
178
|
+
|
|
179
|
+
# Check minimum ICC profile size (128 bytes header)
|
|
180
|
+
if decompressed.length < 128
|
|
181
|
+
add_error("iCCP decompressed profile too short " \
|
|
182
|
+
"(#{decompressed.length} bytes, minimum 128)")
|
|
183
|
+
return false
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Verify ICC signature if enough data
|
|
187
|
+
if decompressed.length >= ICC_SIGNATURE_OFFSET + 4
|
|
188
|
+
signature = decompressed[ICC_SIGNATURE_OFFSET, 4]
|
|
189
|
+
unless signature == ICC_SIGNATURE
|
|
190
|
+
add_warning("iCCP profile signature mismatch " \
|
|
191
|
+
"(expected 'acsp', got '#{signature}')")
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
rescue Zlib::Error => e
|
|
195
|
+
add_error("iCCP decompression failed: #{e.message}")
|
|
196
|
+
return false
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
true
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Check for conflict with sRGB chunk
|
|
203
|
+
def check_srgb_conflict
|
|
204
|
+
if context.retrieve(:uses_srgb)
|
|
205
|
+
add_warning("iCCP chunk present with sRGB chunk " \
|
|
206
|
+
"(iCCP takes precedence)")
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
true
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Store ICC profile information in context
|
|
213
|
+
def store_profile_info
|
|
214
|
+
data = chunk.chunk_data
|
|
215
|
+
null_pos = data.index("\0")
|
|
216
|
+
profile_name = data[0, null_pos]
|
|
217
|
+
compressed_data = data[(null_pos + 2)..] || ""
|
|
218
|
+
|
|
219
|
+
# Decompress profile
|
|
220
|
+
decompressed = Zlib::Inflate.inflate(compressed_data)
|
|
221
|
+
|
|
222
|
+
# Store in context
|
|
223
|
+
context.store(:has_icc_profile, true)
|
|
224
|
+
context.store(:icc_profile_name, profile_name)
|
|
225
|
+
context.store(:icc_profile_size, decompressed.length)
|
|
226
|
+
|
|
227
|
+
# Add info about the ICC profile
|
|
228
|
+
compression_ratio = if compressed_data.length.positive?
|
|
229
|
+
(decompressed.length.to_f /
|
|
230
|
+
compressed_data.length).round(2)
|
|
231
|
+
else
|
|
232
|
+
0
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
add_info("iCCP: \"#{profile_name}\" " \
|
|
236
|
+
"(#{decompressed.length} bytes, " \
|
|
237
|
+
"compressed from #{compressed_data.length} bytes, " \
|
|
238
|
+
"ratio #{compression_ratio}:1)")
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|