sevgi-graphics 0.93.1 → 0.95.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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +152 -0
  3. data/LICENSE +674 -0
  4. data/README.md +34 -2
  5. data/lib/sevgi/graphics/attribute.rb +146 -32
  6. data/lib/sevgi/graphics/auxiliary/canvas.rb +64 -14
  7. data/lib/sevgi/graphics/auxiliary/content.rb +156 -21
  8. data/lib/sevgi/graphics/auxiliary/margin.rb +14 -2
  9. data/lib/sevgi/graphics/auxiliary/paper.rb +91 -84
  10. data/lib/sevgi/graphics/auxiliary/scalar.rb +40 -0
  11. data/lib/sevgi/graphics/auxiliary/sizes.rb +62 -0
  12. data/lib/sevgi/graphics/auxiliary.rb +2 -0
  13. data/lib/sevgi/graphics/document.rb +234 -56
  14. data/lib/sevgi/graphics/element.rb +42 -15
  15. data/lib/sevgi/graphics/mixtures/call.rb +29 -3
  16. data/lib/sevgi/graphics/mixtures/core.rb +138 -35
  17. data/lib/sevgi/graphics/mixtures/duplicate.rb +66 -16
  18. data/lib/sevgi/graphics/mixtures/export.rb +10 -2
  19. data/lib/sevgi/graphics/mixtures/identify.rb +3 -1
  20. data/lib/sevgi/graphics/mixtures/include.rb +28 -2
  21. data/lib/sevgi/graphics/mixtures/inkscape.rb +15 -0
  22. data/lib/sevgi/graphics/mixtures/rdf.rb +20 -0
  23. data/lib/sevgi/graphics/mixtures/render.rb +103 -24
  24. data/lib/sevgi/graphics/mixtures/save.rb +9 -0
  25. data/lib/sevgi/graphics/mixtures/symbols.rb +3 -0
  26. data/lib/sevgi/graphics/mixtures/tile.rb +17 -3
  27. data/lib/sevgi/graphics/mixtures/underscore.rb +14 -3
  28. data/lib/sevgi/graphics/mixtures/validate.rb +12 -0
  29. data/lib/sevgi/graphics/mixtures/wrappers.rb +4 -1
  30. data/lib/sevgi/graphics/mixtures.rb +4 -0
  31. data/lib/sevgi/graphics/version.rb +1 -1
  32. data/lib/sevgi/graphics/xml.rb +127 -0
  33. data/lib/sevgi/graphics.rb +33 -18
  34. metadata +10 -5
@@ -2,16 +2,101 @@
2
2
 
3
3
  module Sevgi
4
4
  module Graphics
5
- # Renderable text-like content inside an SVG element.
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.
6
8
  class Content
7
- # @!attribute [r] content
8
- # @return [Object] wrapped content
9
- attr_reader :content
9
+ # Immutable payload capture and caller-owned copy helpers.
10
+ # @api private
11
+ module Snapshot
12
+ SCALARS = [::NilClass, ::TrueClass, ::FalseClass, ::Symbol, ::Integer, ::Float, ::Rational, ::Complex].freeze
13
+ private_constant :SCALARS
10
14
 
