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,74 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Vectory
4
- module Conversion
5
- # Ghostscript-based conversion strategy
6
- #
7
- # Handles PS/EPS → PDF conversions using Ghostscript.
8
- # Ghostscript is used for its accurate BoundingBox preservation.
9
- #
10
- # @see https://www.ghostscript.com/
11
- class GhostscriptStrategy < Strategy
12
- # Ghostscript supports PS/EPS → PDF conversions
13
- SUPPORTED_CONVERSIONS = [
14
- %i[ps pdf],
15
- %i[eps pdf],
16
- ].freeze
17
-
18
- # Convert PS/EPS content to PDF using Ghostscript
19
- #
20
- # @param content [String] the PS/EPS content to convert
21
- # @param input_format [Symbol] the input format (:ps or :eps)
22
- # @param output_format [Symbol] the output format (must be :pdf)
23
- # @param options [Hash] additional options
24
- # @option options [Boolean] :eps_crop use EPSCrop for better BoundingBox handling
25
- # @return [String] the PDF content
26
- # @raise [Vectory::GhostscriptNotFoundError] if Ghostscript is not available
27
- # @raise [Vectory::ConversionError] if conversion fails
28
- def convert(content, input_format:, output_format:, **options)
29
- unless output_format == :pdf
30
- raise ArgumentError,
31
- "Ghostscript only supports PDF output, got: #{output_format}"
32
- end
33
-
34
- unless %i[ps eps].include?(input_format)
35
- raise ArgumentError,
36
- "Ghostscript only supports PS/EPS input, got: #{input_format}"
37
- end
38
-
39
- GhostscriptWrapper.convert(content,
40
- eps_crop: options[:eps_crop] || false)
41
- end
42
-
43
- # Check if this conversion is supported
44
- #
45
- # @param input_format [Symbol] the input format
46
- # @param output_format [Symbol] the output format
47
- # @return [Boolean] true if Ghostscript supports this conversion
48
- def supports?(input_format, output_format)
49
- SUPPORTED_CONVERSIONS.include?([input_format, output_format])
50
- end
51
-
52
- # Get supported conversions
53
- #
54
- # @return [Array<Array<Symbol>>] array of [input, output] format pairs
55
- def supported_conversions
56
- SUPPORTED_CONVERSIONS
57
- end
58
-
59
- # Check if Ghostscript is available
60
- #
61
- # @return [Boolean] true if Ghostscript can be found
62
- def available?
63
- GhostscriptWrapper.available?
64
- end
65
-
66
- # Get the tool name
67
- #
68
- # @return [String] "ghostscript"
69
- def tool_name
70
- "ghostscript"
71
- end
72
- end
73
- end
74
- end
@@ -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