sevgi-graphics 0.95.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +221 -2
  3. data/README.md +12 -9
  4. data/lib/sevgi/graphics/attribute.rb +166 -45
  5. data/lib/sevgi/graphics/auxiliary/canvas.rb +100 -43
  6. data/lib/sevgi/graphics/auxiliary/content.rb +56 -47
  7. data/lib/sevgi/graphics/auxiliary/margin.rb +19 -12
  8. data/lib/sevgi/graphics/auxiliary/paper.rb +74 -49
  9. data/lib/sevgi/graphics/auxiliary/path.rb +44 -0
  10. data/lib/sevgi/graphics/auxiliary/scalar.rb +36 -7
  11. data/lib/sevgi/graphics/auxiliary.rb +1 -0
  12. data/lib/sevgi/graphics/document/base.rb +6 -2
  13. data/lib/sevgi/graphics/document/default.rb +1 -1
  14. data/lib/sevgi/graphics/document.rb +239 -117
  15. data/lib/sevgi/graphics/element.rb +132 -34
  16. data/lib/sevgi/graphics/mixtures/call.rb +234 -88
  17. data/lib/sevgi/graphics/mixtures/core.rb +67 -27
  18. data/lib/sevgi/graphics/mixtures/duplicate.rb +47 -25
  19. data/lib/sevgi/graphics/mixtures/export.rb +54 -12
  20. data/lib/sevgi/graphics/mixtures/hatch.rb +49 -7
  21. data/lib/sevgi/graphics/mixtures/identify.rb +30 -17
  22. data/lib/sevgi/graphics/mixtures/include.rb +26 -8
  23. data/lib/sevgi/graphics/mixtures/inkscape.rb +214 -47
  24. data/lib/sevgi/graphics/mixtures/rdf.rb +59 -7
  25. data/lib/sevgi/graphics/mixtures/render.rb +60 -120
  26. data/lib/sevgi/graphics/mixtures/save.rb +79 -35
  27. data/lib/sevgi/graphics/mixtures/symbols.rb +81 -12
  28. data/lib/sevgi/graphics/mixtures/tile.rb +85 -67
  29. data/lib/sevgi/graphics/mixtures/transform.rb +89 -30
  30. data/lib/sevgi/graphics/mixtures/underscore.rb +16 -7
  31. data/lib/sevgi/graphics/mixtures/validate.rb +2 -2
  32. data/lib/sevgi/graphics/mixtures/wrappers.rb +111 -23
  33. data/lib/sevgi/graphics/mixtures.rb +15 -13
  34. data/lib/sevgi/graphics/version.rb +1 -1
  35. data/lib/sevgi/graphics/xml.rb +4 -9
  36. data/lib/sevgi/graphics.rb +69 -18
  37. metadata +7 -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
@@ -62,7 +64,7 @@ module Sevgi
62
64
  insertion = Adoption.index_for(self, new_parent, index)
63
65
 
64
66
  self.Orphan()
65
- (@parent = new_parent).children.insert(insertion, self)
67
+ Element.send(:attach, self, new_parent, index: insertion)
66
68
  end
67
69
  end
68
70
 
@@ -78,6 +80,12 @@ module Sevgi
78
80
 
79
81
  # Appends distinct existing elements as children in argument order.
80
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
81
89
  # @param elements [Array<Sevgi::Graphics::Element>] distinct elements to append
82
90
  # @return [Sevgi::Graphics::Element] self
83
91
  # @raise [Sevgi::ArgumentError] when an argument has a different element class, is repeated, or is this target or its ancestor
@@ -86,7 +94,7 @@ module Sevgi
86
94
  end
87
95
 
88
96
  # Adds CSS classes without duplicating existing values.
89
- # @param classes [Array<String, Symbol, Array>] class tokens; strings are split on whitespace
97
+ # @param classes [Array<String, Symbol, Array>] class tokens. Strings are split on whitespace
90
98
  # @return [Sevgi::Graphics::Element] self
