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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +150 -2
- data/LICENSE +672 -3
- data/README.md +1 -1
- data/lib/sevgi/graphics/attribute.rb +132 -38
- data/lib/sevgi/graphics/auxiliary/canvas.rb +38 -16
- data/lib/sevgi/graphics/auxiliary/content.rb +152 -29
- data/lib/sevgi/graphics/auxiliary/margin.rb +14 -2
- data/lib/sevgi/graphics/auxiliary/paper.rb +91 -84
- data/lib/sevgi/graphics/auxiliary/scalar.rb +40 -0
- data/lib/sevgi/graphics/auxiliary/sizes.rb +62 -0
- data/lib/sevgi/graphics/auxiliary.rb +2 -0
- data/lib/sevgi/graphics/document.rb +186 -74
- data/lib/sevgi/graphics/element.rb +41 -14
- data/lib/sevgi/graphics/mixtures/core.rb +89 -18
- data/lib/sevgi/graphics/mixtures/duplicate.rb +33 -13
- data/lib/sevgi/graphics/mixtures/export.rb +6 -0
- data/lib/sevgi/graphics/mixtures/include.rb +17 -0
- data/lib/sevgi/graphics/mixtures/inkscape.rb +15 -0
- data/lib/sevgi/graphics/mixtures/rdf.rb +20 -0
- data/lib/sevgi/graphics/mixtures/render.rb +56 -11
- data/lib/sevgi/graphics/mixtures/save.rb +9 -0
- data/lib/sevgi/graphics/mixtures/symbols.rb +3 -0
- data/lib/sevgi/graphics/mixtures/tile.rb +6 -0
- data/lib/sevgi/graphics/mixtures/underscore.rb +2 -2
- data/lib/sevgi/graphics/mixtures/validate.rb +12 -0
- data/lib/sevgi/graphics/mixtures/wrappers.rb +4 -1
- data/lib/sevgi/graphics/mixtures.rb +4 -0
- data/lib/sevgi/graphics/version.rb +1 -1
- data/lib/sevgi/graphics/xml.rb +127 -0
- data/lib/sevgi/graphics.rb +19 -18
- metadata +7 -4
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] ||=
|
|
31
|
-
.
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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
|
|
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]
|
|
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
|
-
|
|
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
|
|
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
|
|
144
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
56
|
-
#
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
attr_reader :
|
|
62
|
-
|
|
63
|
-
#
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
#
|
|
8
|
-
#
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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(
|
|
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) =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|