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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +275 -2
- data/LICENSE +672 -3
- data/README.md +8 -8
- data/lib/sevgi/graphics/attribute.rb +271 -54
- data/lib/sevgi/graphics/auxiliary/canvas.rb +137 -58
- data/lib/sevgi/graphics/auxiliary/content.rb +200 -57
- data/lib/sevgi/graphics/auxiliary/margin.rb +33 -14
- data/lib/sevgi/graphics/auxiliary/paper.rb +118 -86
- data/lib/sevgi/graphics/auxiliary/path.rb +44 -0
- data/lib/sevgi/graphics/auxiliary/scalar.rb +69 -0
- data/lib/sevgi/graphics/auxiliary/sizes.rb +62 -0
- data/lib/sevgi/graphics/auxiliary.rb +3 -0
- data/lib/sevgi/graphics/document/base.rb +6 -2
- data/lib/sevgi/graphics/document/default.rb +1 -1
- data/lib/sevgi/graphics/document.rb +370 -124
- data/lib/sevgi/graphics/element.rb +143 -33
- data/lib/sevgi/graphics/mixtures/call.rb +249 -88
- data/lib/sevgi/graphics/mixtures/core.rb +143 -33
- data/lib/sevgi/graphics/mixtures/duplicate.rb +76 -22
- data/lib/sevgi/graphics/mixtures/export.rb +58 -12
- data/lib/sevgi/graphics/mixtures/hatch.rb +49 -7
- data/lib/sevgi/graphics/mixtures/identify.rb +30 -17
- data/lib/sevgi/graphics/mixtures/include.rb +40 -5
- data/lib/sevgi/graphics/mixtures/inkscape.rb +215 -46
- data/lib/sevgi/graphics/mixtures/rdf.rb +79 -7
- data/lib/sevgi/graphics/mixtures/render.rb +88 -19
- data/lib/sevgi/graphics/mixtures/save.rb +79 -26
- data/lib/sevgi/graphics/mixtures/symbols.rb +81 -9
- data/lib/sevgi/graphics/mixtures/tile.rb +88 -64
- data/lib/sevgi/graphics/mixtures/transform.rb +68 -28
- data/lib/sevgi/graphics/mixtures/underscore.rb +16 -7
- data/lib/sevgi/graphics/mixtures/validate.rb +14 -2
- data/lib/sevgi/graphics/mixtures/wrappers.rb +66 -24
- data/lib/sevgi/graphics/mixtures.rb +19 -13
- data/lib/sevgi/graphics/version.rb +1 -1
- data/lib/sevgi/graphics/xml.rb +127 -0
- data/lib/sevgi/graphics.rb +75 -28
- 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
|
-
|
|
11
|
-
private_constant :ORIGIN_FIELDS, :
|
|
25
|
+
REQUIRED = %i[width height].freeze
|
|
26
|
+
private_constant :DEFAULTS, :FIELDS, :ORIGIN_FIELDS, :REQUIRED
|
|
12
27
|
|
|
13
|
-
# @overload call(
|
|
14
|
-
# Builds a canvas from a paper profile
|
|
15
|
-
# @param
|
|
16
|
-
# @param
|
|
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
|
|
19
|
-
|
|
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
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
36
|
-
case
|
|
61
|
+
def self.profile(paper)
|
|
62
|
+
case paper
|
|
37
63
|
when Paper
|
|
38
|
-
|
|
64
|
+
paper
|
|
39
65
|
when ::Symbol, ::String
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
Paper.public_send(arg)
|
|
66
|
+
Paper.fetch(paper)
|
|
43
67
|
else
|
|
44
|
-
ArgumentError.("
|
|
68
|
+
ArgumentError.("Paper must be a Paper, Symbol, or String: #{paper}")
|
|
45
69
|
end
|
|
46
70
|
end
|
|
47
71
|
|
|
48
|
-
private_class_method :
|
|
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
|
-
#
|
|
56
|
-
#
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
attr_reader :
|
|
62
|
-
|
|
63
|
-
#
|
|
64
|
-
# @
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
# @
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
#
|
|
82
|
-
# @
|
|
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
|
-
#
|
|
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 -
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
8
|
-
# @return [Object] wrapped content
|
|
9
|
-
attr_reader :content
|
|
35
|
+
private_class_method :new
|
|
10
36
|
|
|
11
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
137
|
+
# @param _output [#append] rendering output collaborator
|
|
19
138
|
# @param _depth [Integer] current render depth
|
|
20
|
-
# @return [
|
|
139
|
+
# @return [Object] ignored by the rendering engine
|
|
21
140
|
# @raise [Sevgi::PanicError] when a subclass does not implement render
|
|
22
|
-
def render(
|
|
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 =
|
|
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
|
-
#
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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 [
|
|
193
|
+
# @param renderer [#append] rendering output collaborator
|
|
75
194
|
# @param depth [Integer] current render depth
|
|
76
|
-
# @return [
|
|
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(
|
|
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) =
|
|
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
|
|
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 [
|
|
228
|
+
# @param renderer [#append] rendering output collaborator
|
|
105
229
|
# @param depth [Integer] current render depth
|
|
106
|
-
# @return [
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
274
|
+
def to_s = XML.text(payload).encode(xml: :text)
|
|
139
275
|
|
|
140
276
|
# Renders encoded text content.
|
|
141
|
-
# @param renderer [
|
|
277
|
+
# @param renderer [#append] rendering output collaborator
|
|
142
278
|
# @param depth [Integer] current render depth
|
|
143
|
-
# @return [
|
|
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
|
|
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 [
|
|
293
|
+
# @param renderer [#append] rendering output collaborator
|
|
151
294
|
# @param depth [Integer] current render depth
|
|
152
|
-
# @return [
|
|
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
|