sevgi-graphics 0.94.0 → 0.98.2

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +275 -2
  3. data/LICENSE +672 -3
  4. data/README.md +8 -8
  5. data/lib/sevgi/graphics/attribute.rb +271 -54
  6. data/lib/sevgi/graphics/auxiliary/canvas.rb +137 -58
  7. data/lib/sevgi/graphics/auxiliary/content.rb +200 -57
  8. data/lib/sevgi/graphics/auxiliary/margin.rb +33 -14
  9. data/lib/sevgi/graphics/auxiliary/paper.rb +118 -86
  10. data/lib/sevgi/graphics/auxiliary/path.rb +44 -0
  11. data/lib/sevgi/graphics/auxiliary/scalar.rb +69 -0
  12. data/lib/sevgi/graphics/auxiliary/sizes.rb +62 -0
  13. data/lib/sevgi/graphics/auxiliary.rb +3 -0
  14. data/lib/sevgi/graphics/document/base.rb +6 -2
  15. data/lib/sevgi/graphics/document/default.rb +1 -1
  16. data/lib/sevgi/graphics/document.rb +370 -124
  17. data/lib/sevgi/graphics/element.rb +143 -33
  18. data/lib/sevgi/graphics/mixtures/call.rb +249 -88
  19. data/lib/sevgi/graphics/mixtures/core.rb +143 -33
  20. data/lib/sevgi/graphics/mixtures/duplicate.rb +76 -22
  21. data/lib/sevgi/graphics/mixtures/export.rb +58 -12
  22. data/lib/sevgi/graphics/mixtures/hatch.rb +49 -7
  23. data/lib/sevgi/graphics/mixtures/identify.rb +30 -17
  24. data/lib/sevgi/graphics/mixtures/include.rb +40 -5
  25. data/lib/sevgi/graphics/mixtures/inkscape.rb +215 -46
  26. data/lib/sevgi/graphics/mixtures/rdf.rb +79 -7
  27. data/lib/sevgi/graphics/mixtures/render.rb +88 -19
  28. data/lib/sevgi/graphics/mixtures/save.rb +79 -26
  29. data/lib/sevgi/graphics/mixtures/symbols.rb +81 -9
  30. data/lib/sevgi/graphics/mixtures/tile.rb +88 -64
  31. data/lib/sevgi/graphics/mixtures/transform.rb +68 -28
  32. data/lib/sevgi/graphics/mixtures/underscore.rb +16 -7
  33. data/lib/sevgi/graphics/mixtures/validate.rb +14 -2
  34. data/lib/sevgi/graphics/mixtures/wrappers.rb +66 -24
  35. data/lib/sevgi/graphics/mixtures.rb +19 -13
  36. data/lib/sevgi/graphics/version.rb +1 -1
  37. data/lib/sevgi/graphics/xml.rb +127 -0
  38. data/lib/sevgi/graphics.rb +75 -28
  39. metadata +10 -6
@@ -6,27 +6,42 @@ module Sevgi
6
6
  # DSL wrappers for common SVG shapes and content patterns.
7
7
  module Wrappers
8
8
  # Builds a line path ending at an absolute point.
9
- # @param x2 [Numeric] ending x coordinate
10
- # @param y2 [Numeric] ending y coordinate
11
- # @param x1 [Numeric] starting x coordinate
12
- # @param y1 [Numeric] starting y coordinate
9
+ # @example Render a rational coordinate as an SVG number
10
+ # Sevgi::Graphics.SVG { LineTo(x2: Rational(1, 2), y2: 1) }
11
+ # @param x2 [Numeric] finite ending x coordinate
12
+ # @param y2 [Numeric] finite ending y coordinate
13
+ # @param x1 [Numeric] finite starting x coordinate
14
+ # @param y1 [Numeric] finite starting y coordinate
13
15
  # @return [Sevgi::Graphics::Element] path element
16
+ # @raise [Sevgi::ArgumentError] when a coordinate is not a finite real number
14
17
  def LineTo(x2:, y2:, x1: 0, y1: 0, **)
