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
@@ -5,6 +5,22 @@ module Sevgi
5
5
  module Mixtures
6
6
  # Inkscape-specific SVG DSL helpers.
7
7
  module Inkscape
8
+ # Normalizes callable wrapper attributes outside the DSL method surface.
9
+ # @api private
10
+ module Wrapper
11
+ # Returns owned wrapper attributes with a stable default id when the module is named.
12
+ # @param mod [Module] callable drawing module
13
+ # @param attributes [Hash] wrapper attributes
14
+ # @return [Hash{Symbol => Object}] normalized attributes
15
+ # @raise [Sevgi::ArgumentError] when attributes contains colliding names
16
+ def self.attributes(mod, attributes)
17
+ defaults = mod.name ? {id: F.demodulize(mod.name).to_sym} : {}
18
+ Attribute.defaults(attributes, **defaults)
19
+ end
20
+ end
21
+
22
+ private_constant :Wrapper
23
+
8
24
  # Adds Inkscape template metadata.
9
25
  # @param name [String] template name
10
26
  # @param desc [String, nil] short description
@@ -23,42 +39,60 @@ module Sevgi
23
39
  end
24
40
 
25
41
  # Renders a callable module inside a group.
26
- # @param mod [Module] callable drawing module
42
+ # Named modules default the group id to their final constant name. Anonymous modules omit the id unless supplied.
43
+ # @example Supply a stable id for an anonymous callable
44
+ # drawing = Module.new do
45
+ # extend Sevgi::Graphics::Module
46
+ # def call = circle r: 5
47
+ # end
48
+ # Sevgi::Graphics.SVG(:inkscape) { Group drawing, attributes: {id: "drawing"} }
49
+ # @param mod [Module] module extended with {Sevgi::Graphics::Module}
27
50
  # @param args [Array<Object>] callable arguments
28
- # @param kwargs [Hash] group attributes
51
+ # @param attributes [Hash] group attributes; String and Symbol names are normalized and must not collide
52
+ # @param kwargs [Hash] callable keyword arguments
29
53
  # @yield forwards customization to the callable module
30
54
  # @yieldreturn [Object] callable customization result
31
55
  # @return [Sevgi::Graphics::Element] group element
32
- # @raise [Sevgi::ArgumentError] when mod is not a plain module
33
- def Group(mod, *args, **kwargs, &block)
34
- kwargs = kwargs.merge(id: F.demodulize(mod).to_sym) unless kwargs.key?(:id)
35
- g(**kwargs) { Call(mod, *args, &block) }
56
+ # @raise [Sevgi::ArgumentError] when mod is not a callable drawing module or attributes is not a Hash
57
+ def Group(mod, *args, attributes: {}, **kwargs, &block)
58
+ Graphics::Module.__send__(:callables, mod)
59
+ ArgumentError.("Group attributes must be a Hash") unless attributes.is_a?(::Hash)
60
+ attributes = Wrapper.attributes(mod, attributes)
61
+ g(**attributes) { Call(mod, *args, **kwargs, &block) }
36
62
  end
37
63
 
38
64
  # Renders a callable module inside an Inkscape layer.
39
- # @param mod [Module] callable drawing module
65
+ # Named modules default the layer id to their final constant name. Anonymous modules omit the id unless supplied.
66
+ # @param mod [Module] module extended with {Sevgi::Graphics::Module}
40
67
  # @param args [Array<Object>] callable arguments
41
- # @param kwargs [Hash] layer attributes
68
+ # @param attributes [Hash] layer attributes; String and Symbol names are normalized and must not collide
69
+ # @param kwargs [Hash] callable keyword arguments
42
70
  # @yield forwards customization to the callable module
43
71
  # @yieldreturn [Object] callable customization result
44
72
  # @return [Sevgi::Graphics::Element] layer element
