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
@@ -4,73 +4,108 @@ require "forwardable"
4
4
 
5
5
  module Sevgi
6
6
  module Graphics
7
- # SVG canvas size, margins, viewport, and viewBox.
7
+ # Immutable SVG canvas size, margins, viewport, and viewBox.
8
+ #
9
+ # `size` is the outer paper. `inner` subtracts the margins but remains a
10
+ # size value, not a positioned rectangle. By default the viewBox origin is
11
+ # the negative left/top margin, so drawing coordinates `(0, 0)` begin at the
12
+ # inner area's top-left while the SVG viewport still includes the margins.
13
+ # Pass nil to {#viewbox} or {#attributes} for a literal zero origin.
14
+ # @example Build from a paper profile or an explicit size
15
+ # page = Sevgi::Graphics::Canvas.from_paper(:a4, margins: [10])
16
+ # icon = Sevgi::Graphics::Canvas.call(width: 24, height: 24, unit: :px)
17
+ # page.viewbox # uses the negative left and top margins as its origin
18
+ # icon.viewbox(nil) # uses `0 0` as its origin
19
+ # @see Sevgi::Graphics::Document
20
+ # @see Sevgi::Sundries::Grid
8
21
  class Canvas
22
+ DEFAULTS = {unit: "mm", name: :custom, margins: []}.freeze
23
+ FIELDS = %i[width height unit name margins].freeze
9
24
  ORIGIN_FIELDS = %i[x y].freeze
10
- REPLACEMENTS = %i[width height unit name margins].freeze
11
- private_constant :ORIGIN_FIELDS, :REPLACEMENTS
25
+ REQUIRED = %i[width height].freeze
26
+ private_constant :DEFAULTS, :FIELDS, :ORIGIN_FIELDS, :REQUIRED
12
27
 
13
- # @overload call(arg = Undefined, **kwargs)
14
- # Builds a canvas from a paper profile or explicit size.
15
- # @param arg [Sevgi::Graphics::Paper, Symbol, String, Sevgi::Undefined] paper profile or paper object
16
- # @param kwargs [Hash] canvas keyword arguments
28
+ # @overload call(paper, **overrides)
29
+ # Builds a canvas from a paper profile with optional field overrides.
30
+ # @param paper [Sevgi::Graphics::Paper, Symbol, String] paper object or registered profile
31
+ # @param overrides [Hash] canvas field overrides
17
32
  # @return [Sevgi::Graphics::Canvas]
18
- # @raise [Sevgi::ArgumentError] when the paper profile is unknown
19
- def self.call(...) = from_paper(...)
33
+ # @raise [Sevgi::ArgumentError] when the paper or an override is invalid
34
+ # @overload call(width:, height:, unit: "mm", name: :custom, margins: [])
35
+ # Builds a canvas from an explicit size.
36
+ # @param width [Numeric] canvas width
37
+ # @param height [Numeric] canvas height
38
+ # @param unit [Symbol, String] SVG unit
39
+ # @param name [Symbol, String] paper name
40
+ # @param margins [Array<Numeric>] margin shorthand values
41
+ # @return [Sevgi::Graphics::Canvas]
42
+ # @raise [Sevgi::ArgumentError] when a required field is omitted or a value is invalid
43
+ def self.call(paper = Undefined, **fields)
44
+ paper.equal?(Undefined) ? new(**fields) : from_paper(paper, **fields)
45
+ end
20
46
 
21
- # Builds a canvas from a paper profile or explicit size.
22
- # @param arg [Sevgi::Graphics::Paper, Symbol, String, Sevgi::Undefined] paper profile or paper object
23
- # @param kwargs [Hash] canvas keyword arguments
24
- # @return [Sevgi::Graphics::Canvas]
25
- # @raise [Sevgi::ArgumentError] when the paper profile is unknown
26
- def self.from_paper(arg = Undefined, **kwargs)
27
- case arg
28
- when Undefined
29
- new(**kwargs)
30
- else
31
- new(**paper(arg).to_h, **kwargs)
32
- end
47
+ # @overload from_paper(paper, **overrides)
48
+ # Builds a canvas from a paper profile with optional field overrides.
49
+ # @param paper [Sevgi::Graphics::Paper, Symbol, String] paper object or registered profile
50
+ # @param overrides [Hash] canvas field overrides
51
+ # @return [Sevgi::Graphics::Canvas]
52
+ # @raise [Sevgi::ArgumentError] when the paper is omitted or invalid, or an override is invalid
53
+ # @example Override a registered paper profile
54
+ # Sevgi::Graphics::Canvas.from_paper(:a4, margins: [10])
55
+ def self.from_paper(paper = Undefined, **overrides)
56
+ ArgumentError.("Paper is required") if paper.equal?(Undefined)
57
+
58
+ new(**profile(paper).to_h, **overrides)
33
59
  end