18
+ x1 = Scalar.number(x1, context: "absolute line", field: :x1)
19
+ y1 = Scalar.number(y1, context: "absolute line", field: :y1)
20
+ x2 = Scalar.number(x2, context: "absolute line", field: :x2)
21
+ y2 = Scalar.number(y2, context: "absolute line", field: :y2)
22
+
15
23
  path(d: "M #{x1} #{y1} L #{x2} #{y2}", **)
16
24
  end
17
25
 
18
26
  # Builds a horizontal line path ending at an absolute x coordinate.
19
- # @param x2 [Numeric] ending x coordinate
20
- # @param x1 [Numeric] starting x coordinate
21
- # @param y1 [Numeric] starting y coordinate
27
+ # @param x2 [Numeric] finite ending x coordinate
28
+ # @param x1 [Numeric] finite starting x coordinate
29
+ # @param y1 [Numeric] finite starting y coordinate
22
30
  # @return [Sevgi::Graphics::Element] path element
31
+ # @raise [Sevgi::ArgumentError] when a coordinate is not a finite real number
23
32
  def HLineTo(x2:, x1: 0, y1: 0, **)
33
+ x1 = Scalar.number(x1, context: "horizontal line", field: :x1)
34
+ y1 = Scalar.number(y1, context: "horizontal line", field: :y1)
35
+ x2 = Scalar.number(x2, context: "horizontal line", field: :x2)
36
+
24
37
  path(d: "M #{x1} #{y1} H #{x2}", **)
25
38
  end
26
39
 
27
40
  # Builds a titled SVG symbol.
28
41
  # @param name [String] human-readable symbol name
29
42
  # @param kwargs [Hash] symbol attributes
43
+ # @yield evaluates the drawing DSL in the symbol element
44
+ # @yieldreturn [Object] ignored block result
30
45
  # @return [Sevgi::Graphics::Element] symbol element
31
46
  def Symbol(name, **kwargs, &block)
32
47
  id = (words = name.split).map(&:downcase).join("-")
@@ -39,23 +54,36 @@ module Sevgi
39
54
  end
40
55
 
41
56
  # Builds a vertical line path ending at an absolute y coordinate.
42
- # @param y2 [Numeric] ending y coordinate
43
- # @param x1 [Numeric] starting x coordinate
44
- # @param y1 [Numeric] starting y coordinate
57
+ # @param y2 [Numeric] finite ending y coordinate
58
+ # @param x1 [Numeric] finite starting x coordinate
59
+ # @param y1 [Numeric] finite starting y coordinate
45
60
  # @return [Sevgi::Graphics::Element] path element
61
+ # @raise [Sevgi::ArgumentError] when a coordinate is not a finite real number
46
62
  def VLineTo(y2:, x1: 0, y1: 0, **)
63
+ x1 = Scalar.number(x1, context: "vertical line", field: :x1)
64
+ y1 = Scalar.number(y1, context: "vertical line", field: :y1)
65
+ y2 = Scalar.number(y2, context: "vertical line", field: :y2)
66
+
47
67
  path(d: "M #{x1} #{y1} V #{y2}", **)
48
68
  end
49
69
 
50
70
  # Builds a relative line path from angle and length.
51
- # @param angle [Numeric] angle in degrees
52
- # @param length [Numeric] line length
53
- # @param x [Numeric] starting x coordinate
54
- # @param y [Numeric] starting y coordinate
71
+ # @param angle [Numeric] finite angle in degrees, normalized before calculation
72
+ # @param length [Numeric] finite line length, normalized before calculation
73
+ # @param x [Numeric] finite starting x coordinate, normalized to an SVG number
74
+ # @param y [Numeric] finite starting y coordinate, normalized to an SVG number
55
75
  # @return [Sevgi::Graphics::Element] path element
76
+ # @raise [Sevgi::ArgumentError] when an operand is not a finite real number
56
77
  def LineBy(angle:, length:, x: 0, y: 0, **)
57
- dx = length * ::Math.cos(angle.to_f / 180 * ::Math::PI)
58
- dy = length * ::Math.sin(angle.to_f / 180 * ::Math::PI)
78
+ angle, length, x, y = Scalar.numbers([angle, length, x, y], context: "relative line")
79
+ dx, dy = Scalar.numbers(
80
+ [
81
+ length * ::Math.cos(angle.to_f / 180 * ::Math::PI),
82
+ length * ::Math.sin(angle.to_f / 180 * ::Math::PI)
83
+ ],
84
+ context: "relative line result"
85
+ )
86
+
59
87
  path(d: "M #{x} #{y} l #{dx} #{dy}", **)
