sevgi-graphics 0.98.2 → 1.1.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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +115 -1
  3. data/README.md +55 -1
  4. data/lib/sevgi/graphics/attribute.rb +15 -10
  5. data/lib/sevgi/graphics/auxiliary/canvas.rb +4 -4
  6. data/lib/sevgi/graphics/auxiliary/content.rb +5 -16
  7. data/lib/sevgi/graphics/auxiliary/paper.rb +52 -1
  8. data/lib/sevgi/graphics/{xml.rb → auxiliary/xml.rb} +4 -9
  9. data/lib/sevgi/graphics/auxiliary.rb +1 -2
  10. data/lib/sevgi/graphics/document/base.rb +3 -2
  11. data/lib/sevgi/graphics/document.rb +15 -27
  12. data/lib/sevgi/graphics/element.rb +23 -8
  13. data/lib/sevgi/graphics/mixtures/call.rb +9 -24
  14. data/lib/sevgi/graphics/mixtures/core.rb +19 -23
  15. data/lib/sevgi/graphics/mixtures/duplicate.rb +4 -16
  16. data/lib/sevgi/graphics/mixtures/export.rb +8 -2
  17. data/lib/sevgi/graphics/mixtures/hatch.rb +5 -5
  18. data/lib/sevgi/graphics/mixtures/identify.rb +1 -1
  19. data/lib/sevgi/graphics/mixtures/inkscape.rb +23 -10
  20. data/lib/sevgi/graphics/mixtures/rdf.rb +1 -1
  21. data/lib/sevgi/graphics/mixtures/render.rb +11 -95
  22. data/lib/sevgi/graphics/mixtures/save.rb +4 -4
  23. data/lib/sevgi/graphics/mixtures/style/declaration.rb +117 -0
  24. data/lib/sevgi/graphics/mixtures/style.rb +36 -0
  25. data/lib/sevgi/graphics/mixtures/symbols.rb +2 -2
  26. data/lib/sevgi/graphics/mixtures/tile.rb +4 -4
  27. data/lib/sevgi/graphics/mixtures/transform.rb +22 -3
  28. data/lib/sevgi/graphics/mixtures/underscore.rb +2 -2
  29. data/lib/sevgi/graphics/mixtures/wrappers.rb +49 -0
  30. data/lib/sevgi/graphics/mixtures.rb +1 -0
  31. data/lib/sevgi/graphics/version.rb +1 -1
  32. data/lib/sevgi/graphics.rb +23 -19
  33. metadata +21 -7
  34. data/lib/sevgi/graphics/auxiliary/path.rb +0 -44
  35. data/lib/sevgi/graphics/auxiliary/sizes.rb +0 -62
@@ -6,16 +6,18 @@ module Sevgi
6
6
  #
7
7
  # @!method self.valid?(name)
8
8
  # Reports whether a candidate can dispatch as an SVG element name.
9
- # With Standard loaded, the name must be known; standalone Graphics accepts any valid XML name.
9
+ # With Standard loaded, the name must be known. Standalone Graphics accepts any valid XML name.
10
10
  # @param name [Object] candidate element name
11
11
  # @return [Boolean]
12
12
  #
13
13
  # Dynamic SVG element methods accept text, content objects, and any number of attribute Hashes in one call. Hashes
14
- # are applied from left to right; later values replace earlier values unless their names use the `+` update suffix.
14
+ # are applied from left to right. Later values replace earlier values unless their names use the `+` update suffix.
15
+ # Ordinary `dup` and `clone` create independent subtrees, preserve IDs, and detach non-root copies from their parent.
16
+ # Document copies remain roots. Use `Duplicate` for DSL-specific ID remapping and attachment.
15
17
  class Element
16
18
  # Builds an element node.
17
19
  # @param name [Symbol, String] SVG element name
18
- # @param arguments [Array<Hash, String, Sevgi::Graphics::Content>] ordered content and attribute channels; every
20
+ # @param arguments [Array<Hash, String, Sevgi::Graphics::Content>] ordered content and attribute channels. Every
19
21
  # Hash is applied through {Sevgi::Graphics::Attributes} from left to right
20
22
  # @param parent [Sevgi::Graphics::Element] parent element
21
23
  # @yield evaluates the drawing DSL in the new element
@@ -178,9 +180,25 @@ module Sevgi
178
180
  instance_exec(&block) if block
