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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +275 -2
- data/LICENSE +672 -3
- data/README.md +8 -8
- data/lib/sevgi/graphics/attribute.rb +271 -54
- data/lib/sevgi/graphics/auxiliary/canvas.rb +137 -58
- data/lib/sevgi/graphics/auxiliary/content.rb +200 -57
- data/lib/sevgi/graphics/auxiliary/margin.rb +33 -14
- data/lib/sevgi/graphics/auxiliary/paper.rb +118 -86
- data/lib/sevgi/graphics/auxiliary/path.rb +44 -0
- data/lib/sevgi/graphics/auxiliary/scalar.rb +69 -0
- data/lib/sevgi/graphics/auxiliary/sizes.rb +62 -0
- data/lib/sevgi/graphics/auxiliary.rb +3 -0
- data/lib/sevgi/graphics/document/base.rb +6 -2
- data/lib/sevgi/graphics/document/default.rb +1 -1
- data/lib/sevgi/graphics/document.rb +370 -124
- data/lib/sevgi/graphics/element.rb +143 -33
- data/lib/sevgi/graphics/mixtures/call.rb +249 -88
- data/lib/sevgi/graphics/mixtures/core.rb +143 -33
- data/lib/sevgi/graphics/mixtures/duplicate.rb +76 -22
- data/lib/sevgi/graphics/mixtures/export.rb +58 -12
- data/lib/sevgi/graphics/mixtures/hatch.rb +49 -7
- data/lib/sevgi/graphics/mixtures/identify.rb +30 -17
- data/lib/sevgi/graphics/mixtures/include.rb +40 -5
- data/lib/sevgi/graphics/mixtures/inkscape.rb +215 -46
- data/lib/sevgi/graphics/mixtures/rdf.rb +79 -7
- data/lib/sevgi/graphics/mixtures/render.rb +88 -19
- data/lib/sevgi/graphics/mixtures/save.rb +79 -26
- data/lib/sevgi/graphics/mixtures/symbols.rb +81 -9
- data/lib/sevgi/graphics/mixtures/tile.rb +88 -64
- data/lib/sevgi/graphics/mixtures/transform.rb +68 -28
- data/lib/sevgi/graphics/mixtures/underscore.rb +16 -7
- data/lib/sevgi/graphics/mixtures/validate.rb +14 -2
- data/lib/sevgi/graphics/mixtures/wrappers.rb +66 -24
- data/lib/sevgi/graphics/mixtures.rb +19 -13
- data/lib/sevgi/graphics/version.rb +1 -1
- data/lib/sevgi/graphics/xml.rb +127 -0
- data/lib/sevgi/graphics.rb +75 -28
- 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
|
-
|
|
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
|
-
(
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
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 [
|
|
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
|
-
|
|
163
|
+
return if Root?()
|
|
164
|
+
|
|
165
|
+
Element.send(:detach, self)
|
|
166
|
+
self
|
|
154
167
|
end
|
|
155
168
|
|
|
156
|
-
# Prepends existing elements as children.
|
|
157
|
-
#
|
|
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
|
|
175
|
+
tap { Adoption.batch(elements, self, front: true) }
|
|
161
176
|
end
|
|
162
177
|
|
|
163
|
-
# Returns the
|
|
164
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
# @
|
|
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
|
-
|
|
219
|
-
|
|
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
|
-
# @
|
|
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
|
-
|
|
227
|
-
|
|
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
|
|
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.
|
|
10
|
-
# moved to
|
|
11
|
-
# ids without rendering
|
|
12
|
-
#
|
|
13
|
-
# @param
|
|
14
|
-
# @param
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
-
# @
|
|
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
|
-
#
|
|
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
|
-
# @
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
# @
|
|
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
|
|
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
|
-
# @
|
|
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 [
|
|
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
|
|
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
|
-
|
|
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
|