45
- # @raise [Sevgi::ArgumentError] when mod is not a plain module
46
- def Layer(mod, *args, **kwargs, &block)
47
- kwargs = kwargs.merge(id: F.demodulize(mod).to_sym) unless kwargs.key?(:id)
48
- layer(**kwargs) { Call(mod, *args, &block) }
73
+ # @raise [Sevgi::ArgumentError] when mod is not a callable drawing module or attributes is not a Hash
74
+ def Layer(mod, *args, attributes: {}, **kwargs, &block)
75
+ Graphics::Module.__send__(:callables, mod)
76
+ ArgumentError.("Layer attributes must be a Hash") unless attributes.is_a?(::Hash)
77
+ attributes = Wrapper.attributes(mod, attributes)
78
+ layer(**attributes) { Call(mod, *args, **kwargs, &block) }
49
79
  end
50
80
 
51
81
  # Renders a callable module inside an insensitive Inkscape layer.
52
- # @param mod [Module] callable drawing module
82
+ # Named modules default the layer id to their final constant name. Anonymous modules omit the id unless supplied.
83
+ # @param mod [Module] module extended with {Sevgi::Graphics::Module}
53
84
  # @param args [Array<Object>] callable arguments
54
- # @param kwargs [Hash] layer attributes
85
+ # @param attributes [Hash] layer attributes; String and Symbol names are normalized and must not collide
86
+ # @param kwargs [Hash] callable keyword arguments
55
87
  # @yield forwards customization to the callable module
56
88
  # @yieldreturn [Object] callable customization result
57
89
  # @return [Sevgi::Graphics::Element] layer element
58
- # @raise [Sevgi::ArgumentError] when mod is not a plain module
59
- def Layer!(mod, *args, **kwargs, &block)
60
- kwargs = kwargs.merge(id: F.demodulize(mod).to_sym) unless kwargs.key?(:id)
61
- layer!(**kwargs) { Call(mod, *args, &block) }
90
+ # @raise [Sevgi::ArgumentError] when mod is not a callable drawing module or attributes is not a Hash
91
+ def Layer!(mod, *args, attributes: {}, **kwargs, &block)
92
+ Graphics::Module.__send__(:callables, mod)
93
+ ArgumentError.("Layer attributes must be a Hash") unless attributes.is_a?(::Hash)
94
+ attributes = Wrapper.attributes(mod, attributes)
95
+ layer!(**attributes) { Call(mod, *args, **kwargs, &block) }
62
96
  end
63
97
 
64
98
  # @overload layer(**attributes)
@@ -81,46 +115,166 @@ module Sevgi
81
115
  layer("sodipodi:insensitive": "true", **, &block)
82
116
  end
83
117
 
