sevgi-graphics 0.93.1 → 0.94.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2aa7779435a4a7fb66f31fc412abac9eaf39f9f310d598a02a6fe0713e9aceab
4
- data.tar.gz: 5f17372a0ff45b66c9388993c7270413b359d89889633bb775bac27747ff1128
3
+ metadata.gz: 74dd7631f2aeaa252c236f7dd1c372bd4860ebca07f23b787329aebdc04862b1
4
+ data.tar.gz: 04617a19db7b7e6485037ddd534f115f8c89d21dfd62cfa69de9fd8a6ff5a08f
5
5
  SHA512:
6
- metadata.gz: 46fed1c304dc35516dc15e12eff33cfe5eea984ed070c675e62a8ecf659ad9f1aee3f5f1884525b98c622480c4d59ea72ab20b1d2d5b6704a30691026765bdfe
7
- data.tar.gz: f35d041432cde1a165e8f753cbbe895dc2e90850a6f74cbcf89f4278ef60b022471072340959c9db21e96499ad45f09505416f7ef140b8ef620811319509a986
6
+ metadata.gz: 0acdf02ba08e49a7c3673e3457befc3c5270acb5fe05fd9985ec9d02b32f1231c79bc4127176ea3ebead9e3b9cd6e628af024e5de0aee951578eb72ff58e68d2
7
+ data.tar.gz: 90794ba32fd574c0747c61d71a7f0a47914bbf67a43e4239492cd97a8d9beec70cb482a9f2da5e095bf61e725f1dcafd2d4e0054561b884e0a6c32be5b8e2ee7
data/CHANGELOG.md ADDED
@@ -0,0 +1,4 @@
1
+ # Changelog
2
+
3
+ Sevgi Graphics follows the root Sevgi release notes:
4
+ https://github.com/roktas/sevgi/blob/main/CHANGELOG.md
data/LICENSE ADDED
@@ -0,0 +1,5 @@
1
+ Sevgi Graphics is distributed under the GNU General Public License v3.0 or later.
2
+
3
+ SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ Full project license: https://github.com/roktas/sevgi/blob/main/LICENSE
data/README.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Sevgi Graphics
2
2
 
3
- Provides the core SVG DSL, document profiles, and rendering behavior.
3
+ Core SVG DSL, document profiles, and rendering behavior.
4
4
 
5
- See the root [README](../README.md) and the documentation site for usage.
5
+ ## Install
6
+
7
+ ```sh
8
+ gem install sevgi-graphics
9
+ ```
10
+
11
+ ## Require
12
+
13
+ ```ruby
14
+ require "sevgi/graphics"
15
+ ```
16
+
17
+ ## Example
18
+
19
+ ```ruby
20
+ doc = Sevgi::Graphics.SVG(:minimal) { rect(width: 3, height: 5) }
21
+ doc.call
22
+ ```
23
+
24
+ ## Ruby compatibility
25
+
26
+ Requires Ruby 3.4.0 or newer. CI verifies Ruby 3.4 and the current development Ruby from `.ruby-version`.
27
+
28
+ ## Native prerequisites
29
+
30
+ None beyond Ruby and this gem's Ruby dependencies.
31
+
32
+ ## Links
33
+
34
+ - Documentation: https://sevgi.roktas.dev
35
+ - API documentation: https://www.rubydoc.info/gems/sevgi-graphics
36
+ - Source: https://github.com/roktas/sevgi/tree/main/graphics
37
+ - Changelog: https://github.com/roktas/sevgi/blob/main/CHANGELOG.md
@@ -41,6 +41,21 @@ module Sevgi
41
41
 
42
42
  extend Ident
43
43
 
44
+ # Returns the text form used for an XML attribute value before escaping.
45
+ # @param value [Object] attribute value
46
+ # @return [String]
47
+ # @api private
48
+ def self.xml_text(value)
49
+ case value
50
+ when ::Hash
51
+ value.map { |key, attr_value| "#{key}:#{attr_value}" }.join("; ")
52
+ when ::Array
53
+ value.join(" ")
54
+ else
55
+ value.to_s
56
+ end
57
+ end
58
+
44
59
  # Mutable SVG attribute store with Sevgi update syntax.
45
60
  class Store
46
61
  # Creates an attribute store.
@@ -60,7 +75,7 @@ module Sevgi
60
75
  .compact
61
76
  .to_a
62
77
  .to_h do |key, value|
63
- [key.to_sym, value.is_a?(::Hash) ? value.transform_keys(&:to_sym) : value]
78
+ [key.to_sym, import_value(value)]
64
79
  end
65
80
 
66
81
  @store.merge!(hash)
@@ -143,6 +158,20 @@ module Sevgi
143
158
 
144
159
  private
145
160
 
