sevgi-graphics 0.98.2 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +96 -2
- data/README.md +4 -1
- data/lib/sevgi/graphics/attribute.rb +8 -10
- data/lib/sevgi/graphics/auxiliary/canvas.rb +4 -4
- data/lib/sevgi/graphics/auxiliary/content.rb +5 -16
- data/lib/sevgi/graphics/document/base.rb +2 -2
- data/lib/sevgi/graphics/document.rb +15 -27
- data/lib/sevgi/graphics/element.rb +23 -8
- data/lib/sevgi/graphics/mixtures/call.rb +9 -24
- data/lib/sevgi/graphics/mixtures/core.rb +10 -9
- data/lib/sevgi/graphics/mixtures/duplicate.rb +4 -16
- data/lib/sevgi/graphics/mixtures/export.rb +2 -0
- data/lib/sevgi/graphics/mixtures/hatch.rb +5 -5
- data/lib/sevgi/graphics/mixtures/identify.rb +1 -1
- data/lib/sevgi/graphics/mixtures/inkscape.rb +23 -10
- data/lib/sevgi/graphics/mixtures/rdf.rb +1 -1
- data/lib/sevgi/graphics/mixtures/render.rb +11 -95
- data/lib/sevgi/graphics/mixtures/save.rb +2 -2
- data/lib/sevgi/graphics/mixtures/symbols.rb +2 -2
- data/lib/sevgi/graphics/mixtures/tile.rb +4 -4
- data/lib/sevgi/graphics/mixtures/transform.rb +22 -3
- data/lib/sevgi/graphics/mixtures/underscore.rb +2 -2
- data/lib/sevgi/graphics/mixtures/wrappers.rb +49 -0
- data/lib/sevgi/graphics/version.rb +1 -1
- data/lib/sevgi/graphics/xml.rb +4 -9
- data/lib/sevgi/graphics.rb +22 -17
- metadata +4 -4
|
@@ -10,8 +10,9 @@ module Sevgi
|
|
|
10
10
|
# moved to non-rendering `-id` metadata before the optional block runs, allowing the block to derive replacement
|
|
11
11
|
# ids without rendering duplicates. A pre-existing `-id` takes precedence over the visible id.
|
|
12
12
|
# Translation and parent channels are validated before the subtree is copied or the customization block runs.
|
|
13
|
-
#
|
|
14
|
-
# @param
|
|
13
|
+
# The copied subtree has no parent during customization and is attached to the target parent afterward.
|
|
14
|
+
# @param dx [Numeric, nil] finite x translation. Nil omits the axis
|
|
15
|
+
# @param dy [Numeric, nil] finite y translation. Nil omits the axis
|
|
15
16
|
# @param parent [Sevgi::Graphics::Element, nil] explicit parent, or the source parent when nil
|
|
16
17
|
# @yield [element] optional customization hook for each copied element
|
|
17
18
|
# @yieldparam element [Sevgi::Graphics::Element] copied element
|
|
@@ -27,7 +28,7 @@ module Sevgi
|
|
|
27
28
|
# end
|
|
28
29
|
def Duplicate(dx: nil, dy: nil, parent: nil, &block)
|
|
29
30
|
dx, dy, target = Subtree.channels(self, dx, dy, parent)
|
|
30
|
-
duplicated =
|
|
31
|
+
duplicated = dup
|
|
31
32
|
Subtree.prepare(duplicated, &block)
|
|
32
33
|
Subtree.translate(duplicated, dx, dy)
|
|
33
34
|
Subtree.attach(duplicated, target)
|
|
@@ -80,19 +81,6 @@ module Sevgi
|
|
|
80
81
|
[dx, dy, target]
|
|
81
82
|
end
|
|
82
83
|
|
|
83
|
-
# Builds an independent copy of an element subtree.
|
|
84
|
-
# @param element [Sevgi::Graphics::Element] source subtree root
|
|
85
|
-
# @param parent [Sevgi::Graphics::Element, Object] parent for the copied root
|
|
86
|
-
# @return [Sevgi::Graphics::Element] copied subtree root
|
|
87
|
-
def self.copy(element, parent = Element.send(:tree_parent, element))
|
|
88
|
-
element.dup.tap do |duplicated|
|
|
89
|
-
duplicated.send(:parent=, parent)
|
|
90
|
-
duplicated.send(:attributes=, element.attributes.dup)
|
|
91
|
-
duplicated.send(:contents=, element.contents.map(&:dup))
|
|
92
|
-
duplicated.send(:children=, element.children.map { |child| copy(child, duplicated) })
|
|
93
|
-
end
|
|
94
|
-
end
|
|
95
|
-
|
|
96
84
|
# Removes copied public ids and applies an optional customization hook.
|
|
97
85
|
# @api private
|
|
98
86
|
def self.prepare(element, &block)
|
|
@@ -4,6 +4,8 @@ module Sevgi
|
|
|
4
4
|
module Graphics
|
|
5
5
|
module Mixtures
|
|
6
6
|
# DSL helpers for native SVG export formats.
|
|
7
|
+
# Export CSS is a late adjustment after document checks. Its supported XML endings and cascade limitations are
|
|
8
|
+
# documented in {Sevgi::Sundries::Export}. The source callback receives SVG after CSS insertion.
|
|
7
9
|
module Export
|
|
8
10
|
# Validates the closed export option channel before optional components load or rendering starts.
|
|
9
11
|
# @api private
|
|
@@ -6,10 +6,10 @@ module Sevgi
|
|
|
6
6
|
# DSL helpers for drawing geometry values and geometry-derived hatch lines.
|
|
7
7
|
#
|
|
8
8
|
# `Draw` delegates to each geometry object's drawing protocol. `Hatch`
|
|
9
|
-
# first sweeps interior spans through a closed
|
|
10
|
-
# them. Its `angle` describes line direction
|
|
9
|
+
# first sweeps interior spans through a closed geometry element, then draws
|
|
10
|
+
# them. Its `angle` describes line direction. `step` is perpendicular
|
|
11
11
|
# spacing. The default initial line passes through `element.position`.
|
|
12
|
-
# The built-in `:inkscape` document profile includes this mixture
|
|
12
|
+
# The built-in `:inkscape` document profile includes this mixture.
|
|
13
13
|
# `:minimal`, `:default`, and `:html` do not.
|
|
14
14
|
#
|
|
15
15
|
# Hatch materializes separate finite SVG path elements. When only a repeated visual fill matters, use an SVG
|
|
@@ -52,7 +52,7 @@ module Sevgi
|
|
|
52
52
|
# Sevgi::Graphics.SVG(:inkscape) do
|
|
53
53
|
# Hatch region, initial: [2, 8], angle: 0, step: 3
|
|
54
54
|
# end
|
|
55
|
-
# @param element [Sevgi::Geometry::Element
|
|
55
|
+
# @param element [Sevgi::Geometry::Element] geometry element to sweep
|
|
56
56
|
# @param angle [Numeric] hatch angle in degrees
|
|
57
57
|
# @param step [Numeric] distance between hatch lines
|
|
58
58
|
# @param initial [Sevgi::Geometry::Point, Array<Numeric>, nil] initial sweep point, or nil for element.position
|
|
@@ -72,7 +72,7 @@ module Sevgi
|
|
|
72
72
|
MissingComponentError.("sevgi/geometry")
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
-
initial = element.position if initial.nil? && element.is_a?(Geometry::Element
|
|
75
|
+
initial = element.position if initial.nil? && element.is_a?(Geometry::Element)
|
|
76
76
|
Draw(Geometry::Operation.sweep!(element, initial:, angle:, step:), **kwargs)
|
|
77
77
|
end
|
|
78
78
|
end
|
|
@@ -6,7 +6,7 @@ module Sevgi
|
|
|
6
6
|
# DSL helpers for collecting and hiding SVG ids.
|
|
7
7
|
module Identify
|
|
8
8
|
# Immutable snapshot of every rendered element id under a subtree. Keys are the serialized id values, including
|
|
9
|
-
# `"false"` and the empty String. Keys and containers are owned by the index
|
|
9
|
+
# `"false"` and the empty String. Keys and containers are owned by the index. Values retain references to the
|
|
10
10
|
# elements present when the snapshot is built. Later tree changes require a new index.
|
|
11
11
|
class Identifiers
|
|
12
12
|
# @return [Sevgi::Graphics::Element] indexed root element
|
|
@@ -48,7 +48,7 @@ module Sevgi
|
|
|
48
48
|
# Sevgi::Graphics.SVG(:inkscape) { Group drawing, attributes: {id: "drawing"} }
|
|
49
49
|
# @param mod [Module] module extended with {Sevgi::Graphics::Module}
|
|
50
50
|
# @param args [Array<Object>] callable arguments
|
|
51
|
-
# @param attributes [Hash] group attributes
|
|
51
|
+
# @param attributes [Hash] group attributes. String and Symbol names are normalized and must not collide
|
|
52
52
|
# @param kwargs [Hash] callable keyword arguments
|
|
53
53
|
# @yield forwards customization to the callable module
|
|
54
54
|
# @yieldreturn [Object] callable customization result
|
|
@@ -65,7 +65,7 @@ module Sevgi
|
|
|
65
65
|
# Named modules default the layer id to their final constant name. Anonymous modules omit the id unless supplied.
|
|
66
66
|
# @param mod [Module] module extended with {Sevgi::Graphics::Module}
|
|
67
67
|
# @param args [Array<Object>] callable arguments
|
|
68
|
-
# @param attributes [Hash] layer attributes
|
|
68
|
+
# @param attributes [Hash] layer attributes. String and Symbol names are normalized and must not collide
|
|
69
69
|
# @param kwargs [Hash] callable keyword arguments
|
|
70
70
|
# @yield forwards customization to the callable module
|
|
71
71
|
# @yieldreturn [Object] callable customization result
|
|
@@ -82,7 +82,7 @@ module Sevgi
|
|
|
82
82
|
# Named modules default the layer id to their final constant name. Anonymous modules omit the id unless supplied.
|
|
83
83
|
# @param mod [Module] module extended with {Sevgi::Graphics::Module}
|
|
84
84
|
# @param args [Array<Object>] callable arguments
|
|
85
|
-
# @param attributes [Hash] layer attributes
|
|
85
|
+
# @param attributes [Hash] layer attributes. String and Symbol names are normalized and must not collide
|
|
86
86
|
# @param kwargs [Hash] callable keyword arguments
|
|
87
87
|
# @yield forwards customization to the callable module
|
|
88
88
|
# @yieldreturn [Object] callable customization result
|
|
@@ -163,7 +163,7 @@ module Sevgi
|
|
|
163
163
|
def channels(namedview, page)
|
|
164
164
|
ArgumentError.("Namedview attributes must be a Hash") unless namedview.is_a?(::Hash)
|
|
165
165
|
ArgumentError.("Page attributes must be a Hash") unless page.is_a?(::Hash)
|
|
166
|
-
[Attribute.defaults(namedview, id: "namedview"),
|
|
166
|
+
[Attribute.defaults(namedview, id: "namedview"), page_attributes(page)]
|
|
167
167
|
end
|
|
168
168
|
|
|
169
169
|
# Normalizes one explicit or generated page id.
|
|
@@ -181,7 +181,7 @@ module Sevgi
|
|
|
181
181
|
# @api private
|
|
182
182
|
def normalize(attributes, defaults, index)
|
|
183
183
|
ArgumentError.("Page #{index + 1} must be a Hash") unless attributes.is_a?(::Hash)
|
|
184
|
-
attributes = defaults.merge(
|
|
184
|
+
attributes = defaults.merge(page_attributes(attributes))
|
|
185
185
|
%i[x y].each { number(attributes, it, index) }
|
|
186
186
|
%i[width height].each do |field|
|
|
187
187
|
number(attributes, field, index, positive: true)
|
|
@@ -190,6 +190,19 @@ module Sevgi
|
|
|
190
190
|
identify(attributes, index)
|
|
191
191
|
end
|
|
192
192
|
|
|
193
|
+
# Coerces numeric page fields before attribute snapshots stringify mutable numeric subclasses.
|
|
194
|
+
def page_attributes(attributes)
|
|
195
|
+
Attribute.normalize(
|
|
196
|
+
attributes.to_h do |key, value|
|
|
197
|
+
if %w[x y width height].include?(key.to_s) && value.is_a?(::Numeric)
|
|
198
|
+
value = Scalar.number(value, context: "page", field: key)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
[key, value]
|
|
202
|
+
end
|
|
203
|
+
)
|
|
204
|
+
end
|
|
205
|
+
|
|
193
206
|
# Validates and normalizes page-grid arguments.
|
|
194
207
|
# @return [Array<(Integer, Float)>] normalized width, height, and gap
|
|
195
208
|
# @raise [Sevgi::ArgumentError] when a count, dimension, or gap is invalid
|
|
@@ -239,9 +252,9 @@ module Sevgi
|
|
|
239
252
|
# page: {class: "print"}
|
|
240
253
|
# )
|
|
241
254
|
# end
|
|
242
|
-
# @param pages [Array<Hash>] page attribute hashes
|
|
243
|
-
# @param namedview [Hash] namedview attributes
|
|
244
|
-
# @param page [Hash] page defaults
|
|
255
|
+
# @param pages [Array<Hash>] page attribute hashes. Numeric page fields are normalized to SVG numbers
|
|
256
|
+
# @param namedview [Hash] namedview attributes. String and Symbol names are normalized and must not collide
|
|
257
|
+
# @param page [Hash] page defaults. Normalized page attributes override these defaults by name
|
|
245
258
|
# @yield [page] customizes each generated page element
|
|
246
259
|
# @yieldparam page [Sevgi::Graphics::Element] generated page element
|
|
247
260
|
# @yieldreturn [Object] ignored customization result
|
|
@@ -262,8 +275,8 @@ module Sevgi
|
|
|
262
275
|
# @param width [Numeric] finite positive page width, normalized to an SVG number
|
|
263
276
|
# @param height [Numeric] finite positive page height, normalized to an SVG number
|
|
264
277
|
# @param gap [Numeric] finite non-negative gap, normalized to an SVG number
|
|
265
|
-
# @param namedview [Hash] namedview attributes
|
|
266
|
-
# @param page [Hash] page defaults
|
|
278
|
+
# @param namedview [Hash] namedview attributes. String and Symbol names are normalized and must not collide
|
|
279
|
+
# @param page [Hash] page defaults. Normalized page attributes override these defaults by name
|
|
267
280
|
# @yield [page] customizes each generated page element
|
|
268
281
|
# @yieldparam page [Sevgi::Graphics::Element] generated page element
|
|
269
282
|
# @yieldreturn [Object] ignored customization result
|
|
@@ -114,7 +114,7 @@ module Sevgi
|
|
|
114
114
|
def License_LAL(**kwargs, &block) = License(**kwargs, license: "https://artlibre.org/licence/lal/en/", &block)
|
|
115
115
|
|
|
116
116
|
# Builds an RDF root element.
|
|
117
|
-
# @param kwargs [Hash] options
|
|
117
|
+
# @param kwargs [Hash] options. RDF currently accepts none
|
|
118
118
|
# @yield evaluates the RDF drawing DSL
|
|
119
119
|
# @yieldreturn [Object] ignored block result
|
|
120
120
|
# @return [Sevgi::Graphics::Element] RDF element
|
|
@@ -13,68 +13,6 @@ module Sevgi
|
|
|
13
13
|
STYLES = %i[hybrid inline block].freeze
|
|
14
14
|
SVG_NAMESPACE = "http://www.w3.org/2000/svg"
|
|
15
15
|
|
|
16
|
-
# Attribute rendering strategies.
|
|
17
|
-
# @api private
|
|
18
|
-
module Attributes
|
|
19
|
-
# Block-style attribute renderer.
|
|
20
|
-
# @api private
|
|
21
|
-
module Block
|
|
22
|
-
# Renders attributes in block form.
|
|
23
|
-
# @param element [Sevgi::Graphics::Element] rendered element
|
|
24
|
-
# @param depth [Integer] element depth
|
|
25
|
-
# @return [void]
|
|
26
|
-
def attributes(element, depth)
|
|
27
|
-
attributes_block(element, depth, element.attributes.send(:xml_lines))
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
# Hybrid attribute renderer.
|
|
32
|
-
# @api private
|
|
33
|
-
module Hybrid
|
|
34
|
-
# Renders attributes inline or in block form according to line length.
|
|
35
|
-
# @param element [Sevgi::Graphics::Element] rendered element
|
|
36
|
-
# @param depth [Integer] element depth
|
|
37
|
-
# @return [void]
|
|
38
|
-
def attributes(element, depth)
|
|
39
|
-
if attributes_as_block?(lines = element.attributes.send(:xml_lines), depth)
|
|
40
|
-
attributes_block(element, depth, lines)
|
|
41
|
-
else
|
|
42
|
-
attributes_inline(element, depth, lines)
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
# Reports whether attributes should be rendered in block form.
|
|
47
|
-
# @param lines [Array<String>] rendered attribute lines
|
|
48
|
-
# @param depth [Integer] element depth
|
|
49
|
-
# @return [Boolean]
|
|
50
|
-
def attributes_as_block?(lines, depth)
|
|
51
|
-
linelength(lines, depth) > options[:linelength]
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# Returns the effective inline line length.
|
|
55
|
-
# @param lines [Array<String>] rendered attribute lines
|
|
56
|
-
# @param depth [Integer] element depth
|
|
57
|
-
# @return [Integer]
|
|
58
|
-
def linelength(lines, depth)
|
|
59
|
-
indent(depth).length + lines.sum(&:length)
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
# Inline attribute renderer.
|
|
64
|
-
# @api private
|
|
65
|
-
module Inline
|
|
66
|
-
# Renders attributes inline.
|
|
67
|
-
# @param element [Sevgi::Graphics::Element] rendered element
|
|
68
|
-
# @param depth [Integer] element depth
|
|
69
|
-
# @return [void]
|
|
70
|
-
def attributes(element, depth)
|
|
71
|
-
attributes_inline(element, depth, element.attributes.send(:xml_lines))
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
private_constant :Attributes
|
|
77
|
-
|
|
78
16
|
ELEMENTS_WITH_INLINE_CONTENT = %i[title].freeze
|
|
79
17
|
ELEMENTS_WITH_BLOCK_CONTENT = %i[style].freeze
|
|
80
18
|
SEPARATOR = "\n"
|
|
@@ -102,17 +40,11 @@ module Sevgi
|
|
|
102
40
|
# Inline mark with start, stop, and depth.
|
|
103
41
|
Mark = Struct.new(:start, :stop, :depth)
|
|
104
42
|
|
|
105
|
-
# Creates an inline splice tracker.
|
|
106
|
-
# @return [void]
|
|
107
43
|
def initialize
|
|
108
44
|
@marks = []
|
|
109
45
|
@stack = []
|
|
110
46
|
end
|
|
111
47
|
|
|
112
|
-
# Starts an inline splice range.
|
|
113
|
-
# @param index [Integer] output index
|
|
114
|
-
# @param depth [Integer] element depth
|
|
115
|
-
# @return [Sevgi::Graphics::Mixtures::Render::Renderer::Inlines::Mark] opened mark
|
|
116
48
|
def start(index, depth)
|
|
117
49
|
Mark.new(start: index, depth:).tap do |mark|
|
|
118
50
|
@marks << mark
|
|
@@ -120,10 +52,6 @@ module Sevgi
|
|
|
120
52
|
end
|
|
121
53
|
end
|
|
122
54
|
|
|
123
|
-
# Ends the innermost inline splice range.
|
|
124
|
-
# @param index [Integer] output index
|
|
125
|
-
# @return [Integer]
|
|
126
|
-
# @raise [Sevgi::PanicError] when no inline range is open
|
|
127
55
|
def stop(index)
|
|
128
56
|
mark = @stack.pop
|
|
129
57
|
PanicError.("Inline content range was not opened") unless mark
|
|
@@ -131,12 +59,6 @@ module Sevgi
|
|
|
131
59
|
mark.stop = index
|
|
132
60
|
end
|
|
133
61
|
|
|
134
|
-
# Joins marked output ranges into inline content.
|
|
135
|
-
# @param output [Array<Array<String>, nil>] renderer output buffer
|
|
136
|
-
# @param indent [String] indentation unit
|
|
137
|
-
# @param separator [String] line separator
|
|
138
|
-
# @return [Array<Array<String>, nil>, nil]
|
|
139
|
-
# @raise [Sevgi::PanicError] when an inline range was not closed
|
|
140
62
|
def join(output, indent:, separator:)
|
|
141
63
|
return if @marks.empty?
|
|
142
64
|
|
|
@@ -175,8 +97,7 @@ module Sevgi
|
|
|
175
97
|
@options = self.class.send(:validate, **)
|
|
176
98
|
@output = []
|
|
177
99
|
@inlines = Inlines.new
|
|
178
|
-
|
|
179
|
-
build
|
|
100
|
+
unclosed
|
|
180
101
|
end
|
|
181
102
|
|
|
182
103
|
# @overload call(root, **options)
|
|
@@ -246,6 +167,14 @@ module Sevgi
|
|
|
246
167
|
|
|
247
168
|
attr_reader :inlines
|
|
248
169
|
|
|
170
|
+
def attributes(element, depth)
|
|
171
|
+
lines = element.attributes.send(:xml_lines)
|
|
172
|
+
block = options[:style] == :block ||
|
|
173
|
+
(options[:style] == :hybrid && indent(depth).length + lines.sum(&:length) > options[:linelength])
|
|
174
|
+
|
|
175
|
+
block ? attributes_block(element, depth, lines) : attributes_inline(element, depth, lines)
|
|
176
|
+
end
|
|
177
|
+
|
|
249
178
|
def attributes_block(element, depth, lines)
|
|
250
179
|
return attributes_inline(element, depth, lines) if lines.empty?
|
|
251
180
|
|
|
@@ -265,19 +194,6 @@ module Sevgi
|
|
|
265
194
|
end
|
|
266
195
|
end
|
|
267
196
|
|
|
268
|
-
def build
|
|
269
|
-
case options[:style]
|
|
270
|
-
when :hybrid
|
|
271
|
-
extend(Attributes::Hybrid)
|
|
272
|
-
when :inline
|
|
273
|
-
extend(Attributes::Inline)
|
|
274
|
-
when :block
|
|
275
|
-
extend(Attributes::Block)
|
|
276
|
-
end
|
|
277
|
-
|
|
278
|
-
unclosed
|
|
279
|
-
end
|
|
280
|
-
|
|
281
197
|
def childless?(element)
|
|
282
198
|
element.children.empty? && element.contents.empty?
|
|
283
199
|
end
|
|
@@ -361,9 +277,9 @@ module Sevgi
|
|
|
361
277
|
|
|
362
278
|
# @overload Render(**options)
|
|
363
279
|
# Renders this element as SVG source.
|
|
364
|
-
# Elements with inline text content
|
|
280
|
+
# Elements with inline text content can also contain inline children such as `tspan`. The renderer keeps those
|
|
365
281
|
# descendants in the same text line. Whitespace inside content objects is preserved as given, and encoded
|
|
366
|
-
# content is XML-escaped unless a verbatim content object is used. SVG `style` elements use block content
|
|
282
|
+
# content is XML-escaped unless a verbatim content object is used. SVG `style` elements use block content.
|
|
367
283
|
# same-named elements under a foreign default namespace retain ordinary inline text formatting.
|
|
368
284
|
# @example Keep every attribute on the element's opening line
|
|
369
285
|
# SVG(:minimal) { rect id: "card", width: 80, height: 40 }.Render(style: :inline)
|
|
@@ -54,10 +54,10 @@ module Sevgi
|
|
|
54
54
|
|
|
55
55
|
# Saves rendered SVG when its content differs from the destination.
|
|
56
56
|
# Relative destinations are expanded before being returned. When a non-empty backup suffix is given, an
|
|
57
|
-
# existing destination is copied immediately before replacement
|
|
57
|
+
# existing destination is copied immediately before replacement. Unchanged saves leave both files untouched.
|
|
58
58
|
# Missing parent directories are created. An existing directory target uses the default file name.
|
|
59
59
|
# @example Save to a relative destination
|
|
60
|
-
# path = Sevgi::Graphics.SVG
|
|
60
|
+
# path = Sevgi::Graphics.SVG.Save("build/drawing.svg")
|
|
61
61
|
# path == File.expand_path("build/drawing.svg") # => true
|
|
62
62
|
# @param path [String, #to_path, nil] output path or existing directory
|
|
63
63
|
# @param default [String, #to_path, nil] default output path
|
|
@@ -65,7 +65,7 @@ module Sevgi
|
|
|
65
65
|
|
|
66
66
|
private_constant :Expansion
|
|
67
67
|
|
|
68
|
-
# Renders module callables as symbols under defs. Named modules default the defs id to their final constant name
|
|
68
|
+
# Renders module callables as symbols under defs. Named modules default the defs id to their final constant name.
|
|
69
69
|
# anonymous modules omit the id unless supplied.
|
|
70
70
|
# @param mod [Module] module extended with {Sevgi::Graphics::Module}
|
|
71
71
|
# @param args [Array<Object>] callable arguments
|
|
@@ -78,7 +78,7 @@ module Sevgi
|
|
|
78
78
|
# def tick = path d: "M 0 2 L 2 4 L 6 0"
|
|
79
79
|
# end
|
|
80
80
|
# Sevgi::Graphics.SVG(:minimal) { Symbols icons }
|
|
81
|
-
# @param attributes [Hash] defs attributes
|
|
81
|
+
# @param attributes [Hash] defs attributes. String and Symbol names are normalized and must not collide
|
|
82
82
|
# @param ids [#call, nil] optional callable mapping each method name to a symbol id
|
|
83
83
|
# @param kwargs [Hash] callable keyword arguments
|
|
84
84
|
# @yield forwarded to each callable
|
|
@@ -8,7 +8,7 @@ module Sevgi
|
|
|
8
8
|
# Use {Sevgi::Sundries::Tile} instead when Ruby code needs inspectable repeated geometry or row/column bounds
|
|
9
9
|
# rather than SVG references.
|
|
10
10
|
# @see Sevgi::Sundries::Tile
|
|
11
|
-
# @see https://sevgi.roktas.dev/
|
|
11
|
+
# @see https://sevgi.roktas.dev/layout/#choose-a-layout-model Choosing a layout model
|
|
12
12
|
module Tile
|
|
13
13
|
# Stable prefix used for generated tile CSS classes.
|
|
14
14
|
PREFIX = "tile"
|
|
@@ -30,7 +30,7 @@ module Sevgi
|
|
|
30
30
|
# @param dy [Numeric] finite vertical spacing, normalized before coordinates are rendered
|
|
31
31
|
# @param oy [Numeric] finite vertical offset, normalized before coordinates are rendered
|
|
32
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
|
|
33
|
+
# zero-based coordinates and total counts. The callback can mutate the element. Its return value is ignored
|
|
34
34
|
# @yield evaluates the template drawing DSL in a generated `defs` group named by id
|
|
35
35
|
# @yieldreturn [Object] ignored block result
|
|
36
36
|
# @return [Sevgi::Graphics::Element] self
|
|
@@ -83,7 +83,7 @@ module Sevgi
|
|
|
83
83
|
# @param d [Numeric] finite horizontal spacing, normalized before coordinates are rendered
|
|
84
84
|
# @param o [Numeric] finite horizontal offset, normalized before coordinates are rendered
|
|
85
85
|
# @param proc [Proc, nil] optional callback invoked for each use as `(element, x:, n:)`, with a zero-based column
|
|
86
|
-
# and total count
|
|
86
|
+
# and total count. The callback can mutate the element. Its return value is ignored
|
|
87
87
|
# @yield evaluates the template drawing DSL in a generated `defs` group named by id
|
|
88
88
|
# @yieldreturn [Object] ignored block result
|
|
89
89
|
# @return [Sevgi::Graphics::Element] self
|
|
@@ -117,7 +117,7 @@ module Sevgi
|
|
|
117
117
|
# @param d [Numeric] finite vertical spacing, normalized before coordinates are rendered
|
|
118
118
|
# @param o [Numeric] finite vertical offset, normalized before coordinates are rendered
|
|
119
119
|
# @param proc [Proc, nil] optional callback invoked for each use as `(element, y:, n:)`, with a zero-based row and
|
|
120
|
-
# total count
|
|
120
|
+
# total count. The callback can mutate the element. Its return value is ignored
|
|
121
121
|
# @yield evaluates the template drawing DSL in a generated `defs` group named by id
|
|
122
122
|
# @yieldreturn [Object] ignored block result
|
|
123
123
|
# @return [Sevgi::Graphics::Element] self
|
|
@@ -11,13 +11,18 @@ module Sevgi
|
|
|
11
11
|
# rect(width: 8, height: 4).Translate(12, 6).Rotate(15, 4, 2)
|
|
12
12
|
# end
|
|
13
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
|
+
|
|
14
17
|
# Aligns an inner box inside an outer box.
|
|
15
18
|
# @param position [Symbol, String, nil] alignment name
|
|
16
|
-
# @param inner [#width, #height, nil] inner box
|
|
17
|
-
# @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
|
|
18
21
|
# @return [Sevgi::Graphics::Element] self
|
|
19
22
|
# @raise [Sevgi::ArgumentError] when alignment is unsupported
|
|
20
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.
|
|
21
26
|
def Align(position, inner:, outer:)
|
|
22
27
|
return self unless position && inner && outer
|
|
23
28
|
|
|
@@ -25,12 +30,26 @@ module Sevgi
|
|
|
25
30
|
when :center
|
|
26
31
|
dimensions = [inner.width, inner.height, outer.width, outer.height]
|
|
27
32
|
iw, ih, ow, oh = dimensions.map { Scalar.number(it, context: "alignment", field: :dimension) }
|
|
28
|
-
|
|
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))
|
|
29
46
|
else
|
|
30
47
|
ArgumentError.("Unsupported alignment: #{position}")
|
|
31
48
|
end
|
|
32
49
|
end
|
|
33
50
|
|
|
51
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
52
|
+
|
|
34
53
|
# Appends a scale(-1, -1) transform.
|
|
35
54
|
# @return [Sevgi::Graphics::Element] self
|
|
36
55
|
def Flip
|
|
@@ -18,7 +18,7 @@ module Sevgi
|
|
|
18
18
|
# Adds an XML comment.
|
|
19
19
|
# @param comment [Object] comment text
|
|
20
20
|
# @return [Sevgi::Graphics::Element] floating comment element
|
|
21
|
-
# @raise [Sevgi::ArgumentError] when comment cannot be stringified as valid XML or
|
|
21
|
+
# @raise [Sevgi::ArgumentError] when comment cannot be stringified as valid XML or forms malformed markup
|
|
22
22
|
def Comment(comment)
|
|
23
23
|
comment = XML.text(comment, context: "XML comment")
|
|
24
24
|
|
|
@@ -29,7 +29,7 @@ module Sevgi
|
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
# Merges `-context` metadata from the document root, ancestors, and this element.
|
|
32
|
-
# Only the direct root-to-self ancestor chain participates
|
|
32
|
+
# Only the direct root-to-self ancestor chain participates. Sibling subtrees are ignored. When the same key is
|
|
33
33
|
# present on multiple chain elements, the nearest element to the receiver wins. Context remains available in
|
|
34
34
|
# memory but is omitted from rendered SVG. It is unrelated to the `_:` XML namespace syntax preserved by
|
|
35
35
|
# Derender.
|
|
@@ -5,6 +5,55 @@ module Sevgi
|
|
|
5
5
|
module Mixtures
|
|
6
6
|
# DSL wrappers for common SVG shapes and content patterns.
|
|
7
7
|
module Wrappers
|
|
8
|
+
# rubocop:disable Metrics/ParameterLists
|
|
9
|
+
|
|
10
|
+
# Builds an elliptical arc path ending at an absolute point.
|
|
11
|
+
# SVG resolves the ellipse from its endpoints, radii, and flags, including radius correction and zero radii.
|
|
12
|
+
# @example Draw an upper semicircle in screen coordinates
|
|
13
|
+
# Sevgi::Graphics.SVG { ArcTo x1: 0, y1: 10, x2: 20, y2: 10, rx: 10, ry: 10, sweep: true }
|
|
14
|
+
# @param x2 [Numeric] finite ending x coordinate
|
|
15
|
+
# @param y2 [Numeric] finite ending y coordinate
|
|
16
|
+
# @param rx [Numeric] finite local x radius
|
|
17
|
+
# @param ry [Numeric] finite local y radius
|
|
18
|
+
# @param x1 [Numeric] finite starting x coordinate
|
|
19
|
+
# @param y1 [Numeric] finite starting y coordinate
|
|
20
|
+
# @param rotation [Numeric] clockwise ellipse rotation in degrees
|
|
21
|
+
# @param large [Boolean] select the arc exceeding a half turn
|
|
22
|
+
# @param sweep [Boolean] select increasing parameter angles
|
|
23
|
+
# @return [Sevgi::Graphics::Element] path element
|
|
24
|
+
# @raise [Sevgi::ArgumentError] when an operand is not finite real or a flag is not Boolean
|
|
25
|
+
def ArcTo(x2:, y2:, rx:, ry:, x1: 0, y1: 0, rotation: 0, large: false, sweep: false, **)
|
|
26
|
+
x1, y1, x2, y2, rx, ry, rotation = Scalar.numbers([x1, y1, x2, y2, rx, ry, rotation], context: "absolute arc")
|
|
27
|
+
unless [large, sweep].all? { it.equal?(true) || it.equal?(false) }
|
|
28
|
+
ArgumentError.("Arc flags must be Boolean")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
path(d: "M #{x1} #{y1} A #{rx} #{ry} #{rotation} #{large ? 1 : 0} #{sweep ? 1 : 0} #{x2} #{y2}", **)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Builds an elliptical arc path ending at a relative offset.
|
|
35
|
+
# @param dx [Numeric] finite x displacement from the starting point
|
|
36
|
+
# @param dy [Numeric] finite y displacement from the starting point
|
|
37
|
+
# @param rx [Numeric] finite local x radius
|
|
38
|
+
# @param ry [Numeric] finite local y radius
|
|
39
|
+
# @param x [Numeric] finite starting x coordinate
|
|
40
|
+
# @param y [Numeric] finite starting y coordinate
|
|
41
|
+
# @param rotation [Numeric] clockwise ellipse rotation in degrees
|
|
42
|
+
# @param large [Boolean] select the arc exceeding a half turn
|
|
43
|
+
# @param sweep [Boolean] select increasing parameter angles
|
|
44
|
+
# @return [Sevgi::Graphics::Element] path element
|
|
45
|
+
# @raise [Sevgi::ArgumentError] when an operand is not finite real or a flag is not Boolean
|
|
46
|
+
# @see #ArcTo
|
|
47
|
+
def ArcBy(dx:, dy:, rx:, ry:, x: 0, y: 0, rotation: 0, large: false, sweep: false, **)
|
|
48
|
+
x, y, dx, dy, rx, ry, rotation = Scalar.numbers([x, y, dx, dy, rx, ry, rotation], context: "relative arc")
|
|
49
|
+
unless [large, sweep].all? { it.equal?(true) || it.equal?(false) }
|
|
50
|
+
ArgumentError.("Arc flags must be Boolean")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
path(d: "M #{x} #{y} a #{rx} #{ry} #{rotation} #{large ? 1 : 0} #{sweep ? 1 : 0} #{dx} #{dy}", **)
|
|
54
|
+
end
|
|
55
|
+
# rubocop:enable Metrics/ParameterLists
|
|
56
|
+
|
|
8
57
|
# Builds a line path ending at an absolute point.
|
|
9
58
|
# @example Render a rational coordinate as an SVG number
|
|
10
59
|
# Sevgi::Graphics.SVG { LineTo(x2: Rational(1, 2), y2: 1) }
|
data/lib/sevgi/graphics/xml.rb
CHANGED
|
@@ -11,8 +11,9 @@ module Sevgi
|
|
|
11
11
|
NCNAME_CHAR = "#{NCNAME_START}\\-.0-9\u{B7}\u{300}-\u{36F}\u{203F}-\u{2040}".freeze
|
|
12
12
|
NCNAME = "[#{NCNAME_START}][#{NCNAME_CHAR}]*".freeze
|
|
13
13
|
QNAME = /\A#{NCNAME}(?::#{NCNAME})?\z/u
|
|
14
|
+
ILLEGAL_CHARACTER = /[^\u0009\u000A\u000D\u0020-\uD7FF\uE000-\uFFFD\u{10000}-\u{10FFFF}]/u
|
|
14
15
|
|
|
15
|
-
private_constant :NCNAME, :NCNAME_CHAR, :NCNAME_START, :QNAME
|
|
16
|
+
private_constant :NCNAME, :NCNAME_CHAR, :NCNAME_START, :QNAME, :ILLEGAL_CHARACTER
|
|
16
17
|
|
|
17
18
|
class << self
|
|
18
19
|
# Validates an XML 1.0 string representation.
|
|
@@ -104,8 +105,8 @@ module Sevgi
|
|
|
104
105
|
ArgumentError.("#{context} must be valid UTF-8") unless value.valid_encoding?
|
|
105
106
|
|
|
106
107
|
text = value.encode("UTF-8")
|
|
107
|
-
if (
|
|
108
|
-
ArgumentError.("#{context} contains illegal character U+#{format("%04X",
|
|
108
|
+
if (match = ILLEGAL_CHARACTER.match(text))
|
|
109
|
+
ArgumentError.("#{context} contains illegal character U+#{format("%04X", match[0].ord)}")
|
|
109
110
|
end
|
|
110
111
|
|
|
111
112
|
text
|
|
@@ -113,12 +114,6 @@ module Sevgi
|
|
|
113
114
|
ArgumentError.("#{context} must be valid UTF-8: #{e.message}")
|
|
114
115
|
end
|
|
115
116
|
|
|
116
|
-
def legal_codepoint?(codepoint)
|
|
117
|
-
[0x9, 0xA, 0xD].include?(codepoint) ||
|
|
118
|
-
(0x20..0xD7FF).cover?(codepoint) ||
|
|
119
|
-
(0xE000..0xFFFD).cover?(codepoint) ||
|
|
120
|
-
(0x10000..0x10FFFF).cover?(codepoint)
|
|
121
|
-
end
|
|
122
117
|
end
|
|
123
118
|
end
|
|
124
119
|
|