sevgi-graphics 0.95.0 → 0.98.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +125 -0
  3. data/README.md +8 -8
  4. data/lib/sevgi/graphics/attribute.rb +160 -37
  5. data/lib/sevgi/graphics/auxiliary/canvas.rb +100 -43
  6. data/lib/sevgi/graphics/auxiliary/content.rb +54 -34
  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 +236 -102
  15. data/lib/sevgi/graphics/element.rb +114 -31
  16. data/lib/sevgi/graphics/mixtures/call.rb +249 -88
  17. data/lib/sevgi/graphics/mixtures/core.rb +59 -20
  18. data/lib/sevgi/graphics/mixtures/duplicate.rb +49 -15
  19. data/lib/sevgi/graphics/mixtures/export.rb +52 -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 +201 -47
  24. data/lib/sevgi/graphics/mixtures/rdf.rb +59 -7
  25. data/lib/sevgi/graphics/mixtures/render.rb +52 -28
  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 +68 -28
  30. data/lib/sevgi/graphics/mixtures/underscore.rb +14 -5
  31. data/lib/sevgi/graphics/mixtures/validate.rb +2 -2
  32. data/lib/sevgi/graphics/mixtures/wrappers.rb +62 -23
  33. data/lib/sevgi/graphics/mixtures.rb +15 -13
  34. data/lib/sevgi/graphics/version.rb +1 -1
  35. data/lib/sevgi/graphics.rb +58 -12
  36. 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
@@ -127,7 +135,8 @@ module Sevgi
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 would create a tree cycle
248
287
  def <<(element)
249
288
  Append(element)
250
289
  end
@@ -6,23 +6,31 @@ module Sevgi
6
6
  # DSL helpers for duplicating independent element subtrees.
7
7
  module Duplicate
8
8
  # Duplicates an element subtree as an independent tree and optionally translates it.
9
- # Copied elements receive new child arrays, attribute stores, and content arrays. Public `id` attributes are
10
- # moved to an internal `-id` attribute before the optional block runs, allowing the block to derive replacement
11
- # ids without rendering duplicate public ids.
12
- # @param dx [Numeric, nil] x translation
13
- # @param dy [Numeric, nil] y translation
14
- # @param parent [Sevgi::Graphics::Element, nil] parent for the duplicated subtree
9
+ # Copied elements receive new child arrays, attribute stores, and content arrays. Visible `id` attributes are
10
+ # moved to non-rendering `-id` metadata before the optional block runs, allowing the block to derive replacement
11
+ # ids without rendering duplicates. A pre-existing `-id` takes precedence over the visible id.
12
+ # Translation and parent channels are validated before the subtree is copied or the customization block runs.
13
+ # @param dx [Numeric, nil] finite x translation; nil omits the axis
14
+ # @param dy [Numeric, nil] finite y translation; nil omits the axis
15
+ # @param parent [Sevgi::Graphics::Element, nil] explicit parent, or the source parent when nil
15
16
  # @yield [element] optional customization hook for each copied element
16
17
  # @yieldparam element [Sevgi::Graphics::Element] copied element
17
18
  # @yieldreturn [Object] ignored customization result
18
19
  # @return [Sevgi::Graphics::Element] duplicated element
19
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
20
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
21
28
  def Duplicate(dx: nil, dy: nil, parent: nil, &block)
29
+ dx, dy, target = Subtree.channels(self, dx, dy, parent)
22
30
  duplicated = Subtree.copy(self)
23
31
  Subtree.prepare(duplicated, &block)
24
32
  Subtree.translate(duplicated, dx, dy)
25
- Subtree.attach(duplicated, self, parent)
33
+ Subtree.attach(duplicated, target)
26
34
  end
27
35
 
28
36
  # Duplicates an element subtree along the x-axis.
@@ -33,8 +41,12 @@ module Sevgi
33
41
  # @yieldreturn [Object] ignored customization result
34
42
  # @return [Sevgi::Graphics::Element] duplicated element
35
43
  # @raise [Sevgi::ArgumentError] when the target parent has a different element class
44
+ # @raise [Sevgi::ArgumentError] when dx is not a finite real number
36
45
  # @raise [Sevgi::ArgumentError] when copied attributes or contents contain cyclic payloads
