vectory 0.8.2 → 0.9.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1825d8ed689d1eb94da1c473ffd9671bc3237471f3f4b675d6f2d8ff6db4e2a7
4
- data.tar.gz: 7d9ee417ac1b74b481589a83f45a359d556f1c3d2aa401149f57439c39085811
3
+ metadata.gz: fc294acc45e36e018340d14a047047d5addc5c712fabc42fec2642b82bc77aa8
4
+ data.tar.gz: '09b35052e8b019a2553eaa34ae14c6fce0344c5fc8d0e61b42fc4bb574bbe569'
5
5
  SHA512:
6
- metadata.gz: 03d2373df0d22c8168dddecb4e073a62974db0d00b898d3a9896d839390c9aba6c959553d2e172e84534ece21913d0aa503d6469f600fae15e7bf5f32cc7a5b7
7
- data.tar.gz: 169c5f762f9eb0a9897e7277633a1e7181f17e918341198b8d420ec482a3c83425d1ad45de4bce6428970d1850bc916fd91231a6c22dbf01c7ef6660070a7e5b
6
+ metadata.gz: ffb21fcdc31b15763547be90085ec49288588c7fabc9d9a2f803f5a0fcf5f958c3bf020e7f5aa96abeb2d31de2c1077e6cb9b9fa57b108e28355328e2fa64eb3
7
+ data.tar.gz: 989f2bb3ade3b0416571b67d7cf8262def78291e9730ee73050052ecc3fa84639be2f93d21af392fc7f7b2a0eb3be3aedd19afe7628598aa60f01afcd4d4f5ae
@@ -15,5 +15,7 @@ permissions:
15
15
  jobs:
16
16
  rake:
17
17
  uses: metanorma/ci/.github/workflows/inkscape-rake.yml@main
18
+ with:
19
+ private-fonts: 'false'
18
20
  secrets:
19
21
  pat_token: ${{ secrets.CLARICLE_CI_PAT_TOKEN }}
data/Gemfile CHANGED
@@ -6,6 +6,7 @@ source "https://rubygems.org"
6
6
  gemspec
7
7
 
8
8
  gem "canon", "~> 0.1.7"
9
+ gem "connection_pool", "~> 2.0"
9
10
  gem "openssl", "~> 3.0"
10
11
  gem "rake"
11
12
  gem "rspec"
data/lib/vectory/cli.rb CHANGED
@@ -1,6 +1,5 @@
1
1
  require "thor"
2
- require_relative "../vectory"
3
- require_relative "file_magic"
2
+ require "vectory"
4
3
 
5
4
  module Vectory
6
5
  class CLI < Thor
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../ghostscript_wrapper"
4
- require_relative "strategy"
5
-
6
3
  module Vectory
7
4
  module Conversion
8
5
  # Ghostscript-based conversion strategy
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../inkscape_wrapper"
4
- require_relative "strategy"
5
-
6
3
  module Vectory
7
4
  module Conversion
8
5
  # Inkscape-based conversion strategy
@@ -1,9 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "conversion/strategy"
4
- require_relative "conversion/inkscape_strategy"
5
- require_relative "conversion/ghostscript_strategy"
6
-
7
3
  module Vectory
8
4
  # Conversion module provides strategy-based conversion interface
9
5
  #
@@ -16,6 +12,10 @@ module Vectory
16
12
  # @example Get available strategies for a conversion
17
13
  # Vectory::Conversion.strategies_for(:svg, :eps)
18
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
19
  class << self
20
20
  # Convert content from one format to another
21
21
  #
data/lib/vectory/eps.rb CHANGED
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "ghostscript_wrapper"
4
- require_relative "pdf"
5
-
6
3
  module Vectory
7
4
  class Eps < Vector
8
5
  def self.default_extension
@@ -2,18 +2,18 @@
2
2
 
3
3
  require "tempfile"
4
4
  require "fileutils"
5
- require_relative "errors"
6
- require_relative "system_call"
7
- require_relative "platform"
5
+ require "ukiryu"
8
6
 
9
7
  module Vectory
