sevgi-graphics 0.95.0 → 1.0.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +221 -2
  3. data/README.md +12 -9
  4. data/lib/sevgi/graphics/attribute.rb +166 -45
  5. data/lib/sevgi/graphics/auxiliary/canvas.rb +100 -43
  6. data/lib/sevgi/graphics/auxiliary/content.rb +56 -47
  7. data/lib/sevgi/graphics/auxiliary/margin.rb +19 -12
  8. data/lib/sevgi/graphics/auxiliary/paper.rb +74 -49
  9. data/lib/sevgi/graphics/auxiliary/path.rb +44 -0
  10. data/lib/sevgi/graphics/auxiliary/scalar.rb +36 -7
  11. data/lib/sevgi/graphics/auxiliary.rb +1 -0
  12. data/lib/sevgi/graphics/document/base.rb +6 -2
  13. data/lib/sevgi/graphics/document/default.rb +1 -1
  14. data/lib/sevgi/graphics/document.rb +239 -117
  15. data/lib/sevgi/graphics/element.rb +132 -34
  16. data/lib/sevgi/graphics/mixtures/call.rb +234 -88
  17. data/lib/sevgi/graphics/mixtures/core.rb +67 -27
  18. data/lib/sevgi/graphics/mixtures/duplicate.rb +47 -25
  19. data/lib/sevgi/graphics/mixtures/export.rb +54 -12
  20. data/lib/sevgi/graphics/mixtures/hatch.rb +49 -7
  21. data/lib/sevgi/graphics/mixtures/identify.rb +30 -17
  22. data/lib/sevgi/graphics/mixtures/include.rb +26 -8
  23. data/lib/sevgi/graphics/mixtures/inkscape.rb +214 -47
  24. data/lib/sevgi/graphics/mixtures/rdf.rb +59 -7
  25. data/lib/sevgi/graphics/mixtures/render.rb +60 -120
  26. data/lib/sevgi/graphics/mixtures/save.rb +79 -35
  27. data/lib/sevgi/graphics/mixtures/symbols.rb +81 -12
  28. data/lib/sevgi/graphics/mixtures/tile.rb +85 -67
  29. data/lib/sevgi/graphics/mixtures/transform.rb +89 -30
  30. data/lib/sevgi/graphics/mixtures/underscore.rb +16 -7
  31. data/lib/sevgi/graphics/mixtures/validate.rb +2 -2
  32. data/lib/sevgi/graphics/mixtures/wrappers.rb +111 -23
  33. data/lib/sevgi/graphics/mixtures.rb +15 -13
  34. data/lib/sevgi/graphics/version.rb +1 -1
  35. data/lib/sevgi/graphics/xml.rb +4 -9
  36. data/lib/sevgi/graphics.rb +69 -18
  37. metadata +7 -6
@@ -3,22 +3,35 @@
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/layout/#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
21
- # @yield evaluates the template drawing DSL in a generated group
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 can mutate the element. Its return value is ignored
34
+ # @yield evaluates the template drawing DSL in a generated `defs` group named by id
22
35
  # @yieldreturn [Object] ignored block result
23
36
  # @return [Sevgi::Graphics::Element] self
24
37
  # @raise [Sevgi::ArgumentError] when a required tile argument is missing or invalid
@@ -33,14 +46,9 @@ module Sevgi
33
46
  proc: nil,
34
47
  &block
35
48
  )
36
- Helper.assert(id:, nx:, dx:, ox:, ny:, dy:, oy:, proc:)
37
-
38
- href, coords = id, proc do |x, y|
39
- # rubocop:disable Style/NestedTernaryOperator
40
- # for pretty kwargs handling
41
- x.zero? ? (y.zero? ? {} : {y:}) : (y.zero? ? {x:} : {x:, y:})
42
- # rubocop:enable Style/NestedTernaryOperator
43
- 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)
44
52
 
45
53
  defs { g(id:, &block) } if block
46
54
 
@@ -52,34 +60,36 @@ module Sevgi
52
60
  cs = Helper.classify(as: "col", index: x, upper: nx)
