sevgi-graphics 0.94.0 → 0.95.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.
data/README.md CHANGED
@@ -23,7 +23,7 @@ doc.call
23
23
 
24
24
  ## Ruby compatibility
25
25
 
26
- Requires Ruby 3.4.0 or newer. CI verifies Ruby 3.4 and the current development Ruby from `.ruby-version`.
26
+ Requires Ruby 3.4.0 or newer. CI verifies Ruby 3.4.0 and the current development Ruby from `.ruby-version`.
27
27
 
28
28
  ## Native prerequisites
29
29
 
@@ -20,33 +20,119 @@ module Sevgi
20
20
  # @param given [String, Symbol] attribute name
21
21
  # @return [Boolean]
22
22
  def internal?(given)
23
- (@internal ||= {})[given] ||= given.start_with?(ATTRIBUTE_INTERNAL_PREFIX)
23
+ (@internal ||= {})[given] ||= key(given).start_with?(ATTRIBUTE_INTERNAL_PREFIX)
24
24
  end
25
25
 
26
26
  # Returns the normalized attribute id.
27
27
  # @param given [String, Symbol] attribute name
28
28
  # @return [Symbol]
29
29
  def id(given)
30
- (@id ||= {})[given] ||= (updateable?(given) ? given.to_s.delete_suffix(ATTRIBUTE_UPDATE_SUFFIX) : given)
31
- .to_sym
30
+ (@id ||= {})[given] ||= begin
31
+ name = updateable?(given) ? key(given).delete_suffix(ATTRIBUTE_UPDATE_SUFFIX) : key(given)
32
+ XML.name(name, context: "XML attribute name") unless internal?(given)
33
+ name.to_sym
34
+ end
32
35
  end
33
36
 
34
37
  # Reports whether an attribute uses merge/update syntax.
35
38
  # @param given [String, Symbol] attribute name
36
39
  # @return [Boolean]
37
40
  def updateable?(given)
38
- (@updateable ||= {})[given] ||= given.end_with?(ATTRIBUTE_UPDATE_SUFFIX)
41
+ (@updateable ||= {})[given] ||= key(given).end_with?(ATTRIBUTE_UPDATE_SUFFIX)
42
+ end
43
+
44
+ private
45
+
46
+ def key(given)
47
+ return given.to_s if given.is_a?(::String) || given.is_a?(::Symbol)
48
+
49
+ ArgumentError.("XML attribute name must be a String or Symbol")
39
50
  end
40
51
  end
41
52
 
42
53
  extend Ident
43
54
 
55
+ # Owned mutable snapshots for values entering an attribute store.
56
+ # @api private
57
+ module Snapshot
58
+ class << self
59
+ def capture(value, normalize_keys: false, seen: {}.compare_by_identity)
60
+ case value
61
+ when ::Hash
62
+ nested(value, seen) { capture_hash(value, normalize_keys:, seen:) }
63
+ when ::Array
64
+ nested(value, seen) { value.map { capture(it, seen:) } }
65
+ else
66
+ capture_value(value)
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def capture_hash(value, normalize_keys:, seen:)
73
+ identities = {}
74
+ value.each_with_object({}) do |(key, item), captured|
75
+ key = normalize_keys ? normalize_key(key) : capture(key, seen:)
76
+ identity = normalize_keys ? key : XML.snapshot(key, context: "XML attribute value")
77
+ if captured.key?(key) || identities.key?(identity)
78
+ ArgumentError.("Attribute keys collide after normalization or stringification")
79
+ end
80
+
81
+ identities[identity] = true
82
+ captured[key] = capture(item, seen:)
83
+ end
84
+ end
85
+
86
+ def capture_value(value)
87
+ text = XML.text(value, context: "XML attribute value")
88
+ case value
89
+ when ::Numeric, ::Symbol, ::NilClass, ::TrueClass, ::FalseClass
90
+ value
91
+ else
92
+ text
93
+ end
94
+ end
95
+
96
+ def nested(value, seen)
97
+ ArgumentError.("Cyclic XML attribute value is not supported") if seen.key?(value)
98
+
99
+ seen[value] = true
100
+ yield
101
+ ensure
102
+ seen.delete(value)
103
+ end
104
+
105
+ def normalize_key(key)
106
+ normalized = key.to_sym if key.respond_to?(:to_sym)
107
+ return normalized if normalized.is_a?(::Symbol)
108
+
109
+ ArgumentError.("Attribute Hash keys must normalize to Symbols")
110
+ rescue Sevgi::ArgumentError
111
+ raise
112
+ rescue ::StandardError => e
113
+ ArgumentError.("Attribute Hash key cannot be normalized: #{e.class}: #{e.message}")
114
+ end
115
+ end
116
+ end
117
+
118
+ private_constant :Snapshot
119
+
120
+ # Captures a caller-independent attribute value.
121
+ # @param value [Object] value to capture
122
+ # @param normalize_keys [Boolean] normalize direct Hash keys to Symbols
123
+ # @return [Object] owned mutable snapshot
124
+ # @raise [Sevgi::ArgumentError] when value is invalid, cyclic, collides, or cannot be converted
125
+ # @api private
126
+ def self.capture(value, normalize_keys: false) = Snapshot.capture(value, normalize_keys:)
127
+
44
128
  # Returns the text form used for an XML attribute value before escaping.
