sevgi-graphics 0.95.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 +125 -0
- data/README.md +8 -8
- data/lib/sevgi/graphics/attribute.rb +160 -37
- data/lib/sevgi/graphics/auxiliary/canvas.rb +100 -43
- data/lib/sevgi/graphics/auxiliary/content.rb +54 -34
- 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 +236 -102
- data/lib/sevgi/graphics/element.rb +114 -31
- data/lib/sevgi/graphics/mixtures/call.rb +249 -88
- data/lib/sevgi/graphics/mixtures/core.rb +59 -20
- data/lib/sevgi/graphics/mixtures/duplicate.rb +49 -15
- data/lib/sevgi/graphics/mixtures/export.rb +52 -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 +201 -47
- data/lib/sevgi/graphics/mixtures/rdf.rb +59 -7
- data/lib/sevgi/graphics/mixtures/render.rb +52 -28
- 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 +68 -28
- data/lib/sevgi/graphics/mixtures/underscore.rb +14 -5
- data/lib/sevgi/graphics/mixtures/validate.rb +2 -2
- data/lib/sevgi/graphics/mixtures/wrappers.rb +62 -23
- data/lib/sevgi/graphics/mixtures.rb +15 -13
- data/lib/sevgi/graphics/version.rb +1 -1
- data/lib/sevgi/graphics.rb +58 -12
- metadata +7 -6
|
@@ -4,48 +4,72 @@ 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
|
|
|
@@ -64,17 +88,24 @@ module Sevgi
|
|
|
64
88
|
# @return [Sevgi::Graphics::Paper]
|
|
65
89
|
attr_reader :inner
|
|
66
90
|
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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]]
|
|
78
109
|
|
|
79
110
|
compute
|
|
80
111
|
freeze
|
|
@@ -82,22 +113,48 @@ module Sevgi
|
|
|
82
113
|
|
|
83
114
|
# @overload attributes(origin = Undefined)
|
|
84
115
|
# Returns SVG root viewport attributes.
|
|
85
|
-
#
|
|
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
|
|
86
122
|
# @return [Hash{Symbol => String}] SVG viewport and viewBox attributes
|
|
87
123
|
# @raise [Sevgi::ArgumentError] when origin is invalid
|
|
88
124
|
def attributes(...) = {**viewport, viewBox: viewbox(...)}
|
|
89
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
|
+
|
|
90
140
|
# Returns SVG width and height attributes.
|
|
91
141
|
# @return [Hash{Symbol => String}] SVG width and height attributes
|
|
92
142
|
def viewport = {width: "#{width}#{unit}", height: "#{height}#{unit}"}
|
|
93
143
|
|
|
94
144
|
# Returns the SVG viewBox string.
|
|
95
|
-
#
|
|
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
|
|
96
147
|
# @return [String]
|
|
97
148
|
# @raise [Sevgi::ArgumentError] when origin is invalid
|
|
98
149
|
def viewbox(origin = Undefined) = prettify(*originate(origin), width, height).join(" ")
|
|
99
150
|
|
|
100
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]
|
|
101
158
|
# @param kwargs [Hash] replacement options
|
|
102
159
|
# @option kwargs [Numeric] :width replacement canvas width
|
|
103
160
|
# @option kwargs [Numeric] :height replacement canvas height
|
|
@@ -108,7 +165,7 @@ module Sevgi
|
|
|
108
165
|
# @raise [Sevgi::ArgumentError] when an unknown option is supplied
|
|
109
166
|
# @raise [Sevgi::ArgumentError] when a replacement value is invalid
|
|
110
167
|
def with(**kwargs)
|
|
111
|
-
unknown = kwargs.keys -
|
|
168
|
+
unknown = kwargs.keys - FIELDS
|
|
112
169
|
|
|
113
170
|
ArgumentError.("Unknown canvas option: #{unknown.first}") unless unknown.empty?
|
|
114
171
|
|
|
@@ -2,10 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
module Sevgi
|
|
4
4
|
module Graphics
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
|
8
34
|
class Content
|
|
35
|
+
private_class_method :new
|
|
36
|
+
|
|
9
37
|
# Immutable payload capture and caller-owned copy helpers.
|
|
10
38
|
# @api private
|
|
11
39
|
module Snapshot
|
|
@@ -84,6 +112,7 @@ module Sevgi
|
|
|
84
112
|
# @return [void]
|
|
85
113
|
# @raise [Sevgi::ArgumentError] when content cannot be stringified, contains invalid encoding or illegal XML
|
|
86
114
|
# characters, contains cycles, or has keys that collide after stringification
|
|
115
|
+
# @api private
|
|
87
116
|
def initialize(content)
|
|
88
117
|
@content = Snapshot.capture(content)
|
|
89
118
|
XML.validate(@content)
|
|
@@ -98,13 +127,18 @@ module Sevgi
|
|
|
98
127
|
super
|
|
99
128
|
end
|
|
100
129
|
|
|
101
|
-
|
|
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.
|
|
102
136
|
# @abstract Subclasses implement content rendering.
|
|
103
|
-
# @param
|
|
137
|
+
# @param _output [#append] rendering output collaborator
|
|
104
138
|
# @param _depth [Integer] current render depth
|
|
105
|
-
# @return [
|
|
139
|
+
# @return [Object] ignored by the rendering engine
|
|
106
140
|
# @raise [Sevgi::PanicError] when a subclass does not implement render
|
|
107
|
-
def render(
|
|
141
|
+
def render(_output, _depth) = PanicError.("#{self.class}#render must be implemented")
|
|
108
142
|
|
|
109
143
|
# Returns content as a string.
|
|
110
144
|
# @return [String]
|
|
@@ -121,15 +155,7 @@ module Sevgi
|
|
|
121
155
|
# @return [Sevgi::Graphics::Content::CData]
|
|
122
156
|
# @raise [Sevgi::ArgumentError] when content cannot be stringified, contains invalid encoding or illegal XML 1.0
|
|
123
157
|
# characters, contains cycles, or has keys that collide after stringification
|
|
124
|
-
def self.cdata(...) = CData.new
|
|
125
|
-
|
|
126
|
-
# Wraps content arguments, encoding non-content values.
|
|
127
|
-
# Mutable non-content values are stringified by encoded content during construction, before XML text escaping.
|
|
128
|
-
# @param args [Array<Object>] content arguments
|
|
129
|
-
# @return [Array<Sevgi::Graphics::Content>]
|
|
130
|
-
# @raise [Sevgi::ArgumentError] when an argument cannot be stringified, contains invalid encoding or illegal XML
|
|
131
|
-
# 1.0 characters, contains cycles, or has keys that collide after stringification
|
|
132
|
-
def self.contents(*args) = args.map { it.is_a?(Content) ? it : encoded(it) }
|
|
158
|
+
def self.cdata(...) = CData.send(:new, ...)
|
|
133
159
|
|
|
134
160
|
# @overload css(content)
|
|
135
161
|
# Builds CSS content.
|
|
@@ -138,7 +164,7 @@ module Sevgi
|
|
|
138
164
|
# @raise [Sevgi::ArgumentError] when content is not a hash, contains a malformed style, cannot be stringified,
|
|
139
165
|
# contains invalid encoding or illegal XML 1.0 characters, contains cycles, or has keys that collide after
|
|
140
166
|
# stringification
|
|
141
|
-
def self.css(...) = CSS.new
|
|
167
|
+
def self.css(...) = CSS.send(:new, ...)
|
|
142
168
|
|
|
143
169
|
# @overload encoded(content)
|
|
144
170
|
# Builds XML text-encoded content.
|
|
@@ -147,12 +173,7 @@ module Sevgi
|
|
|
147
173
|
# @return [Sevgi::Graphics::Content::Encoded]
|
|
148
174
|
# @raise [Sevgi::ArgumentError] when content cannot be stringified, contains invalid encoding or illegal XML 1.0
|
|
149
175
|
# characters, contains cycles, or has keys that collide after stringification
|
|
150
|
-
def self.encoded(...) = Encoded.new
|
|
151
|
-
|
|
152
|
-
# Joins content lines with newlines.
|
|
153
|
-
# @param contents [Object, Array<Object>] content lines
|
|
154
|
-
# @return [String]
|
|
155
|
-
def self.text(contents) = Array(contents).join("\n")
|
|
176
|
+
def self.encoded(...) = Encoded.send(:new, ...)
|
|
156
177
|
|
|
157
178
|
# @overload verbatim(content)
|
|
158
179
|
# Builds verbatim content.
|
|
@@ -161,7 +182,7 @@ module Sevgi
|
|
|
161
182
|
# @return [Sevgi::Graphics::Content::Verbatim]
|
|
162
183
|
# @raise [Sevgi::ArgumentError] when content cannot be stringified, contains invalid encoding or illegal XML 1.0
|
|
163
184
|
# characters, contains cycles, or has keys that collide after stringification
|
|
164
|
-
def self.verbatim(...) = Verbatim.new
|
|
185
|
+
def self.verbatim(...) = Verbatim.send(:new, ...)
|
|
165
186
|
|
|
166
187
|
# CDATA section content backed by an immutable payload snapshot. Mutable leaf objects are stringified during
|
|
167
188
|
# construction; embedded terminators are split during rendering.
|
|
@@ -169,9 +190,9 @@ module Sevgi
|
|
|
169
190
|
class CData < Content
|
|
170
191
|
# Renders CDATA content.
|
|
171
192
|
# Embedded `]]>` terminators are split across adjacent CDATA sections so the output remains valid XML.
|
|
172
|
-
# @param renderer [
|
|
193
|
+
# @param renderer [#append] rendering output collaborator
|
|
173
194
|
# @param depth [Integer] current render depth
|
|
174
|
-
# @return [
|
|
195
|
+
# @return [Object] ignored by the rendering engine
|
|
175
196
|
def render(renderer, depth)
|
|
176
197
|
depth += 1
|
|
177
198
|
|
|
@@ -195,6 +216,7 @@ module Sevgi
|
|
|
195
216
|
# @raise [Sevgi::ArgumentError] when content is not a hash, contains a malformed style, cannot be stringified,
|
|
196
217
|
# contains invalid encoding or illegal XML 1.0 characters, contains cycles, or has keys that collide after
|
|
197
218
|
# stringification
|
|
219
|
+
# @api private
|
|
198
220
|
def initialize(content)
|
|
199
221
|
ArgumentError.("CSS content must be a hash") unless content.is_a?(::Hash)
|
|
200
222
|
validate_styles(content)
|
|
@@ -202,11 +224,10 @@ module Sevgi
|
|
|
202
224
|
super
|
|
203
225
|
end
|
|
204
226
|
|
|
205
|
-
# rubocop:disable Metrics/MethodLength
|
|
206
227
|
# Renders CSS content.
|
|
207
|
-
# @param renderer [
|
|
228
|
+
# @param renderer [#append] rendering output collaborator
|
|
208
229
|
# @param depth [Integer] current render depth
|
|
209
|
-
# @return [
|
|
230
|
+
# @return [Object] ignored by the rendering engine
|
|
210
231
|
def render(renderer, depth)
|
|
211
232
|
depth += 1
|
|
212
233
|
|
|
@@ -230,7 +251,6 @@ module Sevgi
|
|
|
230
251
|
|
|
231
252
|
renderer.append(depth, "]]>")
|
|
232
253
|
end
|
|
233
|
-
# rubocop:enable Metrics/MethodLength
|
|
234
254
|
|
|
235
255
|
private
|
|
236
256
|
|
|
@@ -254,9 +274,9 @@ module Sevgi
|
|
|
254
274
|
def to_s = XML.text(payload).encode(xml: :text)
|
|
255
275
|
|
|
256
276
|
# Renders encoded text content.
|
|
257
|
-
# @param renderer [
|
|
277
|
+
# @param renderer [#append] rendering output collaborator
|
|
258
278
|
# @param depth [Integer] current render depth
|
|
259
|
-
# @return [
|
|
279
|
+
# @return [Object] ignored by the rendering engine
|
|
260
280
|
def render(renderer, depth) = renderer.append(depth + 1, to_s)
|
|
261
281
|
end
|
|
262
282
|
|
|
@@ -270,9 +290,9 @@ module Sevgi
|
|
|
270
290
|
def to_s = XML.text(payload)
|
|
271
291
|
|
|
272
292
|
# Renders verbatim content.
|
|
273
|
-
# @param renderer [
|
|
293
|
+
# @param renderer [#append] rendering output collaborator
|
|
274
294
|
# @param depth [Integer] current render depth
|
|
275
|
-
# @return [
|
|
295
|
+
# @return [Object] ignored by the rendering engine
|
|
276
296
|
def render(renderer, depth) = renderer.append(depth + 1, to_s)
|
|
277
297
|
end
|
|
278
298
|
end
|
|
@@ -3,6 +3,19 @@
|
|
|
3
3
|
module Sevgi
|
|
4
4
|
module Graphics
|
|
5
5
|
# Four-sided margin with CSS-like shorthand normalization.
|
|
6
|
+
# @!parse
|
|
7
|
+
# class Margin
|
|
8
|
+
# # Creates a margin from one to four CSS-like shorthand values.
|
|
9
|
+
# # @param top [Numeric, nil] top value or all-sides shorthand
|
|
10
|
+
# # @param right [Numeric, nil] right value or horizontal shorthand
|
|
11
|
+
# # @param bottom [Numeric, nil] bottom value
|
|
12
|
+
# # @param left [Numeric, nil] left value
|
|
13
|
+
# # @return [Sevgi::Graphics::Margin]
|
|
14
|
+
# # @raise [Sevgi::ArgumentError] when a supplied value is not a finite, non-negative real number
|
|
15
|
+
# # @example Create a vertical and horizontal margin
|
|
16
|
+
# # Sevgi::Graphics::Margin[5, 10]
|
|
17
|
+
# def self.[](top = nil, right = nil, bottom = nil, left = nil); end
|
|
18
|
+
# end
|
|
6
19
|
Margin = Data.define(:top, :right, :bottom, :left) do
|
|
7
20
|
include Comparable
|
|
8
21
|
|
|
@@ -28,14 +41,17 @@ module Sevgi
|
|
|
28
41
|
end
|
|
29
42
|
|
|
30
43
|
# Compares margins by top, right, bottom, then left.
|
|
31
|
-
# @param other [
|
|
32
|
-
# @return [Integer, nil]
|
|
33
|
-
def <=>(other)
|
|
44
|
+
# @param other [Object] margin to compare
|
|
45
|
+
# @return [Integer, nil] comparison result, or nil for a non-Margin operand
|
|
46
|
+
def <=>(other)
|
|
47
|
+
deconstruct <=> other.deconstruct if other.is_a?(self.class)
|
|
48
|
+
end
|
|
34
49
|
|
|
35
50
|
# Returns a margin inflated horizontally and vertically.
|
|
36
51
|
# @param h [Numeric] horizontal addition
|
|
37
52
|
# @param v [Numeric] vertical addition
|
|
38
53
|
# @return [Sevgi::Graphics::Margin]
|
|
54
|
+
# @raise [Sevgi::ArgumentError] when the adjusted margin is negative or not finite
|
|
39
55
|
def adjust(h, v) = self.class[top + v, right + h, bottom + v, left + h]
|
|
40
56
|
|
|
41
57
|
# Reports strict margin equality.
|
|
@@ -61,15 +77,6 @@ module Sevgi
|
|
|
61
77
|
# @return [Array<Float>]
|
|
62
78
|
alias_method :to_a, :deconstruct
|
|
63
79
|
|
|
64
|
-
# Builds a margin from an array-like shorthand.
|
|
65
|
-
# @param array [Object] value converted with Array()
|
|
66
|
-
# @return [Sevgi::Graphics::Margin]
|
|
67
|
-
def self.margin(array)
|
|
68
|
-
self[
|
|
69
|
-
*(array = Array(array)[0...(size = Margin.members.size)]).fill(nil, array.size, size - array.size)
|
|
70
|
-
]
|
|
71
|
-
end
|
|
72
|
-
|
|
73
80
|
# Returns a zero margin.
|
|
74
81
|
# @return [Sevgi::Graphics::Margin]
|
|
75
82
|
def self.zero = (@zero ||= self[0.0, 0.0, 0.0, 0.0])
|
|
@@ -3,6 +3,19 @@
|
|
|
3
3
|
module Sevgi
|
|
4
4
|
module Graphics
|
|
5
5
|
# Paper size and unit profile.
|
|
6
|
+
# @!parse
|
|
7
|
+
# class Paper
|
|
8
|
+
# # Creates a paper profile from dimensions and optional metadata.
|
|
9
|
+
# # @param width [Numeric] paper width
|
|
10
|
+
# # @param height [Numeric] paper height
|
|
11
|
+
# # @param unit [Symbol, String] SVG unit
|
|
12
|
+
# # @param name [Symbol, String] profile name
|
|
13
|
+
# # @return [Sevgi::Graphics::Paper]
|
|
14
|
+
# # @raise [Sevgi::ArgumentError] when dimensions, unit, or name are invalid
|
|
15
|
+
# # @example Create a custom paper profile with value notation
|
|
16
|
+
# # Sevgi::Graphics::Paper[90, 50, :mm, :card]
|
|
17
|
+
# def self.[](width, height, unit = "mm", name = :custom); end
|
|
18
|
+
# end
|
|
6
19
|
Paper = Data.define(:width, :height, :unit, :name) do
|
|
7
20
|
include Comparable
|
|
8
21
|
|
|
@@ -71,10 +84,25 @@ module Sevgi
|
|
|
71
84
|
name ? @mutex.synchronize { @profiles.key?(name) } : false
|
|
72
85
|
end
|
|
73
86
|
|
|
87
|
+
# Returns a registered paper profile by name.
|
|
88
|
+
# @param name [Symbol, String] profile name, including names that are not Ruby identifiers
|
|
89
|
+
# @return [Sevgi::Graphics::Paper] registered profile
|
|
90
|
+
# @raise [Sevgi::ArgumentError] when name is invalid or no profile is registered
|
|
91
|
+
# @example Look up a non-identifier profile name
|
|
92
|
+
# Sevgi::Graphics::Paper.define("business-card", width: 90, height: 50)
|
|
93
|
+
# Sevgi::Graphics::Paper.fetch("business-card")
|
|
94
|
+
def self.fetch(name)
|
|
95
|
+
name = normalize!(:name, name)
|
|
96
|
+
@mutex.synchronize { @profiles.fetch(name) { ArgumentError.("Unknown paper profile: #{name}") } }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Returns registered profile names.
|
|
100
|
+
# @return [Array<Symbol>] frozen name snapshot
|
|
101
|
+
def self.keys = @mutex.synchronize { @profiles.keys.freeze }
|
|
102
|
+
|
|
74
103
|
# Defines a named paper profile after complete validation. Registration is process-global and thread-atomic.
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
# `public_send`.
|
|
104
|
+
# Identical definitions return the canonical profile and conflicting definitions raise unless replacement is
|
|
105
|
+
# explicitly requested. Names that are not Ruby call syntax remain accessible through {.fetch}.
|
|
78
106
|
# @param name [Symbol, String] profile name
|
|
79
107
|
# @param overwrite [Boolean] true to replace an existing profile
|
|
80
108
|
# @param spec [Hash] paper dimensions and unit
|
|
@@ -84,7 +112,11 @@ module Sevgi
|
|
|
84
112
|
# @return [Sevgi::Graphics::Paper]
|
|
85
113
|
# @raise [Sevgi::ArgumentError] when the name, dimensions, unit, overwrite flag, or options are reserved or invalid,
|
|
86
114
|
# or a non-bang definition conflicts with the registered profile
|
|
87
|
-
|
|
115
|
+
# @example Define or reuse a matching profile
|
|
116
|
+
# Sevgi::Graphics::Paper.define(:card, width: 90, height: 50)
|
|
117
|
+
# @example Replace a profile explicitly
|
|
118
|
+
# Sevgi::Graphics::Paper.define(:card, width: 100, height: 60, overwrite: true)
|
|
119
|
+
def self.define(name, overwrite: false, **spec)
|
|
88
120
|
name = normalize!(:name, name)
|
|
89
121
|
ArgumentError.("Paper name is reserved: #{name}") if reserved?(name)
|
|
90
122
|
overwrite = overwrite!(overwrite)
|
|
@@ -93,68 +125,61 @@ module Sevgi
|
|
|
93
125
|
register(name, profile, overwrite:)
|
|
94
126
|
end
|
|
95
127
|
|
|
96
|
-
|
|
97
|
-
|
|
128
|
+
class << self
|
|
129
|
+
private
|
|
98
130
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
end
|
|
131
|
+
def install(name)
|
|
132
|
+
return if @accessors.key?(name)
|
|
102
133
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
ArgumentError.("Paper already defined differently: #{name}") unless current == profile
|
|
134
|
+
define_singleton_method(name) { @mutex.synchronize { @profiles.fetch(name) } }
|
|
135
|
+
@accessors[name] = true
|
|
136
|
+
end
|
|
107
137
|
|
|
108
|
-
|
|
109
|
-
|
|
138
|
+
def register(name, profile, overwrite:)
|
|
139
|
+
@mutex.synchronize do
|
|
140
|
+
if !overwrite && (current = @profiles[name])
|
|
141
|
+
ArgumentError.("Paper already defined differently: #{name}") unless current == profile
|
|
142
|
+
|
|
143
|
+
next current
|
|
144
|
+
end
|
|
110
145
|
|
|
111
|
-
|
|
112
|
-
|
|
146
|
+
install(name)
|
|
147
|
+
@profiles[name] = profile
|
|
148
|
+
end
|
|
113
149
|
end
|
|
114
|
-
end
|
|
115
150
|
|
|
116
|
-
|
|
151
|
+
def reserved?(name) = @reserved.include?(name)
|
|
117
152
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
153
|
+
def dimension!(field, value)
|
|
154
|
+
Scalar.finite(value, context: "paper", field:, positive: true)
|
|
155
|
+
end
|
|
121
156
|
|
|
122
|
-
|
|
123
|
-
|
|
157
|
+
def options!(options)
|
|
158
|
+
return if options.empty?
|
|
124
159
|
|
|
125
|
-
|
|
126
|
-
|
|
160
|
+
ArgumentError.("Unknown paper options: #{options.keys.join(", ")}")
|
|
161
|
+
end
|
|
127
162
|
|
|
128
|
-
|
|
129
|
-
|
|
163
|
+
def overwrite!(value)
|
|
164
|
+
return value if [true, false].include?(value)
|
|
130
165
|
|
|
131
|
-
|
|
132
|
-
|
|
166
|
+
ArgumentError.("Paper overwrite must be true or false")
|
|
167
|
+
end
|
|
133
168
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
169
|
+
def normalize(value)
|
|
170
|
+
normalized = value.to_sym if value.respond_to?(:to_sym)
|
|
171
|
+
normalized if normalized.is_a?(::Symbol)
|
|
172
|
+
rescue ::StandardError
|
|
173
|
+
nil
|
|
174
|
+
end
|
|
140
175
|
|
|
141
|
-
|
|
142
|
-
|
|
176
|
+
def normalize!(field, value)
|
|
177
|
+
normalize(value) || ArgumentError.("Invalid paper #{field}")
|
|
178
|
+
end
|
|
143
179
|
end
|
|
144
180
|
|
|
145
181
|
@reserved = methods.map(&:to_sym).freeze
|
|
146
182
|
|
|
147
|
-
private_class_method(
|
|
148
|
-
:dimension!,
|
|
149
|
-
:install,
|
|
150
|
-
:normalize,
|
|
151
|
-
:normalize!,
|
|
152
|
-
:options!,
|
|
153
|
-
:overwrite!,
|
|
154
|
-
:register,
|
|
155
|
-
:reserved?
|
|
156
|
-
)
|
|
157
|
-
|
|
158
183
|
PAPER_SIZES.each { |name, (width, height, unit)| define(name, width:, height:, unit:) }
|
|
159
184
|
|
|
160
185
|
# Returns the default paper profile.
|
|
@@ -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
|