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.
- checksums.yaml +4 -4
- data/README.adoc +4 -6
- data/lib/vectory/cli.rb +0 -4
- data/lib/vectory/configuration.rb +3 -31
- data/lib/vectory/emf.rb +25 -12
- data/lib/vectory/eps.rb +2 -14
- data/lib/vectory/errors.rb +0 -14
- data/lib/vectory/ps.rb +2 -14
- data/lib/vectory/svg.rb +25 -34
- data/lib/vectory/vector.rb +6 -2
- data/lib/vectory/version.rb +1 -1
- data/lib/vectory.rb +0 -10
- data/scripts/regenerate_fixtures.rb +33 -0
- data/vectory.gemspec +1 -2
- metadata +4 -25
- data/lib/vectory/conversion/ghostscript_strategy.rb +0 -74
- data/lib/vectory/conversion/inkscape_strategy.rb +0 -121
- data/lib/vectory/conversion/strategy.rb +0 -58
- data/lib/vectory/conversion.rb +0 -104
- data/lib/vectory/ghostscript_wrapper.rb +0 -183
- data/lib/vectory/inkscape_wrapper.rb +0 -226
- data/lib/vectory/pdf.rb +0 -116
- data/lib/vectory/platform.rb +0 -105
|
@@ -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
|
data/lib/vectory/conversion.rb
DELETED
|
@@ -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
|