sevgi-graphics 0.94.0 → 0.98.2

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +275 -2
  3. data/LICENSE +672 -3
  4. data/README.md +8 -8
  5. data/lib/sevgi/graphics/attribute.rb +271 -54
  6. data/lib/sevgi/graphics/auxiliary/canvas.rb +137 -58
  7. data/lib/sevgi/graphics/auxiliary/content.rb +200 -57
  8. data/lib/sevgi/graphics/auxiliary/margin.rb +33 -14
  9. data/lib/sevgi/graphics/auxiliary/paper.rb +118 -86
  10. data/lib/sevgi/graphics/auxiliary/path.rb +44 -0
  11. data/lib/sevgi/graphics/auxiliary/scalar.rb +69 -0
  12. data/lib/sevgi/graphics/auxiliary/sizes.rb +62 -0
  13. data/lib/sevgi/graphics/auxiliary.rb +3 -0
  14. data/lib/sevgi/graphics/document/base.rb +6 -2
  15. data/lib/sevgi/graphics/document/default.rb +1 -1
  16. data/lib/sevgi/graphics/document.rb +370 -124
  17. data/lib/sevgi/graphics/element.rb +143 -33
  18. data/lib/sevgi/graphics/mixtures/call.rb +249 -88
  19. data/lib/sevgi/graphics/mixtures/core.rb +143 -33
  20. data/lib/sevgi/graphics/mixtures/duplicate.rb +76 -22
  21. data/lib/sevgi/graphics/mixtures/export.rb +58 -12
  22. data/lib/sevgi/graphics/mixtures/hatch.rb +49 -7
  23. data/lib/sevgi/graphics/mixtures/identify.rb +30 -17
  24. data/lib/sevgi/graphics/mixtures/include.rb +40 -5
  25. data/lib/sevgi/graphics/mixtures/inkscape.rb +215 -46
  26. data/lib/sevgi/graphics/mixtures/rdf.rb +79 -7
  27. data/lib/sevgi/graphics/mixtures/render.rb +88 -19
  28. data/lib/sevgi/graphics/mixtures/save.rb +79 -26
  29. data/lib/sevgi/graphics/mixtures/symbols.rb +81 -9
  30. data/lib/sevgi/graphics/mixtures/tile.rb +88 -64
  31. data/lib/sevgi/graphics/mixtures/transform.rb +68 -28
  32. data/lib/sevgi/graphics/mixtures/underscore.rb +16 -7
  33. data/lib/sevgi/graphics/mixtures/validate.rb +14 -2
  34. data/lib/sevgi/graphics/mixtures/wrappers.rb +66 -24
  35. data/lib/sevgi/graphics/mixtures.rb +19 -13
  36. data/lib/sevgi/graphics/version.rb +1 -1
  37. data/lib/sevgi/graphics/xml.rb +127 -0
  38. data/lib/sevgi/graphics.rb +75 -28
  39. metadata +10 -6
@@ -9,7 +9,9 @@ module Sevgi
9
9
  #
10
10
  # @!attribute [r] value
11
11
  # @return [Object] value returned from traversal
12
- Stop = Data.define(:value)
12
+ Stop = Data.define(:value) do
13
+ private_class_method :[], :new
14
+ end
13
15
 
14
16
  # Internal traversal engine.
15
17
  # @api private
@@ -53,20 +55,16 @@ module Sevgi
53
55
  # @return [Sevgi::Graphics::Element] self
54
56
  # @raise [Sevgi::ArgumentError] when the target parent has a different element class
55
57
  # @raise [Sevgi::ArgumentError] when the target parent is this element or one of its descendants
58
+ # @raise [Sevgi::ArgumentError] when index is not an Integer insertion position
56
59
  def Adopt(new_parent = nil, index: -1)
57
60
  tap do
58
- if new_parent
59
- unless instance_of?(new_parent.class)
60
- ArgumentError.("Element type does not match the new parent type: #{self.class}")
61
- end
62
- else
63
- new_parent = parent
64
- end
65
-
61
+ new_parent ||= parent
66
62
  Adoption.validate(self, new_parent)
67
63
 
64
+ insertion = Adoption.index_for(self, new_parent, index)
65
+
68
66
  self.Orphan()
69
- (@parent = new_parent).children.insert(index, self)
67
+ Element.send(:attach, self, new_parent, index: insertion)
70
68
  end
71
69
  end
72
70
 
@@ -80,11 +78,19 @@ module Sevgi
80
78
  Adopt(*, index: 0)
81
79
  end
82
80
 