45
129
  # @param value [Object] attribute value
46
130
  # @return [String]
131
+ # @raise [Sevgi::ArgumentError] when value is invalid, cyclic, or cannot be stringified as XML
47
132
  # @api private
48
133
  def self.xml_text(value)
49
- case value
134
+ value = XML.snapshot(value, context: "XML attribute value")
135
+ text = case value
50
136
  when ::Hash
51
137
  value.map { |key, attr_value| "#{key}:#{attr_value}" }.join("; ")
52
138
  when ::Array
@@ -54,61 +140,78 @@ module Sevgi
54
140
  else
55
141
  value.to_s
56
142
  end
143
+
144
+ XML.text(text, context: "XML attribute value")
57
145
  end
58
146
 
59
147
  # Mutable SVG attribute store with Sevgi update syntax.
60
148
  class Store
61
- # Creates an attribute store.
149
+ # Creates an attribute store from recursively owned snapshots. Mutable non-container leaves are stringified
150
+ # once; later caller mutation cannot change the store.
62
151
  # @param attributes [Hash] initial attributes
63
152
  # @return [void]
153
+ # @raise [Sevgi::ArgumentError] when input is not a Hash or a name/value is invalid, cyclic, colliding, or cannot
154
+ # be converted
64
155
  def initialize(attributes = {})
65
156
  @store = {}
66
157
 
67
158
  import(attributes)
68
159
  end
69
160
 
70
- # Imports attributes into the store.
161
+ # Atomically imports recursively owned attribute snapshots.
71
162
  # @param attributes [Hash] attributes to merge
72
163
  # @return [Hash] internal store
164
+ # @raise [Sevgi::ArgumentError] when input is not a Hash or a name/value is invalid, cyclic, colliding, or cannot
165
+ # be converted
73
166
  def import(attributes)
74
- hash = attributes
75
- .compact
76
- .to_a
77
- .to_h do |key, value|
78
- [key.to_sym, import_value(value)]
79
- end
167
+ ArgumentError.("Attributes must be imported from a Hash") unless attributes.is_a?(::Hash)
168
+
169
+ hash = attributes.each_with_object({}) do |(key, value), captured|
170
+ next if value.nil?
171
+
172
+ id = Attribute.id(key)
173
+ ArgumentError.("Attribute names collide after normalization: #{id}") if captured.key?(id)
174
+
175
+ captured[id] = Attribute.capture(value, normalize_keys: value.is_a?(::Hash))
176
+ end
80
177
 
81
178
  @store.merge!(hash)
82
179
  end
83
180
 
84
- # Returns an attribute by normalized key.
181
+ # Returns a live stored attribute value. Mutating a returned container intentionally mutates this store; rendering
182
+ # revalidates the resulting value.
85
183
  # @param key [String, Symbol] attribute key
86
184
  # @return [Object, nil]
185
+ # @raise [Sevgi::ArgumentError] when key is not a valid XML attribute name
87
186
  def [](key)
88
187
  @store[Attribute.id(key)]
89
188
  end
90
189
 
91
- # Assigns an attribute value.
190
+ # Assigns a recursively owned attribute snapshot. Mutable non-container leaves are stringified once.
92
191
  # @param key [String, Symbol] attribute key
93
192
  # @param value [Object, nil] attribute value; nil is ignored