179
181
  end
180
182
 
183
+ # Copies owned storage and reconnects child copies without attaching to the source tree.
184
+ # @param original [Sevgi::Graphics::Element] source node
185
+ # @return [void]
186
+ # @api private
187
+ def initialize_copy(original)
188
+ super
189
+ @parent = Element.root?(original) ? RootParent : DetachedParent
190
+ @attributes = original.attributes.dup
191
+ @contents = original.contents.map(&:dup)
192
+ @children = original.children.map do |child|
193
+ child.dup.tap { it.instance_variable_set(:@parent, self) }
194
+ end
195
+ end
196
+
197
+ private :initialize_copy
198
+
181
199
  # Dispatches SVG element DSL calls and caches valid element methods.
182
200
  # @param name [Symbol] missing method name
183
- # @param arguments [Array<Hash, String, Sevgi::Graphics::Content>] ordered content and attribute channels; later
201
+ # @param arguments [Array<Hash, String, Sevgi::Graphics::Content>] ordered content and attribute channels. Later
184
202
  # Hashes replace or update attributes assigned by earlier Hashes
185
203
  # @yield evaluates the drawing DSL in the dispatched child element
186
204
  # @yieldreturn [Object] ignored block result
@@ -232,7 +250,7 @@ module Sevgi
232
250
 
233
251
  # Parses element DSL arguments.
234
252
  # @param name [Symbol] SVG element name
235
- # @param args [Array<Object>] positional DSL arguments; Hashes are imported from left to right
253
+ # @param args [Array<Object>] positional DSL arguments. Hashes are imported from left to right
236
254
  # @return [Hash] parsed :attributes and :contents
237
255
  # @raise [Sevgi::ArgumentError] when an argument is not a Hash, String, or Content
238
256
  def parse(name, *args)
@@ -257,9 +275,6 @@ module Sevgi
257
275
 
258
276
  private_constant :Dispatch
259
277
 
260
- protected
261
-
262
- attr_writer :attributes, :children, :contents, :parent
263
278
  end
264
279
  end
265
280
  end
@@ -4,10 +4,10 @@ module Sevgi
4
4
  module Graphics
5
5
  # Callable drawing module support.
6
6
  # Extend a plain Ruby module with this API to make its public instance methods callable drawing steps. Name the
7
- # method `call` when the module has a single drawing step; use descriptive method names when it has multiple steps.
7
+ # method `call` when the module has a single drawing step. Use descriptive method names for multiple steps.
8
8
  # Base blocks add argument-independent shared SVG content once per invocation before the public drawing methods.
9
- # Invocation does not change the configured module, so it may be frozen after its drawing steps are defined. A
10
- # duplicate or clone owns an independent configuration snapshot; freezing a callable module prevents later base
9
+ # Invocation does not change the configured module, so it can be frozen after its drawing steps are defined. A
10
+ # duplicate or clone owns an independent configuration snapshot. Freezing a callable module prevents later base
11
11
  # registration while leaving invocation available.
12
12
  # @example Define and call a drawing module
13
13
  # Widget = Module.new do
@@ -73,8 +73,9 @@ module Sevgi
73
73
 
74
74
  # Registers argument-independent shared drawing steps. Every invocation runs inherited base blocks parent-first,
75
75
  # then locally registered base blocks in registration order, before the module's public drawing methods. The block
76
- # runs once in the current element context and does not receive the invocation arguments.
77
- # @yield evaluates the drawing DSL in the current element context
76
+ # runs once on the callable receiver, which delegates drawing words to the current SVG element. It receives no
77
+ # invocation arguments. Callable methods receive those arguments and the caller's block unchanged.
78
+ # @yield evaluates shared drawing steps on the callable receiver
78
79
  # @yieldreturn [Object] ignored block result
79
80
  # @return [nil]
80
81
  # @raise [Sevgi::ArgumentError] when no block is given
@@ -133,7 +134,7 @@ module Sevgi
133
134
  invoke(context, receiver, methods, ...)
134
135
  end
135
136
 
136
- # Returns the methods that should be executed for a callable module.
137
+ # Returns the executable drawing methods for a callable module.
137
138
  # @param mod [Module] module extended with {Sevgi::Graphics::Module}
138
139
  # @return [Array<UnboundMethod>]
139
140
  # @raise [Sevgi::ArgumentError] when mod is not a callable drawing module
