sevgi-graphics 0.94.0 → 0.95.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -53,20 +53,16 @@ module Sevgi
53
53
  # @return [Sevgi::Graphics::Element] self
54
54
  # @raise [Sevgi::ArgumentError] when the target parent has a different element class
55
55
  # @raise [Sevgi::ArgumentError] when the target parent is this element or one of its descendants
56
+ # @raise [Sevgi::ArgumentError] when index is not an Integer insertion position
56
57
  def Adopt(new_parent = nil, index: -1)
57
58
  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
-
59
+ new_parent ||= parent
66
60
  Adoption.validate(self, new_parent)
67
61
 
62
+ insertion = Adoption.index_for(self, new_parent, index)
63
+
68
64
  self.Orphan()
69
- (@parent = new_parent).children.insert(index, self)
65
+ (@parent = new_parent).children.insert(insertion, self)
70
66
  end
71
67
  end
72
68
 
@@ -80,11 +76,13 @@ module Sevgi
80
76
  Adopt(*, index: 0)
81
77
  end
82
78
 
83
- # Appends existing elements as children.
84
- # @param elements [Array<Sevgi::Graphics::Element>] elements to append
79
+ # Appends distinct existing elements as children in argument order.
80
+ # Each element transfers from its current parent. The complete batch is validated before any element moves.
81
+ # @param elements [Array<Sevgi::Graphics::Element>] distinct elements to append
85
82
  # @return [Sevgi::Graphics::Element] self
83
+ # @raise [Sevgi::ArgumentError] when an argument has a different element class, is repeated, or is this target or its ancestor
86
84
  def Append(*elements)
87
- tap { elements.each { it.Adopt(self) } }
85
+ tap { Adoption.batch(elements, self, front: false) }
88
86
  end
89
87
 
90
88
  # Adds CSS classes without duplicating existing values.
@@ -124,9 +122,12 @@ module Sevgi
124
122
  # @param tag [Symbol, String] SVG tag name
125
123
  # @param contents [Array<Object>] text or content objects; non-content objects are stringified and XML-encoded
126
124
  # @param attributes [Hash] SVG attributes
125
+ # @yield evaluates the drawing DSL in the new child element
126
+ # @yieldreturn [Object] ignored block result
127
127
  # @return [Sevgi::Graphics::Element] new child element
128
+ # @raise [Sevgi::ArgumentError] when the tag, attributes, or content are not valid XML
128
129
  def Element(tag, *contents, **attributes, &block)
129
- self.class.send(:new, tag.to_sym, contents: Content.contents(*contents), attributes:, parent: self, &block)
130
+ self.class.send(:new, tag, contents: Content.contents(*contents), attributes:, parent: self, &block)
130
131
  end
131
132
 
132
133
  # Forwards this element as the first argument to another receiver.
@@ -148,16 +149,18 @@ module Sevgi
148
149
  end
149
150
 
150
151
  # Removes this element from its parent.
151
- # @return [Array<Sevgi::Graphics::Element>, nil] parent children after deletion, or nil for root elements
152
+ # @return [Sevgi::Graphics::Element, nil] the deleted element, or nil for root elements
152
153
  def Orphan
153
154
  parent.children&.delete(self) unless Root?()
154
155
  end
155
156
 
156
- # Prepends existing elements as children.
157
- # @param elements [Array<Sevgi::Graphics::Element>] elements to prepend
157
+ # Prepends distinct existing elements as children in argument order.
158
+ # Each element transfers from its current parent. The complete batch is validated before any element moves.
159
+ # @param elements [Array<Sevgi::Graphics::Element>] distinct elements to prepend
158
160
  # @return [Sevgi::Graphics::Element] self
161
+ # @raise [Sevgi::ArgumentError] when an argument has a different element class, is repeated, or is this target or its ancestor
159
162
  def Prepend(*elements)
160
- tap { elements.each { it.AdoptFirst(self) } }
163
+ tap { Adoption.batch(elements, self, front: true) }
161
164
  end
162
165
 
163
166
  # Returns the root document element.
@@ -184,6 +187,10 @@ module Sevgi
184
187
  # Traverses the subtree depth-first.
185
188
  # @param depth [Integer] starting depth
186
189
  # @param leave [Proc, nil] optional leave callback
190
+ # @yield [element, depth] visits each element before its children
191
+ # @yieldparam element [Sevgi::Graphics::Element] visited element
192
+ # @yieldparam depth [Integer] element depth
193
+ # @yieldreturn [Object] ignored unless it is a {Stop} token
187
194
  # @return [Sevgi::Graphics::Element, Object] self or the value passed through Stay
