sevgi-sundries 0.73.2 → 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: d304fd508f93b48a0e255d7e87d5e7477ed8a4ba5f132af7e0644c5b70055fa9
4
- data.tar.gz: bddf4a00ae89d3f726cd1961815c89da921157f43eff7d57eaca13c5da25e049
3
+ metadata.gz: e8ddc5568e2938adbef615c7bc12663083287016955cd708bd342ec95423bfea
4
+ data.tar.gz: 64b06db206dcf954131c8348e0e1871a39c80677f83a15fc683cc84ab943d2aa
5
5
  SHA512:
6
- metadata.gz: 5ef78b3c182dae1ff9c45659e7b1c94d0b7e29f36ed52d092b5d05c779f8e9ccf51c302e354131ccfc5f379d85e6e611beed1b8639a34ad1897fcb6f2b32a66d
7
- data.tar.gz: d8ca3086912c399b09d25fb2b65fd30ad1782d77caa586148de52d20ff3029898c01eba42f7834e8531a0c7e575a8fcf7ddba05a6a5c3c762509870792d183de
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
@@ -0,0 +1,55 @@
1
+ # Sevgi Sundries
2
+
3
+ Helper objects and export tools for Sevgi drawings.
4
+
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"
@@ -9,31 +11,51 @@ require "tempfile"
9
11
  module Sevgi
10
12
  module Sundries
11
13
  module Export
12
- ExportError = Class.new(Error)
13
-
14
- DEFAULT_DPI = 96.0
15
-
16
- def self.call(svg, output, format: nil, width: nil, height: nil, dpi: DEFAULT_DPI, css: nil, &block)
14
+ %i[
15
+ call
16
+ stamp
17
+ stamp!
18
+ ].each { remove_method(it) if method_defined?(it) }
19
+
20
+ # Exports SVG source to a PDF or PNG file using librsvg and Cairo.
21
+ # @param svg [String] SVG source content
22
+ # @param output [String, #to_s] output file path
23
+ # @param format [Symbol, String, nil] explicit output format, or nil to infer from output extension
24
+ # @param width [Numeric, nil] target width in output pixels for PNG, or CSS pixels before PDF point conversion
25
+ # @param height [Numeric, nil] target height in output pixels for PNG, or CSS pixels before PDF point conversion
26
+ # @param dpi [Numeric] CSS pixel density used for absolute SVG units and PDF point conversion
27
+ # @param css [String, nil] CSS inserted before the closing svg tag before rendering
28
+ # @yield [svg] optional source transformation applied before rendering
29
+ # @yieldparam svg [String] SVG source after optional CSS injection
30
+ # @yieldreturn [String] SVG source to render
31
+ # @return [Object] the original output argument
32
+ # @raise [Sevgi::ArgumentError] when SVG content is not a string or output is blank
33
+ # @raise [Sevgi::Sundries::Export::ExportError] when format, SVG parsing, SVG dimensions, or render dimensions are invalid
34
+ def call(svg, output, format: nil, width: nil, height: nil, dpi: DEFAULT_DPI, css: nil, &block)
17
35
  ArgumentError.("SVG content must be a String") unless svg.is_a?(String)
18
36
  ArgumentError.("Export output must be provided") if output.nil? || output.to_s.strip.empty?
19
37
 
20
38
  svg = inject(svg, css) if css && !css.strip.empty?
21
39
  svg = block.call(svg) if block
40
+ ArgumentError.("SVG content must be a String") unless svg.is_a?(String)
22
41
 
23
- renderer = Renderer.method(format_for!(format, output))
24
- handle = Rsvg::Handle.new_from_data(svg)
42
+ format = format_for!(format, output)
43
+ renderer = Renderer.method(format)
25
44
 
26
- iw, ih = intrinsic_size(handle)
27
- ExportError.("Invalid SVG dimensions") if iw <= 0 || ih <= 0
45
+ begin
46
+ handle = Rsvg::Handle.new_from_data(svg)
28
47
 
29
- scale = dpi / DEFAULT_DPI
48
+ iw, ih = intrinsic_size(handle)
49
+ ExportError.("Invalid SVG dimensions") if iw <= 0 || ih <= 0
30
50
 
31
- iw *= scale
32
- ih *= scale
51
+ scale = dpi / DEFAULT_DPI
33
52
 
34
- tw, th = target_size(iw, ih, width, height)
53
+ iw *= scale
54
+ ih *= scale
55
+
56
+ tw, th = target_size(iw, ih, width, height)
57
+ ExportError.("Invalid export dimensions") unless target_size?(format, tw, th)
35
58
 