@@ -173,26 +174,12 @@ module Sevgi
173
174
 
174
175
  private
175
176
 
176
- # Gives a duplicated callable module independent configuration containers.
177
+ # Gives a copied callable module independent configuration containers.
177
178
  # @param original [Module] source callable module
178
179
  # @return [void]
179
180
  # @api private
180
- def initialize_dup(original)
181
+ def initialize_copy(original)
181
182
  super
182
- copy_configuration(original)
183
- end
184
-
185
- # Gives a cloned callable module independent configuration containers.
186
- # @param original [Module] source callable module
187
- # @param freeze [Boolean] whether Ruby preserves the source frozen state
188
- # @return [void]
189
- # @api private
190
- def initialize_clone(original, freeze: true)
191
- super
192
- copy_configuration(original)
193
- end
194
-
195
- def copy_configuration(original)
196
183
  @sevgi_bases = original.instance_variable_get(:@sevgi_bases).dup
197
184
  @sevgi_callables = original.instance_variable_get(:@sevgi_callables).dup
198
185
  @sevgi_configuration_owner = object_id
@@ -216,8 +203,6 @@ module Sevgi
216
203
  own_configuration
217
204
  @sevgi_callables << method
218
205
  end
219
-
220
- private :copy_configuration, :own_configuration
221
206
  end
222
207
 
223
208
  # Recursive callable drawing module support. Extend a module with this API when it and the modules defined beneath
@@ -93,24 +93,19 @@ module Sevgi
93
93
  tap { Adoption.batch(elements, self, front: false) }
94
94
  end
95
95
 
96
+ # Returns distinct CSS class names without changing this element.
97
+ # An absent class attribute returns an empty Array. Strings are split on whitespace and nested Arrays are flattened.
98
+ # @return [Array<String>] owned class-name snapshot in rendered order
99
+ # @see #Classify
100
+ def Classes = Attribute.classes(self[:class])
101
+
96
102
  # Adds CSS classes without duplicating existing values.
97
- # @param classes [Array<String, Symbol, Array>] class tokens; strings are split on whitespace
103
+ # @param classes [Array<String, Symbol, Array>] class tokens. Strings are split on whitespace
98
104
  # @return [Sevgi::Graphics::Element] self
105
+ # @raise [Sevgi::ArgumentError] when a class value is invalid, cyclic, or cannot be stringified as XML
106
+ # @see #Classes
99
107
  def Classify(*classes)
100
- tap do
101
- tokens = proc do |value|
102
- case value
103
- when nil
104
- []
105
- when ::Array
106
- value.flat_map { tokens.call(it) }
107
- else
108
- value.to_s.split
109
- end
110
- end
111
-
112
- self[:class] = [*tokens.call(self[:class]), *tokens.call(classes)].uniq
113
- end
108
+ tap { self[:class] = Attribute.classes(self[:class], classes) }
114
109
  end
115
110
 
116
111
  # Assigns default attributes only when they are absent.
@@ -128,7 +123,7 @@ module Sevgi
128
123
 
129
124
  # Builds a child element with an explicit tag name.
130
125
  # @param tag [Symbol, String] SVG tag name
131
- # @param contents [Array<Object>] text or content objects; non-content objects are stringified and XML-encoded
126
+ # @param contents [Array<Object>] text or content objects. Non-content objects are stringified and XML-encoded
132
127
  # @param attributes [Hash] SVG attributes
133
128
  # @yield evaluates the drawing DSL in the new child element
134
129
  # @yieldreturn [Object] ignored block result
@@ -176,7 +171,7 @@ module Sevgi
176
171
  end
177
172
 
178
173
  # Returns the topmost element in this tree.
179
- # A detached subtree returns its detached topmost element; use {#Root?} to distinguish a document root.
174
+ # A detached subtree returns its detached topmost element. Use {#Root?} to distinguish a document root.
180
175
  # @return [Sevgi::Graphics::Element] document root or detached topmost element
181
176
  def Root
182
177
  element = self
@@ -283,7 +278,7 @@ module Sevgi
283
278
  # Appends an element as a child.
284
279
  # @param element [Sevgi::Graphics::Element] element to append
285
280
  # @return [Sevgi::Graphics::Element] self
286
- # @raise [Sevgi::ArgumentError] when the element has a different concrete class or would create a tree cycle
281
+ # @raise [Sevgi::ArgumentError] when the element has a different concrete class or creates a tree cycle
287
282
  def <<(element)
