sevgi-graphics 0.94.0 → 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.
@@ -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)
@@ -15,9 +15,9 @@ module Sevgi
15
15
  # Adds an XML comment.
16
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
+ # @raise [Sevgi::ArgumentError] when comment cannot be stringified as valid XML or would form malformed markup
19
19
  def Comment(comment)
20
- comment = comment.to_s
20
+ comment = XML.text(comment, context: "XML comment")
21
21
 
22
22
  ArgumentError.("XML comment must not contain '--'") if comment.include?("--")
23
23
  ArgumentError.("XML comment must not end with '-'") if comment.end_with?("-")
@@ -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.94.0"
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"
@@ -30,35 +31,40 @@ module Sevgi
30
31
  # @return [Class] document class
31
32
  # @raise [Sevgi::ArgumentError] when the profile is unknown
32
33
  # @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.
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.
35
39
  # @param name [Symbol, String] profile name
36
40
  # @param preambles [Array<String>, nil, Sevgi::Undefined] document preamble lines
37
- # @param attributes [Hash, Sevgi::Undefined] default root attributes
41
+ # @param attributes [Hash, nil, Sevgi::Undefined] default root attributes; nil means an empty Hash
38
42
  # @return [Class] document class
39
- # @raise [Sevgi::ArgumentError] when a named profile conflicts with an existing profile
43
+ # @raise [Sevgi::ArgumentError] when a profile conflicts or metadata is invalid XML, cyclic, or cannot be stringified
40
44
  # @overload document(preambles: Undefined, attributes: Undefined)
41
45
  # Defines an anonymous document profile without registering it globally.
42
46
  # @param preambles [Array<String>, nil, Sevgi::Undefined] document preamble lines
43
- # @param attributes [Hash, Sevgi::Undefined] default root attributes
47
+ # @param attributes [Hash, nil, Sevgi::Undefined] default root attributes; nil means an empty Hash
44
48
  # @return [Class] anonymous document class
45
- # @raise [Sevgi::ArgumentError] when profile input is invalid
49
+ # @raise [Sevgi::ArgumentError] when metadata is invalid XML, cyclic, or cannot be stringified
46
50
  # @return [Class] document class
47
51
  def document(name = Undefined, preambles: Undefined, attributes: Undefined)
48
52
  Graphics::Document.define(name, preambles:, attributes:)
49
53
  end
50
54
 
51
55
  # Defines or replaces a document profile class.
56
+ # Validation and snapshot capture complete before an existing registration is atomically replaced.
52
57
  # @param name [Symbol, String] profile name
53
58
  # @param preambles [Array<String>, nil] document preamble lines
54
- # @param attributes [Hash] default root attributes
59
+ # @param attributes [Hash, nil] default root attributes; nil means an empty Hash
55
60
  # @return [Class] document class
56
- # @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
57
62
  def document!(name, preambles: [], attributes: {})
58
63
  Graphics::Document.define(name, preambles:, attributes:, overwrite: true)
59
64
  end
60
65
 
61
- # 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.
62
68
  # @param width [Numeric] paper width
63
69
  # @param height [Numeric] paper height
64
70
  # @param name [Symbol, String] profile name
@@ -66,13 +72,7 @@ module Sevgi
66
72
  # @return [Symbol, String] original profile name
67
73
  # @raise [Sevgi::ArgumentError] when the profile is invalid or an existing profile has different dimensions
68
74
  def paper(width, height, name = :custom, unit: "mm")
69
- profile = Graphics::Paper[width, height, unit, name]
70
-
71
- if Graphics::Paper.exist?(name)
72
- ArgumentError.("Paper already defined differently: #{name}") unless Graphics::Paper.public_send(name) == profile
73
- else
74
- Graphics::Paper.define(name, width:, height:, unit:)
75
- end
75
+ Graphics::Paper.define(name, width:, height:, unit:, overwrite: false)
76
76
 
77
77
  name
78
78
  end
@@ -91,9 +91,10 @@ module Sevgi
91
91
  # Builds an SVG root document.
92
92
  # @param document [Symbol, String, Class] document profile name or document class
93
93
  # @param canvas [Sevgi::Graphics::Canvas, Sevgi::Graphics::Paper, Symbol, String, Sevgi::Undefined, nil] canvas input
94
- # @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
95
96
  # @return [Sevgi::Graphics::Document::Proto] SVG root element
96
- # @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
97
98
  def SVG(document = :default, canvas = Undefined, **, &block)
98
99
  Graphics::Document.(document, canvas, **, &block)
99
100
  end
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.94.0
4
+ version: 0.95.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Recai Oktaş
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.94.0
18
+ version: 0.95.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.94.0
25
+ version: 0.95.0
26
26
  description: Provides an extensible DSL for creating SVG content.
27
27
  email: roktas@gmail.com
28
28
  executables: []
@@ -39,6 +39,8 @@ files:
39
39
  - lib/sevgi/graphics/auxiliary/content.rb
40
40
  - lib/sevgi/graphics/auxiliary/margin.rb
41
41
  - lib/sevgi/graphics/auxiliary/paper.rb
42
+ - lib/sevgi/graphics/auxiliary/scalar.rb
43
+ - lib/sevgi/graphics/auxiliary/sizes.rb
42
44
  - lib/sevgi/graphics/document.rb
43
45
  - lib/sevgi/graphics/document/base.rb
44
46
  - lib/sevgi/graphics/document/default.rb
@@ -67,6 +69,7 @@ files:
67
69
  - lib/sevgi/graphics/mixtures/validate.rb
68
70
  - lib/sevgi/graphics/mixtures/wrappers.rb
69
71
  - lib/sevgi/graphics/version.rb
72
+ - lib/sevgi/graphics/xml.rb
70
73
  homepage: https://sevgi.roktas.dev
71
74
  licenses:
72
75
  - GPL-3.0-or-later
@@ -89,7 +92,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
89
92
  - !ruby/object:Gem::Version
90
93
  version: '0'
91
94
  requirements: []
92
- rubygems_version: 4.0.10
95
+ rubygems_version: 4.0.11
93
96
  specification_version: 4
94
97
  summary: Core DSL for the Sevgi toolkit.
95
98
  test_files: []