png_conform 0.1.0

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