188
195
  # @raise [Sevgi::ArgumentError] when no block is given
189
196
  def Traverse(depth = 0, leave = nil, &block)
@@ -194,6 +201,10 @@ module Sevgi
194
201
 
195
202
  # Traverses ancestors from this element to the root.
196
203
  # @param height [Integer] starting height
204
+ # @yield [element, height] visits this element and each ancestor
205
+ # @yieldparam element [Sevgi::Graphics::Element] visited element
206
+ # @yieldparam height [Integer] ancestor height
207
+ # @yieldreturn [Object] ignored unless it is a {Stop} token
197
208
  # @return [Object, nil] value passed through Stay, or nil
198
209
  # @raise [Sevgi::ArgumentError] when no block is given
199
210
  def TraverseUp(height = 0, &block)
@@ -214,6 +225,8 @@ module Sevgi
214
225
  # Evaluates a block in the parent element context.
215
226
  # @param args [Array<Object>] optional receiver override followed by block arguments
216
227
  # @param kwargs [Hash] keyword arguments passed to the block
228
+ # @yield evaluates in the selected receiver's parent context
229
+ # @yieldreturn [Object] ignored block result
217
230
  # @return [Sevgi::Graphics::Element] self
218
231
  def With(*args, **kwargs, &block)
219
232
  tap { (args.shift || self).parent.instance_exec(*args, **kwargs, &block) }
@@ -222,6 +235,8 @@ module Sevgi
222
235
  # Evaluates a block in this element context.
223
236
  # @param args [Array<Object>] optional receiver override followed by block arguments
224
237
  # @param kwargs [Hash] keyword arguments passed to the block
238
+ # @yield evaluates in the selected receiver context
239
+ # @yieldreturn [Object] ignored block result
225
240
  # @return [Sevgi::Graphics::Element] self
226
241
  def Within(*args, **kwargs, &block)
227
242
  tap { (args.shift || self).instance_exec(*args, **kwargs, &block) }
@@ -237,12 +252,32 @@ module Sevgi
237
252
  # Adoption target validation that keeps tree mutation atomic.
238
253
  # @api private
239
254
  module Adoption
255
+ # Moves a validated batch to the front or back of a parent.
256
+ # @param elements [Array<Sevgi::Graphics::Element>] elements to move
257
+ # @param parent [Sevgi::Graphics::Element] target parent
258
+ # @param front [Boolean] whether to prepend instead of append
259
+ # @return [void]
260
+ # @raise [Sevgi::ArgumentError] when an argument is incompatible, repeated, or would create a cycle
261
+ def self.batch(elements, parent, front:)
262
+ validate_batch(elements, parent)
263
+
264
+ if front
265
+ elements.reverse_each { it.AdoptFirst(parent) }
266
+ else
267
+ elements.each { it.Adopt(parent) }
268
+ end
269
+ end
270
+
240
271
  # Rejects target parents that would create a cycle.
241
272
  # @param element [Sevgi::Graphics::Element] element being moved
242
273
  # @param parent [Sevgi::Graphics::Element, Object] target parent
243
274
  # @return [void]
244
- # @raise [Sevgi::ArgumentError] when the target parent is this element or one of its descendants
275
+ # @raise [Sevgi::ArgumentError] when the parent is incompatible or would create a cycle
245
276
  def self.validate(element, parent)
277
+ unless element.instance_of?(parent.class)
278
+ ArgumentError.("Element type does not match the new parent type: #{element.class}")
279
+ end
280
+
246
281
  ArgumentError.("Element cannot be adopted under itself") if parent.equal?(element)
247
282
 
248
283
  while parent.respond_to?(:Root?)
@@ -252,6 +287,42 @@ module Sevgi
252
287
  parent = parent.parent
253
288
  end
254
289
  end