91
99
  def Classify(*classes)
92
100
  tap do
@@ -120,14 +128,15 @@ module Sevgi
120
128
 
121
129
  # Builds a child element with an explicit tag name.
122
130
  # @param tag [Symbol, String] SVG tag name
123
- # @param contents [Array<Object>] text or content objects; non-content objects are stringified and XML-encoded
131
+ # @param contents [Array<Object>] text or content objects. Non-content objects are stringified and XML-encoded
124
132
  # @param attributes [Hash] SVG attributes
125
133
  # @yield evaluates the drawing DSL in the new child element
126
134
  # @yieldreturn [Object] ignored block result
127
135
  # @return [Sevgi::Graphics::Element] new child element
128
136
  # @raise [Sevgi::ArgumentError] when the tag, attributes, or content are not valid XML
129
137
  def Element(tag, *contents, **attributes, &block)
130
- self.class.send(:new, tag, 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)
131
140
  end
132
141
 
133
142
  # Forwards this element as the first argument to another receiver.
@@ -148,10 +157,13 @@ module Sevgi
148
157
  self.name() == name.to_sym
149
158
  end
150
159
 
151
- # Removes this element from its parent.
152
- # @return [Sevgi::Graphics::Element, nil] the deleted element, 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
153
162
  def Orphan
154
- parent.children&.delete(self) unless Root?()
163
+ return if Root?()
164
+
165
+ Element.send(:detach, self)
166
+ self
155
167
  end
156
168
 
157
169
  # Prepends distinct existing elements as children in argument order.
@@ -163,11 +175,12 @@ module Sevgi
163
175
  tap { Adoption.batch(elements, self, front: true) }
164
176
  end
165
177
 
166
- # Returns the root document element.
167
- # @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
168
181
  def Root
169
182
  element = self
170
- element = element.parent until element.Root?()
183
+ element = element.parent while element.parent
171
184
 
172
185
  element
173
186
  end
@@ -175,16 +188,20 @@ module Sevgi
175
188
  # Reports whether this element is the root document element.
176
189
  # @return [Boolean]
177
190
  def Root?
178
- self.class.root?(self)
191
+ Element.root?(self)
179
192
  end
180
193
 
181
194
  # @overload Stay(value)
182
195
  # Wraps a traversal return value as a stop token.
183
196
  # @param value [Object] value returned from traversal
184
197
  # @return [Sevgi::Graphics::Mixtures::Stop]
185
- def Stay(...) = Stop.new(...)
198
+ def Stay(...) = Stop.send(:new, ...)
186
199
 
187
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"
188
205
  # @param depth [Integer] starting depth
189
206
  # @param leave [Proc, nil] optional leave callback
190
207
  # @yield [element, depth] visits each element before its children
@@ -215,7 +232,7 @@ module Sevgi
215
232
  loop do
216
233
  yield(element, height).tap { return it.value if it.is_a?(Stop) }
217
234
 
218
- break if element.Root?()
235
+ break unless element.parent
219
236
 
220
237
  element = element.parent
221
238
  height += 1
@@ -223,28 +240,50 @@ module Sevgi
223
240
  end
224
241
 
225
242
  # Evaluates a block in the parent element context.
226
- # @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
227
249
  # @param kwargs [Hash] keyword arguments passed to the block
228
- # @yield evaluates in the selected receiver's parent context
250
+ # @yield [*args, **kwargs] evaluates in the selected element's parent context
229
251
  # @yieldreturn [Object] ignored block result
230
252
  # @return [Sevgi::Graphics::Element] self
231
- def With(*args, **kwargs, &block)
232
- 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) }
233
263
  end
234
264
 
235
265
  # Evaluates a block in this element context.
236
- # @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
237
272
  # @param kwargs [Hash] keyword arguments passed to the block
