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
@@ -5,15 +5,17 @@ module Sevgi
5
5
  module Mixtures
6
6
  # DSL helpers for collecting and hiding SVG ids.
7
7
  module Identify
8
- # Index of element ids under a subtree.
8
+ # Immutable snapshot of every rendered element id under a subtree. Keys are the serialized id values, including
9
+ # `"false"` and the empty String. Keys and containers are owned by the index; values retain references to the
10
+ # elements present when the snapshot is built. Later tree changes require a new index.
9
11
  class Identifiers
10
12
  # @return [Sevgi::Graphics::Element] indexed root element
11
13
  attr_reader :element
12
14
 
13
- # @return [Hash<String, Sevgi::Graphics::Element>] id namespace
15
+ # @return [Hash<String, Sevgi::Graphics::Element>] frozen namespace keyed by serialized rendered ids
14
16
  attr_reader :namespace
15
17
 
16
- # @return [Hash<String, Array<Sevgi::Graphics::Element>>] duplicate id groups
18
+ # @return [Hash<String, Array<Sevgi::Graphics::Element>>] frozen duplicate groups keyed by serialized ids
17
19
  attr_reader :collision
18
20
 
19
21
  # Builds an id index for an element subtree.
@@ -25,6 +27,10 @@ module Sevgi
25
27
  @collision = {}
26
28
 
27
29
  build
30
+ @namespace.freeze
31
+ @collision.each_value(&:freeze)
32
+ @collision.freeze
33
+ freeze
28
34
  end
29
35
 
30
36
  # Reports whether duplicate ids were found.
@@ -33,21 +39,20 @@ module Sevgi
33
39
  !@collision.empty?
34
40
  end
35
41
 
36
- # @overload [](id)
37
- # Returns the element registered for an id.
38
- # @param id [String] SVG id
39
- # @return [Sevgi::Graphics::Element, nil]
40
- def [](*)
41
- @namespace[*]
42
- end
42
+ # Returns the first element registered for an id in the snapshot.
43
+ # Duplicate entries are available through {#collision}.
44
+ # @param id [String] serialized rendered SVG id
45
+ # @return [Sevgi::Graphics::Element, nil]
46
+ def [](id) = @namespace[id]
43
47
 
44
48
  private
45
49
 
46
50
  def build
47
51
  element.Traverse() do |element|
48
- next unless (value = element[:id])
52
+ next unless element.attributes.has?(:id)
49
53
 
50
- id = Attribute.xml_text(value)
54
+ value = element[:id]
55
+ id = Attribute.xml_text(value).dup.freeze
51
56
 
52
57
  if @namespace.key?(id)
53
58
  (@collision[id] ||= [@namespace[id]]) << element
@@ -58,18 +63,26 @@ module Sevgi
58
63
  end
59
64
  end
60
65
 
61
- # Moves visible id attributes to Sevgi-internal id storage.
66
+ # Moves visible id attributes to non-rendering `-id` metadata throughout the subtree.
67
+ # A pre-existing `-id` takes precedence over the visible id.
62
68
  # @return [Sevgi::Graphics::Element] self
69
+ # @example Hide ids while retaining their source identity
70
+ # document = Sevgi::Graphics.SVG { rect id: "source-id" }
71
+ # document.Disidentify
72
+ # document.children.first[:"-id"] # => "source-id"
63
73
  def Disidentify
64
74
  Traverse do |element|
65
- next unless element[:id]
75
+ next unless element.attributes.has?(:id)
66
76
 
67
- element[:"#{ATTRIBUTE_INTERNAL_PREFIX}id"] = element.attributes.delete(:id)
77
+ id = element.attributes.delete(:id)
78
+ metadata = :"#{Attributes::META_PREFIX}id"
79
+ element[metadata] = id unless element.attributes.has?(metadata)
68
80
  end
69
81
  end
70
82
 
71
- # Builds an id index for this element subtree.
72
- # @return [Sevgi::Graphics::Mixtures::Identify::Identifiers]
83
+ # Builds an immutable id index snapshot for the current element subtree.
84
+ # Rebuild the index to observe later id or tree changes.
85
+ # @return [Sevgi::Graphics::Mixtures::Identify::Identifiers] new snapshot retaining indexed element references
73
86
  def Identifiers = Identifiers.new(self)
74
87
  end
75
88
  end
@@ -4,6 +4,33 @@ module Sevgi
4
4
  module Graphics
5
5
  module Mixtures
6
6
  # DSL helpers for including derendered SVG/XML fragments.