10
8
  # GhostscriptWrapper converts PS and EPS files to PDF using Ghostscript
9
+ #
10
+ # Uses Ukiryu for platform-adaptive command execution.
11
11
  class GhostscriptWrapper
12
12
  SUPPORTED_INPUT_FORMATS = %w[ps eps].freeze
13
13
 
14
14
  class << self
15
15
  def available?
16
- ghostscript_path
16
+ ghostscript_tool
17
17
  true
18
18
  rescue GhostscriptNotFoundError
19
19
  false
@@ -22,9 +22,8 @@ module Vectory
22
22
  def version
23
23
  return nil unless available?
24
24
 
25
- cmd = [ghostscript_path, "--version"]
26
- call = SystemCall.new(cmd).call
27
- call.stdout.strip
25
+ tool = ghostscript_tool
26
+ tool.version
28
27
  rescue StandardError
29
28
  nil
30
29
  end
@@ -49,15 +48,22 @@ module Vectory
49
48
  # Close output file so GhostScript can write to it
50
49
  output_file.close
51
50
 
52
- cmd = build_command(input_file.path, output_file.path,
53
- eps_crop: eps_crop)
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)
54
55
 
55
- call = nil
56
- begin
57
- call = SystemCall.new(cmd).call
58
- rescue SystemCallError => e
56
+ result = tool.execute(:convert,
57
+ execution_timeout: Configuration.instance.timeout,
58
+ **params)
59
+
60
+ unless result.success?
59
61
  raise ConversionError,
60
- "GhostScript conversion failed: #{e.message}"
62
+ "GhostScript conversion failed. " \
63
+ "Command: #{result.command}, " \
64
+ "Exit status: #{result.status}, " \
65
+ "stdout: '#{result.stdout.strip}', " \
66
+ "stderr: '#{result.stderr.strip}'"
61
67
  end
62
68
 
63
69
  unless File.exist?(output_file.path)
@@ -71,9 +77,9 @@ module Vectory
71
77
  if output_content.size < 100
72
78
  raise ConversionError,
73
79
  "GhostScript created invalid PDF (#{output_content.size} bytes). " \
74
- "Command: #{cmd.join(' ')}, " \
75
- "stdout: '#{call&.stdout&.strip}', " \
76
- "stderr: '#{call&.stderr&.strip}'"
80
+ "Command: #{result.command}, " \
81
+ "stdout: '#{result.stdout.strip}', " \
82
+ "stderr: '#{result.stderr.strip}'"
77
83
  end
78
84
 
79
85
  output_content
@@ -86,74 +92,91 @@ module Vectory
86
92
  end
87
93
  end
88
94
 
89
- private
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
90
117
 
91
- def ghostscript_path
92
- # First try common installation paths specific to each platform
93
- if Platform.windows?
94
- # Check common Windows installation directories first
95
- common_windows_paths = [
96
- "C:/Program Files/gs/gs*/bin/gswin64c.exe",
97
- "C:/Program Files (x86)/gs/gs*/bin/gswin32c.exe",
98
- ]
99
-
100
- common_windows_paths.each do |pattern|
101
- Dir.glob(pattern).sort.reverse.each do |path|
102
- return path if File.executable?(path)
103
- end
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}'"
104
139
  end
105
140
 
106
- # Then try PATH for Windows executables
107
- ["gswin64c.exe", "gswin32c.exe", "gs"].each do |cmd|
108
- path = find_in_path(cmd)
109
- return path if path
141
+ unless File.exist?(output_file.path)
142
+ raise ConversionError,
143
+ "GhostScript did not create output file: #{output_file.path}"
110
144
  end
111
- else
112
- # On Unix-like systems, check PATH
113
- path = find_in_path("gs")
114
- return path if path
115
- end
116
145
 
117
- raise GhostscriptNotFoundError
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
118
153
  end
119
154
 
120
- def find_in_path(cmd)
121
- # If command already has an extension, try it as-is first
122
- if File.extname(cmd) != ""
123
- Platform.executable_search_paths.each do |path|
124
- exe = File.join(path, cmd)
125
- return exe if File.executable?(exe) && !File.directory?(exe)
126
- end
127
- end
155
+ private
128
156
 
