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,180 @@
|
|
|
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 sCAL (Physical Scale) chunk
|
|
9
|
+
#
|
|
10
|
+
# sCAL specifies the physical scale of the image:
|
|
11
|
+
# - Unit specifier (1 byte)
|
|
12
|
+
# - Pixel width (null-terminated ASCII floating point)
|
|
13
|
+
# - Pixel height (null-terminated ASCII floating point)
|
|
14
|
+
#
|
|
15
|
+
# Validation rules from PNG spec:
|
|
16
|
+
# - Unit must be 1 (meters) or 2 (radians)
|
|
17
|
+
# - Width and height must be valid ASCII floating point numbers
|
|
18
|
+
# - Width and height must be positive
|
|
19
|
+
# - Must appear before IDAT chunk
|
|
20
|
+
# - Only one sCAL chunk allowed
|
|
21
|
+
class ScalValidator < BaseValidator
|
|
22
|
+
# Valid unit specifiers
|
|
23
|
+
UNIT_METER = 1
|
|
24
|
+
UNIT_RADIAN = 2
|
|
25
|
+
VALID_UNITS = [UNIT_METER, UNIT_RADIAN].freeze
|
|
26
|
+
|
|
27
|
+
# Unit names for display
|
|
28
|
+
UNIT_NAMES = {
|
|
29
|
+
UNIT_METER => "meter",
|
|
30
|
+
UNIT_RADIAN => "radian",
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
# ASCII floating point regex (simple form)
|
|
34
|
+
FLOAT_REGEX = /\A[+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\z/
|
|
35
|
+
|
|
36
|
+
# Validate sCAL chunk
|
|
37
|
+
#
|
|
38
|
+
# @return [Boolean] True if validation passed
|
|
39
|
+
def validate
|
|
40
|
+
return false unless check_crc
|
|
41
|
+
return false unless check_uniqueness
|
|
42
|
+
return false unless check_position
|
|
43
|
+
return false unless check_structure
|
|
44
|
+
return false unless check_unit
|
|
45
|
+
return false unless check_dimensions
|
|
46
|
+
|
|
47
|
+
store_scale_info
|
|
48
|
+
true
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# Check that only one sCAL chunk exists
|
|
54
|
+
def check_uniqueness
|
|
55
|
+
if context.seen?("sCAL") || context.retrieve(:has_scale)
|
|
56
|
+
add_error("duplicate sCAL chunk (only one allowed)")
|
|
57
|
+
return false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
true
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Check that sCAL appears before IDAT
|
|
64
|
+
def check_position
|
|
65
|
+
if context.seen?("IDAT")
|
|
66
|
+
add_error("sCAL chunk after IDAT chunk")
|
|
67
|
+
return false
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
true
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Check sCAL chunk structure
|
|
74
|
+
def check_structure
|
|
75
|
+
data = chunk.chunk_data
|
|
76
|
+
|
|
77
|
+
# Must contain at least unit + width + null + height + null
|
|
78
|
+
if data.length < 5
|
|
79
|
+
add_error("sCAL chunk too short (minimum 5 bytes)")
|
|
80
|
+
return false
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Must contain two null separators
|
|
84
|
+
nulls = data.bytes.each_index.select { |i| data[i] == "\0" }
|
|
85
|
+
if nulls.length < 2
|
|
86
|
+
add_error("sCAL chunk missing null separators " \
|
|
87
|
+
"(found #{nulls.length}, need 2)")
|
|
88
|
+
return false
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
true
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Check unit specifier
|
|
95
|
+
def check_unit
|
|
96
|
+
data = chunk.chunk_data
|
|
97
|
+
unit = data[0].ord
|
|
98
|
+
|
|
99
|
+
unless VALID_UNITS.include?(unit)
|
|
100
|
+
add_error("sCAL invalid unit specifier (#{unit}, " \
|
|
101
|
+
"must be 1 or 2)")
|
|
102
|
+
return false
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
true
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Check dimension strings
|
|
109
|
+
def check_dimensions
|
|
110
|
+
data = chunk.chunk_data
|
|
111
|
+
first_null = data.index("\0", 1)
|
|
112
|
+
second_null = data.index("\0", first_null + 1)
|
|
113
|
+
|
|
114
|
+
width_str = data[1, first_null - 1]
|
|
115
|
+
height_str = data[(first_null + 1), second_null - first_null - 1]
|
|
116
|
+
|
|
117
|
+
# Check width
|
|
118
|
+
unless valid_float_string?(width_str)
|
|
119
|
+
add_error("sCAL invalid width format: '#{width_str}'")
|
|
120
|
+
return false
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
width = width_str.to_f
|
|
124
|
+
unless width.positive?
|
|
125
|
+
add_error("sCAL width must be positive (got #{width})")
|
|
126
|
+
return false
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Check height
|
|
130
|
+
unless valid_float_string?(height_str)
|
|
131
|
+
add_error("sCAL invalid height format: '#{height_str}'")
|
|
132
|
+
return false
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
height = height_str.to_f
|
|
136
|
+
unless height.positive?
|
|
137
|
+
add_error("sCAL height must be positive (got #{height})")
|
|
138
|
+
return false
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
true
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Validate floating point string format
|
|
145
|
+
def valid_float_string?(str)
|
|
146
|
+
return false if str.empty?
|
|
147
|
+
return false unless str.match?(FLOAT_REGEX)
|
|
148
|
+
|
|
149
|
+
# Additional check: must be finite
|
|
150
|
+
value = str.to_f
|
|
151
|
+
value.finite?
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Store scale information in context
|
|
155
|
+
def store_scale_info
|
|
156
|
+
data = chunk.chunk_data
|
|
157
|
+
unit = data[0].ord
|
|
158
|
+
first_null = data.index("\0", 1)
|
|
159
|
+
second_null = data.index("\0", first_null + 1)
|
|
160
|
+
|
|
161
|
+
width_str = data[1, first_null - 1]
|
|
162
|
+
height_str = data[(first_null + 1), second_null - first_null - 1]
|
|
163
|
+
|
|
164
|
+
width = width_str.to_f
|
|
165
|
+
height = height_str.to_f
|
|
166
|
+
unit_name = UNIT_NAMES[unit]
|
|
167
|
+
|
|
168
|
+
# Store in context
|
|
169
|
+
context.store(:has_scale, true)
|
|
170
|
+
context.store(:scale_width, width)
|
|
171
|
+
context.store(:scale_height, height)
|
|
172
|
+
context.store(:scale_unit, unit)
|
|
173
|
+
|
|
174
|
+
# Add info about the scale
|
|
175
|
+
add_info("sCAL: #{width} x #{height} #{unit_name}s per pixel")
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,223 @@
|
|
|
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 sPLT (Suggested Palette) chunk
|
|
9
|
+
#
|
|
10
|
+
# sPLT suggests a reduced palette for quantization:
|
|
11
|
+
# - Palette name (1-79 bytes, Latin-1)
|
|
12
|
+
# - Null separator (1 byte)
|
|
13
|
+
# - Sample depth (1 byte, 8 or 16)
|
|
14
|
+
# - Palette entries (6 or 10 bytes each):
|
|
15
|
+
# * Red (1 or 2 bytes)
|
|
16
|
+
# * Green (1 or 2 bytes)
|
|
17
|
+
# * Blue (1 or 2 bytes)
|
|
18
|
+
# * Alpha (1 or 2 bytes)
|
|
19
|
+
# * Frequency (2 bytes)
|
|
20
|
+
#
|
|
21
|
+
# Validation rules from PNG spec:
|
|
22
|
+
# - Palette name must be 1-79 characters, Latin-1 printable
|
|
23
|
+
# - Palette name must not have leading/trailing/consecutive spaces
|
|
24
|
+
# - Sample depth must be 8 or 16
|
|
25
|
+
# - Number of entries must be exact (no partial entries)
|
|
26
|
+
# - Must appear before IDAT chunk
|
|
27
|
+
# - Multiple sPLT chunks allowed with different names
|
|
28
|
+
class SpltValidator < BaseValidator
|
|
29
|
+
# Maximum palette name length
|
|
30
|
+
MAX_PALETTE_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 sample depths
|
|
36
|
+
VALID_SAMPLE_DEPTHS = [8, 16].freeze
|
|
37
|
+
|
|
38
|
+
# Entry sizes by sample depth
|
|
39
|
+
ENTRY_SIZE_8BIT = 6 # RGBA (1 byte each) + frequency (2 bytes)
|
|
40
|
+
ENTRY_SIZE_16BIT = 10 # RGBA (2 bytes each) + frequency (2 bytes)
|
|
41
|
+
|
|
42
|
+
# Validate sPLT chunk
|
|
43
|
+
#
|
|
44
|
+
# @return [Boolean] True if validation passed
|
|
45
|
+
def validate
|
|
46
|
+
return false unless check_crc
|
|
47
|
+
return false unless check_position
|
|
48
|
+
return false unless check_structure
|
|
49
|
+
return false unless check_palette_name
|
|
50
|
+
return false unless check_sample_depth
|
|
51
|
+
return false unless check_entries
|
|
52
|
+
|
|
53
|
+
store_palette_info
|
|
54
|
+
true
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# Check that sPLT appears before IDAT
|
|
60
|
+
def check_position
|
|
61
|
+
if context.seen?("IDAT")
|
|
62
|
+
add_error("sPLT chunk after IDAT chunk")
|
|
63
|
+
return false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
true
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Check sPLT chunk structure
|
|
70
|
+
def check_structure
|
|
71
|
+
data = chunk.chunk_data
|
|
72
|
+
|
|
73
|
+
# Must contain at least name + null + sample depth
|
|
74
|
+
if data.length < 3
|
|
75
|
+
add_error("sPLT chunk too short (minimum 3 bytes)")
|
|
76
|
+
return false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Must contain null separator
|
|
80
|
+
null_pos = data.index("\0")
|
|
81
|
+
unless null_pos
|
|
82
|
+
add_error("sPLT chunk missing null separator")
|
|
83
|
+
return false
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
true
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Check palette name validity
|
|
90
|
+
def check_palette_name
|
|
91
|
+
data = chunk.chunk_data
|
|
92
|
+
null_pos = data.index("\0")
|
|
93
|
+
palette_name = data[0, null_pos]
|
|
94
|
+
|
|
95
|
+
# Check palette name length
|
|
96
|
+
if palette_name.empty?
|
|
97
|
+
add_error("sPLT chunk has empty palette name")
|
|
98
|
+
return false
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
if palette_name.length > MAX_PALETTE_NAME_LENGTH
|
|
102
|
+
add_error("sPLT palette name too long (#{palette_name.length}, " \
|
|
103
|
+
"max #{MAX_PALETTE_NAME_LENGTH})")
|
|
104
|
+
return false
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Check for Latin-1 printable characters
|
|
108
|
+
palette_name.bytes.each do |byte|
|
|
109
|
+
next if PRINTABLE_LATIN1.include?(byte)
|
|
110
|
+
|
|
111
|
+
add_error("sPLT palette name contains non-printable " \
|
|
112
|
+
"character (0x#{byte.to_s(16)})")
|
|
113
|
+
return false
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Check for leading/trailing spaces
|
|
117
|
+
if palette_name.start_with?(" ")
|
|
118
|
+
add_error("sPLT palette name has leading space")
|
|
119
|
+
return false
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
if palette_name.end_with?(" ")
|
|
123
|
+
add_error("sPLT palette name has trailing space")
|
|
124
|
+
return false
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Check for consecutive spaces
|
|
128
|
+
if palette_name.include?(" ")
|
|
129
|
+
add_error("sPLT palette name has consecutive spaces")
|
|
130
|
+
return false
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
true
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Check sample depth
|
|
137
|
+
def check_sample_depth
|
|
138
|
+
data = chunk.chunk_data
|
|
139
|
+
null_pos = data.index("\0")
|
|
140
|
+
sample_depth = data[null_pos + 1].ord
|
|
141
|
+
|
|
142
|
+
unless VALID_SAMPLE_DEPTHS.include?(sample_depth)
|
|
143
|
+
add_error("sPLT invalid sample depth (#{sample_depth}, " \
|
|
144
|
+
"must be 8 or 16)")
|
|
145
|
+
return false
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
true
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Check palette entries
|
|
152
|
+
def check_entries
|
|
153
|
+
data = chunk.chunk_data
|
|
154
|
+
null_pos = data.index("\0")
|
|
155
|
+
sample_depth = data[null_pos + 1].ord
|
|
156
|
+
entries_data = data[(null_pos + 2)..] || ""
|
|
157
|
+
|
|
158
|
+
# Determine entry size
|
|
159
|
+
entry_size = sample_depth == 8 ? ENTRY_SIZE_8BIT : ENTRY_SIZE_16BIT
|
|
160
|
+
|
|
161
|
+
# Check that entries data is exact multiple of entry size
|
|
162
|
+
if (entries_data.length % entry_size) != 0
|
|
163
|
+
add_error("sPLT entries data length (#{entries_data.length}) " \
|
|
164
|
+
"not a multiple of entry size (#{entry_size})")
|
|
165
|
+
return false
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Check minimum entries (at least 1)
|
|
169
|
+
num_entries = entries_data.length / entry_size
|
|
170
|
+
if num_entries.zero?
|
|
171
|
+
add_error("sPLT chunk has no palette entries")
|
|
172
|
+
return false
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
true
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Store suggested palette information in context
|
|
179
|
+
def store_palette_info
|
|
180
|
+
data = chunk.chunk_data
|
|
181
|
+
null_pos = data.index("\0")
|
|
182
|
+
palette_name = data[0, null_pos]
|
|
183
|
+
sample_depth = data[null_pos + 1].ord
|
|
184
|
+
entries_data = data[(null_pos + 2)..] || ""
|
|
185
|
+
|
|
186
|
+
# Calculate number of entries
|
|
187
|
+
entry_size = sample_depth == 8 ? ENTRY_SIZE_8BIT : ENTRY_SIZE_16BIT
|
|
188
|
+
num_entries = entries_data.length / entry_size
|
|
189
|
+
|
|
190
|
+
# Parse entries and find frequency range
|
|
191
|
+
frequencies = []
|
|
192
|
+
(0...num_entries).each do |i|
|
|
193
|
+
offset = i * entry_size
|
|
194
|
+
freq_offset = offset + (entry_size - 2)
|
|
195
|
+
freq = (entries_data[freq_offset].ord << 8) |
|
|
196
|
+
entries_data[freq_offset + 1].ord
|
|
197
|
+
frequencies << freq
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Store in context (allow multiple sPLT chunks)
|
|
201
|
+
palettes = context.retrieve(:suggested_palettes) || []
|
|
202
|
+
palettes << {
|
|
203
|
+
name: palette_name,
|
|
204
|
+
sample_depth: sample_depth,
|
|
205
|
+
num_entries: num_entries,
|
|
206
|
+
frequencies: frequencies,
|
|
207
|
+
}
|
|
208
|
+
context.store(:suggested_palettes, palettes)
|
|
209
|
+
|
|
210
|
+
# Add info about the suggested palette
|
|
211
|
+
max_freq = frequencies.max || 0
|
|
212
|
+
min_freq = frequencies.min || 0
|
|
213
|
+
total_freq = frequencies.sum
|
|
214
|
+
|
|
215
|
+
add_info("sPLT: \"#{palette_name}\" " \
|
|
216
|
+
"(#{num_entries} entries, #{sample_depth}-bit, " \
|
|
217
|
+
"frequency range #{min_freq}-#{max_freq}, " \
|
|
218
|
+
"total #{total_freq})")
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
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 sRGB (Standard RGB Color Space) chunk
|
|
9
|
+
#
|
|
10
|
+
# sRGB indicates that the image uses the sRGB color space:
|
|
11
|
+
# - Rendering intent (1 byte):
|
|
12
|
+
# 0 = Perceptual
|
|
13
|
+
# 1 = Relative colorimetric
|
|
14
|
+
# 2 = Saturation
|
|
15
|
+
# 3 = Absolute colorimetric
|
|
16
|
+
#
|
|
17
|
+
# Validation rules from PNG spec:
|
|
18
|
+
# - Must be exactly 1 byte
|
|
19
|
+
# - Must appear before PLTE and IDAT
|
|
20
|
+
# - Only one sRGB chunk allowed
|
|
21
|
+
# - Rendering intent must be 0-3
|
|
22
|
+
# - If both sRGB and gAMA present, gAMA should be 45455 (gamma 2.2)
|
|
23
|
+
# - If both sRGB and cHRM present, values should match sRGB
|
|
24
|
+
class SrgbValidator < BaseValidator
|
|
25
|
+
# Rendering intents
|
|
26
|
+
INTENT_PERCEPTUAL = 0
|
|
27
|
+
INTENT_RELATIVE_COLORIMETRIC = 1
|
|
28
|
+
INTENT_SATURATION = 2
|
|
29
|
+
INTENT_ABSOLUTE_COLORIMETRIC = 3
|
|
30
|
+
|
|
31
|
+
INTENT_NAMES = {
|
|
32
|
+
INTENT_PERCEPTUAL => "perceptual",
|
|
33
|
+
INTENT_RELATIVE_COLORIMETRIC => "relative colorimetric",
|
|
34
|
+
INTENT_SATURATION => "saturation",
|
|
35
|
+
INTENT_ABSOLUTE_COLORIMETRIC => "absolute colorimetric",
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
# Expected gamma for sRGB (45455 = 2.2)
|
|
39
|
+
SRGB_GAMMA = 45_455
|
|
40
|
+
|
|
41
|
+
# Validate sRGB chunk
|
|
42
|
+
#
|
|
43
|
+
# @return [Boolean] True if validation passed
|
|
44
|
+
def validate
|
|
45
|
+
return false unless check_crc
|
|
46
|
+
return false unless check_length(1)
|
|
47
|
+
return false unless check_position
|
|
48
|
+
return false unless check_uniqueness
|
|
49
|
+
return false unless check_intent
|
|
50
|
+
|
|
51
|
+
check_gamma_consistency
|
|
52
|
+
|
|
53
|
+
store_srgb_info
|
|
54
|
+
true
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# Check sRGB position relative to other chunks
|
|
60
|
+
def check_position
|
|
61
|
+
valid = true
|
|
62
|
+
|
|
63
|
+
# sRGB should appear before PLTE and IDAT
|
|
64
|
+
if context.seen?("PLTE")
|
|
65
|
+
add_warning("sRGB chunk after PLTE (should be before)")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
if context.seen?("IDAT")
|
|
69
|
+
add_error("sRGB chunk after IDAT (must be before)")
|
|
70
|
+
valid = false
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
valid
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Check that only one sRGB chunk is present
|
|
77
|
+
def check_uniqueness
|
|
78
|
+
if context.seen?("sRGB")
|
|
79
|
+
add_error("duplicate sRGB chunk")
|
|
80
|
+
return false
|
|
81
|
+
end
|
|
82
|
+
true
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Check rendering intent
|
|
86
|
+
def check_intent
|
|
87
|
+
intent = chunk.chunk_data.unpack1("C")
|
|
88
|
+
check_enum(intent, INTENT_NAMES.keys, "rendering intent")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Check consistency with gAMA chunk if present
|
|
92
|
+
def check_gamma_consistency
|
|
93
|
+
return unless context.seen?("gAMA")
|
|
94
|
+
|
|
95
|
+
gamma = context.retrieve(:gamma)
|
|
96
|
+
return unless gamma
|
|
97
|
+
|
|
98
|
+
return unless gamma != SRGB_GAMMA
|
|
99
|
+
|
|
100
|
+
actual_gamma = gamma / 100_000.0
|
|
101
|
+
add_warning("sRGB chunk present but gAMA is #{actual_gamma} " \
|
|
102
|
+
"(should be 2.2 for sRGB)")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Store sRGB information in context
|
|
106
|
+
def store_srgb_info
|
|
107
|
+
intent = chunk.chunk_data.unpack1("C")
|
|
108
|
+
context.store(:srgb_intent, intent)
|
|
109
|
+
context.store(:uses_srgb, true)
|
|
110
|
+
|
|
111
|
+
intent_name = INTENT_NAMES[intent]
|
|
112
|
+
add_info("sRGB: #{intent_name} rendering intent")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
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 sTER (Stereo Image Indicator) chunk
|
|
9
|
+
#
|
|
10
|
+
# sTER indicates that the image is part of a stereo image pair:
|
|
11
|
+
# - Mode (1 byte)
|
|
12
|
+
#
|
|
13
|
+
# Validation rules from PNG spec:
|
|
14
|
+
# - Chunk must be exactly 1 byte
|
|
15
|
+
# - Mode must be 0 (cross-fuse) or 1 (diverging-fuse)
|
|
16
|
+
# - Must appear before IDAT chunk
|
|
17
|
+
# - Only one sTER chunk allowed
|
|
18
|
+
class SterValidator < BaseValidator
|
|
19
|
+
# Expected chunk length
|
|
20
|
+
EXPECTED_LENGTH = 1
|
|
21
|
+
|
|
22
|
+
# Valid stereo modes
|
|
23
|
+
MODE_CROSS_FUSE = 0
|
|
24
|
+
MODE_DIVERGING_FUSE = 1
|
|
25
|
+
VALID_MODES = [MODE_CROSS_FUSE, MODE_DIVERGING_FUSE].freeze
|
|
26
|
+
|
|
27
|
+
# Mode names for display
|
|
28
|
+
MODE_NAMES = {
|
|
29
|
+
MODE_CROSS_FUSE => "cross-fuse layout",
|
|
30
|
+
MODE_DIVERGING_FUSE => "diverging-fuse layout",
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
# Validate sTER chunk
|
|
34
|
+
#
|
|
35
|
+
# @return [Boolean] True if validation passed
|
|
36
|
+
def validate
|
|
37
|
+
return false unless check_crc
|
|
38
|
+
return false unless check_uniqueness
|
|
39
|
+
return false unless check_position
|
|
40
|
+
return false unless check_length
|
|
41
|
+
return false unless check_mode
|
|
42
|
+
|
|
43
|
+
store_stereo_info
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# Check that only one sTER chunk exists
|
|
50
|
+
def check_uniqueness
|
|
51
|
+
if context.retrieve(:has_stereo)
|
|
52
|
+
add_error("Multiple sTER chunks (only one allowed)")
|
|
53
|
+
return false
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
true
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Check that sTER appears before IDAT
|
|
60
|
+
def check_position
|
|
61
|
+
if context.seen?("IDAT")
|
|
62
|
+
add_error("sTER chunk after IDAT chunk")
|
|
63
|
+
return false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
true
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Check chunk length
|
|
70
|
+
def check_length
|
|
71
|
+
actual_length = chunk.chunk_data.length
|
|
72
|
+
|
|
73
|
+
unless actual_length == EXPECTED_LENGTH
|
|
74
|
+
add_error("sTER chunk wrong length (#{actual_length} byte(s), " \
|
|
75
|
+
"expected #{EXPECTED_LENGTH})")
|
|
76
|
+
return false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
true
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Check stereo mode
|
|
83
|
+
def check_mode
|
|
84
|
+
data = chunk.chunk_data
|
|
85
|
+
mode = data[0].ord
|
|
86
|
+
|
|
87
|
+
unless VALID_MODES.include?(mode)
|
|
88
|
+
add_error("sTER invalid mode (#{mode}, must be 0 or 1)")
|
|
89
|
+
return false
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
true
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Store stereo information in context
|
|
96
|
+
def store_stereo_info
|
|
97
|
+
data = chunk.chunk_data
|
|
98
|
+
mode = data[0].ord
|
|
99
|
+
mode_name = MODE_NAMES[mode]
|
|
100
|
+
|
|
101
|
+
# Store in context
|
|
102
|
+
context.store(:has_stereo, true)
|
|
103
|
+
context.store(:stereo_mode, mode)
|
|
104
|
+
|
|
105
|
+
# Add info about the stereo mode
|
|
106
|
+
add_info("sTER: #{mode_name} (mode #{mode})")
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|