53
61
 
54
62
  element = use(
55
- id: [href, y + 1, x + 1].join("-"),
56
- href: "##{href}",
63
+ id: [id, y + 1, x + 1].join("-"),
64
+ href: "##{id}",
57
65
  class: [*rs, *cs].join(" "),
58
- **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
+ )
59
70
  )
60
- proc&.call(element, x:, y:, nx:, ny:)
71
+ callback&.call(element, x:, y:, nx:, ny:)
61
72
  end
62
73
  end
63
74
  end
64
75
  end
65
76
 
66
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.
67
81
  # @param id [String] referenced template id
68
82
  # @param n [Integer] number of instances
69
- # @param d [Numeric] horizontal spacing
70
- # @param o [Numeric] horizontal offset
71
- # @param proc [Proc, nil] optional coordinate/customization proc
72
- # @yield evaluates the template drawing DSL in a generated group
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 can mutate the element. Its return value is ignored
87
+ # @yield evaluates the template drawing DSL in a generated `defs` group named by id
73
88
  # @yieldreturn [Object] ignored block result
74
89
  # @return [Sevgi::Graphics::Element] self
75
90
  # @raise [Sevgi::ArgumentError] when a required tile argument is missing or invalid
76
91
  def TileX(id = Undefined, n: Undefined, d: Undefined, o: 0, proc: nil, &block)
77
- Helper.assert(id:, n:, d:, o:, proc:)
78
-
79
- href, coords = id, proc do |x|
80
- # for pretty kwargs handling
81
- x.zero? ? {} : {x:}
82
- end
92
+ id, n, d, o, callback = Helper.normalize(id:, n:, d:, o:, proc:).values_at(:id, :n, :d, :o, :proc)
83
93
 
84
94
  defs { g(id:, &block) } if block
85
95
 
@@ -88,33 +98,32 @@ module Sevgi
88
98
  cs = Helper.classify(as: "col", index: x, upper: n)
89
99
 
90
100
  element = use(
91
- id: [href, x + 1].join("-"),
92
- href: "##{href}",
101
+ id: [id, x + 1].join("-"),
102
+ href: "##{id}",
93
103
  class: cs.join(" "),
94
- **coords.((x * d) + o)
104
+ **Helper.coordinates(x: Scalar.number((x * d) + o, context: "tile", field: :x))
95
105
  )
96
- proc&.call(element, x:, n:)
106
+ callback&.call(element, x:, n:)
97
107
  end
98
108
  end
99
109
  end
100
110
 
101
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.
102
115
  # @param id [String] referenced template id
103
116
  # @param n [Integer] number of instances
104
- # @param d [Numeric] vertical spacing
105
- # @param o [Numeric] vertical offset
106
- # @param proc [Proc, nil] optional coordinate/customization proc
107
- # @yield evaluates the template drawing DSL in a generated group
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 can mutate the element. Its return value is ignored
121
+ # @yield evaluates the template drawing DSL in a generated `defs` group named by id
108
122
  # @yieldreturn [Object] ignored block result
109
123
  # @return [Sevgi::Graphics::Element] self
110
124
  # @raise [Sevgi::ArgumentError] when a required tile argument is missing or invalid
111
125
  def TileY(id = Undefined, n: Undefined, d: Undefined, o: 0, proc: nil, &block)
112
- Helper.assert(id:, n:, d:, o:, proc:)
113
-
114
- href, coords = id, proc do |y|
115
- # for pretty kwargs handling
116
- y.zero? ? {} : {y:}
117
- end
126
+ id, n, d, o, callback = Helper.normalize(id:, n:, d:, o:, proc:).values_at(:id, :n, :d, :o, :proc)
118
127
 
119
128
  defs { g(id:, &block) } if block
120
129
 
@@ -123,12 +132,12 @@ module Sevgi
123
132
  rs = Helper.classify(as: "row", index: y, upper: n)
124
133
 
