sevgi-graphics 0.94.0 → 0.98.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +275 -2
  3. data/LICENSE +672 -3
  4. data/README.md +8 -8
  5. data/lib/sevgi/graphics/attribute.rb +271 -54
  6. data/lib/sevgi/graphics/auxiliary/canvas.rb +137 -58
  7. data/lib/sevgi/graphics/auxiliary/content.rb +200 -57
  8. data/lib/sevgi/graphics/auxiliary/margin.rb +33 -14
  9. data/lib/sevgi/graphics/auxiliary/paper.rb +118 -86
  10. data/lib/sevgi/graphics/auxiliary/path.rb +44 -0
  11. data/lib/sevgi/graphics/auxiliary/scalar.rb +69 -0
  12. data/lib/sevgi/graphics/auxiliary/sizes.rb +62 -0
  13. data/lib/sevgi/graphics/auxiliary.rb +3 -0
  14. data/lib/sevgi/graphics/document/base.rb +6 -2
  15. data/lib/sevgi/graphics/document/default.rb +1 -1
  16. data/lib/sevgi/graphics/document.rb +370 -124
  17. data/lib/sevgi/graphics/element.rb +143 -33
  18. data/lib/sevgi/graphics/mixtures/call.rb +249 -88
  19. data/lib/sevgi/graphics/mixtures/core.rb +143 -33
  20. data/lib/sevgi/graphics/mixtures/duplicate.rb +76 -22
  21. data/lib/sevgi/graphics/mixtures/export.rb +58 -12
  22. data/lib/sevgi/graphics/mixtures/hatch.rb +49 -7
  23. data/lib/sevgi/graphics/mixtures/identify.rb +30 -17
  24. data/lib/sevgi/graphics/mixtures/include.rb +40 -5
  25. data/lib/sevgi/graphics/mixtures/inkscape.rb +215 -46
  26. data/lib/sevgi/graphics/mixtures/rdf.rb +79 -7
  27. data/lib/sevgi/graphics/mixtures/render.rb +88 -19
  28. data/lib/sevgi/graphics/mixtures/save.rb +79 -26
  29. data/lib/sevgi/graphics/mixtures/symbols.rb +81 -9
  30. data/lib/sevgi/graphics/mixtures/tile.rb +88 -64
  31. data/lib/sevgi/graphics/mixtures/transform.rb +68 -28
  32. data/lib/sevgi/graphics/mixtures/underscore.rb +16 -7
  33. data/lib/sevgi/graphics/mixtures/validate.rb +14 -2
  34. data/lib/sevgi/graphics/mixtures/wrappers.rb +66 -24
  35. data/lib/sevgi/graphics/mixtures.rb +19 -13
  36. data/lib/sevgi/graphics/version.rb +1 -1
  37. data/lib/sevgi/graphics/xml.rb +127 -0
  38. data/lib/sevgi/graphics.rb +75 -28
  39. metadata +10 -6
@@ -4,66 +4,124 @@ module Sevgi
4
4
  module Graphics
5
5
  module Mixtures
6
6
  # DSL helpers for RDF and license metadata.
7
+ #
8
+ # @example Add Creative Commons metadata
9
+ # Sevgi::Graphics.SVG :inkscape do
10
+ # License_CC0 title: "Example", creator: "A. Creator"
11
+ # end
7
12
  module RDF
13
+ WORK_OPTIONS = %i[title description creator publisher date language license].freeze
14
+ private_constant :WORK_OPTIONS
15
+
8
16
  # Adds RDF license metadata inside a metadata element.
9
17
  # @param kwargs [Hash] RDF work options
18
+ # @yield evaluates additional RDF work metadata
19
+ # @yieldreturn [Object] ignored block result
10
20
  # @return [Sevgi::Graphics::Element] metadata element
11
- def License(**kwargs, &block) = metadata { RDFWork(**kwargs, &block) }
21
+ # @raise [Sevgi::ArgumentError] when an option is unknown
22
+ # @see #RDFWork
23
+ def License(**kwargs, &block)
24
+ unknown = kwargs.keys - WORK_OPTIONS
25
+ ArgumentError.("Unknown license options: #{unknown.join(", ")}") unless unknown.empty?
26
+
27
+ metadata { RDFWork(**kwargs, &block) }
28
+ end
29
+
30
+ # Adds Creative Commons BY license metadata.
31
+ # @param kwargs [Hash] RDF work options
32
+ # @yield evaluates additional RDF work metadata
33
+ # @yieldreturn [Object] ignored block result
34
+ # @return [Sevgi::Graphics::Element] metadata element
35
+ # @raise [Sevgi::ArgumentError] when an option is unknown
36
+ # @see #RDFWork
37
+ def License_CC_BY(**kwargs, &block)
38
+ License(**kwargs, license: "https://creativecommons.org/licenses/by/4.0/", &block)
39
+ end
12
40
 
