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 +4 -4
- data/CHANGELOG.md +4 -0
- data/LICENSE +5 -0
- data/README.md +34 -2
- data/lib/sevgi/graphics/attribute.rb +31 -11
- data/lib/sevgi/graphics/auxiliary/canvas.rb +30 -2
- data/lib/sevgi/graphics/auxiliary/content.rb +14 -2
- data/lib/sevgi/graphics/document.rb +84 -18
- data/lib/sevgi/graphics/element.rb +1 -1
- data/lib/sevgi/graphics/mixtures/call.rb +29 -3
- data/lib/sevgi/graphics/mixtures/core.rb +51 -19
- data/lib/sevgi/graphics/mixtures/duplicate.rb +35 -5
- data/lib/sevgi/graphics/mixtures/export.rb +4 -2
- data/lib/sevgi/graphics/mixtures/identify.rb +3 -1
- data/lib/sevgi/graphics/mixtures/include.rb +11 -2
- data/lib/sevgi/graphics/mixtures/render.rb +50 -16
- data/lib/sevgi/graphics/mixtures/tile.rb +11 -3
- data/lib/sevgi/graphics/mixtures/underscore.rb +14 -3
- data/lib/sevgi/graphics/version.rb +1 -1
- data/lib/sevgi/graphics.rb +20 -6
- metadata +6 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 74dd7631f2aeaa252c236f7dd1c372bd4860ebca07f23b787329aebdc04862b1
|
|
4
|
+
data.tar.gz: 04617a19db7b7e6485037ddd534f115f8c89d21dfd62cfa69de9fd8a6ff5a08f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0acdf02ba08e49a7c3673e3457befc3c5270acb5fe05fd9985ec9d02b32f1231c79bc4127176ea3ebead9e3b9cd6e628af024e5de0aee951578eb72ff58e68d2
|
|
7
|
+
data.tar.gz: 90794ba32fd574c0747c61d71a7f0a47914bbf67a43e4239492cd97a8d9beec70cb482a9f2da5e095bf61e725f1dcafd2d4e0054561b884e0a6c32be5b8e2ee7
|
data/CHANGELOG.md
ADDED
data/LICENSE
ADDED
data/README.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Sevgi Graphics
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Core SVG DSL, document profiles, and rendering behavior.
|
|
4
4
|
|
|
5
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
46
|
-
return
|
|
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
|
-
|
|
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
|
|
@@ -3,9 +3,20 @@
|
|
|
3
3
|
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.
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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
|
|
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?(
|
|
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
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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::
|
|
172
|
-
def Stay(...) =
|
|
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?(
|
|
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
|
|
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 =
|
|
20
|
+
duplicated = Subtree.copy(self)
|
|
15
21
|
|
|
16
22
|
duplicated.Traverse() do |element|
|
|
17
|
-
|
|
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::
|
|
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::
|
|
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"
|
|
@@ -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.
|
|
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
|
|
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 [
|
|
105
|
-
def 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
|
|
115
|
+
# Ends the innermost inline splice range.
|
|
108
116
|
# @param index [Integer] output index
|
|
109
117
|
# @return [Integer]
|
|
110
|
-
|
|
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.
|
|
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
|
|
295
|
-
def RenderChildren(separator = "\n\n") = children.map(
|
|
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|
|
|
139
|
-
nx: proc { |name, value|
|
|
140
|
-
ny: proc { |name, value|
|
|
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 [
|
|
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
|
|
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
|
-
|
|
37
|
+
chain.each { |element| result.merge!(element[:_]) if element.has?(:_) }
|
|
27
38
|
end
|
|
28
39
|
end
|
|
29
40
|
end
|
data/lib/sevgi/graphics.rb
CHANGED
|
@@ -24,13 +24,27 @@ module Sevgi
|
|
|
24
24
|
Graphics::Canvas.from_paper(...)
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
85
|
+
version: 3.4.0
|
|
84
86
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
85
87
|
requirements:
|
|
86
88
|
- - ">="
|