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,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PngConform
4
+ module Validators
5
+ module Apng
6
+ # Validator for fcTL (Frame Control) chunk
7
+ #
8
+ # The fcTL chunk specifies the parameters of each frame in an animated PNG.
9
+ #
10
+ # Structure (26 bytes):
11
+ # - sequence_number (4 bytes): Sequence number (starts at 0)
12
+ # - width (4 bytes): Frame width in pixels
13
+ # - height (4 bytes): Frame height in pixels
14
+ # - x_offset (4 bytes): X position at which to render frame
15
+ # - y_offset (4 bytes): Y position at which to render frame
16
+ # - delay_num (2 bytes): Frame delay numerator
17
+ # - delay_den (2 bytes): Frame delay denominator
18
+ # - dispose_op (1 byte): Disposal operation (0-2)
19
+ # - blend_op (1 byte): Blend operation (0-1)
20
+ #
21
+ # Constraints:
22
+ # - Must have acTL chunk before any fcTL
23
+ # - Frame dimensions must be <= IHDR dimensions
24
+ # - Frame position + size must fit within IHDR dimensions
25
+ # - Sequence numbers must be consecutive
26
+ # - dispose_op: 0=NONE, 1=BACKGROUND, 2=PREVIOUS
27
+ # - blend_op: 0=SOURCE, 1=OVER
28
+ #
29
+ class FctlValidator < BaseValidator
30
+ CHUNK_TYPE = "fcTL"
31
+ EXPECTED_LENGTH = 26
32
+
33
+ DISPOSE_OP_NONE = 0
34
+ DISPOSE_OP_BACKGROUND = 1
35
+ DISPOSE_OP_PREVIOUS = 2
36
+
37
+ BLEND_OP_SOURCE = 0
38
+ BLEND_OP_OVER = 1
39
+
40
+ def validate
41
+ return unless check_crc
42
+ return unless check_length(EXPECTED_LENGTH)
43
+
44
+ validate_structure
45
+ validate_frame_dimensions
46
+ validate_sequence_number
47
+ end
48
+
49
+ private
50
+
51
+ def validate_structure
52
+ data = chunk.chunk_data
53
+
54
+ # Extract all fields
55
+ sequence_number = data[0..3].unpack1("N")
56
+ width = data[4..7].unpack1("N")
57
+ height = data[8..11].unpack1("N")
58
+ data[12..15].unpack1("N")
59
+ data[16..19].unpack1("N")
60
+ delay_num = data[20..21].unpack1("n")
61
+ delay_den = data[22..23].unpack1("n")
62
+ dispose_op = data[24].unpack1("C")
63
+ blend_op = data[25].unpack1("C")
64
+
65
+ # Store in context
66
+ context.store(:last_fctl_sequence, sequence_number)
67
+ context.store(:fctl_count, (context.retrieve(:fctl_count) || 0) + 1)
68
+
69
+ # Validate frame dimensions
70
+ if width.zero? || height.zero?
71
+ add_error("fcTL width and height must be > 0")
72
+ return false
73
+ end
74
+
75
+ # Validate dispose_op
76
+ unless check_enum(dispose_op, [DISPOSE_OP_NONE, DISPOSE_OP_BACKGROUND,
77
+ DISPOSE_OP_PREVIOUS], "dispose_op")
78
+ return false
79
+ end
80
+
81
+ # Validate blend_op
82
+ unless check_enum(blend_op, [BLEND_OP_SOURCE, BLEND_OP_OVER],
83
+ "blend_op")
84
+ return false
85
+ end
86
+
87
+ # Validate delay
88
+ if delay_den.zero?
89
+ # delay_den of 0 means denominator is 100
90
+ context.store(:frame_delay, delay_num / 100.0)
91
+ else
92
+ context.store(:frame_delay, delay_num.to_f / delay_den)
93
+ end
94
+
95
+ true
96
+ end
97
+
98
+ def validate_frame_dimensions
99
+ ihdr_width = context.retrieve(:ihdr_width)
100
+ ihdr_height = context.retrieve(:ihdr_height)
101
+
102
+ return true unless ihdr_width && ihdr_height
103
+
104
+ data = chunk.chunk_data
105
+ width = data[4..7].unpack1("N")
106
+ height = data[8..11].unpack1("N")
107
+ x_offset = data[12..15].unpack1("N")
108
+ y_offset = data[16..19].unpack1("N")
109
+
110
+ # Frame must fit within IHDR dimensions
111
+ if width > ihdr_width || height > ihdr_height
112
+ add_error("fcTL frame dimensions exceed IHDR dimensions")
113
+ return false
114
+ end
115
+
116
+ # Frame position + size must fit within IHDR
117
+ if x_offset + width > ihdr_width
118
+ add_error("fcTL frame extends beyond IHDR width")
119
+ return false
120
+ end
121
+
122
+ if y_offset + height > ihdr_height
123
+ add_error("fcTL frame extends beyond IHDR height")
124
+ return false
125
+ end
126
+
127
+ true
128
+ end
129
+
130
+ def validate_sequence_number
131
+ # Check if acTL exists
132
+ unless context.retrieve(:actl_num_frames)
133
+ add_error("fcTL requires acTL chunk")
134
+ return false
135
+ end
136
+
137
+ data = chunk.chunk_data
138
+ sequence_number = data[0..3].unpack1("N")
139
+ expected_sequence = context.retrieve(:expected_apng_sequence) || 0
140
+
141
+ if sequence_number != expected_sequence
142
+ add_error("fcTL sequence number mismatch " \
143
+ "(expected #{expected_sequence}, got #{sequence_number})")
144
+ return false
145
+ end
146
+
147
+ # Update expected sequence for next frame chunk
148
+ context.store(:expected_apng_sequence, expected_sequence + 1)
149
+
150
+ true
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PngConform
4
+ module Validators
5
+ module Apng
6
+ # Validator for fdAT (Frame Data) chunk
7
+ #
8
+ # The fdAT chunk contains the compressed image data for a single frame
9
+ # in an animated PNG. It is similar to IDAT but includes a sequence number.
10
+ #
11
+ # Structure:
12
+ # - sequence_number (4 bytes): Sequence number
13
+ # - frame_data (remaining bytes): Compressed image data
14
+ #
15
+ # Constraints:
16
+ # - Must have fcTL chunk before fdAT
17
+ # - Sequence numbers must be consecutive (continuing from fcTL)
18
+ # - Must have acTL chunk present
19
+ # - Frame data must be valid zlib compressed data
20
+ #
21
+ class FdatValidator < BaseValidator
22
+ CHUNK_TYPE = "fdAT"
23
+ MIN_LENGTH = 5 # At least 4 bytes for sequence + 1 byte data
24
+
25
+ def validate
26
+ return unless check_crc
27
+ return unless validate_min_length
28
+
29
+ validate_structure
30
+ validate_sequence_number
31
+ validate_ordering
32
+ end
33
+
34
+ private
35
+
36
+ def validate_min_length
37
+ actual = chunk.chunk_data.length
38
+ if actual < MIN_LENGTH
39
+ add_error("fdAT chunk too short (#{actual} bytes, " \
40
+ "minimum #{MIN_LENGTH})")
41
+ return false
42
+ end
43
+ true
44
+ end
45
+
46
+ def validate_structure
47
+ data = chunk.chunk_data
48
+
49
+ # Extract sequence number
50
+ sequence_number = data[0..3].unpack1("N")
51
+
52
+ # Store in context
53
+ context.store(:last_fdat_sequence, sequence_number)
54
+ context.store(:fdat_count, (context.retrieve(:fdat_count) || 0) + 1)
55
+
56
+ # Frame data is the rest (starting at byte 4)
57
+ frame_data = data[4..]
58
+
59
+ if frame_data.nil? || frame_data.empty?
60
+ add_error("fdAT has no frame data")
61
+ return false
62
+ end
63
+
64
+ # Store frame data length for statistics
65
+ context.store(:fdat_data_length,
66
+ (context.retrieve(:fdat_data_length) || 0) + frame_data.length)
67
+
68
+ true
69
+ end
70
+
71
+ def validate_sequence_number
72
+ # Check if acTL exists
73
+ unless context.retrieve(:actl_num_frames)
74
+ add_error("fdAT requires acTL chunk")
75
+ return false
76
+ end
77
+
78
+ data = chunk.chunk_data
79
+ sequence_number = data[0..3].unpack1("N")
80
+ expected_sequence = context.retrieve(:expected_apng_sequence)
81
+
82
+ # First fdAT should follow fcTL sequences
83
+ if expected_sequence.nil?
84
+ add_error("fdAT must follow fcTL chunk")
85
+ return false
86
+ end
87
+
88
+ if sequence_number != expected_sequence
89
+ add_error("fdAT sequence number mismatch " \
90
+ "(expected #{expected_sequence}, got #{sequence_number})")
91
+ return false
92
+ end
93
+
94
+ # Update expected sequence for next chunk
95
+ context.store(:expected_apng_sequence, expected_sequence + 1)
96
+
97
+ true
98
+ end
99
+
100
+ def validate_ordering
101
+ # fdAT must have corresponding fcTL
102
+ fctl_count = context.retrieve(:fctl_count) || 0
103
+ context.retrieve(:fdat_count) || 0
104
+
105
+ # Each frame should have fcTL followed by fdAT(s)
106
+ # We can have multiple fdAT chunks per frame, but must have at least one fcTL
107
+ if fctl_count.zero?
108
+ add_error("fdAT chunk requires fcTL chunk")
109
+ return false
110
+ end
111
+
112
+ true
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module PngConform
6
+ module Validators
7
+ # Base class for all chunk validators
8
+ #
9
+ # Validators follow a consistent pattern:
10
+ # 1. Initialize with chunk and context
11
+ # 2. Validate method returns ValidationResult
12
+ # 3. Protected helper methods for specific checks
13
+ #
14
+ # Validators are MECE - each handles exactly one chunk type
15
+ # and validates all aspects of that chunk type completely.
16
+ class BaseValidator
17
+ attr_reader :chunk, :context
18
+
19
+ # Initialize validator with chunk and validation context
20
+ #
21
+ # @param chunk [BinData::Record] The chunk to validate
22
+ # @param context [ValidationContext] The validation context
23
+ def initialize(chunk, context = nil)
24
+ @chunk = chunk
25
+ @context = context || ValidationContext.new
26
+ end
27
+
28
+ # Validate the chunk
29
+ #
30
+ # @return [ValidationResult] The validation result
31
+ def validate
32
+ raise NotImplementedError, "Subclasses must implement #validate"
33
+ end
34
+
35
+ protected
36
+
37
+ # Add an error to the validation result
38
+ #
39
+ # @param message [String] The error message
40
+ # @param severity [Symbol] :error, :warning, or :info
41
+ def add_error(message, severity: :error)
42
+ context.add_error(
43
+ chunk_type: chunk.chunk_type,
44
+ message: message,
45
+ severity: severity,
46
+ offset: chunk.abs_offset,
47
+ )
48
+ end
49
+
50
+ # Add a warning to the validation result
51
+ #
52
+ # @param message [String] The warning message
53
+ def add_warning(message)
54
+ add_error(message, severity: :warning)
55
+ end
56
+
57
+ # Add an info message to the validation result
58
+ #
59
+ # @param message [String] The info message
60
+ def add_info(message)
61
+ add_error(message, severity: :info)
62
+ end
63
+
64
+ # Check if chunk data length matches expected length
65
+ #
66
+ # @param expected [Integer] Expected data length
67
+ # @return [Boolean] True if length matches
68
+ def check_length(expected)
69
+ actual = chunk.chunk_data.length
70
+ return true if actual == expected
71
+
72
+ add_error("invalid #{chunk.chunk_type} length (#{actual}, " \
73
+ "should be #{expected})")
74
+ false
75
+ end
76
+
77
+ # Check if value is within valid range
78
+ #
79
+ # @param value [Integer] Value to check
80
+ # @param min [Integer] Minimum valid value
81
+ # @param max [Integer] Maximum valid value
82
+ # @param name [String] Name of the value for error message
83
+ # @return [Boolean] True if value is in range
84
+ def check_range(value, min, max, name)
85
+ return true if value >= min && value <= max
86
+
87
+ add_error("invalid #{name} (#{value}, must be #{min}-#{max})")
88
+ false
89
+ end
90
+
91
+ # Check if value is one of valid options
92
+ #
93
+ # @param value [Object] Value to check
94
+ # @param valid [Array] Array of valid values
95
+ # @param name [String] Name of the value for error message
96
+ # @return [Boolean] True if value is valid
97
+ def check_enum(value, valid, name)
98
+ return true if valid.include?(value)
99
+
100
+ add_error("invalid #{name} (#{value}, must be one of " \
101
+ "#{valid.join(', ')})")
102
+ false
103
+ end
104
+
105
+ # Check if chunk CRC is valid
106
+ #
107
+ # @return [Boolean] True if CRC is valid
108
+ def check_crc
109
+ return true if chunk.crc_valid?
110
+
111
+ add_error("CRC error in #{chunk.chunk_type} chunk")
112
+ false
113
+ end
114
+ end
115
+
116
+ # Validation context maintains state during validation
117
+ class ValidationContext
118
+ attr_reader :errors, :chunks_seen, :file_info
119
+
120
+ def initialize
121
+ @errors = []
122
+ @chunks_seen = {}
123
+ @file_info = {}
124
+ end
125
+
126
+ # Add an error/warning/info to the context
127
+ #
128
+ # @param chunk_type [String] The chunk type
129
+ # @param message [String] The error message
130
+ # @param severity [Symbol] :error, :warning, or :info
131
+ # @param offset [Integer] File offset of the error
132
+ def add_error(chunk_type:, message:, severity: :error, offset: nil)
133
+ @errors << {
134
+ chunk_type: chunk_type,
135
+ message: message,
136
+ severity: severity,
137
+ offset: offset,
138
+ }
139
+ end
140
+
141
+ # Record that a chunk type has been seen
142
+ #
143
+ # @param chunk_type [String] The chunk type
144
+ # @param chunk [BinData::Record] The chunk
145
+ def record_chunk(chunk_type, chunk = nil)
146
+ @chunks_seen[chunk_type] ||= []
147
+ @chunks_seen[chunk_type] << chunk if chunk
148
+ end
149
+ alias mark_chunk_seen record_chunk
150
+
151
+ # Check if a chunk type has been seen
152
+ #
153
+ # @param chunk_type [String] The chunk type
154
+ # @return [Boolean] True if chunk type has been seen
155
+ def seen?(chunk_type)
156
+ @chunks_seen.key?(chunk_type)
157
+ end
158
+
159
+ # Get chunks of a specific type
160
+ #
161
+ # @param chunk_type [String] The chunk type
162
+ # @return [Array] Array of chunks of that type
163
+ def chunks_of_type(chunk_type)
164
+ @chunks_seen[chunk_type] || []
165
+ end
166
+
167
+ # Store file information
168
+ #
169
+ # @param key [Symbol] The key
170
+ # @param value [Object] The value
171
+ def store(key, value)
172
+ @file_info[key] = value
173
+ end
174
+
175
+ # Retrieve file information
176
+ #
177
+ # @param key [Symbol] The key
178
+ # @return [Object] The value
179
+ def retrieve(key)
180
+ @file_info[key]
181
+ end
182
+
183
+ # Check if validation has errors
184
+ #
185
+ # @return [Boolean] True if there are any errors
186
+ def has_errors?
187
+ @errors.any? { |e| e[:severity] == :error }
188
+ end
189
+
190
+ # Check if validation has warnings
191
+ #
192
+ # @return [Boolean] True if there are any warnings
193
+ def has_warnings?
194
+ @errors.any? { |e| e[:severity] == :warning }
195
+ end
196
+
197
+ # Get all errors
198
+ #
199
+ # @return [Array] Array of error hashes
200
+ def all_errors
201
+ @errors.select { |e| e[:severity] == :error }
202
+ end
203
+
204
+ # Get all warnings
205
+ #
206
+ # @return [Array] Array of warning hashes
207
+ def all_warnings
208
+ @errors.select { |e| e[:severity] == :warning }
209
+ end
210
+
211
+ # Get all info messages
212
+ #
213
+ # @return [Array] Array of info hashes
214
+ def all_info
215
+ @errors.select { |e| e[:severity] == :info }
216
+ end
217
+
218
+ # Provide attribute-style access to file_info
219
+ #
220
+ # Allows context.width instead of context.retrieve(:width)
221
+ # and context.width = 100 instead of context.store(:width, 100)
222
+ def method_missing(method, *args)
223
+ method_name = method.to_s
224
+ if method_name.end_with?("=")
225
+ # Setter: context.width = 100
226
+ key = method_name.chomp("=").to_sym
227
+ store(key, args.first)
228
+ elsif args.empty?
229
+ # Getter: context.width
230
+ retrieve(method.to_sym)
231
+ else
232
+ super
233
+ end
234
+ end
235
+
236
+ def respond_to_missing?(_method, _include_private = false)
237
+ true
238
+ end
239
+ end
240
+ end
241
+ end