11
- # Creates content.
15
+ class << self
16
+ def capture(value, seen = {}.compare_by_identity)
17
+ case value
18
+ when ::String
19
+ XML.text(value).freeze
20
+ when ::Hash
21
+ nested(value, seen) { capture_hash(value, seen) }.freeze
22
+ when ::Array
23
+ nested(value, seen) { value.map { capture(it, seen) } }.freeze
24
+ else
25
+ SCALARS.include?(value.class) ? value : stringify(value).freeze
26
+ end
27
+ end
28
+
29
+ def copy(value)
30
+ case value
31
+ when ::String
32
+ value.dup
33
+ when ::Hash
34
+ value.to_h { |key, item| [copy(key), copy(item)] }
35
+ when ::Array
36
+ value.map { copy(it) }
37
+ else
38
+ value
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def capture_hash(value, seen)
45
+ value.each_with_object({}) do |(key, item), captured|
46
+ key = capture(key, seen)
47
+ ArgumentError.("XML content keys collide after stringification") if captured.key?(key)
48
+
49
+ captured[key] = capture(item, seen)
50
+ end
51
+ end
52
+
53
+ def nested(value, seen)
54
+ ArgumentError.("Cyclic XML content is not supported") if seen.key?(value)
55
+
56
+ seen[value] = true
57
+ yield
58
+ ensure
59
+ seen.delete(value)
60
+ end
61
+
62
+ def stringify(value)
63
+ text = value.to_s
64
+ ArgumentError.("XML content stringification must return a String") unless text.is_a?(::String)
65
+
66
+ XML.text(text)
67
+ rescue Sevgi::ArgumentError
68
+ raise
69
+ rescue ::StandardError => e
70
+ ArgumentError.("XML content cannot be stringified: #{e.class}: #{e.message}")
71
+ end
72
+ end
73
+ end
74
+
75
+ private_constant :Snapshot
76
+
77
+ # Returns a recursively independent, caller-owned payload snapshot.
78
+ # @return [Object] wrapped content snapshot
79
+ def content = Snapshot.copy(@content)
80
+
81
+ # Creates immutable content from a deep payload snapshot. Strings and containers are copied recursively; mutable
82
+ # non-container objects are stringified once during construction. The caller's objects are never retained.
12
83
  # @param content [Object] wrapped content
13
84
  # @return [void]
14
- def initialize(content) = @content = content
85
+ # @raise [Sevgi::ArgumentError] when content cannot be stringified, contains invalid encoding or illegal XML
86
+ # characters, contains cycles, or has keys that collide after stringification
87
+ def initialize(content)
88
+ @content = Snapshot.capture(content)
89
+ XML.validate(@content)
90
+ end
91
+
92
+ # Copies content payload ownership for duplicated element trees.
93
+ # @param original [Sevgi::Graphics::Content] source content
94
+ # @return [void]
95
+ # @api private
96
+ def initialize_copy(original)
97
+ @content = Snapshot.capture(original.content)
98
+ super
99
+ end
15
100
 
16
101
  # Renders content with a renderer.
17
102
  # @abstract Subclasses implement content rendering.
@@ -23,30 +108,45 @@ module Sevgi
23
108
 
24
109
  # Returns content as a string.
25
110
  # @return [String]
26
- def to_s = content.to_s
111
+ def to_s = XML.text(payload)
112
+
113
+ def payload = @content
114
+
115
+ private :payload
27
116
 
28
117
  # @overload cdata(content)
29
118
  # Builds CDATA content.
119
+ # Mutable objects are stringified during construction, and embedded CDATA terminators are split safely.
30
120
  # @param content [Object] wrapped content
31
121
  # @return [Sevgi::Graphics::Content::CData]
122
+ # @raise [Sevgi::ArgumentError] when content cannot be stringified, contains invalid encoding or illegal XML 1.0
123
+ # characters, contains cycles, or has keys that collide after stringification
32
124
  def self.cdata(...) = CData.new(...)
33
125
 
34
126
  # Wraps content arguments, encoding non-content values.
127
+ # Mutable non-content values are stringified by encoded content during construction, before XML text escaping.
35
128
  # @param args [Array<Object>] content arguments
36
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
37
132
  def self.contents(*args) = args.map { it.is_a?(Content) ? it : encoded(it) }
38
133
 
39
134
  # @overload css(content)
40
135
  # Builds CSS content.
41
136
  # @param content [Hash] CSS rules
42
137
  # @return [Sevgi::Graphics::Content::CSS]
43
- # @raise [Sevgi::ArgumentError] when content is not a hash
138
+ # @raise [Sevgi::ArgumentError] when content is not a hash, contains a malformed style, cannot be stringified,
139
+ # contains invalid encoding or illegal XML 1.0 characters, contains cycles, or has keys that collide after
140
+ # stringification
44
141
  def self.css(...) = CSS.new(...)
45
142
 
46
143
  # @overload encoded(content)
47
144
  # Builds XML text-encoded content.
145
+ # Mutable objects are stringified during construction before XML text escaping.
48
146
  # @param content [Object] wrapped content
49
147
  # @return [Sevgi::Graphics::Content::Encoded]
148
+ # @raise [Sevgi::ArgumentError] when content cannot be stringified, contains invalid encoding or illegal XML 1.0
149
+ # characters, contains cycles, or has keys that collide after stringification
50
150
  def self.encoded(...) = Encoded.new(...)
51
151
 
