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