37
- def DuplicateX(dx, parent: nil, &block) = Duplicate(dx:, dy: 0, parent:, &block)
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
38
50
 
39
51
  # Duplicates an element subtree along the y-axis.
40
52
  # @param dy [Numeric] y translation
@@ -44,17 +56,35 @@ module Sevgi
44
56
  # @yieldreturn [Object] ignored customization result
45
57
  # @return [Sevgi::Graphics::Element] duplicated element
46
58
  # @raise [Sevgi::ArgumentError] when the target parent has a different element class
59
+ # @raise [Sevgi::ArgumentError] when dy is not a finite real number
47
60
  # @raise [Sevgi::ArgumentError] when copied attributes or contents contain cyclic payloads
48
- def DuplicateY(dy, parent: nil, &block) = Duplicate(dx: 0, dy:, parent:, &block)
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
49
65
 
50
66
  # Recursive subtree copier that keeps duplicate implementation state out of the DSL surface.
51
67
  # @api private
52
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
+
53
83
  # Builds an independent copy of an element subtree.
54
84
  # @param element [Sevgi::Graphics::Element] source subtree root
55
85
  # @param parent [Sevgi::Graphics::Element, Object] parent for the copied root
56
86
  # @return [Sevgi::Graphics::Element] copied subtree root
57
- def self.copy(element, parent = element.parent)
87
+ def self.copy(element, parent = Element.send(:tree_parent, element))
58
88
  element.dup.tap do |duplicated|
59
89
  duplicated.send(:parent=, parent)
60
90
  duplicated.send(:attributes=, element.attributes.dup)
@@ -67,8 +97,12 @@ module Sevgi
67
97
  # @api private
68
98
  def self.prepare(element, &block)
69
99
  element.Traverse() do |node|
70
- id = node.attributes.delete(:id)
71
- node[:"#{ATTRIBUTE_INTERNAL_PREFIX}id"] = id if id
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
+
72
106
  block&.call(node)
73
107
  end
74
108
  end
@@ -76,13 +110,13 @@ module Sevgi
76
110
  # Applies an optional translation to a copied subtree.
77
111
  # @api private
78
112
  def self.translate(element, dx, dy)
79
- element.Translate(dx, dy) if dx || dy
113
+ element.Translate(dx || 0, dy) unless dx.nil? && dy.nil?
80
114
  end
81
115
 
82
116
  # Attaches a copied subtree unless it is a detached root copy.
83
117
  # @api private
84
- def self.attach(element, source, parent)
85
- element.Adopt(parent) if parent || !source.Root?()
118
+ def self.attach(element, target)
119
+ element.Adopt(target) if target
86
120
  element
87
121
  end
88
122
  end
@@ -5,17 +5,48 @@ module Sevgi
5
5
  module Mixtures
6
6
  # DSL helpers for native SVG export formats.
7
7
  module Export
8
+ # Validates the closed export option channel before optional components load or rendering starts.
9
+ # @api private
10
+ module Options
11
+ KEYS = %i[css default dpi height width].freeze
12
+ private_constant :KEYS
13
+
14
+ # Returns validated export options.
15
+ # @param options [Hash] export options
16
+ # @return [Hash] original options
17
+ # @raise [Sevgi::ArgumentError] when an option is unknown
18
+ def self.call(options)
19
+ unknown = options.keys - KEYS
20
+ ArgumentError.("Unknown export option: #{unknown.first}") unless unknown.empty?
21
+ options
22
+ end
23
+ end
24
+
25
+ private_constant :Options
26
+
8
27
  # Exports the document as PDF.
9
- # @param path [String, nil] output path or directory
28
+ # Relative paths are expanded, missing parent directories are created after export validation, and an existing
29
+ # file is replaced. An existing directory target uses the caller-derived default PDF name.
30
+ # @param path [String, #to_path, nil] output path or existing directory
10
31
  # @param kwargs [Hash] export options
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
11
37
  # @yield [svg] transforms SVG source before rendering
12
38
  # @yieldparam svg [String] rendered SVG source
13
39
  # @yieldreturn [String] transformed SVG source
14
- # @return [String] output path
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
15
44
  # @raise [Sevgi::MissingComponentError] when sevgi/sundries is unavailable
16
45
  # @raise [Sevgi::MissingComponentError] when native export gems are unavailable
17
46
  # @raise [Sevgi::Sundries::Export::ExportError] when native export fails