52
152
  # Joins content lines with newlines.
@@ -56,13 +156,19 @@ module Sevgi
56
156
 
57
157
  # @overload verbatim(content)
58
158
  # Builds verbatim content.
159
+ # Mutable objects are stringified during construction.
59
160
  # @param content [Object] wrapped content
60
161
  # @return [Sevgi::Graphics::Content::Verbatim]
162
+ # @raise [Sevgi::ArgumentError] when content cannot be stringified, contains invalid encoding or illegal XML 1.0
163
+ # characters, contains cycles, or has keys that collide after stringification
61
164
  def self.verbatim(...) = Verbatim.new(...)
62
165
 
63
- # CDATA section content.
166
+ # CDATA section content backed by an immutable payload snapshot. Mutable leaf objects are stringified during
167
+ # construction; embedded terminators are split during rendering.
168
+ # @see Content.cdata
64
169
  class CData < Content
65
170
  # Renders CDATA content.
171
+ # Embedded `]]>` terminators are split across adjacent CDATA sections so the output remains valid XML.
66
172
  # @param renderer [Object] renderer receiving output
67
173
  # @param depth [Integer] current render depth
68
174
  # @return [void]
@@ -70,19 +176,28 @@ module Sevgi
70
176
  depth += 1
71
177
 
72
178
  renderer.append(depth, "<![CDATA[")
73
- renderer.append(depth + 1, *Array(content))
179
+ renderer.append(depth + 1, *Array(payload).map { safe(it) })
74
180
  renderer.append(depth, "]]>")
75
181
  end
182
+
183
+ private
184
+
185
+ def safe(value) = XML.cdata(value)
76
186
  end
77
187
 
78
- # CSS content rendered inside a CDATA section.
188
+ # CSS content rendered inside a CDATA section. Rules are captured recursively during construction; mutable
189
+ # selectors, property names, and values are stringified once, and embedded CDATA terminators are split safely.
190
+ # @see Content.css
79
191
  class CSS < Content
80
192
  # Creates CSS content.
81
193
  # @param content [Hash] CSS rules
82
194
  # @return [void]
83
- # @raise [Sevgi::ArgumentError] when content is not a hash
195
+ # @raise [Sevgi::ArgumentError] when content is not a hash, contains a malformed style, cannot be stringified,
196
+ # contains invalid encoding or illegal XML 1.0 characters, contains cycles, or has keys that collide after
197
+ # stringification
84
198
  def initialize(content)
85
- ArgumentError.("CSS content must be a hash: #{content}") unless content.is_a?(::Hash)
199
+ ArgumentError.("CSS content must be a hash") unless content.is_a?(::Hash)
200
+ validate_styles(content)
86
201
 
87
202
  super
88
203
  end
@@ -92,21 +207,20 @@ module Sevgi
92
207
  # @param renderer [Object] renderer receiving output
93
208
  # @param depth [Integer] current render depth
94
209
  # @return [void]
95
- # @raise [Sevgi::ArgumentError] when a style value cannot be rendered
96
210
  def render(renderer, depth)
97
211
  depth += 1
98
212
 
99
213
  renderer.append(depth, "<![CDATA[")
100
214
 
101
215
  depth += 1
102
- content.each do |rule, styles|
216
+ payload.each do |rule, styles|
103
217
  case styles
104
218
  when ::Hash
105
- renderer.append(depth, "#{rule} {")
106
- renderer.append(depth + 1, *styles.map { |key, value| "#{key}: #{value};" })
219
+ renderer.append(depth, safe("#{rule} {"))
220
+ renderer.append(depth + 1, *styles.map { |key, value| safe("#{key}: #{value};") })
107
221
  renderer.append(depth, "}")
108
222
  when ::String, ::Symbol, ::Numeric
109
- renderer.append(depth, "#{rule}: #{styles};")
223
+ renderer.append(depth, safe("#{rule}: #{styles};"))
110
224
  else
111
225
  ArgumentError.("Malformed style: #{styles}")
112
226
  end
@@ -117,13 +231,27 @@ module Sevgi
117
231
  renderer.append(depth, "]]>")
118
232
  end
119
233
  # rubocop:enable Metrics/MethodLength