125
134
  element = use(
126
- id: [href, y + 1].join("-"),
127
- href: "##{href}",
135
+ id: [id, y + 1].join("-"),
136
+ href: "##{id}",
128
137
  class: rs.join(" "),
129
- **coords.((y * d) + o)
138
+ **Helper.coordinates(y: Scalar.number((y * d) + o, context: "tile", field: :y))
130
139
  )
131
- proc&.call(element, y:, n:)
140
+ callback&.call(element, y:, n:)
132
141
  end
133
142
  end
134
143
  end
@@ -138,18 +147,14 @@ module Sevgi
138
147
  module Helper
139
148
  extend self
140
149
 
150
+ FINITE = %i[d dx dy o ox oy].freeze
151
+
141
152
  # Argument validators for tile helpers.
142
153
  ASSERTION = {
143
154
  id: proc { |name, value| "Argument '#{name}' must be a string" unless value.is_a?(::String) },
144
155
  n: proc { |name, value| positive_integer_issue(name, value) },
145
156
  nx: proc { |name, value| positive_integer_issue(name, value) },
146
157
  ny: proc { |name, value| positive_integer_issue(name, value) },
147
- d: proc { |name, value| "Argument '#{name}' must be a number" unless value.is_a?(::Numeric) },
148
- dx: proc { |name, value| "Argument '#{name}' must be a number" unless value.is_a?(::Numeric) },
149
- dy: proc { |name, value| "Argument '#{name}' must be a number" unless value.is_a?(::Numeric) },
150
- o: proc { |name, value| "Argument '#{name}' must be a number" unless value.is_a?(::Numeric) },
151
- ox: proc { |name, value| "Argument '#{name}' must be a number" unless value.is_a?(::Numeric) },
152
- oy: proc { |name, value| "Argument '#{name}' must be a number" unless value.is_a?(::Numeric) },
153
158
  proc: proc { |name, value| "Argument '#{name}' must be a proc" unless value.nil? || value.is_a?(::Proc) }
154
159
  }.freeze
155
160
 
@@ -161,22 +166,33 @@ module Sevgi
161
166
  "Argument '#{name}' must be a positive integer" unless value.is_a?(::Integer) && value.positive?
162
167
  end
163
168
 
164
- # Validates tile arguments.
169
+ # Validates and normalizes tile arguments.
165
170
  # @param kwargs [Hash] tile arguments
166
- # @return [nil]
171
+ # @return [Hash] independent arguments with spacing and offsets normalized to SVG numbers
167
172
  # @raise [Sevgi::ArgumentError] when an argument is missing or invalid
168
- def assert(**kwargs)
169
- kwargs.each do |name, value|
170
- 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
171
179
 
172
- unless issue
173
- next unless (assertion = ASSERTION[name])
174
- next unless (issue = assertion.call(name, value))
175
- end
180
+ def coordinates(**coordinates)
181
+ coordinates.reject { |_, value| value.zero? }
182
+ end
176
183
 
177
- ArgumentError.(issue)
178
- end
179
- # 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")
180
196
  end
181
197
 
182
198
  # Returns positional tile CSS classes.
@@ -191,6 +207,8 @@ module Sevgi
191
207
  classes << "#{PREFIX}-#{as}-last" if index + 1 == upper
192
208
  end
193
209
  end
210
+
211
+ private :normalize_value, :number
194
212
  end
195
213
 
196
214
  private_constant :Helper
@@ -4,29 +4,57 @@ 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
14
+ # Keep box validation here: mixture helper methods would also enter the SVG DSL namespace.
15
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
16
+
8
17
  # Aligns an inner box inside an outer box.
9
18
  # @param position [Symbol, String, nil] alignment name
10
- # @param inner [#width, #height, nil] inner box
11
- # @param outer [#width, #height, nil] outer box
19
+ # @param inner [#width, #height, nil] inner box, with optional position exposing x and y
20
+ # @param outer [#width, #height, nil] outer box, with optional position exposing x and y
12
21
  # @return [Sevgi::Graphics::Element] self
13
22
  # @raise [Sevgi::ArgumentError] when alignment is unsupported