83
- # Appends existing elements as children.
84
- # @param elements [Array<Sevgi::Graphics::Element>] elements to append
81
+ # Appends distinct existing elements as children in argument order.
82
+ # Each element transfers from its current parent. The complete batch is validated before any element moves.
83
+ # @example Move existing elements into a group
84
+ # Sevgi::Graphics.SVG(:minimal) do
85
+ # dot = circle r: 2
86
+ # label = text "Ready", x: 6
87
+ # g(id: "status").Append(dot, label)
88
+ # end
89
+ # @param elements [Array<Sevgi::Graphics::Element>] distinct elements to append
85
90
  # @return [Sevgi::Graphics::Element] self
91
+ # @raise [Sevgi::ArgumentError] when an argument has a different element class, is repeated, or is this target or its ancestor
86
92
  def Append(*elements)
87
- tap { elements.each { it.Adopt(self) } }
93
+ tap { Adoption.batch(elements, self, front: false) }
88
94
  end
89
95
 
90
96
  # Adds CSS classes without duplicating existing values.
@@ -124,9 +130,13 @@ module Sevgi
124
130
  # @param tag [Symbol, String] SVG tag name
125
131
  # @param contents [Array<Object>] text or content objects; non-content objects are stringified and XML-encoded
126
132
  # @param attributes [Hash] SVG attributes
133
+ # @yield evaluates the drawing DSL in the new child element
134
+ # @yieldreturn [Object] ignored block result
127
135
  # @return [Sevgi::Graphics::Element] new child element
136
+ # @raise [Sevgi::ArgumentError] when the tag, attributes, or content are not valid XML
128
137
  def Element(tag, *contents, **attributes, &block)
129
- self.class.send(:new, tag.to_sym, contents: Content.contents(*contents), attributes:, parent: self, &block)
138
+ contents.map! { it.is_a?(Content) ? it : Content.encoded(it) }
139
+ self.class.send(:new, tag, contents:, attributes:, parent: self, &block)
130
140
  end
131
141
 
132
142
  # Forwards this element as the first argument to another receiver.
@@ -147,24 +157,30 @@ module Sevgi
147
157
  self.name() == name.to_sym
148
158
  end
149
159
 
150
- # Removes this element from its parent.
151
- # @return [Array<Sevgi::Graphics::Element>, nil] parent children after deletion, or nil for root elements
160
+ # Removes this element from its parent and makes it a detached subtree root.
161
+ # @return [Sevgi::Graphics::Element, nil] self, or nil for root elements
152
162
  def Orphan
153
- parent.children&.delete(self) unless Root?()
163
+ return if Root?()
164
+
165
+ Element.send(:detach, self)
166
+ self
154
167
  end
155
168
 
156
- # Prepends existing elements as children.
157
- # @param elements [Array<Sevgi::Graphics::Element>] elements to prepend
169
+ # Prepends distinct existing elements as children in argument order.
170
+ # Each element transfers from its current parent. The complete batch is validated before any element moves.
171
+ # @param elements [Array<Sevgi::Graphics::Element>] distinct elements to prepend
158
172
  # @return [Sevgi::Graphics::Element] self
173
+ # @raise [Sevgi::ArgumentError] when an argument has a different element class, is repeated, or is this target or its ancestor
159
174
  def Prepend(*elements)
160
- tap { elements.each { it.AdoptFirst(self) } }
175
+ tap { Adoption.batch(elements, self, front: true) }
161
176
  end
162
177
 
163
- # Returns the root document element.
164
- # @return [Sevgi::Graphics::Element]
178
+ # Returns the topmost element in this tree.
179
+ # A detached subtree returns its detached topmost element; use {#Root?} to distinguish a document root.
180
+ # @return [Sevgi::Graphics::Element] document root or detached topmost element
165
181
  def Root
166
182
  element = self
167
- element = element.parent until element.Root?()
183
+ element = element.parent while element.parent
168
184
 
169
185
  element
170
186
  end
@@ -172,18 +188,26 @@ module Sevgi
172
188
  # Reports whether this element is the root document element.
173
189
  # @return [Boolean]
174
190
  def Root?
175
- self.class.root?(self)
191
+ Element.root?(self)
176
192
  end
177
193
 
178
194
  # @overload Stay(value)
179
195
  # Wraps a traversal return value as a stop token.
180
196
  # @param value [Object] value returned from traversal
181
197
  # @return [Sevgi::Graphics::Mixtures::Stop]
182
- def Stay(...) = Stop.new(...)
198
+ def Stay(...) = Stop.send(:new, ...)
183
199
 
184
200
  # Traverses the subtree depth-first.
