sevgi-sundries 0.93.1 → 0.94.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6094018249be152bff171c6077c6b5f7c9bd9f0d24c68381afbe05674e121da0
4
- data.tar.gz: 8b9a02a83b2fd4b3614b65fed7c7922b611b78790e187167db3361cdd301cdeb
3
+ metadata.gz: e8ddc5568e2938adbef615c7bc12663083287016955cd708bd342ec95423bfea
4
+ data.tar.gz: 64b06db206dcf954131c8348e0e1871a39c80677f83a15fc683cc84ab943d2aa
5
5
  SHA512:
6
- metadata.gz: ea257e8c2b131b3ae278d35d14850391f6a4bf33467a528d87aa5ec0cd8dd3c4109376da978a3fdd4352d230f5c668d268914e390f33be4c1d28a775d5eabee9
7
- data.tar.gz: 7ba89c1d630142ac180bc64aaa15564bb2d54b5a5e68daa1cd2a814f546cef6c1b76d08ff8504f64e4d0a9880c505577ca958c9ae96ce3e814fb584035593879
6
+ metadata.gz: de63cbabb66ebaa15dfbb78d4aa5cb14384fb8414c15cd13f4a5e1665c3e8a28d66bfef4e7014acb51c486024efcc1e545f04d10172b228716e27050ac1854ff
7
+ data.tar.gz: 9b74f16efebe20b184b0ba277d588e20403cb3633256868b03b8d9bcf608f846bb266283283bbf7316e65fb0de5c065d69e20ab3f5fd5c7c597ecf74b0f1bc57
data/CHANGELOG.md ADDED
@@ -0,0 +1,4 @@
1
+ # Changelog
2
+
3
+ Sevgi Sundries follows the root Sevgi release notes:
4
+ https://github.com/roktas/sevgi/blob/main/CHANGELOG.md
data/LICENSE ADDED
@@ -0,0 +1,5 @@
1
+ Sevgi Sundries is distributed under the GNU General Public License v3.0 or later.
2
+
3
+ SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ Full project license: https://github.com/roktas/sevgi/blob/main/LICENSE
data/README.md CHANGED
@@ -1,5 +1,55 @@
1
1
  # Sevgi Sundries
2
2
 
3
- Provides helper objects and export tools for Sevgi drawings.
3
+ Helper objects and export tools for Sevgi drawings.
4
4
 
5
- See the root [README](../README.md) and the documentation site for usage.
5
+ ## Install
6
+
7
+ ```sh
8
+ gem install sevgi-sundries
9
+ ```
10
+
11
+ ## Require
12
+
13
+ ```ruby
14
+ require "sevgi/sundries"
15
+ ```
16
+
17
+ ## Example
18
+
19
+ ```ruby
20
+ rect = Sevgi::Geometry::Rect[3, 5]
21
+ tile = Sevgi::Sundries::Tile.new(rect)
22
+ tile.box.height
23
+ ```
24
+
25
+ ## Ruby compatibility
26
+
27
+ Requires Ruby 3.4.0 or newer. CI verifies Ruby 3.4 and the current development Ruby from `.ruby-version`.
28
+
29
+ ## Native prerequisites
30
+
31
+ Basic ruler, grid, and tile helpers need only Ruby dependencies. Installing `sevgi-sundries` does not install native
32
+ export gems.
33
+
34
+ PDF/PNG export helpers load the optional Ruby gems `cairo`, `rsvg2`, and `hexapdf` only when export is used. Install
35
+ their system libraries and gems separately:
36
+
37
+ ```sh
38
+ sudo apt-get update
39
+ sudo apt-get install -y libcairo2-dev libgdk-pixbuf-2.0-dev libgirepository1.0-dev libglib2.0-dev librsvg2-dev pkg-config
40
+ gem install cairo rsvg2 hexapdf
41
+ ```
42
+
43
+ On macOS with Homebrew:
44
+
45
+ ```sh
46
+ brew install cairo gdk-pixbuf gobject-introspection librsvg pkg-config
47
+ gem install cairo rsvg2 hexapdf
48
+ ```
49
+
50
+ ## Links
51
+
52
+ - Documentation: https://sevgi.roktas.dev
53
+ - API documentation: https://www.rubydoc.info/gems/sevgi-sundries
54
+ - Source: https://github.com/roktas/sevgi/tree/main/sundries
55
+ - Changelog: https://github.com/roktas/sevgi/blob/main/CHANGELOG.md
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../export"
4
+
3
5
  require "cairo"
