vectory 0.7.8 → 0.8.1
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/.github/workflows/docs.yml +59 -0
- data/.github/workflows/links.yml +99 -0
- data/.github/workflows/rake.yml +5 -1
- data/.github/workflows/release.yml +7 -3
- data/.gitignore +5 -0
- data/.rubocop.yml +11 -3
- data/.rubocop_todo.yml +252 -0
- data/Gemfile +4 -2
- data/README.adoc +23 -1
- data/Rakefile +13 -0
- data/docs/Gemfile +18 -0
- data/docs/_config.yml +179 -0
- data/docs/features/conversion.adoc +205 -0
- data/docs/features/external-dependencies.adoc +305 -0
- data/docs/features/format-detection.adoc +173 -0
- data/docs/features/index.adoc +205 -0
- data/docs/getting-started/core-concepts.adoc +214 -0
- data/docs/getting-started/index.adoc +37 -0
- data/docs/getting-started/installation.adoc +318 -0
- data/docs/getting-started/quick-start.adoc +160 -0
- data/docs/guides/error-handling.adoc +400 -0
- data/docs/guides/index.adoc +197 -0
- data/docs/index.adoc +146 -0
- data/docs/lychee.toml +25 -0
- data/docs/reference/api.adoc +355 -0
- data/docs/reference/index.adoc +189 -0
- data/docs/understanding/architecture.adoc +277 -0
- data/docs/understanding/index.adoc +148 -0
- data/docs/understanding/inkscape-wrapper.adoc +270 -0
- data/lib/vectory/capture.rb +165 -37
- data/lib/vectory/cli.rb +2 -0
- data/lib/vectory/configuration.rb +177 -0
- data/lib/vectory/conversion/ghostscript_strategy.rb +77 -0
- data/lib/vectory/conversion/inkscape_strategy.rb +124 -0
- data/lib/vectory/conversion/strategy.rb +58 -0
- data/lib/vectory/conversion.rb +104 -0
- data/lib/vectory/datauri.rb +1 -1
- data/lib/vectory/emf.rb +17 -5
- data/lib/vectory/eps.rb +45 -3
- data/lib/vectory/errors.rb +25 -0
- data/lib/vectory/file_magic.rb +2 -2
- data/lib/vectory/ghostscript_wrapper.rb +160 -0
- data/lib/vectory/image_resize.rb +98 -12
- data/lib/vectory/inkscape_wrapper.rb +205 -0
- data/lib/vectory/pdf.rb +76 -0
- data/lib/vectory/platform.rb +105 -0
- data/lib/vectory/ps.rb +47 -3
- data/lib/vectory/svg.rb +46 -3
- data/lib/vectory/svg_document.rb +40 -24
- data/lib/vectory/system_call.rb +36 -9
- data/lib/vectory/vector.rb +3 -23
- data/lib/vectory/version.rb +1 -1
- data/lib/vectory.rb +16 -11
- metadata +34 -3
- data/lib/vectory/inkscape_converter.rb +0 -141
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "singleton"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require_relative "system_call"
|
|
6
|
+
require_relative "platform"
|
|
7
|
+
|
|
8
|
+
module Vectory
|
|
9
|
+
class InkscapeWrapper
|
|
10
|
+
include Singleton
|
|
11
|
+
|
|
12
|
+
def self.convert(content:, input_format:, output_format:, output_class:,
|
|
13
|
+
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
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def convert(content:, input_format:, output_format:, output_class:,
|
|
24
|
+
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)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def height(content, format)
|
|
45
|
+
query_integer(content, format, "--query-height")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def width(content, format)
|
|
49
|
+
query_integer(content, format, "--query-width")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def inkscape_path_or_raise_error
|
|
55
|
+
inkscape_path or raise(InkscapeNotFoundError,
|
|
56
|
+
"Inkscape missing in PATH, unable to " \
|
|
57
|
+
"convert image. Aborting.")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def inkscape_path
|
|
61
|
+
@inkscape_path ||= find_inkscape
|
|
62
|
+
end
|
|
63
|
+
|
|
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}")
|
|
69
|
+
|
|
70
|
+
return exe if File.executable?(exe) && !File.directory?(exe)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def cmds
|
|
79
|
+
["inkscapecom", "inkscape"]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def extensions
|
|
83
|
+
ENV["PATHEXT"] ? ENV["PATHEXT"].split(";") : [""]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def find_output(source_path, output_extension)
|
|
87
|
+
basenames = [File.basename(source_path, ".*"),
|
|
88
|
+
File.basename(source_path)]
|
|
89
|
+
|
|
90
|
+
paths = basenames.map do |basename|
|
|
91
|
+
"#{File.join(File.dirname(source_path), basename)}.#{output_extension}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
paths.find { |p| File.exist?(p) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def raise_conversion_error(call)
|
|
98
|
+
raise Vectory::ConversionError,
|
|
99
|
+
"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
|
|
105
|
+
|
|
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
|
|
122
|
+
|
|
123
|
+
def inkscape_version_modern?
|
|
124
|
+
return @inkscape_version_modern if defined?(@inkscape_version_modern)
|
|
125
|
+
|
|
126
|
+
exe = inkscape_path
|
|
127
|
+
return @inkscape_version_modern = true unless exe # Default to modern
|
|
128
|
+
|
|
129
|
+
version_output = `#{external_path(exe)} --version 2>&1`
|
|
130
|
+
version_match = version_output.match(/Inkscape (\d+)\./)
|
|
131
|
+
|
|
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
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def with_temp_files(content, input_format, output_format)
|
|
140
|
+
Dir.mktmpdir do |dir|
|
|
141
|
+
input_path = File.join(dir, "image.#{input_format}")
|
|
142
|
+
output_path = File.join(dir, "image.#{output_format}")
|
|
143
|
+
File.binwrite(input_path, content)
|
|
144
|
+
|
|
145
|
+
yield input_path, output_path
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
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?
|
|
163
|
+
|
|
164
|
+
call.stdout
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
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
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def raise_query_error(call)
|
|
178
|
+
raise Vectory::InkscapeQueryError,
|
|
179
|
+
"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" => "" }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Format paths for command execution on current platform
|
|
194
|
+
# Handles Windows backslash conversion and quoting for paths with spaces
|
|
195
|
+
def external_path(path)
|
|
196
|
+
return path unless path
|
|
197
|
+
return path unless Platform.windows?
|
|
198
|
+
|
|
199
|
+
# Convert forward slashes to backslashes
|
|
200
|
+
path.gsub!(%r{/}, "\\")
|
|
201
|
+
# Quote paths with spaces
|
|
202
|
+
path[/\s/] ? "\"#{path}\"" : path
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
data/lib/vectory/pdf.rb
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "vector"
|
|
4
|
+
require_relative "inkscape_wrapper"
|
|
5
|
+
|
|
6
|
+
module Vectory
|
|
7
|
+
class Pdf < Vector
|
|
8
|
+
attr_accessor :original_height, :original_width
|
|
9
|
+
|
|
10
|
+
def self.default_extension
|
|
11
|
+
"pdf"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.mimetype
|
|
15
|
+
"application/pdf"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
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
|
+
)
|
|
26
|
+
|
|
27
|
+
# If we have original dimensions from EPS/PS, adjust the SVG
|
|
28
|
+
if original_height && original_width
|
|
29
|
+
adjusted_content = adjust_svg_dimensions(svg.content, original_width,
|
|
30
|
+
original_height)
|
|
31
|
+
svg = Svg.new(adjusted_content, svg.initial_path)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
svg
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def to_eps
|
|
38
|
+
InkscapeWrapper.convert(
|
|
39
|
+
content: content,
|
|
40
|
+
input_format: :pdf,
|
|
41
|
+
output_format: :eps,
|
|
42
|
+
output_class: Eps,
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def to_ps
|
|
47
|
+
InkscapeWrapper.convert(
|
|
48
|
+
content: content,
|
|
49
|
+
input_format: :pdf,
|
|
50
|
+
output_format: :ps,
|
|
51
|
+
output_class: Ps,
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def to_emf
|
|
56
|
+
InkscapeWrapper.convert(
|
|
57
|
+
content: content,
|
|
58
|
+
input_format: :pdf,
|
|
59
|
+
output_format: :emf,
|
|
60
|
+
output_class: Emf,
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def adjust_svg_dimensions(svg_content, width, height)
|
|
67
|
+
# Replace width and height attributes in SVG root element
|
|
68
|
+
svg_content.gsub(/(<svg[^>]*\s)width="[^"]*"/, "\\1width=\"#{width}\"")
|
|
69
|
+
.gsub(/(<svg[^>]*\s)height="[^"]*"/, "\\1height=\"#{height}\"")
|
|
70
|
+
.gsub(/(<svg[^>]*\s)viewBox="[^"]*"/) do |match|
|
|
71
|
+
# Adjust viewBox to match new dimensions
|
|
72
|
+
"#{match.split('viewBox')[0]}viewBox=\"0 0 #{width} #{height}\""
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
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
|
data/lib/vectory/ps.rb
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "ghostscript_wrapper"
|
|
4
|
+
require_relative "pdf"
|
|
5
|
+
|
|
3
6
|
module Vectory
|
|
4
7
|
class Ps < Vector
|
|
5
8
|
def self.default_extension
|
|
@@ -20,15 +23,56 @@ module Vectory
|
|
|
20
23
|
end
|
|
21
24
|
|
|
22
25
|
def to_eps
|
|
23
|
-
|
|
26
|
+
to_pdf.to_eps
|
|
24
27
|
end
|
|
25
28
|
|
|
26
29
|
def to_emf
|
|
27
|
-
|
|
30
|
+
to_pdf.to_emf
|
|
28
31
|
end
|
|
29
32
|
|
|
30
33
|
def to_svg
|
|
31
|
-
|
|
34
|
+
to_pdf.to_svg
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def to_pdf
|
|
38
|
+
pdf_content = GhostscriptWrapper.convert(content, eps_crop: false)
|
|
39
|
+
pdf = Pdf.new(pdf_content)
|
|
40
|
+
# Pass original BoundingBox dimensions to preserve them in conversions
|
|
41
|
+
bbox = parse_bounding_box
|
|
42
|
+
if bbox
|
|
43
|
+
pdf.original_width = bbox[:urx] - bbox[:llx]
|
|
44
|
+
pdf.original_height = bbox[:ury] - bbox[:lly]
|
|
45
|
+
end
|
|
46
|
+
pdf
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def height
|
|
50
|
+
bbox = parse_bounding_box
|
|
51
|
+
return super unless bbox
|
|
52
|
+
|
|
53
|
+
bbox[:ury] - bbox[:lly]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def width
|
|
57
|
+
bbox = parse_bounding_box
|
|
58
|
+
return super unless bbox
|
|
59
|
+
|
|
60
|
+
bbox[:urx] - bbox[:llx]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def parse_bounding_box
|
|
66
|
+
# Look for %%BoundingBox: llx lly urx ury
|
|
67
|
+
match = content.match(/^%%BoundingBox:\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)/m)
|
|
68
|
+
return nil unless match
|
|
69
|
+
|
|
70
|
+
{
|
|
71
|
+
llx: match[1].to_f,
|
|
72
|
+
lly: match[2].to_f,
|
|
73
|
+
urx: match[3].to_f,
|
|
74
|
+
ury: match[4].to_f,
|
|
75
|
+
}
|
|
32
76
|
end
|
|
33
77
|
end
|
|
34
78
|
end
|
data/lib/vectory/svg.rb
CHANGED
|
@@ -32,15 +32,58 @@ module Vectory
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def to_emf
|
|
35
|
-
|
|
35
|
+
InkscapeWrapper.convert(
|
|
36
|
+
content: content,
|
|
37
|
+
input_format: :svg,
|
|
38
|
+
output_format: :emf,
|
|
39
|
+
output_class: Emf,
|
|
40
|
+
)
|
|
36
41
|
end
|
|
37
42
|
|
|
38
43
|
def to_eps
|
|
39
|
-
|
|
44
|
+
InkscapeWrapper.convert(
|
|
45
|
+
content: content,
|
|
46
|
+
input_format: :svg,
|
|
47
|
+
output_format: :eps,
|
|
48
|
+
output_class: Eps,
|
|
49
|
+
)
|
|
40
50
|
end
|
|
41
51
|
|
|
42
52
|
def to_ps
|
|
43
|
-
|
|
53
|
+
InkscapeWrapper.convert(
|
|
54
|
+
content: content,
|
|
55
|
+
input_format: :svg,
|
|
56
|
+
output_format: :ps,
|
|
57
|
+
output_class: Ps,
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def height
|
|
62
|
+
# Try to read height from SVG attributes first
|
|
63
|
+
doc = Nokogiri::XML(content)
|
|
64
|
+
svg_element = doc.at_xpath("//svg:svg",
|
|
65
|
+
"svg" => SVG_NS) || doc.at_xpath("//svg")
|
|
66
|
+
|
|
67
|
+
if svg_element && svg_element["height"]
|
|
68
|
+
svg_element["height"].to_f.round
|
|
69
|
+
else
|
|
70
|
+
# Fall back to Inkscape query if no height attribute
|
|
71
|
+
super
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def width
|
|
76
|
+
# Try to read width from SVG attributes first
|
|
77
|
+
doc = Nokogiri::XML(content)
|
|
78
|
+
svg_element = doc.at_xpath("//svg:svg",
|
|
79
|
+
"svg" => SVG_NS) || doc.at_xpath("//svg")
|
|
80
|
+
|
|
81
|
+
if svg_element && svg_element["width"]
|
|
82
|
+
svg_element["width"].to_f.round
|
|
83
|
+
else
|
|
84
|
+
# Fall back to Inkscape query if no width attribute
|
|
85
|
+
super
|
|
86
|
+
end
|
|
44
87
|
end
|
|
45
88
|
|
|
46
89
|
private
|
data/lib/vectory/svg_document.rb
CHANGED
|
@@ -4,6 +4,44 @@ module Vectory
|
|
|
4
4
|
class SvgDocument
|
|
5
5
|
SVG_NS = "http://www.w3.org/2000/svg".freeze
|
|
6
6
|
|
|
7
|
+
class << self
|
|
8
|
+
# Update instances of id in style statements in a nokogiri document
|
|
9
|
+
def update_ids_css(document, ids, suffix)
|
|
10
|
+
suffix = suffix.is_a?(Integer) ? sprintf("%09d", suffix) : suffix
|
|
11
|
+
document.xpath(".//m:style", "m" => SVG_NS).each do |s|
|
|
12
|
+
c = s.children.to_xml
|
|
13
|
+
s.children = update_ids_css_string(c, ids, suffix)
|
|
14
|
+
end
|
|
15
|
+
document.xpath(".//*[@style]").each do |s|
|
|
16
|
+
s["style"] = update_ids_css_string(s["style"], ids, suffix)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Update instances of id in style statements in the string style
|
|
21
|
+
def update_ids_css_string(style, ids, suffix)
|
|
22
|
+
ids.each do |i|
|
|
23
|
+
style = style.gsub(%r[##{i}\b],
|
|
24
|
+
sprintf("#%<id>s_%<suffix>s", id: i,
|
|
25
|
+
suffix: suffix))
|
|
26
|
+
.gsub(%r(\[id\s*=\s*['"]?#{i}['"]?\]),
|
|
27
|
+
sprintf("[id='%<id>s_%<suffix>s']", id: i,
|
|
28
|
+
suffix: suffix))
|
|
29
|
+
end
|
|
30
|
+
style
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def update_ids_attrs(document, ids, suffix)
|
|
34
|
+
suffix = suffix.is_a?(Integer) ? sprintf("%09d", suffix) : suffix
|
|
35
|
+
document.xpath(". | .//*[@*]").each do |a|
|
|
36
|
+
a.attribute_nodes.each do |x|
|
|
37
|
+
val = x.value.sub(/^#/, "")
|
|
38
|
+
ids.include?(val) and x.value += "_#{suffix}"
|
|
39
|
+
x.value = x.value.sub(%r{url\(#([^()]+)\)}, "url(#\\1_#{suffix})")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
7
45
|
def initialize(content)
|
|
8
46
|
@document = Nokogiri::XML(content)
|
|
9
47
|
end
|
|
@@ -33,8 +71,8 @@ module Vectory
|
|
|
33
71
|
ids = collect_ids
|
|
34
72
|
return if ids.empty?
|
|
35
73
|
|
|
36
|
-
update_ids_attrs(ids, suffix)
|
|
37
|
-
update_ids_css(ids, suffix)
|
|
74
|
+
self.class.update_ids_attrs(@document.root, ids, suffix)
|
|
75
|
+
self.class.update_ids_css(@document.root, ids, suffix)
|
|
38
76
|
|
|
39
77
|
self
|
|
40
78
|
end
|
|
@@ -50,27 +88,5 @@ module Vectory
|
|
|
50
88
|
def collect_ids
|
|
51
89
|
@document.xpath("./@id | .//@id").map(&:value)
|
|
52
90
|
end
|
|
53
|
-
|
|
54
|
-
def update_ids_attrs(ids, suffix)
|
|
55
|
-
@document.xpath(". | .//*[@*]").each do |a|
|
|
56
|
-
a.attribute_nodes.each do |x|
|
|
57
|
-
ids.include?(x.value) and x.value += sprintf("_%09d", suffix)
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def update_ids_css(ids, suffix)
|
|
63
|
-
@document.xpath("//m:style", "m" => SVG_NS).each do |s|
|
|
64
|
-
c = s.children.to_xml
|
|
65
|
-
ids.each do |i|
|
|
66
|
-
c = c.gsub(%r[##{i}\b],
|
|
67
|
-
sprintf("#%<id>s_%<suffix>09d", id: i, suffix: suffix))
|
|
68
|
-
.gsub(%r(\[id\s*=\s*['"]?#{i}['"]?\]),
|
|
69
|
-
sprintf("[id='%<id>s_%<suffix>09d']", id: i, suffix: suffix))
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
s.children = c
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
91
|
end
|
|
76
92
|
end
|
data/lib/vectory/system_call.rb
CHANGED
|
@@ -7,15 +7,16 @@ module Vectory
|
|
|
7
7
|
|
|
8
8
|
attr_reader :status, :stdout, :stderr, :cmd
|
|
9
9
|
|
|
10
|
-
def initialize(cmd, timeout = TIMEOUT)
|
|
10
|
+
def initialize(cmd, timeout = TIMEOUT, env: {})
|
|
11
11
|
@cmd = cmd
|
|
12
12
|
@timeout = timeout
|
|
13
|
+
@env = env
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def call
|
|
16
17
|
log_cmd(@cmd)
|
|
17
18
|
|
|
18
|
-
execute(@cmd)
|
|
19
|
+
execute(@cmd, @env)
|
|
19
20
|
|
|
20
21
|
log_result
|
|
21
22
|
|
|
@@ -30,14 +31,26 @@ module Vectory
|
|
|
30
31
|
Vectory.ui.debug("Cmd: '#{cmd}'")
|
|
31
32
|
end
|
|
32
33
|
|
|
33
|
-
def execute(cmd)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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] || ""
|
|
40
52
|
@status = result[:status]
|
|
53
|
+
@timed_out = result[:timeout]
|
|
41
54
|
rescue Errno::ENOENT => e
|
|
42
55
|
raise SystemCallError, e.inspect
|
|
43
56
|
end
|
|
@@ -49,6 +62,20 @@ module Vectory
|
|
|
49
62
|
end
|
|
50
63
|
|
|
51
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
|
+
|
|
52
79
|
raise SystemCallError,
|
|
53
80
|
"Failed to run #{@cmd},\n " \
|
|
54
81
|
"status: #{@status.exitstatus},\n " \
|