288
283
  Append(element)
289
284
  end
@@ -296,7 +291,7 @@ module Sevgi
296
291
  # @param parent [Sevgi::Graphics::Element] target parent
297
292
  # @param front [Boolean] whether to prepend instead of append
298
293
  # @return [void]
299
- # @raise [Sevgi::ArgumentError] when an argument is incompatible, repeated, or would create a cycle
294
+ # @raise [Sevgi::ArgumentError] when an argument is incompatible, repeated, or creates a cycle
300
295
  def self.batch(elements, parent, front:)
301
296
  validate_batch(elements, parent)
302
297
 
@@ -307,11 +302,11 @@ module Sevgi
307
302
  end
308
303
  end
309
304
 
310
- # Rejects target parents that would create a cycle.
305
+ # Rejects target parents that create a cycle.
311
306
  # @param element [Sevgi::Graphics::Element] element being moved
312
307
  # @param parent [Sevgi::Graphics::Element, Object] target parent
313
308
  # @return [void]
314
- # @raise [Sevgi::ArgumentError] when the parent is incompatible or would create a cycle
309
+ # @raise [Sevgi::ArgumentError] when the parent is incompatible or creates a cycle
315
310
  def self.validate(element, parent)
316
311
  unless element.instance_of?(parent.class)
317
312
  ArgumentError.("Element type does not match the new parent type: #{element.class}")
@@ -347,8 +342,9 @@ module Sevgi
347
342
  # @param index [Integer] requested insertion index
348
343
  # @return [Integer] normalized insertion index
349
344
  def self.index_for(element, parent, index)
350
- same_parent = element.parent.equal?(parent) && parent.children.include?(element)
351
- index(index, parent.children.size - (same_parent ? 1 : 0))
345
+ children = Element.send(:tree_children, parent)
346
+ same_parent = element.parent.equal?(parent) && children.include?(element)
347
+ index(index, children.size - (same_parent ? 1 : 0))
352
348
  end
353
349
 
354
350
  def self.validate_batch(elements, parent)
@@ -10,8 +10,9 @@ module Sevgi
10
10
  # moved to non-rendering `-id` metadata before the optional block runs, allowing the block to derive replacement
11
11
  # ids without rendering duplicates. A pre-existing `-id` takes precedence over the visible id.
12
12
  # Translation and parent channels are validated before the subtree is copied or the customization block runs.
13
- # @param dx [Numeric, nil] finite x translation; nil omits the axis
14
- # @param dy [Numeric, nil] finite y translation; nil omits the axis
13
+ # The copied subtree has no parent during customization and is attached to the target parent afterward.
14
+ # @param dx [Numeric, nil] finite x translation. Nil omits the axis
15
+ # @param dy [Numeric, nil] finite y translation. Nil omits the axis
15
16
  # @param parent [Sevgi::Graphics::Element, nil] explicit parent, or the source parent when nil
16
17
  # @yield [element] optional customization hook for each copied element
17
18
  # @yieldparam element [Sevgi::Graphics::Element] copied element
@@ -27,7 +28,7 @@ module Sevgi
27
28
  # end
28
29
  def Duplicate(dx: nil, dy: nil, parent: nil, &block)
29
30
  dx, dy, target = Subtree.channels(self, dx, dy, parent)
30
- duplicated = Subtree.copy(self)
31
+ duplicated = dup
31
32
  Subtree.prepare(duplicated, &block)
32
33
  Subtree.translate(duplicated, dx, dy)
33
34
  Subtree.attach(duplicated, target)
@@ -80,19 +81,6 @@ module Sevgi
80
81
  [dx, dy, target]
81
82
  end
82
83
 
83
- # Builds an independent copy of an element subtree.
84
- # @param element [Sevgi::Graphics::Element] source subtree root
85
- # @param parent [Sevgi::Graphics::Element, Object] parent for the copied root
86
- # @return [Sevgi::Graphics::Element] copied subtree root
87
- def self.copy(element, parent = Element.send(:tree_parent, element))
88
- element.dup.tap do |duplicated|
89
- duplicated.send(:parent=, parent)
90
- duplicated.send(:attributes=, element.attributes.dup)
91
- duplicated.send(:contents=, element.contents.map(&:dup))
92
- duplicated.send(:children=, element.children.map { |child| copy(child, duplicated) })
93
- end
94
- end
95
-
96
84
  # Removes copied public ids and applies an optional customization hook.