94
- # @return [Object, nil] assigned value or nil
193
+ # @return [Object, nil] stored snapshot or nil
95
194
  # @raise [Sevgi::ArgumentError] when update syntax receives incompatible values
96
195
  # @raise [Sevgi::ArgumentError] when update syntax receives an unsupported value type
196
+ # @raise [Sevgi::ArgumentError] when a name/value is invalid, cyclic, colliding, or cannot be converted
97
197
  def []=(key, value)
98
198
  return if value.nil?
99
199
 
100
- @store[id = Attribute.id(key)] = @store.key?(id) && Attribute.updateable?(key) ? update(id, value) : value
200
+ id = Attribute.id(key)
201
+ value = Attribute.capture(value, normalize_keys: value.is_a?(::Hash))
202
+ @store[id] = @store.key?(id) && Attribute.updateable?(key) ? update(id, value) : value
101
203
  end
102
204
 
103
205
  # Deletes an attribute by normalized key.
104
206
  # @param key [String, Symbol] attribute key
105
207
  # @return [Object, nil] deleted value
208
+ # @raise [Sevgi::ArgumentError] when key is not a valid XML attribute name
106
209
  def delete(key)
107
210
  @store.delete(Attribute.id(key))
108
211
  end
109
212
 
110
- # Returns public attributes ready for rendering.
111
- # @return [Hash]
213
+ # Returns public attributes ready for rendering. Nested values remain live store values.
214
+ # @return [Hash] shallow attribute view
112
215
  def export
113
216
  hash = @store.reject { |id, _| Attribute.internal?(id) }
114
217
  return hash unless hash.key?(:id)
@@ -120,16 +223,18 @@ module Sevgi
120
223
  # Reports whether an attribute exists.
121
224
  # @param key [String, Symbol] attribute key
122
225
  # @return [Boolean]
226
+ # @raise [Sevgi::ArgumentError] when key is not a valid XML attribute name
123
227
  def has?(key)
124
228
  @store.key?(Attribute.id(key))
125
229
  end
126
230
 
127
- # Copies the attribute store.
231
+ # Copies the attribute store with recursively independent values.
128
232
  # @param original [Sevgi::Graphics::Attribute::Store] store to copy
129
233
  # @return [void]
234
+ # @raise [Sevgi::ArgumentError] when live stored values became cyclic or invalid
130
235
  def initialize_copy(original)
131
236
  @store = {}
132
- original.store.each { |key, value| @store[key] = value.dup }
237
+ original.store.each { |key, value| @store[key] = Attribute.capture(value) }
133
238
 
134
239
  super
135
240
  end
@@ -140,14 +245,16 @@ module Sevgi
140
245
  export.keys
141
246
  end
142
247
 
143
- # Returns the internal attribute hash.
144
- # @return [Hash]
248
+ # Returns the live internal attribute Hash. Mutating it intentionally mutates this store; rendering revalidates
249
+ # names and values.
250
+ # @return [Hash] live internal store
145
251
  def to_h
146
252
  @store
147
253
  end
148
254
 
149
255
  # Returns rendered XML attribute lines.
150
256
  # @return [Array<String>]
257
+ # @raise [Sevgi::ArgumentError] when stored names or values are invalid, cyclic, or cannot be stringified
151
258
  def to_xml_lines
152
259
  export.map { |id, value| to_xml(id, value) }
153
260
  end
@@ -158,20 +265,6 @@ module Sevgi
158
265
 
159
266
  private
160
267
 
