sevgi-graphics 0.93.1 → 0.95.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 +152 -0
- data/LICENSE +674 -0
- data/README.md +34 -2
- data/lib/sevgi/graphics/attribute.rb +146 -32
- data/lib/sevgi/graphics/auxiliary/canvas.rb +64 -14
- data/lib/sevgi/graphics/auxiliary/content.rb +156 -21
- data/lib/sevgi/graphics/auxiliary/margin.rb +14 -2
- data/lib/sevgi/graphics/auxiliary/paper.rb +91 -84
- data/lib/sevgi/graphics/auxiliary/scalar.rb +40 -0
- data/lib/sevgi/graphics/auxiliary/sizes.rb +62 -0
- data/lib/sevgi/graphics/auxiliary.rb +2 -0
- data/lib/sevgi/graphics/document.rb +234 -56
- data/lib/sevgi/graphics/element.rb +42 -15
- data/lib/sevgi/graphics/mixtures/call.rb +29 -3
- data/lib/sevgi/graphics/mixtures/core.rb +138 -35
- data/lib/sevgi/graphics/mixtures/duplicate.rb +66 -16
- data/lib/sevgi/graphics/mixtures/export.rb +10 -2
- data/lib/sevgi/graphics/mixtures/identify.rb +3 -1
- data/lib/sevgi/graphics/mixtures/include.rb +28 -2
- data/lib/sevgi/graphics/mixtures/inkscape.rb +15 -0
- data/lib/sevgi/graphics/mixtures/rdf.rb +20 -0
- data/lib/sevgi/graphics/mixtures/render.rb +103 -24
- data/lib/sevgi/graphics/mixtures/save.rb +9 -0
- data/lib/sevgi/graphics/mixtures/symbols.rb +3 -0
- data/lib/sevgi/graphics/mixtures/tile.rb +17 -3
- data/lib/sevgi/graphics/mixtures/underscore.rb +14 -3
- data/lib/sevgi/graphics/mixtures/validate.rb +12 -0
- data/lib/sevgi/graphics/mixtures/wrappers.rb +4 -1
- data/lib/sevgi/graphics/mixtures.rb +4 -0
- data/lib/sevgi/graphics/version.rb +1 -1
- data/lib/sevgi/graphics/xml.rb +127 -0
- data/lib/sevgi/graphics.rb +33 -18
- metadata +10 -5
|
@@ -10,6 +10,7 @@ module Sevgi
|
|
|
10
10
|
class Renderer
|
|
11
11
|
# Default renderer options.
|
|
12
12
|
DEFAULTS = {indent: " ", linelength: 140, style: :hybrid}.freeze
|
|
13
|
+
SVG_NAMESPACE = "http://www.w3.org/2000/svg"
|
|
13
14
|
|
|
14
15
|
# Attribute rendering strategies.
|
|
15
16
|
# @api private
|
|
@@ -77,7 +78,7 @@ module Sevgi
|
|
|
77
78
|
ELEMENTS_WITH_BLOCK_CONTENT = %i[style].freeze
|
|
78
79
|
SEPARATOR = "\n"
|
|
79
80
|
|
|
80
|
-
private_constant :ELEMENTS_WITH_INLINE_CONTENT, :ELEMENTS_WITH_BLOCK_CONTENT, :SEPARATOR
|
|
81
|
+
private_constant :ELEMENTS_WITH_INLINE_CONTENT, :ELEMENTS_WITH_BLOCK_CONTENT, :SEPARATOR, :SVG_NAMESPACE
|
|
81
82
|
|
|
82
83
|
# @return [Sevgi::Graphics::Element] root element
|
|
83
84
|
attr_reader :root
|
|
@@ -96,39 +97,61 @@ module Sevgi
|
|
|
96
97
|
|
|
97
98
|
# Creates an inline splice tracker.
|
|
98
99
|
# @return [void]
|
|
99
|
-
def initialize
|
|
100
|
+
def initialize
|
|
101
|
+
@marks = []
|
|
102
|
+
@stack = []
|
|
103
|
+
end
|
|
100
104
|
|
|
101
105
|
# Starts an inline splice range.
|
|
102
106
|
# @param index [Integer] output index
|
|
103
107
|
# @param depth [Integer] element depth
|
|
104
|
-
# @return [
|
|
105
|
-
def start(index, depth)
|
|
108
|
+
# @return [Sevgi::Graphics::Mixtures::Render::Renderer::Inlines::Mark] opened mark
|
|
109
|
+
def start(index, depth)
|
|
110
|
+
Mark.new(start: index, depth:).tap do |mark|
|
|
111
|
+
@marks << mark
|
|
112
|
+
@stack << mark
|
|
113
|
+
end
|
|
114
|
+
end
|
|
106
115
|
|
|
107
|
-
# Ends the
|
|
116
|
+
# Ends the innermost inline splice range.
|
|
108
117
|
# @param index [Integer] output index
|
|
109
118
|
# @return [Integer]
|
|
110
|
-
|
|
119
|
+
# @raise [Sevgi::PanicError] when no inline range is open
|
|
120
|
+
def stop(index)
|
|
121
|
+
mark = @stack.pop
|
|
122
|
+
PanicError.("Inline content range was not opened") unless mark
|
|
123
|
+
|
|
124
|
+
mark.stop = index
|
|
125
|
+
end
|
|
111
126
|
|
|
112
127
|
# Joins marked output ranges into inline content.
|
|
113
128
|
# @param output [Array<Array<String>, nil>] renderer output buffer
|
|
114
129
|
# @param indent [String] indentation unit
|
|
115
130
|
# @param separator [String] line separator
|
|
116
131
|
# @return [Array<Array<String>, nil>, nil]
|
|
132
|
+
# @raise [Sevgi::PanicError] when an inline range was not closed
|
|
117
133
|
def join(output, indent:, separator:)
|
|
118
134
|
return if @marks.empty?
|
|
119
135
|
|
|
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
|
|
136
|
+
@marks.reverse_each { |mark| join_mark(output, mark, indent:, separator:) }
|
|
129
137
|
|
|
130
138
|
output.compact!
|
|
131
139
|
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def join_mark(output, mark, indent:, separator:)
|
|
144
|
+
PanicError.("Inline content range was not closed") unless mark.stop
|
|
145
|
+
|
|
146
|
+
((mark.start + 1)..mark.stop).each do |index|
|
|
147
|
+
lines = output[index]
|
|
148
|
+
next unless lines
|
|
149
|
+
|
|
150
|
+
lines.map! { |line| line.delete_prefix(indent * (mark.depth + 1)) }
|
|
151
|
+
output[mark.start][-1] += lines.join(separator)
|
|
152
|
+
output[index] = nil
|
|
153
|
+
end
|
|
154
|
+
end
|
|
132
155
|
end
|
|
133
156
|
|
|
134
157
|
# @overload initialize(root, **options)
|
|
@@ -139,7 +162,7 @@ module Sevgi
|
|
|
139
162
|
# @option options [Integer] :linelength hybrid attribute line length
|
|
140
163
|
# @option options [Symbol] :style attribute style, one of :hybrid, :inline, or :block
|
|
141
164
|
# @return [void]
|
|
142
|
-
# @raise [Sevgi::ArgumentError] when
|
|
165
|
+
# @raise [Sevgi::ArgumentError] when an option is unknown, malformed, missing, or unsupported
|
|
143
166
|
def initialize(root, **)
|
|
144
167
|
@root = root
|
|
145
168
|
@options = DEFAULTS.merge(**)
|
|
@@ -154,9 +177,16 @@ module Sevgi
|
|
|
154
177
|
# @param root [Sevgi::Graphics::Element] root element
|
|
155
178
|
# @param options [Hash] renderer options
|
|
156
179
|
# @return [String] SVG source
|
|
157
|
-
# @raise [Sevgi::ArgumentError] when
|
|
180
|
+
# @raise [Sevgi::ArgumentError] when options, preambles, names, attributes, or content are invalid XML
|
|
158
181
|
def self.call(root, **) = new(root, **).call(*root.class.preambles)
|
|
159
182
|
|
|
183
|
+
# Renders a root element without document preambles.
|
|
184
|
+
# @param root [Sevgi::Graphics::Element] root element
|
|
185
|
+
# @param options [Hash] renderer options
|
|
186
|
+
# @return [String] SVG fragment source
|
|
187
|
+
# @raise [Sevgi::ArgumentError] when options, names, attributes, or content are invalid XML
|
|
188
|
+
def self.fragment(root, **options) = new(root, **options).call
|
|
189
|
+
|
|
160
190
|
# Appends rendered lines to the output buffer.
|
|
161
191
|
# @param depth [Integer, nil] indentation depth
|
|
162
192
|
# @param lines [Array<String>] rendered lines
|
|
@@ -170,8 +200,9 @@ module Sevgi
|
|
|
170
200
|
# Renders the document.
|
|
171
201
|
# @param preambles [Array<String>] preamble lines
|
|
172
202
|
# @return [String] SVG source
|
|
203
|
+
# @raise [Sevgi::ArgumentError] when a preamble or rendered value is not valid XML text
|
|
173
204
|
def call(*preambles)
|
|
174
|
-
output.append(preambles) unless preambles.empty?
|
|
205
|
+
output.append(preambles.map { XML.text(it, context: "XML preamble") }) unless preambles.empty?
|
|
175
206
|
|
|
176
207
|
root.Traverse(
|
|
177
208
|
0,
|
|
@@ -205,6 +236,7 @@ module Sevgi
|
|
|
205
236
|
end
|
|
206
237
|
|
|
207
238
|
def build
|
|
239
|
+
validate_options!
|
|
208
240
|
ArgumentError.("Missing style") unless options[:style]
|
|
209
241
|
|
|
210
242
|
case options[:style]
|
|
@@ -225,10 +257,29 @@ module Sevgi
|
|
|
225
257
|
element.children.empty? && element.contents.empty?
|
|
226
258
|
end
|
|
227
259
|
|
|
260
|
+
def block_content?(element)
|
|
261
|
+
return false unless ELEMENTS_WITH_BLOCK_CONTENT.include?(element.name)
|
|
262
|
+
|
|
263
|
+
namespace = default_namespace(element)
|
|
264
|
+
namespace.nil? || namespace.to_s.empty? || namespace == SVG_NAMESPACE
|
|
265
|
+
end
|
|
266
|
+
|
|
228
267
|
def closed = @closed = true
|
|
229
268
|
|
|
230
269
|
def closed? = @closed.tap { unclosed }
|
|
231
270
|
|
|
271
|
+
def default_namespace(element)
|
|
272
|
+
current = element
|
|
273
|
+
|
|
274
|
+
while current.is_a?(Element)
|
|
275
|
+
return current.attributes[:xmlns] if current.attributes.has?(:xmlns)
|
|
276
|
+
|
|
277
|
+
current = current.parent
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
nil
|
|
281
|
+
end
|
|
282
|
+
|
|
232
283
|
def contents(element, depth)
|
|
233
284
|
return if element.contents.empty?
|
|
234
285
|
|
|
@@ -243,10 +294,10 @@ module Sevgi
|
|
|
243
294
|
def floating?(element) = element.Is?(:_)
|
|
244
295
|
|
|
245
296
|
def inline_content?(element)
|
|
246
|
-
return false if
|
|
297
|
+
return false if block_content?(element)
|
|
247
298
|
return false if floating?(element)
|
|
248
299
|
|
|
249
|
-
element.contents.
|
|
300
|
+
element.contents.any? || ELEMENTS_WITH_INLINE_CONTENT.include?(element.name)
|
|
250
301
|
end
|
|
251
302
|
|
|
252
303
|
def indent(depth)
|
|
@@ -255,7 +306,7 @@ module Sevgi
|
|
|
255
306
|
|
|
256
307
|
def join
|
|
257
308
|
inlines.join(output, indent: options[:indent], separator: SEPARATOR)
|
|
258
|
-
output.join(SEPARATOR)
|
|
309
|
+
XML.text(output.join(SEPARATOR), context: "SVG output")
|
|
259
310
|
end
|
|
260
311
|
|
|
261
312
|
def render_enter(element, depth)
|
|
@@ -278,21 +329,49 @@ module Sevgi
|
|
|
278
329
|
end
|
|
279
330
|
|
|
280
331
|
def unclosed = @closed = false
|
|
332
|
+
|
|
333
|
+
def validate_options!
|
|
334
|
+
unknown = options.keys - DEFAULTS.keys
|
|
335
|
+
ArgumentError.("Unknown renderer options: #{unknown.join(", ")}") unless unknown.empty?
|
|
336
|
+
|
|
337
|
+
indent = options[:indent]
|
|
338
|
+
unless indent.is_a?(::String) && /\A[\t\n\r ]*\z/.match?(indent)
|
|
339
|
+
ArgumentError.("Renderer indent must contain only XML whitespace")
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
options[:indent] = XML.text(indent, context: "Renderer indent")
|
|
343
|
+
linelength = options[:linelength]
|
|
344
|
+
return if linelength.is_a?(::Integer) && linelength >= 0
|
|
345
|
+
|
|
346
|
+
ArgumentError.("Renderer linelength must be a non-negative Integer")
|
|
347
|
+
end
|
|
281
348
|
end
|
|
282
349
|
|
|
283
350
|
private_constant :Renderer
|
|
284
351
|
|
|
285
352
|
# @overload Render(**options)
|
|
286
353
|
# Renders this element as SVG source.
|
|
354
|
+
# Elements with inline text content may also contain inline children such as `tspan`; the renderer keeps those
|
|
355
|
+
# descendants in the same text line. Whitespace inside content objects is preserved as given, and encoded
|
|
356
|
+
# content is XML-escaped unless a verbatim content object is used. SVG `style` elements use block content;
|
|
357
|
+
# same-named elements under a foreign default namespace retain ordinary inline text formatting.
|
|
287
358
|
# @param options [Hash] renderer options
|
|
288
359
|
# @return [String] SVG source
|
|
289
|
-
# @raise [Sevgi::ArgumentError] when
|
|
360
|
+
# @raise [Sevgi::ArgumentError] when options, preambles, names, attributes, or content are invalid XML
|
|
290
361
|
def Render(**) = Renderer.(self, **)
|
|
291
362
|
|
|
292
363
|
# Renders only this element's children.
|
|
364
|
+
# Child render output omits document preambles and preserves each child's text whitespace and inline
|
|
365
|
+
# mixed-content formatting.
|
|
293
366
|
# @param separator [String] separator between child documents
|
|
294
|
-
# @return [String] rendered
|
|
295
|
-
|
|
367
|
+
# @return [String] rendered child fragments
|
|
368
|
+
# @raise [Sevgi::ArgumentError] when separator or rendered child data is not valid XML text
|
|
369
|
+
def RenderChildren(separator = "\n\n")
|
|
370
|
+
ArgumentError.("SVG fragment separator must be a String") unless separator.is_a?(::String)
|
|
371
|
+
|
|
372
|
+
separator = XML.text(separator, context: "SVG fragment separator")
|
|
373
|
+
children.map { Renderer.fragment(it) }.join(separator)
|
|
374
|
+
end
|
|
296
375
|
end
|
|
297
376
|
end
|
|
298
377
|
end
|
|
@@ -12,6 +12,9 @@ module Sevgi
|
|
|
12
12
|
|
|
13
13
|
# Writes rendered SVG to standard output.
|
|
14
14
|
# @param kwargs [Hash] render options
|
|
15
|
+
# @yield [content] optionally transforms rendered content before output
|
|
16
|
+
# @yieldparam content [String] rendered SVG source
|
|
17
|
+
# @yieldreturn [String] transformed SVG source
|
|
15
18
|
# @return [Object] F.out return value
|
|
16
19
|
def Out(**kwargs, &filter)
|
|
17
20
|
F.out(self.(**kwargs), &filter)
|
|
@@ -21,6 +24,9 @@ module Sevgi
|
|
|
21
24
|
# @param path [String, nil] output path or directory
|
|
22
25
|
# @param default [String, nil] default output path
|
|
23
26
|
# @param backup_suffix [String, nil] suffix used for an existing-file backup
|
|
27
|
+
# @yield [content] optionally transforms rendered content before output
|
|
28
|
+
# @yieldparam content [String] rendered SVG source
|
|
29
|
+
# @yieldreturn [String] transformed SVG source
|
|
24
30
|
# @return [Object] F.out return value
|
|
25
31
|
def Save(path = nil, default: nil, backup_suffix: nil, &filter)
|
|
26
32
|
default ||= F.subext(EXT, caller_locations(1..1).first.path)
|
|
@@ -42,6 +48,9 @@ module Sevgi
|
|
|
42
48
|
# Writes rendered SVG to a path.
|
|
43
49
|
# @param path [String] output path
|
|
44
50
|
# @param kwargs [Hash] render options
|
|
51
|
+
# @yield [content] optionally transforms rendered content before output
|
|
52
|
+
# @yieldparam content [String] rendered SVG source
|
|
53
|
+
# @yieldreturn [String] transformed SVG source
|
|
45
54
|
# @return [Object] F.out return value
|
|
46
55
|
def Write(path, **kwargs, &filter)
|
|
47
56
|
F.out(self.(**kwargs), path, &filter)
|
|
@@ -9,6 +9,9 @@ module Sevgi
|
|
|
9
9
|
# @param mod [Module] callable drawing module
|
|
10
10
|
# @param args [Array<Object>] callable arguments
|
|
11
11
|
# @param kwargs [Hash] defs attributes
|
|
12
|
+
# @yield [name] converts each callable name to a symbol id
|
|
13
|
+
# @yieldparam name [Symbol] callable method name
|
|
14
|
+
# @yieldreturn [String, Symbol] symbol id
|
|
12
15
|
# @return [Sevgi::Graphics::Element] defs element
|
|
13
16
|
# @raise [Sevgi::ArgumentError] when mod is not a plain module
|
|
14
17
|
def Symbols(mod, *args, **kwargs, &block)
|
|
@@ -18,6 +18,8 @@ module Sevgi
|
|
|
18
18
|
# @param dy [Numeric] vertical spacing
|
|
19
19
|
# @param oy [Numeric] vertical offset
|
|
20
20
|
# @param proc [Proc, nil] optional coordinate/customization proc
|
|
21
|
+
# @yield evaluates the template drawing DSL in a generated group
|
|
22
|
+
# @yieldreturn [Object] ignored block result
|
|
21
23
|
# @return [Sevgi::Graphics::Element] self
|
|
22
24
|
# @raise [Sevgi::ArgumentError] when a required tile argument is missing or invalid
|
|
23
25
|
def Tile(
|
|
@@ -67,6 +69,8 @@ module Sevgi
|
|
|
67
69
|
# @param d [Numeric] horizontal spacing
|
|
68
70
|
# @param o [Numeric] horizontal offset
|
|
69
71
|
# @param proc [Proc, nil] optional coordinate/customization proc
|
|
72
|
+
# @yield evaluates the template drawing DSL in a generated group
|
|
73
|
+
# @yieldreturn [Object] ignored block result
|
|
70
74
|
# @return [Sevgi::Graphics::Element] self
|
|
71
75
|
# @raise [Sevgi::ArgumentError] when a required tile argument is missing or invalid
|
|
72
76
|
def TileX(id = Undefined, n: Undefined, d: Undefined, o: 0, proc: nil, &block)
|
|
@@ -100,6 +104,8 @@ module Sevgi
|
|
|
100
104
|
# @param d [Numeric] vertical spacing
|
|
101
105
|
# @param o [Numeric] vertical offset
|
|
102
106
|
# @param proc [Proc, nil] optional coordinate/customization proc
|
|
107
|
+
# @yield evaluates the template drawing DSL in a generated group
|
|
108
|
+
# @yieldreturn [Object] ignored block result
|
|
103
109
|
# @return [Sevgi::Graphics::Element] self
|
|
104
110
|
# @raise [Sevgi::ArgumentError] when a required tile argument is missing or invalid
|
|
105
111
|
def TileY(id = Undefined, n: Undefined, d: Undefined, o: 0, proc: nil, &block)
|
|
@@ -135,9 +141,9 @@ module Sevgi
|
|
|
135
141
|
# Argument validators for tile helpers.
|
|
136
142
|
ASSERTION = {
|
|
137
143
|
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|
|
|
144
|
+
n: proc { |name, value| positive_integer_issue(name, value) },
|
|
145
|
+
nx: proc { |name, value| positive_integer_issue(name, value) },
|
|
146
|
+
ny: proc { |name, value| positive_integer_issue(name, value) },
|
|
141
147
|
d: proc { |name, value| "Argument '#{name}' must be a number" unless value.is_a?(::Numeric) },
|
|
142
148
|
dx: proc { |name, value| "Argument '#{name}' must be a number" unless value.is_a?(::Numeric) },
|
|
143
149
|
dy: proc { |name, value| "Argument '#{name}' must be a number" unless value.is_a?(::Numeric) },
|
|
@@ -147,6 +153,14 @@ module Sevgi
|
|
|
147
153
|
proc: proc { |name, value| "Argument '#{name}' must be a proc" unless value.nil? || value.is_a?(::Proc) }
|
|
148
154
|
}.freeze
|
|
149
155
|
|
|
156
|
+
# Returns a validation issue unless value is a positive integer.
|
|
157
|
+
# @param name [Symbol] argument name
|
|
158
|
+
# @param value [Object] argument value
|
|
159
|
+
# @return [String, nil] validation issue
|
|
160
|
+
def positive_integer_issue(name, value)
|
|
161
|
+
"Argument '#{name}' must be a positive integer" unless value.is_a?(::Integer) && value.positive?
|
|
162
|
+
end
|
|
163
|
+
|
|
150
164
|
# Validates tile arguments.
|
|
151
165
|
# @param kwargs [Hash] tile arguments
|
|
152
166
|
# @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 cannot be stringified as valid XML or would form malformed markup
|
|
18
19
|
def Comment(comment)
|
|
20
|
+
comment = XML.text(comment, context: "XML comment")
|
|
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
|
|
@@ -4,6 +4,18 @@ module Sevgi
|
|
|
4
4
|
module Graphics
|
|
5
5
|
module Mixtures
|
|
6
6
|
# DSL helpers for SVG standard validation.
|
|
7
|
+
#
|
|
8
|
+
# @!method CData
|
|
9
|
+
# Returns text content used as SVG character data.
|
|
10
|
+
# @return [String, nil] joined text content or nil
|
|
11
|
+
# @!method NS?(name)
|
|
12
|
+
# Reports whether a namespace is available on this element or an ancestor.
|
|
13
|
+
# @param name [Symbol, String] namespace suffix without xmlns:
|
|
14
|
+
# @return [Boolean]
|
|
15
|
+
# @!method Validate
|
|
16
|
+
# Validates this subtree against SVG standard metadata when that component is available.
|
|
17
|
+
# @return [Sevgi::Graphics::Element, nil] self after validation, or nil without sevgi/standard
|
|
18
|
+
# @raise [Sevgi::ValidationError] when SVG validation fails
|
|
7
19
|
module Validate
|
|
8
20
|
# Returns text content used as SVG character data.
|
|
9
21
|
# @return [String, nil] joined text content or nil
|
|
@@ -27,6 +27,8 @@ module Sevgi
|
|
|
27
27
|
# Builds a titled SVG symbol.
|
|
28
28
|
# @param name [String] human-readable symbol name
|
|
29
29
|
# @param kwargs [Hash] symbol attributes
|
|
30
|
+
# @yield evaluates the drawing DSL in the symbol element
|
|
31
|
+
# @yieldreturn [Object] ignored block result
|
|
30
32
|
# @return [Sevgi::Graphics::Element] symbol element
|
|
31
33
|
def Symbol(name, **kwargs, &block)
|
|
32
34
|
id = (words = name.split).map(&:downcase).join("-")
|
|
@@ -63,7 +65,8 @@ module Sevgi
|
|
|
63
65
|
# @param hash [Hash, nil] CSS rules
|
|
64
66
|
# @param attributes [Hash] style attributes or CSS rules when hash is nil
|
|
65
67
|
# @return [Sevgi::Graphics::Element] style element
|
|
66
|
-
# @raise [Sevgi::ArgumentError] when CSS
|
|
68
|
+
# @raise [Sevgi::ArgumentError] when CSS rules are malformed, cyclic, cannot be stringified, or contain invalid
|
|
69
|
+
# encoding or illegal XML 1.0 characters
|
|
67
70
|
def css(hash = nil, **attributes)
|
|
68
71
|
hash, attributes = attributes, {} unless hash
|
|
69
72
|
|
|
@@ -8,11 +8,15 @@ module Sevgi
|
|
|
8
8
|
# Includes a named mixture and optional anonymous extension into a document class.
|
|
9
9
|
# @param mod [Symbol, String] mixture constant name
|
|
10
10
|
# @param document [Class] document class receiving the mixture
|
|
11
|
+
# @yield defines methods in the anonymous mixture
|
|
12
|
+
# @yieldreturn [Object] ignored module-definition result
|
|
11
13
|
# @return [Module, nil] optional anonymous mixture when a block is given
|
|
12
14
|
# @raise [NameError] when the mixture does not exist
|
|
13
15
|
# @overload mixin(document = Graphics::Document::Base, &block)
|
|
14
16
|
# Includes only an anonymous extension into a document class.
|
|
15
17
|
# @param document [Class] document class receiving the mixture
|
|
18
|
+
# @yield defines methods in the anonymous mixture
|
|
19
|
+
# @yieldreturn [Object] ignored module-definition result
|
|
16
20
|
# @return [Module] anonymous mixture
|
|
17
21
|
# @raise [Sevgi::ArgumentError] when no named mixture or block is given
|
|
18
22
|
def self.mixin(mod = Undefined, document = Graphics::Document::Base, &block)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sevgi
|
|
4
|
+
module Graphics
|
|
5
|
+
# XML 1.0 text and qualified-name validation shared by renderer boundaries.
|
|
6
|
+
# @api private
|
|
7
|
+
module XML
|
|
8
|
+
NCNAME_START = "A-Z_a-z\u{C0}-\u{D6}\u{D8}-\u{F6}\u{F8}-\u{2FF}\u{370}-\u{37D}\u{37F}-\u{1FFF}" \
|
|
9
|
+
"\u{200C}-\u{200D}\u{2070}-\u{218F}\u{2C00}-\u{2FEF}\u{3001}-\u{D7FF}\u{F900}-\u{FDCF}" \
|
|
10
|
+
"\u{FDF0}-\u{FFFD}\u{10000}-\u{EFFFF}"
|
|
11
|
+
NCNAME_CHAR = "#{NCNAME_START}\\-.0-9\u{B7}\u{300}-\u{36F}\u{203F}-\u{2040}".freeze
|
|
12
|
+
NCNAME = "[#{NCNAME_START}][#{NCNAME_CHAR}]*".freeze
|
|
13
|
+
QNAME = /\A#{NCNAME}(?::#{NCNAME})?\z/u
|
|
14
|
+
|
|
15
|
+
private_constant :NCNAME, :NCNAME_CHAR, :NCNAME_START, :QNAME
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
# Validates an XML 1.0 string representation.
|
|
19
|
+
# @param value [Object] value to stringify and validate
|
|
20
|
+
# @param context [String] error-message subject
|
|
21
|
+
# @return [String] UTF-8 text
|
|
22
|
+
# @raise [Sevgi::ArgumentError] when stringification, encoding, or XML character validation fails
|
|
23
|
+
def text(value, context: "XML content")
|
|
24
|
+
value = stringify(value, context:) unless value.is_a?(::String)
|
|
25
|
+
validate_string(value, context:)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Validates and snapshots a nested XML-bound value.
|
|
29
|
+
# @param value [Object] scalar or nested container
|
|
30
|
+
# @param context [String] error-message subject
|
|
31
|
+
# @param seen [Hash] container identities on the current traversal path
|
|
32
|
+
# @return [Object] validated caller-owned snapshot
|
|
33
|
+
# @raise [Sevgi::ArgumentError] when the value is cyclic, collides after stringification, or has invalid XML text
|
|
34
|
+
def snapshot(value, context:, seen: {}.compare_by_identity)
|
|
35
|
+
case value
|
|
36
|
+
when ::Hash
|
|
37
|
+
nested(value, context:, seen:) { snapshot_hash(value, context:, seen:) }
|
|
38
|
+
when ::Array
|
|
39
|
+
nested(value, context:, seen:) { value.map { snapshot(it, context:, seen:) } }
|
|
40
|
+
else
|
|
41
|
+
text(value, context:)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Validates an XML qualified name.
|
|
46
|
+
# @param value [Object] candidate name
|
|
47
|
+
# @param context [String] error-message subject
|
|
48
|
+
# @return [String] validated qualified name
|
|
49
|
+
# @raise [Sevgi::ArgumentError] when text conversion or QName validation fails
|
|
50
|
+
def name(value, context: "XML name")
|
|
51
|
+
text(value, context:).tap do |candidate|
|
|
52
|
+
ArgumentError.("#{context} is invalid: #{candidate.inspect}") unless QNAME.match?(candidate)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Validates a nested XML value without retaining its snapshot.
|
|
57
|
+
# @param value [Object] scalar or nested container
|
|
58
|
+
# @param context [String] error-message subject
|
|
59
|
+
# @return [Object] original value
|
|
60
|
+
# @raise [Sevgi::ArgumentError] when nested content is cyclic or contains invalid XML text
|
|
61
|
+
def validate(value, context: "XML content")
|
|
62
|
+
snapshot(value, context:)
|
|
63
|
+
value
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Escapes embedded CDATA terminators after validating text.
|
|
67
|
+
# @param value [Object] CDATA value
|
|
68
|
+
# @return [String] safe CDATA body
|
|
69
|
+
# @raise [Sevgi::ArgumentError] when value is not valid XML text
|
|
70
|
+
def cdata(value) = text(value).gsub("]]>", "]]]]><![CDATA[>")
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def nested(value, context:, seen:)
|
|
75
|
+
ArgumentError.("Cyclic #{context} is not supported") if seen.key?(value)
|
|
76
|
+
|
|
77
|
+
seen[value] = true
|
|
78
|
+
yield
|
|
79
|
+
ensure
|
|
80
|
+
seen.delete(value)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def snapshot_hash(value, context:, seen:)
|
|
84
|
+
value.each_with_object({}) do |(key, item), captured|
|
|
85
|
+
key = snapshot(key, context:, seen:)
|
|
86
|
+
ArgumentError.("#{context} keys collide after stringification") if captured.key?(key)
|
|
87
|
+
|
|
88
|
+
captured[key] = snapshot(item, context:, seen:)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def stringify(value, context:)
|
|
93
|
+
text = value.to_s
|
|
94
|
+
ArgumentError.("#{context} stringification must return a String") unless text.is_a?(::String)
|
|
95
|
+
|
|
96
|
+
text
|
|
97
|
+
rescue Sevgi::ArgumentError
|
|
98
|
+
raise
|
|
99
|
+
rescue ::StandardError => e
|
|
100
|
+
ArgumentError.("#{context} cannot be stringified: #{e.class}: #{e.message}")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def validate_string(value, context:)
|
|
104
|
+
ArgumentError.("#{context} must be valid UTF-8") unless value.valid_encoding?
|
|
105
|
+
|
|
106
|
+
text = value.encode("UTF-8")
|
|
107
|
+
if (codepoint = text.each_codepoint.find { !legal_codepoint?(it) })
|
|
108
|
+
ArgumentError.("#{context} contains illegal character U+#{format("%04X", codepoint)}")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
text
|
|
112
|
+
rescue ::EncodingError => e
|
|
113
|
+
ArgumentError.("#{context} must be valid UTF-8: #{e.message}")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def legal_codepoint?(codepoint)
|
|
117
|
+
[0x9, 0xA, 0xD].include?(codepoint) ||
|
|
118
|
+
(0x20..0xD7FF).cover?(codepoint) ||
|
|
119
|
+
(0xE000..0xFFFD).cover?(codepoint) ||
|
|
120
|
+
(0x10000..0x10FFFF).cover?(codepoint)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private_constant :XML
|
|
126
|
+
end
|
|
127
|
+
end
|
data/lib/sevgi/graphics.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "sevgi/function"
|
|
4
4
|
|
|
5
|
+
require_relative "graphics/xml"
|
|
5
6
|
require_relative "graphics/attribute"
|
|
6
7
|
require_relative "graphics/auxiliary"
|
|
7
8
|
require_relative "graphics/element"
|
|
@@ -24,27 +25,46 @@ module Sevgi
|
|
|
24
25
|
Graphics::Canvas.from_paper(...)
|
|
25
26
|
end
|
|
26
27
|
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
28
|
+
# @overload document(name)
|
|
29
|
+
# Looks up an existing document profile by name.
|
|
30
|
+
# @param name [Symbol, String] profile name
|
|
31
|
+
# @return [Class] document class
|
|
32
|
+
# @raise [Sevgi::ArgumentError] when the profile is unknown
|
|
33
|
+
# @overload document(name, preambles: Undefined, attributes: Undefined)
|
|
34
|
+
# Looks up a named profile when both definition keywords are omitted. Supplying `preambles:` or `attributes:`
|
|
35
|
+
# defines a named profile, or returns an existing profile when every explicitly supplied field matches. Omitted
|
|
36
|
+
# fields are ignored during existing-profile comparison. Profile containers and strings are copied into the
|
|
37
|
+
# process-global, thread-atomic registry; mutable non-container attribute values are stringified once before
|
|
38
|
+
# registration. Concurrent identical definitions return the same canonical registered class.
|
|
39
|
+
# @param name [Symbol, String] profile name
|
|
40
|
+
# @param preambles [Array<String>, nil, Sevgi::Undefined] document preamble lines
|
|
41
|
+
# @param attributes [Hash, nil, Sevgi::Undefined] default root attributes; nil means an empty Hash
|
|
42
|
+
# @return [Class] document class
|
|
43
|
+
# @raise [Sevgi::ArgumentError] when a profile conflicts or metadata is invalid XML, cyclic, or cannot be stringified
|
|
44
|
+
# @overload document(preambles: Undefined, attributes: Undefined)
|
|
45
|
+
# Defines an anonymous document profile without registering it globally.
|
|
46
|
+
# @param preambles [Array<String>, nil, Sevgi::Undefined] document preamble lines
|
|
47
|
+
# @param attributes [Hash, nil, Sevgi::Undefined] default root attributes; nil means an empty Hash
|
|
48
|
+
# @return [Class] anonymous document class
|
|
49
|
+
# @raise [Sevgi::ArgumentError] when metadata is invalid XML, cyclic, or cannot be stringified
|
|
31
50
|
# @return [Class] document class
|
|
32
|
-
|
|
33
|
-
def document(name = Undefined, preambles: [], attributes: {})
|
|
51
|
+
def document(name = Undefined, preambles: Undefined, attributes: Undefined)
|
|
34
52
|
Graphics::Document.define(name, preambles:, attributes:)
|
|
35
53
|
end
|
|
36
54
|
|
|
37
55
|
# Defines or replaces a document profile class.
|
|
56
|
+
# Validation and snapshot capture complete before an existing registration is atomically replaced.
|
|
38
57
|
# @param name [Symbol, String] profile name
|
|
39
58
|
# @param preambles [Array<String>, nil] document preamble lines
|
|
40
|
-
# @param attributes [Hash] default root attributes
|
|
59
|
+
# @param attributes [Hash, nil] default root attributes; nil means an empty Hash
|
|
41
60
|
# @return [Class] document class
|
|
42
|
-
# @raise [Sevgi::ArgumentError] when the
|
|
61
|
+
# @raise [Sevgi::ArgumentError] when the name or metadata is invalid XML, cyclic, or cannot be stringified
|
|
43
62
|
def document!(name, preambles: [], attributes: {})
|
|
44
63
|
Graphics::Document.define(name, preambles:, attributes:, overwrite: true)
|
|
45
64
|
end
|
|
46
65
|
|
|
47
|
-
# Defines a paper profile unless the same profile already exists.
|
|
66
|
+
# Defines a paper profile unless the same profile already exists. Registration is process-global and thread-atomic;
|
|
67
|
+
# an identical concurrent definition is idempotent and a conflicting definition is rejected.
|
|
48
68
|
# @param width [Numeric] paper width
|
|
49
69
|
# @param height [Numeric] paper height
|
|
50
70
|
# @param name [Symbol, String] profile name
|
|
@@ -52,13 +72,7 @@ module Sevgi
|
|
|
52
72
|
# @return [Symbol, String] original profile name
|
|
53
73
|
# @raise [Sevgi::ArgumentError] when the profile is invalid or an existing profile has different dimensions
|
|
54
74
|
def paper(width, height, name = :custom, unit: "mm")
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if Graphics::Paper.exist?(name)
|
|
58
|
-
ArgumentError.("Paper already defined differently: #{name}") unless Graphics::Paper.public_send(name) == profile
|
|
59
|
-
else
|
|
60
|
-
Graphics::Paper.define(name, width:, height:, unit:)
|
|
61
|
-
end
|
|
75
|
+
Graphics::Paper.define(name, width:, height:, unit:, overwrite: false)
|
|
62
76
|
|
|
63
77
|
name
|
|
64
78
|
end
|
|
@@ -77,9 +91,10 @@ module Sevgi
|
|
|
77
91
|
# Builds an SVG root document.
|
|
78
92
|
# @param document [Symbol, String, Class] document profile name or document class
|
|
79
93
|
# @param canvas [Sevgi::Graphics::Canvas, Sevgi::Graphics::Paper, Symbol, String, Sevgi::Undefined, nil] canvas input
|
|
80
|
-
# @
|
|
94
|
+
# @yield evaluates the drawing DSL in the root element
|
|
95
|
+
# @yieldreturn [Object] ignored block result
|
|
81
96
|
# @return [Sevgi::Graphics::Document::Proto] SVG root element
|
|
82
|
-
# @raise [Sevgi::ArgumentError] when the document or
|
|
97
|
+
# @raise [Sevgi::ArgumentError] when the document/canvas profile or root XML attributes are invalid
|
|
83
98
|
def SVG(document = :default, canvas = Undefined, **, &block)
|
|
84
99
|
Graphics::Document.(document, canvas, **, &block)
|
|
85
100
|
end
|