34
60
 
35
- def self.paper(arg)
36
- case arg
61
+ def self.profile(paper)
62
+ case paper
37
63
  when Paper
38
- arg
64
+ paper
39
65
  when ::Symbol, ::String
40
- ArgumentError.("Unknown paper profile: #{arg}") unless Paper.exist?(arg)
41
-
42
- Paper.public_send(arg)
66
+ Paper.fetch(paper)
43
67
  else
44
- ArgumentError.("Argument must be a Paper symbol: #{arg}")
68
+ ArgumentError.("Paper must be a Paper, Symbol, or String: #{paper}")
45
69
  end
46
70
  end
47
71
 
48
- private_class_method :paper
72
+ private_class_method :profile
49
73
 
50
74
  extend Forwardable
51
75
 
52
76
  def_delegators :@margin, *Margin.members
53
77
  def_delegators :@size, *Paper.members
54
78
 
55
- # @!attribute [r] size
56
- # @return [Sevgi::Graphics::Paper] paper size object
57
- # @!attribute [r] margin
58
- # @return [Sevgi::Graphics::Margin] canvas margins
59
- # @!attribute [r] inner
60
- # @return [Sevgi::Graphics::Paper] inner paper after margins
61
- attr_reader :size, :margin, :inner
62
-
63
- # Creates a canvas.
64
- # @param width [Numeric] canvas width
65
- # @param height [Numeric] canvas height
66
- # @param unit [Symbol, String] SVG unit
67
- # @param name [Symbol, String] paper name
68
- # @param margins [Array<Numeric>] margin shorthand values
69
- # @return [void]
70
- # @raise [Sevgi::ArgumentError] when margins or numeric dimensions are invalid
71
- def initialize(width:, height:, unit: "mm", name: :custom, margins: [])
72
- @size = Paper[width, height, unit, name]
73
- @margin = Margin[*margins]
79
+ # Returns the paper size object.
80
+ # @return [Sevgi::Graphics::Paper]
81
+ attr_reader :size
82
+
83
+ # Returns the canvas margins.
84
+ # @return [Sevgi::Graphics::Margin]
85
+ attr_reader :margin
86
+
87
+ # Returns the inner paper after margins.
88
+ # @return [Sevgi::Graphics::Paper]
89
+ attr_reader :inner
90
+
91
+ # @overload initialize(width:, height:, unit: "mm", name: :custom, margins: [])
92
+ # Creates a canvas with finite real dimensions greater than zero and margins that leave a positive inner area.
93
+ # @param width [Numeric] canvas width
94
+ # @param height [Numeric] canvas height
95
+ # @param unit [Symbol, String] SVG unit
96
+ # @param name [Symbol, String] paper name
97
+ # @param margins [Array<Numeric>] margin shorthand values
98
+ # @return [void]
99
+ # @raise [Sevgi::ArgumentError] when a required field is omitted, an option is unknown, or a value is invalid
100
+ def initialize(**fields)
101
+ unknown = fields.keys - FIELDS
102
+ ArgumentError.("Unknown canvas option: #{unknown.first}") unless unknown.empty?
103
+ ArgumentError.("Canvas width and height are required") unless REQUIRED.all? { fields.key?(it) }
104
+
105
+ fields = DEFAULTS.merge(fields)
106
+
107
+ @size = Paper[*fields.values_at(:width, :height, :unit, :name)]
108
+ @margin = Margin[*fields[:margins]]
74
109
 
75
110
  compute
76
111
  freeze
@@ -78,22 +113,48 @@ module Sevgi
78
113
 
79
114
  # @overload attributes(origin = Undefined)
80
115
  # Returns SVG root viewport attributes.