7
+ #
8
+ # @!method Include(file, id, omit: nil)
9
+ # Includes a derendered node matching an id.
10
+ # SVG/XML content is treated as data and is not evaluated as Ruby source.
11
+ # @example Import a fragment without editor ids and inline styles
12
+ # Sevgi::Graphics.SVG do
13
+ # Include "badge.svg", "mark", omit: %i[id style]
14
+ # end
15
+ # @param file [String] source SVG/XML file
16
+ # @param id [String, Symbol] source node id
17
+ # @param omit [String, Symbol, Array<String, Symbol>, nil] exact attribute name or names omitted from the selected
18
+ # subtree after id selection
19
+ # @return [Sevgi::Graphics::Element, nil] included element, or nil when it produces no graphics output
20
+ # @raise [Sevgi::ArgumentError] when the file is absent or XML content is malformed, rootless, or lacks the id
21
+ # @raise [SystemCallError] when the file cannot be read
22
+ # @raise [Sevgi::MissingComponentError] when sevgi/derender is unavailable
23
+ # @!method IncludeChildren(file, id, omit: nil)
24
+ # Includes the children of a derendered node matching an id.
25
+ # SVG/XML content is treated as data and is not evaluated as Ruby source.
26
+ # @param file [String] source SVG/XML file
27
+ # @param id [String, Symbol] source node id
28
+ # @param omit [String, Symbol, Array<String, Symbol>, nil] exact attribute name or names omitted from the selected
29
+ # subtree after id selection
30
+ # @return [Array<Sevgi::Graphics::Element>] immutable included-child snapshot
31
+ # @raise [Sevgi::ArgumentError] when the file is absent or XML content is malformed, rootless, or lacks the id
32
+ # @raise [SystemCallError] when the file cannot be read
33
+ # @raise [Sevgi::MissingComponentError] when sevgi/derender is unavailable
7
34
  module Include
8
35
  require "sevgi/derender"
9
36
 
@@ -12,38 +39,46 @@ module Sevgi
12
39
  # SVG/XML file content is treated as data and is not evaluated as Ruby source.
13
40
  # @param file [String] source SVG/XML file
14
41
  # @param id [String, Symbol] source node id
42
+ # @param omit [String, Symbol, Array<String, Symbol>, nil] exact attribute name or names omitted from the selected
43
+ # subtree after id selection
15
44
  # @return [Sevgi::Graphics::Element, nil] included element, or nil when the selected node produces no graphics
16
45
  # output
17
46
  # @raise [Sevgi::ArgumentError] when the file cannot be found, file content is malformed or rootless, or the id is
18
47
  # absent
48
+ # @raise [SystemCallError] when the file cannot be read
19
49
  # @raise [Sevgi::MissingComponentError] when sevgi/derender is unavailable
20
- def Include(file, id) = Derender.evaluate_file(file, self, id:)
50
+ def Include(file, id, omit: nil) = Derender.evaluate_file(file, self, id:, omit:)
21
51
 
22
52
  # Includes the children of a derendered node matching an id.
23
53
  #
24
54
  # SVG/XML file content is treated as data and is not evaluated as Ruby source.
25
55
  # @param file [String] source SVG/XML file
26
56
  # @param id [String, Symbol] source node id
27
- # @return [Array<Sevgi::Graphics::Element>] included children
57
+ # @param omit [String, Symbol, Array<String, Symbol>, nil] exact attribute name or names omitted from the selected
58
+ # subtree after id selection
59
+ # @return [Array<Sevgi::Graphics::Element>] immutable included-child snapshot
28
60
  # @raise [Sevgi::ArgumentError] when the file cannot be found, file content is malformed or rootless, or the id is
29
61
  # absent
62
+ # @raise [SystemCallError] when the file cannot be read
30
63
  # @raise [Sevgi::MissingComponentError] when sevgi/derender is unavailable
31
- def IncludeChildren(file, id) = Derender.evaluate_file_children(file, self, id:)
64
+ def IncludeChildren(file, id, omit: nil) = Derender.evaluate_children_file(file, self, id:, omit:)
32
65
  rescue ::LoadError => e
33
66
  raise unless e.path == "sevgi/derender"
34
67
 
35
- # @overload IncludeChildren(file, id)
68
+ # @overload IncludeChildren(file, id, omit: nil)
36
69
  # Raises because sevgi/derender is unavailable.
37
70
  # @param file [String] source SVG/XML file
38
71
  # @param id [String, Symbol] source node id
72
+ # @param omit [String, Symbol, Array<String, Symbol>, nil] ignored because the component is unavailable
39
73
  # @return [void]
40
74
  # @raise [Sevgi::MissingComponentError] always
41
75
  def IncludeChildren(...) = MissingComponentError.("sevgi/derender")
42
76
 
43
- # @overload Include(file, id)
77
+ # @overload Include(file, id, omit: nil)
44
78
  # Raises because sevgi/derender is unavailable.
45
79
  # @param file [String] source SVG/XML file
46
80
  # @param id [String, Symbol] source node id
81
+ # @param omit [String, Symbol, Array<String, Symbol>, nil] ignored because the component is unavailable
47
82
  # @return [void]
48
83
  # @raise [Sevgi::MissingComponentError] always
49
84
  def Include(...) = MissingComponentError.("sevgi/derender")
@@ -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,41 +39,67 @@ 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
53
+ # @yield forwards customization to the callable module
54
+ # @yieldreturn [Object] callable customization result
29
55
  # @return [Sevgi::Graphics::Element] group element