118
+ # Validates and constructs Inkscape page collections outside the DSL method surface.
119
+ # @api private
120
+ module Pagination
121
+ class << self
122
+ # Builds a namedview from normalized page attributes.
123
+ # @param context [Sevgi::Graphics::Element] DSL element receiving the namedview
124
+ # @param pages [Array<Hash>] page attributes
125
+ # @param namedview [Hash] namedview attributes
126
+ # @param page [Hash] shared page attributes
127
+ # @return [Sevgi::Graphics::Element] namedview element
128
+ # @raise [Sevgi::ArgumentError] when an attribute channel or page is invalid
129
+ def call(context, pages, namedview:, page:, &block)
130
+ namedview, page = channels(namedview, page)
131
+ pages = pages.each_with_index.map { |attributes, index| normalize(attributes, page, index) }
132
+
133
+ context.Element(:"sodipodi:namedview", **namedview) do
134
+ pages.each do |attributes|
135
+ element = Element(:"inkscape:page", **attributes)
136
+ block&.call(element)
137
+ end
138
+ end
139
+ end
140
+
141
+ # Generates validated attributes for a rectangular page grid.
142
+ # @return [Array<Hash{Symbol => Object}>] page attribute hashes
143
+ # @raise [Sevgi::ArgumentError] when a count, dimension, or gap is invalid
144
+ def tabular(rows:, cols:, width:, height:, gap:)
145
+ width, height, gap = normalize_grid(rows:, cols:, width:, height:, gap:)
146
+
147
+ rows.times.flat_map do |row|
148
+ cols.times.map do |col|
149
+ x = col * (width + gap)
150
+ y = row * (height + gap)
151
+ label = "#{row + 1}x#{col + 1}"
152
+ {id: "pageview-#{label}", x:, y:, width:, height:}
153
+ end
154
+ end
155
+ end
156
+
157
+ private
158
+
159
+ # Validates and normalizes the namedview and page-default channels.
160
+ # @return [Array<Hash>] normalized namedview attributes and page defaults
161
+ # @raise [Sevgi::ArgumentError] when either channel is not a Hash or has invalid attributes
162
+ # @api private
163
+ def channels(namedview, page)
164
+ ArgumentError.("Namedview attributes must be a Hash") unless namedview.is_a?(::Hash)
165
+ ArgumentError.("Page attributes must be a Hash") unless page.is_a?(::Hash)
166
+ [Attribute.defaults(namedview, id: "namedview"), Attribute.normalize(page)]
167
+ end
168
+
169
+ # Normalizes one explicit or generated page id.
170
+ # @return [Hash{String, Symbol => Object}] attributes beginning with a canonical id
171
+ # @raise [Sevgi::ArgumentError] when String and Symbol ids collide
172
+ # @api private
173
+ def identify(attributes, index)
174
+ id = attributes.key?(:id) ? attributes.delete(:id) : "page-#{index + 1}"
175
+ {id:}.merge(attributes)
176
+ end
177
+
178
+ # Normalizes and validates one page.
179
+ # @return [Hash{String, Symbol => Object}] independent page attributes
180
+ # @raise [Sevgi::ArgumentError] when attributes or dimensions are invalid
181
+ # @api private
182
+ def normalize(attributes, defaults, index)
183
+ ArgumentError.("Page #{index + 1} must be a Hash") unless attributes.is_a?(::Hash)
184
+ attributes = defaults.merge(Attribute.normalize(attributes))
185
+ %i[x y].each { number(attributes, it, index) }
186
+ %i[width height].each do |field|
187
+ number(attributes, field, index, positive: true)
188
+ end
189
+
190
+ identify(attributes, index)
191
+ end
192
+
193
+ # Validates and normalizes page-grid arguments.
194
+ # @return [Array<(Integer, Float)>] normalized width, height, and gap
195
+ # @raise [Sevgi::ArgumentError] when a count, dimension, or gap is invalid
196
+ # @api private
197
+ def normalize_grid(rows:, cols:, width:, height:, gap:)
198
+ {rows:, cols:}.each do |field, count|
199
+ unless count.is_a?(::Integer) && count.positive?
200
+ ArgumentError.("Page #{field} must be a positive Integer")
201
+ end
202
+ end
203
+
204
+ [
205
+ Scalar.number(width, context: "page", field: :width, positive: true),
206
+ Scalar.number(height, context: "page", field: :height, positive: true),
207
+ Scalar.number(gap, context: "page", field: :gap, nonnegative: true)
208
+ ]
209
+ end
210
+
211
+ # Replaces one page field with its normalized SVG number.
212
+ # @return [Integer, Float] normalized number
213
+ # @api private
214
+ def number(attributes, field, index, **range)
215
+ key = attributes.key?(field) ? field : field.to_s
216
+ attributes[key] = Scalar.number(value(attributes, field, index), context: "page", field:, **range)
217
+ end
218
+
219
+ # Returns one required page field.
220
+ # @return [Object] page field value
221
+ # @raise [Sevgi::ArgumentError] when the field is absent
222
+ # @api private
223
+ def value(attributes, field, index)
224
+ attributes.fetch(field) do
225
+ attributes.fetch(field.to_s) { ArgumentError.("Page #{index + 1} requires #{field}") }
226
+ end
227
+ end
228
+ end
229
+ end
230
+
231
+ private_constant :Pagination
232
+
84
233
  # Builds an Inkscape namedview containing page elements.
