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
@@ -3,21 +3,36 @@
3
3
  module Sevgi
4
4
  module Graphics
5
5
  module Mixtures
6
- # rubocop:disable Metrics/MethodLength
7
- # DSL helpers for repeated SVG use elements.
6
+ # DSL helpers for defining one SVG template and repeating it through `use` elements.
7
+ #
8
+ # Use {Sevgi::Sundries::Tile} instead when Ruby code needs inspectable repeated geometry or row/column bounds
9
+ # rather than SVG references.
10
+ # @see Sevgi::Sundries::Tile
11
+ # @see https://sevgi.roktas.dev/sundries/#choose-a-layout-model Choosing a layout model
8
12
  module Tile
9
- # Prefix used for generated tile CSS classes.
13
+ # Stable prefix used for generated tile CSS classes.
10
14
  PREFIX = "tile"
11
15
 
12
16
  # Builds a two-dimensional tile grid.
17
+ # Each use id has the form `id-row-column`, with one-based row and column numbers. Generated classes identify
18
+ # the one-based row and column and mark their first and last positions. A block defines the referenced template
19
+ # as a group under `defs` before the uses are added.
20
+ # @example Define and customize a tile grid
21
+ # customize = proc { |use, x:, y:, nx:, ny:| use[:opacity] = (x + y + 1).fdiv(nx + ny) }
22
+ # Sevgi::Graphics.SVG(:minimal) do
23
+ # Tile("dot", nx: 2, dx: 10, ny: 2, dy: 10, proc: customize) { circle r: 2 }
24
+ # end
13
25
  # @param id [String] referenced template id
14
26
  # @param nx [Integer] number of columns
15
- # @param dx [Numeric] horizontal spacing
16
- # @param ox [Numeric] horizontal offset
27
+ # @param dx [Numeric] finite horizontal spacing, normalized before coordinates are rendered
28
+ # @param ox [Numeric] finite horizontal offset, normalized before coordinates are rendered
17
29
  # @param ny [Integer] number of rows
18
- # @param dy [Numeric] vertical spacing
19
- # @param oy [Numeric] vertical offset
20
- # @param proc [Proc, nil] optional coordinate/customization proc
30
+ # @param dy [Numeric] finite vertical spacing, normalized before coordinates are rendered
31
+ # @param oy [Numeric] finite vertical offset, normalized before coordinates are rendered
32
+ # @param proc [Proc, nil] optional callback invoked for each use as `(element, x:, y:, nx:, ny:)`, with
33
+ # zero-based coordinates and total counts; the callback may mutate the element and its return value is ignored
34
+ # @yield evaluates the template drawing DSL in a generated `defs` group named by id
35
+ # @yieldreturn [Object] ignored block result
21
36
  # @return [Sevgi::Graphics::Element] self
22
37
  # @raise [Sevgi::ArgumentError] when a required tile argument is missing or invalid
23
38
  def Tile(
@@ -31,14 +46,9 @@ module Sevgi
31
46
  proc: nil,
32
47
  &block
33
48
  )
34
- Helper.assert(id:, nx:, dx:, ox:, ny:, dy:, oy:, proc:)
35
-
36
- href, coords = id, proc do |x, y|
37
- # rubocop:disable Style/NestedTernaryOperator
38
- # for pretty kwargs handling
39
- x.zero? ? (y.zero? ? {} : {y:}) : (y.zero? ? {x:} : {x:, y:})
40
- # rubocop:enable Style/NestedTernaryOperator
41
- end
49
+ id, nx, dx, ox, ny, dy, oy, callback = Helper
50
+ .normalize(id:, nx:, dx:, ox:, ny:, dy:, oy:, proc:)
51
+ .values_at(:id, :nx, :dx, :ox, :ny, :dy, :oy, :proc)
42
52
 
43
53
  defs { g(id:, &block) } if block
44
54
 
@@ -50,32 +60,36 @@ module Sevgi
50
60
  cs = Helper.classify(as: "col", index: x, upper: nx)
51
61
 