290
+
291
+ # Normalizes an insertion index before tree mutation.
292
+ # @param index [Integer] requested insertion index
293
+ # @param size [Integer] number of available child positions
294
+ # @return [Integer] normalized non-negative insertion index
295
+ # @raise [Sevgi::ArgumentError] when the index is not an insertion position
296
+ def self.index(index, size)
297
+ ArgumentError.("Adoption index must be an Integer") unless index.is_a?(Integer)
298
+
299
+ normalized = index.negative? ? size + index + 1 : index
300
+ ArgumentError.("Adoption index is outside the child list") unless normalized.between?(0, size)
301
+
302
+ normalized
303
+ end
304
+
305
+ # Returns an insertion index after accounting for a same-parent move.
306
+ # @param element [Sevgi::Graphics::Element] element being moved
307
+ # @param parent [Sevgi::Graphics::Element] target parent
308
+ # @param index [Integer] requested insertion index
309
+ # @return [Integer] normalized insertion index
310
+ def self.index_for(element, parent, index)
311
+ same_parent = element.parent.equal?(parent) && parent.children.include?(element)
312
+ index(index, parent.children.size - (same_parent ? 1 : 0))
313
+ end
314
+
315
+ def self.validate_batch(elements, parent)
316
+ seen = {}.compare_by_identity
317
+ elements.each do |element|
318
+ ArgumentError.("Element appears more than once in adoption batch") if seen.key?(element)
319
+
320
+ seen[element] = true
321
+ validate(element, parent)
322
+ end
323
+ end
324
+
325
+ private_class_method :validate_batch
255
326
  end
256
327
 
257
328
  private_constant :Adoption
@@ -14,22 +14,15 @@ module Sevgi
14
14
  # @param parent [Sevgi::Graphics::Element, nil] parent for the duplicated subtree
15
15
  # @yield [element] optional customization hook for each copied element
16
16
  # @yieldparam element [Sevgi::Graphics::Element] copied element
17
+ # @yieldreturn [Object] ignored customization result
17
18
  # @return [Sevgi::Graphics::Element] duplicated element
18
19
  # @raise [Sevgi::ArgumentError] when the target parent has a different element class
20
+ # @raise [Sevgi::ArgumentError] when copied attributes or contents contain cyclic payloads
19
21
  def Duplicate(dx: nil, dy: nil, parent: nil, &block)
20
22
  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
23
+ Subtree.prepare(duplicated, &block)
24
+ Subtree.translate(duplicated, dx, dy)
25
+ Subtree.attach(duplicated, self, parent)
33
26
  end
34
27
 
35
28
  # Duplicates an element subtree along the x-axis.
@@ -37,8 +30,10 @@ module Sevgi
37
30
  # @param parent [Sevgi::Graphics::Element, nil] parent for the duplicated subtree
38
31
  # @yield [element] optional customization hook for each copied element
39
32
  # @yieldparam element [Sevgi::Graphics::Element] copied element
33
+ # @yieldreturn [Object] ignored customization result
40
34
  # @return [Sevgi::Graphics::Element] duplicated element
41
35
  # @raise [Sevgi::ArgumentError] when the target parent has a different element class
36
+ # @raise [Sevgi::ArgumentError] when copied attributes or contents contain cyclic payloads
42
37
  def DuplicateX(dx, parent: nil, &block) = Duplicate(dx:, dy: 0, parent:, &block)
43
38
 
44
39
  # Duplicates an element subtree along the y-axis.
@@ -46,8 +41,10 @@ module Sevgi
46
41
  # @param parent [Sevgi::Graphics::Element, nil] parent for the duplicated subtree
47
42
  # @yield [element] optional customization hook for each copied element
48
43
  # @yieldparam element [Sevgi::Graphics::Element] copied element
44
+ # @yieldreturn [Object] ignored customization result
49
45
  # @return [Sevgi::Graphics::Element] duplicated element
50
46
  # @raise [Sevgi::ArgumentError] when the target parent has a different element class
47
+ # @raise [Sevgi::ArgumentError] when copied attributes or contents contain cyclic payloads
51
48
  def DuplicateY(dy, parent: nil, &block) = Duplicate(dx: 0, dy:, parent:, &block)
52
49
 
53
50
  # Recursive subtree copier that keeps duplicate implementation state out of the DSL surface.
@@ -61,10 +58,33 @@ module Sevgi
61
58
  element.dup.tap do |duplicated|
62
59
  duplicated.send(:parent=, parent)
63
60
  duplicated.send(:attributes=, element.attributes.dup)
64
- duplicated.send(:contents=, element.contents.dup)
61
+ duplicated.send(:contents=, element.contents.map(&:dup))
65
62
  duplicated.send(:children=, element.children.map { |child| copy(child, duplicated) })
66
63
  end
67
64
  end