129
- # Try with PATHEXT extensions
130
- exts = ENV["PATHEXT"] ? ENV["PATHEXT"].split(";") : [""]
131
- Platform.executable_search_paths.each do |path|
132
- exts.each do |ext|
133
- exe = File.join(path, "#{cmd}#{ext}")
134
- return exe if File.executable?(exe) && !File.directory?(exe)
135
- end
136
- end
137
- nil
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}"
138
163
  end
139
164
 
140
- def build_command(input_path, output_path, options = {})
141
- cmd_parts = []
142
- cmd_parts << ghostscript_path
143
- cmd_parts << "-sDEVICE=pdfwrite"
144
- cmd_parts << "-dNOPAUSE"
145
- cmd_parts << "-dBATCH"
146
- cmd_parts << "-dSAFER"
147
- # Use separate arguments for output file to ensure proper path handling
148
- cmd_parts << "-sOutputFile=#{output_path}"
149
- cmd_parts << "-dEPSCrop" if options[:eps_crop]
150
- cmd_parts << "-dAutoRotatePages=/None"
151
- cmd_parts << "-dQUIET"
152
- # Use -f to explicitly specify input file
153
- cmd_parts << "-f"
154
- cmd_parts << input_path
155
-
156
- cmd_parts
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
157
180
  end
158
181
  end
159
182
  end
@@ -2,87 +2,102 @@
2
2
 
3
3
  require "singleton"
4
4
  require "tmpdir"
5
- require_relative "system_call"
6
- require_relative "platform"
5
+ require "ukiryu"
7
6
 
8
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.
9
12
  class InkscapeWrapper
10
13
  include Singleton
11
14
 
12
- def self.convert(content:, input_format:, output_format:, output_class:,
15
+ class << self
16
+ def convert(content:, input_format:, output_format:, output_class:,
13
17
  plain: false)
14
- instance.convert(
15
- content: content,
16
- input_format: input_format,
17
- output_format: output_format,
18
- output_class: output_class,
19
- plain: plain,
20
- )
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
21
26
  end
22
27
 
23
28
  def convert(content:, input_format:, output_format:, output_class:,
24
29
  plain: false)
25
- with_temp_files(content, input_format,
26
- output_format) do |input_path, output_path|
27
- exe = inkscape_path_or_raise_error
28
- exe = external_path(exe)
29
- input_path = external_path(input_path)
30
- output_path = external_path(output_path)
31
-
32
- cmd = build_command(exe, input_path, output_path, output_format, plain)
33
- # Pass environment to disable display on non-Windows systems
34
- env = headless_environment
35
- call = SystemCall.new(cmd, env: env).call
36
-
37
- actual_output = find_output(input_path, output_format)
38
- raise_conversion_error(call) unless actual_output
39
-
40
- output_class.from_path(actual_output)
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)
41
58
  end
42
59
  end
43
60
 
44
61
  def height(content, format)
45
- query_integer(content, format, "--query-height")
62
+ query_integer(content, format, :height)
46
63
  end
47
64
 
48
65
  def width(content, format)
49
- query_integer(content, format, "--query-width")
66
+ query_integer(content, format, :width)
50
67
  end
51
68
 
52
69
  private
53
70
 
54
- def inkscape_path_or_raise_error
55
- inkscape_path or raise(InkscapeNotFoundError,
56
- "Inkscape missing in PATH, unable to " \
57
- "convert image. Aborting.")
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}"
58
77
  end
59
78
 
60
- def inkscape_path
61
- @inkscape_path ||= find_inkscape
62
- end
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
+ }
63
85
 
64
- def find_inkscape
65
- cmds.each do |cmd|
66
- extensions.each do |ext|
67
- Platform.executable_search_paths.each do |path|
68
- exe = File.join(path, "#{cmd}#{ext}")
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
69
90
 