81
- # @param origin [Numeric, Array<Numeric>, nil, Sevgi::Undefined] viewBox origin
82
- # @return [Hash]
116
+ # Omission uses the negative left and top margins; nil uses zero for both coordinates.
117
+ # @example Compare margin-aware and zero-origin viewBoxes
118
+ # canvas = Sevgi::Graphics::Canvas.call(width: 80, height: 50, margins: [5, 10])
119
+ # canvas.attributes[:viewBox] # => "-10 -5 80 50"
120
+ # canvas.attributes(nil)[:viewBox] # => "0 0 80 50"
121
+ # @param origin [Numeric, Array<Numeric>, nil, Sevgi::Undefined] viewBox origin; a scalar sets both coordinates
122
+ # @return [Hash{Symbol => String}] SVG viewport and viewBox attributes
83
123
  # @raise [Sevgi::ArgumentError] when origin is invalid
84
124
  def attributes(...) = {**viewport, viewBox: viewbox(...)}
85
125
 
126
+ # Reports structural canvas equality by size and margins.
127
+ # @example Compare equivalent canvas values
128
+ # Sevgi::Graphics::Canvas.from_paper(:a4, margins: [10]) ==
129
+ # Sevgi::Graphics::Canvas.from_paper("a4", margins: [10]) # => true
130
+ # @param other [Object] object to compare
131
+ # @return [Boolean]
132
+ def eql?(other) = self.class == other.class && size.eql?(other.size) && margin.eql?(other.margin)
133
+
134
+ # Returns a hash compatible with structural equality.
135
+ # @return [Integer]
136
+ def hash = [self.class, size, margin].hash
137
+
138
+ alias == eql?
139
+
86
140
  # Returns SVG width and height attributes.
87
- # @return [Hash]
141
+ # @return [Hash{Symbol => String}] SVG width and height attributes
88
142
  def viewport = {width: "#{width}#{unit}", height: "#{height}#{unit}"}
89
143
 
90
144
  # Returns the SVG viewBox string.
91
- # @param origin [Numeric, Array<Numeric>, nil, Sevgi::Undefined] viewBox origin
145
+ # Omission uses the negative left and top margins; nil uses zero for both coordinates.
146
+ # @param origin [Numeric, Array<Numeric>, nil, Sevgi::Undefined] viewBox origin; a scalar sets both coordinates
92
147
  # @return [String]
93
148
  # @raise [Sevgi::ArgumentError] when origin is invalid
94
149
  def viewbox(origin = Undefined) = prettify(*originate(origin), width, height).join(" ")
95
150
 
96
151
  # Returns a canvas with selected fields replaced.
152
+ # Unspecified fields, including margins, retain their current values.
153
+ # @example Derive a canvas while preserving the source
154
+ # source = Sevgi::Graphics::Canvas.call(width: 80, height: 50, margins: [5])
155
+ # derived = source.with(width: 100, margins: [10, 5])
156
+ # source.width # => 80.0
157
+ # derived.inner.deconstruct # => [90.0, 30.0, :mm, :custom]
97
158
  # @param kwargs [Hash] replacement options
98
159
  # @option kwargs [Numeric] :width replacement canvas width
99
160
  # @option kwargs [Numeric] :height replacement canvas height
@@ -104,7 +165,7 @@ module Sevgi
104
165
  # @raise [Sevgi::ArgumentError] when an unknown option is supplied
105
166
  # @raise [Sevgi::ArgumentError] when a replacement value is invalid
106
167
  def with(**kwargs)
107
- unknown = kwargs.keys - REPLACEMENTS
168
+ unknown = kwargs.keys - FIELDS
108
169
 
109
170
  ArgumentError.("Unknown canvas option: #{unknown.first}") unless unknown.empty?
110
171
 
@@ -117,7 +178,21 @@ module Sevgi
117
178
  private
118
179
 
119
180
  def compute
120
- @inner = size.with(width: width - margin.left - margin.right, height: height - margin.top - margin.bottom)
181
+ @inner = size.with(**inner_size)
182
+ ensure_inner_area
183
+ end
184
+
185
+ def ensure_inner_area
186
+ return if @inner.width.positive? && @inner.height.positive?
187
+
188
+ ArgumentError.("Canvas margins must leave a positive inner area")
189
+ end
190
+
191
+ def inner_size
192
+ {
193
+ width: width - margin.left - margin.right,
194
+ height: height - margin.top - margin.bottom
195
+ }
121
196
  end
122
197
 
123
198
  def originate(origin)