85
- # @param pages [Array<Hash>] page attribute hashes
86
- # @param id [String] namedview id
234
+ # @example Build explicit pages with separate namedview and page attributes
235
+ # Sevgi::Graphics.SVG(:inkscape) do
236
+ # Pages(
237
+ # {x: 0, y: 0, width: 100, height: 50, label: "front"},
238
+ # namedview: {id: "views"},
239
+ # page: {class: "print"}
240
+ # )
241
+ # end
242
+ # @param pages [Array<Hash>] page attribute hashes; numeric page fields are normalized to SVG numbers
243
+ # @param namedview [Hash] namedview attributes; String and Symbol names are normalized and must not collide
244
+ # @param page [Hash] page defaults; normalized page attributes override these defaults by name
87
245
  # @yield [page] customizes each generated page element
88
246
  # @yieldparam page [Sevgi::Graphics::Element] generated page element
89
247
  # @yieldreturn [Object] ignored customization result
90
248
  # @return [Sevgi::Graphics::Element] namedview element
91
- def Pages(*pages, id: "namedview", **, &block)
92
- Element(:"sodipodi:namedview", id:, **) do
93
- pages.each_with_index do |page, i|
94
- id = page[:id] || "page-#{i + 1}"
95
- x, y, width, height = page.values_at(*%i[x y width height])
96
- element = Element(:"inkscape:page", id:, x:, y:, width:, height:)
97
- yield(element) if block
98
- end
99
- end
100
- end
249
+ # @raise [Sevgi::ArgumentError] when an attribute channel, page, coordinate, or dimension is invalid
250
+ def Pages(*pages, namedview: {}, page: {}, &block) = Pagination.call(self, pages, namedview:, page:, &block)
101
251
 
102
252
  # Builds a tabular set of Inkscape pages.
253
+ # @example Build a page grid using the same attribute channels as {#Pages}
254
+ # Sevgi::Graphics.SVG(:inkscape) do
255
+ # PagesTabular(
256
+ # rows: 2, cols: 3, width: 100, height: 50, gap: 5,
257
+ # namedview: {id: "views"}, page: {class: "print"}
258
+ # )
259
+ # end
103
260
  # @param rows [Integer] number of rows
104
261
  # @param cols [Integer] number of columns
105
- # @param width [Numeric] page width
106
- # @param height [Numeric] page height
107
- # @param gap [Numeric] gap between pages
108
- # @param id [String] namedview id
109
- # @return [Array<Array>] matrix entries as x, y, and label tuples
110
- def PagesTabular(rows:, cols:, width:, height:, gap:, id: "namedview", **)
111
- [].tap do |matrix|
112
- Element(:"sodipodi:namedview", id:) do
113
- rows.times do |row|
114
- cols.times do |col|
115
- matrix << (x, y, label = col * (width + gap), row * (height + gap), "#{row + 1}x#{col + 1}")
116
- Element(:"inkscape:page", id: "pageview-#{label}", x:, y:, width:, height:, **)
117
- end
118
- end
119
- end
120
- end
262
+ # @param width [Numeric] finite positive page width, normalized to an SVG number
263
+ # @param height [Numeric] finite positive page height, normalized to an SVG number
264
+ # @param gap [Numeric] finite non-negative gap, normalized to an SVG number
265
+ # @param namedview [Hash] namedview attributes; String and Symbol names are normalized and must not collide
266
+ # @param page [Hash] page defaults; normalized page attributes override these defaults by name
267
+ # @yield [page] customizes each generated page element
268
+ # @yieldparam page [Sevgi::Graphics::Element] generated page element
269
+ # @yieldreturn [Object] ignored customization result
270
+ # @return [Sevgi::Graphics::Element] namedview element
271
+ # @raise [Sevgi::ArgumentError] when counts, dimensions, gap, or an attribute channel is invalid
272
+ # @see #Pages
273
+ def PagesTabular(rows:, cols:, width:, height:, gap:, namedview: {}, page: {}, &block)
274
+ pages = Pagination.tabular(rows:, cols:, width:, height:, gap:)
275
+ Pages(*pages, namedview:, page:, &block)
121
276
  end