23
+ # @raise [Sevgi::ArgumentError] when a box dimension is not a finite real number
24
+ # @raise [Sevgi::ArgumentError] when a supplied box position does not expose finite real x and y coordinates
25
+ # @note Size-only boxes have origin (0, 0). Positions describe known box geometry, not renderer-computed bounds.
14
26
  def Align(position, inner:, outer:)
15
27
  return self unless position && inner && outer
16
28
 
17
29
  case position.to_sym
18
30
  when :center
19
- Translate((outer.width - inner.width) / 2.0, (outer.height - inner.height) / 2.0)
31
+ dimensions = [inner.width, inner.height, outer.width, outer.height]
32
+ iw, ih, ow, oh = dimensions.map { Scalar.number(it, context: "alignment", field: :dimension) }
33
+ origins = [inner, outer].map do |box|
34
+ next [0, 0] unless box.respond_to?(:position)
35
+
36
+ origin = box.position
37
+ unless origin.respond_to?(:x) && origin.respond_to?(:y)
38
+ ArgumentError.("Alignment box position must expose x and y")
39
+ end
40
+
41
+ [origin.x, origin.y].map { Scalar.number(it, context: "alignment", field: :position) }
42
+ end
43
+
44
+ (ix, iy), (ox, oy) = origins
45
+ Translate(ox - ix + ((ow - iw) / 2.0), oy - iy + ((oh - ih) / 2.0))
20
46
  else
21
47
  ArgumentError.("Unsupported alignment: #{position}")
22
48
  end
23
49
  end
24
50
 
51
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
52
+
25
53
  # Appends a scale(-1, -1) transform.
26
54
  # @return [Sevgi::Graphics::Element] self
27
55
  def Flip
28
56
  tap do
29
- attributes[:"transform#{ATTRIBUTE_UPDATE_SUFFIX}"] = "scale(-1, -1)"
57
+ attributes[:"transform#{Attributes::UPDATE_SUFFIX}"] = "scale(-1, -1)"
30
58
  end
31
59
  end
32
60
 
@@ -34,7 +62,7 @@ module Sevgi
34
62
  # @return [Sevgi::Graphics::Element] self
35
63
  def FlipX
36
64
  tap do
37
- attributes[:"transform#{ATTRIBUTE_UPDATE_SUFFIX}"] = "scale(-1, 1)"
65
+ attributes[:"transform#{Attributes::UPDATE_SUFFIX}"] = "scale(-1, 1)"
38
66
  end
39
67
  end
40
68
 
@@ -42,36 +70,40 @@ module Sevgi
42
70
  # @return [Sevgi::Graphics::Element] self
43
71
  def FlipY
44
72
  tap do
45
- attributes[:"transform#{ATTRIBUTE_UPDATE_SUFFIX}"] = "scale(1, -1)"
73
+ attributes[:"transform#{Attributes::UPDATE_SUFFIX}"] = "scale(1, -1)"
46
74
  end
47
75
  end
48
76
 
49
77
  # Appends a six-value SVG matrix transform.
50
- # @param values [Array<Numeric>] matrix values
78
+ # @param values [Array<Numeric>] finite matrix values, normalized to SVG numbers
51
79
  # @return [Sevgi::Graphics::Element] self
52
- # @raise [Sevgi::ArgumentError] when exactly six values are not supplied
80
+ # @raise [Sevgi::ArgumentError] when six finite real values are not supplied
53
81
  def Matrix(*values)
54
82
  tap do
55
83
  ArgumentError.("Incorrect transform matrix (six values required): #{values}") if values.size != 6
84
+ values = Scalar.numbers(values, context: "matrix")
56
85
 
57
- attributes[:"transform#{ATTRIBUTE_UPDATE_SUFFIX}"] = "matrix(#{values.join(" ")})"
86
+ attributes[:"transform#{Attributes::UPDATE_SUFFIX}"] = "matrix(#{values.join(" ")})"
58
87
  end
59
88
  end
60
89
 
61
90
  # Appends an SVG rotate transform.