4
6
  require "fileutils"
5
7
  require "hexapdf"
@@ -8,13 +10,12 @@ require "tempfile"
8
10
 
9
11
  module Sevgi
10
12
  module Sundries
11
- # Exports SVG content and post-processes PDF output.
12
13
  module Export
13
- # Raised when SVG export or PDF post-processing cannot be completed.
14
- ExportError = Class.new(Error)
15
-
16
- # Default SVG CSS pixel density.
17
- DEFAULT_DPI = 96.0
14
+ %i[
15
+ call
16
+ stamp
17
+ stamp!
18
+ ].each { remove_method(it) if method_defined?(it) }
18
19
 
19
20
  # Exports SVG source to a PDF or PNG file using librsvg and Cairo.
20
21
  # @param svg [String] SVG source content
@@ -30,7 +31,7 @@ module Sevgi
30
31
  # @return [Object] the original output argument
31
32
  # @raise [Sevgi::ArgumentError] when SVG content is not a string or output is blank
32
33
  # @raise [Sevgi::Sundries::Export::ExportError] when format, SVG parsing, SVG dimensions, or render dimensions are invalid
33
- def self.call(svg, output, format: nil, width: nil, height: nil, dpi: DEFAULT_DPI, css: nil, &block)
34
+ def call(svg, output, format: nil, width: nil, height: nil, dpi: DEFAULT_DPI, css: nil, &block)
34
35
  ArgumentError.("SVG content must be a String") unless svg.is_a?(String)
35
36
  ArgumentError.("Export output must be provided") if output.nil? || output.to_s.strip.empty?
36
37
 
@@ -71,81 +72,49 @@ module Sevgi
71
72
  output
72
73
  end
73
74
 
74
- # Supported export format names mapped to file extensions.
75
- AVAILABLE = (EXTENSIONS = {
76
- ".pdf" => :pdf,
77
- ".png" => :png
78
- }.freeze)
79
- .invert
80
- .freeze
81
-
82
- # Resolves the export format from an explicit value or output extension.
83
- # @param format [Symbol, String, nil] explicit format
84
- # @param output [String, #to_s] output path
85
- # @return [Symbol] resolved format
86
- # @raise [Sevgi::Sundries::Export::ExportError] when the explicit format or output extension is unsupported
87
- def format_for!(format, output)
88
- if format
89
- format = format.to_sym
90
- ExportError.("Unsupported export format: #{format}") unless AVAILABLE.key?(format)
91
-
92
- format
93
- else
94
- ext = File.extname(output.to_s).downcase
95
- ExportError.("Unrecognized file extension: #{ext}") unless EXTENSIONS.key?(ext)
96
-
97
- EXTENSIONS[ext]
98
- end
99
- end
100
-
101
- # Inserts CSS before the closing svg tag.
102
- # @param svg [String] SVG source content
103
- # @param css [String] CSS source content
104
- # @return [String] SVG source with an added style element when a closing svg tag is present
105
- def inject(svg, css) = svg.sub("</svg>", "<style>#{css}</style></svg>")
106
-
107
- # Replaces a placeholder text object in a PDF stream.
75
+ # Replaces exact placeholder text objects in PDF streams.
76
+ # The placeholder must appear as a PDF literal string in a white text object matching the export stamp pattern.
77
+ # Replacement text is escaped as a PDF literal string. When no exact match is replaced, the output file is not
78
+ # written.
108
79
  # @param infile [String] source PDF file path
109
80
  # @param outfile [String] destination PDF file path
110
81
  # @param stamp [String] replacement text
111
82
  # @param placeholder [String] placeholder text to replace