70
- return exe if File.executable?(exe) && !File.directory?(exe)
71
- end
72
- end
73
- end
74
-
75
- nil
76
- end
91
+ # Add plain SVG flag
92
+ params[:plain] = true if plain && output_format == :svg
77
93
 
78
- def cmds
79
- ["inkscapecom", "inkscape"]
80
- end
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
81
96
 
82
- def extensions
83
- ENV["PATHEXT"] ? ENV["PATHEXT"].split(";") : [""]
97
+ params
84
98
  end
85
99
 
100
+ # Find the output file (Inkscape may create it with different name)
86
101
  def find_output(source_path, output_extension)
87
102
  basenames = [File.basename(source_path, ".*"),
88
103
  File.basename(source_path)]
@@ -94,100 +109,106 @@ plain: false)
94
109
  paths.find { |p| File.exist?(p) }
95
110
  end
96
111
 
97
- def raise_conversion_error(call)
112
+ # Raise conversion error with details
113
+ def raise_conversion_error(result)
98
114
  raise Vectory::ConversionError,
99
115
  "Could not convert with Inkscape. " \
100
- "Inkscape cmd: '#{call.cmd}',\n" \
101
- "status: '#{call.status}',\n" \
102
- "stdout: '#{call.stdout.strip}',\n" \
103
- "stderr: '#{call.stderr.strip}'."
104
- end
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
105
141
 
106
- def build_command(exe, input_path, output_path, output_format, plain)
107
- # Modern Inkscape (1.0+) uses --export-filename
108
- # Older versions use --export-<format>-file or --export-type
109
- if inkscape_version_modern?
110
- cmd = "#{exe} --export-filename=#{output_path}"
111
- cmd += " --export-plain-svg" if plain && output_format == :svg
112
- # For PDF input, specify which page to use (avoid interactive prompt)
113
- cmd += " --export-page=1" if input_path.end_with?(".pdf")
114
- else
115
- # Legacy Inkscape (0.x) uses --export-type
116
- cmd = "#{exe} --export-type=#{output_format}"
117
- cmd += " --export-plain-svg" if plain && output_format == :svg
118
- end
119
- cmd += " #{input_path}"
120
- cmd
121
- end
142
+ with_temp_file(content, format) do |path|
143
+ params = { input: path, param_key => true }
122
144
 
123
- def inkscape_version_modern?
124
- return @inkscape_version_modern if defined?(@inkscape_version_modern)
145
+ result = tool.execute(:query,
146
+ execution_timeout: Configuration.instance.timeout,
147
+ **params)
148
+ raise_query_error(result) if result.stdout.empty?
125
149
 
126
- exe = inkscape_path
127
- return @inkscape_version_modern = true unless exe # Default to modern
150
+ result.stdout
151
+ end
152
+ end
128
153
 
129
- version_output = `#{external_path(exe)} --version 2>&1`
130
- version_match = version_output.match(/Inkscape (\d+)\./)
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)
131
159
 
132
- @inkscape_version_modern = if version_match
133
- version_match[1].to_i >= 1
134
- else
135
- true # Default to modern if we can't detect
136
- end
160
+ yield path
161
+ end
137
162
  end
138
163
 
164
+ # Create temp files for input and output
139
165
  def with_temp_files(content, input_format, output_format)
140
166
  Dir.mktmpdir do |dir|
141
167
  input_path = File.join(dir, "image.#{input_format}")
142
168
  output_path = File.join(dir, "image.#{output_format}")
143
169
  File.binwrite(input_path, content)
144
170
 
145
- yield input_path, output_path
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
146
178
  end
147
179
  end
148
180
 
149
- def query_integer(content, format, options)
150
- query(content, format, options).to_f.round
151
- end
152
-
153
- def query(content, format, options)
154
- exe = inkscape_path_or_raise_error
155
-
156
- with_temp_file(content, format) do |path|
157
- cmd = "#{external_path(exe)} #{options} #{external_path(path)}"
158
-
159
- # Pass environment to disable display on non-Windows systems
160
- env = headless_environment
161
- call = SystemCall.new(cmd, env: env).call
162
- raise_query_error(call) if call.stdout.empty?
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)
163
186
 