60
88
  end
61
89
 
@@ -63,7 +91,8 @@ module Sevgi
63
91
  # @param hash [Hash, nil] CSS rules
64
92
  # @param attributes [Hash] style attributes or CSS rules when hash is nil
65
93
  # @return [Sevgi::Graphics::Element] style element
66
- # @raise [Sevgi::ArgumentError] when CSS content is not a hash
94
+ # @raise [Sevgi::ArgumentError] when CSS rules are malformed, cyclic, cannot be stringified, or contain invalid
95
+ # encoding or illegal XML 1.0 characters
67
96
  def css(hash = nil, **attributes)
68
97
  hash, attributes = attributes, {} unless hash
69
98
 
@@ -71,27 +100,40 @@ module Sevgi
71
100
  end
72
101
 
73
102
  # Builds a relative horizontal line path.
74
- # @param length [Numeric] line length
75
- # @param x [Numeric] starting x coordinate
76
- # @param y [Numeric] starting y coordinate
103
+ # @param length [Numeric] finite line length
104
+ # @param x [Numeric] finite starting x coordinate
105
+ # @param y [Numeric] finite starting y coordinate
77
106
  # @return [Sevgi::Graphics::Element] path element
107
+ # @raise [Sevgi::ArgumentError] when an operand is not a finite real number
78
108
  def HLineBy(length:, x: 0, y: 0, **)
109
+ length = Scalar.number(length, context: "horizontal line", field: :length)
110
+ x = Scalar.number(x, context: "horizontal line", field: :x)
111
+ y = Scalar.number(y, context: "horizontal line", field: :y)
112
+
79
113
  path(d: "M #{x} #{y} h #{length}", **)
80
114
  end
81
115
 
82
116
  # Builds a square rect.
83
- # @param length [Numeric] side length
117
+ # @param length [Numeric] finite side length
84
118
  # @return [Sevgi::Graphics::Element] rect element
119
+ # @raise [Sevgi::ArgumentError] when length is not a finite real number
85
120
  def square(length:, **)
121
+ length = Scalar.number(length, context: "square", field: :length)
122
+
86
123
  rect(width: length, height: length, **)
87
124
  end
88
125
 
89
126
  # Builds a relative vertical line path.
90
- # @param length [Numeric] line length
91
- # @param x [Numeric] starting x coordinate
92
- # @param y [Numeric] starting y coordinate
127
+ # @param length [Numeric] finite line length
128
+ # @param x [Numeric] finite starting x coordinate
129
+ # @param y [Numeric] finite starting y coordinate
93
130
  # @return [Sevgi::Graphics::Element] path element
131
+ # @raise [Sevgi::ArgumentError] when an operand is not a finite real number
94
132
  def VLineBy(length:, x: 0, y: 0, **)
133
+ length = Scalar.number(length, context: "vertical line", field: :length)
134
+ x = Scalar.number(x, context: "vertical line", field: :x)
135
+ y = Scalar.number(y, context: "vertical line", field: :y)
136
+
95
137
  path(d: "M #{x} #{y} v #{length}", **)
96
138
  end
97
139
  end
@@ -8,37 +8,43 @@ 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)
19
23
  mod, document = normalize(mod, document, block)
20
24
  ArgumentError.("Mixture name or block required") if mod == Undefined && !block
21
25
 
22
- document.mixture(mod) unless mod == Undefined
26
+ document.send(:mixture, mod) unless mod == Undefined
23
27
  include_anonymous(document, &block) if block
24
28
  end
25
29
 
26
- def self.anonymous_document?(mod, block)
27
- block && mod.is_a?(::Class) && mod <= Graphics::Document::Proto
28
- end
30
+ class << self
31
+ private
29
32
 
30
- def self.include_anonymous(document, &block)
31
- ::Module.new(&block).tap do |anonymous|
32
- document.send(:include, anonymous)
33
- document.extend(anonymous.const_get(:ClassMethods)) if anonymous.const_defined?(:ClassMethods, false)
33
+ def anonymous_document?(mod, block)
34
+ block && mod.is_a?(::Class) && mod <= Graphics::Document::Proto
34
35
  end