234
+
235
+ private
236
+
237
+ def safe(value) = XML.cdata(value)
238
+
239
+ def validate_styles(content)
240
+ content.each_value do |styles|
241
+ next if styles.is_a?(::Hash) || styles.is_a?(::String) || styles.is_a?(::Symbol) || styles.is_a?(::Numeric)
242
+
243
+ ArgumentError.("Malformed style type: #{styles.class}")
244
+ end
245
+ end
120
246
  end
121
247
 
122
- # XML text-encoded content.
248
+ # XML text-encoded content backed by an immutable payload snapshot. Mutable leaf objects are stringified during
249
+ # construction, before XML escaping.
250
+ # @see Content.encoded
123
251
  class Encoded < Content
124
252
  # Returns XML text-encoded content.
125
253
  # @return [String]
126
- def to_s = content.encode(xml: :text)
254
+ def to_s = XML.text(payload).encode(xml: :text)
127
255
 
128
256
  # Renders encoded text content.
129
257
  # @param renderer [Object] renderer receiving output
@@ -132,8 +260,15 @@ module Sevgi
132
260
  def render(renderer, depth) = renderer.append(depth + 1, to_s)
133
261
  end
134
262
 
135
- # Verbatim content rendered without XML text encoding.
263
+ # Verbatim content backed by an immutable payload snapshot. Mutable leaf objects are stringified during
264
+ # construction. Verbatim content bypasses XML escaping; validation guarantees encoding and legal XML 1.0 code
265
+ # points, not well-formed markup supplied by the caller.
266
+ # @see Content.verbatim
136
267
  class Verbatim < Content
268
+ # Returns validated verbatim content.
269
+ # @return [String]
270
+ def to_s = XML.text(payload)
271
+
137
272
  # Renders verbatim content.
138
273
  # @param renderer [Object] renderer receiving output
139
274
  # @param depth [Integer] current render depth
@@ -15,12 +15,14 @@ module Sevgi
15
15
  # @!attribute [r] left
16
16
  # @return [Float] left margin
17
17
 
18
- # Creates a margin from one to four shorthand values.
18
+ # Creates a margin from one to four shorthand values. Values must be finite real numbers greater than or equal to
19
+ # zero.
19
20
  # @param top [Numeric, nil] top value or all-sides shorthand
20
21
  # @param right [Numeric, nil] right value or horizontal shorthand
21
22
  # @param bottom [Numeric, nil] bottom value
22
23
  # @param left [Numeric, nil] left value
23
24
  # @return [void]
25
+ # @raise [Sevgi::ArgumentError] when a supplied margin is not a finite, non-negative real number
24
26
  def initialize(top: nil, right: nil, bottom: nil, left: nil)
25
27
  super(**normalize(top, right, bottom, left))
26
28
  end
@@ -54,6 +56,9 @@ module Sevgi
54
56
  def vertical = top + bottom
55
57
 
56
58
  alias_method :==, :eql?
59
+
60
+ # Returns margin values in top, right, bottom, left order.
61
+ # @return [Array<Float>]
57
62
  alias_method :to_a, :deconstruct
58
63
 
59
64
  # Builds a margin from an array-like shorthand.
@@ -74,7 +79,14 @@ module Sevgi
74
79
  def normalize(top, right, bottom, left)
75
80
  values = [top, right, bottom, left]
76
81
 
77
- Margin.members.zip(values_for(values.compact.size, values).map { Float(it) }).to_h
82
+ Margin
83
+ .members
84
+ .zip(
85
+ values_for(values.compact.size, values).map do |value|
86
+ Scalar.finite(value, context: "margin", field: :value, nonnegative: true)
87
+ end
88
+ )
89
+ .to_h
78
90
  end
79
91
 
80
92
  def values_for(size, values)
@@ -15,26 +15,30 @@ module Sevgi
15
15
  # @!attribute [r] name
16
16
  # @return [Symbol] profile name
17
17
 
18
- # Creates a paper profile.
18
+ # Creates a paper profile. Dimensions must be finite real numbers greater than zero.
19
19
  # @param width [Numeric] paper width
20
20
  # @param height [Numeric] paper height
21
21
  # @param unit [Symbol, String] SVG unit
22
22
  # @param name [Symbol, String] profile name
23
+ # @param options [Hash] unsupported extra options
23
24
  # @return [void]