13
- # Use SPDX license codes in underscored form: https://spdx.org/licenses/
14
- #
15
41
  # Adds Creative Commons BY-SA license metadata.
16
42
  # @param kwargs [Hash] RDF work options
43
+ # @yield evaluates additional RDF work metadata
44
+ # @yieldreturn [Object] ignored block result
17
45
  # @return [Sevgi::Graphics::Element] metadata element
46
+ # @raise [Sevgi::ArgumentError] when an option is unknown
47
+ # @see #RDFWork
18
48
  def License_CC_BY_SA(**kwargs, &block)
19
49
  License(**kwargs, license: "https://creativecommons.org/licenses/by-sa/4.0/", &block)
20
50
  end
21
51
 
22
52
  # Adds Creative Commons BY-NC license metadata.
23
53
  # @param kwargs [Hash] RDF work options
54
+ # @yield evaluates additional RDF work metadata
55
+ # @yieldreturn [Object] ignored block result
24
56
  # @return [Sevgi::Graphics::Element] metadata element
57
+ # @raise [Sevgi::ArgumentError] when an option is unknown
58
+ # @see #RDFWork
25
59
  def License_CC_BY_NC(**kwargs, &block)
26
60
  License(**kwargs, license: "https://creativecommons.org/licenses/by-nc/4.0/", &block)
27
61
  end
28
62
 
29
63
  # Adds Creative Commons BY-NC-SA license metadata.
30
64
  # @param kwargs [Hash] RDF work options
65
+ # @yield evaluates additional RDF work metadata
66
+ # @yieldreturn [Object] ignored block result
31
67
  # @return [Sevgi::Graphics::Element] metadata element
68
+ # @raise [Sevgi::ArgumentError] when an option is unknown
69
+ # @see #RDFWork
32
70
  def License_CC_BY_NC_SA(**kwargs, &block)
33
71
  License(**kwargs, license: "https://creativecommons.org/licenses/by-nc-sa/4.0/", &block)
34
72
  end
35
73
 
36
74
  # Adds Creative Commons BY-ND license metadata.
37
75
  # @param kwargs [Hash] RDF work options
76
+ # @yield evaluates additional RDF work metadata
77
+ # @yieldreturn [Object] ignored block result
38
78
  # @return [Sevgi::Graphics::Element] metadata element
79
+ # @raise [Sevgi::ArgumentError] when an option is unknown
80
+ # @see #RDFWork
39
81
  def License_CC_BY_ND(**kwargs, &block)
40
82
  License(**kwargs, license: "https://creativecommons.org/licenses/by-nd/4.0/", &block)
41
83
  end
42
84
 
43
85
  # Adds Creative Commons BY-NC-ND license metadata.
44
86
  # @param kwargs [Hash] RDF work options
87
+ # @yield evaluates additional RDF work metadata
88
+ # @yieldreturn [Object] ignored block result
45
89
  # @return [Sevgi::Graphics::Element] metadata element
90
+ # @raise [Sevgi::ArgumentError] when an option is unknown
91
+ # @see #RDFWork
46
92
  def License_CC_BY_NC_ND(**kwargs, &block)
47
93
  License(**kwargs, license: "https://creativecommons.org/licenses/by-nc-nd/4.0/", &block)
48
94
  end
49
95
 
50
96
  # Adds Creative Commons Zero metadata.
51
97
  # @param kwargs [Hash] RDF work options
98
+ # @yield evaluates additional RDF work metadata
99
+ # @yieldreturn [Object] ignored block result
52
100
  # @return [Sevgi::Graphics::Element] metadata element
101
+ # @raise [Sevgi::ArgumentError] when an option is unknown
102
+ # @see #RDFWork
53
103
  def License_CC0(**kwargs, &block)
54
104
  License(**kwargs, license: "https://creativecommons.org/publicdomain/zero/1.0/", &block)
55
105
  end
56
106
 
57
107
  # Adds Free Art License metadata.
58
108
  # @param kwargs [Hash] RDF work options
