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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +125 -0
  3. data/README.md +8 -8
  4. data/lib/sevgi/graphics/attribute.rb +160 -37
  5. data/lib/sevgi/graphics/auxiliary/canvas.rb +100 -43
  6. data/lib/sevgi/graphics/auxiliary/content.rb +54 -34
  7. data/lib/sevgi/graphics/auxiliary/margin.rb +19 -12
  8. data/lib/sevgi/graphics/auxiliary/paper.rb +74 -49
  9. data/lib/sevgi/graphics/auxiliary/path.rb +44 -0
  10. data/lib/sevgi/graphics/auxiliary/scalar.rb +36 -7
  11. data/lib/sevgi/graphics/auxiliary.rb +1 -0
  12. data/lib/sevgi/graphics/document/base.rb +6 -2
  13. data/lib/sevgi/graphics/document/default.rb +1 -1
  14. data/lib/sevgi/graphics/document.rb +236 -102
  15. data/lib/sevgi/graphics/element.rb +114 -31
  16. data/lib/sevgi/graphics/mixtures/call.rb +249 -88
  17. data/lib/sevgi/graphics/mixtures/core.rb +59 -20
  18. data/lib/sevgi/graphics/mixtures/duplicate.rb +49 -15
  19. data/lib/sevgi/graphics/mixtures/export.rb +52 -12
  20. data/lib/sevgi/graphics/mixtures/hatch.rb +49 -7
  21. data/lib/sevgi/graphics/mixtures/identify.rb +30 -17
  22. data/lib/sevgi/graphics/mixtures/include.rb +26 -8
  23. data/lib/sevgi/graphics/mixtures/inkscape.rb +201 -47
  24. data/lib/sevgi/graphics/mixtures/rdf.rb +59 -7
  25. data/lib/sevgi/graphics/mixtures/render.rb +52 -28
  26. data/lib/sevgi/graphics/mixtures/save.rb +79 -35
  27. data/lib/sevgi/graphics/mixtures/symbols.rb +81 -12
  28. data/lib/sevgi/graphics/mixtures/tile.rb +85 -67
  29. data/lib/sevgi/graphics/mixtures/transform.rb +68 -28
  30. data/lib/sevgi/graphics/mixtures/underscore.rb +14 -5
  31. data/lib/sevgi/graphics/mixtures/validate.rb +2 -2
  32. data/lib/sevgi/graphics/mixtures/wrappers.rb +62 -23
  33. data/lib/sevgi/graphics/mixtures.rb +15 -13
  34. data/lib/sevgi/graphics/version.rb +1 -1
  35. data/lib/sevgi/graphics.rb +58 -12
  36. 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
- REPLACEMENTS = %i[width height unit name margins].freeze
11
- private_constant :ORIGIN_FIELDS, :REPLACEMENTS
25
+ REQUIRED = %i[width height].freeze
26
+ private_constant :DEFAULTS, :FIELDS, :ORIGIN_FIELDS, :REQUIRED
12
27
 
13
- # @overload call(arg = Undefined, **kwargs)
14
- # Builds a canvas from a paper profile or explicit size.
15
- # @param arg [Sevgi::Graphics::Paper, Symbol, String, Sevgi::Undefined] paper profile or paper object
16
- # @param kwargs [Hash] canvas keyword arguments
28
+ # @overload call(paper, **overrides)
29
+ # Builds a canvas from a paper profile with optional field overrides.
30
+ # @param paper [Sevgi::Graphics::Paper, Symbol, String] paper object or registered profile
31
+ # @param overrides [Hash] canvas field overrides
17
32
  # @return [Sevgi::Graphics::Canvas]
18
- # @raise [Sevgi::ArgumentError] when the paper profile is unknown
19
- def self.call(...) = from_paper(...)
33
+ # @raise [Sevgi::ArgumentError] when the paper or an override is invalid
34
+ # @overload call(width:, height:, unit: "mm", name: :custom, margins: [])
35
+ # Builds a canvas from an explicit size.
36
+ # @param width [Numeric] canvas width
37
+ # @param height [Numeric] canvas height
38
+ # @param unit [Symbol, String] SVG unit
39
+ # @param name [Symbol, String] paper name
40
+ # @param margins [Array<Numeric>] margin shorthand values
41
+ # @return [Sevgi::Graphics::Canvas]
42
+ # @raise [Sevgi::ArgumentError] when a required field is omitted or a value is invalid
43
+ def self.call(paper = Undefined, **fields)
44
+ paper.equal?(Undefined) ? new(**fields) : from_paper(paper, **fields)
45
+ end
20
46
 