97
85
  # @api private
98
86
  def self.prepare(element, &block)
@@ -4,11 +4,13 @@ module Sevgi
4
4
  module Graphics
5
5
  module Mixtures
6
6
  # DSL helpers for native SVG export formats.
7
+ # Export CSS is a late adjustment after document checks. Its supported XML endings and cascade limitations are
8
+ # documented in {Sevgi::Sundries::Export}. The source callback receives SVG after CSS insertion.
7
9
  module Export
8
10
  # Validates the closed export option channel before optional components load or rendering starts.
9
11
  # @api private
10
12
  module Options
11
- KEYS = %i[css default dpi height width].freeze
13
+ KEYS = %i[css default dpi fallback height style width].freeze
12
14
  private_constant :KEYS
13
15
 
14
16
  # Returns validated export options.
@@ -34,6 +36,8 @@ module Sevgi
34
36
  # @option kwargs [Numeric, nil] :height finite positive target height in CSS pixels
35
37
  # @option kwargs [Numeric] :dpi (96.0) finite positive CSS pixel density
36
38
  # @option kwargs [String, nil] :css CSS inserted before rendering
39
+ # @option kwargs [Hash, nil] :style (document Style) producer declaration
40
+ # @option kwargs [Boolean] :fallback (true) warn and use ordinary PDF after preparation failure
37
41
  # @yield [svg] transforms SVG source before rendering
38
42
  # @yieldparam svg [String] rendered SVG source
39
43
  # @yieldreturn [String] transformed SVG source
@@ -44,6 +48,7 @@ module Sevgi
44
48
  # @raise [Sevgi::MissingComponentError] when sevgi/sundries is unavailable
45
49
  # @raise [Sevgi::MissingComponentError] when native export gems are unavailable
46
50
  # @raise [Sevgi::Sundries::Export::ExportError] when native export fails
51
+ # @raise [Sevgi::Sundries::Mainz::Error] when required preparation cannot complete
47
52
  # @raise [SystemCallError] when the output directory or file cannot be created or written
48
53
  def PDF(path = nil, **kwargs, &block)
49
54
  kwargs = Options.(kwargs)
@@ -56,6 +61,7 @@ module Sevgi
56
61
  MissingComponentError.("sevgi/sundries")
57
62
  end
58
63
 
64
+ kwargs[:style] = self.Style unless kwargs.key?(:style) || self.Style.nil?
59
65
  Export(path, **kwargs, format: :pdf, &block)
60
66
  end
61
67
 
@@ -102,7 +108,7 @@ module Sevgi
102
108
  default = F.subext(extension, caller_locations(2..2).first.path)
103
109
  end
104
110
 
105
- path = Path.resolve(path, default:, context: "Export")
111
+ path = F.output_path(path, default:, context: "Export")
106
112
 
107
113
  Sundries::Export.(call, path, **kwargs, &block)
108
114
  end
@@ -6,10 +6,10 @@ module Sevgi
6
6
  # DSL helpers for drawing geometry values and geometry-derived hatch lines.
7
7
  #
8
8
  # `Draw` delegates to each geometry object's drawing protocol. `Hatch`
9
- # first sweeps interior spans through a closed lined element, then draws
10
- # them. Its `angle` describes line direction; `step` is perpendicular
9
+ # first sweeps interior spans through a closed geometry element, then draws
10
+ # them. Its `angle` describes line direction. `step` is perpendicular
11
11
  # spacing. The default initial line passes through `element.position`.
12
- # The built-in `:inkscape` document profile includes this mixture;
12
+ # The built-in `:inkscape` document profile includes this mixture.
13
13
  # `:minimal`, `:default`, and `:html` do not.
14
14
  #
15
15
  # Hatch materializes separate finite SVG path elements. When only a repeated visual fill matters, use an SVG
@@ -52,7 +52,7 @@ module Sevgi
52
52
  # Sevgi::Graphics.SVG(:inkscape) do
53
53
  # Hatch region, initial: [2, 8], angle: 0, step: 3
54
54
  # end
55
- # @param element [Sevgi::Geometry::Element::Lined] lined geometry element to sweep
55
+ # @param element [Sevgi::Geometry::Element] geometry element to sweep
56
56
  # @param angle [Numeric] hatch angle in degrees
