sevgi-graphics 0.94.0 → 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.
@@ -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"