vectory 0.8.0 → 0.8.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/.github/workflows/docs.yml +59 -0
- data/.github/workflows/links.yml +99 -0
- data/.github/workflows/rake.yml +5 -1
- data/.github/workflows/release.yml +7 -3
- data/.gitignore +5 -0
- data/.rubocop.yml +11 -3
- data/.rubocop_todo.yml +252 -0
- data/Gemfile +4 -2
- data/README.adoc +23 -1
- data/Rakefile +13 -0
- data/docs/Gemfile +18 -0
- data/docs/_config.yml +179 -0
- data/docs/features/conversion.adoc +205 -0
- data/docs/features/external-dependencies.adoc +305 -0
- data/docs/features/format-detection.adoc +173 -0
- data/docs/features/index.adoc +205 -0
- data/docs/getting-started/core-concepts.adoc +214 -0
- data/docs/getting-started/index.adoc +37 -0
- data/docs/getting-started/installation.adoc +318 -0
- data/docs/getting-started/quick-start.adoc +160 -0
- data/docs/guides/error-handling.adoc +400 -0
- data/docs/guides/index.adoc +197 -0
- data/docs/index.adoc +146 -0
- data/docs/lychee.toml +25 -0
- data/docs/reference/api.adoc +355 -0
- data/docs/reference/index.adoc +189 -0
- data/docs/understanding/architecture.adoc +277 -0
- data/docs/understanding/index.adoc +148 -0
- data/docs/understanding/inkscape-wrapper.adoc +270 -0
- data/lib/vectory/capture.rb +165 -37
- data/lib/vectory/cli.rb +2 -0
- data/lib/vectory/configuration.rb +177 -0
- data/lib/vectory/conversion/ghostscript_strategy.rb +77 -0
- data/lib/vectory/conversion/inkscape_strategy.rb +124 -0
- data/lib/vectory/conversion/strategy.rb +58 -0
- data/lib/vectory/conversion.rb +104 -0
- data/lib/vectory/datauri.rb +1 -1
- data/lib/vectory/emf.rb +17 -5
- data/lib/vectory/eps.rb +45 -3
- data/lib/vectory/errors.rb +25 -0
- data/lib/vectory/file_magic.rb +2 -2
- data/lib/vectory/ghostscript_wrapper.rb +160 -0
- data/lib/vectory/image_resize.rb +2 -2
- data/lib/vectory/inkscape_wrapper.rb +205 -0
- data/lib/vectory/pdf.rb +76 -0
- data/lib/vectory/platform.rb +105 -0
- data/lib/vectory/ps.rb +47 -3
- data/lib/vectory/svg.rb +46 -3
- data/lib/vectory/svg_document.rb +40 -24
- data/lib/vectory/system_call.rb +36 -9
- data/lib/vectory/vector.rb +3 -23
- data/lib/vectory/version.rb +1 -1
- data/lib/vectory.rb +16 -11
- metadata +34 -3
- data/lib/vectory/inkscape_converter.rb +0 -141
data/lib/vectory/capture.rb
CHANGED
|
@@ -3,15 +3,16 @@ require "timeout"
|
|
|
3
3
|
module Vectory
|
|
4
4
|
module Capture
|
|
5
5
|
class << self
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
def windows?
|
|
7
|
+
!!((RUBY_PLATFORM =~ /(win|w)(32|64)$/) ||
|
|
8
|
+
(RUBY_PLATFORM =~ /mswin|mingw/))
|
|
9
|
+
end
|
|
10
|
+
|
|
10
11
|
# Capture the standard output and the standard error of a command.
|
|
11
12
|
# Almost same as Open3.capture3 method except for timeout handling and return value.
|
|
12
13
|
# See Open3.capture3.
|
|
13
14
|
#
|
|
14
|
-
# result =
|
|
15
|
+
# result = with_timeout([env,] cmd... [, opts])
|
|
15
16
|
#
|
|
16
17
|
# The arguments env, cmd and opts are passed to Process.spawn except
|
|
17
18
|
# opts[:stdin_data], opts[:binmode], opts[:timeout], opts[:signal]
|
|
@@ -39,12 +40,19 @@ module Vectory
|
|
|
39
40
|
# }
|
|
40
41
|
def with_timeout(*cmd)
|
|
41
42
|
spawn_opts = Hash === cmd.last ? cmd.pop.dup : {}
|
|
43
|
+
|
|
44
|
+
# Separate environment variables (string keys) from spawn options (symbol keys)
|
|
45
|
+
env_vars = spawn_opts.reject { |k, _| k.is_a?(Symbol) }
|
|
46
|
+
spawn_opts = spawn_opts.reject { |k, _| k.is_a?(String) }
|
|
47
|
+
|
|
48
|
+
# Windows only supports :KILL signal reliably, Unix can use :TERM for graceful shutdown
|
|
49
|
+
default_signal = windows? ? :KILL : :TERM
|
|
42
50
|
opts = {
|
|
43
|
-
:
|
|
44
|
-
:
|
|
45
|
-
:
|
|
46
|
-
:
|
|
47
|
-
:
|
|
51
|
+
stdin_data: spawn_opts.delete(:stdin_data) || "",
|
|
52
|
+
binmode: spawn_opts.delete(:binmode) || false,
|
|
53
|
+
timeout: spawn_opts.delete(:timeout),
|
|
54
|
+
signal: spawn_opts.delete(:signal) || default_signal,
|
|
55
|
+
kill_after: spawn_opts.delete(:kill_after) || 2,
|
|
48
56
|
}
|
|
49
57
|
|
|
50
58
|
in_r, in_w = IO.pipe
|
|
@@ -63,53 +71,173 @@ module Vectory
|
|
|
63
71
|
spawn_opts[:err] = err_w
|
|
64
72
|
|
|
65
73
|
result = {
|
|
66
|
-
:
|
|
67
|
-
:
|
|
68
|
-
:
|
|
69
|
-
:
|
|
70
|
-
:
|
|
74
|
+
pid: nil,
|
|
75
|
+
status: nil,
|
|
76
|
+
stdout: "",
|
|
77
|
+
stderr: "",
|
|
78
|
+
timeout: false,
|
|
71
79
|
}
|
|
72
80
|
|
|
73
81
|
out_reader = nil
|
|
74
82
|
err_reader = nil
|
|
75
83
|
wait_thr = nil
|
|
84
|
+
watchdog = nil
|
|
76
85
|
|
|
77
86
|
begin
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
87
|
+
# Pass environment variables and command to spawn
|
|
88
|
+
# spawn signature: spawn([env], cmd..., [options])
|
|
89
|
+
# If env_vars is not empty, pass it as the first argument
|
|
90
|
+
result[:pid] = if env_vars.any?
|
|
91
|
+
spawn(env_vars, *cmd, spawn_opts)
|
|
92
|
+
else
|
|
93
|
+
spawn(*cmd, spawn_opts)
|
|
94
|
+
end
|
|
95
|
+
wait_thr = Process.detach(result[:pid])
|
|
96
|
+
in_r.close
|
|
97
|
+
out_w.close
|
|
98
|
+
err_w.close
|
|
84
99
|
|
|
85
|
-
|
|
86
|
-
|
|
100
|
+
# Start reader threads with timeout protection
|
|
101
|
+
out_reader = Thread.new do
|
|
102
|
+
out_r.read
|
|
103
|
+
rescue StandardError => e
|
|
104
|
+
Vectory.ui.debug("Output reader error: #{e}")
|
|
105
|
+
""
|
|
106
|
+
end
|
|
87
107
|
|
|
108
|
+
err_reader = Thread.new do
|
|
109
|
+
err_r.read
|
|
110
|
+
rescue StandardError => e
|
|
111
|
+
Vectory.ui.debug("Error reader error: #{e}")
|
|
112
|
+
""
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Write input data
|
|
116
|
+
begin
|
|
88
117
|
in_w.write opts[:stdin_data]
|
|
89
118
|
in_w.close
|
|
119
|
+
rescue Errno::EPIPE
|
|
120
|
+
# Process may have exited early
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Watchdog thread to enforce timeout
|
|
124
|
+
if opts[:timeout]
|
|
125
|
+
watchdog = Thread.new do
|
|
126
|
+
sleep opts[:timeout]
|
|
127
|
+
if windows?
|
|
128
|
+
# Windows: Use spawn to run taskkill in background (non-blocking)
|
|
129
|
+
if wait_thr.alive?
|
|
130
|
+
result[:timeout] = true
|
|
131
|
+
# Spawn taskkill in background to avoid blocking
|
|
132
|
+
begin
|
|
133
|
+
Process.spawn("taskkill", "/pid", result[:pid].to_s, "/f",
|
|
134
|
+
%i[out err] => File::NULL)
|
|
135
|
+
rescue Errno::ENOENT
|
|
136
|
+
# taskkill not found (shouldn't happen on Windows)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
elsif wait_thr.alive?
|
|
140
|
+
# Unix: Use Process.kill which works reliably
|
|
141
|
+
result[:timeout] = true
|
|
142
|
+
pid = spawn_opts[:pgroup] ? -result[:pid] : result[:pid]
|
|
143
|
+
|
|
144
|
+
begin
|
|
145
|
+
Process.kill(opts[:signal], pid)
|
|
146
|
+
rescue Errno::ESRCH, Errno::EINVAL, Errno::EPERM
|
|
147
|
+
# Process already dead, invalid signal, or permission denied
|
|
148
|
+
end
|
|
90
149
|
|
|
91
|
-
|
|
150
|
+
# Wait for kill_after duration, then force kill
|
|
151
|
+
sleep opts[:kill_after]
|
|
152
|
+
if wait_thr.alive?
|
|
153
|
+
begin
|
|
154
|
+
Process.kill(:KILL, pid)
|
|
155
|
+
rescue Errno::ESRCH, Errno::EINVAL, Errno::EPERM
|
|
156
|
+
# Process already dead, invalid signal, or permission denied
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
92
161
|
end
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
162
|
+
|
|
163
|
+
# Wait for process to complete with timeout
|
|
164
|
+
if opts[:timeout]
|
|
165
|
+
if windows?
|
|
166
|
+
# On Windows, use polling with timeout to avoid long sleeps
|
|
167
|
+
deadline = Time.now + opts[:timeout] + 5
|
|
168
|
+
loop do
|
|
169
|
+
break unless wait_thr.alive?
|
|
170
|
+
break if Time.now > deadline
|
|
171
|
+
|
|
172
|
+
sleep 0.5
|
|
173
|
+
end
|
|
174
|
+
else
|
|
175
|
+
deadline = Time.now + opts[:timeout] + (opts[:kill_after] || 2) + 1
|
|
176
|
+
loop do
|
|
177
|
+
break unless wait_thr.alive?
|
|
178
|
+
break if Time.now > deadline
|
|
179
|
+
|
|
180
|
+
sleep 0.1
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Force kill if still alive after deadline
|
|
184
|
+
if wait_thr.alive?
|
|
185
|
+
begin
|
|
186
|
+
Process.kill(:KILL, result[:pid])
|
|
187
|
+
rescue Errno::ESRCH, Errno::EINVAL, Errno::EPERM
|
|
188
|
+
# Process already dead, invalid signal, or permission denied
|
|
189
|
+
end
|
|
190
|
+
end
|
|
100
191
|
end
|
|
101
192
|
end
|
|
193
|
+
|
|
194
|
+
# Wait for process status (with timeout protection)
|
|
195
|
+
status_deadline = Time.now + 5
|
|
196
|
+
while wait_thr.alive? && Time.now < status_deadline
|
|
197
|
+
sleep 0.1
|
|
198
|
+
end
|
|
102
199
|
ensure
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
200
|
+
# Clean up watchdog
|
|
201
|
+
watchdog&.kill
|
|
202
|
+
|
|
203
|
+
# Get process status
|
|
204
|
+
begin
|
|
205
|
+
result[:status] = wait_thr.value if wait_thr
|
|
206
|
+
rescue StandardError => e
|
|
207
|
+
Vectory.ui.debug("Error getting process status: #{e}")
|
|
208
|
+
# Create a fake failed status
|
|
209
|
+
result[:status] = Process::Status.allocate
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Get output with timeout protection
|
|
213
|
+
if out_reader
|
|
214
|
+
if out_reader.join(2)
|
|
215
|
+
result[:stdout] = out_reader.value || ""
|
|
216
|
+
else
|
|
217
|
+
out_reader.kill
|
|
218
|
+
result[:stdout] = ""
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
if err_reader
|
|
223
|
+
if err_reader.join(2)
|
|
224
|
+
result[:stderr] = err_reader.value || ""
|
|
225
|
+
else
|
|
226
|
+
err_reader.kill
|
|
227
|
+
result[:stderr] = ""
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Close all pipes
|
|
232
|
+
[in_w, out_r, err_r].each do |io|
|
|
233
|
+
io.close unless io.closed?
|
|
234
|
+
rescue StandardError => e
|
|
235
|
+
Vectory.ui.debug("Error closing pipe: #{e}")
|
|
236
|
+
end
|
|
108
237
|
end
|
|
109
238
|
|
|
110
239
|
result
|
|
111
240
|
end
|
|
112
|
-
# rubocop:enable all
|
|
113
241
|
end
|
|
114
242
|
end
|
|
115
243
|
end
|
data/lib/vectory/cli.rb
CHANGED
|
@@ -12,10 +12,12 @@ module Vectory
|
|
|
12
12
|
STATUS_SYSTEM_CALL_ERROR = 5
|
|
13
13
|
STATUS_INKSCAPE_NOT_FOUND_ERROR = 6
|
|
14
14
|
STATUS_SAME_FORMAT_ERROR = 7
|
|
15
|
+
STATUS_GHOSTSCRIPT_NOT_FOUND_ERROR = 8
|
|
15
16
|
|
|
16
17
|
MAP_ERROR_TO_STATUS = {
|
|
17
18
|
Vectory::ConversionError => STATUS_CONVERSION_ERROR,
|
|
18
19
|
Vectory::InkscapeNotFoundError => STATUS_INKSCAPE_NOT_FOUND_ERROR,
|
|
20
|
+
Vectory::GhostscriptNotFoundError => STATUS_GHOSTSCRIPT_NOT_FOUND_ERROR,
|
|
19
21
|
Vectory::SystemCallError => STATUS_SYSTEM_CALL_ERROR,
|
|
20
22
|
}.freeze
|
|
21
23
|
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vectory
|
|
4
|
+
# Configuration for Vectory
|
|
5
|
+
#
|
|
6
|
+
# Provides centralized configuration for tool paths, timeouts, caching, etc.
|
|
7
|
+
# Can be loaded from environment variables or a configuration file.
|
|
8
|
+
#
|
|
9
|
+
# @example Set custom Inkscape path
|
|
10
|
+
# Vectory::Configuration.instance.inkscape_path = "/path/to/inkscape"
|
|
11
|
+
#
|
|
12
|
+
# @example Load from environment variables
|
|
13
|
+
# # Set VECTORY_INKSCAPE_PATH environment variable
|
|
14
|
+
# Vectory::Configuration.load_from_environment
|
|
15
|
+
class Configuration
|
|
16
|
+
# Default timeout for external tool execution (seconds)
|
|
17
|
+
DEFAULT_TIMEOUT = 120
|
|
18
|
+
|
|
19
|
+
# Default cache TTL for memoized values (seconds)
|
|
20
|
+
DEFAULT_CACHE_TTL = 300
|
|
21
|
+
|
|
22
|
+
# Default temporary directory
|
|
23
|
+
DEFAULT_TEMP_DIR = nil # Use system default
|
|
24
|
+
|
|
25
|
+
attr_accessor :inkscape_path, :ghostscript_path, :timeout, :cache_ttl,
|
|
26
|
+
:cache_enabled, :temp_dir, :verbose_logging
|
|
27
|
+
|
|
28
|
+
# Get the singleton instance
|
|
29
|
+
#
|
|
30
|
+
# @return [Vectory::Configuration] the configuration instance
|
|
31
|
+
def self.instance
|
|
32
|
+
@instance ||= new
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Reset the configuration to defaults
|
|
36
|
+
#
|
|
37
|
+
# @api private
|
|
38
|
+
def self.reset!
|
|
39
|
+
@instance = new
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Initialize configuration with default values
|
|
43
|
+
def initialize
|
|
44
|
+
@timeout = DEFAULT_TIMEOUT
|
|
45
|
+
@cache_ttl = DEFAULT_CACHE_TTL
|
|
46
|
+
@cache_enabled = true
|
|
47
|
+
@temp_dir = DEFAULT_TEMP_DIR
|
|
48
|
+
@verbose_logging = false
|
|
49
|
+
@inkscape_path = nil
|
|
50
|
+
@ghostscript_path = nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Load configuration from environment variables
|
|
54
|
+
#
|
|
55
|
+
# Supported environment variables:
|
|
56
|
+
# - VECTORY_INKSCAPE_PATH: Path to Inkscape executable
|
|
57
|
+
# - VECTORY_GHOSTSCRIPT_PATH: Path to Ghostscript executable
|
|
58
|
+
# - VECTORY_TIMEOUT: Timeout for external tools (default: 120)
|
|
59
|
+
# - VECTORY_CACHE_TTL: Cache TTL in seconds (default: 300)
|
|
60
|
+
# - VECTORY_CACHE_ENABLED: Enable/disable caching (default: true)
|
|
61
|
+
# - VECTORY_TEMP_DIR: Temporary directory path
|
|
62
|
+
# - VECTORY_VERBOSE: Enable verbose logging (default: false)
|
|
63
|
+
#
|
|
64
|
+
# @return [self] the configuration instance
|
|
65
|
+
def self.load_from_environment
|
|
66
|
+
config = instance
|
|
67
|
+
|
|
68
|
+
config.inkscape_path = ENV["VECTORY_INKSCAPE_PATH"] if ENV["VECTORY_INKSCAPE_PATH"]
|
|
69
|
+
config.ghostscript_path = ENV["VECTORY_GHOSTSCRIPT_PATH"] if ENV["VECTORY_GHOSTSCRIPT_PATH"]
|
|
70
|
+
config.timeout = ENV["VECTORY_TIMEOUT"]&.to_i || config.timeout
|
|
71
|
+
config.cache_ttl = ENV["VECTORY_CACHE_TTL"]&.to_i || config.cache_ttl
|
|
72
|
+
config.cache_enabled = ENV["VECTORY_CACHE_ENABLED"] != "false"
|
|
73
|
+
config.temp_dir = ENV["VECTORY_TEMP_DIR"] if ENV["VECTORY_TEMP_DIR"]
|
|
74
|
+
config.verbose_logging = ENV["VECTORY_VERBOSE"] == "true"
|
|
75
|
+
|
|
76
|
+
config
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Load configuration from a YAML file
|
|
80
|
+
#
|
|
81
|
+
# @param path [String] path to the YAML configuration file
|
|
82
|
+
# @return [self] the configuration instance
|
|
83
|
+
# @raise [Errno::ENOENT] if the file doesn't exist
|
|
84
|
+
def self.load_from_file(path)
|
|
85
|
+
require "yaml"
|
|
86
|
+
config_data = YAML.load_file(path)
|
|
87
|
+
|
|
88
|
+
config = instance
|
|
89
|
+
config.inkscape_path = config_data["inkscape_path"] if config_data["inkscape_path"]
|
|
90
|
+
config.ghostscript_path = config_data["ghostscript_path"] if config_data["ghostscript_path"]
|
|
91
|
+
config.timeout = config_data["timeout"] || config.timeout
|
|
92
|
+
config.cache_ttl = config_data["cache_ttl"] || config.cache_ttl
|
|
93
|
+
config.cache_enabled = config_data.fetch("cache_enabled",
|
|
94
|
+
config.cache_enabled)
|
|
95
|
+
config.temp_dir = config_data["temp_dir"] || config.temp_dir
|
|
96
|
+
config.verbose_logging = config_data.fetch("verbose_logging",
|
|
97
|
+
config.verbose_logging)
|
|
98
|
+
|
|
99
|
+
config
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Export configuration as a hash
|
|
103
|
+
#
|
|
104
|
+
# @return [Hash] the configuration as a hash
|
|
105
|
+
def to_h
|
|
106
|
+
{
|
|
107
|
+
inkscape_path: @inkscape_path,
|
|
108
|
+
ghostscript_path: @ghostscript_path,
|
|
109
|
+
timeout: @timeout,
|
|
110
|
+
cache_ttl: @cache_ttl,
|
|
111
|
+
cache_enabled: @cache_enabled,
|
|
112
|
+
temp_dir: @temp_dir,
|
|
113
|
+
verbose_logging: @verbose_logging,
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Get the Inkscape path (custom or auto-detected)
|
|
118
|
+
#
|
|
119
|
+
# @return [String, nil] the configured Inkscape path or nil if not set
|
|
120
|
+
def effective_inkscape_path
|
|
121
|
+
@inkscape_path
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Get the Ghostscript path (custom or auto-detected)
|
|
125
|
+
#
|
|
126
|
+
# @return [String, nil] the configured Ghostscript path or nil if not set
|
|
127
|
+
def effective_ghostscript_path
|
|
128
|
+
@ghostscript_path
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Check if caching is enabled
|
|
132
|
+
#
|
|
133
|
+
# @return [Boolean] true if caching is enabled
|
|
134
|
+
def caching_enabled?
|
|
135
|
+
@cache_enabled
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Get the temporary directory
|
|
139
|
+
#
|
|
140
|
+
# @return [String] the temporary directory path
|
|
141
|
+
def temporary_directory
|
|
142
|
+
@temp_dir || Dir.tmpdir
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Check if verbose logging is enabled
|
|
146
|
+
#
|
|
147
|
+
# @return [Boolean] true if verbose logging is enabled
|
|
148
|
+
def verbose_logging?
|
|
149
|
+
@verbose_logging
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Validate the configuration
|
|
153
|
+
#
|
|
154
|
+
# @return [Boolean] true if configuration is valid
|
|
155
|
+
# @raise [ArgumentError] if configuration is invalid
|
|
156
|
+
def validate!
|
|
157
|
+
errors = []
|
|
158
|
+
|
|
159
|
+
if @timeout && @timeout <= 0
|
|
160
|
+
errors << "timeout must be positive, got: #{@timeout}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
if @cache_ttl&.negative?
|
|
164
|
+
errors << "cache_ttl must be non-negative, got: #{@cache_ttl}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
if @temp_dir && !File.directory?(@temp_dir)
|
|
168
|
+
errors << "temp_dir does not exist: #{@temp_dir}"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
return true if errors.empty?
|
|
172
|
+
|
|
173
|
+
raise ArgumentError,
|
|
174
|
+
"Invalid configuration:\n - #{errors.join("\n - ")}"
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../ghostscript_wrapper"
|
|
4
|
+
require_relative "strategy"
|
|
5
|
+
|
|
6
|
+
module Vectory
|
|
7
|
+
module Conversion
|
|
8
|
+
# Ghostscript-based conversion strategy
|
|
9
|
+
#
|
|
10
|
+
# Handles PS/EPS → PDF conversions using Ghostscript.
|
|
11
|
+
# Ghostscript is used for its accurate BoundingBox preservation.
|
|
12
|
+
#
|
|
13
|
+
# @see https://www.ghostscript.com/
|
|
14
|
+
class GhostscriptStrategy < Strategy
|
|
15
|
+
# Ghostscript supports PS/EPS → PDF conversions
|
|
16
|
+
SUPPORTED_CONVERSIONS = [
|
|
17
|
+
%i[ps pdf],
|
|
18
|
+
%i[eps pdf],
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
# Convert PS/EPS content to PDF using Ghostscript
|
|
22
|
+
#
|
|
23
|
+
# @param content [String] the PS/EPS content to convert
|
|
24
|
+
# @param input_format [Symbol] the input format (:ps or :eps)
|
|
25
|
+
# @param output_format [Symbol] the output format (must be :pdf)
|
|
26
|
+
# @param options [Hash] additional options
|
|
27
|
+
# @option options [Boolean] :eps_crop use EPSCrop for better BoundingBox handling
|
|
28
|
+
# @return [String] the PDF content
|
|
29
|
+
# @raise [Vectory::GhostscriptNotFoundError] if Ghostscript is not available
|
|
30
|
+
# @raise [Vectory::ConversionError] if conversion fails
|
|
31
|
+
def convert(content, input_format:, output_format:, **options)
|
|
32
|
+
unless output_format == :pdf
|
|
33
|
+
raise ArgumentError,
|
|
34
|
+
"Ghostscript only supports PDF output, got: #{output_format}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
unless %i[ps eps].include?(input_format)
|
|
38
|
+
raise ArgumentError,
|
|
39
|
+
"Ghostscript only supports PS/EPS input, got: #{input_format}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
GhostscriptWrapper.convert(content,
|
|
43
|
+
eps_crop: options[:eps_crop] || false)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Check if this conversion is supported
|
|
47
|
+
#
|
|
48
|
+
# @param input_format [Symbol] the input format
|
|
49
|
+
# @param output_format [Symbol] the output format
|
|
50
|
+
# @return [Boolean] true if Ghostscript supports this conversion
|
|
51
|
+
def supports?(input_format, output_format)
|
|
52
|
+
SUPPORTED_CONVERSIONS.include?([input_format, output_format])
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Get supported conversions
|
|
56
|
+
#
|
|
57
|
+
# @return [Array<Array<Symbol>>] array of [input, output] format pairs
|
|
58
|
+
def supported_conversions
|
|
59
|
+
SUPPORTED_CONVERSIONS
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Check if Ghostscript is available
|
|
63
|
+
#
|
|
64
|
+
# @return [Boolean] true if Ghostscript can be found
|
|
65
|
+
def available?
|
|
66
|
+
GhostscriptWrapper.available?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Get the tool name
|
|
70
|
+
#
|
|
71
|
+
# @return [String] "ghostscript"
|
|
72
|
+
def tool_name
|
|
73
|
+
"ghostscript"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../inkscape_wrapper"
|
|
4
|
+
require_relative "strategy"
|
|
5
|
+
|
|
6
|
+
module Vectory
|
|
7
|
+
module Conversion
|
|
8
|
+
# Inkscape-based conversion strategy
|
|
9
|
+
#
|
|
10
|
+
# Handles conversions using Inkscape, including:
|
|
11
|
+
# - SVG ↔ EPS
|
|
12
|
+
# - SVG ↔ PS
|
|
13
|
+
# - SVG ↔ EMF
|
|
14
|
+
# - SVG ↔ PDF
|
|
15
|
+
# - EPS/PS → PDF
|
|
16
|
+
#
|
|
17
|
+
# @see https://inkscape.org/
|
|
18
|
+
class InkscapeStrategy < Strategy
|
|
19
|
+
# Inkscape supports bidirectional conversion with SVG as source/target
|
|
20
|
+
SUPPORTED_CONVERSIONS = [
|
|
21
|
+
%i[svg eps],
|
|
22
|
+
%i[svg ps],
|
|
23
|
+
%i[svg emf],
|
|
24
|
+
%i[svg pdf],
|
|
25
|
+
%i[eps svg],
|
|
26
|
+
%i[eps pdf],
|
|
27
|
+
%i[ps svg],
|
|
28
|
+
%i[ps pdf],
|
|
29
|
+
%i[pdf svg],
|
|
30
|
+
%i[emf svg],
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
# Convert content using Inkscape
|
|
34
|
+
#
|
|
35
|
+
# @param content [String] the input content to convert
|
|
36
|
+
# @param input_format [Symbol] the input format
|
|
37
|
+
# @param output_format [Symbol] the output format
|
|
38
|
+
# @param options [Hash] additional options
|
|
39
|
+
# @option options [Boolean] :plain export plain SVG (for SVG output)
|
|
40
|
+
# @option options [Class] :output_class the class to instantiate with result
|
|
41
|
+
# @return [Vectory::Vector] the converted vector object
|
|
42
|
+
# @raise [Vectory::InkscapeNotFoundError] if Inkscape is not available
|
|
43
|
+
def convert(content, input_format:, output_format:, **options)
|
|
44
|
+
output_class = options.fetch(:output_class) do
|
|
45
|
+
format_class(output_format)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
InkscapeWrapper.convert(
|
|
49
|
+
content: content,
|
|
50
|
+
input_format: input_format,
|
|
51
|
+
output_format: output_format,
|
|
52
|
+
output_class: output_class,
|
|
53
|
+
plain: options[:plain] || false,
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Check if this conversion is supported
|
|
58
|
+
#
|
|
59
|
+
# @param input_format [Symbol] the input format
|
|
60
|
+
# @param output_format [Symbol] the output format
|
|
61
|
+
# @return [Boolean] true if Inkscape supports this conversion
|
|
62
|
+
def supports?(input_format, output_format)
|
|
63
|
+
SUPPORTED_CONVERSIONS.include?([input_format, output_format])
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Get supported conversions
|
|
67
|
+
#
|
|
68
|
+
# @return [Array<Array<Symbol>>] array of [input, output] format pairs
|
|
69
|
+
def supported_conversions
|
|
70
|
+
SUPPORTED_CONVERSIONS
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Check if Inkscape is available
|
|
74
|
+
#
|
|
75
|
+
# @return [Boolean] true if Inkscape can be found in PATH
|
|
76
|
+
def available?
|
|
77
|
+
InkscapeWrapper.instance.send(:inkscape_path)
|
|
78
|
+
true
|
|
79
|
+
rescue Vectory::InkscapeNotFoundError
|
|
80
|
+
false
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Get the tool name
|
|
84
|
+
#
|
|
85
|
+
# @return [String] "inkscape"
|
|
86
|
+
def tool_name
|
|
87
|
+
"inkscape"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Query the width of content
|
|
91
|
+
#
|
|
92
|
+
# @param content [String] the vector content
|
|
93
|
+
# @param format [Symbol] the format of the content
|
|
94
|
+
# @return [Integer] the width in pixels
|
|
95
|
+
def width(content, format)
|
|
96
|
+
InkscapeWrapper.instance.width(content, format)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Query the height of content
|
|
100
|
+
#
|
|
101
|
+
# @param content [String] the vector content
|
|
102
|
+
# @param format [Symbol] the format of the content
|
|
103
|
+
# @return [Integer] the height in pixels
|
|
104
|
+
def height(content, format)
|
|
105
|
+
InkscapeWrapper.instance.height(content, format)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
# Get the Vectory class for a format
|
|
111
|
+
def format_class(format)
|
|
112
|
+
case format
|
|
113
|
+
when :svg then Vectory::Svg
|
|
114
|
+
when :eps then Vectory::Eps
|
|
115
|
+
when :ps then Vectory::Ps
|
|
116
|
+
when :emf then Vectory::Emf
|
|
117
|
+
when :pdf then Vectory::Pdf
|
|
118
|
+
else
|
|
119
|
+
raise ArgumentError, "Unsupported format: #{format}"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vectory
|
|
4
|
+
module Conversion
|
|
5
|
+
# Base class for conversion strategies
|
|
6
|
+
#
|
|
7
|
+
# Conversion strategies encapsulate the logic for converting between
|
|
8
|
+
# different vector formats using external tools (Inkscape, Ghostscript, etc.)
|
|
9
|
+
#
|
|
10
|
+
# @abstract Subclasses must implement the {#convert} method
|
|
11
|
+
class Strategy
|
|
12
|
+
# Convert content from one format to another
|
|
13
|
+
#
|
|
14
|
+
# @param content [String] the input content to convert
|
|
15
|
+
# @param input_format [Symbol] the input format (e.g., :svg, :eps, :ps)
|
|
16
|
+
# @param output_format [Symbol] the desired output format (e.g., :svg, :pdf, :eps)
|
|
17
|
+
# @param options [Hash] additional options for the conversion
|
|
18
|
+
# @return [String] the converted content
|
|
19
|
+
# @raise [Vectory::ConversionError] if conversion fails
|
|
20
|
+
# @abstract
|
|
21
|
+
def convert(content, input_format:, output_format:, **options)
|
|
22
|
+
raise NotImplementedError,
|
|
23
|
+
"#{self.class} must implement #convert method"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Check if this strategy supports the given conversion
|
|
27
|
+
#
|
|
28
|
+
# @param input_format [Symbol] the input format
|
|
29
|
+
# @param output_format [Symbol] the output format
|
|
30
|
+
# @return [Boolean] true if this strategy supports the conversion
|
|
31
|
+
def supports?(input_format, output_format)
|
|
32
|
+
supported_conversions.include?([input_format, output_format])
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Get the list of conversions this strategy supports
|
|
36
|
+
#
|
|
37
|
+
# @return [Array<Array<Symbol>>] array of [input, output] format pairs
|
|
38
|
+
def supported_conversions
|
|
39
|
+
[]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Check if the required external tool is available
|
|
43
|
+
#
|
|
44
|
+
# @return [Boolean] true if the tool is available
|
|
45
|
+
def available?
|
|
46
|
+
raise NotImplementedError,
|
|
47
|
+
"#{self.class} must implement #available? method"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Get the name of the external tool used by this strategy
|
|
51
|
+
#
|
|
52
|
+
# @return [String] the tool name
|
|
53
|
+
def tool_name
|
|
54
|
+
self.class.name.split("::").last.gsub(/Strategy$/, "").downcase
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|