sevgi-graphics 0.93.1 → 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 +152 -0
- data/LICENSE +674 -0
- data/README.md +34 -2
- data/lib/sevgi/graphics/attribute.rb +146 -32
- data/lib/sevgi/graphics/auxiliary/canvas.rb +64 -14
- data/lib/sevgi/graphics/auxiliary/content.rb +156 -21
- 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 +234 -56
- data/lib/sevgi/graphics/element.rb +42 -15
- data/lib/sevgi/graphics/mixtures/call.rb +29 -3
- data/lib/sevgi/graphics/mixtures/core.rb +138 -35
- data/lib/sevgi/graphics/mixtures/duplicate.rb +66 -16
- data/lib/sevgi/graphics/mixtures/export.rb +10 -2
- data/lib/sevgi/graphics/mixtures/identify.rb +3 -1
- data/lib/sevgi/graphics/mixtures/include.rb +28 -2
- 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 +103 -24
- 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 +17 -3
- data/lib/sevgi/graphics/mixtures/underscore.rb +14 -3
- 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 +33 -18
- metadata +10 -5
|
@@ -4,11 +4,89 @@ module Sevgi
|
|
|
4
4
|
module Graphics
|
|
5
5
|
# SVG document profile factory.
|
|
6
6
|
module Document
|
|
7
|
+
# Defensive copy helper for profile metadata snapshots.
|
|
8
|
+
# @api private
|
|
9
|
+
module Snapshot
|
|
10
|
+
class << self
|
|
11
|
+
# Captures recursively immutable profile metadata. Mutable non-container values are stringified once.
|
|
12
|
+
# @param value [Object] value to capture
|
|
13
|
+
# @param seen [Hash] container identities on the current traversal path
|
|
14
|
+
# @return [Object] immutable captured value
|
|
15
|
+
# @raise [Sevgi::ArgumentError] when metadata is cyclic, has colliding keys, or cannot be stringified
|
|
16
|
+
def capture(value, seen = {}.compare_by_identity)
|
|
17
|
+
case value
|
|
18
|
+
when ::Hash
|
|
19
|
+
nested(value, seen) { capture_hash(value, seen) }.freeze
|
|
20
|
+
when ::Array
|
|
21
|
+
nested(value, seen) { value.map { capture(it, seen) } }.freeze
|
|
22
|
+
else
|
|
23
|
+
capture_value(value)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Returns a recursively caller-owned copy of captured metadata.
|
|
28
|
+
# @param value [Object] captured value to copy
|
|
29
|
+
# @return [Object] caller-owned copy
|
|
30
|
+
def copy(value)
|
|
31
|
+
case value
|
|
32
|
+
when ::String
|
|
33
|
+
value.dup
|
|
34
|
+
when ::Hash
|
|
35
|
+
value.to_h { |key, item| [copy(key), copy(item)] }
|
|
36
|
+
when ::Array
|
|
37
|
+
value.map { copy(it) }
|
|
38
|
+
else
|
|
39
|
+
value
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def capture_hash(value, seen)
|
|
46
|
+
value.each_with_object({}) do |(key, item), captured|
|
|
47
|
+
key = capture(key, seen)
|
|
48
|
+
ArgumentError.("Document profile metadata keys collide after stringification") if captured.key?(key)
|
|
49
|
+
|
|
50
|
+
captured[key] = capture(item, seen)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def nested(value, seen)
|
|
55
|
+
ArgumentError.("Cyclic document profile metadata is not supported") if seen.key?(value)
|
|
56
|
+
|
|
57
|
+
seen[value] = true
|
|
58
|
+
yield
|
|
59
|
+
ensure
|
|
60
|
+
seen.delete(value)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def capture_value(value)
|
|
64
|
+
case value
|
|
65
|
+
when ::String
|
|
66
|
+
XML.text(value, context: "Document profile metadata").freeze
|
|
67
|
+
when ::Numeric, ::Symbol, ::NilClass, ::TrueClass, ::FalseClass
|
|
68
|
+
XML.text(value, context: "Document profile metadata")
|
|
69
|
+
value
|
|
70
|
+
else
|
|
71
|
+
stringify(value).freeze
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def stringify(value)
|
|
76
|
+
XML.text(value, context: "Document profile metadata")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private_constant :Snapshot
|
|
82
|
+
|
|
7
83
|
# Builds a root SVG element from a document profile.
|
|
8
84
|
# @param document [Symbol, String, Class] profile name or document class
|
|
9
85
|
# @param canvas [Sevgi::Graphics::Canvas, Sevgi::Graphics::Paper, Symbol, String, Sevgi::Undefined, nil] canvas input
|
|
86
|
+
# @yield evaluates the drawing DSL in the new root element
|
|
87
|
+
# @yieldreturn [Object] ignored block result
|
|
10
88
|
# @return [Sevgi::Graphics::Document::Proto] SVG root element
|
|
11
|
-
# @raise [Sevgi::ArgumentError] when the document profile
|
|
89
|
+
# @raise [Sevgi::ArgumentError] when the document profile or root XML attributes are invalid
|
|
12
90
|
def self.call(document, canvas = Undefined, **, &block)
|
|
13
91
|
klass = case document
|
|
14
92
|
when ::Class
|
|
@@ -36,67 +114,130 @@ module Sevgi
|
|
|
36
114
|
private_class_method :canvas_attributes
|
|
37
115
|
|
|
38
116
|
# Defines or returns a document profile class.
|
|
117
|
+
# Profile metadata is captured before class or thread-atomic registry mutation. Mutable non-container attribute
|
|
118
|
+
# values are stringified once during capture. Successful named definitions return the canonical class stored by
|
|
119
|
+
# the registry, including when identical definitions race.
|
|
39
120
|
# @param name [Symbol, String, Sevgi::Undefined] profile name, or Undefined for an anonymous profile
|
|
40
|
-
# @param preambles [Array<String>, nil] document preamble lines
|
|
41
|
-
# @param attributes [Hash] default root attributes
|
|
121
|
+
# @param preambles [Array<String>, nil, Sevgi::Undefined] document preamble lines
|
|
122
|
+
# @param attributes [Hash, nil, Sevgi::Undefined] default root attributes
|
|
42
123
|
# @param overwrite [Boolean] true to replace an existing profile
|
|
43
124
|
# @return [Class] document class
|
|
44
|
-
# @raise [Sevgi::ArgumentError] when a
|
|
45
|
-
def self.define(name = Undefined, preambles:
|
|
46
|
-
return
|
|
125
|
+
# @raise [Sevgi::ArgumentError] when a name conflicts or metadata is invalid XML, cyclic, or cannot be stringified
|
|
126
|
+
def self.define(name = Undefined, preambles: Undefined, attributes: Undefined, overwrite: false)
|
|
127
|
+
return anonymous(attributes:, preambles:) if name == Undefined
|
|
47
128
|
|
|
48
|
-
|
|
129
|
+
return lookup(name) if preambles == Undefined && attributes == Undefined
|
|
49
130
|
|
|
50
|
-
|
|
51
|
-
unless overwrite || current.profile == profile
|
|
52
|
-
ArgumentError.("Document profile already defined differently: #{name}")
|
|
53
|
-
end
|
|
131
|
+
name = Profile.normalize!(name)
|
|
54
132
|
|
|
133
|
+
if (current = Profile[name])
|
|
134
|
+
reject_conflict(name, current, attributes:, preambles:) unless overwrite
|
|
55
135
|
return current unless overwrite
|
|
56
136
|
end
|
|
57
137
|
|
|
138
|
+
attributes, preambles = defaults(attributes:, preambles:)
|
|
58
139
|
Class.new(Base) { document(name, preambles:, attributes:, overwrite:) }
|
|
140
|
+
Registry[name]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def self.anonymous(attributes:, preambles:)
|
|
144
|
+
attributes, preambles = defaults(attributes:, preambles:)
|
|
145
|
+
Class.new(Base) { document(Undefined, preambles:, attributes:, register: false) }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def self.lookup(name)
|
|
149
|
+
name = Profile.normalize!(name)
|
|
150
|
+
Profile[name] || ArgumentError.("Unknown document profile: #{name}")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def self.defaults(attributes:, preambles:)
|
|
154
|
+
[attributes == Undefined ? {} : attributes, preambles == Undefined ? nil : preambles]
|
|
59
155
|
end
|
|
60
156
|
|
|
61
|
-
|
|
157
|
+
def self.reject_conflict(name, current, attributes:, preambles:)
|
|
158
|
+
return if compatible?(current, attributes:, preambles:)
|
|
159
|
+
|
|
160
|
+
ArgumentError.("Document profile already defined differently: #{name}")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def self.compatible?(klass, attributes:, preambles:)
|
|
164
|
+
profile = klass.profile
|
|
165
|
+
|
|
166
|
+
(attributes == Undefined || Profile.new(nil, attributes:).attributes == profile.attributes) &&
|
|
167
|
+
(preambles == Undefined || Profile.new(nil, preambles:).preambles == profile.preambles)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
private_class_method :anonymous, :compatible?, :defaults, :lookup, :reject_conflict
|
|
171
|
+
|
|
172
|
+
# Process-global document profile registry.
|
|
62
173
|
# @api private
|
|
63
|
-
|
|
174
|
+
module Registry
|
|
64
175
|
@available = {}
|
|
176
|
+
@mutex = ::Mutex.new
|
|
65
177
|
|
|
66
178
|
class << self
|
|
67
|
-
|
|
68
|
-
attr_reader :available
|
|
69
|
-
end
|
|
179
|
+
def [](name) = @mutex.synchronize { @available[name] }
|
|
70
180
|
|
|
71
|
-
|
|
72
|
-
# @param name [Object] profile name
|
|
73
|
-
# @return [Class, nil]
|
|
74
|
-
def self.[](name) = (name = normalize(name)) && available[name]
|
|
181
|
+
def available = @mutex.synchronize { @available.dup.freeze }
|
|
75
182
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
183
|
+
def register(name, klass, profile: nil, overwrite: false)
|
|
184
|
+
name = Profile.normalize!(name)
|
|
185
|
+
validate!(name, klass, profile)
|
|
186
|
+
|
|
187
|
+
@mutex.synchronize { store(name, klass, profile, overwrite) }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
private
|
|
191
|
+
|
|
192
|
+
def store(name, klass, profile, overwrite)
|
|
193
|
+
if (current = @available[name])
|
|
194
|
+
unless overwrite || current.profile == profile
|
|
195
|
+
ArgumentError.("Document profile already defined differently: #{name}")
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
return current unless overwrite
|
|
88
199
|
end
|
|
89
200
|
|
|
90
|
-
|
|
201
|
+
@available[name] = klass
|
|
91
202
|
end
|
|
92
203
|
|
|
93
|
-
|
|
204
|
+
def validate!(name, klass, profile)
|
|
205
|
+
unless klass.is_a?(::Class) && klass <= Proto
|
|
206
|
+
ArgumentError.("Document profile class must inherit Document::Proto: #{klass}")
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
return if profile.is_a?(Profile) && profile.name == name
|
|
210
|
+
|
|
211
|
+
ArgumentError.("Document profile class has invalid metadata: #{klass}")
|
|
212
|
+
end
|
|
94
213
|
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
private_constant :Registry
|
|
217
|
+
|
|
218
|
+
# Immutable, read-only document profile metadata exposed by document classes. Process-global lookup and registration
|
|
219
|
+
# are thread-atomic. Metadata containers and strings are captured recursively; other mutable attribute values are
|
|
220
|
+
# stringified once during construction.
|
|
221
|
+
# @see Sevgi::Graphics.document
|
|
222
|
+
class Profile
|
|
223
|
+
# Returns a thread-coherent immutable snapshot of registered profile classes.
|
|
224
|
+
# @return [Hash<Symbol, Class>]
|
|
225
|
+
def self.available = Registry.available
|
|
226
|
+
|
|
227
|
+
# Returns a profile class by name from the process-global registry.
|
|
228
|
+
# @param name [Object] profile name
|
|
229
|
+
# @return [Class, nil]
|
|
230
|
+
def self.[](name) = (name = normalize(name)) && Registry[name]
|
|
95
231
|
|
|
96
232
|
# Normalizes a profile name.
|
|
97
233
|
# @param name [Object] profile name
|
|
98
234
|
# @return [Symbol, nil]
|
|
99
|
-
def self.normalize(name)
|
|
235
|
+
def self.normalize(name)
|
|
236
|
+
normalized = name.to_sym if name.respond_to?(:to_sym)
|
|
237
|
+
normalized if normalized.is_a?(::Symbol)
|
|
238
|
+
rescue ::StandardError
|
|
239
|
+
nil
|
|
240
|
+
end
|
|
100
241
|
|
|
101
242
|
# Normalizes a profile name or raises.
|
|
102
243
|
# @param name [Object] profile name
|
|
@@ -107,22 +248,17 @@ module Sevgi
|
|
|
107
248
|
# @return [Symbol, nil] profile name
|
|
108
249
|
attr_reader :name
|
|
109
250
|
|
|
110
|
-
# @return [Hash] default root attributes
|
|
111
|
-
attr_reader :attributes
|
|
112
|
-
|
|
113
|
-
# @return [Array<String>, nil] preamble lines
|
|
114
|
-
attr_reader :preambles
|
|
115
|
-
|
|
116
251
|
# Creates profile metadata.
|
|
117
252
|
# @param name [Object, nil] profile name
|
|
118
|
-
# @param attributes [Hash, nil] default root attributes
|
|
253
|
+
# @param attributes [Hash, nil] default root attributes; nil means an empty Hash
|
|
119
254
|
# @param preambles [Array<String>, nil] preamble lines
|
|
120
255
|
# @return [void]
|
|
121
|
-
# @raise [Sevgi::ArgumentError] when name cannot be
|
|
256
|
+
# @raise [Sevgi::ArgumentError] when name or metadata is invalid XML, cyclic, or cannot be stringified
|
|
122
257
|
def initialize(name, attributes: nil, preambles: nil)
|
|
123
258
|
@name = name.nil? ? nil : self.class.normalize!(name)
|
|
124
|
-
|
|
125
|
-
@
|
|
259
|
+
validate_attributes!(attributes)
|
|
260
|
+
@attributes = Snapshot.capture(attributes || {})
|
|
261
|
+
@preambles = capture_preambles(preambles)
|
|
126
262
|
end
|
|
127
263
|
|
|
128
264
|
# Reports strict profile equality.
|
|
@@ -130,31 +266,71 @@ module Sevgi
|
|
|
130
266
|
# @return [Boolean]
|
|
131
267
|
def ==(other) = self.class == other.class && deconstruct == other.deconstruct
|
|
132
268
|
|
|
269
|
+
# Returns default root attributes.
|
|
270
|
+
# @return [Hash] mutation-isolated attribute snapshot
|
|
271
|
+
def attributes = Snapshot.copy(@attributes)
|
|
272
|
+
|
|
133
273
|
# Returns profile components.
|
|
134
274
|
# @return [Array<(Symbol, nil), Hash, (Array<String>, nil)>]
|
|
135
275
|
def deconstruct = [name, attributes, preambles]
|
|
136
|
-
end
|
|
137
276
|
|
|
138
|
-
|
|
277
|
+
# Returns preamble lines.
|
|
278
|
+
# @return [Array<String>, nil] mutation-isolated preamble snapshot
|
|
279
|
+
def preambles = Snapshot.copy(@preambles)
|
|
280
|
+
|
|
281
|
+
private
|
|
282
|
+
|
|
283
|
+
def validate_attributes!(attributes)
|
|
284
|
+
return if attributes.nil?
|
|
285
|
+
|
|
286
|
+
ArgumentError.("Document profile attributes must be a Hash") unless attributes.is_a?(::Hash)
|
|
287
|
+
|
|
288
|
+
normalized = {}
|
|
289
|
+
attributes.each_key do |key|
|
|
290
|
+
id = normalize_attribute!(key)
|
|
291
|
+
ArgumentError.("Document profile attribute names collide after normalization: #{id}") if normalized.key?(id)
|
|
292
|
+
|
|
293
|
+
normalized[id] = true
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def normalize_attribute!(key)
|
|
298
|
+
if key.is_a?(::String) || key.is_a?(::Symbol)
|
|
299
|
+
return XML.name(key, context: "Document profile attribute name").to_sym
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
ArgumentError.("Document profile attribute names must be Strings or Symbols")
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def capture_preambles(preambles)
|
|
306
|
+
return if preambles.nil?
|
|
307
|
+
|
|
308
|
+
unless preambles.is_a?(::Array) && preambles.all?(::String)
|
|
309
|
+
ArgumentError.("Document profile preambles must be an Array of Strings")
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
Snapshot.capture(preambles)
|
|
313
|
+
end
|
|
314
|
+
end
|
|
139
315
|
|
|
140
316
|
# Class-level DSL used while defining document classes.
|
|
141
317
|
# @api private
|
|
142
318
|
module DSL
|
|
143
|
-
# @return [Sevgi::Graphics::Document::Profile] document profile metadata
|
|
319
|
+
# @return [Sevgi::Graphics::Document::Profile] immutable document profile metadata
|
|
144
320
|
attr_reader :profile
|
|
145
321
|
|
|
146
322
|
# Sets document profile metadata on a class.
|
|
147
323
|
# @param name [Object] profile name
|
|
148
|
-
# @param attributes [Hash] default root attributes
|
|
324
|
+
# @param attributes [Hash, nil] default root attributes
|
|
149
325
|
# @param preambles [Array<String>, nil] preamble lines
|
|
150
326
|
# @param register [Boolean] true to register the profile globally
|
|
151
327
|
# @param overwrite [Boolean] true to replace an existing profile
|
|
152
|
-
# @return [Sevgi::Graphics::Document::Profile]
|
|
153
|
-
# @raise [Sevgi::ArgumentError] when registration fails
|
|
328
|
+
# @return [Sevgi::Graphics::Document::Profile] immutable document profile metadata
|
|
329
|
+
# @raise [Sevgi::ArgumentError] when registration fails or metadata is invalid XML, cyclic, or cannot be stringified
|
|
154
330
|
def document(name, attributes: {}, preambles: nil, register: true, overwrite: false)
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
@profile
|
|
331
|
+
profile = Profile.new(register ? name : nil, attributes:, preambles:)
|
|
332
|
+
Registry.register(name, self, profile:, overwrite:) if register
|
|
333
|
+
@profile = profile
|
|
158
334
|
end
|
|
159
335
|
|
|
160
336
|
# Includes a graphics mixture into the document class.
|
|
@@ -189,15 +365,17 @@ module Sevgi
|
|
|
189
365
|
# @param objects [Array<Object>] optional renderer arguments
|
|
190
366
|
# @param options [Hash] render options
|
|
191
367
|
# @return [String] SVG document source
|
|
368
|
+
# @raise [Sevgi::ArgumentError] when renderer options or XML-bound values are invalid
|
|
192
369
|
def call(*, **)
|
|
193
370
|
options = DEFAULTS.merge(**)
|
|
194
371
|
|
|
195
372
|
self.PreRender(*, **options) if respond_to?(:PreRender)
|
|
196
|
-
|
|
373
|
+
render_options = options.reject { |key, _| DEFAULTS.key?(key) }
|
|
374
|
+
self.Render(*, **render_options)
|
|
197
375
|
end
|
|
198
376
|
|
|
199
377
|
# Returns inherited root attributes for this document class.
|
|
200
|
-
# @return [Hash]
|
|
378
|
+
# @return [Hash{Symbol => Object}] inherited root attributes
|
|
201
379
|
def self.attributes = self == Proto ? {} : {**superclass.attributes, **profile.attributes}
|
|
202
380
|
|
|
203
381
|
# Returns inherited preamble lines for this document class.
|
|
@@ -3,17 +3,27 @@
|
|
|
3
3
|
module Sevgi
|
|
4
4
|
module Graphics
|
|
5
5
|
# SVG element node used by the graphics DSL.
|
|
6
|
+
#
|
|
7
|
+
# @!method self.valid?(name)
|
|
8
|
+
# Reports whether an SVG element name is known.
|
|
9
|
+
# @param name [Symbol] SVG element name
|
|
10
|
+
# @return [Boolean]
|
|
6
11
|
class Element
|
|
7
12
|
# Builds an element node.
|
|
8
13
|
# @param name [Symbol, String] SVG element name
|
|
9
14
|
# @param parent [Sevgi::Graphics::Element] parent element
|
|
15
|
+
# @yield evaluates the drawing DSL in the new element
|
|
16
|
+
# @yieldreturn [Object] ignored block result
|
|
10
17
|
# @return [Sevgi::Graphics::Element]
|
|
11
18
|
# @raise [Sevgi::ArgumentError] when an argument cannot be parsed as attributes or content
|
|
19
|
+
# @raise [Sevgi::ArgumentError] when the element name or an attribute is not valid XML
|
|
12
20
|
def self.element(name, *, parent:, &block) = new(name, **Dispatch.parse(name, *), parent:, &block)
|
|
13
21
|
|
|
14
22
|
# Builds an SVG root element.
|
|
15
|
-
# @
|
|
23
|
+
# @yield evaluates the drawing DSL in the root element
|
|
24
|
+
# @yieldreturn [Object] ignored block result
|
|
16
25
|
# @return [Sevgi::Graphics::Element]
|
|
26
|
+
# @raise [Sevgi::ArgumentError] when a root attribute is not valid XML
|
|
17
27
|
def self.root(*, &block) = element(:svg, *, parent: RootParent, &block)
|
|
18
28
|
|
|
19
29
|
# Reports whether an element is the root element.
|
|
@@ -52,26 +62,41 @@ module Sevgi
|
|
|
52
62
|
|
|
53
63
|
extend Ident
|
|
54
64
|
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
attr_reader :
|
|
65
|
+
# Returns the SVG element name.
|
|
66
|
+
# @return [Symbol]
|
|
67
|
+
attr_reader :name
|
|
68
|
+
|
|
69
|
+
# Returns the attribute store.
|
|
70
|
+
# @return [Sevgi::Graphics::Attributes]
|
|
71
|
+
attr_reader :attributes
|
|
72
|
+
|
|
73
|
+
# Returns child elements.
|
|
74
|
+
# @return [Array<Sevgi::Graphics::Element>]
|
|
75
|
+
attr_reader :children
|
|
76
|
+
|
|
77
|
+
# Returns element content objects.
|
|
78
|
+
# @return [Array<Sevgi::Graphics::Content>]
|
|
79
|
+
attr_reader :contents
|
|
80
|
+
|
|
81
|
+
# Returns the parent element or root sentinel.
|
|
82
|
+
# @return [Sevgi::Graphics::Element, Object] parent element or root sentinel
|
|
83
|
+
attr_reader :parent
|
|
66
84
|
|
|
67
85
|
# Creates an element.
|
|
68
86
|
# @param name [Symbol] SVG element name
|
|
69
87
|
# @param parent [Sevgi::Graphics::Element, Object] parent element or root sentinel
|
|
70
88
|
# @param attributes [Hash] SVG attributes
|
|
71
89
|
# @param contents [Array<Sevgi::Graphics::Content>] content objects
|
|
90
|
+
# @yield evaluates the drawing DSL in the new element
|
|
91
|
+
# @yieldreturn [Object] ignored block result
|
|
72
92
|
# @return [void]
|
|
93
|
+
# @raise [Sevgi::ArgumentError] when the element name or an attribute is not valid XML
|
|
73
94
|
def initialize(name, parent:, attributes: {}, contents: [], &block)
|
|
74
|
-
|
|
95
|
+
unless name.is_a?(::String) || name.is_a?(::Symbol)
|
|
96
|
+
ArgumentError.("XML element name must be a String or Symbol")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
@name = XML.name(name, context: "XML element name").to_sym
|
|
75
100
|
@attributes = Attributes.new(attributes)
|
|
76
101
|
@children = []
|
|
77
102
|
@contents = contents
|
|
@@ -84,6 +109,8 @@ module Sevgi
|
|
|
84
109
|
|
|
85
110
|
# Dispatches SVG element DSL calls and caches valid element methods.
|
|
86
111
|
# @param name [Symbol] missing method name
|
|
112
|
+
# @yield evaluates the drawing DSL in the dispatched child element
|
|
113
|
+
# @yieldreturn [Object] ignored block result
|
|
87
114
|
# @return [Sevgi::Graphics::Element]
|
|
88
115
|
# @raise [NameError] when the name is not a valid SVG element
|
|
89
116
|
# @raise [Sevgi::ArgumentError] when an argument cannot be parsed as attributes or content
|
|
@@ -95,6 +122,7 @@ module Sevgi
|
|
|
95
122
|
# @param name [Symbol] queried method name
|
|
96
123
|
# @param include_private [Boolean] standard Ruby respond_to? flag
|
|
97
124
|
# @return [Boolean]
|
|
125
|
+
# @api private
|
|
98
126
|
def respond_to_missing?(name, include_private = false)
|
|
99
127
|
Element.valid?(Element.id(name)) || super
|
|
100
128
|
end
|
|
@@ -110,7 +138,6 @@ module Sevgi
|
|
|
110
138
|
# @param tag [Symbol] SVG element name
|
|
111
139
|
# @return [Sevgi::Graphics::Element]
|
|
112
140
|
def call(element, method, tag, *, &)
|
|
113
|
-
# Low-hanging fruit optimization: define missing method to avoid dispatching cost
|
|
114
141
|
unless Element.method_defined?(method)
|
|
115
142
|
Element.class_exec do
|
|
116
143
|
define_method(method) { |*args, &block| self.class.element(tag, *args, parent: self, &block) }
|
|
@@ -149,7 +176,7 @@ module Sevgi
|
|
|
149
176
|
|
|
150
177
|
protected
|
|
151
178
|
|
|
152
|
-
attr_writer :children, :
|
|
179
|
+
attr_writer :attributes, :children, :contents, :parent
|
|
153
180
|
end
|
|
154
181
|
end
|
|
155
182
|
end
|
|
@@ -3,9 +3,20 @@
|
|
|
3
3
|
module Sevgi
|
|
4
4
|
module Graphics
|
|
5
5
|
# Callable drawing module support.
|
|
6
|
-
#
|
|
6
|
+
# Extend a plain Ruby module with this API to make its public instance methods callable drawing steps.
|
|
7
|
+
# @example Define and call a drawing module
|
|
8
|
+
# Widget = Module.new do
|
|
9
|
+
# extend Sevgi::Graphics::Module
|
|
10
|
+
#
|
|
11
|
+
# def item(id)
|
|
12
|
+
# rect(id:)
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# SVG { Call(Widget, "box") }
|
|
7
17
|
module Module
|
|
8
|
-
# Tracks newly defined
|
|
18
|
+
# Tracks newly defined methods as callable drawing candidates.
|
|
19
|
+
# Invocation runs unique methods that are still public, preserving tracked definition order.
|
|
9
20
|
# @param method [Symbol] method name Ruby reports as added
|
|
10
21
|
# @return [Array<Symbol>, nil]
|
|
11
22
|
def method_added(method)
|
|
@@ -49,6 +60,7 @@ module Sevgi
|
|
|
49
60
|
# @param args [Array<Object>] callable arguments
|
|
50
61
|
# @param kwargs [Hash] callable keyword arguments
|
|
51
62
|
# @return [Object, nil] last callable return value
|
|
63
|
+
# @raise [Sevgi::ArgumentError] when mod is not a plain module
|
|
52
64
|
def self.call(mod, receiver, ...)
|
|
53
65
|
mod._befores.each { receiver.Within(receiver, &it) } if mod.respond_to?(:_befores) && mod._befores
|
|
54
66
|
# return last callable return value
|
|
@@ -64,8 +76,22 @@ module Sevgi
|
|
|
64
76
|
def self.callables(mod)
|
|
65
77
|
ArgumentError.("Must be a module: #{mod}") unless mod.instance_of?(::Module)
|
|
66
78
|
|
|
67
|
-
(mod
|
|
79
|
+
callable_names(mod).uniq.filter_map do |name|
|
|
80
|
+
mod.instance_method(name) if mod.public_method_defined?(name)
|
|
81
|
+
end
|
|
68
82
|
end
|
|
83
|
+
|
|
84
|
+
def self.callable_names(mod)
|
|
85
|
+
tracked = mod.ancestors.reverse_each.filter_map do |ancestor|
|
|
86
|
+
ancestor._callables if ancestor.respond_to?(:_callables)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
return tracked.flatten unless tracked.empty?
|
|
90
|
+
|
|
91
|
+
mod.public_instance_methods
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private_class_method :callable_names
|
|
69
95
|
end
|
|
70
96
|
|
|
71
97
|
module Mixtures
|