109
+ # @yield evaluates additional RDF work metadata
110
+ # @yieldreturn [Object] ignored block result
59
111
  # @return [Sevgi::Graphics::Element] metadata element
112
+ # @raise [Sevgi::ArgumentError] when an option is unknown
113
+ # @see #RDFWork
60
114
  def License_LAL(**kwargs, &block) = License(**kwargs, license: "https://artlibre.org/licence/lal/en/", &block)
61
115
 
62
116
  # Builds an RDF root element.
63
- # @param _kwargs [Hash] currently unused options
117
+ # @param kwargs [Hash] options; RDF currently accepts none
118
+ # @yield evaluates the RDF drawing DSL
119
+ # @yieldreturn [Object] ignored block result
64
120
  # @return [Sevgi::Graphics::Element] RDF element
65
121
  # @raise [Sevgi::ArgumentError] when no block is given
66
- def RDF(**_kwargs, &block)
122
+ # @raise [Sevgi::ArgumentError] when an option is given
123
+ def RDF(**kwargs, &block)
124
+ ArgumentError.("Unknown RDF options: #{kwargs.keys.join(", ")}") unless kwargs.empty?
67
125
  ArgumentError.("Block required") unless block
68
126
 
69
127
  Element(
@@ -76,7 +134,6 @@ module Sevgi
76
134
  end
77
135
  end
78
136
 
79
- # rubocop:disable Metrics/MethodLength
80
137
  # Builds a Creative Commons RDF Work element.
81
138
  # @param kwargs [Hash] RDF work options
82
139
  # @option kwargs [String] :title work title
@@ -86,8 +143,24 @@ module Sevgi
86
143
  # @option kwargs [String] :date date
87
144
  # @option kwargs [String] :language language
88
145
  # @option kwargs [String] :license license URL
146
+ #
147
+ # | Option | RDF element |
148
+ # | --- | --- |
149
+ # | `title` | `dc:title` |
150
+ # | `description` | `dc:description` |
151
+ # | `creator` | `dc:creator` |
152
+ # | `publisher` | `dc:publisher` |
153
+ # | `date` | `dc:date` |
154
+ # | `language` | `dc:language` |
155
+ # | `license` | `cc:license` resource |
156
+ # @yield evaluates additional RDF work metadata
157
+ # @yieldreturn [Object] ignored block result
89
158
  # @return [Sevgi::Graphics::Element] RDF element
159
+ # @raise [Sevgi::ArgumentError] when an option is unknown
90
160
  def RDFWork(**kwargs, &block)
161
+ unknown = kwargs.keys - WORK_OPTIONS
162
+ ArgumentError.("Unknown RDF work options: #{unknown.join(", ")}") unless unknown.empty?
163
+
91
164
  RDF do
92
165
  Element(:"cc:Work", "rdf:about": "") do
93
166
  Element(:"dc:format", "image/svg+xml")
@@ -104,7 +177,6 @@ module Sevgi
104
177
  end
105
178
  end
106
179
  end
107
- # rubocop:enable Metrics/MethodLength
108
180
  end
109
181
  end
110
182
  end
@@ -10,6 +10,8 @@ module Sevgi
10
10
  class Renderer
11
11
  # Default renderer options.
12
12
  DEFAULTS = {indent: " ", linelength: 140, style: :hybrid}.freeze
13
+ STYLES = %i[hybrid inline block].freeze
14
+ SVG_NAMESPACE = "http://www.w3.org/2000/svg"
13
15
 
14
16
  # Attribute rendering strategies.
15
17
  # @api private
@@ -22,7 +24,7 @@ module Sevgi
22
24
  # @param depth [Integer] element depth
23
25
  # @return [void]
24
26
  def attributes(element, depth)
25
- attributes_block(element, depth, element.attributes.to_xml_lines)
27
+ attributes_block(element, depth, element.attributes.send(:xml_lines))
26
28
  end
27
29
  end
28
30
 
@@ -34,7 +36,7 @@ module Sevgi
34
36
  # @param depth [Integer] element depth
35
37
  # @return [void]
36
38
  def attributes(element, depth)
37
- if attributes_as_block?(lines = element.attributes.to_xml_lines, depth)
39
+ if attributes_as_block?(lines = element.attributes.send(:xml_lines), depth)
38
40
  attributes_block(element, depth, lines)
39
41
  else
40
42
  attributes_inline(element, depth, lines)
@@ -66,7 +68,7 @@ module Sevgi
66
68
  # @param depth [Integer] element depth
67
69
  # @return [void]
68
70
  def attributes(element, depth)
69
- attributes_inline(element, depth, element.attributes.to_xml_lines)
71
+ attributes_inline(element, depth, element.attributes.send(:xml_lines))
70
72
  end
71
73
  end
72
74
  end
@@ -77,7 +79,13 @@ module Sevgi
77
79
  ELEMENTS_WITH_BLOCK_CONTENT = %i[style].freeze
78
80
  SEPARATOR = "\n"
79
81
 
80
- private_constant :ELEMENTS_WITH_INLINE_CONTENT, :ELEMENTS_WITH_BLOCK_CONTENT, :SEPARATOR
82
+ private_constant(
83
+ :ELEMENTS_WITH_INLINE_CONTENT,
84
+ :ELEMENTS_WITH_BLOCK_CONTENT,
85
+ :SEPARATOR,
86
+ :STYLES,
87
+ :SVG_NAMESPACE
88
+ )
81
89
 
82
90
  # @return [Sevgi::Graphics::Element] root element
83
91
  attr_reader :root
@@ -161,10 +169,10 @@ module Sevgi
161
169
  # @option options [Integer] :linelength hybrid attribute line length
162
170
  # @option options [Symbol] :style attribute style, one of :hybrid, :inline, or :block
163
171
  # @return [void]
164
- # @raise [Sevgi::ArgumentError] when style is missing or unsupported
172
+ # @raise [Sevgi::ArgumentError] when an option is unknown, malformed, missing, or unsupported
165
173
  def initialize(root, **)
166
174
  @root = root
167
- @options = DEFAULTS.merge(**)
175
+ @options = self.class.send(:validate, **)
168
176
  @output = []
169
177
  @inlines = Inlines.new
170
178
 
@@ -176,16 +184,39 @@ module Sevgi
176
184
  # @param root [Sevgi::Graphics::Element] root element
177
185
  # @param options [Hash] renderer options
178
186
  # @return [String] SVG source
179
- # @raise [Sevgi::ArgumentError] when style is missing or unsupported
187
+ # @raise [Sevgi::ArgumentError] when options, preambles, names, attributes, or content are invalid XML
180
188
  def self.call(root, **) = new(root, **).call(*root.class.preambles)
181
189
 
182
190
  # Renders a root element without document preambles.
183
191
  # @param root [Sevgi::Graphics::Element] root element
184
192
  # @param options [Hash] renderer options
185
193
  # @return [String] SVG fragment source
186
- # @raise [Sevgi::ArgumentError] when style is missing or unsupported
194
+ # @raise [Sevgi::ArgumentError] when options, names, attributes, or content are invalid XML
187
195
  def self.fragment(root, **options) = new(root, **options).call
188
196
 
197
+ def self.validate(**options)
198
+ options = DEFAULTS.merge(options)
199
+ unknown = options.keys - DEFAULTS.keys
200
+ ArgumentError.("Unknown renderer options: #{unknown.join(", ")}") unless unknown.empty?
201
+
202
+ indent = options[:indent]
203
+ unless indent.is_a?(::String) && /\A[\t\n\r ]*\z/.match?(indent)
204
+ ArgumentError.("Renderer indent must contain only XML whitespace")
205
+ end
206
+
207
+ options[:indent] = XML.text(indent, context: "Renderer indent")
208
+ linelength = options[:linelength]
209
+ unless linelength.is_a?(::Integer) && linelength >= 0
210
+ ArgumentError.("Renderer linelength must be a non-negative Integer")
211
+ end
212
+
213
+ ArgumentError.("Unrecognized style: #{options[:style]}") unless STYLES.include?(options[:style])
214
+
215
+ options.freeze
216
+ end
217
+
218
+ private_class_method :validate
219
+
189
220
  # Appends rendered lines to the output buffer.
190
221
  # @param depth [Integer, nil] indentation depth
191
222
  # @param lines [Array<String>] rendered lines
@@ -199,8 +230,9 @@ module Sevgi
199
230
  # Renders the document.
200
231
  # @param preambles [Array<String>] preamble lines
201
232
  # @return [String] SVG source
233
+ # @raise [Sevgi::ArgumentError] when a preamble or rendered value is not valid XML text
202
234
  def call(*preambles)
203
- output.append(preambles) unless preambles.empty?
235
+ output.append(preambles.map { XML.text(it, context: "XML preamble") }) unless preambles.empty?
204
236
 
205
237
  root.Traverse(
206
238
  0,
@@ -234,8 +266,6 @@ module Sevgi
234
266
  end
235
267
 
236
268
  def build
237
- ArgumentError.("Missing style") unless options[:style]
238
-
239
269
  case options[:style]
240
270
  when :hybrid
241
271
  extend(Attributes::Hybrid)
@@ -243,8 +273,6 @@ module Sevgi
243
273
  extend(Attributes::Inline)
244
274
  when :block
245
275
  extend(Attributes::Block)
246
- else
247
- ArgumentError.("Unrecognized style: #{options[:style]}")
248
276
  end
249
277
 
250
278
  unclosed
@@ -254,10 +282,29 @@ module Sevgi
254
282
  element.children.empty? && element.contents.empty?
255
283
  end
256
284
 
285
+ def block_content?(element)
286
+ return false unless ELEMENTS_WITH_BLOCK_CONTENT.include?(element.name)
287
+
288
+ namespace = default_namespace(element)
289
+ namespace.nil? || namespace.to_s.empty? || namespace == SVG_NAMESPACE
290
+ end
291
+
257
292
  def closed = @closed = true
258
293
 
259
294
  def closed? = @closed.tap { unclosed }
260
295
 
296
+ def default_namespace(element)
297
+ current = element
298
+
299
+ while current.is_a?(Element)
300
+ return current.attributes[:xmlns] if current.attributes.has?(:xmlns)
301
+
302
+ current = current.parent
303
+ end
304
+
305
+ nil
306
+ end
307
+
261
308
  def contents(element, depth)
262
309
  return if element.contents.empty?
263
310
 
@@ -272,10 +319,10 @@ module Sevgi
272
319
  def floating?(element) = element.Is?(:_)
273
320
 
274
321
  def inline_content?(element)
275
- return false if ELEMENTS_WITH_BLOCK_CONTENT.include?(element.name)
322
+ return false if block_content?(element)
276
323
  return false if floating?(element)
277
324
 
278
- element.contents.size == 1 || ELEMENTS_WITH_INLINE_CONTENT.include?(element.name)
325
+ element.contents.any? || ELEMENTS_WITH_INLINE_CONTENT.include?(element.name)
279
326
  end
280
327
 
281
328
  def indent(depth)
@@ -284,7 +331,7 @@ module Sevgi
284
331
 
285
332
  def join
286
333
  inlines.join(output, indent: options[:indent], separator: SEPARATOR)
287
- output.join(SEPARATOR)
334
+ XML.text(output.join(SEPARATOR), context: "SVG output")
288
335
  end
289
336
 
290
337
  def render_enter(element, depth)
@@ -307,6 +354,7 @@ module Sevgi
307
354
  end
308
355
 
309
356
  def unclosed = @closed = false
357
+
310
358
  end
311
359
 
312
360
  private_constant :Renderer
@@ -315,18 +363,39 @@ module Sevgi
315
363
  # Renders this element as SVG source.
316
364
  # Elements with inline text content may also contain inline children such as `tspan`; the renderer keeps those
317
365
  # 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.
366
+ # content is XML-escaped unless a verbatim content object is used. SVG `style` elements use block content;
367
+ # same-named elements under a foreign default namespace retain ordinary inline text formatting.
368
+ # @example Keep every attribute on the element's opening line
369
+ # SVG(:minimal) { rect id: "card", width: 80, height: 40 }.Render(style: :inline)
319
370
  # @param options [Hash] renderer options
371
+ # @option options [String] :indent (" ") XML-whitespace indentation unit
372
+ # @option options [Integer] :linelength (140) non-negative line length that switches hybrid attributes to block
373
+ # style
374
+ # @option options [Symbol] :style (:hybrid) attribute layout: `:hybrid`, `:inline`, or `:block`
320
375
  # @return [String] SVG source
321
- # @raise [Sevgi::ArgumentError] when style is missing or unsupported
376
+ # @raise [Sevgi::ArgumentError] when options, preambles, names, attributes, or content are invalid XML
322
377
  def Render(**) = Renderer.(self, **)
323
378
 
324
379
  # Renders only this element's children.
325
380
  # Child render output omits document preambles and preserves each child's text whitespace and inline
326
381
  # mixed-content formatting.
382
+ # @example Render child fragments with block-style attributes
383
+ # Sevgi::Graphics.SVG(:minimal) { rect id: "one" }.RenderChildren(style: :block, indent: "\t")
327
384
  # @param separator [String] separator between child documents
385
+ # @param options [Hash] renderer options applied to every child fragment
386
+ # @option options [String] :indent (" ") XML-whitespace indentation unit
387
+ # @option options [Integer] :linelength (140) non-negative line length that switches hybrid attributes to block
388
+ # style
389
+ # @option options [Symbol] :style (:hybrid) attribute layout: `:hybrid`, `:inline`, or `:block`
328
390
  # @return [String] rendered child fragments
329
- def RenderChildren(separator = "\n\n") = children.map { Renderer.fragment(it) }.join(separator)
391
+ # @raise [Sevgi::ArgumentError] when separator, options, or rendered child data is invalid
392
+ def RenderChildren(separator = "\n\n", **options)
393
+ ArgumentError.("SVG fragment separator must be a String") unless separator.is_a?(::String)
394
+
395
+ separator = XML.text(separator, context: "SVG fragment separator")
396
+ Renderer.send(:validate, **options)
397
+ children.map { Renderer.fragment(it, **options) }.join(separator)
398
+ end
330
399
  end
331
400
  end
332
401
  end
@@ -8,43 +8,96 @@ module Sevgi
8
8
  # DSL helpers for writing rendered SVG output.
9
9
  module Save
10
10
  # Default SVG extension.
11
+ # @api private
11
12
  EXT = ".svg"
12
13
 
14
+ private_constant :EXT
15
+
16
+ # Change-aware file writer with optional backup support.
17
+ # @api private
18
+ class Writer
19
+ # Writes content when it differs from the destination.
20
+ # @param path [String] expanded output path
21
+ # @param content [String] rendered content
22
+ # @param backup_suffix [String, nil] suffix used for an existing-file backup
23
+ # @yield [content] optionally normalizes old and new content for change detection
24
+ # @yieldparam content [String] old or new content
25
+ # @yieldreturn [String] normalized content
26
+ # @return [String, nil] expanded path when written, otherwise nil
27
+ # @raise [SystemCallError] when the destination or backup cannot be created, read, or written
28
+ def self.call(path, content, backup_suffix: nil, &filter)
29
+ output = "#{content.chomp}\n"
30
+
31
+ return unless F.changed?(path, output, &filter)
32
+
33
+ ::FileUtils.mkdir_p(::File.dirname(path))
34
+ if backup_suffix && !backup_suffix.empty? && ::File.exist?(path)
35
+ ::FileUtils.cp(path, "#{path}#{backup_suffix}")
36
+ end
37
+
38
+ path.tap { ::File.write(path, output) }
39
+ end
40
+ end
41
+
42
+ private_constant :Writer
43
+
13
44
  # Writes rendered SVG to standard output.
14
- # @param kwargs [Hash] render options
15
- # @return [Object] F.out return value
16
- def Out(**kwargs, &filter)
17
- F.out(self.(**kwargs), &filter)
45
+ # @param kwargs [Hash] pre-render and renderer options accepted by {Sevgi::Graphics::Document::Proto#call}
46
+ # @return [nil]
47
+ # @raise [Sevgi::ArgumentError] when a render option or XML-bound value is invalid
48
+ # @raise [Sevgi::ValidationError] when validation is enabled and the document violates the SVG standard
49
+ # @raise [Sevgi::Graphics::LintError] when linting is enabled and the document has structural conflicts
50
+ # @see Sevgi::Graphics::Document::Proto#call
51
+ def Out(**kwargs)
52
+ F.out(self.(**kwargs))
18
53
  end
19
54
 
20
- # Saves rendered SVG to a path derived from the caller by default.
21
- # @param path [String, nil] output path or directory
22
- # @param default [String, nil] default output path
55
+ # Saves rendered SVG when its content differs from the destination.
56
+ # Relative destinations are expanded before being returned. When a non-empty backup suffix is given, an
57
+ # existing destination is copied immediately before replacement; unchanged saves leave both files untouched.
58
+ # Missing parent directories are created. An existing directory target uses the default file name.
59
+ # @example Save to a relative destination
60
+ # path = Sevgi::Graphics.SVG(:minimal).Save("build/drawing.svg")
61
+ # path == File.expand_path("build/drawing.svg") # => true
62
+ # @param path [String, #to_path, nil] output path or existing directory
63
+ # @param default [String, #to_path, nil] default output path
23
64
  # @param backup_suffix [String, nil] suffix used for an existing-file backup
24
- # @return [Object] F.out return value
25
- def Save(path = nil, default: nil, backup_suffix: nil, &filter)
26
- default ||= F.subext(EXT, caller_locations(1..1).first.path)
27
-
28
- if path
29
- ::File.directory?(path) ? ::File.join(path, ::File.basename(default)) : path
30
- else
31
- default
32
- end => path
33
-
34
- ::FileUtils.mkdir_p(::File.dirname(path))
35
- if backup_suffix && !backup_suffix.empty? && ::File.exist?(path)
36
- ::FileUtils.cp(path, "#{path}#{backup_suffix}")
37
- end
65
+ # @param kwargs [Hash] pre-render and renderer options accepted by {Sevgi::Graphics::Document::Proto#call}
66
+ # @yield [content] optionally normalizes old and new content for change detection
67
+ # @yieldparam content [String] old or new SVG source
68
+ # @yieldreturn [String] normalized SVG source
69
+ # @return [String, nil] expanded path when written, or nil when unchanged
70
+ # @raise [Sevgi::ArgumentError] when a selected path/default, render option, or XML-bound value is invalid
71
+ # @raise [Sevgi::ValidationError] when validation is enabled and the document violates the SVG standard
72
+ # @raise [Sevgi::Graphics::LintError] when linting is enabled and the document has structural conflicts
73
+ # @raise [SystemCallError] when the destination or backup cannot be created, read, or written
74
+ # @see Sevgi::Graphics::Document::Proto#call
75
+ def Save(path = nil, default: nil, backup_suffix: nil, **kwargs, &filter)
76
+ default = F.subext(EXT, caller_locations(1..1).first.path) if default.nil?
77
+ path = Path.resolve(path, default:, context: "Save")
38
78
 
39
- Write(path, &filter)
79
+ Writer.(path, self.(**kwargs), backup_suffix:, &filter)
40
80
  end
41
81
 
42
82
  # Writes rendered SVG to a path.
43
- # @param path [String] output path
44
- # @param kwargs [Hash] render options
45
- # @return [Object] F.out return value
83
+ # Missing parent directories are created. Unlike {#Save}, a directory is not treated as a request for a default
84
+ # file name.
85
+ # @param path [String, #to_path] output file path
86
+ # @param kwargs [Hash] pre-render and renderer options accepted by {Sevgi::Graphics::Document::Proto#call}
87
+ # @yield [content] optionally normalizes old and new content for change detection
88
+ # @yieldparam content [String] old or new SVG source
89
+ # @yieldreturn [String] normalized SVG source
90
+ # @return [String, nil] expanded path when written, or nil when unchanged
91
+ # @raise [Sevgi::ArgumentError] when path, a render option, or an XML-bound value is invalid
92
+ # @raise [Sevgi::ValidationError] when validation is enabled and the document violates the SVG standard
93
+ # @raise [Sevgi::Graphics::LintError] when linting is enabled and the document has structural conflicts
94
+ # @raise [SystemCallError] when the destination cannot be read or written
95
+ # @see Sevgi::Graphics::Document::Proto#call
46
96
  def Write(path, **kwargs, &filter)
47
- F.out(self.(**kwargs), path, &filter)
97
+ path = Path.(path, context: "Write path")
98
+ ArgumentError.("Write path must name a file") if ::File.directory?(path)
99
+
100
+ Writer.(path, self.(**kwargs), &filter)
48
101
  end
49
102
  end
50
103
  end
@@ -5,17 +5,89 @@ module Sevgi
5
5
  module Mixtures
6
6
  # DSL helpers for expanding callable modules into SVG symbols.
7
7
  module Symbols
8
- # Renders module callables as symbols under defs.
9
- # @param mod [Module] callable drawing module
8
+ # Builds one symbol set without adding helper methods to the document DSL.
9
+ # @api private
10
+ class Expansion
11
+ # Creates a symbol expansion.
12
+ # @param receiver [Sevgi::Graphics::Element] parent element
13
+ # @param mod [Module] module extended with {Sevgi::Graphics::Module}
14
+ # @return [void]
15
+ def initialize(receiver, mod)
16
+ @receiver = receiver
17
+ @mod = mod
18
+ end
19
+
20
+ # Builds a defs container and populates it from the callable module.
21
+ # @param args [Array<Object>] callable positional arguments
22
+ # @param attributes [Hash] defs attributes
23
+ # @param ids [#call, nil] symbol id mapper
24
+ # @param kwargs [Hash] callable keyword arguments
25
+ # @param block [Proc, nil] callable block argument
26
+ # @return [Sevgi::Graphics::Element] defs element
27
+ # @raise [Sevgi::ArgumentError] when an input channel is invalid
28
+ def call(*args, attributes:, ids:, **kwargs, &block)
29
+ methods = Graphics::Module.__send__(:callables, @mod)
30
+ ArgumentError.("Defs attributes must be a Hash") unless attributes.is_a?(::Hash)
31
+ ArgumentError.("Symbol ids must respond to call") if ids && !ids.respond_to?(:call)
32
+
33
+ defaults = @mod.name ? {id: F.demodulize(@mod.name).to_sym} : {}
34
+ attributes = Attribute.defaults(attributes, **defaults)
35
+ @args, @kwargs, @block = args, kwargs, block
36
+ @receiver.defs(**attributes).tap { populate(it, methods, ids) }
37
+ end
38
+
39
+ private
40
+
41
+ # Adds bases and symbols to a defs element.
42
+ # @param defs [Sevgi::Graphics::Element] defs element
43
+ # @param methods [Array<UnboundMethod>] callable methods
44
+ # @param ids [#call, nil] symbol id mapper
45
+ # @return [void]
46
+ def populate(defs, methods, ids)
47
+ context = Graphics::Module.__send__(:context, @mod, defs)
48
+ Graphics::Module.__send__(:bases, @mod).each { context.instance_exec(&it) }
49
+ methods.each { draw(defs, it, ids) }
50
+ end
51
+
52
+ # Adds one callable symbol.
53
+ # @param defs [Sevgi::Graphics::Element] defs element
54
+ # @param method [UnboundMethod] callable method
55
+ # @param ids [#call, nil] symbol id mapper
56
+ # @return [Object, nil] callable return value
57
+ def draw(defs, method, ids)
58
+ name = method.name
59
+ symbol = defs.symbol(id: ids ? ids.call(name) : name.to_s.tr("_", "-"))
60
+ symbol.title(name.to_s.split("_").map(&:capitalize).join(" "))
61
+ context = Graphics::Module.__send__(:context, @mod, symbol)
62
+ Graphics::Module.__send__(:invoke, context, symbol, [method], *@args, **@kwargs, &@block)
63
+ end
64
+ end
65
+
66
+ private_constant :Expansion
67
+
68
+ # Renders module callables as symbols under defs. Named modules default the defs id to their final constant name;
69
+ # anonymous modules omit the id unless supplied.
70
+ # @param mod [Module] module extended with {Sevgi::Graphics::Module}
10
71
  # @param args [Array<Object>] callable arguments
11
- # @param kwargs [Hash] defs attributes
72
+ # Base blocks run once in the defs element before symbols are created. Positional arguments, keyword arguments,
73
+ # and the block are forwarded to each callable.
74
+ # @example Expand named drawing methods into reusable symbols
75
+ # icons = Module.new do
76
+ # extend Sevgi::Graphics::Module
77
+ # def dot = circle r: 2
78
+ # def tick = path d: "M 0 2 L 2 4 L 6 0"
79
+ # end
80
+ # Sevgi::Graphics.SVG(:minimal) { Symbols icons }
81
+ # @param attributes [Hash] defs attributes; String and Symbol names are normalized and must not collide
82
+ # @param ids [#call, nil] optional callable mapping each method name to a symbol id
83
+ # @param kwargs [Hash] callable keyword arguments
84
+ # @yield forwarded to each callable
85
+ # @yieldreturn [Object] callable-defined block result
12
86
  # @return [Sevgi::Graphics::Element] defs element
13
- # @raise [Sevgi::ArgumentError] when mod is not a plain module
14
- def Symbols(mod, *args, **kwargs, &block)
15
- CallWithin(mod, :defs, :symbol, *args, **kwargs) do |name, element|
16
- element[:id] = block ? block.call(name) : name.to_s.split("_").join("-")
17
- title(name.to_s.split("_").map(&:capitalize).join(" "))
18
- end
87
+ # @raise [Sevgi::ArgumentError] when mod is not a callable drawing module, attributes is not a Hash, or ids is
88
+ # not callable
89
+ def Symbols(mod, *args, attributes: {}, ids: nil, **kwargs, &block)
90
+ Expansion.new(self, mod).call(*args, attributes:, ids:, **kwargs, &block)
19
91
  end
20
92
  end
21
93
  end