47
+ # @raise [SystemCallError] when the output directory or file cannot be created or written
18
48
  def PDF(path = nil, **kwargs, &block)
49
+ kwargs = Options.(kwargs)
19
50
  begin
20
51
  require "sevgi/sundries"
21
52
 
@@ -29,16 +60,28 @@ module Sevgi
29
60
  end
30
61
 
31
62
  # Exports the document as PNG.
32
- # @param path [String, nil] output path or directory
63
+ # Relative paths are expanded, missing parent directories are created after export validation, and an existing
64
+ # file is replaced. An existing directory target uses the caller-derived default PNG name.
65
+ # @param path [String, #to_path, nil] output path or existing directory
33
66
  # @param kwargs [Hash] export options
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
34
72
  # @yield [svg] transforms SVG source before rendering
35
73
  # @yieldparam svg [String] rendered SVG source
36
74
  # @yieldreturn [String] transformed SVG source
37
- # @return [String] output path
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
38
79
  # @raise [Sevgi::MissingComponentError] when sevgi/sundries is unavailable
39
80
  # @raise [Sevgi::MissingComponentError] when native export gems are unavailable
40
81
  # @raise [Sevgi::Sundries::Export::ExportError] when native export fails
82
+ # @raise [SystemCallError] when the output directory or file cannot be created or written
41
83
  def PNG(path = nil, **kwargs, &block)
84
+ kwargs = Options.(kwargs)
42
85
  begin
43
86
  require "sevgi/sundries"
44
87
 
@@ -54,15 +97,12 @@ module Sevgi
54
97
  private
55
98
 
56
99
  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
100
+ if default.nil?
101
+ extension = kwargs[:format] ? ".#{kwargs[:format]}" : ".pdf"
102
+ default = F.subext(extension, caller_locations(2..2).first.path)
103
+ end
64
104
 
65
- ::FileUtils.mkdir_p(::File.dirname(path))
105
+ path = Path.resolve(path, default:, context: "Export")
66
106
 
67
107
  Sundries::Export.(call, path, **kwargs, &block)
68
108
  end
@@ -3,24 +3,65 @@
3
3
  module Sevgi
4
4
  module Graphics
5
5
  module Mixtures
6
- # DSL helpers for drawing geometry-derived hatch lines.
6
+ # DSL helpers for drawing geometry values and geometry-derived hatch lines.
7
+ #
8
+ # `Draw` delegates to each geometry object's drawing protocol. `Hatch`
9
+ # first sweeps interior spans through a closed lined element, then draws
10
+ # them. Its `angle` describes line direction; `step` is perpendicular
11
+ # spacing. The default initial line passes through `element.position`.
12
+ # The built-in `:inkscape` document profile includes this mixture;
13
+ # `:minimal`, `:default`, and `:html` do not.
14
+ #
15
+ # Hatch materializes separate finite SVG path elements. When only a repeated visual fill matters, use an SVG
16
+ # pattern and leave repetition and clipping to the renderer instead of computing line geometry.
17
+ # @example Add geometry drawing to a scoped custom profile
18
+ # profile = Class.new(Sevgi::Graphics::Document::Base)
19
+ # Sevgi::Graphics::Mixtures.mixin(:Hatch, profile)
20
+ # region = Sevgi::Geometry::Rect[24, 12]
21
+ # Sevgi::Graphics.SVG(profile) do
22
+ # Draw region.lines, stroke: "silver"
23
+ # Hatch region, angle: 30, step: 3, stroke: "black"
24
+ # end.Render
25
+ # @see Sevgi::Geometry::Operation.sweep
26
+ # @see https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Element/pattern SVG pattern element
7
27
  module Hatch
8
28
  # Draws one or more geometry line-like objects into this element.
9
- # @param lines [Object, Array<Object>] drawable geometry objects
29
+ # @example Draw geometry lines into a library-built document
30
+ # lines = [
31
+ # Sevgi::Geometry::Line.([0, 0], [20, 0]),
32
+ # Sevgi::Geometry::Line.([0, 5], [20, 5])
33
+ # ]
34
+ # drawing = Sevgi::Graphics.SVG(:inkscape) { Draw lines, stroke: "silver" }
35
+ # drawing.Render
36
+ # @param lines [#draw, Array<#draw>] drawable geometry objects
10
37
  # @param kwargs [Hash] SVG attributes passed to each draw call