238
- # @yield evaluates in the selected receiver context
273
+ # @yield [*args, **kwargs] evaluates in the selected receiver context
239
274
  # @yieldreturn [Object] ignored block result
240
275
  # @return [Sevgi::Graphics::Element] self
241
- def Within(*args, **kwargs, &block)
242
- 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) }
243
281
  end
244
282
 
245
283
  # Appends an element as a child.
246
284
  # @param element [Sevgi::Graphics::Element] element to append
247
285
  # @return [Sevgi::Graphics::Element] self
286
+ # @raise [Sevgi::ArgumentError] when the element has a different concrete class or creates a tree cycle
248
287
  def <<(element)
249
288
  Append(element)
250
289
  end
@@ -257,7 +296,7 @@ module Sevgi
257
296
  # @param parent [Sevgi::Graphics::Element] target parent
258
297
  # @param front [Boolean] whether to prepend instead of append
259
298
  # @return [void]
260
- # @raise [Sevgi::ArgumentError] when an argument is incompatible, repeated, or would create a cycle
299
+ # @raise [Sevgi::ArgumentError] when an argument is incompatible, repeated, or creates a cycle
261
300
  def self.batch(elements, parent, front:)
262
301
  validate_batch(elements, parent)
263
302
 
@@ -268,11 +307,11 @@ module Sevgi
268
307
  end
269
308
  end
270
309
 
271
- # Rejects target parents that would create a cycle.
310
+ # Rejects target parents that create a cycle.
272
311
  # @param element [Sevgi::Graphics::Element] element being moved
273
312
  # @param parent [Sevgi::Graphics::Element, Object] target parent
274
313
  # @return [void]
275
- # @raise [Sevgi::ArgumentError] when the parent is incompatible or would create a cycle
314
+ # @raise [Sevgi::ArgumentError] when the parent is incompatible or creates a cycle
276
315
  def self.validate(element, parent)
277
316
  unless element.instance_of?(parent.class)
278
317
  ArgumentError.("Element type does not match the new parent type: #{element.class}")
@@ -308,8 +347,9 @@ module Sevgi
308
347
  # @param index [Integer] requested insertion index
309
348
  # @return [Integer] normalized insertion index
310
349
  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))
350
+ children = Element.send(:tree_children, parent)
351
+ same_parent = element.parent.equal?(parent) && children.include?(element)
352
+ index(index, children.size - (same_parent ? 1 : 0))
313
353
  end
314
354
 
315
355
  def self.validate_batch(elements, parent)
@@ -6,23 +6,32 @@ 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
+ # The copied subtree has no parent during customization and is attached to the target parent afterward.
14
+ # @param dx [Numeric, nil] finite x translation. Nil omits the axis
15
+ # @param dy [Numeric, nil] finite y translation. Nil omits the axis
16
+ # @param parent [Sevgi::Graphics::Element, nil] explicit parent, or the source parent when nil
15
17
  # @yield [element] optional customization hook for each copied element
16
18
  # @yieldparam element [Sevgi::Graphics::Element] copied element
17
19
  # @yieldreturn [Object] ignored customization result
18
20
  # @return [Sevgi::Graphics::Element] duplicated element
19
21
  # @raise [Sevgi::ArgumentError] when the target parent has a different element class
22
+ # @raise [Sevgi::ArgumentError] when a translation is not a finite real number
20
23
  # @raise [Sevgi::ArgumentError] when copied attributes or contents contain cyclic payloads
24
+ # @example Remap source ids on a duplicate
25
+ # source = Sevgi::Graphics.SVG { rect id: "shape" }.children.first
26
+ # copy = source.Duplicate do |node|
27
+ # node[:id] = "#{node[:"-id"]}-copy" if node[:"-id"]
28
+ # end
21
29
  def Duplicate(dx: nil, dy: nil, parent: nil, &block)
22
- duplicated = Subtree.copy(self)
30
+ dx, dy, target = Subtree.channels(self, dx, dy, parent)
31
+ duplicated = dup
23
32
  Subtree.prepare(duplicated, &block)
