breadkit-render 0.1.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 +7 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +58 -0
- data/SECURITY.md +5 -0
- data/docs/images/sensor-demo.png +0 -0
- data/docs/images/sensor-demo.svg +45 -0
- data/exe/bkrender +5 -0
- data/lib/breadkit/render/cli.rb +90 -0
- data/lib/breadkit/render/rasterizer.rb +127 -0
- data/lib/breadkit/render/svg_renderer.rb +1160 -0
- data/lib/breadkit/render/version.rb +7 -0
- data/lib/breadkit/render.rb +15 -0
- data/package-lock.json +1172 -0
- data/package.json +12 -0
- data/sig/breadkit/render.rbs +38 -0
- data/site/favicon.svg +7 -0
- data/site/images/sensor-demo.png +0 -0
- data/site/images/sensor-demo.svg +45 -0
- data/site/index.html +94 -0
- data/site/styles.css +14 -0
- metadata +81 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Breadkit
|
|
6
|
+
module Render
|
|
7
|
+
class CLI
|
|
8
|
+
def run(argv)
|
|
9
|
+
options = { scale: 2.0, theme: "light", orientation: "portrait", color_by: "wire", crop: "auto", backend: "auto", quality: 90 }
|
|
10
|
+
parser = OptionParser.new do |opts|
|
|
11
|
+
opts.banner = "Usage: bkrender [options] INPUT"
|
|
12
|
+
opts.on("-o", "--output PATH") { |value| options[:output] = value }
|
|
13
|
+
opts.on("-f", "--format FORMAT", %w[svg png jpeg jpg]) { |value| options[:format] = value == "jpg" ? "jpeg" : value }
|
|
14
|
+
opts.on("--scale N", Float) { |value| options[:scale] = value }
|
|
15
|
+
opts.on("--theme NAME", %w[light dark print]) { |value| options[:theme] = value }
|
|
16
|
+
opts.on("--orientation NAME", %w[portrait landscape]) { |value| options[:orientation] = value }
|
|
17
|
+
opts.on("--rail-pattern PATTERN", SvgRenderer::RAIL_PATTERNS) { |value| options[:rail_pattern] = value }
|
|
18
|
+
opts.on("--color-by MODE", %w[wire net]) { |value| options[:color_by] = value }
|
|
19
|
+
opts.on("--show-nets") { options[:show_nets] = true }
|
|
20
|
+
opts.on("--legend") { options[:legend] = true }
|
|
21
|
+
opts.on("--crop MODE", %w[auto none]) { |value| options[:crop] = value }
|
|
22
|
+
opts.on("--annotations FILE") { |value| options[:annotations] = value }
|
|
23
|
+
opts.on("--backend NAME", %w[auto rsvg vips magick]) { |value| options[:backend] = value }
|
|
24
|
+
opts.on("--background COLOR") { |value| options[:background] = value }
|
|
25
|
+
opts.on("--static") { options[:static] = true }
|
|
26
|
+
opts.on("--quality N", Integer) { |value| options[:quality] = value }
|
|
27
|
+
opts.on("--force") { options[:force] = true }
|
|
28
|
+
opts.on("-v", "--version") { puts "bkrender #{VERSION}"; return 0 }
|
|
29
|
+
opts.on("-h", "--help") { puts opts; return 0 }
|
|
30
|
+
end
|
|
31
|
+
parser.parse!(argv)
|
|
32
|
+
input = argv.shift
|
|
33
|
+
raise ArgumentError, "input file required\n#{parser}" unless input
|
|
34
|
+
raise ArgumentError, "unexpected arguments: #{argv.join(' ')}" unless argv.empty?
|
|
35
|
+
format = output_format(options)
|
|
36
|
+
raise ArgumentError, "--background is only supported for JPEG" if options[:background] && format != "jpeg"
|
|
37
|
+
raise ArgumentError, "cannot write binary image data to a terminal; use -o PATH" if format != "svg" && !options[:output] && $stdout.tty?
|
|
38
|
+
circuit = Breadkit.load(input)
|
|
39
|
+
errors = circuit.diagnostics.reject { |item| %w[warning info].include?(item.severity) }
|
|
40
|
+
if !errors.empty? && !options[:force]
|
|
41
|
+
errors.each do |item|
|
|
42
|
+
location = [item.location&.path, item.location&.line].compact.join(":")
|
|
43
|
+
warn [location, item.message].compact.reject(&:empty?).join(": ")
|
|
44
|
+
end
|
|
45
|
+
return 1
|
|
46
|
+
end
|
|
47
|
+
svg = SvgRenderer.new.render(circuit, crop: options[:crop], theme: options[:theme], orientation: options[:orientation],
|
|
48
|
+
show_nets: options[:show_nets], legend: options[:legend], color_by: options[:color_by],
|
|
49
|
+
annotations: read_annotations(options[:annotations], input), rail_pattern: options[:rail_pattern],
|
|
50
|
+
interactive_layers: format == "svg" && !options[:static])
|
|
51
|
+
output = if format == "svg"
|
|
52
|
+
svg
|
|
53
|
+
else
|
|
54
|
+
Rasterizer.new.rasterize(svg, format: format, scale: options[:scale],
|
|
55
|
+
background: options[:background] || "white", quality: options[:quality], backend: options[:backend])
|
|
56
|
+
end
|
|
57
|
+
options[:output] ? File.binwrite(options[:output], output) : $stdout.write(output)
|
|
58
|
+
0
|
|
59
|
+
rescue OptionParser::ParseError, ArgumentError, JSON::ParserError, Breadkit::Error, Error => e
|
|
60
|
+
warn "bkrender: #{e.message}"
|
|
61
|
+
2
|
|
62
|
+
rescue StandardError => e
|
|
63
|
+
warn "bkrender: #{e.message}"
|
|
64
|
+
2
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def output_format(options)
|
|
70
|
+
extension = File.extname(options[:output].to_s).downcase
|
|
71
|
+
from_path = { ".svg" => "svg", ".png" => "png", ".jpg" => "jpeg", ".jpeg" => "jpeg" }[extension]
|
|
72
|
+
raise ArgumentError, "unsupported output extension: #{extension}" if options[:output] && !from_path
|
|
73
|
+
if options[:format] && from_path && options[:format] != from_path
|
|
74
|
+
raise ArgumentError, "--format conflicts with output extension"
|
|
75
|
+
end
|
|
76
|
+
options[:format] || from_path || "svg"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def read_annotations(path, input)
|
|
80
|
+
return [] unless path
|
|
81
|
+
data = JSON.parse(File.read(path, encoding: "UTF-8"))
|
|
82
|
+
raise ArgumentError, "unsupported lint JSON schema_version" unless data["schema_version"] == 1
|
|
83
|
+
files = Array(data["files"])
|
|
84
|
+
match = files.find { |file| File.expand_path(file["path"].to_s) == File.expand_path(input) }
|
|
85
|
+
match ||= files.first if files.length == 1
|
|
86
|
+
Array(match && match["offenses"])
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Breadkit
|
|
4
|
+
module Render
|
|
5
|
+
class Rasterizer
|
|
6
|
+
def rasterize(svg, format:, scale: 2.0, background: "white", quality: 90, backend: "auto")
|
|
7
|
+
format = format.to_s
|
|
8
|
+
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)
|
|
11
|
+
|
|
12
|
+
begin
|
|
13
|
+
scale = Float(scale)
|
|
14
|
+
rescue ArgumentError, TypeError
|
|
15
|
+
raise Error, "scale must be a positive finite number"
|
|
16
|
+
end
|
|
17
|
+
raise Error, "scale must be a positive finite number" unless scale.finite? && scale.positive?
|
|
18
|
+
raise Error, "quality must be an integer between 0 and 100" unless quality.is_a?(Integer) && quality.between?(0, 100)
|
|
19
|
+
background = color(background) if format == "jpeg"
|
|
20
|
+
|
|
21
|
+
candidates = case backend
|
|
22
|
+
when "rsvg" then ["rsvg"]
|
|
23
|
+
when "magick" then ["magick"]
|
|
24
|
+
when "vips" then ["vips"]
|
|
25
|
+
else format == "png" ? %w[rsvg vips magick] : %w[vips magick]
|
|
26
|
+
end
|
|
27
|
+
candidates.each do |name|
|
|
28
|
+
next unless supports?(name, format)
|
|
29
|
+
begin
|
|
30
|
+
return send(name, svg, format, scale, background, quality)
|
|
31
|
+
rescue Error
|
|
32
|
+
raise if backend != "auto"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
raise Error, "no raster backend found for #{format}; install librsvg (brew install librsvg / apt install librsvg2-bin) or ImageMagick"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
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"
|
|
44
|
+
require "vips"
|
|
45
|
+
true
|
|
46
|
+
rescue LoadError
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
|
|
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)
|
|
52
|
+
raise Error, "rsvg-convert failed: #{stderr}" unless status.success?
|
|
53
|
+
stdout
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def magick(svg, format, scale, background, quality)
|
|
57
|
+
command = magick_command
|
|
58
|
+
background_color = format == "png" ? "none" : "##{background.map { |channel| channel.to_s(16).rjust(2, "0") }.join}"
|
|
59
|
+
args = ["-density", (96 * scale).to_s, "-background", background_color]
|
|
60
|
+
font = font_file
|
|
61
|
+
args.concat(["-font", font]) if font
|
|
62
|
+
args << "svg:-"
|
|
63
|
+
args += ["-background", background_color, "-alpha", "remove"] if format == "jpeg"
|
|
64
|
+
args += ["-quality", quality.to_s] if format == "jpeg"
|
|
65
|
+
args << "#{format}:-"
|
|
66
|
+
stdout, stderr, status = Open3.capture3(command, *args, stdin_data: svg, binmode: true)
|
|
67
|
+
raise Error, "ImageMagick failed: #{stderr}" unless status.success?
|
|
68
|
+
stdout
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def vips(svg, format, scale, background, quality)
|
|
72
|
+
image = Vips::Image.svgload_buffer(svg, scale: scale.to_f)
|
|
73
|
+
if format == "jpeg"
|
|
74
|
+
image = image.flatten(background: background) if image.has_alpha?
|
|
75
|
+
image.jpegsave_buffer(Q: quality.to_i)
|
|
76
|
+
else
|
|
77
|
+
image.pngsave_buffer
|
|
78
|
+
end
|
|
79
|
+
rescue StandardError => e
|
|
80
|
+
raise Error, "libvips failed: #{e.message}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def color(value)
|
|
84
|
+
named = { "aqua" => [0, 255, 255], "black" => [0, 0, 0], "blue" => [0, 0, 255],
|
|
85
|
+
"fuchsia" => [255, 0, 255], "gray" => [128, 128, 128], "green" => [0, 128, 0],
|
|
86
|
+
"lime" => [0, 255, 0], "maroon" => [128, 0, 0], "navy" => [0, 0, 128],
|
|
87
|
+
"olive" => [128, 128, 0], "purple" => [128, 0, 128], "red" => [255, 0, 0],
|
|
88
|
+
"silver" => [192, 192, 192], "teal" => [0, 128, 128], "white" => [255, 255, 255],
|
|
89
|
+
"yellow" => [255, 255, 0] }
|
|
90
|
+
name = value.to_s.downcase
|
|
91
|
+
return named[name] if named.key?(name)
|
|
92
|
+
|
|
93
|
+
hex = value.to_s.delete_prefix("#")
|
|
94
|
+
raise Error, "unsupported JPEG background color: #{value}; use a basic CSS color name, #RGB, or #RRGGBB" unless /\A(?:[0-9a-f]{3}|[0-9a-f]{6})\z/i.match?(hex)
|
|
95
|
+
|
|
96
|
+
hex = hex.chars.map { |char| char * 2 }.join if hex.length == 3
|
|
97
|
+
hex.scan(/../).map { |part| part.to_i(16) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def executable(name)
|
|
101
|
+
extensions = RbConfig::CONFIG["host_os"].match?(/mswin|mingw/i) ? ENV.fetch("PATHEXT", ".EXE;.COM;.BAT;.CMD").split(";") : [""]
|
|
102
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |dir|
|
|
103
|
+
extensions.each do |extension|
|
|
104
|
+
path = File.join(dir, "#{name}#{extension}")
|
|
105
|
+
return path if File.executable?(path)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def magick_command
|
|
112
|
+
executable("magick") || (executable("convert") unless RbConfig::CONFIG["host_os"].match?(/mswin|mingw/i))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def font_file
|
|
116
|
+
candidates = if RbConfig::CONFIG["host_os"].match?(/darwin/i)
|
|
117
|
+
["/System/Library/Fonts/Supplemental/Arial Unicode.ttf", "/System/Library/Fonts/Supplemental/Arial.ttf"]
|
|
118
|
+
elsif RbConfig::CONFIG["host_os"].match?(/mswin|mingw/i)
|
|
119
|
+
["C:/Windows/Fonts/arial.ttf"]
|
|
120
|
+
else
|
|
121
|
+
["/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"]
|
|
122
|
+
end
|
|
123
|
+
candidates.find { |path| File.file?(path) }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|