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 +4 -4
- data/CHANGELOG.md +4 -0
- data/LICENSE +5 -0
- data/README.md +55 -0
- data/lib/sevgi/sundries/export/native.rb +122 -47
- data/lib/sevgi/sundries/export/system.rb +52 -7
- data/lib/sevgi/sundries/export.rb +115 -1
- data/lib/sevgi/sundries/grid.rb +82 -6
- data/lib/sevgi/sundries/ruler.rb +167 -20
- data/lib/sevgi/sundries/tile.rb +73 -2
- data/lib/sevgi/sundries/version.rb +2 -1
- data/lib/sevgi/sundries.rb +12 -0
- metadata +16 -28
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e8ddc5568e2938adbef615c7bc12663083287016955cd708bd342ec95423bfea
|
|
4
|
+
data.tar.gz: 64b06db206dcf954131c8348e0e1871a39c80677f83a15fc683cc84ab943d2aa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: de63cbabb66ebaa15dfbb78d4aa5cb14384fb8414c15cd13f4a5e1665c3e8a28d66bfef4e7014acb51c486024efcc1e545f04d10172b228716e27050ac1854ff
|
|
7
|
+
data.tar.gz: 9b74f16efebe20b184b0ba277d588e20403cb3633256868b03b8d9bcf608f846bb266283283bbf7316e65fb0de5c065d69e20ab3f5fd5c7c597ecf74b0f1bc57
|
data/CHANGELOG.md
ADDED
data/LICENSE
ADDED
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
24
|
-
|
|
42
|
+
format = format_for!(format, output)
|
|
43
|
+
renderer = Renderer.method(format)
|
|
25
44
|
|
|
26
|
-
|
|
27
|
-
|
|
45
|
+
begin
|
|
46
|
+
handle = Rsvg::Handle.new_from_data(svg)
|
|
28
47
|
|
|
29
|
-
|
|
48
|
+
iw, ih = intrinsic_size(handle)
|
|
49
|
+
ExportError.("Invalid SVG dimensions") if iw <= 0 || ih <= 0
|
|
30
50
|
|
|
31
|
-
|
|
32
|
-
ih *= scale
|
|
51
|
+
scale = dpi / DEFAULT_DPI
|
|
33
52
|
|
|
34
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
|
94
|
+
data, count = stamp_stream(obj.stream, stamp:, placeholder:)
|
|
95
|
+
next if count.zero?
|
|
87
96
|
|
|
88
|
-
|
|
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
|
|
100
|
-
|
|
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
|
-
#
|
|
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
|
|
@@ -1,4 +1,118 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
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
|
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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/sevgi/sundries/ruler.rb
CHANGED
|
@@ -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
|
-
#
|
|
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 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
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
|
-
|
|
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
|
data/lib/sevgi/sundries/tile.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/sevgi/sundries.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
25
|
+
version: 0.94.0
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
|
-
name:
|
|
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:
|
|
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:
|
|
39
|
+
version: 0.94.0
|
|
54
40
|
- !ruby/object:Gem::Dependency
|
|
55
|
-
name:
|
|
41
|
+
name: sevgi-graphics
|
|
56
42
|
requirement: !ruby/object:Gem::Requirement
|
|
57
43
|
requirements:
|
|
58
|
-
- -
|
|
44
|
+
- - '='
|
|
59
45
|
- !ruby/object:Gem::Version
|
|
60
|
-
version:
|
|
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:
|
|
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
|
|
86
|
+
version: 3.4.0
|
|
99
87
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
100
88
|
requirements:
|
|
101
89
|
- - ">="
|