52
62
  element = use(
53
- id: [href, y + 1, x + 1].join("-"),
54
- href: "##{href}",
63
+ id: [id, y + 1, x + 1].join("-"),
64
+ href: "##{id}",
55
65
  class: [*rs, *cs].join(" "),
56
- **coords.((x * dx) + ox, (y * dy) + oy)
66
+ **Helper.coordinates(
67
+ x: Scalar.number((x * dx) + ox, context: "tile", field: :x),
68
+ y: Scalar.number((y * dy) + oy, context: "tile", field: :y)
69
+ )
57
70
  )
58
- proc&.call(element, x:, y:, nx:, ny:)
71
+ callback&.call(element, x:, y:, nx:, ny:)
59
72
  end
60
73
  end
61
74
  end
62
75
  end
63
76
 
64
77
  # Builds a one-dimensional horizontal tile row.
78
+ # Each use id has the form `id-column`, with a one-based column number. Generated classes identify the column and
79
+ # mark its first and last positions. A block defines the referenced template as a group under `defs` before the
80
+ # uses are added.
65
81
  # @param id [String] referenced template id
66
82
  # @param n [Integer] number of instances
67
- # @param d [Numeric] horizontal spacing
68
- # @param o [Numeric] horizontal offset
69
- # @param proc [Proc, nil] optional coordinate/customization proc
83
+ # @param d [Numeric] finite horizontal spacing, normalized before coordinates are rendered
84
+ # @param o [Numeric] finite horizontal offset, normalized before coordinates are rendered
85
+ # @param proc [Proc, nil] optional callback invoked for each use as `(element, x:, n:)`, with a zero-based column
86
+ # and total count; the callback may mutate the element and its return value is ignored
87
+ # @yield evaluates the template drawing DSL in a generated `defs` group named by id
88
+ # @yieldreturn [Object] ignored block result
70
89
  # @return [Sevgi::Graphics::Element] self
71
90
  # @raise [Sevgi::ArgumentError] when a required tile argument is missing or invalid
72
91
  def TileX(id = Undefined, n: Undefined, d: Undefined, o: 0, proc: nil, &block)
73
- Helper.assert(id:, n:, d:, o:, proc:)
74
-
75
- href, coords = id, proc do |x|
76
- # for pretty kwargs handling
77
- x.zero? ? {} : {x:}
78
- end
92
+ id, n, d, o, callback = Helper.normalize(id:, n:, d:, o:, proc:).values_at(:id, :n, :d, :o, :proc)
79
93
 
80
94
  defs { g(id:, &block) } if block
81
95
 
@@ -84,31 +98,32 @@ module Sevgi
84
98
  cs = Helper.classify(as: "col", index: x, upper: n)
85
99
 
86
100
  element = use(
87
- id: [href, x + 1].join("-"),
88
- href: "##{href}",
101
+ id: [id, x + 1].join("-"),
102
+ href: "##{id}",
89
103
  class: cs.join(" "),
90
- **coords.((x * d) + o)
104
+ **Helper.coordinates(x: Scalar.number((x * d) + o, context: "tile", field: :x))
91
105
  )
92
- proc&.call(element, x:, n:)
106
+ callback&.call(element, x:, n:)
93
107
  end
94
108
  end
95
109
  end
96
110
 
97
111
  # Builds a one-dimensional vertical tile column.
112
+ # Each use id has the form `id-row`, with a one-based row number. Generated classes identify the row and mark its
113
+ # first and last positions. A block defines the referenced template as a group under `defs` before the uses are
114
+ # added.
98
115
  # @param id [String] referenced template id
99
116
  # @param n [Integer] number of instances
100
- # @param d [Numeric] vertical spacing
101
- # @param o [Numeric] vertical offset
102
- # @param proc [Proc, nil] optional coordinate/customization proc
117
+ # @param d [Numeric] finite vertical spacing, normalized before coordinates are rendered
118
+ # @param o [Numeric] finite vertical offset, normalized before coordinates are rendered
119
+ # @param proc [Proc, nil] optional callback invoked for each use as `(element, y:, n:)`, with a zero-based row and
120
+ # total count; the callback may mutate the element and its return value is ignored
121
+ # @yield evaluates the template drawing DSL in a generated `defs` group named by id
122
+ # @yieldreturn [Object] ignored block result
103
123
  # @return [Sevgi::Graphics::Element] self
