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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/docs.yml +59 -0
  3. data/.github/workflows/links.yml +99 -0
  4. data/.github/workflows/rake.yml +5 -1
  5. data/.github/workflows/release.yml +7 -3
  6. data/.gitignore +5 -0
  7. data/.rubocop.yml +11 -3
  8. data/.rubocop_todo.yml +252 -0
  9. data/Gemfile +4 -2
  10. data/README.adoc +23 -1
  11. data/Rakefile +13 -0
  12. data/docs/Gemfile +18 -0
  13. data/docs/_config.yml +179 -0
  14. data/docs/features/conversion.adoc +205 -0
  15. data/docs/features/external-dependencies.adoc +305 -0
  16. data/docs/features/format-detection.adoc +173 -0
  17. data/docs/features/index.adoc +205 -0
  18. data/docs/getting-started/core-concepts.adoc +214 -0
  19. data/docs/getting-started/index.adoc +37 -0
  20. data/docs/getting-started/installation.adoc +318 -0
  21. data/docs/getting-started/quick-start.adoc +160 -0
  22. data/docs/guides/error-handling.adoc +400 -0
  23. data/docs/guides/index.adoc +197 -0
  24. data/docs/index.adoc +146 -0
  25. data/docs/lychee.toml +25 -0
  26. data/docs/reference/api.adoc +355 -0
  27. data/docs/reference/index.adoc +189 -0
  28. data/docs/understanding/architecture.adoc +277 -0
  29. data/docs/understanding/index.adoc +148 -0
  30. data/docs/understanding/inkscape-wrapper.adoc +270 -0
  31. data/lib/vectory/capture.rb +165 -37
  32. data/lib/vectory/cli.rb +2 -0
  33. data/lib/vectory/configuration.rb +177 -0
  34. data/lib/vectory/conversion/ghostscript_strategy.rb +77 -0
  35. data/lib/vectory/conversion/inkscape_strategy.rb +124 -0
  36. data/lib/vectory/conversion/strategy.rb +58 -0
  37. data/lib/vectory/conversion.rb +104 -0
  38. data/lib/vectory/datauri.rb +1 -1
  39. data/lib/vectory/emf.rb +17 -5
  40. data/lib/vectory/eps.rb +45 -3
  41. data/lib/vectory/errors.rb +25 -0
  42. data/lib/vectory/file_magic.rb +2 -2
  43. data/lib/vectory/ghostscript_wrapper.rb +160 -0
  44. data/lib/vectory/image_resize.rb +2 -2
  45. data/lib/vectory/inkscape_wrapper.rb +205 -0
  46. data/lib/vectory/pdf.rb +76 -0
  47. data/lib/vectory/platform.rb +105 -0
  48. data/lib/vectory/ps.rb +47 -3
  49. data/lib/vectory/svg.rb +46 -3
  50. data/lib/vectory/svg_document.rb +40 -24
  51. data/lib/vectory/system_call.rb +36 -9
  52. data/lib/vectory/vector.rb +3 -23
  53. data/lib/vectory/version.rb +1 -1
  54. data/lib/vectory.rb +16 -11
  55. metadata +34 -3
  56. 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
@@ -8,7 +8,7 @@ module Vectory
8
8
  (?:charset=[^;]+;)?
9
9
  base64,
10
10
  (?<data>.+)
11
- $}x.freeze
11
+ $}x
12
12
 
13
13
  def self.from_vector(vector)
14
14
  mimetype = vector.class.mimetype
data/lib/vectory/emf.rb CHANGED
@@ -28,19 +28,31 @@ module Vectory
28
28
  end
29
29
 
30
30
  def to_svg
31
- with_file("emf") do |input_path|
32
- content = Emf2svg.from_file(input_path)
31
+ Dir.mktmpdir do |dir|
32
+ input_path = File.join(dir, "image.emf")
33
+ File.binwrite(input_path, content)
33
34
 
34
- Svg.from_content(content)
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
- convert_with_inkscape("--export-type=eps", Eps)
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
- convert_with_inkscape("--export-type=ps", Ps)
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
- convert_with_inkscape("--export-type=ps", Ps)
26
+ to_pdf.to_ps
24
27
  end
25
28
 
26
29
  def to_svg
27
- convert_with_inkscape("--export-plain-svg --export-type=svg", Svg)
30
+ to_pdf.to_svg
28
31
  end
29
32
 
30
33
  def to_emf
31
- convert_with_inkscape("--export-type=emf", Emf)
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
@@ -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
- return :svg if contain_svg_tag?
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
- return :svg if content.include?("<svg")
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
@@ -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 > 0 ? realsize[0] : nil
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