62
- # @param a [Numeric] angle in degrees
63
- # @param origin [Array<Numeric>] optional x and y origin
91
+ # @param a [Numeric] finite angle in degrees, normalized to an SVG number
92
+ # @param origin [Array<Numeric>] optional finite x and y origin, normalized to SVG numbers
64
93
  # @return [Sevgi::Graphics::Element] self
65
- # @raise [Sevgi::ArgumentError] when origin is not two coordinates
94
+ # @raise [Sevgi::ArgumentError] when angle or origin is not finite real, or origin is not two coordinates
66
95
  def Rotate(a, *origin)
67
96
  tap do
68
97
  if !origin.empty? && origin.size != 2
69
98
  ArgumentError.("Incorrect origin (two coordinates required): #{origin}")
70
99
  end
71
100
 
72
- next if a.to_f == 0.0
101
+ angle = Scalar.number(a, context: "rotation", field: :angle)
102
+ origin = Scalar.numbers(origin, context: "rotation")
73
103
 
74
- attributes[:"transform#{ATTRIBUTE_UPDATE_SUFFIX}"] = "rotate(#{[a, *origin].join(", ")})"
104
+ next if angle.zero?
105
+
106
+ attributes[:"transform#{Attributes::UPDATE_SUFFIX}"] = "rotate(#{[angle, *origin].join(", ")})"
75
107
  end
76
108
  end
77
109
 
@@ -88,56 +120,83 @@ module Sevgi
88
120
  def RotateLeft(...) = Rotate(-90, ...)
89
121
 
90
122
  # 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
123
+ # @param x [Numeric] finite x scale, or uniform scale when y is nil
124
+ # @param y [Numeric, nil] finite y scale
93
125
  # @return [Sevgi::Graphics::Element] self
126
+ # @raise [Sevgi::ArgumentError] when a scale is not a finite real number
94
127
  def Scale(x, y = nil)
95
128
  tap do
96
- attributes[:"transform#{ATTRIBUTE_UPDATE_SUFFIX}"] = "scale(#{(y ? [x, y] : [x]).join(", ")})"
129
+ x = Scalar.number(x, context: "scale", field: :x)
130
+ y = Scalar.number(y, context: "scale", field: :y) unless y.nil?
131
+ attributes[:"transform#{Attributes::UPDATE_SUFFIX}"] = "scale(#{(y.nil? ? [x] : [x, y]).join(", ")})"
97
132
  end
98
133
  end
99
134
 
100
135
  # 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
136
+ # @param ax [Numeric] finite x skew angle in degrees, normalized before calculation
137
+ # @param ay [Numeric] finite y skew angle in degrees, normalized before calculation
103
138
  # @return [Sevgi::Graphics::Element] self
139
+ # @raise [Sevgi::ArgumentError] when an angle is not a finite real number
104
140
  def Skew(ax, ay)
141
+ ax = Scalar.number(ax, context: "skew", field: :x)
142
+ ay = Scalar.number(ay, context: "skew", field: :y)
105
143
  Matrix(1.0, ::Math.tan(ay / 180.0 * ::Math::PI), ::Math.tan(ax / 180.0 * ::Math::PI), 1.0, 0.0, 0.0)
106
144
  end
107
145
 
108
146
  # Appends an SVG skewX transform.
109
- # @param a [Numeric] angle in degrees
147
+ # @param a [Numeric] finite angle in degrees, normalized to an SVG number
110
148
  # @return [Sevgi::Graphics::Element] self
149
+ # @raise [Sevgi::ArgumentError] when angle is not a finite real number
111
150
  def SkewX(a)
112
151
  tap do
113
- next if a.to_f == 0.0
152
+ angle = Scalar.number(a, context: "skew", field: :x)
153
+ next if angle.zero?
114
154
 
115
- attributes[:"transform#{ATTRIBUTE_UPDATE_SUFFIX}"] = "skewX(#{a})"
155
+ attributes[:"transform#{Attributes::UPDATE_SUFFIX}"] = "skewX(#{angle})"
116
156
  end
117
157
  end
118
158
 
119
159
  # Appends an SVG skewY transform.