24
- # @raise [Sevgi::ArgumentError] when dimensions, unit, or profile name are invalid
25
- def initialize(width:, height:, unit: "mm", name: :custom)
25
+ # @raise [Sevgi::ArgumentError] when dimensions, unit, name, or options are invalid
26
+ def initialize(width:, height:, unit: "mm", name: :custom, **options)
27
+ self.class.send(:options!, options)
26
28
  super(
27
29
  width: self.class.send(:dimension!, :width, width),
28
30
  height: self.class.send(:dimension!, :height, height),
29
- unit: self.class.send(:symbol!, :unit, unit),
30
- name: self.class.send(:symbol!, :name, name)
31
+ unit: self.class.send(:normalize!, :unit, unit),
32
+ name: self.class.send(:normalize!, :name, name)
31
33
  )
32
34
  end
33
35
 
34
36
  # Compares papers by width, height, unit, then name.
35
37
  # @param other [Sevgi::Graphics::Paper] paper to compare
36
- # @return [Integer, nil]
37
- def <=>(other) = deconstruct <=> other.deconstruct
38
+ # @return [Integer, nil] comparison result, or nil for a non-Paper operand
39
+ def <=>(other)
40
+ deconstruct <=> other.deconstruct if other.is_a?(self.class)
41
+ end
38
42
 
39
43
  # Reports strict paper equality.
40
44
  # @param other [Object] object to compare
@@ -56,108 +60,111 @@ module Sevgi
56
60
  alias_method :==, :eql?
57
61
 
58
62
  @profiles = {}
63
+ @accessors = {}
64
+ @mutex = ::Mutex.new
59
65
 
60
- # Reports whether a named paper profile exists.
66
+ # Reports whether a normalizable named paper profile exists. Invalid converters return false.
61
67
  # @param name [Object] profile name
62
68
  # @return [Boolean]
63
- def self.exist?(name) = name.respond_to?(:to_sym) && profiles.key?(name.to_sym)
69
+ def self.exist?(name)
70
+ name = normalize(name)
71
+ name ? @mutex.synchronize { @profiles.key?(name) } : false
72
+ end
64
73
 
65
- # Defines or replaces a named paper profile.
74
+ # 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`.
66
78
  # @param name [Symbol, String] profile name
79
+ # @param overwrite [Boolean] true to replace an existing profile
67
80
  # @param spec [Hash] paper dimensions and unit
68
81
  # @option spec [Numeric] :width paper width
69
82
  # @option spec [Numeric] :height paper height
70
83
  # @option spec [Symbol, String] :unit SVG unit
71
84
  # @return [Sevgi::Graphics::Paper]
72
- # @raise [Sevgi::ArgumentError] when the profile name is reserved or invalid
73
- def self.define(name, **spec)
74
- name = symbol!(:name, name)
85
+ # @raise [Sevgi::ArgumentError] when the name, dimensions, unit, overwrite flag, or options are reserved or invalid,
86
+ # or a non-bang definition conflicts with the registered profile
87
+ def self.define(name, overwrite: true, **spec)
88
+ name = normalize!(:name, name)
89
+ ArgumentError.("Paper name is reserved: #{name}") if reserved?(name)
90
+ overwrite = overwrite!(overwrite)
91
+ profile = new(name:, **spec)
92
+
93
+ register(name, profile, overwrite:)
94
+ end
75
95
 
76
- ArgumentError.("Paper name is reserved: #{name}") if reserved?(name) && !exist?(name)
96
+ def self.install(name)
97
+ return if @accessors.key?(name)
77
98
 
78
- singleton_class.remove_method(name) if singleton_class.method_defined?(name, false)
79
- singleton_class.attr_reader(name)
80
- profiles[name] = instance_variable_set("@#{name}", new(name:, **spec))
99
+ define_singleton_method(name) { @mutex.synchronize { @profiles.fetch(name) } }
100
+ @accessors[name] = true
81
101
  end
82
102
 
83
- def self.reserved?(name) = @reserved.include?(name.to_sym)
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
84
107
 
85
- def self.profiles = @profiles
108
+ next current
109
+ end
110
+
111
+ install(name)
112
+ @profiles[name] = profile
113
+ end
114
+ end
115
+
116
+ def self.reserved?(name) = @reserved.include?(name)
86
117
 
87
118
  def self.dimension!(field, value)