112
- # @return [Boolean] true when a matching placeholder was replaced
113
- # @raise [Sevgi::Sundries::Export::ExportError] when the PDF files cannot be read, written, or stamped
83
+ # @return [Boolean] true when at least one matching placeholder was replaced
84
+ # @raise [Sevgi::Sundries::Export::ExportError] when the PDF cannot be read, rewritten, or stamped
114
85
  def stamp(infile, outfile, stamp:, placeholder:)
115
86
  doc = HexaPDF::Document.open(infile)
116
- stamped = false
87
+ replacements = 0
117
88
 
118
89
  doc.pages.each do |page|
119
90
  Array(page[:Contents]).each do |ref|
120
91
  obj = doc.object(ref)
121
92
  next unless obj.respond_to?(:stream)
122
93
 
123
- data = obj.stream
124
- next unless data.include?("(#{placeholder})")
94
+ data, count = stamp_stream(obj.stream, stamp:, placeholder:)
95
+ next if count.zero?
125
96
 
126
- data = data.gsub(
127
- %r{1 1 1 rg (BT\s+.*?/\S+ \d+ Tf\s+)\(#{Regexp.escape(placeholder)}\)Tj}m,
128
- "0.101961 0.101961 0.101961 rg \\1(#{stamp})Tj"
129
- )
97
+ replacements += count
130
98
 
131
99
  obj.stream = data
132
100
  obj.set_filter(:FlateDecode)
133
- stamped = true
134
101
  end
135
102
  end
136
103
 
137
- doc.write(outfile, optimize: true) if stamped
138
- stamped
104
+ doc.write(outfile, optimize: true) if replacements.positive?
105
+ replacements.positive?
139
106
  rescue HexaPDF::Error, ::SystemCallError => e
140
107
  ExportError.("PDF stamp error: #{e.message}")
141
108
  end
142
109
 
143
- # Replaces a placeholder text object inside a PDF file in place.
110
+ # Replaces exact placeholder text objects inside a PDF file in place.
111
+ # The input file is replaced only after at least one exact placeholder match is rewritten into a non-empty output
112
+ # file.
144
113
  # @param infile [String] PDF file path to modify
145
114
  # @param stamp [String] replacement text
146
115
  # @param placeholder [String] placeholder text to replace
147
- # @return [Boolean] true when a matching placeholder was replaced
148
- # @raise [Sevgi::Sundries::Export::ExportError] when the PDF file cannot be read, written, stamped, or replaced
116
+ # @return [Boolean] true when at least one matching placeholder was replaced
117
+ # @raise [Sevgi::Sundries::Export::ExportError] when the PDF cannot be read, rewritten, stamped, or replaced
149
118
  def stamp!(infile, stamp:, placeholder:)
150
119
  temp = Tempfile.new(%w[stamp .pdf], File.dirname(infile))
151
120
  stamped = stamp(infile, temp.path, stamp:, placeholder:)
@@ -234,6 +203,34 @@ module Sevgi
234
203
 
235
204
  width.round.positive? && height.round.positive?
236
205
  end
206
+
207
+ PDF_LITERAL_ESCAPE = {
208
+ "\\" => "\\\\",
209
+ "(" => "\\(",
210
+ ")" => "\\)",
211
+ "\b" => "\\b",
212
+ "\f" => "\\f",
213
+ "\n" => "\\n",
214
+ "\r" => "\\r",
215
+ "\t" => "\\t"
216
+ }.freeze
217
+
218
+ private_constant :PDF_LITERAL_ESCAPE
219
+
220
+ def pdf_literal(text)
221
+ "(#{text.to_s.each_char.map { PDF_LITERAL_ESCAPE.fetch(it, it) }.join})"
222
+ end
223
+
224
+ def stamp_stream(data, stamp:, placeholder:)
225
+ count = 0
226
+ pattern = %r{1 1 1 rg (BT\s+.*?/\S+ \d+ Tf\s+)#{Regexp.escape(pdf_literal(placeholder))}Tj}m
227
+ stamped = data.gsub(pattern) do
228
+ count += 1
229
+ "0.101961 0.101961 0.101961 rg #{Regexp.last_match(1)}#{pdf_literal(stamp)}Tj"
230
+ end
231
+
232
+ [stamped, count]
233
+ end
237
234
  end
238
235
 
239
236
  # Low-level format renderers used by {Export.call}.
@@ -1,4 +1,118 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "export/native"
3
+ require "sevgi/function"
4
+
4
5
  require_relative "export/system"
6
+
7
+ module Sevgi
8
+ module Sundries
9
+ # Exports SVG content and post-processes PDF output.
10
+ #
11
+ # Native PDF/PNG rendering is loaded lazily so installing `sevgi-sundries` for SVG-only helpers does not require the
12
+ # Cairo, RSVG, or HexaPDF gems. Native export entrypoints raise {Sevgi::MissingComponentError} when those optional
13
+ # gems are unavailable.
14
+ module Export
15
+ # Supported export format names mapped to file extensions.
16
+ AVAILABLE = (EXTENSIONS = {
17
+ ".pdf" => :pdf,
18
+ ".png" => :png
19
+ }.freeze)
20
+ .invert
21
+ .freeze
22
+
23
+ # Default SVG CSS pixel density.
24
+ DEFAULT_DPI = 96.0
25
+
26
+ # Raised when SVG export or PDF post-processing cannot be completed.
27
+ ExportError = Class.new(Error)
28
+
29
+ NATIVE_COMPONENTS = %w[
30
+ cairo
31
+ hexapdf
32
+ rsvg2
33
+ ].freeze
34
+
35
+ private_constant :NATIVE_COMPONENTS
36
+
37
+ # @overload call(svg, output, format: nil, width: nil, height: nil, dpi: DEFAULT_DPI, css: nil)
38
+ # Exports SVG source to a PDF or PNG file using the optional native export gems.
39
+ # @param svg [String] SVG source content
40
+ # @param output [String, #to_s] output file path
41
+ # @param format [Symbol, String, nil] explicit output format, or nil to infer from output extension
42
+ # @param width [Numeric, nil] target width in output pixels for PNG, or CSS pixels before PDF point conversion
43
+ # @param height [Numeric, nil] target height in output pixels for PNG, or CSS pixels before PDF point conversion
44
+ # @param dpi [Numeric] CSS pixel density used for absolute SVG units and PDF point conversion
45
+ # @param css [String, nil] CSS inserted before the closing svg tag before rendering
46
+ # @yield [svg] optional source transformation applied before rendering
47
+ # @yieldparam svg [String] SVG source after optional CSS injection
48
+ # @yieldreturn [String] SVG source to render
49
+ # @return [Object] the original output argument
50
+ # @raise [Sevgi::ArgumentError] when SVG content is not a string or output is blank
51
+ # @raise [Sevgi::MissingComponentError] when cairo, hexapdf, or rsvg2 is unavailable
52
+ # @raise [Sevgi::Sundries::Export::ExportError] when format, SVG parsing, SVG dimensions, or render dimensions
53
+ # are invalid
54
+ def call(*args, **kwargs, &block) = native!.call(*args, **kwargs, &block)
55
+
56
+ # Resolves the export format from an explicit value or output extension.
57
+ # @param format [Symbol, String, nil] explicit format
58
+ # @param output [String, #to_s] output path
59
+ # @return [Symbol] resolved format
60
+ # @raise [Sevgi::Sundries::Export::ExportError] when the explicit format or output extension is unsupported
61
+ def format_for!(format, output)
62
+ if format
63
+ format = format.to_sym
64
+ ExportError.("Unsupported export format: #{format}") unless AVAILABLE.key?(format)
65
+
66
+ format
67
+ else
68
+ ext = File.extname(output.to_s).downcase
69
+ ExportError.("Unrecognized file extension: #{ext}") unless EXTENSIONS.key?(ext)
70
+
71
+ EXTENSIONS[ext]
72
+ end
73
+ end
74
+
75
+ # Inserts CSS before the closing svg tag.
76
+ # @param svg [String] SVG source content
77
+ # @param css [String] CSS source content
78
+ # @return [String] SVG source with an added style element when a closing svg tag is present
79
+ # @raise [NoMethodError] when SVG content does not support string substitution
80
+ def inject(svg, css) = svg.sub("</svg>", "<style>#{css}</style></svg>")
81
+
82
+ # Replaces exact placeholder text objects in PDF streams.
83
+ # @param infile [String] source PDF file path
84
+ # @param outfile [String] destination PDF file path
85
+ # @param stamp [String] replacement text
86
+ # @param placeholder [String] placeholder text to replace
87
+ # @return [Boolean] true when at least one matching placeholder was replaced
88
+ # @raise [Sevgi::MissingComponentError] when native export gems are unavailable
89
+ # @raise [Sevgi::Sundries::Export::ExportError] when the PDF cannot be read, rewritten, or stamped
90
+ def stamp(infile, outfile, stamp:, placeholder:) = native!.stamp(infile, outfile, stamp:, placeholder:)
91
+
92
+ # Replaces exact placeholder text objects inside a PDF file in place.
93
+ # @param infile [String] PDF file path to modify
94
+ # @param stamp [String] replacement text
95
+ # @param placeholder [String] placeholder text to replace
96
+ # @return [Boolean] true when at least one matching placeholder was replaced
97
+ # @raise [Sevgi::MissingComponentError] when native export gems are unavailable
98
+ # @raise [Sevgi::Sundries::Export::ExportError] when the PDF cannot be read, rewritten, stamped, or replaced
99
+ def stamp!(infile, stamp:, placeholder:) = native!.stamp!(infile, stamp:, placeholder:)
100
+
101
+ extend self
102
+
103
+ class << self
104
+ private
105
+
106
+ def native!
107
+ require_relative "export/native"
108
+
109
+ self
110
+ rescue ::LoadError => e
111
+ raise unless NATIVE_COMPONENTS.include?(e.path)
112
+
113
+ MissingComponentError.(NATIVE_COMPONENTS.join(", "))
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -51,7 +51,6 @@ module Sevgi
51
51
  def width = x.d
52
52
 
53
53
  # Axis wrapper exposing grid line queries for one ruler direction.
54
- # @api private
55
54
  class Axis < DelegateClass(Ruler)
56
55
  # Returns the major line query.
57
56
  # @return [Sevgi::Sundries::Grid::Axis::Major]
@@ -78,7 +77,6 @@ module Sevgi
78
77
  end
79
78
 
80
79
  # Memoized grid line query for an axis.
81
- # @api private
82
80
  class Query
83
81
  # Creates a query.
84
82
  # @param this [Sevgi::Sundries::Grid::Axis] axis receiving generated lines
@@ -104,7 +102,6 @@ module Sevgi
104
102
  end
105
103
 
106
104
  # Major grid line query.
107
- # @api private
108
105
  class Major < Query
109
106
  # Returns lines at major tick distances.
110
107
  # @return [Array<Sevgi::Geometry::Line>]
@@ -112,7 +109,6 @@ module Sevgi
112
109
  end
113
110
 
114
111
  # Midpoint grid line query.
115
- # @api private
116
112
  class Halve < Query
117
113
  # Returns lines at midpoint tick distances.
118
114
  # @return [Array<Sevgi::Geometry::Line>]
@@ -120,7 +116,6 @@ module Sevgi
120
116
  end
121
117
 
122
118
  # Minor grid line query.
123
- # @api private
124
119
  class Minor < Query
125
120
  # Returns lines at minor tick distances.
126
121
  # @return [Array<Sevgi::Geometry::Line>]
@@ -129,7 +124,6 @@ module Sevgi
129
124
  end
130
125
 
131
126
  # Horizontal grid axis.
132
- # @api private
133
127
  class X < Axis
134
128
  # Returns the base horizontal line for this axis.
135
129
  # @return [Sevgi::Geometry::Line]
@@ -142,7 +136,6 @@ module Sevgi
142
136
  end
143
137
 
144
138
  # Vertical grid axis.
145
- # @api private
146
139
  class Y < Axis
147
140
  # Creates a vertical axis wrapper.
148
141
  # @param this [Sevgi::Sundries::Ruler] horizontal ruler
@@ -20,7 +20,10 @@ module Sevgi
20
20
  # @param n [Integer] non-negative interval count
21
21
  # @return [Sevgi::Sundries::Interval]
22
22
  # @raise [Sevgi::ArgumentError] when count is not a non-negative integer
23
- # @raise [Sevgi::ArgumentError] when the unit length cannot be measured
23
+ # @raise [Sevgi::ArgumentError] when the unit object does not expose length
24
+ # @raise [Sevgi::ArgumentError] when the measured unit length is not numeric
25
+ # @raise [Sevgi::ArgumentError] when the measured unit length is not finite
26
+ # @raise [Sevgi::ArgumentError] when the measured unit length is not positive
24
27
  def self.[](e, n) = new(e, n)
25
28
 
26
29
  # @!attribute [r] n
@@ -34,12 +37,13 @@ module Sevgi
34
37
  # @param n [Integer] non-negative interval count
35
38
  # @return [void]
36
39
  # @raise [Sevgi::ArgumentError] when count is not a non-negative integer
37
- # @raise [Sevgi::ArgumentError] when the unit length cannot be measured
40
+ # @raise [Sevgi::ArgumentError] when the unit object does not expose length
41
+ # @raise [Sevgi::ArgumentError] when the measured unit length is not numeric
42
+ # @raise [Sevgi::ArgumentError] when the measured unit length is not finite
43
+ # @raise [Sevgi::ArgumentError] when the measured unit length is not positive
38
44
  def initialize(e, n)
39
- ArgumentError.("Interval count must be a non-negative Integer") unless n.is_a?(::Integer) && !n.negative?
40
-
45
+ @n = non_negative_integer(n, "Interval count")
41
46
  @u = measure(e)
42
- @n = n
43
47
  end
44
48
 
45
49
  # Returns a major tick distance by index.
@@ -50,7 +54,10 @@ module Sevgi
50
54
  # Counts how many whole lengths fit into this interval.
51
55
  # @param length [Numeric] candidate length
52
56
  # @return [Integer]
53
- def count(length) = (d / length.to_f).to_i
57
+ # @raise [Sevgi::ArgumentError] when length is not numeric
58
+ # @raise [Sevgi::ArgumentError] when length is not finite
59
+ # @raise [Sevgi::ArgumentError] when length is not positive
60
+ def count(length) = (d / positive_number(length, "Interval count length")).to_i
54
61
 
55
62
  # Returns the total interval distance.
56
63
  # @return [Float]
@@ -82,11 +89,50 @@ module Sevgi
82
89
  private
83
90
 
84
91
  def measure(e)
85
- return e.to_f if e.is_a?(::Numeric)
92
+ value = if e.is_a?(::Numeric)
93
+ e
94
+ else
95
+ ArgumentError.("#{e.class}#length must be implemented") unless e.respond_to?(:length)
96
+
97
+ e.length
98
+ end
99
+
100
+ positive_number(value, "Interval unit length")
101
+ end
102
+
103
+ def non_negative_integer(value, field)
104
+ ArgumentError.("#{field} must be a non-negative Integer") unless value.is_a?(::Integer) && !value.negative?
105
+
106
+ value
107
+ end
108
+
109
+ def non_negative_number(value, field)
110
+ number = numeric(value, field)
111
+ ArgumentError.("#{field} must be non-negative") if number.negative?
112
+
113
+ number
114
+ end
115
+
116
+ def numeric(value, field)
117
+ ArgumentError.("#{field} must be Numeric") unless value.is_a?(::Numeric)
118
+
119
+ number = value.to_f
120
+ ArgumentError.("#{field} must be finite") unless number.finite?
121
+
122
+ number
123
+ end
86
124
 
87
- ArgumentError.("#{e.class}#length must be implemented") unless e.respond_to?(:length)
125
+ def positive_integer(value, field)
126
+ ArgumentError.("#{field} must be a positive Integer") unless value.is_a?(::Integer) && value.positive?
88
127
 
89
- e.length
128
+ value
129
+ end
130
+
131
+ def positive_number(value, field)
132
+ number = numeric(value, field)
133
+ ArgumentError.("#{field} must be positive") unless number.positive?
134
+
135
+ number
90
136
  end
91
137
  end
92
138
 
@@ -116,16 +162,22 @@ module Sevgi
116
162
  # @param multiple [Integer] number of subinterval units per major interval
117
163
  # @param margin [Numeric] minimum margin on each side
118
164
  # @return [void]
119
- # @raise [Sevgi::ArgumentError] when unit or multiple is not positive
165
+ # @raise [Sevgi::ArgumentError] when brut is not numeric
166
+ # @raise [Sevgi::ArgumentError] when brut is not finite
167
+ # @raise [Sevgi::ArgumentError] when brut is negative
168
+ # @raise [Sevgi::ArgumentError] when unit is not numeric
169
+ # @raise [Sevgi::ArgumentError] when unit is not finite
170
+ # @raise [Sevgi::ArgumentError] when unit is not positive
171
+ # @raise [Sevgi::ArgumentError] when multiple is not a positive integer
172
+ # @raise [Sevgi::ArgumentError] when margin is not numeric
173
+ # @raise [Sevgi::ArgumentError] when margin is not finite
120
174
  # @raise [Sevgi::ArgumentError] when margin is negative
121
175
  # @raise [Sevgi::ArgumentError] when the fitting span is negative
122
176
  def initialize(brut:, unit:, multiple:, margin: 0.0)
123
- ArgumentError.("Ruler unit must be positive") unless unit.to_f.positive?
124
- ArgumentError.("Ruler multiple must be positive") unless multiple.to_f.positive?
125
- ArgumentError.("Ruler margin must be non-negative") if margin.to_f.negative?
126
-
127
- @brut = brut.to_f
128
- margin = margin.to_f
177
+ @brut = non_negative_number(brut, "Ruler brut")
178
+ unit = positive_number(unit, "Ruler unit")
179
+ multiple = positive_integer(multiple, "Ruler multiple")
180
+ margin = non_negative_number(margin, "Ruler margin")
129
181
  @sub = Interval.new(unit, multiple)
130
182
 
131
183
  n = divide(unit:, multiple:, brut: @brut, margin:)
@@ -24,13 +24,14 @@ module Sevgi
24
24
  # @return [void]
25
25
  # @raise [Sevgi::ArgumentError] when element is not a geometry element
26
26
  # @raise [Sevgi::ArgumentError] when nx or ny is not a positive integer
27
+ # @raise [Sevgi::ArgumentError] when position is not a point or two-number array
27
28
  def initialize(element, position: Geometry::Origin, nx: 1, ny: 1)
28
29
  ArgumentError.("Must be an Element object: #{element}") unless element.is_a?(Geometry::Element)
29
30
  ArgumentError.("Tile nx must be positive") unless nx.is_a?(::Integer) && nx.positive?
30
31
  ArgumentError.("Tile ny must be positive") unless ny.is_a?(::Integer) && ny.positive?
31
32
 
32
33
  @element = element
33
- @position = position
34
+ @position = self.class.send(:position, position)
34
35
 
35
36
  @nx = nx
36
37
  @ny = ny
@@ -97,6 +98,22 @@ module Sevgi
97
98
 
98
99
  private
99
100
 
101
+ # Coerces a public tile position.
102
+ # @param position [Sevgi::Geometry::Point, Array<Numeric>] tile origin
103
+ # @return [Sevgi::Geometry::Point]
104
+ # @raise [Sevgi::ArgumentError] when position is not a point or two-number array
105
+ def self.position(position)
106
+ return position if position.is_a?(Geometry::Point)
107
+
108
+ unless position.is_a?(::Array) && position.size == 2 && position.all?(::Numeric)
109
+ ArgumentError.("Tile position must be a Point or two-number Array")
110
+ end
111
+
112
+ Geometry::Point[*position]
113
+ end
114
+
115
+ private_class_method :position
116
+
100
117
  def coordinate(i, j = 0) = position.translate(j * element.box.width, i * element.box.height)
101
118
  end
102
119
  end
@@ -3,6 +3,6 @@
3
3
  module Sevgi
4
4
  module Sundries
5
5
  # Component version.
6
- VERSION = "0.93.1"
6
+ VERSION = "0.94.0"
7
7
  end
8
8
  end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "sevgi/function"
4
+ require "sevgi/geometry"
5
+ require "sevgi/graphics"
4
6
 
5
7
  require_relative "sundries/ruler"
6
8
  require_relative "sundries/tile"
@@ -12,6 +14,10 @@ require_relative "sundries/version"
12
14
 
13
15
  module Sevgi
14
16
  # Layout, tiling, grid, and export helpers shared by Sevgi consumers.
17
+ #
18
+ # This component loads its eager public surfaces directly. `Ruler`, `Tile`, and `Grid` therefore require
19
+ # `sevgi-function`, `sevgi-geometry`, and `sevgi-graphics` as runtime dependencies of the `sevgi-sundries` gem.
20
+ # Native PDF/PNG export gems are optional and loaded only by {Export} when export is used.
15
21
  module Sundries
16
22
  end
17
23
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sevgi-sundries
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.93.1
4
+ version: 0.94.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Recai Oktaş
@@ -15,62 +15,50 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.93.1
18
+ version: 0.94.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - '='
24
24
  - !ruby/object:Gem::Version
25
- version: 0.93.1
25
+ version: 0.94.0
26
26
  - !ruby/object:Gem::Dependency
27
- name: cairo
27
+ name: sevgi-geometry
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
- - - ">="
31
- - !ruby/object:Gem::Version
32
- version: '0'
33
- type: :runtime
34
- prerelease: false
35
- version_requirements: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - ">="
38
- - !ruby/object:Gem::Version
39
- version: '0'
40
- - !ruby/object:Gem::Dependency
41
- name: hexapdf
42
- requirement: !ruby/object:Gem::Requirement
43
- requirements:
44
- - - "~>"
30
+ - - '='
45
31
  - !ruby/object:Gem::Version
46
- version: '1.5'
32
+ version: 0.94.0
47
33
  type: :runtime
48
34
  prerelease: false
49
35
  version_requirements: !ruby/object:Gem::Requirement
50
36
  requirements:
51
- - - "~>"
37
+ - - '='
52
38
  - !ruby/object:Gem::Version
53
- version: '1.5'
39
+ version: 0.94.0
54
40
  - !ruby/object:Gem::Dependency
55
- name: rsvg2
41
+ name: sevgi-graphics
56
42
  requirement: !ruby/object:Gem::Requirement
57
43
  requirements:
58
- - - ">="
44
+ - - '='
59
45
  - !ruby/object:Gem::Version
60
- version: 4.3.6
46
+ version: 0.94.0
61
47
  type: :runtime
62
48
  prerelease: false
63
49
  version_requirements: !ruby/object:Gem::Requirement
64
50
  requirements:
65
- - - ">="
51
+ - - '='
66
52
  - !ruby/object:Gem::Version
67
- version: 4.3.6
53
+ version: 0.94.0
68
54
  description: Enhances the Sevgi toolkit with helper objects.
69
55
  email: roktas@gmail.com
70
56
  executables: []
71
57
  extensions: []
72
58
  extra_rdoc_files: []
73
59
  files:
60
+ - CHANGELOG.md
61
+ - LICENSE
74
62
  - README.md
75
63
  - lib/sevgi/sundries.rb
76
64
  - lib/sevgi/sundries/export.rb
@@ -95,7 +83,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
95
83
  requirements:
96
84
  - - ">="
97
85
  - !ruby/object:Gem::Version
98
- version: 3.4.0.pre.preview1
86
+ version: 3.4.0
99
87
  required_rubygems_version: !ruby/object:Gem::Requirement
100
88
  requirements:
101
89
  - - ">="