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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +152 -0
  3. data/LICENSE +674 -0
  4. data/README.md +34 -2
  5. data/lib/sevgi/graphics/attribute.rb +146 -32
  6. data/lib/sevgi/graphics/auxiliary/canvas.rb +64 -14
  7. data/lib/sevgi/graphics/auxiliary/content.rb +156 -21
  8. data/lib/sevgi/graphics/auxiliary/margin.rb +14 -2
  9. data/lib/sevgi/graphics/auxiliary/paper.rb +91 -84
  10. data/lib/sevgi/graphics/auxiliary/scalar.rb +40 -0
  11. data/lib/sevgi/graphics/auxiliary/sizes.rb +62 -0
  12. data/lib/sevgi/graphics/auxiliary.rb +2 -0
  13. data/lib/sevgi/graphics/document.rb +234 -56
  14. data/lib/sevgi/graphics/element.rb +42 -15
  15. data/lib/sevgi/graphics/mixtures/call.rb +29 -3
  16. data/lib/sevgi/graphics/mixtures/core.rb +138 -35
  17. data/lib/sevgi/graphics/mixtures/duplicate.rb +66 -16
  18. data/lib/sevgi/graphics/mixtures/export.rb +10 -2
  19. data/lib/sevgi/graphics/mixtures/identify.rb +3 -1
  20. data/lib/sevgi/graphics/mixtures/include.rb +28 -2
  21. data/lib/sevgi/graphics/mixtures/inkscape.rb +15 -0
  22. data/lib/sevgi/graphics/mixtures/rdf.rb +20 -0
  23. data/lib/sevgi/graphics/mixtures/render.rb +103 -24
  24. data/lib/sevgi/graphics/mixtures/save.rb +9 -0
  25. data/lib/sevgi/graphics/mixtures/symbols.rb +3 -0
  26. data/lib/sevgi/graphics/mixtures/tile.rb +17 -3
  27. data/lib/sevgi/graphics/mixtures/underscore.rb +14 -3
  28. data/lib/sevgi/graphics/mixtures/validate.rb +12 -0
  29. data/lib/sevgi/graphics/mixtures/wrappers.rb +4 -1
  30. data/lib/sevgi/graphics/mixtures.rb +4 -0
  31. data/lib/sevgi/graphics/version.rb +1 -1
  32. data/lib/sevgi/graphics/xml.rb +127 -0
  33. data/lib/sevgi/graphics.rb +33 -18
  34. 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 is unknown
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 named profile conflicts with an existing profile
45
- def self.define(name = Undefined, preambles: [], attributes: {}, overwrite: false)
46
- return Class.new(Base) { document(name, preambles:, attributes:, register: false) } if name == Undefined
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
- profile = Profile.new(name, attributes:, preambles:)
129
+ return lookup(name) if preambles == Undefined && attributes == Undefined
49
130
 
50
- if (current = Profile[name])
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
- # Immutable document profile metadata.
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
- class Profile
174
+ module Registry
64
175
  @available = {}
176
+ @mutex = ::Mutex.new
65
177
 
66
178
  class << self
67
- # @return [Hash<Symbol, Class>] registered profile classes
68
- attr_reader :available
69
- end
179
+ def [](name) = @mutex.synchronize { @available[name] }
70
180
 
71
- # Returns a profile class by name.
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
- # Registers a profile class.
77
- # @param name [Object] profile name
78
- # @param klass [Class] document class
79
- # @param overwrite [Boolean] true to replace an existing profile
80
- # @return [Class] registered class
81
- # @raise [Sevgi::ArgumentError] when name is invalid or conflicts with an existing profile
82
- def self.register(name, klass, overwrite: false)
83
- name = normalize!(name)
84
-
85
- if (current = available[name])
86
- unless overwrite || current.profile == klass.profile
87
- ArgumentError.("Document profile already defined differently: #{name}")
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
- return current unless overwrite
201
+ @available[name] = klass
91
202
  end
92
203
 
93
- available[name] = klass
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) = name.respond_to?(:to_sym) ? name.to_sym : nil
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 normalized
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
- @attributes = attributes || {}
125
- @preambles = preambles
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
- private_constant :Profile
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
- @profile = Profile.new(register ? name : nil, attributes:, preambles:)
156
- Profile.register(name, self, overwrite:) if register
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
- self.Render(*, **options)
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
- # @param block [Proc, nil] DSL block evaluated in the root element
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
- # @!attribute [r] name
56
- # @return [Symbol] SVG element name
57
- # @!attribute [r] attributes
58
- # @return [Sevgi::Graphics::Attributes] attribute store
59
- # @!attribute [r] children
60
- # @return [Array<Sevgi::Graphics::Element>] child elements
61
- # @!attribute [r] contents
62
- # @return [Array<Sevgi::Graphics::Content>] element content objects
63
- # @!attribute [r] parent
64
- # @return [Sevgi::Graphics::Element, Object] parent element or root sentinel
65
- attr_reader :name, :attributes, :children, :contents, :parent
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
- @name = name
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, :attributes
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
- # @api private
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 public methods as callable drawing steps.
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.respond_to?(:_callables) ? mod._callables : mod.instance_methods).map { mod.instance_method(it) }
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