122
277
 
123
- # Internal symbol which does not show up Symbols Menu
124
278
  # @overload symbol!(**attributes)
125
279
  # Builds an Inkscape symbol group hidden from the symbols menu.
126
280
  # @param attributes [Hash] symbol attributes
@@ -4,21 +4,47 @@ 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
10
18
  # @yield evaluates additional RDF work metadata
11
19
  # @yieldreturn [Object] ignored block result
12
20
  # @return [Sevgi::Graphics::Element] metadata element
13
- 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
14
40
 
15
- # Use SPDX license codes in underscored form: https://spdx.org/licenses/
16
- #
17
41
  # Adds Creative Commons BY-SA license metadata.
18
42
  # @param kwargs [Hash] RDF work options
19
43
  # @yield evaluates additional RDF work metadata
20
44
  # @yieldreturn [Object] ignored block result
21
45
  # @return [Sevgi::Graphics::Element] metadata element
46
+ # @raise [Sevgi::ArgumentError] when an option is unknown
47
+ # @see #RDFWork
22
48
  def License_CC_BY_SA(**kwargs, &block)
23
49
  License(**kwargs, license: "https://creativecommons.org/licenses/by-sa/4.0/", &block)
24
50
  end
@@ -28,6 +54,8 @@ module Sevgi
28
54
  # @yield evaluates additional RDF work metadata
29
55
  # @yieldreturn [Object] ignored block result
30
56
  # @return [Sevgi::Graphics::Element] metadata element
57
+ # @raise [Sevgi::ArgumentError] when an option is unknown
58
+ # @see #RDFWork
31
59
  def License_CC_BY_NC(**kwargs, &block)
32
60
  License(**kwargs, license: "https://creativecommons.org/licenses/by-nc/4.0/", &block)
33
61
  end
@@ -37,6 +65,8 @@ module Sevgi
37
65
  # @yield evaluates additional RDF work metadata
38
66
  # @yieldreturn [Object] ignored block result
39
67
  # @return [Sevgi::Graphics::Element] metadata element
68
+ # @raise [Sevgi::ArgumentError] when an option is unknown
69
+ # @see #RDFWork
40
70
  def License_CC_BY_NC_SA(**kwargs, &block)
41
71
  License(**kwargs, license: "https://creativecommons.org/licenses/by-nc-sa/4.0/", &block)
42
72
  end
@@ -46,6 +76,8 @@ module Sevgi
46
76
  # @yield evaluates additional RDF work metadata
47
77
  # @yieldreturn [Object] ignored block result
48
78
  # @return [Sevgi::Graphics::Element] metadata element
79
+ # @raise [Sevgi::ArgumentError] when an option is unknown
80
+ # @see #RDFWork
49
81
  def License_CC_BY_ND(**kwargs, &block)
50
82
  License(**kwargs, license: "https://creativecommons.org/licenses/by-nd/4.0/", &block)
51
83
  end
@@ -55,6 +87,8 @@ module Sevgi
55
87
  # @yield evaluates additional RDF work metadata
56
88
  # @yieldreturn [Object] ignored block result
57
89
  # @return [Sevgi::Graphics::Element] metadata element
90
+ # @raise [Sevgi::ArgumentError] when an option is unknown
91
+ # @see #RDFWork
58
92
  def License_CC_BY_NC_ND(**kwargs, &block)
59
93
  License(**kwargs, license: "https://creativecommons.org/licenses/by-nc-nd/4.0/", &block)
60
94
  end
@@ -64,6 +98,8 @@ module Sevgi
64
98
  # @yield evaluates additional RDF work metadata
65
99
  # @yieldreturn [Object] ignored block result
66
100
  # @return [Sevgi::Graphics::Element] metadata element