65
+
66
+ # Removes copied public ids and applies an optional customization hook.
67
+ # @api private
68
+ def self.prepare(element, &block)
69
+ element.Traverse() do |node|
70
+ id = node.attributes.delete(:id)
71
+ node[:"#{ATTRIBUTE_INTERNAL_PREFIX}id"] = id if id
72
+ block&.call(node)
73
+ end
74
+ end
75
+
76
+ # Applies an optional translation to a copied subtree.
77
+ # @api private
78
+ def self.translate(element, dx, dy)
79
+ element.Translate(dx, dy) if dx || dy
80
+ end
81
+
82
+ # Attaches a copied subtree unless it is a detached root copy.
83
+ # @api private
84
+ def self.attach(element, source, parent)
85
+ element.Adopt(parent) if parent || !source.Root?()
86
+ element
87
+ end
68
88
  end
69
89
 
70
90
  private_constant :Subtree
@@ -8,6 +8,9 @@ module Sevgi
8
8
  # Exports the document as PDF.
9
9
  # @param path [String, nil] output path or directory
10
10
  # @param kwargs [Hash] export options
11
+ # @yield [svg] transforms SVG source before rendering
12
+ # @yieldparam svg [String] rendered SVG source
13
+ # @yieldreturn [String] transformed SVG source
11
14
  # @return [String] output path
12
15
  # @raise [Sevgi::MissingComponentError] when sevgi/sundries is unavailable
13
16
  # @raise [Sevgi::MissingComponentError] when native export gems are unavailable
@@ -28,6 +31,9 @@ module Sevgi
28
31
  # Exports the document as PNG.
29
32
  # @param path [String, nil] output path or directory
30
33
  # @param kwargs [Hash] export options
34
+ # @yield [svg] transforms SVG source before rendering
35
+ # @yieldparam svg [String] rendered SVG source
36
+ # @yieldreturn [String] transformed SVG source
31
37
  # @return [String] output path
32
38
  # @raise [Sevgi::MissingComponentError] when sevgi/sundries is unavailable
33
39
  # @raise [Sevgi::MissingComponentError] when native export gems are unavailable
@@ -4,6 +4,23 @@ module Sevgi
4
4
  module Graphics
5
5
  module Mixtures
6
6
  # DSL helpers for including derendered SVG/XML fragments.
7
+ #
8
+ # @!method Include(file, id)
9
+ # Includes a derendered node matching an id.
10
+ # SVG/XML content is treated as data and is not evaluated as Ruby source.
11
+ # @param file [String] source SVG/XML file
12
+ # @param id [String, Symbol] source node id
13
+ # @return [Sevgi::Graphics::Element, nil] included element, or nil when it produces no graphics output
14
+ # @raise [Sevgi::ArgumentError] when the file is absent or XML content is malformed, rootless, or lacks the id
15
+ # @raise [Sevgi::MissingComponentError] when sevgi/derender is unavailable
16
+ # @!method IncludeChildren(file, id)
17
+ # Includes the children of a derendered node matching an id.
18
+ # SVG/XML content is treated as data and is not evaluated as Ruby source.
19
+ # @param file [String] source SVG/XML file
20
+ # @param id [String, Symbol] source node id
21
+ # @return [Array<Sevgi::Graphics::Element>] included children
22
+ # @raise [Sevgi::ArgumentError] when the file is absent or XML content is malformed, rootless, or lacks the id
23
+ # @raise [Sevgi::MissingComponentError] when sevgi/derender is unavailable
7
24
  module Include
8
25
  require "sevgi/derender"
9
26
 
@@ -26,6 +26,8 @@ module Sevgi
26
26
  # @param mod [Module] callable drawing module
27
27
  # @param args [Array<Object>] callable arguments
28
28
  # @param kwargs [Hash] group attributes
29
+ # @yield forwards customization to the callable module
30
+ # @yieldreturn [Object] callable customization result
29
31
  # @return [Sevgi::Graphics::Element] group element
30
32
  # @raise [Sevgi::ArgumentError] when mod is not a plain module
31
33
  def Group(mod, *args, **kwargs, &block)
@@ -37,6 +39,8 @@ module Sevgi
37
39
  # @param mod [Module] callable drawing module
38
40
  # @param args [Array<Object>] callable arguments
39
41
  # @param kwargs [Hash] layer attributes
42
+ # @yield forwards customization to the callable module
43
+ # @yieldreturn [Object] callable customization result
40
44
  # @return [Sevgi::Graphics::Element] layer element
41
45
  # @raise [Sevgi::ArgumentError] when mod is not a plain module
