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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +275 -2
- data/LICENSE +672 -3
- data/README.md +8 -8
- data/lib/sevgi/graphics/attribute.rb +271 -54
- data/lib/sevgi/graphics/auxiliary/canvas.rb +137 -58
- data/lib/sevgi/graphics/auxiliary/content.rb +200 -57
- data/lib/sevgi/graphics/auxiliary/margin.rb +33 -14
- data/lib/sevgi/graphics/auxiliary/paper.rb +118 -86
- data/lib/sevgi/graphics/auxiliary/path.rb +44 -0
- data/lib/sevgi/graphics/auxiliary/scalar.rb +69 -0
- data/lib/sevgi/graphics/auxiliary/sizes.rb +62 -0
- data/lib/sevgi/graphics/auxiliary.rb +3 -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 +370 -124
- data/lib/sevgi/graphics/element.rb +143 -33
- data/lib/sevgi/graphics/mixtures/call.rb +249 -88
- data/lib/sevgi/graphics/mixtures/core.rb +143 -33
- data/lib/sevgi/graphics/mixtures/duplicate.rb +76 -22
- data/lib/sevgi/graphics/mixtures/export.rb +58 -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 +40 -5
- data/lib/sevgi/graphics/mixtures/inkscape.rb +215 -46
- data/lib/sevgi/graphics/mixtures/rdf.rb +79 -7
- data/lib/sevgi/graphics/mixtures/render.rb +88 -19
- data/lib/sevgi/graphics/mixtures/save.rb +79 -26
- data/lib/sevgi/graphics/mixtures/symbols.rb +81 -9
- data/lib/sevgi/graphics/mixtures/tile.rb +88 -64
- data/lib/sevgi/graphics/mixtures/transform.rb +68 -28
- data/lib/sevgi/graphics/mixtures/underscore.rb +16 -7
- data/lib/sevgi/graphics/mixtures/validate.rb +14 -2
- data/lib/sevgi/graphics/mixtures/wrappers.rb +66 -24
- data/lib/sevgi/graphics/mixtures.rb +19 -13
- data/lib/sevgi/graphics/version.rb +1 -1
- data/lib/sevgi/graphics/xml.rb +127 -0
- data/lib/sevgi/graphics.rb +75 -28
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
172
|
+
# @raise [Sevgi::ArgumentError] when an option is unknown, malformed, missing, or unsupported
|
|
165
173
|
def initialize(root, **)
|
|
166
174
|
@root = root
|
|
167
|
-
@options =
|
|
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
|
|
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
|
|
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
|
|
322
|
+
return false if block_content?(element)
|
|
276
323
|
return false if floating?(element)
|
|
277
324
|
|
|
278
|
-
element.contents.
|
|
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
|
|
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
|
-
|
|
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 [
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
21
|
-
#
|
|
22
|
-
#
|
|
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
|
-
# @
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
79
|
+
Writer.(path, self.(**kwargs), backup_suffix:, &filter)
|
|
40
80
|
end
|
|
41
81
|
|
|
42
82
|
# Writes rendered SVG to a path.
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
# @
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|