161
- # Returns a caller-owned value copy for import.
162
- # @param value [Object] attribute value
163
- # @return [Object] imported value
164
- def import_value(value)
165
- case value
166
- when ::Hash
167
- value.transform_keys(&:to_sym)
168
- when ::Array
169
- value.dup
170
- else
171
- value
172
- end
173
- end
174
-
175
268
  UPDATER = {
176
269
  ::String => proc { |old_value, new_value| [old_value, new_value].reject(&:empty?).join(" ") },
177
270
  ::Symbol => proc { |old_value, new_value| [old_value, new_value].reject(&:empty?).join(" ").to_sym },
@@ -193,7 +286,8 @@ module Sevgi
193
286
  private_constant :UPDATER
194
287
 
195
288
  def to_xml(id, value)
196
- "#{id}=#{Attribute.xml_text(value).encode(xml: :attr)}"
289
+ name = XML.name(id, context: "XML attribute name")
290
+ "#{name}=#{Attribute.xml_text(value).encode(xml: :attr)}"
197
291
  end
198
292
  end
199
293
  end
@@ -52,15 +52,19 @@ module Sevgi
52
52
  def_delegators :@margin, *Margin.members
53
53
  def_delegators :@size, *Paper.members
54
54
 
55
- # @!attribute [r] size
56
- # @return [Sevgi::Graphics::Paper] paper size object
57
- # @!attribute [r] margin
58
- # @return [Sevgi::Graphics::Margin] canvas margins
59
- # @!attribute [r] inner
60
- # @return [Sevgi::Graphics::Paper] inner paper after margins
61
- attr_reader :size, :margin, :inner
62
-
63
- # Creates a canvas.
55
+ # Returns the paper size object.
56
+ # @return [Sevgi::Graphics::Paper]
57
+ attr_reader :size
58
+
59
+ # Returns the canvas margins.
60
+ # @return [Sevgi::Graphics::Margin]
61
+ attr_reader :margin
62
+
63
+ # Returns the inner paper after margins.
64
+ # @return [Sevgi::Graphics::Paper]
65
+ attr_reader :inner
66
+
67
+ # Creates a canvas with finite real dimensions greater than zero and margins that leave a positive inner area.
64
68
  # @param width [Numeric] canvas width
65
69
  # @param height [Numeric] canvas height
66
70
  # @param unit [Symbol, String] SVG unit
@@ -79,12 +83,12 @@ module Sevgi
79
83
  # @overload attributes(origin = Undefined)
80
84
  # Returns SVG root viewport attributes.
81
85
  # @param origin [Numeric, Array<Numeric>, nil, Sevgi::Undefined] viewBox origin
82
- # @return [Hash]
86
+ # @return [Hash{Symbol => String}] SVG viewport and viewBox attributes
83
87
  # @raise [Sevgi::ArgumentError] when origin is invalid
84
88
  def attributes(...) = {**viewport, viewBox: viewbox(...)}
85
89
 
86
90
  # Returns SVG width and height attributes.
87
- # @return [Hash]
91
+ # @return [Hash{Symbol => String}] SVG width and height attributes
88
92
  def viewport = {width: "#{width}#{unit}", height: "#{height}#{unit}"}
89
93
 
90
94
  # Returns the SVG viewBox string.
@@ -117,7 +121,21 @@ module Sevgi
117
121
  private
118
122
 
119
123
  def compute
120
- @inner = size.with(width: width - margin.left - margin.right, height: height - margin.top - margin.bottom)
124
+ @inner = size.with(**inner_size)
125
+ ensure_inner_area
126
+ end
127
+
128
+ def ensure_inner_area
129
+ return if @inner.width.positive? && @inner.height.positive?
130
+
131
+ ArgumentError.("Canvas margins must leave a positive inner area")
132
+ end
133
+
134
+ def inner_size
135
+ {
136
+ width: width - margin.left - margin.right,
137
+ height: height - margin.top - margin.bottom
138
+ }
121
139
  end
122
140
 
123
141
  def originate(origin)
@@ -125,7 +143,7 @@ module Sevgi
125
143
  when Undefined
126
144
  [-margin.left, -margin.top]
127
145
  when ::Numeric, ::NilClass
128
- [origin.to_f, origin.to_f]
146
+ scalar_origin(origin)
129
147
  when ::Array
130
148
  pair(origin)
131
149
  else
@@ -134,9 +152,13 @@ module Sevgi
134
152
  end
135
153
 
136
154
  def coordinate!(field, value)
137
- Float(value)
138
- rescue ::ArgumentError, ::TypeError
139
- ArgumentError.("Invalid canvas origin #{field}: #{value.inspect}")
155
+ return 0.0 if value.nil?
156
+
157
+ Scalar.finite(value, context: "canvas origin", field:)
158
+ end
159
+
160
+ def scalar_origin(origin)
161
+ coordinate!(:origin, origin || 0).then { [it, it] }
140
162
  end
141
163
 
142
164
  def prettify(*floats)
@@ -2,16 +2,101 @@
2
2
 
3
3
  module Sevgi
4
4
  module Graphics
5
- # Renderable text-like content inside an SVG element.
5
+ # Renderable text-like content inside an SVG element. Content owns an immutable deep snapshot: strings and containers
6
+ # are copied, mutable leaf objects are stringified once during construction, and {#content} returns caller-owned
7
+ # copies. Later mutations cannot change rendering or invalidate the construction-time XML checks.
6
8
  class Content
7
- # @!attribute [r] content
8
- # @return [Object] wrapped content
9
- attr_reader :content
9
+ # Immutable payload capture and caller-owned copy helpers.
10
+ # @api private
11
+ module Snapshot
12
+ SCALARS = [::NilClass, ::TrueClass, ::FalseClass, ::Symbol, ::Integer, ::Float, ::Rational, ::Complex].freeze
13
+ private_constant :SCALARS
10
14
 
11
- # Creates content.
15
+ class << self
16
+ def capture(value, seen = {}.compare_by_identity)
17
+ case value
18
+ when ::String
19
+ XML.text(value).freeze
20
+ when ::Hash
21
+ nested(value, seen) { capture_hash(value, seen) }.freeze
22
+ when ::Array
23
+ nested(value, seen) { value.map { capture(it, seen) } }.freeze
24
+ else
25
+ SCALARS.include?(value.class) ? value : stringify(value).freeze
26
+ end
27
+ end
28
+
29
+ def copy(value)
30
+ case value
31
+ when ::String
32
+ value.dup
33
+ when ::Hash
34
+ value.to_h { |key, item| [copy(key), copy(item)] }
35
+ when ::Array
36
+ value.map { copy(it) }
37
+ else
38
+ value
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def capture_hash(value, seen)
45
+ value.each_with_object({}) do |(key, item), captured|
46
+ key = capture(key, seen)
47
+ ArgumentError.("XML content keys collide after stringification") if captured.key?(key)
48
+
49
+ captured[key] = capture(item, seen)
50
+ end
51
+ end
52
+
53
+ def nested(value, seen)
54
+ ArgumentError.("Cyclic XML content is not supported") if seen.key?(value)
55
+
56
+ seen[value] = true
57
+ yield
58
+ ensure
59
+ seen.delete(value)
60
+ end
61
+
62
+ def stringify(value)
63
+ text = value.to_s
64
+ ArgumentError.("XML content stringification must return a String") unless text.is_a?(::String)
65
+
66
+ XML.text(text)
67
+ rescue Sevgi::ArgumentError
68
+ raise
69
+ rescue ::StandardError => e
70
+ ArgumentError.("XML content cannot be stringified: #{e.class}: #{e.message}")
71
+ end
72
+ end
73
+ end
74
+
75
+ private_constant :Snapshot
76
+
77
+ # Returns a recursively independent, caller-owned payload snapshot.
78
+ # @return [Object] wrapped content snapshot
79
+ def content = Snapshot.copy(@content)
80
+
81
+ # Creates immutable content from a deep payload snapshot. Strings and containers are copied recursively; mutable
82
+ # non-container objects are stringified once during construction. The caller's objects are never retained.
12
83
  # @param content [Object] wrapped content
13
84
  # @return [void]
14
- def initialize(content) = @content = content
85
+ # @raise [Sevgi::ArgumentError] when content cannot be stringified, contains invalid encoding or illegal XML
86
+ # characters, contains cycles, or has keys that collide after stringification
87
+ def initialize(content)
88
+ @content = Snapshot.capture(content)
89
+ XML.validate(@content)
90
+ end
91
+
92
+ # Copies content payload ownership for duplicated element trees.
93
+ # @param original [Sevgi::Graphics::Content] source content
94
+ # @return [void]
95
+ # @api private
96
+ def initialize_copy(original)
97
+ @content = Snapshot.capture(original.content)
98
+ super
99
+ end
15
100
 
16
101
  # Renders content with a renderer.
17
102
  # @abstract Subclasses implement content rendering.
@@ -23,33 +108,45 @@ module Sevgi
23
108
 
24
109
  # Returns content as a string.
25
110
  # @return [String]
26
- def to_s = content.to_s
111
+ def to_s = XML.text(payload)
112
+
113
+ def payload = @content
114
+
115
+ private :payload
27
116
 
28
117
  # @overload cdata(content)
29
118
  # Builds CDATA content.
30
- # Content values are stringified during rendering, and embedded CDATA terminators are split safely.
119
+ # Mutable objects are stringified during construction, and embedded CDATA terminators are split safely.
31
120
  # @param content [Object] wrapped content
32
121
  # @return [Sevgi::Graphics::Content::CData]
122
+ # @raise [Sevgi::ArgumentError] when content cannot be stringified, contains invalid encoding or illegal XML 1.0
123
+ # characters, contains cycles, or has keys that collide after stringification
33
124
  def self.cdata(...) = CData.new(...)
34
125
 
35
126
  # Wraps content arguments, encoding non-content values.
36
- # Non-content values are stringified by encoded content before XML text escaping.
127
+ # Mutable non-content values are stringified by encoded content during construction, before XML text escaping.
37
128
  # @param args [Array<Object>] content arguments
38
129
  # @return [Array<Sevgi::Graphics::Content>]
130
+ # @raise [Sevgi::ArgumentError] when an argument cannot be stringified, contains invalid encoding or illegal XML
131
+ # 1.0 characters, contains cycles, or has keys that collide after stringification
39
132
  def self.contents(*args) = args.map { it.is_a?(Content) ? it : encoded(it) }
40
133
 
41
134
  # @overload css(content)
42
135
  # Builds CSS content.
43
136
  # @param content [Hash] CSS rules
44
137
  # @return [Sevgi::Graphics::Content::CSS]
45
- # @raise [Sevgi::ArgumentError] when content is not a hash
138
+ # @raise [Sevgi::ArgumentError] when content is not a hash, contains a malformed style, cannot be stringified,
139
+ # contains invalid encoding or illegal XML 1.0 characters, contains cycles, or has keys that collide after
140
+ # stringification
46
141
  def self.css(...) = CSS.new(...)
47
142
 
48
143
  # @overload encoded(content)
49
144
  # Builds XML text-encoded content.
50
- # Arbitrary objects are stringified before XML text escaping.
145
+ # Mutable objects are stringified during construction before XML text escaping.
51
146
  # @param content [Object] wrapped content
52
147
  # @return [Sevgi::Graphics::Content::Encoded]
148
+ # @raise [Sevgi::ArgumentError] when content cannot be stringified, contains invalid encoding or illegal XML 1.0
149
+ # characters, contains cycles, or has keys that collide after stringification
53
150
  def self.encoded(...) = Encoded.new(...)
54
151
 
55
152
  # Joins content lines with newlines.
@@ -59,16 +156,17 @@ module Sevgi
59
156
 
60
157
  # @overload verbatim(content)
61
158
  # Builds verbatim content.
159
+ # Mutable objects are stringified during construction.
62
160
  # @param content [Object] wrapped content
63
161
  # @return [Sevgi::Graphics::Content::Verbatim]
162
+ # @raise [Sevgi::ArgumentError] when content cannot be stringified, contains invalid encoding or illegal XML 1.0
163
+ # characters, contains cycles, or has keys that collide after stringification
64
164
  def self.verbatim(...) = Verbatim.new(...)
65
165
 
66
- # CDATA section content.
166
+ # CDATA section content backed by an immutable payload snapshot. Mutable leaf objects are stringified during
167
+ # construction; embedded terminators are split during rendering.
168
+ # @see Content.cdata
67
169
  class CData < Content
68
- TERMINATOR = "]]>"
69
- TERMINATOR_SPLIT = "]]]]><![CDATA[>"
70
- private_constant :TERMINATOR, :TERMINATOR_SPLIT
71
-
72
170
  # Renders CDATA content.
73
171
  # Embedded `]]>` terminators are split across adjacent CDATA sections so the output remains valid XML.
74
172
  # @param renderer [Object] renderer receiving output
@@ -78,23 +176,28 @@ module Sevgi
78
176
  depth += 1
79
177
 
80
178
  renderer.append(depth, "<![CDATA[")
81
- renderer.append(depth + 1, *Array(content).map { safe(it) })
179
+ renderer.append(depth + 1, *Array(payload).map { safe(it) })
82
180
  renderer.append(depth, "]]>")
83
181
  end
84
182
 
85
183
  private
86
184
 
87
- def safe(value) = value.to_s.gsub(TERMINATOR, TERMINATOR_SPLIT)
185
+ def safe(value) = XML.cdata(value)
88
186
  end
89
187
 
90
- # CSS content rendered inside a CDATA section.
188
+ # CSS content rendered inside a CDATA section. Rules are captured recursively during construction; mutable
189
+ # selectors, property names, and values are stringified once, and embedded CDATA terminators are split safely.
190
+ # @see Content.css
91
191
  class CSS < Content
92
192
  # Creates CSS content.
93
193
  # @param content [Hash] CSS rules
94
194
  # @return [void]
95
- # @raise [Sevgi::ArgumentError] when content is not a hash
195
+ # @raise [Sevgi::ArgumentError] when content is not a hash, contains a malformed style, cannot be stringified,
196
+ # contains invalid encoding or illegal XML 1.0 characters, contains cycles, or has keys that collide after
197
+ # stringification
96
198
  def initialize(content)
97
- ArgumentError.("CSS content must be a hash: #{content}") unless content.is_a?(::Hash)
199
+ ArgumentError.("CSS content must be a hash") unless content.is_a?(::Hash)
200
+ validate_styles(content)
98
201
 
99
202
  super
100
203
  end
@@ -104,21 +207,20 @@ module Sevgi
104
207
  # @param renderer [Object] renderer receiving output
105
208
  # @param depth [Integer] current render depth
106
209
  # @return [void]
107
- # @raise [Sevgi::ArgumentError] when a style value cannot be rendered
108
210
  def render(renderer, depth)
109
211
  depth += 1
110
212
 
111
213
  renderer.append(depth, "<![CDATA[")
112
214
 
113
215
  depth += 1
114
- content.each do |rule, styles|
216
+ payload.each do |rule, styles|
115
217
  case styles
116
218
  when ::Hash
117
- renderer.append(depth, "#{rule} {")
118
- renderer.append(depth + 1, *styles.map { |key, value| "#{key}: #{value};" })
219
+ renderer.append(depth, safe("#{rule} {"))
220
+ renderer.append(depth + 1, *styles.map { |key, value| safe("#{key}: #{value};") })
119
221
  renderer.append(depth, "}")
120
222
  when ::String, ::Symbol, ::Numeric
121
- renderer.append(depth, "#{rule}: #{styles};")
223
+ renderer.append(depth, safe("#{rule}: #{styles};"))
122
224
  else
123
225
  ArgumentError.("Malformed style: #{styles}")
124
226
  end
@@ -129,13 +231,27 @@ module Sevgi
129
231
  renderer.append(depth, "]]>")
130
232
  end
131
233
  # rubocop:enable Metrics/MethodLength
234
+
235
+ private
236
+
237
+ def safe(value) = XML.cdata(value)
238
+
239
+ def validate_styles(content)
240
+ content.each_value do |styles|
241
+ next if styles.is_a?(::Hash) || styles.is_a?(::String) || styles.is_a?(::Symbol) || styles.is_a?(::Numeric)
242
+
243
+ ArgumentError.("Malformed style type: #{styles.class}")
244
+ end
245
+ end
132
246
  end
133
247
 
134
- # XML text-encoded content.
248
+ # XML text-encoded content backed by an immutable payload snapshot. Mutable leaf objects are stringified during
249
+ # construction, before XML escaping.
250
+ # @see Content.encoded
135
251
  class Encoded < Content
136
252
  # Returns XML text-encoded content.
137
253
  # @return [String]
138
- def to_s = content.to_s.encode(xml: :text)
254
+ def to_s = XML.text(payload).encode(xml: :text)
139
255
 
140
256
  # Renders encoded text content.
141
257
  # @param renderer [Object] renderer receiving output
@@ -144,8 +260,15 @@ module Sevgi
144
260
  def render(renderer, depth) = renderer.append(depth + 1, to_s)
145
261
  end
146
262
 
147
- # Verbatim content rendered without XML text encoding.
263
+ # Verbatim content backed by an immutable payload snapshot. Mutable leaf objects are stringified during
264
+ # construction. Verbatim content bypasses XML escaping; validation guarantees encoding and legal XML 1.0 code
265
+ # points, not well-formed markup supplied by the caller.
266
+ # @see Content.verbatim
148
267
  class Verbatim < Content
268
+ # Returns validated verbatim content.
269
+ # @return [String]
270
+ def to_s = XML.text(payload)
271
+
149
272
  # Renders verbatim content.
150
273
  # @param renderer [Object] renderer receiving output
151
274
  # @param depth [Integer] current render depth