164
- call.stdout
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
165
192
  end
166
- end
167
193
 
168
- def with_temp_file(content, extension)
169
- Dir.mktmpdir do |dir|
170
- path = File.join(dir, "image.#{extension}")
171
- File.binwrite(path, content)
172
-
173
- yield path
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
174
199
  end
200
+ rescue StandardError
201
+ # Best effort cleanup, don't raise
175
202
  end
176
203
 
177
- def raise_query_error(call)
204
+ # Raise query error with details
205
+ def raise_query_error(result)
178
206
  raise Vectory::InkscapeQueryError,
179
207
  "Could not query with Inkscape. " \
180
- "Inkscape cmd: '#{call.cmd}',\n" \
181
- "status: '#{call.status}',\n" \
182
- "stdout: '#{call.stdout.strip}',\n" \
183
- "stderr: '#{call.stderr.strip}'."
184
- end
185
-
186
- # Returns environment variables for headless operation
187
- # On non-Windows systems, disable DISPLAY to prevent X11/GDK initialization
188
- def headless_environment
189
- # On macOS/Linux, disable DISPLAY to prevent Gdk/X11 warnings
190
- Platform.windows? ? {} : { "DISPLAY" => "" }
208
+ "Command: '#{result.command}',\n" \
209
+ "Exit status: '#{result.status}',\n" \
210
+ "stdout: '#{result.stdout.strip}',\n" \
211
+ "stderr: '#{result.stderr.strip}'."
191
212
  end
192
213
 
193
214
  # Format paths for command execution on current platform
data/lib/vectory/pdf.rb CHANGED
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "vector"
4
- require_relative "inkscape_wrapper"
5
-
6
3
  module Vectory
7
4
  class Pdf < Vector
8
5
  attr_accessor :original_height, :original_width
@@ -16,13 +13,7 @@ module Vectory
16
13
  end
17
14
 
18
15
  def to_svg
19
- svg = InkscapeWrapper.convert(
20
- content: content,
21
- input_format: :pdf,
22
- output_format: :svg,
23
- output_class: Svg,
24
- plain: true,
25
- )
16
+ svg = convert_to_svg
26
17
 
27
18
  # If we have original dimensions from EPS/PS, adjust the SVG
28
19
  if original_height && original_width
@@ -35,33 +26,82 @@ module Vectory
35
26
  end
36
27
 
37
28
  def to_eps
38
- InkscapeWrapper.convert(
39
- content: content,
40
- input_format: :pdf,
41
- output_format: :eps,
42
- output_class: Eps,
43
- )
29
+ with_inkscape_pdf_fallback(:eps, Eps)
44
30
  end
45
31
 
46
32
  def to_ps
47
- InkscapeWrapper.convert(
48
- content: content,
49
- input_format: :pdf,
50
- output_format: :ps,
51
- output_class: Ps,
52
- )
33
+ with_inkscape_pdf_fallback(:ps, Ps)
53
34
  end
54
35
 
