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.
@@ -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