24
33
  Subtree.translate(duplicated, dx, dy)
25
- Subtree.attach(duplicated, self, parent)
34
+ Subtree.attach(duplicated, target)
26
35
  end
27
36
 
28
37
  # Duplicates an element subtree along the x-axis.
@@ -33,8 +42,12 @@ module Sevgi
33
42
  # @yieldreturn [Object] ignored customization result
34
43
  # @return [Sevgi::Graphics::Element] duplicated element
35
44
  # @raise [Sevgi::ArgumentError] when the target parent has a different element class
45
+ # @raise [Sevgi::ArgumentError] when dx is not a finite real number
36
46
  # @raise [Sevgi::ArgumentError] when copied attributes or contents contain cyclic payloads
37
- def DuplicateX(dx, parent: nil, &block) = Duplicate(dx:, dy: 0, parent:, &block)
47
+ def DuplicateX(dx, parent: nil, &block)
48
+ ArgumentError.("Duplicate x translation cannot be nil") if dx.nil?
49
+ Duplicate(dx:, dy: 0, parent:, &block)
50
+ end
38
51
 
39
52
  # Duplicates an element subtree along the y-axis.
40
53
  # @param dy [Numeric] y translation
@@ -44,31 +57,40 @@ module Sevgi
44
57
  # @yieldreturn [Object] ignored customization result
45
58
  # @return [Sevgi::Graphics::Element] duplicated element
46
59
  # @raise [Sevgi::ArgumentError] when the target parent has a different element class
60
+ # @raise [Sevgi::ArgumentError] when dy is not a finite real number
47
61
  # @raise [Sevgi::ArgumentError] when copied attributes or contents contain cyclic payloads
48
- def DuplicateY(dy, parent: nil, &block) = Duplicate(dx: 0, dy:, parent:, &block)
62
+ def DuplicateY(dy, parent: nil, &block)
63
+ ArgumentError.("Duplicate y translation cannot be nil") if dy.nil?
64
+ Duplicate(dx: 0, dy:, parent:, &block)
65
+ end
49
66
 
50
67
  # Recursive subtree copier that keeps duplicate implementation state out of the DSL surface.
51
68
  # @api private
52
69
  module Subtree
53
- # Builds an independent copy of an element subtree.
54
- # @param element [Sevgi::Graphics::Element] source subtree root
55
- # @param parent [Sevgi::Graphics::Element, Object] parent for the copied root
56
- # @return [Sevgi::Graphics::Element] copied subtree root
57
- def self.copy(element, parent = element.parent)
58
- element.dup.tap do |duplicated|
59
- duplicated.send(:parent=, parent)
60
- duplicated.send(:attributes=, element.attributes.dup)
61
- duplicated.send(:contents=, element.contents.map(&:dup))
62
- duplicated.send(:children=, element.children.map { |child| copy(child, duplicated) })
70
+ # Validates and normalizes duplicate option channels before copying.
71
+ # @return [Array<(Integer, Float, Sevgi::Graphics::Element, nil)>] normalized dx, dy, and target parent
72
+ # @raise [Sevgi::ArgumentError] when a translation or parent is invalid
73
+ def self.channels(source, dx, dy, parent)
74
+ target = parent.nil? ? source.parent : parent
75
+ unless target.nil? || source.instance_of?(target.class)
76
+ ArgumentError.("Element type does not match the new parent type: #{source.class}")
63
77
  end
78
+
79
+ dx = Scalar.number(dx, context: "duplicate translation", field: :x) unless dx.nil?
80
+ dy = Scalar.number(dy, context: "duplicate translation", field: :y) unless dy.nil?
81
+ [dx, dy, target]
64
82
  end
65
83
 
66
84
  # Removes copied public ids and applies an optional customization hook.
67
85
  # @api private
68
86
  def self.prepare(element, &block)