30
- # @raise [Sevgi::ArgumentError] when mod is not a plain module
31
- def Group(mod, *args, **kwargs, &block)
32
- kwargs = kwargs.merge(id: F.demodulize(mod).to_sym) unless kwargs.key?(:id)
33
- 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) }
34
62
  end
35
63
 
36
64
  # Renders a callable module inside an Inkscape layer.
37
- # @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}
38
67
  # @param args [Array<Object>] callable arguments
39
- # @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
70
+ # @yield forwards customization to the callable module
71
+ # @yieldreturn [Object] callable customization result
40
72
  # @return [Sevgi::Graphics::Element] layer element
41
- # @raise [Sevgi::ArgumentError] when mod is not a plain module
42
- def Layer(mod, *args, **kwargs, &block)
43
- kwargs = kwargs.merge(id: F.demodulize(mod).to_sym) unless kwargs.key?(:id)
44
- 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) }
45
79
  end
46
80
 
47
81
  # Renders a callable module inside an insensitive Inkscape layer.
48
- # @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}
49
84
  # @param args [Array<Object>] callable arguments
50
- # @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
87
+ # @yield forwards customization to the callable module
88
+ # @yieldreturn [Object] callable customization result
51
89
  # @return [Sevgi::Graphics::Element] layer element
52
- # @raise [Sevgi::ArgumentError] when mod is not a plain module
53
- def Layer!(mod, *args, **kwargs, &block)
54
- kwargs = kwargs.merge(id: F.demodulize(mod).to_sym) unless kwargs.key?(:id)
55
- 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) }
56
96
  end
57
97
 
58
98
  # @overload layer(**attributes)
59
99
  # Builds an Inkscape layer group.
60
100
  # @param attributes [Hash] layer attributes
101
+ # @yield evaluates the drawing DSL in the layer
102
+ # @yieldreturn [Object] ignored block result
61
103
  # @return [Sevgi::Graphics::Element] layer group
62
104
  def layer(**, &block)
63
105
  g("inkscape:groupmode": "layer", **, &block)
@@ -66,51 +108,178 @@ module Sevgi
66
108
  # @overload layer!(**attributes)
67
109
  # Builds an insensitive Inkscape layer group.
68
110
  # @param attributes [Hash] layer attributes
111
+ # @yield evaluates the drawing DSL in the layer
112
+ # @yieldreturn [Object] ignored block result
69
113
  # @return [Sevgi::Graphics::Element] layer group
70
114
  def layer!(**, &block)
71
115
  layer("sodipodi:insensitive": "true", **, &block)
72
116
  end
73
117
 
74
- # Builds an Inkscape namedview containing page elements.
75
- # @param pages [Array<Hash>] page attribute hashes
76
- # @param id [String] namedview id
77
- # @return [Sevgi::Graphics::Element] namedview element
78
- def Pages(*pages, id: "namedview", **, &block)
79
- Element(:"sodipodi:namedview", id:, **) do
80
- pages.each_with_index do |page, i|
81
- id = page[:id] || "page-#{i + 1}"
82
- x, y, width, height = page.values_at(*%i[x y width height])
83
- element = Element(:"inkscape:page", id:, x:, y:, width:, height:)
84
- yield(element) if block
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
85
227
  end
86
228
  end
87
229
  end
88
230
 
231
+ private_constant :Pagination
232
+
233
+ # Builds an Inkscape namedview containing page elements.
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
245
+ # @yield [page] customizes each generated page element
246
+ # @yieldparam page [Sevgi::Graphics::Element] generated page element
247
+ # @yieldreturn [Object] ignored customization result
248
+ # @return [Sevgi::Graphics::Element] namedview element
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)
251
+
89
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
90
260
  # @param rows [Integer] number of rows
91
261
  # @param cols [Integer] number of columns
92
- # @param width [Numeric] page width
93
- # @param height [Numeric] page height
94
- # @param gap [Numeric] gap between pages
95
- # @param id [String] namedview id
96
- # @return [Array<Array>] matrix entries as x, y, and label tuples
97
- def PagesTabular(rows:, cols:, width:, height:, gap:, id: "namedview", **)
98
- [].tap do |matrix|
99
- Element(:"sodipodi:namedview", id:) do
100
- rows.times do |row|
101
- cols.times do |col|
102
- matrix << (x, y, label = col * (width + gap), row * (height + gap), "#{row + 1}x#{col + 1}")
103
- Element(:"inkscape:page", id: "pageview-#{label}", x:, y:, width:, height:, **)
104
- end
105
- end
106
- end
107
- 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)
108
276
  end
109
277
 
110
- # Internal symbol which does not show up Symbols Menu
111
278
  # @overload symbol!(**attributes)
112
279
  # Builds an Inkscape symbol group hidden from the symbols menu.
113
280
  # @param attributes [Hash] symbol attributes
281
+ # @yield evaluates the drawing DSL in the symbol group
282
+ # @yieldreturn [Object] ignored block result
114
283
  # @return [Sevgi::Graphics::Element] symbol group
115
284
  def symbol!(**, &block)
116
285
  if Is?(:defs)