104
124
  # @raise [Sevgi::ArgumentError] when a required tile argument is missing or invalid
105
125
  def TileY(id = Undefined, n: Undefined, d: Undefined, o: 0, proc: nil, &block)
106
- Helper.assert(id:, n:, d:, o:, proc:)
107
-
108
- href, coords = id, proc do |y|
109
- # for pretty kwargs handling
110
- y.zero? ? {} : {y:}
111
- end
126
+ id, n, d, o, callback = Helper.normalize(id:, n:, d:, o:, proc:).values_at(:id, :n, :d, :o, :proc)
112
127
 
113
128
  defs { g(id:, &block) } if block
114
129
 
@@ -117,12 +132,12 @@ module Sevgi
117
132
  rs = Helper.classify(as: "row", index: y, upper: n)
118
133
 
119
134
  element = use(
120
- id: [href, y + 1].join("-"),
121
- href: "##{href}",
135
+ id: [id, y + 1].join("-"),
136
+ href: "##{id}",
122
137
  class: rs.join(" "),
123
- **coords.((y * d) + o)
138
+ **Helper.coordinates(y: Scalar.number((y * d) + o, context: "tile", field: :y))
124
139
  )
125
- proc&.call(element, y:, n:)
140
+ callback&.call(element, y:, n:)
126
141
  end
127
142
  end
128
143
  end
@@ -132,18 +147,14 @@ module Sevgi
132
147
  module Helper
133
148
  extend self
134
149
 
150
+ FINITE = %i[d dx dy o ox oy].freeze
151
+
135
152
  # Argument validators for tile helpers.
136
153
  ASSERTION = {
137
154
  id: proc { |name, value| "Argument '#{name}' must be a string" unless value.is_a?(::String) },
138
155
  n: proc { |name, value| positive_integer_issue(name, value) },
139
156
  nx: proc { |name, value| positive_integer_issue(name, value) },
140
157
  ny: proc { |name, value| positive_integer_issue(name, value) },
141
- d: proc { |name, value| "Argument '#{name}' must be a number" unless value.is_a?(::Numeric) },
142
- dx: proc { |name, value| "Argument '#{name}' must be a number" unless value.is_a?(::Numeric) },
143
- dy: proc { |name, value| "Argument '#{name}' must be a number" unless value.is_a?(::Numeric) },
144
- o: proc { |name, value| "Argument '#{name}' must be a number" unless value.is_a?(::Numeric) },
145
- ox: proc { |name, value| "Argument '#{name}' must be a number" unless value.is_a?(::Numeric) },
146
- oy: proc { |name, value| "Argument '#{name}' must be a number" unless value.is_a?(::Numeric) },
147
158
  proc: proc { |name, value| "Argument '#{name}' must be a proc" unless value.nil? || value.is_a?(::Proc) }
148
159
  }.freeze
149
160
 
@@ -155,22 +166,33 @@ module Sevgi
155
166
  "Argument '#{name}' must be a positive integer" unless value.is_a?(::Integer) && value.positive?
156
167
  end
157
168
 
158
- # Validates tile arguments.
169
+ # Validates and normalizes tile arguments.
159
170
  # @param kwargs [Hash] tile arguments
160
- # @return [nil]
171
+ # @return [Hash] independent arguments with spacing and offsets normalized to SVG numbers
161
172
  # @raise [Sevgi::ArgumentError] when an argument is missing or invalid
162
- def assert(**kwargs)
163
- kwargs.each do |name, value|
164
- issue = "Argument '#{name}' required" if value == Undefined
173
+ def normalize(**kwargs)
174
+ kwargs.to_h do |name, value|
175
+ ArgumentError.("Argument '#{name}' required") if value == Undefined
176
+ [name, normalize_value(name, value)]
177
+ end
178
+ end
165
179
 