201
+ # @example Find the first circle and stop the traversal
202
+ # drawing = Sevgi::Graphics.SVG(:minimal) { g { circle id: "target"; circle id: "later" } }
203
+ # found = drawing.Traverse { |node| node.Stay(node) if node.Is? :circle }
204
+ # found[:id] # => "target"
185
205
  # @param depth [Integer] starting depth
186
206
  # @param leave [Proc, nil] optional leave callback
207
+ # @yield [element, depth] visits each element before its children
208
+ # @yieldparam element [Sevgi::Graphics::Element] visited element
209
+ # @yieldparam depth [Integer] element depth
210
+ # @yieldreturn [Object] ignored unless it is a {Stop} token
187
211
  # @return [Sevgi::Graphics::Element, Object] self or the value passed through Stay
188
212
  # @raise [Sevgi::ArgumentError] when no block is given
189
213
  def Traverse(depth = 0, leave = nil, &block)
@@ -194,6 +218,10 @@ module Sevgi
194
218
 
195
219
  # Traverses ancestors from this element to the root.
196
220
  # @param height [Integer] starting height
221
+ # @yield [element, height] visits this element and each ancestor
222
+ # @yieldparam element [Sevgi::Graphics::Element] visited element
223
+ # @yieldparam height [Integer] ancestor height
224
+ # @yieldreturn [Object] ignored unless it is a {Stop} token
197
225
  # @return [Object, nil] value passed through Stay, or nil
198
226
  # @raise [Sevgi::ArgumentError] when no block is given
199
227
  def TraverseUp(height = 0, &block)
@@ -204,7 +232,7 @@ module Sevgi
204
232
  loop do
205
233
  yield(element, height).tap { return it.value if it.is_a?(Stop) }
206
234
 
207
- break if element.Root?()
235
+ break unless element.parent
208
236
 
209
237
  element = element.parent
210
238
  height += 1
@@ -212,24 +240,50 @@ module Sevgi
212
240
  end
213
241
 
214
242
  # Evaluates a block in the parent element context.
215
- # @param args [Array<Object>] optional receiver override followed by block arguments
243
+ # @example Add a sibling while forwarding its id
244
+ # root = Sevgi::Graphics.SVG id: "root"
245
+ # child = root.g id: "child"
246
+ # child.With("sibling") { |id| line id: }
247
+ # @param args [Array<Object>] positional arguments passed to the block
248
+ # @param receiver [Sevgi::Graphics::Element] element whose parent becomes the block receiver
216
249
  # @param kwargs [Hash] keyword arguments passed to the block
250
+ # @yield [*args, **kwargs] evaluates in the selected element's parent context
251
+ # @yieldreturn [Object] ignored block result
217
252
  # @return [Sevgi::Graphics::Element] self
218
- def With(*args, **kwargs, &block)
219
- tap { (args.shift || self).parent.instance_exec(*args, **kwargs, &block) }
253
+ # @raise [Sevgi::ArgumentError] when no block is given
254
+ # @raise [Sevgi::ArgumentError] when receiver is not an element or has no parent
255
+ def With(*args, receiver: self, **kwargs, &block)
256
+ ArgumentError.("Block required") unless block
257
+ ArgumentError.("Receiver must be an element") unless receiver.is_a?(Element)
258
+
259
+ parent = receiver.parent
260
+ ArgumentError.("Receiver has no parent") unless parent
261
+
262
+ tap { parent.instance_exec(*args, **kwargs, &block) }
220
263
  end
221
264
 
222
265
  # Evaluates a block in this element context.
223
- # @param args [Array<Object>] optional receiver override followed by block arguments
266
+ # @example Select a receiver without consuming the block argument
267
+ # target = Sevgi::Graphics.SVG id: "target"
268
+ # source = Sevgi::Graphics.SVG id: "source"
269
+ # source.Within("child", receiver: target) { |id| g id: }
270
+ # @param args [Array<Object>] positional arguments passed to the block
271
+ # @param receiver [Object] block receiver
224
272
  # @param kwargs [Hash] keyword arguments passed to the block
273
+ # @yield [*args, **kwargs] evaluates in the selected receiver context
274
+ # @yieldreturn [Object] ignored block result
225
275
  # @return [Sevgi::Graphics::Element] self
226
- def Within(*args, **kwargs, &block)
227
- tap { (args.shift || self).instance_exec(*args, **kwargs, &block) }
276
+ # @raise [Sevgi::ArgumentError] when no block is given
277
+ def Within(*args, receiver: self, **kwargs, &block)
278
+ ArgumentError.("Block required") unless block
279
+
280
+ tap { receiver.instance_exec(*args, **kwargs, &block) }
228
281
  end