@@ -125,7 +200,7 @@ module Sevgi
125
200
  when Undefined
126
201
  [-margin.left, -margin.top]
127
202
  when ::Numeric, ::NilClass
128
- [origin.to_f, origin.to_f]
203
+ scalar_origin(origin)
129
204
  when ::Array
130
205
  pair(origin)
131
206
  else
@@ -134,9 +209,13 @@ module Sevgi
134
209
  end
135
210
 
136
211
  def coordinate!(field, value)
137
- Float(value)
138
- rescue ::ArgumentError, ::TypeError
139
- ArgumentError.("Invalid canvas origin #{field}: #{value.inspect}")
212
+ return 0.0 if value.nil?
213
+
214
+ Scalar.finite(value, context: "canvas origin", field:)
215
+ end
216
+
217
+ def scalar_origin(origin)
218
+ coordinate!(:origin, origin || 0).then { [it, it] }
140
219
  end
141
220
 
142
221
  def prettify(*floats)
@@ -2,123 +2,246 @@
2
2
 
3
3
  module Sevgi
4
4
  module Graphics
5
- # Renderable text-like content inside an SVG element.
5
+ # Extensible protocol for renderable text-like content inside an SVG element. Use {.cdata}, {.css}, {.encoded}, or
6
+ # {.verbatim} for built-in content, or subclass Content and implement {#render} for a custom serialization. Content
7
+ # owns an immutable deep snapshot: strings and containers are copied, mutable leaf objects are stringified once
8
+ # during construction, and {#content} returns caller-owned copies. Later mutations cannot change rendering or
9
+ # invalidate the construction-time XML checks.
10
+ #
11
+ # @abstract Subclasses implement {#render} and expose their own construction API.
12
+ # @example Choose a built-in content channel
13
+ # Sevgi::Graphics.SVG(:minimal) do
14
+ # text "A & B" # ordinary String arguments are encoded automatically
15
+ # text Sevgi::Graphics::Content.encoded("A & B")
16
+ # style Sevgi::Graphics::Content.cdata(".note { fill: red; }")
17
+ # style Sevgi::Graphics::Content.css(".note" => {fill: "red"})
18
+ # g Sevgi::Graphics::Content.verbatim("<title>trusted markup</title>")
19
+ # end
20
+ # @example Define custom content that emits an SVG tspan
21
+ # class Emphasis < Sevgi::Graphics::Content
22
+ # def self.[](content) = send(:new, content)
23
+ #
24
+ # def render(output, depth)
25
+ # text = Sevgi::Graphics::Content.encoded(to_s).to_s
26
+ # output.append(depth + 1, %(<tspan font-style="italic">#{text}</tspan>))
27
+ # end
28
+ # end
29
+ #
30
+ # Sevgi::Graphics.SVG(:minimal) { text Emphasis["important & safe"] }.Render
31
+ #
32
+ # # The renderer ignores Emphasis#render's return value. Custom content must escape inserted data itself.
33
+ # @see https://sevgi.roktas.dev/svg/#content-safety Content safety guide
6
34
  class Content
7
- # @!attribute [r] content
8
- # @return [Object] wrapped content
9
- attr_reader :content
35
+ private_class_method :new
10
36
 