42
46
  def Layer(mod, *args, **kwargs, &block)
@@ -48,6 +52,8 @@ module Sevgi
48
52
  # @param mod [Module] callable drawing module
49
53
  # @param args [Array<Object>] callable arguments
50
54
  # @param kwargs [Hash] layer attributes
55
+ # @yield forwards customization to the callable module
56
+ # @yieldreturn [Object] callable customization result
51
57
  # @return [Sevgi::Graphics::Element] layer element
52
58
  # @raise [Sevgi::ArgumentError] when mod is not a plain module
53
59
  def Layer!(mod, *args, **kwargs, &block)
@@ -58,6 +64,8 @@ module Sevgi
58
64
  # @overload layer(**attributes)
59
65
  # Builds an Inkscape layer group.
60
66
  # @param attributes [Hash] layer attributes
67
+ # @yield evaluates the drawing DSL in the layer
68
+ # @yieldreturn [Object] ignored block result
61
69
  # @return [Sevgi::Graphics::Element] layer group
62
70
  def layer(**, &block)
63
71
  g("inkscape:groupmode": "layer", **, &block)
@@ -66,6 +74,8 @@ module Sevgi
66
74
  # @overload layer!(**attributes)
67
75
  # Builds an insensitive Inkscape layer group.
68
76
  # @param attributes [Hash] layer attributes
77
+ # @yield evaluates the drawing DSL in the layer
78
+ # @yieldreturn [Object] ignored block result
69
79
  # @return [Sevgi::Graphics::Element] layer group
70
80
  def layer!(**, &block)
71
81
  layer("sodipodi:insensitive": "true", **, &block)
@@ -74,6 +84,9 @@ module Sevgi
74
84
  # Builds an Inkscape namedview containing page elements.
75
85
  # @param pages [Array<Hash>] page attribute hashes
76
86
  # @param id [String] namedview id
87
+ # @yield [page] customizes each generated page element
88
+ # @yieldparam page [Sevgi::Graphics::Element] generated page element
89
+ # @yieldreturn [Object] ignored customization result
77
90
  # @return [Sevgi::Graphics::Element] namedview element
78
91
  def Pages(*pages, id: "namedview", **, &block)
79
92
  Element(:"sodipodi:namedview", id:, **) do
@@ -111,6 +124,8 @@ module Sevgi
111
124
  # @overload symbol!(**attributes)
112
125
  # Builds an Inkscape symbol group hidden from the symbols menu.
113
126
  # @param attributes [Hash] symbol attributes
127
+ # @yield evaluates the drawing DSL in the symbol group
128
+ # @yieldreturn [Object] ignored block result
114
129
  # @return [Sevgi::Graphics::Element] symbol group
115
130
  def symbol!(**, &block)
116
131
  if Is?(:defs)
@@ -7,6 +7,8 @@ module Sevgi
7
7
  module RDF
8
8
  # Adds RDF license metadata inside a metadata element.
9
9
  # @param kwargs [Hash] RDF work options
10
+ # @yield evaluates additional RDF work metadata
11
+ # @yieldreturn [Object] ignored block result
10
12
  # @return [Sevgi::Graphics::Element] metadata element
11
13
  def License(**kwargs, &block) = metadata { RDFWork(**kwargs, &block) }
12
14
 
@@ -14,6 +16,8 @@ module Sevgi
14
16
  #
15
17
  # Adds Creative Commons BY-SA license metadata.
16
18
  # @param kwargs [Hash] RDF work options
19
+ # @yield evaluates additional RDF work metadata
20
+ # @yieldreturn [Object] ignored block result
17
21
  # @return [Sevgi::Graphics::Element] metadata element
18
22
  def License_CC_BY_SA(**kwargs, &block)
19
23
  License(**kwargs, license: "https://creativecommons.org/licenses/by-sa/4.0/", &block)
@@ -21,6 +25,8 @@ module Sevgi
21
25
 
22
26
  # Adds Creative Commons BY-NC license metadata.
23
27
  # @param kwargs [Hash] RDF work options
28
+ # @yield evaluates additional RDF work metadata
29
+ # @yieldreturn [Object] ignored block result
24
30
  # @return [Sevgi::Graphics::Element] metadata element
25
31
  def License_CC_BY_NC(**kwargs, &block)
26
32
  License(**kwargs, license: "https://creativecommons.org/licenses/by-nc/4.0/", &block)
@@ -28,6 +34,8 @@ module Sevgi
28
34
 