229
282
 
230
283
  # Appends an element as a child.
231
284
  # @param element [Sevgi::Graphics::Element] element to append
232
285
  # @return [Sevgi::Graphics::Element] self
286
+ # @raise [Sevgi::ArgumentError] when the element has a different concrete class or would create a tree cycle
233
287
  def <<(element)
234
288
  Append(element)
235
289
  end
@@ -237,12 +291,32 @@ module Sevgi
237
291
  # Adoption target validation that keeps tree mutation atomic.
238
292
  # @api private
239
293
  module Adoption
294
+ # Moves a validated batch to the front or back of a parent.
295
+ # @param elements [Array<Sevgi::Graphics::Element>] elements to move
296
+ # @param parent [Sevgi::Graphics::Element] target parent
297
+ # @param front [Boolean] whether to prepend instead of append
298
+ # @return [void]
299
+ # @raise [Sevgi::ArgumentError] when an argument is incompatible, repeated, or would create a cycle
300
+ def self.batch(elements, parent, front:)
301
+ validate_batch(elements, parent)
302
+
303
+ if front
304
+ elements.reverse_each { it.AdoptFirst(parent) }
305
+ else
306
+ elements.each { it.Adopt(parent) }
307
+ end
308
+ end
309
+
240
310
  # Rejects target parents that would create a cycle.
241
311
  # @param element [Sevgi::Graphics::Element] element being moved
242
312
  # @param parent [Sevgi::Graphics::Element, Object] target parent
243
313
  # @return [void]
244
- # @raise [Sevgi::ArgumentError] when the target parent is this element or one of its descendants
314
+ # @raise [Sevgi::ArgumentError] when the parent is incompatible or would create a cycle
245
315
  def self.validate(element, parent)
316
+ unless element.instance_of?(parent.class)
317
+ ArgumentError.("Element type does not match the new parent type: #{element.class}")
318
+ end
319
+
246
320
  ArgumentError.("Element cannot be adopted under itself") if parent.equal?(element)
247
321
 
248
322
  while parent.respond_to?(:Root?)
@@ -252,6 +326,42 @@ module Sevgi
252
326
  parent = parent.parent
253
327
  end
254
328
  end
329
+
330
+ # Normalizes an insertion index before tree mutation.
331
+ # @param index [Integer] requested insertion index
332
+ # @param size [Integer] number of available child positions
333
+ # @return [Integer] normalized non-negative insertion index
334
+ # @raise [Sevgi::ArgumentError] when the index is not an insertion position
335
+ def self.index(index, size)
336
+ ArgumentError.("Adoption index must be an Integer") unless index.is_a?(Integer)
337
+
338
+ normalized = index.negative? ? size + index + 1 : index
339
+ ArgumentError.("Adoption index is outside the child list") unless normalized.between?(0, size)
340
+
341
+ normalized
342
+ end
343
+
344
+ # Returns an insertion index after accounting for a same-parent move.
345
+ # @param element [Sevgi::Graphics::Element] element being moved
346
+ # @param parent [Sevgi::Graphics::Element] target parent
347
+ # @param index [Integer] requested insertion index
348
+ # @return [Integer] normalized insertion index
349
+ def self.index_for(element, parent, index)
350
+ same_parent = element.parent.equal?(parent) && parent.children.include?(element)
351
+ index(index, parent.children.size - (same_parent ? 1 : 0))
352
+ end
353
+
354
+ def self.validate_batch(elements, parent)
355
+ seen = {}.compare_by_identity
356
+ elements.each do |element|
357
+ ArgumentError.("Element appears more than once in adoption batch") if seen.key?(element)
358
+
359
+ seen[element] = true
360
+ validate(element, parent)
361
+ end
362
+ end
363
+
364
+ private_class_method :validate_batch
255
365
  end
256
366
 
257
367
  private_constant :Adoption
@@ -6,30 +6,31 @@ module Sevgi
6
6
  # DSL helpers for duplicating independent element subtrees.
7
7
  module Duplicate
8
8
  # Duplicates an element subtree as an independent tree and optionally translates it.
