vectory 0.8.2 → 0.9.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/.github/workflows/rake.yml +2 -0
- data/Gemfile +1 -0
- data/lib/vectory/cli.rb +1 -2
- data/lib/vectory/conversion/ghostscript_strategy.rb +0 -3
- data/lib/vectory/conversion/inkscape_strategy.rb +0 -3
- data/lib/vectory/conversion.rb +4 -4
- data/lib/vectory/eps.rb +0 -3
- data/lib/vectory/ghostscript_wrapper.rb +98 -75
- data/lib/vectory/inkscape_wrapper.rb +141 -120
- data/lib/vectory/pdf.rb +65 -25
- data/lib/vectory/ps.rb +0 -3
- data/lib/vectory/svg_mapping.rb +0 -3
- data/lib/vectory/vector.rb +0 -1
- data/lib/vectory/version.rb +1 -1
- data/lib/vectory.rb +39 -20
- data/vectory.gemspec +2 -1
- metadata +17 -6
- data/.hound.yml +0 -5
- data/lib/vectory/capture.rb +0 -243
- data/lib/vectory/system_call.rb +0 -86
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fc294acc45e36e018340d14a047047d5addc5c712fabc42fec2642b82bc77aa8
|
|
4
|
+
data.tar.gz: '09b35052e8b019a2553eaa34ae14c6fce0344c5fc8d0e61b42fc4bb574bbe569'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ffb21fcdc31b15763547be90085ec49288588c7fabc9d9a2f803f5a0fcf5f958c3bf020e7f5aa96abeb2d31de2c1077e6cb9b9fa57b108e28355328e2fa64eb3
|
|
7
|
+
data.tar.gz: 989f2bb3ade3b0416571b67d7cf8262def78291e9730ee73050052ecc3fa84639be2f93d21af392fc7f7b2a0eb3be3aedd19afe7628598aa60f01afcd4d4f5ae
|
data/.github/workflows/rake.yml
CHANGED
data/Gemfile
CHANGED
data/lib/vectory/cli.rb
CHANGED
data/lib/vectory/conversion.rb
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "conversion/strategy"
|
|
4
|
-
require_relative "conversion/inkscape_strategy"
|
|
5
|
-
require_relative "conversion/ghostscript_strategy"
|
|
6
|
-
|
|
7
3
|
module Vectory
|
|
8
4
|
# Conversion module provides strategy-based conversion interface
|
|
9
5
|
#
|
|
@@ -16,6 +12,10 @@ module Vectory
|
|
|
16
12
|
# @example Get available strategies for a conversion
|
|
17
13
|
# Vectory::Conversion.strategies_for(:svg, :eps)
|
|
18
14
|
module Conversion
|
|
15
|
+
# Autoload strategy classes
|
|
16
|
+
autoload :Strategy, "vectory/conversion/strategy"
|
|
17
|
+
autoload :InkscapeStrategy, "vectory/conversion/inkscape_strategy"
|
|
18
|
+
autoload :GhostscriptStrategy, "vectory/conversion/ghostscript_strategy"
|
|
19
19
|
class << self
|
|
20
20
|
# Convert content from one format to another
|
|
21
21
|
#
|
data/lib/vectory/eps.rb
CHANGED
|
@@ -2,18 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
require "tempfile"
|
|
4
4
|
require "fileutils"
|
|
5
|
-
|
|
6
|
-
require_relative "system_call"
|
|
7
|
-
require_relative "platform"
|
|
5
|
+
require "ukiryu"
|
|
8
6
|
|
|
9
7
|
module Vectory
|
|
10
8
|
# GhostscriptWrapper converts PS and EPS files to PDF using Ghostscript
|
|
9
|
+
#
|
|
10
|
+
# Uses Ukiryu for platform-adaptive command execution.
|
|
11
11
|
class GhostscriptWrapper
|
|
12
12
|
SUPPORTED_INPUT_FORMATS = %w[ps eps].freeze
|
|
13
13
|
|
|
14
14
|
class << self
|
|
15
15
|
def available?
|
|
16
|
-
|
|
16
|
+
ghostscript_tool
|
|
17
17
|
true
|
|
18
18
|
rescue GhostscriptNotFoundError
|
|
19
19
|
false
|
|
@@ -22,9 +22,8 @@ module Vectory
|
|
|
22
22
|
def version
|
|
23
23
|
return nil unless available?
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
call.stdout.strip
|
|
25
|
+
tool = ghostscript_tool
|
|
26
|
+
tool.version
|
|
28
27
|
rescue StandardError
|
|
29
28
|
nil
|
|
30
29
|
end
|
|
@@ -49,15 +48,22 @@ module Vectory
|
|
|
49
48
|
# Close output file so GhostScript can write to it
|
|
50
49
|
output_file.close
|
|
51
50
|
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
# Get the tool and execute
|
|
52
|
+
tool = ghostscript_tool
|
|
53
|
+
params = build_convert_params(input_file.path, output_file.path,
|
|
54
|
+
eps_crop: eps_crop)
|
|
54
55
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
result = tool.execute(:convert,
|
|
57
|
+
execution_timeout: Configuration.instance.timeout,
|
|
58
|
+
**params)
|
|
59
|
+
|
|
60
|
+
unless result.success?
|
|
59
61
|
raise ConversionError,
|
|
60
|
-
"GhostScript conversion failed
|
|
62
|
+
"GhostScript conversion failed. " \
|
|
63
|
+
"Command: #{result.command}, " \
|
|
64
|
+
"Exit status: #{result.status}, " \
|
|
65
|
+
"stdout: '#{result.stdout.strip}', " \
|
|
66
|
+
"stderr: '#{result.stderr.strip}'"
|
|
61
67
|
end
|
|
62
68
|
|
|
63
69
|
unless File.exist?(output_file.path)
|
|
@@ -71,9 +77,9 @@ module Vectory
|
|
|
71
77
|
if output_content.size < 100
|
|
72
78
|
raise ConversionError,
|
|
73
79
|
"GhostScript created invalid PDF (#{output_content.size} bytes). " \
|
|
74
|
-
"Command: #{
|
|
75
|
-
"stdout: '#{
|
|
76
|
-
"stderr: '#{
|
|
80
|
+
"Command: #{result.command}, " \
|
|
81
|
+
"stdout: '#{result.stdout.strip}', " \
|
|
82
|
+
"stderr: '#{result.stderr.strip}'"
|
|
77
83
|
end
|
|
78
84
|
|
|
79
85
|
output_content
|
|
@@ -86,74 +92,91 @@ module Vectory
|
|
|
86
92
|
end
|
|
87
93
|
end
|
|
88
94
|
|
|
89
|
-
|
|
95
|
+
# Convert PDF content to PostScript
|
|
96
|
+
#
|
|
97
|
+
# This is useful as a fallback when Inkscape's PDF import fails.
|
|
98
|
+
# Ghostscript can reliably convert PDF to EPS, and Inkscape can then
|
|
99
|
+
# import the EPS file.
|
|
100
|
+
#
|
|
101
|
+
# @param pdf_content [String] the PDF content to convert
|
|
102
|
+
# @return [String] the EPS content
|
|
103
|
+
# @raise [Vectory::ConversionError] if conversion fails
|
|
104
|
+
# @raise [Vectory::GhostscriptNotFoundError] if Ghostscript is not available
|
|
105
|
+
def pdf_to_eps(pdf_content)
|
|
106
|
+
raise GhostscriptNotFoundError unless available?
|
|
107
|
+
|
|
108
|
+
input_file = Tempfile.new(["pdf_input", ".pdf"])
|
|
109
|
+
output_file = Tempfile.new(["eps_output", ".eps"])
|
|
110
|
+
|
|
111
|
+
begin
|
|
112
|
+
input_file.binmode
|
|
113
|
+
input_file.write(pdf_content)
|
|
114
|
+
input_file.flush
|
|
115
|
+
input_file.close
|
|
116
|
+
output_file.close
|
|
90
117
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
118
|
+
tool = ghostscript_tool
|
|
119
|
+
params = {
|
|
120
|
+
inputs: [input_file.path],
|
|
121
|
+
device: :eps2write,
|
|
122
|
+
output: output_file.path,
|
|
123
|
+
batch: true,
|
|
124
|
+
no_pause: true,
|
|
125
|
+
quiet: true,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
result = tool.execute(:convert,
|
|
129
|
+
execution_timeout: Configuration.instance.timeout,
|
|
130
|
+
**params)
|
|
131
|
+
|
|
132
|
+
unless result.success?
|
|
133
|
+
raise ConversionError,
|
|
134
|
+
"GhostScript PDF to EPS conversion failed. " \
|
|
135
|
+
"Command: #{result.command}, " \
|
|
136
|
+
"Exit status: #{result.status}, " \
|
|
137
|
+
"stdout: '#{result.stdout.strip}', " \
|
|
138
|
+
"stderr: '#{result.stderr.strip}'"
|
|
104
139
|
end
|
|
105
140
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
return path if path
|
|
141
|
+
unless File.exist?(output_file.path)
|
|
142
|
+
raise ConversionError,
|
|
143
|
+
"GhostScript did not create output file: #{output_file.path}"
|
|
110
144
|
end
|
|
111
|
-
else
|
|
112
|
-
# On Unix-like systems, check PATH
|
|
113
|
-
path = find_in_path("gs")
|
|
114
|
-
return path if path
|
|
115
|
-
end
|
|
116
145
|
|
|
117
|
-
|
|
146
|
+
File.binread(output_file.path)
|
|
147
|
+
ensure
|
|
148
|
+
input_file.close unless input_file.closed?
|
|
149
|
+
input_file.unlink
|
|
150
|
+
output_file.close unless output_file.closed?
|
|
151
|
+
output_file.unlink
|
|
152
|
+
end
|
|
118
153
|
end
|
|
119
154
|
|
|
120
|
-
|
|
121
|
-
# If command already has an extension, try it as-is first
|
|
122
|
-
if File.extname(cmd) != ""
|
|
123
|
-
Platform.executable_search_paths.each do |path|
|
|
124
|
-
exe = File.join(path, cmd)
|
|
125
|
-
return exe if File.executable?(exe) && !File.directory?(exe)
|
|
126
|
-
end
|
|
127
|
-
end
|
|
155
|
+
private
|
|
128
156
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
end
|
|
136
|
-
end
|
|
137
|
-
nil
|
|
157
|
+
# Get the Ghostscript tool from Ukiryu
|
|
158
|
+
def ghostscript_tool
|
|
159
|
+
Ukiryu::Tool.get("ghostscript")
|
|
160
|
+
rescue Ukiryu::Errors::ToolNotFoundError => e
|
|
161
|
+
# Tool not found - raise the original GhostscriptNotFoundError
|
|
162
|
+
raise GhostscriptNotFoundError, "Ghostscript not available: #{e.message}"
|
|
138
163
|
end
|
|
139
164
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
cmd_parts
|
|
165
|
+
# Build convert parameters for Ukiryu
|
|
166
|
+
def build_convert_params(input_path, output_path, options = {})
|
|
167
|
+
params = {
|
|
168
|
+
inputs: [input_path],
|
|
169
|
+
device: :pdfwrite,
|
|
170
|
+
output: output_path,
|
|
171
|
+
batch: true,
|
|
172
|
+
no_pause: true,
|
|
173
|
+
quiet: true,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
# Add EPS crop option
|
|
177
|
+
params[:eps_crop] = true if options[:eps_crop]
|
|
178
|
+
|
|
179
|
+
params
|
|
157
180
|
end
|
|
158
181
|
end
|
|
159
182
|
end
|
|
@@ -2,87 +2,102 @@
|
|
|
2
2
|
|
|
3
3
|
require "singleton"
|
|
4
4
|
require "tmpdir"
|
|
5
|
-
|
|
6
|
-
require_relative "platform"
|
|
5
|
+
require "ukiryu"
|
|
7
6
|
|
|
8
7
|
module Vectory
|
|
8
|
+
# InkscapeWrapper using Ukiryu for platform-adaptive command execution
|
|
9
|
+
#
|
|
10
|
+
# This class provides backward compatibility with the original InkscapeWrapper
|
|
11
|
+
# while using Ukiryu under the hood for shell detection, escaping, and execution.
|
|
9
12
|
class InkscapeWrapper
|
|
10
13
|
include Singleton
|
|
11
14
|
|
|
12
|
-
|
|
15
|
+
class << self
|
|
16
|
+
def convert(content:, input_format:, output_format:, output_class:,
|
|
13
17
|
plain: false)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
instance.convert(
|
|
19
|
+
content: content,
|
|
20
|
+
input_format: input_format,
|
|
21
|
+
output_format: output_format,
|
|
22
|
+
output_class: output_class,
|
|
23
|
+
plain: plain,
|
|
24
|
+
)
|
|
25
|
+
end
|
|
21
26
|
end
|
|
22
27
|
|
|
23
28
|
def convert(content:, input_format:, output_format:, output_class:,
|
|
24
29
|
plain: false)
|
|
25
|
-
with_temp_files(content, input_format,
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
30
|
+
with_temp_files(content, input_format, output_format) do |input_path, output_path|
|
|
31
|
+
# Get the tool
|
|
32
|
+
tool = get_inkscape_tool
|
|
33
|
+
|
|
34
|
+
# Build parameters
|
|
35
|
+
params = build_export_params(input_path, output_path, output_format, plain)
|
|
36
|
+
|
|
37
|
+
# Execute export command
|
|
38
|
+
result = tool.execute(:export,
|
|
39
|
+
execution_timeout: Configuration.instance.timeout,
|
|
40
|
+
**params)
|
|
41
|
+
|
|
42
|
+
raise_conversion_error(result) unless result.success?
|
|
43
|
+
|
|
44
|
+
# Check if output file exists at specified path
|
|
45
|
+
unless File.exist?(output_path)
|
|
46
|
+
# Raise error with stderr details if output file not found
|
|
47
|
+
# This handles cases where Inkscape returns exit code 0 but fails to create output
|
|
48
|
+
raise Vectory::ConversionError,
|
|
49
|
+
"Output file not found. " \
|
|
50
|
+
"Expected: #{output_path}\n" \
|
|
51
|
+
"Command: '#{result.command}',\n" \
|
|
52
|
+
"Exit status: '#{result.status}',\n" \
|
|
53
|
+
"stdout: '#{result.stdout.strip}',\n" \
|
|
54
|
+
"stderr: '#{result.stderr.strip}'."
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
output_class.from_path(output_path)
|
|
41
58
|
end
|
|
42
59
|
end
|
|
43
60
|
|
|
44
61
|
def height(content, format)
|
|
45
|
-
query_integer(content, format,
|
|
62
|
+
query_integer(content, format, :height)
|
|
46
63
|
end
|
|
47
64
|
|
|
48
65
|
def width(content, format)
|
|
49
|
-
query_integer(content, format,
|
|
66
|
+
query_integer(content, format, :width)
|
|
50
67
|
end
|
|
51
68
|
|
|
52
69
|
private
|
|
53
70
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
71
|
+
# Get the Inkscape tool from Ukiryu
|
|
72
|
+
def get_inkscape_tool
|
|
73
|
+
Ukiryu::Tool.get("inkscape")
|
|
74
|
+
rescue Ukiryu::Errors::ToolNotFoundError => e
|
|
75
|
+
# Tool not found - raise the original InkscapeNotFoundError
|
|
76
|
+
raise InkscapeNotFoundError, "Inkscape not available: #{e.message}"
|
|
58
77
|
end
|
|
59
78
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
79
|
+
# Build export parameters for Ukiryu
|
|
80
|
+
def build_export_params(input_path, output_path, output_format, plain)
|
|
81
|
+
params = {
|
|
82
|
+
inputs: [input_path],
|
|
83
|
+
output: output_path,
|
|
84
|
+
}
|
|
63
85
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
exe = File.join(path, "#{cmd}#{ext}")
|
|
86
|
+
# Add format if specified (different from output extension)
|
|
87
|
+
# Inkscape can detect format from output extension in modern versions
|
|
88
|
+
# But we can be explicit
|
|
89
|
+
params[:format] = output_format.to_sym if output_format
|
|
69
90
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
nil
|
|
76
|
-
end
|
|
91
|
+
# Add plain SVG flag
|
|
92
|
+
params[:plain] = true if plain && output_format == :svg
|
|
77
93
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
end
|
|
94
|
+
# Note: PDF import via Inkscape on macOS may have compatibility issues
|
|
95
|
+
# The pages option can specify which page to import, but may not work on all platforms
|
|
81
96
|
|
|
82
|
-
|
|
83
|
-
ENV["PATHEXT"] ? ENV["PATHEXT"].split(";") : [""]
|
|
97
|
+
params
|
|
84
98
|
end
|
|
85
99
|
|
|
100
|
+
# Find the output file (Inkscape may create it with different name)
|
|
86
101
|
def find_output(source_path, output_extension)
|
|
87
102
|
basenames = [File.basename(source_path, ".*"),
|
|
88
103
|
File.basename(source_path)]
|
|
@@ -94,100 +109,106 @@ plain: false)
|
|
|
94
109
|
paths.find { |p| File.exist?(p) }
|
|
95
110
|
end
|
|
96
111
|
|
|
97
|
-
|
|
112
|
+
# Raise conversion error with details
|
|
113
|
+
def raise_conversion_error(result)
|
|
98
114
|
raise Vectory::ConversionError,
|
|
99
115
|
"Could not convert with Inkscape. " \
|
|
100
|
-
"
|
|
101
|
-
"status: '#{
|
|
102
|
-
"stdout: '#{
|
|
103
|
-
"stderr: '#{
|
|
104
|
-
end
|
|
116
|
+
"Command: '#{result.command}',\n" \
|
|
117
|
+
"Exit status: '#{result.status}',\n" \
|
|
118
|
+
"stdout: '#{result.stdout.strip}',\n" \
|
|
119
|
+
"stderr: '#{result.stderr.strip}'."
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Query integer value from Inkscape
|
|
123
|
+
#
|
|
124
|
+
# @param content [String] the file content
|
|
125
|
+
# @param format [String] the file format
|
|
126
|
+
# @param param_key [Symbol] the query parameter key (:width, :height, :x, :y)
|
|
127
|
+
# @return [Integer] the query result as an integer
|
|
128
|
+
def query_integer(content, format, param_key)
|
|
129
|
+
query(content, format, param_key).to_f.round
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Query Inkscape for information
|
|
133
|
+
#
|
|
134
|
+
# @param content [String] the file content
|
|
135
|
+
# @param format [String] the file format
|
|
136
|
+
# @param param_key [Symbol] the query parameter key (:width, :height, :x, :y)
|
|
137
|
+
# @return [String] the query result
|
|
138
|
+
def query(content, format, param_key)
|
|
139
|
+
tool = get_inkscape_tool
|
|
140
|
+
raise InkscapeNotFoundError, "Inkscape not available" unless tool
|
|
105
141
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
# Older versions use --export-<format>-file or --export-type
|
|
109
|
-
if inkscape_version_modern?
|
|
110
|
-
cmd = "#{exe} --export-filename=#{output_path}"
|
|
111
|
-
cmd += " --export-plain-svg" if plain && output_format == :svg
|
|
112
|
-
# For PDF input, specify which page to use (avoid interactive prompt)
|
|
113
|
-
cmd += " --export-page=1" if input_path.end_with?(".pdf")
|
|
114
|
-
else
|
|
115
|
-
# Legacy Inkscape (0.x) uses --export-type
|
|
116
|
-
cmd = "#{exe} --export-type=#{output_format}"
|
|
117
|
-
cmd += " --export-plain-svg" if plain && output_format == :svg
|
|
118
|
-
end
|
|
119
|
-
cmd += " #{input_path}"
|
|
120
|
-
cmd
|
|
121
|
-
end
|
|
142
|
+
with_temp_file(content, format) do |path|
|
|
143
|
+
params = { input: path, param_key => true }
|
|
122
144
|
|
|
123
|
-
|
|
124
|
-
|
|
145
|
+
result = tool.execute(:query,
|
|
146
|
+
execution_timeout: Configuration.instance.timeout,
|
|
147
|
+
**params)
|
|
148
|
+
raise_query_error(result) if result.stdout.empty?
|
|
125
149
|
|
|
126
|
-
|
|
127
|
-
|
|
150
|
+
result.stdout
|
|
151
|
+
end
|
|
152
|
+
end
|
|
128
153
|
|
|
129
|
-
|
|
130
|
-
|
|
154
|
+
# Create temp file with content
|
|
155
|
+
def with_temp_file(content, extension)
|
|
156
|
+
Dir.mktmpdir do |dir|
|
|
157
|
+
path = File.join(dir, "image.#{extension}")
|
|
158
|
+
File.binwrite(path, content)
|
|
131
159
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
else
|
|
135
|
-
true # Default to modern if we can't detect
|
|
136
|
-
end
|
|
160
|
+
yield path
|
|
161
|
+
end
|
|
137
162
|
end
|
|
138
163
|
|
|
164
|
+
# Create temp files for input and output
|
|
139
165
|
def with_temp_files(content, input_format, output_format)
|
|
140
166
|
Dir.mktmpdir do |dir|
|
|
141
167
|
input_path = File.join(dir, "image.#{input_format}")
|
|
142
168
|
output_path = File.join(dir, "image.#{output_format}")
|
|
143
169
|
File.binwrite(input_path, content)
|
|
144
170
|
|
|
145
|
-
|
|
171
|
+
begin
|
|
172
|
+
yield input_path, output_path
|
|
173
|
+
ensure
|
|
174
|
+
# On Windows, aggressively clean up temp files to avoid ENOTEMPTY errors
|
|
175
|
+
# caused by Inkscape leaving behind lock files or hanging processes
|
|
176
|
+
cleanup_temp_dir(dir) if Platform.windows?
|
|
177
|
+
end
|
|
146
178
|
end
|
|
147
179
|
end
|
|
148
180
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
exe = inkscape_path_or_raise_error
|
|
155
|
-
|
|
156
|
-
with_temp_file(content, format) do |path|
|
|
157
|
-
cmd = "#{external_path(exe)} #{options} #{external_path(path)}"
|
|
158
|
-
|
|
159
|
-
# Pass environment to disable display on non-Windows systems
|
|
160
|
-
env = headless_environment
|
|
161
|
-
call = SystemCall.new(cmd, env: env).call
|
|
162
|
-
raise_query_error(call) if call.stdout.empty?
|
|
181
|
+
# Aggressively clean up temp directory on Windows
|
|
182
|
+
# Handles cases where Inkscape leaves behind files or processes
|
|
183
|
+
def cleanup_temp_dir(dir)
|
|
184
|
+
# Give processes a moment to release file handles
|
|
185
|
+
sleep(0.2)
|
|
163
186
|
|
|
164
|
-
|
|
187
|
+
# Try to remove all files in the directory
|
|
188
|
+
Dir.glob(File.join(dir, "**", "*")).reverse_each do |file|
|
|
189
|
+
File.delete(file) if File.file?(file)
|
|
190
|
+
rescue Errno::EACCES, Errno::ENOENT
|
|
191
|
+
# File may be locked or already deleted, ignore
|
|
165
192
|
end
|
|
166
|
-
end
|
|
167
193
|
|
|
168
|
-
|
|
169
|
-
Dir.
|
|
170
|
-
path
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
yield path
|
|
194
|
+
# Try to remove subdirectories
|
|
195
|
+
Dir.glob(File.join(dir, "**", "*")).reverse_each do |path|
|
|
196
|
+
Dir.rmdir(path) if File.directory?(path)
|
|
197
|
+
rescue Errno::EACCES, Errno::ENOENT, Errno::ENOTEMPTY
|
|
198
|
+
# Directory may be locked or not empty, ignore
|
|
174
199
|
end
|
|
200
|
+
rescue StandardError
|
|
201
|
+
# Best effort cleanup, don't raise
|
|
175
202
|
end
|
|
176
203
|
|
|
177
|
-
|
|
204
|
+
# Raise query error with details
|
|
205
|
+
def raise_query_error(result)
|
|
178
206
|
raise Vectory::InkscapeQueryError,
|
|
179
207
|
"Could not query with Inkscape. " \
|
|
180
|
-
"
|
|
181
|
-
"status: '#{
|
|
182
|
-
"stdout: '#{
|
|
183
|
-
"stderr: '#{
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
# Returns environment variables for headless operation
|
|
187
|
-
# On non-Windows systems, disable DISPLAY to prevent X11/GDK initialization
|
|
188
|
-
def headless_environment
|
|
189
|
-
# On macOS/Linux, disable DISPLAY to prevent Gdk/X11 warnings
|
|
190
|
-
Platform.windows? ? {} : { "DISPLAY" => "" }
|
|
208
|
+
"Command: '#{result.command}',\n" \
|
|
209
|
+
"Exit status: '#{result.status}',\n" \
|
|
210
|
+
"stdout: '#{result.stdout.strip}',\n" \
|
|
211
|
+
"stderr: '#{result.stderr.strip}'."
|
|
191
212
|
end
|
|
192
213
|
|
|
193
214
|
# Format paths for command execution on current platform
|
data/lib/vectory/pdf.rb
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "vector"
|
|
4
|
-
require_relative "inkscape_wrapper"
|
|
5
|
-
|
|
6
3
|
module Vectory
|
|
7
4
|
class Pdf < Vector
|
|
8
5
|
attr_accessor :original_height, :original_width
|
|
@@ -16,13 +13,7 @@ module Vectory
|
|
|
16
13
|
end
|
|
17
14
|
|
|
18
15
|
def to_svg
|
|
19
|
-
svg =
|
|
20
|
-
content: content,
|
|
21
|
-
input_format: :pdf,
|
|
22
|
-
output_format: :svg,
|
|
23
|
-
output_class: Svg,
|
|
24
|
-
plain: true,
|
|
25
|
-
)
|
|
16
|
+
svg = convert_to_svg
|
|
26
17
|
|
|
27
18
|
# If we have original dimensions from EPS/PS, adjust the SVG
|
|
28
19
|
if original_height && original_width
|
|
@@ -35,33 +26,82 @@ module Vectory
|
|
|
35
26
|
end
|
|
36
27
|
|
|
37
28
|
def to_eps
|
|
38
|
-
|
|
39
|
-
content: content,
|
|
40
|
-
input_format: :pdf,
|
|
41
|
-
output_format: :eps,
|
|
42
|
-
output_class: Eps,
|
|
43
|
-
)
|
|
29
|
+
with_inkscape_pdf_fallback(:eps, Eps)
|
|
44
30
|
end
|
|
45
31
|
|
|
46
32
|
def to_ps
|
|
47
|
-
|
|
48
|
-
content: content,
|
|
49
|
-
input_format: :pdf,
|
|
50
|
-
output_format: :ps,
|
|
51
|
-
output_class: Ps,
|
|
52
|
-
)
|
|
33
|
+
with_inkscape_pdf_fallback(:ps, Ps)
|
|
53
34
|
end
|
|
54
35
|
|
|
55
36
|
def to_emf
|
|
37
|
+
with_inkscape_pdf_fallback(:emf, Emf)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Execute a conversion with fallback for Inkscape PDF import issues
|
|
43
|
+
#
|
|
44
|
+
# Inkscape 1.4.x on Windows and macOS has a PDF import bug where it
|
|
45
|
+
# may fail to create output files. This method catches any conversion
|
|
46
|
+
# error and retries via the PDF → EPS → target format path.
|
|
47
|
+
#
|
|
48
|
+
# @param output_format [Symbol] the target format (:svg, :eps, :ps, :emf)
|
|
49
|
+
# @param output_class [Class] the output class (Svg, Eps, Ps, Emf)
|
|
50
|
+
# @param plain [Boolean] whether to use plain SVG format (only for SVG)
|
|
51
|
+
# @return [Vector] the converted output
|
|
52
|
+
# @raise [Vectory::ConversionError] if both methods fail
|
|
53
|
+
def with_inkscape_pdf_fallback(output_format, output_class, plain: false)
|
|
56
54
|
InkscapeWrapper.convert(
|
|
57
55
|
content: content,
|
|
58
56
|
input_format: :pdf,
|
|
59
|
-
output_format:
|
|
60
|
-
output_class:
|
|
57
|
+
output_format: output_format,
|
|
58
|
+
output_class: output_class,
|
|
59
|
+
plain: plain,
|
|
61
60
|
)
|
|
61
|
+
rescue Vectory::ConversionError => e
|
|
62
|
+
log_conversion_failure(e, output_format)
|
|
63
|
+
|
|
64
|
+
# Try fallback: PDF → EPS (Ghostscript) → target format (Inkscape)
|
|
65
|
+
begin
|
|
66
|
+
warn "[VECTORY] Attempting fallback: PDF → EPS → #{output_format.upcase}" if fallback_logging_enabled?
|
|
67
|
+
eps_content = GhostscriptWrapper.pdf_to_eps(content)
|
|
68
|
+
warn "[VECTORY] PDF → EPS succeeded, now trying EPS → #{output_format.upcase}" if fallback_logging_enabled?
|
|
69
|
+
return InkscapeWrapper.convert(
|
|
70
|
+
content: eps_content,
|
|
71
|
+
input_format: :eps,
|
|
72
|
+
output_format: output_format,
|
|
73
|
+
output_class: output_class,
|
|
74
|
+
plain: plain,
|
|
75
|
+
)
|
|
76
|
+
rescue StandardError => fallback_error
|
|
77
|
+
# Wrap non-Vectory errors in ConversionError for consistent error handling
|
|
78
|
+
error_to_raise = if fallback_error.is_a?(Vectory::Error)
|
|
79
|
+
fallback_error
|
|
80
|
+
else
|
|
81
|
+
ConversionError.new(
|
|
82
|
+
"PDF fallback conversion failed: #{fallback_error.message}"
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
warn "[VECTORY] Fallback also failed: #{fallback_error.message[0..100]}" if fallback_logging_enabled?
|
|
86
|
+
raise error_to_raise
|
|
87
|
+
end
|
|
62
88
|
end
|
|
63
89
|
|
|
64
|
-
|
|
90
|
+
# Convert PDF to SVG using fallback mechanism
|
|
91
|
+
def convert_to_svg
|
|
92
|
+
with_inkscape_pdf_fallback(:svg, Svg, plain: true)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def log_conversion_failure(error, output_format)
|
|
96
|
+
return unless fallback_logging_enabled?
|
|
97
|
+
|
|
98
|
+
warn "[VECTORY] PDF → #{output_format.upcase} direct conversion failed:"
|
|
99
|
+
warn "[VECTORY] Error: #{error.message[0..200]}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def fallback_logging_enabled?
|
|
103
|
+
ENV["VECTORY_DEBUG"] || ENV["CI"]
|
|
104
|
+
end
|
|
65
105
|
|
|
66
106
|
def adjust_svg_dimensions(svg_content, width, height)
|
|
67
107
|
# Replace width and height attributes in SVG root element
|
data/lib/vectory/ps.rb
CHANGED
data/lib/vectory/svg_mapping.rb
CHANGED
data/lib/vectory/vector.rb
CHANGED
data/lib/vectory/version.rb
CHANGED
data/lib/vectory.rb
CHANGED
|
@@ -1,30 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# External dependencies
|
|
3
4
|
require "logger"
|
|
4
|
-
|
|
5
|
-
require_relative "vectory/utils"
|
|
5
|
+
require "ukiryu"
|
|
6
6
|
|
|
7
|
+
# Define base error class and additional error classes
|
|
8
|
+
# (used in class bodies like cli.rb, so can't be autoloaded)
|
|
7
9
|
module Vectory
|
|
8
10
|
class Error < StandardError; end
|
|
9
|
-
end
|
|
10
|
-
|
|
11
|
-
require_relative "vectory/errors"
|
|
12
|
-
require_relative "vectory/platform"
|
|
13
|
-
require_relative "vectory/configuration"
|
|
14
|
-
require_relative "vectory/conversion"
|
|
15
|
-
require_relative "vectory/image"
|
|
16
|
-
require_relative "vectory/image_resize"
|
|
17
|
-
require_relative "vectory/datauri"
|
|
18
|
-
require_relative "vectory/vector"
|
|
19
|
-
require_relative "vectory/ghostscript_wrapper"
|
|
20
|
-
require_relative "vectory/pdf"
|
|
21
|
-
require_relative "vectory/eps"
|
|
22
|
-
require_relative "vectory/ps"
|
|
23
|
-
require_relative "vectory/emf"
|
|
24
|
-
require_relative "vectory/svg"
|
|
25
|
-
require_relative "vectory/svg_mapping"
|
|
26
11
|
|
|
27
|
-
module Vectory
|
|
28
12
|
class SystemCallError < Error; end
|
|
29
13
|
|
|
30
14
|
class NotImplementedError < Error; end
|
|
@@ -32,7 +16,42 @@ module Vectory
|
|
|
32
16
|
class NotWrittenToDiskError < Error; end
|
|
33
17
|
|
|
34
18
|
class ParsingError < Error; end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
require_relative "vectory/errors"
|
|
35
22
|
|
|
23
|
+
# Lazy load: all other internal Vectory dependencies via autoload
|
|
24
|
+
module Vectory
|
|
25
|
+
# Core utilities
|
|
26
|
+
autoload :Version, "vectory/version"
|
|
27
|
+
autoload :Utils, "vectory/utils"
|
|
28
|
+
autoload :Platform, "vectory/platform"
|
|
29
|
+
|
|
30
|
+
# Wrappers
|
|
31
|
+
autoload :GhostscriptWrapper, "vectory/ghostscript_wrapper"
|
|
32
|
+
autoload :InkscapeWrapper, "vectory/inkscape_wrapper"
|
|
33
|
+
|
|
34
|
+
# Conversion system
|
|
35
|
+
autoload :Conversion, "vectory/conversion"
|
|
36
|
+
|
|
37
|
+
# Format classes
|
|
38
|
+
autoload :Configuration, "vectory/configuration"
|
|
39
|
+
autoload :Image, "vectory/image"
|
|
40
|
+
autoload :ImageResize, "vectory/image_resize"
|
|
41
|
+
autoload :Datauri, "vectory/datauri"
|
|
42
|
+
autoload :Vector, "vectory/vector"
|
|
43
|
+
autoload :Pdf, "vectory/pdf"
|
|
44
|
+
autoload :Eps, "vectory/eps"
|
|
45
|
+
autoload :Ps, "vectory/ps"
|
|
46
|
+
autoload :Emf, "vectory/emf"
|
|
47
|
+
autoload :Svg, "vectory/svg"
|
|
48
|
+
autoload :SvgMapping, "vectory/svg_mapping"
|
|
49
|
+
autoload :SvgDocument, "vectory/svg_document"
|
|
50
|
+
autoload :FileMagic, "vectory/file_magic"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Define additional module methods
|
|
54
|
+
module Vectory
|
|
36
55
|
def self.ui
|
|
37
56
|
@ui ||= Logger.new($stdout).tap do |logger|
|
|
38
57
|
logger.level = ENV["VECTORY_LOG"] || Logger::WARN
|
data/vectory.gemspec
CHANGED
|
@@ -24,7 +24,7 @@ Gem::Specification.new do |spec|
|
|
|
24
24
|
f.match(%r{^(bin|spec)/})
|
|
25
25
|
end
|
|
26
26
|
spec.test_files = `git ls-files -- {spec}/*`.split("\n")
|
|
27
|
-
spec.required_ruby_version = ">=
|
|
27
|
+
spec.required_ruby_version = ">= 3.1.0"
|
|
28
28
|
|
|
29
29
|
spec.add_dependency "base64"
|
|
30
30
|
spec.add_dependency "emf2svg"
|
|
@@ -32,4 +32,5 @@ Gem::Specification.new do |spec|
|
|
|
32
32
|
spec.add_dependency "marcel", "~> 1.0"
|
|
33
33
|
spec.add_dependency "nokogiri", "~> 1.14"
|
|
34
34
|
spec.add_dependency "thor", "~> 1.0"
|
|
35
|
+
spec.add_dependency "ukiryu", "~> 0.2"
|
|
35
36
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: vectory
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-02-
|
|
11
|
+
date: 2026-02-19 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: base64
|
|
@@ -94,6 +94,20 @@ dependencies:
|
|
|
94
94
|
- - "~>"
|
|
95
95
|
- !ruby/object:Gem::Version
|
|
96
96
|
version: '1.0'
|
|
97
|
+
- !ruby/object:Gem::Dependency
|
|
98
|
+
name: ukiryu
|
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
|
100
|
+
requirements:
|
|
101
|
+
- - "~>"
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: '0.2'
|
|
104
|
+
type: :runtime
|
|
105
|
+
prerelease: false
|
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
107
|
+
requirements:
|
|
108
|
+
- - "~>"
|
|
109
|
+
- !ruby/object:Gem::Version
|
|
110
|
+
version: '0.2'
|
|
97
111
|
description: |
|
|
98
112
|
Vectory performs pairwise vector image conversions for common
|
|
99
113
|
vector image formats, such as SVG, EMF, EPS and PS.
|
|
@@ -110,7 +124,6 @@ files:
|
|
|
110
124
|
- ".github/workflows/rake.yml"
|
|
111
125
|
- ".github/workflows/release.yml"
|
|
112
126
|
- ".gitignore"
|
|
113
|
-
- ".hound.yml"
|
|
114
127
|
- ".rubocop.yml"
|
|
115
128
|
- ".rubocop_todo.yml"
|
|
116
129
|
- Gemfile
|
|
@@ -137,7 +150,6 @@ files:
|
|
|
137
150
|
- docs/understanding/inkscape-wrapper.adoc
|
|
138
151
|
- exe/vectory
|
|
139
152
|
- lib/vectory.rb
|
|
140
|
-
- lib/vectory/capture.rb
|
|
141
153
|
- lib/vectory/cli.rb
|
|
142
154
|
- lib/vectory/configuration.rb
|
|
143
155
|
- lib/vectory/conversion.rb
|
|
@@ -159,7 +171,6 @@ files:
|
|
|
159
171
|
- lib/vectory/svg.rb
|
|
160
172
|
- lib/vectory/svg_document.rb
|
|
161
173
|
- lib/vectory/svg_mapping.rb
|
|
162
|
-
- lib/vectory/system_call.rb
|
|
163
174
|
- lib/vectory/utils.rb
|
|
164
175
|
- lib/vectory/vector.rb
|
|
165
176
|
- lib/vectory/version.rb
|
|
@@ -177,7 +188,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
177
188
|
requirements:
|
|
178
189
|
- - ">="
|
|
179
190
|
- !ruby/object:Gem::Version
|
|
180
|
-
version:
|
|
191
|
+
version: 3.1.0
|
|
181
192
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
182
193
|
requirements:
|
|
183
194
|
- - ">="
|
data/.hound.yml
DELETED
data/lib/vectory/capture.rb
DELETED
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
require "timeout"
|
|
2
|
-
|
|
3
|
-
module Vectory
|
|
4
|
-
module Capture
|
|
5
|
-
class << self
|
|
6
|
-
def windows?
|
|
7
|
-
!!((RUBY_PLATFORM =~ /(win|w)(32|64)$/) ||
|
|
8
|
-
(RUBY_PLATFORM =~ /mswin|mingw/))
|
|
9
|
-
end
|
|
10
|
-
|
|
11
|
-
# Capture the standard output and the standard error of a command.
|
|
12
|
-
# Almost same as Open3.capture3 method except for timeout handling and return value.
|
|
13
|
-
# See Open3.capture3.
|
|
14
|
-
#
|
|
15
|
-
# result = with_timeout([env,] cmd... [, opts])
|
|
16
|
-
#
|
|
17
|
-
# The arguments env, cmd and opts are passed to Process.spawn except
|
|
18
|
-
# opts[:stdin_data], opts[:binmode], opts[:timeout], opts[:signal]
|
|
19
|
-
# and opts[:kill_after]. See Process.spawn.
|
|
20
|
-
#
|
|
21
|
-
# If opts[:stdin_data] is specified, it is sent to the command's standard input.
|
|
22
|
-
#
|
|
23
|
-
# If opts[:binmode] is true, internal pipes are set to binary mode.
|
|
24
|
-
#
|
|
25
|
-
# If opts[:timeout] is specified, SIGTERM is sent to the command after specified seconds.
|
|
26
|
-
#
|
|
27
|
-
# If opts[:signal] is specified, it is used instead of SIGTERM on timeout.
|
|
28
|
-
#
|
|
29
|
-
# If opts[:kill_after] is specified, also send a SIGKILL after specified seconds.
|
|
30
|
-
# it is only sent if the command is still running after the initial signal was sent.
|
|
31
|
-
#
|
|
32
|
-
# The return value is a Hash as shown below.
|
|
33
|
-
#
|
|
34
|
-
# {
|
|
35
|
-
# :pid => PID of the command,
|
|
36
|
-
# :status => Process::Status of the command,
|
|
37
|
-
# :stdout => the standard output of the command,
|
|
38
|
-
# :stderr => the standard error of the command,
|
|
39
|
-
# :timeout => whether the command was timed out,
|
|
40
|
-
# }
|
|
41
|
-
def with_timeout(*cmd)
|
|
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
|
|
50
|
-
opts = {
|
|
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,
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
in_r, in_w = IO.pipe
|
|
59
|
-
out_r, out_w = IO.pipe
|
|
60
|
-
err_r, err_w = IO.pipe
|
|
61
|
-
in_w.sync = true
|
|
62
|
-
|
|
63
|
-
if opts[:binmode]
|
|
64
|
-
in_w.binmode
|
|
65
|
-
out_r.binmode
|
|
66
|
-
err_r.binmode
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
spawn_opts[:in] = in_r
|
|
70
|
-
spawn_opts[:out] = out_w
|
|
71
|
-
spawn_opts[:err] = err_w
|
|
72
|
-
|
|
73
|
-
result = {
|
|
74
|
-
pid: nil,
|
|
75
|
-
status: nil,
|
|
76
|
-
stdout: "",
|
|
77
|
-
stderr: "",
|
|
78
|
-
timeout: false,
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
out_reader = nil
|
|
82
|
-
err_reader = nil
|
|
83
|
-
wait_thr = nil
|
|
84
|
-
watchdog = nil
|
|
85
|
-
|
|
86
|
-
begin
|
|
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
|
|
99
|
-
|
|
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
|
|
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
|
|
117
|
-
in_w.write opts[:stdin_data]
|
|
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
|
|
149
|
-
|
|
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
|
|
161
|
-
end
|
|
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
|
|
191
|
-
end
|
|
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
|
|
199
|
-
ensure
|
|
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
|
|
237
|
-
end
|
|
238
|
-
|
|
239
|
-
result
|
|
240
|
-
end
|
|
241
|
-
end
|
|
242
|
-
end
|
|
243
|
-
end
|
data/lib/vectory/system_call.rb
DELETED
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
require "open3"
|
|
2
|
-
require_relative "capture"
|
|
3
|
-
|
|
4
|
-
module Vectory
|
|
5
|
-
class SystemCall
|
|
6
|
-
TIMEOUT = 60
|
|
7
|
-
|
|
8
|
-
attr_reader :status, :stdout, :stderr, :cmd
|
|
9
|
-
|
|
10
|
-
def initialize(cmd, timeout = TIMEOUT, env: {})
|
|
11
|
-
@cmd = cmd
|
|
12
|
-
@timeout = timeout
|
|
13
|
-
@env = env
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
def call
|
|
17
|
-
log_cmd(@cmd)
|
|
18
|
-
|
|
19
|
-
execute(@cmd, @env)
|
|
20
|
-
|
|
21
|
-
log_result
|
|
22
|
-
|
|
23
|
-
raise_error unless @status.success?
|
|
24
|
-
|
|
25
|
-
self
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
private
|
|
29
|
-
|
|
30
|
-
def log_cmd(cmd)
|
|
31
|
-
Vectory.ui.debug("Cmd: '#{cmd}'")
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def execute(cmd, env)
|
|
35
|
-
# Build spawn options with environment variables and timeout settings
|
|
36
|
-
spawn_opts = {}
|
|
37
|
-
spawn_opts.merge!(env) if env.any?
|
|
38
|
-
spawn_opts[:timeout] = @timeout
|
|
39
|
-
spawn_opts[:signal] = :TERM # Use TERM on Unix for graceful shutdown
|
|
40
|
-
spawn_opts[:kill_after] = 2
|
|
41
|
-
|
|
42
|
-
# Pass command and options directly (without splatting)
|
|
43
|
-
# Capture.with_timeout expects: cmd, options_hash
|
|
44
|
-
# It will handle extracting the options correctly
|
|
45
|
-
result = if cmd.is_a?(Array)
|
|
46
|
-
Capture.with_timeout(*cmd, spawn_opts)
|
|
47
|
-
else
|
|
48
|
-
Capture.with_timeout(cmd, spawn_opts)
|
|
49
|
-
end
|
|
50
|
-
@stdout = result[:stdout] || ""
|
|
51
|
-
@stderr = result[:stderr] || ""
|
|
52
|
-
@status = result[:status]
|
|
53
|
-
@timed_out = result[:timeout]
|
|
54
|
-
rescue Errno::ENOENT => e
|
|
55
|
-
raise SystemCallError, e.inspect
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def log_result
|
|
59
|
-
Vectory.ui.debug("Status: #{@status.inspect}")
|
|
60
|
-
Vectory.ui.debug("Stdout: '#{@stdout.strip}'")
|
|
61
|
-
Vectory.ui.debug("Stderr: '#{@stderr.strip}'")
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def raise_error
|
|
65
|
-
if @timed_out
|
|
66
|
-
raise SystemCallError,
|
|
67
|
-
"Command timed out after #{@timeout} seconds: #{@cmd},\n " \
|
|
68
|
-
"stdout: '#{@stdout.strip}',\n " \
|
|
69
|
-
"stderr: '#{@stderr.strip}'"
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
if @status.nil?
|
|
73
|
-
raise SystemCallError,
|
|
74
|
-
"Failed to run #{@cmd} (no status available),\n " \
|
|
75
|
-
"stdout: '#{@stdout.strip}',\n " \
|
|
76
|
-
"stderr: '#{@stderr.strip}'"
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
raise SystemCallError,
|
|
80
|
-
"Failed to run #{@cmd},\n " \
|
|
81
|
-
"status: #{@status.exitstatus},\n " \
|
|
82
|
-
"stdout: '#{@stdout.strip}',\n " \
|
|
83
|
-
"stderr: '#{@stderr.strip}'"
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
|
-
end
|