29
35
  # Adds Creative Commons BY-NC-SA license metadata.
30
36
  # @param kwargs [Hash] RDF work options
37
+ # @yield evaluates additional RDF work metadata
38
+ # @yieldreturn [Object] ignored block result
31
39
  # @return [Sevgi::Graphics::Element] metadata element
32
40
  def License_CC_BY_NC_SA(**kwargs, &block)
33
41
  License(**kwargs, license: "https://creativecommons.org/licenses/by-nc-sa/4.0/", &block)
@@ -35,6 +43,8 @@ module Sevgi
35
43
 
36
44
  # Adds Creative Commons BY-ND license metadata.
37
45
  # @param kwargs [Hash] RDF work options
46
+ # @yield evaluates additional RDF work metadata
47
+ # @yieldreturn [Object] ignored block result
38
48
  # @return [Sevgi::Graphics::Element] metadata element
39
49
  def License_CC_BY_ND(**kwargs, &block)
40
50
  License(**kwargs, license: "https://creativecommons.org/licenses/by-nd/4.0/", &block)
@@ -42,6 +52,8 @@ module Sevgi
42
52
 
43
53
  # Adds Creative Commons BY-NC-ND license metadata.
44
54
  # @param kwargs [Hash] RDF work options
55
+ # @yield evaluates additional RDF work metadata
56
+ # @yieldreturn [Object] ignored block result
45
57
  # @return [Sevgi::Graphics::Element] metadata element
46
58
  def License_CC_BY_NC_ND(**kwargs, &block)
47
59
  License(**kwargs, license: "https://creativecommons.org/licenses/by-nc-nd/4.0/", &block)
@@ -49,6 +61,8 @@ module Sevgi
49
61
 
50
62
  # Adds Creative Commons Zero metadata.
51
63
  # @param kwargs [Hash] RDF work options
64
+ # @yield evaluates additional RDF work metadata
65
+ # @yieldreturn [Object] ignored block result
52
66
  # @return [Sevgi::Graphics::Element] metadata element
53
67
  def License_CC0(**kwargs, &block)
54
68
  License(**kwargs, license: "https://creativecommons.org/publicdomain/zero/1.0/", &block)
@@ -56,11 +70,15 @@ module Sevgi
56
70
 
57
71
  # Adds Free Art License metadata.
58
72
  # @param kwargs [Hash] RDF work options
73
+ # @yield evaluates additional RDF work metadata
74
+ # @yieldreturn [Object] ignored block result
59
75
  # @return [Sevgi::Graphics::Element] metadata element
60
76
  def License_LAL(**kwargs, &block) = License(**kwargs, license: "https://artlibre.org/licence/lal/en/", &block)
61
77
 
62
78
  # Builds an RDF root element.
63
79
  # @param _kwargs [Hash] currently unused options
80
+ # @yield evaluates the RDF drawing DSL
81
+ # @yieldreturn [Object] ignored block result
64
82
  # @return [Sevgi::Graphics::Element] RDF element
65
83
  # @raise [Sevgi::ArgumentError] when no block is given
66
84
  def RDF(**_kwargs, &block)
@@ -86,6 +104,8 @@ module Sevgi
86
104
  # @option kwargs [String] :date date
87
105
  # @option kwargs [String] :language language
88
106
  # @option kwargs [String] :license license URL
107
+ # @yield evaluates additional RDF work metadata
108
+ # @yieldreturn [Object] ignored block result
89
109
  # @return [Sevgi::Graphics::Element] RDF element
90
110
  def RDFWork(**kwargs, &block)
91
111
  RDF do
@@ -10,6 +10,7 @@ module Sevgi
10
10
  class Renderer
11
11
  # Default renderer options.
12
12
  DEFAULTS = {indent: " ", linelength: 140, style: :hybrid}.freeze
13
+ SVG_NAMESPACE = "http://www.w3.org/2000/svg"
13
14
 
14
15
  # Attribute rendering strategies.
15
16
  # @api private
@@ -77,7 +78,7 @@ module Sevgi
77
78
  ELEMENTS_WITH_BLOCK_CONTENT = %i[style].freeze
78
79
  SEPARATOR = "\n"
79
80
 
80
- private_constant :ELEMENTS_WITH_INLINE_CONTENT, :ELEMENTS_WITH_BLOCK_CONTENT, :SEPARATOR
81
+ private_constant :ELEMENTS_WITH_INLINE_CONTENT, :ELEMENTS_WITH_BLOCK_CONTENT, :SEPARATOR, :SVG_NAMESPACE
81
82
 