166
- unless issue
167
- next unless (assertion = ASSERTION[name])
168
- next unless (issue = assertion.call(name, value))
169
- end
180
+ def coordinates(**coordinates)
181
+ coordinates.reject { |_, value| value.zero? }
182
+ end
170
183
 
171
- ArgumentError.(issue)
172
- end
173
- # rubocop:enable Metrics/MethodLength
184
+ def normalize_value(name, value)
185
+ return number(name, value) if FINITE.include?(name)
186
+ return value unless (assertion = ASSERTION[name])
187
+ return value unless (issue = assertion.call(name, value))
188
+
189
+ ArgumentError.(issue)
190
+ end
191
+
192
+ def number(name, value)
193
+ Scalar.number(value, context: "tile", field: name)
194
+ rescue ::Sevgi::ArgumentError
195
+ ArgumentError.("Argument '#{name}' must be a finite real number")
174
196
  end
175
197
 
176
198
  # Returns positional tile CSS classes.
@@ -185,6 +207,8 @@ module Sevgi
185
207
  classes << "#{PREFIX}-#{as}-last" if index + 1 == upper
186
208
  end
187
209
  end
210
+
211
+ private :normalize_value, :number
188
212
  end
189
213
 
190
214
  private_constant :Helper
@@ -4,6 +4,12 @@ module Sevgi
4
4
  module Graphics
5
5
  module Mixtures
6
6
  # DSL helpers for SVG transform attributes.
7
+ #
8
+ # Transform calls are appended in call order and return the element, so they can be composed.
9
+ # @example Compose transforms on one element
10
+ # Sevgi::Graphics.SVG(:minimal) do
11
+ # rect(width: 8, height: 4).Translate(12, 6).Rotate(15, 4, 2)
12
+ # end
7
13
  module Transform
8
14
  # Aligns an inner box inside an outer box.
9
15
  # @param position [Symbol, String, nil] alignment name
@@ -11,12 +17,15 @@ module Sevgi
11
17
  # @param outer [#width, #height, nil] outer box
12
18
  # @return [Sevgi::Graphics::Element] self
13
19
  # @raise [Sevgi::ArgumentError] when alignment is unsupported
20
+ # @raise [Sevgi::ArgumentError] when a box dimension is not a finite real number
14
21
  def Align(position, inner:, outer:)
15
22
  return self unless position && inner && outer
16
23
 
17
24
  case position.to_sym
18
25
  when :center
19
- Translate((outer.width - inner.width) / 2.0, (outer.height - inner.height) / 2.0)
26
+ dimensions = [inner.width, inner.height, outer.width, outer.height]
27
+ iw, ih, ow, oh = dimensions.map { Scalar.number(it, context: "alignment", field: :dimension) }
28
+ Translate((ow - iw) / 2.0, (oh - ih) / 2.0)
20
29
  else
21
30
  ArgumentError.("Unsupported alignment: #{position}")
22
31
  end
@@ -26,7 +35,7 @@ module Sevgi
26
35
  # @return [Sevgi::Graphics::Element] self
27
36
  def Flip
28
37
  tap do
29
- attributes[:"transform#{ATTRIBUTE_UPDATE_SUFFIX}"] = "scale(-1, -1)"
38
+ attributes[:"transform#{Attributes::UPDATE_SUFFIX}"] = "scale(-1, -1)"
30
39
  end
31
40
  end
32
41
 
@@ -34,7 +43,7 @@ module Sevgi
34
43
  # @return [Sevgi::Graphics::Element] self
35
44
  def FlipX
36
45
  tap do
37
- attributes[:"transform#{ATTRIBUTE_UPDATE_SUFFIX}"] = "scale(-1, 1)"
46
+ attributes[:"transform#{Attributes::UPDATE_SUFFIX}"] = "scale(-1, 1)"
38
47
  end
39
48
  end
40
49
 