101
+ # @raise [Sevgi::ArgumentError] when an option is unknown
102
+ # @see #RDFWork
67
103
  def License_CC0(**kwargs, &block)
68
104
  License(**kwargs, license: "https://creativecommons.org/publicdomain/zero/1.0/", &block)
69
105
  end
@@ -73,15 +109,19 @@ module Sevgi
73
109
  # @yield evaluates additional RDF work metadata
74
110
  # @yieldreturn [Object] ignored block result
75
111
  # @return [Sevgi::Graphics::Element] metadata element
112
+ # @raise [Sevgi::ArgumentError] when an option is unknown
113
+ # @see #RDFWork
76
114
  def License_LAL(**kwargs, &block) = License(**kwargs, license: "https://artlibre.org/licence/lal/en/", &block)
77
115
 
78
116
  # Builds an RDF root element.
79
- # @param _kwargs [Hash] currently unused options
117
+ # @param kwargs [Hash] options; RDF currently accepts none
80
118
  # @yield evaluates the RDF drawing DSL
81
119
  # @yieldreturn [Object] ignored block result
82
120
  # @return [Sevgi::Graphics::Element] RDF element
83
121
  # @raise [Sevgi::ArgumentError] when no block is given
84
- 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?
85
125
  ArgumentError.("Block required") unless block
86
126
 