69
87
  element.Traverse() do |node|
70
- id = node.attributes.delete(:id)
71
- node[:"#{ATTRIBUTE_INTERNAL_PREFIX}id"] = id if id
88
+ if node.attributes.has?(:id)
89
+ id = node.attributes.delete(:id)
90
+ metadata = :"#{Attributes::META_PREFIX}id"
91
+ node[metadata] = id unless node.attributes.has?(metadata)
92
+ end
93
+
72
94
  block&.call(node)
73
95
  end
74
96
  end
@@ -76,13 +98,13 @@ module Sevgi
76
98
  # Applies an optional translation to a copied subtree.
77
99
  # @api private
78
100
  def self.translate(element, dx, dy)
79
- element.Translate(dx, dy) if dx || dy
101
+ element.Translate(dx || 0, dy) unless dx.nil? && dy.nil?
80
102
  end
81
103
 
82
104
  # Attaches a copied subtree unless it is a detached root copy.
83
105
  # @api private
84
- def self.attach(element, source, parent)
85
- element.Adopt(parent) if parent || !source.Root?()
106
+ def self.attach(element, target)
107
+ element.Adopt(target) if target
86
108
  element
87
109
  end
88
110
  end
@@ -4,18 +4,51 @@ module Sevgi
4
4
  module Graphics
5
5
  module Mixtures
6
6
  # DSL helpers for native SVG export formats.
7
+ # Export CSS is a late adjustment after document checks. Its supported XML endings and cascade limitations are
8
+ # documented in {Sevgi::Sundries::Export}. The source callback receives SVG after CSS insertion.
7
9
  module Export
10
+ # Validates the closed export option channel before optional components load or rendering starts.
11
+ # @api private
12
+ module Options
13
+ KEYS = %i[css default dpi height width].freeze
14
+ private_constant :KEYS
15
+
16
+ # Returns validated export options.
17
+ # @param options [Hash] export options
18
+ # @return [Hash] original options
19
+ # @raise [Sevgi::ArgumentError] when an option is unknown
20
+ def self.call(options)
21
+ unknown = options.keys - KEYS
22
+ ArgumentError.("Unknown export option: #{unknown.first}") unless unknown.empty?
23
+ options
24
+ end
25
+ end
26
+
27
+ private_constant :Options
28
+
8
29
  # Exports the document as PDF.
9
- # @param path [String, nil] output path or directory
30
+ # Relative paths are expanded, missing parent directories are created after export validation, and an existing
31
+ # file is replaced. An existing directory target uses the caller-derived default PDF name.
32
+ # @param path [String, #to_path, nil] output path or existing directory
10
33
  # @param kwargs [Hash] export options
34
+ # @option kwargs [String, #to_path, nil] :default caller-derived output name used when path is nil or a directory
35
+ # @option kwargs [Numeric, nil] :width finite positive target width in CSS pixels
36
+ # @option kwargs [Numeric, nil] :height finite positive target height in CSS pixels
37
+ # @option kwargs [Numeric] :dpi (96.0) finite positive CSS pixel density
38
+ # @option kwargs [String, nil] :css CSS inserted before rendering
11
39
  # @yield [svg] transforms SVG source before rendering
12
40
  # @yieldparam svg [String] rendered SVG source
13
41
  # @yieldreturn [String] transformed SVG source
14
- # @return [String] output path
42
+ # @return [String] expanded output path
43
+ # @raise [Sevgi::ArgumentError] when a path, default, option name, CSS value, or transformed SVG is invalid
44
+ # @raise [Sevgi::ValidationError] when validation is enabled and the document violates the SVG standard
45
+ # @raise [Sevgi::Graphics::LintError] when linting is enabled and the document has structural conflicts
15
46
  # @raise [Sevgi::MissingComponentError] when sevgi/sundries is unavailable
16
47
  # @raise [Sevgi::MissingComponentError] when native export gems are unavailable
