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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/docs.yml +59 -0
  3. data/.github/workflows/links.yml +99 -0
  4. data/.github/workflows/rake.yml +5 -1
  5. data/.github/workflows/release.yml +7 -3
  6. data/.gitignore +5 -0
  7. data/.rubocop.yml +11 -3
  8. data/.rubocop_todo.yml +252 -0
  9. data/Gemfile +4 -2
  10. data/README.adoc +23 -1
  11. data/Rakefile +13 -0
  12. data/docs/Gemfile +18 -0
  13. data/docs/_config.yml +179 -0
  14. data/docs/features/conversion.adoc +205 -0
  15. data/docs/features/external-dependencies.adoc +305 -0
  16. data/docs/features/format-detection.adoc +173 -0
  17. data/docs/features/index.adoc +205 -0
  18. data/docs/getting-started/core-concepts.adoc +214 -0
  19. data/docs/getting-started/index.adoc +37 -0
  20. data/docs/getting-started/installation.adoc +318 -0
  21. data/docs/getting-started/quick-start.adoc +160 -0
  22. data/docs/guides/error-handling.adoc +400 -0
  23. data/docs/guides/index.adoc +197 -0
  24. data/docs/index.adoc +146 -0
  25. data/docs/lychee.toml +25 -0
  26. data/docs/reference/api.adoc +355 -0
  27. data/docs/reference/index.adoc +189 -0
  28. data/docs/understanding/architecture.adoc +277 -0
  29. data/docs/understanding/index.adoc +148 -0
  30. data/docs/understanding/inkscape-wrapper.adoc +270 -0
  31. data/lib/vectory/capture.rb +165 -37
  32. data/lib/vectory/cli.rb +2 -0
  33. data/lib/vectory/configuration.rb +177 -0
  34. data/lib/vectory/conversion/ghostscript_strategy.rb +77 -0
  35. data/lib/vectory/conversion/inkscape_strategy.rb +124 -0
  36. data/lib/vectory/conversion/strategy.rb +58 -0
  37. data/lib/vectory/conversion.rb +104 -0
  38. data/lib/vectory/datauri.rb +1 -1
  39. data/lib/vectory/emf.rb +17 -5
  40. data/lib/vectory/eps.rb +45 -3
  41. data/lib/vectory/errors.rb +25 -0
  42. data/lib/vectory/file_magic.rb +2 -2
  43. data/lib/vectory/ghostscript_wrapper.rb +160 -0
  44. data/lib/vectory/image_resize.rb +2 -2
  45. data/lib/vectory/inkscape_wrapper.rb +205 -0
  46. data/lib/vectory/pdf.rb +76 -0
  47. data/lib/vectory/platform.rb +105 -0
  48. data/lib/vectory/ps.rb +47 -3
  49. data/lib/vectory/svg.rb +46 -3
  50. data/lib/vectory/svg_document.rb +40 -24
  51. data/lib/vectory/system_call.rb +36 -9
  52. data/lib/vectory/vector.rb +3 -23
  53. data/lib/vectory/version.rb +1 -1
  54. data/lib/vectory.rb +16 -11
  55. metadata +34 -3
  56. data/lib/vectory/inkscape_converter.rb +0 -141
@@ -3,15 +3,16 @@ require "timeout"
3
3
  module Vectory
4
4
  module Capture
5
5
  class << self
6
- # rubocop:disable all
7
- #
8
- # Originally from https://gist.github.com/pasela/9392115
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 = capture3_with_timeout([env,] cmd... [, opts])
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
- :stdin_data => spawn_opts.delete(:stdin_data) || "",
44
- :binmode => spawn_opts.delete(:binmode) || false,
45
- :timeout => spawn_opts.delete(:timeout),
46
- :signal => spawn_opts.delete(:signal) || :TERM,
47
- :kill_after => spawn_opts.delete(:kill_after),
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
- :pid => nil,
67
- :status => nil,
68
- :stdout => nil,
69
- :stderr => nil,
70
- :timeout => false,
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
- Timeout.timeout(opts[:timeout]) do
79
- result[:pid] = spawn(*cmd, spawn_opts)
80
- wait_thr = Process.detach(result[:pid])
81
- in_r.close
82
- out_w.close
83
- err_w.close
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
- out_reader = Thread.new { out_r.read }
86
- err_reader = Thread.new { err_r.read }
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
- result[:status] = wait_thr.value
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
- rescue Timeout::Error
94
- result[:timeout] = true
95
- pid = spawn_opts[:pgroup] ? -result[:pid] : result[:pid]
96
- Process.kill(opts[:signal], pid)
97
- if opts[:kill_after]
98
- unless wait_thr.join(opts[:kill_after])
99
- Process.kill(:KILL, pid)
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
- result[:status] = wait_thr.value if wait_thr
104
- result[:stdout] = out_reader.value if out_reader
105
- result[:stderr] = err_reader.value if err_reader
106
- out_r.close unless out_r.closed?
107
- err_r.close unless err_r.closed?
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