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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +221 -2
- data/README.md +12 -9
- data/lib/sevgi/graphics/attribute.rb +166 -45
- data/lib/sevgi/graphics/auxiliary/canvas.rb +100 -43
- data/lib/sevgi/graphics/auxiliary/content.rb +56 -47
- data/lib/sevgi/graphics/auxiliary/margin.rb +19 -12
- data/lib/sevgi/graphics/auxiliary/paper.rb +74 -49
- data/lib/sevgi/graphics/auxiliary/path.rb +44 -0
- data/lib/sevgi/graphics/auxiliary/scalar.rb +36 -7
- data/lib/sevgi/graphics/auxiliary.rb +1 -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 +239 -117
- data/lib/sevgi/graphics/element.rb +132 -34
- data/lib/sevgi/graphics/mixtures/call.rb +234 -88
- data/lib/sevgi/graphics/mixtures/core.rb +67 -27
- data/lib/sevgi/graphics/mixtures/duplicate.rb +47 -25
- data/lib/sevgi/graphics/mixtures/export.rb +54 -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 +26 -8
- data/lib/sevgi/graphics/mixtures/inkscape.rb +214 -47
- data/lib/sevgi/graphics/mixtures/rdf.rb +59 -7
- data/lib/sevgi/graphics/mixtures/render.rb +60 -120
- data/lib/sevgi/graphics/mixtures/save.rb +79 -35
- data/lib/sevgi/graphics/mixtures/symbols.rb +81 -12
- data/lib/sevgi/graphics/mixtures/tile.rb +85 -67
- data/lib/sevgi/graphics/mixtures/transform.rb +89 -30
- data/lib/sevgi/graphics/mixtures/underscore.rb +16 -7
- data/lib/sevgi/graphics/mixtures/validate.rb +2 -2
- data/lib/sevgi/graphics/mixtures/wrappers.rb +111 -23
- data/lib/sevgi/graphics/mixtures.rb +15 -13
- data/lib/sevgi/graphics/version.rb +1 -1
- data/lib/sevgi/graphics/xml.rb +4 -9
- data/lib/sevgi/graphics.rb +69 -18
- metadata +7 -6
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sevgi
|
|
4
|
+
module Graphics
|
|
5
|
+
# Output path normalization shared by Graphics writers and exporters.
|
|
6
|
+
# @api private
|
|
7
|
+
module Path
|
|
8
|
+
# Converts a non-blank path to an expanded String.
|
|
9
|
+
# @param value [String, #to_path] raw path value
|
|
10
|
+
# @param context [String] public operation named in errors
|
|
11
|
+
# @return [String] expanded path
|
|
12
|
+
# @raise [Sevgi::ArgumentError] when value is blank, has an invalid type, or path conversion fails
|
|
13
|
+
def self.call(value, context:)
|
|
14
|
+
path = value.respond_to?(:to_path) ? value.to_path : value
|
|
15
|
+
ArgumentError.("#{context} must be a String or path-like object") unless path.is_a?(::String)
|
|
16
|
+
ArgumentError.("#{context} must be provided") if path.strip.empty?
|
|
17
|
+
|
|
18
|
+
::File.expand_path(path)
|
|
19
|
+
rescue ::Sevgi::ArgumentError
|
|
20
|
+
raise
|
|
21
|
+
rescue ::StandardError => e
|
|
22
|
+
ArgumentError.("#{context} must be a String or path-like object: #{e.message}")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Resolves an optional path with the writer/exporter directory convention.
|
|
26
|
+
# @param value [String, #to_path, nil] explicit path or directory
|
|
27
|
+
# @param default [String, #to_path] default output path
|
|
28
|
+
# @param context [String] public operation named in errors
|
|
29
|
+
# @return [String] expanded output file path
|
|
30
|
+
# @raise [Sevgi::ArgumentError] when a selected path/default is blank, invalid, or cannot be converted
|
|
31
|
+
def self.resolve(value, default:, context:)
|
|
32
|
+
return call(default, context: "#{context} default") if value.nil?
|
|
33
|
+
|
|
34
|
+
path = call(value, context: "#{context} path")
|
|
35
|
+
return path unless ::File.directory?(path)
|
|
36
|
+
|
|
37
|
+
default = call(default, context: "#{context} default")
|
|
38
|
+
::File.join(path, ::File.basename(default))
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private_constant :Path
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
module Sevgi
|
|
4
4
|
module Graphics
|
|
5
|
-
# Validates finite real numeric values used by
|
|
5
|
+
# Validates finite real numeric values used by Graphics APIs.
|
|
6
6
|
# @api private
|
|
7
7
|
module Scalar
|
|
8
8
|
# Converts one finite real value.
|
|
9
9
|
# @param value [Numeric] value to validate
|
|
10
10
|
# @param context [String] error context
|
|
11
|
-
# @param field [Symbol] field name
|
|
11
|
+
# @param field [Symbol, Integer] field name or position
|
|
12
12
|
# @param positive [Boolean] require a strictly positive value
|
|
13
13
|
# @param nonnegative [Boolean] require a non-negative value
|
|
14
14
|
# @return [Float] validated value
|
|
@@ -24,15 +24,44 @@ module Sevgi
|
|
|
24
24
|
invalid(context, field, value)
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
# Converts one finite real value to an SVG number.
|
|
28
|
+
# @param value [Numeric] value to validate
|
|
29
|
+
# @param context [String] error context
|
|
30
|
+
# @param field [Symbol, Integer] field name or position
|
|
31
|
+
# @param positive [Boolean] require a strictly positive value
|
|
32
|
+
# @param nonnegative [Boolean] require a non-negative value
|
|
33
|
+
# @return [Integer, Float] normalized number with integral values represented as Integer
|
|
34
|
+
# @raise [Sevgi::ArgumentError] when value is not a finite real number
|
|
35
|
+
def self.number(value, context:, field:, positive: false, nonnegative: false)
|
|
36
|
+
if value.is_a?(::Integer)
|
|
37
|
+
invalid(context, field, value) unless valid?(value, positive:, nonnegative:)
|
|
38
|
+
return value
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
value = finite(value, context:, field:, positive:, nonnegative:)
|
|
42
|
+
value == value.to_i ? value.to_i : value
|
|
43
|
+
end
|
|
28
44
|
|
|
29
|
-
|
|
30
|
-
|
|
45
|
+
# Converts indexed finite real values to SVG numbers.
|
|
46
|
+
# @param values [Array<Numeric>] values to normalize
|
|
47
|
+
# @param context [String] error context
|
|
48
|
+
# @return [Array<(Integer, Float)>] normalized SVG numbers
|
|
49
|
+
# @raise [Sevgi::ArgumentError] when a value is not a finite real number
|
|
50
|
+
def self.numbers(values, context:)
|
|
51
|
+
values.each_with_index.map { |value, index| number(value, context:, field: index) }
|
|
31
52
|
end
|
|
32
53
|
|
|
33
|
-
|
|
54
|
+
class << self
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def real?(value) = value.is_a?(::Numeric) && !value.is_a?(::Complex)
|
|
34
58
|
|
|
35
|
-
|
|
59
|
+
def valid?(number, positive:, nonnegative:)
|
|
60
|
+
number.finite? && (!positive || number.positive?) && (!nonnegative || number >= 0)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def invalid(context, field, value) = ArgumentError.("Invalid #{context} #{field}: #{value.inspect}")
|
|
64
|
+
end
|
|
36
65
|
end
|
|
37
66
|
|
|
38
67
|
private_constant :Scalar
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
module Sevgi
|
|
4
4
|
module Graphics
|
|
5
5
|
module Document
|
|
6
|
-
#
|
|
6
|
+
# Abstract common document layer with the profile-independent DSL mixture set. It is not registered as a
|
|
7
|
+
# selectable profile. Advanced extensions can target this class through {Sevgi::Graphics::Mixtures.mixin}. This
|
|
8
|
+
# changes every descendant profile process-wide. Subclass it first to keep an extension scoped.
|
|
7
9
|
class Base < Proto
|
|
8
|
-
document :
|
|
10
|
+
document nil, register: false
|
|
9
11
|
|
|
10
12
|
mixture :Call
|
|
11
13
|
mixture :Duplicate
|
|
@@ -21,6 +23,8 @@ module Sevgi
|
|
|
21
23
|
|
|
22
24
|
# Runs pre-render validation and lint checks.
|
|
23
25
|
# @param options [Hash] pre-render options
|
|
26
|
+
# @option options [Boolean] :validate run SVG standard validation
|
|
27
|
+
# @option options [Boolean] :lint run document lint checks
|
|
24
28
|
# @return [void]
|
|
25
29
|
# @raise [Sevgi::ValidationError] when validation fails
|
|
26
30
|
# @raise [Sevgi::Graphics::LintError] when linting fails
|
|
@@ -2,11 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
module Sevgi
|
|
4
4
|
module Graphics
|
|
5
|
-
# SVG document profile factory.
|
|
5
|
+
# SVG document profile factory and process-global named-profile registry.
|
|
6
|
+
#
|
|
7
|
+
# A profile owns SVG root attributes and optional preamble lines, but not canvas size. Built-in and named profiles
|
|
8
|
+
# can be passed to {Sevgi::Graphics.SVG}. An anonymous profile class is useful when library code needs one-off
|
|
9
|
+
# metadata without adding a global name.
|
|
10
|
+
#
|
|
11
|
+
# | Profile | Preamble | Root metadata | Additional DSL |
|
|
12
|
+
# | --- | --- | --- | --- |
|
|
13
|
+
# | `:minimal` | none | none | common document DSL |
|
|
14
|
+
# | `:default` | XML declaration | SVG namespace | common document DSL |
|
|
15
|
+
# | `:html` | none | SVG namespace | common document DSL |
|
|
16
|
+
# | `:inkscape` | XML declaration | SVG and editor namespaces with crisp edges | `Draw`, `Hatch`, and editor/RDF helpers |
|
|
17
|
+
#
|
|
18
|
+
# The Inkscape root adds Sevgi, Inkscape, and Sodipodi namespaces plus `shape-rendering="crispEdges"`. Every
|
|
19
|
+
# selectable profile has the same validation and lint lifecycle. `:minimal` changes serialization metadata, not
|
|
20
|
+
# checking policy. {Base} is the public common extension layer rather than a selectable profile. {Minimal} and
|
|
21
|
+
# {Default} are sibling concrete profiles: Minimal contributes no metadata and is not the semantic base of the other
|
|
22
|
+
# profiles. Targeting Base through {Sevgi::Graphics::Mixtures.mixin} changes every descendant profile process-wide.
|
|
23
|
+
# Subclass Base first to keep an extension scoped.
|
|
24
|
+
#
|
|
25
|
+
# @see https://sevgi.roktas.dev/documents/#profiles Document profiles guide
|
|
6
26
|
module Document
|
|
7
27
|
# Defensive copy helper for profile metadata snapshots.
|
|
8
28
|
# @api private
|
|
9
29
|
module Snapshot
|
|
30
|
+
SCALARS = [::NilClass, ::TrueClass, ::FalseClass, ::Symbol, ::Integer, ::Float, ::Rational, ::Complex].freeze
|
|
31
|
+
private_constant :SCALARS
|
|
32
|
+
|
|
10
33
|
class << self
|
|
11
34
|
# Captures recursively immutable profile metadata. Mutable non-container values are stringified once.
|
|
12
35
|
# @param value [Object] value to capture
|
|
@@ -61,24 +84,33 @@ module Sevgi
|
|
|
61
84
|
end
|
|
62
85
|
|
|
63
86
|
def capture_value(value)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
XML.text(value, context: "Document profile metadata").freeze
|
|
67
|
-
when ::Numeric, ::Symbol, ::NilClass, ::TrueClass, ::FalseClass
|
|
68
|
-
XML.text(value, context: "Document profile metadata")
|
|
69
|
-
value
|
|
70
|
-
else
|
|
71
|
-
stringify(value).freeze
|
|
72
|
-
end
|
|
87
|
+
text = XML.text(value, context: "Document profile metadata")
|
|
88
|
+
SCALARS.include?(value.class) ? value : text.freeze
|
|
73
89
|
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
74
92
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
93
|
+
# Document profile name normalization.
|
|
94
|
+
# @api private
|
|
95
|
+
module Name
|
|
96
|
+
# Normalizes a profile name.
|
|
97
|
+
# @param name [Object] profile name
|
|
98
|
+
# @return [Symbol, nil]
|
|
99
|
+
def self.normalize(name)
|
|
100
|
+
normalized = name.to_sym if name.respond_to?(:to_sym)
|
|
101
|
+
normalized if normalized.is_a?(::Symbol)
|
|
102
|
+
rescue ::StandardError
|
|
103
|
+
nil
|
|
78
104
|
end
|
|
105
|
+
|
|
106
|
+
# Normalizes a profile name or raises.
|
|
107
|
+
# @param name [Object] profile name
|
|
108
|
+
# @return [Symbol]
|
|
109
|
+
# @raise [Sevgi::ArgumentError] when name cannot be normalized
|
|
110
|
+
def self.normalize!(name) = normalize(name) || ArgumentError.("Invalid document profile: #{name}")
|
|
79
111
|
end
|
|
80
112
|
|
|
81
|
-
private_constant :Snapshot
|
|
113
|
+
private_constant :Name, :Snapshot
|
|
82
114
|
|
|
83
115
|
# Builds a root SVG element from a document profile.
|
|
84
116
|
# @param document [Symbol, String, Class] profile name or document class
|
|
@@ -87,12 +119,15 @@ module Sevgi
|
|
|
87
119
|
# @yieldreturn [Object] ignored block result
|
|
88
120
|
# @return [Sevgi::Graphics::Document::Proto] SVG root element
|
|
89
121
|
# @raise [Sevgi::ArgumentError] when the document profile or root XML attributes are invalid
|
|
122
|
+
# @example Build from a scoped Base-derived profile
|
|
123
|
+
# Card = Class.new(Sevgi::Graphics::Document::Base)
|
|
124
|
+
# Sevgi::Graphics::Document.(Card) { rect width: 10, height: 5 }
|
|
90
125
|
def self.call(document, canvas = Undefined, **, &block)
|
|
91
126
|
klass = case document
|
|
92
127
|
when ::Class
|
|
93
128
|
document if document <= Proto
|
|
94
129
|
else
|
|
95
|
-
|
|
130
|
+
fetch(document)
|
|
96
131
|
end
|
|
97
132
|
|
|
98
133
|
ArgumentError.("Unknown document profile: #{document}") unless klass
|
|
@@ -113,61 +148,120 @@ module Sevgi
|
|
|
113
148
|
|
|
114
149
|
private_class_method :canvas_attributes
|
|
115
150
|
|
|
116
|
-
#
|
|
151
|
+
# Returns a registered document class by profile name.
|
|
152
|
+
# @param name [Symbol, String] profile name
|
|
153
|
+
# @return [Class] registered subclass of {Sevgi::Graphics::Document::Proto}
|
|
154
|
+
# @raise [Sevgi::ArgumentError] when name is invalid or unknown
|
|
155
|
+
# @example Look up a document class and its metadata
|
|
156
|
+
# klass = Sevgi::Graphics::Document.fetch(:minimal)
|
|
157
|
+
# Sevgi::Graphics::Document.profile(:minimal) # => klass.profile
|
|
158
|
+
def self.fetch(name)
|
|
159
|
+
name = Name.normalize!(name)
|
|
160
|
+
Registry[name] || ArgumentError.("Unknown document profile: #{name}")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Reports whether a normalizable document profile name is registered.
|
|
164
|
+
# Invalid converters return false and do not change the registry.
|
|
165
|
+
# @example Check a built-in profile
|
|
166
|
+
# Sevgi::Graphics::Document.exist?(:minimal) # => true
|
|
167
|
+
# @param name [Object] profile name
|
|
168
|
+
# @return [Boolean]
|
|
169
|
+
def self.exist?(name)
|
|
170
|
+
name = Name.normalize(name)
|
|
171
|
+
name ? !Registry[name].nil? : false
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Returns registered document profile names.
|
|
175
|
+
# @return [Array<Symbol>] frozen name snapshot
|
|
176
|
+
def self.keys = Registry.available.keys.freeze
|
|
177
|
+
|
|
178
|
+
# Returns immutable metadata for a registered document profile.
|
|
179
|
+
# @param name [Symbol, String] profile name
|
|
180
|
+
# @return [Sevgi::Graphics::Document::Profile] registered profile metadata
|
|
181
|
+
# @raise [Sevgi::ArgumentError] when name is invalid or unknown
|
|
182
|
+
def self.profile(name) = fetch(name).profile
|
|
183
|
+
|
|
184
|
+
# Defines, looks up, or returns an anonymous document profile class.
|
|
185
|
+
#
|
|
186
|
+
# A name without metadata performs lookup. A name plus either metadata
|
|
187
|
+
# keyword defines or compatibly reuses a named profile. Omitting the name
|
|
188
|
+
# creates an anonymous class and leaves the registry unchanged. Named
|
|
189
|
+
# profiles are process-global. Use them for shared vocabulary rather than
|
|
190
|
+
# per-call configuration.
|
|
117
191
|
# Profile metadata is captured before class or thread-atomic registry mutation. Mutable non-container attribute
|
|
118
|
-
# values are stringified once
|
|
119
|
-
# the registry, including when
|
|
192
|
+
# values are stringified once, attribute names and nested Hash keys are normalized, and nil attributes are omitted
|
|
193
|
+
# during capture. Successful named definitions return the canonical class stored by the registry, including when
|
|
194
|
+
# identical definitions race.
|
|
120
195
|
# @param name [Symbol, String, Sevgi::Undefined] profile name, or Undefined for an anonymous profile
|
|
121
196
|
# @param preambles [Array<String>, nil, Sevgi::Undefined] document preamble lines
|
|
122
197
|
# @param attributes [Hash, nil, Sevgi::Undefined] default root attributes
|
|
123
198
|
# @param overwrite [Boolean] true to replace an existing profile
|
|
124
199
|
# @return [Class] document class
|
|
125
|
-
# @raise [Sevgi::ArgumentError] when a name conflicts or metadata is invalid XML,
|
|
200
|
+
# @raise [Sevgi::ArgumentError] when overwrite is not Boolean, a name conflicts, or metadata is invalid XML,
|
|
201
|
+
# cyclic, or cannot be stringified
|
|
202
|
+
# @example Define a reusable library profile
|
|
203
|
+
# profile = Sevgi::Graphics::Document.define(
|
|
204
|
+
# :icon,
|
|
205
|
+
# preambles: [],
|
|
206
|
+
# attributes: {xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24"}
|
|
207
|
+
# )
|
|
208
|
+
# Sevgi::Graphics::Document.(profile) { circle cx: 12, cy: 12, r: 10 }.Render
|
|
209
|
+
# @example Build an anonymous one-off profile
|
|
210
|
+
# profile = Sevgi::Graphics::Document.define(attributes: {viewBox: "0 0 10 10"})
|
|
211
|
+
# profile.profile.name # => nil
|
|
126
212
|
def self.define(name = Undefined, preambles: Undefined, attributes: Undefined, overwrite: false)
|
|
213
|
+
overwrite!(overwrite)
|
|
127
214
|
return anonymous(attributes:, preambles:) if name == Undefined
|
|
128
215
|
|
|
129
|
-
return
|
|
216
|
+
return fetch(name) if preambles == Undefined && attributes == Undefined
|
|
130
217
|
|
|
131
|
-
name =
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
reject_conflict(name, current, attributes:, preambles:) unless overwrite
|
|
135
|
-
return current unless overwrite
|
|
136
|
-
end
|
|
218
|
+
name = Name.normalize!(name)
|
|
219
|
+
current = reuse(name, attributes:, preambles:, overwrite:)
|
|
220
|
+
return current if current
|
|
137
221
|
|
|
138
222
|
attributes, preambles = defaults(attributes:, preambles:)
|
|
139
223
|
Class.new(Base) { document(name, preambles:, attributes:, overwrite:) }
|
|
140
224
|
Registry[name]
|
|
141
225
|
end
|
|
142
226
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
Class.new(Base) { document(Undefined, preambles:, attributes:, register: false) }
|
|
146
|
-
end
|
|
227
|
+
class << self
|
|
228
|
+
private
|
|
147
229
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
230
|
+
def anonymous(attributes:, preambles:)
|
|
231
|
+
attributes, preambles = defaults(attributes:, preambles:)
|
|
232
|
+
Class.new(Base) { document(Undefined, preambles:, attributes:, register: false) }
|
|
233
|
+
end
|
|
152
234
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
235
|
+
def defaults(attributes:, preambles:)
|
|
236
|
+
[attributes == Undefined ? {} : attributes, preambles == Undefined ? nil : preambles]
|
|
237
|
+
end
|
|
156
238
|
|
|
157
|
-
|
|
158
|
-
|
|
239
|
+
def reject_conflict(name, current, attributes:, preambles:)
|
|
240
|
+
return if compatible?(current, attributes:, preambles:)
|
|
159
241
|
|
|
160
|
-
|
|
161
|
-
|
|
242
|
+
ArgumentError.("Document profile already defined differently: #{name}")
|
|
243
|
+
end
|
|
162
244
|
|
|
163
|
-
|
|
164
|
-
|
|
245
|
+
def compatible?(klass, attributes:, preambles:)
|
|
246
|
+
profile = klass.profile
|
|
165
247
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
248
|
+
(attributes == Undefined || Profile.new(nil, attributes:).attributes == profile.attributes) &&
|
|
249
|
+
(preambles == Undefined || Profile.new(nil, preambles:).preambles == profile.preambles)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def reuse(name, attributes:, preambles:, overwrite:)
|
|
253
|
+
return unless (current = Registry[name])
|
|
254
|
+
|
|
255
|
+
reject_conflict(name, current, attributes:, preambles:) unless overwrite
|
|
256
|
+
current unless overwrite
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def overwrite!(value)
|
|
260
|
+
return value if [true, false].include?(value)
|
|
169
261
|
|
|
170
|
-
|
|
262
|
+
ArgumentError.("Document overwrite must be true or false")
|
|
263
|
+
end
|
|
264
|
+
end
|
|
171
265
|
|
|
172
266
|
# Process-global document profile registry.
|
|
173
267
|
# @api private
|
|
@@ -181,7 +275,8 @@ module Sevgi
|
|
|
181
275
|
def available = @mutex.synchronize { @available.dup.freeze }
|
|
182
276
|
|
|
183
277
|
def register(name, klass, profile: nil, overwrite: false)
|
|
184
|
-
|
|
278
|
+
overwrite = Document.send(:overwrite!, overwrite)
|
|
279
|
+
name = Name.normalize!(name)
|
|
185
280
|
validate!(name, klass, profile)
|
|
186
281
|
|
|
187
282
|
@mutex.synchronize { store(name, klass, profile, overwrite) }
|
|
@@ -198,6 +293,7 @@ module Sevgi
|
|
|
198
293
|
return current unless overwrite
|
|
199
294
|
end
|
|
200
295
|
|
|
296
|
+
klass.instance_variable_set(:@profile, profile)
|
|
201
297
|
@available[name] = klass
|
|
202
298
|
end
|
|
203
299
|
|
|
@@ -216,58 +312,42 @@ module Sevgi
|
|
|
216
312
|
private_constant :Registry
|
|
217
313
|
|
|
218
314
|
# Immutable, read-only document profile metadata exposed by document classes. Process-global lookup and registration
|
|
219
|
-
# are thread-atomic. Metadata containers and strings are captured recursively
|
|
220
|
-
# stringified once during construction.
|
|
315
|
+
# are thread-atomic. Metadata containers and strings are captured recursively. Other mutable attribute values are
|
|
316
|
+
# stringified once during construction. Attribute names and nested Hash keys are normalized to Symbols, nil values
|
|
317
|
+
# are omitted, and update-suffix intent is retained for inheritance.
|
|
318
|
+
# Returned attribute and preamble collections are caller-owned snapshots,
|
|
319
|
+
# so changing them does not alter the registered profile.
|
|
221
320
|
# @see Sevgi::Graphics.document
|
|
222
321
|
class Profile
|
|
223
|
-
# Returns a thread-coherent immutable snapshot of registered profile classes.
|
|
224
|
-
# @return [Hash<Symbol, Class>]
|
|
225
|
-
def self.available = Registry.available
|
|
226
|
-
|
|
227
|
-
# Returns a profile class by name from the process-global registry.
|
|
228
|
-
# @param name [Object] profile name
|
|
229
|
-
# @return [Class, nil]
|
|
230
|
-
def self.[](name) = (name = normalize(name)) && Registry[name]
|
|
231
|
-
|
|
232
|
-
# Normalizes a profile name.
|
|
233
|
-
# @param name [Object] profile name
|
|
234
|
-
# @return [Symbol, nil]
|
|
235
|
-
def self.normalize(name)
|
|
236
|
-
normalized = name.to_sym if name.respond_to?(:to_sym)
|
|
237
|
-
normalized if normalized.is_a?(::Symbol)
|
|
238
|
-
rescue ::StandardError
|
|
239
|
-
nil
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
# Normalizes a profile name or raises.
|
|
243
|
-
# @param name [Object] profile name
|
|
244
|
-
# @return [Symbol]
|
|
245
|
-
# @raise [Sevgi::ArgumentError] when name cannot be normalized
|
|
246
|
-
def self.normalize!(name) = normalize(name) || ArgumentError.("Invalid document profile: #{name}")
|
|
247
|
-
|
|
248
322
|
# @return [Symbol, nil] profile name
|
|
249
323
|
attr_reader :name
|
|
250
324
|
|
|
251
325
|
# Creates profile metadata.
|
|
252
326
|
# @param name [Object, nil] profile name
|
|
253
|
-
# @param attributes [Hash, nil] default root attributes
|
|
327
|
+
# @param attributes [Hash, nil] default root attributes. Nil means an empty Hash
|
|
254
328
|
# @param preambles [Array<String>, nil] preamble lines
|
|
255
329
|
# @return [void]
|
|
256
330
|
# @raise [Sevgi::ArgumentError] when name or metadata is invalid XML, cyclic, or cannot be stringified
|
|
257
331
|
def initialize(name, attributes: nil, preambles: nil)
|
|
258
|
-
@name = name.nil? ? nil :
|
|
259
|
-
|
|
260
|
-
@attributes = Snapshot.capture(attributes || {})
|
|
332
|
+
@name = name.nil? ? nil : Name.normalize!(name)
|
|
333
|
+
@attributes = capture_attributes(attributes)
|
|
261
334
|
@preambles = capture_preambles(preambles)
|
|
335
|
+
freeze
|
|
262
336
|
end
|
|
263
337
|
|
|
264
338
|
# Reports strict profile equality.
|
|
265
339
|
# @param other [Object] object to compare
|
|
266
340
|
# @return [Boolean]
|
|
267
|
-
def
|
|
341
|
+
def eql?(other) = self.class == other.class && deconstruct == other.deconstruct
|
|
268
342
|
|
|
269
|
-
# Returns
|
|
270
|
-
# @return [
|
|
343
|
+
# Returns a hash compatible with strict equality.
|
|
344
|
+
# @return [Integer]
|
|
345
|
+
def hash = [self.class, name, @attributes, @preambles].hash
|
|
346
|
+
|
|
347
|
+
# Returns canonical default root attributes for this profile.
|
|
348
|
+
# Names and nested Hash keys are Symbols, nil attributes are omitted, and update suffixes remain explicit for
|
|
349
|
+
# application by a document class.
|
|
350
|
+
# @return [Hash{Symbol => Object}] mutation-isolated attribute snapshot
|
|
271
351
|
def attributes = Snapshot.copy(@attributes)
|
|
272
352
|
|
|
273
353
|
# Returns profile components.
|
|
@@ -278,28 +358,35 @@ module Sevgi
|
|
|
278
358
|
# @return [Array<String>, nil] mutation-isolated preamble snapshot
|
|
279
359
|
def preambles = Snapshot.copy(@preambles)
|
|
280
360
|
|
|
361
|
+
alias == eql?
|
|
362
|
+
|
|
281
363
|
private
|
|
282
364
|
|
|
283
|
-
def
|
|
284
|
-
|
|
365
|
+
def capture_attribute(key, value, identities)
|
|
366
|
+
update = Attribute.updateable?(key)
|
|
367
|
+
id = Attribute.id(key)
|
|
368
|
+
if identities.key?(id)
|
|
369
|
+
ArgumentError.("Document profile attribute names collide after normalization: #{id}")
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
identities[id] = true
|
|
373
|
+
key = update ? :"#{id}#{Attributes::UPDATE_SUFFIX}" : id
|
|
374
|
+
value = Snapshot.capture(value)
|
|
375
|
+
[key, Attribute.capture(value, normalize_keys: value.is_a?(::Hash))]
|
|
376
|
+
end
|
|
285
377
|
|
|
378
|
+
def capture_attributes(attributes)
|
|
379
|
+
attributes = {} if attributes.nil?
|
|
286
380
|
ArgumentError.("Document profile attributes must be a Hash") unless attributes.is_a?(::Hash)
|
|
287
381
|
|
|
288
|
-
|
|
289
|
-
attributes.
|
|
290
|
-
|
|
291
|
-
ArgumentError.("Document profile attribute names collide after normalization: #{id}") if normalized.key?(id)
|
|
382
|
+
identities = {}
|
|
383
|
+
captured = attributes.filter_map do |key, value|
|
|
384
|
+
next if value.nil?
|
|
292
385
|
|
|
293
|
-
|
|
386
|
+
capture_attribute(key, value, identities)
|
|
294
387
|
end
|
|
295
|
-
end
|
|
296
388
|
|
|
297
|
-
|
|
298
|
-
if key.is_a?(::String) || key.is_a?(::Symbol)
|
|
299
|
-
return XML.name(key, context: "Document profile attribute name").to_sym
|
|
300
|
-
end
|
|
301
|
-
|
|
302
|
-
ArgumentError.("Document profile attribute names must be Strings or Symbols")
|
|
389
|
+
Snapshot.capture(captured.to_h)
|
|
303
390
|
end
|
|
304
391
|
|
|
305
392
|
def capture_preambles(preambles)
|
|
@@ -316,9 +403,6 @@ module Sevgi
|
|
|
316
403
|
# Class-level DSL used while defining document classes.
|
|
317
404
|
# @api private
|
|
318
405
|
module DSL
|
|
319
|
-
# @return [Sevgi::Graphics::Document::Profile] immutable document profile metadata
|
|
320
|
-
attr_reader :profile
|
|
321
|
-
|
|
322
406
|
# Sets document profile metadata on a class.
|
|
323
407
|
# @param name [Object] profile name
|
|
324
408
|
# @param attributes [Hash, nil] default root attributes
|
|
@@ -328,9 +412,14 @@ module Sevgi
|
|
|
328
412
|
# @return [Sevgi::Graphics::Document::Profile] immutable document profile metadata
|
|
329
413
|
# @raise [Sevgi::ArgumentError] when registration fails or metadata is invalid XML, cyclic, or cannot be stringified
|
|
330
414
|
def document(name, attributes: {}, preambles: nil, register: true, overwrite: false)
|
|
415
|
+
overwrite = Document.send(:overwrite!, overwrite)
|
|
331
416
|
profile = Profile.new(register ? name : nil, attributes:, preambles:)
|
|
332
|
-
|
|
333
|
-
@profile = profile
|
|
417
|
+
Attributes.new(superclass.attributes).merge!(profile.attributes)
|
|
418
|
+
return (@profile = profile) unless register
|
|
419
|
+
|
|
420
|
+
registered = Registry.register(name, self, profile:, overwrite:)
|
|
421
|
+
@profile = profile unless registered.equal?(self)
|
|
422
|
+
@profile
|
|
334
423
|
end
|
|
335
424
|
|
|
336
425
|
# Includes a graphics mixture into the document class.
|
|
@@ -342,17 +431,19 @@ module Sevgi
|
|
|
342
431
|
include(mod = ns.const_get(mixture))
|
|
343
432
|
extend(mod::ClassMethods) if defined?(mod::ClassMethods)
|
|
344
433
|
end
|
|
434
|
+
|
|
435
|
+
private :document, :mixture
|
|
345
436
|
end
|
|
346
437
|
|
|
347
438
|
private_constant :DSL
|
|
348
439
|
|
|
349
440
|
# Default render-time checks.
|
|
441
|
+
# @api private
|
|
350
442
|
DEFAULTS = {lint: true, validate: true}.freeze
|
|
443
|
+
private_constant :DEFAULTS
|
|
351
444
|
|
|
352
445
|
# Base document root element class.
|
|
353
446
|
class Proto < Element
|
|
354
|
-
public_class_method :new
|
|
355
|
-
|
|
356
447
|
extend DSL
|
|
357
448
|
|
|
358
449
|
mixture :Core
|
|
@@ -360,27 +451,58 @@ module Sevgi
|
|
|
360
451
|
mixture :Render
|
|
361
452
|
mixture :Wrappers
|
|
362
453
|
|
|
363
|
-
#
|
|
364
|
-
#
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
# @return [String] SVG document source
|
|
368
|
-
# @raise [Sevgi::ArgumentError] when renderer options or XML-bound values are invalid
|
|
369
|
-
def call(*, **)
|
|
370
|
-
options = DEFAULTS.merge(**)
|
|
454
|
+
# Returns the nearest immutable profile metadata in the class hierarchy.
|
|
455
|
+
# @return [Sevgi::Graphics::Document::Profile, nil] nearest profile, or nil when no ancestor is configured
|
|
456
|
+
def self.profile
|
|
457
|
+
return @profile if instance_variable_defined?(:@profile)
|
|
371
458
|
|
|
372
|
-
|
|
459
|
+
superclass.profile if superclass.respond_to?(:profile)
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# Renders this document after its optional pre-render checks.
|
|
463
|
+
# @example Render a document directly with separate check and renderer options
|
|
464
|
+
# document = Sevgi::Graphics.SVG(:minimal) { rect width: 3 }
|
|
465
|
+
# document.call(lint: false, style: :inline)
|
|
466
|
+
# @param options [Hash] `lint` and `validate` check switches plus renderer options accepted by
|
|
467
|
+
# {Sevgi::Graphics::Mixtures::Render#Render}
|
|
468
|
+
# @option options [Boolean] :lint (true) run document lint checks
|
|
469
|
+
# @option options [Boolean] :validate (true) run SVG standard validation
|
|
470
|
+
# @return [String] SVG document source
|
|
471
|
+
# @raise [Sevgi::ArgumentError] when an option or XML-bound value is invalid
|
|
472
|
+
# @raise [Sevgi::ValidationError] when validation is enabled and the document violates the SVG standard
|
|
473
|
+
# @raise [Sevgi::Graphics::LintError] when linting is enabled and the document has structural conflicts
|
|
474
|
+
# @see Sevgi::Graphics::Mixtures::Render#Render
|
|
475
|
+
def call(**options)
|
|
476
|
+
checks = DEFAULTS.merge(options.select { |key, _| DEFAULTS.key?(key) })
|
|
477
|
+
self.PreRender(**checks) if respond_to?(:PreRender)
|
|
373
478
|
render_options = options.reject { |key, _| DEFAULTS.key?(key) }
|
|
374
|
-
self.Render(
|
|
479
|
+
self.Render(**render_options)
|
|
375
480
|
end
|
|
376
481
|
|
|
377
|
-
# Returns inherited root attributes for this document class.
|
|
378
|
-
#
|
|
379
|
-
|
|
482
|
+
# Returns effective inherited root attributes for this document class.
|
|
483
|
+
# Profile update suffixes are applied from the oldest configured ancestor to this class.
|
|
484
|
+
# @return [Hash{Symbol => Object}] inherited root attributes without update suffixes
|
|
485
|
+
# @raise [Sevgi::ArgumentError] when a non-Proto class has no configured ancestor
|
|
486
|
+
def self.attributes
|
|
487
|
+
return {} if self == Proto
|
|
488
|
+
|
|
489
|
+
ArgumentError.("Document class has no configured profile: #{self}") unless profile
|
|
490
|
+
return superclass.attributes unless instance_variable_defined?(:@profile)
|
|
491
|
+
|
|
492
|
+
Attributes.new(superclass.attributes).tap { it.merge!(@profile.attributes) }.to_h
|
|
493
|
+
end
|
|
380
494
|
|
|
381
495
|
# Returns inherited preamble lines for this document class.
|
|
382
496
|
# @return [Array<String>, nil]
|
|
383
|
-
|
|
497
|
+
# @raise [Sevgi::ArgumentError] when a non-Proto class has no configured ancestor
|
|
498
|
+
def self.preambles
|
|
499
|
+
return if self == Proto
|
|
500
|
+
|
|
501
|
+
ArgumentError.("Document class has no configured profile: #{self}") unless profile
|
|
502
|
+
return superclass.preambles unless instance_variable_defined?(:@profile)
|
|
503
|
+
|
|
504
|
+
@profile.preambles || superclass.preambles
|
|
505
|
+
end
|
|
384
506
|
end
|
|
385
507
|
|
|
386
508
|
require_relative "document/base"
|