35
- end
36
36
 
37
- def self.normalize(mod, document, block)
38
- anonymous_document?(mod, block) ? [Undefined, mod] : [mod, document]
39
- end
37
+ def include_anonymous(document, &block)
38
+ ::Module.new(&block).tap do |anonymous|
39
+ document.send(:include, anonymous)
40
+ document.extend(anonymous.const_get(:ClassMethods)) if anonymous.const_defined?(:ClassMethods, false)
41
+ end
42
+ end
40
43
 
41
- private_class_method :anonymous_document?, :include_anonymous, :normalize
44
+ def normalize(mod, document, block)
45
+ anonymous_document?(mod, block) ? [Undefined, mod] : [mod, document]
46
+ end
47
+ end
42
48
  end
43
49
  end
44
50
  end
@@ -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.98.2"
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"
@@ -13,15 +14,44 @@ require_relative "graphics/version"
13
14
 
14
15
  module Sevgi
15
16
  # SVG document builder and DSL namespace.
17
+ #
18
+ # A document profile controls root metadata and preambles; a {Canvas} controls
19
+ # physical size, margins, viewport, and viewBox. {Graphics.SVG} combines them
20
+ # into an element tree. Library code keeps that tree as an object until it
21
+ # explicitly renders, validates, saves, or exports it.
22
+ #
23
+ # @example Build and render a minimal SVG document
24
+ # drawing = Sevgi::Graphics.SVG :minimal, width: 10, height: 10 do
25
+ # circle cx: 5, cy: 5, r: 4
26
+ # end
27
+ # drawing.Render #=> "<svg width=\"10\" height=\"10\">\n <circle cx=\"5\" cy=\"5\" r=\"4\"/>\n</svg>"
28
+ # @see https://sevgi.roktas.dev/svg/ SVG construction and profile guide
29
+ # @see https://sevgi.roktas.dev/library-mode/ Library composition guide
30
+ # @see https://sevgi.roktas.dev/showcase/ Runnable drawing examples
31
+ # @see Sevgi::Graphics::Document
32
+ # @see Sevgi::Graphics::Canvas
16
33
  module Graphics
17
- # @overload canvas(arg = Undefined, **kwargs)
18
- # Builds a canvas from a paper profile or explicit size.
19
- # @param arg [Sevgi::Graphics::Paper, Symbol, String, Sevgi::Undefined] paper profile or paper object
20
- # @param kwargs [Hash] canvas keyword arguments
34
+ # @overload canvas(paper, **overrides)
35
+ # Builds a canvas from a paper profile with optional field overrides.
36
+ # @param paper [Sevgi::Graphics::Paper, Symbol, String] paper object or registered profile
37
+ # @param overrides [Hash] canvas field overrides
21
38
  # @return [Sevgi::Graphics::Canvas]
22
- # @raise [Sevgi::ArgumentError] when the paper profile is unknown
39
+ # @raise [Sevgi::ArgumentError] when the paper or an override is invalid
40
+ # @overload canvas(width:, height:, unit: "mm", name: :custom, margins: [])
41
+ # Builds a canvas from an explicit size.
42
+ # @param width [Numeric] canvas width
43
+ # @param height [Numeric] canvas height
44
+ # @param unit [Symbol, String] SVG unit
45
+ # @param name [Symbol, String] paper name
46
+ # @param margins [Array<Numeric>] margin shorthand values
47
+ # @return [Sevgi::Graphics::Canvas]
48
+ # @raise [Sevgi::ArgumentError] when a required field is omitted or a value is invalid
49
+ # @example Use the component constructor in library code
50
+ # page = Sevgi::Graphics.canvas(:a4, margins: [12, 10])
51
+ # icon = Sevgi::Graphics.canvas(width: 24, height: 24, unit: :px)
52
+ # [page.name, icon.viewport] # => [:a4, {width: "24.0px", height: "24.0px"}]
23
53
  def canvas(...)
24
- Graphics::Canvas.from_paper(...)
54
+ Graphics::Canvas.call(...)
25
55
  end
26
56
 
27
57
  # @overload document(name)
