breadkit-render 0.1.0 → 0.2.0

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.
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Breadkit
4
+ module Render
5
+ # Shows each resolved net as a bus with its connected physical terminals.
6
+ # This layout keeps large circuits readable without crossing unrelated nets.
7
+ class NetlistRenderer
8
+ COLORS = %w[#d85e65 #4f8fd6 #319b7d #aa75c7 #c58a37 #4a9da7 #a0b95c #d57da8
9
+ #8499df #d67c53 #55b58c #c299d3].freeze
10
+ WIDTH = 1120
11
+ LEFT = 48
12
+ CARD_WIDTH = WIDTH - LEFT * 2
13
+ TERMINALS_PER_ROW = 5
14
+
15
+ def render(circuit, theme: "light", state: nil)
16
+ raise ArgumentError, "unknown netlist theme: #{theme}" unless %w[light dark print].include?(theme)
17
+
18
+ colors = palette(theme)
19
+ nets = circuit.nets(state)
20
+ devices = component_devices(circuit) + supply_devices(circuit)
21
+ terminals = devices.flat_map { |device| device[:terminals] }
22
+ terminal_nets = nets.each_with_object({}) { |net, index| net.members.each { |member| index[member] = net } }
23
+ terminals.each do |terminal|
24
+ raise Error, "netlist cannot resolve terminal #{terminal[:id]}" unless terminal_nets[terminal[:id]]
25
+ end
26
+
27
+ device_rows = (devices.length.to_f / 3).ceil
28
+ net_y = 116 + device_rows * 88 + 58
29
+ net_y += 36 if devices.empty?
30
+ net_layout = nets.map do |net|
31
+ members = terminals.select { |terminal| terminal_nets[terminal[:id]] == net }
32
+ rows = (members.length.to_f / TERMINALS_PER_ROW).ceil
33
+ height = 82 + [rows, 1].max * 66
34
+ layout = { net: net, terminals: members, y: net_y, height: height }
35
+ net_y += height + 16
36
+ layout
37
+ end
38
+ height = [net_y + 32, 300].max
39
+
40
+ lines = []
41
+ lines << %(<?xml version="1.0" encoding="UTF-8"?>)
42
+ lines << %(<svg xmlns="http://www.w3.org/2000/svg" width="#{WIDTH}" height="#{height}" viewBox="0 0 #{WIDTH} #{height}" role="img" aria-label="Circuit netlist" data-view="netlist" data-theme="#{theme}"#{state&.name ? %( data-state="#{xml(state.name)}") : ""}>)
43
+ lines << %(<title>Circuit netlist</title>)
44
+ lines << %(<desc>#{xml((["Each colored bus is one resolved net. Terminals on the same bus are connected."] + nets.map { |net| "#{net.name}: #{net.members.join(', ')}" }).join(' '))}</desc>)
45
+ lines << %(<rect width="#{WIDTH}" height="#{height}" fill="#{colors[:background]}"/>)
46
+ lines << %(<g font-family="Arial, Helvetica, sans-serif">)
47
+ lines << %(<text x="48" y="39" fill="#{colors[:muted]}" font-size="11" font-weight="700" letter-spacing="2.4">BREADKIT / NETLIST</text>)
48
+ lines << %(<text x="48" y="76" fill="#{colors[:text]}" font-size="29" font-weight="700">Circuit netlist</text>)
49
+ lines << %(<text x="1072" y="75" text-anchor="end" fill="#{colors[:muted]}" font-size="12">#{devices.length} devices · #{nets.length} nets#{state&.name ? " · #{xml(state.name)} closed" : ""}</text>)
50
+ lines << %(<line x1="48" y1="93" x2="1072" y2="93" stroke="#{colors[:border]}"/>)
51
+
52
+ lines << %(<g id="devices">)
53
+ devices.each_with_index do |device, index|
54
+ x = LEFT + (index % 3) * 346
55
+ y = 116 + (index / 3) * 88
56
+ lines << %(<g data-ref="#{xml(device[:ref])}">)
57
+ lines << %(<rect x="#{x}" y="#{y}" width="328" height="70" rx="12" fill="#{colors[:card]}" stroke="#{colors[:border]}"/>)
58
+ lines << %(<text x="#{x + 18}" y="#{y + 30}" fill="#{colors[:text]}" font-size="17" font-weight="700">#{xml(device[:ref])}</text>)
59
+ lines << %(<text x="#{x + 18}" y="#{y + 51}" fill="#{colors[:muted]}" font-size="11">#{xml(device[:detail])} · #{device[:terminals].length} pins</text>)
60
+ lines << %(</g>)
61
+ end
62
+ lines << %(</g>)
63
+ lines << %(<text x="48" y="#{net_layout.first ? net_layout.first[:y] - 18 : net_y - 18}" fill="#{colors[:muted]}" font-size="11" font-weight="700" letter-spacing="1.8">RESOLVED NETS</text>)
64
+
65
+ lines << %(<g id="nets">)
66
+ net_layout.each_with_index do |layout, index|
67
+ net = layout[:net]
68
+ y = layout[:y]
69
+ accent = COLORS[index % COLORS.length]
70
+ lines << %(<g data-net="#{xml(net.name)}">)
71
+ lines << %(<rect x="#{LEFT}" y="#{y}" width="#{CARD_WIDTH}" height="#{layout[:height]}" rx="12" fill="#{colors[:card]}" stroke="#{colors[:border]}"/>)
72
+ lines << %(<rect x="#{LEFT}" y="#{y}" width="5" height="#{layout[:height]}" rx="2.5" fill="#{accent}"/>)
73
+ lines << %(<text x="#{LEFT + 22}" y="#{y + 31}" fill="#{colors[:text]}" font-size="18" font-weight="700">#{xml(net.name)}</text>)
74
+ lines << %(<text x="#{LEFT + 22}" y="#{y + 51}" fill="#{colors[:muted]}" font-size="11">#{layout[:terminals].length} terminals · #{net.holes.length} holes</text>)
75
+ lines << %(<line x1="#{LEFT + 20}" y1="#{y + 61}" x2="#{LEFT + CARD_WIDTH - 20}" y2="#{y + 61}" stroke="#{colors[:border]}"/>)
76
+ if layout[:terminals].empty?
77
+ lines << %(<text x="#{LEFT + 25}" y="#{y + 105}" fill="#{colors[:muted]}" font-size="12">No device terminals on this net</text>)
78
+ else
79
+ draw_terminals(lines, layout, accent, colors)
80
+ end
81
+ lines << %(</g>)
82
+ end
83
+ lines << %(</g>)
84
+ lines << %(</g></svg>)
85
+ lines.join("\n")
86
+ end
87
+
88
+ private
89
+
90
+ def draw_terminals(lines, layout, accent, colors)
91
+ y = layout[:y]
92
+ rows = (layout[:terminals].length.to_f / TERMINALS_PER_ROW).ceil
93
+ lines << %(<line x1="#{LEFT + 27}" y1="#{y + 77}" x2="#{LEFT + 27}" y2="#{y + 77 + (rows - 1) * 66}" stroke="#{accent}" stroke-width="2"/>) if rows > 1
94
+ layout[:terminals].each_slice(TERMINALS_PER_ROW).with_index do |terminals, row|
95
+ bus_y = y + 77 + row * 66
96
+ last_x = LEFT + 35 + (terminals.length - 1) * 196 + 90
97
+ lines << %(<line x1="#{LEFT + 27}" y1="#{bus_y}" x2="#{last_x}" y2="#{bus_y}" stroke="#{accent}" stroke-width="2"/>)
98
+ terminals.each_with_index do |terminal, column|
99
+ chip_x = LEFT + 35 + column * 196
100
+ center_x = chip_x + 90
101
+ lines << %(<path d="M #{center_x} #{bus_y} V #{bus_y + 13}" stroke="#{accent}" stroke-width="2" data-terminal="#{xml(terminal[:id])}" data-net="#{xml(layout[:net].name)}"/>)
102
+ lines << %(<circle cx="#{center_x}" cy="#{bus_y}" r="3" fill="#{accent}"/>)
103
+ lines << %(<rect x="#{chip_x}" y="#{bus_y + 13}" width="180" height="42" rx="8" fill="#{colors[:chip]}" stroke="#{colors[:border]}"/>)
104
+ lines << %(<text x="#{chip_x + 11}" y="#{bus_y + 39}" fill="#{colors[:text]}" font-size="13" font-weight="700">#{xml(terminal[:ref])}</text>)
105
+ lines << %(<text x="#{chip_x + 169}" y="#{bus_y + 39}" text-anchor="end" fill="#{colors[:muted]}" font-size="12">#{xml(terminal[:pin])}</text>)
106
+ end
107
+ end
108
+ end
109
+
110
+ def component_devices(circuit)
111
+ circuit.components.values.map do |component|
112
+ { ref: component.ref, detail: component.part.id,
113
+ terminals: component.pins.values.map { |pin| { id: "#{component.ref}.#{pin.name}", ref: component.ref, pin: pin.name } } }
114
+ end
115
+ end
116
+
117
+ def supply_devices(circuit)
118
+ circuit.supplies.map do |supply|
119
+ voltage = supply.voltage_range ? "#{supply.voltage_range.join('–')} V" : format("%g V", supply.voltage)
120
+ { ref: supply.name, detail: "Power supply · #{voltage}",
121
+ terminals: [{ id: "#{supply.name}.+", ref: supply.name, pin: "+" },
122
+ { id: "#{supply.name}.-", ref: supply.name, pin: "−" }] }
123
+ end
124
+ end
125
+
126
+ def palette(theme)
127
+ return { background: "#ffffff", card: "#ffffff", chip: "#ffffff", border: "#bec5cf", text: "#111827", muted: "#4b5563" } if theme == "print"
128
+ return { background: "#0d1420", card: "#172233", chip: "#202d40", border: "#35435a", text: "#f2f6fc", muted: "#a5b4c8" } if theme == "dark"
129
+
130
+ { background: "#f4f7fb", card: "#ffffff", chip: "#f1f5fa", border: "#d6deea", text: "#172338", muted: "#63738a" }
131
+ end
132
+
133
+ def xml(value)
134
+ CGI.escapeHTML(value.to_s)
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Breadkit
4
+ module Render
5
+ class OrthogonalRouter
6
+ DIRECTIONS = [[1, 0], [0, 1], [-1, 0], [0, -1]].freeze
7
+
8
+ def initialize(obstacles)
9
+ @obstacles = obstacles.map { |left, bottom, right, top| [left - 0.35, bottom - 0.35, right + 0.35, top + 0.35] }
10
+ @used_edges = Hash.new(0)
11
+ end
12
+
13
+ def route(from, to)
14
+ start = breakout(from, to)
15
+ goal = breakout(to, from)
16
+ start_grid, goal_grid = [start, goal].map { |point| point.map { |coordinate| (coordinate * 2).round } }
17
+ grid = search(start_grid, goal_grid)
18
+ points = [from, start, [start[0], start_grid[1] / 2.0]] + grid.map { |x, y| [x / 2.0, y / 2.0] } +
19
+ [[goal[0], goal_grid[1] / 2.0], goal, to]
20
+ simplify(points)
21
+ end
22
+
23
+ private
24
+
25
+ def breakout(point, target)
26
+ box = @obstacles.find { |left, bottom, right, top| point[0].between?(left, right) && point[1].between?(bottom, top) }
27
+ return point unless box
28
+
29
+ left = (box[0] * 2).floor / 2.0 - 0.5
30
+ right = (box[2] * 2).ceil / 2.0 + 0.5
31
+ distance_left, distance_right = point[0] - left, right - point[0]
32
+ x = if distance_left == distance_right
33
+ target[0] < point[0] ? left : right
34
+ else
35
+ distance_left < distance_right ? left : right
36
+ end
37
+ [x, point[1]]
38
+ end
39
+
40
+ def search(start, goal)
41
+ all_x = [start[0], goal[0]] + @obstacles.flat_map { |box| [box[0] * 2, box[2] * 2] }
42
+ all_y = [start[1], goal[1]] + @obstacles.flat_map { |box| [box[1] * 2, box[3] * 2] }
43
+ xmin, xmax = all_x.min.floor - 4, all_x.max.ceil + 4
44
+ ymin, ymax = all_y.min.floor - 4, all_y.max.ceil + 4
45
+ heap = []
46
+ serial = 0
47
+ initial = [start[0], start[1], -1]
48
+ distance = { initial => 0 }
49
+ parent = {}
50
+ push(heap, [heuristic(start, goal), 0, serial, *initial])
51
+ until heap.empty?
52
+ _score, cost, _serial, x, y, direction = pop(heap)
53
+ key = [x, y, direction]
54
+ next unless distance[key] == cost
55
+ if [x, y] == goal
56
+ path = [key]
57
+ path << parent[path.last] while parent.key?(path.last)
58
+ points = path.reverse.map { |item| item.first(2) }
59
+ points.each_cons(2) { |a, b| @used_edges[edge(a, b)] += 1 }
60
+ return points
61
+ end
62
+ DIRECTIONS.each_with_index do |(dx, dy), next_direction|
63
+ nx, ny = x + dx, y + dy
64
+ next unless nx.between?(xmin, xmax) && ny.between?(ymin, ymax)
65
+ next if blocked?(nx, ny) && [nx, ny] != goal
66
+
67
+ next_key = [nx, ny, next_direction]
68
+ step = 1 + (direction >= 0 && direction != next_direction ? 2 : 0) + @used_edges[edge([x, y], [nx, ny])] * 5
69
+ next_cost = cost + step
70
+ next if distance.key?(next_key) && distance[next_key] <= next_cost
71
+
72
+ distance[next_key] = next_cost
73
+ parent[next_key] = key
74
+ serial += 1
75
+ push(heap, [next_cost + heuristic([nx, ny], goal), next_cost, serial, *next_key])
76
+ end
77
+ raise Error, "automatic wire route exceeds search limit" if distance.length > 100_000
78
+ end
79
+ raise Error, "no obstacle-free wire route found"
80
+ end
81
+
82
+ def blocked?(x, y)
83
+ @obstacles.any? { |left, bottom, right, top| x / 2.0 > left && x / 2.0 < right && y / 2.0 > bottom && y / 2.0 < top }
84
+ end
85
+
86
+ def heuristic(point, goal)
87
+ (point[0] - goal[0]).abs + (point[1] - goal[1]).abs
88
+ end
89
+
90
+ def edge(a, b)
91
+ (a <=> b).negative? ? [a, b] : [b, a]
92
+ end
93
+
94
+ def simplify(points)
95
+ result = []
96
+ points.each do |point|
97
+ next if result.last == point
98
+ result.pop if result.length >= 2 &&
99
+ (result[-2][0] == result[-1][0] && result[-1][0] == point[0] ||
100
+ result[-2][1] == result[-1][1] && result[-1][1] == point[1])
101
+ result << point
102
+ end
103
+ result
104
+ end
105
+
106
+ def push(heap, item)
107
+ index = heap.length
108
+ heap << item
109
+ while index.positive?
110
+ parent = (index - 1) / 2
111
+ break if (heap[parent] <=> item) <= 0
112
+
113
+ heap[index] = heap[parent]
114
+ index = parent
115
+ end
116
+ heap[index] = item
117
+ end
118
+
119
+ def pop(heap)
120
+ first = heap.first
121
+ last = heap.pop
122
+ return first if heap.empty?
123
+
124
+ index = 0
125
+ while (child = index * 2 + 1) < heap.length
126
+ child += 1 if child + 1 < heap.length && (heap[child + 1] <=> heap[child]).negative?
127
+ break if (last <=> heap[child]) <= 0
128
+
129
+ heap[index] = heap[child]
130
+ index = child
131
+ end
132
+ heap[index] = last
133
+ first
134
+ end
135
+ end
136
+ end
137
+ end
@@ -1,13 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "tmpdir"
4
+
3
5
  module Breadkit
4
6
  module Render
5
7
  class Rasterizer
6
- def rasterize(svg, format:, scale: 2.0, background: "white", quality: 90, backend: "auto")
8
+ class TimeoutError < Error; end
9
+
10
+ def rasterize(svg, format:, scale: 2.0, background: "white", quality: 90, backend: "auto", timeout: 60)
7
11
  format = format.to_s
8
12
  backend = backend.to_s
9
- raise Error, "unsupported raster format: #{format}" unless %w[png jpeg].include?(format)
10
- raise Error, "unsupported raster backend: #{backend}" unless %w[auto rsvg vips magick].include?(backend)
13
+ raise Error, "unsupported raster format: #{format}" unless %w[png jpeg webp pdf].include?(format)
14
+ raise Error, "unsupported raster backend: #{backend}" unless %w[auto rsvg resvg vips magick chrome].include?(backend)
11
15
 
12
16
  begin
13
17
  scale = Float(scale)
@@ -16,63 +20,150 @@ module Breadkit
16
20
  end
17
21
  raise Error, "scale must be a positive finite number" unless scale.finite? && scale.positive?
18
22
  raise Error, "quality must be an integer between 0 and 100" unless quality.is_a?(Integer) && quality.between?(0, 100)
23
+ raise Error, "render timeout must be a positive finite number" unless timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
19
24
  background = color(background) if format == "jpeg"
20
25
 
21
26
  candidates = case backend
22
27
  when "rsvg" then ["rsvg"]
28
+ when "resvg" then ["resvg"]
29
+ when "chrome" then ["chrome"]
23
30
  when "magick" then ["magick"]
24
31
  when "vips" then ["vips"]
25
- else format == "png" ? %w[rsvg vips magick] : %w[vips magick]
32
+ else case format
33
+ when "png" then %w[rsvg resvg vips magick chrome]
34
+ when "pdf" then %w[rsvg]
35
+ else %w[vips magick]
36
+ end
26
37
  end
27
38
  candidates.each do |name|
28
39
  next unless supports?(name, format)
29
40
  begin
30
- return send(name, svg, format, scale, background, quality)
31
- rescue Error
32
- raise if backend != "auto"
41
+ return send(name, svg, format, scale, background, quality, timeout)
42
+ rescue Error => e
43
+ raise if backend != "auto" || e.is_a?(TimeoutError)
33
44
  end
34
45
  end
35
- raise Error, "no raster backend found for #{format}; install librsvg (brew install librsvg / apt install librsvg2-bin) or ImageMagick"
46
+ raise Error, "no conversion backend found for #{format}; install librsvg, resvg, ruby-vips, ImageMagick, or Chrome"
36
47
  end
37
48
 
38
49
  private
39
50
 
40
51
  def supports?(name, format)
41
- return !!executable("rsvg-convert") if name == "rsvg" && format == "png"
42
- return !!magick_command if name == "magick"
43
- return false unless name == "vips"
52
+ return !!executable("rsvg-convert") if name == "rsvg" && %w[png pdf].include?(format)
53
+ return !!executable("resvg") if name == "resvg" && format == "png"
54
+ return !!chrome_binary if name == "chrome" && format == "png"
55
+ return !!magick_command if name == "magick" && format != "pdf"
56
+ return false unless name == "vips" && format != "pdf"
44
57
  require "vips"
45
58
  true
46
59
  rescue LoadError
47
60
  false
48
61
  end
49
62
 
50
- def rsvg(svg, _format, scale, _background, _quality)
51
- stdout, stderr, status = Open3.capture3(executable("rsvg-convert"), "--format=png", "--zoom=#{scale}", stdin_data: svg, binmode: true)
63
+ def rsvg(svg, format, scale, _background, _quality, timeout)
64
+ stdout, stderr, status = capture_command(executable("rsvg-convert"), "--format=#{format}", "--zoom=#{scale}", svg: svg, timeout: timeout)
52
65
  raise Error, "rsvg-convert failed: #{stderr}" unless status.success?
53
66
  stdout
54
67
  end
55
68
 
56
- def magick(svg, format, scale, background, quality)
69
+ def resvg(svg, _format, scale, _background, _quality, timeout)
70
+ stdout, stderr, status = capture_command(executable("resvg"), "-z", scale.to_s, "-", "-c", svg: svg, timeout: timeout)
71
+ raise Error, "resvg failed: #{stderr}" unless status.success?
72
+
73
+ stdout
74
+ end
75
+
76
+ def chrome(svg, _format, scale, background, _quality, timeout)
77
+ raise Error, "Chrome PNG supports only an opaque white background" unless background == "white"
78
+
79
+ width, height = chrome_dimensions(svg, scale)
80
+ Dir.mktmpdir("breadkit-chrome-") do |dir|
81
+ html_path = File.join(dir, "image.html")
82
+ png_path = File.join(dir, "image.png")
83
+ error_path = File.join(dir, "chrome.log")
84
+ encoded = [svg].pack("m0")
85
+ File.write(html_path, %(<html><head><meta charset="utf-8"><style>html,body{margin:0;padding:0}img{display:block;width:#{width}px;height:#{height}px}</style></head><body><img src="data:image/svg+xml;base64,#{encoded}"></body></html>))
86
+ command = [chrome_binary, "--headless", "--disable-gpu", "--disable-background-networking", "--no-first-run",
87
+ "--no-default-browser-check", "--hide-scrollbars", "--force-device-scale-factor=1",
88
+ "--window-size=#{width},#{height}", "--screenshot=#{png_path}",
89
+ "--user-data-dir=#{File.join(dir, 'profile')}", "file://#{html_path}"]
90
+ process_options = { out: File::NULL, err: error_path }
91
+ process_options[:pgroup] = true unless RbConfig::CONFIG["host_os"].match?(/mswin|mingw/i)
92
+ pid = Process.spawn(*command, **process_options)
93
+ begin
94
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
95
+ loop do
96
+ if File.file?(png_path)
97
+ bytes = File.binread(png_path)
98
+ if bytes.start_with?("\x89PNG\r\n\x1a\n".b) && bytes.end_with?("IEND\xAE\x42\x60\x82".b)
99
+ actual = bytes.byteslice(16, 8).unpack("N2")
100
+ raise Error, "Chrome PNG dimensions #{actual.join('x')} differ from #{width}x#{height}" unless actual == [width, height]
101
+ return bytes
102
+ end
103
+ end
104
+ break if Process.waitpid(pid, Process::WNOHANG)
105
+ raise TimeoutError, "Chrome timed out after #{timeout} seconds" if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
106
+
107
+ sleep 0.02
108
+ end
109
+ raise Error, "Chrome failed: #{File.read(error_path).lines.last.to_s.strip}"
110
+ ensure
111
+ signal = process_options[:pgroup] ? -pid : pid
112
+ begin
113
+ Process.kill("KILL", signal)
114
+ Process.waitpid(pid)
115
+ rescue Errno::ESRCH, Errno::ECHILD
116
+ nil
117
+ end
118
+ end
119
+ end
120
+ end
121
+
122
+ def chrome_dimensions(svg, scale)
123
+ match = /<svg\b[^>]*\bwidth="([\d.]+)"\s+height="([\d.]+)"/.match(svg)
124
+ raise Error, "Chrome needs SVG width and height dimensions" unless match
125
+
126
+ dimensions = match.captures.map { |value| (value.to_f * scale).round }
127
+ raise Error, "Chrome dimensions must be 1 to 16384 pixels" unless dimensions.all? { |value| value.between?(1, 16_384) }
128
+ raise Error, "Chrome image exceeds 25 million pixels" if dimensions.inject(:*) > 25_000_000
129
+
130
+ dimensions
131
+ end
132
+
133
+ def chrome_binary
134
+ configured = ENV["BREADKIT_CHROME"]
135
+ return configured if configured && File.executable?(configured)
136
+
137
+ %w[google-chrome chromium chromium-browser chrome].each do |name|
138
+ path = executable(name)
139
+ return path if path
140
+ end
141
+ mac = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
142
+ mac if File.executable?(mac)
143
+ end
144
+
145
+ def magick(svg, format, scale, background, quality, timeout)
57
146
  command = magick_command
58
- background_color = format == "png" ? "none" : "##{background.map { |channel| channel.to_s(16).rjust(2, "0") }.join}"
147
+ background_color = format == "jpeg" ? "##{background.map { |channel| channel.to_s(16).rjust(2, "0") }.join}" : "none"
59
148
  args = ["-density", (96 * scale).to_s, "-background", background_color]
60
149
  font = font_file
61
150
  args.concat(["-font", font]) if font
62
151
  args << "svg:-"
63
152
  args += ["-background", background_color, "-alpha", "remove"] if format == "jpeg"
64
- args += ["-quality", quality.to_s] if format == "jpeg"
153
+ args += ["-quality", quality.to_s] if %w[jpeg webp].include?(format)
65
154
  args << "#{format}:-"
66
- stdout, stderr, status = Open3.capture3(command, *args, stdin_data: svg, binmode: true)
155
+ stdout, stderr, status = capture_command(command, *args, svg: svg, timeout: timeout)
67
156
  raise Error, "ImageMagick failed: #{stderr}" unless status.success?
68
157
  stdout
69
158
  end
70
159
 
71
- def vips(svg, format, scale, background, quality)
160
+ def vips(svg, format, scale, background, quality, _timeout)
72
161
  image = Vips::Image.svgload_buffer(svg, scale: scale.to_f)
73
162
  if format == "jpeg"
74
163
  image = image.flatten(background: background) if image.has_alpha?
75
164
  image.jpegsave_buffer(Q: quality.to_i)
165
+ elsif format == "webp"
166
+ image.webpsave_buffer(Q: quality.to_i)
76
167
  else
77
168
  image.pngsave_buffer
78
169
  end
@@ -80,6 +171,35 @@ module Breadkit
80
171
  raise Error, "libvips failed: #{e.message}"
81
172
  end
82
173
 
174
+ def capture_command(*command, svg:, timeout:)
175
+ Open3.popen3(*command) do |stdin, stdout, stderr, process|
176
+ [stdin, stdout, stderr].each(&:binmode)
177
+ writer = Thread.new do
178
+ stdin.write(svg)
179
+ rescue Errno::EPIPE, IOError
180
+ nil
181
+ ensure
182
+ stdin.close
183
+ end
184
+ output = Thread.new { stdout.read }
185
+ errors = Thread.new { stderr.read }
186
+ unless process.join(timeout)
187
+ begin
188
+ Process.kill("KILL", process.pid)
189
+ rescue Errno::ESRCH
190
+ nil
191
+ end
192
+ process.join
193
+ raise TimeoutError, "#{File.basename(command.first)} timed out after #{timeout} seconds"
194
+ end
195
+ raise TimeoutError, "#{File.basename(command.first)} output timed out after #{timeout} seconds" unless output.join(timeout) && errors.join(timeout)
196
+ [output.value, errors.value, process.value]
197
+ ensure
198
+ stdin.close unless stdin.closed?
199
+ [writer, output, errors].each { |thread| thread.kill if thread&.alive? }
200
+ end
201
+ end
202
+
83
203
  def color(value)
84
204
  named = { "aqua" => [0, 255, 255], "black" => [0, 0, 0], "blue" => [0, 0, 255],
85
205
  "fuchsia" => [255, 0, 255], "gray" => [128, 128, 128], "green" => [0, 128, 0],