vectory 0.1.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/rake.yml +1 -1
  3. data/.github/workflows/release.yml +24 -0
  4. data/.gitignore +4 -0
  5. data/Gemfile +3 -1
  6. data/README.adoc +307 -1
  7. data/Rakefile +4 -0
  8. data/bin/vectory +9 -0
  9. data/emf.emf +1 -0
  10. data/lib/vectory/cli.rb +140 -0
  11. data/lib/vectory/datauri.rb +48 -0
  12. data/lib/vectory/emf.rb +31 -0
  13. data/lib/vectory/eps.rb +31 -0
  14. data/lib/vectory/file_magic.rb +53 -0
  15. data/lib/vectory/image.rb +27 -0
  16. data/lib/vectory/inkscape_converter.rb +99 -0
  17. data/lib/vectory/ps.rb +25 -0
  18. data/lib/vectory/svg.rb +110 -0
  19. data/lib/vectory/svg_mapping.rb +118 -0
  20. data/lib/vectory/system_call.rb +58 -0
  21. data/lib/vectory/utils.rb +165 -0
  22. data/lib/vectory/vector.rb +75 -0
  23. data/lib/vectory/version.rb +1 -1
  24. data/lib/vectory.rb +35 -1
  25. data/spec/examples/emf2eps/img.emf +0 -0
  26. data/spec/examples/emf2eps/img.emf.datauri +1 -0
  27. data/spec/examples/emf2eps/ref.eps +88 -0
  28. data/spec/examples/emf2ps/img.emf +0 -0
  29. data/spec/examples/emf2ps/ref.ps +125 -0
  30. data/spec/examples/emf2svg/img.emf +0 -0
  31. data/spec/examples/emf2svg/ref.svg +9 -0
  32. data/spec/examples/eps2emf/img.eps +199 -0
  33. data/spec/examples/eps2emf/img.eps.datauri +1 -0
  34. data/spec/examples/eps2emf/ref.emf +0 -0
  35. data/spec/examples/eps2ps/img.eps +199 -0
  36. data/spec/examples/eps2ps/ref.ps +549 -0
  37. data/spec/examples/eps2svg/img.eps +199 -0
  38. data/spec/examples/eps2svg/ref.svg +173 -0
  39. data/spec/examples/eps_but_svg_extension.svg +199 -0
  40. data/spec/examples/img.jpg +0 -0
  41. data/spec/examples/ps2emf/img.ps +549 -0
  42. data/spec/examples/ps2emf/img.ps.datauri +1 -0
  43. data/spec/examples/ps2emf/ref.emf +0 -0
  44. data/spec/examples/ps2eps/img.ps +549 -0
  45. data/spec/examples/ps2eps/ref.eps +844 -0
  46. data/spec/examples/ps2svg/img.ps +549 -0
  47. data/spec/examples/ps2svg/ref.svg +476 -0
  48. data/spec/examples/svg/action_schemaexpg1.svg +124 -0
  49. data/spec/examples/svg/action_schemaexpg2.svg +124 -0
  50. data/spec/examples/svg/doc-ref.xml +109 -0
  51. data/spec/examples/svg/doc.xml +51 -0
  52. data/spec/examples/svg/doc2-ref.xml +59 -0
  53. data/spec/examples/svg/doc2.xml +28 -0
  54. data/spec/examples/svg2emf/img.svg +1 -0
  55. data/spec/examples/svg2emf/img.svg.datauri +1 -0
  56. data/spec/examples/svg2emf/ref.emf +0 -0
  57. data/spec/examples/svg2eps/img.svg +1 -0
  58. data/spec/examples/svg2eps/ref.eps +88 -0
  59. data/spec/examples/svg2ps/img.svg +1 -0
  60. data/spec/examples/svg2ps/ref.ps +125 -0
  61. data/spec/spec_helper.rb +7 -0
  62. data/spec/support/matchers.rb +39 -0
  63. data/spec/support/text_matcher.rb +63 -0
  64. data/spec/support/vectory_helper.rb +31 -0
  65. data/spec/vectory/cli_spec.rb +214 -0
  66. data/spec/vectory/datauri_spec.rb +101 -0
  67. data/spec/vectory/emf_spec.rb +38 -0
  68. data/spec/vectory/eps_spec.rb +40 -0
  69. data/spec/vectory/file_magic_spec.rb +24 -0
  70. data/spec/vectory/inkscape_converter_spec.rb +43 -0
  71. data/spec/vectory/ps_spec.rb +33 -0
  72. data/spec/vectory/svg_mapping_spec.rb +42 -0
  73. data/spec/vectory/svg_spec.rb +41 -0
  74. data/spec/vectory/vector_spec.rb +60 -0
  75. data/tmp/.keep +0 -0
  76. data/vectory.gemspec +6 -3
  77. metadata +116 -21