57
57
  # @param step [Numeric] distance between hatch lines
58
58
  # @param initial [Sevgi::Geometry::Point, Array<Numeric>, nil] initial sweep point, or nil for element.position
@@ -72,7 +72,7 @@ module Sevgi
72
72
  MissingComponentError.("sevgi/geometry")
73
73
  end
74
74
 
75
- initial = element.position if initial.nil? && element.is_a?(Geometry::Element::Lined)
75
+ initial = element.position if initial.nil? && element.is_a?(Geometry::Element)
76
76
  Draw(Geometry::Operation.sweep!(element, initial:, angle:, step:), **kwargs)
77
77
  end
78
78
  end
@@ -6,7 +6,7 @@ module Sevgi
6
6
  # DSL helpers for collecting and hiding SVG ids.
7
7
  module Identify
8
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
9
+ # `"false"` and the empty String. Keys and containers are owned by the index. Values retain references to the
10
10
  # elements present when the snapshot is built. Later tree changes require a new index.
11
11
  class Identifiers
12
12
  # @return [Sevgi::Graphics::Element] indexed root element
@@ -48,7 +48,7 @@ module Sevgi
48
48
  # Sevgi::Graphics.SVG(:inkscape) { Group drawing, attributes: {id: "drawing"} }
49
49
  # @param mod [Module] module extended with {Sevgi::Graphics::Module}
50
50
  # @param args [Array<Object>] callable arguments
51
- # @param attributes [Hash] group attributes; String and Symbol names are normalized and must not collide
51
+ # @param attributes [Hash] group attributes. String and Symbol names are normalized and must not collide
52
52
  # @param kwargs [Hash] callable keyword arguments
53
53
  # @yield forwards customization to the callable module
54
54
  # @yieldreturn [Object] callable customization result
@@ -65,7 +65,7 @@ module Sevgi
65
65
  # Named modules default the layer id to their final constant name. Anonymous modules omit the id unless supplied.
66
66
  # @param mod [Module] module extended with {Sevgi::Graphics::Module}
67
67
  # @param args [Array<Object>] callable arguments
68
- # @param attributes [Hash] layer attributes; String and Symbol names are normalized and must not collide
68
+ # @param attributes [Hash] layer attributes. String and Symbol names are normalized and must not collide
69
69
  # @param kwargs [Hash] callable keyword arguments
70
70
  # @yield forwards customization to the callable module
71
71
  # @yieldreturn [Object] callable customization result
@@ -82,7 +82,7 @@ module Sevgi
82
82
  # Named modules default the layer id to their final constant name. Anonymous modules omit the id unless supplied.
83
83
  # @param mod [Module] module extended with {Sevgi::Graphics::Module}
84
84
  # @param args [Array<Object>] callable arguments
85
- # @param attributes [Hash] layer attributes; String and Symbol names are normalized and must not collide
85
+ # @param attributes [Hash] layer attributes. String and Symbol names are normalized and must not collide
86
86
  # @param kwargs [Hash] callable keyword arguments
87
87
  # @yield forwards customization to the callable module
88
88
  # @yieldreturn [Object] callable customization result
@@ -163,7 +163,7 @@ module Sevgi
163
163
  def channels(namedview, page)
164
164
  ArgumentError.("Namedview attributes must be a Hash") unless namedview.is_a?(::Hash)
165
165
  ArgumentError.("Page attributes must be a Hash") unless page.is_a?(::Hash)
166
- [Attribute.defaults(namedview, id: "namedview"), Attribute.normalize(page)]
166
+ [Attribute.defaults(namedview, id: "namedview"), page_attributes(page)]
167
167
  end
168
168
 
169
169
  # Normalizes one explicit or generated page id.
@@ -181,7 +181,7 @@ module Sevgi
181
181
  # @api private
182
182
  def normalize(attributes, defaults, index)
183
183
  ArgumentError.("Page #{index + 1} must be a Hash") unless attributes.is_a?(::Hash)
184
- attributes = defaults.merge(Attribute.normalize(attributes))
184
+ attributes = defaults.merge(page_attributes(attributes))
185
185
  %i[x y].each { number(attributes, it, index) }
186
186
  %i[width height].each do |field|
187
187
  number(attributes, field, index, positive: true)