9
- # Copied elements receive new child arrays, attribute stores, and content arrays. Public `id` attributes are
10
- # moved to an internal `-id` attribute before the optional block runs, allowing the block to derive replacement
11
- # ids without rendering duplicate public ids.
12
- # @param dx [Numeric, nil] x translation
13
- # @param dy [Numeric, nil] y translation
14
- # @param parent [Sevgi::Graphics::Element, nil] parent for the duplicated subtree
9
+ # Copied elements receive new child arrays, attribute stores, and content arrays. Visible `id` attributes are
10
+ # moved to non-rendering `-id` metadata before the optional block runs, allowing the block to derive replacement
11
+ # ids without rendering duplicates. A pre-existing `-id` takes precedence over the visible id.
12
+ # Translation and parent channels are validated before the subtree is copied or the customization block runs.
13
+ # @param dx [Numeric, nil] finite x translation; nil omits the axis
14
+ # @param dy [Numeric, nil] finite y translation; nil omits the axis
15
+ # @param parent [Sevgi::Graphics::Element, nil] explicit parent, or the source parent when nil
15
16
  # @yield [element] optional customization hook for each copied element
16
17
  # @yieldparam element [Sevgi::Graphics::Element] copied element
18
+ # @yieldreturn [Object] ignored customization result
17
19
  # @return [Sevgi::Graphics::Element] duplicated element
18
20
  # @raise [Sevgi::ArgumentError] when the target parent has a different element class
21
+ # @raise [Sevgi::ArgumentError] when a translation is not a finite real number
22
+ # @raise [Sevgi::ArgumentError] when copied attributes or contents contain cyclic payloads
23
+ # @example Remap source ids on a duplicate
24
+ # source = Sevgi::Graphics.SVG { rect id: "shape" }.children.first
25
+ # copy = source.Duplicate do |node|
26
+ # node[:id] = "#{node[:"-id"]}-copy" if node[:"-id"]
27
+ # end
19
28
  def Duplicate(dx: nil, dy: nil, parent: nil, &block)
29
+ dx, dy, target = Subtree.channels(self, dx, dy, parent)
20
30
  duplicated = Subtree.copy(self)
21
-
22
- duplicated.Traverse() do |element|
23
- id = element.attributes.delete(:id)
24
- element[:"#{ATTRIBUTE_INTERNAL_PREFIX}id"] = id if id
25
- block&.call(element)
26
- end
27
-
28
- duplicated.Translate(dx, dy) if dx || dy
29
-
30
- duplicated.Adopt(parent)
31
-
32
- duplicated
31
+ Subtree.prepare(duplicated, &block)
32
+ Subtree.translate(duplicated, dx, dy)
33
+ Subtree.attach(duplicated, target)
33
34
  end
34
35
 
35
36
  # Duplicates an element subtree along the x-axis.
@@ -37,34 +38,87 @@ module Sevgi
37
38
  # @param parent [Sevgi::Graphics::Element, nil] parent for the duplicated subtree
38
39
  # @yield [element] optional customization hook for each copied element
39
40
  # @yieldparam element [Sevgi::Graphics::Element] copied element
41
+ # @yieldreturn [Object] ignored customization result
40
42
  # @return [Sevgi::Graphics::Element] duplicated element
41
43
  # @raise [Sevgi::ArgumentError] when the target parent has a different element class
42
- def DuplicateX(dx, parent: nil, &block) = Duplicate(dx:, dy: 0, parent:, &block)
44
+ # @raise [Sevgi::ArgumentError] when dx is not a finite real number
45
+ # @raise [Sevgi::ArgumentError] when copied attributes or contents contain cyclic payloads
46
+ def DuplicateX(dx, parent: nil, &block)
47
+ ArgumentError.("Duplicate x translation cannot be nil") if dx.nil?
48
+ Duplicate(dx:, dy: 0, parent:, &block)
49
+ end
43
50
 
44
51
  # Duplicates an element subtree along the y-axis.
45
52
  # @param dy [Numeric] y translation
46
53
  # @param parent [Sevgi::Graphics::Element, nil] parent for the duplicated subtree
47
54
  # @yield [element] optional customization hook for each copied element
48
55
  # @yieldparam element [Sevgi::Graphics::Element] copied element
56
+ # @yieldreturn [Object] ignored customization result
49
57
  # @return [Sevgi::Graphics::Element] duplicated element
50
58
  # @raise [Sevgi::ArgumentError] when the target parent has a different element class
51
- def DuplicateY(dy, parent: nil, &block) = Duplicate(dx: 0, dy:, parent:, &block)
59
+ # @raise [Sevgi::ArgumentError] when dy is not a finite real number
60
+ # @raise [Sevgi::ArgumentError] when copied attributes or contents contain cyclic payloads
61
+ def DuplicateY(dy, parent: nil, &block)
62
+ ArgumentError.("Duplicate y translation cannot be nil") if dy.nil?
63
+ Duplicate(dx: 0, dy:, parent:, &block)
64
+ end
52
65
 
