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.
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 +98 -12
  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
@@ -6,6 +6,7 @@ module Vectory
6
6
 
7
7
  def call(img, path, maxheight, maxwidth)
8
8
  s, realsize = get_image_size(img, path)
9
+ # Ensure s[0] and s[1] are not nil before setting viewBox
9
10
  img.name == "svg" && !img["viewBox"] && s[0] && s[1] and
10
11
  img["viewBox"] = viewbox(s)
11
12
  s, skip = image_dont_resize(s, realsize)
@@ -18,7 +19,8 @@ module Vectory
18
19
  detected = detect_size(path)
19
20
  realsize = detected if detected
20
21
  s = image_size_interpret(img, realsize || [nil, nil])
21
- image_size_zeroes_complete(s, realsize)
22
+ # No call to image_size_zeroes_complete here
23
+ [s, realsize] # s should now be correctly populated
22
24
  end
23
25
 
24
26
  private
@@ -53,7 +55,11 @@ module Vectory
53
55
  end
54
56
 
55
57
  def fill_size(current, another, real_current, real_another)
56
- return current unless current.zero? && !another.zero?
58
+ # Ensure 'current' and 'another' are not nil before calling zero?
59
+ # Also ensure real_another is not nil or zero to prevent division by zero
60
+ return current unless (current.nil? || current.zero?) &&
61
+ !(another.nil? || another.zero?) &&
62
+ !(real_another.nil? || real_another.zero?)
57
63
 
58
64
  another * real_current / real_another
59
65
  end
@@ -67,22 +73,102 @@ module Vectory
67
73
  end
68
74
 
69
75
  def image_size_interpret(img, realsize)
70
- w = image_size_percent(img["width"], realsize[0])
71
- h = image_size_percent(img["height"], realsize[1])
76
+ # Extract parent dimensions for percentage calculations
77
+ parent_w_px = realsize.is_a?(Array) && realsize.length.positive? ? realsize[0] : nil
78
+ parent_h_px = realsize.is_a?(Array) && realsize.length > 1 ? realsize[1] : nil
79
+
80
+ # Process width and height using the original css_size_to_px signature
81
+ w = css_size_to_px(img["width"], parent_w_px)
82
+ h = css_size_to_px(img["height"], parent_h_px)
83
+
84
+ # If attributes resulted in no dimensions, but realsize exists, use realsize.
85
+ # This brings back some of the logic from the removed image_size_zeroes_complete.
86
+ if w.nil? && h.nil? && realsize && !(realsize[0].nil? && realsize[1].nil?)
87
+ w = realsize[0]
88
+ h = realsize[1]
89
+ end
90
+
91
+ # If a dimension attribute led to a nil value (e.g. "auto", or "%" of nil parent)
92
+ # and the other dimension is resolved, default the nil dimension to 0.
93
+ if img["width"] && w.nil? && !h.nil?
94
+ w = 0
95
+ end
96
+ if img["height"] && h.nil? && !w.nil?
97
+ h = 0
98
+ end
99
+
72
100
  [w, h]
73
101
  end
74
102
 
75
- def image_size_percent(value, real)
76
- /%$/.match?(value) && !real.nil? and
77
- value = real * (value.sub(/%$/, "").to_f / 100)
78
- value.to_i
103
+ # Enhanced version of image_size_percent that handles percentage calculations
104
+ # This is now a helper method for css_size_to_px
105
+ def image_size_percent(percentage_numeric_value, real_dimension)
106
+ return nil if real_dimension.nil? || percentage_numeric_value.nil?
107
+
108
+ # Calculates the dimension in pixels. The result is a float.
109
+ (real_dimension * (percentage_numeric_value / 100.0))
79
110
  end
80
111
 
81
- def image_size_zeroes_complete(dim, realsize)
82
- if dim[0].zero? && dim[1].zero?
83
- dim = realsize
112
+ # New method from image_sizer.rb, incorporated directly into ImageResize
113
+ # Converts CSS length string to pixels.
114
+ def css_size_to_px(size_str, real_dimension_for_percentage, dpi: 96)
115
+ return nil unless size_str.is_a?(String)
116
+
117
+ cleaned_size_str = size_str.strip.downcase
118
+
119
+ # Handle keywords like "auto" which don't resolve to a numeric pixel value.
120
+ return nil if cleaned_size_str == "auto"
121
+
122
+ # Handle plain numbers (e.g., "150") as pixels.
123
+ if cleaned_size_str.match?(/^\d+(\.\d+)?$/)
124
+ return cleaned_size_str.to_f.to_i
125
+ end
126
+
127
+ # Match numeric value and unit (e.g., "10.5cm", "50%", "-5px").
128
+ match = cleaned_size_str.match(/^([-+]?[0-9]*\.?[0-9]+)([a-z%]+)$/)
129
+ unless match
130
+ # Not a plain number and not in "valueUnit" format
131
+ return nil
132
+ end
133
+
134
+ value = match[1].to_f
135
+ unit = match[2]
136
+ px_value = nil # This will store the calculated pixel value as a float.
137
+
138
+ case unit
139
+ when "px"
140
+ px_value = value
141
+ when "in"
142
+ px_value = value * dpi
143
+ when "cm"
144
+ px_value = value * dpi / 2.54
145
+ when "mm"
146
+ px_value = value * dpi / 25.4
147
+ when "pt" # 1 point = 1/72 inch
148
+ px_value = value * dpi / 72.0
149
+ when "pc" # 1 pica = 12 points
150
+ px_value = value * 12.0 * dpi / 72.0
151
+ when "%"
152
+ # Use the enhanced image_size_percent method
153
+ px_value = image_size_percent(value, real_dimension_for_percentage)
154
+ else
155
+ return nil # Unknown unit
156
+ end
157
+
158
+ return nil if px_value.nil?
159
+
160
+ # For 'px' and '%' units, truncate to match "integer conversion" expectation.
161
+ # For physical units, round to handle floating point inaccuracies correctly.
162
+ if ["px", "%"].include?(unit)
163
+ px_value.to_i
164
+ else
165
+ px_value.round
84
166
  end
85
- [dim, realsize]
86
167
  end
168
+
169
+ # REMOVE the image_size_zeroes_complete method entirely if it's still present
170
+ # def image_size_zeroes_complete(dim, realsize)
171
+ # ...
172
+ # end
87
173
  end
88
174
  end