@@ -42,36 +51,40 @@ module Sevgi
42
51
  # @return [Sevgi::Graphics::Element] self
43
52
  def FlipY
44
53
  tap do
45
- attributes[:"transform#{ATTRIBUTE_UPDATE_SUFFIX}"] = "scale(1, -1)"
54
+ attributes[:"transform#{Attributes::UPDATE_SUFFIX}"] = "scale(1, -1)"
46
55
  end
47
56
  end
48
57
 
49
58
  # Appends a six-value SVG matrix transform.
50
- # @param values [Array<Numeric>] matrix values
59
+ # @param values [Array<Numeric>] finite matrix values, normalized to SVG numbers
51
60
  # @return [Sevgi::Graphics::Element] self
52
- # @raise [Sevgi::ArgumentError] when exactly six values are not supplied
61
+ # @raise [Sevgi::ArgumentError] when six finite real values are not supplied
53
62
  def Matrix(*values)
54
63
  tap do
55
64
  ArgumentError.("Incorrect transform matrix (six values required): #{values}") if values.size != 6
65
+ values = Scalar.numbers(values, context: "matrix")
56
66
 
57
- attributes[:"transform#{ATTRIBUTE_UPDATE_SUFFIX}"] = "matrix(#{values.join(" ")})"
67
+ attributes[:"transform#{Attributes::UPDATE_SUFFIX}"] = "matrix(#{values.join(" ")})"
58
68
  end
59
69
  end
60
70
 
61
71
  # Appends an SVG rotate transform.
62
- # @param a [Numeric] angle in degrees
63
- # @param origin [Array<Numeric>] optional x and y origin
72
+ # @param a [Numeric] finite angle in degrees, normalized to an SVG number
73
+ # @param origin [Array<Numeric>] optional finite x and y origin, normalized to SVG numbers
64
74
  # @return [Sevgi::Graphics::Element] self
65
- # @raise [Sevgi::ArgumentError] when origin is not two coordinates
75
+ # @raise [Sevgi::ArgumentError] when angle or origin is not finite real, or origin is not two coordinates
66
76
  def Rotate(a, *origin)
67
77
  tap do
68
78
  if !origin.empty? && origin.size != 2
69
79
  ArgumentError.("Incorrect origin (two coordinates required): #{origin}")
70
80
  end
71
81
 
72
- next if a.to_f == 0.0
82
+ angle = Scalar.number(a, context: "rotation", field: :angle)
83
+ origin = Scalar.numbers(origin, context: "rotation")
73
84
 
74
- attributes[:"transform#{ATTRIBUTE_UPDATE_SUFFIX}"] = "rotate(#{[a, *origin].join(", ")})"
85
+ next if angle.zero?
86
+
87
+ attributes[:"transform#{Attributes::UPDATE_SUFFIX}"] = "rotate(#{[angle, *origin].join(", ")})"
75
88
  end
76
89
  end
77
90
 
@@ -88,56 +101,83 @@ module Sevgi
88
101
  def RotateLeft(...) = Rotate(-90, ...)
89
102
 
90
103
  # Appends an SVG scale transform.
91
- # @param x [Numeric] x scale, or uniform scale when y is nil
92
- # @param y [Numeric, nil] y scale
104
+ # @param x [Numeric] finite x scale, or uniform scale when y is nil
105
+ # @param y [Numeric, nil] finite y scale
93
106
  # @return [Sevgi::Graphics::Element] self
107
+ # @raise [Sevgi::ArgumentError] when a scale is not a finite real number
94
108
  def Scale(x, y = nil)
95
109
  tap do
96
- attributes[:"transform#{ATTRIBUTE_UPDATE_SUFFIX}"] = "scale(#{(y ? [x, y] : [x]).join(", ")})"
110
+ x = Scalar.number(x, context: "scale", field: :x)
111
+ y = Scalar.number(y, context: "scale", field: :y) unless y.nil?
112
+ attributes[:"transform#{Attributes::UPDATE_SUFFIX}"] = "scale(#{(y.nil? ? [x] : [x, y]).join(", ")})"
97
113
  end