@@ -30,35 +60,55 @@ module Sevgi
30
60
  # @return [Class] document class
31
61
  # @raise [Sevgi::ArgumentError] when the profile is unknown
32
62
  # @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.
63
+ # Looks up a named profile when both definition keywords are omitted. Supplying `preambles:` or `attributes:`
64
+ # defines a named profile, or returns an existing profile when every explicitly supplied field matches. Omitted
65
+ # fields are ignored during existing-profile comparison. Profile containers and strings are copied into the
66
+ # process-global, thread-atomic registry; attribute names and nested Hash keys are normalized, nil attributes are
67
+ # omitted, update suffixes are retained for document-class inheritance, and mutable non-container values are
68
+ # stringified once before registration. Concurrent identical definitions return the same canonical registered
69
+ # class.
35
70
  # @param name [Symbol, String] profile name
36
71
  # @param preambles [Array<String>, nil, Sevgi::Undefined] document preamble lines
37
- # @param attributes [Hash, Sevgi::Undefined] default root attributes
72
+ # @param attributes [Hash, nil, Sevgi::Undefined] default root attributes; nil means an empty Hash
38
73
  # @return [Class] document class
39
- # @raise [Sevgi::ArgumentError] when a named profile conflicts with an existing profile
74
+ # @raise [Sevgi::ArgumentError] when a profile conflicts or metadata is invalid XML, cyclic, or cannot be stringified
40
75
  # @overload document(preambles: Undefined, attributes: Undefined)
41
76
  # Defines an anonymous document profile without registering it globally.
42
77
  # @param preambles [Array<String>, nil, Sevgi::Undefined] document preamble lines
43
- # @param attributes [Hash, Sevgi::Undefined] default root attributes
78
+ # @param attributes [Hash, nil, Sevgi::Undefined] default root attributes; nil means an empty Hash
44
79
  # @return [Class] anonymous document class
45
- # @raise [Sevgi::ArgumentError] when profile input is invalid
80
+ # @raise [Sevgi::ArgumentError] when metadata is invalid XML, cyclic, or cannot be stringified
46
81
  # @return [Class] document class
82
+ # @example Define, look up, and inspect a named document
83
+ # Sevgi::Graphics.document(:card, attributes: {viewBox: "0 0 100 60"})
84
+ # Sevgi::Graphics::Document.fetch(:card) # => the registered document class
85
+ # Sevgi::Graphics::Document.profile(:card).attributes # => {viewBox: "0 0 100 60"}
86
+ # @example Define an anonymous document without changing the registry
87
+ # klass = Sevgi::Graphics.document(attributes: {viewBox: "0 0 10 10"})
88
+ # klass.profile.name # => nil
89
+ # @example Define and use a named profile from library code
90
+ # Sevgi::Graphics.document(:badge, attributes: {viewBox: "0 0 24 24"})
91
+ # drawing = Sevgi::Graphics.SVG(:badge) { circle cx: 12, cy: 12, r: 10 }
92
+ # drawing[:viewBox] # => "0 0 24 24"
47
93
  def document(name = Undefined, preambles: Undefined, attributes: Undefined)
48
94
  Graphics::Document.define(name, preambles:, attributes:)
49
95
  end
50
96
 
51
97
  # Defines or replaces a document profile class.
98
+ # Validation and snapshot capture complete before an existing registration is atomically replaced.
99
+ # Use the non-bang {#document} for ordinary idempotent definitions; this
100
+ # bang form is an explicit process-global replacement.
52
101
  # @param name [Symbol, String] profile name
53
102
  # @param preambles [Array<String>, nil] document preamble lines
54
- # @param attributes [Hash] default root attributes
103
+ # @param attributes [Hash, nil] default root attributes; nil means an empty Hash
55
104
  # @return [Class] document class
56
- # @raise [Sevgi::ArgumentError] when the profile name is invalid
105
+ # @raise [Sevgi::ArgumentError] when the name or metadata is invalid XML, cyclic, or cannot be stringified
57
106
  def document!(name, preambles: [], attributes: {})
58
107
  Graphics::Document.define(name, preambles:, attributes:, overwrite: true)
59
108
  end
60
109
 
