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.
- checksums.yaml +4 -4
- data/.yamllint +16 -0
- data/CHANGELOG.md +33 -3
- data/Dockerfile +21 -0
- data/Dockerfile.dockerignore +15 -0
- data/README.md +62 -40
- data/docs/REFERENCE.md +115 -0
- data/docs/images/led-netlist.png +0 -0
- data/docs/images/led-schematic.png +0 -0
- data/lib/breadkit/render/apng_encoder.rb +79 -0
- data/lib/breadkit/render/cli.rb +547 -22
- data/lib/breadkit/render/netlist_renderer.rb +138 -0
- data/lib/breadkit/render/orthogonal_router.rb +137 -0
- data/lib/breadkit/render/rasterizer.rb +138 -18
- data/lib/breadkit/render/schematic_renderer.rb +245 -0
- data/lib/breadkit/render/state_selection.rb +23 -0
- data/lib/breadkit/render/svg_renderer.rb +604 -101
- data/lib/breadkit/render/svg_template.rb +84 -0
- data/lib/breadkit/render/theme.rb +46 -0
- data/lib/breadkit/render/version.rb +1 -1
- data/lib/breadkit/render.rb +7 -0
- data/scripts/check-contrast.mjs +28 -0
- data/sig/breadkit/render.rbs +25 -2
- data/site/images/sensor-demo.png +0 -0
- data/site/images/sensor-demo.svg +8 -8
- data/site/index.html +5 -3
- metadata +36 -10
- data/docs/images/sensor-demo.png +0 -0
- data/docs/images/sensor-demo.svg +0 -45
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
42
|
-
return !!
|
|
43
|
-
return
|
|
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,
|
|
51
|
-
stdout, stderr, status =
|
|
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
|
|
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 == "
|
|
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
|
|
153
|
+
args += ["-quality", quality.to_s] if %w[jpeg webp].include?(format)
|
|
65
154
|
args << "#{format}:-"
|
|
66
|
-
stdout, stderr, status =
|
|
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],
|