21
- # Builds a canvas from a paper profile or explicit size.
22
- # @param arg [Sevgi::Graphics::Paper, Symbol, String, Sevgi::Undefined] paper profile or paper object
23
- # @param kwargs [Hash] canvas keyword arguments
24
- # @return [Sevgi::Graphics::Canvas]
25
- # @raise [Sevgi::ArgumentError] when the paper profile is unknown
26
- def self.from_paper(arg = Undefined, **kwargs)
27
- case arg
28
- when Undefined
29
- new(**kwargs)
30
- else
31
- new(**paper(arg).to_h, **kwargs)
32
- end
47
+ # @overload from_paper(paper, **overrides)
48
+ # Builds a canvas from a paper profile with optional field overrides.
49
+ # @param paper [Sevgi::Graphics::Paper, Symbol, String] paper object or registered profile
50
+ # @param overrides [Hash] canvas field overrides
51
+ # @return [Sevgi::Graphics::Canvas]
52
+ # @raise [Sevgi::ArgumentError] when the paper is omitted or invalid, or an override is invalid
53
+ # @example Override a registered paper profile
54
+ # Sevgi::Graphics::Canvas.from_paper(:a4, margins: [10])
55
+ def self.from_paper(paper = Undefined, **overrides)
56
+ ArgumentError.("Paper is required") if paper.equal?(Undefined)
57
+
58
+ new(**profile(paper).to_h, **overrides)
33
59
  end
34
60
 
35
- def self.paper(arg)
36
- case arg
61
+ def self.profile(paper)
62
+ case paper
37
63
  when Paper
38
- arg
64
+ paper
39
65
  when ::Symbol, ::String
40
- ArgumentError.("Unknown paper profile: #{arg}") unless Paper.exist?(arg)
41
-
42
- Paper.public_send(arg)
66
+ Paper.fetch(paper)
43
67
  else
44
- ArgumentError.("Argument must be a Paper symbol: #{arg}")
68
+ ArgumentError.("Paper must be a Paper, Symbol, or String: #{paper}")
45
69
  end
46
70
  end
47
71
 
48
- private_class_method :paper
72
+ private_class_method :profile
49
73
 
50
74
  extend Forwardable
51
75
 
@@ -64,17 +88,24 @@ module Sevgi
64
88
  # @return [Sevgi::Graphics::Paper]
65
89
  attr_reader :inner
66
90
 
67
- # Creates a canvas with finite real dimensions greater than zero and margins that leave a positive inner area.
68
- # @param width [Numeric] canvas width
69
- # @param height [Numeric] canvas height
70
- # @param unit [Symbol, String] SVG unit
71
- # @param name [Symbol, String] paper name
72
- # @param margins [Array<Numeric>] margin shorthand values
73
- # @return [void]
74
- # @raise [Sevgi::ArgumentError] when margins or numeric dimensions are invalid
75
- def initialize(width:, height:, unit: "mm", name: :custom, margins: [])
76
- @size = Paper[width, height, unit, name]
77
- @margin = Margin[*margins]
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
- # @param origin [Numeric, Array<Numeric>, nil, Sevgi::Undefined] viewBox origin
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
- # @param origin [Numeric, Array<Numeric>, nil, Sevgi::Undefined] viewBox origin
145
+ # Omission uses the negative left and top margins; nil uses zero for both coordinates.
146
+ # @param origin [Numeric, Array<Numeric>, nil, Sevgi::Undefined] viewBox origin; a scalar sets both coordinates
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 - REPLACEMENTS
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
- # Renderable text-like content inside an SVG element. Content owns an immutable deep snapshot: strings and containers
6
- # are copied, mutable leaf objects are stringified once during construction, and {#content} returns caller-owned
7
- # copies. Later mutations cannot change rendering or invalidate the construction-time XML checks.
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
- # Renders content with a renderer.
130
+ private :initialize_copy
131
+
132
+ # Appends this content's serialized XML lines to rendering output.
133
+ # The output collaborator responds to `append(depth, *lines)`, where `depth` is an Integer or nil and every line
134
+ # is a String containing valid serialized XML text. The rendering engine ignores both `append` and `render` return
135
+ # values. Custom content is responsible for escaping data that it inserts into markup.
102
136
  # @abstract Subclasses implement content rendering.