@@ -190,6 +190,19 @@ module Sevgi
190
190
  identify(attributes, index)
191
191
  end
192
192
 
193
+ # Coerces numeric page fields before attribute snapshots stringify mutable numeric subclasses.
194
+ def page_attributes(attributes)
195
+ Attribute.normalize(
196
+ attributes.to_h do |key, value|
197
+ if %w[x y width height].include?(key.to_s) && value.is_a?(::Numeric)
198
+ value = Scalar.number(value, context: "page", field: key)
199
+ end
200
+
201
+ [key, value]
202
+ end
203
+ )
204
+ end
205
+
193
206
  # Validates and normalizes page-grid arguments.
194
207
  # @return [Array<(Integer, Float)>] normalized width, height, and gap
195
208
  # @raise [Sevgi::ArgumentError] when a count, dimension, or gap is invalid
@@ -239,9 +252,9 @@ module Sevgi
239
252
  # page: {class: "print"}
240
253
  # )
241
254
  # 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
255
+ # @param pages [Array<Hash>] page attribute hashes. Numeric page fields are normalized to SVG numbers
256
+ # @param namedview [Hash] namedview attributes. String and Symbol names are normalized and must not collide
257
+ # @param page [Hash] page defaults. Normalized page attributes override these defaults by name
245
258
  # @yield [page] customizes each generated page element
246
259
  # @yieldparam page [Sevgi::Graphics::Element] generated page element
247
260
  # @yieldreturn [Object] ignored customization result
@@ -262,8 +275,8 @@ module Sevgi
262
275
  # @param width [Numeric] finite positive page width, normalized to an SVG number
263
276
  # @param height [Numeric] finite positive page height, normalized to an SVG number
264
277
  # @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
278
+ # @param namedview [Hash] namedview attributes. String and Symbol names are normalized and must not collide
279
+ # @param page [Hash] page defaults. Normalized page attributes override these defaults by name
267
280
  # @yield [page] customizes each generated page element
268
281
  # @yieldparam page [Sevgi::Graphics::Element] generated page element
269
282
  # @yieldreturn [Object] ignored customization result
@@ -114,7 +114,7 @@ module Sevgi
114
114
  def License_LAL(**kwargs, &block) = License(**kwargs, license: "https://artlibre.org/licence/lal/en/", &block)
115
115
 
116
116
  # Builds an RDF root element.
117
- # @param kwargs [Hash] options; RDF currently accepts none
117
+ # @param kwargs [Hash] options. RDF currently accepts none
118
118
  # @yield evaluates the RDF drawing DSL
119
119
  # @yieldreturn [Object] ignored block result
120
120
  # @return [Sevgi::Graphics::Element] RDF element
@@ -13,68 +13,6 @@ module Sevgi
13
13
  STYLES = %i[hybrid inline block].freeze
14
14
  SVG_NAMESPACE = "http://www.w3.org/2000/svg"
15
15
 
16
- # Attribute rendering strategies.
17
- # @api private
18
- module Attributes
19
- # Block-style attribute renderer.
20
- # @api private
21
- module Block
22
- # Renders attributes in block form.
23
- # @param element [Sevgi::Graphics::Element] rendered element
24
- # @param depth [Integer] element depth
25
- # @return [void]
26
- def attributes(element, depth)
27
- attributes_block(element, depth, element.attributes.send(:xml_lines))
28
- end
29
- end
30
-
31
- # Hybrid attribute renderer.
32
- # @api private
33
- module Hybrid
34
- # Renders attributes inline or in block form according to line length.
35
- # @param element [Sevgi::Graphics::Element] rendered element
36
- # @param depth [Integer] element depth
37
- # @return [void]
38
- def attributes(element, depth)
39
- if attributes_as_block?(lines = element.attributes.send(:xml_lines), depth)
40
- attributes_block(element, depth, lines)
41
- else
42
- attributes_inline(element, depth, lines)
43
- end
44
- end
45
-
46
- # Reports whether attributes should be rendered in block form.
47
- # @param lines [Array<String>] rendered attribute lines
48
- # @param depth [Integer] element depth
49
- # @return [Boolean]
50
- def attributes_as_block?(lines, depth)
51
- linelength(lines, depth) > options[:linelength]
52
- end
53
-
54
- # Returns the effective inline line length.
55
- # @param lines [Array<String>] rendered attribute lines
56
- # @param depth [Integer] element depth
57
- # @return [Integer]
58
- def linelength(lines, depth)
59
- indent(depth).length + lines.sum(&:length)
60
- end
61
- end
62
-
63
- # Inline attribute renderer.
64
- # @api private
65
- module Inline
66
- # Renders attributes inline.
67
- # @param element [Sevgi::Graphics::Element] rendered element
68
- # @param depth [Integer] element depth
69
- # @return [void]
70
- def attributes(element, depth)
71
- attributes_inline(element, depth, element.attributes.send(:xml_lines))
72
- end
73
- end
74
- end
75
-
76
- private_constant :Attributes
77
-
78
16
  ELEMENTS_WITH_INLINE_CONTENT = %i[title].freeze