88
- Float(value)
89
- rescue ::ArgumentError, ::TypeError
90
- ArgumentError.("Invalid paper #{field}: #{value.inspect}")
119
+ Scalar.finite(value, context: "paper", field:, positive: true)
91
120
  end
92
121
 
93
- def self.symbol!(field, value)
94
- value.to_sym
95
- rescue ::NoMethodError, ::TypeError
96
- ArgumentError.("Invalid paper #{field}: #{value.inspect}")
122
+ def self.options!(options)
123
+ return if options.empty?
124
+
125
+ ArgumentError.("Unknown paper options: #{options.keys.join(", ")}")
126
+ end
127
+
128
+ def self.overwrite!(value)
129
+ return value if [true, false].include?(value)
130
+
131
+ ArgumentError.("Paper overwrite must be true or false")
132
+ end
133
+
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
140
+
141
+ def self.normalize!(field, value)
142
+ normalize(value) || ArgumentError.("Invalid paper #{field}")
97
143
  end
98
144
 
99
145
  @reserved = methods.map(&:to_sym).freeze
100
146
 
101
- private_class_method :dimension!, :profiles, :reserved?, :symbol!
102
-
103
- {
104
- a0: [841, 1189, "mm"],
105
- a1: [594, 841, "mm"],
106
- a2: [420, 594, "mm"],
107
- a3: [297, 420, "mm"],
108
- a4: [210, 297, "mm"],
109
- a5: [148, 210, "mm"],
110
- a6: [105, 148, "mm"],
111
- a7: [74, 105, "mm"],
112
- a8: [52, 74, "mm"],
113
- a9: [37, 52, "mm"],
114
- a10: [26, 37, "mm"],
115
-
116
- b0: [1000, 1414, "mm"],
117
- b1: [707, 1000, "mm"],
118
- b2: [500, 707, "mm"],
119
- b3: [353, 500, "mm"],
120
- b4: [250, 353, "mm"],
121
- b5: [176, 250, "mm"],
122
- b6: [125, 176, "mm"],
123
- b7: [88, 125, "mm"],
124
- b8: [62, 88, "mm"],
125
- b9: [44, 62, "mm"],
126
- b10: [31, 44, "mm"],
127
-
128
- c0: [917, 1297, "mm"],
129
- c1: [648, 917, "mm"],
130
- c2: [458, 648, "mm"],
131
- c3: [324, 458, "mm"],
132
- c4: [229, 324, "mm"],
133
- c5: [162, 229, "mm"],
134
- c6: [114, 162, "mm"],
135
- c7: [81, 114, "mm"],
136
- c8: [57, 81, "mm"],
137
- c9: [40, 57, "mm"],
138
- c10: [28, 40, "mm"],
139
-
140
- business: [85, 55, "mm"],
141
- large: [130, 210, "mm"],
142
- passport: [88, 125, "mm"],
143
- pocket: [90, 140, "mm"],
144
- travelers: [110, 210, "mm"],
145
- us: [216, 279, "mm"],
146
- xlarge: [190, 250, "mm"],
147
-
148
- icon16: [16, 16, "px"],
149
- icon32: [32, 32, "px"],
150
- icon64: [64, 64, "px"],
151
- icon128: [128, 128, "px"],
152
- icon256: [256, 256, "px"],
153
- icon512: [512, 512, "px"]
154
- }.each { |name, (width, height, unit)| define(name, width:, height:, unit:) }
155
-
156
- class << self
157
- alias_method :default, :a4
147
+ private_class_method(
148
+ :dimension!,
149
+ :install,
150
+ :normalize,
151
+ :normalize!,
152
+ :options!,
153
+ :overwrite!,
154
+ :register,
155
+ :reserved?
156
+ )
157
+
158
+ PAPER_SIZES.each { |name, (width, height, unit)| define(name, width:, height:, unit:) }
159
+
160
+ # Returns the default paper profile.
161
+ # @return [Sevgi::Graphics::Paper]
162
+ def self.default
163
+ @mutex.synchronize { @profiles.fetch(:default) }
158
164
  end
159
165
 
160
- profiles[:default] = profiles.fetch(:a4)
166
+ @accessors[:default] = true
167
+ @profiles[:default] = @profiles.fetch(:a4)
161
168
  end
162
169
  end