11
- # @return [Array<Sevgi::Graphics::Element>] rendered line elements
38
+ # @return [Array<Sevgi::Graphics::Element>] rendered SVG elements
12
39
  def Draw(lines, **kwargs)
13
40
  Array(lines).map { it.draw(self, **kwargs) }
14
41
  end
15
42
 
16
43
  # Draws hatch lines swept through a geometry element.
17
- # @param element [Object] geometry element responding to position
44
+ # @example Hatch a closed geometry shape
45
+ # region = Sevgi::Geometry::Rect[24, 12, position: [2, 2]]
46
+ # drawing = Sevgi::Graphics.SVG(:inkscape) do
47
+ # Hatch region, angle: 30, step: 3, stroke: "black"
48
+ # end
49
+ # drawing.Render
50
+ # @example Control the first sweep line explicitly
51
+ # region = Sevgi::Geometry::Rect[24, 12, position: [2, 2]]
52
+ # Sevgi::Graphics.SVG(:inkscape) do
53
+ # Hatch region, initial: [2, 8], angle: 0, step: 3
54
+ # end
55
+ # @param element [Sevgi::Geometry::Element::Lined] lined geometry element to sweep
18
56
  # @param angle [Numeric] hatch angle in degrees
19
57
  # @param step [Numeric] distance between hatch lines
20
- # @param initial [Object, nil] initial point for the sweep
58
+ # @param initial [Sevgi::Geometry::Point, Array<Numeric>, nil] initial sweep point, or nil for element.position
21
59
  # @param kwargs [Hash] SVG attributes passed to each draw call
22
- # @return [Array<Sevgi::Graphics::Element>] rendered line elements
60
+ # @return [Array<Sevgi::Graphics::Element>] rendered hatch path elements
23
61
  # @raise [Sevgi::MissingComponentError] when sevgi/geometry is unavailable
62
+ # @raise [Sevgi::Geometry::Operation::OperationInapplicableError] when element is not sweepable
63
+ # @raise [Sevgi::Geometry::Error] when initial, angle, or step is invalid
64
+ # @raise [Sevgi::Geometry::Operation::OperationError] when no hatch lines are found or iteration reaches the limit
24
65
  def Hatch(element, angle:, step:, initial: nil, **kwargs)
25
66
  begin
26
67
  require "sevgi/geometry"
@@ -31,7 +72,8 @@ module Sevgi
31
72
  MissingComponentError.("sevgi/geometry")
32
73
  end
33
74
 
34
- Draw(Geometry::Operation.sweep!(element, initial: initial || element.position, angle:, step:), **kwargs)
75
+ initial = element.position if initial.nil? && element.is_a?(Geometry::Element::Lined)
76
+ Draw(Geometry::Operation.sweep!(element, initial:, angle:, step:), **kwargs)
35
77
  end
36
78
  end
37
79
  end
@@ -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
@@ -5,21 +5,31 @@ module Sevgi
5
5
  module Mixtures
6
6
  # DSL helpers for including derendered SVG/XML fragments.
7
7
  #
8
- # @!method Include(file, id)
8
+ # @!method Include(file, id, omit: nil)
9
9
  # Includes a derendered node matching an id.
10
10
  # SVG/XML content is treated as data and is not evaluated as Ruby source.
11
+ # @example Import a fragment without editor ids and inline styles
12
+ # Sevgi::Graphics.SVG do
13
+ # Include "badge.svg", "mark", omit: %i[id style]
14
+ # end
11
15
  # @param file [String] source SVG/XML file
12
16
  # @param id [String, Symbol] source node id
17
+ # @param omit [String, Symbol, Array<String, Symbol>, nil] exact attribute name or names omitted from the selected
18
+ # subtree after id selection
13
19
  # @return [Sevgi::Graphics::Element, nil] included element, or nil when it produces no graphics output
14
20
  # @raise [Sevgi::ArgumentError] when the file is absent or XML content is malformed, rootless, or lacks the id
21
+ # @raise [SystemCallError] when the file cannot be read
15
22
  # @raise [Sevgi::MissingComponentError] when sevgi/derender is unavailable
16
- # @!method IncludeChildren(file, id)
23
+ # @!method IncludeChildren(file, id, omit: nil)
17
24
  # Includes the children of a derendered node matching an id.