120
- # @param a [Numeric] angle in degrees
160
+ # @param a [Numeric] finite angle in degrees, normalized to an SVG number
121
161
  # @return [Sevgi::Graphics::Element] self
162
+ # @raise [Sevgi::ArgumentError] when angle is not a finite real number
122
163
  def SkewY(a)
123
164
  tap do
124
- next if a.to_f == 0.0
165
+ angle = Scalar.number(a, context: "skew", field: :y)
166
+ next if angle.zero?
125
167
 
126
- attributes[:"transform#{ATTRIBUTE_UPDATE_SUFFIX}"] = "skewY(#{a})"
168
+ attributes[:"transform#{Attributes::UPDATE_SUFFIX}"] = "skewY(#{angle})"
127
169
  end
128
170
  end
129
171
 
130
- # Appends an SVG translate transform.
131
- # @param x [Numeric] x translation
132
- # @param y [Numeric, nil] y translation
172
+ # Appends an SVG translate transform. One argument translates only the x axis.
173
+ # @param x [Numeric] finite x translation, normalized to an SVG number
174
+ # @param y [Numeric, nil] finite y translation, normalized to an SVG number
133
175
  # @return [Sevgi::Graphics::Element] self
176
+ # @raise [Sevgi::ArgumentError] when a coordinate is not a finite real number
134
177
  def Translate(x, y = nil)
135
178
  tap do
136
- next if x.to_f == 0.0 && (y.nil? || y.to_f == 0.0)
179
+ dx = Scalar.number(x, context: "translation", field: :x)
180
+ dy = Scalar.number(y, context: "translation", field: :y) unless y.nil?
181
+ next if dx.zero? && (dy.nil? || dy.zero?)
137
182
 
138
- attributes[:"transform#{ATTRIBUTE_UPDATE_SUFFIX}"] = "translate(#{(y ? [x, y] : [x]).join(" ")})"
183
+ attributes[:"transform#{Attributes::UPDATE_SUFFIX}"] = "translate(#{(dy.nil? ? [dx] : [dx, dy]).join(" ")})"
139
184
  end
140
185
  end
186
+
187
+ # Appends an x-axis SVG translate transform.
188
+ # @param x [Numeric] finite x translation
189
+ # @return [Sevgi::Graphics::Element] self
190
+ # @raise [Sevgi::ArgumentError] when x is not a finite real number
191
+ # @see #Translate
192
+ def TranslateX(x) = Translate(x)
193
+
194
+ # Appends a y-axis SVG translate transform.
195
+ # @param y [Numeric] finite y translation
196
+ # @return [Sevgi::Graphics::Element] self
197
+ # @raise [Sevgi::ArgumentError] when y is not a finite real number
198
+ # @see #Translate
199
+ def TranslateY(y) = Translate(0, y)
141
200
  end
142
201
  end
143
202
  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,7 +18,7 @@ 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 cannot be stringified as valid XML or would form malformed markup
21
+ # @raise [Sevgi::ArgumentError] when comment cannot be stringified as valid XML or forms malformed markup
19
22
  def Comment(comment)
20
23
  comment = XML.text(comment, context: "XML comment")
21
24
 
@@ -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.
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.
31
- # @return [Hash] merged internal attributes
31
+ # Merges `-context` metadata from the document root, ancestors, and this element.
32
+ # Only the direct root-to-self ancestor chain participates. Sibling subtrees are ignored. When the same key is
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
@@ -22,7 +22,7 @@ module Sevgi
22
22
  def CData
23
23
  return if !contents || contents.empty?
24
24
 
25
- Content.text(contents)
25
+ contents.join("\n")
26
26
  end
27
27
 
28
28
  # Reports whether a namespace is available on this element or an ancestor.
@@ -41,7 +41,7 @@ module Sevgi
41
41
  Traverse do |element|
42
42
  Standard.conform(
43
43
  element.name,
44
- attributes: element.attributes.list,
44
+ attributes: element.attributes.keys,
45
45
  cdata: element.CData(),
46
46
  elements: element.children.map(&:name)
47
47
  )