sevgi-sundries 0.73.2 → 0.93.1

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: 6094018249be152bff171c6077c6b5f7c9bd9f0d24c68381afbe05674e121da0
4
+ data.tar.gz: 8b9a02a83b2fd4b3614b65fed7c7922b611b78790e187167db3361cdd301cdeb
5
5
  SHA512:
6
- metadata.gz: 5ef78b3c182dae1ff9c45659e7b1c94d0b7e29f36ed52d092b5d05c779f8e9ccf51c302e354131ccfc5f379d85e6e611beed1b8639a34ad1897fcb6f2b32a66d
7
- data.tar.gz: d8ca3086912c399b09d25fb2b65fd30ad1782d77caa586148de52d20ff3029898c01eba42f7834e8531a0c7e575a8fcf7ddba05a6a5c3c762509870792d183de
6
+ metadata.gz: ea257e8c2b131b3ae278d35d14850391f6a4bf33467a528d87aa5ec0cd8dd3c4109376da978a3fdd4352d230f5c668d268914e390f33be4c1d28a775d5eabee9
7
+ data.tar.gz: 7ba89c1d630142ac180bc64aaa15564bb2d54b5a5e68daa1cd2a814f546cef6c1b76d08ff8504f64e4d0a9880c505577ca958c9ae96ce3e814fb584035593879
data/README.md CHANGED
@@ -0,0 +1,5 @@
1
+ # Sevgi Sundries
2
+
3
+ Provides helper objects and export tools for Sevgi drawings.
4
+
5
+ See the root [README](../README.md) and the documentation site for usage.
@@ -8,32 +8,53 @@ require "tempfile"
8
8
 
9
9
  module Sevgi
10
10
  module Sundries
11
+ # Exports SVG content and post-processes PDF output.
11
12
  module Export
13
+ # Raised when SVG export or PDF post-processing cannot be completed.
12
14
  ExportError = Class.new(Error)
13
15
 
16
+ # Default SVG CSS pixel density.
14
17
  DEFAULT_DPI = 96.0
15
18
 
19
+ # Exports SVG source to a PDF or PNG file using librsvg and Cairo.
20
+ # @param svg [String] SVG source content
21
+ # @param output [String, #to_s] output file path
22
+ # @param format [Symbol, String, nil] explicit output format, or nil to infer from output extension
23
+ # @param width [Numeric, nil] target width in output pixels for PNG, or CSS pixels before PDF point conversion
24
+ # @param height [Numeric, nil] target height in output pixels for PNG, or CSS pixels before PDF point conversion
25
+ # @param dpi [Numeric] CSS pixel density used for absolute SVG units and PDF point conversion
26
+ # @param css [String, nil] CSS inserted before the closing svg tag before rendering
27
+ # @yield [svg] optional source transformation applied before rendering
28
+ # @yieldparam svg [String] SVG source after optional CSS injection
29
+ # @yieldreturn [String] SVG source to render
30
+ # @return [Object] the original output argument
31
+ # @raise [Sevgi::ArgumentError] when SVG content is not a string or output is blank
32
+ # @raise [Sevgi::Sundries::Export::ExportError] when format, SVG parsing, SVG dimensions, or render dimensions are invalid
16
33
  def self.call(svg, output, format: nil, width: nil, height: nil, dpi: DEFAULT_DPI, css: nil, &block)
17
34
  ArgumentError.("SVG content must be a String") unless svg.is_a?(String)
18
35
  ArgumentError.("Export output must be provided") if output.nil? || output.to_s.strip.empty?
19
36
 
20
37
  svg = inject(svg, css) if css && !css.strip.empty?
21
38
  svg = block.call(svg) if block
39
+ ArgumentError.("SVG content must be a String") unless svg.is_a?(String)
40
+
41
+ format = format_for!(format, output)
42
+ renderer = Renderer.method(format)
22
43
 
23
- renderer = Renderer.method(format_for!(format, output))
24
- handle = Rsvg::Handle.new_from_data(svg)
44
+ begin
45
+ handle = Rsvg::Handle.new_from_data(svg)
25
46
 
26
- iw, ih = intrinsic_size(handle)
27
- ExportError.("Invalid SVG dimensions") if iw <= 0 || ih <= 0
47
+ iw, ih = intrinsic_size(handle)
48
+ ExportError.("Invalid SVG dimensions") if iw <= 0 || ih <= 0
28
49
 