98
114
  end
99
115
 
100
116
  # Appends a skew transform through an SVG matrix.
101
- # @param ax [Numeric] x skew angle in degrees
102
- # @param ay [Numeric] y skew angle in degrees
117
+ # @param ax [Numeric] finite x skew angle in degrees, normalized before calculation
118
+ # @param ay [Numeric] finite y skew angle in degrees, normalized before calculation
103
119
  # @return [Sevgi::Graphics::Element] self
120
+ # @raise [Sevgi::ArgumentError] when an angle is not a finite real number
104
121
  def Skew(ax, ay)
122
+ ax = Scalar.number(ax, context: "skew", field: :x)
123
+ ay = Scalar.number(ay, context: "skew", field: :y)
105
124
  Matrix(1.0, ::Math.tan(ay / 180.0 * ::Math::PI), ::Math.tan(ax / 180.0 * ::Math::PI), 1.0, 0.0, 0.0)
106
125
  end
107
126
 
108
127
  # Appends an SVG skewX transform.
109
- # @param a [Numeric] angle in degrees
128
+ # @param a [Numeric] finite angle in degrees, normalized to an SVG number
110
129
  # @return [Sevgi::Graphics::Element] self
130
+ # @raise [Sevgi::ArgumentError] when angle is not a finite real number
111
131
  def SkewX(a)
112
132
  tap do
113
- next if a.to_f == 0.0
133
+ angle = Scalar.number(a, context: "skew", field: :x)
134
+ next if angle.zero?
114
135
 
115
- attributes[:"transform#{ATTRIBUTE_UPDATE_SUFFIX}"] = "skewX(#{a})"
136
+ attributes[:"transform#{Attributes::UPDATE_SUFFIX}"] = "skewX(#{angle})"
116
137
  end
117
138
  end
118
139
 
119
140
  # Appends an SVG skewY transform.
120
- # @param a [Numeric] angle in degrees
141
+ # @param a [Numeric] finite angle in degrees, normalized to an SVG number
121
142
  # @return [Sevgi::Graphics::Element] self
143
+ # @raise [Sevgi::ArgumentError] when angle is not a finite real number
122
144
  def SkewY(a)
123
145
  tap do
124
- next if a.to_f == 0.0
146
+ angle = Scalar.number(a, context: "skew", field: :y)
147
+ next if angle.zero?
125
148
 
126
- attributes[:"transform#{ATTRIBUTE_UPDATE_SUFFIX}"] = "skewY(#{a})"
149
+ attributes[:"transform#{Attributes::UPDATE_SUFFIX}"] = "skewY(#{angle})"
127
150
  end
128
151
  end
129
152
 
130
- # Appends an SVG translate transform.
131
- # @param x [Numeric] x translation
132
- # @param y [Numeric, nil] y translation
153
+ # Appends an SVG translate transform. One argument translates only the x axis.
154
+ # @param x [Numeric] finite x translation, normalized to an SVG number
155
+ # @param y [Numeric, nil] finite y translation, normalized to an SVG number
133
156
  # @return [Sevgi::Graphics::Element] self
157
+ # @raise [Sevgi::ArgumentError] when a coordinate is not a finite real number
134
158
  def Translate(x, y = nil)
135
159
  tap do
136
- next if x.to_f == 0.0 && (y.nil? || y.to_f == 0.0)
160
+ dx = Scalar.number(x, context: "translation", field: :x)
161
+ dy = Scalar.number(y, context: "translation", field: :y) unless y.nil?
162
+ next if dx.zero? && (dy.nil? || dy.zero?)
137
163
 
138
- attributes[:"transform#{ATTRIBUTE_UPDATE_SUFFIX}"] = "translate(#{(y ? [x, y] : [x]).join(" ")})"
164
+ attributes[:"transform#{Attributes::UPDATE_SUFFIX}"] = "translate(#{(dy.nil? ? [dx] : [dx, dy]).join(" ")})"
139
165
  end
140
166
  end
