vectory 0.10.2 → 0.12.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.
@@ -1,121 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Vectory
4
- module Conversion
5
- # Inkscape-based conversion strategy
6
- #
7
- # Handles conversions using Inkscape, including:
8
- # - SVG ↔ EPS
9
- # - SVG ↔ PS
10
- # - SVG ↔ EMF
11
- # - SVG ↔ PDF
12
- # - EPS/PS → PDF
13
- #
14
- # @see https://inkscape.org/
15
- class InkscapeStrategy < Strategy
16
- # Inkscape supports bidirectional conversion with SVG as source/target
17
- SUPPORTED_CONVERSIONS = [
18
- %i[svg eps],
19
- %i[svg ps],
20
- %i[svg emf],
21
- %i[svg pdf],
22
- %i[eps svg],
23
- %i[eps pdf],
24
- %i[ps svg],
25
- %i[ps pdf],
26
- %i[pdf svg],
27
- %i[emf svg],
28
- ].freeze
29
-
30
- # Convert content using Inkscape
31
- #
32
- # @param content [String] the input content to convert
33
- # @param input_format [Symbol] the input format
34
- # @param output_format [Symbol] the output format
35
- # @param options [Hash] additional options
36
- # @option options [Boolean] :plain export plain SVG (for SVG output)
37
- # @option options [Class] :output_class the class to instantiate with result
38
- # @return [Vectory::Vector] the converted vector object
39
- # @raise [Vectory::InkscapeNotFoundError] if Inkscape is not available
40
- def convert(content, input_format:, output_format:, **options)
41
- output_class = options.fetch(:output_class) do
42
- format_class(output_format)
43
- end
44
-
45
- InkscapeWrapper.convert(
46
- content: content,
47
- input_format: input_format,
48
- output_format: output_format,
49
- output_class: output_class,
50
- plain: options[:plain] || false,
51
- )
52
- end
53
-
54
- # Check if this conversion is supported
55
- #
56
- # @param input_format [Symbol] the input format
57
- # @param output_format [Symbol] the output format
58
- # @return [Boolean] true if Inkscape supports this conversion
59
- def supports?(input_format, output_format)
60
- SUPPORTED_CONVERSIONS.include?([input_format, output_format])
61
- end
62
-
63
- # Get supported conversions
64
- #
65
- # @return [Array<Array<Symbol>>] array of [input, output] format pairs
66
- def supported_conversions
67
- SUPPORTED_CONVERSIONS
68
- end
69
-
70
- # Check if Inkscape is available
71
- #
72
- # @return [Boolean] true if Inkscape can be found in PATH
73
- def available?
74
- InkscapeWrapper.instance.send(:inkscape_path)
75
- true
76
- rescue Vectory::InkscapeNotFoundError
77
- false
78
- end
79
-
80
- # Get the tool name
81
- #
82
- # @return [String] "inkscape"
83
- def tool_name
84
- "inkscape"
85
- end
86
-
87
- # Query the width of content
88
- #
89
- # @param content [String] the vector content
90
- # @param format [Symbol] the format of the content
91
- # @return [Integer] the width in pixels
92
- def width(content, format)
93
- InkscapeWrapper.instance.width(content, format)
94
- end
95
-
96
- # Query the height of content
97
- #
98
- # @param content [String] the vector content
99
- # @param format [Symbol] the format of the content
100
- # @return [Integer] the height in pixels
101
- def height(content, format)
102
- InkscapeWrapper.instance.height(content, format)
103
- end
104
-
105
- private
106
-
107
- # Get the Vectory class for a format
108
- def format_class(format)
109
- case format
110
- when :svg then Vectory::Svg
111
- when :eps then Vectory::Eps
112
- when :ps then Vectory::Ps
113
- when :emf then Vectory::Emf
114
- when :pdf then Vectory::Pdf
115
- else
116
- raise ArgumentError, "Unsupported format: #{format}"
117
- end
118
- end
119
- end
120
- end
121
- end
@@ -1,58 +0,0 @@
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
@@ -1,104 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Vectory
4
- # Conversion module provides strategy-based conversion interface
5
- #
6
- # This module encapsulates different conversion strategies for converting
7
- # between vector formats using external tools like Inkscape and Ghostscript.
8
- #
9
- # @example Convert SVG to EPS using Inkscape
10
- # Vectory::Conversion.convert(svg_content, from: :svg, to: :eps)
11
- #
12
- # @example Get available strategies for a conversion
13
- # Vectory::Conversion.strategies_for(:svg, :eps)
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
- class << self
20
- # Convert content from one format to another
21
- #
22
- # Automatically selects the appropriate strategy based on the input/output formats.
23
- #
24
- # @param content [String] the content to convert
25
- # @param from [Symbol] the input format
26
- # @param to [Symbol] the output format
27
- # @param options [Hash] additional options passed to the strategy
28
- # @return [Vectory::Vector, String] the converted result
29
- # @raise [Vectory::ConversionError] if no strategy supports the conversion
30
- def convert(content, from:, to:, **options)
31
- strategy = find_strategy(from, to)
32
-
33
- unless strategy
34
- supported = supported_conversions.map do |a, b|
35
- "#{a} → #{b}"
36
- end.join(", ")
37
- raise Vectory::ConversionError,
38
- "No strategy found for #{from} → #{to} conversion. " \
39
- "Supported: #{supported}"
40
- end
41
-
42
- strategy.convert(content, input_format: from, output_format: to,
43
- **options)
44
- end
45
-
46
- # Get all available strategies
47
- #
48
- # @return [Array<Vectory::Conversion::Strategy>] all registered strategies
49
- def strategies
50
- @strategies ||= [
51
- InkscapeStrategy.new,
52
- GhostscriptStrategy.new,
53
- ]
54
- end
55
-
56
- # Get strategies that support a specific conversion
57
- #
58
- # @param input_format [Symbol] the input format
59
- # @param output_format [Symbol] the output format
60
- # @return [Array<Vectory::Conversion::Strategy>] matching strategies
61
- def strategies_for(input_format, output_format)
62
- strategies.select { |s| s.supports?(input_format, output_format) }
63
- end
64
-
65
- # Check if a conversion is supported
66
- #
67
- # @param input_format [Symbol] the input format
68
- # @param output_format [Symbol] the output format
69
- # @return [Boolean] true if any strategy supports this conversion
70
- def supports?(input_format, output_format)
71
- strategies_for(input_format, output_format).any?
72
- end
73
-
74
- # Get all supported conversions
75
- #
76
- # @return [Array<Array<Symbol>>] array of [input, output] format pairs
77
- def supported_conversions
78
- @supported_conversions ||= strategies.flat_map(&:supported_conversions).uniq
79
- end
80
-
81
- # Check if a specific tool is available
82
- #
83
- # @param tool [Symbol, String] the tool name (:inkscape, :ghostscript, etc.)
84
- # @return [Boolean] true if the tool is available
85
- def tool_available?(tool)
86
- strategy = strategies.find { |s| s.tool_name == tool.to_s.downcase }
87
- strategy&.available? || false
88
- end
89
-
90
- private
91
-
92
- # Find a strategy for the given conversion
93
- #
94
- # @param input_format [Symbol] the input format
95
- # @param output_format [Symbol] the output format
96
- # @return [Vectory::Conversion::Strategy, nil] the strategy or nil if not found
97
- def find_strategy(input_format, output_format)
98
- strategies.find do |strategy|
99
- strategy.supports?(input_format, output_format) && strategy.available?
100
- end
101
- end
102
- end
103
- end
104
- end
@@ -1,183 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "tempfile"
4
- require "fileutils"
5
- require "ukiryu"
6
-
7
- module Vectory
8
- # GhostscriptWrapper converts PS and EPS files to PDF using Ghostscript
9
- #
10
- # Uses Ukiryu for platform-adaptive command execution.
11
- class GhostscriptWrapper
12
- SUPPORTED_INPUT_FORMATS = %w[ps eps].freeze
13
-
14
- class << self
15
- def available?
16
- ghostscript_tool
17
- true
18
- rescue GhostscriptNotFoundError
19
- false
20
- end
21
-
22
- def version
23
- return nil unless available?
24
-
25
- tool = ghostscript_tool
26
- tool.version
27
- rescue StandardError
28
- nil
29
- end
30
-
31
- def convert(content, options = {})
32
- raise GhostscriptNotFoundError unless available?
33
-
34
- eps_crop = options.fetch(:eps_crop, false)
35
- input_ext = eps_crop ? ".eps" : ".ps"
36
-
37
- # Create temporary input file
38
- input_file = Tempfile.new(["gs_input", input_ext])
39
- output_file = Tempfile.new(["gs_output", ".pdf"])
40
-
41
- begin
42
- # Write content and close the input file so GhostScript can read it
43
- input_file.binmode
44
- input_file.write(content)
45
- input_file.flush
46
- input_file.close
47
-
48
- # Close output file so GhostScript can write to it
49
- output_file.close
50
-
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)
55
-
56
- result = tool.execute(:convert,
57
- execution_timeout: Configuration.instance.timeout,
58
- **params)
59
-
60
- unless result.success?
61
- raise ConversionError,
62
- "GhostScript conversion failed. " \
63
- "Command: #{result.command}, " \
64
- "Exit status: #{result.status}, " \
65
- "stdout: '#{result.stdout.strip}', " \
66
- "stderr: '#{result.stderr.strip}'"
67
- end
68
-
69
- unless File.exist?(output_file.path)
70
- raise ConversionError,
71
- "GhostScript did not create output file: #{output_file.path}"
72
- end
73
-
74
- output_content = File.binread(output_file.path)
75
-
76
- # Check if the PDF is valid (should be more than just the header)
77
- if output_content.size < 100
78
- raise ConversionError,
79
- "GhostScript created invalid PDF (#{output_content.size} bytes). " \
80
- "Command: #{result.command}, " \
81
- "stdout: '#{result.stdout.strip}', " \
82
- "stderr: '#{result.stderr.strip}'"
83
- end
84
-
85
- output_content
86
- ensure
87
- # Clean up temp files
88
- input_file.close unless input_file.closed?
89
- input_file.unlink
90
- output_file.close unless output_file.closed?
91
- output_file.unlink
92
- end
93
- end
94
-
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
117
-
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}'"
139
- end
140
-
141
- unless File.exist?(output_file.path)
142
- raise ConversionError,
143
- "GhostScript did not create output file: #{output_file.path}"
144
- end
145
-
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
153
- end
154
-
155
- private
156
-
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}"
163
- end
164
-
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
180
- end
181
- end
182
- end
183
- end
@@ -1,226 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "singleton"
4
- require "tmpdir"
5
- require "ukiryu"
6
-
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.
12
- class InkscapeWrapper
13
- include Singleton
14
-
15
- class << self
16
- def convert(content:, input_format:, output_format:, output_class:,
17
- plain: false)
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
26
- end
27
-
28
- def convert(content:, input_format:, output_format:, output_class:,
29
- plain: false)
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)
58
- end
59
- end
60
-
61
- def height(content, format)
62
- query_integer(content, format, :height)
63
- end
64
-
65
- def width(content, format)
66
- query_integer(content, format, :width)
67
- end
68
-
69
- private
70
-
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}"
77
- end
78
-
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
- }
85
-
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
90
-
91
- # Add plain SVG flag
92
- params[:plain] = true if plain && output_format == :svg
93
-
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
96
-
97
- params
98
- end
99
-
100
- # Find the output file (Inkscape may create it with different name)
101
- def find_output(source_path, output_extension)
102
- basenames = [File.basename(source_path, ".*"),
103
- File.basename(source_path)]
104
-
105
- paths = basenames.map do |basename|
106
- "#{File.join(File.dirname(source_path), basename)}.#{output_extension}"
107
- end
108
-
109
- paths.find { |p| File.exist?(p) }
110
- end
111
-
112
- # Raise conversion error with details
113
- def raise_conversion_error(result)
114
- raise Vectory::ConversionError,
115
- "Could not convert with Inkscape. " \
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
141
-
142
- with_temp_file(content, format) do |path|
143
- params = { input: path, param_key => true }
144
-
145
- result = tool.execute(:query,
146
- execution_timeout: Configuration.instance.timeout,
147
- **params)
148
- raise_query_error(result) if result.stdout.empty?
149
-
150
- result.stdout
151
- end
152
- end
153
-
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)
159
-
160
- yield path
161
- end
162
- end
163
-
164
- # Create temp files for input and output
165
- def with_temp_files(content, input_format, output_format)
166
- Dir.mktmpdir do |dir|
167
- input_path = File.join(dir, "image.#{input_format}")
168
- output_path = File.join(dir, "image.#{output_format}")
169
- File.binwrite(input_path, content)
170
-
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
178
- end
179
- end
180
-
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)
186
-
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
192
- end
193
-
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
199
- end
200
- rescue StandardError
201
- # Best effort cleanup, don't raise
202
- end
203
-
204
- # Raise query error with details
205
- def raise_query_error(result)
206
- raise Vectory::InkscapeQueryError,
207
- "Could not query with Inkscape. " \
208
- "Command: '#{result.command}',\n" \
209
- "Exit status: '#{result.status}',\n" \
210
- "stdout: '#{result.stdout.strip}',\n" \
211
- "stderr: '#{result.stderr.strip}'."
212
- end
213
-
214
- # Format paths for command execution on current platform
215
- # Handles Windows backslash conversion and quoting for paths with spaces
216
- def external_path(path)
217
- return path unless path
218
- return path unless Platform.windows?
219
-
220
- # Convert forward slashes to backslashes
221
- path.gsub!(%r{/}, "\\")
222
- # Quote paths with spaces
223
- path[/\s/] ? "\"#{path}\"" : path
224
- end
225
- end
226
- end