11
- # Creates content.
37
+ # Immutable payload capture and caller-owned copy helpers.
38
+ # @api private
39
+ module Snapshot
40
+ SCALARS = [::NilClass, ::TrueClass, ::FalseClass, ::Symbol, ::Integer, ::Float, ::Rational, ::Complex].freeze
41
+ private_constant :SCALARS
42
+
43
+ class << self
44
+ def capture(value, seen = {}.compare_by_identity)
45
+ case value
46
+ when ::String
47
+ XML.text(value).freeze
48
+ when ::Hash
49
+ nested(value, seen) { capture_hash(value, seen) }.freeze
50
+ when ::Array
51
+ nested(value, seen) { value.map { capture(it, seen) } }.freeze
52
+ else
53
+ SCALARS.include?(value.class) ? value : stringify(value).freeze
54
+ end
55
+ end
56
+
57
+ def copy(value)
58
+ case value
59
+ when ::String
60
+ value.dup
61
+ when ::Hash
62
+ value.to_h { |key, item| [copy(key), copy(item)] }
63
+ when ::Array
64
+ value.map { copy(it) }
65
+ else
66
+ value
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def capture_hash(value, seen)
73
+ value.each_with_object({}) do |(key, item), captured|
74
+ key = capture(key, seen)
75
+ ArgumentError.("XML content keys collide after stringification") if captured.key?(key)
76
+
77
+ captured[key] = capture(item, seen)
78
+ end
79
+ end
80
+
81
+ def nested(value, seen)
82
+ ArgumentError.("Cyclic XML content is not supported") if seen.key?(value)
83
+
84
+ seen[value] = true
85
+ yield
86
+ ensure
87
+ seen.delete(value)
88
+ end
89
+
90
+ def stringify(value)
91
+ text = value.to_s
92
+ ArgumentError.("XML content stringification must return a String") unless text.is_a?(::String)
93
+
94
+ XML.text(text)
95
+ rescue Sevgi::ArgumentError
96
+ raise
97
+ rescue ::StandardError => e
98
+ ArgumentError.("XML content cannot be stringified: #{e.class}: #{e.message}")
99
+ end
100
+ end
101
+ end
102
+
103
+ private_constant :Snapshot
104
+
105
+ # Returns a recursively independent, caller-owned payload snapshot.
106
+ # @return [Object] wrapped content snapshot
107
+ def content = Snapshot.copy(@content)
108
+
109
+ # Creates immutable content from a deep payload snapshot. Strings and containers are copied recursively; mutable
110
+ # non-container objects are stringified once during construction. The caller's objects are never retained.
12
111
  # @param content [Object] wrapped content
13
112
  # @return [void]
14
- def initialize(content) = @content = content
113
+ # @raise [Sevgi::ArgumentError] when content cannot be stringified, contains invalid encoding or illegal XML
114
+ # characters, contains cycles, or has keys that collide after stringification
115
+ # @api private
116
+ def initialize(content)
117
+ @content = Snapshot.capture(content)
118
+ XML.validate(@content)
119
+ end
120
+
121
+ # Copies content payload ownership for duplicated element trees.
122
+ # @param original [Sevgi::Graphics::Content] source content
123
+ # @return [void]
124
+ # @api private
125
+ def initialize_copy(original)
126
+ @content = Snapshot.capture(original.content)
127
+ super
128
+ end
15
129
 
16
- # Renders content with a renderer.
130
+ private :initialize_copy
131
+
132
+ # Appends this content's serialized XML lines to rendering output.
133
+ # The output collaborator responds to `append(depth, *lines)`, where `depth` is an Integer or nil and every line
134
+ # is a String containing valid serialized XML text. The rendering engine ignores both `append` and `render` return
135
+ # values. Custom content is responsible for escaping data that it inserts into markup.
17
136
  # @abstract Subclasses implement content rendering.
18
- # @param _renderer [Object] renderer receiving output
137
+ # @param _output [#append] rendering output collaborator
19
138
  # @param _depth [Integer] current render depth
20
- # @return [void]
139
+ # @return [Object] ignored by the rendering engine
21
140
  # @raise [Sevgi::PanicError] when a subclass does not implement render
22
- def render(_renderer, _depth) = PanicError.("#{self.class}#render must be implemented")
141
+ def render(_output, _depth) = PanicError.("#{self.class}#render must be implemented")
23
142
 
24
143
  # Returns content as a string.
25
144
  # @return [String]
26
- def to_s = content.to_s
145
+ def to_s = XML.text(payload)
146
+
147
+ def payload = @content
148
+
149
+ private :payload
27
150
 
28
151
  # @overload cdata(content)
29
152
  # Builds CDATA content.
30
- # Content values are stringified during rendering, and embedded CDATA terminators are split safely.
153
+ # Mutable objects are stringified during construction, and embedded CDATA terminators are split safely.
31
154
  # @param content [Object] wrapped content
32
155
  # @return [Sevgi::Graphics::Content::CData]
33
- def self.cdata(...) = CData.new(...)
34
-
35
- # Wraps content arguments, encoding non-content values.
36
- # Non-content values are stringified by encoded content before XML text escaping.
37
- # @param args [Array<Object>] content arguments
38
- # @return [Array<Sevgi::Graphics::Content>]
39
- def self.contents(*args) = args.map { it.is_a?(Content) ? it : encoded(it) }
156
+ # @raise [Sevgi::ArgumentError] when content cannot be stringified, contains invalid encoding or illegal XML 1.0
157
+ # characters, contains cycles, or has keys that collide after stringification
158
+ def self.cdata(...) = CData.send(:new, ...)
40
159
 