103
- # @param _renderer [Object] renderer receiving output
137
+ # @param _output [#append] rendering output collaborator
104
138
  # @param _depth [Integer] current render depth
105
- # @return [void]
139
+ # @return [Object] ignored by the rendering engine
106
140
  # @raise [Sevgi::PanicError] when a subclass does not implement render
107
- def render(_renderer, _depth) = PanicError.("#{self.class}#render must be implemented")
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 [Object] renderer receiving output
193
+ # @param renderer [#append] rendering output collaborator
173
194
  # @param depth [Integer] current render depth
174
- # @return [void]
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 [Object] renderer receiving output
228
+ # @param renderer [#append] rendering output collaborator
208
229
  # @param depth [Integer] current render depth
209
- # @return [void]
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 [Object] renderer receiving output
277
+ # @param renderer [#append] rendering output collaborator
258
278
  # @param depth [Integer] current render depth
259
- # @return [void]
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 [Object] renderer receiving output
293
+ # @param renderer [#append] rendering output collaborator
274
294
  # @param depth [Integer] current render depth
275
- # @return [void]
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 [Sevgi::Graphics::Margin] margin to compare
32
- # @return [Integer, nil]
33
- def <=>(other) = deconstruct <=> other.deconstruct
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
- # Existing profiles are replaced by default; with `overwrite: false`, identical definitions return the canonical
76
- # profile and conflicting definitions raise. Names that are not Ruby call syntax remain accessible through
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
- def self.define(name, overwrite: true, **spec)
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
- def self.install(name)
97
- return if @accessors.key?(name)
128
+ class << self
129
+ private
98
130
 
99
- define_singleton_method(name) { @mutex.synchronize { @profiles.fetch(name) } }
100
- @accessors[name] = true
101
- end
131
+ def install(name)
132
+ return if @accessors.key?(name)
102
133
 
103
- def self.register(name, profile, overwrite:)
104
- @mutex.synchronize do
105
- if !overwrite && (current = @profiles[name])
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
- next current
109
- end
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
- install(name)
112
- @profiles[name] = profile
146
+ install(name)
147
+ @profiles[name] = profile
148
+ end
113
149
  end
114
- end
115
150
 
116
- def self.reserved?(name) = @reserved.include?(name)
151
+ def reserved?(name) = @reserved.include?(name)
117
152
 
118
- def self.dimension!(field, value)
119
- Scalar.finite(value, context: "paper", field:, positive: true)
120
- end
153
+ def dimension!(field, value)
154
+ Scalar.finite(value, context: "paper", field:, positive: true)
155
+ end
121
156
 
122
- def self.options!(options)
123
- return if options.empty?
157
+ def options!(options)
158
+ return if options.empty?
124
159
 
125
- ArgumentError.("Unknown paper options: #{options.keys.join(", ")}")
126
- end
160
+ ArgumentError.("Unknown paper options: #{options.keys.join(", ")}")
161
+ end
127
162
 
128
- def self.overwrite!(value)
129
- return value if [true, false].include?(value)
163
+ def overwrite!(value)
164
+ return value if [true, false].include?(value)
130
165
 
131
- ArgumentError.("Paper overwrite must be true or false")
132
- end
166
+ ArgumentError.("Paper overwrite must be true or false")
167
+ end
133
168
 
134
- def self.normalize(value)
135
- normalized = value.to_sym if value.respond_to?(:to_sym)
136
- normalized if normalized.is_a?(::Symbol)
137
- rescue ::StandardError
138
- nil
139
- end
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
- def self.normalize!(field, value)
142
- normalize(value) || ArgumentError.("Invalid paper #{field}")
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