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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +152 -0
  3. data/LICENSE +674 -0
  4. data/README.md +34 -2
  5. data/lib/sevgi/graphics/attribute.rb +146 -32
  6. data/lib/sevgi/graphics/auxiliary/canvas.rb +64 -14
  7. data/lib/sevgi/graphics/auxiliary/content.rb +156 -21
  8. data/lib/sevgi/graphics/auxiliary/margin.rb +14 -2
  9. data/lib/sevgi/graphics/auxiliary/paper.rb +91 -84
  10. data/lib/sevgi/graphics/auxiliary/scalar.rb +40 -0
  11. data/lib/sevgi/graphics/auxiliary/sizes.rb +62 -0
  12. data/lib/sevgi/graphics/auxiliary.rb +2 -0
  13. data/lib/sevgi/graphics/document.rb +234 -56
  14. data/lib/sevgi/graphics/element.rb +42 -15
  15. data/lib/sevgi/graphics/mixtures/call.rb +29 -3
  16. data/lib/sevgi/graphics/mixtures/core.rb +138 -35
  17. data/lib/sevgi/graphics/mixtures/duplicate.rb +66 -16
  18. data/lib/sevgi/graphics/mixtures/export.rb +10 -2
  19. data/lib/sevgi/graphics/mixtures/identify.rb +3 -1
  20. data/lib/sevgi/graphics/mixtures/include.rb +28 -2
  21. data/lib/sevgi/graphics/mixtures/inkscape.rb +15 -0
  22. data/lib/sevgi/graphics/mixtures/rdf.rb +20 -0
  23. data/lib/sevgi/graphics/mixtures/render.rb +103 -24
  24. data/lib/sevgi/graphics/mixtures/save.rb +9 -0
  25. data/lib/sevgi/graphics/mixtures/symbols.rb +3 -0
  26. data/lib/sevgi/graphics/mixtures/tile.rb +17 -3
  27. data/lib/sevgi/graphics/mixtures/underscore.rb +14 -3
  28. data/lib/sevgi/graphics/mixtures/validate.rb +12 -0
  29. data/lib/sevgi/graphics/mixtures/wrappers.rb +4 -1
  30. data/lib/sevgi/graphics/mixtures.rb +4 -0
  31. data/lib/sevgi/graphics/version.rb +1 -1
  32. data/lib/sevgi/graphics/xml.rb +127 -0
  33. data/lib/sevgi/graphics.rb +33 -18
  34. 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 = @marks = []
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 [Array<Sevgi::Graphics::Mixtures::Render::Renderer::Inlines::Mark>]
105
- def start(index, depth) = @marks << Mark.new(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 latest inline splice range.
116
+ # Ends the innermost inline splice range.
108
117
  # @param index [Integer] output index
109
118
  # @return [Integer]
110
- def stop(index) = @marks.last.stop = index
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.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
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 style is missing or unsupported
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 style is missing or unsupported
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 ELEMENTS_WITH_BLOCK_CONTENT.include?(element.name)
297
+ return false if block_content?(element)
247
298
  return false if floating?(element)
248
299
 
249
- element.contents.size == 1 || ELEMENTS_WITH_INLINE_CONTENT.include?(element.name)
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 style is missing or unsupported
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 children
295
- def RenderChildren(separator = "\n\n") = children.map(&:Render).join(separator)
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| "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? },
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 [String] comment text
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 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
@@ -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 content is not a hash
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)
@@ -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.95.0"
7
7
  end
8
8
  end
@@ -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
@@ -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
- # 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
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
- # @raise [Sevgi::ArgumentError] when a named profile conflicts with an existing profile
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 profile name is invalid
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
- profile = Graphics::Paper[width, height, unit, name]
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
- # @param block [Proc, nil] DSL block evaluated in the root element
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 canvas profile is unknown
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