87
127
  Element(
@@ -94,7 +134,6 @@ module Sevgi
94
134
  end
95
135
  end
96
136
 
97
- # rubocop:disable Metrics/MethodLength
98
137
  # Builds a Creative Commons RDF Work element.
99
138
  # @param kwargs [Hash] RDF work options
100
139
  # @option kwargs [String] :title work title
@@ -104,10 +143,24 @@ module Sevgi
104
143
  # @option kwargs [String] :date date
105
144
  # @option kwargs [String] :language language
106
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 |
107
156
  # @yield evaluates additional RDF work metadata
108
157
  # @yieldreturn [Object] ignored block result
109
158
  # @return [Sevgi::Graphics::Element] RDF element
159
+ # @raise [Sevgi::ArgumentError] when an option is unknown
110
160
  def RDFWork(**kwargs, &block)
161
+ unknown = kwargs.keys - WORK_OPTIONS
162
+ ArgumentError.("Unknown RDF work options: #{unknown.join(", ")}") unless unknown.empty?
163
+
111
164
  RDF do
112
165
  Element(:"cc:Work", "rdf:about": "") do
113
166
  Element(:"dc:format", "image/svg+xml")
@@ -124,7 +177,6 @@ module Sevgi
124
177
  end
125
178
  end
126
179
  end
127
- # rubocop:enable Metrics/MethodLength
128
180
  end
129
181
  end
130
182
  end
@@ -10,6 +10,7 @@ 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
16
  # Attribute rendering strategies.
@@ -23,7 +24,7 @@ module Sevgi
23
24
  # @param depth [Integer] element depth
24
25
  # @return [void]
25
26
  def attributes(element, depth)
26
- attributes_block(element, depth, element.attributes.to_xml_lines)
27
+ attributes_block(element, depth, element.attributes.send(:xml_lines))
27
28
  end
28
29
  end
29
30
 
@@ -35,7 +36,7 @@ module Sevgi
35
36
  # @param depth [Integer] element depth
36
37
  # @return [void]
37
38
  def attributes(element, depth)
38
- if attributes_as_block?(lines = element.attributes.to_xml_lines, depth)
39
+ if attributes_as_block?(lines = element.attributes.send(:xml_lines), depth)
39
40
  attributes_block(element, depth, lines)
40
41
  else
41
42
  attributes_inline(element, depth, lines)
@@ -67,7 +68,7 @@ module Sevgi
67
68
  # @param depth [Integer] element depth
68
69
  # @return [void]
69
70
  def attributes(element, depth)
70
- attributes_inline(element, depth, element.attributes.to_xml_lines)
71
+ attributes_inline(element, depth, element.attributes.send(:xml_lines))
71
72
  end
72
73
  end
73
74
  end
@@ -78,7 +79,13 @@ module Sevgi
78
79
  ELEMENTS_WITH_BLOCK_CONTENT = %i[style].freeze
79
80
  SEPARATOR = "\n"
80
81
 
81
- private_constant :ELEMENTS_WITH_INLINE_CONTENT, :ELEMENTS_WITH_BLOCK_CONTENT, :SEPARATOR, :SVG_NAMESPACE
82
+ private_constant(
83
+ :ELEMENTS_WITH_INLINE_CONTENT,
84
+ :ELEMENTS_WITH_BLOCK_CONTENT,
85
+ :SEPARATOR,
86
+ :STYLES,
87
+ :SVG_NAMESPACE
88
+ )
82
89
 
83
90
  # @return [Sevgi::Graphics::Element] root element
84
91
  attr_reader :root
@@ -165,7 +172,7 @@ module Sevgi
165
172
  # @raise [Sevgi::ArgumentError] when an option is unknown, malformed, missing, or unsupported
166
173
  def initialize(root, **)
167
174
  @root = root
168
- @options = DEFAULTS.merge(**)
175
+ @options = self.class.send(:validate, **)
169
176
  @output = []
170
177
  @inlines = Inlines.new
171
178
 
@@ -187,6 +194,29 @@ module Sevgi
187
194
  # @raise [Sevgi::ArgumentError] when options, names, attributes, or content are invalid XML
188
195
  def self.fragment(root, **options) = new(root, **options).call
189
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
+
190
220
  # Appends rendered lines to the output buffer.
191
221
  # @param depth [Integer, nil] indentation depth
192
222
  # @param lines [Array<String>] rendered lines
@@ -236,9 +266,6 @@ module Sevgi
236
266
  end
237
267
 
238
268
  def build
239
- validate_options!
240
- ArgumentError.("Missing style") unless options[:style]
241
-
242
269
  case options[:style]
243
270
  when :hybrid
244
271
  extend(Attributes::Hybrid)
@@ -246,8 +273,6 @@ module Sevgi
246
273
  extend(Attributes::Inline)
247
274
  when :block
248
275
  extend(Attributes::Block)
249
- else
250
- ArgumentError.("Unrecognized style: #{options[:style]}")
251
276
  end
252
277
 
253
278
  unclosed
@@ -330,21 +355,6 @@ module Sevgi
330
355
 
331
356
  def unclosed = @closed = false
332
357
 
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
358
  end
349
359
 
350
360
  private_constant :Renderer
@@ -355,7 +365,13 @@ module Sevgi
355
365
  # descendants in the same text line. Whitespace inside content objects is preserved as given, and encoded
356
366
  # content is XML-escaped unless a verbatim content object is used. SVG `style` elements use block content;
357
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)
358
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`
359
375
  # @return [String] SVG source
360
376
  # @raise [Sevgi::ArgumentError] when options, preambles, names, attributes, or content are invalid XML
361
377
  def Render(**) = Renderer.(self, **)
@@ -363,14 +379,22 @@ module Sevgi
363
379
  # Renders only this element's children.
364
380
  # Child render output omits document preambles and preserves each child's text whitespace and inline
365
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")
366
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`
367
390
  # @return [String] rendered child fragments
368
- # @raise [Sevgi::ArgumentError] when separator or rendered child data is not valid XML text
369
- def RenderChildren(separator = "\n\n")
391
+ # @raise [Sevgi::ArgumentError] when separator, options, or rendered child data is invalid
392
+ def RenderChildren(separator = "\n\n", **options)
370
393
  ArgumentError.("SVG fragment separator must be a String") unless separator.is_a?(::String)
371
394
 
372
395
  separator = XML.text(separator, context: "SVG fragment separator")
373
- children.map { Renderer.fragment(it) }.join(separator)
396
+ Renderer.send(:validate, **options)
397
+ children.map { Renderer.fragment(it, **options) }.join(separator)
374
398
  end
375
399
  end
376
400
  end