41
160
  # @overload css(content)
42
161
  # Builds CSS content.
43
162
  # @param content [Hash] CSS rules
44
163
  # @return [Sevgi::Graphics::Content::CSS]
45
- # @raise [Sevgi::ArgumentError] when content is not a hash
46
- def self.css(...) = CSS.new(...)
164
+ # @raise [Sevgi::ArgumentError] when content is not a hash, contains a malformed style, cannot be stringified,
165
+ # contains invalid encoding or illegal XML 1.0 characters, contains cycles, or has keys that collide after
166
+ # stringification
167
+ def self.css(...) = CSS.send(:new, ...)
47
168
 
48
169
  # @overload encoded(content)
49
170
  # Builds XML text-encoded content.
50
- # Arbitrary objects are stringified before XML text escaping.
171
+ # Mutable objects are stringified during construction before XML text escaping.
51
172
  # @param content [Object] wrapped content
52
173
  # @return [Sevgi::Graphics::Content::Encoded]
53
- def self.encoded(...) = Encoded.new(...)
54
-
55
- # Joins content lines with newlines.
56
- # @param contents [Object, Array<Object>] content lines
57
- # @return [String]
58
- def self.text(contents) = Array(contents).join("\n")
174
+ # @raise [Sevgi::ArgumentError] when content cannot be stringified, contains invalid encoding or illegal XML 1.0
175
+ # characters, contains cycles, or has keys that collide after stringification
176
+ def self.encoded(...) = Encoded.send(:new, ...)
59
177
 
60
178
  # @overload verbatim(content)
61
179
  # Builds verbatim content.
180
+ # Mutable objects are stringified during construction.
62
181
  # @param content [Object] wrapped content
63
182
  # @return [Sevgi::Graphics::Content::Verbatim]
64
- def self.verbatim(...) = Verbatim.new(...)
183
+ # @raise [Sevgi::ArgumentError] when content cannot be stringified, contains invalid encoding or illegal XML 1.0
184
+ # characters, contains cycles, or has keys that collide after stringification
185
+ def self.verbatim(...) = Verbatim.send(:new, ...)
65
186
 
66
- # CDATA section content.
187
+ # CDATA section content backed by an immutable payload snapshot. Mutable leaf objects are stringified during
188
+ # construction; embedded terminators are split during rendering.
189
+ # @see Content.cdata
67
190
  class CData < Content
68
- TERMINATOR = "]]>"
69
- TERMINATOR_SPLIT = "]]]]><![CDATA[>"
70
- private_constant :TERMINATOR, :TERMINATOR_SPLIT
71
-
72
191
  # Renders CDATA content.
73
192
  # Embedded `]]>` terminators are split across adjacent CDATA sections so the output remains valid XML.
74
- # @param renderer [Object] renderer receiving output
193
+ # @param renderer [#append] rendering output collaborator
75
194
  # @param depth [Integer] current render depth
76
- # @return [void]
195
+ # @return [Object] ignored by the rendering engine
77
196
  def render(renderer, depth)
78
197
  depth += 1
79
198
 
80
199
  renderer.append(depth, "<![CDATA[")
81
- renderer.append(depth + 1, *Array(content).map { safe(it) })
200
+ renderer.append(depth + 1, *Array(payload).map { safe(it) })
82
201
  renderer.append(depth, "]]>")
83
202
  end
84
203
 
85
204
  private
86
205
 
87
- def safe(value) = value.to_s.gsub(TERMINATOR, TERMINATOR_SPLIT)
206
+ def safe(value) = XML.cdata(value)
88
207
  end
89
208
 
90
- # CSS content rendered inside a CDATA section.
209
+ # CSS content rendered inside a CDATA section. Rules are captured recursively during construction; mutable
210
+ # selectors, property names, and values are stringified once, and embedded CDATA terminators are split safely.
211
+ # @see Content.css
91
212
  class CSS < Content
92
213
  # Creates CSS content.
93
214
  # @param content [Hash] CSS rules
94
215
  # @return [void]
95
- # @raise [Sevgi::ArgumentError] when content is not a hash
216
+ # @raise [Sevgi::ArgumentError] when content is not a hash, contains a malformed style, cannot be stringified,
217
+ # contains invalid encoding or illegal XML 1.0 characters, contains cycles, or has keys that collide after
218
+ # stringification
219
+ # @api private
96
220
  def initialize(content)