163
170
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sevgi
4
+ module Graphics
5
+ # Validates finite real numeric values used by graphics auxiliaries.
6
+ # @api private
7
+ module Scalar
8
+ # Converts one finite real value.
9
+ # @param value [Numeric] value to validate
10
+ # @param context [String] error context
11
+ # @param field [Symbol] field name
12
+ # @param positive [Boolean] require a strictly positive value
13
+ # @param nonnegative [Boolean] require a non-negative value
14
+ # @return [Float] validated value
15
+ # @raise [Sevgi::ArgumentError] when value is not a finite real number in the requested range
16
+ def self.finite(value, context:, field:, positive: false, nonnegative: false)
17
+ invalid(context, field, value) unless real?(value)
18
+
19
+ number = Float(value)
20
+ invalid(context, field, value) unless valid?(number, positive:, nonnegative:)
21
+
22
+ number
23
+ rescue ::ArgumentError, ::RangeError, ::TypeError
24
+ invalid(context, field, value)
25
+ end
26
+
27
+ def self.real?(value) = value.is_a?(::Numeric) && !value.is_a?(::Complex)
28
+
29
+ def self.valid?(number, positive:, nonnegative:)
30
+ number.finite? && (!positive || number.positive?) && (!nonnegative || number >= 0)
31
+ end
32
+
33
+ def self.invalid(context, field, value) = ArgumentError.("Invalid #{context} #{field}: #{value.inspect}")
34
+
35
+ private_class_method :invalid, :real?, :valid?
36
+ end
37
+
38
+ private_constant :Scalar
39
+ end
40
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sevgi
4
+ module Graphics
5
+ # Built-in paper dimensions and units.
6
+ # @api private
7
+ PAPER_SIZES = {
8
+ a0: [841, 1189, "mm"],
9
+ a1: [594, 841, "mm"],
10
+ a2: [420, 594, "mm"],
11
+ a3: [297, 420, "mm"],
12
+ a4: [210, 297, "mm"],
13
+ a5: [148, 210, "mm"],
14
+ a6: [105, 148, "mm"],
15
+ a7: [74, 105, "mm"],
16
+ a8: [52, 74, "mm"],
17
+ a9: [37, 52, "mm"],
18
+ a10: [26, 37, "mm"],
19
+
20
+ b0: [1000, 1414, "mm"],
21
+ b1: [707, 1000, "mm"],
22
+ b2: [500, 707, "mm"],
23
+ b3: [353, 500, "mm"],
24
+ b4: [250, 353, "mm"],
25
+ b5: [176, 250, "mm"],
26
+ b6: [125, 176, "mm"],
27
+ b7: [88, 125, "mm"],
28
+ b8: [62, 88, "mm"],
29
+ b9: [44, 62, "mm"],
30
+ b10: [31, 44, "mm"],
31
+
32
+ c0: [917, 1297, "mm"],
33
+ c1: [648, 917, "mm"],
34
+ c2: [458, 648, "mm"],
35
+ c3: [324, 458, "mm"],
36
+ c4: [229, 324, "mm"],
37
+ c5: [162, 229, "mm"],
38
+ c6: [114, 162, "mm"],
39
+ c7: [81, 114, "mm"],
40
+ c8: [57, 81, "mm"],
41
+ c9: [40, 57, "mm"],
42
+ c10: [28, 40, "mm"],
43
+
44
+ business: [85, 55, "mm"],
45
+ large: [130, 210, "mm"],
46
+ passport: [88, 125, "mm"],
47
+ pocket: [90, 140, "mm"],
48
+ travelers: [110, 210, "mm"],
49
+ us: [216, 279, "mm"],
50
+ xlarge: [190, 250, "mm"],
51
+
52
+ icon16: [16, 16, "px"],
53
+ icon32: [32, 32, "px"],
54
+ icon64: [64, 64, "px"],
55
+ icon128: [128, 128, "px"],
56
+ icon256: [256, 256, "px"],
57
+ icon512: [512, 512, "px"]
58
+ }.transform_values(&:freeze).freeze
59
+
60
+ private_constant :PAPER_SIZES
61
+ end
62
+ end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "auxiliary/scalar"
3
4
  require_relative "auxiliary/margin"
5
+ require_relative "auxiliary/sizes"
4
6
  require_relative "auxiliary/paper"
5
7
 
6
8
  require_relative "auxiliary/canvas"