161
+ # Returns a caller-owned value copy for import.
162
+ # @param value [Object] attribute value
163
+ # @return [Object] imported value
164
+ def import_value(value)
165
+ case value
166
+ when ::Hash
167
+ value.transform_keys(&:to_sym)
168
+ when ::Array
169
+ value.dup
170
+ else
171
+ value
172
+ end
173
+ end
174
+
146
175
  UPDATER = {
147
176
  ::String => proc { |old_value, new_value| [old_value, new_value].reject(&:empty?).join(" ") },
148
177
  ::Symbol => proc { |old_value, new_value| [old_value, new_value].reject(&:empty?).join(" ").to_sym },
@@ -164,16 +193,7 @@ module Sevgi
164
193
  private_constant :UPDATER
165
194
 
166
195
  def to_xml(id, value)
167
- text = case value
168
- when ::Hash
169
- value.map { |key, attr_value| "#{key}:#{attr_value}" }.join("; ")
170
- when ::Array
171
- value.join(" ")
172
- else
173
- value.to_s
174
- end
175
-
176
- "#{id}=#{text.encode(xml: :attr)}"
196
+ "#{id}=#{Attribute.xml_text(value).encode(xml: :attr)}"
177
197
  end
178
198
  end
179
199
  end
@@ -6,6 +6,10 @@ module Sevgi
6
6
  module Graphics
7
7
  # SVG canvas size, margins, viewport, and viewBox.
8
8
  class Canvas
9
+ ORIGIN_FIELDS = %i[x y].freeze
10
+ REPLACEMENTS = %i[width height unit name margins].freeze
11
+ private_constant :ORIGIN_FIELDS, :REPLACEMENTS
12
+
9
13
  # @overload call(arg = Undefined, **kwargs)
10
14
  # Builds a canvas from a paper profile or explicit size.
11
15
  # @param arg [Sevgi::Graphics::Paper, Symbol, String, Sevgi::Undefined] paper profile or paper object
@@ -91,8 +95,24 @@ module Sevgi
91
95
 
92
96
  # Returns a canvas with selected fields replaced.
93
97
  # @param kwargs [Hash] replacement options
98
+ # @option kwargs [Numeric] :width replacement canvas width
99
+ # @option kwargs [Numeric] :height replacement canvas height
100
+ # @option kwargs [Symbol, String] :unit replacement SVG unit
101
+ # @option kwargs [Symbol, String] :name replacement paper name
102
+ # @option kwargs [Array<Numeric>] :margins replacement margin shorthand values
94
103
  # @return [Sevgi::Graphics::Canvas]
95
- def with(**kwargs) = self.class.new(**size.to_h, margins: kwargs.fetch(:margins, margin.to_a))
104
+ # @raise [Sevgi::ArgumentError] when an unknown option is supplied
105
+ # @raise [Sevgi::ArgumentError] when a replacement value is invalid
106
+ def with(**kwargs)
107
+ unknown = kwargs.keys - REPLACEMENTS
108
+
109
+ ArgumentError.("Unknown canvas option: #{unknown.first}") unless unknown.empty?
110
+
111
+ margins = kwargs.fetch(:margins, margin.to_a)
112
+ replacements = kwargs.dup.tap { it.delete(:margins) }
113
+
114
+ self.class.new(**size.to_h, **replacements, margins:)
115
+ end
96
116
 
97
117
  private
98
118
 
@@ -113,12 +133,20 @@ module Sevgi
113
133
  end
114
134
  end
115
135
 
136
+ def coordinate!(field, value)
137
+ Float(value)
138
+ rescue ::ArgumentError, ::TypeError
139
+ ArgumentError.("Invalid canvas origin #{field}: #{value.inspect}")
140
+ end
141
+
116
142
  def prettify(*floats)
117
143
  floats.map { (it % 1).zero? ? it.to_i : it }
118
144
  end
119
145
 
120
146
  def pair(array)
121
- array.size == 2 ? array.map(&:to_f) : ArgumentError.("Argument must be an Array of size 2")
147
+ ArgumentError.("Canvas origin must have exactly two coordinates") unless array.size == 2
148
+
149
+ ORIGIN_FIELDS.zip(array).map { |field, value| coordinate!(field, value) }
122
150
  end
123
151
  end
124
152
  end
@@ -27,11 +27,13 @@ module Sevgi
27
27
 
28
28
  # @overload cdata(content)
29
29
  # Builds CDATA content.
30
+ # Content values are stringified during rendering, and embedded CDATA terminators are split safely.
30
31
  # @param content [Object] wrapped content
31
32
  # @return [Sevgi::Graphics::Content::CData]
32
33
  def self.cdata(...) = CData.new(...)
33
34
 
34
35
  # Wraps content arguments, encoding non-content values.
36
+ # Non-content values are stringified by encoded content before XML text escaping.
35
37
  # @param args [Array<Object>] content arguments
36
38
  # @return [Array<Sevgi::Graphics::Content>]
37
39
  def self.contents(*args) = args.map { it.is_a?(Content) ? it : encoded(it) }
@@ -45,6 +47,7 @@ module Sevgi
45
47
 
46
48
  # @overload encoded(content)
47
49
  # Builds XML text-encoded content.
50
+ # Arbitrary objects are stringified before XML text escaping.
48
51
  # @param content [Object] wrapped content
49
52
  # @return [Sevgi::Graphics::Content::Encoded]
50
53
  def self.encoded(...) = Encoded.new(...)
@@ -62,7 +65,12 @@ module Sevgi
62
65
 
63
66
  # CDATA section content.
64
67
  class CData < Content
68
+ TERMINATOR = "]]>"
69
+ TERMINATOR_SPLIT = "]]]]><![CDATA[>"
70
+ private_constant :TERMINATOR, :TERMINATOR_SPLIT
71
+
65
72
  # Renders CDATA content.
73
+ # Embedded `]]>` terminators are split across adjacent CDATA sections so the output remains valid XML.
66
74
  # @param renderer [Object] renderer receiving output
67
75
  # @param depth [Integer] current render depth
68
76
  # @return [void]
@@ -70,9 +78,13 @@ module Sevgi
70
78
  depth += 1
71
79
 
72
80
  renderer.append(depth, "<![CDATA[")
73
- renderer.append(depth + 1, *Array(content))
81
+ renderer.append(depth + 1, *Array(content).map { safe(it) })
74
82
  renderer.append(depth, "]]>")
75
83
  end
84
+
85
+ private
86
+
87
+ def safe(value) = value.to_s.gsub(TERMINATOR, TERMINATOR_SPLIT)
76
88
  end
77
89
 
78
90
  # CSS content rendered inside a CDATA section.
@@ -123,7 +135,7 @@ module Sevgi
123
135
  class Encoded < Content
124
136
  # Returns XML text-encoded content.
125
137
  # @return [String]
126
- def to_s = content.encode(xml: :text)
138
+ def to_s = content.to_s.encode(xml: :text)
127
139
 
128
140
  # Renders encoded text content.
129
141
  # @param renderer [Object] renderer receiving output
@@ -4,6 +4,50 @@ module Sevgi
4
4
  module Graphics
5
5
  # SVG document profile factory.
6
6
  module Document
7
+ # Defensive copy helper for profile metadata snapshots.
8
+ # @api private
9
+ module Snapshot
10
+ # Returns a recursively independent copy of a value.
11
+ # @param value [Object] value to copy
12
+ # @return [Object] copied value
13
+ def self.copy(value)
14
+ case value
15
+ when ::Hash
16
+ hash(value)
17
+ when ::Array
18
+ value.map { copy(it) }
19
+ else
20
+ duplicate(value)
21
+ end
22
+ end
23
+
24
+ # Returns a recursively frozen independent copy of a value.
25
+ # @param value [Object] value to copy and freeze
26
+ # @return [Object] frozen copied value
27
+ def self.frozen(value)
28
+ case value
29
+ when ::Hash
30
+ value.to_h { |key, item| [frozen(key), frozen(item)] }.freeze
31
+ when ::Array
32
+ value.map { frozen(it) }.freeze
33
+ else
34
+ duplicate(value).freeze
35
+ end
36
+ end
37
+
38
+ def self.hash(value) = value.to_h { |key, item| [copy(key), copy(item)] }
39
+
40
+ def self.duplicate(value)
41
+ value.dup
42
+ rescue ::TypeError
43
+ value
44
+ end
45
+
46
+ private_class_method :duplicate, :hash
47
+ end
48
+
49
+ private_constant :Snapshot
50
+
7
51
  # Builds a root SVG element from a document profile.
8
52
  # @param document [Symbol, String, Class] profile name or document class
9
53
  # @param canvas [Sevgi::Graphics::Canvas, Sevgi::Graphics::Paper, Symbol, String, Sevgi::Undefined, nil] canvas input
@@ -37,27 +81,47 @@ module Sevgi
37
81
 
38
82
  # Defines or returns a document profile class.
39
83
  # @param name [Symbol, String, Sevgi::Undefined] profile name, or Undefined for an anonymous profile
40
- # @param preambles [Array<String>, nil] document preamble lines
41
- # @param attributes [Hash] default root attributes
84
+ # @param preambles [Array<String>, nil, Sevgi::Undefined] document preamble lines
85
+ # @param attributes [Hash, Sevgi::Undefined] default root attributes
42
86
  # @param overwrite [Boolean] true to replace an existing profile
43
87
  # @return [Class] document class
44
88
  # @raise [Sevgi::ArgumentError] when a named profile conflicts with an existing profile
45
- def self.define(name = Undefined, preambles: [], attributes: {}, overwrite: false)
46
- return Class.new(Base) { document(name, preambles:, attributes:, register: false) } if name == Undefined
47
-
48
- profile = Profile.new(name, attributes:, preambles:)
89
+ def self.define(name = Undefined, preambles: Undefined, attributes: Undefined, overwrite: false)
90
+ return anonymous(attributes:, preambles:) if name == Undefined
49
91
 
50
92
  if (current = Profile[name])
51
- unless overwrite || current.profile == profile
52
- ArgumentError.("Document profile already defined differently: #{name}")
53
- end
54
-
93
+ reject_conflict(name, current, attributes:, preambles:) unless overwrite
55
94
  return current unless overwrite
56
95
  end
57
96
 
97
+ attributes, preambles = defaults(attributes:, preambles:)
58
98
  Class.new(Base) { document(name, preambles:, attributes:, overwrite:) }
59
99
  end
60
100
 
101
+ def self.anonymous(attributes:, preambles:)
102
+ attributes, preambles = defaults(attributes:, preambles:)
103
+ Class.new(Base) { document(Undefined, preambles:, attributes:, register: false) }
104
+ end
105
+
106
+ def self.defaults(attributes:, preambles:)
107
+ [attributes == Undefined ? {} : attributes, preambles == Undefined ? nil : preambles]
108
+ end
109
+
110
+ def self.reject_conflict(name, current, attributes:, preambles:)
111
+ return if compatible?(current, attributes:, preambles:)
112
+
113
+ ArgumentError.("Document profile already defined differently: #{name}")
114
+ end
115
+
116
+ def self.compatible?(klass, attributes:, preambles:)
117
+ profile = klass.profile
118
+
119
+ (attributes == Undefined || Profile.new(nil, attributes:).attributes == profile.attributes) &&
120
+ (preambles == Undefined || Profile.new(nil, preambles:).preambles == profile.preambles)
121
+ end
122
+
123
+ private_class_method :anonymous, :compatible?, :defaults, :reject_conflict
124
+
61
125
  # Immutable document profile metadata.
62
126
  # @api private
63
127
  class Profile
@@ -107,12 +171,6 @@ module Sevgi
107
171
  # @return [Symbol, nil] profile name
108
172
  attr_reader :name
109
173
 
110
- # @return [Hash] default root attributes
111
- attr_reader :attributes
112
-
113
- # @return [Array<String>, nil] preamble lines
114
- attr_reader :preambles
115
-
116
174
  # Creates profile metadata.
117
175
  # @param name [Object, nil] profile name
118
176
  # @param attributes [Hash, nil] default root attributes
@@ -121,8 +179,8 @@ module Sevgi
121
179
  # @raise [Sevgi::ArgumentError] when name cannot be normalized
122
180
  def initialize(name, attributes: nil, preambles: nil)
123
181
  @name = name.nil? ? nil : self.class.normalize!(name)
124
- @attributes = attributes || {}
125
- @preambles = preambles
182
+ @attributes = Snapshot.frozen(attributes || {})
183
+ @preambles = preambles.nil? ? nil : Snapshot.frozen(preambles)
126
184
  end
127
185
 
128
186
  # Reports strict profile equality.
@@ -130,9 +188,17 @@ module Sevgi
130
188
  # @return [Boolean]
131
189
  def ==(other) = self.class == other.class && deconstruct == other.deconstruct
132
190
 
191
+ # Returns default root attributes.
192
+ # @return [Hash] mutation-isolated attribute snapshot
193
+ def attributes = Snapshot.copy(@attributes)
194
+
133
195
  # Returns profile components.
134
196
  # @return [Array<(Symbol, nil), Hash, (Array<String>, nil)>]
135
197
  def deconstruct = [name, attributes, preambles]
198
+
199
+ # Returns preamble lines.
200
+ # @return [Array<String>, nil] mutation-isolated preamble snapshot
201
+ def preambles = Snapshot.copy(@preambles)
136
202
  end
137
203
 
138
204
  private_constant :Profile
@@ -149,7 +149,7 @@ module Sevgi
149
149
 
150
150
  protected
151
151
 
152
- attr_writer :children, :attributes
152
+ attr_writer :attributes, :children, :contents, :parent
153
153
  end
154
154
  end
155
155
  end
@@ -3,9 +3,20 @@
3
3
  module Sevgi
4
4
  module Graphics
5
5
  # Callable drawing module support.
6
- # @api private
6
+ # Extend a plain Ruby module with this API to make its public instance methods callable drawing steps.
7
+ # @example Define and call a drawing module
8
+ # Widget = Module.new do
9
+ # extend Sevgi::Graphics::Module
10
+ #
11
+ # def item(id)
12
+ # rect(id:)
13
+ # end
14
+ # end
15
+ #
16
+ # SVG { Call(Widget, "box") }
7
17
  module Module
8
- # Tracks newly defined public methods as callable drawing steps.
18
+ # Tracks newly defined methods as callable drawing candidates.
19
+ # Invocation runs unique methods that are still public, preserving tracked definition order.
9
20
  # @param method [Symbol] method name Ruby reports as added
10
21
  # @return [Array<Symbol>, nil]
11
22
  def method_added(method)
@@ -49,6 +60,7 @@ module Sevgi
49
60
  # @param args [Array<Object>] callable arguments
50
61
  # @param kwargs [Hash] callable keyword arguments
51
62
  # @return [Object, nil] last callable return value
63
+ # @raise [Sevgi::ArgumentError] when mod is not a plain module
52
64
  def self.call(mod, receiver, ...)
53
65
  mod._befores.each { receiver.Within(receiver, &it) } if mod.respond_to?(:_befores) && mod._befores
54
66
  # return last callable return value
@@ -64,8 +76,22 @@ module Sevgi
64
76
  def self.callables(mod)
65
77
  ArgumentError.("Must be a module: #{mod}") unless mod.instance_of?(::Module)
66
78
 
67
- (mod.respond_to?(:_callables) ? mod._callables : mod.instance_methods).map { mod.instance_method(it) }
79
+ callable_names(mod).uniq.filter_map do |name|
80
+ mod.instance_method(name) if mod.public_method_defined?(name)
81
+ end
68
82
  end
83
+
84
+ def self.callable_names(mod)
85
+ tracked = mod.ancestors.reverse_each.filter_map do |ancestor|
86
+ ancestor._callables if ancestor.respond_to?(:_callables)
87
+ end
88
+
89
+ return tracked.flatten unless tracked.empty?
90
+
91
+ mod.public_instance_methods
92
+ end
93
+
94
+ private_class_method :callable_names
69
95
  end
70
96
 
71
97
  module Mixtures
@@ -5,9 +5,15 @@ require "forwardable"
5
5
  module Sevgi
6
6
  module Graphics
7
7
  module Mixtures
8
- # Internal traversal stop token.
8
+ # Traversal stop token returned by {Core#Stay}.
9
+ #
10
+ # @!attribute [r] value
11
+ # @return [Object] value returned from traversal
12
+ Stop = Data.define(:value)
13
+
14
+ # Internal traversal engine.
9
15
  # @api private
10
- Traversal = Data.define(:value) do
16
+ module Traversal
11
17
  # Runs a depth-first traversal.
12
18
  # @param element [Sevgi::Graphics::Element] starting element
13
19
  # @param depth [Integer] starting depth
@@ -21,7 +27,7 @@ module Sevgi
21
27
  end
22
28
 
23
29
  def self.stop(value)
24
- throw(:traversal, value.value) if value.is_a?(self)
30
+ throw(:traversal, value.value) if value.is_a?(Stop)
25
31
  end
26
32
 
27
33
  def self.visit(element, depth, leave, &block)
@@ -46,6 +52,7 @@ module Sevgi
46
52
  # @param index [Integer] insertion index
47
53
  # @return [Sevgi::Graphics::Element] self
48
54
  # @raise [Sevgi::ArgumentError] when the target parent has a different element class
55
+ # @raise [Sevgi::ArgumentError] when the target parent is this element or one of its descendants
49
56
  def Adopt(new_parent = nil, index: -1)
50
57
  tap do
51
58
  if new_parent
@@ -56,6 +63,8 @@ module Sevgi
56
63
  new_parent = parent
57
64
  end
58
65
 
66
+ Adoption.validate(self, new_parent)
67
+
59
68
  self.Orphan()
60
69
  (@parent = new_parent).children.insert(index, self)
61
70
  end
@@ -65,6 +74,8 @@ module Sevgi
65
74
  # Moves this element to the beginning of a parent.
66
75
  # @param new_parent [Sevgi::Graphics::Element, nil] target parent or current parent
67
76
  # @return [Sevgi::Graphics::Element] self
77
+ # @raise [Sevgi::ArgumentError] when the target parent has a different element class
78
+ # @raise [Sevgi::ArgumentError] when the target parent is this element or one of its descendants
68
79
  def AdoptFirst(*)
69
80
  Adopt(*, index: 0)
70
81
  end
@@ -77,23 +88,22 @@ module Sevgi
77
88
  end
78
89
 
79
90
  # Adds CSS classes without duplicating existing values.
80
- # @param classes [Array<String, Symbol>] class names
91
+ # @param classes [Array<String, Symbol, Array>] class tokens; strings are split on whitespace
81
92
  # @return [Sevgi::Graphics::Element] self
82
93
  def Classify(*classes)
83
94
  tap do
84
- klasses = case self[:class]
85
- when ::Array
86
- self[:class]
87
- when ::String
88
- self[:class].split
89
- when nil
90
- []
91
- else
92
- [self[:class]]
95
+ tokens = proc do |value|
96
+ case value
97
+ when nil
98
+ []
99
+ when ::Array
100
+ value.flat_map { tokens.call(it) }
101
+ else
102
+ value.to_s.split
103
+ end
93
104
  end
94
105
 
95
- classes.each { klasses << it unless klasses.include?(it) }
96
- self[:class] = klasses
106
+ self[:class] = [*tokens.call(self[:class]), *tokens.call(classes)].uniq
97
107
  end
98
108
  end
99
109
 
@@ -112,7 +122,7 @@ module Sevgi
112
122
 
113
123
  # Builds a child element with an explicit tag name.
114
124
  # @param tag [Symbol, String] SVG tag name
115
- # @param contents [Array<Object>] text or content objects
125
+ # @param contents [Array<Object>] text or content objects; non-content objects are stringified and XML-encoded
116
126
  # @param attributes [Hash] SVG attributes
117
127
  # @return [Sevgi::Graphics::Element] new child element
118
128
  def Element(tag, *contents, **attributes, &block)
@@ -168,8 +178,8 @@ module Sevgi
168
178
  # @overload Stay(value)
169
179
  # Wraps a traversal return value as a stop token.
170
180
  # @param value [Object] value returned from traversal
171
- # @return [Sevgi::Graphics::Mixtures::Traversal]
172
- def Stay(...) = Traversal.new(...)
181
+ # @return [Sevgi::Graphics::Mixtures::Stop]
182
+ def Stay(...) = Stop.new(...)
173
183
 
174
184
  # Traverses the subtree depth-first.
175
185
  # @param depth [Integer] starting depth
@@ -192,7 +202,7 @@ module Sevgi
192
202
  element = self
193
203
 
194
204
  loop do
195
- yield(element, height).tap { return it.value if it.is_a?(Traversal) }
205
+ yield(element, height).tap { return it.value if it.is_a?(Stop) }
196
206
 
197
207
  break if element.Root?()
198
208
 
@@ -223,6 +233,28 @@ module Sevgi
223
233
  def <<(element)
224
234
  Append(element)
225
235
  end
236
+
237
+ # Adoption target validation that keeps tree mutation atomic.
238
+ # @api private
239
+ module Adoption
240
+ # Rejects target parents that would create a cycle.
241
+ # @param element [Sevgi::Graphics::Element] element being moved
242
+ # @param parent [Sevgi::Graphics::Element, Object] target parent
243
+ # @return [void]
244
+ # @raise [Sevgi::ArgumentError] when the target parent is this element or one of its descendants
245
+ def self.validate(element, parent)
246
+ ArgumentError.("Element cannot be adopted under itself") if parent.equal?(element)
247
+
248
+ while parent.respond_to?(:Root?)
249
+ ArgumentError.("Element cannot be adopted under its descendant") if parent.equal?(element)
250
+ break if parent.Root?()
251
+
252
+ parent = parent.parent
253
+ end
254
+ end
255
+ end
256
+
257
+ private_constant :Adoption
226
258
  end
227
259
  end
228
260
  end
@@ -3,19 +3,24 @@
3
3
  module Sevgi
4
4
  module Graphics
5
5
  module Mixtures
6
- # DSL helpers for duplicating elements and their subtrees.
6
+ # DSL helpers for duplicating independent element subtrees.
7
7
  module Duplicate
8
- # Duplicates an element subtree and optionally translates it.
8
+ # Duplicates an element subtree as an independent tree and optionally translates it.
9
+ # Copied elements receive new child arrays, attribute stores, and content arrays. Public `id` attributes are
10
+ # moved to an internal `-id` attribute before the optional block runs, allowing the block to derive replacement
11
+ # ids without rendering duplicate public ids.
9
12
  # @param dx [Numeric, nil] x translation
10
13
  # @param dy [Numeric, nil] y translation
11
14
  # @param parent [Sevgi::Graphics::Element, nil] parent for the duplicated subtree
15
+ # @yield [element] optional customization hook for each copied element
16
+ # @yieldparam element [Sevgi::Graphics::Element] copied element
12
17
  # @return [Sevgi::Graphics::Element] duplicated element
18
+ # @raise [Sevgi::ArgumentError] when the target parent has a different element class
13
19
  def Duplicate(dx: nil, dy: nil, parent: nil, &block)
14
- duplicated = dup
20
+ duplicated = Subtree.copy(self)
15
21
 
16
22
  duplicated.Traverse() do |element|
17
- element.children = element.children.map(&:dup)
18
- id = (element.attributes = element.attributes.dup).delete(:id)
23
+ id = element.attributes.delete(:id)
19
24
  element[:"#{ATTRIBUTE_INTERNAL_PREFIX}id"] = id if id
20
25
  block&.call(element)
21
26
  end
@@ -30,14 +35,39 @@ module Sevgi
30
35
  # Duplicates an element subtree along the x-axis.
31
36
  # @param dx [Numeric] x translation
32
37
  # @param parent [Sevgi::Graphics::Element, nil] parent for the duplicated subtree
38
+ # @yield [element] optional customization hook for each copied element
39
+ # @yieldparam element [Sevgi::Graphics::Element] copied element
33
40
  # @return [Sevgi::Graphics::Element] duplicated element
41
+ # @raise [Sevgi::ArgumentError] when the target parent has a different element class
34
42
  def DuplicateX(dx, parent: nil, &block) = Duplicate(dx:, dy: 0, parent:, &block)
35
43
 
36
44
  # Duplicates an element subtree along the y-axis.
37
45
  # @param dy [Numeric] y translation
38
46
  # @param parent [Sevgi::Graphics::Element, nil] parent for the duplicated subtree
47
+ # @yield [element] optional customization hook for each copied element
48
+ # @yieldparam element [Sevgi::Graphics::Element] copied element
39
49
  # @return [Sevgi::Graphics::Element] duplicated element
50
+ # @raise [Sevgi::ArgumentError] when the target parent has a different element class
40
51
  def DuplicateY(dy, parent: nil, &block) = Duplicate(dx: 0, dy:, parent:, &block)
52
+
53
+ # Recursive subtree copier that keeps duplicate implementation state out of the DSL surface.
54
+ # @api private
55
+ module Subtree
56
+ # Builds an independent copy of an element subtree.
57
+ # @param element [Sevgi::Graphics::Element] source subtree root
58
+ # @param parent [Sevgi::Graphics::Element, Object] parent for the copied root
59
+ # @return [Sevgi::Graphics::Element] copied subtree root
60
+ def self.copy(element, parent = element.parent)
61
+ element.dup.tap do |duplicated|
62
+ duplicated.send(:parent=, parent)
63
+ duplicated.send(:attributes=, element.attributes.dup)
64
+ duplicated.send(:contents=, element.contents.dup)
65
+ duplicated.send(:children=, element.children.map { |child| copy(child, duplicated) })
66
+ end
67
+ end
68
+ end
69
+
70
+ private_constant :Subtree
41
71
  end
42
72
  end
43
73
  end
@@ -10,7 +10,8 @@ module Sevgi
10
10
  # @param kwargs [Hash] export options
11
11
  # @return [String] output path
12
12
  # @raise [Sevgi::MissingComponentError] when sevgi/sundries is unavailable
13
- # @raise [Sevgi::Sundries::ExportError] when native export fails
13
+ # @raise [Sevgi::MissingComponentError] when native export gems are unavailable
14
+ # @raise [Sevgi::Sundries::Export::ExportError] when native export fails
14
15
  def PDF(path = nil, **kwargs, &block)
15
16
  begin
16
17
  require "sevgi/sundries"
@@ -29,7 +30,8 @@ module Sevgi
29
30
  # @param kwargs [Hash] export options
30
31
  # @return [String] output path
31
32
  # @raise [Sevgi::MissingComponentError] when sevgi/sundries is unavailable
32
- # @raise [Sevgi::Sundries::ExportError] when native export fails
33
+ # @raise [Sevgi::MissingComponentError] when native export gems are unavailable
34
+ # @raise [Sevgi::Sundries::Export::ExportError] when native export fails
33
35
  def PNG(path = nil, **kwargs, &block)
34
36
  begin
35
37
  require "sevgi/sundries"
@@ -45,7 +45,9 @@ module Sevgi
45
45
 
46
46
  def build
47
47
  element.Traverse() do |element|
48
- next unless (id = element[:id])
48
+ next unless (value = element[:id])
49
+
50
+ id = Attribute.xml_text(value)
49
51
 
50
52
  if @namespace.key?(id)
51
53
  (@collision[id] ||= [@namespace[id]]) << element
@@ -8,18 +8,27 @@ module Sevgi
8
8
  require "sevgi/derender"
9
9
 
10
10
  # Includes a derendered node matching an id.
11
+ #
12
+ # SVG/XML file content is treated as data and is not evaluated as Ruby source.
11
13
  # @param file [String] source SVG/XML file
12
14
  # @param id [String, Symbol] source node id
13
- # @return [Sevgi::Graphics::Element] included element
15
+ # @return [Sevgi::Graphics::Element, nil] included element, or nil when the selected node produces no graphics
16
+ # output
17
+ # @raise [Sevgi::ArgumentError] when the file cannot be found, file content is malformed or rootless, or the id is
18
+ # absent
14
19
  # @raise [Sevgi::MissingComponentError] when sevgi/derender is unavailable
15
20
  def Include(file, id) = Derender.evaluate_file(file, self, id:)
16
21
 
17
22
  # Includes the children of a derendered node matching an id.
23
+ #
24
+ # SVG/XML file content is treated as data and is not evaluated as Ruby source.
18
25
  # @param file [String] source SVG/XML file
19
26
  # @param id [String, Symbol] source node id
20
27
  # @return [Array<Sevgi::Graphics::Element>] included children
28
+ # @raise [Sevgi::ArgumentError] when the file cannot be found, file content is malformed or rootless, or the id is
29
+ # absent
21
30
  # @raise [Sevgi::MissingComponentError] when sevgi/derender is unavailable
22
- def IncludeChildren(file, id) = Derender.evaluate_file!(file, self, id:)
31
+ def IncludeChildren(file, id) = Derender.evaluate_file_children(file, self, id:)
23
32
  rescue ::LoadError => e
24
33
  raise unless e.path == "sevgi/derender"
25
34
 
@@ -96,39 +96,61 @@ module Sevgi
96
96
 
97
97
  # Creates an inline splice tracker.
98
98
  # @return [void]
99
- def initialize = @marks = []
99
+ def initialize
100
+ @marks = []
101
+ @stack = []
102
+ end
100
103
 
101
104
  # Starts an inline splice range.
102
105
  # @param index [Integer] output index
103
106
  # @param depth [Integer] element depth
104
- # @return [Array<Sevgi::Graphics::Mixtures::Render::Renderer::Inlines::Mark>]
105
- def start(index, depth) = @marks << Mark.new(start: index, depth:)
107
+ # @return [Sevgi::Graphics::Mixtures::Render::Renderer::Inlines::Mark] opened mark
108
+ def start(index, depth)
109
+ Mark.new(start: index, depth:).tap do |mark|
110
+ @marks << mark
111
+ @stack << mark
112
+ end
113
+ end
106
114
 
107
- # Ends the latest inline splice range.
115
+ # Ends the innermost inline splice range.
108
116
  # @param index [Integer] output index
109
117
  # @return [Integer]
110
- def stop(index) = @marks.last.stop = index
118
+ # @raise [Sevgi::PanicError] when no inline range is open
119
+ def stop(index)
120
+ mark = @stack.pop
121
+ PanicError.("Inline content range was not opened") unless mark
122
+
123
+ mark.stop = index
124
+ end
111
125
 
112
126
  # Joins marked output ranges into inline content.
113
127
  # @param output [Array<Array<String>, nil>] renderer output buffer
114
128
  # @param indent [String] indentation unit
115
129
  # @param separator [String] line separator
116
130
  # @return [Array<Array<String>, nil>, nil]
131
+ # @raise [Sevgi::PanicError] when an inline range was not closed
117
132
  def join(output, indent:, separator:)
118
133
  return if @marks.empty?
119
134
 
120
- @marks.each do |mark|
121
- depth = mark.depth
122
-
123
- ((mark.start + 1)..mark.stop).each do |i|
124
- output[i].map! { |line| line.delete_prefix(indent * (depth + 1)) }
125
- output[mark.start][-1] += output[i].join(separator)
126
- output[i] = nil
127
- end
128
- end
135
+ @marks.reverse_each { |mark| join_mark(output, mark, indent:, separator:) }
129
136
 
130
137
  output.compact!
131
138
  end
139
+
140
+ private
141
+
142
+ def join_mark(output, mark, indent:, separator:)
143
+ PanicError.("Inline content range was not closed") unless mark.stop
144
+
145
+ ((mark.start + 1)..mark.stop).each do |index|
146
+ lines = output[index]
147
+ next unless lines
148
+
149
+ lines.map! { |line| line.delete_prefix(indent * (mark.depth + 1)) }
150
+ output[mark.start][-1] += lines.join(separator)
151
+ output[index] = nil
152
+ end
153
+ end
132
154
  end
133
155
 
134
156
  # @overload initialize(root, **options)
@@ -157,6 +179,13 @@ module Sevgi
157
179
  # @raise [Sevgi::ArgumentError] when style is missing or unsupported
158
180
  def self.call(root, **) = new(root, **).call(*root.class.preambles)
159
181
 
182
+ # Renders a root element without document preambles.
183
+ # @param root [Sevgi::Graphics::Element] root element
184
+ # @param options [Hash] renderer options
185
+ # @return [String] SVG fragment source
186
+ # @raise [Sevgi::ArgumentError] when style is missing or unsupported
187
+ def self.fragment(root, **options) = new(root, **options).call
188
+
160
189
  # Appends rendered lines to the output buffer.
161
190
  # @param depth [Integer, nil] indentation depth
162
191
  # @param lines [Array<String>] rendered lines
@@ -284,15 +313,20 @@ module Sevgi
284
313
 
285
314
  # @overload Render(**options)
286
315
  # Renders this element as SVG source.
316
+ # Elements with inline text content may also contain inline children such as `tspan`; the renderer keeps those
317
+ # 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.
287
319
  # @param options [Hash] renderer options
288
320
  # @return [String] SVG source
289
321
  # @raise [Sevgi::ArgumentError] when style is missing or unsupported
290
322
  def Render(**) = Renderer.(self, **)
291
323
 
292
324
  # Renders only this element's children.
325
+ # Child render output omits document preambles and preserves each child's text whitespace and inline
326
+ # mixed-content formatting.
293
327
  # @param separator [String] separator between child documents
294
- # @return [String] rendered children
295
- def RenderChildren(separator = "\n\n") = children.map(&:Render).join(separator)
328
+ # @return [String] rendered child fragments
329
+ def RenderChildren(separator = "\n\n") = children.map { Renderer.fragment(it) }.join(separator)
296
330
  end
297
331
  end
298
332
  end
@@ -135,9 +135,9 @@ module Sevgi
135
135
  # Argument validators for tile helpers.
136
136
  ASSERTION = {
137
137
  id: proc { |name, value| "Argument '#{name}' must be a string" unless value.is_a?(::String) },
138
- n: proc { |name, value| "Argument '#{name}' must be a positive integer" unless value.positive? },
139
- nx: proc { |name, value| "Argument '#{name}' must be a positive integer" unless value.positive? },
140
- ny: proc { |name, value| "Argument '#{name}' must be a positive integer" unless value.positive? },
138
+ n: proc { |name, value| positive_integer_issue(name, value) },
139
+ nx: proc { |name, value| positive_integer_issue(name, value) },
140
+ ny: proc { |name, value| positive_integer_issue(name, value) },
141
141
  d: proc { |name, value| "Argument '#{name}' must be a number" unless value.is_a?(::Numeric) },
142
142
  dx: proc { |name, value| "Argument '#{name}' must be a number" unless value.is_a?(::Numeric) },
143
143
  dy: proc { |name, value| "Argument '#{name}' must be a number" unless value.is_a?(::Numeric) },
@@ -147,6 +147,14 @@ module Sevgi
147
147
  proc: proc { |name, value| "Argument '#{name}' must be a proc" unless value.nil? || value.is_a?(::Proc) }
148
148
  }.freeze
149
149
 
150
+ # Returns a validation issue unless value is a positive integer.
151
+ # @param name [Symbol] argument name
152
+ # @param value [Object] argument value
153
+ # @return [String, nil] validation issue
154
+ def positive_integer_issue(name, value)
155
+ "Argument '#{name}' must be a positive integer" unless value.is_a?(::Integer) && value.positive?
156
+ end
157
+
150
158
  # Validates tile arguments.
151
159
  # @param kwargs [Hash] tile arguments
152
160
  # @return [nil]
@@ -13,17 +13,28 @@ module Sevgi
13
13
  end
14
14
 
15
15
  # Adds an XML comment.
16
- # @param comment [String] comment text
16
+ # @param comment [Object] comment text
17
17
  # @return [Sevgi::Graphics::Element] floating comment element
18
+ # @raise [Sevgi::ArgumentError] when comment text would produce malformed XML
18
19
  def Comment(comment)
20
+ comment = comment.to_s
21
+
22
+ ArgumentError.("XML comment must not contain '--'") if comment.include?("--")
23
+ ArgumentError.("XML comment must not end with '-'") if comment.end_with?("-")
24
+
19
25
  _(Content.verbatim("<!-- #{comment} -->"))
20
26
  end
21
27
 
22
- # Merges internal ancestral attributes from the document root to this element.
28
+ # Merges internal attributes from the document root, ancestors, and this element.
29
+ # Only the direct root-to-self ancestor chain participates; sibling subtrees are ignored. When the same key is
30
+ # present on multiple chain elements, the nearest element to the receiver wins.
23
31
  # @return [Hash] merged internal attributes
24
32
  def Ancestral
33
+ chain = []
34
+ TraverseUp { |element| chain.unshift(element) }
35
+
25
36
  {}.tap do |result|
26
- Root().Traverse() { |element| result.merge!(element[:_]) if element.has?(:_) }
37
+ chain.each { |element| result.merge!(element[:_]) if element.has?(:_) }
27
38
  end
28
39
  end
29
40
  end
@@ -3,6 +3,6 @@
3
3
  module Sevgi
4
4
  module Graphics
5
5
  # Current graphics component version.
6
- VERSION = "0.93.1"
6
+ VERSION = "0.94.0"
7
7
  end
8
8
  end
@@ -24,13 +24,27 @@ module Sevgi
24
24
  Graphics::Canvas.from_paper(...)
25
25
  end
26
26
 
27
- # Defines or returns a document profile class.
28
- # @param name [Symbol, String, Sevgi::Undefined] profile name, or Undefined for an anonymous profile
29
- # @param preambles [Array<String>, nil] document preamble lines
30
- # @param attributes [Hash] default root attributes
27
+ # @overload document(name)
28
+ # Looks up an existing document profile by name.
29
+ # @param name [Symbol, String] profile name
30
+ # @return [Class] document class
31
+ # @raise [Sevgi::ArgumentError] when the profile is unknown
32
+ # @overload document(name, preambles: Undefined, attributes: Undefined)
33
+ # Defines a named document profile, or returns an existing profile when every explicitly supplied field matches.
34
+ # Omitted fields are ignored during existing-profile comparison. Profile inputs are copied into the registry.
35
+ # @param name [Symbol, String] profile name
36
+ # @param preambles [Array<String>, nil, Sevgi::Undefined] document preamble lines
37
+ # @param attributes [Hash, Sevgi::Undefined] default root attributes
38
+ # @return [Class] document class
39
+ # @raise [Sevgi::ArgumentError] when a named profile conflicts with an existing profile
40
+ # @overload document(preambles: Undefined, attributes: Undefined)
41
+ # Defines an anonymous document profile without registering it globally.
42
+ # @param preambles [Array<String>, nil, Sevgi::Undefined] document preamble lines
43
+ # @param attributes [Hash, Sevgi::Undefined] default root attributes
44
+ # @return [Class] anonymous document class
45
+ # @raise [Sevgi::ArgumentError] when profile input is invalid
31
46
  # @return [Class] document class
32
- # @raise [Sevgi::ArgumentError] when a named profile conflicts with an existing profile
33
- def document(name = Undefined, preambles: [], attributes: {})
47
+ def document(name = Undefined, preambles: Undefined, attributes: Undefined)
34
48
  Graphics::Document.define(name, preambles:, attributes:)
35
49
  end
36
50
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sevgi-graphics
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.93.1
4
+ version: 0.94.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Recai Oktaş
@@ -15,20 +15,22 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.93.1
18
+ version: 0.94.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - '='
24
24
  - !ruby/object:Gem::Version
25
- version: 0.93.1
25
+ version: 0.94.0
26
26
  description: Provides an extensible DSL for creating SVG content.
27
27
  email: roktas@gmail.com
28
28
  executables: []
29
29
  extensions: []
30
30
  extra_rdoc_files: []
31
31
  files:
32
+ - CHANGELOG.md
33
+ - LICENSE
32
34
  - README.md
33
35
  - lib/sevgi/graphics.rb
34
36
  - lib/sevgi/graphics/attribute.rb
@@ -80,7 +82,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
80
82
  requirements:
81
83
  - - ">="
82
84
  - !ruby/object:Gem::Version
83
- version: 3.4.0.pre.preview1
85
+ version: 3.4.0
84
86
  required_rubygems_version: !ruby/object:Gem::Requirement
85
87
  requirements:
86
88
  - - ">="