79
17
  ELEMENTS_WITH_BLOCK_CONTENT = %i[style].freeze
80
18
  SEPARATOR = "\n"
@@ -102,17 +40,11 @@ module Sevgi
102
40
  # Inline mark with start, stop, and depth.
103
41
  Mark = Struct.new(:start, :stop, :depth)
104
42
 
105
- # Creates an inline splice tracker.
106
- # @return [void]
107
43
  def initialize
108
44
  @marks = []
109
45
  @stack = []
110
46
  end
111
47
 
112
- # Starts an inline splice range.
113
- # @param index [Integer] output index
114
- # @param depth [Integer] element depth
115
- # @return [Sevgi::Graphics::Mixtures::Render::Renderer::Inlines::Mark] opened mark
116
48
  def start(index, depth)
117
49
  Mark.new(start: index, depth:).tap do |mark|
118
50
  @marks << mark
@@ -120,10 +52,6 @@ module Sevgi
120
52
  end
121
53
  end
122
54
 
123
- # Ends the innermost inline splice range.
124
- # @param index [Integer] output index
125
- # @return [Integer]
126
- # @raise [Sevgi::PanicError] when no inline range is open
127
55
  def stop(index)
128
56
  mark = @stack.pop
129
57
  PanicError.("Inline content range was not opened") unless mark
@@ -131,12 +59,6 @@ module Sevgi
131
59
  mark.stop = index
132
60
  end
133
61
 
134
- # Joins marked output ranges into inline content.
135
- # @param output [Array<Array<String>, nil>] renderer output buffer
136
- # @param indent [String] indentation unit
137
- # @param separator [String] line separator
138
- # @return [Array<Array<String>, nil>, nil]
139
- # @raise [Sevgi::PanicError] when an inline range was not closed
140
62
  def join(output, indent:, separator:)
141
63
  return if @marks.empty?
142
64
 
@@ -175,8 +97,7 @@ module Sevgi
175
97
  @options = self.class.send(:validate, **)
176
98
  @output = []
177
99
  @inlines = Inlines.new
178
-
179
- build
100
+ unclosed
180
101
  end
181
102
 
182
103
  # @overload call(root, **options)
@@ -246,6 +167,14 @@ module Sevgi
246
167
 
247
168
  attr_reader :inlines
248
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
+
249
178
  def attributes_block(element, depth, lines)
250
179
  return attributes_inline(element, depth, lines) if lines.empty?
251
180
 
@@ -265,19 +194,6 @@ module Sevgi
265
194
  end
266
195
  end
267
196
 
268
- def build
269
- case options[:style]
270
- when :hybrid
271
- extend(Attributes::Hybrid)
272
- when :inline
273
- extend(Attributes::Inline)
274
- when :block
275
- extend(Attributes::Block)
276
- end
277
-
278
- unclosed
279
- end
280
-
281
197
  def childless?(element)
282
198
  element.children.empty? && element.contents.empty?
283
199
  end
@@ -361,9 +277,9 @@ module Sevgi
361
277
 
362
278
  # @overload Render(**options)
363
279
  # Renders this element as SVG source.
364
- # Elements with inline text content may also contain inline children such as `tspan`; the renderer keeps those
280
+ # Elements with inline text content can also contain inline children such as `tspan`. The renderer keeps those
365
281
  # descendants in the same text line. Whitespace inside content objects is preserved as given, and encoded
366
- # 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.
367
283
  # same-named elements under a foreign default namespace retain ordinary inline text formatting.
368
284
  # @example Keep every attribute on the element's opening line
369
285
  # SVG(:minimal) { rect id: "card", width: 80, height: 40 }.Render(style: :inline)