61
- # Defines a paper profile unless the same profile already exists.
110
+ # Defines a paper profile unless the same profile already exists. Registration is process-global and thread-atomic;
111
+ # an identical concurrent definition is idempotent and a conflicting definition is rejected.
62
112
  # @param width [Numeric] paper width
63
113
  # @param height [Numeric] paper height
64
114
  # @param name [Symbol, String] profile name
@@ -66,18 +116,13 @@ module Sevgi
66
116
  # @return [Symbol, String] original profile name
67
117
  # @raise [Sevgi::ArgumentError] when the profile is invalid or an existing profile has different dimensions
68
118
  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
119
+ Graphics::Paper.define(name, width:, height:, unit:, overwrite: false)
76
120
 
77
121
  name
78
122
  end
79
123
 
80
124
  # Defines or replaces a paper profile.
125
+ # Use this only when replacing the process-global meaning of a name is intentional.
81
126
  # @param width [Numeric] paper width
82
127
  # @param height [Numeric] paper height
83
128
  # @param name [Symbol, String] profile name
@@ -85,15 +130,19 @@ module Sevgi
85
130
  # @return [Symbol, String] original profile name
86
131
  # @raise [Sevgi::ArgumentError] when the profile is invalid or the profile name is reserved
87
132
  def paper!(width, height, name = :custom, unit: "mm")
88
- name.tap { Graphics::Paper.define(name, width:, height:, unit:) }
133
+ name.tap { Graphics::Paper.define(name, width:, height:, unit:, overwrite: true) }
89
134
  end
90
135
 
91
- # Builds an SVG root document.
136
+ # Builds an SVG root element tree without rendering it.
137
+ #
138
+ # The first argument selects root metadata; the second supplies physical
139
+ # canvas attributes. Keyword attributes are applied to the root after both.
92
140
  # @param document [Symbol, String, Class] document profile name or document class
93
141
  # @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
142
+ # @yield evaluates the drawing DSL in the root element
143
+ # @yieldreturn [Object] ignored block result
95
144
  # @return [Sevgi::Graphics::Document::Proto] SVG root element
96
- # @raise [Sevgi::ArgumentError] when the document or canvas profile is unknown
145
+ # @raise [Sevgi::ArgumentError] when the document/canvas profile or root XML attributes are invalid
97
146
  def SVG(document = :default, canvas = Undefined, **, &block)
98
147
  Graphics::Document.(document, canvas, **, &block)
99
148
  end
@@ -101,6 +150,4 @@ module Sevgi
101
150
  extend self
102
151
  end
103
152
 
104
- # Top-level alias for the graphics DSL namespace.
105
- SVG = Graphics
106
153
  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.98.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Recai Oktaş
@@ -15,15 +15,15 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.94.0
18
+ version: 0.98.2
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
26
- description: Provides an extensible DSL for creating SVG content.
25
+ version: 0.98.2
26
+ description: Builds and renders SVG document trees from Ruby.
27
27
  email: roktas@gmail.com
28
28
  executables: []
29
29
  extensions: []
@@ -39,6 +39,9 @@ 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/path.rb
43
+ - lib/sevgi/graphics/auxiliary/scalar.rb
44
+ - lib/sevgi/graphics/auxiliary/sizes.rb
42
45
  - lib/sevgi/graphics/document.rb
43
46
  - lib/sevgi/graphics/document/base.rb
44
47
  - lib/sevgi/graphics/document/default.rb
@@ -67,6 +70,7 @@ files:
67
70
  - lib/sevgi/graphics/mixtures/validate.rb
68
71
  - lib/sevgi/graphics/mixtures/wrappers.rb
69
72
  - lib/sevgi/graphics/version.rb
73
+ - lib/sevgi/graphics/xml.rb
70
74
  homepage: https://sevgi.roktas.dev
71
75
  licenses:
72
76
  - GPL-3.0-or-later
@@ -89,7 +93,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
89
93
  - !ruby/object:Gem::Version
90
94
  version: '0'
91
95
  requirements: []
92
- rubygems_version: 4.0.10
96
+ rubygems_version: 4.0.16
93
97
  specification_version: 4
94
- summary: Core DSL for the Sevgi toolkit.
98
+ summary: Core SVG DSL for Sevgi.
95
99
  test_files: []