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.
- checksums.yaml +4 -4
- data/README.adoc +4 -6
- data/docs/features/conversion.adoc +11 -10
- data/docs/features/index.adoc +14 -7
- data/docs/getting-started/core-concepts.adoc +2 -1
- 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 +5 -15
- data/lib/vectory/errors.rb +0 -14
- data/lib/vectory/ps.rb +5 -15
- 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 -1
- metadata +9 -16
- 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,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
|
data/lib/vectory/platform.rb
DELETED
|
@@ -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
|