@@ -0,0 +1,53 @@
1
+ module Vectory
2
+ class FileMagic
3
+ EPS_30_MAGIC = "\x25\x21\x50\x53\x2d\x41\x64\x6f\x62\x65\x2d\x33\x2e\x30" \
4
+ "\x20\x45\x50\x53\x46\x2d\x33\x2e\x30"
5
+ .force_encoding("BINARY") # "%!PS-Adobe-3.0 EPSF-3.0"
6
+
7
+ EPS_31_MAGIC = "\x25\x21\x50\x53\x2d\x41\x64\x6f\x62\x65\x2d\x33\x2e\x31" \
8
+ "\x20\x45\x50\x53\x46\x2d\x33\x2e\x30"
9
+ .force_encoding("BINARY") # "%!PS-Adobe-3.1 EPSF-3.0"
10
+
11
+ PS_MAGIC = "\x25\x21\x50\x53\x2d\x41\x64\x6f\x62\x65\x2d\x33\x2e\x30"
12
+ .force_encoding("BINARY") # "%!PS-Adobe-3.0"
13
+
14
+ EMF_MAGIC = "\x01\x00\x00\x00".force_encoding("BINARY")
15
+
16
+ def self.detect(path)
17
+ new(path).detect
18
+ end
19
+
20
+ def initialize(path)
21
+ @path = path
22
+ end
23
+
24
+ def detect
25
+ beginning = File.read(@path, 100, mode: "rb")
26
+
27
+ eps_slice = beginning.byteslice(0, EPS_30_MAGIC.size)
28
+ Vectory.ui.debug("File magic is '#{to_bytes(beginning)}' of '#{@path}'.")
29
+
30
+ return :eps if [EPS_30_MAGIC, EPS_31_MAGIC].include?(eps_slice)
31
+
32
+ ps_slice = beginning.byteslice(0, PS_MAGIC.size)
33
+ return :ps if ps_slice == PS_MAGIC
34
+
35
+ emf_slice = beginning.byteslice(0, EMF_MAGIC.size)
36
+ return :emf if emf_slice == EMF_MAGIC
37
+
38
+ return :svg if contain_svg_tag?
39
+ end
40
+
41
+ private
42
+
43
+ def to_bytes(str)
44
+ str.unpack("c*").map { |e| "\\x#{e.to_s(16).rjust(2, '0')}" }.join
45
+ end
46
+
47
+ def contain_svg_tag?
48
+ content = File.read(@path, 4096)
49
+
50
+ return :svg if content.include?("<svg")
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+
5
+ module Vectory
6
+ class Image
7
+ class << self
8
+ def from_path(path)
9
+ new(File.read(path, mode: "rb"))
10
+ end
11
+
12
+ def from_content(content)
13
+ new(content)
14
+ end
15
+ end
16
+
17
+ attr_reader :content
18
+
19
+ def initialize(content)
20
+ @content = content
21
+ end
22
+
23
+ private
24
+
25
+ attr_writer :content
26
+ end
27
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+ require_relative "system_call"
5
+
6
+ module Vectory
7
+ class InkscapeConverter
8
+ include Singleton
9
+
10
+ def self.convert(uri, output_extension, option)
11
+ instance.convert(uri, output_extension, option)
12
+ end
13
+
14
+ def convert(uri, output_extension, option)
15
+ exe = inkscape_path_or_raise_error(uri)
16
+ uri = external_path uri
17
+ exe = external_path exe
18
+ cmd = %(#{exe} #{option} #{uri})
19
+
20
+ call = SystemCall.new(cmd).call
21
+
22
+ output_path = find_output(uri, output_extension)
23
+ raise_conversion_error(call) unless output_path
24
+
25
+ # and return Vectory::Utils::datauri(file)
26
+
27
+ output_path
28
+ end
29
+
30
+ private
31
+
32
+ def inkscape_path_or_raise_error(path)
33
+ inkscape_path or raise(InkscapeNotFoundError,
34
+ "Inkscape missing in PATH, unable to " \
35
+ "convert image #{path}. Aborting.")
36
+ end
37
+
38
+ def inkscape_path
39
+ @inkscape_path ||= find_inkscape
40
+ end
41
+
42
+ def find_inkscape
43
+ cmds.each do |cmd|
44
+ extensions.each do |ext|
45
+ paths.each do |path|
46
+ exe = File.join(path, "#{cmd}#{ext}")
47
+
48
+ return exe if File.executable?(exe) && !File.directory?(exe)
49
+ end
50
+ end
51
+ end
52
+
53
+ nil
54
+ end
55
+
56
+ def cmds
57
+ ["inkscapecom", "inkscape"]
58
+ end
59
+
60
+ def extensions
61
+ ENV["PATHEXT"] ? ENV["PATHEXT"].split(";") : [""]
62
+ end
63
+
64
+ def paths
65
+ ENV["PATH"].split(File::PATH_SEPARATOR)
66
+ end
67
+
68
+ def find_output(source_path, output_extension)
69
+ basenames = [File.basename(source_path, ".*"),
70
+ File.basename(source_path)]
71
+
72
+ paths = basenames.map do |basename|
73
+ "#{File.join(File.dirname(source_path), basename)}.#{output_extension}"
74
+ end
75
+
76
+ paths.find { |p| File.exist?(p) }
77
+ end
78
+
79
+ def raise_conversion_error(call)
80
+ raise Vectory::ConversionError,
81
+ "Could not convert with Inkscape. " \
82
+ "Inkscape cmd: '#{call.cmd}',\n" \
83
+ "status: '#{call.status}',\n" \
84
+ "stdout: '#{call.stdout.strip}',\n" \
85
+ "stderr: '#{call.stderr.strip}'."
86
+ end
87
+
88
+ def external_path(path)
89
+ win = !!((RUBY_PLATFORM =~ /(win|w)(32|64)$/) ||
90
+ (RUBY_PLATFORM =~ /mswin|mingw/))
91
+ if win
92
+ path.gsub!(%{/}, "\\")
93
+ path[/\s/] ? "\"#{path}\"" : path
94
+ else
95
+ path
96
+ end
97
+ end
98
+ end
99
+ end
data/lib/vectory/ps.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vectory
4
+ class Ps < Vector
5
+ def self.default_extension
6
+ "ps"
7
+ end
8
+
9
+ def self.mimetype
10
+ "application/postscript"
11
+ end
12
+
13
+ def to_eps
14
+ convert_with_inkscape("--export-type=eps", Eps)
15
+ end
16
+
17
+ def to_emf
18
+ convert_with_inkscape("--export-type=emf", Emf)
19
+ end
20
+
21
+ def to_svg
22
+ convert_with_inkscape("--export-type=svg", Svg)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Vectory
6
+ class Svg < Vector
7
+ SVG_NS = "http://www.w3.org/2000/svg"
8
+
9
+ def self.default_extension
10
+ "svg"
11
+ end
12
+
13
+ def self.mimetype
14
+ "image/svg+xml"
15
+ end
16
+
17
+ def content
18
+ @document&.to_xml || @content
19
+ end
20
+
21
+ def to_emf
22
+ convert_with_inkscape("--export-type=emf", Emf)
23
+ end
24
+
25
+ def to_eps
26
+ convert_with_inkscape("--export-type=eps", Eps)
27
+ end
28
+
29
+ def to_ps
30
+ convert_with_inkscape("--export-type=ps", Ps)
31
+ end
32
+
33
+ def namespace(suffix, links, xpath_to_remove)
34
+ remap_links(links)
35
+ suffix_ids(suffix)
36
+ remove_xpath(xpath_to_remove)
37
+ end
38
+
39
+ def remap_links(map)
40
+ document.xpath(".//m:a", "m" => SVG_NS).each do |a|
41
+ href_attrs = ["xlink:href", "href"]
42
+ href_attrs.each do |p|
43
+ a[p] and x = map[File.expand_path(a[p])] and a[p] = x
44
+ end
45
+ end
46
+
47
+ self
48
+ end
49
+
50
+ def suffix_ids(suffix)
51
+ ids = collect_ids
52
+ return if ids.empty?
53
+
54
+ update_ids_attrs(ids, suffix)
55
+ update_ids_css(ids, suffix)
56
+
57
+ self
58
+ end
59
+
60
+ def remove_xpath(xpath)
61
+ document.xpath(xpath).remove
62
+
63
+ self
64
+ end
65
+
66
+ private
67
+
68
+ def content=(content)
69
+ if @document
70
+ @document = Nokogiri::XML(content)
71
+ else
72
+ @content = content
73
+ end
74
+ end
75
+
76
+ def document
77
+ @document ||= begin
78
+ doc = Nokogiri::XML(@content)
79
+ @content = nil
80
+ doc
81
+ end
82
+ end
83
+
84
+ def collect_ids
85
+ document.xpath("./@id | .//@id").map(&:value)
86
+ end
87
+
88
+ def update_ids_attrs(ids, suffix)
89
+ document.xpath(". | .//*[@*]").each do |a|
90
+ a.attribute_nodes.each do |x|
91
+ ids.include?(x.value) and x.value += sprintf("_%09d", suffix)
92
+ end
93
+ end
94
+ end
95
+
96
+ def update_ids_css(ids, suffix)
97
+ document.xpath("//m:style", "m" => SVG_NS).each do |s|
98
+ c = s.children.to_xml
99
+ ids.each do |i|
100
+ c = c.gsub(%r[##{i}\b],
101
+ sprintf("#%<id>s_%<suffix>09d", id: i, suffix: suffix))
102
+ .gsub(%r(\[id\s*=\s*['"]?#{i}['"]?\]),
103
+ sprintf("[id='%<id>s_%<suffix>09d']", id: i, suffix: suffix))
104
+ end
105
+
106
+ s.children = c
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,118 @@
1
+ require_relative "svg"
2
+
3
+ module Vectory
4
+ class SvgMapping
5
+ class Namespace
6
+ def initialize(xmldoc)
7
+ @namespace = xmldoc.root.namespace
8
+ end
9
+
10
+ def ns(path)
11
+ return path if @namespace.nil?
12
+
13
+ path.gsub(%r{/([a-zA-z])}, "/xmlns:\\1")
14
+ .gsub(%r{::([a-zA-z])}, "::xmlns:\\1")
15
+ .gsub(%r{\[([a-zA-z][a-z0-9A-Z@/]* ?=)}, "[xmlns:\\1")
16
+ .gsub(%r{\[([a-zA-z][a-z0-9A-Z@/]*\])}, "[xmlns:\\1")
17
+ end
18
+ end
19
+
20
+ SVG_NS = "http://www.w3.org/2000/svg".freeze
21
+ PROCESSING_XPATH =
22
+ "processing-instruction()|.//processing-instruction()".freeze
23
+
24
+ def self.from_path(path)
25
+ new(File.read(path))
26
+ end
27
+
28
+ def initialize(xml, local_directory = "")
29
+ @xml = xml
30
+ @local_directory = local_directory
31
+ end
32
+
33
+ def call
34
+ xmldoc = Nokogiri::XML(@xml)
35
+ @namespace = Namespace.new(xmldoc)
36
+
37
+ xmldoc.xpath(@namespace.ns("//svgmap")).each_with_index do |svgmap, index|
38
+ process_svgmap(svgmap, index)
39
+ end
40
+
41
+ xmldoc.to_xml
42
+ end
43
+
44
+ private
45
+
46
+ def process_svgmap(svgmap, suffix)
47
+ image = extract_image_tag(svgmap)
48
+ return unless image
49
+
50
+ content = generate_content(image, svgmap, suffix)
51
+ return unless content
52
+
53
+ image.replace(content)
54
+
55
+ simplify_svgmap(svgmap)
56
+ end
57
+
58
+ def extract_image_tag(svgmap)
59
+ image = svgmap.at(@namespace.ns(".//image"))
60
+ return image if image && image["src"] && !image["src"].empty?
61
+
62
+ svgmap.at(".//m:svg", "m" => SVG_NS)
63
+ end
64
+
65
+ def generate_content(image, svgmap, suffix)
66
+ vector = build_vector(image)
67
+ return unless vector
68
+
69
+ links_map = from_targets_to_links_map(svgmap)
70
+ vector.namespace(suffix, links_map, PROCESSING_XPATH)
71
+
72
+ vector.content
73
+ end
74
+
75
+ def build_vector(image)
76
+ return Vectory::Svg.from_content(image.to_xml) if image.name == "svg"
77
+
78
+ return unless image.name == "image"
79
+
80
+ src = image["src"]
81
+ return Vectory::Datauri.new(src).to_vector if /^data:/.match?(src)
82
+
83
+ path = @local_directory.empty? ? src : File.join(@local_directory, src)
84
+ return unless File.exist?(path)
85
+
86
+ Vectory::Svg.from_path(path)
87
+ end
88
+
89
+ def from_targets_to_links_map(svgmap)
90
+ targets = svgmap.xpath(@namespace.ns("./target"))
91
+ targets.each_with_object({}) do |target_tag, m|
92
+ target = link_target(target_tag)
93
+ next unless target
94
+
95
+ href = File.expand_path(target_tag["href"])
96
+ m[href] = target
97
+
98
+ target_tag.remove
99
+ end
100
+ end
101
+
102
+ def link_target(target_tag)
103
+ xref = target_tag.at(@namespace.ns("./xref"))
104
+ return "##{xref['target']}" if xref
105
+
106
+ link = target_tag.at(@namespace.ns("./link"))
107
+ return unless link
108
+
109
+ link["target"]
110
+ end
111
+
112
+ def simplify_svgmap(svgmap)
113
+ return if svgmap.at(@namespace.ns("./target/eref"))
114
+
115
+ svgmap.replace(svgmap.at(@namespace.ns("./figure")))
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,58 @@
1
+ require "open3"
2
+
3
+ module Vectory
4
+ class SystemCall
5
+ TIMEOUT = 60
6
+
7
+ attr_reader :status, :stdout, :stderr, :cmd
8
+
9
+ def initialize(cmd, timeout = TIMEOUT)
10
+ @cmd = cmd
11
+ @timeout = timeout
12
+ end
13
+
14
+ def call
15
+ log_cmd(@cmd)
16
+
17
+ execute(@cmd)
18
+
19
+ log_result
20
+
21
+ raise_error unless @status.success?
22
+
23
+ self
24
+ end
25
+
26
+ private
27
+
28
+ def log_cmd(cmd)
29
+ Vectory.ui.debug("Cmd: '#{cmd}'")
30
+ end
31
+
32
+ def execute(cmd)
33
+ result = Utils.capture3_with_timeout(cmd,
34
+ timeout: @timeout,
35
+ kill_after: @timeout)
36
+ Vectory.ui.error(result.inspect)
37
+ @stdout = result[:stdout]
38
+ @stderr = result[:stderr]
39
+ @status = result[:status]
40
+ rescue Errno::ENOENT => e
41
+ raise SystemCallError, e.inspect
42
+ end
43
+
44
+ def log_result
45
+ Vectory.ui.debug("Status: #{@status.inspect}")
46
+ Vectory.ui.debug("Stdout: '#{@stdout.strip}'")
47
+ Vectory.ui.debug("Stderr: '#{@stderr.strip}'")
48
+ end
49
+
50
+ def raise_error
51
+ raise SystemCallError,
52
+ "Failed to run #{@cmd},\n " \
53
+ "status: #{@status.exitstatus},\n " \
54
+ "stdout: '#{@stdout.strip}',\n " \
55
+ "stderr: '#{@stderr.strip}'"
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "marcel"
4
+ require "timeout"
5
+
6
+ module Vectory
7
+ class Utils
8
+ # Extracted from https://github.com/metanorma/metanorma-utils/blob/v1.5.2/lib/utils/image.rb
9
+ class << self
10
+ # sources/plantuml/plantuml20200524-90467-1iqek5i.png
11
+ # already includes localdir
12
+ # Check whether just the local path or the other specified relative path
13
+ # works.
14
+ def datauri(uri, local_dir = ".")
15
+ return uri if datauri?(uri) || url?(uri)
16
+
17
+ options = absolute_path?(uri) ? [uri] : [uri, File.join(local_dir, uri)]
18
+ path = options.detect do |p|
19
+ File.exist?(p) ? p : nil
20
+ end
21
+
22
+ unless path
23
+ warn "Image specified at `#{uri}` does not exist."
24
+ return uri # Return original provided location
25
+ end
26
+
27
+ encode_datauri(path)
28
+ end
29
+
30
+ def encode_datauri(path)
31
+ return nil unless File.exist?(path)
32
+
33
+ type = Marcel::MimeType.for(Pathname.new(path)) ||
34
+ 'text/plain; charset="utf-8"'
35
+
36
+ bin = File.binread(path)
37
+ data = Base64.strict_encode64(bin)
38
+ "data:#{type};base64,#{data}"
39
+ rescue StandardError
40
+ warn "Data-URI encoding of `#{path}` failed."
41
+ nil
42
+ end
43
+
44
+ def datauri?(uri)
45
+ /^data:/.match?(uri)
46
+ end
47
+
48
+ def url?(url)
49
+ %r{^[A-Z]{2,}://}i.match?(url)
50
+ end
51
+
52
+ def absolute_path?(uri)
53
+ %r{^/}.match?(uri) || %r{^[A-Z]:/}.match?(uri)
54
+ end
55
+
56
+ # rubocop:disable all
57
+ #
58
+ # Originally from https://gist.github.com/pasela/9392115
59
+ #
60
+ # Capture the standard output and the standard error of a command.
61
+ # Almost same as Open3.capture3 method except for timeout handling and return value.
62
+ # See Open3.capture3.
63
+ #
64
+ # result = capture3_with_timeout([env,] cmd... [, opts])
65
+ #
66
+ # The arguments env, cmd and opts are passed to Process.spawn except
67
+ # opts[:stdin_data], opts[:binmode], opts[:timeout], opts[:signal]
68
+ # and opts[:kill_after]. See Process.spawn.
69
+ #
70
+ # If opts[:stdin_data] is specified, it is sent to the command's standard input.
71
+ #
72
+ # If opts[:binmode] is true, internal pipes are set to binary mode.
73
+ #
74
+ # If opts[:timeout] is specified, SIGTERM is sent to the command after specified seconds.
75
+ #
76
+ # If opts[:signal] is specified, it is used instead of SIGTERM on timeout.
77
+ #
78
+ # If opts[:kill_after] is specified, also send a SIGKILL after specified seconds.
79
+ # it is only sent if the command is still running after the initial signal was sent.
80
+ #
81
+ # The return value is a Hash as shown below.
82
+ #
83
+ # {
84
+ # :pid => PID of the command,
85
+ # :status => Process::Status of the command,
86
+ # :stdout => the standard output of the command,
87
+ # :stderr => the standard error of the command,
88
+ # :timeout => whether the command was timed out,
89
+ # }
90
+ def capture3_with_timeout(*cmd)
91
+ spawn_opts = Hash === cmd.last ? cmd.pop.dup : {}
92
+ opts = {
93
+ :stdin_data => spawn_opts.delete(:stdin_data) || "",
94
+ :binmode => spawn_opts.delete(:binmode) || false,
95
+ :timeout => spawn_opts.delete(:timeout),
96
+ :signal => spawn_opts.delete(:signal) || :TERM,
97
+ :kill_after => spawn_opts.delete(:kill_after),
98
+ }
99
+
100
+ in_r, in_w = IO.pipe
101
+ out_r, out_w = IO.pipe
102
+ err_r, err_w = IO.pipe
103
+ in_w.sync = true
104
+
105
+ if opts[:binmode]
106
+ in_w.binmode
107
+ out_r.binmode
108
+ err_r.binmode
109
+ end
110
+
111
+ spawn_opts[:in] = in_r
112
+ spawn_opts[:out] = out_w
113
+ spawn_opts[:err] = err_w
114
+
115
+ result = {
116
+ :pid => nil,
117
+ :status => nil,
118
+ :stdout => nil,
119
+ :stderr => nil,
120
+ :timeout => false,
121
+ }
122
+
123
+ out_reader = nil
124
+ err_reader = nil
125
+ wait_thr = nil
126
+
127
+ begin
128
+ Timeout.timeout(opts[:timeout]) do
129
+ result[:pid] = spawn(*cmd, spawn_opts)
130
+ wait_thr = Process.detach(result[:pid])
131
+ in_r.close
132
+ out_w.close
133
+ err_w.close
134
+
135
+ out_reader = Thread.new { out_r.read }
136
+ err_reader = Thread.new { err_r.read }
137
+
138
+ in_w.write opts[:stdin_data]
139
+ in_w.close
140
+
141
+ result[:status] = wait_thr.value
142
+ end
143
+ rescue Timeout::Error
144
+ result[:timeout] = true
145
+ pid = spawn_opts[:pgroup] ? -result[:pid] : result[:pid]
146
+ Process.kill(opts[:signal], pid)
147
+ if opts[:kill_after]
148
+ unless wait_thr.join(opts[:kill_after])
149
+ Process.kill(:KILL, pid)
150
+ end
151
+ end
152
+ ensure
153
+ result[:status] = wait_thr.value if wait_thr
154
+ result[:stdout] = out_reader.value if out_reader
155
+ result[:stderr] = err_reader.value if err_reader
156
+ out_r.close unless out_r.closed?
157
+ err_r.close unless err_r.closed?
158
+ end
159
+
160
+ result
161
+ end
162
+ # rubocop:enable all
163
+ end
164
+ end
165
+ end