82
83
  # @return [Sevgi::Graphics::Element] root element
83
84
  attr_reader :root
@@ -161,7 +162,7 @@ module Sevgi
161
162
  # @option options [Integer] :linelength hybrid attribute line length
162
163
  # @option options [Symbol] :style attribute style, one of :hybrid, :inline, or :block
163
164
  # @return [void]
164
- # @raise [Sevgi::ArgumentError] when style is missing or unsupported
165
+ # @raise [Sevgi::ArgumentError] when an option is unknown, malformed, missing, or unsupported
165
166
  def initialize(root, **)
166
167
  @root = root
167
168
  @options = DEFAULTS.merge(**)
@@ -176,14 +177,14 @@ module Sevgi
176
177
  # @param root [Sevgi::Graphics::Element] root element
177
178
  # @param options [Hash] renderer options
178
179
  # @return [String] SVG source
179
- # @raise [Sevgi::ArgumentError] when style is missing or unsupported
180
+ # @raise [Sevgi::ArgumentError] when options, preambles, names, attributes, or content are invalid XML
180
181
  def self.call(root, **) = new(root, **).call(*root.class.preambles)
181
182
 
182
183
  # Renders a root element without document preambles.
183
184
  # @param root [Sevgi::Graphics::Element] root element
184
185
  # @param options [Hash] renderer options
185
186
  # @return [String] SVG fragment source
186
- # @raise [Sevgi::ArgumentError] when style is missing or unsupported
187
+ # @raise [Sevgi::ArgumentError] when options, names, attributes, or content are invalid XML
187
188
  def self.fragment(root, **options) = new(root, **options).call
188
189
 
189
190
  # Appends rendered lines to the output buffer.
@@ -199,8 +200,9 @@ module Sevgi
199
200
  # Renders the document.
200
201
  # @param preambles [Array<String>] preamble lines
201
202
  # @return [String] SVG source
203
+ # @raise [Sevgi::ArgumentError] when a preamble or rendered value is not valid XML text
202
204
  def call(*preambles)
203
- output.append(preambles) unless preambles.empty?
205
+ output.append(preambles.map { XML.text(it, context: "XML preamble") }) unless preambles.empty?
204
206
 