36
- begin
37
59
  renderer.call(
38
60
  handle: handle,
39
61
  output: output.to_s,
@@ -50,56 +72,49 @@ module Sevgi
50
72
  output
51
73
  end
52
74
 
53
- AVAILABLE = (EXTENSIONS = {
54
- ".pdf" => :pdf,
55
- ".png" => :png
56
- }.freeze)
57
- .invert
58
- .freeze
59
-
60
- def format_for!(format, output)
61
- if format
62
- format = format.to_sym
63
- ExportError.("Unsupported export format: #{format}") unless AVAILABLE.key?(format)
64
-
65
- format
66
- else
67
- ext = File.extname(output.to_s).downcase
68
- ExportError.("Unrecognized file extension: #{ext}") unless EXTENSIONS.key?(ext)
69
-
70
- EXTENSIONS[ext]
71
- end
72
- end
73
-
74
- def inject(svg, css) = svg.sub("</svg>", "<style>#{css}</style></svg>")
75
-
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.
79
+ # @param infile [String] source PDF file path
80
+ # @param outfile [String] destination PDF file path
81
+ # @param stamp [String] replacement text
82
+ # @param placeholder [String] placeholder text to replace
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
76
85
  def stamp(infile, outfile, stamp:, placeholder:)
77
86
  doc = HexaPDF::Document.open(infile)
78
- stamped = false
87
+ replacements = 0
79
88
 
80
89
  doc.pages.each do |page|
81
90
  Array(page[:Contents]).each do |ref|
82
91
  obj = doc.object(ref)
83
92
  next unless obj.respond_to?(:stream)
84
93
 
85
- data = obj.stream
86
- next unless data.include?("(#{placeholder})")
94
+ data, count = stamp_stream(obj.stream, stamp:, placeholder:)
95
+ next if count.zero?
87
96
 
88
- data = data.gsub(
89
- %r{1 1 1 rg (BT\s+.*?/\S+ \d+ Tf\s+)\(#{Regexp.escape(placeholder)}\)Tj}m,
90
- "0.101961 0.101961 0.101961 rg \\1(#{stamp})Tj"
91
- )
97
+ replacements += count
92
98
 
93
99
  obj.stream = data
94
100
  obj.set_filter(:FlateDecode)
95
- stamped = true
96
101
  end
97
102
  end
98
103
 
99
- doc.write(outfile, optimize: true) if stamped
100
- stamped
104
+ doc.write(outfile, optimize: true) if replacements.positive?
105
+ replacements.positive?
106
+ rescue HexaPDF::Error, ::SystemCallError => e
107
+ ExportError.("PDF stamp error: #{e.message}")
101
108
  end
102
109
 
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.
113
+ # @param infile [String] PDF file path to modify
114
+ # @param stamp [String] replacement text
115
+ # @param placeholder [String] placeholder text to replace
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
103
118
  def stamp!(infile, stamp:, placeholder:)
104
119
  temp = Tempfile.new(%w[stamp .pdf], File.dirname(infile))
105
120
  stamped = stamp(infile, temp.path, stamp:, placeholder:)
@@ -112,6 +127,8 @@ module Sevgi
112
127
  end
113
128
 
114
129
  stamped
130
+ rescue ::SystemCallError => e
131
+ ExportError.("PDF stamp error: #{e.message}")
115
132
  ensure
116
133
  temp&.close!
117
134
  end
@@ -178,11 +195,52 @@ module Sevgi
178
195
  [iw, ih]
179
196
  end
180
197
  end
198
+
199
+ def target_size?(format, width, height)
200
+ return false if width <= 0 || height <= 0
201
+ return false unless width.finite? && height.finite?
202
+ return true unless format == :png
203
+
204
+ width.round.positive? && height.round.positive?
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
181
234
  end
182
235
 
236
+ # Low-level format renderers used by {Export.call}.
237
+ # @api private
183
238
  module Renderer
184
239
  extend self
185
240
 
241
+ # Returns a renderer method for a format.
242
+ # @param format [Symbol, String, nil] format name
243
+ # @return [Method, nil]
186
244
  def [](format)
187
245
  case format&.to_sym
188
246
  when :png
@@ -192,6 +250,15 @@ module Sevgi
192
250
  end
193
251
  end
194
252
 
253
+ # Renders SVG data to a PDF surface.
254
+ # @param handle [Rsvg::Handle] parsed SVG handle
255
+ # @param output [String] output file path
256
+ # @param tw [Numeric] target width in CSS pixels
257
+ # @param th [Numeric] target height in CSS pixels
258
+ # @param dpi [Numeric] CSS pixel density
259
+ # @return [void]
260
+ # @raise [Cairo::Error] when Cairo cannot write the PDF surface
261
+ # @raise [Rsvg::Error] when librsvg cannot render the document
195
262
  def pdf(handle:, output:, tw:, th:, dpi:, **)
196
263
  pw, ph = tw * (72.0 / dpi), th * (72.0 / dpi)
197
264
  surface = Cairo::PDFSurface.new(output, pw, ph)
@@ -202,6 +269,14 @@ module Sevgi
202
269
  surface.finish
203
270
  end
204
271
 
272
+ # Renders SVG data to a PNG image.
273
+ # @param handle [Rsvg::Handle] parsed SVG handle
274
+ # @param output [String] output file path
275
+ # @param tw [Numeric] target width in CSS pixels
276
+ # @param th [Numeric] target height in CSS pixels
277
+ # @return [void]
278
+ # @raise [Cairo::Error] when Cairo cannot write the PNG surface
279
+ # @raise [Rsvg::Error] when librsvg cannot render the document
205
280
  def png(handle:, output:, tw:, th:, **)
206
281
  surface = Cairo::ImageSurface.new(Cairo::FORMAT_ARGB32, tw.round, th.round)
207
282
  context = Cairo::Context.new(surface)
@@ -6,18 +6,44 @@ require "tempfile"
6
6
  module Sevgi
7
7
  module Sundries
8
8
  module Export
9
- # pdfcpu - https://pdfcpu.io/ (pdfcpu package)
10
- def a5ona4(infile, outfile) = F.sh!("pdfcpu", "nup", "--", "form:A4L, border:off", outfile, "2", infile)
9
+ # Places a single A5 PDF page twice on an A4 landscape sheet with pdfcpu.
10
+ # @param infile [String] source PDF file path
11
+ # @param outfile [String] destination PDF file path
12
+ # @return [Sevgi::Function::Shell::Result] command result
13
+ # @raise [Sevgi::Error] when pdfcpu is missing or the command fails
14
+ # @raise [Errno::ENOENT] when the executable cannot be spawned
15
+ # @see https://pdfcpu.io/ pdfcpu
16
+ def a5_on_a4(infile, outfile) = F.sh!("pdfcpu", "nup", "--", "form:A4L, border:off", outfile, "2", infile)
11
17
 
12
- def a5ona4!(infile)
18
+ # Replaces a PDF file with an A5-on-A4 layout generated by pdfcpu.
19
+ # @param infile [String] PDF file path to modify
20
+ # @return [void]
21
+ # @raise [Sevgi::Error] when pdfcpu is missing or the command fails
22
+ # @raise [Errno::ENOENT] when the executable cannot be spawned
23
+ def a5_on_a4!(infile)
13
24
  temp = Tempfile.new(%w[output .pdf], File.dirname(infile))
14
- a5ona4(infile, temp.path)
25
+ a5_on_a4(infile, temp.path)
15
26
  FileUtils.mv(temp.path, infile)
16
27
  ensure
17
28
  temp&.close!
18
29
  end
19
30
 
20
- # inkscape - https://inkscape.org/ (inkscape package)
31
+ # Exports an SVG file through Inkscape.
32
+ # @param infile [String] source SVG file path
33
+ # @param outfile [String, nil] output path, defaulting to infile with a .pdf extension
34
+ # @param format [Symbol, String, nil] explicit output format, or nil to infer from outfile
35
+ # @param background [String, nil] export background color
36
+ # @param opacity [Numeric, nil] export background opacity
37
+ # @param width [Numeric, nil] target export width
38
+ # @param height [Numeric, nil] target export height
39
+ # @param id [String, nil] SVG element id to export
40
+ # @param page [Integer, String, nil] page selector passed to Inkscape
41
+ # @param css [String, nil] CSS inserted before exporting
42
+ # @return [Sevgi::Function::Shell::Result] command result
43
+ # @raise [Sevgi::Sundries::Export::ExportError] when format or output extension is unsupported
44
+ # @raise [Sevgi::Error] when Inkscape is missing or the command fails
45
+ # @raise [Errno::ENOENT] when the executable cannot be spawned
46
+ # @see https://inkscape.org/ Inkscape
21
47
  def inkscape(
22
48
  infile,
23
49
  outfile = nil,
@@ -62,7 +88,20 @@ module Sevgi
62
88
  temp&.close!
63
89
  end
64
90
 
65
- # rsvg-convert - https://gitlab.gnome.org/GNOME/librsvg (librsvg2-bin package)
91
+ # Exports an SVG file through rsvg-convert.
92
+ # @param infile [String] source SVG file path
93
+ # @param outfile [String, nil] output path, defaulting to infile with a .pdf extension
94
+ # @param format [Symbol, String, nil] explicit output format, or nil to infer from outfile
95
+ # @param background [String, nil] export background color
96
+ # @param width [Numeric, nil] target export width
97
+ # @param height [Numeric, nil] target export height
98
+ # @param id [String, nil] SVG element id to export
99
+ # @param css [String, nil] CSS inserted before exporting
100
+ # @return [Sevgi::Function::Shell::Result] command result
101
+ # @raise [Sevgi::Sundries::Export::ExportError] when format or output extension is unsupported
102
+ # @raise [Sevgi::Error] when rsvg-convert is missing or the command fails
103
+ # @raise [Errno::ENOENT] when the executable cannot be spawned
104
+ # @see https://gitlab.gnome.org/GNOME/librsvg librsvg
66
105
  def rsvg(
67
106
  infile,
68
107
  outfile = nil,
@@ -100,7 +139,13 @@ module Sevgi
100
139
  temp&.close!
101
140
  end
102
141
 
103
- # pdfunite - https://poppler.freedesktop.org/ (poppler-utils package)
142
+ # Merges PDF files with pdfunite.
143
+ # @param sources [Array<String>] source PDF file paths
144
+ # @param outfile [String] destination PDF file path
145
+ # @return [Sevgi::Function::Shell::Result] command result
146
+ # @raise [Sevgi::Error] when pdfunite is missing or the command fails
147
+ # @raise [Errno::ENOENT] when the executable cannot be spawned
148
+ # @see https://poppler.freedesktop.org/ Poppler
104
149
  def unite(sources, outfile) = F.sh!("pdfunite", *sources, outfile)
105
150
 
106
151
  extend self
@@ -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
@@ -4,11 +4,26 @@ require "delegate"
4
4
 
5
5
  module Sevgi
6
6
  module Sundries
7
+ # Builds a tile-like grid from horizontal and vertical rulers.
7
8
  class Grid < Tile
9
+ # Builds a grid using bracket syntax.
10
+ # @param x [Sevgi::Sundries::Ruler] horizontal ruler
11
+ # @param y [Sevgi::Sundries::Ruler] vertical ruler
12
+ # @return [Sevgi::Sundries::Grid]
13
+ # @raise [Sevgi::ArgumentError] when either argument is not a ruler
8
14
  def self.[](x, y) = new(x:, y:)
9
15
 
16
+ # @!attribute [r] x
17
+ # @return [Sevgi::Sundries::Grid::X] horizontal axis ruler and line queries
18
+ # @!attribute [r] y
19
+ # @return [Sevgi::Sundries::Grid::Y] vertical axis ruler and line queries
10
20
  attr_reader :x, :y
11
21
 
22
+ # Creates a grid from horizontal and vertical rulers.
23
+ # @param x [Sevgi::Sundries::Ruler] horizontal ruler
24
+ # @param y [Sevgi::Sundries::Ruler] vertical ruler
25
+ # @return [void]
26
+ # @raise [Sevgi::ArgumentError] when either argument is not a ruler
12
27
  def initialize(x:, y:)
13
28
  ArgumentError.("Arguments must be Ruler objects") unless [x, y].all?(Ruler)
14
29
 
@@ -18,6 +33,8 @@ module Sevgi
18
33
  super(Geometry::Rect[@x.u, @y.u], nx: @x.n, ny: @y.n)
19
34
  end
20
35
 
36
+ # Returns a graphics canvas matching the rulers and computed margins.
37
+ # @return [Sevgi::Graphics::Canvas]
21
38
  def canvas
22
39
  Graphics::Canvas.new(
23
40
  **Graphics::Paper[x.brut, y.brut].to_h,
@@ -25,13 +42,32 @@ module Sevgi
25
42
  )
26
43
  end
27
44
 
45
+ # Returns the fitted grid height.
46
+ # @return [Float]
28
47
  def height = y.d
29
48
 
49
+ # Returns the fitted grid width.
50
+ # @return [Float]
30
51
  def width = x.d
31
52
 
53
+ # Axis wrapper exposing grid line queries for one ruler direction.
32
54
  class Axis < DelegateClass(Ruler)
33
- attr_reader :major, :halve, :minor
34
-
55
+ # Returns the major line query.
56
+ # @return [Sevgi::Sundries::Grid::Axis::Major]
57
+ attr_reader :major
58
+
59
+ # Returns the midpoint line query.
60
+ # @return [Sevgi::Sundries::Grid::Axis::Halve]
61
+ attr_reader :halve
62
+
63
+ # Returns the minor line query.
64
+ # @return [Sevgi::Sundries::Grid::Axis::Minor]
65
+ attr_reader :minor
66
+
67
+ # Creates an axis wrapper.
68
+ # @param this [Sevgi::Sundries::Ruler] ruler represented by this axis
69
+ # @param other [Sevgi::Sundries::Ruler] ruler used for perpendicular tick locations
70
+ # @return [void]
35
71
  def initialize(this, other)
36
72
  super(this)
37
73
 
@@ -40,13 +76,24 @@ module Sevgi
40
76
  @minor = Minor.new(self, other)
41
77
  end
42
78
 
79
+ # Memoized grid line query for an axis.
43
80
  class Query
81
+ # Creates a query.
82
+ # @param this [Sevgi::Sundries::Grid::Axis] axis receiving generated lines
83
+ # @param other [Sevgi::Sundries::Ruler] perpendicular ruler supplying tick distances
84
+ # @return [void]
44
85
  def initialize(this, other) = (@this, @other = this, other)
45
86
 
87
+ # Returns grid line endpoints as coordinate pairs.
88
+ # @return [Array<Array<Array<Float>>>]
46
89
  def xys = @xys ||= lines.map { it.points(true).map(&:deconstruct) }
47
90
 
91
+ # Returns grid line endpoints as points.
92
+ # @return [Array<Array<Sevgi::Geometry::Point>>]
48
93
  def points = @points ||= lines.map { it.points(true) }
49
94
 
95
+ # Returns generated grid lines.
96
+ # @return [Array<Sevgi::Geometry::Line>]
50
97
  def lines = @lines ||= lines!
51
98
 
52
99
  private
@@ -54,27 +101,56 @@ module Sevgi
54
101
  attr_reader :this, :other
55
102
  end
56
103
 
104
+ # Major grid line query.
57
105
  class Major < Query
58
- def lines! = other.ds.map { this.line.shift(it) }
106
+ # Returns lines at major tick distances.
107
+ # @return [Array<Sevgi::Geometry::Line>]
108
+ def lines! = other.ds.map { this.line_at(it) }
59
109
  end
60
110
 
111
+ # Midpoint grid line query.
61
112
  class Halve < Query
62
- def lines! = other.hs.map { this.line.shift(it) }
113
+ # Returns lines at midpoint tick distances.
114
+ # @return [Array<Sevgi::Geometry::Line>]
115
+ def lines! = other.hs.map { this.line_at(it) }
63
116
  end
64
117
 
118
+ # Minor grid line query.
65
119
  class Minor < Query
66
- def lines! = other.ms.map { this.line.shift(it) }
120
+ # Returns lines at minor tick distances.
121
+ # @return [Array<Sevgi::Geometry::Line>]
122
+ def lines! = other.ms.map { this.line_at(it) }
67
123
  end
68
124
  end
69
125
 
126
+ # Horizontal grid axis.
70
127
  class X < Axis
128
+ # Returns the base horizontal line for this axis.
129
+ # @return [Sevgi::Geometry::Line]
71
130
  def line = @line ||= Geometry::Line[d, 0.0]
131
+
132
+ # Returns a horizontal line translated to a y coordinate.
133
+ # @param y [Numeric] y coordinate
134
+ # @return [Sevgi::Geometry::Line]
135
+ def line_at(y) = line.at([0.0, y])
72
136
  end
73
137
 
138
+ # Vertical grid axis.
74
139
  class Y < Axis
140
+ # Creates a vertical axis wrapper.
141
+ # @param this [Sevgi::Sundries::Ruler] horizontal ruler
142
+ # @param other [Sevgi::Sundries::Ruler] vertical ruler
143
+ # @return [void]
75
144
  def initialize(this, other) = super(other, this)
76
145
 
77
- def line = @line || Geometry::Line[d, 90.0]
146
+ # Returns the base vertical line for this axis.
147
+ # @return [Sevgi::Geometry::Line]
148
+ def line = @line ||= Geometry::Line[d, 90.0]
149
+
150
+ # Returns a vertical line translated to an x coordinate.
151
+ # @param x [Numeric] x coordinate
152
+ # @return [Sevgi::Geometry::Line]
153
+ def line_at(x) = line.at([x, 0.0])
78
154
  end
79
155
  end
80
156
  end
@@ -2,89 +2,236 @@
2
2
 
3
3
  module Sevgi
4
4
  module Sundries
5
+ # A one-dimensional interval divided into equal units.
5
6
  #
6
- # <----------------------------------- d = n x u --------------------------------->
7
- #
8
- # <--------------h = d / 2 --------------->
9
- #
10
- # |---------+---------+---------+---------|---------+---------+---------+---------|
11
- #
12
- # <--- u --->
7
+ # The compact reader names are part of the domain vocabulary:
8
+ # `u` is the unit length, `n` is the interval count, `d` is total
9
+ # distance, and `h` is the midpoint distance.
13
10
  #
11
+ # @example Interval geometry
12
+ # # <---------------- d = n x u ---------------->
13
+ # # |---------+---------+---------+---------|
14
+ # # <--- u --->
15
+ # interval = Sevgi::Sundries::Interval[3, 4]
16
+ # interval.d # => 12.0
14
17
  class Interval
18
+ # Builds an interval using bracket syntax.
19
+ # @param e [Numeric, #length] unit length or an object exposing length
20
+ # @param n [Integer] non-negative interval count
21
+ # @return [Sevgi::Sundries::Interval]
22
+ # @raise [Sevgi::ArgumentError] when count is not a non-negative integer
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
15
27
  def self.[](e, n) = new(e, n)
16
28
 
29
+ # @!attribute [r] n
30
+ # @return [Integer] interval count
31
+ # @!attribute [r] u
32
+ # @return [Float] unit length
17
33
  attr_reader :n, :u
18
34
 
19
- def initialize(e, n) = (@u, @n = measure(e), n)
35
+ # Creates an interval.
36
+ # @param e [Numeric, #length] unit length or an object exposing length
37
+ # @param n [Integer] non-negative interval count
38
+ # @return [void]
39
+ # @raise [Sevgi::ArgumentError] when count is not a non-negative integer
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
44
+ def initialize(e, n)
45
+ @n = non_negative_integer(n, "Interval count")
46
+ @u = measure(e)
47
+ end
20
48
 
49
+ # Returns a major tick distance by index.
50
+ # @param i [Integer] tick index
51
+ # @return [Float, nil] distance from the interval origin, or nil when out of range
21
52
  def [](i) = ds[i]
22
53
 
23
- def count(length) = (d / length.to_f).to_i
54
+ # Counts how many whole lengths fit into this interval.
55
+ # @param length [Numeric] candidate length
56
+ # @return [Integer]
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
24
61
 
62
+ # Returns the total interval distance.
63
+ # @return [Float]
25
64
  def d = @d ||= n * u
26
65
 
66
+ # Returns major tick distances, including both endpoints.
67
+ # @return [Array<Float>]
27
68
  def ds = @ds ||= Array.new(n + 1) { |i| i * u }
28
69
 
70
+ # Returns the midpoint distance.
71
+ # @return [Float]
29
72
  def h = @h ||= d / 2.0
30
73
 
74
+ # Returns midpoint tick distances for each interval segment.
75
+ # @return [Array<Float>]
31
76
  def hs = @hs ||= Array.new(n) { |i| u * (0.5 + i) }
32
77
 
78
+ # Returns the last major tick index.
79
+ # @return [Integer]
33
80
  def nds = @nds ||= ds.size - 1
34
81
 
82
+ # Returns the last midpoint tick index.
83
+ # @return [Integer]
35
84
  def nhs = @nhs ||= hs.size - 1
36
85
 
86
+ # @return [Float] total interval distance
37
87
  alias length d
38
88
 
39
89
  private
40
90
 
41
91
  def measure(e)
42
- 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
43
102
 
44
- raise NoMethodError, "#{e.class}#length must be implemented" unless e.respond_to?(:length)
103
+ def non_negative_integer(value, field)
104
+ ArgumentError.("#{field} must be a non-negative Integer") unless value.is_a?(::Integer) && !value.negative?
45
105
 
46
- e.length
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
124
+
125
+ def positive_integer(value, field)
126
+ ArgumentError.("#{field} must be a positive Integer") unless value.is_a?(::Integer) && value.positive?
127
+
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
47
136
  end
48
137
  end
49
138
 
139
+ # Fits a repeated interval into a broader span with computed margins.
50
140
  #
51
- # <------------------------ d = n x sd -----------------------------><--- waste = 2 x margin --->
52
- # <----- u = unit x multiple ----->
53
- # <------ sd = su x sn -----> (computed margin >= given margin)
54
- #
55
- # |----------+----------+----------|----------+----------+----------|···························|
56
- #
57
- # <----------------------------------------- brut ---------------------------------------------->
141
+ # A ruler stores both the fitted major interval and the source subinterval.
142
+ # The compact reader names mirror {Interval}: `brut` is the full available
143
+ # span, `sd/su/sn` describe the subinterval, and `waste` is distributed as
144
+ # equal margins.
58
145
  #
146
+ # @example Ruler geometry
147
+ # # <--------- d = n x sd ---------><--- waste = 2 x margin --->
148
+ # # <----- u = unit x multiple ----->
149
+ # # <---------------- brut ---------------->
150
+ # ruler = Sevgi::Sundries::Ruler.new(unit: 1, multiple: 10, brut: 150)
151
+ # ruler.d # => 150.0
59
152
  class Ruler < Interval
153
+ # @!attribute [r] brut
154
+ # @return [Float] full available span before fitting
155
+ # @!attribute [r] sub
156
+ # @return [Sevgi::Sundries::Interval] source subinterval
60
157
  attr_reader :brut, :sub
61
158
 
159
+ # Creates a ruler fitted into the given span.
160
+ # @param brut [Numeric] full available span
161
+ # @param unit [Numeric] subinterval unit length
162
+ # @param multiple [Integer] number of subinterval units per major interval
163
+ # @param margin [Numeric] minimum margin on each side
164
+ # @return [void]
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
174
+ # @raise [Sevgi::ArgumentError] when margin is negative
175
+ # @raise [Sevgi::ArgumentError] when the fitting span is negative
62
176
  def initialize(brut:, unit:, multiple:, margin: 0.0)
63
- super(@sub = Interval.new(unit, multiple), divide(unit:, multiple:, brut: (@brut = brut.to_f), margin:))
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")
181
+ @sub = Interval.new(unit, multiple)
182
+
183
+ n = divide(unit:, multiple:, brut: @brut, margin:)
184
+
185
+ ArgumentError.("Ruler fitting span must not be negative") if n.negative?
186
+
187
+ super(@sub, n)
64
188
  end
65
189
 
190
+ # Returns a ruler where the source subinterval is flattened into units.
191
+ # @return [Sevgi::Sundries::Ruler]
66
192
  def expand = self.class.new(unit: sub.u, multiple: 1, brut: d + waste, margin:)
67
193
 
194
+ # Returns the computed margin after fitting.
195
+ # @return [Float]
68
196
  def margin = @margin ||= waste / 2.0
69
197
 
198
+ # Returns minor tick distances across the fitted span.
199
+ # @return [Array<Float>]
70
200
  def ms = @ms ||= expand.ds
71
201
 
202
+ # Returns the unfitted distance distributed outside the fitted span.
203
+ # @return [Float]
72
204
  def waste = @waste ||= brut - d
73
205
 
206
+ # Returns the source subinterval count.
207
+ # @return [Integer]
74
208
  def sn = @sub.n
75
209
 
210
+ # Returns the source subinterval distance.
211
+ # @return [Float]
76
212
  def sd = @sub.d
77
213
 
214
+ # Returns the source subinterval unit length.
215
+ # @return [Float]
78
216
  def su = @sub.u
79
217
 
80
218
  protected
81
219
 
220
+ # Computes the number of major intervals fitting in the available span.
221
+ # @param unit [Numeric] subinterval unit length
222
+ # @param multiple [Integer] number of subinterval units per major interval
223
+ # @param brut [Numeric] full available span
224
+ # @param margin [Numeric] minimum margin on each side
225
+ # @return [Integer]
82
226
  def divide(unit:, multiple:, brut:, margin:) = F.count(brut - (2 * margin), unit * multiple)
83
227
  end
84
228
 
229
+ # Ruler variant that always chooses an even number of major intervals.
85
230
  class RulerEven < Ruler
86
231
  protected
87
232
 
233
+ # Computes an even number of major intervals fitting in the available span.
234
+ # @return [Integer]
88
235
  def divide(...) = (n = super).even? ? n : (n - 1)
89
236
  end
90
237
  end
@@ -2,47 +2,118 @@
2
2
 
3
3
  module Sevgi
4
4
  module Sundries
5
+ # Repeats a geometry element over a rectangular row and column layout.
5
6
  class Tile
6
7
  include Enumerable
7
8
 
9
+ # @!attribute [r] element
10
+ # @return [Sevgi::Geometry::Element] source element repeated by the tile
11
+ # @!attribute [r] position
12
+ # @return [Sevgi::Geometry::Point] tile origin
13
+ # @!attribute [r] nx
14
+ # @return [Integer] number of columns
15
+ # @!attribute [r] ny
16
+ # @return [Integer] number of rows
8
17
  attr_reader :element, :position, :nx, :ny
9
18
 
19
+ # Creates a tile from a source geometry element.
20
+ # @param element [Sevgi::Geometry::Element] geometry element to repeat
21
+ # @param position [Sevgi::Geometry::Point, Array<Numeric>] tile origin
22
+ # @param nx [Integer] number of columns
23
+ # @param ny [Integer] number of rows
24
+ # @return [void]
25
+ # @raise [Sevgi::ArgumentError] when element is not a geometry element
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
10
28
  def initialize(element, position: Geometry::Origin, nx: 1, ny: 1)
11
- raise ArgumentError, "Must be an Element object: #{element}" unless element.is_a?(Geometry::Element)
29
+ ArgumentError.("Must be an Element object: #{element}") unless element.is_a?(Geometry::Element)
30
+ ArgumentError.("Tile nx must be positive") unless nx.is_a?(::Integer) && nx.positive?
31
+ ArgumentError.("Tile ny must be positive") unless ny.is_a?(::Integer) && ny.positive?
12
32
 
13
33
  @element = element
14
- @position = position
34
+ @position = self.class.send(:position, position)
15
35
 
16
36
  @nx = nx
17
37
  @ny = ny
18
38
  end
19
39
 
40
+ # Returns a row by index.
41
+ # @param i [Integer] row index
42
+ # @return [Array<Sevgi::Geometry::Element>, nil]
20
43
  def [](i) = rows[i]
21
44
 
45
+ # Returns the bounding rectangle of the whole tile.
46
+ # @return [Sevgi::Geometry::Rect]
22
47
  def box = @box ||= Geometry::Rect[nx * element.box.width, ny * element.box.height, position:]
23
48
 
49
+ # Returns the first cell in the tile.
50
+ # @return [Sevgi::Geometry::Element]
24
51
  def cell = row.first
25
52
 
53
+ # Returns the bounding rectangle of a column.
54
+ # @param i [Integer] column index
55
+ # @return [Sevgi::Geometry::Rect]
26
56
  def colbox(i = 0) = Geometry::Rect[element.box.width, box.height, position: coordinate(0, i)]
27
57
 
58
+ # Returns cells grouped by column.
59
+ # @return [Array<Array<Sevgi::Geometry::Element>>]
28
60
  def cols = @cols ||= rows.transpose
29
61
 
62
+ # Returns a column by index.
63
+ # @param i [Integer] column index
64
+ # @return [Array<Sevgi::Geometry::Element>, nil]
30
65
  def col(i = 0) = cols[i]
31
66
 
67
+ # Iterates over rows.
68
+ # @yield [row] each row
69
+ # @yieldparam row [Array<Sevgi::Geometry::Element>] row cells
70
+ # @yieldreturn [void]
71
+ # @return [Enumerator, Array<Array<Sevgi::Geometry::Element>>] enumerator without a block, otherwise rows
32
72
  def each(...) = rows.each(...)
33
73
 
74
+ # Iterates over columns.
75
+ # @yield [column] each column
76
+ # @yieldparam column [Array<Sevgi::Geometry::Element>] column cells
77
+ # @yieldreturn [void]
78
+ # @return [Enumerator, Array<Array<Sevgi::Geometry::Element>>] enumerator without a block, otherwise columns
34
79
  def each_col(...) = cols.each(...)
35
80
 
81
+ # Returns a row by index.
82
+ # @param i [Integer] row index
83
+ # @return [Array<Sevgi::Geometry::Element>, nil]
36
84
  def row(i = 0) = rows[i]
37
85
 
86
+ # Returns the bounding rectangle of a row.
87
+ # @param i [Integer] row index
88
+ # @return [Sevgi::Geometry::Rect]
38
89
  def rowbox(i = 0) = Geometry::Rect[box.width, element.box.height, position: coordinate(i)]
39
90
 
91
+ # Returns cells grouped by row.
92
+ # @return [Array<Array<Sevgi::Geometry::Element>>]
40
93
  def rows = @rows ||= (0...ny).map { |i| (0...nx).map { |j| element.at(coordinate(i, j)) } }
41
94
 
95
+ # Iterates over rows.
96
+ # @return [Enumerator, Array<Array<Sevgi::Geometry::Element>>] enumerator without a block, otherwise rows
42
97
  alias each_row each
43
98
 
44
99
  private
45
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
+
46
117
  def coordinate(i, j = 0) = position.translate(j * element.box.width, i * element.box.height)
47
118
  end
48
119
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  module Sevgi
4
4
  module Sundries
5
- VERSION = "0.73.2"
5
+ # Component version.
6
+ VERSION = "0.94.0"
6
7
  end
7
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"
@@ -9,3 +11,13 @@ require_relative "sundries/grid"
9
11
  require_relative "sundries/export"
10
12
 
11
13
  require_relative "sundries/version"
14
+
15
+ module Sevgi
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.
21
+ module Sundries
22
+ end
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.73.2
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.73.2
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.73.2
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
  - - ">="