53
66
  # Recursive subtree copier that keeps duplicate implementation state out of the DSL surface.
54
67
  # @api private
55
68
  module Subtree
69
+ # Validates and normalizes duplicate option channels before copying.
70
+ # @return [Array<(Integer, Float, Sevgi::Graphics::Element, nil)>] normalized dx, dy, and target parent
71
+ # @raise [Sevgi::ArgumentError] when a translation or parent is invalid
72
+ def self.channels(source, dx, dy, parent)
73
+ target = parent.nil? ? source.parent : parent
74
+ unless target.nil? || source.instance_of?(target.class)
75
+ ArgumentError.("Element type does not match the new parent type: #{source.class}")
76
+ end
77
+
78
+ dx = Scalar.number(dx, context: "duplicate translation", field: :x) unless dx.nil?
79
+ dy = Scalar.number(dy, context: "duplicate translation", field: :y) unless dy.nil?
80
+ [dx, dy, target]
81
+ end
82
+
56
83
  # Builds an independent copy of an element subtree.
57
84
  # @param element [Sevgi::Graphics::Element] source subtree root
58
85
  # @param parent [Sevgi::Graphics::Element, Object] parent for the copied root
59
86
  # @return [Sevgi::Graphics::Element] copied subtree root
60
- def self.copy(element, parent = element.parent)
87
+ def self.copy(element, parent = Element.send(:tree_parent, element))
61
88
  element.dup.tap do |duplicated|
62
89
  duplicated.send(:parent=, parent)
63
90
  duplicated.send(:attributes=, element.attributes.dup)
64
- duplicated.send(:contents=, element.contents.dup)
91
+ duplicated.send(:contents=, element.contents.map(&:dup))
65
92
  duplicated.send(:children=, element.children.map { |child| copy(child, duplicated) })
66
93
  end
67
94
  end
95
+
96
+ # Removes copied public ids and applies an optional customization hook.
97
+ # @api private
98
+ def self.prepare(element, &block)
99
+ element.Traverse() do |node|
100
+ if node.attributes.has?(:id)
101
+ id = node.attributes.delete(:id)
102
+ metadata = :"#{Attributes::META_PREFIX}id"
103
+ node[metadata] = id unless node.attributes.has?(metadata)
104
+ end
105
+
106
+ block&.call(node)
107
+ end
108
+ end
109
+
110
+ # Applies an optional translation to a copied subtree.
111
+ # @api private
112
+ def self.translate(element, dx, dy)
113
+ element.Translate(dx || 0, dy) unless dx.nil? && dy.nil?
114
+ end
115
+
116
+ # Attaches a copied subtree unless it is a detached root copy.
117
+ # @api private
118
+ def self.attach(element, target)
119
+ element.Adopt(target) if target
120
+ element
121
+ end
68
122
  end
69
123
 
70
124
  private_constant :Subtree
@@ -5,14 +5,48 @@ module Sevgi
5
5
  module Mixtures
6
6
  # DSL helpers for native SVG export formats.
7
7
  module Export
8
+ # Validates the closed export option channel before optional components load or rendering starts.
9
+ # @api private
10
+ module Options
11
+ KEYS = %i[css default dpi height width].freeze
12
+ private_constant :KEYS
13
+
14
+ # Returns validated export options.
15
+ # @param options [Hash] export options
16
+ # @return [Hash] original options
17
+ # @raise [Sevgi::ArgumentError] when an option is unknown
18
+ def self.call(options)
19
+ unknown = options.keys - KEYS
20
+ ArgumentError.("Unknown export option: #{unknown.first}") unless unknown.empty?
21
+ options
22
+ end
23
+ end
24
+
25
+ private_constant :Options
26
+
8
27
  # Exports the document as PDF.
9
- # @param path [String, nil] output path or directory
28
+ # Relative paths are expanded, missing parent directories are created after export validation, and an existing
29
+ # file is replaced. An existing directory target uses the caller-derived default PDF name.
30
+ # @param path [String, #to_path, nil] output path or existing directory
10
31
  # @param kwargs [Hash] export options