55
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)
56
54
  InkscapeWrapper.convert(
57
55
  content: content,
58
56
  input_format: :pdf,
59
- output_format: :emf,
60
- output_class: Emf,
57
+ output_format: output_format,
58
+ output_class: output_class,
59
+ plain: plain,
61
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
+ return 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
62
88
  end
63
89
 
64
- private
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
65
105
 
66
106
  def adjust_svg_dimensions(svg_content, width, height)
67
107
  # Replace width and height attributes in SVG root element
data/lib/vectory/ps.rb CHANGED
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "ghostscript_wrapper"
4
- require_relative "pdf"
5
-
6
3
  module Vectory
7
4
  class Ps < Vector
8
5
  def self.default_extension
@@ -1,6 +1,3 @@
1
- require_relative "svg"
2
- require_relative "svg_document"
3
-
4
1
  module Vectory
5
2
  # Processes SVG mapping in XML documents.
6
3
  #
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "tempfile"
4
- require_relative "inkscape_wrapper"
5
4
 
6
5
  module Vectory
7
6
  class Vector < Image
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Vectory
4
- VERSION = "0.8.2"
4
+ VERSION = "0.9.0"
5
5
  end
data/lib/vectory.rb CHANGED
@@ -1,30 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # External dependencies
3
4
  require "logger"
4
- require_relative "vectory/version"
5
- require_relative "vectory/utils"
5
+ require "ukiryu"
6
6
 
7
+ # Define base error class and additional error classes
8
+ # (used in class bodies like cli.rb, so can't be autoloaded)
7
9
  module Vectory
8
10
  class Error < StandardError; end
9
- end
10
-
11
- require_relative "vectory/errors"
12
- require_relative "vectory/platform"
13
- require_relative "vectory/configuration"
14
- require_relative "vectory/conversion"
15
- require_relative "vectory/image"
16
- require_relative "vectory/image_resize"
17
- require_relative "vectory/datauri"
18
- require_relative "vectory/vector"
19
- require_relative "vectory/ghostscript_wrapper"
20
- require_relative "vectory/pdf"
21
- require_relative "vectory/eps"
22
- require_relative "vectory/ps"
23
- require_relative "vectory/emf"
24
- require_relative "vectory/svg"
25
- require_relative "vectory/svg_mapping"
26
11
 
27
- module Vectory
28
12
  class SystemCallError < Error; end
29
13
 
30
14
  class NotImplementedError < Error; end
@@ -32,7 +16,42 @@ module Vectory
32
16
  class NotWrittenToDiskError < Error; end
33
17
 
34
18
  class ParsingError < Error; end
19
+ end
20
+
21
+ require_relative "vectory/errors"
35
22
 
23
+ # Lazy load: all other internal Vectory dependencies via autoload
24
+ module Vectory
25
+ # Core utilities
26
+ autoload :Version, "vectory/version"
27
+ autoload :Utils, "vectory/utils"
28
+ autoload :Platform, "vectory/platform"
29
+
30
+ # Wrappers
31
+ autoload :GhostscriptWrapper, "vectory/ghostscript_wrapper"
32
+ autoload :InkscapeWrapper, "vectory/inkscape_wrapper"
33
+
34
+ # Conversion system
35
+ autoload :Conversion, "vectory/conversion"
36
+
37
+ # Format classes
38
+ autoload :Configuration, "vectory/configuration"
39
+ autoload :Image, "vectory/image"
40
+ autoload :ImageResize, "vectory/image_resize"
41
+ autoload :Datauri, "vectory/datauri"
42
+ autoload :Vector, "vectory/vector"
43
+ autoload :Pdf, "vectory/pdf"
44
+ autoload :Eps, "vectory/eps"
45
+ autoload :Ps, "vectory/ps"
46
+ autoload :Emf, "vectory/emf"
47
+ autoload :Svg, "vectory/svg"
48
+ autoload :SvgMapping, "vectory/svg_mapping"
49
+ autoload :SvgDocument, "vectory/svg_document"
50
+ autoload :FileMagic, "vectory/file_magic"
51
+ end
52
+
53
+ # Define additional module methods
54
+ module Vectory
36
55
  def self.ui
37
56
  @ui ||= Logger.new($stdout).tap do |logger|
38
57
  logger.level = ENV["VECTORY_LOG"] || Logger::WARN
data/vectory.gemspec CHANGED
@@ -24,7 +24,7 @@ Gem::Specification.new do |spec|
24
24
  f.match(%r{^(bin|spec)/})
25
25
  end
26
26
  spec.test_files = `git ls-files -- {spec}/*`.split("\n")
27
- spec.required_ruby_version = ">= 2.5.0"
27
+ spec.required_ruby_version = ">= 3.1.0"
28
28
 
29
29
  spec.add_dependency "base64"
30
30
  spec.add_dependency "emf2svg"
@@ -32,4 +32,5 @@ Gem::Specification.new do |spec|
32
32
  spec.add_dependency "marcel", "~> 1.0"
33
33
  spec.add_dependency "nokogiri", "~> 1.14"
34
34
  spec.add_dependency "thor", "~> 1.0"
35
+ spec.add_dependency "ukiryu", "~> 0.2"
35
36
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vectory
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.2
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-02-05 00:00:00.000000000 Z
11
+ date: 2026-02-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -94,6 +94,20 @@ dependencies:
94
94
  - - "~>"
95
95
  - !ruby/object:Gem::Version
96
96
  version: '1.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: ukiryu
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '0.2'
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '0.2'
97
111
  description: |
98
112
  Vectory performs pairwise vector image conversions for common
99
113
  vector image formats, such as SVG, EMF, EPS and PS.
@@ -110,7 +124,6 @@ files:
110
124
  - ".github/workflows/rake.yml"
111
125
  - ".github/workflows/release.yml"
112
126
  - ".gitignore"
113
- - ".hound.yml"
114
127
  - ".rubocop.yml"
115
128
  - ".rubocop_todo.yml"
116
129
  - Gemfile
@@ -137,7 +150,6 @@ files:
137
150
  - docs/understanding/inkscape-wrapper.adoc
138
151
  - exe/vectory
139
152
  - lib/vectory.rb
140
- - lib/vectory/capture.rb
141
153
  - lib/vectory/cli.rb
142
154
  - lib/vectory/configuration.rb
143
155
  - lib/vectory/conversion.rb
@@ -159,7 +171,6 @@ files:
159
171
  - lib/vectory/svg.rb
160
172
  - lib/vectory/svg_document.rb
161
173
  - lib/vectory/svg_mapping.rb
162
- - lib/vectory/system_call.rb
163
174
  - lib/vectory/utils.rb
164
175
  - lib/vectory/vector.rb
165
176
  - lib/vectory/version.rb
@@ -177,7 +188,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
177
188
  requirements:
178
189
  - - ">="
179
190
  - !ruby/object:Gem::Version
180
- version: 2.5.0
191
+ version: 3.1.0
181
192
  required_rubygems_version: !ruby/object:Gem::Requirement
182
193
  requirements:
183
194
  - - ">="
data/.hound.yml DELETED
@@ -1,5 +0,0 @@
1
- # Auto-generated by Cimas: Do not edit it manually!
2
- # See https://github.com/metanorma/cimas
3
- ruby:
4
- enabled: true
5
- config_file: .rubocop.yml
@@ -1,243 +0,0 @@
1
- require "timeout"
2
-
3
- module Vectory
4
- module Capture
5
- class << self
6
- def windows?
7
- !!((RUBY_PLATFORM =~ /(win|w)(32|64)$/) ||
8
- (RUBY_PLATFORM =~ /mswin|mingw/))
9
- end
10
-
11
- # Capture the standard output and the standard error of a command.
12
- # Almost same as Open3.capture3 method except for timeout handling and return value.
13
- # See Open3.capture3.
14
- #
15
- # result = with_timeout([env,] cmd... [, opts])
16
- #
17
- # The arguments env, cmd and opts are passed to Process.spawn except
18
- # opts[:stdin_data], opts[:binmode], opts[:timeout], opts[:signal]
19
- # and opts[:kill_after]. See Process.spawn.
20
- #
21
- # If opts[:stdin_data] is specified, it is sent to the command's standard input.
22
- #
23
- # If opts[:binmode] is true, internal pipes are set to binary mode.
24
- #
25
- # If opts[:timeout] is specified, SIGTERM is sent to the command after specified seconds.
26
- #
27
- # If opts[:signal] is specified, it is used instead of SIGTERM on timeout.
28
- #
29
- # If opts[:kill_after] is specified, also send a SIGKILL after specified seconds.
30
- # it is only sent if the command is still running after the initial signal was sent.
31
- #
32
- # The return value is a Hash as shown below.
33
- #
34
- # {
35
- # :pid => PID of the command,
36
- # :status => Process::Status of the command,
37
- # :stdout => the standard output of the command,
38
- # :stderr => the standard error of the command,
39
- # :timeout => whether the command was timed out,
40
- # }
41
- def with_timeout(*cmd)
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
50
- opts = {
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,
56
- }
57
-
58
- in_r, in_w = IO.pipe
59
- out_r, out_w = IO.pipe
60
- err_r, err_w = IO.pipe
61
- in_w.sync = true
62
-
63
- if opts[:binmode]
64
- in_w.binmode
65
- out_r.binmode
66
- err_r.binmode
67
- end
68
-
69
- spawn_opts[:in] = in_r
70
- spawn_opts[:out] = out_w
71
- spawn_opts[:err] = err_w
72
-
73
- result = {
74
- pid: nil,
75
- status: nil,
76
- stdout: "",
77
- stderr: "",
78
- timeout: false,
79
- }
80
-
81
- out_reader = nil
82
- err_reader = nil
83
- wait_thr = nil
84
- watchdog = nil
85
-
86
- begin
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
99
-
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
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
117
- in_w.write opts[:stdin_data]
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
149
-
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
161
- end
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
191
- end
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
199
- ensure
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
237
- end
238
-
239
- result
240
- end
241
- end
242
- end
243
- end
@@ -1,86 +0,0 @@
1
- require "open3"
2
- require_relative "capture"
3
-
4
- module Vectory
5
- class SystemCall
6
- TIMEOUT = 60
7
-
8
- attr_reader :status, :stdout, :stderr, :cmd
9
-
10
- def initialize(cmd, timeout = TIMEOUT, env: {})
11
- @cmd = cmd
12
- @timeout = timeout
13
- @env = env
14
- end
15
-
16
- def call
17
- log_cmd(@cmd)
18
-
19
- execute(@cmd, @env)
20
-
21
- log_result
22
-
23
- raise_error unless @status.success?
24
-
25
- self
26
- end
27
-
28
- private
29
-
30
- def log_cmd(cmd)
31
- Vectory.ui.debug("Cmd: '#{cmd}'")
32
- end
33
-
34
- def execute(cmd, env)
35
- # Build spawn options with environment variables and timeout settings
36
- spawn_opts = {}
37
- spawn_opts.merge!(env) if env.any?
38
- spawn_opts[:timeout] = @timeout
39
- spawn_opts[:signal] = :TERM # Use TERM on Unix for graceful shutdown
40
- spawn_opts[:kill_after] = 2
41
-
42
- # Pass command and options directly (without splatting)
43
- # Capture.with_timeout expects: cmd, options_hash
44
- # It will handle extracting the options correctly
45
- result = if cmd.is_a?(Array)
46
- Capture.with_timeout(*cmd, spawn_opts)
47
- else
48
- Capture.with_timeout(cmd, spawn_opts)
49
- end
50
- @stdout = result[:stdout] || ""
51
- @stderr = result[:stderr] || ""
52
- @status = result[:status]
53
- @timed_out = result[:timeout]
54
- rescue Errno::ENOENT => e
55
- raise SystemCallError, e.inspect
56
- end
57
-
58
- def log_result
59
- Vectory.ui.debug("Status: #{@status.inspect}")
60
- Vectory.ui.debug("Stdout: '#{@stdout.strip}'")
61
- Vectory.ui.debug("Stderr: '#{@stderr.strip}'")
62
- end
63
-
64
- def raise_error
65
- if @timed_out
66
- raise SystemCallError,
67
- "Command timed out after #{@timeout} seconds: #{@cmd},\n " \
68
- "stdout: '#{@stdout.strip}',\n " \
69
- "stderr: '#{@stderr.strip}'"
70
- end
71
-
72
- if @status.nil?
73
- raise SystemCallError,
74
- "Failed to run #{@cmd} (no status available),\n " \
75
- "stdout: '#{@stdout.strip}',\n " \
76
- "stderr: '#{@stderr.strip}'"
77
- end
78
-
79
- raise SystemCallError,
80
- "Failed to run #{@cmd},\n " \
81
- "status: #{@status.exitstatus},\n " \
82
- "stdout: '#{@stdout.strip}',\n " \
83
- "stderr: '#{@stderr.strip}'"
84
- end
85
- end
86
- end