29
- scale = dpi / DEFAULT_DPI
50
+ scale = dpi / DEFAULT_DPI
30
51
 
31
- iw *= scale
32
- ih *= scale
52
+ iw *= scale
53
+ ih *= scale
33
54
 
34
- tw, th = target_size(iw, ih, width, height)
55
+ tw, th = target_size(iw, ih, width, height)
56
+ ExportError.("Invalid export dimensions") unless target_size?(format, tw, th)
35
57
 
36
- begin
37
58
  renderer.call(
38
59
  handle: handle,
39
60
  output: output.to_s,
@@ -50,6 +71,7 @@ module Sevgi
50
71
  output
51
72
  end
52
73
 
74
+ # Supported export format names mapped to file extensions.
53
75
  AVAILABLE = (EXTENSIONS = {
54
76
  ".pdf" => :pdf,
55
77
  ".png" => :png
@@ -57,6 +79,11 @@ module Sevgi
57
79
  .invert
58
80
  .freeze
59
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
60
87
  def format_for!(format, output)
61
88
  if format
62
89
  format = format.to_sym
@@ -71,8 +98,19 @@ module Sevgi
71
98
  end
72
99
  end
73
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
74
105
  def inject(svg, css) = svg.sub("</svg>", "<style>#{css}</style></svg>")
75
106
 
107
+ # Replaces a placeholder text object in a PDF stream.
108
+ # @param infile [String] source PDF file path
109
+ # @param outfile [String] destination PDF file path
110
+ # @param stamp [String] replacement text
111
+ # @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
76
114
  def stamp(infile, outfile, stamp:, placeholder:)
77
115
  doc = HexaPDF::Document.open(infile)
78
116
  stamped = false
@@ -98,8 +136,16 @@ module Sevgi
98
136
 
99
137
  doc.write(outfile, optimize: true) if stamped
100
138
  stamped
139
+ rescue HexaPDF::Error, ::SystemCallError => e
140
+ ExportError.("PDF stamp error: #{e.message}")
101
141
  end
102
142
 
143
+ # Replaces a placeholder text object inside a PDF file in place.
144
+ # @param infile [String] PDF file path to modify
145
+ # @param stamp [String] replacement text
146
+ # @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
103
149
  def stamp!(infile, stamp:, placeholder:)
104
150
  temp = Tempfile.new(%w[stamp .pdf], File.dirname(infile))
105
151
  stamped = stamp(infile, temp.path, stamp:, placeholder:)
@@ -112,6 +158,8 @@ module Sevgi
112
158
  end
113
159
 
114
160
  stamped
161
+ rescue ::SystemCallError => e
162
+ ExportError.("PDF stamp error: #{e.message}")
115
163
  ensure
116
164
  temp&.close!
117
165
  end
@@ -178,11 +226,24 @@ module Sevgi
178
226
  [iw, ih]
179
227
  end
180
228
  end
229
+
230
+ def target_size?(format, width, height)
231
+ return false if width <= 0 || height <= 0
232
+ return false unless width.finite? && height.finite?
233
+ return true unless format == :png
234
+
235
+ width.round.positive? && height.round.positive?
236
+ end
181
237
  end
182
238
 
239
+ # Low-level format renderers used by {Export.call}.
240
+ # @api private
183
241
  module Renderer
184
242
  extend self
185
243
 
244
+ # Returns a renderer method for a format.
245
+ # @param format [Symbol, String, nil] format name
246
+ # @return [Method, nil]
186
247
  def [](format)
187
248
  case format&.to_sym
188
249
  when :png
@@ -192,6 +253,15 @@ module Sevgi
192
253
  end
193
254
  end
194
255
 
256
+ # Renders SVG data to a PDF surface.
257
+ # @param handle [Rsvg::Handle] parsed SVG handle
258
+ # @param output [String] output file path
259
+ # @param tw [Numeric] target width in CSS pixels
260
+ # @param th [Numeric] target height in CSS pixels
261
+ # @param dpi [Numeric] CSS pixel density
262
+ # @return [void]
263
+ # @raise [Cairo::Error] when Cairo cannot write the PDF surface
264
+ # @raise [Rsvg::Error] when librsvg cannot render the document
195
265
  def pdf(handle:, output:, tw:, th:, dpi:, **)
196
266
  pw, ph = tw * (72.0 / dpi), th * (72.0 / dpi)
197
267
  surface = Cairo::PDFSurface.new(output, pw, ph)
@@ -202,6 +272,14 @@ module Sevgi
202
272
  surface.finish
203
273
  end
204
274
 
275
+ # Renders SVG data to a PNG image.
276
+ # @param handle [Rsvg::Handle] parsed SVG handle
277
+ # @param output [String] output file path
278
+ # @param tw [Numeric] target width in CSS pixels
279
+ # @param th [Numeric] target height in CSS pixels
280
+ # @return [void]
281
+ # @raise [Cairo::Error] when Cairo cannot write the PNG surface
282
+ # @raise [Rsvg::Error] when librsvg cannot render the document
205
283
  def png(handle:, output:, tw:, th:, **)
206
284
  surface = Cairo::ImageSurface.new(Cairo::FORMAT_ARGB32, tw.round, th.round)
207
285
  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
@@ -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,33 @@ 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.
54
+ # @api private
32
55
  class Axis < DelegateClass(Ruler)
33
- attr_reader :major, :halve, :minor
34
-
56
+ # Returns the major line query.
57
+ # @return [Sevgi::Sundries::Grid::Axis::Major]
58
+ attr_reader :major
59
+
60
+ # Returns the midpoint line query.
61
+ # @return [Sevgi::Sundries::Grid::Axis::Halve]
62
+ attr_reader :halve
63
+
64
+ # Returns the minor line query.
65
+ # @return [Sevgi::Sundries::Grid::Axis::Minor]
66
+ attr_reader :minor
67
+
68
+ # Creates an axis wrapper.
69
+ # @param this [Sevgi::Sundries::Ruler] ruler represented by this axis
70
+ # @param other [Sevgi::Sundries::Ruler] ruler used for perpendicular tick locations
71
+ # @return [void]
35
72
  def initialize(this, other)
36
73
  super(this)
37
74
 
@@ -40,13 +77,25 @@ module Sevgi
40
77
  @minor = Minor.new(self, other)
41
78
  end
42
79
 
80
+ # Memoized grid line query for an axis.
81
+ # @api private
43
82
  class Query
83
+ # Creates a query.
84
+ # @param this [Sevgi::Sundries::Grid::Axis] axis receiving generated lines
85
+ # @param other [Sevgi::Sundries::Ruler] perpendicular ruler supplying tick distances
86
+ # @return [void]
44
87
  def initialize(this, other) = (@this, @other = this, other)
45
88
 
89
+ # Returns grid line endpoints as coordinate pairs.
90
+ # @return [Array<Array<Array<Float>>>]
46
91
  def xys = @xys ||= lines.map { it.points(true).map(&:deconstruct) }
47
92
 
93
+ # Returns grid line endpoints as points.
94
+ # @return [Array<Array<Sevgi::Geometry::Point>>]
48
95
  def points = @points ||= lines.map { it.points(true) }
49
96
 
97
+ # Returns generated grid lines.
98
+ # @return [Array<Sevgi::Geometry::Line>]
50
99
  def lines = @lines ||= lines!
51
100
 
52
101
  private
@@ -54,27 +103,61 @@ module Sevgi
54
103
  attr_reader :this, :other
55
104
  end
56
105
 
106
+ # Major grid line query.
107
+ # @api private
57
108
  class Major < Query
58
- def lines! = other.ds.map { this.line.shift(it) }
109
+ # Returns lines at major tick distances.
110
+ # @return [Array<Sevgi::Geometry::Line>]
111
+ def lines! = other.ds.map { this.line_at(it) }
59
112
  end
60
113
 
114
+ # Midpoint grid line query.
115
+ # @api private
61
116
  class Halve < Query
62
- def lines! = other.hs.map { this.line.shift(it) }
117
+ # Returns lines at midpoint tick distances.
118
+ # @return [Array<Sevgi::Geometry::Line>]
119
+ def lines! = other.hs.map { this.line_at(it) }
63
120
  end
64
121
 
122
+ # Minor grid line query.
123
+ # @api private
65
124
  class Minor < Query
66
- def lines! = other.ms.map { this.line.shift(it) }
125
+ # Returns lines at minor tick distances.
126
+ # @return [Array<Sevgi::Geometry::Line>]
127
+ def lines! = other.ms.map { this.line_at(it) }
67
128
  end
68
129
  end
69
130
 
131
+ # Horizontal grid axis.
132
+ # @api private
70
133
  class X < Axis
134
+ # Returns the base horizontal line for this axis.
135
+ # @return [Sevgi::Geometry::Line]
71
136
  def line = @line ||= Geometry::Line[d, 0.0]
137
+
138
+ # Returns a horizontal line translated to a y coordinate.
139
+ # @param y [Numeric] y coordinate
140
+ # @return [Sevgi::Geometry::Line]
141
+ def line_at(y) = line.at([0.0, y])
72
142
  end
73
143
 
144
+ # Vertical grid axis.
145
+ # @api private
74
146
  class Y < Axis
147
+ # Creates a vertical axis wrapper.
148
+ # @param this [Sevgi::Sundries::Ruler] horizontal ruler
149
+ # @param other [Sevgi::Sundries::Ruler] vertical ruler
150
+ # @return [void]
75
151
  def initialize(this, other) = super(other, this)
76
152
 
77
- def line = @line || Geometry::Line[d, 90.0]
153
+ # Returns the base vertical line for this axis.
154
+ # @return [Sevgi::Geometry::Line]
155
+ def line = @line ||= Geometry::Line[d, 90.0]
156
+
157
+ # Returns a vertical line translated to an x coordinate.
158
+ # @param x [Numeric] x coordinate
159
+ # @return [Sevgi::Geometry::Line]
160
+ def line_at(x) = line.at([x, 0.0])
78
161
  end
79
162
  end
80
163
  end
@@ -2,38 +2,81 @@
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 length cannot be measured
15
24
  def self.[](e, n) = new(e, n)
16
25
 
26
+ # @!attribute [r] n
27
+ # @return [Integer] interval count
28
+ # @!attribute [r] u
29
+ # @return [Float] unit length
17
30
  attr_reader :n, :u
18
31
 
19
- def initialize(e, n) = (@u, @n = measure(e), n)
32
+ # Creates an interval.
33
+ # @param e [Numeric, #length] unit length or an object exposing length
34
+ # @param n [Integer] non-negative interval count
35
+ # @return [void]
36
+ # @raise [Sevgi::ArgumentError] when count is not a non-negative integer
37
+ # @raise [Sevgi::ArgumentError] when the unit length cannot be measured
38
+ def initialize(e, n)
39
+ ArgumentError.("Interval count must be a non-negative Integer") unless n.is_a?(::Integer) && !n.negative?
40
+
41
+ @u = measure(e)
42
+ @n = n
43
+ end
20
44
 
45
+ # Returns a major tick distance by index.
46
+ # @param i [Integer] tick index
47
+ # @return [Float, nil] distance from the interval origin, or nil when out of range
21
48
  def [](i) = ds[i]
22
49
 
50
+ # Counts how many whole lengths fit into this interval.
51
+ # @param length [Numeric] candidate length
52
+ # @return [Integer]
23
53
  def count(length) = (d / length.to_f).to_i
24
54
 
55
+ # Returns the total interval distance.
56
+ # @return [Float]
25
57
  def d = @d ||= n * u
26
58
 
59
+ # Returns major tick distances, including both endpoints.
60
+ # @return [Array<Float>]
27
61
  def ds = @ds ||= Array.new(n + 1) { |i| i * u }
28
62
 
63
+ # Returns the midpoint distance.
64
+ # @return [Float]
29
65
  def h = @h ||= d / 2.0
30
66
 
67
+ # Returns midpoint tick distances for each interval segment.
68
+ # @return [Array<Float>]
31
69
  def hs = @hs ||= Array.new(n) { |i| u * (0.5 + i) }
32
70
 
71
+ # Returns the last major tick index.
72
+ # @return [Integer]
33
73
  def nds = @nds ||= ds.size - 1
34
74
 
75
+ # Returns the last midpoint tick index.
76
+ # @return [Integer]
35
77
  def nhs = @nhs ||= hs.size - 1
36
78
 
79
+ # @return [Float] total interval distance
37
80
  alias length d
38
81
 
39
82
  private
@@ -41,50 +84,102 @@ module Sevgi
41
84
  def measure(e)
42
85
  return e.to_f if e.is_a?(::Numeric)
43
86
 
44
- raise NoMethodError, "#{e.class}#length must be implemented" unless e.respond_to?(:length)
87
+ ArgumentError.("#{e.class}#length must be implemented") unless e.respond_to?(:length)
45
88
 
46
89
  e.length
47
90
  end
48
91
  end
49
92
 
93
+ # Fits a repeated interval into a broader span with computed margins.
50
94
  #
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 ---------------------------------------------->
95
+ # A ruler stores both the fitted major interval and the source subinterval.
96
+ # The compact reader names mirror {Interval}: `brut` is the full available
97
+ # span, `sd/su/sn` describe the subinterval, and `waste` is distributed as
98
+ # equal margins.
58
99
  #
100
+ # @example Ruler geometry
101
+ # # <--------- d = n x sd ---------><--- waste = 2 x margin --->
102
+ # # <----- u = unit x multiple ----->
103
+ # # <---------------- brut ---------------->
104
+ # ruler = Sevgi::Sundries::Ruler.new(unit: 1, multiple: 10, brut: 150)
105
+ # ruler.d # => 150.0
59
106
  class Ruler < Interval
107
+ # @!attribute [r] brut
108
+ # @return [Float] full available span before fitting
109
+ # @!attribute [r] sub
110
+ # @return [Sevgi::Sundries::Interval] source subinterval
60
111
  attr_reader :brut, :sub
61
112
 
113
+ # Creates a ruler fitted into the given span.
114
+ # @param brut [Numeric] full available span
115
+ # @param unit [Numeric] subinterval unit length
116
+ # @param multiple [Integer] number of subinterval units per major interval
117
+ # @param margin [Numeric] minimum margin on each side
118
+ # @return [void]
119
+ # @raise [Sevgi::ArgumentError] when unit or multiple is not positive
120
+ # @raise [Sevgi::ArgumentError] when margin is negative
121
+ # @raise [Sevgi::ArgumentError] when the fitting span is negative
62
122
  def initialize(brut:, unit:, multiple:, margin: 0.0)
63
- super(@sub = Interval.new(unit, multiple), divide(unit:, multiple:, brut: (@brut = brut.to_f), margin:))
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
129
+ @sub = Interval.new(unit, multiple)
130
+
131
+ n = divide(unit:, multiple:, brut: @brut, margin:)
132
+
133
+ ArgumentError.("Ruler fitting span must not be negative") if n.negative?
134
+
135
+ super(@sub, n)
64
136
  end
65
137
 
138
+ # Returns a ruler where the source subinterval is flattened into units.
139
+ # @return [Sevgi::Sundries::Ruler]
66
140
  def expand = self.class.new(unit: sub.u, multiple: 1, brut: d + waste, margin:)
67
141
 
142
+ # Returns the computed margin after fitting.
143
+ # @return [Float]
68
144
  def margin = @margin ||= waste / 2.0
69
145
 
146
+ # Returns minor tick distances across the fitted span.
147
+ # @return [Array<Float>]
70
148
  def ms = @ms ||= expand.ds
71
149
 
150
+ # Returns the unfitted distance distributed outside the fitted span.
151
+ # @return [Float]
72
152
  def waste = @waste ||= brut - d
73
153
 
154
+ # Returns the source subinterval count.
155
+ # @return [Integer]
74
156
  def sn = @sub.n
75
157
 
158
+ # Returns the source subinterval distance.
159
+ # @return [Float]
76
160
  def sd = @sub.d
77
161
 
162
+ # Returns the source subinterval unit length.
163
+ # @return [Float]
78
164
  def su = @sub.u
79
165
 
80
166
  protected
81
167
 
168
+ # Computes the number of major intervals fitting in the available span.
169
+ # @param unit [Numeric] subinterval unit length
170
+ # @param multiple [Integer] number of subinterval units per major interval
171
+ # @param brut [Numeric] full available span
172
+ # @param margin [Numeric] minimum margin on each side
173
+ # @return [Integer]
82
174
  def divide(unit:, multiple:, brut:, margin:) = F.count(brut - (2 * margin), unit * multiple)
83
175
  end
84
176
 
177
+ # Ruler variant that always chooses an even number of major intervals.
85
178
  class RulerEven < Ruler
86
179
  protected
87
180
 
181
+ # Computes an even number of major intervals fitting in the available span.
182
+ # @return [Integer]
88
183
  def divide(...) = (n = super).even? ? n : (n - 1)
89
184
  end
90
185
  end
@@ -2,13 +2,32 @@
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
10
27
  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)
28
+ ArgumentError.("Must be an Element object: #{element}") unless element.is_a?(Geometry::Element)
29
+ ArgumentError.("Tile nx must be positive") unless nx.is_a?(::Integer) && nx.positive?
30
+ ArgumentError.("Tile ny must be positive") unless ny.is_a?(::Integer) && ny.positive?
12
31
 
13
32
  @element = element
14
33
  @position = position
@@ -17,28 +36,63 @@ module Sevgi
17
36
  @ny = ny
18
37
  end
19
38
 
39
+ # Returns a row by index.
40
+ # @param i [Integer] row index
41
+ # @return [Array<Sevgi::Geometry::Element>, nil]
20
42
  def [](i) = rows[i]
21
43
 
44
+ # Returns the bounding rectangle of the whole tile.
45
+ # @return [Sevgi::Geometry::Rect]
22
46
  def box = @box ||= Geometry::Rect[nx * element.box.width, ny * element.box.height, position:]
23
47
 
48
+ # Returns the first cell in the tile.
49
+ # @return [Sevgi::Geometry::Element]
24
50
  def cell = row.first
25
51
 
52
+ # Returns the bounding rectangle of a column.
53
+ # @param i [Integer] column index
54
+ # @return [Sevgi::Geometry::Rect]
26
55
  def colbox(i = 0) = Geometry::Rect[element.box.width, box.height, position: coordinate(0, i)]
27
56
 
57
+ # Returns cells grouped by column.
58
+ # @return [Array<Array<Sevgi::Geometry::Element>>]
28
59
  def cols = @cols ||= rows.transpose
29
60
 
61
+ # Returns a column by index.
62
+ # @param i [Integer] column index
63
+ # @return [Array<Sevgi::Geometry::Element>, nil]
30
64
  def col(i = 0) = cols[i]
31
65
 
66
+ # Iterates over rows.
67
+ # @yield [row] each row
68
+ # @yieldparam row [Array<Sevgi::Geometry::Element>] row cells
69
+ # @yieldreturn [void]
70
+ # @return [Enumerator, Array<Array<Sevgi::Geometry::Element>>] enumerator without a block, otherwise rows
32
71
  def each(...) = rows.each(...)
33
72
 
73
+ # Iterates over columns.
74
+ # @yield [column] each column
75
+ # @yieldparam column [Array<Sevgi::Geometry::Element>] column cells
76
+ # @yieldreturn [void]
77
+ # @return [Enumerator, Array<Array<Sevgi::Geometry::Element>>] enumerator without a block, otherwise columns
34
78
  def each_col(...) = cols.each(...)
35
79
 
80
+ # Returns a row by index.
81
+ # @param i [Integer] row index
82
+ # @return [Array<Sevgi::Geometry::Element>, nil]
36
83
  def row(i = 0) = rows[i]
37
84
 
85
+ # Returns the bounding rectangle of a row.
86
+ # @param i [Integer] row index
87
+ # @return [Sevgi::Geometry::Rect]
38
88
  def rowbox(i = 0) = Geometry::Rect[box.width, element.box.height, position: coordinate(i)]
39
89
 
90
+ # Returns cells grouped by row.
91
+ # @return [Array<Array<Sevgi::Geometry::Element>>]
40
92
  def rows = @rows ||= (0...ny).map { |i| (0...nx).map { |j| element.at(coordinate(i, j)) } }
41
93
 
94
+ # Iterates over rows.
95
+ # @return [Enumerator, Array<Array<Sevgi::Geometry::Element>>] enumerator without a block, otherwise rows
42
96
  alias each_row each
43
97
 
44
98
  private
@@ -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.93.1"
6
7
  end
7
8
  end
@@ -9,3 +9,9 @@ require_relative "sundries/grid"
9
9
  require_relative "sundries/export"
10
10
 
11
11
  require_relative "sundries/version"
12
+
13
+ module Sevgi
14
+ # Layout, tiling, grid, and export helpers shared by Sevgi consumers.
15
+ module Sundries
16
+ end
17
+ 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.93.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Recai Oktaş
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.73.2
18
+ version: 0.93.1
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.93.1
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: cairo
28
28
  requirement: !ruby/object:Gem::Requirement