167
+
168
+ # Appends an x-axis SVG translate transform.
169
+ # @param x [Numeric] finite x translation
170
+ # @return [Sevgi::Graphics::Element] self
171
+ # @raise [Sevgi::ArgumentError] when x is not a finite real number
172
+ # @see #Translate
173
+ def TranslateX(x) = Translate(x)
174
+
175
+ # Appends a y-axis SVG translate transform.
176
+ # @param y [Numeric] finite y translation
177
+ # @return [Sevgi::Graphics::Element] self
178
+ # @raise [Sevgi::ArgumentError] when y is not a finite real number
179
+ # @see #Translate
180
+ def TranslateY(y) = Translate(0, y)
141
181
  end
142
182
  end
143
183
  end
@@ -3,8 +3,11 @@
3
3
  module Sevgi
4
4
  module Graphics
5
5
  module Mixtures
6
- # DSL helpers for floating text, comments, and inherited internal attributes.
6
+ # DSL helpers for floating text, comments, and inherited non-rendering context.
7
7
  module Underscore
8
+ CONTEXT = :"#{Attributes::META_PREFIX}context"
9
+ private_constant :CONTEXT
10
+
8
11
  # Creates a floating content element.
9
12
  # @param contents [Array<Object>] text or content objects
10
13
  # @return [Sevgi::Graphics::Element] floating element
@@ -15,9 +18,9 @@ module Sevgi
15
18
  # Adds an XML comment.
16
19
  # @param comment [Object] comment text
17
20
  # @return [Sevgi::Graphics::Element] floating comment element
18
- # @raise [Sevgi::ArgumentError] when comment text would produce malformed XML
21
+ # @raise [Sevgi::ArgumentError] when comment cannot be stringified as valid XML or would form malformed markup
19
22
  def Comment(comment)
20
- comment = comment.to_s
23
+ comment = XML.text(comment, context: "XML comment")
21
24
 
22
25
  ArgumentError.("XML comment must not contain '--'") if comment.include?("--")
23
26
  ArgumentError.("XML comment must not end with '-'") if comment.end_with?("-")
@@ -25,16 +28,22 @@ module Sevgi
25
28
  _(Content.verbatim("<!-- #{comment} -->"))
26
29
  end
27
30
 
28
- # Merges internal attributes from the document root, ancestors, and this element.
31
+ # Merges `-context` metadata from the document root, ancestors, and this element.
29
32
  # 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.
31
- # @return [Hash] merged internal attributes
33
+ # present on multiple chain elements, the nearest element to the receiver wins. Context remains available in
34
+ # memory but is omitted from rendered SVG. It is unrelated to the `_:` XML namespace syntax preserved by
35
+ # Derender.
36
+ # @return [Hash] merged context
37
+ # @example Inherit drawing context without rendering it
38
+ # Sevgi::Graphics.SVG "-context": {tone: "tomato"} do
39
+ # rect fill: Ancestral()[:tone]
40
+ # end
32
41
  def Ancestral
33
42
  chain = []
34
43
  TraverseUp { |element| chain.unshift(element) }
35
44
 
36
45
  {}.tap do |result|
37
- chain.each { |element| result.merge!(element[:_]) if element.has?(:_) }
46
+ chain.each { |element| result.merge!(element[CONTEXT]) if element.has?(CONTEXT) }
38
47
  end
39
48
  end
40
49
  end
@@ -4,13 +4,25 @@ 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
10
22
  def CData
11
23
  return if !contents || contents.empty?
12
24
 
13
- Content.text(contents)
25
+ contents.join("\n")
14
26
  end
15
27
 
16
28
  # Reports whether a namespace is available on this element or an ancestor.
@@ -29,7 +41,7 @@ module Sevgi
29
41
  Traverse do |element|
30
42
  Standard.conform(
31
43
  element.name,
32
- attributes: element.attributes.list,
44
+ attributes: element.attributes.keys,
33
45
  cdata: element.CData(),
34
46
  elements: element.children.map(&:name)
35
47
  )