vectory 0.8.0 → 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 +2 -2
- 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,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "conversion/strategy"
|
|
4
|
+
require_relative "conversion/inkscape_strategy"
|
|
5
|
+
require_relative "conversion/ghostscript_strategy"
|
|
6
|
+
|
|
7
|
+
module Vectory
|
|
8
|
+
# Conversion module provides strategy-based conversion interface
|
|
9
|
+
#
|
|
10
|
+
# This module encapsulates different conversion strategies for converting
|
|
11
|
+
# between vector formats using external tools like Inkscape and Ghostscript.
|
|
12
|
+
#
|
|
13
|
+
# @example Convert SVG to EPS using Inkscape
|
|
14
|
+
# Vectory::Conversion.convert(svg_content, from: :svg, to: :eps)
|
|
15
|
+
#
|
|
16
|
+
# @example Get available strategies for a conversion
|
|
17
|
+
# Vectory::Conversion.strategies_for(:svg, :eps)
|
|
18
|
+
module Conversion
|
|
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
|
data/lib/vectory/datauri.rb
CHANGED
data/lib/vectory/emf.rb
CHANGED
|
@@ -28,19 +28,31 @@ module Vectory
|
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def to_svg
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
Dir.mktmpdir do |dir|
|
|
32
|
+
input_path = File.join(dir, "image.emf")
|
|
33
|
+
File.binwrite(input_path, content)
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
svg_content = Emf2svg.from_file(input_path)
|
|
36
|
+
Svg.from_content(svg_content)
|
|
35
37
|
end
|
|
36
38
|
end
|
|
37
39
|
|
|
38
40
|
def to_eps
|
|
39
|
-
|
|
41
|
+
InkscapeWrapper.convert(
|
|
42
|
+
content: content,
|
|
43
|
+
input_format: :emf,
|
|
44
|
+
output_format: :eps,
|
|
45
|
+
output_class: Eps,
|
|
46
|
+
)
|
|
40
47
|
end
|
|
41
48
|
|
|
42
49
|
def to_ps
|
|
43
|
-
|
|
50
|
+
InkscapeWrapper.convert(
|
|
51
|
+
content: content,
|
|
52
|
+
input_format: :emf,
|
|
53
|
+
output_format: :ps,
|
|
54
|
+
output_class: Ps,
|
|
55
|
+
)
|
|
44
56
|
end
|
|
45
57
|
end
|
|
46
58
|
end
|
data/lib/vectory/eps.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 Eps < Vector
|
|
5
8
|
def self.default_extension
|
|
@@ -20,19 +23,58 @@ module Vectory
|
|
|
20
23
|
end
|
|
21
24
|
|
|
22
25
|
def to_ps
|
|
23
|
-
|
|
26
|
+
to_pdf.to_ps
|
|
24
27
|
end
|
|
25
28
|
|
|
26
29
|
def to_svg
|
|
27
|
-
|
|
30
|
+
to_pdf.to_svg
|
|
28
31
|
end
|
|
29
32
|
|
|
30
33
|
def to_emf
|
|
31
|
-
|
|
34
|
+
to_pdf.to_emf
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def to_pdf
|
|
38
|
+
pdf_content = GhostscriptWrapper.convert(content, eps_crop: true)
|
|
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]
|
|
32
61
|
end
|
|
33
62
|
|
|
34
63
|
private
|
|
35
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
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
36
78
|
def imgfile_suffix(uri, suffix)
|
|
37
79
|
"#{File.join(File.dirname(uri), File.basename(uri, '.*'))}.#{suffix}"
|
|
38
80
|
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vectory
|
|
4
|
+
class ConversionError < Error; end
|
|
5
|
+
|
|
6
|
+
class InkscapeNotFoundError < Error
|
|
7
|
+
def initialize(msg = nil)
|
|
8
|
+
super(msg || "Inkscape not found in PATH. Please install Inkscape.")
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class GhostscriptNotFoundError < Error
|
|
13
|
+
def initialize(msg = nil)
|
|
14
|
+
super(msg || "Ghostscript not found in PATH. Please install Ghostscript.")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class InkscapeQueryError < Error; end
|
|
19
|
+
|
|
20
|
+
class InvalidFormatError < Error
|
|
21
|
+
def initialize(format, supported_formats)
|
|
22
|
+
super("Invalid format '#{format}'. Supported formats: #{supported_formats.join(', ')}")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
data/lib/vectory/file_magic.rb
CHANGED
|
@@ -35,7 +35,7 @@ module Vectory
|
|
|
35
35
|
emf_slice = beginning.byteslice(0, EMF_MAGIC.size)
|
|
36
36
|
return :emf if emf_slice == EMF_MAGIC
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
:svg if contain_svg_tag?
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
private
|
|
@@ -47,7 +47,7 @@ module Vectory
|
|
|
47
47
|
def contain_svg_tag?
|
|
48
48
|
content = File.read(@path, 4096)
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
:svg if content.include?("<svg")
|
|
51
51
|
end
|
|
52
52
|
end
|
|
53
53
|
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
require_relative "system_call"
|
|
7
|
+
require_relative "platform"
|
|
8
|
+
|
|
9
|
+
module Vectory
|
|
10
|
+
# GhostscriptWrapper converts PS and EPS files to PDF using Ghostscript
|
|
11
|
+
class GhostscriptWrapper
|
|
12
|
+
SUPPORTED_INPUT_FORMATS = %w[ps eps].freeze
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def available?
|
|
16
|
+
ghostscript_path
|
|
17
|
+
true
|
|
18
|
+
rescue GhostscriptNotFoundError
|
|
19
|
+
false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def version
|
|
23
|
+
return nil unless available?
|
|
24
|
+
|
|
25
|
+
cmd = [ghostscript_path, "--version"]
|
|
26
|
+
call = SystemCall.new(cmd).call
|
|
27
|
+
call.stdout.strip
|
|
28
|
+
rescue StandardError
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def convert(content, options = {})
|
|
33
|
+
raise GhostscriptNotFoundError unless available?
|
|
34
|
+
|
|
35
|
+
eps_crop = options.fetch(:eps_crop, false)
|
|
36
|
+
input_ext = eps_crop ? ".eps" : ".ps"
|
|
37
|
+
|
|
38
|
+
# Create temporary input file
|
|
39
|
+
input_file = Tempfile.new(["gs_input", input_ext])
|
|
40
|
+
output_file = Tempfile.new(["gs_output", ".pdf"])
|
|
41
|
+
|
|
42
|
+
begin
|
|
43
|
+
# Write content and close the input file so GhostScript can read it
|
|
44
|
+
input_file.binmode
|
|
45
|
+
input_file.write(content)
|
|
46
|
+
input_file.flush
|
|
47
|
+
input_file.close
|
|
48
|
+
|
|
49
|
+
# Close output file so GhostScript can write to it
|
|
50
|
+
output_file.close
|
|
51
|
+
|
|
52
|
+
cmd = build_command(input_file.path, output_file.path,
|
|
53
|
+
eps_crop: eps_crop)
|
|
54
|
+
|
|
55
|
+
call = nil
|
|
56
|
+
begin
|
|
57
|
+
call = SystemCall.new(cmd).call
|
|
58
|
+
rescue SystemCallError => e
|
|
59
|
+
raise ConversionError,
|
|
60
|
+
"GhostScript conversion failed: #{e.message}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
unless File.exist?(output_file.path)
|
|
64
|
+
raise ConversionError,
|
|
65
|
+
"GhostScript did not create output file: #{output_file.path}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
output_content = File.binread(output_file.path)
|
|
69
|
+
|
|
70
|
+
# Check if the PDF is valid (should be more than just the header)
|
|
71
|
+
if output_content.size < 100
|
|
72
|
+
raise ConversionError,
|
|
73
|
+
"GhostScript created invalid PDF (#{output_content.size} bytes). " \
|
|
74
|
+
"Command: #{cmd.join(' ')}, " \
|
|
75
|
+
"stdout: '#{call&.stdout&.strip}', " \
|
|
76
|
+
"stderr: '#{call&.stderr&.strip}'"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
output_content
|
|
80
|
+
ensure
|
|
81
|
+
# Clean up temp files
|
|
82
|
+
input_file.close unless input_file.closed?
|
|
83
|
+
input_file.unlink
|
|
84
|
+
output_file.close unless output_file.closed?
|
|
85
|
+
output_file.unlink
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
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
|
|
104
|
+
end
|
|
105
|
+
|
|
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
|
|
110
|
+
end
|
|
111
|
+
else
|
|
112
|
+
# On Unix-like systems, check PATH
|
|
113
|
+
path = find_in_path("gs")
|
|
114
|
+
return path if path
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
raise GhostscriptNotFoundError
|
|
118
|
+
end
|
|
119
|
+
|
|
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
|
|
128
|
+
|
|
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
|
|
138
|
+
end
|
|
139
|
+
|
|
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
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
data/lib/vectory/image_resize.rb
CHANGED
|
@@ -74,7 +74,7 @@ module Vectory
|
|
|
74
74
|
|
|
75
75
|
def image_size_interpret(img, realsize)
|
|
76
76
|
# Extract parent dimensions for percentage calculations
|
|
77
|
-
parent_w_px = realsize.is_a?(Array) && realsize.length
|
|
77
|
+
parent_w_px = realsize.is_a?(Array) && realsize.length.positive? ? realsize[0] : nil
|
|
78
78
|
parent_h_px = realsize.is_a?(Array) && realsize.length > 1 ? realsize[1] : nil
|
|
79
79
|
|
|
80
80
|
# Process width and height using the original css_size_to_px signature
|
|
@@ -90,7 +90,7 @@ module Vectory
|
|
|
90
90
|
|
|
91
91
|
# If a dimension attribute led to a nil value (e.g. "auto", or "%" of nil parent)
|
|
92
92
|
# and the other dimension is resolved, default the nil dimension to 0.
|
|
93
|
-
if img["width"] && w.nil? && !h.nil?
|
|
93
|
+
if img["width"] && w.nil? && !h.nil?
|
|
94
94
|
w = 0
|
|
95
95
|
end
|
|
96
96
|
if img["height"] && h.nil? && !w.nil?
|
|
@@ -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
|