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 +4 -4
- data/README.md +5 -0
- data/lib/sevgi/sundries/export/native.rb +87 -9
- data/lib/sevgi/sundries/export/system.rb +52 -7
- data/lib/sevgi/sundries/grid.rb +89 -6
- data/lib/sevgi/sundries/ruler.rb +112 -17
- data/lib/sevgi/sundries/tile.rb +55 -1
- data/lib/sevgi/sundries/version.rb +2 -1
- data/lib/sevgi/sundries.rb +6 -0
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6094018249be152bff171c6077c6b5f7c9bd9f0d24c68381afbe05674e121da0
|
|
4
|
+
data.tar.gz: 8b9a02a83b2fd4b3614b65fed7c7922b611b78790e187167db3361cdd301cdeb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ea257e8c2b131b3ae278d35d14850391f6a4bf33467a528d87aa5ec0cd8dd3c4109376da978a3fdd4352d230f5c668d268914e390f33be4c1d28a775d5eabee9
|
|
7
|
+
data.tar.gz: 7ba89c1d630142ac180bc64aaa15564bb2d54b5a5e68daa1cd2a814f546cef6c1b76d08ff8504f64e4d0a9880c505577ca958c9ae96ce3e814fb584035593879
|
data/README.md
CHANGED
|
@@ -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
|
-
|
|
24
|
-
|
|
44
|
+
begin
|
|
45
|
+
handle = Rsvg::Handle.new_from_data(svg)
|
|
25
46
|
|
|
26
|
-
|
|
27
|
-
|
|
47
|
+
iw, ih = intrinsic_size(handle)
|
|
48
|
+
ExportError.("Invalid SVG dimensions") if iw <= 0 || ih <= 0
|
|
28
49
|
|
|
29
|
-
|
|
50
|
+
scale = dpi / DEFAULT_DPI
|
|
30
51
|
|
|
31
|
-
|
|
32
|
-
|
|
52
|
+
iw *= scale
|
|
53
|
+
ih *= scale
|
|
33
54
|
|
|
34
|
-
|
|
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
|
-
#
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
#
|
|
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
|
data/lib/sevgi/sundries/grid.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/sevgi/sundries/ruler.rb
CHANGED
|
@@ -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
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
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
|
-
|
|
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
|
data/lib/sevgi/sundries/tile.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/sevgi/sundries.rb
CHANGED
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.
|
|
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.
|
|
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.
|
|
25
|
+
version: 0.93.1
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: cairo
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|