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,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