vectory 0.10.1 → 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,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
data/lib/vectory/pdf.rb DELETED
@@ -1,116 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Vectory
4
- class Pdf < Vector
5
- attr_accessor :original_height, :original_width
6
-
7
- def self.default_extension
8
- "pdf"
9
- end
10
-
11
- def self.mimetype
12
- "application/pdf"
13
- end
14
-
15
- def to_svg
16
- svg = convert_to_svg
17
-
18
- # If we have original dimensions from EPS/PS, adjust the SVG
19
- if original_height && original_width
20
- adjusted_content = adjust_svg_dimensions(svg.content, original_width,
21
- original_height)
22
- svg = Svg.new(adjusted_content, svg.initial_path)
23
- end
24
-
25
- svg
26
- end
27
-
28
- def to_eps
29
- with_inkscape_pdf_fallback(:eps, Eps)
30
- end
31
-
32
- def to_ps
33
- with_inkscape_pdf_fallback(:ps, Ps)
34
- end
35
-
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)
54
- InkscapeWrapper.convert(
55
- content: content,
56
- input_format: :pdf,
57
- output_format: output_format,
58
- output_class: output_class,
59
- plain: plain,
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
- 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
88
- end
89
-
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
105
-
106
- def adjust_svg_dimensions(svg_content, width, height)
107
- # Replace width and height attributes in SVG root element
108
- svg_content.gsub(/(<svg[^>]*\s)width="[^"]*"/, "\\1width=\"#{width}\"")
109
- .gsub(/(<svg[^>]*\s)height="[^"]*"/, "\\1height=\"#{height}\"")
110
- .gsub(/(<svg[^>]*\s)viewBox="[^"]*"/) do |match|
111
- # Adjust viewBox to match new dimensions
112
- "#{match.split('viewBox')[0]}viewBox=\"0 0 #{width} #{height}\""
113
- end
114
- end
115
- end
116
- end
@@ -1,105 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Vectory
4
- # Platform abstraction for centralized OS-specific behavior
5
- #
6
- # This class provides a single source of truth for platform detection
7
- # and platform-specific path handling, eliminating duplicated logic
8
- # across InkscapeWrapper, GhostscriptWrapper, and other classes.
9
- #
10
- # @example Check if running on Windows
11
- # Vectory::Platform.windows? # => true or false
12
- #
13
- # @example Format a path for execution on the current platform
14
- # Vectory::Platform.path_for_execution("C:/Program Files/Inkscape/inkscape.exe")
15
- # # On Windows: "C:\\Program Files\\Inkscape\\inkscape.exe"
16
- # # On Unix: "C:/Program Files/Inkscape/inkscape.exe"
17
- class Platform
18
- class << self
19
- # Detect if running on Windows
20
- #
21
- # @return [Boolean] true if on Windows platform
22
- def windows?
23
- Gem.win_platform?
24
- end
25
-
26
- # Detect if running on macOS
27
- #
28
- # @return [Boolean] true if on macOS platform
29
- def macos?
30
- RbConfig::CONFIG["host_os"].include?("darwin")
31
- end
32
-
33
- # Detect if running on Linux
34
- #
35
- # @return [Boolean] true if on Linux platform
36
- def linux?
37
- RbConfig::CONFIG["host_os"].include?("linux")
38
- end
39
-
40
- # Format a file path for execution on the current platform
41
- #
42
- # On Windows, converts forward slashes to backslashes and quotes paths with spaces.
43
- # On Unix-like systems, returns the path unchanged.
44
- #
45
- # @param path [String] the file path to format
46
- # @return [String] platform-formatted path
47
- def path_for_execution(path)
48
- return path unless path
49
-
50
- formatted_path = windows? ? path.gsub("/", "\\") : path
51
-
52
- # Quote paths with spaces to prevent shell parsing issues
53
- formatted_path[/\s/] ? "\"#{formatted_path}\"" : formatted_path
54
- end
55
-
56
- # Get the PATH environment variable as an array
57
- #
58
- # Handles different PATH separators on Windows (;) vs Unix (:)
59
- #
60
- # @return [Array<String>] array of directory paths
61
- def executable_search_paths
62
- @executable_search_paths ||= begin
63
- path_sep = windows? ? ";" : ":"
64
- (ENV["PATH"] || "").split(path_sep)
65
- end
66
- end
67
-
68
- # Check if a command is available in the system PATH
69
- #
70
- # @param command [String] the command to check
71
- # @return [Boolean] true if command is found in PATH
72
- def command_available?(command)
73
- executable_search_paths.any? do |dir|
74
- executable_path = File.join(dir, command)
75
- File.executable?(executable_path) && !File.directory?(executable_path)
76
- end
77
- end
78
-
79
- # Get the appropriate shell command extension for the platform
80
- #
81
- # @return [String, nil] ".exe" on Windows, nil on Unix
82
- def command_extension
83
- windows? ? ".exe" : nil
84
- end
85
-
86
- # Get the default shell for the platform
87
- #
88
- # @return [String] shell command (e.g., "cmd.exe" on Windows, "sh" on Unix)
89
- def default_shell
90
- if windows?
91
- "cmd.exe"
92
- else
93
- "sh"
94
- end
95
- end
96
-
97
- # Reset cached values (primarily for testing)
98
- #
99
- # @api private
100
- def reset_cache
101
- @executable_search_paths = nil
102
- end
103
- end
104
- end
105
- end