17
48
  # @raise [Sevgi::Sundries::Export::ExportError] when native export fails
49
+ # @raise [SystemCallError] when the output directory or file cannot be created or written
18
50
  def PDF(path = nil, **kwargs, &block)
51
+ kwargs = Options.(kwargs)
19
52
  begin
20
53
  require "sevgi/sundries"
21
54
 
@@ -29,16 +62,28 @@ module Sevgi
29
62
  end
30
63
 
31
64
  # Exports the document as PNG.
32
- # @param path [String, nil] output path or directory
65
+ # Relative paths are expanded, missing parent directories are created after export validation, and an existing
66
+ # file is replaced. An existing directory target uses the caller-derived default PNG name.
67
+ # @param path [String, #to_path, nil] output path or existing directory
33
68
  # @param kwargs [Hash] export options
69
+ # @option kwargs [String, #to_path, nil] :default caller-derived output name used when path is nil or a directory
70
+ # @option kwargs [Numeric, nil] :width finite positive target width in output pixels
71
+ # @option kwargs [Numeric, nil] :height finite positive target height in output pixels
72
+ # @option kwargs [Numeric] :dpi (96.0) finite positive CSS pixel density
73
+ # @option kwargs [String, nil] :css CSS inserted before rendering
34
74
  # @yield [svg] transforms SVG source before rendering
35
75
  # @yieldparam svg [String] rendered SVG source
36
76
  # @yieldreturn [String] transformed SVG source
37
- # @return [String] output path
77
+ # @return [String] expanded output path
78
+ # @raise [Sevgi::ArgumentError] when a path, default, option name, CSS value, or transformed SVG is invalid
79
+ # @raise [Sevgi::ValidationError] when validation is enabled and the document violates the SVG standard
80
+ # @raise [Sevgi::Graphics::LintError] when linting is enabled and the document has structural conflicts
38
81
  # @raise [Sevgi::MissingComponentError] when sevgi/sundries is unavailable
39
82
  # @raise [Sevgi::MissingComponentError] when native export gems are unavailable
40
83
  # @raise [Sevgi::Sundries::Export::ExportError] when native export fails
84
+ # @raise [SystemCallError] when the output directory or file cannot be created or written
41
85
  def PNG(path = nil, **kwargs, &block)
86
+ kwargs = Options.(kwargs)
42
87
  begin
43
88
  require "sevgi/sundries"
44
89
 
@@ -54,15 +99,12 @@ module Sevgi
54
99
  private
55
100
 
56
101
  def Export(path = nil, default: nil, **kwargs, &block)
57
- default ||= F.subext(kwargs[:format] ? ".#{kwargs[:format]}" : ".pdf", caller_locations(2..2).first.path)
58
-
59
- if path
60
- ::File.directory?(path) ? ::File.join(path, ::File.basename(default)) : path
61
- else
62
- default
63
- end => path
102
+ if default.nil?
103
+ extension = kwargs[:format] ? ".#{kwargs[:format]}" : ".pdf"
104
+ default = F.subext(extension, caller_locations(2..2).first.path)
105
+ end
64
106
 
65
- ::FileUtils.mkdir_p(::File.dirname(path))
107
+ path = Path.resolve(path, default:, context: "Export")
66
108
 
67
109
  Sundries::Export.(call, path, **kwargs, &block)
68
110
  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 geometry 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] 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)
76
+ Draw(Geometry::Operation.sweep!(element, initial:, angle:, step:), **kwargs)
35
77
  end
36
78
  end
37
79
  end
@@ -5,15 +5,17 @@ module Sevgi
5
5
  module Mixtures
6
6
  # DSL helpers for collecting and hiding SVG ids.
7
7
  module Identify
8
- # Index of element ids under a subtree.
8
+ # Immutable snapshot of every rendered element id under a subtree. Keys are the serialized id values, including
9
+ # `"false"` and the empty String. Keys and containers are owned by the index. Values retain references to the
10
+ # elements present when the snapshot is built. Later tree changes require a new index.
9
11
  class Identifiers