11
- # @return [String] output path
32
+ # @option kwargs [String, #to_path, nil] :default caller-derived output name used when path is nil or a directory
33
+ # @option kwargs [Numeric, nil] :width finite positive target width in CSS pixels
34
+ # @option kwargs [Numeric, nil] :height finite positive target height in CSS pixels
35
+ # @option kwargs [Numeric] :dpi (96.0) finite positive CSS pixel density
36
+ # @option kwargs [String, nil] :css CSS inserted before rendering
37
+ # @yield [svg] transforms SVG source before rendering
38
+ # @yieldparam svg [String] rendered SVG source
39
+ # @yieldreturn [String] transformed SVG source
40
+ # @return [String] expanded output path
41
+ # @raise [Sevgi::ArgumentError] when a path, default, option name, CSS value, or transformed SVG is invalid
42
+ # @raise [Sevgi::ValidationError] when validation is enabled and the document violates the SVG standard
43
+ # @raise [Sevgi::Graphics::LintError] when linting is enabled and the document has structural conflicts
12
44
  # @raise [Sevgi::MissingComponentError] when sevgi/sundries is unavailable
13
45
  # @raise [Sevgi::MissingComponentError] when native export gems are unavailable
14
46
  # @raise [Sevgi::Sundries::Export::ExportError] when native export fails
47
+ # @raise [SystemCallError] when the output directory or file cannot be created or written
15
48
  def PDF(path = nil, **kwargs, &block)
49
+ kwargs = Options.(kwargs)
16
50
  begin
17
51
  require "sevgi/sundries"
18
52
 
@@ -26,13 +60,28 @@ module Sevgi
26
60
  end
27
61
 
28
62
  # Exports the document as PNG.
29
- # @param path [String, nil] output path or directory
63
+ # Relative paths are expanded, missing parent directories are created after export validation, and an existing
64
+ # file is replaced. An existing directory target uses the caller-derived default PNG name.
65
+ # @param path [String, #to_path, nil] output path or existing directory
30
66
  # @param kwargs [Hash] export options
31
- # @return [String] output path
67
+ # @option kwargs [String, #to_path, nil] :default caller-derived output name used when path is nil or a directory
68
+ # @option kwargs [Numeric, nil] :width finite positive target width in output pixels
69
+ # @option kwargs [Numeric, nil] :height finite positive target height in output pixels
70
+ # @option kwargs [Numeric] :dpi (96.0) finite positive CSS pixel density
71
+ # @option kwargs [String, nil] :css CSS inserted before rendering
72
+ # @yield [svg] transforms SVG source before rendering
73
+ # @yieldparam svg [String] rendered SVG source
74
+ # @yieldreturn [String] transformed SVG source
75
+ # @return [String] expanded output path
76
+ # @raise [Sevgi::ArgumentError] when a path, default, option name, CSS value, or transformed SVG is invalid
77
+ # @raise [Sevgi::ValidationError] when validation is enabled and the document violates the SVG standard
78
+ # @raise [Sevgi::Graphics::LintError] when linting is enabled and the document has structural conflicts
32
79
  # @raise [Sevgi::MissingComponentError] when sevgi/sundries is unavailable
33
80
  # @raise [Sevgi::MissingComponentError] when native export gems are unavailable
34
81
  # @raise [Sevgi::Sundries::Export::ExportError] when native export fails
82
+ # @raise [SystemCallError] when the output directory or file cannot be created or written
35
83
  def PNG(path = nil, **kwargs, &block)
84
+ kwargs = Options.(kwargs)
36
85
  begin
37
86
  require "sevgi/sundries"
38
87
 
@@ -48,15 +97,12 @@ module Sevgi
48
97
  private
49
98
 
50
99
  def Export(path = nil, default: nil, **kwargs, &block)
51
- default ||= F.subext(kwargs[:format] ? ".#{kwargs[:format]}" : ".pdf", caller_locations(2..2).first.path)
52
-
53
- if path
54
- ::File.directory?(path) ? ::File.join(path, ::File.basename(default)) : path
55
- else
56
- default
57
- end => path
100
+ if default.nil?
101
+ extension = kwargs[:format] ? ".#{kwargs[:format]}" : ".pdf"
102
+ default = F.subext(extension, caller_locations(2..2).first.path)
103
+ end
58
104
 
59
- ::FileUtils.mkdir_p(::File.dirname(path))
105
+ path = Path.resolve(path, default:, context: "Export")
60
106
 
61
107
  Sundries::Export.(call, path, **kwargs, &block)
62
108
  end
@@ -3,24 +3,65 @@
3
3
  module Sevgi
4
4
  module Graphics
5
5
  module Mixtures