97
- ArgumentError.("CSS content must be a hash: #{content}") unless content.is_a?(::Hash)
221
+ ArgumentError.("CSS content must be a hash") unless content.is_a?(::Hash)
222
+ validate_styles(content)
98
223
 
99
224
  super
100
225
  end
101
226
 
102
- # rubocop:disable Metrics/MethodLength
103
227
  # Renders CSS content.
104
- # @param renderer [Object] renderer receiving output
228
+ # @param renderer [#append] rendering output collaborator
105
229
  # @param depth [Integer] current render depth
106
- # @return [void]
107
- # @raise [Sevgi::ArgumentError] when a style value cannot be rendered
230
+ # @return [Object] ignored by the rendering engine
108
231
  def render(renderer, depth)
109
232
  depth += 1
110
233
 
111
234
  renderer.append(depth, "<![CDATA[")
112
235
 
113
236
  depth += 1
114
- content.each do |rule, styles|
237
+ payload.each do |rule, styles|
115
238
  case styles
116
239
  when ::Hash
117
- renderer.append(depth, "#{rule} {")
118
- renderer.append(depth + 1, *styles.map { |key, value| "#{key}: #{value};" })
240
+ renderer.append(depth, safe("#{rule} {"))
241
+ renderer.append(depth + 1, *styles.map { |key, value| safe("#{key}: #{value};") })
119
242
  renderer.append(depth, "}")
120
243
  when ::String, ::Symbol, ::Numeric
121
- renderer.append(depth, "#{rule}: #{styles};")
244
+ renderer.append(depth, safe("#{rule}: #{styles};"))
122
245
  else
123
246
  ArgumentError.("Malformed style: #{styles}")
124
247
  end
@@ -128,28 +251,48 @@ module Sevgi
128
251
 
129
252
  renderer.append(depth, "]]>")
130
253
  end
131
- # rubocop:enable Metrics/MethodLength
254
+
255
+ private
256
+
257
+ def safe(value) = XML.cdata(value)
258
+
259
+ def validate_styles(content)
260
+ content.each_value do |styles|
261
+ next if styles.is_a?(::Hash) || styles.is_a?(::String) || styles.is_a?(::Symbol) || styles.is_a?(::Numeric)
262
+
263
+ ArgumentError.("Malformed style type: #{styles.class}")
264
+ end
265
+ end
132
266
  end
133
267
 
134
- # XML text-encoded content.
268
+ # XML text-encoded content backed by an immutable payload snapshot. Mutable leaf objects are stringified during
269
+ # construction, before XML escaping.
270
+ # @see Content.encoded
135
271
  class Encoded < Content
136
272
  # Returns XML text-encoded content.
137
273
  # @return [String]
138
- def to_s = content.to_s.encode(xml: :text)
274
+ def to_s = XML.text(payload).encode(xml: :text)
139
275
 
140
276
  # Renders encoded text content.
141
- # @param renderer [Object] renderer receiving output
277
+ # @param renderer [#append] rendering output collaborator
142
278
  # @param depth [Integer] current render depth
143
- # @return [void]
279
+ # @return [Object] ignored by the rendering engine
144
280
  def render(renderer, depth) = renderer.append(depth + 1, to_s)
145
281
  end
146
282
 
147
- # Verbatim content rendered without XML text encoding.
283
+ # Verbatim content backed by an immutable payload snapshot. Mutable leaf objects are stringified during
284
+ # construction. Verbatim content bypasses XML escaping; validation guarantees encoding and legal XML 1.0 code
285
+ # points, not well-formed markup supplied by the caller.
286
+ # @see Content.verbatim
148
287
  class Verbatim < Content
288
+ # Returns validated verbatim content.
289
+ # @return [String]
290
+ def to_s = XML.text(payload)
291
+
149
292
  # Renders verbatim content.
150
- # @param renderer [Object] renderer receiving output
293
+ # @param renderer [#append] rendering output collaborator
151
294
  # @param depth [Integer] current render depth
152
- # @return [void]
295
+ # @return [Object] ignored by the rendering engine
153
296
  def render(renderer, depth) = renderer.append(depth + 1, to_s)
154
297
  end
155
298
  end