205
207
  root.Traverse(
206
208
  0,
@@ -234,6 +236,7 @@ module Sevgi
234
236
  end
235
237
 
236
238
  def build
239
+ validate_options!
237
240
  ArgumentError.("Missing style") unless options[:style]
238
241
 
239
242
  case options[:style]
@@ -254,10 +257,29 @@ module Sevgi
254
257
  element.children.empty? && element.contents.empty?
255
258
  end
256
259
 
260
+ def block_content?(element)
261
+ return false unless ELEMENTS_WITH_BLOCK_CONTENT.include?(element.name)
262
+
263
+ namespace = default_namespace(element)
264
+ namespace.nil? || namespace.to_s.empty? || namespace == SVG_NAMESPACE
265
+ end
266
+
257
267
  def closed = @closed = true
258
268
 
259
269
  def closed? = @closed.tap { unclosed }
260
270
 
271
+ def default_namespace(element)
272
+ current = element
273
+
274
+ while current.is_a?(Element)
275
+ return current.attributes[:xmlns] if current.attributes.has?(:xmlns)
276
+
277
+ current = current.parent
278
+ end
279
+
280
+ nil
281
+ end
282
+
261
283
  def contents(element, depth)
262
284
  return if element.contents.empty?
263
285
 
@@ -272,10 +294,10 @@ module Sevgi
272
294
  def floating?(element) = element.Is?(:_)
273
295
 
274
296
  def inline_content?(element)
275
- return false if ELEMENTS_WITH_BLOCK_CONTENT.include?(element.name)
297
+ return false if block_content?(element)
276
298
  return false if floating?(element)
277
299
 
278
- element.contents.size == 1 || ELEMENTS_WITH_INLINE_CONTENT.include?(element.name)
300
+ element.contents.any? || ELEMENTS_WITH_INLINE_CONTENT.include?(element.name)
279
301
  end
280
302
 
281
303
  def indent(depth)
@@ -284,7 +306,7 @@ module Sevgi
284
306
 
285
307
  def join
286
308
  inlines.join(output, indent: options[:indent], separator: SEPARATOR)
287
- output.join(SEPARATOR)
309
+ XML.text(output.join(SEPARATOR), context: "SVG output")
288
310
  end
289
311
 
290
312
  def render_enter(element, depth)
@@ -307,6 +329,22 @@ module Sevgi
307
329
  end
308
330
 
309
331
  def unclosed = @closed = false
332
+
333
+ def validate_options!
334
+ unknown = options.keys - DEFAULTS.keys
335
+ ArgumentError.("Unknown renderer options: #{unknown.join(", ")}") unless unknown.empty?
336
+
337
+ indent = options[:indent]
338
+ unless indent.is_a?(::String) && /\A[\t\n\r ]*\z/.match?(indent)
339
+ ArgumentError.("Renderer indent must contain only XML whitespace")
340
+ end
341
+
342
+ options[:indent] = XML.text(indent, context: "Renderer indent")
343
+ linelength = options[:linelength]
344
+ return if linelength.is_a?(::Integer) && linelength >= 0
345
+
346
+ ArgumentError.("Renderer linelength must be a non-negative Integer")
347
+ end
310
348
  end
311
349
 
312
350
  private_constant :Renderer
@@ -315,10 +353,11 @@ module Sevgi
315
353
  # Renders this element as SVG source.
316
354
  # Elements with inline text content may also contain inline children such as `tspan`; the renderer keeps those
317
355
  # descendants in the same text line. Whitespace inside content objects is preserved as given, and encoded
318
- # content is XML-escaped unless a verbatim content object is used.
356
+ # content is XML-escaped unless a verbatim content object is used. SVG `style` elements use block content;
357
+ # same-named elements under a foreign default namespace retain ordinary inline text formatting.
319
358
  # @param options [Hash] renderer options
320
359
  # @return [String] SVG source
321
- # @raise [Sevgi::ArgumentError] when style is missing or unsupported
360
+ # @raise [Sevgi::ArgumentError] when options, preambles, names, attributes, or content are invalid XML
322
361
  def Render(**) = Renderer.(self, **)
323
362
 
324
363
  # Renders only this element's children.
@@ -326,7 +365,13 @@ module Sevgi
326
365
  # mixed-content formatting.
327
366
  # @param separator [String] separator between child documents
328
367
  # @return [String] rendered child fragments
329
- def RenderChildren(separator = "\n\n") = children.map { Renderer.fragment(it) }.join(separator)
368
+ # @raise [Sevgi::ArgumentError] when separator or rendered child data is not valid XML text
369
+ def RenderChildren(separator = "\n\n")
370
+ ArgumentError.("SVG fragment separator must be a String") unless separator.is_a?(::String)
371
+
372
+ separator = XML.text(separator, context: "SVG fragment separator")
373
+ children.map { Renderer.fragment(it) }.join(separator)
374
+ end
330
375
  end
331
376
  end
332
377
  end
@@ -12,6 +12,9 @@ module Sevgi
12
12
 
13
13
  # Writes rendered SVG to standard output.
14
14
  # @param kwargs [Hash] render options
15
+ # @yield [content] optionally transforms rendered content before output
16
+ # @yieldparam content [String] rendered SVG source
17
+ # @yieldreturn [String] transformed SVG source
15
18
  # @return [Object] F.out return value
16
19
  def Out(**kwargs, &filter)
17
20
  F.out(self.(**kwargs), &filter)
@@ -21,6 +24,9 @@ module Sevgi
21
24
  # @param path [String, nil] output path or directory
22
25
  # @param default [String, nil] default output path
23
26
  # @param backup_suffix [String, nil] suffix used for an existing-file backup
27
+ # @yield [content] optionally transforms rendered content before output
28
+ # @yieldparam content [String] rendered SVG source
29
+ # @yieldreturn [String] transformed SVG source
24
30
  # @return [Object] F.out return value
25
31
  def Save(path = nil, default: nil, backup_suffix: nil, &filter)
26
32
  default ||= F.subext(EXT, caller_locations(1..1).first.path)
@@ -42,6 +48,9 @@ module Sevgi
42
48
  # Writes rendered SVG to a path.
43
49
  # @param path [String] output path
44
50
  # @param kwargs [Hash] render options
51
+ # @yield [content] optionally transforms rendered content before output
52
+ # @yieldparam content [String] rendered SVG source
53
+ # @yieldreturn [String] transformed SVG source
45
54
  # @return [Object] F.out return value
46
55
  def Write(path, **kwargs, &filter)
47
56
  F.out(self.(**kwargs), path, &filter)