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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +221 -2
- data/README.md +12 -9
- data/lib/sevgi/graphics/attribute.rb +166 -45
- data/lib/sevgi/graphics/auxiliary/canvas.rb +100 -43
- data/lib/sevgi/graphics/auxiliary/content.rb +56 -47
- data/lib/sevgi/graphics/auxiliary/margin.rb +19 -12
- data/lib/sevgi/graphics/auxiliary/paper.rb +74 -49
- data/lib/sevgi/graphics/auxiliary/path.rb +44 -0
- data/lib/sevgi/graphics/auxiliary/scalar.rb +36 -7
- data/lib/sevgi/graphics/auxiliary.rb +1 -0
- data/lib/sevgi/graphics/document/base.rb +6 -2
- data/lib/sevgi/graphics/document/default.rb +1 -1
- data/lib/sevgi/graphics/document.rb +239 -117
- data/lib/sevgi/graphics/element.rb +132 -34
- data/lib/sevgi/graphics/mixtures/call.rb +234 -88
- data/lib/sevgi/graphics/mixtures/core.rb +67 -27
- data/lib/sevgi/graphics/mixtures/duplicate.rb +47 -25
- data/lib/sevgi/graphics/mixtures/export.rb +54 -12
- data/lib/sevgi/graphics/mixtures/hatch.rb +49 -7
- data/lib/sevgi/graphics/mixtures/identify.rb +30 -17
- data/lib/sevgi/graphics/mixtures/include.rb +26 -8
- data/lib/sevgi/graphics/mixtures/inkscape.rb +214 -47
- data/lib/sevgi/graphics/mixtures/rdf.rb +59 -7
- data/lib/sevgi/graphics/mixtures/render.rb +60 -120
- data/lib/sevgi/graphics/mixtures/save.rb +79 -35
- data/lib/sevgi/graphics/mixtures/symbols.rb +81 -12
- data/lib/sevgi/graphics/mixtures/tile.rb +85 -67
- data/lib/sevgi/graphics/mixtures/transform.rb +89 -30
- data/lib/sevgi/graphics/mixtures/underscore.rb +16 -7
- data/lib/sevgi/graphics/mixtures/validate.rb +2 -2
- data/lib/sevgi/graphics/mixtures/wrappers.rb +111 -23
- data/lib/sevgi/graphics/mixtures.rb +15 -13
- data/lib/sevgi/graphics/version.rb +1 -1
- data/lib/sevgi/graphics/xml.rb +4 -9
- data/lib/sevgi/graphics.rb +69 -18
- metadata +7 -6
|
@@ -10,75 +10,20 @@ 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
|
|
13
14
|
SVG_NAMESPACE = "http://www.w3.org/2000/svg"
|
|
14
15
|
|
|
15
|
-
# Attribute rendering strategies.
|
|
16
|
-
# @api private
|
|
17
|
-
module Attributes
|
|
18
|
-
# Block-style attribute renderer.
|
|
19
|
-
# @api private
|
|
20
|
-
module Block
|
|
21
|
-
# Renders attributes in block form.
|
|
22
|
-
# @param element [Sevgi::Graphics::Element] rendered element
|
|
23
|
-
# @param depth [Integer] element depth
|
|
24
|
-
# @return [void]
|
|
25
|
-
def attributes(element, depth)
|
|
26
|
-
attributes_block(element, depth, element.attributes.to_xml_lines)
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
# Hybrid attribute renderer.
|
|
31
|
-
# @api private
|
|
32
|
-
module Hybrid
|
|
33
|
-
# Renders attributes inline or in block form according to line length.
|
|
34
|
-
# @param element [Sevgi::Graphics::Element] rendered element
|
|
35
|
-
# @param depth [Integer] element depth
|
|
36
|
-
# @return [void]
|
|
37
|
-
def attributes(element, depth)
|
|
38
|
-
if attributes_as_block?(lines = element.attributes.to_xml_lines, depth)
|
|
39
|
-
attributes_block(element, depth, lines)
|
|
40
|
-
else
|
|
41
|
-
attributes_inline(element, depth, lines)
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
# Reports whether attributes should be rendered in block form.
|
|
46
|
-
# @param lines [Array<String>] rendered attribute lines
|
|
47
|
-
# @param depth [Integer] element depth
|
|
48
|
-
# @return [Boolean]
|
|
49
|
-
def attributes_as_block?(lines, depth)
|
|
50
|
-
linelength(lines, depth) > options[:linelength]
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
# Returns the effective inline line length.
|
|
54
|
-
# @param lines [Array<String>] rendered attribute lines
|
|
55
|
-
# @param depth [Integer] element depth
|
|
56
|
-
# @return [Integer]
|
|
57
|
-
def linelength(lines, depth)
|
|
58
|
-
indent(depth).length + lines.sum(&:length)
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
# Inline attribute renderer.
|
|
63
|
-
# @api private
|
|
64
|
-
module Inline
|
|
65
|
-
# Renders attributes inline.
|
|
66
|
-
# @param element [Sevgi::Graphics::Element] rendered element
|
|
67
|
-
# @param depth [Integer] element depth
|
|
68
|
-
# @return [void]
|
|
69
|
-
def attributes(element, depth)
|
|
70
|
-
attributes_inline(element, depth, element.attributes.to_xml_lines)
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
private_constant :Attributes
|
|
76
|
-
|
|
77
16
|
ELEMENTS_WITH_INLINE_CONTENT = %i[title].freeze
|
|
78
17
|
ELEMENTS_WITH_BLOCK_CONTENT = %i[style].freeze
|
|
79
18
|
SEPARATOR = "\n"
|
|
80
19
|
|
|
81
|
-
private_constant
|
|
20
|
+
private_constant(
|
|
21
|
+
:ELEMENTS_WITH_INLINE_CONTENT,
|
|
22
|
+
:ELEMENTS_WITH_BLOCK_CONTENT,
|
|
23
|
+
:SEPARATOR,
|
|
24
|
+
:STYLES,
|
|
25
|
+
:SVG_NAMESPACE
|
|
26
|
+
)
|
|
82
27
|
|
|
83
28
|
# @return [Sevgi::Graphics::Element] root element
|
|
84
29
|
attr_reader :root
|
|
@@ -95,17 +40,11 @@ module Sevgi
|
|
|
95
40
|
# Inline mark with start, stop, and depth.
|
|
96
41
|
Mark = Struct.new(:start, :stop, :depth)
|
|
97
42
|
|
|
98
|
-
# Creates an inline splice tracker.
|
|
99
|
-
# @return [void]
|
|
100
43
|
def initialize
|
|
101
44
|
@marks = []
|
|
102
45
|
@stack = []
|
|
103
46
|
end
|
|
104
47
|
|
|
105
|
-
# Starts an inline splice range.
|
|
106
|
-
# @param index [Integer] output index
|
|
107
|
-
# @param depth [Integer] element depth
|
|
108
|
-
# @return [Sevgi::Graphics::Mixtures::Render::Renderer::Inlines::Mark] opened mark
|
|
109
48
|
def start(index, depth)
|
|
110
49
|
Mark.new(start: index, depth:).tap do |mark|
|
|
111
50
|
@marks << mark
|
|
@@ -113,10 +52,6 @@ module Sevgi
|
|
|
113
52
|
end
|
|
114
53
|
end
|
|
115
54
|
|
|
116
|
-
# Ends the innermost inline splice range.
|
|
117
|
-
# @param index [Integer] output index
|
|
118
|
-
# @return [Integer]
|
|
119
|
-
# @raise [Sevgi::PanicError] when no inline range is open
|
|
120
55
|
def stop(index)
|
|
121
56
|
mark = @stack.pop
|
|
122
57
|
PanicError.("Inline content range was not opened") unless mark
|
|
@@ -124,12 +59,6 @@ module Sevgi
|
|
|
124
59
|
mark.stop = index
|
|
125
60
|
end
|
|
126
61
|
|
|
127
|
-
# Joins marked output ranges into inline content.
|
|
128
|
-
# @param output [Array<Array<String>, nil>] renderer output buffer
|
|
129
|
-
# @param indent [String] indentation unit
|
|
130
|
-
# @param separator [String] line separator
|
|
131
|
-
# @return [Array<Array<String>, nil>, nil]
|
|
132
|
-
# @raise [Sevgi::PanicError] when an inline range was not closed
|
|
133
62
|
def join(output, indent:, separator:)
|
|
134
63
|
return if @marks.empty?
|
|
135
64
|
|
|
@@ -165,11 +94,10 @@ module Sevgi
|
|
|
165
94
|
# @raise [Sevgi::ArgumentError] when an option is unknown, malformed, missing, or unsupported
|
|
166
95
|
def initialize(root, **)
|
|
167
96
|
@root = root
|
|
168
|
-
@options =
|
|
97
|
+
@options = self.class.send(:validate, **)
|
|
169
98
|
@output = []
|
|
170
99
|
@inlines = Inlines.new
|
|
171
|
-
|
|
172
|
-
build
|
|
100
|
+
unclosed
|
|
173
101
|
end
|
|
174
102
|
|
|
175
103
|
# @overload call(root, **options)
|
|
@@ -187,6 +115,29 @@ module Sevgi
|
|
|
187
115
|
# @raise [Sevgi::ArgumentError] when options, names, attributes, or content are invalid XML
|
|
188
116
|
def self.fragment(root, **options) = new(root, **options).call
|
|
189
117
|
|
|
118
|
+
def self.validate(**options)
|
|
119
|
+
options = DEFAULTS.merge(options)
|
|
120
|
+
unknown = options.keys - DEFAULTS.keys
|
|
121
|
+
ArgumentError.("Unknown renderer options: #{unknown.join(", ")}") unless unknown.empty?
|
|
122
|
+
|
|
123
|
+
indent = options[:indent]
|
|
124
|
+
unless indent.is_a?(::String) && /\A[\t\n\r ]*\z/.match?(indent)
|
|
125
|
+
ArgumentError.("Renderer indent must contain only XML whitespace")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
options[:indent] = XML.text(indent, context: "Renderer indent")
|
|
129
|
+
linelength = options[:linelength]
|
|
130
|
+
unless linelength.is_a?(::Integer) && linelength >= 0
|
|
131
|
+
ArgumentError.("Renderer linelength must be a non-negative Integer")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
ArgumentError.("Unrecognized style: #{options[:style]}") unless STYLES.include?(options[:style])
|
|
135
|
+
|
|
136
|
+
options.freeze
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private_class_method :validate
|
|
140
|
+
|
|
190
141
|
# Appends rendered lines to the output buffer.
|
|
191
142
|
# @param depth [Integer, nil] indentation depth
|
|
192
143
|
# @param lines [Array<String>] rendered lines
|
|
@@ -216,6 +167,14 @@ module Sevgi
|
|
|
216
167
|
|
|
217
168
|
attr_reader :inlines
|
|
218
169
|
|
|
170
|
+
def attributes(element, depth)
|
|
171
|
+
lines = element.attributes.send(:xml_lines)
|
|
172
|
+
block = options[:style] == :block ||
|
|
173
|
+
(options[:style] == :hybrid && indent(depth).length + lines.sum(&:length) > options[:linelength])
|
|
174
|
+
|
|
175
|
+
block ? attributes_block(element, depth, lines) : attributes_inline(element, depth, lines)
|
|
176
|
+
end
|
|
177
|
+
|
|
219
178
|
def attributes_block(element, depth, lines)
|
|
220
179
|
return attributes_inline(element, depth, lines) if lines.empty?
|
|
221
180
|
|
|
@@ -235,24 +194,6 @@ module Sevgi
|
|
|
235
194
|
end
|
|
236
195
|
end
|
|
237
196
|
|
|
238
|
-
def build
|
|
239
|
-
validate_options!
|
|
240
|
-
ArgumentError.("Missing style") unless options[:style]
|
|
241
|
-
|
|
242
|
-
case options[:style]
|
|
243
|
-
when :hybrid
|
|
244
|
-
extend(Attributes::Hybrid)
|
|
245
|
-
when :inline
|
|
246
|
-
extend(Attributes::Inline)
|
|
247
|
-
when :block
|
|
248
|
-
extend(Attributes::Block)
|
|
249
|
-
else
|
|
250
|
-
ArgumentError.("Unrecognized style: #{options[:style]}")
|
|
251
|
-
end
|
|
252
|
-
|
|
253
|
-
unclosed
|
|
254
|
-
end
|
|
255
|
-
|
|
256
197
|
def childless?(element)
|
|
257
198
|
element.children.empty? && element.contents.empty?
|
|
258
199
|
end
|
|
@@ -330,32 +271,23 @@ module Sevgi
|
|
|
330
271
|
|
|
331
272
|
def unclosed = @closed = false
|
|
332
273
|
|
|
333
|
-
def validate_options!
|
|
334
|
-
unknown = options.keys - DEFAULTS.keys
|
|
335
|
-
ArgumentError.("Unknown renderer options: #{unknown.join(", ")}") unless unknown.empty?
|
|
336
|
-
|
|
337
|
-
indent = options[:indent]
|
|
338
|
-
unless indent.is_a?(::String) && /\A[\t\n\r ]*\z/.match?(indent)
|
|
339
|
-
ArgumentError.("Renderer indent must contain only XML whitespace")
|
|
340
|
-
end
|
|
341
|
-
|
|
342
|
-
options[:indent] = XML.text(indent, context: "Renderer indent")
|
|
343
|
-
linelength = options[:linelength]
|
|
344
|
-
return if linelength.is_a?(::Integer) && linelength >= 0
|
|
345
|
-
|
|
346
|
-
ArgumentError.("Renderer linelength must be a non-negative Integer")
|
|
347
|
-
end
|
|
348
274
|
end
|
|
349
275
|
|
|
350
276
|
private_constant :Renderer
|
|
351
277
|
|
|
352
278
|
# @overload Render(**options)
|
|
353
279
|
# Renders this element as SVG source.
|
|
354
|
-
# Elements with inline text content
|
|
280
|
+
# Elements with inline text content can also contain inline children such as `tspan`. The renderer keeps those
|
|
355
281
|
# descendants in the same text line. Whitespace inside content objects is preserved as given, and encoded
|
|
356
|
-
# content is XML-escaped unless a verbatim content object is used. SVG `style` elements use block content
|
|
282
|
+
# content is XML-escaped unless a verbatim content object is used. SVG `style` elements use block content.
|
|
357
283
|
# same-named elements under a foreign default namespace retain ordinary inline text formatting.
|
|
284
|
+
# @example Keep every attribute on the element's opening line
|
|
285
|
+
# SVG(:minimal) { rect id: "card", width: 80, height: 40 }.Render(style: :inline)
|
|
358
286
|
# @param options [Hash] renderer options
|
|
287
|
+
# @option options [String] :indent (" ") XML-whitespace indentation unit
|
|
288
|
+
# @option options [Integer] :linelength (140) non-negative line length that switches hybrid attributes to block
|
|
289
|
+
# style
|
|
290
|
+
# @option options [Symbol] :style (:hybrid) attribute layout: `:hybrid`, `:inline`, or `:block`
|
|
359
291
|
# @return [String] SVG source
|
|
360
292
|
# @raise [Sevgi::ArgumentError] when options, preambles, names, attributes, or content are invalid XML
|
|
361
293
|
def Render(**) = Renderer.(self, **)
|
|
@@ -363,14 +295,22 @@ module Sevgi
|
|
|
363
295
|
# Renders only this element's children.
|
|
364
296
|
# Child render output omits document preambles and preserves each child's text whitespace and inline
|
|
365
297
|
# mixed-content formatting.
|
|
298
|
+
# @example Render child fragments with block-style attributes
|
|
299
|
+
# Sevgi::Graphics.SVG(:minimal) { rect id: "one" }.RenderChildren(style: :block, indent: "\t")
|
|
366
300
|
# @param separator [String] separator between child documents
|
|
301
|
+
# @param options [Hash] renderer options applied to every child fragment
|
|
302
|
+
# @option options [String] :indent (" ") XML-whitespace indentation unit
|
|
303
|
+
# @option options [Integer] :linelength (140) non-negative line length that switches hybrid attributes to block
|
|
304
|
+
# style
|
|
305
|
+
# @option options [Symbol] :style (:hybrid) attribute layout: `:hybrid`, `:inline`, or `:block`
|
|
367
306
|
# @return [String] rendered child fragments
|
|
368
|
-
# @raise [Sevgi::ArgumentError] when separator or rendered child data is
|
|
369
|
-
def RenderChildren(separator = "\n\n")
|
|
307
|
+
# @raise [Sevgi::ArgumentError] when separator, options, or rendered child data is invalid
|
|
308
|
+
def RenderChildren(separator = "\n\n", **options)
|
|
370
309
|
ArgumentError.("SVG fragment separator must be a String") unless separator.is_a?(::String)
|
|
371
310
|
|
|
372
311
|
separator = XML.text(separator, context: "SVG fragment separator")
|
|
373
|
-
|
|
312
|
+
Renderer.send(:validate, **options)
|
|
313
|
+
children.map { Renderer.fragment(it, **options) }.join(separator)
|
|
374
314
|
end
|
|
375
315
|
end
|
|
376
316
|
end
|
|
@@ -8,52 +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
|
-
# @
|
|
16
|
-
# @
|
|
17
|
-
# @
|
|
18
|
-
# @
|
|
19
|
-
|
|
20
|
-
|
|
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))
|
|
21
53
|
end
|
|
22
54
|
|
|
23
|
-
# Saves rendered SVG
|
|
24
|
-
#
|
|
25
|
-
#
|
|
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.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
|
|
26
64
|
# @param backup_suffix [String, nil] suffix used for an existing-file backup
|
|
27
|
-
# @
|
|
28
|
-
# @
|
|
29
|
-
# @
|
|
30
|
-
# @
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
::FileUtils.mkdir_p(::File.dirname(path))
|
|
41
|
-
if backup_suffix && !backup_suffix.empty? && ::File.exist?(path)
|
|
42
|
-
::FileUtils.cp(path, "#{path}#{backup_suffix}")
|
|
43
|
-
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")
|
|
44
78
|
|
|
45
|
-
|
|
79
|
+
Writer.(path, self.(**kwargs), backup_suffix:, &filter)
|
|
46
80
|
end
|
|
47
81
|
|
|
48
82
|
# Writes rendered SVG to a path.
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
# @
|
|
52
|
-
# @
|
|
53
|
-
# @
|
|
54
|
-
# @
|
|
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
|
|
55
96
|
def Write(path, **kwargs, &filter)
|
|
56
|
-
|
|
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)
|
|
57
101
|
end
|
|
58
102
|
end
|
|
59
103
|
end
|
|
@@ -5,20 +5,89 @@ module Sevgi
|
|
|
5
5
|
module Mixtures
|
|
6
6
|
# DSL helpers for expanding callable modules into SVG symbols.
|
|
7
7
|
module Symbols
|
|
8
|
-
#
|
|
9
|
-
# @
|
|
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
|
-
#
|
|
12
|
-
#
|
|
13
|
-
# @
|
|
14
|
-
#
|
|
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
|
|
15
86
|
# @return [Sevgi::Graphics::Element] defs element
|
|
16
|
-
# @raise [Sevgi::ArgumentError] when mod is not a
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
title(name.to_s.split("_").map(&:capitalize).join(" "))
|
|
21
|
-
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)
|
|
22
91
|
end
|
|
23
92
|
end
|
|
24
93
|
end
|