6
- # DSL helpers for drawing geometry-derived hatch lines.
6
+ # DSL helpers for drawing geometry values and geometry-derived hatch lines.
7
+ #
8
+ # `Draw` delegates to each geometry object's drawing protocol. `Hatch`
9
+ # first sweeps interior spans through a closed lined element, then draws
10
+ # them. Its `angle` describes line direction; `step` is perpendicular
11
+ # spacing. The default initial line passes through `element.position`.
12
+ # The built-in `:inkscape` document profile includes this mixture;
13
+ # `:minimal`, `:default`, and `:html` do not.
14
+ #
15
+ # Hatch materializes separate finite SVG path elements. When only a repeated visual fill matters, use an SVG
16
+ # pattern and leave repetition and clipping to the renderer instead of computing line geometry.
17
+ # @example Add geometry drawing to a scoped custom profile
18
+ # profile = Class.new(Sevgi::Graphics::Document::Base)
19
+ # Sevgi::Graphics::Mixtures.mixin(:Hatch, profile)
20
+ # region = Sevgi::Geometry::Rect[24, 12]
21
+ # Sevgi::Graphics.SVG(profile) do
22
+ # Draw region.lines, stroke: "silver"
23
+ # Hatch region, angle: 30, step: 3, stroke: "black"
24
+ # end.Render
25
+ # @see Sevgi::Geometry::Operation.sweep
26
+ # @see https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Element/pattern SVG pattern element
7
27
  module Hatch
8
28
  # Draws one or more geometry line-like objects into this element.
9
- # @param lines [Object, Array<Object>] drawable geometry objects
29
+ # @example Draw geometry lines into a library-built document
30
+ # lines = [
31
+ # Sevgi::Geometry::Line.([0, 0], [20, 0]),
32
+ # Sevgi::Geometry::Line.([0, 5], [20, 5])
33
+ # ]
34
+ # drawing = Sevgi::Graphics.SVG(:inkscape) { Draw lines, stroke: "silver" }
35
+ # drawing.Render
36
+ # @param lines [#draw, Array<#draw>] drawable geometry objects
10
37
  # @param kwargs [Hash] SVG attributes passed to each draw call
11
- # @return [Array<Sevgi::Graphics::Element>] rendered line elements
38
+ # @return [Array<Sevgi::Graphics::Element>] rendered SVG elements
12
39
  def Draw(lines, **kwargs)
13
40
  Array(lines).map { it.draw(self, **kwargs) }
14
41
  end
15
42
 
16
43
  # Draws hatch lines swept through a geometry element.
17
- # @param element [Object] geometry element responding to position
44
+ # @example Hatch a closed geometry shape
45
+ # region = Sevgi::Geometry::Rect[24, 12, position: [2, 2]]
46
+ # drawing = Sevgi::Graphics.SVG(:inkscape) do
47
+ # Hatch region, angle: 30, step: 3, stroke: "black"
48
+ # end
49
+ # drawing.Render
50
+ # @example Control the first sweep line explicitly
51
+ # region = Sevgi::Geometry::Rect[24, 12, position: [2, 2]]
52
+ # Sevgi::Graphics.SVG(:inkscape) do
53
+ # Hatch region, initial: [2, 8], angle: 0, step: 3
54
+ # end
55
+ # @param element [Sevgi::Geometry::Element::Lined] lined geometry element to sweep
18
56
  # @param angle [Numeric] hatch angle in degrees
19
57
  # @param step [Numeric] distance between hatch lines
20
- # @param initial [Object, nil] initial point for the sweep
58
+ # @param initial [Sevgi::Geometry::Point, Array<Numeric>, nil] initial sweep point, or nil for element.position
21
59
  # @param kwargs [Hash] SVG attributes passed to each draw call
22
- # @return [Array<Sevgi::Graphics::Element>] rendered line elements
60
+ # @return [Array<Sevgi::Graphics::Element>] rendered hatch path elements
23
61
  # @raise [Sevgi::MissingComponentError] when sevgi/geometry is unavailable
62
+ # @raise [Sevgi::Geometry::Operation::OperationInapplicableError] when element is not sweepable
63
+ # @raise [Sevgi::Geometry::Error] when initial, angle, or step is invalid
64
+ # @raise [Sevgi::Geometry::Operation::OperationError] when no hatch lines are found or iteration reaches the limit
24
65
  def Hatch(element, angle:, step:, initial: nil, **kwargs)
25
66
  begin
26
67
  require "sevgi/geometry"
@@ -31,7 +72,8 @@ module Sevgi
31
72
  MissingComponentError.("sevgi/geometry")
32
73
  end
33
74
 
34
- Draw(Geometry::Operation.sweep!(element, initial: initial || element.position, angle:, step:), **kwargs)
75
+ initial = element.position if initial.nil? && element.is_a?(Geometry::Element::Lined)
76
+ Draw(Geometry::Operation.sweep!(element, initial:, angle:, step:), **kwargs)
35
77
  end
36
78
  end
37
79
  end