10
12
  # @return [Sevgi::Graphics::Element] indexed root element
11
13
  attr_reader :element
12
14
 
13
- # @return [Hash<String, Sevgi::Graphics::Element>] id namespace
15
+ # @return [Hash<String, Sevgi::Graphics::Element>] frozen namespace keyed by serialized rendered ids
14
16
  attr_reader :namespace
15
17
 
16
- # @return [Hash<String, Array<Sevgi::Graphics::Element>>] duplicate id groups
18
+ # @return [Hash<String, Array<Sevgi::Graphics::Element>>] frozen duplicate groups keyed by serialized ids
17
19
  attr_reader :collision
18
20
 
19
21
  # Builds an id index for an element subtree.
@@ -25,6 +27,10 @@ module Sevgi
25
27
  @collision = {}
26
28
 
27
29
  build
30
+ @namespace.freeze
31
+ @collision.each_value(&:freeze)
32
+ @collision.freeze
33
+ freeze
28
34
  end
29
35
 
30
36
  # Reports whether duplicate ids were found.
@@ -33,21 +39,20 @@ module Sevgi
33
39
  !@collision.empty?
34
40
  end
35
41
 
36
- # @overload [](id)
37
- # Returns the element registered for an id.
38
- # @param id [String] SVG id
39
- # @return [Sevgi::Graphics::Element, nil]
40
- def [](*)
41
- @namespace[*]
42
- end
42
+ # Returns the first element registered for an id in the snapshot.
43
+ # Duplicate entries are available through {#collision}.
44
+ # @param id [String] serialized rendered SVG id
45
+ # @return [Sevgi::Graphics::Element, nil]
46
+ def [](id) = @namespace[id]
43
47
 
44
48
  private
45
49
 
46
50
  def build
47
51
  element.Traverse() do |element|
48
- next unless (value = element[:id])
52
+ next unless element.attributes.has?(:id)
49
53
 
50
- id = Attribute.xml_text(value)
54
+ value = element[:id]
55
+ id = Attribute.xml_text(value).dup.freeze
51
56
 
52
57
  if @namespace.key?(id)
53
58
  (@collision[id] ||= [@namespace[id]]) << element
@@ -58,18 +63,26 @@ module Sevgi
58
63
  end
59
64
  end
60
65
 
61
- # Moves visible id attributes to Sevgi-internal id storage.
66
+ # Moves visible id attributes to non-rendering `-id` metadata throughout the subtree.
67
+ # A pre-existing `-id` takes precedence over the visible id.
62
68
  # @return [Sevgi::Graphics::Element] self
69
+ # @example Hide ids while retaining their source identity
70
+ # document = Sevgi::Graphics.SVG { rect id: "source-id" }
71
+ # document.Disidentify
72
+ # document.children.first[:"-id"] # => "source-id"
63
73
  def Disidentify
64
74
  Traverse do |element|
65
- next unless element[:id]
75
+ next unless element.attributes.has?(:id)
66
76
 
67
- element[:"#{ATTRIBUTE_INTERNAL_PREFIX}id"] = element.attributes.delete(:id)
77
+ id = element.attributes.delete(:id)
78
+ metadata = :"#{Attributes::META_PREFIX}id"
79
+ element[metadata] = id unless element.attributes.has?(metadata)
68
80
  end
69
81
  end
70
82
 
71
- # Builds an id index for this element subtree.
72
- # @return [Sevgi::Graphics::Mixtures::Identify::Identifiers]
83
+ # Builds an immutable id index snapshot for the current element subtree.
84
+ # Rebuild the index to observe later id or tree changes.
85
+ # @return [Sevgi::Graphics::Mixtures::Identify::Identifiers] new snapshot retaining indexed element references
73
86
  def Identifiers = Identifiers.new(self)
74
87
  end
75
88
  end