18
25
  # SVG/XML content is treated as data and is not evaluated as Ruby source.
19
26
  # @param file [String] source SVG/XML file
20
27
  # @param id [String, Symbol] source node id
21
- # @return [Array<Sevgi::Graphics::Element>] included children
28
+ # @param omit [String, Symbol, Array<String, Symbol>, nil] exact attribute name or names omitted from the selected
29
+ # subtree after id selection
30
+ # @return [Array<Sevgi::Graphics::Element>] immutable included-child snapshot
22
31
  # @raise [Sevgi::ArgumentError] when the file is absent or XML content is malformed, rootless, or lacks the id
32
+ # @raise [SystemCallError] when the file cannot be read
23
33
  # @raise [Sevgi::MissingComponentError] when sevgi/derender is unavailable
24
34
  module Include
25
35
  require "sevgi/derender"
@@ -29,38 +39,46 @@ module Sevgi
29
39
  # SVG/XML file content is treated as data and is not evaluated as Ruby source.
30
40
  # @param file [String] source SVG/XML file
31
41
  # @param id [String, Symbol] source node id
42
+ # @param omit [String, Symbol, Array<String, Symbol>, nil] exact attribute name or names omitted from the selected
43
+ # subtree after id selection
32
44
  # @return [Sevgi::Graphics::Element, nil] included element, or nil when the selected node produces no graphics
33
45
  # output
34
46
  # @raise [Sevgi::ArgumentError] when the file cannot be found, file content is malformed or rootless, or the id is
35
47
  # absent
48
+ # @raise [SystemCallError] when the file cannot be read
36
49
  # @raise [Sevgi::MissingComponentError] when sevgi/derender is unavailable
37
- def Include(file, id) = Derender.evaluate_file(file, self, id:)
50
+ def Include(file, id, omit: nil) = Derender.evaluate_file(file, self, id:, omit:)
38
51
 
39
52
  # Includes the children of a derendered node matching an id.
40
53
  #
41
54
  # SVG/XML file content is treated as data and is not evaluated as Ruby source.
42
55
  # @param file [String] source SVG/XML file
43
56
  # @param id [String, Symbol] source node id
44
- # @return [Array<Sevgi::Graphics::Element>] included children
57
+ # @param omit [String, Symbol, Array<String, Symbol>, nil] exact attribute name or names omitted from the selected
58
+ # subtree after id selection
59
+ # @return [Array<Sevgi::Graphics::Element>] immutable included-child snapshot
45
60
  # @raise [Sevgi::ArgumentError] when the file cannot be found, file content is malformed or rootless, or the id is
46
61
  # absent
62
+ # @raise [SystemCallError] when the file cannot be read
47
63
  # @raise [Sevgi::MissingComponentError] when sevgi/derender is unavailable
48
- def IncludeChildren(file, id) = Derender.evaluate_file_children(file, self, id:)
64
+ def IncludeChildren(file, id, omit: nil) = Derender.evaluate_children_file(file, self, id:, omit:)
49
65
  rescue ::LoadError => e
50
66
  raise unless e.path == "sevgi/derender"
51
67
 
52
- # @overload IncludeChildren(file, id)
68
+ # @overload IncludeChildren(file, id, omit: nil)
53
69
  # Raises because sevgi/derender is unavailable.
54
70
  # @param file [String] source SVG/XML file
55
71
  # @param id [String, Symbol] source node id
72
+ # @param omit [String, Symbol, Array<String, Symbol>, nil] ignored because the component is unavailable
56
73
  # @return [void]
57
74
  # @raise [Sevgi::MissingComponentError] always
58
75
  def IncludeChildren(...) = MissingComponentError.("sevgi/derender")
59
76
 
60
- # @overload Include(file, id)
77
+ # @overload Include(file, id, omit: nil)
61
78
  # Raises because sevgi/derender is unavailable.
62
79
  # @param file [String] source SVG/XML file
63
80
  # @param id [String, Symbol] source node id
81
+ # @param omit [String, Symbol, Array<String, Symbol>, nil] ignored because the component is unavailable
64
82
  # @return [void]
65
83
  # @raise [Sevgi::MissingComponentError] always
66
84
  def Include(...) = MissingComponentError.("sevgi/derender")